From f8e0aa639393c658a0f3e1d49863dae482e5199b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 14:59:42 +0300 Subject: [PATCH 01/55] docs: correct the corpus Packet 7 implements against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roadmap's Packet 7 block under-scoped the packet by roughly half: ADR-0036 additionally assigns host classification, TenantContextFactory, TenantContextOrigin, IOrganizationScopeValidator, DenyAllTenantMembership- Reader and eight architecture tests to it, none of which the roadmap named. An implementer working from the roadmap alone ships half a packet. Alongside that, six statements the implementer reads first were false. Two Accepted ADRs disagreed on whether the app.tenant_id setter set is closed at six. The one written CachedHostToTenantResolver body — the block an implementer copies — dropped the is_active term ADR-0036 requires and justified the omission with a premise that is false against the shipped migration, and it routed the unknown-host answer through a cache whose stored null never reads back as a hit while still consuming a globally evicted slot. The marker rule keyed on "has a TenantId property", which captures platform_host_to_tenant — the one table read to determine the tenant — and misses tenants, whose id is the tenant id. And TenantContextBehavior still claimed RLS is not enforced at runtime, which Packet 6 falsified. The five decisions Packet 7 was handed are settled here rather than discovered in review: the aggregate boundary resolves as promotion, the app.scope carrier is a forced deferral to Phase 02b, the tenant_locales single default is a partial unique index, an authority-ceiling refusal is byte-identical to an unresolvable host, and the request-level suite drives a test-only controller rather than claiming Phase 02d's first endpoint. Behaviour is unchanged: the code diff is comments only, and the suite is byte-for-byte the pre-pass baseline at 943 green with zero skips. ADR: 0042 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-architecture-test/SKILL.md | 77 ++-- .claude/skills/add-backend-module/SKILL.md | 28 +- .claude/skills/add-ef-migration/SKILL.md | 42 +- .claude/skills/add-integration-test/SKILL.md | 54 ++- .../skills/add-tenant-owned-entity/SKILL.md | 82 +++- .claude/skills/run-tests-locally/SKILL.md | 59 ++- .claude/skills/seed-tenant/SKILL.md | 391 ++++++++++-------- .../CrossCuttingFoundationExtensions.cs | 12 +- .../Tenancy/TenantAssertionMiddleware.cs | 14 +- .../Pipeline/TenantContextBehavior.cs | 29 +- docs/architecture/02-domain-model.md | 14 +- docs/architecture/09-tenant-isolation.md | 46 ++- docs/architecture/27-custom-domain-tls.md | 219 +++++++--- .../0036-tenant-resolution-trusted-inputs.md | 88 ++++ docs/decisions/0040-ambient-unit-of-work.md | 63 +++ ...rovisioning-cross-aggregate-transaction.md | 259 ++++++++++++ docs/decisions/README.md | 1 + docs/glossary.md | 9 +- docs/modules/tenancy/audit.md | 30 +- docs/modules/tenancy/permissions.md | 10 +- docs/roadmap/phase-02a-kernel-tenancy.md | 109 ++++- docs/standards/01-architecture-standards.md | 19 +- docs/standards/02-backend-coding.md | 8 +- docs/standards/04-api-design.md | 38 ++ docs/standards/05-database.md | 51 ++- docs/standards/06-testing.md | 31 +- docs/standards/11-security.md | 37 +- .../21-architecture-tests-catalogue.md | 72 +++- docs/standards/README.md | 4 +- 29 files changed, 1472 insertions(+), 424 deletions(-) create mode 100644 docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md diff --git a/.claude/skills/add-architecture-test/SKILL.md b/.claude/skills/add-architecture-test/SKILL.md index 80b12580..27fdb832 100644 --- a/.claude/skills/add-architecture-test/SKILL.md +++ b/.claude/skills/add-architecture-test/SKILL.md @@ -110,22 +110,24 @@ Patterns to follow: ### Step 4: Common architecture-test families -The current set lives across these files; add yours to the right one: +**The shipped set is six files, not a family per topic.** Add yours to the one whose +subject it shares: | File | What it covers | |------|----------------| -| `ModuleDependencyTests.cs` | Cross-module reference bans, contracts-only access. | -| `TenantIsolationTests.cs` | `[TenantOwned]` + filter + RLS, `[OrganizationScoped]` + org RLS. | -| `EventBusTests.cs` | Topic naming, integration-event base, inbox guard usage. | -| `AuditTests.cs` | `AuditEntry` inheritance, no direct `audit_log` writes. | -| `EntitlementTests.cs` | Plan-projected vs tenant-flag separation, FeatureKey registry. | -| `PermissionTests.cs` | Closed action set, scope correctness, denied-test presence. | -| `HubContractTests.cs` | No direct Hub-URL references; Hub clients only inside the named adapters (ADR-0034). | -| `DomainGenericTests.cs` | `Core_Modules_HaveNo_DomainSpecific_Names`, no `Verticals/`. | -| `DaprDirectInjectionTests.cs` | No `IConnectionMultiplexer` / `KafkaProducer` / `VaultClient` in modules. | -| `ConventionTests.cs` | Strongly-typed ids in commands, validator pairing, etc. | - -If your rule doesn't fit any file, create a new file with a focused name. +| `ModuleDependencyTests.cs` | Dependency direction between module packages, plus a planted-violation meta test that proves the scanner still detects one. | +| `PersistenceConventionTests.cs` | `row_version` mapping, ambient-unit-of-work enlistment, the Docker trait, and the `migrate` recipe's chain coverage and credential redaction. | +| `TenancyConventionTests.cs` | The ADR-0036 tenancy-edge rules, as source scans until Packet 7 gives them a resolver to inspect. | +| `ApiConventionTests.cs` | Live majors, forwarded headers, required `Deployment:Mode`, unversioned route prefixes. | +| `CrossCuttingFoundationTests.cs` | Pipeline order, `Result` returns, topic naming, and the direct-reference bans (Sentry, `DeploymentMode`, `IEventBus`, provider SDK exceptions). | +| `RepositoryLayoutTests.cs` | `No_Source_Folder_Named_Verticals` and the single-frontend-app rule. | + +Rules for surfaces no file covers yet — audit, permissions, entitlement, event bus, +Hub contract — are **Registered** in +[the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) against +the phase that ships the code they inspect. Check its Status line before assuming a +net is under you, and create a new file only when your rule's subject is not one of +the six above. ### Step 5: Stability of the test @@ -141,8 +143,9 @@ Architecture tests are **non-skippable**. That means: When the rule is about migration content (RLS, partition): ```csharp +/// The migration-scan arm of the rule; see ADR-0003 Amendment 3. [Fact] -public void Every_TenantOwned_Table_HasRls_With_AppTenantId() +public void Every_TenantOwned_Entity_HasFilterAndRlsPolicy() { // RepositoryPaths.BackendSrc() — the shipped helper. A relative "backend/src" // is resolved against the TEST HOST's working directory (bin/Debug/net10.0), @@ -151,32 +154,58 @@ public void Every_TenantOwned_Table_HasRls_With_AppTenantId() var migrationFiles = Directory .GetFiles(RepositoryPaths.BackendSrc(), "*.cs", SearchOption.AllDirectories) .Where(f => f.Contains($"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}")) + .Where(f => !f.EndsWith(".Designer.cs", StringComparison.Ordinal)) .ToList(); - // The guard that makes the emptiness observable. Without it this test passes - // before a single migration exists and keeps passing if the path ever breaks. + // Guard one: the path resolved and found files. Necessary, and on its own + // not sufficient — see guard two. Assert.NotEmpty(migrationFiles); - foreach (var file in migrationFiles) + var tenantOwned = migrationFiles + .Select(f => (File: f, Content: File.ReadAllText(f))) + // `CreateTable(`, not the literal "CREATE TABLE". EF writes tables through + // migrationBuilder.CreateTable(name: "…") and the policy block through + // migrationBuilder.Sql. Measured: "CREATE TABLE" occurs ZERO times in + // 20260828092437_create_tenancy_schema.cs, which creates eight tables. + .Where(x => x.Content.Contains("CreateTable(") && x.Content.Contains("tenant_id")) + .ToList(); + + // Guard two, and the reason this test is worth landing: it asserts on what the + // scan CLASSIFIED, not on what it read. A detection predicate that matches + // nothing runs the loop zero times and reports green over the exact migrations + // the rule exists to cover — which is what the "CREATE TABLE" version did, + // past a NotEmpty guard on the file list. + Assert.NotEmpty(tenantOwned); + + foreach (var (file, content) in tenantOwned) { - var content = File.ReadAllText(file); - if (content.Contains("CREATE TABLE") && content.Contains("tenant_id")) - { - Assert.Contains("ENABLE ROW LEVEL SECURITY", content); + Assert.True( + content.Contains("ENABLE ROW LEVEL SECURITY") // FORCE is the half that matters: without it the table owner bypasses // its own policy and the whole layer is inert while ENABLE stays green. // Matched as a regex because the canonical template writes two spaces. - Assert.Matches(@"FORCE\s+ROW LEVEL SECURITY", content); + && Regex.IsMatch(content, @"FORCE\s+ROW LEVEL SECURITY") // Must match the canonical template's exact shape. A bare // current_setting('app.tenant_id') assertion FAILS against every // correct migration and PASSES against the superseded one-argument // form — see ADR-0003 Amendment 3 and 05-database.md. - Assert.Contains("NULLIF(current_setting('app.tenant_id', true), '')", content); - } + && content.Contains("NULLIF(current_setting('app.tenant_id', true), '')"), + $"{Path.GetFileName(file)} creates a tenant-owned table without the " + + "canonical policy block. Fix: copy it from docs/standards/05-database.md " + + "§ Tenant-Owned and Organization-Scoped Tables — that file is the only " + + "place the template exists."); } } ``` +File granularity is what makes the predicate above safe: the two **table classes** +that key their policy on something else — `tenants` on `id`, `platform_host_to_tenant` +on `app.resolving_host` — ship in a file that also creates ordinary tenant-owned +tables, so the file-level `tenant_id` assertion holds. A per-table version of this +scan needs the table classes from +[Database Standards § Table classes](../../../docs/standards/05-database.md) before +it is correct. + ### Step 7: Test the test Before merging: diff --git a/.claude/skills/add-backend-module/SKILL.md b/.claude/skills/add-backend-module/SKILL.md index d9222b94..0d25be88 100644 --- a/.claude/skills/add-backend-module/SKILL.md +++ b/.claude/skills/add-backend-module/SKILL.md @@ -169,12 +169,23 @@ public sealed class DbContext( { protected override void OnModelCreating(ModelBuilder modelBuilder) { - // Tenant + Organization query filters are applied PER ENTITY in its - // IEntityTypeConfiguration. There is no TenantQueryFilterConvention — - // that type has never existed. Every_TenantOwned_Entity_HasFilterAndRlsPolicy - // is what WILL make a forgotten filter fail — it is registered in the - // catalogue and implemented in Phase 02a Packet 7, not before. modelBuilder.ApplyConfigurationsFromAssembly(typeof(DbContext).Assembly); + + // Tenant + Organization query filters are applied HERE, from + // OnModelCreating, with a DbContext INSTANCE MEMBER as the closure root. + // Not from an IEntityTypeConfiguration: a configuration reached by + // ApplyConfigurationsFromAssembly cannot close over the context instance, + // and a filter whose closure root is anything else is constant-folded into + // EF's cached model as a SQL literal — so request B emits request A's + // baked-in tenant id. There is no TenantQueryFilterConvention either; that + // type has never existed. Every_TenantOwned_Entity_HasFilterAndRlsPolicy is + // what WILL make a forgotten filter fail — registered in the catalogue and + // implemented in Phase 02a Packet 7, not before. + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + // …build the filter over `tenantContext`, the constructor parameter + // captured as an instance field, one expression per entity. + } } } ``` @@ -182,6 +193,13 @@ public sealed class DbContext( Per [05-database.md](../../../docs/standards/05-database.md), one `DbContext` per module — not one global. +`ApplyConfigurationsFromAssembly` also **silently skips** a configuration class +that has constructor arguments — no exception, no log, the entity mapped by +convention with no filter at all — so `Configuration(ITenantContext ctx)` +disappears rather than failing. That is the second reason the filter is not a +configuration's job. See +[add-tenant-owned-entity Step 2](../add-tenant-owned-entity/SKILL.md). + ### Step 5: Architecture test fixture The dependency-direction and cross-module rules live in diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index 8b8c5c84..94a2dc3d 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -168,8 +168,14 @@ migrationBuilder.Sql(""" > for why the two-policy shape was withdrawn. **Session variable names** are canonical: `app.tenant_id` / `app.organization_id` / -`app.scope`. Always pass the second `true` argument so an unset context filters the row -out instead of raising on a pooled connection. +`app.scope` / `app.resolving_host`. Always pass the second `true` argument so an unset +context filters the row out instead of raising on a pooled connection. Write the +`app.scope` term into the policy even though **nothing sets it**: the flag derives from +the actor's role and roles arrive in +[Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md), which is the earliest +phase that can own the carrier, so the cross-organization read hatch is unreachable at +runtime until then — the correct default, and the reason the two `AS RESTRICTIVE` +guards need a test that sets the variable by hand. **Roles.** Migrations run as `learnstack_migration` (the table owner); the application connects as `learnstack_app` (`NOBYPASSRLS`, not the owner). Grant the @@ -254,7 +260,12 @@ migrationBuilder.DropColumn(name: "source_legacy", table: "enrollments"); Small data migrations live inline as SQL. Larger migrations live as **idempotent Hangfire jobs** triggered by the migration. The job sets `app.tenant_id` per tenant -before mutating data: +before mutating data, on the **migration** connection: + +> This loop is a migration-time backfill, not an eighth entry in ADR-0040's closed +> seven-setter set. Application code never opens its own connection to set the +> variable — it goes through `IUnitOfWork.SetTenantContextAsync` on the ambient +> transaction ([ADR-0040](../../../docs/decisions/0040-ambient-unit-of-work.md)). ```csharp foreach (var tenantId in tenantIds) @@ -339,17 +350,26 @@ migration is consistent. - `dotnet ef migrations script` output matches the expected SQL (table, indexes, RLS, partitions). - `dotnet build` is green. -- `LearnStack.Tests.Architecture` is green; specifically - `Every_TenantOwned_Table_HasRls_With_AppTenantId` and - `Every_OrgScoped_Entity_HasOrgIdAndFilter` if applicable. +- `LearnStack.Tests.Architecture` is green. The two rules for this surface — + `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` and + `Every_OrgScoped_Entity_HasOrgIdAndFilter`, the canonical names — are + **Registered and owned by Packet 7**, so today they check nothing. What actually + runs against your migration is the schema sweeps in + `LearnStack.Tests.Integration`'s `TenancySchemaTests`: row security enabled *and* + forced on every table in the catalogue, no second permissive policy for one + command, snake_case identifiers, foreign-key indexing, and the exact grant matrix. - An integration test exercises the new table / column under a tenant + org pair. - For destructive changes: the two-step plan is documented in the PR + migration comment, and the prerequisite tolerant-read release exists. ## Common pitfalls -- **Forgetting RLS on a new tenant-owned table.** The architecture test catches it; - fixing late is painful because production may already have leakable rows. +- **Forgetting RLS on a new tenant-owned table.** The `TenancySchemaTests` sweeps + catch it, and only because they enumerate the applied catalogue rather than a list + of names — a fixture carrying one migration chain silently narrowed every sweep to + eight of ten tables, and a second permissive policy on `outbox_messages` passed the + whole suite. Fixing late is painful because production may already have leakable + rows. - **Wrong session variable name.** `current_setting('app.current_tenant_id')` is silently wrong — RLS returns zero rows because the variable is never set. - **Editing an applied migration.** `__EFMigrationsHistory` holds only @@ -361,5 +381,7 @@ migration is consistent. separate migrations so rollback is granular. - **One migration spanning multiple modules.** Each module owns its own migrations; cross-module schemas go through read-model projections, not shared tables. -- **Skipping `--locked-mode` in CI.** `dotnet restore --locked-mode` and - `dotnet ef --no-build` keep CI deterministic. +- **Building without `CI=true`.** `TreatWarningsAsErrors` is conditioned on it in + `backend/Directory.Build.props`, and CI sets it on the build step. A local build + without it is green on warnings the required check rejects — that shipped once, in + Packet 4. `CI=true` is now the only way this repository is built. diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index f7178522..e48531b9 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -42,7 +42,12 @@ architecture test) plus any other invariant the change touches. See - Domain-method invariants. Those are unit tests in `LearnStack.Tests.Unit`. - Structural rules (no cross-module reference). Architecture tests own those. -- UI flows. E2E tests in `LearnStack.Tests.EndToEnd` own those. +- UI flows. There is no `LearnStack.Tests.EndToEnd` project. End-to-end means a + browser: Playwright over a running stack, owned by + [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md) per + [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md). + [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) puts two + tenants in a browser but gates on a human opening them, not on a Playwright run. ## Inputs @@ -166,8 +171,14 @@ public async Task Unsetting_tenant_context_returns_zero_rows_through_RLS() ``` There is no `TenantContextMissingException` today: the `DbCommandInterceptor` that -would throw it is described in Standards 05 and 11 and owned by Packet 7. Until it -exists, the fail-closed behaviour is the empty result, which is what to assert. +throws it is described in Standards 05 and 11 and lands in **Packet 7**, which owns +it. Until it does, the fail-closed behaviour is the empty result, which is what to +assert. From Packet 7 the same read **through a module `DbContext`** is a loud +`TenantContextMissingException` — the interceptor is an EF `DbCommandInterceptor` +keyed on the marker a sanctioned setter stamps, so it never sees a raw +`NpgsqlCommand`. The case above opens its own connection from `PostgresFixture` and +therefore keeps asserting the empty result; a new case exercising the EF path is +what asserts the throw. For `[OrganizationScoped]` entities, add the cross-org pair: @@ -194,9 +205,23 @@ public async Task Org_X_cannot_read_Org_Y_within_TenantA() And one more, which the org-scoped template needs and an ordinary session cannot reach: with `app.scope = 'tenant'` set, the **read** widens across organizations and neither write does. Without that case both `AS RESTRICTIVE` guards can be -deleted with the suite green — measured, in Packet 6. See +deleted with the suite green — measured, in Packet 6. Set the variable in the test +itself; nothing sets it at runtime, because the flag derives from the actor's role +and roles arrive in +[Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md). See `TenancySchemaTests.TheTenantScopeHatchWidensReadsAndNeitherWrite`. +The Packet 7 half of these cases goes through the **request**, and it needs no +production endpoint to do so. Register a **test-only controller in the test +fixture** — `AddApplicationPart` plus `TestControllerFilter`, the shipped +`IApplicationModelConvention` that keeps only the probe types this fixture names +and removes every other `ITestOnlyController`. That is the precedent +`IdempotencyFixture` set for `/api/v1/sideeffectprobe`, and +`ProductionHostFixture` is the counterpart that adds none, so the production +endpoint set stays exactly what a deployed instance serves. It drives the real +middleware chain and the real EF +query filters without moving Phase 02d's first `/api/v1/*` read endpoints earlier. + ### Step 3: Outbox round-trip > **Steps 3 to 5 are the shape, not today's API.** `IOutbox`, the outbox @@ -272,8 +297,8 @@ For commands with an `Idempotency-Key`: public async Task Create_with_same_idempotency_key_returns_same_result() { var key = "idem-12345"; - var a = await _fx.PostAsync("/v1/enrollments", payload, key); - var b = await _fx.PostAsync("/v1/enrollments", payload, key); + var a = await _fx.PostAsync("/api/v1/enrollments", payload, key); + var b = await _fx.PostAsync("/api/v1/enrollments", payload, key); Assert.Equal(a.EnrollmentId, b.EnrollmentId); Assert.Equal(1, await _fx.Db.Enrollments.CountAsync()); } @@ -287,12 +312,17 @@ Don't substitute `UseInMemoryDatabase` even for "fast" tests. ### Step 7: Speed -The fixture is `IClassFixture` (per-class container lifetime). For test classes -that share the same seed shape, that's fast. If a class needs a unique seed, -prefer **inside-the-fixture** seeding over a new container. - -CI parallelises by class; within a class tests run sequentially against the shared -container. +`SchemaFixture` is a **collection** fixture (`ICollectionFixture` behind +`[Collection(SharedSchema.Name)]`), so one container and one applied schema serve +every class in the schema suite. `PostgresFixture` is taken as an `IClassFixture` +by the class that needs the roles without the schema. If a class needs a unique +seed, prefer **inside-the-fixture** seeding over a new container — a fixture +carrying only one of the two migration chains is what narrowed every structural +sweep to eight of ten tables, and let a second permissive policy on +`outbox_messages` pass the whole suite. + +Roll the transaction back rather than committing, so the seeded row counts other +cases assert on stay what they were. ## Validation diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index 078990b7..ac1404af 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -34,11 +34,13 @@ cross-tenant leak; this skill is the prevention. - `tenants` — **not** because its isolation is conceptual. ADR-0003 Amendment 3 gives it a policy like every other table; it is the **tenant-owned, self-keyed** class, whose predicate keys on `id` because the primary key *is* the tenant id. - Use [Database Standards § Table classes](../../../docs/standards/05-database.md), + It carries no marker-driven `TenantId` filter, because it has no `tenant_id` + column to filter on. Use + [Database Standards § Table classes](../../../docs/standards/05-database.md), not this skill. - `platform_host_to_tenant` — the one **platform-scoped** table, with role-qualified - per-command policies, because it is read *in order to determine* the tenant. - Standards 05 again. + per-command policies, because it is read *in order to determine* the tenant. It + takes **no `[TenantOwned]` marker at all**. Standards 05 again. - `users`, `plans`, `permissions` — owned by phases that have not written their schema yet. Nothing here says they are exempt from row security. - The audit aggregate (`AuditEntry`) — it inherits `Entity`, not @@ -52,6 +54,12 @@ earlier version of this file exempted it, which would have handed the applicatio role a table-wide read of every tenant's plan. - Pure value objects with no own table. +The marker's scope is decided by **table class**, not by whether a `TenantId` +property happens to be there — the note under +`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` in +[the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) is the +authority. + ## Inputs | Input | Required | Description | @@ -309,8 +317,15 @@ Always call `current_setting` with the second argument `true`. Without it an uns context raises inside a pooled connection instead of simply filtering the row out. The session-variable names are **canonical**: `app.tenant_id`, `app.organization_id`, -`app.scope` ([05-database.md](../../../docs/standards/05-database.md)). Other names -break RLS silently. +`app.scope` and `app.resolving_host` +([05-database.md](../../../docs/standards/05-database.md)). Other names break RLS +silently. Note that **nothing sets `app.scope`** — `ITenantContext` exposes no scope +member, the flag derives from the actor's role, and roles arrive in +[Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md), which is the earliest +phase that can own the carrier. The cross-organization read hatch is therefore +unreachable at runtime, which is the correct default; write the term into the policy +anyway, because a test can set the variable and the two `AS RESTRICTIVE` guards need +it to mean anything. The runtime connects as **`learnstack_app`** (`NOBYPASSRLS`, not the table owner); migrations run as `learnstack_migration`, which owns the table. Integration tests for @@ -348,34 +363,55 @@ Every new tenant-owned entity must ship with an isolation test pair in `LearnStack.Tests.Integration`: ```csharp -[Fact] -public async Task _TenantA_cannot_read_TenantB_data() +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class IsolationTests(SchemaFixture schema) { - await using var fixture = await TestFixture.CreateAsync(); - var aId = await fixture.CreateTenantAsync("A"); - var bId = await fixture.CreateTenantAsync("B"); - - using (fixture.AsTenant(aId)) { - await fixture.CreateAsync(); - } - - using (fixture.AsTenant(bId)) { - var rows = await fixture.Db..ToListAsync(); - Assert.Empty(rows); + [Fact] + public async Task _TenantA_cannot_read_TenantB_data() + { + // AppConnectionString — learnstack_app, NOBYPASSRLS, not the owner. A test + // that connects as learnstack_migration, learnstack_platform or + // learnstack_outbox_admin passes with every policy inert and proves nothing. + await using var connection = await PostgresFixture.OpenAsync( + schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + + // The transaction's first statement. set_config(..., true) is + // transaction-local, so outside one it is discarded before the read. + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM WHERE tenant_id = @other", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + read.Parameters.AddWithValue("other", SchemaFixture.TenantB); + + (await read.ExecuteScalarAsync()).Should().Be(0L); } } // Add a matching org-isolation test if the entity is [OrganizationScoped]. ``` +There is no `TestFixture`, no `CreateTenantAsync` and no `AsTenant(...)` helper — +an earlier version of this file used all three. The shipped fixtures are +`PostgresFixture` (container + the four roles) and `SchemaFixture` (both migration +chains, every table seeded for two tenants), shared with +`[Collection(SharedSchema.Name)]`. The fixture must seed **both** tenants: a count +of zero against a table nothing populated passes whatever the policy says. + See [add-integration-test](../add-integration-test/SKILL.md). ## Validation - `dotnet build` is green. - `dotnet ef migrations script` includes the table, both indexes, RLS-enable, and - the policies — matching the templates above verbatim except for column names. -- `LearnStack.Tests.Architecture` passes the auto-conventions on this entity. + the policies — the policies matching the canonical block in + [05-database.md § Tenant-Owned and Organization-Scoped Tables](../../../docs/standards/05-database.md) + verbatim, with only `` substituted. +- `LearnStack.Tests.Architecture` is green. Note that no rule covers this entity's + filter until Packet 7 lands the two in Step 4; the schema sweeps in + `TenancySchemaTests` are what run against your migration today. - `LearnStack.Tests.Integration` includes the cross-tenant test (and cross-org if applicable). - Glossary updated if the entity name is a new domain term. @@ -401,5 +437,7 @@ See [add-integration-test](../add-integration-test/SKILL.md). assertion against a table the fixture never populated passes whatever the policy says; that shipped once in Packet 6 and is the reason `SchemaFixture` fills every table it asserts on. -- **Forgetting `using x.AsTenant(...)` in tests.** Without it, the connection has no - `app.tenant_id` set and queries see nothing — which can mask a missing filter. +- **Forgetting `SchemaQueries.SetTenantAsync` in tests.** Without it the transaction + has no `app.tenant_id` and every query sees nothing — which can mask a missing + filter. Issuing it outside a transaction has the same effect, because + `set_config(..., true)` is transaction-local. diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 06a79383..02142291 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -48,7 +48,9 @@ pnpm --version # Restore — from the directories that hold the solution and the workspace. # There is no solution and no package.json at the repository root, so both -# commands fail there. `make install` runs exactly these two lines. +# commands fail there. `make install` is these two lines, after its `.env` and +# `hooks` prerequisites (which copy `.env.example` and point core.hooksPath at +# `.githooks/`). (cd backend && dotnet restore LearnStack.slnx) (cd frontend && pnpm install --frozen-lockfile) @@ -95,18 +97,27 @@ dotnet test backend/tests/LearnStack.Tests.Architecture \ --no-restore ``` -Common failure messages and fixes: +Common failure messages and fixes, from the rules that are **implemented today**. +A rule you expected and do not see here is probably **Registered** against a later +phase — check its Status line in +[21-architecture-tests-catalogue.md](../../../docs/standards/21-architecture-tests-catalogue.md) +before concluding a net is under you. | Message | Fix | |---------|-----| -| `Every_TenantOwned_Entity_Has_TenantId` | Missing `TenantId` property; see [add-tenant-owned-entity](../add-tenant-owned-entity/SKILL.md). | -| `Every_TenantOwned_Table_HasRls_With_AppTenantId` | Migration missing `ENABLE ROW LEVEL SECURITY` + the policy. | -| `Integration_Event_Handlers_Use_InboxGuard` | Handler skipped `IsAlreadyProcessedAsync`; see [add-integration-event](../add-integration-event/SKILL.md). | -| `Dapr_PubSub_TopicNames_FollowConvention` | Topic isn't `learnstack.{module}.{aggregate}`. | -| `Modules_Do_Not_Inject_Valkey_Directly` | Use `ICacheService` not `IConnectionMultiplexer`. | -| `LearnStack_Modules_DoNotReference_Hub` | Hub URL or namespace referenced outside the dedicated adapter. | -| `No_Source_Folder_Named_Verticals` | A `Verticals/` folder exists; ADR-0018 forbids. | -| `Core_Modules_HaveNo_DomainSpecific_Names` | A `Cefr`, `English`, `Asana`, etc. name appears in core. | +| `ModuleDomain_DoesNotDependOn_OtherModuleDomain` | Depend on the other module's `Application.Contracts`, never its `Domain`. | +| `ModuleDomain_DoesNotDependOn_AnyApplicationOrInfrastructure` | Dependency direction is inverted; see [add-backend-module](../add-backend-module/SKILL.md). | +| `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` | A context opened its own connection — register it with `AddModuleDbContext`, per [ADR-0040](../../../docs/decisions/0040-ambient-unit-of-work.md). | +| `Aggregates_With_Optimistic_Concurrency_Map_RowVersion` | The `row_version` mapping is missing a save behaviour; the token stays 0 and every ETag comparison is meaningless ([ADR-0039](../../../docs/decisions/0039-optimistic-concurrency-token.md)). | +| `Modules_Do_Not_Reference_DeploymentMode` | The composition root branches on the mode; modules never. | +| `Modules_Do_Not_Inject_IEventBus_Directly` | The only sanctioned publisher is the outbox processor; enqueue through `IOutbox`. | +| `Modules_Do_Not_Reference_Sentry_SDK_Directly` | Capture through `IErrorTrackingProvider`. | +| `Handlers_Return_Result` | A handler threw where it should return `Result.Fail(...)`. | +| `MediatR_Pipeline_Order_Matches_Canonical_Sequence` | A behavior moved; the eight-step order is fixed by [ADR-0032](../../../docs/decisions/0032-exception-handling-logging-and-observability.md). | +| `Integration_Event_TopicNames_FollowConvention` | Topic isn't `learnstack.{module}.{aggregate}`. | +| `No_Source_Folder_Named_Verticals` | A `Verticals/` folder exists; ADR-0018 forbids it. | +| `Every_Database_Test_Carries_The_Docker_Trait` | A `Database/` test class is missing `[Trait(RequiresDocker.Key, RequiresDocker.Value)]` and would run in the wrong CI job. | +| `Migrate_Target_Covers_Every_Migration_Chain` | A new chain exists that `make migrate` does not apply. | ### Step 5: Run integration tests @@ -164,8 +175,8 @@ dotnet test --filter "FullyQualifiedName~EnrollmentCreateTests" # Method dotnet test --filter "FullyQualifiedName~EnrollmentCreateTests.Create_succeeds" -# Trait (xUnit) -dotnet test --filter "Trait=tenant-isolation" +# Trait — the only one this repository sets, and the one CI routes on +dotnet test --filter "Requires=Docker" ``` `vitest`: @@ -190,6 +201,12 @@ Application ≥ 80%, Infrastructure ≥ 50%. CI fails on regression. ### Step 9: Reproduce a CI failure +CI runs the **whole solution** with a trait filter, not one project — and it builds +with `CI=true`, which is what turns `TreatWarningsAsErrors` on +(`backend/Directory.Build.props`). A local build without it is green on exactly the +warnings the required check rejects; that shipped once, in Packet 4, and `CI=true` +is now the only way this repository is built. + ```bash # Pull the exact branch CI ran git checkout @@ -197,12 +214,22 @@ git checkout # The same restore CI does make install -# Run the same command CI ran (see .github/workflows/*.yml) -dotnet test backend/tests/LearnStack.Tests.Integration \ - --no-restore \ - --logger "trx;LogFileName=results.trx" +# The `backend` job — build with warnings as errors, then the Docker-free half +(cd backend && CI=true dotnet build LearnStack.slnx --no-restore --configuration Release) +(cd backend && dotnet test LearnStack.slnx \ + --no-restore --no-build --configuration Release \ + --filter "Requires!=Docker" --logger trx) + +# The `backend-integration` job — the exact complement, so every test runs once +(cd backend && dotnet test LearnStack.slnx \ + --no-restore --no-build --configuration Release \ + --filter "Requires=Docker" --logger trx) ``` +`--logger trx` carries no `LogFileName` on purpose: a fixed name makes all four +projects write the same path in the same results directory, and three assemblies' +outcomes are silently overwritten. + For flaky tests, run with `--blame-hang` and `--blame-hang-timeout`: ```bash diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 9e88290f..06538481 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -1,35 +1,39 @@ --- name: seed-tenant description: > - Provision a tenant for local development or integration tests, including its - default organization, branding tokens, seed users (admin / instructor / - learner), customization data (content types, page blocks, lesson item types, - level taxonomy, scoring rules, completion rules, custom fields, templates), and - a sample course / lessons. USE FOR: bringing up a new demo tenant, adding a - second tenant for cross-tenant isolation testing, regenerating customization - data after a schema change. DO NOT USE FOR: production tenant provisioning - (operator action via Hub), Self-Hosted license issuance (Hub-side), or - domain-specific code (forbidden by ADR-0018 — everything is data). + Provision a tenant for local development or the request-level isolation suite — + the `tenants` row, its organizations, locales, settings, feature flags, domain, + and its `platform_host_to_tenant` mapping. USE FOR: bringing up a demo tenant, + adding a second tenant for cross-tenant isolation testing, reseeding after a + tenancy schema change. DO NOT USE FOR: production tenant provisioning (operator + action via Hub), Self-Hosted license issuance (Hub-side), customization data or + course content (later phases own those aggregates — see § What a later phase + adds), or domain-specific code (forbidden by ADR-0018 — everything is data). --- # Seeding a tenant ## Purpose -Stand up a new tenant + organization + memberships + customization data + a small -course tree, all as **data**, so: +Stand up a tenant + its organizations + its host mapping, all as **data**, so: -- Local dev has something to render. -- Integration tests have a deterministic fixture. -- A second non-English tenant proves the substrate is generic per - [Phase 10 exit criteria](../../../docs/roadmap/phase-10-english-learning-mvp.md). +- Local dev has two hosts that resolve to two different tenants. +- The [Packet 7](../../../docs/roadmap/phase-02a-kernel-tenancy.md) request-level + isolation suite has a deterministic two-tenant fixture. +- Two tenants in unrelated domains exist from Packet 7 onward and render side by + side from [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md), so + genericity is proven **continuously**, by construction. + +[Phase 10](../../../docs/roadmap/phase-10-english-learning-mvp.md) is **not** the +genericity proof and disclaims the attribution itself: it is the depth showcase — +the first place one tenant fills all eight customization aggregates at once. ## When to use - Local-dev first-run seed. - Adding a parallel tenant for the cross-tenant isolation tests. -- Reseeding after a schema change to the customization aggregates. -- Authoring a new "domain showcase" tenant (yoga, coding bootcamp, music school). +- Reseeding after a schema change to the tenancy aggregates. +- Authoring a new "domain showcase" tenant (music school, dance studio). ## When not to use @@ -42,116 +46,131 @@ course tree, all as **data**, so: | Input | Required | Description | |-------|----------|-------------| -| Tenant slug | Yes | URL-safe: `demo-english`, `demo-yoga`. | -| Tenant name | Yes | Human-readable: "English Hero", "Anatolia Yoga". | -| Domain showcase | Yes | The "shape" the tenant demonstrates — drives the customization-data set chosen. | -| Default org slug | Yes | One default org seeded per tenant: `main`, `studio-1`. | -| Locale set | Yes | At least one (e.g. `en-US`); typically two. | -| Hub-backed? | No | If yes, the local Hub stack must be up; tenant create routes through `POST /api/internal/tenants`. | +| Tenant id | Yes | Assigned by the registry that owns the tenant — the Hub in SaaS / Dedicated, configuration in Self-Hosted, the seeder here. `Tenant.Create` never mints one; its policy keys on `id`, so a factory-minted id could not satisfy its own `WITH CHECK`. | +| Tenant slug | Yes | URL-safe, ≤ 63 chars, unique across `tenants`: `demo-english`, `demo-yoga`. | +| Tenant name | Yes | Human-readable, ≤ 200 chars: "English Hero", "Anatolia Yoga". | +| Domain showcase | Yes | The "shape" the tenant demonstrates — drives the customization-data set, once the aggregates that hold it exist. | +| Organization slugs | Yes | Two per tenant; the first becomes `tenants.default_organization_id`. | +| Locale set | Yes | At least one, with **exactly one** `is_default`. | +| Host | Yes | One `platform_host_to_tenant` row per tenant, with or without `organization_id`. | ## Workflow ### Step 1: Pick the showcase -The two MVP showcases (per -[phase-10-english-learning-mvp.md](../../../docs/roadmap/phase-10-english-learning-mvp.md)): +The two seeded showcases: -| Showcase | Slug | Customization data set | -|----------|------|------------------------| -| English learning | `demo-english` | CEFR levels, vocabulary cards, speaking prompts, placement-to-CEFR scoring, lesson-package completion. | -| Coding bootcamp (or yoga) | `demo-coding` / `demo-yoga` | Track / difficulty taxonomy, code-challenge / asana content type, track-or-attendance completion. | +| Showcase | Slug | Display name | Host | +|----------|------|--------------|------| +| Online English school | `demo-english` | English Hero | `demo-english.learnstack.local` | +| Yoga studio | `demo-yoga` | Anatolia Yoga | `demo-yoga.learnstack.local` | -Both seed scripts live under `infra/seed//`. +**A coding bootcamp is not a candidate.** Its defining feature — running a +learner's submitted code — is external capability invocation, which +[Platform Vision § Genericity boundary](../../../docs/architecture/01-platform-vision.md) +puts outside the customization model. Choosing it forces either a domain-specific +runner module or a showcase that omits the one thing that made the domain +interesting; [Phase 10](../../../docs/roadmap/phase-10-english-learning-mvp.md) +records the same rejection. A yoga studio's distinctive content and taxonomy are +pure shape, so it is honest about what the model can do. ### Step 2: Run the seed ```bash -make seed-tenant SHOWCASE=english SLUG=demo-english +make seed ``` -The make target runs: +The target brings the stack up and runs `scripts/seed.sh`, which verifies compose +health and the two Keycloak realms, then, from Packet 7, invokes the seeder: ```bash -dotnet run --project infra/seed -- \ - --showcase english \ - --slug demo-english \ - --hub-backed=false # true requires the local Hub +dotnet run --project backend/src/LearnStack.Tools.Seeder -- \ + --tenants demo-english,demo-yoga \ + --platform-admin demo-admin@learnstack.test \ + --connection-string "$ConnectionStrings__Default" ``` -The seed is **idempotent** — running it twice produces the same state. - -### Step 3: What gets created - -In order: - -#### 3.1 Tenant + organization - -- Row in `tenants` (id, slug, display_name, status=Active). -- Row in `organizations` (default org with the chosen slug; every tenant has at - least one). -- Row in `tenant_settings` (locale set, timezone, default-sender). -- Row in `tenant_branding` (theme tokens — primary, secondary, font family). -- Row in `tenant_domains` (subdomain on the platform's local default). - -#### 3.2 Keycloak users - -In the `learnstack` realm: - -- 1 tenant admin (`tenantadmin@.local`). -- 1 instructor (`instructor1@.local`). -- 2 learners (`learner1@.local`, `learner2@.local`). -- Memberships in `(user_id, tenant_id, organization_id)` for each. - -Passwords are seeded from a project-local secret in `.env` -(`SEED_USER_PASSWORD`), never committed. - -#### 3.3 Customization data - -All eight aggregates per -[32-tenant-customization-model.md](../../../docs/architecture/32-tenant-customization-model.md): - -- `TenantContentType` — domain content types. -- `TenantPageBlock` — domain block keys pointing at built-in composites. -- `TenantLessonItemType` — domain lesson item types. -- `TenantLevelTaxonomy` — the level taxonomy (CEFR for English, Track for coding). -- `TenantScoringRule` — placement-test scoring DSL. -- `TenantCompletionRule` — lesson-package completion DSL. -- `TenantCustomFieldDef` — custom fields on built-in entities. -- `TenantTemplateLibrary` — locale-aware notification templates. - -Each item is **versioned**; bumping a schema regenerates with `v+1` and keeps -old data valid. - -#### 3.4 Education catalog +`backend/src/LearnStack.Tools.Seeder` is the **reserved path** — +`scripts/seed.sh` names it in the placeholder section Packet 7 replaces. There is +no `make seed-tenant` and no `infra/seed/` tree. -- 1 `Program`. -- 1 `Course` with 2 published `CourseVersion`s. -- 4 `Module`s with 3 `Lesson`s each, mixing built-in and tenant-defined lesson - item types. -- 1 `Assessment` (placement test) using the tenant's `TenantScoringRule`. -- 1 `Cohort` with all 2 learners enrolled. - -#### 3.5 Live classroom artefacts - -- 1 `InstructorAvailability` window. -- 1 `LiveSession` scheduled in 7 days. -- 1 `LiveBooking` for one learner. - -#### 3.6 Hub mirror (only when `--hub-backed=true`) - -When the local Hub stack is up, the seed additionally: - -- Creates the tenant on Hub via `POST /api/internal/tenants`. -- Receives `PUT /api/internal/tenants/{id}/entitlements` push with a default plan. -- `IEntitlementProvider.RefreshAsync` writes `platform_entitlement_cache` from that push. Nothing writes the table directly, and no Dapr consumer is involved — the transport behind `IEventBus` is `InProcessEventBus` until Phase 11's trigger fires ([ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md)). -- `platform_host_to_tenant` gets the slug → tenant mapping. - -When `--hub-backed=false`, `NullEntitlementProvider` covers entitlement (all -features enabled, no limits) and the host mapping is config-only. - -### Step 4: Hosts file alias (optional) +The seed is **idempotent** — running it twice produces the same state. -To browse the tenant on a host that matches production-like custom domains: +### Step 3: What Packet 7's seed creates + +Per tenant, in one transaction: + +- Row in `tenants` — id, slug, display_name, `status = Trial`. **Not `Active`**: + `Tenant.Create` produces `Trial` and `ChangeStatus` is the only way out of it, + so a seed that wants `Active` calls the transition rather than writing the + column. +- Two rows in `organizations`, and `AssignDefaultOrganization` pointing the tenant + at the first. Tenant + default organization in one transaction is the single + bounded cross-aggregate write + ([ADR-0042](../../../docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)), + and it is bounded by enumeration — one operation, one allow-list entry. +- Rows in `tenant_locales` (exactly one `is_default`), `tenant_settings`, + `tenant_feature_flags` and `tenant_domains`. +- One row in `platform_host_to_tenant` — **per tenant, not per organization**. + `demo-english` leaves `organization_id` NULL (a `TenantHost`); `demo-yoga` sets + it (an `OrgHost`), so both live classification classes from + [ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md) are + exercised by the seed and not only by a fixture. Neither host belongs in + `Tenancy:PlatformHosts`, which lists hosts that map to **no** tenant. + +Two mechanics the seeder cannot skip: + +- **`app.tenant_id` is set before the first insert.** Every table's `WITH CHECK` + is live from the moment the migration finishes, and `tenants` keys its policy on + `id`, so the provisioning transaction sets the session variable to the assigned + id before the `INSERT`. +- **`platform_host_to_tenant` rows go in as `learnstack_app`.** Its policies are + qualified `TO learnstack_app`, so the table owner is denied on it — the one + table where the migration role cannot seed. + +Packet 6's `SchemaFixture` keeps its own `alpha` / `beta` tenants. It asserts +against the applied schema and does not read this seed; changing one does not +change the other. + +### Step 4: What a later phase adds + +The seed above is the whole of the tenancy slice. Everything a "complete" demo +tenant eventually carries belongs to a phase that has not written its schema yet: + +| Aggregate / artefact | Owning phase | +|---|---| +| `User`, `Membership`, roles, invitations | [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md) | +| Keycloak OIDC wiring and the realm's `tenant_id` claim mapper | [Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md) | +| `TenantContentType`, `TenantLevelTaxonomy` | [Phase 02a Packet 8](../../../docs/roadmap/phase-02a-kernel-tenancy.md) | +| `Course`, `Lesson` and their translation satellites | [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) | +| `TenantCustomFieldDef` | [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md) | +| `TenantPageBlock` | [Phase 04](../../../docs/roadmap/phase-04-cms-media-pages.md) | +| `TenantLessonItemType`, `TenantScoringRule`, `TenantCompletionRule` | [Phase 05](../../../docs/roadmap/phase-05-education-learning-content.md) | +| Branding tokens and the surface that writes them | [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md) | +| `TenantTemplateLibrary` | [Phase 08a](../../../docs/roadmap/phase-08a-assessment-notifications.md) | +| `InstructorAvailability`, `LiveSession`, `LiveBooking` | [Phase 08b](../../../docs/roadmap/phase-08b-scheduling.md) / [Phase 08c](../../../docs/roadmap/phase-08c-classroom.md) | +| Hub tenant mirror and the entitlement projection | [Phase 02c](../../../docs/roadmap/phase-02c-hub-foundation.md) / Packet 9 | + +The entitlement projection is demand-gated infrastructure, so its row owes four +things and a phase is only one of them: the port is `IEntitlementProvider`, the +working default is `NullEntitlementProvider`, the owners are the Phase 02c / +Packet 9 pair above, and the trigger — *a tenant must be billed or plan-gated* — +is in +[ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md)'s trigger +table. + +There is **no `tenant_branding` table** and no `tenant_branding` row to write. +Branding tokens are read from `TenantSettings`; the configuration surface that +writes them is Phase 06. + +Keycloak users are **not** seeded by this skill. `infra/keycloak/realms/learnstack.json` +imports them at compose boot and `scripts/seed.sh` prints their credentials; there +is no `SEED_USER_PASSWORD` in `.env.example`, and adding one would put a second +source of truth beside the realm import. + +### Step 5: Hosts file alias + +To browse a tenant on a host that matches production-like custom domains: ``` # /etc/hosts @@ -160,83 +179,117 @@ To browse the tenant on a host that matches production-like custom domains: ``` Then visit `http://demo-english.learnstack.local:3000`. The middleware resolves -the host through `IHostToTenantResolver`. - -### Step 5: Verify +the host through `IHostToTenantResolver`, which reads `platform_host_to_tenant` +and nothing else — never the Hub +([ADR-0034](../../../docs/decisions/0034-hub-contract-surface-invariant.md)). -```bash -# DB sanity -psql $DATABASE_URL -c "SELECT id, slug, display_name FROM tenants;" +### Step 6: Verify -# Customization data -psql $DATABASE_URL -c " - SELECT key, schema_version, created_at - FROM tenant_content_types - WHERE tenant_id = '';" +Connect as `learnstack_app`, inside a transaction, with the tenant context set — +the same way the application does. Without it every tenant-owned table correctly +returns zero rows, which reads exactly like "the seed did not run". -# Keycloak users (admin endpoint requires admin token) -curl -fsS $KEYCLOAK_URL/admin/realms/learnstack/users \ - -H "Authorization: Bearer $KC_TOKEN" | jq '.[] | .username' +`psql` takes its own arguments here. `$ConnectionStrings__Default` is a .NET +keyword string, which libpq rejects (`invalid connection option "Host"`), and +`.env` is read by compose rather than sourced into a shell, so the variable is +usually empty anyway. The password is the `learnstack_app` one in `.env`. -# Web app -open http://demo-english.learnstack.local:3000 +```bash +psql -h localhost -p 5432 -U learnstack_app -d learnstack <<'SQL' +BEGIN; +SELECT set_config('app.tenant_id', '', true); +SELECT slug, display_name, status FROM tenants; +SELECT slug, display_name FROM organizations; +SELECT locale, is_default FROM tenant_locales; +COMMIT; +SQL + +# platform_host_to_tenant is read before any tenant context exists, so its read +# policy admits exactly the host the resolver declares in `app.resolving_host`, +# or the caller's own tenant via `app.tenant_id`. With neither set, +# `learnstack_app` sees nothing — that is what stops an anonymous session +# enumerating the host map, not a failed seed. Check the second row under the +# other host, or a tenant's own row under `app.tenant_id`. +psql -h localhost -p 5432 -U learnstack_app -d learnstack <<'SQL' +BEGIN; +SELECT set_config('app.resolving_host', 'demo-english.learnstack.local', true); +SELECT host, organization_id, is_active FROM platform_host_to_tenant; +COMMIT; +SQL ``` -### Step 6: Reset +### Step 7: Reset + +There is no `make seed-reset`. The seed is idempotent, so re-running it is the +normal repair; a genuine reset drops the volumes and starts over: ```bash -make seed-reset # drops every demo tenant; re-runs seed -make seed-reset SHOWCASE=english # only the English tenant +make clean # stops the stack and drops named volumes — destructive +make dev # brings the stack back up +make migrate # applies both migration chains — `make seed` does not +make seed # reseeds ``` -### Step 7: Authoring a new showcase +### Step 8: Authoring a new showcase To add a third domain showcase (e.g. music school): -1. Create `infra/seed/music/` with: - - `content-types/` JSON Schemas. - - `page-blocks.json` mapping keys → composite renderer keys. - - `lesson-item-types/` JSON Schemas. - - `level-taxonomy.json` (difficulty bands or kyu/dan ranks). - - `scoring-rule.dsl`. - - `completion-rule.dsl`. - - `custom-fields.json`. - - `templates/` per-locale Liquid / Handlebars templates. -2. Add a `--showcase music` branch to the seed runner. -3. Run `make seed-tenant SHOWCASE=music SLUG=demo-music`. - -**No LearnStack code change is required.** This is the substrate-genericity -proof per ADR-0018; if you find yourself touching a module, the design is -wrong. +1. Add its tenant, organizations, locales, settings, feature flags, domain and + host row to the seeder's data set. +2. Register the host in `/etc/hosts` and, from Phase 02d, expect it to render. +3. Run `make seed`. + +Its customization data — content types, level taxonomy, blocks, rules, templates — +is added as each owning phase from § What a later phase adds lands the aggregate +that holds it. + +**No LearnStack code change is required for the domain shape.** That is the +substrate-genericity claim per +[ADR-0018](../../../docs/decisions/0018-tenant-driven-customization-model.md); if +you find yourself touching a module to express a domain, the design is wrong. The +claim's edge is +[Platform Vision § Genericity boundary](../../../docs/architecture/01-platform-vision.md): +stateful entitlement and external capability invocation are platform features +gated by plan, not customization rows. ## Validation -- `make seed-tenant` exits 0. -- The web app renders the tenant's landing page using the seeded customization - data. -- The Studio editor surfaces every customization aggregate the seed populated. -- A learner login works; "My Courses" shows the seeded `CourseVersion`. -- A placement-test attempt scored via the tenant's `TenantScoringRule` returns - the expected level. -- Cross-tenant test (`Tenant_A_cannot_read_Tenant_B`) passes after seeding two - tenants. +- `make seed` exits 0, and exits 0 again on a second run. +- Both tenants are present with `status = Trial`, each with two organizations and + a non-null `default_organization_id`. +- `platform_host_to_tenant` holds one row per tenant — one carrying + `organization_id`, one leaving it NULL. Checked **one host at a time**, each under + its own `app.resolving_host` (§ Step 6): the read policy admits the declared host or + the caller's own tenant, so no single `learnstack_app` query can see both rows, and a + count of two is not observable to the role this skill tells you to connect as. +- The Packet 7 request-level isolation suite is green **connected as + `learnstack_app`**, against both seeded tenants. +- From Phase 02d, both hosts render their own catalog page in a browser. ## Common pitfalls -- **Domain-specific code in the seed runner.** The runner reads JSON / DSL files; - it does not contain `if (showcase == "english") ...` business logic. If you - feel pulled toward that, the data files are missing a field. -- **Non-idempotent seed.** Running twice should produce the same state. Use - `INSERT ... ON CONFLICT DO NOTHING` and reference items by stable keys. -- **Seed password committed.** `SEED_USER_PASSWORD` lives in `.env`. Never check - it in. -- **Hub-backed seed without Hub up.** The seed will fail; either run the - `learnstack-hub` stack or pass `--hub-backed=false`. -- **Schema version bump without resync.** When a customization aggregate's schema - changes (`v1` → `v2`), the seed creates the new version; existing tenants - still need a migration of stored entries. The seed shouldn't bulk-migrate - silently. -- **`/etc/hosts` change for production.** Local-only. Production custom-domains - resolve via `platform_host_to_tenant` populated by Hub. -- **Two tenants sharing the same slug.** Slugs are unique on `tenants`; the seed - will refuse. +- **Domain-specific code in the seeder.** The seeder reads its data set; it does + not contain `if (showcase == "english") ...` business logic. If you feel pulled + toward that, the data is missing a field. +- **Seeding `status = Active` directly.** `Tenant.Create` produces `Trial`. Write + the column and the aggregate's state diagram and the seed disagree from the + first row. +- **Minting the tenant id in the seeder's factory.** `Tenant.Create` takes the id; + the registry assigns it. A minted id has no `app.tenant_id` to match and the + `WITH CHECK` refuses its own insert. +- **A host row per organization.** One row per tenant. An `OrgHost` is a tenant + row that also carries `organization_id`, not a second row. +- **Seeding `platform_host_to_tenant` as the migration role.** Its policies are + qualified `TO learnstack_app`; the owner is denied and the insert fails. +- **Two locales flagged `is_default`.** The invariant lives in the database as a + partial unique index — `UNIQUE (tenant_id) WHERE is_default` — with an + aggregate guard for the message. An aggregate check alone does not hold across + concurrent transactions. +- **Non-idempotent seed.** Running twice should produce the same state. Reference + rows by stable keys. +- **Two tenants sharing the same slug.** Slugs are unique across `tenants`; the + seed will refuse. Note the consequence the aggregate documents: a duplicate-slug + insert reveals that *some* tenant holds the slug, which is accepted only because + slugs appear in hostnames and are public by construction. +- **`/etc/hosts` change for production.** Local-only. Production custom domains + resolve through `platform_host_to_tenant` rows the Hub writes. diff --git a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs index 8e5cc589..7ac9198e 100644 --- a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs @@ -23,7 +23,7 @@ namespace LearnStack.Api.Composition; /// Composition-root extension that wires the entire ADR-0032 surface in one /// disciplined pass — Serilog, OpenTelemetry, error tracking, /// , MediatR pipeline, the singleton -/// , and the request-scoped +/// , and the transient /// default. The wire-cross-cutting-foundation /// skill is the long-form walk; this method is the binary. /// @@ -67,9 +67,13 @@ public static WebApplicationBuilder AddLearnStackCrossCuttingFoundation( builder.Services.AddLearnStackErrorTracking( secretProvider, builder.Configuration, deploymentMode); - // Request-scoped ITenantContext default — Packet 7 swaps this for the - // resolved instance produced by TenantResolverMiddleware. The - // singleton ITenantContextAccessor is set in + // ITenantContext default. Transient is deliberate and pinned by + // DeploymentModeCompositionTests + // .Tenant_Context_Resolution_Forwards_Each_Access_To_The_Accessor: a + // scoped factory would cache the first value for the rest of the scope, + // so a write to the accessor after a handler resolved would never reach + // it. Do not restore this to Scoped. The singleton + // ITenantContextAccessor is registered in // AddLearnStackObservabilityServices above. // Resolved FROM the accessor rather than hard-wired to the unresolved // singleton. Nothing wrote the accessor before the event bus, so this is diff --git a/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs index 1b9fc782..4c33c461 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs @@ -120,10 +120,16 @@ public async Task InvokeAsync( context.User.Identity?.IsAuthenticated == true)); // 404, not 403: saying "wrong tenant" confirms the other tenant - // exists. The code differs by caller — `tenant_mismatch` for an - // authenticated one, `not_found` for an anonymous one — so the - // header adds no bit an anonymous client could not already get by - // retrying without it. + // exists. From Phase 02b the code differs by caller — + // `tenant_mismatch` for an authenticated one, `not_found` for an + // anonymous one — so the header adds no bit an anonymous client + // could not already get by retrying without it. Until then the + // authenticated tier is dormant per ADR-0036 § Staging across + // packets — there is no UseAuthentication to be ordered after and + // the `authenticated` label is constant-false — so every caller + // takes the anonymous branch, and Phase 02b's split needs this + // middleware to write the authenticated code itself rather than + // leaving the body to UseStatusCodePages. await WriteAsync(context, StatusCodes.Status404NotFound); return; } diff --git a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs index b94029de..a22ca756 100644 --- a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs @@ -14,19 +14,23 @@ namespace LearnStack.Application.Pipeline; /// this behavior runs at step 4, before any transaction exists. They are issued by /// TransactionBehavior as the first statement inside the transaction at step 6 /// — see Security Standards § Tenant Context, the single authority for this -/// placement. Two packets, two things: Packet 6 opens the transaction -/// (TransactionBehavior's unit-of-work shell), and Packet 7 issues the -/// SET LOCAL inside it, together with the resolver middleware that gives it -/// a tenant to write. +/// placement. Packet 6 shipped both halves: TransactionBehavior opens the +/// ambient transaction and calls IUnitOfWork.SetTenantContextAsync inside +/// it. Packet 7 adds the resolver middleware that gives it a tenant to write. /// /// /// Phase 02a Packet 3 ships the assertion shell. Until /// Packet 7 lands the resolver middleware every request runs against /// ; this behavior surfaces the fact -/// loudly so no handler reads an unresolved context by accident. Packet 7 -/// flips the default registration to the real resolver. It adds nothing here: -/// the RLS session variables are issued by TransactionBehavior inside -/// the transaction at step 6, never from this behavior. +/// loudly so no handler reads an unresolved context by accident. Packet 7's +/// TenantResolverMiddleware writes the singleton +/// ITenantContextAccessor the injected context reads from, and adds a +/// second rejection here: a request whose TenantContextOrigin exceeds +/// what the request type permits — a host-only context reaches only +/// [PublicSurface] types — is refused at this step, which is what makes +/// ADR-0036's authority ceiling mechanical. The RLS session variables are still +/// issued by TransactionBehavior inside the transaction at step 6, never +/// from this behavior. /// public sealed class TenantContextBehavior( ITenantContext tenantContext) @@ -49,9 +53,12 @@ public Task Handle( return Task.FromResult(Result.FailFor(TenantMismatchError)); } - // Nothing to do here for RLS, and nothing anywhere else yet: no code - // in this repository issues set_config today. Packet 7 adds it to - // TransactionBehavior, and until then RLS is not enforced at runtime. + // Nothing to do here for RLS, and nothing left undone elsewhere: + // TransactionBehavior issues the set_config pair at step 6. RLS is + // enforced today and fail-closed before Packet 7 — an unresolved context + // writes the empty string, so every predicate is NULL and every + // tenant-owned table returns zero rows. What Packet 7 supplies is a + // non-NULL predicate. // // Why it belongs there and not here: the GUCs are transaction-local // (set_config('app.tenant_id', ..., true)) and this behavior runs at diff --git a/docs/architecture/02-domain-model.md b/docs/architecture/02-domain-model.md index 117171b0..da5b0729 100644 --- a/docs/architecture/02-domain-model.md +++ b/docs/architecture/02-domain-model.md @@ -43,7 +43,7 @@ flowchart LR TenantDomain TenantBranding TenantFeatureFlag - TenantSettings + TenantSetting end subgraph identity["Identity"] @@ -216,11 +216,19 @@ flowchart LR |--------|-----------------|-------| | `Tenant` | Yes | Tenant-owned, **self-keyed**: no `tenant_id` column, because its `id` *is* the tenant id and its RLS policy keys on `id`. Status: Trial / Active / Suspended / Archived. | | `Organization` | Yes | Sub-unit within a tenant (branch, studio, campus, department, cohort). Two-level hierarchy strict (ADR-0017). Every tenant has at least one default org. | -| `TenantDomain` | Inside Tenant | Subdomain on `{slug}.learnstack.app` (always available) or custom domain (Hub-managed; see [27-custom-domain-tls.md](27-custom-domain-tls.md)). | +| `TenantDomain` | Yes | Subdomain on `{slug}.learnstack.app` (always available) or custom domain (Hub-managed; see [27-custom-domain-tls.md](27-custom-domain-tls.md)). | | `TenantBranding` | Inside Tenant | Logo, colors, typography tokens. May be overridden per-organization via `OrganizationBranding`. | | `OrganizationBranding` | Inside Organization | Optional partial design-token override (logo / colors / typography) merged on top of `TenantBranding` at render time. When the resolved request carries an organization id and a row exists, the merged token set is injected as CSS variables on the SSR'd HTML root; missing fields fall through to the tenant default. See [Glossary § Branding](../glossary.md). | | `TenantFeatureFlag` | Inside Tenant | Experimental / gradual-rollout flags. Plan-level features are surfaced via the entitlement projection (ADR-0021), not stored here. See [21-feature-flags.md](21-feature-flags.md). | -| `TenantSettings` | Inside Tenant | Locale set, timezone, default notification sender, content settings. | +| `TenantLocale` | Inside Tenant | The locales a tenant publishes in ([ADR-0008](../decisions/0008-localization-schema.md)). Composite key `(tenant_id, locale)`, no surrogate id; exactly one row is the default. | +| `TenantSetting` | Yes | Timezone, default notification sender, content settings. | + +[Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md) settles the boundary +Packet 6 left open, and the two halves do not resolve the same way: `TenantDomain` and +`TenantSetting` are already root-shaped — surrogate Vogen id, `AuditableEntity`, +`row_version`, their own RLS policy — while `TenantLocale` and `TenantFeatureFlag` +carry a composite natural key and no id at all, and therefore cannot be +`IAggregateRoot` under any reading. ## Identity diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index ffb9b4d1..371cc406 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -63,7 +63,7 @@ sequenceDiagram APISIX->>API: Forward with X-Correlation-Id API->>MW: HTTP pipeline MW->>MW: Resolve host via platform_host_to_tenant AND read JWT claims#59;
reject on disagreement (ADR-0036 — agreement, not priority) - MW->>Accessor: SetTenant(tenantId, organizationId, userId) + MW->>Accessor: Current = resolved context (tenant, organization, user) MW->>API: continue API->>EF: BeginTransaction, then SET LOCAL app.tenant_id /
app.organization_id as the first statement (TransactionBehavior, step 6) API->>EF: Query tenant-owned aggregate @@ -82,7 +82,10 @@ In text, for a reader whose renderer does not draw it: 3. Middleware resolves the host through `platform_host_to_tenant` **and** reads the JWT claims, rejecting on disagreement — agreement, not priority ([ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)). -4. It sets the ambient context and the request continues. +4. It writes the ambient context — `ITenantContextAccessor.Current`, the + accessor's only member + ([ADR-0036 Amendment 2](../decisions/0036-tenant-resolution-trusted-inputs.md)) — + and the request continues. 5. `TransactionBehavior` opens the transaction and issues `SET LOCAL app.tenant_id` / `app.organization_id` as its **first** statement. 6. EF Core applies the global query filter, so the SQL carries the tenant and @@ -117,11 +120,14 @@ The `app.scope = 'tenant'` setting belongs with `app.tenant_id` and reason — all three are transaction-local, so middleware setting them would discard them before the guarded query ran. **Two of the three are issued today.** Packet 6 ships `IUnitOfWork.SetTenantContextAsync`, which writes `app.tenant_id` and -`app.organization_id`; `ITenantContext` carries no scope member, so nothing sets -`app.scope` and the hatch below is unreachable at runtime — the correct default, -and [Packet 7](../roadmap/phase-02a-kernel-tenancy.md)'s to decide -([ADR-0040 Amendment 1](../decisions/0040-ambient-unit-of-work.md)) ([Security Standards § Tenant Context](../standards/11-security.md) is the -single authority). What middleware contributes is the *input*: the request comes from a +`app.organization_id`; `ITenantContext` carries no scope member +([ADR-0040 Amendment 1](../decisions/0040-ambient-unit-of-work.md)), so nothing sets +`app.scope` and the hatch below is unreachable at runtime — the correct default. The +flag derives from the actor's role plus a declared tenant-wide operation, and roles +arrive with authentication in [Phase 02b](../roadmap/phase-02b-events-auth.md), so that +is the earliest carrier: the deferral is forced, not chosen +([Security Standards § Tenant Context](../standards/11-security.md) is the single +authority). What middleware contributes is the *input*: the request comes from a tenant-admin role carrying a tenant-wide operation flag (e.g. cross-org reporting), and the behavior turns that into the session variable. It widens **reads** across organizations within the caller's tenant; it never widens @@ -152,12 +158,18 @@ Platform admin (LearnStack operator) access must be explicit: authenticated as `learnstack_platform` — the `BYPASSRLS` role of the four-role model. There is no `learnstack_audit_admin` role, and `learnstack_app` is not a member of `learnstack_platform`, so the application role cannot reach the bypass by `SET ROLE`. - Every cross-tenant access emits a `read-sensitive` audit row, written inside the scope - under the sentinel platform tenant id. See + Every cross-tenant access is recorded. Until + [Packet 9](../roadmap/phase-02a-kernel-tenancy.md) ships `audit_log` and `IAuditStore`, + `EnterPlatformAdminScope(reason)` records the entry through `ILogger` at `Warning` with + the `reason` and the caller — and **not** a sentinel platform tenant id, whose value + Packet 9 fixes with the schema that stores it. Packet 9 replaces the log line with the + audit row written inside the scope; + [the Tenancy audit matrix](../modules/tenancy/audit.md) carries the classification. See [Database Standards § Database roles](../standards/05-database.md). - No hidden arbitrary `IgnoreQueryFilters()` usage; architecture test - `IgnoreQueryFilters_OnlyInPlatformAdminScope` forbids it outside the - `LearnStack.Modules.Identity.Application.Platform` namespace. + `No_IgnoreQueryFilters_Outside_PlatformAdminScope` forbids it outside the audited + `EnterPlatformAdminScope(reason)` call path. The rule is a path check; there is no + escape-hatch comment marker to write. ## Background jobs @@ -174,8 +186,8 @@ public abstract record JobParams } ``` -Workers restore tenant + org context (`accessor.SetTenant(tenantId, orgId, null)`) before -reading or writing tenant-owned data. `LearnStackJob` base class enforces this +Workers restore tenant + org context (`accessor.Current = ...`) before reading or +writing tenant-owned data. `LearnStackJob` base class enforces this (Nexora analogue: `Nexora/docs/architecture/multi-tenancy.md` and `Nexora/docs/decisions/0012-tenant-management.md`; LearnStack will implement equivalent `LearnStackJob` in Phase 02). @@ -192,7 +204,7 @@ public abstract class PlatformJob : LearnStackJob { using var scope = _serviceProvider.CreateAsyncScope(); scope.ServiceProvider.GetRequiredService() - .SetTenant(tenant.TenantId, null, null); + .Current = JobTenantContext.ForTenant(tenant.TenantId); try { await ExecuteForTenantAsync(parameters, tenant, scope.ServiceProvider, ct); @@ -213,13 +225,13 @@ public abstract class PlatformJob : LearnStackJob | Test | Asserts | |------|---------| -| `Every_TenantOwned_Entity_HasTenantId` | Every aggregate marked `[TenantOwned]` (or inheriting `AuditableEntity<>`) has a `TenantId` property and an EF query filter referencing it. | +| `Every_TenantOwned_Entity_HasTenantId` | Every aggregate marked `[TenantOwned]` has a `TenantId` property and an EF query filter referencing it. | | `Every_OrgScoped_Entity_HasOrgIdAndFilter` | Every aggregate marked `[OrganizationScoped]` has `OrganizationId` nullable + EF query filter. | | `Every_TenantOwned_Table_HasRlsPolicy` | Migration scan: every tenant-owned table has `ENABLE` **and** `FORCE ROW LEVEL SECURITY` and **exactly one** permissive policy with an explicit `WITH CHECK`. Two permissive policies fail the test. | | `Every_OrgScoped_Table_HasOrgRlsPolicy` | Migration scan: the organization term is `AND`-ed inside that single policy — not in a second permissive one — and both `AS RESTRICTIVE` write guards are present. | -| `IgnoreQueryFilters_OnlyInPlatformAdminScope` | Roslyn source scan: `IgnoreQueryFilters()` appears only in `LearnStack.Modules.Identity.Application.Platform` or behind an `architecture-allow: ignore-query-filters ADR-NNNN` marker. | +| `No_IgnoreQueryFilters_Outside_PlatformAdminScope` | Roslyn source scan: `IgnoreQueryFilters()` appears only inside the audited `EnterPlatformAdminScope(reason)` call path. No marker exempts a call site. | | `Hangfire_JobPayloads_IncludeTenantId` | Reflection: every `LearnStackJob` subclass's `TParams` has `TenantId`. | -| `LearnStackJob_RunAsync_SetsTenantBeforeExecute` | Source-grep + reflection: `RunAsync` is non-virtual; `SetTenant(...)` precedes `ExecuteAsync(...)`. | +| `LearnStackJob_RunAsync_SetsTenantBeforeExecute` | Source-grep + reflection: `RunAsync` is non-virtual; the write to `ITenantContextAccessor.Current` precedes `ExecuteAsync(...)`. | | `No_DirectDaprClient_OutsideInfrastructure` | Roslyn source scan: `Dapr.Client.*` only in `LearnStack.Infrastructure.{Caching, Messaging, Secrets}`. | | `Provider_SDK_Types_NotImported_InDomain` | Provider SDK types (Stripe, Iyzico, LiveKit, Keycloak admin, SeaweedFS) only in `LearnStack.Infrastructure.*` adapters. | diff --git a/docs/architecture/27-custom-domain-tls.md b/docs/architecture/27-custom-domain-tls.md index 87f42f50..bad0a632 100644 --- a/docs/architecture/27-custom-domain-tls.md +++ b/docs/architecture/27-custom-domain-tls.md @@ -210,10 +210,11 @@ once, and the third is the one that matters operationally: every tenant's public site down, for a lookup whose answer LearnStack already stores. **`IHubClient.LookupHostAsync` is deleted.** `IHostToTenantResolver` reads -`platform_host_to_tenant` and nothing else: +`platform_host_to_tenant` and nothing else. The port is `SharedKernel`; only the adapter +is Infrastructure: ```csharp -namespace LearnStack.Infrastructure.MultiTenancy; +namespace LearnStack.SharedKernel.Tenancy; public interface IHostToTenantResolver { @@ -227,72 +228,158 @@ public interface IHostToTenantResolver } public sealed record HostResolution(TenantId TenantId, OrganizationId? OrganizationId); +``` + +The adapter sends the two answers down two paths — the found one through +`ICacheService`, the unknown one through a structure capped on its own: -// NpgsqlDataSource, NOT a module DbContext and NOT IUnitOfWork. This runs in -// TenantResolverMiddleware, before any transaction exists — and the shared -// registration helper throws by design when a module context is resolved outside -// the ambient transaction, because a context that never saw SET LOCAL reads zero -// rows from every tenant-owned table. ADR-0040 § Who sets app.tenant_id already -// puts every pre-transaction reader on "a short transaction of its own on its own -// connection"; this is that, and the resolver is the only setter of -// app.resolving_host. +```csharp +namespace LearnStack.Infrastructure.MultiTenancy; + +// NpgsqlDataSource, NOT a module DbContext and NOT IUnitOfWork. This runs in host +// classification, before authentication and before any transaction exists — and +// the shared registration helper throws by design when a module context is +// resolved outside the ambient transaction, because a context that never saw +// SET LOCAL reads zero rows from every tenant-owned table. ADR-0040 § Who sets +// app.tenant_id already puts every pre-transaction reader on "a short transaction +// of its own on its own connection"; this is that, and the resolver is the only +// setter of app.resolving_host. +// +// unknownHosts is capped on its own and registered for this resolver alone, so a +// flood of novel hosts evicts only other unknown hosts. Its cap and TTL, and the +// positive TTL in options, are configuration rather than literals: this block is +// copied, and a copied number outlives the measurement that chose it. +// +// The resolver itself is registered as a singleton, so the flight map below is +// process-wide. A scoped registration gives every request its own map and +// coalesces nothing. public sealed class CachedHostToTenantResolver( ICacheService cache, + UnknownHostCache unknownHosts, + HostResolutionOptions options, NpgsqlDataSource dataSource) : IHostToTenantResolver { - public Task ResolveAsync(string host, CancellationToken ct = default) - => cache.GetOrSetAsync( - // Composed by the factory, never interpolated: CacheKey.EnsureValid is - // what stops an unnormalized spelling creating a parallel entry. - CacheKey.ForHostMapping(host), - async token => - { - // The policy on this table admits exactly the row the resolver - // ANNOUNCES. Without the SET LOCAL the predicate is NULL and the - // query returns nothing, so the miss path opens its own - // transaction: SET LOCAL outside a transaction block emits - // "WARNING: SET LOCAL can only be used in transaction blocks" and - // has no effect, and a session-level set_config(..., false) would - // survive on a pooled connection into the next request. - await using var connection = await dataSource.OpenConnectionAsync(token); - await using var tx = await connection.BeginTransactionAsync(token); - - // set_config(..., true) is SET LOCAL's function form and is - // transaction-local for the same reason. It has to be this form: - // `SET LOCAL app.resolving_host = $1` is a syntax error — - // PostgreSQL's SET takes no bind parameter — so the parameterised - // spelling every other query uses is unavailable here, and string - // interpolation into SET would be an injection site on the - // anonymous page-load path. - await using (var announce = new NpgsqlCommand( - "SELECT set_config('app.resolving_host', @host, true)", connection, tx)) - { - announce.Parameters.AddWithValue("host", host); - await announce.ExecuteNonQueryAsync(token); - } - - // is_publicly_live, per ADR-0036 § HostOnly — NOT the Hub-side - // `is_active` in the payload sample below. A domain can be active - // (owned, verified) and not yet publicly live, and only the latter - // may answer an anonymous page load. - await using var read = new NpgsqlCommand( - """ - SELECT tenant_id, organization_id - FROM platform_host_to_tenant - WHERE host = @host AND is_publicly_live - """, connection, tx); - read.Parameters.AddWithValue("host", host); - - var resolution = await ReadSingleAsync(read, token); - - await tx.CommitAsync(token); - return resolution; - }, - new CacheOptions(L1Ttl: TimeSpan.FromMinutes(2), L2Ttl: TimeSpan.FromMinutes(15)), - ct); + private readonly ConcurrentDictionary>> _flights = + new(StringComparer.Ordinal); + + public async Task ResolveAsync( + string host, CancellationToken ct = default) + { + // Composed by the factory, never interpolated: CacheKey.EnsureValid is + // what stops an unnormalized spelling creating a parallel entry. + var key = CacheKey.ForHostMapping(host); + + if (await cache.GetAsync(key, ct) is { } cached) + { + return cached; + } + + if (unknownHosts.Contains(host)) + { + return null; + } + + var resolution = await ReadCoalescedAsync(host, ct); + + if (resolution is null) + { + unknownHosts.Add(host); + return null; + } + + await cache.SetAsync(key, resolution, options.PositiveCache, ct); + return resolution; + } + + private async Task ReadCoalescedAsync( + string host, CancellationToken ct) + { + // One database round trip per host, however many callers arrive during + // it. The flight runs on CancellationToken.None: one caller hanging up + // must not cancel the lookup the others are waiting on. + var flight = _flights.GetOrAdd( + host, + static (h, self) => new Lazy>( + () => self.ReadAsync(h, CancellationToken.None)), + this); + + try + { + return await flight.Value.WaitAsync(ct); + } + finally + { + _flights.TryRemove( + new KeyValuePair>>(host, flight)); + } + } + + private async Task ReadAsync(string host, CancellationToken ct) + { + // The policy on this table admits exactly the row the resolver + // ANNOUNCES. Without the SET LOCAL the predicate is NULL and the query + // returns nothing, so the miss path opens its own transaction: SET LOCAL + // outside a transaction block emits "WARNING: SET LOCAL can only be used + // in transaction blocks" and has no effect, and a session-level + // set_config(..., false) would survive on a pooled connection into the + // next request. + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var tx = await connection.BeginTransactionAsync(ct); + + // set_config(..., true) is SET LOCAL's function form and is + // transaction-local for the same reason. It has to be this form: + // `SET LOCAL app.resolving_host = $1` is a syntax error — PostgreSQL's + // SET takes no bind parameter — so the parameterised spelling every other + // query uses is unavailable here, and string interpolation into SET would + // be an injection site on the anonymous page-load path. + await using (var announce = new NpgsqlCommand( + "SELECT set_config('app.resolving_host', @host, true)", connection, tx)) + { + announce.Parameters.AddWithValue("host", host); + await announce.ExecuteNonQueryAsync(ct); + } + + // BOTH terms, per ADR-0036 § Effective host and the trusted hop. Active + // (owned, verified) and publicly live are distinct states — the row + // exists from submission onward, before DNS points anywhere — and only + // the latter may answer an anonymous page load. Both are read here + // because ADR-0036 invalidates the resolver cache on the transaction that + // flips EITHER flag, which is only meaningful if both feed the answer. + await using var read = new NpgsqlCommand( + """ + SELECT tenant_id, organization_id + FROM platform_host_to_tenant + WHERE host = @host AND is_active AND is_publicly_live + """, connection, tx); + read.Parameters.AddWithValue("host", host); + + var resolution = await ReadSingleAsync(read, ct); + + await tx.CommitAsync(ct); + return resolution; + } } ``` +**The negative answer never enters `ICacheService`.** `InMemoryCacheService.TryRead` +requires `entry.Value is T`, so a stored null reads back as a miss — pinned by +`An_Explicitly_Stored_Null_Reads_Back_As_A_Miss`, not accidental — while `SetAsync` +stores it anyway, consuming one slot of the single process-wide 10,000-entry pool that +every cache family shares and that trims oldest-first. Routing unknown hosts through the +cache therefore buys nothing and evicts real mappings, which is what +[ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) forbids when it +requires unknown hosts to be "negative-cached in a separately capped structure so a flood +cannot evict real mappings". **Packet 7 records** the unknown-host cap, its TTL and the +positive TTL, with the measurements that chose them, in its delivery record in +[Phase 02a](../roadmap/phase-02a-kernel-tenancy.md). + +**The split forfeits `GetOrSetAsync`.** That method runs its factory once for concurrent +misses on one key; get-then-set has no factory to coalesce, so N simultaneous first +requests for one cold host become N transactions unless the resolver re-adds the +coalescing itself. **Packet 7 re-adds it**, in `CachedHostToTenantResolver`, because the +flight it protects is a Postgres transaction opened on an anonymous, pre-authentication +page load — the one path a stranger can make cold at will. + `platform_host_to_tenant` is the one **platform-scoped** table — the row is what *determines* the tenant, so it cannot be filtered by a tenant context that does not exist yet. That does **not** make the read unscoped: row security is enabled and forced @@ -308,8 +395,11 @@ Consequences of the change: - A Hub outage degrades **billing and provisioning**. It does not touch page loads. - The cache in front of the table is a latency optimisation, not an availability mechanism. Even a total cache failure leaves a single indexed primary-key lookup. -- `TenantResolverMiddleware` calls this resolver first, before JWT validation, because - anonymous public routes need a tenant context too. +- Host classification calls this resolver **before authentication**, so an unknown host + is rejected cheaply and an anonymous public route still resolves a tenant; context + construction runs **after** it, so `TenantContextFactory.Create` is called once with + both signals in hand + ([ADR-0036 § Rules](../decisions/0036-tenant-resolution-trusted-inputs.md)). ### How mappings arrive @@ -325,8 +415,9 @@ The endpoint is part of the enumerated Hub → LearnStack surface in chain as every other internal call (mTLS + RS256 JWT with `aud=learnstack-internal` + HMAC body signature + `jti` replay protection), and is handled by `IHubTenantSync`. The handler upserts `platform_host_to_tenant` and invalidates the resolver cache for the -affected hosts on `learnstack.hub.custom-domain.activated` / -`.revoked`. +affected hosts — the positive entry and the unknown-host entry both, or an activation is +invisible until the negative cap's TTL expires — on +`learnstack.hub.custom-domain.activated` / `.revoked`. ### Certificate material never rides the mapping payload diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index fdccae84..fb44f0d9 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -254,6 +254,16 @@ per-request durable write on a *happy* path: nothing re-issues the token, so the disagreement would hold for the whole session and every subresource fetch would re-emit the event. +> **Erratum — 2026-09-01.** The paragraph below says the `[PublicSurface]` set "is +> enumerated in the catalogue". It was enumerated nowhere, and "the catalogue" had three +> candidate referents in this corpus (architecture tests, audit coverage, permissions); +> shown by `grep -rn "PublicSurface" docs/` at this ADR's acceptance, whose only hits are +> inside this file. The set now lives in +> [Standards 04 § Public surface](../standards/04-api-design.md), which this ADR's +> § Architecture tests already designates as the home of its day-to-day rules — so the +> location changed, not the rule. Every rule the paragraph states about the set is +> unchanged, and so is the Decision. Recorded in Amendment 3. + **`TenantContextOrigin` is the authority ceiling, and it is what makes a forged host harmless.** `HostOnly` reaches only request types marked `[PublicSurface]` — the corpus's existing `Portal Public` role, made mechanical — and `TenantContextBehavior` at @@ -601,6 +611,19 @@ output is not retained under audit retention is not a detector anyone can rely o ### Rules +> **Erratum — 2026-08-30.** The second bullet below names the member +> `ITenantContextAccessor.SetTenant`. There is no such member and never was: the +> interface shipped on 2026-05-21 in Phase 02a Packet 3 carrying +> `ITenantContext? Current { get; set; }` and nothing else, exactly as its owning +> [ADR-0032 § Sub-decision 10](0032-exception-handling-logging-and-observability.md) +> specifies; shown by `backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs` +> and by `grep -rn SetTenant backend/src`, whose only hits are the unrelated +> `IUnitOfWork.SetTenantContextAsync`. Read the bullet as governing **writes to +> `ITenantContextAccessor.Current`**; what it decides — exactly four callers, and +> `EnterPlatformAdminScope` not among them — is unchanged. Current authority: +> [ADR-0032 § Sub-decision 10](0032-exception-handling-logging-and-observability.md) +> for the member, this bullet for the caller set. Recorded in Amendment 2. + - **Never** assign `ITenantContext.TenantId` or `OrganizationId` from a bound header. There is no exception and no mode in which there is one. - `ITenantContextAccessor.SetTenant` has exactly the four callers the corpus already @@ -732,6 +755,71 @@ explicitly. Implemented in `LearnStack.SharedKernel.Tenancy.EffectiveHost`; covered by `EffectiveHostTests`. +### 2026-08-30 — Amendment 2: the accessor member is `Current`, not `SetTenant` + +**What was wrong.** § Rules' second bullet opens +"`ITenantContextAccessor.SetTenant` has exactly the four callers…". No member of +that name has ever existed on that interface, so the rule as written governs +nothing. + +**How it was shown.** `backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs` +declares exactly one member, `ITenantContext? Current { get; set; }`, and has +since it shipped in Phase 02a Packet 3 on 2026-05-21 — three months before this +ADR was accepted. `grep -rn SetTenant backend/src` returns only +`IUnitOfWork.SetTenantContextAsync`, a different member on a different type with +a different job. The member this ADR should have named is fixed by +[ADR-0032 § Sub-decision 10](0032-exception-handling-logging-and-observability.md), +which decided the accessor's shape and is unchanged by this amendment. + +**Why the code is not the thing corrected.** The shipped shape is the one its +owning ADR specifies, and the property setter is what the already-shipped fourth +caller needs: `InProcessEventBus` saves the previous context, writes its own, and +restores the previous — possibly `null` — on the way out. A `void SetTenant(ctx)` +cannot express that save-and-restore, so renaming the code to match this ADR +would break a working caller to satisfy a naming error. + +**Every carrier changed.** This ADR (the inline erratum in § Rules and this +amendment); +[Architecture Tests Catalogue](../standards/21-architecture-tests-catalogue.md), +where `SetTenant_Callers_Are_The_Enumerated_Four` is restated against writes to +`Current`; and [architecture/09 Tenant Isolation](../architecture/09-tenant-isolation.md), +which spells `SetTenant(...)` in four places. The test's canonical **name** is +unchanged — the catalogue's § Canonical names rule makes a rename its own +liability, and the name describes the caller set, which is what this ADR +actually decides. + +**The Decision is unchanged.** Exactly four callers populate the ambient tenant +context — `TenantResolverMiddleware` (HTTP), `HubCorrelationMiddleware` +(`/api/internal/*`), the Hangfire `JobActivator` (jobs), and the outbox / inbox +handler scope (integration events) — and `EnterPlatformAdminScope` is not among +them, because it opens a second connection and sets no tenant context. + +### 2026-09-01 — Amendment 3: where the `[PublicSurface]` set is enumerated + +**What was wrong.** § The reconciliation matrix says the `[PublicSurface]` set "is +enumerated in the catalogue with each entry's permitted methods". It was enumerated in +no file, and "the catalogue" is not a resolvable referent: this corpus uses the word for +the architecture-tests catalogue, the audit-coverage catalogue and the permission +catalogue. A rule that reads against a set nobody wrote down cannot be implemented, and +`PublicSurface_Marker_Set_Is_Enumerated` is a Packet 7 deliverable that has to. + +**How it was shown.** `grep -rn "PublicSurface" docs/` returns hits only inside this +file. The architecture-tests catalogue disclaims owning rule content in its own opening +section, so it was never the home. + +**Every carrier changed.** This ADR (the inline erratum in § The reconciliation matrix +and this amendment); [Standards 04 § Public surface](../standards/04-api-design.md), +which now holds the marker's rules and the enumeration table — shipped **empty**, taking +its first rows with Phase 02d's two anonymous read endpoints; and +[the architecture-tests catalogue](../standards/21-architecture-tests-catalogue.md), +where both `PublicSurface_*` entries now point at Standards 04 rather than at +themselves. § Architecture tests of this ADR already named Standards 04 § Tenant Context +as the home of these day-to-day rules, so the correction moves the set to where this ADR +had already sent the reader. + +**The Decision is unchanged.** Every rule about the set holds exactly as written: the +default is `GET`/`HEAD`, a mutating entry states why, no `[PublicSurface]` type performs +a tenant-owned write, and none is classified MUST-class `read-sensitive`. ## References diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index a184c35f..f3272135 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -202,6 +202,16 @@ the business write" — a formulation ADR-0033 **withdrew** in favour of the sam ### Who sets `app.tenant_id`, completely +> **Erratum — 2026-08-30.** The paragraph and table below read that the set "is +> closed" at six. It is seven. The seventh is `IOrganizationScopeValidator`, +> which reads `organizations` by `(tenant_id, id)` "in its own short read-only +> transaction that sets `app.tenant_id` as its first statement"; shown by +> [ADR-0036 § What is out of scope, and what is not](0036-tenant-resolution-trusted-inputs.md), +> Accepted 2026-08-18 — nine days before this ADR. The Decision is unchanged. +> Current authority: this subsection as corrected, reproduced in +> [Security Standards § The out-of-band setters](../standards/11-security.md). +> Recorded in Amendment 3. + The set is closed. Each entry either **is** the ambient transaction or owns a short transaction of its own on its own connection, because it runs where no ambient transaction exists yet: @@ -444,6 +454,59 @@ conclude no nested SQL exists. None of this changes what the ADR decides — one connection per scope, owned by `IUnitOfWork`, with every context and cross-cutting writer enlisted on it. +### Amendment 3 — the setter set is seven, not six (2026-08-30) + +**What was wrong.** § Who sets `app.tenant_id`, completely opens "The set is +closed" over a six-row table. The set was already seven when that sentence +entered the record. + +**How it was shown.** +[ADR-0036](0036-tenant-resolution-trusted-inputs.md) § What is out of scope, and +what is not — Accepted **2026-08-18**, nine days before this ADR's 2026-08-27 — +schedules `IOrganizationScopeValidator`, "reading `organizations` by the +composite key `(tenant_id, id)` in its own short read-only transaction that sets +`app.tenant_id` as its first statement — the same pattern +`CachedHostToTenantResolver` uses for `app.resolving_host`". It is a setter of +`app.tenant_id` by that sentence's own terms, and it is not in the table. It +cannot be `TransactionBehavior`'s ambient transaction either: the organization +assertion is validated in the request edge, before the pipeline reaches step 6. + +**Every carrier changed.** This ADR (the inline erratum above and this +amendment) and +[Security Standards § The out-of-band setters](../standards/11-security.md), +which reproduces the count and the table — "six" becomes seven, "four own a +short transaction of their own" becomes five, and the table gains an +`IOrganizationScopeValidator` row. No other document states the count. + +**The canonical list** remains this subsection, as corrected. Security Standards +reproduces it because that section is the placement authority; it is not a second +enumeration. + +**The Decision is unchanged.** One connection per scope, owned by `IUnitOfWork`, +with every context and cross-cutting writer enlisted on it. The seventh setter +obeys the same rule as the four before it — its own short transaction, on its own +connection, connected as `learnstack_app` — which is the property the enumeration +exists to hold. + +### Amendment 4 — one bounded cross-aggregate write is now sanctioned (2026-08-30) + +Not a correction. § What the transaction spans says the transaction covers "one +aggregate's write" and closes with "**This ADR does not relax either rule.**" +Both statements were true when written and remain true of *this* ADR: it relaxes +nothing. + +[ADR-0042](0042-tenant-provisioning-cross-aggregate-transaction.md) does, once +and by enumeration. Tenant provisioning writes `Tenant` and its default +`Organization` in one transaction, because `tenants.default_organization_id` +carries an invariant no eventual-consistency mechanism can deliver. The table row +is therefore an incomplete description of the sanctioned transaction, and this +amendment is the pointer that keeps a reader of this ADR alone from concluding +the exception does not exist. + +Nothing else changes. Cross-**module** writes remain forbidden with no exception, +which is the property ADR-0010's outbox boundary exists to protect, and the +exception's holder is a literal allow-list of one. + ## References - [ADR-0002 — Initial Architecture](0002-initial-architecture.md) diff --git a/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md b/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md new file mode 100644 index 00000000..df15381d --- /dev/null +++ b/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md @@ -0,0 +1,259 @@ +# ADR-0042: Tenant Provisioning as a Bounded Cross-Aggregate Transaction + +## Status + +Accepted + +**Date:** 2026-08-30 **Deciders:** @platform + +## Decision Drivers + +- **A written rule and a mandated implementation contradict each other, and both + are load-bearing.** + [Architecture Standards § Aggregate Ownership](../standards/01-architecture-standards.md) + says "cross-aggregate writes inside a single transaction are forbidden. Use an + integration event." + [Database Standards § Tenant-owned foreign keys](../standards/05-database.md) + says the provisioning transaction "inserts the tenant, inserts its default + organization, then `UPDATE`s the tenant — three statements in one transaction", + and rejects the deferred-constraint alternative by name. `Tenant` and + `Organization` are two aggregate roots + ([ADR-0017](0017-tenant-organization-hierarchy.md)), so the mandated shape is + the forbidden one. +- **The invariant the shape exists to protect is an isolation invariant, not a + convenience.** `tenants.default_organization_id` is the anonymous + organization scope's fallback and the target of a composite foreign key. A + tenant observable without one is a tenant whose org-scoped reads have no + default and whose `default_organization_id` is `NULL` on a column every later + reader assumes is set after provisioning. The shipped aggregate already states + it: `Tenant.AssignDefaultOrganization` — "Both statements run in one + transaction, so a tenant is never observable without a default organization" + (`Tenant.cs`). +- **No mechanism in the corpus can substitute for the atomicity.** + [ADR-0010](0010-cross-module-communication.md) offers four cross-module + mechanisms and [ADR-0017](0017-tenant-organization-hierarchy.md)'s 2026-08-10 + amendment already reasoned that "none of ADR-0010's four mechanisms offers an + atomic substitute". An integration event moves the second write to a later + transaction, which is precisely the window this invariant forbids. +- **[ADR-0040](0040-ambient-unit-of-work.md) deliberately declined to settle + it.** Its § What the transaction spans says in terms: "It does **not** license + a cross-aggregate or cross-module *write*. … **This ADR does not relax either + rule.**" That was the right scope for an ADR about connection ownership, and + it leaves the contradiction standing rather than resolving it. +- **Phase 02a Packet 7 is the first code that executes the shape.** Until now + nothing wrote a `Tenant`. The seed and the provisioning command land together + in Packet 7, so the exception is about to be established either by a decision + record or by a merged commit that nobody wrote down. +- **Packet 7's aggregate-boundary decision widens the blast radius if the + exception is left unbounded.** If `TenantDomain` and `TenantSetting` are + promoted to roots — the direction Packet 7 takes, and the reason this driver is + written conditionally rather than assumed — a provisioning path that "sets a + tenant up" could plausibly write four roots in one transaction. The exception + has to say which writes it covers, or it covers whatever the next handler + wants. Nothing below depends on which way that boundary resolves. + +## Considered Options + +1. **A bounded exception, enumerated rather than principled** (chosen). Exactly + one operation writes two aggregate roots in one transaction — `Tenant` and + its default `Organization` — and the rule stays otherwise absolute. +2. **An integration event for the default organization** (rejected). The + corpus's own prescribed substitute; it cannot deliver an atomicity invariant. +3. **`DEFERRABLE INITIALLY DEFERRED` on the composite foreign key** (rejected, + and already rejected once by Database Standards). +4. **Fold `Organization` into the `Tenant` aggregate** (rejected). Removes the + cross-aggregate write by removing the aggregate. +5. **Relax § Aggregate Ownership generally** (rejected). Replaces a rule that + keeps module extraction cheap with a judgement call per handler. + +## Decision + +**Tenant provisioning writes exactly two aggregate roots in one transaction: +the `Tenant` and its default `Organization`.** This is a standing, named +exception to +[Architecture Standards § Aggregate Ownership](../standards/01-architecture-standards.md), +and it is the only one. + +The exception is **bounded by enumeration, not by principle**: + +- It covers the two roots named above and nothing else. A tenant's initial + `TenantDomain`, `TenantSetting`, `TenantLocale` and `TenantFeatureFlag` rows + are **not** covered — none of them carries an atomicity invariant against the + tenant row, so each is written by its own command in its own transaction. + `platform_host_to_tenant` is a projection rather than an aggregate and is + outside the rule entirely. +- It is held by one operation. `ProvisionTenantCommand` (and the seeder that + invokes it) is the whole set; the set is written down in the architecture test + as a literal allow-list, so a second holder is a test edit and a review + conversation, not a silent addition. +- It licenses nothing cross-**module**. Both roots are Tenancy's; a write + crossing a module boundary remains forbidden with no exception, which is what + keeps [ADR-0010](0010-cross-module-communication.md)'s extraction property + intact. + +Standards 01 § Aggregate Ownership gains a one-line carve-out citing this ADR. +[ADR-0040](0040-ambient-unit-of-work.md) carries a dated Amendment, because its +"one aggregate's write" table row and its "this ADR does not relax either rule" +sentence become an incomplete description of the sanctioned transaction — both +were true when written, so the instrument is an amendment and not an +[ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md) erratum. + +## Context + +### Why the integration event cannot substitute + +The substitute § Aggregate Ownership prescribes is: write the first aggregate, +enqueue an integration event, let a consumer write the second. Applied here, the +`tenants` row commits first and the `organizations` row arrives in a later +transaction. Between them the tenant is a committed, readable row with +`default_organization_id IS NULL`. + +That window is not theoretical and not brief in the failure case. The outbox +guarantees the event is eventually delivered, not that it is delivered before +the next request. Anything that resolves the tenant in between — a host lookup +against a seeded `platform_host_to_tenant` row, an operator opening the tenant, +Phase 02d's anonymous render — sees a tenant whose default organization does not +exist. And if the consumer fails permanently, the window never closes: the +outbox retries, but no retry can create an organization whose id the tenant row +was supposed to point at. + +The deeper point is that an integration event is an eventual-consistency +mechanism and the invariant is a consistency invariant. Substituting one for the +other does not weaken the guarantee, it deletes it. + +### Why not a deferred constraint + +[Database Standards](../standards/05-database.md) already rejected +`DEFERRABLE INITIALLY DEFERRED`, and its reason stands: a deferred constraint +moves the failure to `COMMIT`, where the error names the constraint rather than +the statement that broke it. It also would not help. Deferring the *constraint* +does not merge two transactions into one; it only relaxes when the check runs +inside whatever transaction is open. The three-statement shape needs one +transaction either way. + +### Why `Organization` is not folded into `Tenant` + +It is the obvious way to make the problem disappear, and +[ADR-0017](0017-tenant-organization-hierarchy.md) already decided against it for +reasons that have not changed: an organization has its own lifecycle +(created, renamed, archived) independent of its tenant; it is a permission scope +in its own right ([Permission Standards](../standards/19-permissions.md)'s +Organization scope); and every organization-scoped row in every module keys on +it. An aggregate that must be loaded whole to rename one branch of a +fifty-branch tenant is the wrong boundary, and the RLS policy reads +`app.organization_id` and joins nothing — the isolation model does not need +containment either. + +### What would change our minds + +- **If `default_organization_id` became legitimately optional** — a deployment + shape where a tenant has no organizations at all — the invariant dissolves and + the exception with it. Nothing in the corpus points that way today: ADR-0017 + makes the default organization the tenant's own root branch. +- **If the two-level hierarchy grew a third level** the provisioning path would + have more than two roots to consider, and the enumerated exception would need + re-deriving rather than extending. ADR-0017 fixes the hierarchy at two levels, + so this is a re-open condition, not a foreseen one. +- **If PostgreSQL grew a usable cross-transaction atomicity primitive** the + shape could change without changing the rule. It has not. + +### What this deliberately does not settle + +- **Whether a child write bumps `Tenant.row_version`.** That is a concurrency + question owned by [ADR-0039](0039-optimistic-concurrency-token.md) and the + Packet 7 boundary decision, not an aggregate-ownership question. +- **The provisioning command's own contract** — its parameters, its + `[AllowsUnresolvedTenantContext]` marker, its permission key. Those are + Packet 7 and Phase 03 respectively. +- **Deprovisioning.** Every foreign key is `ON DELETE RESTRICT` and tenant + hard-deprovisioning has no owning phase + ([Tenancy module spec § Risks](../modules/tenancy/README.md)). Whatever writes + that path will need its own decision; this ADR grants it nothing. + +## Consequences + +### Positive + +- The mandated implementation and the written rule stop contradicting each + other. An implementer reading either one reaches the same code. +- The invariant is preserved by the mechanism that actually delivers it, and the + cost of that choice is written down where a reviewer can find it. +- The exception is countable. A hole nobody counts becomes a hole everybody + uses; a literal allow-list of one is a hole with a name on it. +- The rule keeps its force everywhere else. § Aggregate Ownership remains + absolute for every other handler, and cross-**module** writes stay forbidden + with no exception at all. + +### Negative + +- The corpus now has a rule with an exception, which is strictly harder to teach + **and to enforce** than a rule without one: the architecture test has to carry + an allow-list, and an allow-list is a thing that can grow. Mitigated only by + the exception being singular and named. +- The architecture test that bounds it is a source scan, and a source scan is + not proof. See § Implementation Notes for exactly what it does and does not + catch. +- A future reader may reasonably ask why the same argument does not license the + next atomic-looking pair. The answer is in § Context and not in the rule text, + which is a weaker place for it to live. + +### Neutral + +- No schema change. The three-statement shape, the nullable + `default_organization_id` and the composite foreign key are all already + shipped in Packet 6's migration; this ADR records why they are legal, not what + they are. +- No change to [ADR-0040](0040-ambient-unit-of-work.md)'s connection model. The + two writes already share the ambient unit of work; what changes is that the + second one is now sanctioned rather than tacitly tolerated. + +## Implementation Notes + +- **The transaction shape** is the one + [Database Standards](../standards/05-database.md) and the + [Tenancy module spec](../modules/tenancy/README.md) already draw: `BEGIN` → + `SET LOCAL app.tenant_id` to the registry-supplied id → `INSERT tenants` → + `INSERT organizations` → `UPDATE tenants SET default_organization_id` → + `COMMIT`. The tenant id is never minted in the handler; the self-keyed policy's + `WITH CHECK` passes because the context was set to that id first. +- **The carve-out** is one line in + [Architecture Standards § Aggregate Ownership](../standards/01-architecture-standards.md), + citing this ADR. The rule text itself is unchanged. +- **The architecture test** is + `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning`. The **rule** is + registered in [the catalogue](../standards/21-architecture-tests-catalogue.md) + in the same commit as this ADR, at Status **Registered** and Phase 02a + Packet 7; the **test** lands with the provisioning handler it guards, later in + that packet, and the catalogue row moves to Implemented then. Registering the + rule ahead of the code is the corpus's ordinary order and is why the exception + cannot arrive un-enumerated. It scans MediatR handler sources for write calls + (`Add`/`AddRange`/`Update`/`Remove`) against more than one `DbSet` whose entity + implements `IAggregateRoot`, and holds a literal allow-list of exactly one + handler type. + + **What it proves and what it does not.** It catches the direct form, which is + the form a handler is written in. It does not catch a write routed through a + repository, a helper or a second `DbContext` reached indirectly — the same + limit [the catalogue § What a structural test proves](../standards/21-architecture-tests-catalogue.md) + states for every source scan. The binding control is that the allow-list has + one entry and growing it is a reviewed diff; the scan is what makes the + ordinary mistake loud. +- **The seeder does not hold a second copy of the exception.** It invokes + `ProvisionTenantCommand` rather than writing the two aggregates itself, so the + allow-list stays at one entry and the seed exercises the same path production + does. + +## Amendments + +None yet. + +## References + +- [ADR-0017: Tenant / Organization Hierarchy](0017-tenant-organization-hierarchy.md) +- [ADR-0040: The Ambient Unit of Work](0040-ambient-unit-of-work.md) +- [ADR-0010: Cross-Module Communication](0010-cross-module-communication.md) +- [ADR-0041: Correcting False Statements in Accepted ADRs](0041-correcting-false-statements-in-accepted-adrs.md) +- [Architecture Standards § Aggregate Ownership](../standards/01-architecture-standards.md) +- [Database Standards](../standards/05-database.md) +- [Tenancy module spec](../modules/tenancy/README.md) +- [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](../roadmap/phase-02a-kernel-tenancy.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c47e6c34..8b95aece 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -55,6 +55,7 @@ A body that says something **false** is the one exception, and it is bounded by | 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented inside `AuditableEntity` by the single primitive `MarkUpdated` and `SoftDelete` both route through, and mapped with `HasDefaultValue(0L).IsConcurrencyToken().ValueGeneratedNever()` — exactly those three calls (Amendment 2): `ValueGeneratedOnAddOrUpdate()` / `IsRowVersion()` make EF omit the column from the `UPDATE` entirely (Amendment 1, measured), and `HasDefaultValue` alone leaves `ValueGenerated` at `OnAdd`, which the registered rule rejects; `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | | 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden. Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | | 0041 | [Correcting False Statements in Accepted ADRs](0041-correcting-false-statements-in-accepted-adrs.md) | Inline erratum beside the false text is the default; in-place replacement only where the text is a **canonical artifact for reuse** — a template others are told to copy, a DDL or command meant to be applied — never merely because something could be copied, and never because a non-ADR carrier holds the same token. Bounded to statements false **when they entered the record**, judged per statement: original body at the acceptance commit, amendment text at that amendment's date. A statement that has since gone stale is history, and gets an amendment or a superseding ADR. Every changed Accepted ADR carries its own dated Amendment | +| 0042 | [Tenant Provisioning as a Bounded Cross-Aggregate Transaction](0042-tenant-provisioning-cross-aggregate-transaction.md) | A standing, **enumerated** exception to § Aggregate Ownership: tenant provisioning writes `Tenant` and its default `Organization` in one transaction, because `tenants.default_organization_id` carries an invariant an integration event cannot deliver — the substitute moves the second write to a later transaction and the window between them is exactly what the invariant forbids. Bounded by enumeration rather than by principle: two roots, one operation, a literal allow-list of one in the architecture test. Covers no child entity, no projection, and no cross-**module** write, which stays forbidden with no exception | ## Superseded ADRs diff --git a/docs/glossary.md b/docs/glossary.md index c567e447..ba12f148 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -125,6 +125,11 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Global table** | A table with no tenant dimension at all — a person, not a person-in-a-tenant. `users` is the case ([Phase 03](roadmap/phase-03-identity-admin.md)); tenancy arrives through the membership row beside it. `tenants` is **not** one: it is tenant-owned, self-keyed. | | **Tenant context** | The ambient resolved tenant for a request, job, or background task. | | **Query filter** | EF Core global query filter that injects `WHERE tenant_id = @current_tenant_id` automatically. | +| **`TenantContextOrigin`** | The authority ceiling on a resolved context: `HostOnly`, `HostAndClaim`, `ClaimAndMembership`, `Ambient`. A `HostOnly` context reaches only request types marked `[PublicSurface]`, which is what makes a forged host harmless — it reaches exactly the pages that hostname already serves to anyone who types it. Per [ADR-0036 § The reconciliation matrix](decisions/0036-tenant-resolution-trusted-inputs.md). | +| **`TenantContextFactory`** | The single entry point that constructs the sealed `TenantContext`: it returns `Result.Fail` on any disagreement between the signals and never a partially populated context. `TenantContext` has no public constructor; `TenantContext_Is_Constructed_Only_By_The_Factory` enforces both halves. Per [ADR-0036 § The reconciliation matrix](decisions/0036-tenant-resolution-trusted-inputs.md). | +| **`IOrganizationScopeValidator`** | The reader that answers "does this organization belong to this tenant", resolving `organizations` by the composite key `(tenant_id, id)` in its own short read-only transaction that sets `app.tenant_id` as its first statement — one of the sanctioned out-of-band setters of that GUC ([Security Standards § The out-of-band setters](standards/11-security.md), per [ADR-0040 Amendment 3](decisions/0040-ambient-unit-of-work.md)). A valid organization id from another tenant is a mismatch, not an override. | +| **`DenyAllTenantMembershipReader`** | The Packet 7 `ITenantMembershipReader` that denies every membership question, so the reconciliation matrix's rows 7 and 14 fail closed until [Phase 03](roadmap/phase-03-identity-admin.md) ships `Membership`. It makes the Studio tenant switcher 404 for everyone in that window; that is correct and it will look like a bug. Per [ADR-0036](decisions/0036-tenant-resolution-trusted-inputs.md). | +| **`EnterPlatformAdminScope`** | The explicit, scoped, audited entry to cross-tenant access: a DI scope whose `DbContext` is built on a second, separately-credentialed data source that connects as `learnstack_platform` — never `SET ROLE`, which would make the four-role separation a naming convention rather than a boundary. Not one of the `ITenantContextAccessor` writers. See [Database Standards § How `EnterPlatformAdminScope(reason)` reaches `learnstack_platform`](standards/05-database.md). | ## Extension Model @@ -190,6 +195,8 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`[OrganizationScoped]`** | Marks a `[TenantOwned]` entity that additionally carries `OrganizationId` (nullable; null means tenant-wide). The build asserts a matching org-aware EF filter + RLS policy reading `current_setting('app.organization_id', true)`. Per [ADR-0017](decisions/0017-tenant-organization-hierarchy.md). | | **`[PiiSensitive]`** | Marks a field whose value the audit pipeline must redact before persisting to `audit_log`. The redaction filter strips matching property names from `before` / `after` snapshots and replaces with `""`. | | **`[ConsistencyTier(...)]`** | Optional marker on a command handler that explicitly states the distributed-consistency tier (1 / 2A / 2B / 3) per [01-architecture-standards.md § Distributed-Consistency Tiers](standards/01-architecture-standards.md). Reviewers use it to reason about failure modes. | +| **`[PublicSurface]`** | Marks a request type a `TenantContextOrigin.HostOnly` context may reach; `TenantContextBehavior` at pipeline step 4 rejects anything else. Permitted methods default to `GET`/`HEAD` and a mutating entry states why; no marked type performs a tenant-owned write, and none is classified MUST-class `read-sensitive` — that would turn an anonymous `GET` into a durable standalone audit write. The set is enumerated in [Standards 04 § Public surface](standards/04-api-design.md) and ships **empty** in Packet 7. | +| **`[AllowsUnresolvedTenantContext]`** | Marks the narrow set of tenant-provisioning and platform-admin request types that legitimately run before a tenant is resolved. A deliberate hole in the tenant-context assertion at pipeline step 4, counted by `AllowsUnresolvedTenantContext_Only_On_Provisioning_Commands` — a hole nobody counts becomes a hole everybody uses. See [Standards 21](standards/21-architecture-tests-catalogue.md). | ## Data Protection @@ -292,7 +299,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Tenant keys are `{tenant_id}:{module}:{logical-name}`, or `{tenant_id}:{organization_id}:{module}:{logical-name}` for organization scope, composed by `CacheKey` and enforced by `CacheKey.EnsureValid`. The only platform-wide family is the normalized host map `platform:hub:host-map:{normalized-host}`, composed by `CacheKey.ForHostMapping`; there is no generic platform factory. `RemoveByPrefixAsync` is absent per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). Set invalidation uses a caller-owned durable generation key embedded in the key template. | | **`ISecretProvider`** | Interface for secret reads. `ConfigurationSecretProvider` today; the Vault-backed implementation when a production secret must rotate without a redeploy, or more than one operator needs access to production secrets ([ADR-0035](decisions/0035-demand-gated-infrastructure.md) — *not* when a non-development deployment merely exists, which SaaS satisfies on day one). Secret namespace `learnstack/{deployment}/{module}/{key}`. | | **`IEntitlementProvider`** | Interface for the Entitlement Projection source. Implementations: `NullEntitlementProvider` (Development only — all features enabled, no limits), `HubEntitlementProvider` (SaaS / Dedicated, from Phase 02c), `SignedLicenseKeyEntitlementProvider` (Self-Hosted; skeleton from Hub `P02c-6`, hardened in Phase 11). The Hub-backed provider resolves in the normative order `L1 → L2 → platform_entitlement_cache → Hub` and never throws out of a feature-flag check. | -| **`IHostToTenantResolver`** | Interface for host → `(tenant_id, organization_id?)` resolution. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | +| **`IHostToTenantResolver` / `HostResolution`** | Interface for host → `(tenant_id, organization_id?)` resolution, returning the `HostResolution(TenantId, OrganizationId?)` record or null. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | | **APISIX** | The gateway in standalone YAML-reload mode per [ADR-0015](decisions/0015-api-gateway-apisix.md). The intended tenant-facing ingress, demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); until then ASP.NET middleware carries the same responsibilities in-process. `/api/internal/*` is never proxied by it — that listener is mTLS-only inside the pod. | | **`OutboxProcessor`** | The BackgroundService that **claims** batches of `outbox_messages` by writing a lease (`locked_by` / `locked_until`) under `FOR UPDATE SKIP LOCKED`, dispatches each through `IEventBus`, and handles retry / dead-letter. The lease is written to the row rather than held in the transaction, because `FOR UPDATE` locks end when the transaction ends. Delivery is **at-least-once**; consumer-side `IInboxGuard` is what makes duplicates safe. See [15-event-and-outbox.md](architecture/15-event-and-outbox.md). | | **`IUnitOfWork`** | The ambient unit of work: **one database connection per scope**, and the transaction on it ([ADR-0040](decisions/0040-ambient-unit-of-work.md)). Every module `DbContext` resolved in the scope is built on that connection and enlisted in that transaction, and `IAuditStore` and `IOutbox` reach it through the same seam. The connection count is a correctness property, not a performance one: `SET LOCAL app.tenant_id` is connection- **and** transaction-local, so a context on its own connection reads zero rows from every tenant-owned table — silently. It exposes no `SaveChangesAsync`; contexts save themselves and it owns only the transaction boundary. | diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 6a7c1e37..eaab2536 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -3,9 +3,12 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). -Per [Audit Coverage](../../standards/18-audit-coverage.md). The operations do not -exist yet; the classification does, and it is the floor a later packet may narrow -for SHOULD/MAY but never for MUST. +The operations do not exist yet; the classification does. This matrix is not the +floor — [Audit Coverage § Baseline Coverage](../../standards/18-audit-coverage.md) +is, and a module matrix "cannot remove anything in this list". This file adds rows +beneath that baseline and classifies what the baseline leaves open; a tenant +`AuditConfig` may then narrow SHOULD/MAY at runtime. Neither touches a baseline +MUST. | Resource | Operation | Class | Why | |---|---|---|---| @@ -15,13 +18,22 @@ for SHOULD/MAY but never for MUST. | `Organization` | create / archive | **MUST** | Changes the isolation surface of every org-scoped row | | `Organization` | rename | SHOULD | Presentational | | `TenantDomain` | claim / verify / fail | **MUST** | A host change redirects traffic; a wrongly verified domain serves one tenant's content at another's address | -| `TenantSetting` | write / delete | SHOULD | Configuration, per key; a tenant `AuditConfig` may narrow this | +| `TenantDomain` | release / delete | **MUST** | `ux_tenant_domains_host` is partial on `deleted_at IS NULL`, so a release frees a globally unique host for another tenant to claim | +| `TenantSetting` | write / delete | **MUST** | [Audit Coverage](../../standards/18-audit-coverage.md) puts "tenant setting changed" on the Tenancy baseline row; a tenant `AuditConfig` cannot narrow it | | `TenantLocale` | write | SHOULD | Configuration | -| `TenantFeatureFlag` | write | SHOULD | Configuration, but see the note | +| `TenantFeatureFlag` | write | **MUST** | "Feature flag toggled" is on the same baseline row, and [Feature Flags § Audit](../../architecture/21-feature-flags.md) classes both flag surfaces as security events | +| `TenantFeatureFlag` | killswitch toggle (`tenancy.killswitch.toggle`) | **MUST** | A platform-admin flip stored under the sentinel platform tenant that disables a capability for every tenant at once ([Feature Flags § Killswitch Pattern](../../architecture/21-feature-flags.md)) | | `platform_entitlement_cache` | refresh | **MUST** | Changes what the tenant may do; written only by `IEntitlementProvider.RefreshAsync` | | `platform_host_to_tenant` | write / delete | **MUST** | The resolution index — the row that decides whose data an anonymous request sees | -| any | read under `EnterPlatformAdminScope` | **MUST** (`read-sensitive`) | Cross-tenant access is the one read worth a row | +| any | read under `EnterPlatformAdminScope` | **MUST** (`security-event`) | Cross-tenant access is the one read worth a row; [Audit Coverage](../../standards/18-audit-coverage.md) puts every platform-bypass invocation on `security-event`, and that is what [Packet 9](../../roadmap/phase-02a-kernel-tenancy.md) writes when it replaces the scope's log line | -A feature flag that gates a **billed** capability is plan-level and belongs in the -entitlement projection, not here — so a SHOULD on this table never covers a -change that should have been MUST elsewhere. +A feature flag that gates a **billed** capability is not a `tenant_feature_flags` +row at all. It is plan-level, it is written only by +`IEntitlementProvider.RefreshAsync`, and it is audited on the +`platform_entitlement_cache` refresh row above (`tenancy.entitlement.refresh`). +The distinction routes the change to the right row; it does not make either row +optional. + +The classification is inert until [Packet 9](../../roadmap/phase-02a-kernel-tenancy.md) +lights up `AuditLogBehavior`, and Packet 9 transcribes its in-process catalogue +from this file. diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 43ad59e4..6f382860 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -4,9 +4,15 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). **No permission keys yet** — Packet 6 ships no command or query handler, so there -is nothing to authorize. The matrix below is what Packet 7 registers, in the +is nothing to authorize. The matrix below is a forward declaration in the `{module}.{resource}.{action}` form with the closed action set of -[Permission Standards](../../standards/19-permissions.md). +[Permission Standards](../../standards/19-permissions.md), not a Packet 7 +deliverable. Registration runs through +`IModule.RegisterPermissions(IPermissionRegistry)`, and neither type exists in +`backend/src` yet; the catalogue lands with the Identity module in +[Phase 03](../../roadmap/phase-03-identity-admin.md), together with `Role`, +`Permission` and the lighting-up of the `AuthorizationBehavior` shell. Packet 7 +ships the aggregates and the seed, not the keys. | Resource | read | write | delete | admin | Default role grants | |----------|:----:|:-----:|:------:|:-----:|---------------------| diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 22fbbf62..40c0c935 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -433,6 +433,37 @@ outbox / inbox handler scope (integration events). `[TenantOwned]` and `[OrganizationScoped]` marker attributes. EF global query filters on every entity implementing `ITenantOwned` / `IOrganizationScoped`. +**What [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) assigns to this +packet.** `HostClassificationMiddleware` runs over `/api/v1/*` only and produces exactly +one of `TenantHost(T)`, `OrgHost(T, O)`, `PlatformHost`, or `UnknownHost` → **404** +before any handler. What it does not classify is a **prefix list** rather than a closed +allow-list of endpoint literals, which would 404 the first Hub endpoint nobody +remembered to enumerate; the prefixes are enumerated in +[ADR-0036 § Effective host and the trusted hop](../decisions/0036-tenant-resolution-trusted-inputs.md). +`TenantContextFactory.Create(TenantResolutionAttempt) → Result` is the +sealed context's only entry point: it returns `Result.Fail` on any disagreement between +signals and never a partially populated context. `TenantContextOrigin` is the authority +ceiling, and the `[PublicSurface]` set it gates is enumerated in +[Standards 04 § Tenant Context](../standards/04-api-design.md) — the enumeration ships +**empty**, because the first request types that need it are +[Phase 02d](phase-02d-walking-skeleton.md)'s two anonymous read endpoints. +`IOrganizationScopeValidator` answers "does this organization belong to this tenant" by +reading `organizations` on the composite key `(tenant_id, id)`, in its own short +read-only transaction that sets `app.tenant_id` as its first statement — the **seventh** +sanctioned `app.tenant_id` setter +([ADR-0040 Amendment 3](../decisions/0040-ambient-unit-of-work.md)). +`DenyAllTenantMembershipReader` is the `ITenantMembershipReader` this packet registers: +it covers nothing, so the reconciliation matrix's rows 7 and 14 fail closed and the +Studio tenant switcher returns **404 `not_found`** for everyone until +[Phase 03](phase-03-identity-admin.md) ships `Membership`. That is correct and it will +look like a bug; it is named here with its error code so nobody makes the default +permissive to unblock a demo. The refusal is byte-identical on the wire to an +unresolvable host: a bodyless 404 rendered by `UseStatusCodePages`, because anything a +client can tell apart confirms to an anonymous caller that the tenant exists. The +per-packet staging is +[ADR-0036 § Staging across packets](../decisions/0036-tenant-resolution-trusted-inputs.md); +the matrix is not restated here. + **The RLS session variables are set with `SET LOCAL` inside the ambient transaction**, after it opens. `set_config(..., true)` is transaction-local: set from a MediatR behavior that runs before `TransactionBehavior`, or from a @@ -440,8 +471,20 @@ connection interceptor that fires at connection open, it is discarded before the query it is meant to protect ever runs. The corpus previously described all three placements; [Security Standards § Tenant Context](../standards/11-security.md) is now the single authority. The Packet 3 `TenantContextBehavior` TODO — which -named the connection-interceptor option — was corrected in Packet 3b; this packet -implements the mechanism it now points at. +named the connection-interceptor option — was corrected in Packet 3b, and Packet 6 +shipped the setter it now points at: `IUnitOfWork.SetTenantContextAsync`, called by +`TransactionBehavior`. What this packet supplies is the **resolved context** that setter +writes. + +**The `DbCommandInterceptor` guard lands here too.** With `app.tenant_id` unset or reset +the policy predicate is `NULL`, so a tenant-owned read returns zero rows — fail-closed, +and silent. The interceptor asserts that a sanctioned setter has already issued the +`SET LOCAL` pair on the transaction the command runs in and throws +`TenantContextMissingException` when none has, which turns the silent empty result into +a loud one. Both +[Security Standards § Tenant Context](../standards/11-security.md) and +[Database Standards](../standards/05-database.md) place it in this packet, on the same +argument: the first tenant-owned read on a request path is Packet 7's. Explicit, scoped, audited `EnterPlatformAdminScope(reason)` for the narrow cross-tenant access path. It reaches `learnstack_platform` through a **second, @@ -457,8 +500,12 @@ only `PlatformAdminScope` may resolve The scope's **audit obligation is declared here and satisfied in Packet 9**, which is where `audit_log` and `IAuditStore` land. Until then `EnterPlatformAdminScope(reason)` -records the entry through `ILogger` at `Warning` with the `reason`, the caller and the -sentinel platform tenant id, and Packet 9 replaces that with a `SecurityEvent` audit row +records the entry through `ILogger` at `Warning` with the `reason` and the caller — and +**not** a sentinel platform tenant id, whose value `TenantId.cs` deliberately leaves +unfixed. The irreversible consumer of that value is `audit_log`'s `tenant_id` column, so +Packet 9 chooses it with the schema that stores it; a log line is not a one-way door and +carries the reason and the caller without minting a one-way-door identifier for a table +that does not exist yet. Packet 9 replaces the log line with a `SecurityEvent` audit row written as `learnstack_platform` **before** the operation runs — so an operation that later fails is still recorded. Packet 7 must not claim a durable audit trail it has no table for; a log line that is honestly a log line is better than an audit row that does @@ -477,8 +524,27 @@ not the containment [the module spec's ERD](../modules/tenancy/README.md) described — and the two halves do not resolve the same way: the first pair is root-shaped already, the second cannot be a root at all under `IAggregateRoot`. Packet 7 writes the -first command that touches any of them, which is the first evidence either -reading has, so it decides and reconciles code and spec in one direction. +first command that touches any of them, which is the first evidence either reading +has, and it resolves as **promotion**: `TenantDomain` and `TenantSetting` become +aggregate roots in their own right — each already carries a surrogate Vogen id, an +`AuditableEntity` base, a `row_version` and its own Row Level Security policy — while +`TenantLocale` and `TenantFeatureFlag` become navigations inside the `Tenant` +aggregate, because a composite natural key and no surrogate id is not an +`IAggregateRoot` under any reading. Tenancy therefore has four roots: `Tenant`, +`Organization`, `TenantDomain` and `TenantSetting`. A write to `TenantLocale` or +`TenantFeatureFlag` bumps `Tenant.row_version`; `TenantDomain` and `TenantSetting` +carry their own. Provisioning writes two of those roots in one transaction, which +[ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) +sanctions by enumeration rather than by principle — one operation, +`ProvisionTenantCommand`, a literal allow-list of one in +`Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning`, and no child entity, no +projection and no cross-module write. + +Packet 6 left the `tenant_locales` **single-default** invariant to this packet's call, +with the first code that reads it. It lands in the database, as a partial unique index +`UNIQUE (tenant_id) WHERE is_default`, plus an aggregate-level guard for the error +message. An aggregate invariant on its own does not hold across concurrent +transactions. **Two seed tenants in unrelated domains**, each with two organizations: an English school and a **yoga studio**. This is the artefact that tests the @@ -491,6 +557,18 @@ instead of one. Picks up the application-level seed drop-in deferred from [Phase 01 Packet 8](phase-01-repository-tooling.md), wired through the Tenancy module `DbContext` rather than the placeholder `scripts/seed.sh`. +The two are `demo-english` ("English Hero") and `demo-yoga` ("Anatolia Yoga"), on +`demo-english.learnstack.local` and `demo-yoga.learnstack.local`. **Packet 7 writes +their `platform_host_to_tenant` rows** — one row per tenant, not one per organization — +and sets `organization_id` deliberately: one row carries it and one leaves it `NULL`, +so both live classification classes, `OrgHost` and `TenantHost`, are exercised by the +seed rather than only by a fixture. +[Phase 02d](phase-02d-walking-skeleton.md) lists the same two host rows among its +deliverables; it renders them in a browser, and this packet writes them. Neither host +belongs in `Tenancy:PlatformHosts` — the short static deployment list of hosts that map +to **no** tenant (`app.learnstack.dev`, `localhost`), which is why those need no row at +all. + Cross-tenant and cross-organization isolation integration tests **run as `learnstack_app`**. A test that connects as the table owner or as a `BYPASSRLS` role passes even when every policy is inert, and therefore proves nothing. The @@ -512,6 +590,13 @@ what a *request* does: the same five re-run through `TenantResolverMiddleware` and the EF query filters rather than through `set_config` in a test, plus whatever the two seed tenants add. +**No production `/api/v1` endpoint ships in this packet.** The request-level suite +drives the real middleware chain and the real query filters through a **test-only +controller registered in the test fixture**, which is the precedent +`IdempotencyFixture` already set for `/api/v1/sideeffectprobe`. The first real +`/api/v1/*` read endpoints stay where +[Phase 02d](phase-02d-walking-skeleton.md) declares them. + Carries two Packet 3 follow-ups: converting `ITenantContext` **and** `CapturedContext` from raw `Guid` / `Guid?` to the strongly-typed `TenantId` / `OrganizationId` value objects created in Packet 6, in a single pass to avoid a @@ -990,6 +1075,9 @@ Context is resolvable from: - Org-scoped subdomain (`branch-istanbul.example.edu` → tenant + organization). - Studio / Portal tenant selection, which travels as a **re-issued JWT claim**, never as a selector header ([ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)). + Packet 7 registers `DenyAllTenantMembershipReader`, so the path is wired and **fails + closed** — 404 for everyone — until [Phase 03](phase-03-identity-admin.md) ships + `Membership`. - Background job parameter. - Integration event envelope (envelope contract defined in Phase 02b; the resolver respects it from the start). @@ -997,7 +1085,14 @@ Context is resolvable from: Implementation: - `IHostToTenantResolver` (Postgres-backed default reading `platform_host_to_tenant`). +- `HostClassificationMiddleware` over `/api/v1/*` only, producing `TenantHost` / + `OrgHost` / `PlatformHost` / `UnknownHost` → 404. What it skips is a prefix list, not + an endpoint allow-list. - `TenantResolverMiddleware`. +- `TenantContextFactory.Create(TenantResolutionAttempt) → Result` as the + sealed context's only entry point, with `TenantContextOrigin` as the authority ceiling + over the `[PublicSurface]` set. +- `IOrganizationScopeValidator` and `DenyAllTenantMembershipReader`. - `ITenantContext` (request-scoped) exposing `TenantId`, `OrganizationId?`, `UserId?`. - Tenant- and org-aware query conventions. - Tenant + org context propagation seams for Hangfire jobs and outbox dispatcher @@ -1098,6 +1193,8 @@ identifiers registered in - `AuditEntry_Inherits_Entity_Not_AuditableEntity`. - `MustClass_Audit_Writes_Share_The_Business_Transaction` — per [ADR-0033](../decisions/0033-audit-durability-model.md). +- `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` — per + [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md). - `LearnStack_Modules_DoNotReference_Hub`. - `Modules_Do_Not_Inject_Valkey_Directly`, `Modules_Do_Not_Read_Entitlement_Cache_Directly`, `Modules_Do_Not_Write_AuditLog_Directly`. diff --git a/docs/standards/01-architecture-standards.md b/docs/standards/01-architecture-standards.md index fce713b0..b44555c1 100644 --- a/docs/standards/01-architecture-standards.md +++ b/docs/standards/01-architecture-standards.md @@ -93,6 +93,14 @@ third build-time reference to Domain or SharedKernel requires an ADR. - The aggregate root is the only entry point for state changes inside the aggregate. - Repositories return aggregates, not raw entities. - Cross-aggregate writes inside a single transaction are forbidden. Use an integration event. + **One standing exception, bounded by enumeration:** tenant provisioning writes `Tenant` + and its default `Organization` in one transaction, because + `tenants.default_organization_id` carries an invariant no eventual-consistency + mechanism can deliver + ([ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)). + It covers those two roots and that one operation; the allow-list is literal, and + `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` holds it at one entry. + Cross-**module** writes remain forbidden with no exception at all. ## Cross-Module Communication @@ -132,7 +140,16 @@ Rules: ## Tenant-Scoped Code -- Every entity that has a `TenantId` property must be annotated `[TenantOwned]`. +- Every entity backed by a table in one of the **tenant-owned** table classes carries + `[TenantOwned]`. The two exceptions are classes, not omissions: + `tenants` is tenant-owned **self-keyed** — its `id` *is* the tenant id, so it has no + `TenantId` property and its policy keys on `id` — and `platform_host_to_tenant` is + **platform-scoped**, read in order to determine the tenant, so a tenant-keyed + predicate on it would make host resolution return zero rows forever. See + [Database Standards § Table classes](05-database.md) and + [ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md). + The presence of a `TenantId` property is **not** the test: `PlatformHostMapping` has + one and takes no marker. - `[TenantOwned]` entities must have a configured EF global query filter and a PostgreSQL RLS policy (see [Database Standards](05-database.md)). - Application services must never expose `IgnoreQueryFilters()` directly to callers. - Background jobs and integration event handlers must accept `TenantId` as part of their payload and set it as the ambient context before doing work. diff --git a/docs/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md index 53ec9b12..b94c5699 100644 --- a/docs/standards/02-backend-coding.md +++ b/docs/standards/02-backend-coding.md @@ -1,7 +1,7 @@ # 02 — Backend Coding Standards **Status:** Active -**Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md), [ADR 0006 — Events and Outbox](../decisions/0006-events-and-outbox.md), [ADR 0023 — Strongly-Typed ID Source Generator](../decisions/0023-strongly-typed-id-source-generator.md), [ADR 0031 — PostgreSQL Major Version](../decisions/0031-postgresql-major-version.md). +**Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md), [ADR 0006 — Events and Outbox](../decisions/0006-events-and-outbox.md), [ADR 0023 — Strongly-Typed ID Source Generator](../decisions/0023-strongly-typed-id-source-generator.md), [ADR 0031 — PostgreSQL Major Version](../decisions/0031-postgresql-major-version.md), [ADR 0036 — Trusted Inputs for Tenant and Organization Resolution](../decisions/0036-tenant-resolution-trusted-inputs.md). C# / .NET conventions for LearnStack backend code. @@ -258,7 +258,11 @@ this order and changes only the durability contract of what step 3 records and carries the resolved tenant + organization forward for the rest of the pipeline. Unresolved context short-circuits with `Result.Fail(tenant_mismatch)` unless the request carries - `[AllowsUnresolvedTenantContext]`. + `[AllowsUnresolvedTenantContext]`. The behavior also rejects a request whose + `TenantContextOrigin` exceeds what the request type permits — a `HostOnly` + context reaches only `[PublicSurface]` request types, enumerated in + [04-api-design.md § Public surface](04-api-design.md) and bounded by + [ADR-0036 § The reconciliation matrix](../decisions/0036-tenant-resolution-trusted-inputs.md). This behavior does **not** set the PostgreSQL session variables. It runs at step 4; the transaction opens at step 6; and diff --git a/docs/standards/04-api-design.md b/docs/standards/04-api-design.md index 97e3b2da..d07585b3 100644 --- a/docs/standards/04-api-design.md +++ b/docs/standards/04-api-design.md @@ -135,6 +135,44 @@ The two rules an API author needs at the point of writing an endpoint: A request that cannot resolve a tenant returns **404** (not 403, to avoid disclosure). +### Public surface + +`[PublicSurface]` marks a request type as reachable by a caller LearnStack has not +authenticated — the [`Portal Public`](19-permissions.md) role, made mechanical. The +marker is the whole of the claim under a host-only context: a request type without it is +unreachable from `HostOnly`, whatever its route looks like. Rows 13 and 15 of ADR-0036's +reconciliation matrix are the separate case — no tenant context resolves at all, and +`[AllowsUnresolvedTenantContext]` governs them. + +- **`TenantContextOrigin` is the ceiling.** A context resolved from the host alone + carries `HostOnly` and reaches only `[PublicSurface]` request types; + `TenantContextBehavior` at pipeline step 4 + ([02-backend-coding.md § Pipeline Behaviors](02-backend-coding.md)) rejects anything + else — with the same bodyless **404** `UseStatusCodePages` renders as `not_found` for + an unresolvable host, because anything a client can tell apart confirms to an + anonymous caller that the tenant exists. +- **Permitted methods default to `GET` / `HEAD`.** An entry declaring a mutating method + states why, in the table. +- **No `[PublicSurface]` type performs a tenant-owned write.** +- **No `[PublicSurface]` type is classified MUST-class `read-sensitive`** + ([18-audit-coverage.md](18-audit-coverage.md)) — an anonymous `GET` would otherwise + become a durable standalone audit write. + +[ADR-0036 § The reconciliation matrix](../decisions/0036-tenant-resolution-trusted-inputs.md) +is the authority for why the ceiling holds and what a forged host reaches under it. The +matrix is not restated here. + +The set is this table and nothing else: + +| Request type | Permitted methods | Why | Owning phase | +|--------------|-------------------|-----|--------------| + +Its first rows arrive with [Phase 02d](../roadmap/phase-02d-walking-skeleton.md)'s two +anonymous read endpoints. Until then `PublicSurface_Marker_Set_Is_Enumerated` and +`PublicSurface_Requests_Are_Never_ReadSensitive` +([21-architecture-tests-catalogue.md](21-architecture-tests-catalogue.md)) are vacuously +green over an empty set — the honest state of a marker no request type carries yet. + ## Pagination Cursor pagination by default: diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index edc7d19f..e3f34dff 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -261,8 +261,15 @@ Rules: every organization-scoped table, not an optional hardening step. - The session variable names `app.tenant_id`, `app.organization_id`, `app.scope` and `app.resolving_host` are canonical and the set is closed; do not invent alternatives - (`app.current_tenant_id`, `learnstack.tenant_id`, …). The first three are set by - `TransactionBehavior` inside the ambient transaction. `app.resolving_host` is set by + (`app.current_tenant_id`, `learnstack.tenant_id`, …). `app.tenant_id` and + `app.organization_id` are set inside the ambient transaction — by + `TransactionBehavior` in the general case, and by each of the out-of-band setters on + the transaction it opens ([Security Standards § The out-of-band setters](11-security.md)). + `app.scope` has **no carrier**: it derives from the actor's role plus a declared + tenant-wide operation, roles arrive with authentication in + [Phase 02b](../roadmap/phase-02b-events-auth.md), and until then the tenant-scope read + hatch is unreachable — the correct default. Its placement rule is unchanged and applies + the moment a carrier exists. `app.resolving_host` is set by `CachedHostToTenantResolver` alone, in its own short read-only transaction, and is read by exactly one policy — see § Table classes. Always call `current_setting` with the second argument `true` (missing-OK), and always wrap the result in `NULLIF(…, '')`, @@ -361,6 +368,23 @@ migration states which one its table is. | **Tenant-owned, self-keyed** | Identical, except the tenant term is `id = …` because the row's primary key *is* the tenant id | `tenants` | | **Platform-scoped** | `ENABLE` + `FORCE`, and role-qualified per-command policies: the read is widened by an explicitly declared non-tenant predicate, writes stay tenant-keyed | `platform_host_to_tenant` | +**The EF query filter follows the class.** It is the second layer, not a restatement of +the policy: the filter is what a handler's `IQueryable` meets, the policy is what still +drops the other tenants' rows when the filter is missing. + +- **Tenant-owned, org-scoped** — `e.TenantId == currentTenantId` **and** + `e.OrganizationId == null || e.OrganizationId == currentOrgId`, the null arm being the + tenant-wide row ([ADR-0017](../decisions/0017-tenant-organization-hierarchy.md)). +- **Tenant-owned, tenant-wide** — `e.TenantId == currentTenantId`, with no organization + term, for the same reason the class carries no restrictive guards. +- **Tenant-owned, self-keyed** — `t.Id == currentTenantId`. `tenants` has no `TenantId` + property, so the filter keys on the primary key exactly as the policy does. +- **Platform-scoped** — **no query filter.** `platform_host_to_tenant` is read in order + to *determine* the tenant, so a tenant-keyed filter would return zero rows on every + anonymous request and no host would ever resolve. `PlatformHostMapping` carries a + `TenantId` property and still takes no filter — the property is not the test, the + class is. + **A table is platform-scoped only when it is read before the tenant is known.** That is one table today, and adding a second is a decision, not a convenience. `platform_entitlement_cache` does **not** qualify despite its name: `IFeatureFlags` @@ -705,9 +729,11 @@ The residual risk is that the platform credential exists in the API process's configuration. It is mitigated by resolving it only through `PlatformAdminScope`; by a separate secret path (`learnstack/{deployment}/platform/db-password`) that a deployment needing no platform admin simply does not provision, in which case -`EnterPlatformAdminScope` throws at startup rather than degrading to `learnstack_app`; -and by an audit row written **inside** the scope before the operation runs and committed -on its own, so an operation that later fails is still recorded. That row is written as +`EnterPlatformAdminScope` throws **on entry** — on the first call, naming the missing +`ConnectionStrings:PlatformAdmin`, so a host that never enters the scope still boots, +every test fixture included — rather than degrading to `learnstack_app`; and by an audit +row written **inside** the scope before the operation runs and committed on its own, so +an operation that later fails is still recorded. That row is written as `learnstack_platform` and carries the sentinel platform tenant id, because a cross-tenant operation has no tenant of its own and `audit_log` is itself tenant-owned. @@ -1125,16 +1151,19 @@ Forbidden: string interpolation with non-constant values. - `app.tenant_id` (and `app.organization_id` when relevant) set **within the same transaction** as the work (`SET LOCAL ...`). - A `DbCommandInterceptor` — **not** a connection-checkout interceptor — is to guard - the context. **It does not exist yet**; Packet 6 ships the setter and the policies - it backs up, and the first tenant-owned read on a request path is Packet 7's, which - is where it belongs. Checkout happens before `TransactionBehavior` opens the transaction that + the context. **Packet 7 ships it**: Packet 6 ships the setter and the policies it + backs up, and the first tenant-owned read on a request path is Packet 7's, which is + where the guard belongs. Checkout happens before `TransactionBehavior` opens the transaction that carries the `SET LOCAL` values, so a checkout hook would read an unset `app.tenant_id` on every request and throw universally; under PgBouncer transaction pooling it would sometimes read a *previous* transaction's leftover value, which is worse than throwing. The command interceptor instead checks the in-process marker - `TransactionBehavior` stamps on the ambient transaction once it has issued the - `SET LOCAL` pair, and throws `TenantContextMissingException` when a command against a - `[TenantOwned]` table runs without it — no extra round trip. + **a sanctioned setter** stamps on the transaction it opens, once the `SET LOCAL` + pair is issued, and throws `TenantContextMissingException` when a command against a + `[TenantOwned]` table runs without it — no extra round trip. The setters are a closed + set, named in [Security Standards § The out-of-band setters](11-security.md), which is + the placement authority: a guard keyed on `TransactionBehavior` alone would reject the + writes the idempotency store and the audit store make on their own short transactions. - The database-side guard is fail-closed independently: with `app.tenant_id` unset or reset the policy predicate is `NULL`, so the query returns zero rows rather than leaking. The interceptor exists to turn that silent empty result into a loud failure, diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 297df1c7..47ee1cdd 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -50,7 +50,7 @@ We invest most at **unit + integration**. Architecture tests are zero-flake. E2E ### Integration Tests -`LearnStack.Tests.Integration` holds **two populations**, and which one a test +`LearnStack.Tests.Integration` holds **three populations**, and which one a test belongs to is decided by what it needs, not by what it is about: - **Host tests** — a real HTTP pipeline through `WebApplicationFactory`, no @@ -63,16 +63,31 @@ belongs to is decided by what it needs, not by what it is about: what the isolation assertions compare against. Not Valkey and not SeaweedFS: nothing the backend runs calls either, and both sit behind the gated compose profile ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). Everything that is a - property of the schema lives here, and **every tenant-isolation invariant** - does. **Packet 6** shipped the fixtures, the four-role provisioning suite, both + property of the schema lives here, and **every schema-level tenant-isolation + invariant** does. **Packet 6** shipped the fixtures, the four-role provisioning suite, both migration chains, the policies, a two-tenant seed and the schema-level isolation - suite; **Packet 7** re-runs those cases through `TenantResolverMiddleware` and - the EF query filters, which is the layer a handler actually meets. All of them - connect as `learnstack_app`, because a test run as the owner or as a + suite; **Packet 7** re-runs those cases one layer up — the third population below. + All of them connect as `learnstack_app`, because a test run as the owner or as a `BYPASSRLS` role passes against inert policies. They run in the separate `backend-integration` job, split from the Docker-free tests by `[Trait("Requires","Docker")]`. - -Both: real module configuration, no mocked repositories, and coverage of the +- **Request-level isolation tests** — a `WebApplicationFactory` pointed at + a Testcontainers PostgreSQL. **Packet 7** introduces the population, because it is + the only shape in which the real middleware chain, the real EF query filters and + the real RLS policies run against one another in one request, connected as + `learnstack_app`. It drives no production endpoint — Packet 7 ships none — but a + test-only controller the fixture registers, which is what `IdempotencyFixture` + already does for `/api/v1/sideeffectprobe`. These live under `Database/` with the + rest of the Docker-bound suite and carry the same trait. + +**Where a Docker-bound test lives is what routes it.** +`Every_Database_Test_Carries_The_Docker_Trait` scans +`LearnStack.Tests.Integration/Database` and nothing else, so a class placed outside +that directory is never checked for the trait — and a missing trait does not fail the +class, it runs it in the `backend` job. Both CI runners are `ubuntu-latest` and both +carry a Docker socket, so the container starts and the test passes: nothing goes red, +and the Docker suite stops being where the Docker tests live. + +All three: real module configuration, no mocked repositories, and coverage of the happy path and the edges. **An isolation test connects as `learnstack_app`.** One that runs as diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 966379f8..5f5f70ac 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -224,7 +224,7 @@ Row Level Security predicates read four PostgreSQL session variables — `app.te and canonical policy templates live in [05-database.md § Tenant-Owned and Organization-Scoped Tables](05-database.md) and [05-database.md § Table classes](05-database.md). This section fixes **where** the first -three are set. `app.resolving_host` is set by `CachedHostToTenantResolver` alone, in its +three belong. `app.resolving_host` is set by `CachedHostToTenantResolver` alone, in its own short read-only transaction before the host lookup, because the row that determines the tenant must be readable before any tenant context exists; it is read by exactly one policy, on `platform_host_to_tenant`. Its value is the **normalized effective host** as @@ -236,8 +236,8 @@ from; this section remains the authority for session-variable **placement** only ### The rule -`app.tenant_id`, `app.organization_id` **and `app.scope`** are set with **`SET LOCAL`, -inside the ambient transaction, as the first statement after it opens** — in practice by +`app.tenant_id` and `app.organization_id` are set with **`SET LOCAL`, inside the +ambient transaction, as the first statement after it opens** — in practice by `TransactionBehavior` (step 6 of the MediatR pipeline), from the `ITenantContext` that `TenantContextBehavior` asserted at step 4. @@ -253,13 +253,25 @@ consequences follow, and both are load-bearing: shared across transactions, so the value is either absent or — worse — left over from another tenant's transaction. +**`app.scope` has no carrier.** No application path sets it, and +[Packet 7](../roadmap/phase-02a-kernel-tenancy.md) ships nothing that does: +`ITenantContext` carries no scope member +([ADR-0040 Amendment 1](../decisions/0040-ambient-unit-of-work.md)), and the flag +derives from the actor's role plus a declared tenant-wide operation, so the earliest +carrier arrives with authentication in +[Phase 02b](../roadmap/phase-02b-events-auth.md). The deferral is forced, not chosen. +Until it lifts, the cross-organization read hatch in the policy template is unreachable at +runtime — the hatch term reads an unset variable and is never true, so reads stay inside +the caller's organization plus the tenant-wide rows — which is the correct default. The +placement rule above is unchanged and governs `app.scope` the moment a carrier exists. + ### The out-of-band setters -`TransactionBehavior` is the general case, not the only one. Six setters exist in +`TransactionBehavior` is the general case, not the only one. Seven setters exist in total and the set is closed ([ADR-0040 § Who sets `app.tenant_id`, completely](../decisions/0040-ambient-unit-of-work.md) is the authority; it is reproduced here because this section is the placement -authority). Two set it on the **ambient** transaction; four own a **short +authority). Two set it on the **ambient** transaction; five own a **short transaction of their own**, because they run where no ambient transaction exists yet: @@ -267,6 +279,7 @@ yet: |---|---|---| | `TransactionBehavior` | ambient | — the general case | | The integration-event transport, per delivery | ambient — it opens it | There is no MediatR request: `InProcessEventBus` invokes the handler directly, so no behavior runs. It opens the ambient transaction itself, from the delivery's `EventTenantContext` | +| `IOrganizationScopeValidator` | its own short read-only one | The organization assertion is validated in the request edge, before the pipeline reaches step 6 ([ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)) | | `IIdempotencyStore` (durable) | its own short one | A claim is taken **before** the pipeline reaches step 6 ([ADR-0037](../decisions/0037-idempotency-key-contract.md)) | | `IAuditStore.WriteStandaloneAsync` | its own short one | An audit row that must survive the rollback of the operation it describes cannot share that operation's transaction ([ADR-0033](../decisions/0033-audit-durability-model.md)) | | `IAuditStore.WriteBestEffortAsync` | its own short one | Same shape, SHOULD/MAY class; failures are logged and dropped | @@ -287,13 +300,13 @@ issued the `SET LOCAL` pair on this transaction before any command against a out-of-band setters above, each of which stamps the same marker on the transaction it opens. Naming `TransactionBehavior` alone would make the guard reject every write the idempotency store and the audit store legitimately make. It throws -`TenantContextMissingException` when it has not. **It does not exist yet**: Packet 6 -ships the setter (`IUnitOfWork.SetTenantContextAsync`) and the policies it would -back up, and no packet yet owns the interceptor — the first tenant-owned read on a -request path is Packet 7's, and that is where it belongs. It cannot be a connection-checkout -interceptor, for the same reason it cannot be a `DbConnectionInterceptor` that *sets* the -values: checkout precedes the transaction, so the transaction-local value is not there to -be observed ([05-database.md § Connection Management](05-database.md)). +`TenantContextMissingException` when it has not. **Packet 7 owns it**: Packet 6 ships +the setter (`IUnitOfWork.SetTenantContextAsync`) and the policies it will back up, and +the first tenant-owned read on a request path is Packet 7's, which is where the guard +belongs. It cannot be a connection-checkout interceptor, for the same reason it cannot be +a `DbConnectionInterceptor` that *sets* the values: checkout precedes the transaction, so +the transaction-local value is not there to be observed +([05-database.md § Connection Management](05-database.md)). ### Corrections this supersedes diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index c9a1fac7..ff75adfd 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -666,6 +666,23 @@ otherwise). assemblies carry types. - **Phase:** 02a (Packet 6 introduces, Packet 10 closes). +#### `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` + +- **Asserts:** no MediatR handler issues `Add` / `AddRange` / `Update` / `Remove` + against more than one `DbSet` whose entity implements `IAggregateRoot`, except + the single handler on a literal allow-list — the handler for `ProvisionTenantCommand`, + which writes `Tenant` and its default `Organization` in one transaction per ADR-0042. +- **Source:** [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md); + [Architecture Standards § Aggregate Ownership](01-architecture-standards.md). +- **Type:** xUnit + source scan. **Kind:** structural. +- **Status:** **Registered.** +- **Phase:** 02a Packet 7. +- **Note:** the scan catches the direct form, which is the form a handler is written + in. It does not catch a write routed through a repository, a helper, or a second + `DbContext` reached indirectly. The binding control is that the allow-list has one + entry and growing it is a reviewed diff; the scan is what makes the ordinary mistake + loud. + ### Persistence: concurrency and the unit of work Source: [ADR-0039](../decisions/0039-optimistic-concurrency-token.md), @@ -840,15 +857,30 @@ first two rows are coverage checks; the last three are the proof. - **Type:** xUnit + EF model inspection + migration SQL scan. **Kind:** structural. - **Status:** **Registered.** - **Phase:** 02a (Packet 7 introduces, Packet 10 closes). +- **Note:** the marker's scope is decided by **table class**, not by the presence of a + `TenantId` property. `tenants` is tenant-owned **self-keyed** — its policy is on `id` + and it carries no marker-driven `TenantId` filter — and `platform_host_to_tenant` is + **platform-scoped** and takes no marker at all, because it is read in order to + determine the tenant. See + [Database Standards § Table classes](05-database.md); + [Architecture Standards § Tenant-Scoped Code](01-architecture-standards.md) was + corrected to match in the same pass. #### `Every_OrgScoped_Entity_HasOrgIdAndFilter` - **Asserts:** every entity marked `[OrganizationScoped]` carries a **nullable** - `OrganizationId` (null = tenant-wide per ADR-0017), an org-aware EF query filter, and - an organization term `AND`-ed into the same single policy as the tenant term. + `OrganizationId` (null = tenant-wide per ADR-0017), an org-aware EF query filter, an + organization term `AND`-ed into the same single policy as the tenant term, and — in + the migration that creates its table — the two `AS RESTRICTIVE` write guards, + `FOR UPDATE` and `FOR DELETE`, that ADR-0003 Amendment 3 makes mandatory for every + organization-scoped table. The guards are part of the assertion, not decoration: + [the Tenancy module spec § Risks](../modules/tenancy/README.md) records the + measurement — with the hatch set and the delete guard dropped, a `DELETE` removed + another organization's row. - **Canonical name.** See § Canonical names and superseded spellings for the four superseded spellings. -- **Source:** ADR-0017; ADR-0003 Amendment 3. +- **Source:** ADR-0017; ADR-0003 Amendment 3; + [05-database.md § Tenant-Owned and Organization-Scoped Tables](05-database.md). - **Type:** xUnit + EF model inspection + migration SQL scan. **Kind:** structural. - **Status:** **Registered.** - **Phase:** 02a (Packet 7 introduces, Packet 10 closes). @@ -1945,19 +1977,36 @@ structural test proves — and what it does not. #### `SetTenant_Callers_Are_The_Enumerated_Four` -- **Asserts:** `ITenantContextAccessor.SetTenant` is called only by `TenantResolverMiddleware`, `HubCorrelationMiddleware`, the Hangfire `JobActivator`, and the outbox / inbox handler scope. `EnterPlatformAdminScope` is not among them. -- **Source:** ADR-0036 § The reconciliation matrix. -- **Type:** xUnit + NetArchTest. **Kind:** structural. +- **Asserts:** `ITenantContextAccessor.Current` is **written** only by `TenantResolverMiddleware`, `HubCorrelationMiddleware`, the Hangfire `JobActivator`, and the outbox / inbox handler scope. `EnterPlatformAdminScope` is not among them: it opens a second connection and sets no tenant context. Reads are unconstrained. +- **Source:** ADR-0036 § Rules, second bullet, as corrected by its erratum and + [Amendment 2](../decisions/0036-tenant-resolution-trusted-inputs.md). +- **Type:** Roslyn / IL call-site scan + xUnit. **Kind:** structural. - **Status:** **Registered.** - **Phase:** 02a Packet 7. +- **Note:** the name predates the correction and is kept. `ITenantContextAccessor` + declares one member, `ITenantContext? Current { get; set; }`, and the `SetTenant` + this row used to name has never existed; ADR-0036 Amendment 2 fixes the ADR and + keeps the test's spelling, because § Canonical names makes a rename its own + liability and the name describes the caller set, which is what ADR-0036 decides. +- **Note:** call-site scan rather than NetArchTest: NetArchTest resolves *type* + references and cannot see a write to a property, which is the whole assertion — + the same reason `Effective_Host_Computed_In_One_Place` is a scan. +- **Note:** in Packet 7 the rule can assert only the **negative** — no writer outside + the four. `HubCorrelationMiddleware` is Phase 02c and the Hangfire `JobActivator` + is Phase 02b, so two of the four callers do not exist yet. #### `PublicSurface_Marker_Set_Is_Enumerated` -- **Asserts:** every `[PublicSurface]` request type appears in the catalogue's enumerated set with its permitted methods; the default is `GET`/`HEAD` and a mutating entry states why. No `[PublicSurface]` type performs a tenant-owned write. -- **Source:** ADR-0036 § The reconciliation matrix. +- **Asserts:** every `[PublicSurface]` request type appears in the enumerated set in [Standards 04 § Public surface](04-api-design.md) with its permitted methods; the default is `GET`/`HEAD` and a mutating entry states why. No `[PublicSurface]` type performs a tenant-owned write. +- **Source:** ADR-0036 § The reconciliation matrix; + [Standards 04 § Public surface](04-api-design.md). - **Type:** xUnit + reflection. **Kind:** structural. - **Status:** **Registered.** - **Phase:** 02a Packet 7. +- **Note:** the set ships **empty** in Packet 7, which registers no `[PublicSurface]` + request type, and takes its first rows in + [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). The rule is vacuously green + until then. #### `PublicSurface_Requests_Are_Never_ReadSensitive` @@ -1982,11 +2031,16 @@ structural test proves — and what it does not. - **Type:** xUnit + NetArchTest. **Kind:** structural. - **Status:** **Registered.** - **Phase:** 02a Packet 7. +- **Note:** no `app.scope` carrier ships in Packet 7. `ITenantContext` exposes no scope + member and the flag derives from the actor's role, so the earliest carrier arrives with + authentication in [Phase 02b](../roadmap/phase-02b-events-auth.md) + ([11-security.md § Tenant Context](11-security.md)). The rule holds as a negative until + then — nothing sets the flag, so nothing sets it from request input. #### `PlatformAdminScope_Entry_Requires_Platform_Permission` - **Asserts:** `EnterPlatformAdminScope(reason)` cannot open without an authenticated principal holding a Platform-scope permission, and no handler carries both `[AllowsUnresolvedTenantContext]` and a platform-scope entry. -- **Source:** ADR-0036 § The reconciliation matrix. +- **Source:** ADR-0036 § The platform-admin override is not a resolution source. - **Type:** xUnit. **Kind:** behavioural. - **Status:** **Registered.** - **Phase:** 02a Packet 7. diff --git a/docs/standards/README.md b/docs/standards/README.md index d59020d5..d341299b 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -87,10 +87,10 @@ enforced" change in one commit. Until that lands, **this table wins**. | 05 | [Database](05-database.md) | **Active** | Packet 6 applied it: two migration chains, ten tables, the four-role model, and the canonical RLS template this document owns — `ENABLE` **and** `FORCE`, one `AND`-ed policy per table, an explicit `WITH CHECK` — asserted against a real PostgreSQL as `learnstack_app`. Its § Concurrency, § Table classes, § Indexes and § GRANT matrix each have a test that fails without them. Partitioning and the retention job are still ahead. | | 06 | [Testing](06-testing.md) | **Active** | Unit, architecture, contract **and** integration suites all run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The Docker-bound `backend-integration` job activated in Packet 6 with the four-role provisioning suite; the split is by `[Trait("Requires","Docker")]` and the two jobs' filters are exact complements. | | 07 | [Frontend Architecture](07-frontend-architecture.md) | **Adopted** | Route groups exist as empty layouts; server/client split, tenant context and SDK shape are exercised first in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). | -| 08 | [Localization](08-localization.md) | **Adopted** | Packet 6 shipped `tenant_locales` and the slug schema; the i18n runtime lands in [Phase 04](../roadmap/phase-04-cms-media-pages.md). Nothing enforces "exactly one default locale per tenant" yet — recorded as an open question in the [Tenancy module spec](../modules/tenancy/README.md), Packet 7's call. | +| 08 | [Localization](08-localization.md) | **Adopted** | Packet 6 shipped `tenant_locales` and the slug schema; the i18n runtime lands in [Phase 04](../roadmap/phase-04-cms-media-pages.md). Nothing enforces "exactly one default locale per tenant" yet; [Packet 7](../roadmap/phase-02a-kernel-tenancy.md) does, with a partial unique index `UNIQUE (tenant_id) WHERE is_default` plus an aggregate-level guard for the error message. | | 09 | [Error Handling](09-error-handling.md) | **Active** | L1 `IExceptionHandler`, the exception hierarchy, `ProblemDetailsFactory` and `HttpStatusMap` shipped in Packet 3. | | 10 | [Observability](10-observability.md) | **Active** | Serilog → OTLP, OpenTelemetry SDK, `TenantContextSpanProcessor` and the redaction enrichers shipped in Packet 3. | -| 11 | [Security](11-security.md) | **Adopted** | No auth yet; RLS is live. Packet 6 shipped the policies, the four roles and the isolation suite that runs as `learnstack_app`, and Packet 4 shipped the header-facing half — the tenancy edge, the trusted-hop predicate, host normalization and the anonymous rate limiter. Tenant isolation lands in Packet 7, authentication in [Phase 02b](../roadmap/phase-02b-events-auth.md). Its § Tenant Context is nonetheless the binding authority the implementing PR must follow. | +| 11 | [Security](11-security.md) | **Adopted** | No auth yet; RLS is live. Packet 6 shipped the policies, the four roles and the isolation suite that runs as `learnstack_app`, and Packet 4 shipped the header-facing half — the tenancy edge, the trusted-hop predicate, host normalization and the anonymous rate limiter. Packet 7 adds host and tenant resolution, the tenant context, the EF query filters and the request-level isolation suite; authentication lands in [Phase 02b](../roadmap/phase-02b-events-auth.md). Its § Tenant Context is nonetheless the binding authority the implementing PR must follow. | | 12 | [Infrastructure](12-infrastructure.md) | **Active** | Compose stack, `Makefile`, CI workflow, pre-commit hooks and secret scanning all live since Phase 01. | | 13 | [Documentation](13-documentation.md) | **Active** | Governs this corpus; the CI link audit walks changed Markdown. | | 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). Everything else in Standards 14 is enforced today. | From 53a9a1772bcc518a8ab5ce7e64a7323cbab144f6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 16:02:50 +0300 Subject: [PATCH 02/55] docs: close what the Packet 7 corpus review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight lenses over f8e0aa6, every finding independently verified. Three classes of defect were real. An erratum's own evidence was false. ADR-0036's new erratum and Amendment 3 both cited a grep whose result does not reproduce: the command returns twelve hits across three files, not "only hits inside this file". ADR-0041 makes "how it was shown wrong" mandatory and tells a reviewer to re-run it, so a false evidence line is a defect in the exact slot that rule polices. docs/modules/tenancy/README.md was missed entirely while both its siblings were edited, and still posed three settled decisions as Packet 7's open questions — the aggregate boundary, the app.scope carrier, and the tenant_locales single default. It is the per-module authority ADR-0042 links three times, and standards/README.md's edit had already removed the pointer into the third, orphaning it. Two skills would have made an implementer write the wrong thing: seed-tenant put the whole seed in one transaction, which is three aggregate roots against ADR-0042's allow-list of one; and add-architecture-test's migration scan classified one of the two shipped chains while its own Assert.NotEmpty could not see the gap — the silently-green test that skill exists to prevent. The rest is one claim fixed in one carrier and left standing in another: the marker rule's tenant-key clause, the IgnoreQueryFilters allow-list shape, the ITenantContext lifetime adjective, and the [PublicSurface] enumeration's home. ADR-0032 gains a dated Amendment for the Scoped to Transient registration change Packet 5 made, which its body still describes as scoped — true when written, so an amendment and not an erratum. Behaviour unchanged: the code diff is comments only, 943 green, zero skips. ADR: 0032, 0036, 0040 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-architecture-test/SKILL.md | 21 +++++--- .claude/skills/add-audit-coverage/SKILL.md | 12 +++-- .claude/skills/add-backend-module/SKILL.md | 43 +++++++++++----- .claude/skills/add-integration-test/SKILL.md | 3 +- .../skills/add-tenant-owned-entity/SKILL.md | 12 ++++- .claude/skills/run-tests-locally/SKILL.md | 10 ++-- .claude/skills/seed-tenant/SKILL.md | 50 +++++++++++++------ .claude/skills/standards-check/SKILL.md | 5 +- .../wire-cross-cutting-foundation/SKILL.md | 8 +-- .../TenantContextAccessor.cs | 7 +-- .../Identifiers/TenantId.cs | 2 +- .../Tenancy/ITenantContext.cs | 2 +- .../Tenancy/ITenantContextAccessor.cs | 6 ++- .../Persistence/TenancyDbContext.cs | 2 +- docs/architecture/02-domain-model.md | 1 + docs/architecture/09-tenant-isolation.md | 16 +++--- docs/architecture/27-custom-domain-tls.md | 42 +++++++++++++--- .../28-platform-tenant-organization.md | 9 ++-- ...tion-handling-logging-and-observability.md | 32 ++++++++++++ .../0036-tenant-resolution-trusted-inputs.md | 16 ++++-- docs/decisions/0040-ambient-unit-of-work.md | 4 +- docs/decisions/README.md | 2 +- docs/glossary.md | 4 +- docs/modules/tenancy/README.md | 43 ++++++++++------ docs/roadmap/phase-02a-kernel-tenancy.md | 34 ++++++++----- docs/roadmap/phase-02d-walking-skeleton.md | 9 ++-- .../roadmap/phase-06-renderer-admin-studio.md | 2 +- docs/standards/01-architecture-standards.md | 12 +++-- docs/standards/04-api-design.md | 14 ++++-- docs/standards/05-database.md | 3 +- docs/standards/11-security.md | 16 ++++-- .../21-architecture-tests-catalogue.md | 35 +++++++++++-- scripts/seed.sh | 2 +- 33 files changed, 346 insertions(+), 133 deletions(-) diff --git a/.claude/skills/add-architecture-test/SKILL.md b/.claude/skills/add-architecture-test/SKILL.md index 27fdb832..f921f2bf 100644 --- a/.claude/skills/add-architecture-test/SKILL.md +++ b/.claude/skills/add-architecture-test/SKILL.md @@ -163,18 +163,25 @@ public void Every_TenantOwned_Entity_HasFilterAndRlsPolicy() var tenantOwned = migrationFiles .Select(f => (File: f, Content: File.ReadAllText(f))) - // `CreateTable(`, not the literal "CREATE TABLE". EF writes tables through - // migrationBuilder.CreateTable(name: "…") and the policy block through - // migrationBuilder.Sql. Measured: "CREATE TABLE" occurs ZERO times in - // 20260828092437_create_tenancy_schema.cs, which creates eight tables. - .Where(x => x.Content.Contains("CreateTable(") && x.Content.Contains("tenant_id")) + // Both tokens, because the two shipped chains use one each. EF writes the + // tenancy chain through migrationBuilder.CreateTable(name: "…"): measured, + // "CREATE TABLE" occurs ZERO times in 20260828092437_create_tenancy_schema.cs, + // which creates eight tables. The platform chain writes outbox_messages and + // idempotency_keys through migrationBuilder.Sql("CREATE TABLE …") because + // neither is an EF entity: "CreateTable(" occurs ZERO times in + // 20260828085701_create_platform_infrastructure_tables.cs. Either token alone + // classifies exactly one of the two chains, so the predicate is their union. + .Where(x => (x.Content.Contains("CreateTable(") || x.Content.Contains("CREATE TABLE")) + && x.Content.Contains("tenant_id")) .ToList(); // Guard two, and the reason this test is worth landing: it asserts on what the // scan CLASSIFIED, not on what it read. A detection predicate that matches // nothing runs the loop zero times and reports green over the exact migrations - // the rule exists to cover — which is what the "CREATE TABLE" version did, - // past a NotEmpty guard on the file list. + // the rule exists to cover, past a NotEmpty guard on the file list. It catches + // an EMPTY classification, not a PARTIAL one: either token on its own leaves + // this assertion green while a whole shipped chain goes unscanned, which is + // why the predicate above is a union. Assert.NotEmpty(tenantOwned); foreach (var (file, content) in tenantOwned) diff --git a/.claude/skills/add-audit-coverage/SKILL.md b/.claude/skills/add-audit-coverage/SKILL.md index 3881c4f9..858ca611 100644 --- a/.claude/skills/add-audit-coverage/SKILL.md +++ b/.claude/skills/add-audit-coverage/SKILL.md @@ -237,10 +237,12 @@ public async Task User_NationalId_isRedacted_In_AuditSnapshot() - `dotnet build` and `dotnet test` pass. - Architecture tests: - - `Module__HasAuditMatrix` (the module's `audit.md` exists). + - `Every_Module_Has_An_AuditCoverage_Matrix` (the module's `audit.md` exists) — + Registered, backfilled in Packet 9. - `Modules_Do_Not_Write_AuditLog_Directly` (no `IAuditStore.WriteAsync` call from - outside the audit infrastructure). - - `Every_MustAudit_Operation_HasMatrixEntry`. + outside the audit infrastructure) — Registered, Packet 10. + - `Every_TenantOwned_Command_HasAuditCoverage` — Registered, backfilled in + Packet 9. - An integration test demonstrates the new entry appears in `audit_log` with the right `operation`, `actor`, `before`, `after`, and any `[PiiSensitive]` fields redacted. @@ -261,8 +263,8 @@ public async Task User_NationalId_isRedacted_In_AuditSnapshot() method, and `learnstack_app` holds no `UPDATE` privilege on `audit_log`. - **Truncating snapshots silently.** If a `before/after` JSON is too large, store an external pointer (`audit_blob_id`); never an empty object. -- **Skipping the matrix update.** `Module__HasAuditMatrix` will fail; CI - rejects. +- **Skipping the matrix update.** `Every_Module_Has_An_AuditCoverage_Matrix` will + fail once Packet 9 backfills it; until then review is the only gate. - **Auditing a `read` for noise.** `read-sensitive` is the only read class that should be audited; broad read auditing creates noise that hides real signals. - **Tenants relaxing MUST.** Forbidden by the catalogue API. Calling diff --git a/.claude/skills/add-backend-module/SKILL.md b/.claude/skills/add-backend-module/SKILL.md index 0d25be88..e18f0e8d 100644 --- a/.claude/skills/add-backend-module/SKILL.md +++ b/.claude/skills/add-backend-module/SKILL.md @@ -161,12 +161,18 @@ only three files under `backend/src` may mention `UseNpgsql` at all. In `LearnStack.Modules..Infrastructure/Persistence/DbContext.cs`: ```csharp -public sealed class DbContext( - DbContextOptions<DbContext> options, - ITenantContext tenantContext, - IPublisher publisher) +// ONE constructor parameter. `ModuleDbContextRegistration` builds every module +// context with `Activator.CreateInstance(typeof(TContext), options)`, so a second +// parameter throws `MissingMethodException` on first resolution — and +// `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` forbids registering the +// context any other way. This is the shape the one shipped context carries. +public sealed class DbContext(DbContextOptions<DbContext> options) : DbContext(options) { + // The filters' closure root, held as an instance member. Nullable because the + // registrar cannot hand it over yet; Packet 7 populates it (see below). + private ITenantContextAccessor? _accessor; + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(DbContext).Assembly); @@ -183,13 +189,21 @@ public sealed class DbContext( // implemented in Phase 02a Packet 7, not before. foreach (var entity in modelBuilder.Model.GetEntityTypes()) { - // …build the filter over `tenantContext`, the constructor parameter - // captured as an instance field, one expression per entity. + // …build the filter over `_accessor`, the instance member, one + // expression per entity. } } } ``` +**How the instance member gets populated is Packet 7's choice**, and it is one of +two: switch `ModuleDbContextRegistration` to +`ActivatorUtilities.CreateInstance(provider, typeof(TContext), options)`, or read +the accessor off the application service provider the registrar already passes to +`UseApplicationServiceProvider`. Prefer the singleton `ITenantContextAccessor` +over an `ITenantContext` snapshot: EF parameterises `_accessor.Current` per query, +and it avoids baking in an `UnresolvedTenantContext` whose `TenantId` throws. + Per [05-database.md](../../../docs/standards/05-database.md), one `DbContext` per module — not one global. @@ -203,8 +217,10 @@ configuration's job. See ### Step 5: Architecture test fixture The dependency-direction and cross-module rules live in -`backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs` and are -**scanned**, not listed — a new module needs no edit there. What is still owed is +`backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs`. Both are +`[Theory]`-driven from the literal `ModuleNames` array in that file, not scanned — +**add `` to that array**. Until you do, the new module's `Domain` assembly is +never inspected and both rules pass vacuously. What is still owed is `Every_Module_Has_An_AuditCoverage_Matrix`, registered in [21-architecture-tests-catalogue.md](../../../docs/standards/21-architecture-tests-catalogue.md) and **awaiting backfill in Packet 9** with the audit catalogue it reads. Until it @@ -249,9 +265,9 @@ See [add-ef-migration](../add-ef-migration/SKILL.md) for migration conventions ## Validation - `dotnet build` succeeds for all four projects. -- `LearnStack.Tests.Architecture` is green; specifically - `Module__DependencyDirection_IsCorrect`, - `Module__HasAuditMatrix`, `Module__HasPermissionMatrix`. +- `LearnStack.Tests.Architecture` is green; specifically the two rules that + actually run, `ModuleDomain_DoesNotDependOn_OtherModuleDomain` and + `ModuleDomain_DoesNotDependOn_AnyApplicationOrInfrastructure`, for ``. - `dotnet ef migrations script` for the module shows the expected baseline schema. - The module appears in [03-module-boundaries.md](../../../docs/architecture/03-module-boundaries.md) module map and in [docs/glossary.md](../../../docs/glossary.md) if it owns any @@ -270,5 +286,6 @@ See [add-ef-migration](../add-ef-migration/SKILL.md) for migration conventions reads go through repository contracts or read-model projections. - **Forgetting the `IModule` registration in the composition root.** The module builds but no handlers run; takes hours to diagnose. -- **Missing `docs/modules//` spec files.** The architecture tests - `Module__HasAuditMatrix` / `_HasPermissionMatrix` fail; CI rejects the PR. +- **Missing `docs/modules//` spec files.** Nothing fails. + `Every_Module_Has_An_AuditCoverage_Matrix` is Registered against Packet 9 and + there is no permission-matrix rule at all, so review is the only gate until then. diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index e48531b9..90d087f0 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -170,7 +170,8 @@ public async Task Unsetting_tenant_context_returns_zero_rows_through_RLS() } ``` -There is no `TenantContextMissingException` today: the `DbCommandInterceptor` that +Nothing throws `TenantContextMissingException` today — the type itself shipped in +Packet 3, in `LearnStack.SharedKernel/Errors/`. The `DbCommandInterceptor` that throws it is described in Standards 05 and 11 and lands in **Packet 7**, which owns it. Until it does, the fail-closed behaviour is the empty result, which is what to assert. From Packet 7 the same read **through a module `DbContext`** is a loud diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index ac1404af..56ec62d0 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -208,9 +208,19 @@ Generate the migration: # too produces 20260827120000_20260827120000_add_.cs. dotnet ef migrations add add_ \ --project backend/src/Modules//LearnStack.Modules..Infrastructure \ - --startup-project backend/src/LearnStack.Api + --startup-project backend/src/LearnStack.Api \ + --output-dir Persistence/Migrations ``` +`--output-dir` is not optional, and this is the skill that needs it most: EF +defaults the output to `Migrations/` when the project has no sibling migration to +reuse, which is six of the seven module assemblies today. `make migrate`, +`backend/.editorconfig` and `Migrate_Target_Covers_Every_Migration_Chain` all key +on `Persistence/Migrations` — a chain landing one directory up is skipped by the +Makefile loop in silence and is invisible to the architecture test written for +exactly that hole, while the Testcontainers fixtures call `MigrateAsync()` directly +and keep the suite green. + Edit the generated migration to add the table and **one** RLS policy. > **The canonical template lives in diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 02142291..b453e917 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -24,7 +24,8 @@ flags, and a triage map for the most common failure shapes. ## When not to use -- Generating coverage reports for release. CI does that. +- Generating coverage reports for release — no CI job collects coverage; this + step is local-only and optional. - Writing new tests — different skills cover authoring. - Production data migrations or seed scripts. @@ -196,8 +197,11 @@ reportgenerator -reports:"**/coverage.cobertura.xml" \ ``` Targets per -[06-testing.md](../../../docs/standards/06-testing.md): Domain ≥ 90%, -Application ≥ 80%, Infrastructure ≥ 50%. CI fails on regression. +[06-testing.md § Coverage Targets](../../../docs/standards/06-testing.md): Domain +≥ 90% line and ≥ 80% branch, Application ≥ 80% line, Infrastructure adapters +≥ 70% line. **Coverage gates nothing** — no CI job collects it and the standard +does not make it a blocker. The architecture, isolation and contract suites are +the hard gates. ### Step 9: Reproduce a CI failure diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 06538481..9ed1c5c0 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -98,30 +98,49 @@ The seed is **idempotent** — running it twice produces the same state. ### Step 3: What Packet 7's seed creates -Per tenant, in one transaction: +**The provisioning transaction** — `BEGIN` → `SET LOCAL app.tenant_id` to the +assigned id → `INSERT tenants` → `INSERT organizations` → `UPDATE tenants SET +default_organization_id` → `COMMIT`: - Row in `tenants` — id, slug, display_name, `status = Trial`. **Not `Active`**: `Tenant.Create` produces `Trial` and `ChangeStatus` is the only way out of it, so a seed that wants `Active` calls the transition rather than writing the column. -- Two rows in `organizations`, and `AssignDefaultOrganization` pointing the tenant - at the first. Tenant + default organization in one transaction is the single - bounded cross-aggregate write +- One row in `organizations` — **the default one only** — and + `AssignDefaultOrganization` pointing the tenant at it. Tenant + default + organization in one transaction is the single bounded cross-aggregate write ([ADR-0042](../../../docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)), - and it is bounded by enumeration — one operation, one allow-list entry. -- Rows in `tenant_locales` (exactly one `is_default`), `tenant_settings`, - `tenant_feature_flags` and `tenant_domains`. -- One row in `platform_host_to_tenant` — **per tenant, not per organization**. - `demo-english` leaves `organization_id` NULL (a `TenantHost`); `demo-yoga` sets - it (an `OrgHost`), so both live classification classes from + and it is bounded by enumeration — one operation, one allow-list entry. The + seeder **invokes** `ProvisionTenantCommand` rather than writing the two roots + itself, so the allow-list stays at one entry and the seed exercises the same + path production does. + +**The follow-on writes**, each its own command in its own transaction: + +- The second row in `organizations`. It is a third aggregate root, and the + exception covers the two named above and nothing else. +- Rows in `tenant_domains` and `tenant_settings`. Under Packet 7's promotion + `TenantDomain` and `TenantSetting` are aggregate roots in their own right, so + each is written the way any other root is. +- Rows in `tenant_locales` (exactly one `is_default`) and `tenant_feature_flags`. + These are navigations inside `Tenant` rather than roots, and ADR-0042's + enumeration names them among the rows it does **not** cover: neither carries an + atomicity invariant against the tenant row. +- One row in `platform_host_to_tenant` — **per tenant, not per organization**. It + is a projection rather than an aggregate, outside the rule entirely, and it does + not share the provisioning transaction. `demo-english` leaves `organization_id` + NULL (a `TenantHost`); `demo-yoga` sets it (an `OrgHost`), so both live + classification classes from [ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md) are exercised by the seed and not only by a fixture. Neither host belongs in `Tenancy:PlatformHosts`, which lists hosts that map to **no** tenant. Two mechanics the seeder cannot skip: -- **`app.tenant_id` is set before the first insert.** Every table's `WITH CHECK` - is live from the moment the migration finishes, and `tenants` keys its policy on +- **`app.tenant_id` is set once per transaction, before that transaction's first + insert.** `SET LOCAL` does not survive `COMMIT`, so every one of the writes + above sets it again rather than inheriting it. Every table's `WITH CHECK` is + live from the moment the migration finishes, and `tenants` keys its policy on `id`, so the provisioning transaction sets the session variable to the assigned id before the `INSERT`. - **`platform_host_to_tenant` rows go in as `learnstack_app`.** Its policies are @@ -160,7 +179,7 @@ is in table. There is **no `tenant_branding` table** and no `tenant_branding` row to write. -Branding tokens are read from `TenantSettings`; the configuration surface that +Branding tokens are read from `TenantSetting`; the configuration surface that writes them is Phase 06. Keycloak users are **not** seeded by this skill. `infra/keycloak/realms/learnstack.json` @@ -213,7 +232,7 @@ SQL psql -h localhost -p 5432 -U learnstack_app -d learnstack <<'SQL' BEGIN; SELECT set_config('app.resolving_host', 'demo-english.learnstack.local', true); -SELECT host, organization_id, is_active FROM platform_host_to_tenant; +SELECT host, organization_id, is_active, is_publicly_live FROM platform_host_to_tenant; COMMIT; SQL ``` @@ -262,6 +281,9 @@ gated by plan, not customization rows. its own `app.resolving_host` (§ Step 6): the read policy admits the declared host or the caller's own tenant, so no single `learnstack_app` query can see both rows, and a count of two is not observable to the role this skill tells you to connect as. +- Both host rows carry `is_active` **and** `is_publicly_live` true. The resolver + requires both terms, so a row that is only `is_active` is a host that 404s under + a seed the checks above report as healthy. - The Packet 7 request-level isolation suite is green **connected as `learnstack_app`**, against both seeded tenants. - From Phase 02d, both hosts render their own catalog page in a browser. diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index e417dd05..3b3f7352 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -237,8 +237,9 @@ domain the diff doesn't touch. - [ ] Every `[TenantOwned]` entity ships with the mandatory isolation pair in `LearnStack.Tests.Integration`. - [ ] Architecture tests **non-skippable** — no `[Skip]` / `[Fact(Skip=…)]`. -- [ ] Coverage targets respected: Domain ≥ 90%, Application ≥ 80%, - Infrastructure ≥ 50%. +- [ ] Coverage targets respected: Domain ≥ 90% line / ≥ 80% branch, Application + ≥ 80% line, Infrastructure adapters ≥ 70% line. Reported, not enforced — the + standard does not make coverage a blocker and no CI job collects it. - [ ] Real Postgres via Testcontainers for integration tests; no in-memory substitution. diff --git a/.claude/skills/wire-cross-cutting-foundation/SKILL.md b/.claude/skills/wire-cross-cutting-foundation/SKILL.md index 491fa684..2e78480e 100644 --- a/.claude/skills/wire-cross-cutting-foundation/SKILL.md +++ b/.claude/skills/wire-cross-cutting-foundation/SKILL.md @@ -117,9 +117,11 @@ forbids it. ### Step 4: Register `ITenantContextAccessor` (singleton, AsyncLocal-backed) Per [ADR-0032 § Sub-decision 10](../../../docs/decisions/0032-exception-handling-logging-and-observability.md), -OTel processors are singletons — they cannot inject the request-scoped -`ITenantContext` directly. Register the singleton accessor *before* the OTel -pipeline so `TenantContextSpanProcessor` can resolve it: +OTel processors are singletons, and `ITenantContext` is registered transient and +resolved from the accessor on every access — a singleton that injected it directly +would pass DI validation and then pin one request's value for the process lifetime. +Register the singleton accessor *before* the OTel pipeline so +`TenantContextSpanProcessor` can resolve it: ```csharp services.AddSingleton(); diff --git a/backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs b/backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs index e8dde9dc..e7ea8866 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs @@ -7,9 +7,10 @@ namespace LearnStack.Infrastructure.Observability; /// . Per ADR-0032 § Sub-decision 10: /// cross-cutting infrastructure (OTel span processor, Serilog enricher, /// Sentry enricher) reads the current tenant context through this accessor -/// instead of injecting the request-scoped -/// directly — the lifetime mismatch (singleton processor versus scoped -/// context) would otherwise fail at startup. +/// instead of injecting directly — that context is +/// registered transient and resolved from this accessor on every access, so a +/// singleton processor capturing it would pin one request's value for the +/// process lifetime rather than fail at startup. /// internal sealed class TenantContextAccessor : ITenantContextAccessor { diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs b/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs index 68271515..595da235 100644 --- a/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs +++ b/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs @@ -27,7 +27,7 @@ namespace LearnStack.SharedKernel.Identifiers; /// /// The platform sentinel is deliberately absent. The corpus refers to a /// "sentinel platform tenant id" for the one row that has no tenant of its own — -/// the read-sensitive audit row written inside +/// the audit row written inside /// EnterPlatformAdminScope, which describes a cross-tenant operation. Its /// *value* is fixed nowhere. Packet 7 is the first to emit it — a Warning /// log line from EnterPlatformAdminScope — but a log line is not a diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs index 90def4da..c593bcee 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs @@ -3,7 +3,7 @@ namespace LearnStack.SharedKernel.Tenancy; /// -/// Request-scoped tenant + organization + user context handed to MediatR +/// Tenant + organization + user context handed to MediatR /// handlers, EF interceptors, and the audit pipeline. Populated at scope /// start by TenantResolverMiddleware (HTTP), HubCorrelationMiddleware /// (/api/internal/*), the Hangfire JobActivator (background jobs), diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs index 97fd3bdc..3e987135 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs @@ -4,8 +4,10 @@ namespace LearnStack.SharedKernel.Tenancy; /// Singleton, AsyncLocal<ITenantContext?>-backed accessor that /// gives cross-cutting infrastructure (OTel span processor, Serilog enricher, /// Sentry enricher) a way to read the current tenant context without -/// injecting the request-scoped — which would -/// fail the singleton-vs-scoped lifetime gate. +/// injecting itself, whose production registration +/// is transient and resolves from this accessor on every access. A singleton +/// that captured it would pass DI validation silently and then pin one +/// request's value for the process lifetime — nothing fails at startup. /// /// /// diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs index cfec26b3..6cd46e11 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs @@ -22,7 +22,7 @@ namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; /// /// /// No global query filters here yet. The tenant and organization filters -/// are Packet 7's, with TenantResolverMiddleware and the request-scoped +/// are Packet 7's, with TenantResolverMiddleware and the /// ITenantContext they read. Between the two packets no tenant-owned table /// is read on a request path, and with the policies live and app.tenant_id /// unset every predicate evaluates to NULL and every query correctly diff --git a/docs/architecture/02-domain-model.md b/docs/architecture/02-domain-model.md index da5b0729..20f131d9 100644 --- a/docs/architecture/02-domain-model.md +++ b/docs/architecture/02-domain-model.md @@ -43,6 +43,7 @@ flowchart LR TenantDomain TenantBranding TenantFeatureFlag + TenantLocale TenantSetting end diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 371cc406..1ce28101 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -12,7 +12,9 @@ two scopes (tenant + organization): - Tenant context resolved per request, background job, and event handler. - Organization context resolved alongside tenant context where applicable. -- `tenant_id` on every tenant-owned table (mandatory). +- `tenant_id` on every tenant-owned table (mandatory), except the tenant-owned + **self-keyed** class, whose `id` *is* the tenant id — see + [Database Standards § Table classes](../standards/05-database.md). - `organization_id` on every org-scoped tenant-owned table (nullable; null = tenant-wide). - EF Core global query filters for both `tenant_id` and `organization_id`. - PostgreSQL Row Level Security on every tenant-owned table: **one** permissive policy @@ -41,7 +43,7 @@ two scopes (tenant + organization): | Jobs (Hangfire) | `JobParams.TenantId` mandatory | `JobParams.OrganizationId` nullable | | Audit (ADR-0016) | `audit_log.tenant_id` mandatory | `audit_log.organization_id` nullable | | Logs (Serilog) | Every log scope carries `TenantId` | `OrganizationId` when context set | -| Architecture tests | `Every_TenantOwned_Entity_HasTenantIdAndFilter` | `Every_OrgScoped_Entity_HasOrgIdAndFilter` | +| Architecture tests | `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` | `Every_OrgScoped_Entity_HasOrgIdAndFilter` | ## Isolation flow @@ -223,12 +225,14 @@ public abstract class PlatformJob : LearnStackJob ## Architecture tests (Phase 02 blocker) +Canonical rule names **and their assertions** live in the +[architecture-test catalogue](../standards/21-architecture-tests-catalogue.md); this +table repeats the isolation-facing half of each. + | Test | Asserts | |------|---------| -| `Every_TenantOwned_Entity_HasTenantId` | Every aggregate marked `[TenantOwned]` has a `TenantId` property and an EF query filter referencing it. | -| `Every_OrgScoped_Entity_HasOrgIdAndFilter` | Every aggregate marked `[OrganizationScoped]` has `OrganizationId` nullable + EF query filter. | -| `Every_TenantOwned_Table_HasRlsPolicy` | Migration scan: every tenant-owned table has `ENABLE` **and** `FORCE ROW LEVEL SECURITY` and **exactly one** permissive policy with an explicit `WITH CHECK`. Two permissive policies fail the test. | -| `Every_OrgScoped_Table_HasOrgRlsPolicy` | Migration scan: the organization term is `AND`-ed inside that single policy — not in a second permissive one — and both `AS RESTRICTIVE` write guards are present. | +| `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` | Every entity marked `[TenantOwned]` has a **tenant key** (`TenantId`, or `Id` on the tenant-owned self-keyed class), an EF global query filter referencing it, and — in the migration that creates its table — `ENABLE` **and** `FORCE ROW LEVEL SECURITY` plus exactly one policy carrying both a `USING` and a `WITH CHECK` clause over `app.tenant_id`. A second **permissive** policy on the same table fails the test. | +| `Every_OrgScoped_Entity_HasOrgIdAndFilter` | Every entity marked `[OrganizationScoped]` carries a **nullable** `OrganizationId`, an org-aware EF query filter, an organization term `AND`-ed into that same single policy — not a second permissive one — and, in the creating migration, both `AS RESTRICTIVE` write guards, `FOR UPDATE` and `FOR DELETE`. | | `No_IgnoreQueryFilters_Outside_PlatformAdminScope` | Roslyn source scan: `IgnoreQueryFilters()` appears only inside the audited `EnterPlatformAdminScope(reason)` call path. No marker exempts a call site. | | `Hangfire_JobPayloads_IncludeTenantId` | Reflection: every `LearnStackJob` subclass's `TParams` has `TenantId`. | | `LearnStackJob_RunAsync_SetsTenantBeforeExecute` | Source-grep + reflection: `RunAsync` is non-virtual; the write to `ITenantContextAccessor.Current` precedes `ExecuteAsync(...)`. | diff --git a/docs/architecture/27-custom-domain-tls.md b/docs/architecture/27-custom-domain-tls.md index bad0a632..16417cfc 100644 --- a/docs/architecture/27-custom-domain-tls.md +++ b/docs/architecture/27-custom-domain-tls.md @@ -296,21 +296,41 @@ public sealed class CachedHostToTenantResolver( { // One database round trip per host, however many callers arrive during // it. The flight runs on CancellationToken.None: one caller hanging up - // must not cancel the lookup the others are waiting on. + // must not cancel the lookup the others are waiting on. WaitAsync(ct) + // stops only THIS caller waiting, and a caller that stops waiting never + // reaches unknownHosts.Add above — so the negative cache is populated + // only by a request that survived its own lookup. var flight = _flights.GetOrAdd( host, static (h, self) => new Lazy>( - () => self.ReadAsync(h, CancellationToken.None)), + () => self.ReadAndRetireAsync(h)), this); + return await flight.Value.WaitAsync(ct); + } + + private async Task ReadAndRetireAsync(string host) + { + // Retirement is bound to the FLIGHT's termination, inside the flight's + // own task — never to a caller's exit. Retiring in each waiter's finally + // lets a joiner that cancels de-register a read whose transaction is + // still open, so the next arrival opens a second one: the stampede the + // coalescing exists to prevent, reintroduced by its own cleanup. Packet 5 + // convicted that exact shape in InMemoryCacheService, which retires a + // flight on the factory's termination rather than on a waiter's exit; a + // waiter's exit there only decrements the waiter count, and cancels the + // factory when the count reaches zero with the read still pending. This + // resolver has no cancellation to propagate — the read runs on + // CancellationToken.None — so it needs the retirement rule and not the + // waiter bookkeeping. Exactly one flight is registered per host at a + // time, so the plain TryRemove here has no successor to race. try { - return await flight.Value.WaitAsync(ct); + return await ReadAsync(host, CancellationToken.None); } finally { - _flights.TryRemove( - new KeyValuePair>>(host, flight)); + _flights.TryRemove(host, out _); } } @@ -378,7 +398,17 @@ misses on one key; get-then-set has no factory to coalesce, so N simultaneous fi requests for one cold host become N transactions unless the resolver re-adds the coalescing itself. **Packet 7 re-adds it**, in `CachedHostToTenantResolver`, because the flight it protects is a Postgres transaction opened on an anonymous, pre-authentication -page load — the one path a stranger can make cold at will. +page load — the one path a stranger can make cold at will. The re-added map retires a +flight when the **flight** terminates, never when a caller exits: +[Packet 5's delivery record](../roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-5) +convicts the waiter-side cleanup, where a joiner that cancels de-registers a read whose +transaction is still open and the next arrival starts a second one. What the split does +not get back is supersession — `InMemoryCacheService` also suppresses the store of a +factory result whose key was written or dropped while it ran, and that is an adapter +detail rather than an `ICacheService` guarantee — so an invalidation racing an open +flight can re-store the mapping it just dropped, bounded by the same positive-TTL window +[ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) already accepts for a +deactivated mapping. `platform_host_to_tenant` is the one **platform-scoped** table — the row is what *determines* the tenant, so it cannot be filtered by a tenant context that does not diff --git a/docs/architecture/28-platform-tenant-organization.md b/docs/architecture/28-platform-tenant-organization.md index 587c964a..4bb2087f 100644 --- a/docs/architecture/28-platform-tenant-organization.md +++ b/docs/architecture/28-platform-tenant-organization.md @@ -229,9 +229,12 @@ manage: The following invariants are enforced by architecture tests, integration tests, and operational discipline: -1. **No two tenants share a row.** Every tenant-owned table has `tenant_id` + EF query - filter + RLS policy. Architecture test `Every_TenantOwned_Entity_Has_TenantId` fails - the build on violation. (ADR-0003) +1. **No two tenants share a row.** Every table in a **tenant-owned** table class carries + an EF query filter and an RLS policy over `app.tenant_id`; the marker follows the table + class, not the presence of a `tenant_id` column — see + [Database Standards § Table classes](../standards/05-database.md). Architecture test + `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` fails the build on violation. + (ADR-0003) 2. **No two organizations in the same tenant share a row when org-scoped.** Same shape, one extra column. (ADR-0017) 3. **Hub never stores tenant content.** Hub DB schema is forbidden to contain `course`, diff --git a/docs/decisions/0032-exception-handling-logging-and-observability.md b/docs/decisions/0032-exception-handling-logging-and-observability.md index 6afa2126..491b760a 100644 --- a/docs/decisions/0032-exception-handling-logging-and-observability.md +++ b/docs/decisions/0032-exception-handling-logging-and-observability.md @@ -9,6 +9,38 @@ Accepted ## Amendments +### Amendment 3 — `ITenantContext` is registered transient, not scoped (2026-09-01) + +Not a correction. § Sub-decision 10 and the `TenantContextSpanProcessor` code block both +call `ITenantContext` **request-scoped**, and § Sub-decision 10 adds that injecting it +into the singleton processor "would fail at startup with *Cannot consume scoped service +`ITenantContext` from singleton*". Both were true when written. Packet 5 changed the +registration, so the wording is now history rather than description. + +**What changed and why.** Commit `3c18f88` (2026-08-26) registers the default as +`TryAddTransient(sp => sp.GetRequiredService().Current +?? UnresolvedTenantContext.Instance)`. A *scoped* factory caches the first value it +produced for the rest of the scope, so a write to the accessor after a handler had +already resolved the context would never reach that handler — which is exactly what the +integration-event transport does when it restores a consumer's tenant. `Transient` makes +every access re-read the accessor. +`DeploymentModeCompositionTests.Tenant_Context_Resolution_Forwards_Each_Access_To_The_Accessor` +pins it, and the composition root carries a comment saying not to restore it to `Scoped`. + +**What does not change.** The decision — cross-cutting singletons read the tenant through +`ITenantContextAccessor` and never inject `ITenantContext` — is unchanged, and the +transient registration makes it *more* load-bearing rather than less. Under `Scoped` the +container refused the mistake at startup. Under `Transient` it does not: a singleton that +injects `ITenantContext` gets one instance captured for the life of the process, reading +whatever the accessor held at construction. The rule now has no container-level backstop, +so the accessor is the whole of it. + +**Every carrier changed.** This amendment. The two "request-scoped" phrasings in +§ Sub-decision 10 and its code-block commentary stand as written, read against this +amendment; the corpus's live descriptions — `CrossCuttingFoundationExtensions`, +[Security Standards § Tenant Context](../standards/11-security.md) and the Phase 02a +roadmap — say transient. + ### Amendment 2 — Three corrections from the 2026-08-08 restructure None of the three changes a sub-decision; all three correct text that would mislead an implementer. diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index fb44f0d9..775a1b21 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -257,8 +257,10 @@ the event. > **Erratum — 2026-09-01.** The paragraph below says the `[PublicSurface]` set "is > enumerated in the catalogue". It was enumerated nowhere, and "the catalogue" had three > candidate referents in this corpus (architecture tests, audit coverage, permissions); -> shown by `grep -rn "PublicSurface" docs/` at this ADR's acceptance, whose only hits are -> inside this file. The set now lives in +> shown by `git grep -n PublicSurface 803b381 -- docs/`, whose twelve hits across three +> files — this ADR, its row in the decisions index, and the architecture-tests catalogue +> — never name a marked request type. The catalogue's own entry sent the reader to "the +> catalogue's enumerated set", which is itself. The set now lives in > [Standards 04 § Public surface](../standards/04-api-design.md), which this ADR's > § Architecture tests already designates as the home of its day-to-day rules — so the > location changed, not the rule. Every rule the paragraph states about the set is @@ -803,9 +805,13 @@ the architecture-tests catalogue, the audit-coverage catalogue and the permissio catalogue. A rule that reads against a set nobody wrote down cannot be implemented, and `PublicSurface_Marker_Set_Is_Enumerated` is a Packet 7 deliverable that has to. -**How it was shown.** `grep -rn "PublicSurface" docs/` returns hits only inside this -file. The architecture-tests catalogue disclaims owning rule content in its own opening -section, so it was never the home. +**How it was shown.** `git grep -n PublicSurface 803b381 -- docs/` — this ADR's +acceptance commit — returns twelve hits in three files: this ADR, its one-line row in +the decisions index, and two entries in the architecture-tests catalogue. Not one of +them names a marked request type or its permitted methods. The catalogue's +`PublicSurface_Marker_Set_Is_Enumerated` asserted that every marked type "appears in the +catalogue's enumerated set", which is a rule reading against itself; and the catalogue +disclaims owning rule content in its own opening section, so it was never the home. **Every carrier changed.** This ADR (the inline erratum in § The reconciliation matrix and this amendment); [Standards 04 § Public surface](../standards/04-api-design.md), diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index f3272135..f0068cb7 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -476,7 +476,9 @@ amendment) and [Security Standards § The out-of-band setters](../standards/11-security.md), which reproduces the count and the table — "six" becomes seven, "four own a short transaction of their own" becomes five, and the table gains an -`IOrganizationScopeValidator` row. No other document states the count. +`IOrganizationScopeValidator` row. No other document reproduces the table; a pointer +that only names the count — `.claude/skills/add-ef-migration/SKILL.md` is one — stays +correct by naming the corrected number rather than by carrying a second enumeration. **The canonical list** remains this subsection, as corrected. Security Standards reproduces it because that section is the placement authority; it is not a second diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8b95aece..c638e65f 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -53,7 +53,7 @@ A body that says something **false** is the one exception, and it is bounded by | 0037 | [What an Idempotency Key Identifies, Owns, and Replays](0037-idempotency-key-contract.md) | A client-chosen key is a **nonce inside a tenant's key space**, not an identity: `(tenant, key)` addresses the record and a fingerprint over organization, principal, method, path, query and body decides whether replaying it answers the question asked; a fencing token owns the claim; capacity is **admission, not eviction**, so nothing unexpired is ever displaced; the guarantee is at-most-once while a claim is live and at-least-once across process death | | 0038 | [Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) | **Supersedes ADR-0014.** Retains Dapr behind demand gates; fixes the event envelope, handler isolation, trace/audit scope and cache contracts | | 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented inside `AuditableEntity` by the single primitive `MarkUpdated` and `SoftDelete` both route through, and mapped with `HasDefaultValue(0L).IsConcurrencyToken().ValueGeneratedNever()` — exactly those three calls (Amendment 2): `ValueGeneratedOnAddOrUpdate()` / `IsRowVersion()` make EF omit the column from the `UPDATE` entirely (Amendment 1, measured), and `HasDefaultValue` alone leaves `ValueGenerated` at `OnAdd`, which the registered rule rejects; `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | -| 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden. Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | +| 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden, with the one enumerated exception [ADR-0042](0042-tenant-provisioning-cross-aggregate-transaction.md) carves for tenant provisioning (Amendment 4, 2026-08-30). Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | | 0041 | [Correcting False Statements in Accepted ADRs](0041-correcting-false-statements-in-accepted-adrs.md) | Inline erratum beside the false text is the default; in-place replacement only where the text is a **canonical artifact for reuse** — a template others are told to copy, a DDL or command meant to be applied — never merely because something could be copied, and never because a non-ADR carrier holds the same token. Bounded to statements false **when they entered the record**, judged per statement: original body at the acceptance commit, amendment text at that amendment's date. A statement that has since gone stale is history, and gets an amendment or a superseding ADR. Every changed Accepted ADR carries its own dated Amendment | | 0042 | [Tenant Provisioning as a Bounded Cross-Aggregate Transaction](0042-tenant-provisioning-cross-aggregate-transaction.md) | A standing, **enumerated** exception to § Aggregate Ownership: tenant provisioning writes `Tenant` and its default `Organization` in one transaction, because `tenants.default_organization_id` carries an invariant an integration event cannot deliver — the substitute moves the second write to a later transaction and the window between them is exactly what the invariant forbids. Bounded by enumeration rather than by principle: two roots, one operation, a literal allow-list of one in the architecture test. Covers no child entity, no projection, and no cross-**module** write, which stays forbidden with no exception | diff --git a/docs/glossary.md b/docs/glossary.md index ba12f148..f8e9fe56 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -191,7 +191,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | Term | Definition | |------|------------| -| **`[TenantOwned]`** | Marks a domain entity whose rows are scoped to a tenant. The build inspects the marker to assert: the entity carries a `TenantId`, an EF global query filter for tenant scope is configured, and a PostgreSQL RLS policy on the backing table reads `current_setting('app.tenant_id')`. See [05-database.md](standards/05-database.md). | +| **`[TenantOwned]`** | Marks a domain entity whose rows are scoped to a tenant. The build inspects the marker to assert: the entity carries a **tenant key** (`TenantId`, or `Id` on the tenant-owned self-keyed class), an EF global query filter for tenant scope is configured, and a PostgreSQL RLS policy on the backing table reads `current_setting('app.tenant_id')`. See [05-database.md](standards/05-database.md). | | **`[OrganizationScoped]`** | Marks a `[TenantOwned]` entity that additionally carries `OrganizationId` (nullable; null means tenant-wide). The build asserts a matching org-aware EF filter + RLS policy reading `current_setting('app.organization_id', true)`. Per [ADR-0017](decisions/0017-tenant-organization-hierarchy.md). | | **`[PiiSensitive]`** | Marks a field whose value the audit pipeline must redact before persisting to `audit_log`. The redaction filter strips matching property names from `before` / `after` snapshots and replaces with `""`. | | **`[ConsistencyTier(...)]`** | Optional marker on a command handler that explicitly states the distributed-consistency tier (1 / 2A / 2B / 3) per [01-architecture-standards.md § Distributed-Consistency Tiers](standards/01-architecture-standards.md). Reviewers use it to reason about failure modes. | @@ -277,7 +277,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Telemetry Signal** | One of the three OpenTelemetry signals — logs, traces, metrics. LearnStack emits all three through Microsoft.Extensions.Logging (Serilog implementation) + OTel SDK; errors additionally flow to `IErrorTrackingProvider`. | | **`IErrorTrackingProvider`** | The composition-root abstraction over the error backend. Implementations: `NoOpErrorTracker` (Development / SelfHostedOnline-without-DSN), `SentryErrorTracker` (SaaS / Dedicated / SelfHostedOnline-with-DSN), `LocalFileErrorTracker` (SelfHostedAirGapped). Modules never import `Sentry.SentrySdk`; the architecture test `Modules_Do_Not_Reference_Sentry_SDK_Directly` enforces it. See [ADR-0032 § Sub-decision 9](decisions/0032-exception-handling-logging-and-observability.md). | | **`IProviderResilience`** | The Polly v8 `ResiliencePipeline` (retry + circuit breaker + timeout + bulkhead) that every provider adapter takes as a **collaborator** and routes outbound calls through. Not a decorator: C# forbids a type parameter as a base type, so no `ResilientProviderAdapter : TPort` can exist ([ADR-0032 Amendment 2](decisions/0032-exception-handling-logging-and-observability.md)). The adapter does only SDK exception → `ProviderException` translation. Configured per port in `appsettings.Resilience::`. See [ADR-0032 § Sub-decision 5](decisions/0032-exception-handling-logging-and-observability.md). | -| **`ITenantContextAccessor`** | The singleton, `AsyncLocal`-backed accessor that cross-cutting infrastructure (`TenantContextSpanProcessor`, Serilog enricher, Sentry enricher) reads to enrich telemetry without inheriting the request-scoped DI lifetime. Populated at scope start by `TenantResolverMiddleware` (HTTP), `HubCorrelationMiddleware` (`/api/internal/*`), Hangfire `JobActivator` (background jobs), and the outbox / inbox handler scope. Modules never write to it. See [ADR-0032 § Sub-decision 10](decisions/0032-exception-handling-logging-and-observability.md). | +| **`ITenantContextAccessor`** | The singleton, `AsyncLocal`-backed accessor that cross-cutting infrastructure (`TenantContextSpanProcessor`, Serilog enricher, Sentry enricher) reads to enrich telemetry without injecting `ITenantContext` itself, whose production registration is transient and resolves from this accessor on every access. Populated at scope start by `TenantResolverMiddleware` (HTTP), `HubCorrelationMiddleware` (`/api/internal/*`), Hangfire `JobActivator` (background jobs), and the outbox / inbox handler scope. Modules never write to it. See [ADR-0032 § Sub-decision 10](decisions/0032-exception-handling-logging-and-observability.md). | | **`TenantContextSpanProcessor`** | The `BaseProcessor` registered once at the OTel tracing pipeline; its `OnStart` hook reads from `ITenantContextAccessor` and enriches every span with `tenant.id`, `organization.id`, `user.id`, `module`, `correlation.id` — including spans produced by auto-instrumentation libraries (EF Core, HttpClient, Valkey via Dapr, SeaweedFS S3 SDK, LiveKit). See [ADR-0032 § Sub-decision 10](decisions/0032-exception-handling-logging-and-observability.md). | | **`ProviderException.IsClientError`** | Boolean flag set by adapters when translating upstream 4xx (`true`) or 5xx (`false`) responses. The L1 `IExceptionHandler` reads it to decide whether to Sentry-capture (only 5xx — provider's infra fault) or log-only (4xx — provider's user-error). | | **L1 / L2 / L3 (cache)** | "L1 cache" is the per-pod in-process layer — `InMemoryCacheService` today; "L2 cache" is the cross-pod Valkey state via Dapr. Both layers are managed through `ICacheService`; do not confuse with error-handling layers. See [20-infrastructure-stack.md § Cache layer cheat sheet](standards/20-infrastructure-stack.md). | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 859d3e66..fe38e6c8 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -49,12 +49,13 @@ Tenancy owns **who a request belongs to** and nothing about what they do with it ## Entity-relationship diagram -Aggregate roots are `Tenant` and `Organization` — the two that implement -`IAggregateRoot`. `PlatformHostMapping` and `PlatformEntitlement` are +Aggregate roots in the shipped code are `Tenant` and `Organization` — the two +that implement `IAggregateRoot`; the promotion below adds two more with +Packet 7's first command. `PlatformHostMapping` and `PlatformEntitlement` are projections rather than aggregates: nothing in this module mutates them through a root. -**The other four sit between the two, and the boundary is not settled.** +**The other four resolve two ways, and Packet 7 settles them as promotion.** `TenantDomain`, `TenantSetting`, `TenantLocale` and `TenantFeatureFlag` each have a public factory, a top-level `DbSet` on `TenancyDbContext`, and no navigation from `Tenant` — so there is no path through a root, which @@ -65,11 +66,15 @@ requires for state changes inside an aggregate. They also split: `TenantFeatureFlag` have composite natural keys and no id at all and therefore cannot be `IAggregateRoot` under any reading. -**Packet 7 decides it**, because Packet 7 writes the first command that touches -any of them and a boundary with no writer is a boundary with no evidence. Until -then nothing writes them, so the divergence costs nothing — but it is a -divergence, and this paragraph says so rather than describing a containment the -code does not implement. +So the first pair becomes aggregate roots in their own right and the second +becomes navigations inside `Tenant` — four roots in Tenancy, with a write to +`TenantLocale` or `TenantFeatureFlag` bumping `Tenant.row_version` and the two +promoted roots carrying their own. +[Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) writes the first command +that touches any of them, which is the evidence the boundary had none of and +where the promotion lands; provisioning writing `Tenant` and its default +`Organization` in one transaction is sanctioned by enumeration in +[ADR-0042](../../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md). ```mermaid erDiagram @@ -145,7 +150,8 @@ Text fallback — **TenantDomain lifecycle**: - A `Custom` domain travels the whole path — `Requested → Verifying → Verified`, or `Verifying → Failed → Verifying` on retry. - **A verified row does not serve traffic on its own.** A corresponding - `platform_host_to_tenant` row with `is_publicly_live` does. + `platform_host_to_tenant` row that is `is_active` **and** `is_publicly_live` + does. ## Sequence diagrams @@ -293,11 +299,16 @@ request and are the only Tenancy work an anonymous visitor pays for. ## Risks and open questions -- **`app.scope` has no carrier.** `ITenantContext` exposes no scope member, so +- **`app.scope` has no carrier.** `ITenantContext` exposes no scope member + ([ADR-0040 Amendment 1](../../decisions/0040-ambient-unit-of-work.md)), so no application path sets `app.scope = 'tenant'` and the cross-organization read hatch on `tenant_settings` is unreachable at runtime. That is the correct - default; [Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) decides how the - flag arrives ([ADR-0040 Amendment 1](../../decisions/0040-ambient-unit-of-work.md)). + default, and no carrier ships in + [Packet 7](../../roadmap/phase-02a-kernel-tenancy.md): the flag derives from the + actor's role and roles + arrive with authentication in + [Phase 02b](../../roadmap/phase-02b-events-auth.md), so the deferral is forced, + not chosen ([Security Standards § Tenant Context](../../standards/11-security.md)). The two `AS RESTRICTIVE` write guards are tested **now** rather than then — `TheTenantScopeHatchWidensReadsAndNeitherWrite` sets the flag directly — because under any ordinary organization-scoped session the base policy's own @@ -310,9 +321,11 @@ request and are the only Tenancy work an anonymous visitor pays for. policy predicate is `NULL` and every query returns zero rows — fail-closed by construction rather than by a filter that does not exist. - **Two defaults per tenant are possible.** Nothing stops two `tenant_locales` - rows with `is_default = true` for one tenant. A partial unique index would fix - it; whether the invariant belongs in the database or in the aggregate is - Packet 7's call, with the first code that reads it. + rows with `is_default = true` for one tenant. + [Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) closes it in both places: + a partial unique index `UNIQUE (tenant_id) WHERE is_default`, because an + aggregate invariant alone does not hold across concurrent transactions, plus an + aggregate-level guard for the error message. - **Nothing stops a tenant claiming a hostname it does not own.** `ux_tenant_domains_host` is globally unique — it has to be, or a host would resolve to two tenants — so the *first* tenant to insert a `Requested` row for diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 40c0c935..2925c195 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -424,8 +424,9 @@ frame out. else** — never the Hub, per [ADR-0034](../decisions/0034-hub-contract-surface-invariant.md); an anonymous page load must not depend on a control plane being reachable. -`TenantResolverMiddleware`, request-scoped `ITenantContext` (`TenantId`, -`OrganizationId?`, `UserId?`), singleton `ITenantContextAccessor` +`TenantResolverMiddleware`, transient `ITenantContext` (`TenantId`, +`OrganizationId?`, `UserId?`) resolved from the singleton +`ITenantContextAccessor` on every access, the accessor (`AsyncLocal`-backed) populated at scope start by `TenantResolverMiddleware` (HTTP), `HubCorrelationMiddleware` (`/api/internal/*`), the Hangfire `JobActivator` (background jobs), and the @@ -444,7 +445,7 @@ remembered to enumerate; the prefixes are enumerated in sealed context's only entry point: it returns `Result.Fail` on any disagreement between signals and never a partially populated context. `TenantContextOrigin` is the authority ceiling, and the `[PublicSurface]` set it gates is enumerated in -[Standards 04 § Tenant Context](../standards/04-api-design.md) — the enumeration ships +[Standards 04 § Public surface](../standards/04-api-design.md) — the enumeration ships **empty**, because the first request types that need it are [Phase 02d](phase-02d-walking-skeleton.md)'s two anonymous read endpoints. `IOrganizationScopeValidator` answers "does this organization belong to this tenant" by @@ -696,9 +697,11 @@ Implements `Core_Modules_HaveNo_DomainSpecific_Names` — the mechanical guarantee behind the platform's entire premise, and currently unimplemented while its far weaker sibling `No_Source_Folder_Named_Verticals` is green. -One rule is restated rather than renamed. The catalogue's -`Every_TenantOwned_Table_HasRls_With_AppTenantId` asserts that a policy -**exists** and mentions `app.tenant_id`. The superseded template satisfied that +One rule is restated rather than renamed. `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` +asserts more than that a policy **exists** and mentions `app.tenant_id`: it requires +`ENABLE` **and** `FORCE ROW LEVEL SECURITY` and exactly one policy carrying both a +`USING` and a `WITH CHECK`. The superseded spelling +`Every_TenantOwned_Table_HasRls_With_AppTenantId` asserted only the weaker half. The superseded template satisfied that assertion perfectly while leaking every tenant-wide row across tenants — a structure-shaped test that passes against a broken policy is worse than no test, because it converts an open question into a false answer. Structural assertions @@ -1009,11 +1012,14 @@ Per [ADR-0032](../decisions/0032-exception-handling-logging-and-observability.md `ActivitySource` named per module (`learnstack.`) for use-case spans. - **`ITenantContextAccessor`** (singleton, `AsyncLocal`-backed) - lives in `LearnStack.SharedKernel` alongside the request-scoped - `ITenantContext`. The scoped interface is what handlers and services - inject; the singleton accessor is what cross-cutting infrastructure - (OTel processor, Serilog enricher, Sentry enricher) reads. The accessor - is populated at scope start by `TenantResolverMiddleware` (HTTP), + lives in `LearnStack.SharedKernel` alongside `ITenantContext`, whose + production registration is **transient, resolved from the singleton + accessor on every access** — a scoped factory would cache the first value + for the rest of the scope, so a write to the accessor after a handler + resolved would never reach it. That transient interface is what handlers + and services inject; the singleton accessor is what cross-cutting + infrastructure (OTel processor, Serilog enricher, Sentry enricher) reads. + The accessor is populated at scope start by `TenantResolverMiddleware` (HTTP), `HubCorrelationMiddleware` (`/api/internal/*`), Hangfire `JobActivator` (background jobs), and the outbox / inbox handler scope (integration events). Modules never write to the accessor. @@ -1093,7 +1099,8 @@ Implementation: sealed context's only entry point, with `TenantContextOrigin` as the authority ceiling over the `[PublicSurface]` set. - `IOrganizationScopeValidator` and `DenyAllTenantMembershipReader`. -- `ITenantContext` (request-scoped) exposing `TenantId`, `OrganizationId?`, `UserId?`. +- `ITenantContext` (transient, resolved from the singleton accessor on every access) + exposing `TenantId`, `OrganizationId?`, `UserId?`. - Tenant- and org-aware query conventions. - Tenant + org context propagation seams for Hangfire jobs and outbox dispatcher handlers (wired in 02b). @@ -1188,7 +1195,8 @@ identifiers registered in as `learnstack_app`; a structural assertion passes against a policy that leaks. - Every `[OrganizationScoped]` entity has org filter + RLS (`Every_OrgScoped_Entity_HasOrgIdAndFilter`). -- No `IgnoreQueryFilters()` outside platform-admin module. +- No `IgnoreQueryFilters()` outside the audited `EnterPlatformAdminScope(reason)` + call path (`No_IgnoreQueryFilters_Outside_PlatformAdminScope`). - Audit-coverage matrix file exists per module. - `AuditEntry_Inherits_Entity_Not_AuditableEntity`. - `MustClass_Audit_Writes_Share_The_Business_Transaction` — per diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 8783629d..197d77ee 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -141,15 +141,16 @@ From [Phase 06](phase-06-renderer-admin-studio.md), in `frontend/apps/web` under Both are Server Components fetching through the typed SDK. Both read the tenant's branding tokens, level taxonomy and lesson-body `TenantContentType` from customization data — the lesson page renders the field list the tenant declared, not a fixed one. -Layout, typography and colour come from `TenantSettings`, not from a hard-coded theme. +Layout, typography and colour come from `TenantSetting`, not from a hard-coded theme. ### Host-based tenant resolution, end to end The full path from [Phase 02a Packet 7](phase-02a-kernel-tenancy.md), exercised for real: an inbound request's `Host` header resolves through `platform_host_to_tenant` to -a `(tenant_id, organization_id?)` pair, the request-scoped `ITenantContext` is -populated, the transaction sets `app.tenant_id` / `app.organization_id` with -`SET LOCAL`, and Row Level Security filters every read. +a `(tenant_id, organization_id?)` pair, the singleton `ITenantContextAccessor` is +written and the transient `ITenantContext` resolves from it on every access, the +transaction sets `app.tenant_id` / `app.organization_id` with `SET LOCAL`, and Row +Level Security filters every read. Two hosts are registered in local development, one per seed tenant. diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 891cff18..7fe11eb2 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -23,7 +23,7 @@ and a non-developer tenant admin can maintain it. | Host-based tenant + organization resolution, end to end | Per-organization branding override on the resolved context | | Catalog page and lesson page, Server Components over the typed SDK | Navigation, SEO metadata, 404 and redirect handling, full page composition | | One built-in content primitive | The complete two-tier block registry with safe-render placeholders | -| Branding tokens read from `TenantSettings` | The branding configuration surface that writes them | +| Branding tokens read from `TenantSetting` | The branding configuration surface that writes them | | First frontend tests, replacing the `--passWithNoTests` placeholder | The browser-level end-to-end suite | ### Public site renderer diff --git a/docs/standards/01-architecture-standards.md b/docs/standards/01-architecture-standards.md index b44555c1..bd0bda04 100644 --- a/docs/standards/01-architecture-standards.md +++ b/docs/standards/01-architecture-standards.md @@ -141,11 +141,13 @@ Rules: ## Tenant-Scoped Code - Every entity backed by a table in one of the **tenant-owned** table classes carries - `[TenantOwned]`. The two exceptions are classes, not omissions: - `tenants` is tenant-owned **self-keyed** — its `id` *is* the tenant id, so it has no - `TenantId` property and its policy keys on `id` — and `platform_host_to_tenant` is - **platform-scoped**, read in order to determine the tenant, so a tenant-keyed - predicate on it would make host resolution return zero rows forever. See + `[TenantOwned]`. Both exceptions are decided by **table class**, not by oversight, and + they except different things. `tenants` is tenant-owned **self-keyed**: it **carries + the marker**, and is excepted only from the `TenantId` *property* — its `id` *is* the + tenant id, so both the query filter and the policy key on `id`. + `platform_host_to_tenant` is **platform-scoped**, read in order to determine the + tenant — a tenant-keyed predicate on it would make host resolution return zero rows + forever — so it takes **no marker at all**. See [Database Standards § Table classes](05-database.md) and [ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md). The presence of a `TenantId` property is **not** the test: `PlatformHostMapping` has diff --git a/docs/standards/04-api-design.md b/docs/standards/04-api-design.md index d07585b3..52ebfc5d 100644 --- a/docs/standards/04-api-design.md +++ b/docs/standards/04-api-design.md @@ -148,9 +148,17 @@ reconciliation matrix are the separate case — no tenant context resolves at al carries `HostOnly` and reaches only `[PublicSurface]` request types; `TenantContextBehavior` at pipeline step 4 ([02-backend-coding.md § Pipeline Behaviors](02-backend-coding.md)) rejects anything - else — with the same bodyless **404** `UseStatusCodePages` renders as `not_found` for - an unresolvable host, because anything a client can tell apart confirms to an - anonymous caller that the tenant exists. + else — failing with `lockey_not_found`, so the body it renders is byte-identical to + the one an unresolvable host gets, because anything a client can tell apart confirms + to an anonymous caller that the tenant exists. Not `lockey_tenant_mismatch`: a + `HostOnly` context is anonymous by construction and `tenant_mismatch` is the + authenticated code. The mechanism differs and the wire result must not — + host classification writes a bodyless **404** that `UseStatusCodePages` fills in through + `ProblemDetailsFactory.ForStatus(404)`, while a step-4 failure carries its own Problem + Details body through `ProblemDetailsActionResult`; since + `HttpStatusMap.CanonicalCodeFor(404)` is `not_found` and `Error.Code` strips the + `lockey_` prefix, both carry the same `type`, `title`, `status`, `code` and + `messageKey`. - **Permitted methods default to `GET` / `HEAD`.** An entry declaring a mutating method states why, in the table. - **No `[PublicSurface]` type performs a tenant-owned write.** diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index e3f34dff..dcc328a3 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1160,7 +1160,8 @@ Forbidden: string interpolation with non-constant values. worse than throwing. The command interceptor instead checks the in-process marker **a sanctioned setter** stamps on the transaction it opens, once the `SET LOCAL` pair is issued, and throws `TenantContextMissingException` when a command against a - `[TenantOwned]` table runs without it — no extra round trip. The setters are a closed + `[TenantOwned]` table runs without it — no extra round trip. Both arms are asserted by + [`Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction`](21-architecture-tests-catalogue.md). The setters are a closed set, named in [Security Standards § The out-of-band setters](11-security.md), which is the placement authority: a guard keyed on `TransactionBehavior` alone would reject the writes the idempotency store and the audit store make on their own short transactions. diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 5f5f70ac..157ac2b7 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -57,8 +57,8 @@ answer **yes** to each item, or attach a written justification: the entity may be tenant-wide) and has an org-aware EF query filter + RLS policy per [ADR-0017](../decisions/0017-tenant-organization-hierarchy.md). The architecture test `Every_OrgScoped_Entity_HasOrgIdAndFilter` checks the marker. -- [ ] No `IgnoreQueryFilters()` outside platform-admin code paths (Roslyn-allowlisted + - audit-logged). +- [ ] No `IgnoreQueryFilters()` outside the audited `EnterPlatformAdminScope(reason)` + call path. - [ ] Every background job payload carries `TenantId` (and `OrganizationId?` when relevant); the worker sets ambient tenant + org before any work. - [ ] Every integration event payload carries `tenant_id` (and `organization_id?` when @@ -206,8 +206,12 @@ for the full strategy. Standards-side: and an org-aware EF filter + RLS policy ([ADR-0017](../decisions/0017-tenant-organization-hierarchy.md)). Nullable `OrganizationId` means the row may be tenant-wide. -- `IgnoreQueryFilters()` is allowed only in platform-admin code paths with a - Roslyn-allowlist attribute and an audit-log call. +- `IgnoreQueryFilters()` is allowed only inside the audited + `EnterPlatformAdminScope(reason)` call path, which is itself the record of the + access. The architecture test `No_IgnoreQueryFilters_Outside_PlatformAdminScope` + is a **path check**: no per-call-site attribute or comment marker exempts a call + ([09-tenant-isolation.md § Platform admin access](../architecture/09-tenant-isolation.md), + [21-architecture-tests-catalogue.md](21-architecture-tests-catalogue.md)). - Background jobs **must** receive `TenantId` (and `OrganizationId?`) in their payload; jobs without it fail at registration. - The `app.tenant_id` and `app.organization_id` session variables are set with @@ -300,7 +304,9 @@ issued the `SET LOCAL` pair on this transaction before any command against a out-of-band setters above, each of which stamps the same marker on the transaction it opens. Naming `TransactionBehavior` alone would make the guard reject every write the idempotency store and the audit store legitimately make. It throws -`TenantContextMissingException` when it has not. **Packet 7 owns it**: Packet 6 ships +`TenantContextMissingException` when it has not, which +[`Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction`](21-architecture-tests-catalogue.md) +asserts in both directions. **Packet 7 owns it**: Packet 6 ships the setter (`IUnitOfWork.SetTenantContextAsync`) and the policies it will back up, and the first tenant-owned read on a request path is Packet 7's, which is where the guard belongs. It cannot be a connection-checkout interceptor, for the same reason it cannot be diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index ff75adfd..cf0d5129 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -845,8 +845,9 @@ first two rows are coverage checks; the last three are the proof. #### `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` - **Asserts:** every entity marked `[TenantOwned]` (or implementing `ITenantOwned`) - has a `TenantId` property, an EF global query filter referencing it, and — in the - migration that creates its table — `ENABLE` **and** `FORCE ROW LEVEL SECURITY` plus + has a **tenant key** (`TenantId`, or `Id` on the tenant-owned self-keyed class), an + EF global query filter referencing it, and — in the migration that creates its + table — `ENABLE` **and** `FORCE ROW LEVEL SECURITY` plus exactly one policy carrying both a `USING` and a `WITH CHECK` clause over `app.tenant_id`. A second **permissive** policy on the same table fails the test: that is the defect ADR-0003 Amendment 3 corrects. @@ -891,7 +892,8 @@ first two rows are coverage checks; the last three are the proof. `EnterPlatformAdminScope(reason)` path. - **Source:** ADR-0003; [11-security.md](11-security.md); [05-database.md § Forbidden](05-database.md). -- **Type:** xUnit + source scan / Roslyn allowlist. **Kind:** structural. +- **Type:** xUnit + source scan; the permitted paths are a list inside the scan, not a + call-site marker. **Kind:** structural. - **Status:** **Registered.** - **Phase:** 02a (Packet 7). @@ -952,6 +954,22 @@ alongside the two rules above in Packet 6 step 4 and are listed in [Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md), which re-runs them through the request path. +#### `Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction` + +- **Asserts:** both arms of the `DbCommandInterceptor` guard. A command against a + `[TenantOwned]` table on a transaction no sanctioned setter stamped throws + `TenantContextMissingException`; the same command on a transaction opened by any of + the seven sanctioned setters runs. One arm is not the rule: a guard keyed on + `TransactionBehavior` instead of on the marker passes the first arm and rejects the + writes the idempotency store and the audit store legitimately make on their own short + transactions. +- **Runs as `learnstack_app`.** +- **Source:** [11-security.md § The out-of-band setters](11-security.md); + [05-database.md § Connection Management](05-database.md). +- **Type:** **integration** test (Testcontainers + PostgreSQL). **Kind:** runtime. +- **Status:** **Registered.** +- **Phase:** 02a Packet 7. + #### `Db_Connection_String_Is_TransactionPooled` - **Asserts:** the deployment configuration points at PgBouncer in **transaction** @@ -2011,7 +2029,8 @@ structural test proves — and what it does not. #### `PublicSurface_Requests_Are_Never_ReadSensitive` - **Asserts:** no `[PublicSurface]` request type is classified MUST-class `read-sensitive`. Otherwise an anonymous `GET` becomes a durable standalone audit write. -- **Source:** ADR-0036 § The reconciliation matrix. +- **Source:** ADR-0036 § The reconciliation matrix; + [Standards 04 § Public surface](04-api-design.md). - **Type:** xUnit + audit-catalogue cross-check. **Kind:** structural. - **Status:** **Registered.** - **Phase:** 02a Packet 7. @@ -2044,6 +2063,14 @@ structural test proves — and what it does not. - **Type:** xUnit. **Kind:** behavioural. - **Status:** **Registered.** - **Phase:** 02a Packet 7. +- **Note:** only the second conjunct is live in Packet 7 — no handler carries both + `[AllowsUnresolvedTenantContext]` and a platform-scope entry. The entry gate itself + holds as a **negative** until [Phase 03](../roadmap/phase-03-identity-admin.md): + `AuthorizationBehavior.Handle` is `return next()`, authentication arrives in + [Phase 02b](../roadmap/phase-02b-events-auth.md), and the Platform-scope permission + arrives with the Identity module + ([Tenancy § Permission Matrix](../modules/tenancy/permissions.md)). Packet 7 ships no + caller of the scope, so a gate that refuses everyone blocks nothing this packet ships. #### `Development_Only_Tenant_Header_Override_Is_Mode_Guarded` diff --git a/scripts/seed.sh b/scripts/seed.sh index 7f20ba09..241cdfff 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -209,7 +209,7 @@ cat <<'NOTICE' Packet 7 swaps this section for: dotnet run --project backend/src/LearnStack.Tools.Seeder -- \ - --tenants demo-platform,demo-vertical \ + --tenants demo-english,demo-yoga \ --platform-admin demo-admin@learnstack.test \ --connection-string "$ConnectionStrings__Default" From a487e8366abd293866b7865c429f501169921a18 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 16:35:50 +0300 Subject: [PATCH 03/55] docs: name the phase that can actually carry app.scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sonnet round over Step 1's final state found the deferral pointed at a phase that disowns the mechanism. Four carriers said app.scope's carrier "arrives with authentication in Phase 02b" because the flag derives from the actor's role. Phase 02b says in its own scope that it delivers "only the authentication plumbing" and that User, Membership, Role and Permission land in Phase 03; neither phase document contained the string app.scope at all, so a reader chasing the deferral followed the link and landed nowhere. The owner is Phase 03, with Phase 02b's authenticated principal as the prerequisite, and Phase 03 now says so itself. Two more Accepted ADRs carried statements false when written and were missed by the sweeps that corrected their siblings. ADR-0040's interface sketch still had SetTenantContextAsync issuing app.scope, contradicting its own Amendment 1. ADR-0022's tenant-runtime block calls the SetTenant member that has never existed, treats HostResolution as a nullable scalar, and reads Request.Host directly — which the Packet 4 analyzer now fails the build for; ADR-0036 Amendment 2's carrier list did not reach it. And ADR-0032's new Amendment 3 named Security Standards as a carrier that says "transient". It has zero occurrences of the word, and that section declares itself the authority for session-variable placement only. The previous commit's message called a false evidence line "a defect in the exact slot ADR-0041 polices" and then introduced one; this corrects it. Behaviour unchanged: no C# in the diff, 943 green, zero skips. ADR: 0022, 0032, 0040 Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/09-tenant-isolation.md | 8 ++- docs/decisions/0022-custom-domain-tls.md | 59 ++++++++++++++++++- ...tion-handling-logging-and-observability.md | 10 +++- docs/decisions/0040-ambient-unit-of-work.md | 8 +++ docs/modules/tenancy/README.md | 9 +-- docs/roadmap/phase-02a-kernel-tenancy.md | 3 +- docs/roadmap/phase-03-identity-admin.md | 12 ++++ docs/standards/05-database.md | 8 ++- docs/standards/06-testing.md | 6 +- docs/standards/11-security.md | 9 ++- .../21-architecture-tests-catalogue.md | 9 ++- 11 files changed, 119 insertions(+), 22 deletions(-) diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 1ce28101..4fcc41b7 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -125,9 +125,11 @@ ships `IUnitOfWork.SetTenantContextAsync`, which writes `app.tenant_id` and `app.organization_id`; `ITenantContext` carries no scope member ([ADR-0040 Amendment 1](../decisions/0040-ambient-unit-of-work.md)), so nothing sets `app.scope` and the hatch below is unreachable at runtime — the correct default. The -flag derives from the actor's role plus a declared tenant-wide operation, and roles -arrive with authentication in [Phase 02b](../roadmap/phase-02b-events-auth.md), so that -is the earliest carrier: the deferral is forced, not chosen +flag derives from the actor's role plus a declared tenant-wide operation. +[Phase 02b](../roadmap/phase-02b-events-auth.md) delivers the authenticated principal it +needs and nothing more — `Membership`, `Role` and `Permission` are +[Phase 03](../roadmap/phase-03-identity-admin.md)'s — so Phase 03 is the earliest phase +that can own a working carrier: the deferral is forced, not chosen ([Security Standards § Tenant Context](../standards/11-security.md) is the single authority). What middleware contributes is the *input*: the request comes from a tenant-admin role carrying a tenant-wide operation flag (e.g. cross-org reporting), and diff --git a/docs/decisions/0022-custom-domain-tls.md b/docs/decisions/0022-custom-domain-tls.md index c3efb049..6036ea78 100644 --- a/docs/decisions/0022-custom-domain-tls.md +++ b/docs/decisions/0022-custom-domain-tls.md @@ -357,6 +357,27 @@ APISIX pod) — not stored in cleartext YAML. LearnStack edge receives request with `Host: anatolia-yoga.com`. Resolution: +> **Erratum — 2026-09-01.** The sketch below is wrong in three ways, all of them about +> shapes that were already decided elsewhere when it entered the record. +> (1) `_tenantContextAccessor.SetTenant(...)` names a member that has never existed: the +> interface's sole member is `ITenantContext? Current { get; set; }`, fixed by +> [ADR-0032 § Sub-decision 10](0032-exception-handling-logging-and-observability.md) and +> recorded in [ADR-0036 Amendment 2](0036-tenant-resolution-trusted-inputs.md), whose +> carrier list did not reach this file. Population has exactly four sanctioned sites and +> this is not the shape any of them uses. +> (2) `ResolveAsync` returns `HostResolution?` — a `(TenantId, OrganizationId?)` pair, not +> a nullable scalar — so `tenantId.Value` and a hard-coded `organizationId: null` are both +> wrong; the anonymous organization scope comes from the host-mapping row +> ([ADR-0036](0036-tenant-resolution-trusted-inputs.md)). +> (3) A raw `context.Request.Host.Host` read is exactly what +> `Effective_Host_Computed_In_One_Place` now forbids; the host comes from +> `EffectiveHostAccessor`. +> The Decision — a custom domain resolves to a tenant at the edge, by host — is unchanged. +> Current authority for the mechanism: +> [architecture/27 § Tenant runtime](../architecture/27-custom-domain-tls.md) and +> [ADR-0036](0036-tenant-resolution-trusted-inputs.md). Recorded in the 2026-09-01 +> amendment below. + ```csharp // Nexora-pattern AsyncLocal tenant context; Host-aware bootstrap public sealed class TenantMiddleware @@ -377,7 +398,10 @@ public sealed class TenantMiddleware } ``` -`_hostToTenantResolver` is backed by `ICacheService` (Dapr State / Valkey); cache key +`_hostToTenantResolver` is backed by `ICacheService` — `InMemoryCacheService` today, +with the Valkey-via-Dapr adapter demand-gated to +[Phase 11](../roadmap/phase-11-production-hardening.md) per +[ADR-0035](0035-demand-gated-infrastructure.md); cache key `hub:host:{host}` invalidated on `CustomDomainActivatedEvent` / `CustomDomainRevokedEvent`. ### Public suffix list validation @@ -523,6 +547,39 @@ The canonical shape lives in [Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md), which is the one document that owns it. Nothing else in this decision depends on the spelling. +### 2026-09-01 — Amendment: the tenant-runtime sketch, corrected + +**What was wrong.** § Tenant runtime's `TenantMiddleware` sketch calls +`_tenantContextAccessor.SetTenant(tenantId.Value, organizationId: null, userId: null)`, +treats `ResolveAsync`'s result as a nullable scalar, and reads +`context.Request.Host.Host` directly. All three were false when they entered the record +on 2026-05-18. + +**How it was shown.** `ITenantContextAccessor` has never had a `SetTenant` member — +`backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs` declares +`ITenantContext? Current { get; set; }` and nothing else, the shape +[ADR-0032 § Sub-decision 10](0032-exception-handling-logging-and-observability.md) had +already decided two days before this ADR; `grep -rn SetTenant backend/src` returns only +the unrelated `IUnitOfWork.SetTenantContextAsync`. The resolver returns +`HostResolution(TenantId, OrganizationId?)` +([architecture/27](../architecture/27-custom-domain-tls.md)), so a `.Value` on it does not +compile and a hard-coded `organizationId: null` contradicts +[ADR-0036](0036-tenant-resolution-trusted-inputs.md)'s rule that the anonymous +organization scope is the host-mapping row. And the direct `Request.Host` read is what +`Effective_Host_Computed_In_One_Place` — a Roslyn analyzer shipped in Packet 4 — now +fails the build for. + +**Every carrier changed.** This ADR (the inline erratum in § Tenant runtime and this +amendment). [ADR-0036 Amendment 2](0036-tenant-resolution-trusted-inputs.md) corrected the +`SetTenant` naming in its own body, the architecture-tests catalogue and +architecture/09; its carrier list did not reach this file, and this amendment closes that +gap rather than reopening it. The mechanism's live description is +[architecture/27 § Tenant runtime](../architecture/27-custom-domain-tls.md). + +**The Decision is unchanged.** A custom domain resolves to a tenant at the edge, by host, +before any tenant context exists; certificate material moves by secret-store replication +and is referenced by path. + ## References - ADR-0014 — Adopt Dapr (CustomDomain* events via Dapr pub/sub). diff --git a/docs/decisions/0032-exception-handling-logging-and-observability.md b/docs/decisions/0032-exception-handling-logging-and-observability.md index 491b760a..8dc4b260 100644 --- a/docs/decisions/0032-exception-handling-logging-and-observability.md +++ b/docs/decisions/0032-exception-handling-logging-and-observability.md @@ -37,9 +37,13 @@ so the accessor is the whole of it. **Every carrier changed.** This amendment. The two "request-scoped" phrasings in § Sub-decision 10 and its code-block commentary stand as written, read against this -amendment; the corpus's live descriptions — `CrossCuttingFoundationExtensions`, -[Security Standards § Tenant Context](../standards/11-security.md) and the Phase 02a -roadmap — say transient. +amendment. The carriers that state the lifetime and state it correctly are +`CrossCuttingFoundationExtensions` (the registration and the comment above it), the +Phase 02a and [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) roadmaps, and the +glossary's `ITenantContextAccessor` entry. Not +[Security Standards § Tenant Context](../standards/11-security.md): that section declares +itself the authority for session-variable **placement** only, and a container lifetime is +not a `SET LOCAL` concern. ### Amendment 2 — Three corrections from the 2026-08-08 restructure diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index f0068cb7..f3670010 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -69,6 +69,14 @@ Every module `DbContext` resolved in that scope is constructed against that same connection and enlisted in that same transaction; `IAuditStore` and `IOutbox` reach the same connection through the same seam. +> **Erratum — 2026-09-01.** The `SetTenantContextAsync` doc-comment in the sketch below +> reads "Issues SET LOCAL app.tenant_id / app.organization_id / app.scope". It issues the +> first two and not `app.scope`: `ITenantContext` carries no scope member, so the method +> has nothing to read one from — which Amendment 1 of this ADR already records, making the +> sketch inconsistent with its own document. `app.scope` has no carrier anywhere; see +> § Amendment 1. The Decision is unchanged. Current authority: +> [Security Standards § Tenant Context](../standards/11-security.md). + ```csharp // LearnStack.SharedKernel.Persistence public interface IUnitOfWork : IAsyncDisposable diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index fe38e6c8..2ec3c7f9 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -305,10 +305,11 @@ request and are the only Tenancy work an anonymous visitor pays for. hatch on `tenant_settings` is unreachable at runtime. That is the correct default, and no carrier ships in [Packet 7](../../roadmap/phase-02a-kernel-tenancy.md): the flag derives from the - actor's role and roles - arrive with authentication in - [Phase 02b](../../roadmap/phase-02b-events-auth.md), so the deferral is forced, - not chosen ([Security Standards § Tenant Context](../../standards/11-security.md)). + actor's role, and roles land with `Membership` / `Role` in + [Phase 03](../../roadmap/phase-03-identity-admin.md) — after + [Phase 02b](../../roadmap/phase-02b-events-auth.md)'s authenticated principal, which is + the prerequisite and not the carrier. The deferral is forced, not chosen + ([Security Standards § Tenant Context](../../standards/11-security.md)). The two `AS RESTRICTIVE` write guards are tested **now** rather than then — `TheTenantScopeHatchWidensReadsAndNeitherWrite` sets the flag directly — because under any ordinary organization-scoped session the base policy's own diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 2925c195..1757921a 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -1370,7 +1370,7 @@ Three ADRs targeted Phase 02a as exit blockers; all three are now Accepted | [ADR-0024](../decisions/0024-api-versioning-policy.md) | API versioning policy | **Accepted** (2026-05-20) | URL `/v{N}/`, 6-month deprecation window, RFC 8594 `Sunset` + `Deprecation` headers, OpenAPI `x-sunset` extensions | | [ADR-0028](../decisions/0028-audit-log-partition-management.md) | `audit_log` monthly partition management | **Accepted** (2026-05-20) | Daily Hangfire recurring job (`learnstack:audit:partition-management`); no `pg_partman` runtime dependency. Its *implementation* moves to [Phase 11](phase-11-production-hardening.md) per [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) — the decision stands, the schedule changed | -Six further decisions were taken during the phase and are Accepted: +Seven further decisions were taken during the phase and are Accepted: | # | Topic | Status | Decision | |---|---|---|---| @@ -1382,6 +1382,7 @@ Six further decisions were taken during the phase and are Accepted: | [ADR-0037](../decisions/0037-idempotency-key-contract.md) | What an idempotency key identifies, owns and replays | **Accepted** (2026-08-20) | A key is a **nonce inside a tenant's key space**, not an identity; a fingerprint decides whether a replay answers the question asked; a fencing token owns the claim; capacity is admission, not eviction | | [ADR-0039](../decisions/0039-optimistic-concurrency-token.md) | The optimistic concurrency token | **Accepted** (2026-08-27) | An explicit `row_version bigint` (CLR `long`) project-wide, incremented by the primitive that stamps the audit columns; `xmin` rejected because the token is client-visible through ETag and a restore or replication cutover changes it | | [ADR-0040](../decisions/0040-ambient-unit-of-work.md) | The ambient unit of work | **Accepted** (2026-08-27) | One `DbConnection` per scope owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local. Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | +| [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) | Tenant provisioning as a bounded cross-aggregate transaction | **Accepted** (2026-09-01) | A standing exception to § Aggregate Ownership, bounded by **enumeration**: provisioning writes `Tenant` and its default `Organization` in one transaction, because `tenants.default_organization_id` carries an invariant an integration event cannot deliver. One operation, an allow-list of one, no child entity, no projection, no cross-**module** write | The remaining exit gates (tenant + organization resolution, isolation tests running as `learnstack_app`, the durable audit pipeline, customization runtime read paths, API diff --git a/docs/roadmap/phase-03-identity-admin.md b/docs/roadmap/phase-03-identity-admin.md index 367b54e6..ef8d29f7 100644 --- a/docs/roadmap/phase-03-identity-admin.md +++ b/docs/roadmap/phase-03-identity-admin.md @@ -236,6 +236,18 @@ refresh token storage, or brute-force protection — those are Keycloak responsi deny, per [ADR-0032 § Sub-decision 2](../decisions/0032-exception-handling-logging-and-observability.md). +- **Owns the `app.scope` carrier**, parked here by Phase 02a Packet 7. The + cross-organization read hatch in the canonical Row Level Security policy is reachable + only when a session sets `app.scope = 'tenant'`, and that flag derives from the actor's + **role** plus a declared tenant-wide operation — never from a header, query parameter, + cookie or body, and unreachable under `TenantContextOrigin.HostOnly` + ([ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)). `Membership` and + `Role` land in this phase, so this is the earliest phase that can supply the derivation; + [Phase 02b](phase-02b-events-auth.md)'s authenticated principal is the prerequisite, not + the carrier. Placement is unchanged and already fixed: + [Security Standards § Tenant Context](../standards/11-security.md). + `Tenant_Scope_Widening_Is_Never_Set_From_Request_Input` becomes non-vacuous here. + Authorization is the third layer, not the first. A permission check that passes still runs under the tenant's `ITenantContext` and under Row Level Security; a deny is a better error message, not the isolation boundary. diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index dcc328a3..d99ba3dc 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -266,9 +266,11 @@ Rules: `TransactionBehavior` in the general case, and by each of the out-of-band setters on the transaction it opens ([Security Standards § The out-of-band setters](11-security.md)). `app.scope` has **no carrier**: it derives from the actor's role plus a declared - tenant-wide operation, roles arrive with authentication in - [Phase 02b](../roadmap/phase-02b-events-auth.md), and until then the tenant-scope read - hatch is unreachable — the correct default. Its placement rule is unchanged and applies + tenant-wide operation, and roles land with `Membership` / `Role` in + [Phase 03](../roadmap/phase-03-identity-admin.md) — after + [Phase 02b](../roadmap/phase-02b-events-auth.md)'s authenticated principal, which is the + prerequisite and not the carrier. Until then the tenant-scope read hatch is unreachable + — the correct default. Its placement rule is unchanged and applies the moment a carrier exists. `app.resolving_host` is set by `CachedHostToTenantResolver` alone, in its own short read-only transaction, and is read by exactly one policy — see § Table classes. Always call `current_setting` with diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 47ee1cdd..fc2a1636 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -189,7 +189,11 @@ Rules: - **Infrastructure adapters:** ≥ 70% line. - **UI components:** behavior coverage, not lines. -Coverage is reported in CI but does not block PRs by itself. The architecture + isolation + contract tests are the hard gates. +**No CI job collects coverage today**, so these numbers are a local target and gate +nothing. The architecture, isolation and contract suites are the hard gates. Collection +lands with the release pipeline in +[Phase 11](../roadmap/phase-11-production-hardening.md); until it does, a statement that +coverage "blocks" anything is false. ## Test Speed diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 157ac2b7..fb7369f9 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -261,9 +261,12 @@ consequences follow, and both are load-bearing: [Packet 7](../roadmap/phase-02a-kernel-tenancy.md) ships nothing that does: `ITenantContext` carries no scope member ([ADR-0040 Amendment 1](../decisions/0040-ambient-unit-of-work.md)), and the flag -derives from the actor's role plus a declared tenant-wide operation, so the earliest -carrier arrives with authentication in -[Phase 02b](../roadmap/phase-02b-events-auth.md). The deferral is forced, not chosen. +derives from the actor's role plus a declared tenant-wide operation, and **the role is +the part that does not exist yet**. [Phase 02b](../roadmap/phase-02b-events-auth.md) +delivers the authenticated principal the carrier needs and says in its own scope that it +delivers "only the authentication plumbing"; `Membership`, `Role` and `Permission` land in +[Phase 03](../roadmap/phase-03-identity-admin.md), which is therefore the earliest phase +that can own a working carrier. The deferral is forced, not chosen. Until it lifts, the cross-organization read hatch in the policy template is unreachable at runtime — the hatch term reads an unset variable and is never true, so reads stay inside the caller's organization plus the tenant-wide rows — which is the correct default. The diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index cf0d5129..64c39c03 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -2051,10 +2051,13 @@ structural test proves — and what it does not. - **Status:** **Registered.** - **Phase:** 02a Packet 7. - **Note:** no `app.scope` carrier ships in Packet 7. `ITenantContext` exposes no scope - member and the flag derives from the actor's role, so the earliest carrier arrives with - authentication in [Phase 02b](../roadmap/phase-02b-events-auth.md) + member and the flag derives from the actor's **role**, which lands with `Membership` / + `Role` in [Phase 03](../roadmap/phase-03-identity-admin.md) — after + [Phase 02b](../roadmap/phase-02b-events-auth.md)'s authenticated principal, which is the + prerequisite and not the carrier ([11-security.md § Tenant Context](11-security.md)). The rule holds as a negative until - then — nothing sets the flag, so nothing sets it from request input. + then — nothing sets the flag, so nothing sets it from request input — and becomes + non-vacuous in Phase 03. #### `PlatformAdminScope_Entry_Requires_Platform_Permission` From 54a275e015551a75c1b21d65f51998774efb6b70 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 16:50:05 +0300 Subject: [PATCH 04/55] feat(kernel): type the tenant context's identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ITenantContext and CapturedContext carried raw Guid / Guid?. Packet 6 created TenantId and OrganizationId as Vogen value objects; this converts both contracts in one pass, because a half-typed intermediate is a state where a call site can pass a tenant id where an organization id belongs and the compiler agrees. The delicate part is not the type change, which the compiler drives. It is that every serialization site kept compiling while its meaning moved. Measured on Vogen 7: an uninitialized id's ToString() returns the literal "[UNINITIALIZED]", while string interpolation of the same value returns "". Two spellings of "print this id" disagree, and one of them was on the path to set_config('app.tenant_id'), where PostgreSQL casts it with ::uuid and raises 22P02 on the first policy evaluation instead of filtering. So every emission site now reads .Value under an IsInitialized() gate — the idiom the UserId branches already used — which is also what keeps the exported wire format byte-identical: span tags, Serilog properties, Sentry tags, the local-file JSON envelope and the idempotency fingerprint all still carry a bare GUID string. Two of those were unconstrained by any test and are now asserted rather than assumed: the JSON envelope's shape, and the fail-closed empty string. The second was mutation-checked — with the guard removed the new case fails with 'found "[UNINITIALIZED]"', which is the exact fault it exists to catch. IIdempotencyStore's (Guid, string) key space is ADR-0037's and is not part of this conversion, so the underlying value crosses that seam once, at a single site, rather than at each of its five call sites. 944 green, zero skips. Co-Authored-By: Claude Opus 5 (1M context) --- .../Common/LearnStackExceptionHandler.cs | 9 ++- .../Idempotency/IdempotentAttribute.cs | 19 ++++- .../Tenancy/TenantAssertionMiddleware.cs | 18 ++++- .../Pipeline/LoggingBehavior.cs | 10 ++- .../SentryErrorTracker.cs | 17 ++-- .../Serilog/CorrelationContextEnricher.cs | 8 +- .../TenantContextSpanProcessor.cs | 11 ++- .../Persistence/NpgsqlUnitOfWork.cs | 23 +++++- .../Observability/IErrorTrackingProvider.cs | 8 +- .../Tenancy/EventTenantContext.cs | 14 ++-- .../Tenancy/ITenantContext.cs | 16 +++- .../Tenancy/UnresolvedTenantContext.cs | 4 +- .../CrossCuttingFoundationHttpTests.cs | 4 +- .../Database/UnitOfWorkTests.cs | 80 ++++++++++++++++++- .../DeploymentModeCompositionTests.cs | 7 +- .../IdempotencyHttpTests.cs | 7 +- .../TenantAssertionHttpTests.cs | 8 +- .../CrossCutting/UnassignedActorIdTests.cs | 6 +- .../LocalFileErrorTrackerTests.cs | 22 ++++- .../Messaging/InProcessEventBusTests.cs | 10 +-- .../TenantContextSpanProcessorTests.cs | 8 +- .../IntegrationEventContractTests.cs | 5 +- 22 files changed, 247 insertions(+), 67 deletions(-) diff --git a/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs index 52fb4971..b668c385 100644 --- a/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs +++ b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs @@ -158,10 +158,13 @@ private CapturedContext BuildCapturedContext(HttpContext httpContext) RequestMethod: httpContext.Request.Method, TenantId: context?.IsResolved == true ? context.TenantId : null, OrganizationId: context?.OrganizationId, - // IsInitialized() before Value: this builds context while already - // handling an exception, so a throw here loses the original one. + // IsInitialized() before it is carried: a UserId? being non-null + // says a UserId struct is there, not that it was ever assigned one, + // and every downstream reader of this record reaches for Value. + // This builds context while already handling an exception, so a + // throw further down the pipe would lose the original one. UserId: context?.UserId is { } userId && userId.IsInitialized() - ? userId.Value + ? userId : null, ModuleName: context?.ModuleName, AdditionalTags: null); diff --git a/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs b/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs index ddffe8f1..79f2f598 100644 --- a/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs +++ b/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs @@ -197,7 +197,11 @@ [new LocalizedMessage("lockey_idempotency_key_invalid")], return; } - var tenantId = tenantContext.TenantId; + // Value once, here: IIdempotencyStore's key space is (Guid, string) and + // that port is not part of this conversion, so the seam is crossed at a + // single site rather than at each of its five call sites. Safe — the + // IsResolved gate above has already returned. + var tenantId = tenantContext.TenantId.Value; var cancellationToken = context.HttpContext.RequestAborted; var fingerprint = await ComputeFingerprintAsync(context.HttpContext, cancellationToken) .ConfigureAwait(false); @@ -422,8 +426,17 @@ private async Task ComputeFingerprintAsync( { using var digest = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - Append(digest, tenantContext.TenantId.ToString()); - Append(digest, tenantContext.OrganizationId?.ToString() ?? string.Empty); + // Value, not the id's own ToString(). Measured: for an initialized id the + // two produce the same string, so this conversion leaves every existing + // fingerprint byte-identical — which matters, because a changed digest + // would silently invalidate every live idempotency claim. Value is what + // makes that property independent of Vogen's formatting. + Append(digest, tenantContext.TenantId.Value.ToString()); + Append( + digest, + tenantContext.OrganizationId is { } fingerprintOrganization + ? fingerprintOrganization.Value.ToString() + : string.Empty); Append(digest, tenantContext.UserId is { } user ? $"user:{user}" : "anonymous"); Append(digest, context.Request.Method); Append(digest, context.Request.Path.Value ?? string.Empty); diff --git a/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs index 4c33c461..51c93c9b 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs @@ -114,7 +114,12 @@ public async Task InvokeAsync( if (mismatch is not null) { recorder.RecordRejection(new TenantAssertionRejection( - tenantContext.TenantId, + // Value, not the id: TenantAssertionRejection carries a Guid and + // feeds it to a metric tag, so keeping the underlying value here + // holds the exported dimension byte-identical across this + // conversion. Safe unconditionally — the !IsResolved branch + // above has already returned. + tenantContext.TenantId.Value, mismatch.Value.Dimension, mismatch.Value.Asserted, context.User.Identity?.IsAuthenticated == true)); @@ -140,13 +145,20 @@ public async Task InvokeAsync( private static (TenantAssertionDimension Dimension, Guid Asserted)? Mismatch( ITenantContext tenantContext, Guid? assertedTenant, Guid? assertedOrganization) { - if (assertedTenant is { } tenant && tenant != tenantContext.TenantId) + if (assertedTenant is { } tenant && tenant != tenantContext.TenantId.Value) { return (TenantAssertionDimension.Tenant, tenant); } + // An asserted organization against an unresolved one is a mismatch, not + // a pass. The pre-conversion form was a lifted `Guid != Guid?`, which is + // true when the right side is null; spelling it out keeps that, because + // the alternative — treating "no organization resolved" as agreement — + // would let a header widen the request's scope, which is the one thing + // ADR-0036 says an assertion may never do. if (assertedOrganization is { } organization - && organization != tenantContext.OrganizationId) + && (tenantContext.OrganizationId is not { } resolvedOrganization + || organization != resolvedOrganization.Value)) { return (TenantAssertionDimension.Organization, organization); } diff --git a/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs b/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs index efeaa169..53022c4d 100644 --- a/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs @@ -85,8 +85,14 @@ public async Task Handle( return new Dictionary(StringComparer.Ordinal) { ["RequestName"] = requestName, - ["TenantId"] = context?.IsResolved == true ? context.TenantId : null, - ["OrganizationId"] = context?.OrganizationId, + // Value, so the scope carries a boxed Guid exactly as it did before + // the ids became value objects. Boxing the Vogen struct instead would + // hand Serilog something it destructures rather than renders. + ["TenantId"] = context?.IsResolved == true ? context.TenantId.Value : null, + ["OrganizationId"] = context?.OrganizationId is { } organizationId + && organizationId.IsInitialized() + ? organizationId.Value + : null, // IsInitialized() before Value: an unassigned Vogen id throws on // read, and this runs at pipeline step 2, before the handler. ["UserId"] = context?.UserId is { } userId && userId.IsInitialized() diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs index 9713cd90..b0713611 100644 --- a/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs @@ -27,19 +27,24 @@ public ValueTask CaptureAsync( SentrySdk.CaptureException(exception, scope => { - if (context.TenantId is { } tenantId) + // Value.ToString() under an IsInitialized() gate on every one of the + // three. A Vogen id's own ToString() renders "[UNINITIALIZED]" for an + // unassigned value, and these tags are what a dashboard groups by; + // reading Value without the gate throws, inside the handler that is + // already reporting someone else's exception. + if (context.TenantId is { } tenantId && tenantId.IsInitialized()) { - scope.SetTag("tenant.id", tenantId.ToString()); + scope.SetTag("tenant.id", tenantId.Value.ToString()); } - if (context.OrganizationId is { } orgId) + if (context.OrganizationId is { } orgId && orgId.IsInitialized()) { - scope.SetTag("organization.id", orgId.ToString()); + scope.SetTag("organization.id", orgId.Value.ToString()); } - if (context.UserId is { } userId) + if (context.UserId is { } userId && userId.IsInitialized()) { - scope.User = new SentryUser { Id = userId.ToString() }; + scope.User = new SentryUser { Id = userId.Value.ToString() }; } if (!string.IsNullOrWhiteSpace(context.CorrelationId)) diff --git a/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs b/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs index 7404b2f7..be216ecf 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs @@ -35,13 +35,15 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) if (context.IsResolved) { + // Value.ToString() — see TenantContextSpanProcessor for why the id's + // own ToString() is not a wire format. logEvent.AddOrUpdateProperty( - propertyFactory.CreateProperty("tenant.id", context.TenantId.ToString())); + propertyFactory.CreateProperty("tenant.id", context.TenantId.Value.ToString())); - if (context.OrganizationId is { } orgId) + if (context.OrganizationId is { } orgId && orgId.IsInitialized()) { logEvent.AddOrUpdateProperty( - propertyFactory.CreateProperty("organization.id", orgId.ToString())); + propertyFactory.CreateProperty("organization.id", orgId.Value.ToString())); } // IsInitialized() before Value - see TenantContextSpanProcessor; diff --git a/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs index ae5fc013..1ace8623 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs @@ -44,10 +44,15 @@ public override void OnStart(Activity data) // with no contract on format (some exporters use "D", others // "N"). Pin the wire format here for parity with // SentryErrorTracker and Loki dashboards. - data.SetTag("tenant.id", context.TenantId.ToString()); - if (context.OrganizationId is { } orgId) + // Value.ToString(), not the id's own ToString(). Same reason the + // UserId branch below already reads Value: measured on Vogen 7, an + // uninitialized id's ToString() is the literal "[UNINITIALIZED]" + // while interpolating the same value gives "" — so the id's own + // formatting is not a wire format, and this tag is one. + data.SetTag("tenant.id", context.TenantId.Value.ToString()); + if (context.OrganizationId is { } orgId && orgId.IsInitialized()) { - data.SetTag("organization.id", orgId.ToString()); + data.SetTag("organization.id", orgId.Value.ToString()); } // IsInitialized() before Value: UserId? being non-null says a diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index b94d7ce2..7cb1b3c0 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -157,14 +157,31 @@ public async Task SetTenantContextAsync( // so '' is NULL is fail-closed — and setting it explicitly is what makes // a value left behind on a pooled connection by anything session-scoped // unreachable, rather than merely unlikely. + // + // Value, under an IsInitialized() gate — never ToString() on the id. This + // is the one place where the difference is a fault rather than a + // cosmetic. Measured on Vogen 7: an uninitialized id's ToString() returns + // the literal "[UNINITIALIZED]" while string interpolation of the same + // value returns "", so the two spellings of "print this id" disagree, and + // the first reaches PostgreSQL as '[UNINITIALIZED]'::uuid — which raises + // 22P02 on the first policy evaluation rather than filtering. Reading + // Value on an uninitialized id throws instead, which is why the gate is + // IsInitialized() and not a null check: ITenantContext's contract says + // IsResolved implies initialized, and this is the boundary that does not + // take the contract's word for it. await ExecuteAsync( "SELECT set_config('app.tenant_id', @tenant, true), " + "set_config('app.organization_id', @organization, true)", cancellationToken, - ("tenant", context.IsResolved ? context.TenantId.ToString() : string.Empty), + ("tenant", + context.IsResolved && context.TenantId.IsInitialized() + ? context.TenantId.Value.ToString() + : string.Empty), ("organization", - context.IsResolved && context.OrganizationId is { } organization - ? organization.ToString() + context.IsResolved + && context.OrganizationId is { } organization + && organization.IsInitialized() + ? organization.Value.ToString() : string.Empty)); } diff --git a/backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs b/backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs index 0837d375..fe8e23de 100644 --- a/backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs +++ b/backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs @@ -1,3 +1,5 @@ +using LearnStack.SharedKernel.Identifiers; + namespace LearnStack.SharedKernel.Observability; /// @@ -35,8 +37,8 @@ public sealed record CapturedContext( string? CorrelationId, string? RequestPath, string? RequestMethod, - Guid? TenantId, - Guid? OrganizationId, - Guid? UserId, + TenantId? TenantId, + OrganizationId? OrganizationId, + UserId? UserId, string? ModuleName, IReadOnlyDictionary? AdditionalTags = null); diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs index f692ff86..525ae71a 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs @@ -18,8 +18,8 @@ namespace LearnStack.SharedKernel.Tenancy; public sealed class EventTenantContext : ITenantContext { private EventTenantContext( - Guid tenantId, - Guid? organizationId, + TenantId tenantId, + OrganizationId? organizationId, UserId? causalActorUserId, string? correlationId, string? moduleName) @@ -36,7 +36,7 @@ private EventTenantContext( public bool IsResolved => true; /// - public Guid TenantId { get; } + public TenantId TenantId { get; } /// /// The organization the fact belongs to, when the envelope names one. @@ -54,7 +54,7 @@ private EventTenantContext( /// WITH CHECK rejects writing one. Widening is the /// app.scope = 'tenant' hatch, not an absent value. /// - public Guid? OrganizationId { get; } + public OrganizationId? OrganizationId { get; } /// Who the consumer's writes are attributed to. /// @@ -97,8 +97,10 @@ public static EventTenantContext FromEnvelope( } return new EventTenantContext( - envelope.Event.TenantId, - envelope.OrganizationId, + Identifiers.TenantId.From(envelope.Event.TenantId), + envelope.OrganizationId is { } organization + ? Identifiers.OrganizationId.From(organization) + : null, envelope.ActorUserId, envelope.CorrelationId, moduleName); diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs index c593bcee..2df87128 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs @@ -32,14 +32,26 @@ public interface ITenantContext /// — callers gate on /// first. /// - Guid TenantId { get; } + /// + /// implies this is initialized. Every + /// implementation holds that invariant, and it is what lets a caller write + /// TenantId.Value under an gate — reading + /// Value on an uninitialized Vogen id throws + /// ValueObjectValidationException. Do not reach for + /// ToString() as a substitute: measured on Vogen 7, an uninitialized + /// id's ToString() returns the literal "[UNINITIALIZED]" while + /// string interpolation of the same value returns the empty string, so the + /// two disagree and one of them reaches PostgreSQL as + /// '[UNINITIALIZED]'::uuid, which raises. + /// + TenantId TenantId { get; } /// /// The resolved organization within the tenant, when the request targets /// an [OrganizationScoped] resource. null for tenant-wide /// requests. /// - Guid? OrganizationId { get; } + OrganizationId? OrganizationId { get; } /// /// The effective actor. Authenticated requests carry their user, anonymous diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs index a7ac8049..1dbd4828 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs @@ -22,10 +22,10 @@ public sealed class UnresolvedTenantContext : ITenantContext public bool IsResolved => false; - public Guid TenantId => throw new InvalidOperationException( + public TenantId TenantId => throw new InvalidOperationException( "TenantId is not available on an unresolved tenant context. Gate reads on IsResolved."); - public Guid? OrganizationId => null; + public OrganizationId? OrganizationId => null; public UserId? UserId => null; diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index 2e56299b..6170120c 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -316,8 +316,8 @@ internal sealed class TestResolvedTenantContext : ITenantContext public static TestResolvedTenantContext Instance { get; } = new(); public bool IsResolved => true; - public Guid TenantId { get; } = Guid.Parse("018f4d40-0000-7000-8000-000000000001"); - public Guid? OrganizationId { get; } + public TenantId TenantId { get; } = TenantId.From(Guid.Parse("018f4d40-0000-7000-8000-000000000001")); + public OrganizationId? OrganizationId { get; } public UserId? UserId { get; } public string? CorrelationId => null; public string? ModuleName => "integration-test"; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 949481e6..18a4a2ee 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -66,6 +66,44 @@ await unitOfWork.SetTenantContextAsync( await unitOfWork.RollbackAsync(); } + [Fact] + [Trait(RequiresDocker.Key, RequiresDocker.Value)] + public async Task A_resolved_context_holding_an_uninitialized_id_still_writes_the_empty_string() + { + // The failure this exists to prevent is specific and was measured, not + // imagined. Vogen 7 gives an uninitialized id two different textual + // forms: ToString() returns the literal "[UNINITIALIZED]", while string + // interpolation of the same value returns "". So a setter written the + // obvious way — context.TenantId.ToString() — sends + // '[UNINITIALIZED]' into app.tenant_id, and the first policy predicate + // that evaluates it raises 22P02 instead of filtering. Reading .Value + // without a gate throws instead. Both are worse than the fail-closed + // empty string, and only an IsInitialized() gate produces it. + // + // IsResolved is deliberately true here: an implementation is *supposed* + // to hold "IsResolved implies initialized", and this asserts that the + // one boundary where being wrong is a security fault does not take that + // promise on trust. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(new UninitializedIdContext()); + + (await ReadAsync(unitOfWork, "SELECT current_setting('app.tenant_id', true)")) + .Should().BeEmpty("an uninitialized id must fail closed, not reach ::uuid"); + (await ReadAsync(unitOfWork, "SELECT current_setting('app.organization_id', true)")) + .Should().BeEmpty(); + + // And the fail-closed value is usable: the policy filters rather than + // raising, which is the whole point of writing '' over the alternative. + (await ReadAsync(unitOfWork, "SELECT count(*)::text FROM organizations")) + .Should().Be("0"); + + await unitOfWork.RollbackAsync(); + } + [Fact] public async Task An_unresolved_context_leaves_every_tenant_owned_table_empty() { @@ -743,6 +781,40 @@ private static async Task ExecuteAsync( private static StubTenantContext Resolved(Guid tenant, Guid organization) => new(tenant, organization); + /// + /// Claims to be resolved while carrying ids nothing ever assigned. + /// + /// + /// default(TenantId) does not compile — Vogen's VOG009 analyzer + /// prohibits it — so the uninitialized value comes from an array element, + /// which the analyzer cannot see and the runtime leaves zeroed. That is also + /// how one reaches production: a struct field nobody assigned, a + /// default(T) in a generic, a deserializer that skipped a member. + /// + private sealed class UninitializedIdContext : ITenantContext + { + private static readonly TenantId Unassigned = Zeroed(); + private static readonly OrganizationId UnassignedOrganization = Zeroed(); + + private static T Zeroed() + { + var slot = new T[1]; + return slot[0]; + } + + public bool IsResolved => true; + + public TenantId TenantId => Unassigned; + + public OrganizationId? OrganizationId => UnassignedOrganization; + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => "uninitialized-id-probe"; + } + /// A request type for driving the real behavior. public sealed record Probe : MediatR.IRequest>; @@ -752,11 +824,15 @@ public sealed record Probe : MediatR.IRequest>; /// private sealed class StubTenantContext(Guid tenant, Guid organization) : ITenantContext { + // Converted here rather than at each call site: the stub's callers hold + // raw fixture Guids and the contract holds typed ids. + public bool IsResolved => true; - public Guid TenantId => tenant; + public TenantId TenantId => SharedKernel.Identifiers.TenantId.From(tenant); - public Guid? OrganizationId => organization; + public OrganizationId? OrganizationId => + SharedKernel.Identifiers.OrganizationId.From(organization); public UserId? UserId => null; diff --git a/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs b/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs index ba83679c..e6a9efaf 100644 --- a/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs @@ -3,6 +3,7 @@ using LearnStack.Infrastructure.Messaging; using LearnStack.SharedKernel.Caching; using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Observability; using LearnStack.SharedKernel.Secrets; @@ -133,9 +134,9 @@ private static WebApplicationFactory For(string mode) => private sealed class ResolvedContext(Guid tenantId) : ITenantContext { public bool IsResolved => true; - public Guid TenantId { get; } = tenantId; - public Guid? OrganizationId => null; - public LearnStack.SharedKernel.Identifiers.UserId? UserId => null; + public TenantId TenantId { get; } = TenantId.From(tenantId); + public OrganizationId? OrganizationId => null; + public UserId? UserId => null; public string? CorrelationId => null; public string? ModuleName => "integration-test"; } diff --git a/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs index c71ca8ee..b0baf7b1 100644 --- a/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs @@ -556,10 +556,11 @@ internal sealed class HeaderTenantContext(IHttpContextAccessor accessor) : ITena { public bool IsResolved => Read(TenantHeader) is not null; - public Guid TenantId => Read(TenantHeader) - ?? throw new InvalidOperationException("No tenant on this request."); + public TenantId TenantId => Read(TenantHeader) is { } tenant + ? SharedKernel.Identifiers.TenantId.From(tenant) + : throw new InvalidOperationException("No tenant on this request."); - public Guid? OrganizationId => null; + public OrganizationId? OrganizationId => null; public UserId? UserId => Read(UserHeader) is { } id ? SharedKernel.Identifiers.UserId.From(id) diff --git a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs index dffe3041..1330ccf2 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs @@ -266,8 +266,12 @@ internal sealed class ResolvedContext : ITenantContext public static ResolvedContext Instance { get; } = new(); public bool IsResolved => true; - public Guid TenantId => ResolvedTenantFixture.TenantId; - public Guid? OrganizationId => ResolvedTenantFixture.OrganizationId; + // Fully qualified: each property's own name shadows its type here. + public TenantId TenantId => + SharedKernel.Identifiers.TenantId.From(ResolvedTenantFixture.TenantId); + + public OrganizationId? OrganizationId => + SharedKernel.Identifiers.OrganizationId.From(ResolvedTenantFixture.OrganizationId); public UserId? UserId => null; public string? CorrelationId => null; public string? ModuleName => "integration-test"; diff --git a/backend/tests/LearnStack.Tests.Unit/CrossCutting/UnassignedActorIdTests.cs b/backend/tests/LearnStack.Tests.Unit/CrossCutting/UnassignedActorIdTests.cs index 559bd4f4..c83194ae 100644 --- a/backend/tests/LearnStack.Tests.Unit/CrossCutting/UnassignedActorIdTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/CrossCutting/UnassignedActorIdTests.cs @@ -49,7 +49,7 @@ public sealed class UnassignedActorIdTests { UserId = Command.ActorId, IsResolved = true, - TenantId = Guid.Parse("018f4d40-1234-7000-8000-000000000001"), + TenantId = TenantId.From(Guid.Parse("018f4d40-1234-7000-8000-000000000001")), OrganizationId = null, CorrelationId = "00-aabbccdd-eeff0011-01", ModuleName = "education", @@ -151,9 +151,9 @@ private sealed record TestTenantContext : ITenantContext { public bool IsResolved { get; init; } - public Guid TenantId { get; init; } + public TenantId TenantId { get; init; } - public Guid? OrganizationId { get; init; } + public OrganizationId? OrganizationId { get; init; } public UserId? UserId { get; init; } diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs index 43753d05..201824cb 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using FluentAssertions; using LearnStack.Infrastructure.ErrorTracking; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Observability; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -33,9 +34,9 @@ public async Task CaptureAsync_Writes_JsonEnvelope_To_ConfiguredDirectory() CorrelationId: "00-aabb-ccdd-01", RequestPath: "/v1/courses", RequestMethod: "POST", - TenantId: Guid.NewGuid(), + TenantId: TenantId.From(TenantGuid), OrganizationId: null, - UserId: null, + UserId: UserId.From(UserGuid), ModuleName: "education"); await sut.CaptureAsync(new InvalidOperationException("boom"), context); @@ -49,8 +50,25 @@ public async Task CaptureAsync_Writes_JsonEnvelope_To_ConfiguredDirectory() doc.RootElement.GetProperty("RequestPath").GetString().Should().Be("/v1/courses"); doc.RootElement.GetProperty("exception").GetProperty("type").GetString() .Should().Be(typeof(InvalidOperationException).FullName); + + // The envelope is a wire format an operator greps, and the ids on it + // became value objects rather than raw Guids. Vogen's System.Text.Json + // converter unwraps them to the bare value, so this file is byte-identical + // to what it held before — asserted rather than assumed, because a + // converter that ever emitted {"Value":"..."} would break every existing + // query against these files and no other test would notice. + doc.RootElement.GetProperty("TenantId").GetString().Should().Be(TenantGuid.ToString()); + doc.RootElement.GetProperty("UserId").GetString().Should().Be(UserGuid.ToString()); + doc.RootElement.GetProperty("OrganizationId").ValueKind + .Should().Be(JsonValueKind.Null, "a tenant-wide capture carries no organization"); } + private static readonly Guid TenantGuid = + Guid.Parse("018f4d40-1234-7000-8000-0000000000a1"); + + private static readonly Guid UserGuid = + Guid.Parse("018f4d40-1234-7000-8000-0000000000a2"); + public void Dispose() { if (Directory.Exists(_directory)) diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs index d79dd21b..af23b514 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs @@ -807,7 +807,7 @@ public sealed class TenantReadingHandler(Recorder recorder, ITenantContextAccess { public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) { - recorder.Tenants.Enqueue(accessor.Current!.TenantId); + recorder.Tenants.Enqueue(accessor.Current!.TenantId.Value); return Task.CompletedTask; } } @@ -817,7 +817,7 @@ public sealed class ScopedContextReadingHandler(Recorder recorder, ITenantContex { public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) { - recorder.Tenants.Enqueue(context.TenantId); + recorder.Tenants.Enqueue(context.TenantId.Value); return Task.CompletedTask; } } @@ -836,7 +836,7 @@ public Task HandleAsync(Thing @event, CancellationToken cancellationToken = defa if (context.OrganizationId is { } organization) { - recorder.Organizations.Enqueue(organization); + recorder.Organizations.Enqueue(organization.Value); } return Task.CompletedTask; @@ -894,7 +894,7 @@ public sealed class LateTenantReadingHandler(Recorder recorder, ITenantContextAc public async Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) { await Task.Delay(TimeSpan.FromMilliseconds(20), CancellationToken.None); - recorder.Tenants.Enqueue(accessor.Current!.TenantId); + recorder.Tenants.Enqueue(accessor.Current!.TenantId.Value); } } @@ -939,7 +939,7 @@ public TenantCapturingHandler(Recorder recorder, ITenantContext context) _recorder = recorder; // Captured at CONSTRUCTION, which is the point. - recorder.Tenants.Enqueue(context.TenantId); + recorder.Tenants.Enqueue(context.TenantId.Value); } public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs index f5219dc5..970f635a 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs @@ -39,8 +39,8 @@ public void OnStart_Enriches_Activity_When_Context_Is_Resolved() var accessor = new TestAccessor(new TestTenantContext( IsResolved: true, - TenantId: tenantId, - OrganizationId: organizationId, + TenantId: TenantId.From(tenantId), + OrganizationId: OrganizationId.From(organizationId), UserId: userId, CorrelationId: "00-aabbccdd-eeff0011-01", ModuleName: "education")); @@ -83,8 +83,8 @@ private sealed class TestAccessor(ITenantContext? current) : ITenantContextAcces private sealed record TestTenantContext( bool IsResolved, - Guid TenantId, - Guid? OrganizationId, + TenantId TenantId, + OrganizationId? OrganizationId, UserId? UserId, string? CorrelationId, string? ModuleName) : ITenantContext; diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs index 79cdbe2a..cf328c49 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs @@ -220,8 +220,9 @@ public void The_Consumer_Context_Has_The_Shape_A_Handler_Needs() // consumer that sends a MediatR command — silently, before its business // logic ran. context.IsResolved.Should().BeTrue(); - context.TenantId.Should().Be(Tenant); - context.OrganizationId.Should().Be(organization); + context.TenantId.Should().Be(TenantId.From(Tenant)); + context.OrganizationId.Should().Be( + organization is { } org ? OrganizationId.From(org) : null); context.UserId.Should().Be(UserId.SystemActor); context.CausalActorUserId.Should().Be(actor); context.CorrelationId.Should().Be(Trace); From a6ac128d3f547dcc3f06ce21c28e83ca41d62ee6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 17:43:10 +0300 Subject: [PATCH 05/55] fix(kernel): pin the typed-id conversion where nothing was watching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Opus round over 8043840 found one thing that mattered and proved it by mutation: the organization branch of TenantAssertionMiddleware.Mismatch had no test. Replacing its null clause with the natural-looking OrganizationId is { } r && organization != r.Value leaves all 944 tests green while turning an organization asserted against a tenant-wide context from a 404 into a 200 — a header widening a request's scope, which is the one thing ADR-0036 says an assertion may never do. The commit spent six comment lines explaining the semantics and nothing enforced them. TenantWideFixture and its two cases now do; the mutant dies on the 404, and the companion case proves the 404 is not a mis-wired fixture. Five sites read TenantId.Value under IsResolved alone while gating their sibling ids with IsInitialized() two lines below, with no comment saying why the tenant id was different. Two of them carry "must never throw" contracts (the OTel span processor runs inside Activity.Start; a Serilog enricher that throws takes down the line it enriches) and LoggingBehavior.BuildScope runs outside any try of its own. Before the conversion these were Guid reads that could not throw. They are gated now. The unit of work refuses Guid.Empty alongside the uninitialized case, since IsInitialized() only validates the value's shape and the domain already refuses the all-zero id by hand, and it logs an Error when a context claims to be resolved and yields no usable tenant — the empty string keeps that fail-closed, but silence at that boundary is the worst diagnostic there is. The fail-closed test now seeds a session-scoped leftover before asserting, so it constrains what its name says: that the setter overwrites, not that the variable happened to be unset. Three doc samples stopped compiling when ITenantContext.TenantId stopped being a Guid, and Standards 02 gains the rule the whole conversion turns on. 946 green, zero skips. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-integration-event/SKILL.md | 2 +- .claude/skills/add-mediatr-handler/SKILL.md | 2 +- .../Common/LearnStackExceptionHandler.cs | 12 ++- .../Idempotency/IdempotentAttribute.cs | 6 ++ .../Pipeline/LoggingBehavior.cs | 13 ++- .../LocalFileErrorTracker.cs | 26 +++++- .../Serilog/CorrelationContextEnricher.cs | 11 ++- .../TenantContextSpanProcessor.cs | 12 ++- .../Persistence/NpgsqlUnitOfWork.cs | 55 ++++++++--- .../Database/UnitOfWorkTests.cs | 15 +++ .../TenantAssertionHttpTests.cs | 92 +++++++++++++++++++ docs/architecture/15-event-and-outbox.md | 3 +- docs/standards/02-backend-coding.md | 26 ++++++ 13 files changed, 249 insertions(+), 26 deletions(-) diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index 1b712579..b41dbe43 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -107,7 +107,7 @@ await outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1 { EventId = guidFactory.NewUuidV7(), // IGuidFactory, not Guid.NewGuid OccurredAt = clock.UtcNow, // IClock per Standards 02 § Time - TenantId = tenantContext.TenantId, + TenantId = tenantContext.TenantId.Value, // the envelope carries a Guid EnrollmentId = enrollment.Id.Value, LearnerId = request.LearnerId.Value, CourseVersionId = request.CourseVersionId.Value, diff --git a/.claude/skills/add-mediatr-handler/SKILL.md b/.claude/skills/add-mediatr-handler/SKILL.md index 770cb9cd..79b898f3 100644 --- a/.claude/skills/add-mediatr-handler/SKILL.md +++ b/.claude/skills/add-mediatr-handler/SKILL.md @@ -158,7 +158,7 @@ public sealed class CreateEnrollmentCommandHandler( { EventId = guidFactory.NewUuidV7(), OccurredAt = clock.UtcNow, - TenantId = tenantContext.TenantId, + TenantId = tenantContext.TenantId.Value, // the envelope carries a Guid EnrollmentId = enrollment.Id.Value, LearnerId = request.LearnerId.Value, CourseVersionId = request.CourseVersionId.Value, diff --git a/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs index b668c385..ea7a7ea3 100644 --- a/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs +++ b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs @@ -157,12 +157,18 @@ private CapturedContext BuildCapturedContext(HttpContext httpContext) RequestPath: httpContext.Request.Path.Value, RequestMethod: httpContext.Request.Method, TenantId: context?.IsResolved == true ? context.TenantId : null, - OrganizationId: context?.OrganizationId, + // Gated like the other two, because every downstream reader of this + // record reaches for Value — including the System.Text.Json converter + // that LocalFileErrorTracker serializes it through, whose throw that + // tracker's catch swallows. The envelope would be dropped to a + // Warning while the handler goes on logging the capture as a success. + OrganizationId: context?.OrganizationId is { } organization + && organization.IsInitialized() + ? organization + : null, // IsInitialized() before it is carried: a UserId? being non-null // says a UserId struct is there, not that it was ever assigned one, // and every downstream reader of this record reaches for Value. - // This builds context while already handling an exception, so a - // throw further down the pipe would lose the original one. UserId: context?.UserId is { } userId && userId.IsInitialized() ? userId : null, diff --git a/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs b/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs index 79f2f598..74124e28 100644 --- a/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs +++ b/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs @@ -432,6 +432,12 @@ private async Task ComputeFingerprintAsync( // would silently invalidate every live idempotency claim. Value is what // makes that property independent of Vogen's formatting. Append(digest, tenantContext.TenantId.Value.ToString()); + // Ungated on purpose, and the reason is the opposite of the usual one. + // Falling back to the empty string is exactly what a genuinely + // tenant-wide (null) organization contributes, so gating an + // uninitialized id into the same empty string would merge "no scope" and + // "unknown scope" into one key space and let the two replay each other's + // responses. Throwing is the fail-closed answer here. Append( digest, tenantContext.OrganizationId is { } fingerprintOrganization diff --git a/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs b/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs index 53022c4d..a134100d 100644 --- a/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs @@ -86,9 +86,16 @@ public async Task Handle( { ["RequestName"] = requestName, // Value, so the scope carries a boxed Guid exactly as it did before - // the ids became value objects. Boxing the Vogen struct instead would - // hand Serilog something it destructures rather than renders. - ["TenantId"] = context?.IsResolved == true ? context.TenantId.Value : null, + // the ids became value objects. Measured through a capturing sink on + // the real Serilog: a boxed Vogen id arrives as a ScalarValue holding + // a *string* — Serilog stringifies an unknown scalar at capture — and + // a boxed Guid as a ScalarValue holding a Guid. No destructuring + // either way, but a sink that formats by type would see the change. + // Gated, because BuildScope runs at pipeline step 2 outside any try + // of its own, so a throw here fails the request it is logging. + ["TenantId"] = context?.IsResolved == true && context.TenantId.IsInitialized() + ? context.TenantId.Value + : null, ["OrganizationId"] = context?.OrganizationId is { } organizationId && organizationId.IsInitialized() ? organizationId.Value diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs index e16c281d..191d6167 100644 --- a/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Observability; using LearnStack.SharedKernel.Secrets; using Microsoft.Extensions.Logging; @@ -70,9 +71,15 @@ public async ValueTask CaptureAsync( context.CorrelationId, context.RequestPath, context.RequestMethod, - context.TenantId, - context.OrganizationId, - context.UserId, + // Value under an IsInitialized() gate, not the id itself. Vogen's + // System.Text.Json converter reads Value, so an id that was never + // assigned makes Serialize throw — and this method's catch swallows + // it, dropping the whole envelope to a Warning while the caller logs + // the capture as a success. On SelfHostedAirGapped this file is the + // only place the error would have been recorded. + TenantId = Unwrap(context.TenantId), + OrganizationId = Unwrap(context.OrganizationId), + UserId = Unwrap(context.UserId), context.ModuleName, additionalTags = RedactSensitiveTags(context.AdditionalTags), }; @@ -109,6 +116,19 @@ await JsonSerializer.SerializeAsync(stream, envelope, SerializerOptions, cancell } } + /// + /// The underlying value, or null when the id was never assigned. + /// + /// + /// Keeps the serialized envelope byte-identical to what it held when these + /// members were Guid? — Vogen's JSON converter unwraps an initialized + /// id to the same bare value — while refusing to hand the converter an id + /// whose Value throws. + /// + private static Guid? Unwrap(TId? id) + where TId : struct, IStronglyTypedId => + id is { } value && value.IsInitialized() ? value.Value : null; + private static void DeleteBestEffort(string tempPath) { try diff --git a/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs b/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs index be216ecf..f977554b 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs @@ -37,8 +37,15 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { // Value.ToString() — see TenantContextSpanProcessor for why the id's // own ToString() is not a wire format. - logEvent.AddOrUpdateProperty( - propertyFactory.CreateProperty("tenant.id", context.TenantId.Value.ToString())); + // Gated like the two branches below — an enricher that throws takes + // down the log line it enriches, including the one reporting the + // failure that produced the bad context. + if (context.TenantId.IsInitialized()) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty( + "tenant.id", context.TenantId.Value.ToString())); + } if (context.OrganizationId is { } orgId && orgId.IsInitialized()) { diff --git a/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs index 1ace8623..795fd2ee 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs @@ -49,7 +49,17 @@ public override void OnStart(Activity data) // uninitialized id's ToString() is the literal "[UNINITIALIZED]" // while interpolating the same value gives "" — so the id's own // formatting is not a wire format, and this tag is one. - data.SetTag("tenant.id", context.TenantId.Value.ToString()); + // IsInitialized() on the tenant id too, not only on the two below + // it. This processor runs inside Activity.Start() for every span and + // must never throw; before the ids became value objects this was a + // Guid read that could not, and the asymmetry with the sibling + // branches otherwise reads as an oversight rather than as reliance + // on ITenantContext's IsResolved-implies-initialized invariant. + if (context.TenantId.IsInitialized()) + { + data.SetTag("tenant.id", context.TenantId.Value.ToString()); + } + if (context.OrganizationId is { } orgId && orgId.IsInitialized()) { data.SetTag("organization.id", orgId.Value.ToString()); diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index 7cb1b3c0..08662f98 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -1,5 +1,6 @@ using System.Data.Common; using LearnStack.SharedKernel.Persistence; +using Microsoft.Extensions.Logging; using LearnStack.SharedKernel.Tenancy; using Npgsql; @@ -38,11 +39,15 @@ namespace LearnStack.Infrastructure.Persistence; /// that a later BEGIN clears is not. /// /// -public sealed class NpgsqlUnitOfWork(NpgsqlDataSource dataSource) : IUnitOfWork +public sealed class NpgsqlUnitOfWork( + NpgsqlDataSource dataSource, ILogger logger) : IUnitOfWork { private readonly NpgsqlDataSource _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + private readonly ILogger _logger = + logger ?? throw new ArgumentNullException(nameof(logger)); + private NpgsqlConnection? _connection; private DbTransaction? _transaction; private int _depth; @@ -169,22 +174,50 @@ public async Task SetTenantContextAsync( // IsInitialized() and not a null check: ITenantContext's contract says // IsResolved implies initialized, and this is the boundary that does not // take the contract's word for it. + // Guid.Empty is refused alongside the uninitialized case, because + // IsInitialized() is only half the test: Vogen validates the *shape* of + // the value, not that it names anything, and the domain already refuses + // the all-zero id by hand (TenantOwned.EnsureRealTenant). An all-zero + // tenant would otherwise cast cleanly and match every row a bug wrote + // under it. + var tenant = context.IsResolved + && context.TenantId.IsInitialized() + && context.TenantId.Value != Guid.Empty + ? context.TenantId.Value.ToString() + : string.Empty; + + var organization = context.IsResolved + && context.OrganizationId is { } scoped + && scoped.IsInitialized() + && scoped.Value != Guid.Empty + ? scoped.Value.ToString() + : string.Empty; + + // A context that says it is resolved and yields no usable tenant is a + // bug in whoever built it. The empty string keeps the request + // fail-closed, but silence here is the worst possible diagnostic: every + // query returns nothing, attributed to nobody, and the symptom looks + // like missing data rather than a broken context. + if (context.IsResolved && tenant.Length == 0) + { + LogResolvedWithoutTenant(_logger, context.GetType().Name, null); + } + await ExecuteAsync( "SELECT set_config('app.tenant_id', @tenant, true), " + "set_config('app.organization_id', @organization, true)", cancellationToken, - ("tenant", - context.IsResolved && context.TenantId.IsInitialized() - ? context.TenantId.Value.ToString() - : string.Empty), - ("organization", - context.IsResolved - && context.OrganizationId is { } organization - && organization.IsInitialized() - ? organization.Value.ToString() - : string.Empty)); + ("tenant", tenant), + ("organization", organization)); } + private static readonly Action LogResolvedWithoutTenant = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1, nameof(LogResolvedWithoutTenant)), + "{ContextType} reports IsResolved but carries no usable tenant id. app.tenant_id " + + "was left empty, so every tenant-owned read on this transaction returns zero rows."); + public Task CommitAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 18a4a2ee..4e7236cf 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -89,6 +89,20 @@ public async Task A_resolved_context_holding_an_uninitialized_id_still_writes_th var unitOfWork = scope.ServiceProvider.GetRequiredService(); await unitOfWork.BeginTransactionAsync(); + + // Seeded session-scoped (the third argument is false), so it survives + // the transaction the setter runs in. Without this the assertion below + // passes on a connection where the variable was simply never set, which + // proves nothing about the setter: the property under test is that it + // *overwrites* a leftover with the fail-closed empty string, which is + // the case that matters on a pooled connection. + await ReadAsync( + unitOfWork, + "SELECT set_config('app.tenant_id', '018f4d40-0000-7000-8000-00000000dead', false)"); + await ReadAsync( + unitOfWork, + "SELECT set_config('app.organization_id', '018f4d40-0000-7000-8000-00000000beef', false)"); + await unitOfWork.SetTenantContextAsync(new UninitializedIdContext()); (await ReadAsync(unitOfWork, "SELECT current_setting('app.tenant_id', true)")) @@ -705,6 +719,7 @@ private ServiceProvider BuildProvider() // fixture's container is not the one appsettings names. var services = new ServiceCollection(); services.AddSingleton(NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); + services.AddLogging(); services.AddScoped(); services.AddModuleDbContext(); diff --git a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs index 1330ccf2..f03c212a 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs @@ -203,6 +203,98 @@ private static HttpRequestMessage Get(string path) => new(HttpMethod.Get, new Uri(path, UriKind.Relative)); } +/// +/// The tenant-wide branch of the organization comparison: a resolved context +/// carrying no organization, with an organization asserted at it. +/// +/// +/// +/// Its own fixture because 's context always +/// resolves an organization, so the branch this covers has no fixture there and +/// was reachable by no test. Measured: rewriting the middleware's null clause to +/// the natural-looking +/// OrganizationId is { } r && organization != r.Value leaves the entire +/// suite green while turning this case from a 404 into a 200. +/// +/// +/// What that mutant permits is the one thing +/// ADR-0036 +/// says an assertion may never do: a header would widen a tenant-wide request +/// into an organization scope the resolver never granted. The rule is that an +/// assertion can reject a request and can never fill a gap. +/// +/// +public sealed class TenantWideOrganizationAssertionTests(TenantWideFixture fixture) + : IClassFixture +{ + private readonly HttpClient _client = fixture.CreateClient(); + + [Fact] + public async Task An_Organization_Asserted_Against_A_Tenant_Wide_Context_Is_A_404() + { + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/assertionprobe", UriKind.Relative)); + request.Headers.Add( + TenantAssertionMiddleware.OrganizationHeaderName, Guid.NewGuid().ToString()); + + var response = await _client.SendAsync(request); + + response.StatusCode.Should().Be( + HttpStatusCode.NotFound, + "an assertion may reject a request and may never widen one"); + } + + [Fact] + public async Task The_Same_Request_Without_The_Header_Is_Served() + { + // The companion that stops the 404 above from passing for the wrong + // reason — a mis-wired fixture, a missing probe route, a middleware that + // refuses every tenant-wide request. + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/assertionprobe", UriKind.Relative)); + + var response = await _client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } +} + +/// +/// with the organization removed, so the +/// context is resolved and tenant-wide. +/// +public sealed class TenantWideFixture : ResolvedTenantFixture +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.AddScoped(_ => TenantWideContext.Instance); + }); + } + + private sealed class TenantWideContext : ITenantContext + { + public static TenantWideContext Instance { get; } = new(); + + public bool IsResolved => true; + + public TenantId TenantId => + SharedKernel.Identifiers.TenantId.From(ResolvedTenantFixture.TenantId); + + /// Tenant-wide: no organization, which is a scope and not "unknown". + public OrganizationId? OrganizationId => null; + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => "integration-test"; + } +} + /// /// A host whose is resolved, so the assertion /// comparison has something to compare against. diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index d02cd227..cb3c800f 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -192,7 +192,8 @@ public async Task> Handle(CreateEnrollmentCommand cmd, Can await _outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEvent { EventId = _guidFactory.NewUuidV7(), // IGuidFactory, not Guid.NewGuid - TenantId = _tenantContext.TenantId, // ITenantContext, not the accessor + TenantId = _tenantContext.TenantId.Value, // Guid on the wire; ITenantContext, + // not the accessor OccurredAt = _clock.UtcNow, // IClock per Standards 02 § Time EnrollmentId = enrollment.Id.Value, LearnerId = cmd.LearnerId, diff --git a/docs/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md index b94c5699..a09176c8 100644 --- a/docs/standards/02-backend-coding.md +++ b/docs/standards/02-backend-coding.md @@ -72,6 +72,32 @@ The same annotation covers richer value objects (`Email`, `Slug`, `LocaleCode`, `Money`) — the emitter shape is identical for IDs and value objects, with the value-object's invariant captured in a `Validate` static method. +Emission — **at an export boundary, write `id.Value`, never the id**: + +- The boundaries are: a span tag, a log property, an error-tracker tag, a SQL + parameter, a hash input, a JSON or wire payload, a job payload, and a metric + dimension. Anywhere the identifier leaves the type system, the underlying value + goes, not the wrapper. +- **A Vogen id's own formatting is not a wire format.** Measured on Vogen 7, for + an id that was never assigned: `id.ToString()` returns the literal + `"[UNINITIALIZED]"`, while `$"{id}"` — string interpolation of the same value — + returns the empty string. Two spellings of "print this id" disagree, so neither + is a contract. `id.Value.ToString()` is. +- Reading `Value` on an uninitialized id throws `ValueObjectValidationException`, + so the read is gated on `IsInitialized()` wherever the id may not have been + assigned. `default(TId)` does not compile — Vogen's `VOG009` analyzer prohibits + it — but an array element, a `default(T)` in a generic, and a member a + deserializer skipped all reach that state. +- The cost of getting it wrong is not cosmetic on every path. `'[UNINITIALIZED]'` + reaching `app.tenant_id` is cast by the Row Level Security policy as `::uuid` + and raises `22P02` on the first predicate evaluation, turning a fail-closed + empty result into a hard error — see + [Security Standards § Tenant Context](11-security.md). +- Inside the type system the opposite holds: pass the id, not its value. A domain + factory, a repository and an application contract all take `TenantId`, and + unwrapping early is how a tenant id ends up where an organization id belongs + with the compiler's blessing. + ## Nullability - `Nullable` is on. Treat warnings as errors. From 06c22aa6efe662935d2604c93c7fedc5421d6a46 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 18:19:02 +0300 Subject: [PATCH 06/55] test(kernel): kill the mutants the typed-id guards had left alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sonnet round mutation-tested the guards the previous commit added and found most of them uncovered. Stripping the IsInitialized() check from the span processor, the Serilog enricher, LoggingBehavior and LocalFileErrorTracker.Unwrap — all four at once — left the whole suite green. So did deleting the unit of work's != Guid.Empty clause. Of the five guards that commit claimed to install, only one had a test. Two production sites were also still ungated, and measurement decided both. TenantAssertionMiddleware.Mismatch read OrganizationId.Value with only a null check: on a resolved context whose organization is present but never assigned, that throws, escapes into UseExceptionHandler, and answers 500 — replacing the clean fail-closed 404 this middleware exists to produce, on a pre-auth path, for an attacker-supplied header. And the idempotency fingerprint still interpolated the UserId wrapper; measured, $"user:{id}" and "user:" + id.Value are byte-identical for a real id, but for one nothing assigned interpolation silently yields the literal "user:" while every sibling component throws — two callers with a corrupted principal would share a digest and replay each other's response bodies. TenantId.From(Guid.Empty) does not throw and reports IsInitialized() as true — these ids declare no Validate, so Vogen checks the value's shape and not that it names anything. That is why the unit of work refuses the all-zero id separately, and it now has the test that says so, plus an assertion that the Error it logs actually fires: that log line is the only signal that state produces, since the request itself merely reads nothing. Every new guard was mutation-checked, including the one this commit adds: each fails under the mutation it exists to catch and passes as shipped. ADR-0023 gains Amendment 7 for the generator consequence none of this was foreseen by, and ADR-0032's Amendment 3 records the second stale shape in the section it already corrects. 955 green, zero skips. ADR: 0023, 0032 Co-Authored-By: Claude Opus 5 (1M context) --- .../Idempotency/IdempotentAttribute.cs | 9 +- .../Tenancy/TenantAssertionMiddleware.cs | 10 + .../Database/UnitOfWorkTests.cs | 91 +++++++ .../TenantAssertionHttpTests.cs | 62 +++++ .../CrossCutting/UnassignedTenantIdTests.cs | 245 ++++++++++++++++++ ...0023-strongly-typed-id-source-generator.md | 41 +++ ...tion-handling-logging-and-observability.md | 11 + 7 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 backend/tests/LearnStack.Tests.Unit/CrossCutting/UnassignedTenantIdTests.cs diff --git a/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs b/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs index 74124e28..18b30eac 100644 --- a/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs +++ b/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs @@ -443,7 +443,14 @@ private async Task ComputeFingerprintAsync( tenantContext.OrganizationId is { } fingerprintOrganization ? fingerprintOrganization.Value.ToString() : string.Empty); - Append(digest, tenantContext.UserId is { } user ? $"user:{user}" : "anonymous"); + // Value, not the wrapper. Measured: for an initialized id + // $"user:{id}" and "user:" + id.Value are byte-identical, so no live + // claim is invalidated — but for one nothing assigned, interpolation + // silently yields the literal "user:" while the sibling components + // throw. Two callers with a corrupted principal would then share a + // digest and replay each other's response bodies, which is precisely + // what putting the principal in the fingerprint prevents. + Append(digest, tenantContext.UserId is { } user ? $"user:{user.Value}" : "anonymous"); Append(digest, context.Request.Method); Append(digest, context.Request.Path.Value ?? string.Empty); Append(digest, context.Request.QueryString.Value ?? string.Empty); diff --git a/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs index 51c93c9b..5a1bd796 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs @@ -156,8 +156,18 @@ private static (TenantAssertionDimension Dimension, Guid Asserted)? Mismatch( // the alternative — treating "no organization resolved" as agreement — // would let a header widen the request's scope, which is the one thing // ADR-0036 says an assertion may never do. + // IsInitialized() alongside the null check, and for the same reason: a + // non-null OrganizationId? says a struct is there, not that anything + // assigned it, and Value throws on one nothing did. That throw escapes + // into UseExceptionHandler and answers 500 — replacing the clean + // fail-closed 404 this middleware exists to produce with an uncontrolled + // error, on a pre-auth path, for a request carrying an attacker-supplied + // header. TenantId's two reads need no such clause: ITenantContext + // documents IsResolved as implying an initialized TenantId, and the + // nullable OrganizationId deliberately carries no equivalent promise. if (assertedOrganization is { } organization && (tenantContext.OrganizationId is not { } resolvedOrganization + || !resolvedOrganization.IsInitialized() || organization != resolvedOrganization.Value)) { return (TenantAssertionDimension.Organization, organization); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 4e7236cf..80db0137 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -10,6 +10,7 @@ using LearnStack.SharedKernel.Tenancy; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; using Xunit; @@ -118,6 +119,58 @@ await ReadAsync( await unitOfWork.RollbackAsync(); } + [Fact] + [Trait(RequiresDocker.Key, RequiresDocker.Value)] + public async Task A_resolved_context_holding_an_all_zero_tenant_id_still_writes_the_empty_string() + { + // IsInitialized() is only half the test, and this is the other half. + // Measured: TenantId.From(Guid.Empty) does not throw and IsInitialized() + // returns true for it — Vogen validates the value's shape, and these ids + // declare no Validate() — so an all-zero tenant reaches the setter as a + // perfectly well-formed id. It casts cleanly to ::uuid and then matches + // every row a bug ever wrote under it, which is worse than raising. + // The domain refuses it by hand (TenantOwned.EnsureRealTenant); this is + // the same refusal at the session-variable boundary. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(Resolved(Guid.Empty, Guid.Empty)); + + (await ReadAsync(unitOfWork, "SELECT current_setting('app.tenant_id', true)")) + .Should().BeEmpty("an all-zero tenant names nothing and must not be written"); + (await ReadAsync(unitOfWork, "SELECT current_setting('app.organization_id', true)")) + .Should().BeEmpty(); + (await ReadAsync(unitOfWork, "SELECT count(*)::text FROM organizations")) + .Should().Be("0"); + + // And it said so. A context that claims to be resolved and yields nothing + // usable is a bug in its producer; the empty string keeps the request + // fail-closed, but without this line the only symptom is data that looks + // absent rather than a context that is broken. + provider.GetRequiredService().Records + .Should().ContainSingle(record => record.Level == LogLevel.Error) + .Which.Message.Should().Contain("no usable tenant id"); + + await unitOfWork.RollbackAsync(); + } + + [Fact] + public void The_uninitialized_id_fixture_really_is_uninitialized() + { + // Guards the guard. The two cases above prove the setter's behaviour only + // if the stub actually carries unassigned ids, and it reaches them through + // an array element because VOG009 forbids default(TenantId). If a Vogen + // upgrade ever made that produce an initialized value, both cases would + // keep passing while testing nothing at all — silently, which is the one + // failure mode a test cannot report on its own behalf. + var context = new UninitializedIdContext(); + + context.TenantId.IsInitialized().Should().BeFalse(); + context.OrganizationId!.Value.IsInitialized().Should().BeFalse(); + } + [Fact] public async Task An_unresolved_context_leaves_every_tenant_owned_table_empty() { @@ -720,12 +773,50 @@ private ServiceProvider BuildProvider() var services = new ServiceCollection(); services.AddSingleton(NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); services.AddLogging(); + // Capturing, so a case can assert the unit of work actually complained. + // The Error it logs when a resolved context yields no usable tenant is + // the only signal that state ever produces — the request itself just + // reads zero rows — so an unasserted logger would leave the diagnostic + // in the same position as the silence it replaced. + services.AddSingleton(); + services.AddSingleton>( + sp => sp.GetRequiredService()); services.AddScoped(); services.AddModuleDbContext(); return services.BuildServiceProvider(); } + /// Collects what logged. + private sealed class CapturingLogger : ILogger + { + private readonly List<(LogLevel Level, int EventId, string Message)> _records = []; + + public IReadOnlyList<(LogLevel Level, int EventId, string Message)> Records + { + get { lock (_records) { return [.. _records]; } } + } + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + lock (_records) + { + _records.Add((logLevel, eventId.Id, formatter(state, exception))); + } + } + } + private async Task CountOrganizationsAsync(string slug) { await using var platform = await PostgresFixture.OpenAsync( diff --git a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs index f03c212a..b568b181 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs @@ -244,6 +244,27 @@ public async Task An_Organization_Asserted_Against_A_Tenant_Wide_Context_Is_A_40 "an assertion may reject a request and may never widen one"); } + [Fact] + public async Task An_Organization_Asserted_Against_An_Unassigned_One_Is_A_404_Not_A_500() + { + // A resolved context whose OrganizationId is non-null but was never + // assigned. Reading Value on it throws, and the throw escapes into + // UseExceptionHandler — replacing this middleware's whole purpose, a + // clean fail-closed 404, with an uncontrolled 500 on a pre-auth path, + // triggered by an attacker-supplied header. The comparison must treat an + // unusable resolved organization exactly as it treats an absent one. + using var client = fixture.WithUnassignedOrganization().CreateClient(); + + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/assertionprobe", UriKind.Relative)); + request.Headers.Add( + TenantAssertionMiddleware.OrganizationHeaderName, Guid.NewGuid().ToString()); + + var response = await client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + [Fact] public async Task The_Same_Request_Without_The_Header_Is_Served() { @@ -275,6 +296,47 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } + /// + /// The same host, with an organization that is present but never assigned. + /// + public WebApplicationFactory WithUnassignedOrganization() => + WithWebHostBuilder(builder => builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.AddScoped(_ => UnassignedOrganizationContext.Instance); + })); + + private sealed class UnassignedOrganizationContext : ITenantContext + { + public static UnassignedOrganizationContext Instance { get; } = new(); + + public bool IsResolved => true; + + public TenantId TenantId => + SharedKernel.Identifiers.TenantId.From(ResolvedTenantFixture.TenantId); + + /// + /// Non-null and uninitialized. VOG009 forbids writing that as a literal, + /// so it comes from an array element — the way production reaches it too, + /// through a member nothing assigned. + /// + public OrganizationId? OrganizationId => Unassigned; + + private static readonly OrganizationId Unassigned = Zeroed(); + + private static OrganizationId Zeroed() + { + var slot = new OrganizationId[1]; + return slot[0]; + } + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => "integration-test"; + } + private sealed class TenantWideContext : ITenantContext { public static TenantWideContext Instance { get; } = new(); diff --git a/backend/tests/LearnStack.Tests.Unit/CrossCutting/UnassignedTenantIdTests.cs b/backend/tests/LearnStack.Tests.Unit/CrossCutting/UnassignedTenantIdTests.cs new file mode 100644 index 00000000..db1aa1bc --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/CrossCutting/UnassignedTenantIdTests.cs @@ -0,0 +1,245 @@ +using System.Diagnostics; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.ErrorTracking; +using LearnStack.Infrastructure.Observability; +using LearnStack.Infrastructure.Observability.Serilog; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Serilog.Core; +using Serilog.Events; +using Xunit; + +namespace LearnStack.Tests.Unit.CrossCutting; + +/// +/// The same defect UnassignedActorIdTests covers for the actor, for the +/// two ids that became value objects in Packet 7 step 2: an +/// ITenantContext that reports IsResolved while carrying a +/// TenantId or OrganizationId nothing ever assigned. +/// +/// +/// +/// These exist because the gates they cover were deleted by mutation and the +/// whole suite stayed green. Stripping the IsInitialized() check from +/// the span processor, the Serilog enricher and LoggingBehavior at once — +/// reverting all three to the shape they shipped in before the review round that +/// added them — changed nothing any test could see. A guard nothing kills is a +/// comment. +/// +/// +/// The state is reachable by omission rather than by a literal: Vogen's VOG009 +/// rejects default(TenantId), so it arrives through an array element, a +/// default(T) in a generic, or a member a deserializer skipped. +/// ITenantContext documents IsResolved as implying an initialized +/// TenantId; these cases are what stops that promise from being the only +/// thing standing between a bad context and four paths that must not throw. +/// Packet 7's TenantResolverMiddleware is the first component that builds +/// a resolved context rather than stubbing one, which is why they land now. +/// +/// +public sealed class UnassignedTenantIdTests +{ + private static readonly TestTenantContext ResolvedWithUnassignedIds = new() + { + IsResolved = true, + TenantId = Zeroed(), + OrganizationId = Zeroed(), + UserId = UserId.From(Guid.Parse("018f4d40-1234-7000-8000-000000000003")), + CorrelationId = "00-aabbccdd-eeff0011-01", + ModuleName = "education", + }; + + [Fact] + public void The_Fixture_Really_Is_Present_But_Unassigned() + { + // Guards the guards. If a Vogen upgrade ever made an array element's + // default value initialized, every case below would keep passing while + // exercising nothing — the one failure a test cannot report itself. + ResolvedWithUnassignedIds.IsResolved.Should().BeTrue(); + ResolvedWithUnassignedIds.TenantId.IsInitialized().Should().BeFalse(); + ResolvedWithUnassignedIds.OrganizationId.Should().NotBeNull(); + ResolvedWithUnassignedIds.OrganizationId!.Value.IsInitialized().Should().BeFalse(); + } + + [Fact] + public void SpanProcessor_DoesNotThrow_And_Omits_Both_Tags() + { + var processor = new TenantContextSpanProcessor( + new TestAccessor(ResolvedWithUnassignedIds)); + + using var activity = new Activity("unassigned-tenant"); + activity.Start(); + + var act = () => processor.OnStart(activity); + + act.Should().NotThrow(); + activity.GetTagItem("tenant.id").Should().BeNull(); + activity.GetTagItem("organization.id").Should().BeNull(); + activity.GetTagItem("user.id").Should().NotBeNull("the rest still enriches"); + } + + [Fact] + public void SerilogEnricher_DoesNotThrow_And_Omits_Both_Properties() + { + var enricher = new CorrelationContextEnricher( + new TestAccessor(ResolvedWithUnassignedIds)); + + var logEvent = new LogEvent( + DateTimeOffset.UtcNow, + LogEventLevel.Information, + exception: null, + new MessageTemplate("test", []), + []); + + var act = () => enricher.Enrich(logEvent, new SimplePropertyFactory()); + + act.Should().NotThrow(); + logEvent.Properties.ContainsKey("tenant.id").Should().BeFalse(); + logEvent.Properties.ContainsKey("organization.id").Should().BeFalse(); + logEvent.Properties.ContainsKey("user.id").Should().BeTrue("the rest still enriches"); + } + + [Fact] + public async Task LoggingBehavior_DoesNotThrow_Building_Its_Scope() + { + // BuildScope runs at pipeline step 2, outside any try of its own, so a + // throw here fails the request it was only meant to describe. + var behavior = new LoggingBehavior>( + NullLogger>>.Instance, + new TestAccessor(ResolvedWithUnassignedIds)); + + RequestHandlerDelegate> next = () => Task.FromResult(Result.Ok("ok")); + + var act = async () => await behavior.Handle(new DummyCommand(), next, default); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ExceptionHandler_Reports_Absent_Ids_Rather_Than_Zero_Ones() + { + var tracker = new RecordingErrorTracker(); + var handler = new LearnStackExceptionHandler( + tracker, + new TestAccessor(ResolvedWithUnassignedIds), + NullLogger.Instance); + + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = new MemoryStream(); + + var act = async () => await handler.TryHandleAsync( + httpContext, new InvalidOperationException("boom"), default); + + await act.Should().NotThrowAsync(); + tracker.LastContext.Should().NotBeNull(); + tracker.LastContext!.OrganizationId.Should().BeNull( + "an unassigned organization is absent, not a zero Guid"); + } + + [Fact] + public async Task LocalFileErrorTracker_Writes_The_Envelope_Instead_Of_Dropping_It() + { + // The consequence this one guards is the quietest: Vogen's JSON converter + // reads Value, so an unassigned id makes Serialize throw — and this + // tracker's catch swallows it, dropping the whole envelope to a Warning + // while the caller logs the capture as a success. On an air-gapped + // deployment that file is the only record the error ever had. + var directory = Path.Combine(Path.GetTempPath(), $"ls-unassigned-{Guid.NewGuid():N}"); + + try + { + var sut = new LocalFileErrorTracker( + directory, NullLogger.Instance); + + await sut.CaptureAsync( + new InvalidOperationException("boom"), + new CapturedContext( + CorrelationId: "00-aabb-ccdd-01", + RequestPath: "/api/v1/probe", + RequestMethod: "GET", + TenantId: Zeroed(), + OrganizationId: Zeroed(), + UserId: null, + ModuleName: "education")); + + var files = Directory.GetFiles(directory); + files.Should().HaveCount(1, "the envelope must be written, not swallowed"); + + using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(files[0])); + doc.RootElement.GetProperty("TenantId").ValueKind.Should().Be(JsonValueKind.Null); + doc.RootElement.GetProperty("OrganizationId").ValueKind.Should().Be(JsonValueKind.Null); + doc.RootElement.GetProperty("exception").GetProperty("message").GetString() + .Should().Be("boom", "the part that matters still reaches the file"); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } + + /// + /// An id in the state nothing can write as a literal — VOG009 rejects + /// default(TId) and VOG010 rejects new TId(). An array's + /// elements are zeroed by the runtime, which neither analyzer inspects. + /// + private static TId Zeroed() + where TId : struct + { + var slot = new TId[1]; + return slot[0]; + } + + public sealed record DummyCommand : IRequest>; + + private sealed class TestAccessor(ITenantContext? current) : ITenantContextAccessor + { + public ITenantContext? Current { get; set; } = current; + } + + private sealed record TestTenantContext : ITenantContext + { + public bool IsResolved { get; init; } + + public TenantId TenantId { get; init; } + + public OrganizationId? OrganizationId { get; init; } + + public UserId? UserId { get; init; } + + public string? CorrelationId { get; init; } + + public string? ModuleName { get; init; } + } + + private sealed class SimplePropertyFactory : ILogEventPropertyFactory + { + public LogEventProperty CreateProperty( + string name, object? value, bool destructureObjects = false) => + new(name, value as LogEventPropertyValue ?? new ScalarValue(value)); + } + + private sealed class RecordingErrorTracker : IErrorTrackingProvider + { + public CapturedContext? LastContext { get; private set; } + + public ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default) + { + LastContext = context; + return ValueTask.CompletedTask; + } + } +} diff --git a/docs/decisions/0023-strongly-typed-id-source-generator.md b/docs/decisions/0023-strongly-typed-id-source-generator.md index 7f5b55bc..39e58ea3 100644 --- a/docs/decisions/0023-strongly-typed-id-source-generator.md +++ b/docs/decisions/0023-strongly-typed-id-source-generator.md @@ -428,6 +428,47 @@ record, which is what this amendment supplies. Dated the day it is written, not the day of the edit it discloses: back-dating it to 2026-05-21 would manufacture evidence of a disclosure that never happened. +### Amendment 7 — an id's own formatting is not a wire format (2026-09-01) + +Not a correction. A consequence of choosing a struct-based generator that this ADR +did not foresee, found by measurement in +[Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md) step 2, when +`ITenantContext` moved from raw `Guid` to `TenantId` / `OrganizationId`. + +**What was measured.** On the pinned Vogen 7.0.0, for an id nothing ever assigned: + +| Expression | Result | +|---|---| +| `id.ToString()` | the literal `"[UNINITIALIZED]"` | +| `$"{id}"` (string interpolation) | `""` | +| `id.Value` | throws `ValueObjectValidationException` | +| `default(TId)` | does not compile — analyzer `VOG009` | + +The first two disagree, so **neither is a contract**. `id.Value.ToString()` is. +`default(TId)` being blocked matters less than it looks: an array element, a +`default(T)` inside a generic, and a member a deserializer skipped all reach the +same state, and none of them is a token a grep can find. + +**Why it is worth an amendment rather than a standards line alone.** The cost is +not uniform. `"[UNINITIALIZED]"` in a log line is noise; the same string reaching +`app.tenant_id` is cast `::uuid` by the Row Level Security policy and raises +`22P02` on the first predicate evaluation — converting the corpus's designed +fail-closed empty result into a hard error on every query +([ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md)). A generator +choice reaching that far is this ADR's consequence to record. + +**`IsInitialized()` is only half the guard.** `TenantId.From(Guid.Empty)` does not +throw and reports `IsInitialized() == true` — these ids declare no `Validate`, so +Vogen checks the value's shape and not that it names anything. A boundary that +must refuse an unusable id refuses the all-zero one too, which is what the domain +already does by hand. + +**The rule this produced** lives in +[Standards 02 § Strongly-Typed Identifiers](../standards/02-backend-coding.md): at +an export boundary write `id.Value` under an `IsInitialized()` gate; inside the +type system pass the id. Nothing this ADR decides changes — Vogen, the `IdMask` +conversion set, and the no-`New()` rule all stand. + ## References - [Standards 02 § Strongly-Typed Identifiers](../standards/02-backend-coding.md) diff --git a/docs/decisions/0032-exception-handling-logging-and-observability.md b/docs/decisions/0032-exception-handling-logging-and-observability.md index 8dc4b260..e5ebd1e8 100644 --- a/docs/decisions/0032-exception-handling-logging-and-observability.md +++ b/docs/decisions/0032-exception-handling-logging-and-observability.md @@ -35,6 +35,17 @@ injects `ITenantContext` gets one instance captured for the life of the process, whatever the accessor held at construction. The rule now has no container-level backstop, so the accessor is the whole of it. +**A second stale shape, same section.** § Sub-decision 10's +`TenantContextSpanProcessor` sketch writes `SetTag("tenant.id", context.TenantId)` +and `SetTag("organization.id", orgId)`. Both were correct when written on +2026-05-20, when those members were `Guid` and `Guid?`. Packet 7 step 2 made them +Vogen value objects, and the shipped processor now writes +`context.TenantId.Value.ToString()` under an `IsInitialized()` gate — because an +id's own `ToString()` renders `"[UNINITIALIZED]"` for an unassigned value and is +therefore not a wire format ([ADR-0023 Amendment 7](0023-strongly-typed-id-source-generator.md)). +The **decision** the sketch illustrates is untouched: cross-cutting singletons read +the tenant through the accessor and never inject `ITenantContext`. + **Every carrier changed.** This amendment. The two "request-scoped" phrasings in § Sub-decision 10 and its code-block commentary stand as written, read against this amendment. The carriers that state the lifetime and state it correctly are From 076398a7ef1f19680752aae0ad959534cedc4011 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 18:34:59 +0300 Subject: [PATCH 07/55] feat(tenancy): the marker attributes and the EF query filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second defense-in-depth layer, ahead of the resolver that fills it: every entity marked [TenantOwned] gets a global query filter, and the two entities that must not are asserted rather than assumed. The mechanism is one property on the DbContext, and that is the whole of it. EF compiles a filter into the model once per context type, so anything it closes over that is not reached through the context instance is evaluated then and baked in as a SQL literal. Measured, and not what one would guess: the baked-in failure here is not "the first request's tenant served to the second" — the model is built on first use, reliably before any tenant is resolved, so the literal that bakes in is the all-zero id and every query returns zero rows for the life of the process. Fail-closed, total, and indistinguishable from an empty database. Two_contexts_under_two_tenants_each_see_only_their_own_rows is what holds the property; it fails against the baked-in form, which is why it asserts on the tenant ids the rows carry rather than on counts. Scope is by table class, never by "has a TenantId property". `tenants` is tenant-owned self-keyed — it carries the marker, implements no interface, and gets no filter, because its id is the tenant key and its policy says so. `platform_host_to_tenant` has a TenantId property and carries no marker at all: a tenant-keyed predicate on the table read in order to determine the tenant makes host resolution return zero rows forever, on the anonymous page-load path, with no error anywhere. A marker-gated rule cannot catch a missing marker, so that negative is its own case. Three mechanical consequences, each measured rather than assumed. The internal TenantOwned validation helper in Tenancy.Domain is renamed TenantOwnership, because a non-attribute class of that name makes [TenantOwned] ambiguous under CS1614 in the assembly that needs it most. ModuleDbContextRegistration moves to ActivatorUtilities, since a tenant-scoped context now takes ITenantContext alongside its options. Tenancy.Infrastructure gains a reference to core Infrastructure, where the seam lives: it calls EF model-building APIs, and SharedKernel's EF reference is sanctioned for Vogen-emitted converters only. 959 green, zero skips. Co-Authored-By: Claude Opus 5 (1M context) --- .../ModuleDbContextRegistration.cs | 8 +- .../Persistence/TenantQueryFilters.cs | 202 +++++++++++++ .../Persistence/TenantScoping.cs | 95 ++++++ .../CompositeKeyedEntities.cs | 13 +- .../Organization.cs | 6 +- .../PlatformProjections.cs | 4 +- .../Tenant.cs | 4 +- .../TenantDomain.cs | 6 +- .../TenantSetting.cs | 5 +- ...tack.Modules.Tenancy.Infrastructure.csproj | 6 + .../Persistence/TenancyDbContext.cs | 29 +- .../Persistence/TenancyDbContextFactory.cs | 8 +- .../PersistenceConventionTests.cs | 12 +- .../TenantScopingTests.cs | 284 ++++++++++++++++++ .../Database/MigrationRollbackTests.cs | 11 +- .../Database/SchemaFixture.cs | 4 +- .../Database/UnitOfWorkTests.cs | 96 +++++- docs/glossary.md | 2 +- docs/modules/tenancy/README.md | 15 +- .../21-architecture-tests-catalogue.md | 11 +- 20 files changed, 781 insertions(+), 40 deletions(-) create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs create mode 100644 backend/src/LearnStack.SharedKernel/Persistence/TenantScoping.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs index 06f8a73b..f176032c 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -140,7 +140,13 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle .UseApplicationServiceProvider(provider) .Options; - var context = (TContext)Activator.CreateInstance(typeof(TContext), options)!; + // ActivatorUtilities, not Activator: a module context takes its + // DbContextOptions plus whatever else it needs from DI — + // TenantScopedDbContext takes ITenantContext, because its query + // filters close over it. Activator.CreateInstance can only pass the + // options, so it would fail to construct any context with a second + // parameter, which is now every tenant-scoped one. + var context = ActivatorUtilities.CreateInstance(provider, options); context.Database.UseTransaction(unitOfWork.Transaction); return context; diff --git a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs new file mode 100644 index 00000000..d2e25587 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs @@ -0,0 +1,202 @@ +using System.Linq.Expressions; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Infrastructure.Persistence; + +/// +/// What a module DbContext exposes so its global query filters have +/// something to close over. +/// +/// +/// +/// These must be instance members of the context. That is the whole +/// mechanism, and the reason is EF's model cache: a filter expression is compiled +/// into the model once per context type, and anything it closes over that is +/// not reached through the context instance is evaluated at that moment and +/// baked in as a SQL literal. Reached through the context, EF re-evaluates the +/// member per query and emits a parameter. +/// Two_contexts_under_two_tenants_each_see_only_their_own_rows holds the +/// property and fails against the baked-in form. +/// Measured, and not what one would guess: the baked-in failure here is +/// not "the first request's tenant, served to the second". The model is built on +/// first use, which in this codebase is reliably before any tenant is resolved, +/// so the literal that bakes in is the all-zero id and every query returns +/// zero rows for the life of the process. Fail-closed, and total — a bug that +/// looks like an empty database rather than like a leak. That is the better of +/// the two directions and still an outage. +/// +/// +/// Implemented via , which every module context +/// derives from rather than restating. +/// +/// +public interface ITenantScopedDbContext +{ + /// + /// The tenant every filtered query narrows to, or the all-zero id when no + /// tenant is resolved. + /// + /// + /// Never throws, and never absent. An unresolved context yields + /// over , which no row can + /// carry — the domain refuses it at every factory and + /// NpgsqlUnitOfWork refuses to write it into app.tenant_id — so + /// the filter degenerates to "no rows" rather than to "all rows". Fail-closed + /// is the only acceptable default here; a nullable that EF renders as + /// tenant_id IS NULL would be a different, subtler wrong answer. + /// + TenantId CurrentTenantId { get; } + + /// + /// The organization the request narrows to, or null for a tenant-wide + /// request — which sees tenant-wide rows and no organization's rows, exactly + /// as the Row Level Security policy does with app.organization_id unset. + /// + OrganizationId? CurrentOrganizationId { get; } +} + +/// +/// Applies the tenant and organization global query filters to every entity a +/// module's model marks as scoped. +/// +/// +/// +/// Not the isolation boundary. Row Level Security is +/// ([ADR-0003 Amendment 3](../../../../docs/decisions/0003-tenant-isolation-defense-in-depth.md)), +/// and it holds whether or not a filter exists. These filters are the layer above +/// it: they keep a query from silently returning nothing when it should have +/// narrowed, they make the intent visible in the generated SQL, and they are what +/// an architecture test can check. Deleting them would not open a leak; it would +/// make every cross-tenant read return zero rows from the database instead of +/// from the query — which is the same answer arrived at by luck rather than by +/// design. +/// +/// +/// Applied from OnModelCreating and never from an +/// IEntityTypeConfiguration: a configuration class reached by +/// ApplyConfigurationsFromAssembly is constructed by EF with no access to +/// the context instance, so a filter written there could only close over +/// something else — which is the baked-in-literal failure this seam exists to +/// prevent. +/// +/// +public static class TenantQueryFilters +{ + /// + /// Adds a filter to every entity type implementing , + /// narrowing further for those implementing . + /// + public static ModelBuilder ApplyTenantQueryFilters( + this ModelBuilder modelBuilder, ITenantScopedDbContext context) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + ArgumentNullException.ThrowIfNull(context); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + var clrType = entityType.ClrType; + + if (!typeof(ITenantOwned).IsAssignableFrom(clrType)) + { + // Includes the two deliberate non-implementers: the tenant-owned + // self-keyed class, whose Id is the tenant key and whose policy + // says so, and the platform-scoped host map, which is read before + // any tenant exists. Neither is an omission; both are table + // classes — see Database Standards § Table classes. + continue; + } + + modelBuilder.Entity(clrType).HasQueryFilter(BuildFilter(clrType, context)); + } + + return modelBuilder; + } + + /// + /// e => e.TenantId == context.CurrentTenantId, and for an + /// organization-scoped entity + /// && (e.OrganizationId == null || e.OrganizationId == context.CurrentOrganizationId). + /// + /// + /// Built as an expression tree rather than written as a lambda because the + /// entity type is only known at model-building time. The shape is exactly what + /// the compiler emits for a lambda closing over a context property — a member + /// access rooted at a constant holding the context — which is the shape EF + /// re-evaluates per query. + /// + private static LambdaExpression BuildFilter(Type clrType, ITenantScopedDbContext context) + { + var entity = Expression.Parameter(clrType, "e"); + var contextConstant = Expression.Constant(context); + + Expression predicate = Expression.Equal( + Expression.Property(entity, nameof(ITenantOwned.TenantId)), + Expression.Property(contextConstant, nameof(ITenantScopedDbContext.CurrentTenantId))); + + if (typeof(IOrganizationScoped).IsAssignableFrom(clrType)) + { + var rowOrganization = Expression.Property( + entity, nameof(IOrganizationScoped.OrganizationId)); + + // Tenant-wide OR the caller's own, mirroring the policy's organization + // term. The app.scope = 'tenant' hatch is deliberately absent: it has + // no carrier (see Security Standards § Tenant Context), and a filter + // that widened where the policy does not would return rows the + // database then refuses — the confusing direction of disagreement. + predicate = Expression.AndAlso( + predicate, + Expression.OrElse( + Expression.Equal( + rowOrganization, + Expression.Constant(null, typeof(OrganizationId?))), + Expression.Equal( + rowOrganization, + Expression.Property( + contextConstant, + nameof(ITenantScopedDbContext.CurrentOrganizationId))))); + } + + return Expression.Lambda(predicate, entity); + } +} + +/// +/// The base every module DbContext derives from: it owns the two members +/// the filters close over and applies them. +/// +/// +/// A base class rather than a copied pair of properties, because the properties +/// are the mechanism. A module that wrote its own could reasonably write +/// tenantContext.TenantId — which throws on an unresolved context, inside +/// OnModelCreating, where the failure is a model that cannot be built. +/// +public abstract class TenantScopedDbContext( + DbContextOptions options, ITenantContext tenantContext) + : DbContext(options), ITenantScopedDbContext +{ + private static readonly TenantId NoTenant = TenantId.From(Guid.Empty); + + /// + public TenantId CurrentTenantId => + tenantContext.IsResolved && tenantContext.TenantId.IsInitialized() + ? tenantContext.TenantId + : NoTenant; + + /// + public OrganizationId? CurrentOrganizationId => + tenantContext.IsResolved + && tenantContext.OrganizationId is { } organization + && organization.IsInitialized() + ? organization + : null; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyTenantQueryFilters(this); + } +} diff --git a/backend/src/LearnStack.SharedKernel/Persistence/TenantScoping.cs b/backend/src/LearnStack.SharedKernel/Persistence/TenantScoping.cs new file mode 100644 index 00000000..41c4e2d6 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Persistence/TenantScoping.cs @@ -0,0 +1,95 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Persistence; + +/// +/// An entity whose rows belong to one tenant and are keyed by a +/// column. +/// +/// +/// +/// The interface and the marker say the same +/// thing to two different readers. The interface is what the EF global query +/// filter binds to — it gives the filter a typed property to compare, which a +/// bare attribute cannot — and the attribute is what a reflection scan finds on +/// an entity that is tenant-owned without carrying the column, which is exactly +/// the self-keyed case below. An entity carries the attribute; it implements the +/// interface unless its class exempts it. +/// +/// +/// The tenant-owned self-keyed class does not implement this. On +/// tenants the row's own id is the tenant id, so there is no +/// TenantId column to compare and the policy keys on id. It carries +/// [TenantOwned(SelfKeyed = true)] and nothing else. See +/// Database Standards +/// § Table classes. +/// +/// +public interface ITenantOwned +{ + TenantId TenantId { get; } +} + +/// +/// An entity that additionally narrows to an organization inside its tenant. +/// +/// +/// Nullable, and the null is a scope rather than an absence. A row with no +/// organization is tenant-wide — visible to every organization in its +/// tenant — which is why both the canonical Row Level Security policy and the EF +/// filter read OrganizationId is null || OrganizationId == current rather +/// than an equality alone ([ADR-0017](../../../../docs/decisions/0017-tenant-organization-hierarchy.md)). +/// +public interface IOrganizationScoped : ITenantOwned +{ + OrganizationId? OrganizationId { get; } +} + +/// +/// Marks an entity as belonging to one of the tenant-owned table classes. +/// +/// +/// +/// The marker's scope is decided by table class, not by the presence of a +/// TenantId property. Two tables are exceptions and they except +/// different things: tenants is tenant-owned self-keyed and carries +/// this marker with set, because its id is the +/// tenant id; and platform_host_to_tenant is platform-scoped and +/// carries no marker at all, because it is read in order to determine the +/// tenant — a tenant-keyed predicate on it would make host resolution return zero +/// rows forever, and it has a TenantId property regardless. +/// +/// +/// Every_TenantOwned_Entity_HasFilterAndRlsPolicy reads this marker and +/// requires, for each entity carrying it: a tenant key, an EF global query filter +/// referencing that key, and — in the migration that creates its table — +/// ENABLE and FORCE ROW LEVEL SECURITY plus exactly one policy with +/// both a USING and a WITH CHECK clause. +/// +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class TenantOwnedAttribute : Attribute +{ + /// + /// true when the entity's own identifier is the tenant id, so it + /// carries no TenantId column and does not implement + /// . Exactly one entity class is in this position; + /// a second one is a schema change, not a flag. + /// + public bool SelfKeyed { get; init; } +} + +/// +/// Marks a tenant-owned entity that additionally narrows to an organization. +/// +/// +/// Implies : an organization exists only inside +/// a tenant, so an organization-scoped entity is tenant-owned by construction and +/// carries both markers. Every_OrgScoped_Entity_HasOrgIdAndFilter reads +/// this one and additionally requires the two AS RESTRICTIVE write guards, +/// FOR UPDATE and FOR DELETE — measured as load-bearing, not +/// decorative: with the tenant-scope read hatch set and the delete guard dropped, +/// a DELETE removed another organization's row. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class OrganizationScopedAttribute : Attribute; diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs index 8490e659..b7711e70 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs @@ -1,5 +1,6 @@ using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; namespace LearnStack.Modules.Tenancy.Domain; @@ -24,7 +25,8 @@ namespace LearnStack.Modules.Tenancy.Domain; /// own configuration audit covers as one change, not six. /// /// -public sealed class TenantLocale +[TenantOwned] +public sealed class TenantLocale : ITenantOwned { private TenantLocale() => Locale = null!; @@ -59,7 +61,7 @@ public static TenantLocale Create( MappedLength.EnsureAtMost(locale, 35, nameof(locale)); LocaleTag.EnsureWellFormed(locale, nameof(locale)); - TenantOwned.EnsureRealTenant(tenantId, "A locale belongs to a tenant.", nameof(tenantId)); + TenantOwnership.EnsureRealTenant(tenantId, "A locale belongs to a tenant.", nameof(tenantId)); return new TenantLocale { @@ -91,7 +93,8 @@ public static TenantLocale Create( /// first write, and no soft delete — removing a flag removes the row. /// /// -public sealed class TenantFeatureFlag +[TenantOwned] +public sealed class TenantFeatureFlag : ITenantOwned { private TenantFeatureFlag() { @@ -124,7 +127,7 @@ public static TenantFeatureFlag Create( // ValueObjectValidationException out of the Vogen EF converter. AuditInput.EnsureValid(at, by); - TenantOwned.EnsureRealTenant( + TenantOwnership.EnsureRealTenant( tenantId, "A feature flag belongs to a tenant.", nameof(tenantId)); return new TenantFeatureFlag @@ -212,7 +215,7 @@ public static void EnsureWellFormed(string value, string parameterName) /// this is refused at the factory rather than left to collide with whatever that /// packet chooses. /// -internal static class TenantOwned +internal static class TenantOwnership { public static void EnsureRealTenant(TenantId tenantId, string message, string parameterName) { diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs index 66896c97..401fca3e 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -1,5 +1,6 @@ using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Time; namespace LearnStack.Modules.Tenancy.Domain; @@ -32,7 +33,8 @@ namespace LearnStack.Modules.Tenancy.Domain; /// writes. /// /// -public sealed class Organization : AuditableEntity, IAggregateRoot +[TenantOwned] +public sealed class Organization : AuditableEntity, IAggregateRoot, ITenantOwned { private Organization(OrganizationId id) : base(id) @@ -125,7 +127,7 @@ public static Organization Create( nameof(id)); } - TenantOwned.EnsureRealTenant( + TenantOwnership.EnsureRealTenant( tenantId, "An organization belongs to a tenant; the tenant id was never assigned.", nameof(tenantId)); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs index 50396c61..0b53f729 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs @@ -1,4 +1,5 @@ using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; namespace LearnStack.Modules.Tenancy.Domain; @@ -33,7 +34,8 @@ namespace LearnStack.Modules.Tenancy.Domain; /// ships RefreshAsync for it to guard. /// /// -public sealed class PlatformEntitlement +[TenantOwned] +public sealed class PlatformEntitlement : ITenantOwned { private PlatformEntitlement() { diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index fb9e955f..dc90dbcf 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -1,5 +1,6 @@ using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Time; namespace LearnStack.Modules.Tenancy.Domain; @@ -30,6 +31,7 @@ namespace LearnStack.Modules.Tenancy.Domain; /// list. /// /// +[TenantOwned(SelfKeyed = true)] public sealed class Tenant : AuditableEntity, IAggregateRoot { private Tenant(TenantId id) @@ -97,7 +99,7 @@ public static Tenant Create( MappedLength.EnsureAtMost(displayName, 200, nameof(displayName)); UrlSlug.EnsureUrlSafe(slug, nameof(slug)); - TenantOwned.EnsureRealTenant( + TenantOwnership.EnsureRealTenant( id, "A tenant id is assigned by the registry that owns the tenant, never minted here.", nameof(id)); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs index df14e3b1..31873955 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs @@ -1,5 +1,6 @@ using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Time; @@ -26,7 +27,8 @@ namespace LearnStack.Modules.Tenancy.Domain; /// what Packet 6 owns is the schema both sides write to. /// /// -public sealed class TenantDomain : AuditableEntity +[TenantOwned] +public sealed class TenantDomain : AuditableEntity, ITenantOwned { private TenantDomain(TenantDomainId id) : base(id) => Host = null!; @@ -202,7 +204,7 @@ private static TenantDomain CreateCore( nameof(id)); } - TenantOwned.EnsureRealTenant( + TenantOwnership.EnsureRealTenant( tenantId, "A domain belongs to a tenant.", nameof(tenantId)); // The database carries the same rule as ck_tenant_domains_host_normalized; diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs index fcfa8d37..a8c83e83 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs @@ -1,5 +1,6 @@ using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Time; namespace LearnStack.Modules.Tenancy.Domain; @@ -29,7 +30,9 @@ namespace LearnStack.Modules.Tenancy.Domain; /// tenant-wide — a scope, not "unknown". /// /// -public sealed class TenantSetting : AuditableEntity +[TenantOwned] +[OrganizationScoped] +public sealed class TenantSetting : AuditableEntity, IOrganizationScoped { private TenantSetting(TenantSettingId id) : base(id) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/LearnStack.Modules.Tenancy.Infrastructure.csproj b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/LearnStack.Modules.Tenancy.Infrastructure.csproj index 656eacb6..e3dc82c8 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/LearnStack.Modules.Tenancy.Infrastructure.csproj +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/LearnStack.Modules.Tenancy.Infrastructure.csproj @@ -6,6 +6,12 @@ + + diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs index 6cd46e11..58e8bb98 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs @@ -1,4 +1,6 @@ +using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Tenancy; using Microsoft.EntityFrameworkCore; namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; @@ -21,18 +23,23 @@ namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; /// there is one call site to change and not many. /// /// -/// No global query filters here yet. The tenant and organization filters -/// are Packet 7's, with TenantResolverMiddleware and the -/// ITenantContext they read. Between the two packets no tenant-owned table -/// is read on a request path, and with the policies live and app.tenant_id -/// unset every predicate evaluates to NULL and every query correctly -/// returns zero rows — fail-closed by construction rather than by a filter that -/// does not exist yet +/// The global query filters come from the base. +/// TenantScopedDbContext owns the two members they close over and applies +/// one to every entity implementing ITenantOwned; this context adds none +/// of its own. Two of its eight entity types deliberately get no filter: +/// , which is tenant-owned self-keyed — its id +/// is the tenant id, and its policy says so — and +/// , which is platform-scoped and read +/// in order to determine the tenant, so a tenant-keyed predicate on it would make +/// host resolution return zero rows forever. Row Level Security remains the +/// isolation boundary /// (ADR-0003 -/// Amendment 3). +/// Amendment 3); the filters are the layer above it. /// /// -public sealed class TenancyDbContext(DbContextOptions options) : DbContext(options) +public sealed class TenancyDbContext( + DbContextOptions options, ITenantContext tenantContext) + : TenantScopedDbContext(options, tenantContext) { public DbSet Tenants => Set(); @@ -56,6 +63,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfigurationsFromAssembly(typeof(TenancyDbContext).Assembly); + // The base applies the tenant filters, and it runs after the + // configurations so every entity type is in the model when it sweeps. + base.OnModelCreating(modelBuilder); + // Last, so it also rewrites anything the configurations named explicitly. // ToSnakeCase is idempotent, so a name already in snake_case is unchanged. modelBuilder.ApplySnakeCaseNames(); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs index 73c4af36..b2e804fc 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using LearnStack.SharedKernel.Tenancy; namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; @@ -76,6 +77,11 @@ public TenancyDbContext CreateDbContext(string[] args) npgsql.MigrationsHistoryTable(HistoryTable)) .Options; - return new TenancyDbContext(options); + // UnresolvedTenantContext, because `dotnet ef` has no request and needs + // no tenant: a global query filter emits no DDL, so the model this + // factory builds is byte-identical whatever context it is handed. Passing + // the unresolved one keeps that explicit rather than inventing a tenant + // the design-time path would then appear to depend on. + return new TenancyDbContext(options, UnresolvedTenantContext.Instance); } } diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 2ba42f7c..c7dd1b90 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; +using LearnStack.SharedKernel.Tenancy; namespace LearnStack.Tests.Architecture; @@ -533,7 +534,12 @@ private static bool RecipeReaches(string recipe, string projectPath) => /// value is deliberately not a real credential. /// private static TenancyDbContext BuildTenancyContext() => - new(new DbContextOptionsBuilder() - .UseNpgsql("Host=model-only;Database=model-only;Username=model-only") - .Options); + new( + new DbContextOptionsBuilder() + .UseNpgsql("Host=model-only;Database=model-only;Username=model-only") + .Options, + // The model is what these cases read, and a query filter emits no + // DDL and no table mapping, so the context this builds is identical + // whichever tenant context it holds. + UnresolvedTenantContext.Instance); } diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs new file mode 100644 index 00000000..02865b49 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs @@ -0,0 +1,284 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using FluentAssertions; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// +/// The two defense-in-depth rules +/// ADR-0003 +/// Amendment 3 and +/// ADR-0017 +/// place on a scoped entity, catalogued in +/// Standards 21 +/// § Tenancy and isolation. +/// +/// +/// +/// Read § What a structural test proves before relying on these. They check +/// that each layer is present: a marker, a tenant key, an EF query filter, +/// and the migration's ENABLE + FORCE + one permissive policy with +/// both clauses. None of that is evidence that isolation holds — the +/// superseded policy template satisfied every structural assertion of this kind +/// while leaking every tenant-wide row across tenants. The binding proof is the +/// integration suite running as learnstack_app. +/// +/// +/// Scope is by table class, not by the presence of a TenantId +/// property. Two entities are deliberately outside: Tenant is +/// tenant-owned self-keyed — it carries the marker and no +/// ITenantOwned implementation, because its Id is the tenant key — +/// and PlatformHostMapping is platform-scoped and carries no marker +/// at all despite having a TenantId property, because it is read in order +/// to determine the tenant. Both exclusions are asserted, not assumed: a marker +/// added to the host map would make host resolution return zero rows forever, and +/// nothing else in the build would notice. +/// +/// +public sealed class TenantScopingTests +{ + private static readonly Assembly TenancyDomain = typeof(Tenant).Assembly; + + [Fact] + public void Every_TenantOwned_Entity_HasFilterAndRlsPolicy() + { + var marked = ScopedEntities().ToList(); + + marked.Should().NotBeEmpty( + "a rule that finds nothing to check passes for the wrong reason"); + + using var context = ModelOnlyContext(); + var model = context.Model; + var migrations = MigrationSql(); + + foreach (var entity in marked) + { + var attribute = entity.GetCustomAttribute()!; + + // A tenant key: the TenantId property, or Id on the self-keyed class. + if (attribute.SelfKeyed) + { + typeof(ITenantOwned).IsAssignableFrom(entity).Should().BeFalse( + $"{entity.Name} is self-keyed, so it has no TenantId column to filter on"); + } + else + { + typeof(ITenantOwned).IsAssignableFrom(entity).Should().BeTrue( + $"{entity.Name} carries [TenantOwned] and must expose the key the filter reads"); + } + + var entityType = model.FindEntityType(entity); + entityType.Should().NotBeNull($"{entity.Name} is not mapped by TenancyDbContext"); + var mapped = entityType!; + + var table = mapped.GetTableName()!; + + // The EF filter, on everything but the self-keyed class. That one is + // carried by its policy alone — `tenants` keys on `id`, and a filter + // comparing Id to the current tenant would be correct but redundant + // with the policy and wrong the moment a platform-admin path reads it. + if (attribute.SelfKeyed) + { + FilterText(mapped).Should().BeNull( + $"{table} is tenant-owned self-keyed; its policy keys on id"); + } + else + { + FilterText(mapped).Should().NotBeNull( + $"{table} has no EF global query filter"); + + FilterText(mapped).Should().Contain(nameof(ITenantOwned.TenantId), + $"{table}'s filter must read the tenant key"); + } + + AssertRowSecurity(migrations, table); + } + } + + [Fact] + public void Every_OrgScoped_Entity_HasOrgIdAndFilter() + { + var marked = ScopedEntities() + .Where(entity => entity.GetCustomAttribute() is not null) + .ToList(); + + marked.Should().NotBeEmpty( + "tenant_settings is organization-scoped; a rule finding nothing checks nothing"); + + using var context = ModelOnlyContext(); + var migrations = MigrationSql(); + + foreach (var entity in marked) + { + typeof(IOrganizationScoped).IsAssignableFrom(entity).Should().BeTrue( + $"{entity.Name} carries [OrganizationScoped] and must expose OrganizationId"); + + // Nullable, because null is a scope and not an absence: a row with no + // organization is tenant-wide (ADR-0017). + var property = entity.GetProperty(nameof(IOrganizationScoped.OrganizationId))!; + Nullable.GetUnderlyingType(property.PropertyType).Should().NotBeNull( + $"{entity.Name}.OrganizationId must be nullable — null means tenant-wide"); + + var entityType = context.Model.FindEntityType(entity)!; + var table = entityType.GetTableName()!; + + var filter = FilterText(entityType); + filter.Should().NotBeNull($"{table} has no EF global query filter"); + filter.Should().Contain( + nameof(IOrganizationScoped.OrganizationId), + $"{table}'s filter must carry the organization term too"); + + // The organization term is AND-ed into the same single policy, never a + // second permissive one. + var policy = PermissivePolicyFor(migrations, table); + policy.Should().Contain("organization_id", + $"{table}'s policy must AND the organization term into the tenant term"); + + // And the two AS RESTRICTIVE write guards. Not decoration: measured, + // with the tenant-scope read hatch set and the delete guard dropped, a + // DELETE removed another organization's row. USING is also what selects + // the rows an UPDATE may target, and PostgreSQL has no WITH CHECK for + // DELETE, so these are the only things closing those two paths. + RestrictiveGuard(migrations, table, "UPDATE").Should().NotBeNull( + $"{table} needs an AS RESTRICTIVE FOR UPDATE guard"); + RestrictiveGuard(migrations, table, "DELETE").Should().NotBeNull( + $"{table} needs an AS RESTRICTIVE FOR DELETE guard"); + } + } + + [Fact] + public void The_Host_Map_Carries_No_Tenant_Marker() + { + // The negative a marker-gated rule cannot state about itself. The host map + // has a TenantId property, so any rule keyed on "has a TenantId" would + // capture it — and a tenant-keyed filter on the one table read *in order + // to* determine the tenant makes host resolution return zero rows forever, + // on the anonymous page-load path, with no error anywhere. + typeof(PlatformHostMapping).GetCustomAttribute() + .Should().BeNull("platform_host_to_tenant is platform-scoped"); + typeof(ITenantOwned).IsAssignableFrom(typeof(PlatformHostMapping)) + .Should().BeFalse(); + + using var context = ModelOnlyContext(); + FilterText(context.Model.FindEntityType(typeof(PlatformHostMapping))!) + .Should().BeNull("a tenant filter here would make host resolution impossible"); + } + + /// + /// The entity's declared global query filters as text, or null when it + /// has none. + /// + /// + /// GetDeclaredQueryFilters() rather than the obsolete + /// GetQueryFilter(): EF 10 supports several named filters per entity, + /// and the singular accessor throws once more than one exists. Joining them + /// keeps the assertions honest if a later packet adds a second — a soft-delete + /// filter is the obvious candidate — rather than silently reading only one. + /// + private static string? FilterText(IReadOnlyEntityType entityType) + { + var expressions = entityType.GetDeclaredQueryFilters() + .Select(filter => filter.Expression?.ToString()) + .Where(text => text is not null) + .ToList(); + + return expressions.Count == 0 ? null : string.Join(" && ", expressions); + } + + private static IEnumerable ScopedEntities() => + TenancyDomain.GetTypes() + .Where(type => type.GetCustomAttribute() is not null) + .OrderBy(type => type.Name, StringComparer.Ordinal); + + /// + /// A context built for its model alone. The connection string is never opened. + /// + private static TenancyDbContext ModelOnlyContext() => + new( + new DbContextOptionsBuilder() + .UseNpgsql("Host=model-only;Database=model-only;Username=model-only") + .Options, + UnresolvedTenantContext.Instance); + + /// + /// Every migration source in the repository, concatenated. + /// + /// + /// Both chains, and both spellings. EF writes the tenancy chain's tables + /// through migrationBuilder.CreateTable(...) and the platform chain + /// writes outbox_messages and idempotency_keys through + /// migrationBuilder.Sql("CREATE TABLE …") because neither is an EF + /// entity. A scan that classified on one token would silently cover one chain. + /// + private static string MigrationSql() + { + var files = Directory + .EnumerateDirectories(RepositoryPaths.BackendSrc(), "Migrations", SearchOption.AllDirectories) + .SelectMany(directory => Directory.EnumerateFiles(directory, "*.cs")) + .Where(file => !file.EndsWith(".Designer.cs", StringComparison.Ordinal)) + .ToList(); + + files.Should().NotBeEmpty("the migration scan has nothing to read"); + + return string.Join("\n", files.Select(File.ReadAllText)); + } + + private static void AssertRowSecurity(string migrations, string table) + { + migrations.Should().MatchRegex($@"ALTER TABLE\s+{Regex.Escape(table)}\s+ENABLE ROW LEVEL SECURITY", + $"{table} must ENABLE row level security"); + migrations.Should().MatchRegex($@"ALTER TABLE\s+{Regex.Escape(table)}\s+FORCE\s+ROW LEVEL SECURITY", + $"{table} must FORCE row level security — without it the owner bypasses its own policies"); + + var policy = PermissivePolicyFor(migrations, table); + policy.Should().Contain("USING", $"{table}'s policy needs a USING clause"); + policy.Should().Contain("WITH CHECK", + $"{table}'s policy needs an explicit WITH CHECK — USING alone leaves writes unconstrained"); + } + + /// + /// The one permissive policy on . + /// + /// + /// Exactly one, asserted here rather than assumed. Two permissive policies are + /// OR-ed by PostgreSQL, which is the defect ADR-0003 Amendment 3 corrects and + /// the reason it is worth counting: the superseded template shipped two and + /// every tenant-wide row was visible across tenants. AS RESTRICTIVE + /// policies are excluded from the count — they narrow rather than widen, and + /// the organization-scoped class is required to carry two of them. + /// + private static string PermissivePolicyFor(string migrations, string table) + { + var statements = Regex + .Matches( + migrations, + $@"CREATE POLICY\s+\w+\s+ON\s+{Regex.Escape(table)}\b(?.*?);", + RegexOptions.Singleline) + .Select(match => match.Value) + .Where(statement => !statement.Contains("AS RESTRICTIVE", StringComparison.Ordinal)) + .ToList(); + + statements.Should().ContainSingle( + $"{table} must carry exactly one permissive policy — PostgreSQL OR-s two together, " + + "which is how the superseded template leaked every tenant-wide row"); + + return statements[0]; + } + + private static string? RestrictiveGuard(string migrations, string table, string command) + { + var match = Regex.Match( + migrations, + $@"CREATE POLICY\s+\w+\s+ON\s+{Regex.Escape(table)}\s+AS RESTRICTIVE FOR {command}\b.*?;", + RegexOptions.Singleline); + + return match.Success ? match.Value : null; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs index 68929f1e..162b1880 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations; using Npgsql; using Xunit; +using LearnStack.SharedKernel.Tenancy; namespace LearnStack.Tests.Integration.Database; @@ -112,10 +113,12 @@ public async Task RollBackAsync() } private TenancyDbContext CreateTenancy() => - new(new DbContextOptionsBuilder() - .UseNpgsql(Postgres.MigrationConnectionString, npgsql => - npgsql.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)) - .Options); + new( + new DbContextOptionsBuilder() + .UseNpgsql(Postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)) + .Options, + UnresolvedTenantContext.Instance); private PlatformDbContext CreatePlatform() => new(new DbContextOptionsBuilder() diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs index 68ad6b66..48697fa7 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using Xunit; +using LearnStack.SharedKernel.Tenancy; namespace LearnStack.Tests.Integration.Database; @@ -111,7 +112,8 @@ public async Task InitializeAsync() new DbContextOptionsBuilder() .UseNpgsql(Postgres.MigrationConnectionString, npgsql => npgsql.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)) - .Options)) + .Options, + UnresolvedTenantContext.Instance)) { await tenancy.Database.MigrateAsync(); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 80db0137..f030ccb5 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -213,6 +213,56 @@ public async Task An_unresolved_context_leaves_every_tenant_owned_table_empty() await unitOfWork.RollbackAsync(); } + [Fact] + public async Task Two_contexts_under_two_tenants_each_see_only_their_own_rows() + { + // The property the whole query-filter seam turns on, and the one that + // fails if the filter closes over anything other than a DbContext + // instance member. EF compiles a filter into the model once per context + // type; a closure over a local, a field of something else, or a captured + // value is evaluated at that moment and baked in as a SQL literal. Every + // later request in the process then carries the FIRST request's tenant id + // in its WHERE clause — with a plausible number of rows, from the wrong + // tenant. Two scopes in one process, two tenants, is the smallest shape + // that can tell the two implementations apart: one context alone passes + // either way. + await using var provider = BuildProvider(); + + await using var first = provider.CreateAsyncScope(); + var firstUnitOfWork = first.ServiceProvider.GetRequiredService(); + await firstUnitOfWork.BeginTransactionAsync(); + await EnterTenantAsync( + first.ServiceProvider, firstUnitOfWork, SchemaFixture.TenantA, SchemaFixture.OrgA1); + + var firstContext = first.ServiceProvider.GetRequiredService(); + var tenantACount = await firstContext.Organizations.CountAsync(); + var tenantASeen = await firstContext.Organizations + .Select(organization => organization.TenantId).Distinct().ToListAsync(); + + await firstUnitOfWork.RollbackAsync(); + + await using var second = provider.CreateAsyncScope(); + var secondUnitOfWork = second.ServiceProvider.GetRequiredService(); + await secondUnitOfWork.BeginTransactionAsync(); + await EnterTenantAsync( + second.ServiceProvider, secondUnitOfWork, SchemaFixture.TenantB, Guid.NewGuid()); + + var secondContext = second.ServiceProvider.GetRequiredService(); + var tenantBSeen = await secondContext.Organizations + .Select(organization => organization.TenantId).Distinct().ToListAsync(); + + await secondUnitOfWork.RollbackAsync(); + + // Each sees its own tenant and nothing else. Under a baked-in literal the + // second scope would repeat the first's list, which is why the assertion + // is on the tenant ids the rows carry and not merely on the counts. + tenantACount.Should().BeGreaterThan(0, "the fixture seeds organizations for tenant A"); + tenantASeen.Should().ContainSingle() + .Which.Value.Should().Be(SchemaFixture.TenantA); + tenantBSeen.Should().ContainSingle() + .Which.Value.Should().Be(SchemaFixture.TenantB); + } + [Fact] public async Task A_module_context_enlists_in_the_ambient_transaction() { @@ -225,7 +275,8 @@ public async Task A_module_context_enlists_in_the_ambient_transaction() var unitOfWork = scope.ServiceProvider.GetRequiredService(); await unitOfWork.BeginTransactionAsync(); - await unitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + await EnterTenantAsync( + scope.ServiceProvider, unitOfWork, SchemaFixture.TenantA, SchemaFixture.OrgA1); await ExecuteAsync(unitOfWork, """ @@ -246,7 +297,8 @@ INSERT INTO organizations await using var after = provider.CreateAsyncScope(); var afterUnitOfWork = after.ServiceProvider.GetRequiredService(); await afterUnitOfWork.BeginTransactionAsync(); - await afterUnitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + await EnterTenantAsync( + after.ServiceProvider, afterUnitOfWork, SchemaFixture.TenantA, SchemaFixture.OrgA1); (await after.ServiceProvider.GetRequiredService() .Organizations.CountAsync()).Should().Be(2); @@ -773,6 +825,15 @@ private ServiceProvider BuildProvider() var services = new ServiceCollection(); services.AddSingleton(NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); services.AddLogging(); + // The composition root's shape, not a shortcut: a singleton accessor and + // a transient ITenantContext resolved from it on every access. The module + // context's query filters close over that context, so a provider without + // it cannot build one — which is what these cases would otherwise be + // silently testing around. + services.AddSingleton(); + services.AddTransient(sp => + sp.GetRequiredService().Current + ?? UnresolvedTenantContext.Instance); // Capturing, so a case can assert the unit of work actually complained. // The Error it logs when a resolved context yields no usable tenant is // the only signal that state ever produces — the request itself just @@ -787,6 +848,19 @@ private ServiceProvider BuildProvider() return services.BuildServiceProvider(); } + /// + /// The accessor a case writes to decide what tenant its scope runs under. + /// + /// + /// Not AsyncLocal-backed like the production one: these cases drive it + /// synchronously and want the value to be visible to the whole provider, which + /// is what makes "two scopes, two tenants, one process" expressible. + /// + private sealed class ScopedAccessor : ITenantContextAccessor + { + public ITenantContext? Current { get; set; } + } + /// Collects what logged. private sealed class CapturingLogger : ILogger { @@ -887,6 +961,24 @@ private static async Task ExecuteAsync( private static StubTenantContext Resolved(Guid tenant, Guid organization) => new(tenant, organization); + /// + /// Sets the session variables and the ambient accessor, which is the + /// pairing production makes: TenantResolverMiddleware writes the + /// accessor, and TransactionBehavior issues the SET LOCAL from + /// the same context. A case that wrote only the first would leave the module + /// context's query filter narrowing to the all-zero tenant and reading + /// nothing — which is the filter working, not a fixture bug, and worth + /// spelling out here so the next reader does not remove the filter to make a + /// count come back. + /// + private static async Task EnterTenantAsync( + IServiceProvider scope, IUnitOfWork unitOfWork, Guid tenant, Guid organization) + { + var context = Resolved(tenant, organization); + scope.GetRequiredService().Current = context; + await unitOfWork.SetTenantContextAsync(context); + } + /// /// Claims to be resolved while carrying ids nothing ever assigned. /// diff --git a/docs/glossary.md b/docs/glossary.md index f8e9fe56..d915dd95 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -117,7 +117,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | Term | Definition | |------|------------| -| **Tenant-owned table** | A database table whose every row belongs to one tenant, protected by an EF global query filter **and** a Row Level Security policy from the migration that creates it — both layers, always, once the pair exists. As of Packet 6 the policy is live on every such table and the filters are not: they land in Packet 7 with `TenantResolverMiddleware`, and until then RLS carries the invariant alone, fail-closed. Three sub-classes, per [Database Standards § Table classes](standards/05-database.md). | +| **Tenant-owned table** | A database table whose every row belongs to one tenant, protected by an EF global query filter **and** a Row Level Security policy from the migration that creates it — both layers, always, once the pair exists. Both layers are live as of Packet 7 step 3. Each fails closed on its own: an unset `app.tenant_id` makes every policy predicate `NULL`, and an unresolved context narrows every filter to the all-zero tenant, which no row can carry. RLS is the boundary; the filter is the layer above it. Three sub-classes, per [Database Standards § Table classes](standards/05-database.md). | | **Tenant-owned, tenant-wide** | The ordinary sub-class: a `tenant_id` column, one `AND`-ed policy keyed on it. | | **Tenant-owned, organization-scoped** | Adds a nullable `organization_id`, the `app.scope` read hatch, and two `AS RESTRICTIVE` write guards. | | **Tenant-owned, self-keyed** | `tenants` itself. It has **no** `tenant_id` column — its `id` *is* the tenant id, so its policy keys on `id`. | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 2ec3c7f9..81462052 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -316,11 +316,16 @@ request and are the only Tenancy work an anonymous visitor pays for. organization term already refuses a sibling's row, so both guards could be deleted with the whole suite green. Measured: with the hatch set and the delete guard dropped, a `DELETE` removed another organization's row. -- **No query filters yet.** The EF tenant and organization filters land in Packet - 7 with `TenantResolverMiddleware`. Between the packets nothing reads a - tenant-owned table on a request path, and with `app.tenant_id` unset every - policy predicate is `NULL` and every query returns zero rows — fail-closed by - construction rather than by a filter that does not exist. +- **The query filters landed in Packet 7 step 3**, ahead of + `TenantResolverMiddleware`, which supplies the resolved context they read. Every + entity marked `[TenantOwned]` carries one; the two exceptions are table classes + rather than omissions — `tenants` is tenant-owned **self-keyed** and its policy + keys on `id`, and `platform_host_to_tenant` is **platform-scoped** and takes no + marker at all. Row Level Security remains the isolation boundary: with + `app.tenant_id` unset every policy predicate is `NULL` and every query returns + zero rows whether or not a filter exists. The filter is the layer above it, and + it fails closed the same way — an unresolved context narrows to the all-zero + tenant, which no row can carry. - **Two defaults per tenant are possible.** Nothing stops two `tenant_locales` rows with `is_default = true` for one tenant. [Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) closes it in both places: diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 64c39c03..cbf36b7a 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -856,8 +856,15 @@ first two rows are coverage checks; the last three are the proof. - **Source:** ADR-0003 Amendment 3; [05-database.md § Tenant-Owned and Organization-Scoped Tables](05-database.md). - **Type:** xUnit + EF model inspection + migration SQL scan. **Kind:** structural. -- **Status:** **Registered.** +- **Status:** **Implemented** (Packet 7 step 3, `TenantScopingTests`) for the Tenancy + module; Packet 10 closes it across every module. - **Phase:** 02a (Packet 7 introduces, Packet 10 closes). +- **Note:** a marker-gated rule cannot catch a **missing** marker — it iterates what it + finds. The companion case `The_Host_Map_Carries_No_Tenant_Marker` states the negative + that matters most in this module: `platform_host_to_tenant` has a `TenantId` property + and must carry neither the marker nor a filter, because a tenant-keyed predicate on the + table read *in order to* determine the tenant makes host resolution return zero rows + forever, on the anonymous page-load path, with no error anywhere. - **Note:** the marker's scope is decided by **table class**, not by the presence of a `TenantId` property. `tenants` is tenant-owned **self-keyed** — its policy is on `id` and it carries no marker-driven `TenantId` filter — and `platform_host_to_tenant` is @@ -883,6 +890,8 @@ first two rows are coverage checks; the last three are the proof. - **Source:** ADR-0017; ADR-0003 Amendment 3; [05-database.md § Tenant-Owned and Organization-Scoped Tables](05-database.md). - **Type:** xUnit + EF model inspection + migration SQL scan. **Kind:** structural. +- **Status:** **Implemented** (Packet 7 step 3, `TenantScopingTests`) for the Tenancy + module; Packet 10 closes it across every module. - **Status:** **Registered.** - **Phase:** 02a (Packet 7 introduces, Packet 10 closes). From e3483a63851d024288163f987eeaa68893598484 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 19:48:35 +0300 Subject: [PATCH 08/55] fix(tenancy): give the self-keyed class its filter, and the rules teeth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the Step 3 review, both measured, both about this step's own work. The architecture rules were vacuous. They asserted filter.ToString().Contains("TenantId") — which the context-side member name CurrentTenantId satisfies on its own, so the row side of every comparison went unchecked. Two verifiers independently rebuilt BuildFilter to compare two context members to each other and watched the whole suite stay green. The rules now walk the expression tree and collect only member reads whose target is the lambda's own parameter, so a filter that narrows nothing cannot pass. That mutation now kills two of the three cases. And `tenants` had no filter at all. Standards 05 § Table classes mandates `t.Id == currentTenantId` for the tenant-owned self-keyed class, and four other corpus locations say the same; the step shipped an assertion forbidding it instead. Measured: SELECT ... FROM tenants emitted no WHERE clause, correct only because Row Level Security sits underneath — which is the one argument this project does not accept for dropping a layer. The builder now branches on SelfKeyed and filters on Id, which is what the table's own policy keys on, so filter and policy finally agree. 959 green, zero skips. Co-Authored-By: Claude Opus 5 (1M context) --- .../Persistence/TenantQueryFilters.cs | 46 ++++++++-- .../TenantScopingTests.cs | 86 ++++++++++++------- 2 files changed, 98 insertions(+), 34 deletions(-) diff --git a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs index d2e25587..64994473 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using System.Reflection; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Tenancy; @@ -99,13 +100,26 @@ public static ModelBuilder ApplyTenantQueryFilters( { var clrType = entityType.ClrType; + if (clrType.GetCustomAttribute() is { SelfKeyed: true }) + { + // The self-keyed class filters on its own Id, because that Id *is* + // the tenant key — which is exactly what its policy does + // (`tenants_isolation` keys on `id`). Filtered rather than skipped: + // Database Standards § Table classes mandates `t.Id == + // currentTenantId` for this class, and skipping it would leave + // `SELECT … FROM tenants` with no WHERE clause at all — correct + // only because Row Level Security is underneath, which is the one + // argument this project does not accept for dropping a layer. + modelBuilder.Entity(clrType).HasQueryFilter(BuildSelfKeyedFilter(clrType, context)); + continue; + } + if (!typeof(ITenantOwned).IsAssignableFrom(clrType)) { - // Includes the two deliberate non-implementers: the tenant-owned - // self-keyed class, whose Id is the tenant key and whose policy - // says so, and the platform-scoped host map, which is read before - // any tenant exists. Neither is an omission; both are table - // classes — see Database Standards § Table classes. + // The platform-scoped host map, which is read before any tenant + // exists. Not an omission — a table class, see Database Standards + // § Table classes. A tenant-keyed predicate here would make host + // resolution return zero rows forever. continue; } @@ -127,6 +141,28 @@ public static ModelBuilder ApplyTenantQueryFilters( /// access rooted at a constant holding the context — which is the shape EF /// re-evaluates per query. /// + /// + /// e => e.Id == context.CurrentTenantId for the tenant-owned + /// self-keyed class. + /// + /// + /// Its Id is a , so the comparison is the same + /// one every other entity makes — only the property differs. + /// + private static LambdaExpression BuildSelfKeyedFilter( + Type clrType, ITenantScopedDbContext context) + { + var entity = Expression.Parameter(clrType, "e"); + + return Expression.Lambda( + Expression.Equal( + Expression.Property(entity, "Id"), + Expression.Property( + Expression.Constant(context), + nameof(ITenantScopedDbContext.CurrentTenantId))), + entity); + } + private static LambdaExpression BuildFilter(Type clrType, ITenantScopedDbContext context) { var entity = Expression.Parameter(clrType, "e"); diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs index 02865b49..830c70d7 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using System.Reflection; using System.Text.RegularExpressions; using FluentAssertions; @@ -84,19 +85,12 @@ public void Every_TenantOwned_Entity_HasFilterAndRlsPolicy() // carried by its policy alone — `tenants` keys on `id`, and a filter // comparing Id to the current tenant would be correct but redundant // with the policy and wrong the moment a platform-admin path reads it. - if (attribute.SelfKeyed) - { - FilterText(mapped).Should().BeNull( - $"{table} is tenant-owned self-keyed; its policy keys on id"); - } - else - { - FilterText(mapped).Should().NotBeNull( - $"{table} has no EF global query filter"); + var key = attribute.SelfKeyed ? "Id" : nameof(ITenantOwned.TenantId); - FilterText(mapped).Should().Contain(nameof(ITenantOwned.TenantId), - $"{table}'s filter must read the tenant key"); - } + RowMembersRead(mapped).Should().Contain( + key, + $"{table}'s filter must compare the row's own {key} — a filter that reads " + + "only context members narrows nothing and would return every row"); AssertRowSecurity(migrations, table); } @@ -129,9 +123,10 @@ public void Every_OrgScoped_Entity_HasOrgIdAndFilter() var entityType = context.Model.FindEntityType(entity)!; var table = entityType.GetTableName()!; - var filter = FilterText(entityType); - filter.Should().NotBeNull($"{table} has no EF global query filter"); - filter.Should().Contain( + var members = RowMembersRead(entityType); + members.Should().Contain(nameof(ITenantOwned.TenantId), + $"{table}'s filter must still carry the tenant term"); + members.Should().Contain( nameof(IOrganizationScoped.OrganizationId), $"{table}'s filter must carry the organization term too"); @@ -167,29 +162,62 @@ public void The_Host_Map_Carries_No_Tenant_Marker() .Should().BeFalse(); using var context = ModelOnlyContext(); - FilterText(context.Model.FindEntityType(typeof(PlatformHostMapping))!) - .Should().BeNull("a tenant filter here would make host resolution impossible"); + RowMembersRead(context.Model.FindEntityType(typeof(PlatformHostMapping))!) + .Should().BeEmpty("a tenant filter here would make host resolution impossible"); } /// - /// The entity's declared global query filters as text, or null when it - /// has none. + /// The names of the members each declared query filter reads off the + /// entity — the row side of every comparison, and nothing else. /// /// + /// + /// The tree, not its text. The first version of these rules asserted + /// filter.ToString().Contains("TenantId"), which is satisfied by the + /// context-side member name CurrentTenantId all on its own — so the row + /// side of the comparison was never checked, and a builder that compared two + /// context members to each other passed. Measured: that mutation survived the + /// entire suite. Anchoring on the lambda's own parameter is what makes the + /// assertion about the thing it names. + /// + /// /// GetDeclaredQueryFilters() rather than the obsolete - /// GetQueryFilter(): EF 10 supports several named filters per entity, - /// and the singular accessor throws once more than one exists. Joining them - /// keeps the assertions honest if a later packet adds a second — a soft-delete - /// filter is the obvious candidate — rather than silently reading only one. + /// GetQueryFilter(): EF 10 allows several named filters per entity and + /// the singular accessor throws once more than one exists. All of them are + /// swept, so a later soft-delete filter adds to this set rather than hiding + /// the tenant one. + /// /// - private static string? FilterText(IReadOnlyEntityType entityType) + private static HashSet RowMembersRead(IReadOnlyEntityType entityType) { - var expressions = entityType.GetDeclaredQueryFilters() - .Select(filter => filter.Expression?.ToString()) - .Where(text => text is not null) - .ToList(); + var members = new HashSet(StringComparer.Ordinal); + + foreach (var filter in entityType.GetDeclaredQueryFilters()) + { + if (filter.Expression is not { } lambda || lambda.Parameters.Count == 0) + { + continue; + } - return expressions.Count == 0 ? null : string.Join(" && ", expressions); + new RowMemberVisitor(lambda.Parameters[0], members).Visit(lambda.Body); + } + + return members; + } + + /// Collects member reads whose target is the lambda's own parameter. + private sealed class RowMemberVisitor(ParameterExpression row, HashSet members) + : ExpressionVisitor + { + protected override Expression VisitMember(MemberExpression node) + { + if (node.Expression == row) + { + members.Add(node.Member.Name); + } + + return base.VisitMember(node); + } } private static IEnumerable ScopedEntities() => From 9612acd0af368302f383c5bc0b94e21e1b6d6e62 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 19:53:45 +0300 Subject: [PATCH 09/55] fix(tenancy): let the query filters follow the ambient tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TenantScopedDbContext took an injected ITenantContext and held it for life. That contract is registered transient and resolved from the accessor, so the context captured whatever the accessor happened to hold at construction and never moved again. Measured: a context built under tenant A kept filtering to A after the accessor moved to B. Not reachable today — every flow the corpus designs writes the accessor before the context is built, the resolver middleware at scope start and the event transport per delivery — so the snapshot was correct, and correct for a reason that lives in the calling order rather than in the mechanism. Step 5 adds the first component that builds a resolved context from untrusted input; this is cheaper to hold now than to diagnose then. It is also what ADR-0032 Sub-decision 10 already says every cross-cutting reader does. The base now takes ITenantContextAccessor and reads Current on each access. StaticTenantContextAccessor carries the two non-request callers — the design-time factory, where dotnet ef has no tenant, and a test building a context for its model alone. The test asserts the discriminating observation rather than the obvious one. Moving only the accessor leaves app.tenant_id on A, because SET LOCAL is transaction-local and this transaction already issued it, so a following filter narrows to B and intersects RLS's A to nothing. Zero rows is the answer only the following implementation gives; a frozen one still agrees with the policy and hands back tenant A's rows. Reading the emitted SQL cannot tell them apart — both emit the same parameterised text and differ only in the value bound. Mutation-checked against the frozen form. 960 green, zero skips. Co-Authored-By: Claude Opus 5 (1M context) --- .../Persistence/TenantQueryFilters.cs | 29 ++++++++++--- .../Tenancy/StaticTenantContextAccessor.cs | 20 +++++++++ .../Persistence/TenancyDbContext.cs | 4 +- .../Persistence/TenancyDbContextFactory.cs | 2 +- .../PersistenceConventionTests.cs | 2 +- .../TenantScopingTests.cs | 2 +- .../Database/MigrationRollbackTests.cs | 2 +- .../Database/SchemaFixture.cs | 2 +- .../Database/UnitOfWorkTests.cs | 41 +++++++++++++++++++ 9 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/StaticTenantContextAccessor.cs diff --git a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs index 64994473..27184bdd 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs @@ -210,21 +210,40 @@ private static LambdaExpression BuildFilter(Type clrType, ITenantScopedDbContext /// OnModelCreating, where the failure is a model that cannot be built. /// public abstract class TenantScopedDbContext( - DbContextOptions options, ITenantContext tenantContext) + DbContextOptions options, ITenantContextAccessor accessor) : DbContext(options), ITenantScopedDbContext { private static readonly TenantId NoTenant = TenantId.From(Guid.Empty); + /// + /// The ambient context, read fresh on every access. + /// + /// + /// The accessor, not an injected ITenantContext. That contract is + /// registered transient and resolved from this same accessor, so a context + /// constructed with one captures whatever the accessor happened to hold at + /// construction and never moves again. Measured: with the injected form, a + /// context built under tenant A kept filtering to A after the accessor moved to + /// B. Every flow the corpus designs writes the accessor before the context is + /// built — the resolver middleware at scope start, the event transport per + /// delivery — so the snapshot was correct today and would have been wrong the + /// first time that ordering changed. Reading through the accessor makes the + /// property hold by mechanism rather than by ordering, which is the same reason + /// ADR-0032 + /// § Sub-decision 10 routes every cross-cutting reader through it. + /// + private ITenantContext Ambient => accessor.Current ?? UnresolvedTenantContext.Instance; + /// public TenantId CurrentTenantId => - tenantContext.IsResolved && tenantContext.TenantId.IsInitialized() - ? tenantContext.TenantId + Ambient is { IsResolved: true } context && context.TenantId.IsInitialized() + ? context.TenantId : NoTenant; /// public OrganizationId? CurrentOrganizationId => - tenantContext.IsResolved - && tenantContext.OrganizationId is { } organization + Ambient is { IsResolved: true } context + && context.OrganizationId is { } organization && organization.IsInitialized() ? organization : null; diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/StaticTenantContextAccessor.cs b/backend/src/LearnStack.SharedKernel/Tenancy/StaticTenantContextAccessor.cs new file mode 100644 index 00000000..09d3f995 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/StaticTenantContextAccessor.cs @@ -0,0 +1,20 @@ +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// An holding one context for its lifetime, +/// for the hosts that have no ambient one to read. +/// +/// +/// Two callers, and neither is a request path: the design-time +/// IDesignTimeDbContextFactory, where dotnet ef builds a model and +/// there is no tenant to resolve, and a test building a context for its model +/// alone. The production accessor is AsyncLocal-backed and registered as a +/// singleton at the composition root; this one is not a substitute for it. +/// +public sealed class StaticTenantContextAccessor(ITenantContext? current) : ITenantContextAccessor +{ + /// An accessor holding nothing, which reads as an unresolved tenant. + public static StaticTenantContextAccessor Unresolved { get; } = new(null); + + public ITenantContext? Current { get; set; } = current; +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs index 58e8bb98..679f3347 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs @@ -38,8 +38,8 @@ namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; /// /// public sealed class TenancyDbContext( - DbContextOptions options, ITenantContext tenantContext) - : TenantScopedDbContext(options, tenantContext) + DbContextOptions options, ITenantContextAccessor accessor) + : TenantScopedDbContext(options, accessor) { public DbSet Tenants => Set(); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs index b2e804fc..c4c6c4a2 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs @@ -82,6 +82,6 @@ public TenancyDbContext CreateDbContext(string[] args) // factory builds is byte-identical whatever context it is handed. Passing // the unresolved one keeps that explicit rather than inventing a tenant // the design-time path would then appear to depend on. - return new TenancyDbContext(options, UnresolvedTenantContext.Instance); + return new TenancyDbContext(options, StaticTenantContextAccessor.Unresolved); } } diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index c7dd1b90..ed910057 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -541,5 +541,5 @@ private static TenancyDbContext BuildTenancyContext() => // The model is what these cases read, and a query filter emits no // DDL and no table mapping, so the context this builds is identical // whichever tenant context it holds. - UnresolvedTenantContext.Instance); + StaticTenantContextAccessor.Unresolved); } diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs index 830c70d7..00e9cad3 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs @@ -233,7 +233,7 @@ private static TenancyDbContext ModelOnlyContext() => new DbContextOptionsBuilder() .UseNpgsql("Host=model-only;Database=model-only;Username=model-only") .Options, - UnresolvedTenantContext.Instance); + StaticTenantContextAccessor.Unresolved); /// /// Every migration source in the repository, concatenated. diff --git a/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs index 162b1880..ad2939b2 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs @@ -118,7 +118,7 @@ private TenancyDbContext CreateTenancy() => .UseNpgsql(Postgres.MigrationConnectionString, npgsql => npgsql.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)) .Options, - UnresolvedTenantContext.Instance); + StaticTenantContextAccessor.Unresolved); private PlatformDbContext CreatePlatform() => new(new DbContextOptionsBuilder() diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs index 48697fa7..8c0e2e09 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs @@ -113,7 +113,7 @@ public async Task InitializeAsync() .UseNpgsql(Postgres.MigrationConnectionString, npgsql => npgsql.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)) .Options, - UnresolvedTenantContext.Instance)) + StaticTenantContextAccessor.Unresolved)) { await tenancy.Database.MigrateAsync(); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index f030ccb5..fdb7280a 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -263,6 +263,47 @@ await EnterTenantAsync( .Which.Value.Should().Be(SchemaFixture.TenantB); } + [Fact] + public async Task A_context_follows_the_accessor_after_it_was_built() + { + // Whether the filters read the CURRENT ambient tenant or the one that + // happened to be there when EF constructed the context. In every flow the + // corpus designs, the accessor is written first — the resolver middleware + // at scope start, the event transport per delivery — so a snapshot would + // be correct today and wrong the first time that ordering changed. This + // asserts the mechanism rather than the ordering. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await EnterTenantAsync( + scope.ServiceProvider, unitOfWork, SchemaFixture.TenantA, SchemaFixture.OrgA1); + + // Built while tenant A is ambient. + var context = scope.ServiceProvider.GetRequiredService(); + (await context.Organizations.CountAsync()).Should().BeGreaterThan(0); + + // Now the ambient tenant changes underneath it, and only the accessor + // moves — app.tenant_id stays on A, because SetTenantContextAsync is + // transaction-local and this transaction already issued it. + scope.ServiceProvider.GetRequiredService().Current = + Resolved(SchemaFixture.TenantB, Guid.NewGuid()); + + // Zero is the discriminating answer, and it is the only one available: + // the filter now narrows to B while Row Level Security still narrows to + // A, so their intersection is empty. A context that had frozen its tenant + // context at construction would still be filtering to A, agree with the + // policy, and hand back tenant A's rows — which is what this asserts + // against. Reading the emitted SQL cannot tell the two apart, because both + // emit the same parameterised text and differ only in the value bound. + (await context.Organizations.CountAsync()).Should().Be(0, + "the filter must read the accessor on every query, not the instance it " + + "was built with — a frozen context would still be reading tenant A"); + + await unitOfWork.RollbackAsync(); + } + [Fact] public async Task A_module_context_enlists_in_the_ambient_transaction() { From 4af3317bc9dcda8e23d33c8d52738c1cbde3d080 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 19:58:48 +0300 Subject: [PATCH 10/55] fix(tenancy): close the rest of what the Step 3 review reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review round was cut short by a session limit, so these are the findings whose verification I ran by hand. The measured claim in TenantQueryFilters was one of two directions, and not the one production would get. Whichever tenant is current when the model is first built becomes the baked-in literal; in the API host the first build is necessarily inside a request — the module registration refuses to resolve a context outside the ambient transaction — so the literal would be a real tenant's id and every later request would carry it. The all-zero, always-empty direction is what a test or design-time host produces, and it is what this repository measured, which is exactly how a measurement can name the wrong direction confidently. ApplyTenantQueryFilters was public over the bare interface, so an implementer that is not a DbContext could satisfy its shape and defeat the mechanism it exists to hold. It now constrains to DbContext. The sweep also skips owned and TPH/TPT-derived entity types, which EF refuses a filter on — the first module to model either would otherwise have discovered the rule as a startup exception. Two assertions were not asserting. The organization rule enumerated the tenant-owned set, so an entity carrying only [OrganizationScoped] was invisible to both rules; it now enumerates its own marker and requires the pair. And the nullability check read the CLR property type, which IOrganizationScoped already fixes — it asserted the compiler. It now reads the mapped column, and a configuration marking organization_id required fails it, which is the way a tenant-wide row becomes unrepresentable. Three skills still taught the pre-Step-3 mechanism, including the two a new entity and a new module are built through — add-tenant-owned-entity said the filters were Packet 7's to invent, add-backend-module showed a context that does not derive from the seam, and add-ef-migration said both rules "check nothing". The catalogue also carried two contradictory Status lines on the organization rule, one of them mine. 960 green, zero skips. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-backend-module/SKILL.md | 37 +++++++------- .claude/skills/add-ef-migration/SKILL.md | 7 ++- .../skills/add-tenant-owned-entity/SKILL.md | 48 ++++++++++++------- .../Persistence/TenantQueryFilters.cs | 43 +++++++++++++---- .../TenantScopingTests.cs | 28 ++++++++--- .../21-architecture-tests-catalogue.md | 1 - 6 files changed, 106 insertions(+), 58 deletions(-) diff --git a/.claude/skills/add-backend-module/SKILL.md b/.claude/skills/add-backend-module/SKILL.md index e18f0e8d..3e979e8c 100644 --- a/.claude/skills/add-backend-module/SKILL.md +++ b/.claude/skills/add-backend-module/SKILL.md @@ -166,32 +166,29 @@ In `LearnStack.Modules..Infrastructure/Persistence/DbContext.cs`: // parameter throws `MissingMethodException` on first resolution — and // `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` forbids registering the // context any other way. This is the shape the one shipped context carries. -public sealed class DbContext(DbContextOptions<DbContext> options) - : DbContext(options) +public sealed class DbContext( + DbContextOptions<DbContext> options, ITenantContextAccessor accessor) + : TenantScopedDbContext(options, accessor) { - // The filters' closure root, held as an instance member. Nullable because the - // registrar cannot hand it over yet; Packet 7 populates it (see below). - private ITenantContextAccessor? _accessor; + // The base owns the two members the filters close over and applies one to + // every entity implementing ITenantOwned / IOrganizationScoped. Do not write + // a filter here, and never from an IEntityTypeConfiguration: a configuration + // reached by ApplyConfigurationsFromAssembly cannot close over the context + // instance, and a filter whose closure root is anything else is constant- + // folded into EF's cached model as a SQL literal. There is no + // TenantQueryFilterConvention either; that type has never existed. + // + // The accessor rather than an injected ITenantContext: that contract is + // registered transient and resolved from this same accessor, so a context + // holding one freezes whatever the accessor held at construction. protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(DbContext).Assembly); - // Tenant + Organization query filters are applied HERE, from - // OnModelCreating, with a DbContext INSTANCE MEMBER as the closure root. - // Not from an IEntityTypeConfiguration: a configuration reached by - // ApplyConfigurationsFromAssembly cannot close over the context instance, - // and a filter whose closure root is anything else is constant-folded into - // EF's cached model as a SQL literal — so request B emits request A's - // baked-in tenant id. There is no TenantQueryFilterConvention either; that - // type has never existed. Every_TenantOwned_Entity_HasFilterAndRlsPolicy is - // what WILL make a forgotten filter fail — registered in the catalogue and - // implemented in Phase 02a Packet 7, not before. - foreach (var entity in modelBuilder.Model.GetEntityTypes()) - { - // …build the filter over `_accessor`, the instance member, one - // expression per entity. - } + // AFTER the configurations, so every entity type is in the model when the + // base sweeps it. Forgetting this call loses every filter, silently. + base.OnModelCreating(modelBuilder); } } ``` diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index 94a2dc3d..960a0d96 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -353,8 +353,11 @@ migration is consistent. - `LearnStack.Tests.Architecture` is green. The two rules for this surface — `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` and `Every_OrgScoped_Entity_HasOrgIdAndFilter`, the canonical names — are - **Registered and owned by Packet 7**, so today they check nothing. What actually - runs against your migration is the schema sweeps in + **Implemented** as of Packet 7 step 3 (`TenantScopingTests`): they read the EF + model and scan every migration source, so a marked entity with no filter, no + tenant key, or a table missing `ENABLE` + `FORCE` + one permissive policy with + both clauses fails the build. Alongside them, what runs against your migration is + the schema sweeps in `LearnStack.Tests.Integration`'s `TenancySchemaTests`: row security enabled *and* forced on every table in the catalogue, no second permissive policy for one command, snake_case identifiers, foreign-key indexing, and the exact grant matrix. diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index 56ec62d0..e0bb7d59 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -174,24 +174,36 @@ that cannot be seen locally**, and the two facts behind it are what you need: convention, with no filter at all. `TenancyDbContext` uses that call, so a `ThingConfiguration(ITenantContext ctx)` disappears rather than failing. -Together those rule out the obvious shape, which is why **Phase 02a Packet 7 -owns the filters** — it lands `TenantResolverMiddleware`, the value the filter -reads, and the two rules below, together. Do not invent a filter here ahead of -it; if your entity ships before Packet 7, say so in the PR and rely on RLS, -which is live from the migration that creates the table. - -`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` is what will make a forgotten -filter fail, and `Every_OrgScoped_Entity_HasOrgIdAndFilter` covers the -organization term. Both are **registered and not yet implemented** — Packet 7 -introduces them, so until then a forgotten filter is caught by review only. Check -the [catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) -rather than assuming a net is under you. - -One thing that is true whenever the filter does land: a second `HasQueryFilter` -call **replaces** the first rather than combining with it, so the tenant term, -the organization term and the soft-delete term go into one expression — and the -soft-delete term gates on `DeletedAt`, not the computed `IsDeleted` property, -which EF cannot translate. +Together those rule out the obvious shape. **Since Packet 7 step 3 you do not +write a filter at all** — you declare the entity's scope and the seam applies +one: + +1. Implement `ITenantOwned` (or `IOrganizationScoped`, which extends it) from + `LearnStack.SharedKernel.Persistence`. +2. Mark the class `[TenantOwned]`, adding `[OrganizationScoped]` when it carries + a nullable `OrganizationId`. +3. Make sure the module's `DbContext` derives from `TenantScopedDbContext`. Its + `OnModelCreating` sweeps the model and applies the filter to every entity + implementing those interfaces, closing over the **context instance member** + that is the whole point of the mechanism. + +Two entities take neither treatment, and both are table classes rather than +oversights: the tenant-owned **self-keyed** class carries +`[TenantOwned(SelfKeyed = true)]` and is filtered on its own `Id`, and a +**platform-scoped** table carries no marker at all +([Database Standards § Table classes](../../../docs/standards/05-database.md)). + +`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` and +`Every_OrgScoped_Entity_HasOrgIdAndFilter` are **implemented** as of that step and +will fail the build for a marked entity with no filter, no tenant key, or a +migration missing `ENABLE` + `FORCE` + one permissive policy with both clauses. +They cannot catch a **missing marker** — a marker-gated rule iterates what it +finds — so that one is still on you and on review. + +One thing that stays true: a second `HasQueryFilter` call **replaces** the first +rather than combining with it, so a soft-delete term has to go into the same +expression as the tenant term rather than beside it — and it gates on +`DeletedAt`, not the computed `IsDeleted` property, which EF cannot translate. ### Step 3: Migration — schema + RLS diff --git a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs index 27184bdd..b6655b28 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs @@ -21,17 +21,28 @@ namespace LearnStack.Infrastructure.Persistence; /// member per query and emits a parameter. /// Two_contexts_under_two_tenants_each_see_only_their_own_rows holds the /// property and fails against the baked-in form. -/// Measured, and not what one would guess: the baked-in failure here is -/// not "the first request's tenant, served to the second". The model is built on -/// first use, which in this codebase is reliably before any tenant is resolved, -/// so the literal that bakes in is the all-zero id and every query returns -/// zero rows for the life of the process. Fail-closed, and total — a bug that -/// looks like an empty database rather than like a leak. That is the better of -/// the two directions and still an outage. +/// +/// The baked-in failure has two directions, and which one a host gets depends +/// on who builds the model first. Whatever CurrentTenantId returns at +/// that moment is the literal every later query carries. +/// In the API host the first build is necessarily inside a request — the module +/// registration refuses to resolve a context outside the ambient transaction — so +/// the literal is a real tenant's id and every later request reads **that +/// tenant's rows**. Row Level Security still refuses to serve them, so it is a +/// zero-row outage rather than a leak, but the id in the WHERE clause +/// belongs to someone else. In a test or design-time host the first build is +/// unresolved, the literal is the all-zero id, and every query returns nothing +/// for the life of the process — which is what this repository measured, and why +/// the measurement alone would have named the wrong direction for production. +/// /// /// /// Implemented via , which every module context -/// derives from rather than restating. +/// derives from rather than restating. The extension that consumes this interface +/// additionally constrains its argument to a , because an +/// implementer that is not one cannot be a closure root EF re-evaluates — the +/// interface alone would let the mechanism be defeated by a caller that satisfied +/// its shape. /// /// public interface ITenantScopedDbContext @@ -90,14 +101,26 @@ public static class TenantQueryFilters /// Adds a filter to every entity type implementing , /// narrowing further for those implementing . /// - public static ModelBuilder ApplyTenantQueryFilters( - this ModelBuilder modelBuilder, ITenantScopedDbContext context) + public static ModelBuilder ApplyTenantQueryFilters( + this ModelBuilder modelBuilder, TContext context) + where TContext : DbContext, ITenantScopedDbContext { ArgumentNullException.ThrowIfNull(modelBuilder); ArgumentNullException.ThrowIfNull(context); foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { + // EF refuses a query filter on anything but a root entity type: an + // owned type is queried through its owner, and on a TPH/TPT hierarchy + // only the root may carry one. Skipping them here means the first + // module to model either does not discover the rule by exception at + // startup — and the root still gets its filter, which is what covers + // the derived rows. + if (entityType.BaseType is not null || entityType.IsOwned()) + { + continue; + } + var clrType = entityType.ClrType; if (clrType.GetCustomAttribute() is { SelfKeyed: true }) diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs index 00e9cad3..c7f70d9d 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs @@ -99,8 +99,12 @@ public void Every_TenantOwned_Entity_HasFilterAndRlsPolicy() [Fact] public void Every_OrgScoped_Entity_HasOrgIdAndFilter() { - var marked = ScopedEntities() - .Where(entity => entity.GetCustomAttribute() is not null) + // Enumerated by its own marker, not by filtering the tenant-owned set: an + // entity carrying only [OrganizationScoped] would otherwise be invisible to + // both rules, which is the one arrangement neither could report. + var marked = TenancyDomain.GetTypes() + .Where(type => type.GetCustomAttribute() is not null) + .OrderBy(type => type.Name, StringComparer.Ordinal) .ToList(); marked.Should().NotBeEmpty( @@ -114,15 +118,25 @@ public void Every_OrgScoped_Entity_HasOrgIdAndFilter() typeof(IOrganizationScoped).IsAssignableFrom(entity).Should().BeTrue( $"{entity.Name} carries [OrganizationScoped] and must expose OrganizationId"); - // Nullable, because null is a scope and not an absence: a row with no - // organization is tenant-wide (ADR-0017). - var property = entity.GetProperty(nameof(IOrganizationScoped.OrganizationId))!; - Nullable.GetUnderlyingType(property.PropertyType).Should().NotBeNull( - $"{entity.Name}.OrganizationId must be nullable — null means tenant-wide"); + // An organization exists only inside a tenant, so the two markers travel + // together. Asserted rather than assumed, because the tenant-owned rule + // enumerates its own marker and would not see this entity at all. + entity.GetCustomAttribute().Should().NotBeNull( + $"{entity.Name} is organization-scoped, so it is tenant-owned by construction"); var entityType = context.Model.FindEntityType(entity)!; var table = entityType.GetTableName()!; + // The mapped column, not the CLR property. `IOrganizationScoped` + // already declares `OrganizationId?`, so a reflection check on the + // property type cannot fail — it asserts the compiler. What can go + // wrong is a configuration marking the column required, which would + // make a tenant-wide row unrepresentable: null is a scope here, not an + // absence (ADR-0017). + entityType.FindProperty(nameof(IOrganizationScoped.OrganizationId))!.IsNullable + .Should().BeTrue( + $"{table}.organization_id must be nullable — null means tenant-wide"); + var members = RowMembersRead(entityType); members.Should().Contain(nameof(ITenantOwned.TenantId), $"{table}'s filter must still carry the tenant term"); diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index cbf36b7a..5ea2c65a 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -892,7 +892,6 @@ first two rows are coverage checks; the last three are the proof. - **Type:** xUnit + EF model inspection + migration SQL scan. **Kind:** structural. - **Status:** **Implemented** (Packet 7 step 3, `TenantScopingTests`) for the Tenancy module; Packet 10 closes it across every module. -- **Status:** **Registered.** - **Phase:** 02a (Packet 7 introduces, Packet 10 closes). #### `No_IgnoreQueryFilters_Outside_PlatformAdminScope` From 40372dbf89c77d5e6e782d3c032cb48c4bf9e4d9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 1 Sep 2026 21:27:37 +0300 Subject: [PATCH 11/55] fix(tenancy): close the Step 3 Sonnet round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round found Step 3's runtime behaviour correct and its own fix rounds' drift not. Three of the four worst items were in the files a next author reads first. add-backend-module told a scaffolder to write a DbContext that does not compile: its Step 2 reference graph never gained the core Infrastructure edge that Step 4's sample requires, and the comment above that sample still said a module context takes ONE constructor parameter because the registrar uses Activator.CreateInstance — contradicting the two-parameter class three lines below it and the registrar that moved to ActivatorUtilities in this very step. add-tenant-owned-entity said in Step 2 that both architecture rules are implemented and in Step 4 and Validation that nothing catches a missing filter automatically. A skill that contradicts itself inside one file is worse than one uniformly stale: the reader cannot tell which half to trust. The organization term had no test at any layer. Measured: flipping its OR to an AND survived all 960. It decides whether a tenant-wide row is visible to an organization-scoped request, which is the distinction ADR-0017 exists for. The fixture already seeded the three shapes needed, so the case is small and it kills the flip. Two rules gained their reverse directions. An entity implementing ITenantOwned without the marker is filtered at runtime while both scoping rules skip it, so its policy, tenant key and migration go unchecked; and core Infrastructure now has CoreInfrastructure_DoesNotDependOn_AnyModule, the half that keeps the new Module.Infrastructure edge one-way — core is referenced by every module, so a single edge back makes the graph cyclic. The edge itself is now written down: Standards 01 § Dependency Direction has the node, the arrow, a text fallback and the reason it may not be used for a capability of its own, and the Tenancy component diagram names it. 963 green, zero skips. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-backend-module/SKILL.md | 33 +++++++----- .../skills/add-tenant-owned-entity/SKILL.md | 53 ++++++++++--------- .../ModuleDbContextRegistration.cs | 5 +- .../Persistence/TenantQueryFilters.cs | 39 +++++++++----- .../ModuleDependencyTests.cs | 19 +++++++ .../TenantScopingTests.cs | 25 +++++++++ .../Database/UnitOfWorkTests.cs | 42 +++++++++++++++ docs/modules/tenancy/README.md | 6 ++- docs/standards/01-architecture-standards.md | 21 ++++++++ .../21-architecture-tests-catalogue.md | 17 ++++++ 10 files changed, 208 insertions(+), 52 deletions(-) diff --git a/.claude/skills/add-backend-module/SKILL.md b/.claude/skills/add-backend-module/SKILL.md index 3e979e8c..3e3f0392 100644 --- a/.claude/skills/add-backend-module/SKILL.md +++ b/.claude/skills/add-backend-module/SKILL.md @@ -82,10 +82,18 @@ flowchart LR Application --> Application.Contracts Application -. depends on .-> OtherModule.Application.Contracts Infrastructure --> Application + Infrastructure --> CoreInfrastructure[LearnStack.Infrastructure core] Infrastructure --> ProviderSDKs Application.Contracts --> SharedKernel ``` +`Infrastructure → LearnStack.Infrastructure` (core) is required, not optional: it +carries `TenantScopedDbContext`, the base your module's `DbContext` derives from in +Step 4. The seam lives there rather than in `SharedKernel` because it calls EF +model-building APIs, and SharedKernel's EF reference is sanctioned for Vogen-emitted +converters only. Omit the reference and Step 4's sample does not compile — the base +clause is the first thing that fails. + Forbidden references (architecture test will catch them): - Domain → Application / Infrastructure @@ -161,11 +169,12 @@ only three files under `backend/src` may mention `UseNpgsql` at all. In `LearnStack.Modules..Infrastructure/Persistence/DbContext.cs`: ```csharp -// ONE constructor parameter. `ModuleDbContextRegistration` builds every module -// context with `Activator.CreateInstance(typeof(TContext), options)`, so a second -// parameter throws `MissingMethodException` on first resolution — and -// `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` forbids registering the -// context any other way. This is the shape the one shipped context carries. +// `ModuleDbContextRegistration` builds every module context with +// `ActivatorUtilities.CreateInstance(provider, options)`, which passes the +// options explicitly and resolves every other constructor parameter from DI — which +// is how the accessor below arrives. `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` +// still forbids registering the context any other way. This is the shape the one +// shipped context carries. public sealed class DbContext( DbContextOptions<DbContext> options, ITenantContextAccessor accessor) : TenantScopedDbContext(options, accessor) @@ -193,13 +202,13 @@ public sealed class DbContext( } ``` -**How the instance member gets populated is Packet 7's choice**, and it is one of -two: switch `ModuleDbContextRegistration` to -`ActivatorUtilities.CreateInstance(provider, typeof(TContext), options)`, or read -the accessor off the application service provider the registrar already passes to -`UseApplicationServiceProvider`. Prefer the singleton `ITenantContextAccessor` -over an `ITenantContext` snapshot: EF parameterises `_accessor.Current` per query, -and it avoids baking in an `UnresolvedTenantContext` whose `TenantId` throws. +Packet 7 step 3 settled how the accessor arrives: `ModuleDbContextRegistration` +switched to `ActivatorUtilities.CreateInstance(provider, options)`, so DI +resolves it. The **accessor** and not an injected `ITenantContext`, and that is the +load-bearing half — the context contract is registered transient and resolved from +this same accessor, so a context holding one freezes whatever the accessor held at +construction and never moves again. Measured: a context built under tenant A kept +filtering to A after the accessor moved to B. Per [05-database.md](../../../docs/standards/05-database.md), one `DbContext` per module — not one global. diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index e0bb7d59..c611061b 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -354,27 +354,29 @@ migrations run as `learnstack_migration`, which owns the table. Integration test this entity must connect as `learnstack_app` — a test that connects as the owner passes against an inert policy and proves nothing. -### Step 4: Architecture test (registered, not yet implemented) +### Step 4: Architecture test (implemented for Tenancy; Packet 10 closes it) -**Nothing catches a missing filter automatically today.** An earlier version of -this step said the opposite and named three rules — -`Every_TenantOwned_Entity_Has_TenantId`, -`Every_TenantOwned_Table_HasRls_With_AppTenantId` and -`Every_OrgScoped_Entity_HasOrgIdAndFilter` — of which the first two exist nowhere -in the catalogue or the test tree. A developer who reached this step was told the -work was done. - -The two rules the catalogue actually registers for this surface are `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` and -`Every_OrgScoped_Entity_HasOrgIdAndFilter`, both **Registered** and owned by -Phase 02a Packet 7. What *is* implemented and will catch part of this today: -`Every_Foreign_Key_Has_A_Supporting_Index` and the schema sweeps in -`TenancySchemaTests` — row security enabled *and* forced on every table in the -catalogue, no second permissive policy for one command, snake_case identifiers, -and the exact grant matrix. Those run against the applied schema, so they cover -your migration the moment it lands. - -Until Packet 7, the isolation test in Step 5 is the net, and it is the only one. +`Every_OrgScoped_Entity_HasOrgIdAndFilter` are **implemented** as of Phase 02a +Packet 7 step 3, in `TenantScopingTests`. For a **marked** entity they fail the +build on a missing tenant key, a missing or non-narrowing query filter, a mapped +`organization_id` that is not nullable, or a migration lacking `ENABLE` + `FORCE` +plus exactly one permissive policy with both a `USING` and a `WITH CHECK` clause — +and, for an organization-scoped table, either `AS RESTRICTIVE` write guard. + +Two gaps remain, and both are yours to close by hand: + +- **A marker-gated rule cannot catch a missing marker.** It iterates what it + finds. An entity you forget to mark is invisible to both rules, and the + isolation test in Step 5 is the net for it. +- **The reflection scope is the Tenancy domain assembly** until Packet 10 widens + it across every module. + +Also live against your migration: `Every_Foreign_Key_Has_A_Supporting_Index` and +the schema sweeps in `TenancySchemaTests` — row security enabled *and* forced, +no second permissive policy for one command, snake_case identifiers, and the exact +grant matrix. Those run against the applied schema. + If you're adding a *new* marker attribute or a *new* isolation pattern, write a new architecture test (see [add-architecture-test](../add-architecture-test/SKILL.md)). @@ -447,11 +449,14 @@ See [add-integration-test](../add-integration-test/SKILL.md). tenant-wide rows. The default is `nullable + tenant-wide allowed`. - **`Entity` instead of `AuditableEntity<Id>`.** You lose `created_at` / `updated_at` automation. Only the audit aggregate uses `Entity`. -- **A query filter closing over anything but a `DbContext` instance member.** - EF caches the model, so the value is baked in as a literal and every later - request answers with the first tenant that built it. Under RLS that is a silent - zero-rows outage. See Step 2 — and note there is no convention adding a filter - for you, in either direction. +- **Writing a query filter at all.** Since Packet 7 step 3 the module's + `TenantScopedDbContext` base applies one for every entity implementing + `ITenantOwned` / `IOrganizationScoped`; your job is the interface and the marker. + A hand-written one closing over anything but a `DbContext` instance member is + baked into EF's cached model as a literal, and every later request carries + whichever tenant built the model first — under RLS a zero-rows outage rather + than a leak, which is harder to diagnose, not safer. There is no convention + adding a filter for you either, in either direction. - **No isolation test.** The schema sweeps catch a missing or mis-shaped *policy*; they cannot catch a policy that is well-formed and wrong. An explicit `TenantA_cannot_read_TenantB` test — connecting as `learnstack_app`, against a diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs index f176032c..a32b0b0a 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -142,8 +142,9 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle // ActivatorUtilities, not Activator: a module context takes its // DbContextOptions plus whatever else it needs from DI — - // TenantScopedDbContext takes ITenantContext, because its query - // filters close over it. Activator.CreateInstance can only pass the + // TenantScopedDbContext takes ITenantContextAccessor, because its + // query filters read it on every access rather than holding a context + // captured at construction. Activator.CreateInstance can only pass the // options, so it would fail to construct any context with a second // parameter, which is now every tenant-scoped one. var context = ActivatorUtilities.CreateInstance(provider, options); diff --git a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs index b6655b28..78ca026f 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs @@ -152,18 +152,6 @@ public static ModelBuilder ApplyTenantQueryFilters( return modelBuilder; } - /// - /// e => e.TenantId == context.CurrentTenantId, and for an - /// organization-scoped entity - /// && (e.OrganizationId == null || e.OrganizationId == context.CurrentOrganizationId). - /// - /// - /// Built as an expression tree rather than written as a lambda because the - /// entity type is only known at model-building time. The shape is exactly what - /// the compiler emits for a lambda closing over a context property — a member - /// access rooted at a constant holding the context — which is the shape EF - /// re-evaluates per query. - /// /// /// e => e.Id == context.CurrentTenantId for the tenant-owned /// self-keyed class. @@ -179,13 +167,25 @@ private static LambdaExpression BuildSelfKeyedFilter( return Expression.Lambda( Expression.Equal( - Expression.Property(entity, "Id"), + Expression.Property(entity, nameof(IHasId.Id)), Expression.Property( Expression.Constant(context), nameof(ITenantScopedDbContext.CurrentTenantId))), entity); } + /// + /// e => e.TenantId == context.CurrentTenantId, and for an + /// organization-scoped entity + /// && (e.OrganizationId == null || e.OrganizationId == context.CurrentOrganizationId). + /// + /// + /// Built as an expression tree rather than written as a lambda because the + /// entity type is only known at model-building time. The shape is exactly what + /// the compiler emits for a lambda closing over a context property — a member + /// access rooted at a constant holding the context — which is the shape EF + /// re-evaluates per query. + /// private static LambdaExpression BuildFilter(Type clrType, ITenantScopedDbContext context) { var entity = Expression.Parameter(clrType, "e"); @@ -271,6 +271,19 @@ public abstract class TenantScopedDbContext( ? organization : null; + /// Applies the tenant filters to every entity the model holds. + /// + /// A subclass overriding this calls base.OnModelCreating LAST. The + /// sweep reads modelBuilder.Model.GetEntityTypes(), so anything a fluent + /// configuration introduces that is not reachable from a DbSet<T> + /// property — an entity mapped only by ApplyConfigurationsFromAssembly, a + /// keyless query type, an owned type declared there — is not in the model yet if + /// the base runs first, and silently gets no filter. Omitting the call entirely + /// loses every filter, which + /// Every_TenantOwned_Entity_HasFilterAndRlsPolicy does catch; calling it + /// too early loses only the late arrivals, which nothing catches until one + /// exists. + /// protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); diff --git a/backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs b/backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs index 2ccd6f13..8eff9a26 100644 --- a/backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs @@ -143,4 +143,23 @@ private static Assembly LoadModuleAssembly(string moduleName, string layer) ex); } } + [Fact] + public void CoreInfrastructure_DoesNotDependOn_AnyModule() + { + // The one-way half of the edge Packet 7 step 3 introduced. A module's + // Infrastructure may reference core Infrastructure — that is where + // TenantScopedDbContext lives, and every tenant-owned module derives from + // it. The reverse would close the loop: core Infrastructure is referenced + // by every module, so a single edge back into one makes the whole graph + // cyclic and makes that module impossible to extract. + var result = Types + .InAssembly(typeof(LearnStack.Infrastructure.Persistence.TenantQueryFilters).Assembly) + .Should() + .NotHaveDependencyOn("LearnStack.Modules") + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + "core Infrastructure must reference no module: " + + string.Join(", ", result.FailingTypeNames ?? [])); + } } diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs index c7f70d9d..5c577441 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs @@ -162,6 +162,31 @@ public void Every_OrgScoped_Entity_HasOrgIdAndFilter() } } + [Fact] + public void Every_Scoping_Interface_Carries_Its_Marker() + { + // The reverse direction. The two rules above enumerate the markers and + // check the interfaces; nothing checked that an entity implementing the + // interfaces also carries the marker. An entity in that state is filtered + // at runtime — the sweep gates on the interface — while both rules skip + // it entirely, so its RLS policy, its tenant key and its migration go + // unchecked. The pair has to travel together in both directions. + foreach (var entity in TenancyDomain.GetTypes() + .Where(typeof(ITenantOwned).IsAssignableFrom) + .Where(type => type is { IsInterface: false, IsAbstract: false })) + { + entity.GetCustomAttribute().Should().NotBeNull( + $"{entity.Name} implements ITenantOwned, so it must carry [TenantOwned] " + + "— without it both scoping rules skip it while the filter still applies"); + + if (typeof(IOrganizationScoped).IsAssignableFrom(entity)) + { + entity.GetCustomAttribute().Should().NotBeNull( + $"{entity.Name} implements IOrganizationScoped and must say so"); + } + } + } + [Fact] public void The_Host_Map_Carries_No_Tenant_Marker() { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index fdb7280a..ca25981b 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -263,6 +263,48 @@ await EnterTenantAsync( .Which.Value.Should().Be(SchemaFixture.TenantB); } + [Fact] + public async Task The_organization_filter_admits_tenant_wide_rows_and_the_caller_own() + { + // The organization term — `organization_id IS NULL OR organization_id = + // current` — had no test at any layer. Measured before this case existed: + // flipping its OR to an AND survived all 960. The term is the one that + // decides whether a tenant-wide row is visible to an organization-scoped + // request, which is the distinction ADR-0017 exists for. + // + // The fixture seeds exactly the three shapes tenant A needs: one + // tenant-wide setting (`tz`), one under OrgA1 (`theme`), one under OrgA2 + // (`theme`). Under OrgA1 the answer is the first two and not the third. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await EnterTenantAsync( + scope.ServiceProvider, unitOfWork, SchemaFixture.TenantA, SchemaFixture.OrgA1); + + var context = scope.ServiceProvider.GetRequiredService(); + + var visible = await context.TenantSettings + .Select(setting => new { setting.Key, setting.OrganizationId }) + .ToListAsync(); + + visible.Should().HaveCount(2, + "an organization sees the tenant-wide rows and its own, and no sibling's"); + visible.Should().Contain(setting => setting.OrganizationId == null, + "the tenant-wide row is in scope — null is a scope, not an absence"); + visible.Should().Contain( + setting => setting.OrganizationId != null + && setting.OrganizationId.Value.Value == SchemaFixture.OrgA1, + "the caller's own organization is in scope"); + visible.Should().NotContain( + setting => setting.OrganizationId != null + && setting.OrganizationId.Value.Value == SchemaFixture.OrgA2, + "a sibling organization's row is not"); + + await unitOfWork.RollbackAsync(); + } + [Fact] public async Task A_context_follows_the_accessor_after_it_was_built() { diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 81462052..d4d712f4 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -231,6 +231,7 @@ flowchart LR INF[Infrastructure
TenancyDbContext] end SK[SharedKernel
TenantId, OrganizationId, IUnitOfWork] + CORE[Core Infrastructure
TenantScopedDbContext] PG[(PostgreSQL
8 tables, RLS)] HUB[Hub adapters
IEntitlementProvider, IHubTenantSync] OTHER[Other modules] @@ -238,6 +239,7 @@ flowchart LR DOM --> SK APP --> DOM INF --> APP + INF --> CORE INF --> PG HUB -.-> APP OTHER -.->|application contract only| APP @@ -247,7 +249,9 @@ Text fallback — **components**: Tenancy is three assemblies — `Domain` (the `Tenant` and `Organization` aggregates), `Application`, and `Infrastructure` (`TenancyDbContext`). `Domain` depends on `SharedKernel` for `TenantId`, `OrganizationId` and `IUnitOfWork`; `Application` on `Domain`; `Infrastructure` -on `Application` and on PostgreSQL. The Hub adapters (`IEntitlementProvider`, +on `Application`, on core `LearnStack.Infrastructure` — where +`TenantScopedDbContext`, the base `TenancyDbContext` derives from, applies the +query filters — and on PostgreSQL. The Hub adapters (`IEntitlementProvider`, `IHubTenantSync`) and every other module reach `Application` and nothing deeper. Other modules reach Tenancy **only** through an application contract in diff --git a/docs/standards/01-architecture-standards.md b/docs/standards/01-architecture-standards.md index bd0bda04..f2cbe4b7 100644 --- a/docs/standards/01-architecture-standards.md +++ b/docs/standards/01-architecture-standards.md @@ -48,6 +48,7 @@ flowchart LR domain[Module.Domain] infra[Module.Infrastructure] kernel[SharedKernel] + coreInfra[Core LearnStack.Infrastructure] otherContracts[Other Module.Application.Contracts] providers[Provider SDKs] @@ -56,10 +57,30 @@ flowchart LR app --> contracts app --> otherContracts infra --> app + infra --> coreInfra infra --> providers contracts --> kernel ``` +Text fallback — a module's `Domain` depends on `SharedKernel`; its `Application` on its +own `Domain`, its own `Application.Contracts` and other modules' contracts; its +`Infrastructure` on its own `Application`, on **core `LearnStack.Infrastructure`**, and +on provider SDKs; and `Application.Contracts` on `SharedKernel`. + +**`Module.Infrastructure → LearnStack.Infrastructure` is permitted, and narrowly.** It +carries the shared persistence seams every tenant-owned module derives from rather than +restates — `TenantScopedDbContext` and the query-filter mechanism it applies +([ADR-0040](../decisions/0040-ambient-unit-of-work.md); +[ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md)). Those +call EF model-building APIs, which `SharedKernel` is not sanctioned to do — its EF +reference is scoped to Vogen-emitted converters +([ADR-0023](../decisions/0023-strongly-typed-id-source-generator.md), and § Build-time-only +exceptions below) — so the seam has nowhere else to live. The edge is one-way: core +`LearnStack.Infrastructure` references no module, which is what keeps it acyclic, and +`CoreInfrastructure_DoesNotDependOn_AnyModule` holds that. A module reaching into core +Infrastructure for a **capability of its own** rather than for a shared seam is the +misuse this sentence exists to name. + Forbidden edges: - Domain → Application - Domain → Infrastructure diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 5ea2c65a..5721bcfe 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -832,6 +832,23 @@ first two rows are coverage checks; the last three are the proof. - **Status:** **Awaiting backfill** — cited by the standard, no dispatcher yet. **Phase:** 02b. +#### `CoreInfrastructure_DoesNotDependOn_AnyModule` + +- **Asserts:** the core `LearnStack.Infrastructure` assembly references no + `LearnStack.Modules.*` type. The reverse edge — a module's `Infrastructure` + referencing core Infrastructure — is permitted and required, because + `TenantScopedDbContext` and the query-filter seam live there + ([Architecture Standards § Dependency Direction](01-architecture-standards.md)). + This rule is the half that keeps it one-way: core Infrastructure is referenced by + every module, so a single edge back into one makes the graph cyclic and makes that + module impossible to extract. +- **Source:** [ADR-0002](../decisions/0002-initial-architecture.md); + [ADR-0010](../decisions/0010-cross-module-communication.md); + [Architecture Standards § Dependency Direction](01-architecture-standards.md). +- **Type:** NetArchTest. **Kind:** structural. +- **Status:** **Implemented** (Packet 7 step 3, `ModuleDependencyTests`). +- **Phase:** 02a Packet 7. + #### `Platform_DataSource_Resolved_Only_By_PlatformAdminScope` - **Asserts:** the keyed `NpgsqlDataSource` built from `ConnectionStrings:PlatformAdmin` From 4ab91bf305379577a4eb8f3709001753a1732f27 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 01:19:00 +0300 Subject: [PATCH 12/55] feat(tenancy): host resolution and classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read that happens before any tenant exists, and the middleware that decides which requests need it. CachedHostToTenantResolver opens a short transaction of its own — not a module DbContext, not the ambient IUnitOfWork, both of which refuse to exist before one — announces the host through app.resolving_host, and reads the one row the policy then admits. The announcement must be set_config's function form: SET takes no bind parameter, so the parameterised spelling every other query uses is unavailable, and interpolating a host into SET on the anonymous page-load path would be an injection site. Removing the announcement makes three of the eight resolution cases fail, which is the mechanism being held rather than described. Both flags gate the answer. Active and publicly live are distinct states — the row exists from submission onward, before DNS points anywhere — and reading only one is how a guessed hostname serves an unlaunched tenant's catalog to a stranger. ADR-0036 invalidates this cache on the transaction that flips either flag, which is only meaningful if both feed the answer. The two answers go down two paths. Found ones through ICacheService; unknown ones through a structure capped on its own, because the shared cache is one process-wide pool trimmed oldest-first across every family and a stored null never reads back as a hit there — routing negatives through it would buy eviction and no cache. The split forfeits GetOrSetAsync's single flight, so the resolver re-adds coalescing: one round trip per host however many callers arrive, retired on the flight's termination rather than on a caller's exit, which is the shape Packet 5 convicted in InMemoryCacheService. HostClassificationMiddleware runs over /api/v1 only, before authentication, with the exclusions as a prefix list — a closed list of endpoint literals would 404 the entire Hub contract surface the first time it grew a route. An unknown host gets a bodyless 404 that is byte-identical to the routing 404 for the same path, because anything a caller can tell apart confirms which hostnames exist. The platform branch short-circuits before any database work, and that goes all the way down: the resolver holds Lazy, so constructing it builds nothing. Without that, landing classification broke every Docker-free host suite in the assembly — they run on localhost, which is a platform host, and were paying for a data source they never used. 1003 green, zero skips. Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/LearnStack.Api/Program.cs | 7 + .../Tenancy/HostClassification.cs | 91 +++++++ .../Tenancy/HostClassificationMiddleware.cs | 192 +++++++++++++++ .../Tenancy/PlatformHostOptions.cs | 63 +++++ .../Tenancy/TenancyCompositionExtensions.cs | 29 +++ .../appsettings.Development.json | 5 + .../CachedHostToTenantResolver.cs | 224 +++++++++++++++++ .../MultiTenancy/UnknownHostCache.cs | 133 ++++++++++ .../Tenancy/IHostToTenantResolver.cs | 60 +++++ .../Database/HostResolutionTests.cs | 229 ++++++++++++++++++ .../HostClassificationHttpTests.cs | 206 ++++++++++++++++ .../Tenancy/HostClassificationScopeTests.cs | 129 ++++++++++ .../MultiTenancy/UnknownHostCacheTests.cs | 116 +++++++++ .../21-architecture-tests-catalogue.md | 8 +- 14 files changed, 1491 insertions(+), 1 deletion(-) create mode 100644 backend/src/LearnStack.Api/Tenancy/HostClassification.cs create mode 100644 backend/src/LearnStack.Api/Tenancy/HostClassificationMiddleware.cs create mode 100644 backend/src/LearnStack.Api/Tenancy/PlatformHostOptions.cs create mode 100644 backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs create mode 100644 backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/IHostToTenantResolver.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index 180373b7..c8764d5f 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -87,6 +87,13 @@ // an edge concern — APISIX blocks or allow-lists /openapi per environment. app.MapLearnStackOpenApi(); +// Which host is this /api/v1 request for? Before authentication, so an unknown +// host is refused before any token is validated (ADR-0036 § Rules) — and after +// the rate limiter, so a flood of novel hostnames is bounded before it buys a +// Postgres transaction each. Context construction runs later, after +// authentication, so the factory sees both signals at once. +app.UseLearnStackHostClassification(); + // X-Tenant-Id / X-Organization-Id are assertions: compared against what the // API resolved, never a source of it (ADR-0036). Registered after // MapLearnStackClientErrors so a rejection gets the one Problem Details shape, diff --git a/backend/src/LearnStack.Api/Tenancy/HostClassification.cs b/backend/src/LearnStack.Api/Tenancy/HostClassification.cs new file mode 100644 index 00000000..81256891 --- /dev/null +++ b/backend/src/LearnStack.Api/Tenancy/HostClassification.cs @@ -0,0 +1,91 @@ +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Api.Tenancy; + +/// +/// Which of the three serving classes a request's effective host falls into. +/// +/// +/// The fourth class UnknownHost has no member here on purpose: it never +/// reaches a downstream reader, because +/// answers it 404 before any +/// handler runs (ADR-0036 § The reconciliation matrix, row 1). Representing it +/// would invite a branch on it somewhere that cannot be reached. +/// +public enum HostClass +{ + /// A row with organization_id IS NULL — the tenant's own site. + Tenant, + + /// The same, with an organization — one branch's site. + Organization, + + /// + /// A host in Tenancy:PlatformHosts. The Studio / Portal entry host; it + /// maps to no tenant, so it needs no row. + /// + Platform, +} + +/// +/// What decided about this request. +/// +/// +/// +/// Stored on HttpContext.Features rather than Items: a feature is +/// typed, has one writer, and is what the resolver middleware reads one step +/// later. ADR-0036 § Rules splits the single step architecture/27 once described +/// as "the resolver first, before JWT validation" into two — classification +/// before authentication, context construction after it — and this record is what +/// crosses between them. +/// +/// +/// It is not a tenant context and must never be mistaken for one. A +/// classification says which host was addressed; it carries no authority. The +/// authority ceiling is TenantContextOrigin, and the context itself is +/// built only by TenantContextFactory. +/// +/// +public sealed record HostClassification +{ + private HostClassification( + HostClass @class, string host, TenantId? tenantId, OrganizationId? organizationId) + { + Class = @class; + Host = host; + TenantId = tenantId; + OrganizationId = organizationId; + } + + public HostClass Class { get; } + + /// The normalized effective host this decision was made about. + public string Host { get; } + + /// null only for . + public TenantId? TenantId { get; } + + /// Set only for . + public OrganizationId? OrganizationId { get; } + + /// A host that maps to no tenant, by configuration. + public static HostClassification Platform(string host) + { + ArgumentException.ThrowIfNullOrWhiteSpace(host); + return new HostClassification(HostClass.Platform, host, null, null); + } + + /// A host that resolved, to a tenant and possibly to an organization. + public static HostClassification ForResolution(string host, HostResolution resolution) + { + ArgumentException.ThrowIfNullOrWhiteSpace(host); + ArgumentNullException.ThrowIfNull(resolution); + + return new HostClassification( + resolution.OrganizationId is null ? HostClass.Tenant : HostClass.Organization, + host, + resolution.TenantId, + resolution.OrganizationId); + } +} diff --git a/backend/src/LearnStack.Api/Tenancy/HostClassificationMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/HostClassificationMiddleware.cs new file mode 100644 index 00000000..6a1badee --- /dev/null +++ b/backend/src/LearnStack.Api/Tenancy/HostClassificationMiddleware.cs @@ -0,0 +1,192 @@ +using System.Diagnostics.Metrics; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Api.Tenancy; + +/// +/// Decides which host a /api/v1/* request is for, and answers 404 +/// when it is for none. +/// +/// +/// +/// Before authentication, and that is deliberate. An unknown host is +/// rejected cheaply, before any token is validated or any handler runs +/// (ADR-0036 +/// § Rules). Context construction runs after authentication, so +/// TenantContextFactory sees both signals at once; this middleware +/// contributes only the first of them. +/// +/// +/// 404, never 403, and never a body that distinguishes. Saying "unknown +/// tenant" confirms which hostnames exist. The rejection is counted on an +/// unlabelled counter and never recorded durably: the host is attacker-authored +/// on every anonymous request, so writing it anywhere retained hands a stranger a +/// pen. +/// +/// +public sealed class HostClassificationMiddleware +{ + /// The one prefix classification applies to. + public const string ClassifiedPrefix = "/api/v1"; + + /// + /// Prefixes classification does not apply to. + /// + /// + /// A prefix list, not endpoint literals. A closed allow-list of literals + /// would 404 the entire Hub contract surface the first time it grew a route — + /// /api/internal/* is a whole surface with its own resolver + /// (HubCorrelationMiddleware, Phase 02c) and its tenant comes from the + /// envelope's path segment, not from a host. + /// Host_Classification_Applies_To_Tenant_Facing_Routes_Only asserts the + /// shape as prefixes for that reason. + /// + public static readonly IReadOnlyList UnclassifiedPrefixes = + [ + "/healthz", + "/readyz", + "/openapi", + "/admin/hangfire", + "/api/internal", + ]; + + /// Unknown hosts, unlabelled — the host itself is never a dimension. + public const string RejectedCounterName = "learnstack_host_classification_rejected_total"; + + private readonly RequestDelegate _next; + private readonly EffectiveHostAccessor _hosts; + private readonly IHostToTenantResolver _resolver; + private readonly HashSet _platformHosts; + private readonly ILogger _logger; + private readonly Counter _rejected; + + public HostClassificationMiddleware( + RequestDelegate next, + EffectiveHostAccessor hosts, + IHostToTenantResolver resolver, + PlatformHostOptions platformHosts, + ILogger logger, + IMeterFactory meterFactory) + { + ArgumentNullException.ThrowIfNull(platformHosts); + ArgumentNullException.ThrowIfNull(meterFactory); + + _next = next; + _hosts = hosts; + _resolver = resolver; + _logger = logger; + + // Validated here rather than at first request: a malformed entry is a + // deployment mistake, and the only useful moment to say so is boot. + _platformHosts = platformHosts.Validate(); + + _rejected = meterFactory + .Create(LoggingTenantAssertionRecorder.MeterName) + .CreateCounter(RejectedCounterName); + } + + public async Task InvokeAsync(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!ClassifiesPath(context.Request.Path)) + { + await _next(context); + return; + } + + var host = _hosts.For(context); + + if (host is null) + { + // The host could not name one at all — over-long, an IP literal, a + // percent-escape, an unparseable IDN. Indistinguishable on the wire + // from a host that named nothing, deliberately. + await RejectAsync(context, "unnamed"); + return; + } + + // The platform branch first, and before any database work. A platform host + // maps to no tenant by configuration, so resolving it would be one wasted + // transaction per request on the operator's own entry point — and, in the + // Docker-free host suites, a transaction against a database that is not + // there. + if (_platformHosts.Contains(host)) + { + context.Features.Set(HostClassification.Platform(host)); + await _next(context); + return; + } + + var resolution = await _resolver.ResolveAsync(host, context.RequestAborted); + + if (resolution is null) + { + await RejectAsync(context, host); + return; + } + + context.Features.Set(HostClassification.ForResolution(host, resolution)); + await _next(context); + } + + /// + /// Whether classification applies to . + /// + /// + /// Public because it is the rule + /// Host_Classification_Applies_To_Tenant_Facing_Routes_Only asserts, and + /// a test that drove it through the middleware would need a resolver and a + /// database to observe a predicate that touches neither. + /// + public static bool ClassifiesPath(PathString path) + { + foreach (var prefix in UnclassifiedPrefixes) + { + if (path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return path.StartsWithSegments(ClassifiedPrefix, StringComparison.OrdinalIgnoreCase); + } + + private async Task RejectAsync(HttpContext context, string host) + { + _rejected.Add(1); + + // The host at Debug and nowhere else. It is attacker-authored on every + // anonymous request, so it does not belong at a level anything ships to a + // retained sink — ADR-0036 keeps attacker-authored strings out of + // audit_log, and the same argument applies to an Information log an + // operator forwards. + LogRejected(_logger, host, null); + + // Bodyless: UseStatusCodePages, registered above this middleware, renders + // the one Problem Details shape. Writing a body here would be a second + // writer and — worse — a different one from the routing 404 an unmapped + // path produces, which is exactly the bit an anonymous caller must not be + // able to tell apart. + context.Response.StatusCode = StatusCodes.Status404NotFound; + await Task.CompletedTask; + } + + private static readonly Action LogRejected = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, nameof(LogRejected)), + "Host classification rejected {Host}: no active, publicly-live mapping."); +} + +/// Registration for . +public static class HostClassificationMiddlewareExtensions +{ + public static IApplicationBuilder UseLearnStackHostClassification(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + return app.UseMiddleware(); + } +} diff --git a/backend/src/LearnStack.Api/Tenancy/PlatformHostOptions.cs b/backend/src/LearnStack.Api/Tenancy/PlatformHostOptions.cs new file mode 100644 index 00000000..4dba4d73 --- /dev/null +++ b/backend/src/LearnStack.Api/Tenancy/PlatformHostOptions.cs @@ -0,0 +1,63 @@ +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Api.Tenancy; + +/// +/// The short static list of hosts that map to no tenant — +/// Tenancy:PlatformHosts. +/// +/// +/// +/// The Studio / Portal entry host, and in development localhost. A request +/// arriving on one of these is classified and +/// never reaches the resolver, so it costs no database round trip. +/// +/// +/// Not a second mapping authority. platform_host_to_tenant remains +/// the only answer to "which tenant is this host?"; this list only names hosts +/// that have no answer, which is why it can be configuration rather than data +/// (ADR-0036 § Neutral). +/// +/// +/// Every entry is validated at startup, and the host refuses to boot on one +/// that EffectiveHost.Normalize does not return unchanged. The comparison +/// downstream is ordinal against an already-normalized effective host, so an entry +/// spelled LocalHost or app.learnstack.dev. would silently match +/// nothing — a platform host that quietly becomes an unknown host is a 404 on the +/// operator's own entry point, discovered in production. +/// +/// +public sealed class PlatformHostOptions +{ + /// The configuration section this binds to. + public const string SectionName = "Tenancy:PlatformHosts"; + + /// The configured hosts, empty when the section is absent. + public IReadOnlyList Hosts { get; init; } = []; + + /// + /// Throws when any entry is not a host EffectiveHost.Normalize returns + /// unchanged. + /// + /// The validated set, for ordinal lookup. + public HashSet Validate() + { + var offenders = Hosts + .Where(host => EffectiveHost.Normalize(host) != host) + .ToList(); + + if (offenders.Count > 0) + { + throw new InvalidOperationException( + $"{SectionName} contains {offenders.Count} entry/entries that are not " + + $"normalized effective hosts: {string.Join(", ", offenders)}. " + + "Each must be lowercase, punycoded, without a port and without a trailing " + + "dot — the form EffectiveHost.Normalize produces — because the runtime " + + "comparison is ordinal against an already-normalized host. An entry in any " + + "other spelling matches nothing and turns the platform entry point into a " + + "404 that only production reveals."); + } + + return new HashSet(Hosts, StringComparer.Ordinal); + } +} diff --git a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs index 6d714274..345e3848 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs @@ -1,6 +1,9 @@ +using LearnStack.Infrastructure.MultiTenancy; using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Tenancy; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Npgsql; namespace LearnStack.Api.Tenancy; @@ -145,6 +148,32 @@ public static IServiceCollection AddLearnStackTenancyEdge( ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); + // Tenancy:PlatformHosts — the hosts that map to no tenant. Bound and + // validated here so a malformed entry fails the boot rather than turning + // the operator's own entry point into a 404 nobody sees until production. + var platformHosts = new PlatformHostOptions + { + Hosts = configuration.GetSection(PlatformHostOptions.SectionName).Get() ?? [], + }; + + platformHosts.Validate(); + services.AddSingleton(platformHosts); + + // Singleton, so the resolver's in-flight map is process-wide: a scoped one + // gives every request its own and coalesces nothing. + services.AddSingleton(new UnknownHostCacheOptions()); + services.AddSingleton(new HostResolutionOptions()); + services.AddSingleton(); + + // Lazy, so the resolver's construction builds no data source: a request on + // a platform host is answered from configuration and must cost nothing + // below it. The composition root already registers the data source as a + // factory rather than an instance, so this preserves that deferral instead + // of collapsing it at the first classified request. + services.AddSingleton(provider => + new Lazy(provider.GetRequiredService)); + services.AddSingleton(); + services.Configure( configuration.GetSection(TrustedHopOptions.SectionName)); diff --git a/backend/src/LearnStack.Api/appsettings.Development.json b/backend/src/LearnStack.Api/appsettings.Development.json index 2088d766..b2bcc427 100644 --- a/backend/src/LearnStack.Api/appsettings.Development.json +++ b/backend/src/LearnStack.Api/appsettings.Development.json @@ -8,5 +8,10 @@ }, "Deployment": { "Mode": "Development" + }, + "Tenancy": { + "PlatformHosts": [ + "localhost" + ] } } diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs new file mode 100644 index 00000000..a274cf06 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs @@ -0,0 +1,224 @@ +using System.Collections.Concurrent; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using Npgsql; + +namespace LearnStack.Infrastructure.MultiTenancy; + +/// +/// Reads platform_host_to_tenant for one host, caching both answers — the +/// found one through , the unknown one through a +/// structure capped on its own. +/// +/// +/// +/// , not a module DbContext and not +/// IUnitOfWork. This runs in host classification, before +/// authentication and before any transaction exists — and the shared registration +/// helper throws by design when a module context is resolved outside the ambient +/// transaction, because a context that never saw SET LOCAL reads zero rows +/// from every tenant-owned table. +/// ADR-0040 +/// § Who sets app.tenant_id already puts every pre-transaction reader +/// on "a short transaction of its own on its own connection"; this is that, and +/// this class is the only setter of app.resolving_host. +/// +/// +/// Registered a singleton, so the flight map below is process-wide. A +/// scoped registration gives every request its own map and coalesces nothing, +/// which is the whole of what the map is for. +/// +/// +public sealed class CachedHostToTenantResolver( + ICacheService cache, + UnknownHostCache unknownHosts, + HostResolutionOptions options, + Lazy dataSource) : IHostToTenantResolver +{ + private readonly ConcurrentDictionary>> _flights = + new(StringComparer.Ordinal); + + private readonly ICacheService _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + + private readonly UnknownHostCache _unknownHosts = + unknownHosts ?? throw new ArgumentNullException(nameof(unknownHosts)); + + private readonly HostResolutionOptions _options = + options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Lazy so that constructing the resolver builds no data source. + /// + /// + /// A platform host — the Studio entry point, localhost in development — + /// is answered by configuration and never reaches a lookup, so it must cost no + /// database work. Holding the data source directly would build it when this + /// singleton is constructed, which is at the first classified request whatever + /// its host, and would make a deployment that only ever serves platform hosts + /// require a runtime credential it never uses. The build is already deferred on + /// the other side — the composition root registers a factory, not an instance — + /// so this keeps that deferral rather than defeating it. + /// + private readonly Lazy _dataSource = + dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + + /// + public async Task ResolveAsync( + string host, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(host); + + // Composed by the factory, never interpolated: CacheKey.EnsureValid is what + // stops an unnormalized spelling creating a parallel entry, and it refuses + // an IP literal outright. + var key = CacheKey.ForHostMapping(host); + + if (await _cache.GetAsync(key, cancellationToken) is { } cached) + { + return cached; + } + + if (_unknownHosts.Contains(host)) + { + return null; + } + + var resolution = await ReadCoalescedAsync(host, cancellationToken); + + if (resolution is null) + { + _unknownHosts.Add(host); + return null; + } + + await _cache.SetAsync(key, resolution, _options.PositiveCache, cancellationToken); + return resolution; + } + + /// + /// One database round trip per host, however many callers arrive during it. + /// + /// + /// Get-then-set has no factory for GetOrSetAsync to coalesce, so N + /// simultaneous first requests for one cold host would be N transactions. The + /// flight runs on : one caller hanging up + /// must not cancel the lookup the others are waiting on. + /// WaitAsync(cancellationToken) stops only this caller waiting, + /// and a caller that stops waiting never reaches the negative-cache write above + /// — so that structure is populated only by a request that survived its own + /// lookup. + /// + private async Task ReadCoalescedAsync( + string host, CancellationToken cancellationToken) + { + var flight = _flights.GetOrAdd( + host, + static (h, self) => new Lazy>( + () => self.ReadAndRetireAsync(h)), + this); + + return await flight.Value.WaitAsync(cancellationToken); + } + + private async Task ReadAndRetireAsync(string host) + { + // Retirement is bound to the FLIGHT's termination, inside the flight's own + // task — never to a caller's exit. Retiring in each waiter's finally lets a + // joiner that cancels de-register a read whose transaction is still open, + // so the next arrival opens a second one: the stampede the coalescing + // exists to prevent, reintroduced by its own cleanup. Packet 5 convicted + // that shape in InMemoryCacheService, which retires on the factory's + // termination and lets a waiter's exit only decrement a count. This + // resolver has no cancellation to propagate into the read, so it needs the + // retirement rule and not the waiter bookkeeping. Exactly one flight is + // registered per host at a time, so the plain TryRemove has no successor to + // race. + try + { + return await ReadAsync(host, CancellationToken.None); + } + finally + { + _flights.TryRemove(host, out _); + } + } + + private async Task ReadAsync(string host, CancellationToken cancellationToken) + { + // The policy on this table admits exactly the row the resolver ANNOUNCES. + // Without the SET LOCAL the predicate is NULL and the query returns + // nothing, so the miss path opens its own transaction: SET LOCAL outside a + // transaction block emits "WARNING: SET LOCAL can only be used in + // transaction blocks" and has no effect, and a session-level + // set_config(..., false) would survive on a pooled connection into the next + // request. + await using var connection = await _dataSource.Value.OpenConnectionAsync(cancellationToken); + await using var transaction = + await connection.BeginTransactionAsync(cancellationToken); + + // set_config(..., true) is SET LOCAL's function form and is + // transaction-local for the same reason. It has to be this form: + // `SET LOCAL app.resolving_host = $1` is a syntax error — PostgreSQL's SET + // takes no bind parameter — so the parameterised spelling every other query + // uses is unavailable here, and interpolating into SET would be an + // injection site on the anonymous page-load path. + await using (var announce = new NpgsqlCommand( + "SELECT set_config('app.resolving_host', @host, true)", connection, transaction)) + { + announce.Parameters.AddWithValue("host", host); + await announce.ExecuteNonQueryAsync(cancellationToken); + } + + // BOTH terms. Active (owned, verified) and publicly live are distinct + // states — the row exists from submission onward, before DNS points + // anywhere — and only the latter may answer an anonymous page load. Both + // are read because ADR-0036 invalidates this cache on the transaction that + // flips EITHER flag, which is only meaningful if both feed the answer. + await using var read = new NpgsqlCommand( + """ + SELECT tenant_id, organization_id + FROM platform_host_to_tenant + WHERE host = @host AND is_active AND is_publicly_live + """, + connection, + transaction); + read.Parameters.AddWithValue("host", host); + + var resolution = await ReadSingleAsync(read, cancellationToken); + + await transaction.CommitAsync(cancellationToken); + return resolution; + } + + private static async Task ReadSingleAsync( + NpgsqlCommand command, CancellationToken cancellationToken) + { + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new HostResolution( + TenantId.From(reader.GetGuid(0)), + await reader.IsDBNullAsync(1, cancellationToken) + ? null + : OrganizationId.From(reader.GetGuid(1))); + } +} + +/// How long a found mapping is cached. +/// +/// Configuration rather than a literal for the same reason +/// is: this block gets copied, and a copied +/// number outlives the measurement that chose it. The L2 value is inert until the +/// Phase 11 adapter lands — InMemoryCacheService is L1 only — and is carried +/// so the two are chosen together rather than discovered apart. +/// +public sealed record HostResolutionOptions +{ + public CacheOptions PositiveCache { get; init; } = + new(L1Ttl: TimeSpan.FromMinutes(2), L2Ttl: TimeSpan.FromMinutes(15)); +} diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs new file mode 100644 index 00000000..602d4a2e --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs @@ -0,0 +1,133 @@ +using System.Collections.Concurrent; +using LearnStack.SharedKernel.Time; + +namespace LearnStack.Infrastructure.MultiTenancy; + +/// +/// Remembers hosts that resolved to nothing, in a structure capped on its own. +/// +/// +/// +/// Separately capped is the requirement, not an optimisation. +/// ADR-0036 +/// asks for unknown hosts to be "negative-cached in a separately capped structure +/// so a flood cannot evict real mappings", and the shared ICacheService +/// cannot satisfy that on two counts: it is one process-wide pool trimmed +/// oldest-first across every family, so unknown hosts would age out real ones; and +/// a stored null never reads back as a hit there — deliberately, pinned by +/// An_Explicitly_Stored_Null_Reads_Back_As_A_Miss — so it would occupy a +/// slot and answer nothing. Routing negatives through it buys eviction and no +/// cache. +/// +/// +/// What it protects. Every miss costs one PostgreSQL transaction on an +/// anonymous, pre-authentication path. Without a negative cache, a flood of novel +/// hostnames is a database round trip each. The anonymous rate limiter bounds one +/// peer; it does not bound a distributed flood, which is why the structure exists +/// as well as the limiter. +/// +/// +/// Eviction is oldest-first on a bounded map, and entries expire. Bounded +/// so a flood cannot grow it without limit; expiring so a host that becomes live +/// is not denied for the life of the process. The activation path also invalidates +/// explicitly — — so the TTL is the backstop rather than the +/// mechanism. +/// +/// +public sealed class UnknownHostCache(IClock clock, UnknownHostCacheOptions options) +{ + private readonly ConcurrentDictionary _seen = + new(StringComparer.Ordinal); + + private readonly IClock _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + + private readonly UnknownHostCacheOptions _options = + options ?? throw new ArgumentNullException(nameof(options)); + + /// How many hosts are currently remembered. For tests and diagnostics. + public int Count => _seen.Count; + + /// + /// true when is known to resolve to nothing and + /// that answer has not yet expired. + /// + public bool Contains(string host) + { + if (!_seen.TryGetValue(host, out var recordedAt)) + { + return false; + } + + if (_clock.UtcNow - recordedAt < _options.Ttl) + { + return true; + } + + // Expired. Removed on read rather than by a sweep: the read is the only + // moment the answer matters, and a background sweeper would be a timer + // per process for a map bounded at a few thousand entries. + _seen.TryRemove(new KeyValuePair(host, recordedAt)); + return false; + } + + /// Records that resolved to nothing. + public void Add(string host) + { + _seen[host] = _clock.UtcNow; + + if (_seen.Count > _options.MaxEntries) + { + Trim(); + } + } + + /// + /// Forgets , so the next request re-reads the table. + /// + /// + /// The activation path calls this. Without it a host that was guessed before it + /// went live stays denied for the whole TTL after activation, which is the + /// cache window ADR-0036 asks to be closed on the transaction that flips + /// either flag. + /// + public void Forget(string host) => _seen.TryRemove(host, out _); + + private void Trim() + { + // Oldest-first, in one pass, down to the cap. Racy by construction — a + // concurrent Add may push it back over — and that is acceptable: the cap + // is a bound on growth, not an invariant, and taking a lock on the + // anonymous page-load path to make it exact would cost more than the + // handful of extra entries it saves. + var excess = _seen.Count - _options.MaxEntries; + + if (excess <= 0) + { + return; + } + + foreach (var entry in _seen.OrderBy(entry => entry.Value).Take(excess)) + { + _seen.TryRemove(entry); + } + } +} + +/// +/// The cap and the lifetime of a negative answer. +/// +/// +/// Configuration rather than literals, because the block that reads them is copied +/// and a copied number outlives the measurement that chose it. The defaults are +/// deliberately modest: the structure exists to blunt a flood, not to be a +/// long-lived store, and a host that goes live is forgotten explicitly rather than +/// waited out. +/// +public sealed record UnknownHostCacheOptions +{ + /// Hosts remembered before the oldest are dropped. Default 10 000. + public int MaxEntries { get; init; } = 10_000; + + /// How long a negative answer stands. Default two minutes. + public TimeSpan Ttl { get; init; } = TimeSpan.FromMinutes(2); +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/IHostToTenantResolver.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IHostToTenantResolver.cs new file mode 100644 index 00000000..121eb5c4 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IHostToTenantResolver.cs @@ -0,0 +1,60 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Answers "which tenant is this host?" by reading +/// platform_host_to_tenant and nothing else. +/// +/// +/// +/// Never the Hub. An anonymous page load must not depend on a control +/// plane being reachable, so this port reads the local table and no other source +/// (ADR-0034). +/// The Hub writes that table through +/// PUT /api/internal/tenants/{id}/host-mappings; the resolver only reads +/// what is already there. +/// +/// +/// It runs before any tenant context exists — that is what it is for — so +/// it cannot use a module DbContext or the ambient +/// IUnitOfWork. Its implementation opens a short read-only transaction of +/// its own and is the single setter of app.resolving_host, the fourth and +/// last canonical session variable +/// (Security Standards +/// § Tenant Context). +/// +/// +public interface IHostToTenantResolver +{ + /// + /// The tenant behind , or null when no active, + /// publicly-live mapping exists. + /// + /// + /// The effective host, already normalized by + /// EffectiveHost.Normalize — lowercase, punycoded, no port, no trailing + /// dot. It is not re-normalized here: + /// ADR-0036 + /// puts that computation in exactly one place and + /// Effective_Host_Computed_In_One_Place fails a second one. A caller + /// passing a raw Host header gets a cache key and a policy predicate + /// that match no row, which is a 404 rather than a wider read. + /// + /// Cancels this caller's wait, never the lookup. + Task ResolveAsync(string host, CancellationToken cancellationToken = default); +} + +/// +/// What a host resolves to: a tenant, and the organization when the host serves +/// one. +/// +/// +/// The organization is the mapping row's, not a count. A tenant that wants +/// its default organization's content on its public site seeds +/// organization_id into its platform_host_to_tenant row; the +/// resolver never infers a scope from how many organizations a tenant has +/// (ADR-0036 § The anonymous organization scope). null means the host is +/// tenant-wide. +/// +public sealed record HostResolution(TenantId TenantId, OrganizationId? OrganizationId); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs new file mode 100644 index 00000000..4c519ff7 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs @@ -0,0 +1,229 @@ +using System.Diagnostics.Metrics; +using FluentAssertions; +using LearnStack.Infrastructure.Caching; +using LearnStack.Infrastructure.MultiTenancy; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// CachedHostToTenantResolver against a real database: the one read that +/// happens before any tenant context exists. +/// +/// +/// +/// Connected as learnstack_app. The policy on +/// platform_host_to_tenant is qualified TO learnstack_app and admits +/// exactly the row the resolver announces through app.resolving_host. A +/// test connected as the owner or as a BYPASSRLS role would pass with the +/// announcement removed and prove nothing — the announcement is the mechanism. +/// +/// +/// Each case builds its own resolver rather than resolving the singleton, because +/// the singleton's caches would carry answers between cases and the cases are +/// about what the database returns. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class HostResolutionTests +{ + private static readonly ServiceProvider MeterServices = new ServiceCollection() + .AddMetrics() + .BuildServiceProvider(); + + private readonly SchemaFixture _schema; + + public HostResolutionTests(SchemaFixture schema) => _schema = schema; + + [Fact] + public async Task Host_Resolves_With_No_Tenant_Context_Under_Rls() + { + // The property the whole resolver exists for. app.tenant_id is never set — + // there is no tenant yet, that is what is being asked — so the policy's + // tenant branch is NULL and only the app.resolving_host branch can admit + // the row. If the announcement were dropped, both branches would be NULL + // and this would come back empty. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + var resolver = BuildResolver(dataSource); + + var resolution = await resolver.ResolveAsync(SchemaFixture.HostA); + + resolution.Should().NotBeNull(); + resolution!.TenantId.Value.Should().Be(SchemaFixture.TenantA); + resolution.OrganizationId.Should().NotBeNull(); + resolution.OrganizationId!.Value.Value.Should().Be(SchemaFixture.OrgA1); + } + + [Fact] + public async Task An_Unmapped_Host_Resolves_To_Nothing() + { + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + var resolver = BuildResolver(dataSource); + + (await resolver.ResolveAsync("nobody.example.com")).Should().BeNull(); + } + + [Theory] + [InlineData(false, true, "an inactive mapping is not a tenant's host yet")] + [InlineData(true, false, "a mapping that is not publicly live may not answer an anonymous request")] + [InlineData(false, false, "neither flag set is not half a host")] + public async Task Both_Flags_Gate_The_Answer(bool isActive, bool isPubliclyLive, string because) + { + // The two flags are distinct states and the row exists from submission + // onward, before DNS points anywhere. Reading only one of them is how a + // guessed hostname serves an unlaunched tenant's catalog to a stranger, or + // how a released-then-re-registered domain keeps serving the previous + // tenant. ADR-0036 invalidates this resolver's cache on the transaction + // that flips EITHER flag, which is only meaningful if both feed the answer. + const string Host = "staged.example.com"; + + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + + try + { + await SeedHostAsync(dataSource, Host, isActive, isPubliclyLive); + + var resolver = BuildResolver(dataSource); + + (await resolver.ResolveAsync(Host)).Should().BeNull(because); + } + finally + { + await RemoveHostAsync(Host); + } + } + + [Fact] + public async Task A_Row_With_Both_Flags_Set_Resolves() + { + // The companion the three cases above need: without it they would pass + // against a resolver that never returns anything, and the theory would be + // asserting the absence of a feature rather than the presence of a gate. + const string Host = "staged-live.example.com"; + + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + + try + { + await SeedHostAsync(dataSource, Host, isActive: true, isPubliclyLive: true); + + var resolution = await BuildResolver(dataSource).ResolveAsync(Host); + + resolution.Should().NotBeNull(); + resolution!.TenantId.Value.Should().Be(SchemaFixture.TenantA); + resolution.OrganizationId.Should().BeNull("this row is tenant-wide"); + } + finally + { + await RemoveHostAsync(Host); + } + } + + [Fact] + public async Task A_Second_Lookup_Of_An_Unmapped_Host_Does_Not_Reach_The_Database() + { + // The negative cache, observed through the only thing a caller can see: a + // disposed data source. If the second lookup went to the database it would + // throw rather than answer. + var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + var resolver = BuildResolver(dataSource); + + (await resolver.ResolveAsync("gone.example.com")).Should().BeNull(); + + await dataSource.DisposeAsync(); + + (await resolver.ResolveAsync("gone.example.com")).Should().BeNull( + "the second answer comes from the negative cache, not from a connection"); + } + + [Fact] + public async Task A_Second_Lookup_Of_A_Mapped_Host_Does_Not_Reach_The_Database() + { + var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + var resolver = BuildResolver(dataSource); + + (await resolver.ResolveAsync(SchemaFixture.HostA)).Should().NotBeNull(); + + await dataSource.DisposeAsync(); + + (await resolver.ResolveAsync(SchemaFixture.HostA)).Should().NotBeNull( + "the second answer comes from ICacheService"); + } + + private static CachedHostToTenantResolver BuildResolver(NpgsqlDataSource dataSource) + { + var clock = new FixedClock(new DateTimeOffset(2026, 9, 2, 9, 0, 0, TimeSpan.Zero)); + var meterFactory = MeterServices.GetRequiredService(); + + return new CachedHostToTenantResolver( + new InMemoryCacheService(clock, meterFactory), + new UnknownHostCache(clock, new UnknownHostCacheOptions()), + new HostResolutionOptions(), + new Lazy(() => dataSource)); + } + + /// + /// Writes a host row and commits it, because the resolver reads on a + /// connection of its own and cannot see an uncommitted one. + /// + /// + /// As learnstack_app under the tenant's own context: the table's + /// policies are qualified TO learnstack_app, so the owner is denied, and + /// the insert's WITH CHECK requires app.tenant_id to be the row's + /// tenant. The seed does the same, and this is the one table where that is not + /// optional. + /// + private static async Task SeedHostAsync( + NpgsqlDataSource dataSource, string host, bool isActive, bool isPubliclyLive) + { + await using var connection = await dataSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + + await using (var context = new NpgsqlCommand( + "SELECT set_config('app.tenant_id', @tenant, true)", connection, transaction)) + { + context.Parameters.AddWithValue("tenant", SchemaFixture.TenantA.ToString()); + await context.ExecuteNonQueryAsync(); + } + + await using (var insert = new NpgsqlCommand( + """ + INSERT INTO platform_host_to_tenant (host, tenant_id, organization_id, is_active, is_publicly_live) + VALUES (@host, @tenant, NULL, @active, @live) + """, + connection, + transaction)) + { + insert.Parameters.AddWithValue("host", host); + insert.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + insert.Parameters.AddWithValue("active", isActive); + insert.Parameters.AddWithValue("live", isPubliclyLive); + await insert.ExecuteNonQueryAsync(); + } + + await transaction.CommitAsync(); + } + + /// + /// Removes the row through learnstack_platform, which bypasses the + /// policy — the cleanup must not depend on the thing under test. + /// + private async Task RemoveHostAsync(string host) + { + await using var connection = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var delete = connection.CreateCommand(); + delete.CommandText = "DELETE FROM platform_host_to_tenant WHERE host = @host"; + var parameter = delete.CreateParameter(); + parameter.ParameterName = "host"; + parameter.Value = host; + delete.Parameters.Add(parameter); + await delete.ExecuteNonQueryAsync(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs new file mode 100644 index 00000000..75241ce1 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs @@ -0,0 +1,206 @@ +using System.Net; +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace LearnStack.Tests.Integration; + +/// +/// Host classification through the real pipeline: which requests are classified, +/// what an unknown host gets, and what a classified one carries forward. +/// +/// +/// +/// A host test — no Docker. The resolver is stubbed, because what is under test is +/// the middleware's decisions and not the query behind them; +/// HostResolutionTests covers that against a real database, connected as +/// learnstack_app. +/// +/// +/// The fixture runs in Development, where Tenancy:PlatformHosts +/// carries localhost — which is what every other host test in this +/// assembly depends on without knowing it. A request to localhost is +/// classified Platform and short-circuits before the resolver, so the +/// Docker-free suites keep working with no database at all. +/// +/// +public sealed class HostClassificationHttpTests(HostClassificationFixture fixture) + : IClassFixture +{ + private readonly HttpClient _client = fixture.CreateClient(); + + [Fact] + public async Task A_Platform_Host_Is_Served_Without_Reaching_The_Resolver() + { + fixture.Resolver.Calls.Clear(); + + var response = await _client.GetAsync(new Uri("/api/v1/hostprobe", UriKind.Relative)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Contain("Platform"); + fixture.Resolver.Calls.Should().BeEmpty( + "a platform host maps to no tenant by configuration, so it costs no lookup"); + } + + [Fact] + public async Task An_Unknown_Host_Is_A_404_Before_Any_Handler() + { + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/hostprobe", UriKind.Relative)); + request.Headers.Host = "stranger.example.com"; + + var response = await _client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task An_Unknown_Host_Answers_Exactly_As_An_Unmapped_Path_Does() + { + // The bit an anonymous caller must not be able to tell apart. Saying + // "unknown tenant" — by a different status, a different body, or a + // different content type — confirms which hostnames exist. + // + // The SAME path both times, so `instance` cannot account for a difference: + // one request is refused because its host resolves to nothing, the other + // because the path routes to nothing, and the two answers must be the same + // answer. Only the correlation id may differ, and it is normalized out — + // it is per-request by design and carries no fact about either refusal. + const string Path = "/api/v1/nothing-here"; + + using var unknownHost = new HttpRequestMessage(HttpMethod.Get, new Uri(Path, UriKind.Relative)); + unknownHost.Headers.Host = "stranger.example.com"; + + var rejected = await _client.SendAsync(unknownHost); + var routed = await _client.GetAsync(new Uri(Path, UriKind.Relative)); + + rejected.StatusCode.Should().Be(routed.StatusCode); + rejected.Content.Headers.ContentType?.MediaType + .Should().Be(routed.Content.Headers.ContentType?.MediaType); + WithoutCorrelation(await rejected.Content.ReadAsStringAsync()) + .Should().Be(WithoutCorrelation(await routed.Content.ReadAsStringAsync())); + } + + private static string WithoutCorrelation(string body) => + System.Text.RegularExpressions.Regex.Replace( + body, "\"correlationId\":\"[^\"]*\"", "\"correlationId\":\"\""); + + [Fact] + public async Task A_Resolved_Host_Carries_Its_Classification_Forward() + { + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/hostprobe", UriKind.Relative)); + request.Headers.Host = HostClassificationFixture.OrganizationHost; + + var response = await _client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Contain("Organization", + "a mapping row carrying an organization classifies as OrgHost"); + } + + [Fact] + public async Task A_Tenant_Wide_Host_Classifies_As_Tenant_Rather_Than_Organization() + { + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/hostprobe", UriKind.Relative)); + request.Headers.Host = HostClassificationFixture.TenantHost; + + var response = await _client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Contain("Tenant"); + } + + [Fact] + public async Task An_Unclassified_Prefix_Is_Served_Whatever_Its_Host() + { + // /healthz has no tenant and must answer a probe from anywhere. The same + // property is what keeps /api/internal/* reachable for the Hub, whose + // tenant comes from the envelope rather than from a host. + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/healthz", UriKind.Relative)); + request.Headers.Host = "stranger.example.com"; + + (await _client.SendAsync(request)).StatusCode.Should().Be(HttpStatusCode.OK); + } +} + +/// A host whose resolver is a stub, so no database is involved. +public sealed class HostClassificationFixture : WebApplicationFactory +{ + public const string OrganizationHost = "branch.example.com"; + public const string TenantHost = "school.example.com"; + + public static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-0000000000c1"); + public static readonly Guid Organization = Guid.Parse("018f4d40-0000-7000-8000-0000000000c2"); + + public StubResolver Resolver { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.UseEnvironment(Environments.Development); + builder.ConfigureTestServices(services => + { + services.AddControllers(options => + options.Conventions.Insert(0, new TestControllerFilter( + typeof(HostProbeController)))) + .AddApplicationPart(typeof(HostProbeController).Assembly); + + services.RemoveAll(); + services.AddSingleton(Resolver); + }); + } + + /// Answers from a fixed table, and records what it was asked. + public sealed class StubResolver : IHostToTenantResolver + { + private readonly List _calls = []; + + public IList Calls + { + get { lock (_calls) { return _calls; } } + } + + public Task ResolveAsync( + string host, CancellationToken cancellationToken = default) + { + lock (_calls) + { + _calls.Add(host); + } + + return Task.FromResult(host switch + { + OrganizationHost => new HostResolution( + TenantId.From(Tenant), OrganizationId.From(Organization)), + TenantHost => new HostResolution(TenantId.From(Tenant), null), + _ => null, + }); + } + } +} + +/// Echoes the classification the middleware attached. +[ApiExplorerSettings(IgnoreApi = true)] +public sealed class HostProbeController : ApiControllerBase, ITestOnlyController +{ + [HttpGet] + public IActionResult Get() => + Ok(new + { + @class = HttpContext.Features.Get()?.Class.ToString() ?? "none", + }); +} diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs new file mode 100644 index 00000000..aad5c87c --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs @@ -0,0 +1,129 @@ +using FluentAssertions; +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Tenancy; + +/// +/// Host_Classification_Applies_To_Tenant_Facing_Routes_Only — which paths +/// host classification runs for, and the shape of the exclusions. +/// +/// +/// Catalogued in +/// Standards +/// 21 § Tenant and organization resolution, from +/// ADR-0036 +/// § The reconciliation matrix. Driven against the predicate rather than +/// through the middleware: the rule is about paths, and routing a request through +/// the middleware to observe it would need a resolver and a database that the +/// decision never touches. +/// +public sealed class HostClassificationScopeTests +{ + [Theory] + [InlineData("/api/v1/courses")] + [InlineData("/api/v1")] + [InlineData("/API/V1/courses")] + public void Classification_Applies_To_The_Tenant_Facing_Surface(string path) + { + HostClassificationMiddleware.ClassifiesPath(new PathString(path)) + .Should().BeTrue(); + } + + [Theory] + [InlineData("/healthz")] + [InlineData("/readyz")] + [InlineData("/openapi/v1.json")] + [InlineData("/admin/hangfire/recurring")] + [InlineData("/api/internal/tenants")] + [InlineData("/")] + [InlineData("/api/v2/courses")] + public void Classification_Does_Not_Apply_Anywhere_Else(string path) + { + HostClassificationMiddleware.ClassifiesPath(new PathString(path)) + .Should().BeFalse(); + } + + [Fact] + public void The_Exclusions_Are_Prefixes_And_Not_Endpoint_Literals() + { + // The distinction the catalogue calls out by name. A closed allow-list of + // literals would exclude `/api/internal` and then 404 the entire Hub + // contract surface the first time it grew a route — and `/api/internal/*` + // is a whole surface with its own resolver, whose tenant comes from the + // envelope's path segment rather than from a host. + foreach (var prefix in HostClassificationMiddleware.UnclassifiedPrefixes) + { + HostClassificationMiddleware + .ClassifiesPath(new PathString($"{prefix}/deeper/still")) + .Should().BeFalse( + $"{prefix} excludes everything beneath it, not just itself"); + } + } + + [Fact] + public void A_Prefix_Match_Is_On_Segments_Rather_Than_Characters() + { + // `/api/internalise` starts with the characters of `/api/internal` and is + // not beneath it. Segment matching is what keeps a future route from being + // silently unclassified because its name happens to begin with another's. + HostClassificationMiddleware.ClassifiesPath(new PathString("/api/v1/internalise")) + .Should().BeTrue(); + } +} + +/// +/// Tenancy:PlatformHosts refuses an entry that is not already a normalized +/// effective host. +/// +/// +/// The comparison at runtime is ordinal against a host +/// EffectiveHost.Normalize produced, so an entry in any other spelling +/// matches nothing — and a platform host that quietly becomes an unknown host is a +/// 404 on the operator's own entry point, discovered in production. +/// +public sealed class PlatformHostOptionsTests +{ + [Theory] + [InlineData("LocalHost", "uppercase never matches a lowercased host")] + [InlineData("app.learnstack.dev.", "a trailing dot is stripped before comparison")] + [InlineData("localhost:5001", "a port is stripped before comparison")] + [InlineData("1.2.3.4", "an IP literal is refused as a host name")] + [InlineData("türkçe.example.com", "an unpunycoded IDN never matches its A-label")] + [InlineData(" ", "whitespace names no host")] + public void An_Unnormalized_Entry_Refuses_The_Boot(string host, string because) + { + var options = new PlatformHostOptions { Hosts = [host] }; + + var act = options.Validate; + + act.Should().Throw(because) + .WithMessage($"*{PlatformHostOptions.SectionName}*"); + } + + [Fact] + public void A_Normalized_Entry_Is_Accepted_And_Compared_Ordinally() + { + var options = new PlatformHostOptions + { + Hosts = ["localhost", "app.learnstack.dev", EffectiveHost.Normalize("türkçe.example.com")!], + }; + + var hosts = options.Validate(); + + hosts.Should().HaveCount(3); + hosts.Should().Contain("xn--trke-2oa7j.example.com", + "the A-label is what EffectiveHost.Normalize produces and what a request carries"); + hosts.Contains("LOCALHOST").Should().BeFalse("the set is ordinal, not case-insensitive"); + } + + [Fact] + public void No_Configured_Hosts_Is_Legal() + { + // A deployment with no Studio entry host has none, and that is not a + // misconfiguration — every host is then a tenant host or an unknown one. + new PlatformHostOptions().Validate().Should().BeEmpty(); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs new file mode 100644 index 00000000..4271f995 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs @@ -0,0 +1,116 @@ +using FluentAssertions; +using LearnStack.Infrastructure.MultiTenancy; +using LearnStack.SharedKernel.Time; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.MultiTenancy; + +/// +/// The separately-capped structure +/// ADR-0036 +/// requires for unknown hosts, so a flood cannot evict real mappings. +/// +/// +/// The requirement is two words — "separately capped" — and both halves are load +/// bearing. Separate, because the shared ICacheService is one +/// process-wide pool trimmed oldest-first across every family, so unknown hosts +/// routed through it would age out the mappings they are supposed to protect. +/// Capped, because every miss is a PostgreSQL transaction on an anonymous +/// pre-authentication path and an uncapped memo of every hostname ever guessed is +/// its own denial of service. +/// +public sealed class UnknownHostCacheTests +{ + private static readonly DateTimeOffset Origin = new(2026, 9, 2, 9, 0, 0, TimeSpan.Zero); + + [Fact] + public void An_Unseen_Host_Is_Not_Remembered() + { + Build(out _).Contains("never.example.com").Should().BeFalse(); + } + + [Fact] + public void A_Recorded_Host_Is_Remembered() + { + var cache = Build(out _); + + cache.Add("gone.example.com"); + + cache.Contains("gone.example.com").Should().BeTrue(); + } + + [Fact] + public void A_Recorded_Host_Is_Forgotten_When_Its_Answer_Expires() + { + // The backstop, not the mechanism: activation calls Forget, and this is + // what keeps a host that went live without one from being denied for the + // life of the process. + var cache = Build(out var clock, new UnknownHostCacheOptions { Ttl = TimeSpan.FromMinutes(2) }); + cache.Add("later.example.com"); + + clock.Advance(TimeSpan.FromMinutes(2)); + + cache.Contains("later.example.com").Should().BeFalse(); + cache.Count.Should().Be(0, "an expired entry is dropped on the read that found it"); + } + + [Fact] + public void Forget_Removes_A_Host_Immediately() + { + // What the activation path calls. Without it, a hostname guessed before it + // went live keeps its 404 for the whole TTL after activation — the cache + // window ADR-0036 asks to be closed on the transaction that flips either + // flag. + var cache = Build(out _); + cache.Add("activated.example.com"); + + cache.Forget("activated.example.com"); + + cache.Contains("activated.example.com").Should().BeFalse(); + } + + [Fact] + public void A_Flood_Cannot_Grow_The_Structure_Past_Its_Cap() + { + var cache = Build(out _, new UnknownHostCacheOptions { MaxEntries = 50 }); + + for (var i = 0; i < 500; i++) + { + cache.Add($"flood-{i}.example.com"); + } + + cache.Count.Should().BeLessThanOrEqualTo(50, + "the cap is what stops a flood of novel hostnames becoming an unbounded memo"); + } + + [Fact] + public void A_Flood_Evicts_The_Oldest_Unknown_Hosts_And_Nothing_Else() + { + // The property the separation buys: whatever a flood costs, it costs only + // other unknown hosts. Real mappings live in ICacheService and are not + // reachable from here at all — which is the point, and is why this asserts + // on which unknown host survives rather than on a mapping. + var cache = Build(out var clock, new UnknownHostCacheOptions { MaxEntries = 10 }); + + cache.Add("oldest.example.com"); + clock.Advance(TimeSpan.FromSeconds(1)); + + for (var i = 0; i < 100; i++) + { + cache.Add($"flood-{i}.example.com"); + clock.Advance(TimeSpan.FromMilliseconds(1)); + } + + cache.Contains("oldest.example.com").Should().BeFalse( + "oldest-first is what a bounded structure evicts by"); + cache.Contains("flood-99.example.com").Should().BeTrue( + "the most recent answer is the one worth keeping"); + } + + private static UnknownHostCache Build( + out FixedClock clock, UnknownHostCacheOptions? options = null) + { + clock = new FixedClock(Origin); + return new UnknownHostCache(clock, options ?? new UnknownHostCacheOptions()); + } +} diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 5721bcfe..590f17d6 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -2007,8 +2007,14 @@ structural test proves — and what it does not. - **Asserts:** host classification runs for `/api/v1/*` and for no other prefix. `/healthz`, `/readyz`, `/openapi/*`, `/admin/hangfire*` and `/api/internal/*` are asserted as a **prefix list**, not as endpoint literals — a closed allow-list written as literals 404s the entire Hub contract surface. - **Source:** ADR-0036 § The reconciliation matrix. - **Type:** xUnit + route-table inspection. **Kind:** structural. -- **Status:** **Registered.** +- **Status:** **Implemented** (Packet 7 step 4, `HostClassificationScopeTests`). - **Phase:** 02a Packet 7. +- **Note:** driven against `HostClassificationMiddleware.ClassifiesPath` rather than + through the middleware. The rule is about paths, and routing a request to observe it + would need a resolver and a database the decision never touches. The prefix-versus- + literal distinction is asserted directly — every excluded prefix must also exclude + everything beneath it — because that is the half whose absence 404s the Hub contract + surface. #### `TenantContext_Is_Constructed_Only_By_The_Factory` From f4374438cc1313994f865bf3cbccd9da278f90d3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 02:36:24 +0300 Subject: [PATCH 13/55] fix(tenancy): close the two blockers the Step 4 review measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were on the anonymous pre-authentication path and both turned the designed bodyless 404 into a 500 plus an unsampled error-tracker capture. A trailing dot walked an IPv4 literal past EffectiveHost's gate. The check sits on the value before the trailing-dot strip, so `1.2.3.4.` reached the strip as a name and left it as a literal — and GetAscii's compatibility mapping folds U+3002 and U+FF0E into a dot after that. Measured: `1.2.3.4.`, `1.2.3.4.:443`, `127.0.0.1.`, `9.`, `2130706433.` and `010.010.010.010.` all came back as accepted hosts and every one then threw in CacheKey.ForHostMapping, which the resolver calls as its first statement. The throw also precedes the negative cache, so repeats never coalesced, and since only a host that reaches the resolver can produce it, the 500 was a positive host-existence oracle against the indistinguishability this step built. The refusal now runs on the produced value. That is the general form and this is its second instance — Amendment 1 already made the same argument for the character set and left the IPv4 check on the input side. ADR-0036 carries the erratum and Amendment 4, because the order it publishes is transcribed, and the shipped code was a faithful transcription of the bug. UnknownHostCache.Trim threw under concurrent Add at the cap. Enumerating a ConcurrentDictionary through LINQ buffers via ICollection.CopyTo after a stale Count read; measured, eight threads adding at the cap threw on 33% of adds, ArgumentException from a concurrent insert and ArgumentNullException from a default slot left by a concurrent removal. Add is unguarded in the resolver and the middleware has no catch. The comment licensing the race said the worst case was overshooting the cap; the worst case was throwing. It now snapshots atomically, sweeps the lapsed entries in the pass it already pays for — nothing else swept, so the map ratcheted to its cap for the process lifetime — and trims to a low-water mark. Re-measured: 0% of 1600 adds throw. Two behaviour fixes alongside them. The key composition fails closed, so the next divergence between the two validators is a rejection rather than an incident. And the cache write moved inside the flight: the flight runs on CancellationToken.None so one caller hanging up does not cancel the lookup others wait on, but with the write in the caller's tail that completed lookup threw its answer away. Three headline mechanisms had no test at all — each was deleted in a mutation and left 1003 green. Coalescing, counted at the physical-connection initializer; the cancelled-caller publish; and the unnamed-host branch the IPv4 blocker escaped through. Plus the pairing property whose absence let the blocker exist: for every input Normalize accepts, ForHostMapping must not throw. Connection-string validation runs at boot again when the key is present, which the Lazy had deferred to the first tenant request. 1026 green, zero skips. ADR: 0036 Co-Authored-By: Claude Opus 5 (1M context) --- .../PersistenceCompositionExtensions.cs | 60 +++++++++--- .../CachedHostToTenantResolver.cs | 60 ++++++++---- .../MultiTenancy/UnknownHostCache.cs | 83 ++++++++++++----- .../Tenancy/EffectiveHost.cs | 24 +++++ .../TenancyConventionTests.cs | 34 +++++++ .../Database/HostResolutionTests.cs | 92 +++++++++++++++++++ .../HostClassificationHttpTests.cs | 24 +++++ .../Tenancy/HostClassificationScopeTests.cs | 11 +++ .../MultiTenancy/UnknownHostCacheTests.cs | 72 +++++++++++++++ .../SharedKernel/EffectiveHostTests.cs | 39 ++++++++ docs/architecture/27-custom-domain-tls.md | 8 +- .../0036-tenant-resolution-trusted-inputs.md | 55 +++++++++++ docs/standards/20-infrastructure-stack.md | 2 +- .../21-architecture-tests-catalogue.md | 18 +++- 14 files changed, 527 insertions(+), 55 deletions(-) diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 69f34f32..56657ef1 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -65,8 +65,26 @@ public static IServiceCollection AddLearnStackPersistence( ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); - services.TryAddSingleton(_ => - BuildApplicationDataSource(configuration.GetConnectionString(DefaultConnectionName))); + var connectionString = configuration.GetConnectionString(DefaultConnectionName); + + // Validated at boot when the key is present; built on first use either way. + // + // The build is deferred so that a request on a platform host — answered + // from Tenancy:PlatformHosts, never from the database — costs nothing + // below it, and so the Docker-free host suites keep working with no + // credential at all. But deferring the build used to defer the *checks* + // with it, and those are worth having early: a connection string that + // names learnstack_migration is the ownership mistake FORCE ROW LEVEL + // SECURITY exists to defeat, and discovering it on the first tenant + // request rather than at boot is discovering it in production. An absent + // key still throws lazily, because a deployment that serves only platform + // hosts legitimately has none. + if (!string.IsNullOrWhiteSpace(connectionString)) + { + ValidateApplicationConnectionString(connectionString); + } + + services.TryAddSingleton(_ => BuildApplicationDataSource(connectionString)); // Scoped: one connection per request, owned by this, shared by every // context resolved in the scope (ADR-0040). @@ -92,6 +110,32 @@ public static IServiceCollection AddLearnStackPersistence( /// every log that captured the startup failure. /// internal static NpgsqlDataSource BuildApplicationDataSource(string? connectionString) + { + ValidateApplicationConnectionString(connectionString); + + var builder = new NpgsqlDataSourceBuilder(connectionString); + + // Asked of the server, once per physical connection, because the name is + // not the privilege: learnstack_app could have been granted BYPASSRLS, and + // a superuser bypasses row security with rolbypassrls = false — which is + // why rolsuper is in the predicate. + builder.UsePhysicalConnectionInitializer( + connection => RefuseBypassRole(connection, async: false).GetAwaiter().GetResult(), + connection => RefuseBypassRole(connection, async: true)); + + return builder.Build(); + } + + /// + /// Everything about ConnectionStrings:Default that can be checked + /// without opening a connection. + /// + /// + /// Separate from the build so the composition root can run it at boot while + /// still deferring the data source itself. The server-side bypass check is not + /// here — it needs a connection, and it runs per physical connection. + /// + internal static void ValidateApplicationConnectionString(string? connectionString) { if (string.IsNullOrWhiteSpace(connectionString)) { @@ -134,18 +178,6 @@ internal static NpgsqlDataSource BuildApplicationDataSource(string? connectionSt + "unresolved-tenant state that returns no rows returns every tenant's instead. " + "EnterPlatformAdminScope is the only sanctioned path to a bypass credential."); } - - var builder = new NpgsqlDataSourceBuilder(connectionString); - - // Asked of the server, once per physical connection, because the name is - // not the privilege: learnstack_app could have been granted BYPASSRLS, and - // a superuser bypasses row security with rolbypassrls = false — which is - // why rolsuper is in the predicate. - builder.UsePhysicalConnectionInitializer( - connection => RefuseBypassRole(connection, async: false).GetAwaiter().GetResult(), - connection => RefuseBypassRole(connection, async: true)); - - return builder.Build(); } private static async Task RefuseBypassRole(NpgsqlConnection connection, bool async) diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs index a274cf06..54599e01 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs @@ -72,28 +72,37 @@ public sealed class CachedHostToTenantResolver( // Composed by the factory, never interpolated: CacheKey.EnsureValid is what // stops an unnormalized spelling creating a parallel entry, and it refuses // an IP literal outright. - var key = CacheKey.ForHostMapping(host); + // + // Total, because this is the anonymous pre-authentication path. The two + // validators — EffectiveHost.Normalize and CacheKey.EnsureValid — agree + // today, and the last time they did not, a trailing dot walked an IPv4 + // literal past the first and into the second, where the throw became a 500 + // and an error-tracker capture per request instead of the bodyless 404 + // this method exists to make cheap. A host this key shape refuses is an + // unresolvable host, and saying so is the answer; letting the next + // divergence be an incident is not. + string key; - if (await _cache.GetAsync(key, cancellationToken) is { } cached) + try { - return cached; + key = CacheKey.ForHostMapping(host); } - - if (_unknownHosts.Contains(host)) + catch (ArgumentException) { return null; } - var resolution = await ReadCoalescedAsync(host, cancellationToken); + if (await _cache.GetAsync(key, cancellationToken) is { } cached) + { + return cached; + } - if (resolution is null) + if (_unknownHosts.Contains(host)) { - _unknownHosts.Add(host); return null; } - await _cache.SetAsync(key, resolution, _options.PositiveCache, cancellationToken); - return resolution; + return await ReadCoalescedAsync(host, key, cancellationToken); } /// @@ -110,18 +119,18 @@ public sealed class CachedHostToTenantResolver( /// lookup. /// private async Task ReadCoalescedAsync( - string host, CancellationToken cancellationToken) + string host, string key, CancellationToken cancellationToken) { var flight = _flights.GetOrAdd( host, - static (h, self) => new Lazy>( - () => self.ReadAndRetireAsync(h)), - this); + static (h, state) => new Lazy>( + () => state.Self.ReadAndRetireAsync(h, state.Key)), + (Self: this, Key: key)); return await flight.Value.WaitAsync(cancellationToken); } - private async Task ReadAndRetireAsync(string host) + private async Task ReadAndRetireAsync(string host, string key) { // Retirement is bound to the FLIGHT's termination, inside the flight's own // task — never to a caller's exit. Retiring in each waiter's finally lets a @@ -136,7 +145,26 @@ public sealed class CachedHostToTenantResolver( // race. try { - return await ReadAsync(host, CancellationToken.None); + var resolution = await ReadAsync(host, CancellationToken.None); + + // Published inside the flight, not by each waiter. A lookup whose only + // caller hangs up still completes — that is the point of running it on + // CancellationToken.None — and if the write lived in the caller's tail + // the answer would be thrown away, so the next request would pay for + // the same round trip again. Doing it here also means the answer is + // written once however many waiters there are, rather than once per + // waiter. + if (resolution is null) + { + _unknownHosts.Add(host); + } + else + { + await _cache.SetAsync( + key, resolution, _options.PositiveCache, CancellationToken.None); + } + + return resolution; } finally { diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs index 602d4a2e..32a44141 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs @@ -27,11 +27,17 @@ namespace LearnStack.Infrastructure.MultiTenancy; /// as well as the limiter. /// /// -/// Eviction is oldest-first on a bounded map, and entries expire. Bounded -/// so a flood cannot grow it without limit; expiring so a host that becomes live -/// is not denied for the life of the process. The activation path also invalidates -/// explicitly — — so the TTL is the backstop rather than the -/// mechanism. +/// Eviction is oldest-first down to a low-water mark, and entries expire. +/// Bounded so a flood cannot grow it without limit; expiring so a host that becomes +/// live is not denied for the life of the process. Nothing calls +/// yet, so the TTL is the whole of it today: the +/// invalidation ADR-0036 asks for on the transaction that flips either flag needs +/// a writer of platform_host_to_tenant, and the Hub-side lifecycle that +/// owns one is [Phase 02c](../../../../docs/roadmap/phase-02c-hub-foundation.md). +/// Until then a host activated inside the TTL keeps its 404 for the rest of it. +/// A trim sweeps the lapsed entries on the way past, +/// because nothing else does: a read only drops the one entry it looked at, so the +/// map otherwise ratchets to its cap and stays there for the life of the process. /// /// public sealed class UnknownHostCache(IClock clock, UnknownHostCacheOptions options) @@ -85,28 +91,61 @@ public void Add(string host) /// Forgets , so the next request re-reads the table. /// /// - /// The activation path calls this. Without it a host that was guessed before it - /// went live stays denied for the whole TTL after activation, which is the - /// cache window ADR-0036 asks to be closed on the transaction that flips - /// either flag. + /// Nothing calls this yet. It exists because the alternative to an + /// explicit invalidation is waiting out the TTL, and ADR-0036 asks for the + /// cache window to be closed on the transaction that flips either flag rather + /// than by expiry. The caller is the host-mapping writer, which arrives with + /// the Hub-side custom-domain lifecycle in + /// [Phase 02c](../../../../docs/roadmap/phase-02c-hub-foundation.md). /// public void Forget(string host) => _seen.TryRemove(host, out _); private void Trim() { - // Oldest-first, in one pass, down to the cap. Racy by construction — a - // concurrent Add may push it back over — and that is acceptable: the cap - // is a bound on growth, not an invariant, and taking a lock on the - // anonymous page-load path to make it exact would cost more than the - // handful of extra entries it saves. - var excess = _seen.Count - _options.MaxEntries; + var now = _clock.UtcNow; + + // ToArray() takes every bucket lock and hands back a stable copy. + // Enumerating the dictionary directly does not: LINQ buffers through + // ICollection.CopyTo after a stale Count read, and under a concurrent Add + // that TEARS — a concurrent insert throws ArgumentException, a concurrent + // removal leaves a default slot whose null key makes TryRemove throw + // ArgumentNullException. Measured on the shipped shape: eight threads + // adding at the cap threw on 33% of adds. An earlier comment here called + // the race benign on the theory that the worst case was overshooting the + // cap; the worst case was throwing, out of an unguarded call on the + // anonymous page-load path, where it became a 500 instead of the bodyless + // 404 this structure exists to make cheap — and, because only an unknown + // host reaches Add, a positive host-existence oracle. + var snapshot = _seen.ToArray(); + + // Reclaim the lapsed entries in the pass already being paid for. Contains + // only drops the one entry it happened to read, so a map that filled + // slowly is mostly expired by now, and this often makes the sort below + // unnecessary. + foreach (var pair in snapshot) + { + if (now - pair.Value >= _options.Ttl) + { + _seen.TryRemove(pair); + } + } + + // A low-water mark rather than the cap itself, matching + // InMemoryCacheService. Trimming back to exactly the cap leaves the map + // one add from overflowing, so every subsequent novel host pays for + // another full sort. + var target = Math.Max(1, _options.MaxEntries * 9 / 10); + var excess = _seen.Count - target; if (excess <= 0) { return; } - foreach (var entry in _seen.OrderBy(entry => entry.Value).Take(excess)) + // TryRemove(KeyValuePair) rather than by key: it compares the value too, + // so an entry re-added with a fresher timestamp between the snapshot and + // here is left alone. + foreach (var entry in _seen.ToArray().OrderBy(entry => entry.Value).Take(excess)) { _seen.TryRemove(entry); } @@ -117,11 +156,13 @@ private void Trim() /// The cap and the lifetime of a negative answer. ///
/// -/// Configuration rather than literals, because the block that reads them is copied -/// and a copied number outlives the measurement that chose it. The defaults are -/// deliberately modest: the structure exists to blunt a flood, not to be a -/// long-lived store, and a host that goes live is forgotten explicitly rather than -/// waited out. +/// A named options record rather than inline literals, so the block that reads them +/// carries a reference and not a number — that block gets copied, and a copied +/// number outlives the measurement that chose it. Not bound to +/// IConfiguration: nothing validates these, and an operator who set the +/// cap to zero or the TTL to a day would get a structure that either caches +/// nothing or denies an activated host for a day, with no failure to see. The +/// defaults are deliberately modest — this blunts a flood, it is not a store. /// public sealed record UnknownHostCacheOptions { diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/EffectiveHost.cs b/backend/src/LearnStack.SharedKernel/Tenancy/EffectiveHost.cs index b270a8ef..3d379c40 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/EffectiveHost.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/EffectiveHost.cs @@ -75,6 +75,8 @@ public static class EffectiveHost } // IPv4 literal after the port is gone, so `1.2.3.4:443` is caught too. + // A cheap early exit, and not the guarantee — the one below the + // conversion is. See the return. if (IPAddress.TryParse(withoutPort, out _)) { return null; @@ -131,6 +133,28 @@ public static class EffectiveHost // Measured on .NET 10: nine 20-character `ü` labels convert to 246 and // pass; twenty-one throw. A guard here would be unreachable code // claiming to prevent something that cannot happen. + // + // The IPv4 refusal, re-run on the value about to be RETURNED. The check + // above sees `withoutPort`, and two later steps can PRODUCE a literal it + // never saw: the trailing-dot strip turns `1.2.3.4.` into `1.2.3.4`, and + // GetAscii's compatibility mapping folds U+3002 and U+FF0E into '.'. + // Measured: `1.2.3.4.`, `9.`, `127.0.0.1.`, `2130706433.` and + // `1.2.3.4.:443` all came back as accepted hosts, and every one of them + // then threw in CacheKey.ForHostMapping — a 500 and an error-tracker + // capture, per request, from an unauthenticated caller, where a bodyless + // 404 was designed. That throw is also a host-existence oracle: only a + // host that reaches the resolver can produce it. + // + // The general form, because this is the second instance of it and the + // whitelist below was the first: **every rejection in this function is a + // predicate on the produced value.** An input scan is an optimisation, and + // an optimisation that is also the only check is a gate the next + // normalization step walks around. + if (IPAddress.TryParse(lowered, out _)) + { + return null; + } + return IsLdh(lowered) ? lowered : null; } diff --git a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs index 9db20431..66b8f2ed 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs @@ -236,4 +236,38 @@ private static List Offenders( return offenders; } + [Fact] + public void Resolving_Host_Is_Set_In_One_Place() + { + // app.resolving_host is the fourth canonical session variable and the only + // one with a single setter: the resolver announces the host it is about to + // look up, and the policy on platform_host_to_tenant admits exactly that + // row. A second setter is a second announcement, on the one table read + // before any tenant context exists — the one place a widened read is not + // already caught by app.tenant_id being NULL. + // + // Its own scan rather than the Offenders helper above, which is scoped to + // LearnStack.Api: the sole setter lives in LearnStack.Infrastructure, so a + // rule that only looked at the Api project would be green by construction. + // + // The SETTER spelling only. Banning the bare literal `app.resolving_host` + // fails on the migration's own policy DDL, which must name the variable in + // order to read it. + const string Setter = "set_config('app.resolving_host'"; + const string SoleSetter = "CachedHostToTenantResolver.cs"; + + var offenders = Directory + .EnumerateFiles(RepositoryPaths.BackendSrc(), "*.cs", SearchOption.AllDirectories) + .Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .Where(file => !file.EndsWith(SoleSetter, StringComparison.Ordinal)) + .Where(file => SourceText.WithoutComments(File.ReadAllText(file)) + .Contains(Setter, StringComparison.Ordinal)) + .Select(file => Path.GetRelativePath(RepositoryPaths.BackendSrc(), file)) + .ToList(); + + offenders.Should().BeEmpty( + "CachedHostToTenantResolver is the sole setter of app.resolving_host " + + "(Security Standards § Tenant Context)"); + } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs index 4c519ff7..b15a8e9a 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs @@ -156,6 +156,98 @@ public async Task A_Second_Lookup_Of_A_Mapped_Host_Does_Not_Reach_The_Database() "the second answer comes from ICacheService"); } + [Fact] + public async Task Concurrent_First_Lookups_Of_One_Host_Open_One_Connection() + { + // The coalescing had no test at any layer: deleting it left 1003/1003 + // green. It exists because the split that sends positives and negatives to + // different structures forfeits GetOrSetAsync's single flight, and the + // flight it replaces is a PostgreSQL transaction opened on an anonymous, + // pre-authentication path — N simultaneous first requests for one cold + // host would otherwise be N transactions. + // + // Counted at the physical-connection initializer, which is the only place + // that sees a real open and is a seam the data source already exposes. + var opens = 0; + + var builder = new NpgsqlDataSourceBuilder(_schema.Postgres.AppConnectionString); + builder.UsePhysicalConnectionInitializer( + _ => Interlocked.Increment(ref opens), + _ => { Interlocked.Increment(ref opens); return Task.CompletedTask; }); + + await using var dataSource = builder.Build(); + var resolver = BuildResolver(dataSource); + + // Warm the pool first, so the count below is about flights and not about + // however many physical connections the pool happened to need. + (await resolver.ResolveAsync(SchemaFixture.HostB)).Should().NotBeNull(); + var warm = opens; + + using var release = new ManualResetEventSlim(false); + + var callers = Enumerable.Range(0, 12).Select(_ => Task.Run(async () => + { + release.Wait(); + return await resolver.ResolveAsync(SchemaFixture.HostA); + })).ToArray(); + + release.Set(); + var resolutions = await Task.WhenAll(callers); + + resolutions.Should().OnlyContain(resolution => resolution != null); + (opens - warm).Should().BeLessThanOrEqualTo(1, + "twelve simultaneous first lookups of one cold host are one round trip"); + } + + [Fact] + public async Task A_Cancelled_Caller_Still_Leaves_The_Answer_Cached() + { + // The flight runs on CancellationToken.None precisely so one caller + // hanging up does not cancel the lookup others wait on — but if the cache + // write lived in the caller's tail, a lookup whose only caller cancelled + // would complete and then throw its answer away, and the next request + // would pay for the same round trip. Publishing inside the flight is what + // makes the work survive the caller. + var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + var resolver = BuildResolver(dataSource); + + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + try + { + await resolver.ResolveAsync(SchemaFixture.HostA, cancelled.Token); + } + catch (OperationCanceledException) + { + // Expected: this caller stopped waiting. The flight did not. + } + + // Give the flight a moment to finish and publish, then take the database + // away so only a cached answer can succeed. + for (var attempt = 0; attempt < 50 && !await IsCachedAsync(resolver); attempt++) + { + await Task.Delay(20); + } + + await dataSource.DisposeAsync(); + + (await resolver.ResolveAsync(SchemaFixture.HostA)).Should().NotBeNull( + "the flight published its answer even though its only caller had gone"); + + static async Task IsCachedAsync(CachedHostToTenantResolver resolver) + { + try + { + return await resolver.ResolveAsync(SchemaFixture.HostA) is not null; + } + catch (ObjectDisposedException) + { + return false; + } + } + } + private static CachedHostToTenantResolver BuildResolver(NpgsqlDataSource dataSource) { var clock = new FixedClock(new DateTimeOffset(2026, 9, 2, 9, 0, 0, TimeSpan.Zero)); diff --git a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs index 75241ce1..d2010b30 100644 --- a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs @@ -123,6 +123,30 @@ public async Task A_Tenant_Wide_Host_Classifies_As_Tenant_Rather_Than_Organizati (await response.Content.ReadAsStringAsync()).Should().Contain("Tenant"); } + [Theory] + [InlineData("1.2.3.4", "an IPv4 literal is not a name")] + [InlineData("1.2.3.4.", "nor is one a trailing dot used to hide")] + [InlineData("[::1]", "nor an IPv6 literal")] + [InlineData("ex%41mple.com", "nor a percent-escape, which gives one host two spellings")] + public async Task A_Host_That_Names_Nothing_Is_A_404_And_Not_An_Error( + string host, string because) + { + // The branch that had no test, and the one the IPv4 blocker escaped + // through: `1.2.3.4.` normalized to `1.2.3.4`, reached the resolver, and + // threw in the cache-key factory — a 500 and an error-tracker capture per + // request, from an unauthenticated caller. TryAddWithoutValidation because + // HttpClient refuses to send several of these through Headers.Host, and + // the point is what the server does with a header a client can still put + // on the wire. + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/hostprobe", UriKind.Relative)); + request.Headers.TryAddWithoutValidation("Host", host); + + var response = await _client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound, because); + } + [Fact] public async Task An_Unclassified_Prefix_Is_Served_Whatever_Its_Host() { diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs index aad5c87c..b0c36385 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs @@ -46,6 +46,17 @@ public void Classification_Does_Not_Apply_Anywhere_Else(string path) .Should().BeFalse(); } + [Fact] + public void The_Exclusion_List_Is_Pinned() + { + // The shape assertions below iterate the list, so an emptied list passes + // them vacuously — and an emptied list means the Hub contract surface, + // /healthz and /openapi all start being classified, each of which is a 404 + // for a caller that has no host to resolve. + HostClassificationMiddleware.UnclassifiedPrefixes.Should().Equal( + "/healthz", "/readyz", "/openapi", "/admin/hangfire", "/api/internal"); + } + [Fact] public void The_Exclusions_Are_Prefixes_And_Not_Endpoint_Literals() { diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs index 4271f995..beedab6d 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs @@ -107,6 +107,78 @@ public void A_Flood_Evicts_The_Oldest_Unknown_Hosts_And_Nothing_Else() "the most recent answer is the one worth keeping"); } + [Fact] + public async Task Concurrent_Adds_At_The_Cap_Do_Not_Throw() + { + // The blocker this case exists for was measured, not imagined: the first + // Trim enumerated the dictionary through LINQ, which buffers via + // ICollection.CopyTo after a stale Count read. Eight threads adding at the + // cap threw on 33% of adds — ArgumentException from a concurrent insert, + // ArgumentNullException from a default slot left by a concurrent removal. + // Add is unguarded in the resolver and the middleware has no catch, so + // each throw was a 500 where a bodyless 404 was designed — and, because + // only an unknown host reaches Add, a positive host-existence oracle. + var cache = Build(out _, new UnknownHostCacheOptions { MaxEntries = 200 }); + + for (var i = 0; i < 200; i++) + { + cache.Add($"seed-{i}.example.com"); + } + + using var release = new ManualResetEventSlim(false); + var failures = new System.Collections.Concurrent.ConcurrentBag(); + + var workers = Enumerable.Range(0, 8).Select(worker => Task.Run(() => + { + release.Wait(); + + for (var i = 0; i < 200; i++) + { + try + { + cache.Add($"w{worker}-{i}.example.com"); + } + catch (Exception failure) + { + failures.Add(failure); + } + } + })).ToArray(); + + release.Set(); + await Task.WhenAll(workers); + + failures.Should().BeEmpty( + "Add is called from an unguarded, unauthenticated path — a throw here is a 500"); + cache.Count.Should().BeLessThanOrEqualTo(200, "the cap still bounds growth"); + } + + [Fact] + public void A_Trim_Reclaims_The_Entries_That_Have_Lapsed() + { + // Nothing else sweeps: a read drops only the entry it looked at, so a map + // that filled slowly would otherwise ratchet to its cap and stay there for + // the life of the process, sorting the whole structure on every novel + // host. The trim already pays for one pass; expiring on the way past is + // free. + var cache = Build(out var clock, new UnknownHostCacheOptions + { + MaxEntries = 100, + Ttl = TimeSpan.FromMinutes(2), + }); + + for (var i = 0; i < 100; i++) + { + cache.Add($"lapsed-{i}.example.com"); + } + + clock.Advance(TimeSpan.FromMinutes(3)); + cache.Add("fresh.example.com"); + + cache.Count.Should().Be(1, + "one add past the cap sweeps every lapsed entry, leaving only the fresh one"); + } + private static UnknownHostCache Build( out FixedClock clock, UnknownHostCacheOptions? options = null) { diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/EffectiveHostTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/EffectiveHostTests.cs index 59503564..0ccc0ae6 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/EffectiveHostTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/EffectiveHostTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using LearnStack.SharedKernel.Caching; using LearnStack.SharedKernel.Tenancy; using Xunit; @@ -67,6 +68,17 @@ public void Lowering_Is_Invariant_Not_Cultural() [InlineData("[::1]:5080", "a bracketed IPv6 literal with a port")] [InlineData("1.2.3.4", "an IPv4 literal")] [InlineData("1.2.3.4:443", "an IPv4 literal with a port")] + // A trailing dot walked every one of these past the input-side IPv4 gate, + // because the dot is stripped AFTER it. Measured: each came back as an + // accepted host and then threw in CacheKey.ForHostMapping — a 500 and an + // error-tracker capture per request, from an unauthenticated caller, where a + // bodyless 404 was designed. The refusal now runs on the produced value. + [InlineData("1.2.3.4.", "an IPv4 literal a trailing dot hid from the input scan")] + [InlineData("1.2.3.4.:443", "the same, with a port hiding it twice")] + [InlineData("127.0.0.1.", "loopback, same route")] + [InlineData("9.", "a bare integer IPv4 spelling")] + [InlineData("2130706433.", "the 32-bit integer spelling of 127.0.0.1")] + [InlineData("010.010.010.010.", "the dotted-octal spelling")] [InlineData("example.com..", "two trailing dots")] [InlineData(".", "a bare dot")] [InlineData("a..b.com", "an empty label")] @@ -79,6 +91,33 @@ public void Lowering_Is_Invariant_Not_Cultural() public void An_Input_That_Cannot_Name_A_Host_Returns_Null(string? raw, string why) => EffectiveHost.Normalize(raw).Should().BeNull(why); + [Theory] + [InlineData("example.com")] + [InlineData("EXAMPLE.com")] + [InlineData("english.example.com.")] + [InlineData("example.com:8443")] + [InlineData("türkçe.example.com")] + [InlineData("a-b.example.com")] + [InlineData("sub.sub.example.com")] + public void Anything_Normalize_Accepts_Is_A_Host_The_Cache_Key_Accepts(string raw) + { + // The pairing nothing asserted, and whose absence let a blocker through. + // EffectiveHost.Normalize and CacheKey.EnsureValid are two validators of + // the same idea, written apart; the resolver hands the output of the first + // to the second on the anonymous page-load path, so any input the first + // accepts and the second refuses is an exception where a 404 was designed. + // Asserting the relation is what makes the two move together — checking + // either one alone is how they drifted. + var normalized = EffectiveHost.Normalize(raw); + + normalized.Should().NotBeNull(); + + var act = () => CacheKey.ForHostMapping(normalized!); + + act.Should().NotThrow( + "a host Normalize produced must be a host the resolver can key on"); + } + [Fact] public void A_Host_At_The_DNS_Limit_Is_Accepted_And_One_Over_Is_Not() { diff --git a/docs/architecture/27-custom-domain-tls.md b/docs/architecture/27-custom-domain-tls.md index 16417cfc..9bb391a2 100644 --- a/docs/architecture/27-custom-domain-tls.md +++ b/docs/architecture/27-custom-domain-tls.md @@ -257,7 +257,11 @@ public sealed class CachedHostToTenantResolver( ICacheService cache, UnknownHostCache unknownHosts, HostResolutionOptions options, - NpgsqlDataSource dataSource) : IHostToTenantResolver + // Lazy: a platform host is answered from Tenancy:PlatformHosts and never + // reaches a lookup, so it must cost no database work — including no data + // source construction. Holding it directly builds one at the first classified + // request whatever its host. + Lazy dataSource) : IHostToTenantResolver { private readonly ConcurrentDictionary>> _flights = new(StringComparer.Ordinal); @@ -343,7 +347,7 @@ public sealed class CachedHostToTenantResolver( // in transaction blocks" and has no effect, and a session-level // set_config(..., false) would survive on a pooled connection into the // next request. - await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var connection = await dataSource.Value.OpenConnectionAsync(ct); await using var tx = await connection.BeginTransactionAsync(ct); // set_config(..., true) is SET LOCAL's function form and is diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index 775a1b21..8dc99874 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -740,6 +740,19 @@ hyphen. A whitelist is the right shape and a denylist never was — the set of characters a hostname may contain is small and closed, and the set it may not is neither. +> **Erratum — 2026-09-02.** The corrected order below still lets an IPv4 literal +> through, by the same mechanism it was written to close. It places **reject IPv4 +> literals** before **strip exactly one trailing dot**, so `1.2.3.4.` reaches the +> strip as a name and leaves it as a literal — and `GetAscii`'s compatibility +> mapping folds U+3002 and U+FF0E into `.` after that. Measured on the shipped +> transcription: `1.2.3.4.`, `1.2.3.4.:443`, `127.0.0.1.`, `9.`, `2130706433.` and +> `010.010.010.010.` were all returned as accepted hosts, and every one then threw +> in `CacheKey.ForHostMapping` — a `500` and an error-tracker capture per request, +> from an unauthenticated caller, where a bodyless `404` was designed. The IPv4 +> refusal belongs on the **produced value**, beside the character whitelist, for +> the reason point 2 below already gives for the whitelist. Recorded in +> Amendment 4. + **Corrected order.** Reject empty, whitespace-only, or over-253-character input → reject the input outright if it contains whitespace, `/`, `@`, `%`, NUL, `\`, `?` or `#` (a superset of the original list; the last three are equally not part of a @@ -796,6 +809,48 @@ context — `TenantResolverMiddleware` (HTTP), `HubCorrelationMiddleware` handler scope (integration events) — and `EnterPlatformAdminScope` is not among them, because it opens a second connection and sets no tenant context. +### 2026-09-02 — Amendment 4: the IPv4 refusal belongs on the produced value + +**What was wrong.** Amendment 1's corrected order places **reject IPv4 literals** +before **strip exactly one trailing dot**. Both steps were already in that order +when the amendment was written, so the bypass it exists to close was open in the +order it published: `1.2.3.4.` reaches the strip as a name and leaves it as a +literal. + +**How it was shown.** Measured against the shipped transcription — the whole point +of a step order is that it is transcribed — `EffectiveHost.Normalize` returned +`1.2.3.4.`, `1.2.3.4.:443`, `127.0.0.1.`, `9.`, `2130706433.` and +`010.010.010.010.` as accepted hosts. Each then threw `ArgumentException` in +`CacheKey.ForHostMapping`, which `CachedHostToTenantResolver` calls as its first +statement, producing a `500` and an unsampled `IErrorTrackingProvider` capture per +request from an unauthenticated caller — where this ADR's own § The reconciliation +matrix row 1 specifies a bodyless `404`. The throw also precedes the negative +cache, so repeats never coalesce, and because only a host that reaches the +resolver can produce it, the `500` is a positive host-existence oracle against the +indistinguishability row 1 exists to provide. + +**The general form, which is the part worth keeping.** Amendment 1 already made +this argument once, for the character set: "the character rejection must be an +output whitelist, not only an input denylist", because `GetAscii`'s compatibility +mapping produces characters the input scan never saw. The IPv4 refusal is the same +shape and was left on the input side. **Every rejection in `Normalize` is a +predicate on the produced value**; an input-side check is an optimisation, and an +optimisation that is also the only check is a gate the next normalization step +walks around. Two later steps can produce a literal the early check never saw — +the trailing-dot strip, and `GetAscii` folding U+3002 and U+FF0E into `.`. + +**Every carrier changed.** This ADR — the inline erratum beside Amendment 1's +corrected order, and this amendment. `EffectiveHost` re-runs +`IPAddress.TryParse` on the value it is about to return, keeping the early check +as the cheap exit it always was. `EffectiveHostTests` gains the six spellings +above and, new, the pairing property nothing asserted: for every input `Normalize` +accepts, `CacheKey.ForHostMapping` must not throw — the two validators are the +same idea written apart, and checking either alone is how they drifted. + +**The Decision is unchanged.** `EffectiveHost.Normalize` is still the sole +producer of the lookup key and of `app.resolving_host`, still total, still returns +`null` on every failure, and still never throws. + ### 2026-09-01 — Amendment 3: where the `[PublicSurface]` set is enumerated **What was wrong.** § The reconciliation matrix says the `[PublicSurface]` set "is diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 63e988b9..2fed8788 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -192,7 +192,7 @@ adapter trigger. `CacheKey.EnsureValid`, and none re-prefixes. There is no query filter and no RLS policy in front of a dictionary, so the key is the entire isolation boundary — which is why the shape is validated rather than left to each call site to remember. -- TTL defaults: 60s for hot-path reads (host → tenant, entitlement projection cache, +- TTL defaults: 60s for hot-path reads (entitlement projection cache, permission cache), 5min for medium-warm reads, 1h for cold lookups. Anything longer needs explicit justification in code review. - **Correctness never lives in the cache.** A miss is not an error, and an diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 590f17d6..6c209036 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -2002,9 +2002,25 @@ structural test proves — and what it does not. - **Phase:** 02b. - **Note:** The integration test is the load-bearing half: the structural test passes while issuer validation is disabled in configuration. +#### `Resolving_Host_Is_Set_In_One_Place` + +- **Asserts:** `set_config('app.resolving_host'` appears in exactly one file across + `backend/src` — `CachedHostToTenantResolver`. The bare literal is deliberately not banned: + the migration's own policy DDL must name the variable in order to read it. +- **Why it matters:** `app.resolving_host` is the only session variable whose value *is* the + lookup key. The policy on `platform_host_to_tenant` admits exactly the row the setter + announces, so a second setter is a second announcement on the one table read before any + tenant context exists — the one place a widened read is not already caught by + `app.tenant_id` being `NULL`. +- **Source:** [11-security.md § Tenant Context](11-security.md); + [05-database.md § Table classes](05-database.md); ADR-0036. +- **Type:** xUnit + source scan. **Kind:** structural. +- **Status:** **Implemented** (Packet 7 step 4, `TenancyConventionTests`). +- **Phase:** 02a Packet 7. + #### `Host_Classification_Applies_To_Tenant_Facing_Routes_Only` -- **Asserts:** host classification runs for `/api/v1/*` and for no other prefix. `/healthz`, `/readyz`, `/openapi/*`, `/admin/hangfire*` and `/api/internal/*` are asserted as a **prefix list**, not as endpoint literals — a closed allow-list written as literals 404s the entire Hub contract surface. +- **Asserts:** host classification runs for `/api/v1/*` and for no other prefix. `/healthz`, `/readyz`, `/openapi/*`, `/admin/hangfire*` and `/api/internal/*` are asserted as a **prefix list**, not as endpoint literals — a closed allow-list written as literals 404s the entire Hub contract surface. The list's **contents** are pinned as well as its shape: an emptied or shortened list would otherwise start classifying the Hub surface with every case still green. - **Source:** ADR-0036 § The reconciliation matrix. - **Type:** xUnit + route-table inspection. **Kind:** structural. - **Status:** **Implemented** (Packet 7 step 4, `HostClassificationScopeTests`). From f25ad498c3552db5e4e74af048c5346320b509ed Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 03:32:50 +0300 Subject: [PATCH 14/55] test(tenancy): make the Step 4 guards fail without the code they cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sonnet round measured five mechanisms this step exists to guarantee that survive deletion with 1026 green — including the fix the previous commit shipped. That is the failure Packets 5 and 6 both recorded as their most repeated lesson, a test agreeing with the code instead of constraining it, and it landed hardest on the guard for a blocker one round old. Publish-inside-the-flight had the worst of it. Moving the cache write back to the caller's tail — the exact shape 5c50914 replaced — left all ten cases in HostResolutionTests green, six reruns of six. The case named for the property was false twice over: it cancelled its token BEFORE calling, and InMemoryCacheService.GetAsync throws on a cancelled token as its second statement, so no flight was ever created; then it polled with uncancelled ResolveAsync calls, each of which published the answer itself. It now takes an ACCESS EXCLUSIVE lock on the table so the caller can be cancelled while its flight is provably mid-read — waited for on pg_stat_activity, not on a sleep — and waits on a counting cache decorator rather than by resolving again. Its sibling asserts twelve waiters produce exactly one publish: counting physical connections proves the flight ran once, which is not the same claim. The prefix-matching case diverged from every exclusion prefix at its first character, so it answered true under segment and character matching alike, and all 23 tests passed with StartsWithSegments replaced by StartsWith. `/api/v10` is the path that separates them, and ADR-0024's own versioning plan makes it the one that arrives — under character matching a second major would be swallowed whole by the first's prefix. For the exclusion list the difference is unobservable through this predicate, which the comment now says rather than asserting another vacuous case. The eager connection-string validation was invisible to its own guard file: every case there calls BuildApplicationDataSource, which validates whichever way the composition root behaves. Asserted now on AddLearnStackPersistence itself, with the deliberate absent-key case pinned beside it. The low-water mark, the rejection counter, and the Debug level on the rejected host had no reader at all; the last is load-bearing, since the host is attacker-authored and a bump to Information for observability passed everything. It is asserted against the middleware directly, because Serilog is wired without writeToProviders and a provider in DI receives nothing — measured. Two behaviour changes, both small. A null entry in Tenancy:PlatformHosts was the one spelling that passed: Normalize(null) is null, so `null != null` is false, while "" and " " are both refused. And ADR-0036 now states that the static list beats a mapping row naming the same host — true today, stated nowhere, and the losing row is silent. Also: this ADR's amendments were out of chronological order, because the previous commit inserted Amendment 4 above Amendment 3. Amendment 4's own "every carrier changed" list had omitted the catalogue, which is the mechanism that should have caught it. 1039 green, zero skips. Every guard above was re-measured against a mutation and fails without it. ADR: 0036 Co-Authored-By: Claude Opus 5 (1M context) --- .../PersistenceCompositionExtensions.cs | 15 +- .../Tenancy/PlatformHostOptions.cs | 9 +- .../Tenancy/TenancyCompositionExtensions.cs | 14 +- .../Database/HostResolutionTests.cs | 173 +++++++++++++++--- .../HostClassificationHttpTests.cs | 85 +++++++++ .../ApplicationDataSourceGuardTests.cs | 72 ++++++++ .../Tenancy/HostClassificationLoggingTests.cs | 131 +++++++++++++ .../Tenancy/HostClassificationScopeTests.cs | 31 +++- .../MultiTenancy/UnknownHostCacheTests.cs | 27 +++ .../0036-tenant-resolution-trusted-inputs.md | 99 +++++----- .../21-architecture-tests-catalogue.md | 6 +- 11 files changed, 570 insertions(+), 92 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationLoggingTests.cs diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 56657ef1..4390153e 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -37,11 +37,16 @@ namespace LearnStack.Api.Composition; /// app.tenant_id = '' — turns from "no rows" into "every tenant's rows". /// /// -/// Resolved lazily, not built eagerly. The data source is a singleton -/// whose factory runs when something first needs a connection. A deployment with -/// no database configured therefore fails on the first request that touches one, -/// naming the key — rather than at startup, which would make every -/// WebApplicationFactory test carry a database it does not use. +/// Built lazily; validated eagerly when there is anything to validate. +/// The data source is a singleton whose factory runs when something first needs a +/// connection, so a WebApplicationFactory test — and a deployment serving +/// only platform hosts — carries no database it does not use. The two checks above +/// are not deferred with it: a present ConnectionStrings:Default is +/// name-checked at AddLearnStackPersistence time, because a string naming +/// learnstack_migration is precisely the ownership mistake this guard exists +/// for and the first tenant request is a bad place to discover it. An absent +/// key still fails lazily, on the first request that needs a tenant — which is the +/// first moment its absence means anything. /// /// /// ConnectionStrings:PlatformAdmin and diff --git a/backend/src/LearnStack.Api/Tenancy/PlatformHostOptions.cs b/backend/src/LearnStack.Api/Tenancy/PlatformHostOptions.cs index 4dba4d73..89a7b0b0 100644 --- a/backend/src/LearnStack.Api/Tenancy/PlatformHostOptions.cs +++ b/backend/src/LearnStack.Api/Tenancy/PlatformHostOptions.cs @@ -42,8 +42,15 @@ public sealed class PlatformHostOptions /// The validated set, for ordinal lookup. public HashSet Validate() { + // The null check is not redundant with the comparison below it. Measured: + // a JSON `null` element normalizes to null, so `null != null` is false and + // the entry sails through into the set — while "" and " " are both + // refused. The entry is inert at request time, because Contains on a + // non-null host never matches it; but a configuration typo that every + // other spelling refuses at boot should not be the one that passes. var offenders = Hosts - .Where(host => EffectiveHost.Normalize(host) != host) + .Where(host => host is null || EffectiveHost.Normalize(host) != host) + .Select(host => host ?? "") .ToList(); if (offenders.Count > 0) diff --git a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs index 345e3848..1cd7fa94 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs @@ -8,10 +8,18 @@ namespace LearnStack.Api.Tenancy; /// -/// Composition-root wiring for the Packet 4 half of -/// ADR-0036: -/// the effective host, the trusted hop, and the assertion recorder. +/// Composition-root wiring for +/// ADR-0036's +/// anonymous, pre-authentication tier. /// +/// +/// Packet 4 brought the effective host, the trusted hop and the assertion recorder; +/// Packet 7 added everything host classification needs to answer a request without a +/// database — Tenancy:PlatformHosts and its boot-time validation, the +/// separately-capped UnknownHostCache, HostResolutionOptions, and +/// IHostToTenantResolver over a Lazy<NpgsqlDataSource> so a +/// platform-only deployment never builds one at all. +/// public static class TenancyCompositionExtensions { public const string DeploymentModeKey = "Deployment:Mode"; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs index b15a8e9a..3f6362fb 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics.Metrics; +using System.Globalization; using FluentAssertions; using LearnStack.Infrastructure.Caching; using LearnStack.Infrastructure.MultiTenancy; @@ -37,6 +38,9 @@ public sealed class HostResolutionTests .AddMetrics() .BuildServiceProvider(); + private static readonly DateTimeOffset Origin = + new(2026, 9, 2, 9, 0, 0, TimeSpan.Zero); + private readonly SchemaFixture _schema; public HostResolutionTests(SchemaFixture schema) => _schema = schema; @@ -200,66 +204,177 @@ public async Task Concurrent_First_Lookups_Of_One_Host_Open_One_Connection() } [Fact] - public async Task A_Cancelled_Caller_Still_Leaves_The_Answer_Cached() + public async Task A_Cancelled_Caller_Still_Leaves_The_Answer_Published() { // The flight runs on CancellationToken.None precisely so one caller // hanging up does not cancel the lookup others wait on — but if the cache // write lived in the caller's tail, a lookup whose only caller cancelled // would complete and then throw its answer away, and the next request - // would pay for the same round trip. Publishing inside the flight is what - // makes the work survive the caller. + // would pay for the same round trip. + // + // The window is a real one: an ACCESS EXCLUSIVE lock on the table blocks + // the resolver's SELECT, so the caller can be cancelled while its flight + // is demonstrably mid-read. The first version of this case cancelled the + // token BEFORE calling and then polled with uncancelled reads, which is + // two false negatives in one: InMemoryCacheService.GetAsync throws on a + // cancelled token as its second statement, so no flight was ever created, + // and each poll published the answer itself. Measured — with the write + // moved back into the caller's tail, the whole file stayed green. + await using var lockHolder = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); - var resolver = BuildResolver(dataSource); - - using var cancelled = new CancellationTokenSource(); - await cancelled.CancelAsync(); + var publishes = new PublishCountingCache(NewCache()); + var resolver = BuildResolver(dataSource, publishes); - try - { - await resolver.ResolveAsync(SchemaFixture.HostA, cancelled.Token); - } - catch (OperationCanceledException) + await using var blocking = await lockHolder.OpenConnectionAsync(); + await using var holdingTransaction = await blocking.BeginTransactionAsync(); + await using (var takeLock = new NpgsqlCommand( + "LOCK TABLE platform_host_to_tenant IN ACCESS EXCLUSIVE MODE", blocking, holdingTransaction)) { - // Expected: this caller stopped waiting. The flight did not. + await takeLock.ExecuteNonQueryAsync(); } - // Give the flight a moment to finish and publish, then take the database - // away so only a cached answer can succeed. - for (var attempt = 0; attempt < 50 && !await IsCachedAsync(resolver); attempt++) + using var hangUp = new CancellationTokenSource(); + var caller = resolver.ResolveAsync(SchemaFixture.HostA, hangUp.Token); + + await WaitUntilBlockedOnTheTableAsync(lockHolder); + + // The caller goes away with its flight still inside the SELECT. + await hangUp.CancelAsync(); + var act = async () => await caller; + await act.Should().ThrowAsync(); + + // Release the read, and the flight — which nobody is waiting on any more — + // must still publish. Waited for on the COUNTER and never by resolving + // again: a poll that calls ResolveAsync publishes the answer itself, which + // is exactly how the first version of this case passed against the shape + // it was written to reject. + await holdingTransaction.CommitAsync(); + + for (var attempt = 0; attempt < 100 && !publishes.Observed; attempt++) { - await Task.Delay(20); + await Task.Delay(50); } + publishes.Observed.Should().BeTrue( + "the flight publishes its answer even though its only caller had gone"); + publishes.Count.Should().Be(1); + await dataSource.DisposeAsync(); (await resolver.ResolveAsync(SchemaFixture.HostA)).Should().NotBeNull( - "the flight published its answer even though its only caller had gone"); + "with the database gone, only a published answer can serve this"); + } - static async Task IsCachedAsync(CachedHostToTenantResolver resolver) + [Fact] + public async Task Twelve_Waiters_On_One_Cold_Host_Publish_One_Answer() + { + // The other half of "published inside the flight": once, however many + // waiters there are. Counting physical connections proves the flight ran + // once; counting writes proves the ANSWER was written once, and by the + // flight rather than by each waiter's tail. Measured, the write moved into + // the caller's tail leaves every connection-counting case green — this is + // the assertion that separates the two shapes. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + var publishes = new PublishCountingCache(NewCache()); + var resolver = BuildResolver(dataSource, publishes); + + using var release = new ManualResetEventSlim(false); + + var callers = Enumerable.Range(0, 12).Select(_ => Task.Run(async () => { - try - { - return await resolver.ResolveAsync(SchemaFixture.HostA) is not null; - } - catch (ObjectDisposedException) + release.Wait(); + return await resolver.ResolveAsync(SchemaFixture.HostA); + })).ToArray(); + + release.Set(); + var resolutions = await Task.WhenAll(callers); + + resolutions.Should().OnlyContain(resolution => resolution != null); + publishes.Count.Should().Be(1, + "twelve waiters share one flight, and the flight publishes once"); + } + + /// + /// Blocks until a backend is waiting on the table lock, so the caller can be + /// cancelled while its flight is provably mid-read. + /// + private static async Task WaitUntilBlockedOnTheTableAsync(NpgsqlDataSource observer) + { + // pg_stat_activity rather than a delay: a sleep long enough to be reliable + // on a loaded CI machine is a sleep every run pays for, and one short + // enough not to be is a flake. + for (var attempt = 0; attempt < 100; attempt++) + { + await using var command = observer.CreateCommand( + """ + SELECT count(*) FROM pg_stat_activity + WHERE wait_event_type = 'Lock' + AND query LIKE '%platform_host_to_tenant%' + """); + + if (Convert.ToInt64(await command.ExecuteScalarAsync(), CultureInfo.InvariantCulture) > 0) { - return false; + return; } + + await Task.Delay(50); } + + throw new InvalidOperationException( + "The resolver never blocked on the table lock, so the flight was never " + + "caught mid-read and this case would prove nothing."); } - private static CachedHostToTenantResolver BuildResolver(NpgsqlDataSource dataSource) + private static InMemoryCacheService NewCache() => + new(new FixedClock(Origin), MeterServices.GetRequiredService()); + + private static CachedHostToTenantResolver BuildResolver( + NpgsqlDataSource dataSource, ICacheService? cache = null) { - var clock = new FixedClock(new DateTimeOffset(2026, 9, 2, 9, 0, 0, TimeSpan.Zero)); - var meterFactory = MeterServices.GetRequiredService(); + var clock = new FixedClock(Origin); return new CachedHostToTenantResolver( - new InMemoryCacheService(clock, meterFactory), + cache ?? NewCache(), new UnknownHostCache(clock, new UnknownHostCacheOptions()), new HostResolutionOptions(), new Lazy(() => dataSource)); } + /// + /// Counts what the resolver publishes, so a case can assert who wrote + /// the answer and how many times — neither of which a connection count + /// or a later cache hit can distinguish. + /// + private sealed class PublishCountingCache(ICacheService inner) : ICacheService + { + private int _count; + + public int Count => Volatile.Read(ref _count); + + public bool Observed => Count > 0; + + public Task GetAsync(string key, CancellationToken cancellationToken = default) => + inner.GetAsync(key, cancellationToken); + + public Task SetAsync( + string key, T value, CacheOptions? options = null, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _count); + return inner.SetAsync(key, value, options, cancellationToken); + } + + public Task GetOrSetAsync( + string key, + Func> factory, + CacheOptions? options = null, + CancellationToken cancellationToken = default) => + inner.GetOrSetAsync(key, factory, options, cancellationToken); + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => + inner.RemoveAsync(key, cancellationToken); + } + /// /// Writes a host row and commits it, because the resolver reads on a /// connection of its own and cannot see an uncommitted one. diff --git a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs index d2010b30..cc525a9e 100644 --- a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.Metrics; using System.Net; using FluentAssertions; using LearnStack.Api.Common; @@ -147,6 +148,72 @@ public async Task A_Host_That_Names_Nothing_Is_A_404_And_Not_An_Error( response.StatusCode.Should().Be(HttpStatusCode.NotFound, because); } + [Fact] + public async Task A_Rejection_Is_Counted_And_A_Served_Host_Is_Not() + { + // The counter an operator watches for a hostname flood, which had no + // reader in any of the four test assemblies. The level the rejected host + // is logged at is the other half of this guarantee and is asserted in + // HostClassificationLoggingTests — through the middleware directly, + // because Serilog is wired without writeToProviders, so an ILoggerProvider + // registered in DI here receives nothing at all. + var counted = 0L; + using var meterListener = new MeterListener(); + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Name == HostClassificationMiddleware.RejectedCounterName) + { + listener.EnableMeasurementEvents(instrument); + } + }; + meterListener.SetMeasurementEventCallback( + (_, measurement, _, _) => Interlocked.Add(ref counted, measurement)); + meterListener.Start(); + + using var platform = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/hostprobe", UriKind.Relative)); + (await _client.SendAsync(platform)).StatusCode.Should().Be(HttpStatusCode.OK); + + Interlocked.Read(ref counted).Should().Be(0, "a served host is not a rejection"); + + using var unknown = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/hostprobe", UriKind.Relative)); + unknown.Headers.Host = "counted.example.com"; + (await _client.SendAsync(unknown)).StatusCode.Should().Be(HttpStatusCode.NotFound); + + meterListener.RecordObservableInstruments(); + Interlocked.Read(ref counted).Should().Be(1, "one refused host, one increment"); + } + + [Fact] + public async Task A_Platform_Host_Wins_Over_A_Mapping_Row_That_Names_It() + { + // Pinning today's actual behaviour, which nothing stated. The platform + // branch short-circuits before the resolver is ever called, so a row in + // platform_host_to_tenant for a configured platform host is permanently + // and silently inert — no log, no counter, no startup check. + // + // The precedence is the right way round: Tenancy:PlatformHosts is the + // operator's own entry point, and a tenant that managed to claim that + // hostname would otherwise take it over. What is worth knowing is that the + // losing row is invisible, so this asserts it rather than leaving the next + // reader to discover it from a support ticket. A real cross-check belongs + // to whichever packet builds the host-mapping writer; a database + // constraint cannot see application configuration. + fixture.Resolver.Calls.Clear(); + + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/hostprobe", UriKind.Relative)); + request.Headers.Host = HostClassificationFixture.PlatformHostWithAMappingRow; + + var response = await _client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Contain("Platform"); + fixture.Resolver.Calls.Should().BeEmpty( + "the row is never read, which is why it is inert rather than conflicting"); + } + [Fact] public async Task An_Unclassified_Prefix_Is_Served_Whatever_Its_Host() { @@ -167,6 +234,9 @@ public sealed class HostClassificationFixture : WebApplicationFactory public const string OrganizationHost = "branch.example.com"; public const string TenantHost = "school.example.com"; + /// Configured as a platform host and answered by the resolver. + public const string PlatformHostWithAMappingRow = "both.example.com"; + public static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-0000000000c1"); public static readonly Guid Organization = Guid.Parse("018f4d40-0000-7000-8000-0000000000c2"); @@ -176,6 +246,16 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); + + // UseSetting, not ConfigureAppConfiguration: the composition root reads + // Tenancy:PlatformHosts while the builder is being assembled, which is + // before a deferred ConfigureAppConfiguration runs — the same trap + // DeploymentModeCompositionTests documents for Deployment:Mode, and + // measured here too (the host classified Tenant, not Platform). + // appsettings.Development.json already carries localhost at index 0; this + // adds the one that ALSO has a mapping row. + builder.UseSetting($"{PlatformHostOptions.SectionName}:1", PlatformHostWithAMappingRow); + builder.ConfigureTestServices(services => { services.AddControllers(options => @@ -211,6 +291,11 @@ public IList Calls OrganizationHost => new HostResolution( TenantId.From(Tenant), OrganizationId.From(Organization)), TenantHost => new HostResolution(TenantId.From(Tenant), null), + + // Deliberately answerable. If the platform branch ever stopped + // short-circuiting, this host would classify Tenant and the + // precedence case would fail rather than silently pass. + PlatformHostWithAMappingRow => new HostResolution(TenantId.From(Tenant), null), _ => null, }); } diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs index 69a8115f..419ae9c2 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs @@ -1,5 +1,7 @@ using FluentAssertions; using LearnStack.Api.Composition; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace LearnStack.Tests.Unit.Api.Composition; @@ -126,4 +128,74 @@ public void No_message_carries_the_password() messages.Should().OnlyContain(message => !message.Contains("hunter2", StringComparison.Ordinal)); messages.Should().OnlyContain(message => message.Contains("***", StringComparison.Ordinal)); } + + [Theory] + [InlineData("learnstack_migration")] + [InlineData("learnstack_platform")] + [InlineData("learnstack_outbox_admin")] + public void A_present_but_wrong_credential_refuses_the_boot_at_registration(string role) + { + // The eager half, and the one the other cases here cannot see: they all + // call BuildApplicationDataSource directly, which validates whether or not + // the caller ever reaches it, so every one of them passes with the + // composition root left purely lazy. Measured three times — deleting the + // eager block leaves the whole 1026-test suite green. + // + // What is being asserted is WHEN: the throw comes out of + // AddLearnStackPersistence itself, before any ServiceProvider is built and + // long before the first request. The Lazy that keeps a + // platform-only deployment from needing a credential at all was allowed to + // defer the build; it was not meant to defer the checks with it, because + // a string naming learnstack_migration is the ownership mistake FORCE ROW + // LEVEL SECURITY exists to defeat and production is a bad place to find it. + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Default"] = + $"Host=localhost;Database=learnstack;Username={role};Password=s3cret", // leakwatch:ignore + }) + .Build(); + + var register = () => services.AddLearnStackPersistence(configuration); + + register.Should().Throw() + .WithMessage($"*Username='{role}'*"); + } + + [Fact] + public void A_valid_credential_registers_without_connecting() + { + // The other side of the same coin: eager VALIDATION, still lazy BUILD. + // Registration must not open a socket — nothing is listening during + // composition — and it must not reject the credential the platform runs on. + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Default"] = Valid, + }) + .Build(); + + var register = () => services.AddLearnStackPersistence(configuration); + + register.Should().NotThrow(); + } + + [Fact] + public void An_absent_key_still_fails_lazily_rather_than_at_registration() + { + // Deliberate, and the reason the eager check is guarded on presence: a + // deployment that serves only platform hosts — answered from + // Tenancy:PlatformHosts, never from the database — legitimately has no + // application credential, and must still boot. It fails on the first + // request that needs a tenant, which is the first moment the absence + // actually matters. + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + + var register = () => services.AddLearnStackPersistence(configuration); + + register.Should().NotThrow(); + } } diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationLoggingTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationLoggingTests.cs new file mode 100644 index 00000000..4f4136e8 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationLoggingTests.cs @@ -0,0 +1,131 @@ +using System.Diagnostics.Metrics; +using FluentAssertions; +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Tenancy; + +/// +/// What a refused host is written to the log at. +/// +/// +/// +/// The level is load-bearing rather than a preference. The Host header is +/// attacker-authored on every anonymous request, and +/// ADR-0036 +/// keeps attacker-authored strings out of anything retained — it refuses to put +/// them in audit_log, and an Information line an operator forwards to +/// a shared sink is the same exposure by another route. A well-meaning bump "for +/// observability" passed the entire suite before this case existed. +/// +/// +/// Driven against the middleware directly rather than through the host: Serilog is +/// wired with UseSerilog and no writeToProviders, so an +/// ILoggerProvider registered in a WebApplicationFactory's test +/// services receives nothing — measured, the capture came back empty. The counter +/// half of the same rejection path is asserted through the real pipeline in +/// HostClassificationHttpTests. +/// +/// +public sealed class HostClassificationLoggingTests +{ + [Fact] + public async Task A_Refused_Host_Is_Logged_No_Higher_Than_Debug() + { + var logger = new CapturingLogger(); + var middleware = Build(logger); + var context = ContextFor("stranger.example.com"); + + await middleware.InvokeAsync(context); + + context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); + + var entry = logger.Entries.Should().ContainSingle( + captured => captured.Message.Contains("stranger.example.com", StringComparison.Ordinal)) + .Subject; + + entry.Level.Should().Be(LogLevel.Debug, + "the rejected host is attacker-authored and must not reach a retained sink"); + } + + [Fact] + public async Task A_Host_That_Names_Nothing_Never_Reaches_The_Log_At_All() + { + // The unnamed branch logs the literal "unnamed" and not the header, so a + // 300-character or percent-escaped Host cannot be written anywhere by this + // middleware even at Debug. + var logger = new CapturingLogger(); + var middleware = Build(logger); + var context = ContextFor("ex%41mple.com"); + + await middleware.InvokeAsync(context); + + context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); + logger.Entries.Should().OnlyContain( + captured => !captured.Message.Contains("ex%41mple.com", StringComparison.Ordinal)); + } + + private static HostClassificationMiddleware Build(ILogger logger) + { + var meterFactory = new ServiceCollection() + .AddMetrics() + .BuildServiceProvider() + .GetRequiredService(); + + return new HostClassificationMiddleware( + _ => Task.CompletedTask, + new EffectiveHostAccessor(Options.Create(new TrustedHopOptions())), + new NeverResolvesResolver(), + new PlatformHostOptions(), + logger, + meterFactory); + } + + private static DefaultHttpContext ContextFor(string host) + { + var context = new DefaultHttpContext(); + context.Request.Path = "/api/v1/anything"; + context.Request.Headers.Host = host; + return context; + } + + private sealed class NeverResolvesResolver : IHostToTenantResolver + { + public Task ResolveAsync( + string host, CancellationToken cancellationToken = default) => + Task.FromResult(null); + } + + private sealed class CapturingLogger : ILogger + { + private readonly List _entries = []; + + public IReadOnlyList Entries => _entries; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + // Deliberately always enabled: the assertion is about the level the + // middleware CHOOSES, and a logger that filtered would hide exactly the + // change this case exists to catch. + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + _entries.Add(new Captured(logLevel, formatter(state, exception))); + } + + internal sealed record Captured(LogLevel Level, string Message); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs index b0c36385..efb1af2f 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs @@ -74,14 +74,26 @@ public void The_Exclusions_Are_Prefixes_And_Not_Endpoint_Literals() } } - [Fact] - public void A_Prefix_Match_Is_On_Segments_Rather_Than_Characters() + [Theory] + [InlineData("/api/v10/courses", "the second live major is not beneath the first")] + [InlineData("/api/v1x", "nor is anything else that merely starts with its characters")] + public void A_Prefix_Match_Is_On_Segments_Rather_Than_Characters(string path, string because) { - // `/api/internalise` starts with the characters of `/api/internal` and is - // not beneath it. Segment matching is what keeps a future route from being - // silently unclassified because its name happens to begin with another's. - HostClassificationMiddleware.ClassifiesPath(new PathString("/api/v1/internalise")) - .Should().BeTrue(); + // Measured: every other case in this file passes with StartsWithSegments + // replaced by a plain character StartsWith, because none of them names a + // path that the two strategies answer differently. `/api/v10` is that + // path, and ADR-0024's own `/api/v{N}` plan makes it the shape that + // actually arrives — under character matching the whole of a second major + // would be swallowed by the first's prefix and classified as v1. + // + // Only the CLASSIFIED prefix can show the difference. For the exclusion + // list it is unobservable through this predicate: character matching is + // strictly wider there, and every path it would wrongly exclude + // (`/healthzz`, `/api/internalise`) fails the `/api/v1` test anyway, so + // both strategies answer false. Asserting on one of those would be another + // vacuous case, which is what this one replaced. + HostClassificationMiddleware.ClassifiesPath(new PathString(path)) + .Should().BeFalse(because); } } @@ -104,9 +116,10 @@ public sealed class PlatformHostOptionsTests [InlineData("1.2.3.4", "an IP literal is refused as a host name")] [InlineData("türkçe.example.com", "an unpunycoded IDN never matches its A-label")] [InlineData(" ", "whitespace names no host")] - public void An_Unnormalized_Entry_Refuses_The_Boot(string host, string because) + [InlineData(null, "and a null entry is the one spelling the comparison alone lets through")] + public void An_Unnormalized_Entry_Refuses_The_Boot(string? host, string because) { - var options = new PlatformHostOptions { Hosts = [host] }; + var options = new PlatformHostOptions { Hosts = [host!] }; var act = options.Validate; diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs index beedab6d..afa4e294 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/UnknownHostCacheTests.cs @@ -107,6 +107,33 @@ public void A_Flood_Evicts_The_Oldest_Unknown_Hosts_And_Nothing_Else() "the most recent answer is the one worth keeping"); } + [Fact] + public void A_Trim_Leaves_Headroom_Rather_Than_Stopping_At_The_Cap() + { + // Measured: trimming back to the cap itself passes every other case here, + // including both flood cases, because none of them asserts the count is + // strictly BELOW the cap. Without the headroom the map sits one add from + // overflowing and every subsequent novel host pays for another full sort — + // the property the code's own comment claims and nothing checked. + // + // Exactly one add past the cap, not a flood: a flood lands wherever the + // remainder leaves it, and the number this pins is the target itself. + const int Cap = 100; + var cache = Build(out _, new UnknownHostCacheOptions + { + MaxEntries = Cap, + Ttl = TimeSpan.FromHours(1), + }); + + for (var i = 0; i <= Cap; i++) + { + cache.Add($"host-{i}.example.com"); + } + + cache.Count.Should().Be(Cap * 9 / 10, + "one trim goes to the low-water mark, not to the cap"); + } + [Fact] public async Task Concurrent_Adds_At_The_Cap_Do_Not_Throw() { diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index 8dc99874..a0116086 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -286,6 +286,17 @@ host to disagree with — which grants only their own tenant. This is stated so reader does not treat a passing cross-check as evidence of attacker containment. The control is that no signal outside the intersection can select a tenant. +**`Tenancy:PlatformHosts` is checked first and wins outright.** A host on the static +list classifies `PlatformHost` before the resolver is called at all, so a row in +`platform_host_to_tenant` naming the same host is inert — never read, never logged, +never counted. The precedence is the right way round: the list is the operator's own +entry point, and a tenant that acquired that hostname must not be able to take it over. +What is worth stating is that the losing row is *silent*, so a deployment that creates +one gets no signal. There is no startup cross-check and no constraint, because the two +live in different places — one is application configuration, the other a table — and a +database constraint cannot see the first. Whichever packet builds the host-mapping +writer owns the check; until then the behaviour is pinned by a test. + **The anonymous organization scope is the host-mapping row**, not the tenant's organization count. A tenant that wants its default organization's content on its public site seeds `organization_id` into its `platform_host_to_tenant` row. That removes a code @@ -809,48 +820,6 @@ context — `TenantResolverMiddleware` (HTTP), `HubCorrelationMiddleware` handler scope (integration events) — and `EnterPlatformAdminScope` is not among them, because it opens a second connection and sets no tenant context. -### 2026-09-02 — Amendment 4: the IPv4 refusal belongs on the produced value - -**What was wrong.** Amendment 1's corrected order places **reject IPv4 literals** -before **strip exactly one trailing dot**. Both steps were already in that order -when the amendment was written, so the bypass it exists to close was open in the -order it published: `1.2.3.4.` reaches the strip as a name and leaves it as a -literal. - -**How it was shown.** Measured against the shipped transcription — the whole point -of a step order is that it is transcribed — `EffectiveHost.Normalize` returned -`1.2.3.4.`, `1.2.3.4.:443`, `127.0.0.1.`, `9.`, `2130706433.` and -`010.010.010.010.` as accepted hosts. Each then threw `ArgumentException` in -`CacheKey.ForHostMapping`, which `CachedHostToTenantResolver` calls as its first -statement, producing a `500` and an unsampled `IErrorTrackingProvider` capture per -request from an unauthenticated caller — where this ADR's own § The reconciliation -matrix row 1 specifies a bodyless `404`. The throw also precedes the negative -cache, so repeats never coalesce, and because only a host that reaches the -resolver can produce it, the `500` is a positive host-existence oracle against the -indistinguishability row 1 exists to provide. - -**The general form, which is the part worth keeping.** Amendment 1 already made -this argument once, for the character set: "the character rejection must be an -output whitelist, not only an input denylist", because `GetAscii`'s compatibility -mapping produces characters the input scan never saw. The IPv4 refusal is the same -shape and was left on the input side. **Every rejection in `Normalize` is a -predicate on the produced value**; an input-side check is an optimisation, and an -optimisation that is also the only check is a gate the next normalization step -walks around. Two later steps can produce a literal the early check never saw — -the trailing-dot strip, and `GetAscii` folding U+3002 and U+FF0E into `.`. - -**Every carrier changed.** This ADR — the inline erratum beside Amendment 1's -corrected order, and this amendment. `EffectiveHost` re-runs -`IPAddress.TryParse` on the value it is about to return, keeping the early check -as the cheap exit it always was. `EffectiveHostTests` gains the six spellings -above and, new, the pairing property nothing asserted: for every input `Normalize` -accepts, `CacheKey.ForHostMapping` must not throw — the two validators are the -same idea written apart, and checking either alone is how they drifted. - -**The Decision is unchanged.** `EffectiveHost.Normalize` is still the sole -producer of the lookup key and of `app.resolving_host`, still total, still returns -`null` on every failure, and still never throws. - ### 2026-09-01 — Amendment 3: where the `[PublicSurface]` set is enumerated **What was wrong.** § The reconciliation matrix says the `[PublicSurface]` set "is @@ -909,3 +878,49 @@ a tenant-owned write, and none is classified MUST-class `read-sensitive`. - [architecture/13 Identity and Auth](../architecture/13-identity-and-auth.md) - [architecture/14 Frontend Architecture](../architecture/14-frontend-architecture.md) - [architecture/30 API Gateway](../architecture/30-api-gateway.md) + +### 2026-09-02 — Amendment 4: the IPv4 refusal belongs on the produced value + +**What was wrong.** Amendment 1's corrected order places **reject IPv4 literals** +before **strip exactly one trailing dot**. Both steps were already in that order +when the amendment was written, so the bypass it exists to close was open in the +order it published: `1.2.3.4.` reaches the strip as a name and leaves it as a +literal. + +**How it was shown.** Measured against the shipped transcription — the whole point +of a step order is that it is transcribed — `EffectiveHost.Normalize` returned +`1.2.3.4.`, `1.2.3.4.:443`, `127.0.0.1.`, `9.`, `2130706433.` and +`010.010.010.010.` as accepted hosts. Each then threw `ArgumentException` in +`CacheKey.ForHostMapping`, which `CachedHostToTenantResolver` calls as its first +statement, producing a `500` and an unsampled `IErrorTrackingProvider` capture per +request from an unauthenticated caller — where this ADR's own § The reconciliation +matrix row 1 specifies a bodyless `404`. The throw also precedes the negative +cache, so repeats never coalesce, and because only a host that reaches the +resolver can produce it, the `500` is a positive host-existence oracle against the +indistinguishability row 1 exists to provide. + +**The general form, which is the part worth keeping.** Amendment 1 already made +this argument once, for the character set: "the character rejection must be an +output whitelist, not only an input denylist", because `GetAscii`'s compatibility +mapping produces characters the input scan never saw. The IPv4 refusal is the same +shape and was left on the input side. **Every rejection in `Normalize` is a +predicate on the produced value**; an input-side check is an optimisation, and an +optimisation that is also the only check is a gate the next normalization step +walks around. Two later steps can produce a literal the early check never saw — +the trailing-dot strip, and `GetAscii` folding U+3002 and U+FF0E into `.`. + +**Every carrier changed.** This ADR — the inline erratum beside Amendment 1's +corrected order, and this amendment. `EffectiveHost` re-runs +`IPAddress.TryParse` on the value it is about to return, keeping the early check +as the cheap exit it always was. `EffectiveHostTests` gains the six spellings +above and, new, the pairing property nothing asserted: for every input `Normalize` +accepts, `CacheKey.ForHostMapping` must not throw — the two validators are the +same idea written apart, and checking either alone is how they drifted. +[Standards 21](../standards/21-architecture-tests-catalogue.md)'s +`Effective_Host_Normalization_Is_Total` cites this amendment beside Amendment 1 and +carries the pairing property, which is a distinct invariant and belonged in the +catalogue on its own account. + +**The Decision is unchanged.** `EffectiveHost.Normalize` is still the sole +producer of the lookup key and of `app.resolving_host`, still total, still returns +`null` on every failure, and still never throws. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 6c209036..1a9f5982 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1890,11 +1890,11 @@ structural test proves — and what it does not. #### `Effective_Host_Normalization_Is_Total` -- **Asserts:** `EffectiveHost.Normalize` returns a value or `null` for every input and never throws — including the `xn--` forms that make `HostString.FromUriComponent` raise, which an anonymous remote client could otherwise use to drive unhandled exceptions into the error tracker. Covers the two corrections in [ADR-0036 Amendment 1](../decisions/0036-tenant-resolution-trusted-inputs.md): the port is stripped **before** the IPv4 test, so `1.2.3.4:443` is refused, and the result passes a letters-digits-hyphen-dot whitelist, so `IdnMapping`'s compatibility mapping cannot smuggle `/`, `@` or `%` past the input scan. -- **Source:** ADR-0036 § Normalization, Amendment 1. +- **Asserts:** `EffectiveHost.Normalize` returns a value or `null` for every input and never throws — including the `xn--` forms that make `HostString.FromUriComponent` raise, which an anonymous remote client could otherwise use to drive unhandled exceptions into the error tracker. Covers the two corrections in [ADR-0036 Amendment 1](../decisions/0036-tenant-resolution-trusted-inputs.md): the port is stripped **before** the IPv4 test, so `1.2.3.4:443` is refused, and the result passes a letters-digits-hyphen-dot whitelist, so `IdnMapping`'s compatibility mapping cannot smuggle `/`, `@` or `%` past the input scan. And [Amendment 4](../decisions/0036-tenant-resolution-trusted-inputs.md)'s correction, which generalizes the same argument to the remaining input-side check: the IPv4 refusal re-runs on the value being returned, so a trailing dot cannot carry `1.2.3.4.` past it — nor can the fullwidth and ideographic dots `GetAscii` folds into `.` after the early check has already run. Paired with `Anything_Normalize_Accepts_Is_A_Host_The_Cache_Key_Accepts`, which is a **separate invariant**: `EffectiveHost.Normalize` and `CacheKey.ForHostMapping` are two spellings of "what counts as a host", written in different assemblies, and every input the first accepts the second must accept too. Checking either alone is how they drifted — the accepted-then-throwing literal above was a `500` and an unsampled error-tracker capture per request, from an unauthenticated caller, where a bodyless `404` was specified. +- **Source:** ADR-0036 § Normalization, Amendment 1, Amendment 4. - **Type:** xUnit. **Kind:** behavioural. - **Status:** **Implemented** (`EffectiveHostTests`). -- **Phase:** 02a Packet 4. +- **Phase:** 02a Packet 4; the Amendment 4 correction and the pairing property, Packet 7 step 4. #### `Tenant_Assertions_Are_Compared_Not_Resolved` From 0a7b567717231bb088dd90daa2191ea8a07c14ff Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 10:23:11 +0300 Subject: [PATCH 15/55] feat(tenancy): build the tenant context from the reconciliation matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 7 step 5. The matrix ADR-0036 writes as a table becomes a pure function: TenantResolutionAttempt carries the signals AND the answers to the two questions that need a database, so TenantContextFactory.Create is literally the signature the ADR published — no Async, no CancellationToken — and all seventeen rows are drivable from a unit test with no container. The middleware does the I/O; the factory does the decision. Fifteen cases cover the rows, including the ones no request can reach until Phase 02b, because a pure function is reachable as a function and shipping the arithmetic of an authority ceiling with no evidence is how it is wrong when authentication lands. TenantContext's constructor is internal, not private. C# has no friend types, so a private constructor and a top-level TenantContextFactory — the name an Accepted ADR, the glossary and two roadmap lines all carry — are mutually exclusive, and both normative carriers say only "no public constructor". Internal blocks every other assembly because this one has no InternalsVisibleTo, which the rule now asserts: one attribute would hand a whole assembly the constructor. The residual an internal constructor leaves is a caller inside the kernel, and reflection cannot see a `new` expression, so that conjunct is a source scan. Origin joins ITenantContext as a nullable default member. The default is fail-closed only under an allow-list, which is the obligation this hands step 6: `Origin != HostOnly` passes for null and hands an unstated context the run of the API. IOrganizationScopeValidator is the seventh sanctioned setter of app.tenant_id and obeys the rule the four before it obey — its own short transaction, its own connection, as learnstack_app. It ships registered with no reachable caller, and Standards 11 now says so beside the table that lists it: the assertion path the ADR names is subsumed by TenantAssertionMiddleware refusing on any difference before belonging can matter, and its only non-vacuous caller is row 7, which needs a claim. DenyAllTenantMembershipReader is the same honesty in code: rows 7 and 14 fail closed, nothing can reach the call, and both facts are written down so a green suite is not misread as coverage. The first real-database test found the implementation bug: set_config is (text, text, boolean) and a uuid parameter raises 42883 on the first call. Two guards then survived mutation and were fixed rather than recorded — the pooled connection case passed against a session-scoped set_config because Npgsql sends DISCARD ALL on return, which is the driver cleaning up after the bug and exactly what a PgBouncer in transaction pooling does not do. It runs under NoResetOnClose now. Reading organizations by id alone also survives every runtime case, because with the announcement made the policy hides the row either way; that one is caught structurally, which is the only instrument that can see it. ADR-0036 carries an erratum and Amendment 5: its staging table claims Packet 7 makes rows 2, 3, 6, 9 and 10 live, and the paragraph directly beneath it says the authenticated tier is dormant until Phase 02b. Rows 6, 9 and 10 need a claim. The rows this packet makes live are 2, 3 and 13, and it makes 16 reachable for the first time — Packet 4 shipped the assertion comparison against a context that never resolved. 1069 green, zero skips. Four architecture rules and the validator's announcement, composite key, transaction locality and soft-delete clause were each re-measured against a mutation. ADR: 0036, 0040 Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/LearnStack.Api/Program.cs | 13 +- .../Tenancy/TenancyCompositionExtensions.cs | 15 + .../Tenancy/TenantResolverMiddleware.cs | 192 +++++++++++ .../Pipeline/TenantContextBehavior.cs | 3 +- .../OrganizationScopeValidator.cs | 119 +++++++ .../Tenancy/DenyAllTenantMembershipReader.cs | 43 +++ .../Tenancy/EventTenantContext.cs | 10 + .../Tenancy/IOrganizationScopeValidator.cs | 39 +++ .../Tenancy/ITenantContext.cs | 15 + .../Tenancy/ITenantMembershipReader.cs | 36 ++ .../Tenancy/TenantContext.cs | 72 ++++ .../Tenancy/TenantContextFactory.cs | 127 +++++++ .../Tenancy/TenantContextOrigin.cs | 62 ++++ .../Tenancy/TenantResolutionAttempt.cs | 146 +++++++++ .../Tenancy/UnresolvedTenantContext.cs | 10 +- .../SourceScan.cs | 66 ++++ .../TenantContextConstructionTests.cs | 169 ++++++++++ .../OrganizationScopeValidatorTests.cs | 189 +++++++++++ .../HostClassificationHttpTests.cs | 64 +++- .../Tenancy/TenantResolverMiddlewareTests.cs | 223 +++++++++++++ .../Tenancy/TenantContextFactoryTests.cs | 309 ++++++++++++++++++ .../0036-tenant-resolution-trusted-inputs.md | 107 ++++-- docs/glossary.md | 1 + docs/standards/11-security.md | 10 + .../21-architecture-tests-catalogue.md | 19 +- 25 files changed, 2008 insertions(+), 51 deletions(-) create mode 100644 backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs create mode 100644 backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/DenyAllTenantMembershipReader.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/IOrganizationScopeValidator.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/ITenantMembershipReader.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/TenantContext.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/TenantContextOrigin.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/TenantResolutionAttempt.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/SourceScan.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/OrganizationScopeValidatorTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Api/Tenancy/TenantResolverMiddlewareTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index c8764d5f..f6238b3e 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -94,11 +94,20 @@ // authentication, so the factory sees both signals at once. app.UseLearnStackHostClassification(); +// Which tenant, given the host and — from Phase 02b — the validated claims. +// After classification so TenantContextFactory.Create is called once with both +// signals in hand, rather than twice with one each; ADR-0036 § Rules splits the +// single step architecture/27 once described into exactly these two. Phase 02b +// inserts UseAuthentication ABOVE this line and UseAuthorization below the +// assertions — two insertions, not one block. +app.UseLearnStackTenantResolution(); + // X-Tenant-Id / X-Organization-Id are assertions: compared against what the // API resolved, never a source of it (ADR-0036). Registered after // MapLearnStackClientErrors so a rejection gets the one Problem Details shape, -// and before the endpoints so no handler runs on a request that lost the -// comparison. +// after the resolver so there is something to compare against — until this +// packet the comparison had no resolved value and was unreachable in traffic — +// and before the endpoints so no handler runs on a request that lost it. app.UseLearnStackTenantAssertions(); app.MapGet("/healthz", () => Results.Ok(new { status = "healthy" })) diff --git a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs index 1cd7fa94..7d64d392 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs @@ -182,6 +182,21 @@ public static IServiceCollection AddLearnStackTenancyEdge( new Lazy(provider.GetRequiredService)); services.AddSingleton(); + // The membership reader that covers nothing, and the organization scope + // validator — the two ports the reconciliation matrix consults beyond the + // host. Both are stateless singletons; the validator shares the Lazy above, + // so a platform-only deployment still builds no data source. + // + // Registered UNCONDITIONALLY, with no DeploymentMode anywhere near them. A + // reader that were permissive in Development would reproduce exactly the + // appsettings inversion this file argues against at the top: the mechanism + // would be off in the environment nobody configures and on in the one that + // does, and the demo would pass while production 404'd. Phase 03 replaces + // DenyAllTenantMembershipReader with one that reads Membership; until then + // "nobody is a member of anything" is the true answer, not a placeholder. + services.AddSingleton(); + services.AddSingleton(); + services.Configure( configuration.GetSection(TrustedHopOptions.SectionName)); diff --git a/backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs new file mode 100644 index 00000000..a0e4896f --- /dev/null +++ b/backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs @@ -0,0 +1,192 @@ +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Api.Tenancy; + +/// +/// Turns the host classification — and, from Phase 02b, the validated claims — into +/// the tenant context every layer below reads. +/// +/// +/// +/// One of exactly four writers of ITenantContextAccessor.Current +/// (ADR-0032 § Sub-decision 10): this for HTTP, HubCorrelationMiddleware for +/// /api/internal/*, the Hangfire JobActivator for jobs, and the outbox +/// / inbox handler scope for integration events. It is the first of the four to +/// exist, and SetTenant_Callers_Are_The_Enumerated_Four holds the line. +/// +/// +/// Where it sits, and why not one step earlier. Host classification runs +/// before authentication because it must — an unknown host is refused before any +/// token is validated, which keeps the cheap rejection cheap. Context construction +/// runs after, so +/// ADR-0036 +/// § Rules's single call to TenantContextFactory.Create happens with +/// both signals in hand rather than twice with one each. Phase 02b inserts +/// UseAuthentication between the two, and UseAuthorization after the +/// assertion comparison — two insertions, not one block. +/// +/// +/// The accessor is restored, not merely overwritten. It is +/// AsyncLocal-backed, so a value written here would otherwise flow into +/// whatever continues on this execution context. The restore is in a +/// finally so it also covers the refusal path and a cancelled request — +/// leaving a resolved context behind on either would hand the next thing that reads +/// the accessor a tenant that no longer has a request. +/// +/// +/// It writes no body. A refusal is a bodyless 404 that +/// UseStatusCodePages renders through the one Problem Details shape, exactly +/// as host classification's refusal is. A second writer here would produce a body +/// that differs from the classification 404 — same status, different bytes — which +/// is precisely what an anonymous caller must not be able to tell apart. +/// +/// +public sealed class TenantResolverMiddleware( + RequestDelegate next, + ITenantContextAccessor accessor, + ILogger logger) +{ + public async Task InvokeAsync( + HttpContext context, + IOrganizationScopeValidator organizationScopes, + ITenantMembershipReader memberships) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(organizationScopes); + ArgumentNullException.ThrowIfNull(memberships); + + // Keyed off the feature, not off a second path predicate. A request host + // classification did not classify — /healthz, /openapi, the Hub's + // /api/internal/* surface, whose tenant comes from the envelope's path + // segment rather than from a host — has no host signal at all, and inventing + // one here would be a second resolution authority. + var classification = context.Features.Get(); + + if (classification is null) + { + await next(context); + return; + } + + var attempt = await BuildAttemptAsync(context, classification, organizationScopes, memberships); + + // Rows 13 and 15. A platform host legitimately resolves no tenant, and that + // is not a refusal: the request proceeds on the unresolved context and the + // pipeline decides, which is where [AllowsUnresolvedTenantContext] lives. + // Written explicitly rather than left alone — "nothing wrote to it" is an + // assumption a save-and-restore protocol exists precisely to stop relying on. + var previous = accessor.Current; + + try + { + if (attempt.NamesNoTenant) + { + accessor.Current = UnresolvedTenantContext.Instance; + await next(context); + return; + } + + var resolution = TenantContextFactory.Create(attempt); + + if (!resolution.IsSuccess) + { + LogRefused(logger, classification.Class, null); + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + accessor.Current = resolution.Value; + await next(context); + } + finally + { + accessor.Current = previous; + } + } + + /// + /// Gathers the signals, asking the two ports only on the rows that need them. + /// + /// + /// Membership is asked first, and the order is load-bearing. On row 14 the + /// tenant is named by the claim alone, with no host to vouch for it. Asking the + /// organization validator first would open a transaction and announce that + /// caller-supplied, unconfirmed tenant id to PostgreSQL through + /// set_config('app.tenant_id', …) before anything had confirmed it. + /// Membership first means a denied claim costs no database work at all — which, + /// while DenyAllTenantMembershipReader is registered, is every claim. + /// + private static async Task BuildAttemptAsync( + HttpContext context, + HostClassification classification, + IOrganizationScopeValidator organizationScopes, + ITenantMembershipReader memberships) + { + // Signal J is absent until Phase 02b: there is no UseAuthentication to have + // populated a principal, so every claim field stays null and the matrix + // collapses to its anonymous rows. Left as one place to change rather than + // spread through the branches below. + var attempt = new TenantResolutionAttempt + { + HostTenantId = classification.TenantId, + HostOrganizationId = classification.OrganizationId, + CorrelationId = context.TraceIdentifier, + }; + + if (attempt.RequiresMembershipCheck) + { + attempt = attempt with + { + MembershipCovers = await memberships.CoversAsync( + attempt.UserId!.Value, + attempt.ClaimTenantId!.Value, + attempt.ClaimOrganizationId, + context.RequestAborted), + }; + + // Short-circuit on a denial: the validator's read is only meaningful for + // a claim that got this far, and skipping it is what keeps the denied + // path free of a second transaction. + if (attempt.MembershipCovers is not true) + { + return attempt; + } + } + + if (attempt.RequiresOrganizationScopeCheck) + { + attempt = attempt with + { + ClaimedOrganizationBelongsToTenant = await organizationScopes.BelongsToTenantAsync( + attempt.ClaimTenantId!.Value, + attempt.ClaimOrganizationId!.Value, + context.RequestAborted), + }; + } + + return attempt; + } + + // The host class and nothing else. Which host was addressed is attacker-authored + // on every anonymous request; host classification already keeps it at Debug for + // that reason, and a refusal here would otherwise re-emit it one step later at a + // level an operator forwards. + private static readonly Action LogRefused = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, nameof(LogRefused)), + "Tenant resolution refused a {HostClass} request: the signals did not agree."); +} + +/// Registration for . +public static class TenantResolverMiddlewareExtensions +{ + public static IApplicationBuilder UseLearnStackTenantResolution(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + return app.UseMiddleware(); + } +} diff --git a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs index a22ca756..d6ee425e 100644 --- a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs @@ -16,7 +16,8 @@ namespace LearnStack.Application.Pipeline; /// — see Security Standards § Tenant Context, the single authority for this /// placement. Packet 6 shipped both halves: TransactionBehavior opens the /// ambient transaction and calls IUnitOfWork.SetTenantContextAsync inside -/// it. Packet 7 adds the resolver middleware that gives it a tenant to write. +/// it. Packet 7 step 5 added TenantResolverMiddleware, which is what now +/// gives that setter a tenant to write. /// /// /// Phase 02a Packet 3 ships the assertion shell. Until diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs new file mode 100644 index 00000000..deefe90b --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs @@ -0,0 +1,119 @@ +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using Npgsql; + +namespace LearnStack.Infrastructure.MultiTenancy; + +/// +/// Reads organizations on the composite key (tenant_id, id), under the +/// tenant's own Row Level Security context, in a transaction of its own. +/// +/// +/// +/// Why a transaction of its own — the seventh sanctioned setter. The question +/// is asked at the request edge, deciding whether a claimed organization may be part +/// of the context at all. That is strictly before the MediatR pipeline reaches +/// TransactionBehavior at step 6, so there is no ambient unit of work to +/// enlist on; ADR-0040 +/// Amendment 3 adds this to the closed set of app.tenant_id setters for +/// exactly that reason, and requires it to obey the same rule as the four before it: +/// its own short transaction, on its own connection, connected as +/// learnstack_app. +/// +/// +/// Why the policy does the work and the WHERE clause only helps. The +/// row is admitted by organizations_isolation, which compares the row's +/// tenant_id against app.tenant_id. So the answer is not "the query +/// found a row whose tenant column matched" — that would be a comparison in +/// application code, outside the policy, and it is the shape a lookup by the +/// surrogate primary key invites. With the announcement made first, an organization +/// belonging to another tenant is invisible: the read returns nothing, and nothing +/// is the answer. +/// +/// +/// No cache, deliberately. ADR-0036 § Consequences budgets for this read to be +/// cached, and it should be — when there is traffic to size it against. Its only +/// caller is the reconciliation matrix's row 7, which needs a validated claim and is +/// therefore unreachable until Phase 02b. A TTL chosen now would be a number picked +/// with nothing to measure, copied forward, and outliving the guess; the same +/// argument HostResolutionOptions makes for keeping its own numbers beside +/// their measurement. The cache lands with the traffic. +/// +/// +/// An outage is an outage. Nothing here catches a database failure, matching +/// CachedHostToTenantResolver: the exception propagates and the request is a +/// 500. Converting it to a refusal would render an outage as "this +/// organization does not belong to you", which is a lie to the caller and an +/// invisible incident to the operator. The anonymous-path argument against a +/// 500 does not apply — every caller that can reach this holds a validated +/// token, so there is no host-existence oracle to protect. +/// +/// +public sealed class OrganizationScopeValidator(Lazy dataSource) + : IOrganizationScopeValidator +{ + private readonly Lazy _dataSource = + dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + + /// + public async Task BelongsToTenantAsync( + TenantId tenantId, + OrganizationId organizationId, + CancellationToken cancellationToken = default) + { + // Refused before a connection is opened. Vogen validates the SHAPE of an id, + // not that it names anything: TenantId.From(Guid.Empty) is a legal, + // initialized id — measured — and the domain already refuses the all-zero + // tenant by hand. Announcing one here would be a well-formed uuid matching + // rows only a bug could have written, so this is fail-closed either way; it + // is refused explicitly so the answer is "no" rather than "no, by accident". + if (!tenantId.IsInitialized() || tenantId.Value == Guid.Empty + || !organizationId.IsInitialized() || organizationId.Value == Guid.Empty) + { + return false; + } + + await using var connection = + await _dataSource.Value.OpenConnectionAsync(cancellationToken); + await using var transaction = + await connection.BeginTransactionAsync(cancellationToken); + + // set_config(..., true) and not SET LOCAL: PostgreSQL's SET takes no bind + // parameter, so `SET LOCAL app.tenant_id = $1` is a syntax error and the + // only alternative would be interpolating a caller-supplied identifier into + // DDL-shaped text. The value here came from a token claim, which is exactly + // the input that must never be concatenated. + await using (var announce = new NpgsqlCommand( + "SELECT set_config('app.tenant_id', @tenant, true)", connection, transaction)) + { + // ToString on the Guid, not on the id: set_config's signature is + // (text, text, boolean) and there is no uuid overload — measured, a uuid + // parameter raises 42883 on the first call. TenantId.ToString() would be + // worse than wrong: on Vogen 7 an uninitialized id renders as the literal + // "[UNINITIALIZED]", which reaches the policy cast as + // '[UNINITIALIZED]'::uuid and raises 22P02. + announce.Parameters.AddWithValue( + "tenant", tenantId.Value.ToString()); + await announce.ExecuteNonQueryAsync(cancellationToken); + } + + // Both key columns, never `id` alone. pk_organizations is on the surrogate + // id, so a lookup by it alone is a well-formed query that returns another + // tenant's row for the policy to then hide — or, run before the announcement, + // hands it back. ux_organizations_tenant_id_id serves this shape. + await using var read = new NpgsqlCommand( + """ + SELECT 1 FROM organizations + WHERE tenant_id = @tenant AND id = @organization AND deleted_at IS NULL + """, + connection, + transaction); + read.Parameters.AddWithValue("tenant", tenantId.Value); + read.Parameters.AddWithValue("organization", organizationId.Value); + + var found = await read.ExecuteScalarAsync(cancellationToken) is not null; + + await transaction.CommitAsync(cancellationToken); + return found; + } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/DenyAllTenantMembershipReader.cs b/backend/src/LearnStack.SharedKernel/Tenancy/DenyAllTenantMembershipReader.cs new file mode 100644 index 00000000..0e7644f5 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/DenyAllTenantMembershipReader.cs @@ -0,0 +1,43 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// The that covers nothing. +/// +/// +/// +/// This is correct, and it will look like a bug. There is no +/// Membership aggregate until +/// Phase 03, +/// so there is nothing to read and the only honest answer to "does an active +/// membership cover this?" is false. The consequence is named here with its +/// error code so that nobody makes the default permissive to unblock a demo: the +/// reconciliation matrix's rows 7 and 14 fail closed, and the Studio tenant switcher +/// returns 404 not_found for everyone in that window. +/// +/// +/// Nothing can reach it in Packet 7 either. Rows 7, 10 and 14 all require a +/// validated claim, and there is no UseAuthentication until Phase 02b. So the +/// port is registered and the factory consults it, and no request can produce the +/// claim that would make the call happen. That is stated rather than dressed up, +/// because a reader who believes this path is exercised will draw the wrong +/// conclusion from a green suite. +/// +/// +/// Denying is not the same as throwing. A reader that threw would make an +/// unreachable path an outage the first time Phase 02b made it reachable; a reader +/// that denies makes it a refusal, which is what the matrix specifies and what +/// Phase 03 turns into an answer. +/// +/// +public sealed class DenyAllTenantMembershipReader : ITenantMembershipReader +{ + /// + public Task CoversAsync( + UserId userId, + TenantId tenantId, + OrganizationId? organizationId = null, + CancellationToken cancellationToken = default) => + Task.FromResult(false); +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs index 525ae71a..23917d72 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs @@ -35,6 +35,16 @@ private EventTenantContext( /// public bool IsResolved => true; + /// Matrix row 17: the envelope carried the tenant. + /// + /// Proof that the factory is not the only producer of a resolved context — it is + /// the only producer of the type. There is no + /// host and no token here to reconcile, so there is no matrix to apply; a + /// missing tenant fails at enqueue, which is the only place it can still be + /// fixed. + /// + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + /// public TenantId TenantId { get; } diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/IOrganizationScopeValidator.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IOrganizationScopeValidator.cs new file mode 100644 index 00000000..794cf7e6 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IOrganizationScopeValidator.cs @@ -0,0 +1,39 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Answers one question: does this organization belong to this tenant? +/// +/// +/// +/// The seventh sanctioned setter of app.tenant_id +/// (ADR-0040 +/// Amendment 3). It cannot run on the ambient unit of work, because the +/// question is asked at the request edge — before the pipeline reaches +/// TransactionBehavior at step 6, so there is no ambient transaction to +/// enlist on. It therefore owns a short read-only transaction of its own, on its +/// own connection, connected as learnstack_app like every other setter in +/// that table — a validator that reached for learnstack_platform would be +/// invisible to the isolation suite, which is the failure mode ADR-0003 names by +/// hand. +/// +/// +/// A valid organization id from another tenant is a mismatch, not an override. +/// That is the whole of what this exists to establish, and it is why the read is on +/// the composite key (tenant_id, id) and never on id alone — the +/// primary key is the surrogate id, so a lookup by id would happily return another +/// tenant's row and then compare it in application code, outside the policy. +/// +/// +public interface IOrganizationScopeValidator +{ + /// + /// true when is an organization of + /// . + /// + Task BelongsToTenantAsync( + TenantId tenantId, + OrganizationId organizationId, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs index 2df87128..6bed5f92 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs @@ -67,6 +67,21 @@ public interface ITenantContext ///
UserId? CausalActorUserId => null; + /// + /// Which signals agreed to produce this context — the authority ceiling. + /// null on a context that resolved nothing. + /// + /// + /// Read it as an allow-list, never as a negation. The default is + /// null so that an implementation which has not thought about the ceiling + /// gets no authority rather than the wrong one — but that only holds if the + /// consumer asks "is this origin one of the ones permitted here?". A check + /// written as Origin != HostOnly passes for null and hands an + /// unstated context the run of the API. The pipeline's ceiling enforcement is + /// the consumer that matters. + /// + TenantContextOrigin? Origin => null; + /// /// W3C traceparent string ("00-<trace>-<span>-<flags>") /// that threads through HTTP / outbox / Hangfire / Hub envelopes. The diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantMembershipReader.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantMembershipReader.cs new file mode 100644 index 00000000..935ebe07 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantMembershipReader.cs @@ -0,0 +1,36 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Whether a user holds an active membership covering a tenant, and optionally an +/// organization within it. +/// +/// +/// +/// Membership confirms a claim; it never selects a tenant. +/// ADR-0036 +/// § The signals is explicit that this signal is "confirming J at request +/// time", not a resolution source. It is consulted on exactly the matrix rows where +/// a claim reaches past what the host already vouches for — 7, 10 and 14 — and on +/// no other. +/// +/// +/// Active is part of the question. A membership that was revoked answers +/// false; the caller has no second call to make and no status to interpret, +/// which is what keeps the reconciliation matrix a matrix rather than a workflow. +/// +/// +public interface ITenantMembershipReader +{ + /// + /// true when holds an active membership + /// covering — and + /// when one is named. + /// + Task CoversAsync( + UserId userId, + TenantId tenantId, + OrganizationId? organizationId = null, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContext.cs new file mode 100644 index 00000000..c0437e4d --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContext.cs @@ -0,0 +1,72 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// A resolved tenant context. Constructed only by . +/// +/// +/// +/// The constructor is internal, and that is the ceiling C# offers here. +/// ADR-0036 +/// § Rules asks for "sealed with no public constructor" and names +/// as a separate type. C# has no friend types, so +/// a private constructor and a top-level factory are mutually exclusive — +/// the two shipped private-constructor types in this corpus, EventTenantContext +/// and HostClassification, both put their factories on the type. The +/// ADR's wording is satisfied exactly by internal: the assembly carries no +/// InternalsVisibleTo, so every module project, both infrastructure +/// assemblies, the API and all four test assemblies are blocked by the compiler. +/// The residual — a second caller inside this one assembly — is what +/// TenantContext_Is_Constructed_Only_By_The_Factory covers, with a source +/// scan, because a type-reference test cannot see a call site. +/// +/// +/// Every instance is complete. There is no setter, no builder and no partial +/// state: the factory returns Result.Fail on any disagreement rather than a +/// context with some fields filled in. A half-populated tenant context is the +/// failure this shape exists to make unrepresentable — IsResolved is +/// true for the lifetime of the object, and every reader may take +/// without a gate. +/// +/// +public sealed class TenantContext : ITenantContext +{ + internal TenantContext( + TenantId tenantId, + OrganizationId? organizationId, + UserId? userId, + TenantContextOrigin origin, + string? correlationId) + { + TenantId = tenantId; + OrganizationId = organizationId; + UserId = userId; + Origin = origin; + CorrelationId = correlationId; + } + + /// + public bool IsResolved => true; + + /// + public TenantId TenantId { get; } + + /// + public OrganizationId? OrganizationId { get; } + + /// + public UserId? UserId { get; } + + /// + public TenantContextOrigin? Origin { get; } + + /// + public string? CorrelationId { get; } + + /// + /// Always null here: the resolver runs before routing has selected an + /// endpoint, so nothing at the request edge knows which module owns the request. + /// + public string? ModuleName => null; +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs new file mode 100644 index 00000000..7ec19a57 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs @@ -0,0 +1,127 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// The single entry point that turns a into a +/// — or refuses. +/// +/// +/// +/// Pure, total and synchronous. Every question that needs a database was +/// answered before the attempt was assembled, so this is +/// ADR-0036's +/// reconciliation matrix expressed as a function — which means all seventeen rows +/// are drivable from a unit test with no container, no HTTP and no clock. +/// +/// +/// It never returns a partially populated context, which is the rule the +/// single entry point exists to hold. Any disagreement between signals is +/// Result.Fail, and no caller can assemble a +/// another way. +/// +/// +/// The error it returns is not the wire. The refusal a client sees is a +/// bodyless 404 rendered by UseStatusCodePages, byte-identical to the +/// one an unresolvable host gets — because anything a client can tell apart confirms +/// to an anonymous caller that the tenant exists. The here names +/// the reason for a reader of the code and for the middleware's own logging; it +/// carries lockey_not_found rather than lockey_tenant_mismatch because +/// the wire result must match the anonymous case, and tenant_mismatch is the +/// authenticated code. +/// +/// +public static class TenantContextFactory +{ + /// The one refusal. Deliberately the same for every failing row. + /// + /// One and not one per row: a caller who could tell row 8 + /// (a tenant that exists, claimed by a token for another) from row 10 (an + /// organization no membership covers) would have an oracle over which tenants + /// and organizations exist. The distinction that matters to an operator is + /// carried by the middleware's log line, not by the response. + /// + public static Error Refused { get; } = new(new LocalizedMessage("lockey_not_found")); + + /// + /// Applies the reconciliation matrix. Returns the context, or + /// . + /// + /// + /// Callers must not invoke this for an attempt whose + /// is true — rows 13 + /// and 15 resolve nothing, and "nothing" is not a failure to be rendered as one. + /// The middleware leaves those requests on + /// and lets the pipeline decide, which is where + /// [AllowsUnresolvedTenantContext] lives. + /// + public static Result Create(TenantResolutionAttempt attempt) + { + ArgumentNullException.ThrowIfNull(attempt); + + // Rows 13 and 15. Refusing here would turn "this host serves no tenant" into + // an error, which is what a platform host legitimately is. + if (attempt.NamesNoTenant) + { + return Result.Fail(Refused); + } + + // Rows 8, 11 and 12 — the cross-check. Evaluated before any port answer is + // consulted, so a refused request never spends a database round trip. + if (!attempt.ClaimAgreesWithHost) + { + return Result.Fail(Refused); + } + + // Rows 7, 10 and 14. `is not true` and not `== false`: an unanswered question + // is a refusal, so a caller that forgot to ask cannot widen anything. This is + // where DenyAllTenantMembershipReader makes rows 7 and 14 fail closed until + // Phase 03. + if (attempt.RequiresMembershipCheck && attempt.MembershipCovers is not true) + { + return Result.Fail(Refused); + } + + // Row 7's ∈ term, on the same fail-closed reading. + if (attempt.RequiresOrganizationScopeCheck + && attempt.ClaimedOrganizationBelongsToTenant is not true) + { + return Result.Fail(Refused); + } + + // The tenant is whichever signal named it; they agree by the check above. + var tenantId = attempt.HostTenantId ?? attempt.ClaimTenantId!.Value; + + // The claim narrows, the host supplies the anonymous default. Row 7 takes the + // claim's organization on a tenant-wide host; rows 3 and 9 take the host's. + var organizationId = attempt.ClaimOrganizationId ?? attempt.HostOrganizationId; + + return Result.Ok(new TenantContext( + tenantId, + organizationId, + attempt.UserId, + OriginFor(attempt), + attempt.CorrelationId)); + } + + /// + /// Which signals carried this context — the authority ceiling, decided once. + /// + private static TenantContextOrigin OriginFor(TenantResolutionAttempt attempt) + { + if (attempt.ClaimTenantId is null) + { + // Rows 2 and 3: the host, alone. The ceiling that makes a forged host + // harmless. + return TenantContextOrigin.HostOnly; + } + + // Row 14: no host named a tenant, so membership is what carried it. Rows 6, + // 7, 9 and 10 all have a host that agreed, and membership only confirms + // there — which is why they stay HostAndClaim even when the reader was asked. + return attempt.HostTenantId is null + ? TenantContextOrigin.ClaimAndMembership + : TenantContextOrigin.HostAndClaim; + } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextOrigin.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextOrigin.cs new file mode 100644 index 00000000..5e880926 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextOrigin.cs @@ -0,0 +1,62 @@ +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Which signals agreed to produce a tenant context — and therefore how far that +/// context is allowed to reach. +/// +/// +/// +/// This is an authority ceiling, not a provenance label. +/// ADR-0036 +/// makes it the mechanism that keeps a forged Host harmless: a context +/// assembled from the host alone reaches only request types marked +/// [PublicSurface], so the worst a forged host buys is the pages that +/// hostname already serves to anyone who types it — and only while the mapping row +/// is publicly live. Without the ceiling the trusted hop is a confused deputy, +/// because the edge derives its own assertion from the same string the visitor +/// chose, so the assertion comparison cannot catch it. +/// +/// +/// The value names the signals that AGREED, not every port consulted. +/// Matrix rows 7 and 10 ask ITenantMembershipReader and still carry +/// : membership confirms a claim there, it does not +/// select anything. Only row 14 — where no host names a tenant at all — is +/// carried by membership, and only that row is +/// . +/// +/// +/// There is deliberately no member for the matrix rows whose Origin is "—". +/// Those rows resolve nothing: either the request is refused, or it legitimately +/// carries no tenant and runs under , whose +/// IsResolved is false. A fifth member would be a resolved-looking +/// context with no authority behind it, which is the partially populated context +/// ADR-0036 § Rules forbids the factory from ever returning. +/// +/// +public enum TenantContextOrigin +{ + /// + /// The host named the tenant and nothing else did — an anonymous page load. + /// Reaches [PublicSurface] request types and nothing else. + /// + HostOnly, + + /// + /// The host and a validated token claim named the same tenant. The ordinary + /// authenticated request. + /// + HostAndClaim, + + /// + /// No host named a tenant — a platform host — and a validated claim did, + /// confirmed by an active membership. The Studio tenant switcher. + /// + ClaimAndMembership, + + /// + /// Not an HTTP request at all: a background job's parameters or an integration + /// event's envelope carried the tenant. There is no host and no token to + /// reconcile, so the enqueuing side is where a missing tenant fails. + /// + Ambient, +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/TenantResolutionAttempt.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantResolutionAttempt.cs new file mode 100644 index 00000000..47af18c1 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantResolutionAttempt.cs @@ -0,0 +1,146 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Everything the reconciliation matrix is allowed to look at, gathered before any +/// context exists. +/// +/// +/// +/// It carries answers, never ports. Rows 7, 10 and 14 need a question +/// answered by a database — does this organization belong to this tenant, does an +/// active membership cover this pair — and the answers arrive here as +/// ? rather than the factory holding +/// IOrganizationScopeValidator and ITenantMembershipReader. That is +/// what lets +/// ADR-0036's +/// signature — Create(TenantResolutionAttempt) → Result<TenantContext>, +/// with no Async and no CancellationToken — be literally true, and it +/// makes the whole matrix a pure total function a unit test can drive row by row +/// without a container. The middleware does the I/O; the factory does the decision. +/// +/// +/// What is deliberately absent, each for its own reason. No host string: it +/// is attacker-authored on every anonymous request and host classification keeps it +/// at Debug precisely so it never reaches a retained sink. No +/// X-Tenant-Id / X-Organization-Id: assertions select nothing — +/// admitting them here would make +/// Tenant_Headers_Are_Never_A_Resolution_Source false by construction, and +/// they are compared downstream against what this produced. No host class: the +/// three live classes are already determined by which of the two host-side +/// identifiers are present, and the enum lives in an assembly the kernel cannot +/// see. No module name: the resolver runs before routing has selected an endpoint. +/// +/// +/// UnknownHost never arrives here either — host classification answers it +/// 404 before a context is attempted at all. +/// +/// +public sealed record TenantResolutionAttempt +{ + /// The tenant the host mapping named. null on a platform host. + public TenantId? HostTenantId { get; init; } + + /// + /// The organization the host mapping named, when the row carries one. + /// + /// + /// The anonymous organization scope is this value, per ADR-0036 — not the + /// tenant's organization count. A tenant that wants its default organization's + /// content on its public site seeds organization_id into its + /// platform_host_to_tenant row, which removes a code branch and makes the + /// behaviour visible, seedable and auditable as data. + /// + public OrganizationId? HostOrganizationId { get; init; } + + /// + /// Whether a token was validated for this request. Constant false until + /// Phase 02b registers authentication. + /// + /// + /// Separate from because rows 13 and 15 differ only + /// in this: both resolve no tenant, and only one of them has a principal. An + /// invalid token never arrives — it is a 401 before the outcome is + /// consumed, because a rejected token must never be treated as absence. + /// + public bool HasValidatedPrincipal { get; init; } + + /// The tenant a validated claim named. + public TenantId? ClaimTenantId { get; init; } + + /// The organization a validated claim named. + public OrganizationId? ClaimOrganizationId { get; init; } + + /// The actor, when one is authenticated. + public UserId? UserId { get; init; } + + /// + /// Whether an active membership covers the claimed pair — null when the + /// question was not asked, which is every row that does not need it. + /// + public bool? MembershipCovers { get; init; } + + /// + /// Whether the claimed organization belongs to the resolved tenant — + /// null when the question was not asked. + /// + public bool? ClaimedOrganizationBelongsToTenant { get; init; } + + /// The correlation id, carried through and decided nowhere here. + public string? CorrelationId { get; init; } + + /// + /// Rows 13 and 15: no authoritative signal names a tenant, which is not a + /// failure. + /// + public bool NamesNoTenant => HostTenantId is null && ClaimTenantId is null; + + /// + /// false on rows 8, 11 and 12 — the disagreements — and on nothing else. + /// + /// + /// A signal that is absent cannot disagree, which is why each term is guarded on + /// both sides being present. The organization term matters on its own: row 11 is + /// an org-host and a claim naming a different organization of the same + /// tenant, and ADR-0036 settles it as a mismatch rather than a scope change — + /// an earlier draft let the host win, on a citation that turned out to describe + /// ?org= search parameters, which this ADR trusts for nothing. + /// + public bool ClaimAgreesWithHost => + ClaimTenantId is null + || HostTenantId is null + || (ClaimTenantId == HostTenantId + && (ClaimOrganizationId is null + || HostOrganizationId is null + || ClaimOrganizationId == HostOrganizationId)); + + /// + /// Rows 7, 10 and 14 — a claim that goes beyond what the host already vouches + /// for, and nothing else. + /// + public bool RequiresMembershipCheck => + ClaimTenantId is not null + && ClaimAgreesWithHost + && (HostTenantId is null || ClaimOrganizationId != HostOrganizationId); + + /// + /// Row 7 only: a claim naming an organization the host did not, on a host that + /// did name the tenant. + /// + /// + /// Row 7 is the one row of the matrix carrying an term. Row 14 names + /// membership alone, and that is not an omission: a membership record is per + /// (tenant, organization), so an active membership covering + /// (T_j, O_j) already establishes that O_j belongs to T_j. + /// Adding the structural check there would be an addition beyond the matrix, and + /// the sequencing matters more than the redundancy: membership is asked first, + /// so a row-14 attempt never announces a caller-supplied, unvouched-for tenant + /// id to PostgreSQL through set_config. + /// + public bool RequiresOrganizationScopeCheck => + HostTenantId is not null + && ClaimOrganizationId is not null + && ClaimAgreesWithHost + && ClaimOrganizationId != HostOrganizationId; +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs index 1dbd4828..3df42699 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs @@ -11,10 +11,12 @@ namespace LearnStack.SharedKernel.Tenancy; /// /// /// The real population sites (per ADR-0032 § Sub-decision 10) overwrite the -/// scoped instance once they resolve. Until Packet 7 lands -/// TenantResolverMiddleware, every request runs against this default — -/// the TenantContextBehavior short-circuits with -/// Result.Fail(tenant_mismatch) before any handler runs. +/// scoped instance once they resolve. TenantResolverMiddleware is the first +/// of them and now writes this instance explicitly on the requests that +/// legitimately have no tenant — a platform host, matrix rows 13 and 15. That is not +/// a refusal: the pipeline decides what may run without a tenant. Every request that +/// classification never classified, and every non-HTTP entry point until Phase 02b +/// wires its own, still arrives here by default. /// public sealed class UnresolvedTenantContext : ITenantContext { diff --git a/backend/tests/LearnStack.Tests.Architecture/SourceScan.cs b/backend/tests/LearnStack.Tests.Architecture/SourceScan.cs new file mode 100644 index 00000000..eedb4c95 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/SourceScan.cs @@ -0,0 +1,66 @@ +namespace LearnStack.Tests.Architecture; + +/// +/// Finds a literal in source, for the rules a type-reference scan cannot express. +/// +/// +/// NetArchTest resolves type references, so it sees a constructor's accessibility +/// and a method's return type — and never a new expression, a raw SQL string +/// or a property write. Several catalogued rules are about exactly those, and the +/// alternative to a scan is a rule that names something it cannot check. +/// Comments and whitespace are removed first: every file these rules cover argues in +/// prose about the very literal it is forbidden to write, and the first version of +/// the sibling scan in TenancyConventionTests was per-line, which a line break +/// walked straight through. +/// +internal static class SourceScan +{ + public static string SourceRoot => RepositoryPaths.BackendSrc(); + + public static string KernelRoot => + Path.Combine(RepositoryPaths.BackendSrc(), "LearnStack.SharedKernel"); + + /// + /// Repository-relative paths of the .cs files under + /// whose code contains . + /// + /// + /// One path, relative to and written with / + /// separators, that is allowed to contain it. Compared as a path rather than a + /// bare name — two files may share a name in different folders, and excluding + /// both because one is exempt is how a rule quietly stops covering half of what + /// it names. + /// + public static List FilesContaining(string root, string literal, string? except) + { + var needle = SourceText.WithoutWhitespace(literal); + var found = new List(); + + foreach (var file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(root, file) + .Replace(Path.DirectorySeparatorChar, '/'); + + if (relative.Split('/') is var segments + && (segments.Contains("obj") || segments.Contains("bin"))) + { + continue; + } + + if (except is not null && relative.Equals(except, StringComparison.Ordinal)) + { + continue; + } + + var code = SourceText.WithoutWhitespace( + SourceText.WithoutComments(File.ReadAllText(file))); + + if (code.Contains(needle, StringComparison.Ordinal)) + { + found.Add(relative); + } + } + + return found; + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs new file mode 100644 index 00000000..28158f2b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs @@ -0,0 +1,169 @@ +using System.Reflection; +using FluentAssertions; +using LearnStack.SharedKernel.Tenancy; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// +/// The rules that keep tenant context construction and tenant context writing +/// where ADR-0036 +/// put them. +/// +public sealed class TenantContextConstructionTests +{ + private static readonly Assembly Kernel = typeof(TenantContext).Assembly; + + [Fact] + public void TenantContext_Is_Constructed_Only_By_The_Factory() + { + // Three conjuncts, and they need two different instruments — which is the + // whole reason this test is written out rather than expressed as one + // NetArchTest chain. A type-reference scan can see a constructor's + // accessibility and a method's return type; it cannot see a `new` expression, + // because a call site is not a type reference. So the third conjunct is a + // source scan, and without it the `internal` constructor's one residual — a + // second caller inside this same assembly — is uncovered. + var type = typeof(TenantContext); + + type.IsSealed.Should().BeTrue(); + + type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .Should().BeEmpty("ADR-0036 § Rules: sealed with no public constructor"); + + // Not "no internal constructor". C# has no friend types, so a private + // constructor and a top-level TenantContextFactory — the name ADR-0036, the + // glossary and two roadmap lines all carry — are mutually exclusive. + // `internal` is the ceiling the language offers, and it holds because this + // assembly has no InternalsVisibleTo: every module project, both + // infrastructure assemblies, the API and all four test assemblies are blocked + // by the compiler. That last clause is asserted below, because one attribute + // would silently reopen construction to a whole assembly. + Kernel.GetCustomAttributes() + .Should().BeEmpty( + "an InternalsVisibleTo here would hand a whole assembly the constructor " + + "and reduce this rule to its first two conjuncts"); + + var producers = Kernel.GetTypes() + .SelectMany(candidate => candidate.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + .Where(method => Produces(method.ReturnType)) + .Select(method => $"{method.DeclaringType!.Name}.{method.Name}") + .ToList(); + + producers.Should().BeEquivalentTo( + [$"{nameof(TenantContextFactory)}.{nameof(TenantContextFactory.Create)}"], + "a second member handing back a TenantContext is a second entry point, " + + "whatever it delegates to"); + } + + [Fact] + public void TenantContext_Is_Instantiated_In_One_File() + { + // The conjunct reflection cannot reach. Exempts the factory's own file by + // path rather than by name — two files may share a name in different folders, + // and excluding both because one is exempt is how a rule quietly stops + // covering half of what it names. + const string Factory = "Tenancy/TenantContextFactory.cs"; + + var offenders = SourceScan.FilesContaining( + SourceScan.KernelRoot, + "new TenantContext(", + except: Factory); + + offenders.Should().BeEmpty( + $"TenantContextFactory.Create is the single entry point; only {Factory} may call the " + + "constructor, and an internal constructor leaves exactly this residual"); + } + + [Fact] + public void SetTenant_Callers_Are_The_Enumerated_Four() + { + // ADR-0036 § Rules, as corrected by its Amendment 2: the member is + // `ITenantContextAccessor.Current`, and writes to it have exactly four + // callers — TenantResolverMiddleware (HTTP), HubCorrelationMiddleware + // (/api/internal/*), the Hangfire JobActivator (jobs) and the outbox / inbox + // handler scope (integration events). Only the first exists today; the rule + // is written over the whole set so the second one to arrive is a deliberate + // edit here rather than a silent addition there. + // + // EnterPlatformAdminScope is deliberately NOT among them (Step 7): it opens a + // second connection and sets no tenant context. A test that admitted it would + // be admitting a cross-tenant path into the resolution set. + var writers = SourceScan.FilesContaining( + SourceScan.SourceRoot, ".Current =", except: null) + .Where(file => file.Contains("Tenancy", StringComparison.Ordinal) + || file.Contains("MultiTenancy", StringComparison.Ordinal)) + .ToList(); + + writers.Should().BeEquivalentTo( + ["LearnStack.Api/Tenancy/TenantResolverMiddleware.cs"], + "the four writers are enumerated in ADR-0032 § Sub-decision 10 and only the " + + "HTTP one has landed; a fifth writer is how a request runs under a tenant " + + "nothing resolved"); + } + + [Fact] + public void Organizations_Are_Read_By_Composite_Key() + { + // Two legs, because the rule is broader than its one implementation. The + // primary key is the surrogate id (pk_organizations), so a lookup by id alone + // is a well-formed, index-served query that returns another tenant's row — + // for the policy to hide, if the announcement was made, and to hand back if + // it was not. That is the whole hazard: the belonging must be decided by the + // key and the policy, never by comparing a tenant column in application code + // after the row is already in hand. + + // Leg 1 — the SQL. Every organizations read in the source names both key + // columns. Scanned rather than reflected: a raw command's text is a string + // literal, which is exactly what a type-reference scan cannot see. + var reads = SourceScan.FilesContaining(SourceScan.SourceRoot, "FROM organizations", except: null); + + reads.Should().BeEquivalentTo( + ["LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs"], + "a second raw reader of this table is a second place the composite key can be missed"); + + var validator = File.ReadAllText(Path.Combine( + SourceScan.SourceRoot, + "LearnStack.Infrastructure", "MultiTenancy", "OrganizationScopeValidator.cs")); + var code = SourceText.WithoutWhitespace(SourceText.WithoutComments(validator)); + + code.Should().Contain(SourceText.WithoutWhitespace( + "WHERE tenant_id = @tenant AND id = @organization"), + "both key columns, in the WHERE clause, and never id alone"); + code.Should().Contain(SourceText.WithoutWhitespace("set_config('app.tenant_id'"), + "the announcement is what makes the policy — not the WHERE clause — the " + + "thing that decides, and it must come first"); + + // Leg 2 — the same rule expressed in EF, which is how the NEXT organization + // read will be written. Vacuous today and deliberately kept: nothing reads + // organizations through a DbContext until Step 9 writes the first command, + // and a scan that only starts existing once there is something to catch is a + // scan nobody adds. `Find`/`FindAsync` take the primary key, which here is + // the surrogate id, so they cannot express the composite key at all. + var byPrimaryKey = PrimaryKeyReads + .SelectMany(literal => + SourceScan.FilesContaining(SourceScan.SourceRoot, literal, except: null)) + .ToList(); + + byPrimaryKey.Should().BeEmpty( + "Find takes the primary key, which is the surrogate id alone — an " + + "organization read must name (tenant_id, id)"); + } + + /// The EF spellings that take the primary key, which here is the id alone. + private static readonly string[] PrimaryKeyReads = + ["Organizations.Find", "Organizations.FindAsync"]; + + private static bool Produces(Type returnType) + { + if (returnType == typeof(TenantContext)) + { + return true; + } + + return returnType.IsGenericType + && returnType.GetGenericArguments().Contains(typeof(TenantContext)); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OrganizationScopeValidatorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OrganizationScopeValidatorTests.cs new file mode 100644 index 00000000..d5fef518 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/OrganizationScopeValidatorTests.cs @@ -0,0 +1,189 @@ +using FluentAssertions; +using LearnStack.Infrastructure.MultiTenancy; +using LearnStack.SharedKernel.Identifiers; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// OrganizationScopeValidator against a real database — the seventh sanctioned +/// setter of app.tenant_id. +/// +/// +/// +/// Connected as learnstack_app. A test connected as the table owner or +/// as a BYPASSRLS role passes with every policy inert and therefore proves +/// nothing — which is the failure mode ADR-0003 names by hand and the reason this +/// suite's connection string is the application one. +/// +/// +/// What is actually under test is the policy, not the WHERE clause. The +/// interesting case is the one where a well-formed query would happily return +/// another tenant's row: pk_organizations is the surrogate id alone, so an +/// organization id is globally unique and a lookup by it succeeds. The belonging has +/// to be decided by the announcement plus organizations_isolation, and the +/// cases below are chosen so that a validator which forgot either would answer +/// differently. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class OrganizationScopeValidatorTests +{ + private readonly SchemaFixture _schema; + + public OrganizationScopeValidatorTests(SchemaFixture schema) => _schema = schema; + + [Fact] + public async Task An_Organization_Of_The_Tenant_Belongs_To_It() + { + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + + var belongs = await Build(dataSource).BelongsToTenantAsync( + TenantId.From(SchemaFixture.TenantA), OrganizationId.From(SchemaFixture.OrgA1)); + + belongs.Should().BeTrue(); + } + + [Fact] + public async Task Another_Tenants_Organization_Does_Not() + { + // The case the whole port exists for: a valid organization id from another + // tenant is a mismatch, not an override. OrgB1 exists, and its id is enough + // to find it by primary key — so an implementation that read by id and then + // compared the tenant column in application code would also answer false + // here. What separates the two is the next case. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + + var belongs = await Build(dataSource).BelongsToTenantAsync( + TenantId.From(SchemaFixture.TenantA), OrganizationId.From(SchemaFixture.OrgB1)); + + belongs.Should().BeFalse(); + } + + [Fact] + public async Task Without_The_Announcement_The_Row_Is_Invisible() + { + // The mechanism, asserted directly. Run the validator's own query on a + // connection that never issued set_config('app.tenant_id', …) and the policy + // predicate is NULL, so the row the previous case found is not there at all. + // This is what makes the port's answer a property of Row Level Security + // rather than of its WHERE clause — and it is the case that fails if the + // announcement is ever dropped as redundant. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var connection = await dataSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + + await using var read = new NpgsqlCommand( + """ + SELECT 1 FROM organizations + WHERE tenant_id = @tenant AND id = @organization AND deleted_at IS NULL + """, + connection, + transaction); + read.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + read.Parameters.AddWithValue("organization", SchemaFixture.OrgA1); + + (await read.ExecuteScalarAsync()).Should().BeNull( + "with app.tenant_id unset the policy predicate is NULL and the row is filtered out — " + + "fail-closed, and the reason the announcement is the mechanism"); + } + + [Fact] + public async Task Each_Call_Leaves_No_Setting_Behind_On_The_Pooled_Connection() + { + // set_config(..., true) is SET LOCAL's function form and is discarded at + // COMMIT. A session-level write would survive on a pooled connection into + // whatever borrowed it next — which, on this path, is another tenant's + // request. + // + // NoResetOnClose is what makes this case mean anything. Measured: without it + // the mutation to set_config(..., false) passes, because Npgsql sends + // DISCARD ALL when a connection returns to the pool and cleans up after the + // bug. That is the driver's behaviour, not this code's, and it is exactly + // what a PgBouncer in transaction-pooling mode does not do — the deployment + // the corpus keeps naming. With the reset suppressed and the pool held to one + // connection, the second borrow provably gets the same physical connection + // the validator just released, in the state the validator left it. + var builder = new NpgsqlDataSourceBuilder(_schema.Postgres.AppConnectionString); + builder.ConnectionStringBuilder.MaxPoolSize = 1; + builder.ConnectionStringBuilder.NoResetOnClose = true; + await using var dataSource = builder.Build(); + + await Build(dataSource).BelongsToTenantAsync( + TenantId.From(SchemaFixture.TenantA), OrganizationId.From(SchemaFixture.OrgA1)); + + await using var connection = await dataSource.OpenConnectionAsync(); + await using var read = new NpgsqlCommand( + "SELECT NULLIF(current_setting('app.tenant_id', true), '')", connection); + + var leftBehind = await read.ExecuteScalarAsync(); + + (leftBehind is null or DBNull).Should().BeTrue( + "the setting was transaction-local and the transaction is over"); + } + + [Fact] + public async Task A_Soft_Deleted_Organization_Does_Not_Belong() + { + // deleted_at is the corpus's soft-delete column and the policy does not read + // it, so this is the validator's own clause. An organization someone removed + // must not keep vouching for a claim that names it. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + var validator = Build(dataSource); + var organization = OrganizationId.From(SchemaFixture.OrgA2); + + (await validator.BelongsToTenantAsync(TenantId.From(SchemaFixture.TenantA), organization)) + .Should().BeTrue("precondition: it belongs before it is deleted"); + + await SetDeletedAsync(dataSource, SchemaFixture.TenantA, SchemaFixture.OrgA2, deleted: true); + + try + { + (await validator.BelongsToTenantAsync(TenantId.From(SchemaFixture.TenantA), organization)) + .Should().BeFalse(); + } + finally + { + await SetDeletedAsync(dataSource, SchemaFixture.TenantA, SchemaFixture.OrgA2, deleted: false); + } + } + + /// + /// Flips deleted_at under the tenant's own context, because the table's + /// policies are qualified TO learnstack_app and the update's + /// WITH CHECK requires app.tenant_id to be the row's tenant. + /// + private static async Task SetDeletedAsync( + NpgsqlDataSource dataSource, Guid tenant, Guid organization, bool deleted) + { + await using var connection = await dataSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + + await using (var announce = new NpgsqlCommand( + "SELECT set_config('app.tenant_id', @tenant, true)", connection, transaction)) + { + // ToString: set_config is (text, text, boolean) and has no uuid overload. + announce.Parameters.AddWithValue("tenant", tenant.ToString()); + await announce.ExecuteNonQueryAsync(); + } + + await using (var update = new NpgsqlCommand( + "UPDATE organizations SET deleted_at = @at WHERE tenant_id = @tenant AND id = @id", + connection, + transaction)) + { + update.Parameters.AddWithValue( + "at", deleted ? new DateTimeOffset(2026, 9, 2, 9, 0, 0, TimeSpan.Zero) : DBNull.Value); + update.Parameters.AddWithValue("tenant", tenant); + update.Parameters.AddWithValue("id", organization); + await update.ExecuteNonQueryAsync(); + } + + await transaction.CommitAsync(); + } + + private static OrganizationScopeValidator Build(NpgsqlDataSource dataSource) => + new(new Lazy(() => dataSource)); +} diff --git a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs index cc525a9e..42c5212c 100644 --- a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics.Metrics; using System.Net; +using System.Net.Http.Json; using FluentAssertions; using LearnStack.Api.Common; using LearnStack.Api.Tenancy; @@ -49,7 +50,10 @@ public async Task A_Platform_Host_Is_Served_Without_Reaching_The_Resolver() var response = await _client.GetAsync(new Uri("/api/v1/hostprobe", UriKind.Relative)); response.StatusCode.Should().Be(HttpStatusCode.OK); - (await response.Content.ReadAsStringAsync()).Should().Contain("Platform"); + var probe = await ReadProbeAsync(response); + probe.Class.Should().Be("Platform"); + probe.Resolved.Should().BeFalse( + "matrix row 13: a platform host resolves no tenant, and that is not a refusal"); fixture.Resolver.Calls.Should().BeEmpty( "a platform host maps to no tenant by configuration, so it costs no lookup"); } @@ -93,6 +97,9 @@ public async Task An_Unknown_Host_Answers_Exactly_As_An_Unmapped_Path_Does() .Should().Be(WithoutCorrelation(await routed.Content.ReadAsStringAsync())); } + private static async Task ReadProbeAsync(HttpResponseMessage response) => + (await response.Content.ReadFromJsonAsync())!; + private static string WithoutCorrelation(string body) => System.Text.RegularExpressions.Regex.Replace( body, "\"correlationId\":\"[^\"]*\"", "\"correlationId\":\"\""); @@ -107,8 +114,14 @@ public async Task A_Resolved_Host_Carries_Its_Classification_Forward() var response = await _client.SendAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); - (await response.Content.ReadAsStringAsync()).Should().Contain("Organization", + var probe = await ReadProbeAsync(response); + probe.Class.Should().Be("Organization", "a mapping row carrying an organization classifies as OrgHost"); + probe.Resolved.Should().BeTrue(); + probe.TenantId.Should().Be(HostClassificationFixture.Tenant); + probe.OrganizationId.Should().Be(HostClassificationFixture.Organization, + "matrix row 3: the anonymous organization scope IS the mapping row"); + probe.Origin.Should().Be(nameof(TenantContextOrigin.HostOnly)); } [Fact] @@ -121,7 +134,14 @@ public async Task A_Tenant_Wide_Host_Classifies_As_Tenant_Rather_Than_Organizati var response = await _client.SendAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); - (await response.Content.ReadAsStringAsync()).Should().Contain("Tenant"); + var probe = await ReadProbeAsync(response); + probe.Class.Should().Be("Tenant"); + probe.Resolved.Should().BeTrue(); + probe.TenantId.Should().Be(HostClassificationFixture.Tenant); + probe.OrganizationId.Should().BeNull( + "matrix row 2: a tenant-wide host resolves no organization"); + probe.Origin.Should().Be(nameof(TenantContextOrigin.HostOnly), + "the ceiling that makes a forged host harmless"); } [Theory] @@ -209,7 +229,7 @@ public async Task A_Platform_Host_Wins_Over_A_Mapping_Row_That_Names_It() var response = await _client.SendAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); - (await response.Content.ReadAsStringAsync()).Should().Contain("Platform"); + (await ReadProbeAsync(response)).Class.Should().Be("Platform"); fixture.Resolver.Calls.Should().BeEmpty( "the row is never read, which is why it is inert rather than conflicting"); } @@ -302,14 +322,38 @@ public IList Calls } } -/// Echoes the classification the middleware attached. +/// +/// Echoes what the tenancy edge decided — the host classification, and the tenant +/// context the resolver put on the accessor. +/// +/// +/// Test-only, and registered by the fixture rather than by the application: ADR-0036 +/// and the Packet 7 plan both hold that no production /api/v1 endpoint ships +/// in this packet, and the first real read endpoints are Phase 02d's. It takes +/// ITenantContext by injection precisely as a handler would, so what it +/// reports is what a handler would see rather than what the middleware believes it +/// wrote. +/// [ApiExplorerSettings(IgnoreApi = true)] -public sealed class HostProbeController : ApiControllerBase, ITestOnlyController +public sealed class HostProbeController(ITenantContext tenantContext) + : ApiControllerBase, ITestOnlyController { [HttpGet] public IActionResult Get() => - Ok(new - { - @class = HttpContext.Features.Get()?.Class.ToString() ?? "none", - }); + Ok(new HostProbe( + HttpContext.Features.Get()?.Class.ToString() ?? "none", + tenantContext.IsResolved, + tenantContext.IsResolved ? tenantContext.TenantId.Value : null, + tenantContext.OrganizationId?.Value, + tenantContext.Origin?.ToString())); } + +/// What reports, typed. +/// +/// A record rather than an anonymous object so the assertions deserialize instead of +/// substring-matching a body — Contain("Tenant") is satisfied by the word +/// appearing anywhere, including inside "TenantHost", and a test that passes on a +/// coincidence of spelling is the failure this packet keeps finding. +/// +public sealed record HostProbe( + string Class, bool Resolved, Guid? TenantId, Guid? OrganizationId, string? Origin); diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/TenantResolverMiddlewareTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/TenantResolverMiddlewareTests.cs new file mode 100644 index 00000000..f6218f6e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/TenantResolverMiddlewareTests.cs @@ -0,0 +1,223 @@ +using FluentAssertions; +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Tenancy; + +/// +/// What writes, what it restores, and what an +/// anonymous request costs. +/// +/// +/// Driven against the middleware directly. The accessor's containment is the thing +/// under test, and a host test observes it from a different execution context — +/// where an AsyncLocal written inside the request is invisible whether or not +/// it was restored, which would make the assertion pass for the wrong reason. +/// +public sealed class TenantResolverMiddlewareTests +{ + private static readonly TenantId Tenant = + TenantId.From(Guid.Parse("018f4d40-0000-7000-8000-00000000a001")); + + private static readonly OrganizationId Organization = + OrganizationId.From(Guid.Parse("018f4d40-0000-7000-8000-0000000000a1")); + + [Fact] + public async Task A_Tenant_Host_Resolves_Under_The_Host_Only_Ceiling() + { + var accessor = new StaticTenantContextAccessor(null); + ITenantContext? seenByTheHandler = null; + + await Invoke(accessor, HostClassification.ForResolution( + "school.example.com", new HostResolution(Tenant, null)), + onNext: () => seenByTheHandler = accessor.Current); + + seenByTheHandler.Should().NotBeNull(); + seenByTheHandler!.IsResolved.Should().BeTrue(); + seenByTheHandler.TenantId.Should().Be(Tenant); + seenByTheHandler.Origin.Should().Be(TenantContextOrigin.HostOnly); + } + + [Fact] + public async Task A_Platform_Host_Runs_On_The_Unresolved_Context_And_Is_Not_Refused() + { + // Matrix rows 13 and 15. "No tenant" and "refused" are different outcomes and + // only the second is an error — the pipeline decides what may run without + // one, which is where [AllowsUnresolvedTenantContext] lives. + var accessor = new StaticTenantContextAccessor(null); + ITenantContext? seenByTheHandler = null; + var reached = false; + + var context = await Invoke(accessor, HostClassification.Platform("app.learnstack.dev"), + onNext: () => + { + reached = true; + seenByTheHandler = accessor.Current; + }); + + reached.Should().BeTrue("a platform host is served, not refused"); + context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + seenByTheHandler.Should().BeSameAs(UnresolvedTenantContext.Instance, + "written explicitly — 'nothing wrote to it' is the assumption a " + + "save-and-restore protocol exists to stop relying on"); + } + + [Fact] + public async Task The_Accessor_Is_Restored_On_The_Way_Out() + { + // AsyncLocal flows forward. A value left behind here reaches whatever + // continues on this execution context — with a tenant that no longer has a + // request behind it. + var sentinel = new StubContext(); + var accessor = new StaticTenantContextAccessor(sentinel); + + await Invoke(accessor, HostClassification.ForResolution( + "school.example.com", new HostResolution(Tenant, Organization)), + onNext: () => accessor.Current.Should().NotBeSameAs(sentinel)); + + accessor.Current.Should().BeSameAs(sentinel); + } + + [Fact] + public async Task The_Accessor_Is_Restored_Even_When_The_Handler_Throws() + { + // The restore is in a finally and this is what proves it. Without one, an + // exception on any request leaves that request's tenant on the accessor for + // the next thing to read. + var sentinel = new StubContext(); + var accessor = new StaticTenantContextAccessor(sentinel); + + var act = async () => await Invoke( + accessor, + HostClassification.ForResolution("school.example.com", new HostResolution(Tenant, null)), + onNext: () => throw new InvalidOperationException("the handler failed")); + + await act.Should().ThrowAsync(); + accessor.Current.Should().BeSameAs(sentinel); + } + + [Fact] + public async Task An_Unclassified_Request_Is_Passed_Through_Untouched() + { + // /healthz, /openapi and the Hub's /api/internal/* surface, whose tenant + // comes from the envelope's path segment rather than from a host. Keyed off + // the feature and not off a second path predicate: inventing a host signal + // for a request classification declined to classify would be a second + // resolution authority. + var sentinel = new StubContext(); + var accessor = new StaticTenantContextAccessor(sentinel); + var reached = false; + + await Invoke(accessor, classification: null, onNext: () => + { + reached = true; + accessor.Current.Should().BeSameAs(sentinel, "nothing here has a host to resolve"); + }); + + reached.Should().BeTrue(); + } + + [Fact] + public async Task An_Anonymous_Request_Consults_Neither_Port() + { + // Rows 2 and 3 are decided by the host alone. Both ports cost a PostgreSQL + // transaction each, on the pre-authentication path, so calling either + // unconditionally would put two round trips on every anonymous page load. + // + // The ORDER of the two — membership first, so a row-14 attempt never + // announces a caller-supplied, unconfirmed tenant id through + // set_config('app.tenant_id', …) — is not observable here and is not + // asserted here: it needs a claim, and there is no UseAuthentication until + // Phase 02b. Saying so is better than a case that passes because nothing + // reached the branch. + var validator = new CountingValidator(); + var memberships = new CountingMemberships(); + + await Invoke( + new StaticTenantContextAccessor(null), + HostClassification.ForResolution("branch.example.com", new HostResolution(Tenant, Organization)), + onNext: () => { }, + validator, + memberships); + + validator.Calls.Should().Be(0); + memberships.Calls.Should().Be(0); + } + + private static async Task Invoke( + ITenantContextAccessor accessor, + HostClassification? classification, + Action onNext, + IOrganizationScopeValidator? validator = null, + ITenantMembershipReader? memberships = null) + { + var context = new DefaultHttpContext(); + context.Request.Path = "/api/v1/anything"; + + if (classification is not null) + { + context.Features.Set(classification); + } + + var middleware = new TenantResolverMiddleware( + _ => + { + onNext(); + return Task.CompletedTask; + }, + accessor, + NullLogger.Instance); + + await middleware.InvokeAsync( + context, + validator ?? new CountingValidator(), + memberships ?? new CountingMemberships()); + + return context; + } + + private sealed class CountingValidator : IOrganizationScopeValidator + { + public int Calls { get; private set; } + + public Task BelongsToTenantAsync( + TenantId tenantId, OrganizationId organizationId, + CancellationToken cancellationToken = default) + { + Calls++; + return Task.FromResult(true); + } + } + + private sealed class CountingMemberships : ITenantMembershipReader + { + public int Calls { get; private set; } + + public Task CoversAsync( + UserId userId, TenantId tenantId, OrganizationId? organizationId = null, + CancellationToken cancellationToken = default) + { + Calls++; + return Task.FromResult(true); + } + } + + private sealed class StubContext : ITenantContext + { + public bool IsResolved => true; + + public TenantId TenantId => Tenant; + + public OrganizationId? OrganizationId => null; + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => null; + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs new file mode 100644 index 00000000..cda69bbc --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs @@ -0,0 +1,309 @@ +using FluentAssertions; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using Xunit; + +namespace LearnStack.Tests.Unit.SharedKernel.Tenancy; + +/// +/// ADR-0036's +/// reconciliation matrix, driven row by row. +/// +/// +/// +/// The matrix is "total over the signal space", and a total function is exactly what +/// a unit suite can hold to account. Every row below is named for its number so a +/// reader can put the two documents side by side; a row that stops matching its +/// entry is a failure here rather than a discovery in Phase 02b. +/// +/// +/// Most of these rows cannot happen yet. Rows 6–12, 14 and 15 all need a +/// validated claim, and there is no UseAuthentication until Phase 02b. They +/// are tested anyway, and this is the one place where testing an unreachable path is +/// right: the factory is pure, so the rows are reachable as a function, and +/// the alternative is shipping the arithmetic of an authority ceiling with no +/// evidence and finding out when authentication lands. +/// +/// +public sealed class TenantContextFactoryTests +{ + private static readonly TenantId TenantA = TenantId.From(Guid.Parse("018f4d40-0000-7000-8000-00000000a001")); + private static readonly TenantId TenantB = TenantId.From(Guid.Parse("018f4d40-0000-7000-8000-00000000b001")); + private static readonly OrganizationId OrgOne = OrganizationId.From(Guid.Parse("018f4d40-0000-7000-8000-0000000000a1")); + private static readonly OrganizationId OrgTwo = OrganizationId.From(Guid.Parse("018f4d40-0000-7000-8000-0000000000a2")); + private static readonly UserId Actor = UserId.From(Guid.Parse("018f4d40-0000-7000-8000-0000000000f1")); + + [Fact] + public void Row_2_A_tenant_host_with_no_token_resolves_the_tenant_under_the_host_only_ceiling() + { + var result = TenantContextFactory.Create(new TenantResolutionAttempt + { + HostTenantId = TenantA, + }); + + result.IsSuccess.Should().BeTrue(); + result.Value!.TenantId.Should().Be(TenantA); + result.Value!.OrganizationId.Should().BeNull(); + result.Value!.Origin.Should().Be(TenantContextOrigin.HostOnly, + "an anonymous page load reaches [PublicSurface] request types and nothing else"); + result.Value!.UserId.Should().BeNull(); + } + + [Fact] + public void Row_3_An_org_host_carries_the_mapping_rows_organization_as_the_anonymous_scope() + { + // The anonymous organization scope IS the host-mapping row, not the tenant's + // organization count — a tenant that wants its default branch's content on + // its public site seeds organization_id into that row. That removes a code + // branch, and this is the assertion that the branch stayed removed. + var result = TenantContextFactory.Create(new TenantResolutionAttempt + { + HostTenantId = TenantA, + HostOrganizationId = OrgOne, + }); + + result.IsSuccess.Should().BeTrue(); + result.Value!.OrganizationId.Should().Be(OrgOne); + result.Value!.Origin.Should().Be(TenantContextOrigin.HostOnly); + } + + [Fact] + public void Row_6_A_tenant_host_and_an_agreeing_claim_need_no_membership() + { + // Rows 6 and 9 are the two rows that resolve WITHOUT consulting membership, + // and they are the rows an over-firing predicate breaks. If this ever needs + // MembershipCovers, every authenticated user is 404'd on their own tenant's + // own host the moment Phase 02b lands — and nothing in Packet 7 traffic + // would reveal it, because no claim exists to trigger it. + var result = TenantContextFactory.Create(Authenticated() with + { + HostTenantId = TenantA, + ClaimTenantId = TenantA, + }); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Origin.Should().Be(TenantContextOrigin.HostAndClaim); + result.Value!.UserId.Should().Be(Actor); + } + + [Fact] + public void Row_7_A_claim_reaching_past_the_host_needs_both_answers() + { + var attempt = Authenticated() with + { + HostTenantId = TenantA, + ClaimTenantId = TenantA, + ClaimOrganizationId = OrgOne, + }; + + attempt.RequiresMembershipCheck.Should().BeTrue(); + attempt.RequiresOrganizationScopeCheck.Should().BeTrue( + "row 7 is the one row of the matrix carrying an ∈ term"); + + TenantContextFactory.Create(attempt with { MembershipCovers = true }) + .IsSuccess.Should().BeFalse("the ∈ answer is missing, and missing is a refusal"); + + TenantContextFactory.Create(attempt with { ClaimedOrganizationBelongsToTenant = true }) + .IsSuccess.Should().BeFalse("membership is missing"); + + var resolved = TenantContextFactory.Create(attempt with + { + MembershipCovers = true, + ClaimedOrganizationBelongsToTenant = true, + }); + + resolved.IsSuccess.Should().BeTrue(); + resolved.Value!.OrganizationId.Should().Be(OrgOne, "the claim narrows within the tenant"); + resolved.Value!.Origin.Should().Be(TenantContextOrigin.HostAndClaim, + "membership CONFIRMED a claim here; it did not carry the tenant"); + } + + [Fact] + public void Row_8_A_token_for_another_tenant_on_this_tenants_host_is_refused() + { + // The architecture/13 cross-check. Stated in the corpus as a fault detector + // rather than an authorization control: a client holding a valid token for + // T' can still address a platform host and take row 14, which grants only + // their own tenant. The control is that no signal outside the intersection + // can select a tenant. + var result = TenantContextFactory.Create(Authenticated() with + { + HostTenantId = TenantA, + ClaimTenantId = TenantB, + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public void Row_9_An_org_host_and_a_claim_naming_the_same_pair_agree() + { + var attempt = Authenticated() with + { + HostTenantId = TenantA, + HostOrganizationId = OrgOne, + ClaimTenantId = TenantA, + ClaimOrganizationId = OrgOne, + }; + + attempt.RequiresMembershipCheck.Should().BeFalse("the host already vouches for this pair"); + + var result = TenantContextFactory.Create(attempt); + + result.IsSuccess.Should().BeTrue(); + result.Value!.OrganizationId.Should().Be(OrgOne); + result.Value!.Origin.Should().Be(TenantContextOrigin.HostAndClaim); + } + + [Fact] + public void Row_10_An_org_host_with_a_tenant_wide_claim_needs_membership() + { + var attempt = Authenticated() with + { + HostTenantId = TenantA, + HostOrganizationId = OrgOne, + ClaimTenantId = TenantA, + }; + + attempt.RequiresMembershipCheck.Should().BeTrue(); + attempt.RequiresOrganizationScopeCheck.Should().BeFalse( + "the claim names no organization, so there is nothing whose belonging to ask about"); + + TenantContextFactory.Create(attempt).IsSuccess.Should().BeFalse( + "unasked is refused — DenyAllTenantMembershipReader is what makes this the live answer"); + + var covered = TenantContextFactory.Create(attempt with { MembershipCovers = true }); + covered.IsSuccess.Should().BeTrue(); + covered.Value!.OrganizationId.Should().Be(OrgOne, "the host's organization stands"); + } + + [Fact] + public void Row_11_A_claim_naming_a_different_organization_of_the_same_tenant_is_a_mismatch() + { + // Not a scope change. An earlier draft let the host's organization win over a + // disagreeing claim, citing shareable branch links — a citation that turned + // out to describe `?org=`, a search parameter this ADR trusts for + // nothing. Making it a refusal also removes a durable write from a happy + // path: nothing re-issues the token, so the disagreement would hold for the + // whole session and every subresource fetch would re-emit the event. + var result = TenantContextFactory.Create(Authenticated() with + { + HostTenantId = TenantA, + HostOrganizationId = OrgOne, + ClaimTenantId = TenantA, + ClaimOrganizationId = OrgTwo, + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public void Row_12_A_tenant_disagreement_wins_before_the_organization_term_is_reached() + { + var result = TenantContextFactory.Create(Authenticated() with + { + HostTenantId = TenantA, + HostOrganizationId = OrgOne, + ClaimTenantId = TenantB, + ClaimOrganizationId = OrgOne, + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Theory] + [InlineData(false, "row 13 — a platform host with no token")] + [InlineData(true, "row 15 — a valid token carrying no tenant claim")] + public void Rows_13_And_15_Name_No_Tenant_And_That_Is_Not_A_Failure( + bool authenticated, string row) + { + // NamesNoTenant is what the middleware branches on; Create is not called for + // these rows at all. Asserting the predicate rather than the refusal is the + // point: "no tenant" and "refused" are different outcomes, and only the + // second is an error. + var attempt = new TenantResolutionAttempt { HasValidatedPrincipal = authenticated }; + + attempt.NamesNoTenant.Should().BeTrue(row); + } + + [Fact] + public void Row_14_A_platform_host_with_a_claim_is_carried_by_membership_alone() + { + var attempt = Authenticated() with { ClaimTenantId = TenantB, ClaimOrganizationId = OrgTwo }; + + attempt.NamesNoTenant.Should().BeFalse("the claim names one even though no host does"); + attempt.RequiresMembershipCheck.Should().BeTrue(); + attempt.RequiresOrganizationScopeCheck.Should().BeFalse( + "row 14 names M alone: a membership record is per (tenant, organization), so an " + + "active membership covering the pair already establishes the belonging"); + + TenantContextFactory.Create(attempt).IsSuccess.Should().BeFalse( + "the Studio tenant switcher 404s for everyone until Phase 03 ships Membership"); + + var covered = TenantContextFactory.Create(attempt with { MembershipCovers = true }); + + covered.IsSuccess.Should().BeTrue(); + covered.Value!.TenantId.Should().Be(TenantB); + covered.Value!.Origin.Should().Be(TenantContextOrigin.ClaimAndMembership, + "no host named this tenant, so membership is what carried it — the one row that is"); + } + + [Fact] + public void Every_refusal_carries_the_same_error() + { + // A caller able to tell row 8 (a tenant that exists, claimed by a token for + // another) from row 10 (an organization no membership covers) would have an + // oracle over which tenants and organizations exist. The wire is already + // bodyless; this pins the layer above it. + var refusals = new[] + { + TenantContextFactory.Create(Authenticated() with + { + HostTenantId = TenantA, ClaimTenantId = TenantB, + }), + TenantContextFactory.Create(Authenticated() with + { + HostTenantId = TenantA, HostOrganizationId = OrgOne, ClaimTenantId = TenantA, + }), + TenantContextFactory.Create(Authenticated() with + { + HostTenantId = TenantA, HostOrganizationId = OrgOne, + ClaimTenantId = TenantA, ClaimOrganizationId = OrgTwo, + }), + }; + + refusals.Should().OnlyContain(result => !result.IsSuccess); + refusals.Should().OnlyContain(result => result.Error == TenantContextFactory.Refused); + TenantContextFactory.Refused.Message.Key.Should().Be("lockey_not_found", + "not tenant_mismatch — the wire must match the anonymous case, and " + + "tenant_mismatch is the authenticated code"); + } + + [Fact] + public void An_unanswered_question_is_a_refusal_and_never_a_pass() + { + // `is not true` and not `== false`. The difference is a caller that forgot to + // ask: under `== false` a null answer would sail through and widen the + // context to whatever the claim asked for. + var attempt = Authenticated() with + { + HostTenantId = TenantA, + ClaimTenantId = TenantA, + ClaimOrganizationId = OrgOne, + }; + + attempt.MembershipCovers.Should().BeNull(); + TenantContextFactory.Create(attempt).IsSuccess.Should().BeFalse(); + } + + [Fact] + public void The_factory_refuses_a_null_attempt_rather_than_inventing_one() + { + var act = () => TenantContextFactory.Create(null!); + + act.Should().Throw(); + } + + private static TenantResolutionAttempt Authenticated() => + new() { HasValidatedPrincipal = true, UserId = Actor }; +} diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index a0116086..1ea4d925 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -606,11 +606,25 @@ this ADR actually shipped. |---|---|---| | **Packet 4** | Rate limiter, effective host, normalizer, trusted hop, assertion comparison, `LoggingTenantAssertionRecorder`. No resolver, no claims, no `IAuditStore` | 404 + metric + `Warning` log. Unreachable in traffic — every request is already rejected by the unresolved-context guard — and exercised by unit tests over a stubbed context. **Packet 4 must not describe the outcome as audited.** | | **Packet 6** | `platform_host_to_tenant` with `UNIQUE (host)`, the normalization `CHECK`, `is_publicly_live` | unchanged | -| **Packet 7** | Classification, resolver, `TenantContextFactory`, `TenantContextOrigin`, `IOrganizationScopeValidator`, `DenyAllTenantMembershipReader` | 404 + metric + `Warning`. Matrix rows 2, 3, 6, 9, 10 become live; rows 7 and 14 fail closed until Phase 03 | +| **Packet 7** | Classification, resolver, `TenantContextFactory`, `TenantContextOrigin`, `IOrganizationScopeValidator`, `DenyAllTenantMembershipReader` | 404 + metric + `Warning`. Matrix rows 2, 3, 6, 9, 10 become live; rows 7 and 14 fail closed until Phase 03 — see the erratum below | | **Packet 9** | `IAuditStore`, `audit_log`, `AuditingTenantAssertionRecorder` | MUST-class rows begin. `tenancy.tenant-assertion.reject` per occurrence for authenticated callers; `tenancy.tenant-assertion.anonymous-burst` per window | | **Phase 02b** | Keycloak, `UseAuthentication`, the `tenant_id` claim | The H↔J cross-check goes live through the **same** recorder and the **same** operation key with `metadata.assertionSource = jwt-claim`. Phase 02b's completion criterion is met by this mechanism, not by a second detector | | **Phase 03** | `Membership` | Rows 7 and 14 stop failing closed | +> **Erratum — 2026-09-02.** The Packet 7 row says matrix rows **2, 3, 6, 9, 10** become +> live. Rows 6, 9 and 10 do not: all three require a validated claim in their Auth +> column, and the paragraph immediately below this table says so — "the authenticated +> tier is dormant before Phase 02b — there is no `UseAuthentication` to be ordered +> after". Shown by `grep -rn UseAuthentication backend/src`, whose only hit is a comment +> saying it does not exist yet. The rows Packet 7 makes live are **2, 3 and 13**, and it +> makes **16** reachable for the first time — the assertion comparison shipped in Packet +> 4 with nothing resolved to compare against. Rows 6, 9 and 10 become live in **Phase +> 02b**; 7 and 14 need Phase 02b to be reachable at all and Phase 03 to stop failing +> closed. The table's own Packet 4 row draws exactly this distinction — "unreachable in +> traffic" — and the Packet 7 row did not. Nothing about what the rows *decide* changes; +> the factory implements all seventeen and Packet 7 tests them as a pure function. +> Recorded in Amendment 5. + The authenticated tier is dormant before Phase 02b — there is no `UseAuthentication` to be ordered after, `AuthorizationBehavior.Handle` is `return next()`, and the `authenticated` metric label is constant-false. That is staged explicitly rather than @@ -851,34 +865,6 @@ had already sent the reader. default is `GET`/`HEAD`, a mutating entry states why, no `[PublicSurface]` type performs a tenant-owned write, and none is classified MUST-class `read-sensitive`. -## References - -- [ADR-0003 Tenant Isolation Defense in - Depth](0003-tenant-isolation-defense-in-depth.md) (Amendment 3) -- [ADR-0004 Authentication Strategy](0004-authentication-strategy.md) (Amendment 1 — - the two-realm invariant) -- [ADR-0013 Page Block Schema Versioning](0013-page-block-schema-versioning.md) — one - of the two `/v1/platform/*` dependents left open here. -- [ADR-0015 API Gateway with APISIX](0015-api-gateway-apisix.md) -- [ADR-0017 Tenant + Organization Hierarchy](0017-tenant-organization-hierarchy.md) -- [ADR-0019 LearnStack Hub](0019-learnstack-hub.md) — Option B, which a platform URL - space would re-adopt sideways. -- [ADR-0033 Audit Durability Model](0033-audit-durability-model.md) (Amendment 1 — the - standalone-write failure posture § Recording a rejected assertion depends on) -- [ADR-0034 Hub Contract Surface Invariant](0034-hub-contract-surface-invariant.md) -- [ADR-0035 Demand-Gated Infrastructure](0035-demand-gated-infrastructure.md) -- [Standards 04 § Tenant Context](../standards/04-api-design.md) -- [Standards 05 § Table classes](../standards/05-database.md) -- [Standards 07 § Tenant Resolution](../standards/07-frontend-architecture.md) — the - header's producer. -- [Standards 11 § Tenant Context](../standards/11-security.md) -- [Standards 18 Audit Coverage](../standards/18-audit-coverage.md) -- [Standards 20 § Host → Tenant Resolution](../standards/20-infrastructure-stack.md) -- [architecture/09 Tenant Isolation](../architecture/09-tenant-isolation.md) -- [architecture/13 Identity and Auth](../architecture/13-identity-and-auth.md) -- [architecture/14 Frontend Architecture](../architecture/14-frontend-architecture.md) -- [architecture/30 API Gateway](../architecture/30-api-gateway.md) - ### 2026-09-02 — Amendment 4: the IPv4 refusal belongs on the produced value **What was wrong.** Amendment 1's corrected order places **reject IPv4 literals** @@ -924,3 +910,66 @@ catalogue on its own account. **The Decision is unchanged.** `EffectiveHost.Normalize` is still the sole producer of the lookup key and of `app.resolving_host`, still total, still returns `null` on every failure, and still never throws. + +### 2026-09-02 — Amendment 5: which rows Packet 7 actually makes live + +**What was wrong.** § Staging across packets' Packet 7 row claims matrix rows 2, 3, 6, +9 and 10 "become live". Rows 6, 9 and 10 each require a validated claim, and no packet +before Phase 02b has one. The sentence was false when it entered the record: the same +subsection's next paragraph already stated that the authenticated tier is dormant until +Phase 02b, and its own Packet 4 row already used the words "unreachable in traffic" for +precisely this distinction. + +**How it was shown.** `grep -rn UseAuthentication backend/src` returns one hit, a comment +in `TenantAssertionMiddleware` noting that there is none to be ordered after. The Auth +column of rows 6, 9 and 10 reads `(T, —)`, `(T, O)` and `(T, —)` — a claim in every case. + +**The corrected reading.** Packet 7 makes rows **2, 3 and 13** live and row **16** +reachable. Row 16 is the one worth naming: the assertion comparison shipped in Packet 4 +against a context that never resolved, so every comparison was vacuous; Packet 7 is what +gives it a resolved value to disagree with. + +**Why the distinction is worth an amendment rather than a shrug.** "Live" is the word a +later reader uses to decide whether a green suite is evidence. A packet that believes it +made the authenticated rows live will read `DenyAllTenantMembershipReader`'s untouched +code path as proof that rows 7 and 14 fail closed, when in fact nothing can reach the +call at all. Packet 7 tests all seventeen rows as a pure function of +`TenantResolutionAttempt`, which is the honest form of that evidence and is not the same +claim. + +**Every carrier changed.** This ADR — the inline erratum beside the staging table, and +this amendment. No other document reproduces the row list; +[Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) points here rather than restating it, +and the Packet 7 delivery record states the corrected set directly. + +**The Decision is unchanged.** The matrix, the signals, the ceiling and the staging +order all stand; only the claim about which rows traffic can reach in Packet 7 is +corrected. + +## References + +- [ADR-0003 Tenant Isolation Defense in + Depth](0003-tenant-isolation-defense-in-depth.md) (Amendment 3) +- [ADR-0004 Authentication Strategy](0004-authentication-strategy.md) (Amendment 1 — + the two-realm invariant) +- [ADR-0013 Page Block Schema Versioning](0013-page-block-schema-versioning.md) — one + of the two `/v1/platform/*` dependents left open here. +- [ADR-0015 API Gateway with APISIX](0015-api-gateway-apisix.md) +- [ADR-0017 Tenant + Organization Hierarchy](0017-tenant-organization-hierarchy.md) +- [ADR-0019 LearnStack Hub](0019-learnstack-hub.md) — Option B, which a platform URL + space would re-adopt sideways. +- [ADR-0033 Audit Durability Model](0033-audit-durability-model.md) (Amendment 1 — the + standalone-write failure posture § Recording a rejected assertion depends on) +- [ADR-0034 Hub Contract Surface Invariant](0034-hub-contract-surface-invariant.md) +- [ADR-0035 Demand-Gated Infrastructure](0035-demand-gated-infrastructure.md) +- [Standards 04 § Tenant Context](../standards/04-api-design.md) +- [Standards 05 § Table classes](../standards/05-database.md) +- [Standards 07 § Tenant Resolution](../standards/07-frontend-architecture.md) — the + header's producer. +- [Standards 11 § Tenant Context](../standards/11-security.md) +- [Standards 18 Audit Coverage](../standards/18-audit-coverage.md) +- [Standards 20 § Host → Tenant Resolution](../standards/20-infrastructure-stack.md) +- [architecture/09 Tenant Isolation](../architecture/09-tenant-isolation.md) +- [architecture/13 Identity and Auth](../architecture/13-identity-and-auth.md) +- [architecture/14 Frontend Architecture](../architecture/14-frontend-architecture.md) +- [architecture/30 API Gateway](../architecture/30-api-gateway.md) diff --git a/docs/glossary.md b/docs/glossary.md index d915dd95..1ea37dcb 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -126,6 +126,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Tenant context** | The ambient resolved tenant for a request, job, or background task. | | **Query filter** | EF Core global query filter that injects `WHERE tenant_id = @current_tenant_id` automatically. | | **`TenantContextOrigin`** | The authority ceiling on a resolved context: `HostOnly`, `HostAndClaim`, `ClaimAndMembership`, `Ambient`. A `HostOnly` context reaches only request types marked `[PublicSurface]`, which is what makes a forged host harmless — it reaches exactly the pages that hostname already serves to anyone who types it. Per [ADR-0036 § The reconciliation matrix](decisions/0036-tenant-resolution-trusted-inputs.md). | +| **`TenantResolutionAttempt`** | Every signal the reconciliation matrix may look at, gathered before any context exists: what the host mapping named, what a validated claim named, and the **answers** — never the ports — to the two questions that need a database. Carrying answers is what lets `TenantContextFactory.Create` be pure, total and synchronous, so all seventeen matrix rows are drivable from a unit test. It deliberately carries no host string, no `X-Tenant-Id` / `X-Organization-Id` and no host class. Per [ADR-0036 § The reconciliation matrix](decisions/0036-tenant-resolution-trusted-inputs.md). | | **`TenantContextFactory`** | The single entry point that constructs the sealed `TenantContext`: it returns `Result.Fail` on any disagreement between the signals and never a partially populated context. `TenantContext` has no public constructor; `TenantContext_Is_Constructed_Only_By_The_Factory` enforces both halves. Per [ADR-0036 § The reconciliation matrix](decisions/0036-tenant-resolution-trusted-inputs.md). | | **`IOrganizationScopeValidator`** | The reader that answers "does this organization belong to this tenant", resolving `organizations` by the composite key `(tenant_id, id)` in its own short read-only transaction that sets `app.tenant_id` as its first statement — one of the sanctioned out-of-band setters of that GUC ([Security Standards § The out-of-band setters](standards/11-security.md), per [ADR-0040 Amendment 3](decisions/0040-ambient-unit-of-work.md)). A valid organization id from another tenant is a mismatch, not an override. | | **`DenyAllTenantMembershipReader`** | The Packet 7 `ITenantMembershipReader` that denies every membership question, so the reconciliation matrix's rows 7 and 14 fail closed until [Phase 03](roadmap/phase-03-identity-admin.md) ships `Membership`. It makes the Studio tenant switcher 404 for everyone in that window; that is correct and it will look like a bug. Per [ADR-0036](decisions/0036-tenant-resolution-trusted-inputs.md). | diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index fb7369f9..295d2fd3 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -287,11 +287,21 @@ yet: | `TransactionBehavior` | ambient | — the general case | | The integration-event transport, per delivery | ambient — it opens it | There is no MediatR request: `InProcessEventBus` invokes the handler directly, so no behavior runs. It opens the ambient transaction itself, from the delivery's `EventTenantContext` | | `IOrganizationScopeValidator` | its own short read-only one | The organization assertion is validated in the request edge, before the pipeline reaches step 6 ([ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)) | + | `IIdempotencyStore` (durable) | its own short one | A claim is taken **before** the pipeline reaches step 6 ([ADR-0037](../decisions/0037-idempotency-key-contract.md)) | | `IAuditStore.WriteStandaloneAsync` | its own short one | An audit row that must survive the rollback of the operation it describes cannot share that operation's transaction ([ADR-0033](../decisions/0033-audit-durability-model.md)) | | `IAuditStore.WriteBestEffortAsync` | its own short one | Same shape, SHOULD/MAY class; failures are logged and dropped | | The `AuditConfig` override loader | its own short read | An out-of-band cached projection, never a request-path query | +> **`IOrganizationScopeValidator` is registered and has no reachable caller yet.** Its +> only non-vacuous caller is the reconciliation matrix's row 7, which needs a validated +> claim, and there is no `UseAuthentication` until Phase 02b — so no request can reach the +> call. The assertion path ADR-0036 § What the assertions do names as its caller is +> subsumed by `TenantAssertionMiddleware`, which refuses on any difference between the +> asserted and the resolved value before belonging can matter. This table lists it because +> the set of setters is closed and a closed set is worth stating whole; it is not evidence +> that a seventh short transaction runs on any Packet 7 request path. + Every one of them connects as `learnstack_app`. A setter that reached for `learnstack_platform` would be invisible to the isolation suite, which is the failure mode [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md) diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 1a9f5982..f2f57064 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -2034,10 +2034,19 @@ structural test proves — and what it does not. #### `TenantContext_Is_Constructed_Only_By_The_Factory` -- **Asserts:** `TenantContext` is sealed with no public constructor and `TenantContextFactory.Create` is its only entry point. The factory returns `Result.Fail` on any disagreement and never a partially populated context. +- **Asserts:** `TenantContext` is sealed with no public constructor and `TenantContextFactory.Create` is its only entry point. Four conjuncts, and they need **two instruments** — which is why this is written out rather than expressed as one NetArchTest chain. Reflection covers sealedness, the absent public constructor, the absence of any `InternalsVisibleTo` on `LearnStack.SharedKernel` (one attribute would hand a whole assembly the constructor), and the single member whose return type mentions `TenantContext`. It cannot cover the fourth: a `new` expression is a call site, not a type reference. That one is a source scan — `TenantContext_Is_Instantiated_In_One_File` — banning `new TenantContext(` everywhere in the kernel but the factory's own file, which is exactly the residual an `internal` constructor leaves. **`internal` and not `private`:** C# has no friend types, so a private constructor and a top-level `TenantContextFactory` — the name ADR-0036, the glossary and two roadmap lines all carry — are mutually exclusive, and both normative carriers say only *public*. The factory returns `Result.Fail` on any disagreement and never a partially populated context. - **Source:** ADR-0036 § The reconciliation matrix. - **Type:** xUnit + NetArchTest. **Kind:** structural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`TenantContextConstructionTests`, Packet 7 step 5). +- **Phase:** 02a Packet 7. + +#### `TenantContext_Is_Instantiated_In_One_File` + +- **Asserts:** the literal `new TenantContext(` appears in exactly one file under `backend/src` — `TenantContextFactory.cs`. Comments and whitespace are stripped first, because the files these rules cover argue in prose about the very literal they may not write. +- **Why it matters:** the second instrument `TenantContext_Is_Constructed_Only_By_The_Factory` needs and cannot be. `internal` blocks every other assembly, and nothing but a scan blocks a second caller inside the kernel itself — which would be a second entry point producing a context the matrix never decided. +- **Source:** [ADR-0036 § Rules](../decisions/0036-tenant-resolution-trusted-inputs.md). +- **Type:** xUnit + source scan. **Kind:** structural. +- **Status:** **Implemented** (`TenantContextConstructionTests`, Packet 7 step 5). - **Phase:** 02a Packet 7. #### `SetTenant_Callers_Are_The_Enumerated_Four` @@ -2046,7 +2055,7 @@ structural test proves — and what it does not. - **Source:** ADR-0036 § Rules, second bullet, as corrected by its erratum and [Amendment 2](../decisions/0036-tenant-resolution-trusted-inputs.md). - **Type:** Roslyn / IL call-site scan + xUnit. **Kind:** structural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`TenantContextConstructionTests`, Packet 7 step 5). - **Phase:** 02a Packet 7. - **Note:** the name predates the correction and is kept. `ITenantContextAccessor` declares one member, `ITenantContext? Current { get; set; }`, and the `SetTenant` @@ -2084,10 +2093,10 @@ structural test proves — and what it does not. #### `Organizations_Are_Read_By_Composite_Key` -- **Asserts:** `IOrganizationScopeValidator` and every organization read resolve by the composite key `(tenant_id, id)`, never by `id` alone. +- **Asserts:** `IOrganizationScopeValidator` and every organization read resolve by the composite key `(tenant_id, id)`, never by `id` alone. `pk_organizations` is the surrogate id, so a lookup by it is a well-formed, index-served query that returns another tenant's row — for the policy to hide if the announcement was made, and to hand back if it was not. Two legs: the raw-SQL leg pins the validator's `WHERE` clause and its `set_config` announcement (scanned, because a command's text is a string literal no type-reference test can see), and the EF leg bans `Organizations.Find`/`FindAsync`, which take the primary key and therefore cannot express the composite one. **The EF leg is vacuous today** and deliberately kept: nothing reads `organizations` through a `DbContext` until Packet 7 step 9 writes the first command, and a scan added only once there is something to catch is a scan nobody adds. The runtime suite cannot substitute for either leg — with the announcement made, the policy makes both spellings behave identically, which is defence in depth working and is exactly why the rule has to be structural. - **Source:** ADR-0036 § The reconciliation matrix. - **Type:** xUnit + NetArchTest. **Kind:** structural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`TenantContextConstructionTests`, Packet 7 step 5). - **Phase:** 02a Packet 7. #### `Tenant_Scope_Widening_Is_Never_Set_From_Request_Input` From 6c4dff9338651e0fc8c2fe2b0422fc90fcfa923c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 11:56:37 +0300 Subject: [PATCH 16/55] fix(tenancy): correct the context edge the Step 5 review measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five defects, three of them mine to own, and one of those live today. The context carried Kestrel's TraceIdentifier as its correlation id. The contract on ITenantContext says W3C traceparent, CorrelationHeaderMiddleware rejects TraceIdentifier by name for being per-connection and absent from every response, and this middleware is the first writer of the accessor on an HTTP path — so from the previous commit every span, Serilog line and Sentry scope on the two live matrix rows carried a handle correlating with nothing the caller was given, and IntegrationEventEnvelope's ActivityContext.TryParse was armed to throw at the first outbox enqueue. Measured: TryParse is false for a TraceIdentifier and true for the value now written. SetTenant_Callers_Are_The_Enumerated_Four asserted nothing outside a Tenancy folder. The scan was narrowed to paths containing "Tenancy", which deleted the writer that had already shipped — InProcessEventBus, the integration-event handler scope, which ADR-0036 Amendment 2 names as the fourth caller — and meant a fifth writer anywhere else passed green. A rule whose whole job in this packet is the negative cannot be scoped to the folder its positives live in. It scans the tree now and names both writers, and three shipped sentences claiming this middleware was the first of the four are corrected. Two claim shapes had no row and were answered anyway, both too generously: an organization claim with no tenant claim took the claim's organization under the anonymous HostOnly ceiling — row 11's forbidden scope change reached by omitting a field — and a tenant claim with no subject minted ClaimAndMembership, the strongest ceiling, with a null user. One predicate refuses both, and it also earns the two dereferences in the resolver's port calls that would otherwise have thrown the moment Phase 02b populated claims. Row 10 asked membership about an organization the context would not carry: the resolver asked the strictly weaker tenant-level question while the factory granted the host's organization, where ADR-0036 says the context resolves (T, O) iff M covers (T, O). Rows 7 and 14 were self-consistent only because the host names no organization on either, which is why it was invisible. The question and the grant are one expression now, and the parameter that bounds the authority lost its default so "forgot to narrow" stops compiling. Five fail-closed guards survived the whole suite and now do not. The worst was DenyAllTenantMembershipReader returning true: nothing instantiated the type, so the only membership behaviour the corpus exhibited was a permissive double — for a class whose own documentation says it exists so nobody makes the default permissive to unblock a demo. Row 11 passed with its entire organization term deleted, because the membership guard caught it for an unrelated reason that Phase 03 removes. And the pipeline order had no test at all: moving resolution below the assertion comparison restores the unreachable branch that made every Packet 4 comparison vacuous, serving an X-Tenant-Id that names another tenant while only a metric goes quiet. Also: the validator's transaction is READ ONLY, which four documents already called it; my Standards 11 note had split the closed seven-setter table with a blank line, dropping four rows into literal text; the erratum this packet added named an unwritten delivery record as a changed carrier and omitted row 1; and "the resolver runs before routing has selected an endpoint" was false — measured, routing has run, and the true reason no module name is carried is that resolution must not vary by route. 1078 green, zero skips. Every guard above was re-measured against a mutation. ADR: 0036 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-architecture-test/SKILL.md | 15 +- .../Tenancy/TenantResolverMiddleware.cs | 32 +++- .../OrganizationScopeValidator.cs | 12 ++ .../Tenancy/DenyAllTenantMembershipReader.cs | 2 +- .../Tenancy/ITenantMembershipReader.cs | 2 +- .../Tenancy/TenantContext.cs | 13 +- .../Tenancy/TenantContextFactory.cs | 12 +- .../Tenancy/TenantResolutionAttempt.cs | 54 +++++- .../Tenancy/UnresolvedTenantContext.cs | 6 +- .../TenantContextConstructionTests.cs | 37 ++-- .../HostClassificationHttpTests.cs | 65 ++++++- .../TenantAssertionHttpTests.cs | 2 +- .../IntegrationEventContractTests.cs | 6 + .../Tenancy/TenantContextFactoryTests.cs | 160 +++++++++++++++++- .../0036-tenant-resolution-trusted-inputs.md | 17 +- docs/standards/11-security.md | 1 - .../21-architecture-tests-catalogue.md | 27 +-- 17 files changed, 407 insertions(+), 56 deletions(-) diff --git a/.claude/skills/add-architecture-test/SKILL.md b/.claude/skills/add-architecture-test/SKILL.md index f921f2bf..21acef7f 100644 --- a/.claude/skills/add-architecture-test/SKILL.md +++ b/.claude/skills/add-architecture-test/SKILL.md @@ -110,14 +110,16 @@ Patterns to follow: ### Step 4: Common architecture-test families -**The shipped set is six files, not a family per topic.** Add yours to the one whose +**The shipped set is eight files, not a family per topic.** Add yours to the one whose subject it shares: | File | What it covers | |------|----------------| | `ModuleDependencyTests.cs` | Dependency direction between module packages, plus a planted-violation meta test that proves the scanner still detects one. | | `PersistenceConventionTests.cs` | `row_version` mapping, ambient-unit-of-work enlistment, the Docker trait, and the `migrate` recipe's chain coverage and credential redaction. | -| `TenancyConventionTests.cs` | The ADR-0036 tenancy-edge rules, as source scans until Packet 7 gives them a resolver to inspect. | +| `TenancyConventionTests.cs` | The ADR-0036 **request-edge** rules — what may read a host, where the effective host and `app.resolving_host` are computed, and the assertion budget's independence from `ICacheService`. Source scans, because three of the four banned inputs appear only as string literals. | +| `TenantScopingTests.cs` | The correspondence between the `[TenantOwned]` / `[OrganizationScoped]` markers, the EF global query filters, and the Row Level Security policies. | +| `TenantContextConstructionTests.cs` | How a tenant context comes into existence and who may write it: the factory's single entry point, the constructor's one call site, the enumerated accessor writers, and the composite-key organization read. | | `ApiConventionTests.cs` | Live majors, forwarded headers, required `Deployment:Mode`, unversioned route prefixes. | | `CrossCuttingFoundationTests.cs` | Pipeline order, `Result` returns, topic naming, and the direct-reference bans (Sentry, `DeploymentMode`, `IEventBus`, provider SDK exceptions). | | `RepositoryLayoutTests.cs` | `No_Source_Folder_Named_Verticals` and the single-frontend-app rule. | @@ -127,7 +129,14 @@ Hub contract — are **Registered** in [the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) against the phase that ships the code they inspect. Check its Status line before assuming a net is under you, and create a new file only when your rule's subject is not one of -the six above. +the eight above. + +> **The tenancy rules live in three files, and the split is by subject, not by ADR.** +> All three cite ADR-0036, so "put it with the other ADR-0036 rules" is not a usable +> instruction. Ask what the rule is *about*: the request edge and what may be read from +> it (`TenancyConventionTests`), the marker-to-filter-to-policy correspondence +> (`TenantScopingTests`), or the construction and writing of the context itself +> (`TenantContextConstructionTests`). ### Step 5: Stability of the test diff --git a/backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs index a0e4896f..32cfcf97 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using LearnStack.SharedKernel.Tenancy; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -12,10 +13,12 @@ namespace LearnStack.Api.Tenancy; /// /// /// One of exactly four writers of ITenantContextAccessor.Current -/// (ADR-0032 § Sub-decision 10): this for HTTP, HubCorrelationMiddleware for +/// (ADR-0036 § Rules): this for HTTP, HubCorrelationMiddleware for /// /api/internal/*, the Hangfire JobActivator for jobs, and the outbox -/// / inbox handler scope for integration events. It is the first of the four to -/// exist, and SetTenant_Callers_Are_The_Enumerated_Four holds the line. +/// / inbox handler scope for integration events. It is the second of the four +/// to exist — InProcessEventBus has written the accessor for the handler scope +/// since Packet 5 — and the first on an HTTP request path. +/// SetTenant_Callers_Are_The_Enumerated_Four holds the line for both. /// /// /// Where it sits, and why not one step earlier. Host classification runs @@ -129,11 +132,30 @@ private static async Task BuildAttemptAsync( // populated a principal, so every claim field stays null and the matrix // collapses to its anonymous rows. Left as one place to change rather than // spread through the branches below. + // + // No module name, and NOT because routing has not run — measured, it has: + // minimal hosting inserts UseRouting ahead of every user middleware, so + // context.GetEndpoint() is already non-null here. The reason is a design + // constraint. Resolution must not vary by route; admitting the matched + // endpoint into the attempt would make the matrix a function of which + // endpoint matched, which is a second resolution authority. var attempt = new TenantResolutionAttempt { HostTenantId = classification.TenantId, HostOrganizationId = classification.OrganizationId, - CorrelationId = context.TraceIdentifier, + // Activity.Current.Id, not TraceIdentifier — the same expression + // CorrelationHeaderMiddleware, ProblemDetailsFactory and the L1 handler + // already use, and for the reason CorrelationHeaderMiddleware states by + // name: TraceIdentifier is a per-connection Kestrel string that appears in + // no response header and no error body. ITenantContext.CorrelationId is + // contractually the W3C traceparent, and this is the first writer of the + // accessor on an HTTP path — so the wrong value here is what every span, + // every Serilog line and every Sentry scope on the two live matrix rows + // would carry, correlating with nothing the caller was given. It also + // arms an ArgumentException at the first outbox enqueue, where + // IntegrationEventEnvelope validates with ActivityContext.TryParse — + // measured false for a TraceIdentifier. + CorrelationId = Activity.Current?.Id ?? context.TraceIdentifier, }; if (attempt.RequiresMembershipCheck) @@ -143,7 +165,7 @@ private static async Task BuildAttemptAsync( MembershipCovers = await memberships.CoversAsync( attempt.UserId!.Value, attempt.ClaimTenantId!.Value, - attempt.ClaimOrganizationId, + attempt.MembershipQuestionOrganizationId, context.RequestAborted), }; diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs index deefe90b..f721fc35 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs @@ -78,6 +78,18 @@ public async Task BelongsToTenantAsync( await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + // Four carriers call this a "short READ-ONLY transaction" — the port's own + // doc, the Standards 11 setter table, the glossary and ADR-0040 Amendment 3 — + // and learnstack_app holds write grants on organizations, so nothing but this + // statement made it true. Read-only is the property that makes a seventh + // member of a closed set of app.tenant_id setters uncontroversial; + // set_config(..., true) is still permitted inside one. + await using (var readOnly = new NpgsqlCommand( + "SET TRANSACTION READ ONLY", connection, transaction)) + { + await readOnly.ExecuteNonQueryAsync(cancellationToken); + } + // set_config(..., true) and not SET LOCAL: PostgreSQL's SET takes no bind // parameter, so `SET LOCAL app.tenant_id = $1` is a syntax error and the // only alternative would be interpolating a caller-supplied identifier into diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/DenyAllTenantMembershipReader.cs b/backend/src/LearnStack.SharedKernel/Tenancy/DenyAllTenantMembershipReader.cs index 0e7644f5..9ad54ee1 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/DenyAllTenantMembershipReader.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/DenyAllTenantMembershipReader.cs @@ -37,7 +37,7 @@ public sealed class DenyAllTenantMembershipReader : ITenantMembershipReader public Task CoversAsync( UserId userId, TenantId tenantId, - OrganizationId? organizationId = null, + OrganizationId? organizationId, CancellationToken cancellationToken = default) => Task.FromResult(false); } diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantMembershipReader.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantMembershipReader.cs index 935ebe07..b8cc9172 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantMembershipReader.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantMembershipReader.cs @@ -31,6 +31,6 @@ public interface ITenantMembershipReader Task CoversAsync( UserId userId, TenantId tenantId, - OrganizationId? organizationId = null, + OrganizationId? organizationId, CancellationToken cancellationToken = default); } diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContext.cs index c0437e4d..363371a1 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContext.cs @@ -17,9 +17,9 @@ namespace LearnStack.SharedKernel.Tenancy; /// ADR's wording is satisfied exactly by internal: the assembly carries no /// InternalsVisibleTo, so every module project, both infrastructure /// assemblies, the API and all four test assemblies are blocked by the compiler. -/// The residual — a second caller inside this one assembly — is what -/// TenantContext_Is_Constructed_Only_By_The_Factory covers, with a source -/// scan, because a type-reference test cannot see a call site. +/// The residual — a second caller inside this one assembly — is covered by the +/// separate TenantContext_Is_Instantiated_In_One_File, which is a source scan: +/// its sibling is reflection-only, and no type-reference test can see a call site. /// /// /// Every instance is complete. There is no setter, no builder and no partial @@ -65,8 +65,11 @@ internal TenantContext( public string? CorrelationId { get; } /// - /// Always null here: the resolver runs before routing has selected an - /// endpoint, so nothing at the request edge knows which module owns the request. + /// Always null here. Not for want of routing — it has already run by the + /// time the resolver executes — but because no endpoint metadata names an owning + /// module, so an HTTP-resolved context has nothing truthful to put here. The + /// consumers that do know theirs set it: EventTenantContext takes it from + /// the subscription. /// public string? ModuleName => null; } diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs index 7ec19a57..503ea9f6 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs @@ -67,6 +67,14 @@ public static Result Create(TenantResolutionAttempt attempt) return Result.Fail(Refused); } + // Neither shape is a row. Refused before the cross-check, because the + // cross-check reasons about signals that agree and these signals are not + // well-formed enough to disagree. + if (attempt.HasIncoherentClaims) + { + return Result.Fail(Refused); + } + // Rows 8, 11 and 12 — the cross-check. Evaluated before any port answer is // consulted, so a refused request never spends a database round trip. if (!attempt.ClaimAgreesWithHost) @@ -95,7 +103,9 @@ public static Result Create(TenantResolutionAttempt attempt) // The claim narrows, the host supplies the anonymous default. Row 7 takes the // claim's organization on a tenant-wide host; rows 3 and 9 take the host's. - var organizationId = attempt.ClaimOrganizationId ?? attempt.HostOrganizationId; + // The SAME member the resolver asks membership about, so the organization + // granted and the organization vouched for are one expression. + var organizationId = attempt.MembershipQuestionOrganizationId; return Result.Ok(new TenantContext( tenantId, diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/TenantResolutionAttempt.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantResolutionAttempt.cs index 47af18c1..743bed25 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/TenantResolutionAttempt.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantResolutionAttempt.cs @@ -30,7 +30,12 @@ namespace LearnStack.SharedKernel.Tenancy; /// they are compared downstream against what this produced. No host class: the /// three live classes are already determined by which of the two host-side /// identifiers are present, and the enum lives in an assembly the kernel cannot -/// see. No module name: the resolver runs before routing has selected an endpoint. +/// see. No module name — and not because routing has not run: measured, it has, +/// since minimal hosting inserts UseRouting ahead of every user middleware, so +/// the matched endpoint is already available at the resolver. The reason is a design +/// constraint. Resolution must not vary by route; admitting the endpoint would make +/// the matrix a function of which route matched, which is a second resolution +/// authority. /// /// /// UnknownHost never arrives here either — host classification answers it @@ -115,12 +120,54 @@ ClaimTenantId is null || HostOrganizationId is null || ClaimOrganizationId == HostOrganizationId)); + /// + /// Claim shapes that are no row of the matrix at all. + /// + /// + /// A validated principal always carries a subject, and an organization claim is + /// meaningless without the tenant claim that scopes it. Neither shape appears in + /// the matrix, so without this the factory answered them by falling through — + /// measured, and both answers were too generous. An organization claim with no + /// tenant claim took the claim's organization under the anonymous + /// HostOnly ceiling, which is row 11's forbidden scope change reached + /// by omitting a field; and a tenant claim with no subject minted + /// ClaimAndMembership — the strongest ceiling — with a null user, a + /// membership attributed to no member. + /// + /// Refusing here rather than narrowing + /// alone is deliberate: that predicate is also the factory's did-anyone-answer + /// gate, so narrowing it would skip the refusal instead of causing one. It is + /// also what earns the two ! dereferences in the resolver's port calls, + /// which would otherwise throw once Phase 02b populates claims. + /// + /// + public bool HasIncoherentClaims => + (ClaimTenantId is not null && UserId is null) + || (ClaimOrganizationId is not null && ClaimTenantId is null); + + /// + /// The organization membership is asked about — the same one + /// TenantContextFactory resolves. + /// + /// + /// One expression, so "whether to ask" and "what to ask about" cannot drift. + /// They had: row 10 is an org-host with a tenant-wide claim, and the resolver + /// asked the strictly weaker tenant-level question — organizationId: null — + /// while the factory granted the host's organization. ADR-0036 row 10 says the + /// context resolves (T, O) iff M covers (T, O). Rows 7 and 14 + /// were self-consistent only by coincidence, because the host names no + /// organization on either, which is exactly why the slip was invisible. + /// + public OrganizationId? MembershipQuestionOrganizationId => + ClaimOrganizationId ?? HostOrganizationId; + /// /// Rows 7, 10 and 14 — a claim that goes beyond what the host already vouches /// for, and nothing else. /// public bool RequiresMembershipCheck => - ClaimTenantId is not null + !HasIncoherentClaims + && ClaimTenantId is not null && ClaimAgreesWithHost && (HostTenantId is null || ClaimOrganizationId != HostOrganizationId); @@ -139,7 +186,8 @@ ClaimTenantId is not null /// id to PostgreSQL through set_config. /// public bool RequiresOrganizationScopeCheck => - HostTenantId is not null + !HasIncoherentClaims + && HostTenantId is not null && ClaimOrganizationId is not null && ClaimAgreesWithHost && ClaimOrganizationId != HostOrganizationId; diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs index 3df42699..23edaefc 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs @@ -11,8 +11,10 @@ namespace LearnStack.SharedKernel.Tenancy; ///
/// /// The real population sites (per ADR-0032 § Sub-decision 10) overwrite the -/// scoped instance once they resolve. TenantResolverMiddleware is the first -/// of them and now writes this instance explicitly on the requests that +/// scoped instance once they resolve. TenantResolverMiddleware is the first of +/// them on an HTTP requestInProcessEventBus has written the accessor +/// for the integration-event handler scope since Packet 5 — and it writes this instance +/// explicitly on the requests that /// legitimately have no tenant — a platform host, matrix rows 13 and 15. That is not /// a refusal: the pipeline decides what may run without a tenant. Every request that /// classification never classified, and every non-HTTP entry point until Phase 02b diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs index 28158f2b..140254ff 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs @@ -17,7 +17,7 @@ public sealed class TenantContextConstructionTests [Fact] public void TenantContext_Is_Constructed_Only_By_The_Factory() { - // Three conjuncts, and they need two different instruments — which is the + // Five conjuncts, and they need two different instruments — which is the // whole reason this test is written out rather than expressed as one // NetArchTest chain. A type-reference scan can see a constructor's // accessibility and a method's return type; it cannot see a `new` expression, @@ -84,24 +84,37 @@ public void SetTenant_Callers_Are_The_Enumerated_Four() // `ITenantContextAccessor.Current`, and writes to it have exactly four // callers — TenantResolverMiddleware (HTTP), HubCorrelationMiddleware // (/api/internal/*), the Hangfire JobActivator (jobs) and the outbox / inbox - // handler scope (integration events). Only the first exists today; the rule - // is written over the whole set so the second one to arrive is a deliberate - // edit here rather than a silent addition there. + // handler scope (integration events). Two exist today; the rule is written + // over the whole set so the third to arrive is a deliberate edit here rather + // than a silent addition there. + // + // The needle is receiver-agnostic — a future `Activity.Current =` anywhere in + // backend/src would trip this, which is a false positive to exempt by path + // and not a reason to filter by folder. // // EnterPlatformAdminScope is deliberately NOT among them (Step 7): it opens a // second connection and sets no tenant context. A test that admitted it would // be admitting a cross-tenant path into the resolution set. + // Unfiltered, and that is the whole rule. The first version narrowed the scan + // to files whose path contained "Tenancy", which deleted the writer that had + // already shipped — InProcessEventBus, the integration-event handler scope, + // which ADR-0036 Amendment 2 names as the fourth caller — and, worse, meant a + // fifth writer anywhere else in the tree passed green. A rule whose whole job + // in this packet is the NEGATIVE cannot be scoped to the folder the positives + // happen to live in. If a false positive ever appears, narrow the needle or + // exempt the one file by path; do not re-narrow by folder. var writers = SourceScan.FilesContaining( - SourceScan.SourceRoot, ".Current =", except: null) - .Where(file => file.Contains("Tenancy", StringComparison.Ordinal) - || file.Contains("MultiTenancy", StringComparison.Ordinal)) - .ToList(); + SourceScan.SourceRoot, ".Current =", except: null); writers.Should().BeEquivalentTo( - ["LearnStack.Api/Tenancy/TenantResolverMiddleware.cs"], - "the four writers are enumerated in ADR-0032 § Sub-decision 10 and only the " - + "HTTP one has landed; a fifth writer is how a request runs under a tenant " - + "nothing resolved"); + [ + "LearnStack.Api/Tenancy/TenantResolverMiddleware.cs", + "LearnStack.Infrastructure/Messaging/InProcessEventBus.cs", + ], + "two of the four enumerated writers have landed — the HTTP one and the " + + "integration-event handler scope. HubCorrelationMiddleware and the " + + "Hangfire JobActivator are later phases, and a fifth writer is how a " + + "request runs under a tenant nothing resolved"); } [Fact] diff --git a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs index 42c5212c..937f6520 100644 --- a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs @@ -234,6 +234,61 @@ public async Task A_Platform_Host_Wins_Over_A_Mapping_Row_That_Names_It() "the row is never read, which is why it is inert rather than conflicting"); } + [Theory] + [InlineData(false, HttpStatusCode.OK, "an assertion that agrees changes nothing")] + [InlineData(true, HttpStatusCode.NotFound, "one that disagrees is refused")] + public async Task An_Assertion_Is_Compared_Against_What_The_Resolver_Produced( + bool disagree, HttpStatusCode expected, string because) + { + // Matrix row 16, which this step's own erratum says Packet 7 makes reachable + // for the FIRST time: Packet 4 shipped the comparison against a context that + // never resolved, so every comparison took the !IsResolved branch and passed + // the request through. + // + // This is also the only thing pinning the pipeline ORDER. Measured: moving + // UseLearnStackTenantResolution back above UseLearnStackTenantAssertions + // leaves all 1069 tests green while silently restoring the unreachable + // branch — an X-Tenant-Id naming another tenant is served, and only a metric + // goes quiet. The existing assertion suite cannot see it, because its fixture + // substitutes a scoped ITenantContext stub rather than resolving one. + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/hostprobe", UriKind.Relative)); + request.Headers.Host = HostClassificationFixture.TenantHost; + request.Headers.Add( + "X-Tenant-Id", + (disagree ? Guid.Parse("018f4d40-0000-7000-8000-0000000000ff") + : HostClassificationFixture.Tenant).ToString()); + + var response = await _client.SendAsync(request); + + response.StatusCode.Should().Be(expected, because); + } + + [Fact] + public async Task The_Resolved_Context_Carries_The_Correlation_Id_The_Caller_Was_Given() + { + // ITenantContext.CorrelationId is contractually the W3C traceparent, and this + // middleware is the first writer of the accessor on an HTTP path — so every + // span, Serilog line and Sentry scope on the live matrix rows carries whatever + // it puts here. The first version put Kestrel's TraceIdentifier, which is + // per-connection, parses as no traceparent, and appears in neither the + // response header nor the error body: three sinks correlating with nothing the + // caller holds, and an ArgumentException armed at the first outbox enqueue. + using var request = new HttpRequestMessage( + HttpMethod.Get, new Uri("/api/v1/hostprobe", UriKind.Relative)); + request.Headers.Host = HostClassificationFixture.TenantHost; + + var response = await _client.SendAsync(request); + var probe = await ReadProbeAsync(response); + + probe.CorrelationId.Should().NotBeNullOrEmpty(); + probe.CorrelationId.Should().Be( + response.Headers.GetValues("X-Correlation-Id").Single(), + "the context, the response header and the Problem Details body are one value"); + System.Diagnostics.ActivityContext.TryParse(probe.CorrelationId, null, out _) + .Should().BeTrue("IntegrationEventEnvelope validates it with exactly this call"); + } + [Fact] public async Task An_Unclassified_Prefix_Is_Served_Whatever_Its_Host() { @@ -345,7 +400,8 @@ public IActionResult Get() => tenantContext.IsResolved, tenantContext.IsResolved ? tenantContext.TenantId.Value : null, tenantContext.OrganizationId?.Value, - tenantContext.Origin?.ToString())); + tenantContext.Origin?.ToString(), + tenantContext.CorrelationId)); } /// What reports, typed. @@ -356,4 +412,9 @@ public IActionResult Get() => /// coincidence of spelling is the failure this packet keeps finding. /// public sealed record HostProbe( - string Class, bool Resolved, Guid? TenantId, Guid? OrganizationId, string? Origin); + string Class, + bool Resolved, + Guid? TenantId, + Guid? OrganizationId, + string? Origin, + string? CorrelationId); diff --git a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs index b568b181..365070d5 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs @@ -161,7 +161,7 @@ public async Task A_Client_Supplied_Correlation_Id_Is_Ignored_Not_Reflected(stri // A first version echoed this back under a second header. Kestrel // accepts bytes in a REQUEST header that it refuses to write into a // RESPONSE header, so 'é', a control character or an emoji made the - // assignment throw: a 500 on every route, pre-auth and pre-routing, + // assignment throw: a 500 on every route, before authentication, // each one captured by IErrorTrackingProvider. One header, anonymous, // and the error-tracker quota is someone else's. using var request = Get("/api/v1/assertionprobe"); diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs index cf328c49..b3e0ba32 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs @@ -220,6 +220,12 @@ public void The_Consumer_Context_Has_The_Shape_A_Handler_Needs() // consumer that sends a MediatR command — silently, before its business // logic ran. context.IsResolved.Should().BeTrue(); + + // Matrix row 17. The envelope carried the tenant, so there is no host and no + // token to reconcile and no matrix to apply — which is also the proof that + // TenantContextFactory is the only producer of the TenantContext TYPE rather + // than the only producer of a resolved ITenantContext. + context.Origin.Should().Be(TenantContextOrigin.Ambient); context.TenantId.Should().Be(TenantId.From(Tenant)); context.OrganizationId.Should().Be( organization is { } org ? OrganizationId.From(org) : null); diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs index cda69bbc..d28e1f00 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs @@ -186,15 +186,30 @@ public void Row_11_A_claim_naming_a_different_organization_of_the_same_tenant_is // nothing. Making it a refusal also removes a durable write from a happy // path: nothing re-issues the token, so the disagreement would hold for the // whole session and every subresource fetch would re-emit the event. - var result = TenantContextFactory.Create(Authenticated() with + var attempt = Authenticated() with { HostTenantId = TenantA, HostOrganizationId = OrgOne, ClaimTenantId = TenantA, ClaimOrganizationId = OrgTwo, - }); + }; - result.IsSuccess.Should().BeFalse(); + attempt.ClaimAgreesWithHost.Should().BeFalse( + "the organization term is what refuses this row — asserting only the " + + "outcome let the whole term be deleted, because the membership guard " + + "then caught the row for an unrelated reason"); + + TenantContextFactory.Create(attempt).IsSuccess.Should().BeFalse(); + + // And it stays refused once Phase 03 can answer both questions. This is the + // deliberated decision ADR-0036 records — a disagreeing claim is a mismatch, + // not a scope change — and without this line the real reader removes the + // coincidence that was holding it. + TenantContextFactory.Create(attempt with + { + MembershipCovers = true, + ClaimedOrganizationBelongsToTenant = true, + }).IsSuccess.Should().BeFalse(); } [Fact] @@ -224,6 +239,13 @@ public void Rows_13_And_15_Name_No_Tenant_And_That_Is_Not_A_Failure( var attempt = new TenantResolutionAttempt { HasValidatedPrincipal = authenticated }; attempt.NamesNoTenant.Should().BeTrue(row); + + // The middleware branches on the predicate and never calls Create for these + // rows — but Create must still refuse rather than throw, because its own + // contract says so and because a caller that ignores the note is exactly who + // needs the guard. Without it the tenant dereference below raises + // InvalidOperationException at the request edge. + TenantContextFactory.Create(attempt).IsSuccess.Should().BeFalse(); } [Fact] @@ -248,6 +270,88 @@ public void Row_14_A_platform_host_with_a_claim_is_carried_by_membership_alone() "no host named this tenant, so membership is what carried it — the one row that is"); } + [Fact] + public void Row_14_Without_An_Organization_Claim_Still_Needs_Membership() + { + // The variant that has no organization anywhere: a platform host and a + // tenant-wide claim. RequiresOrganizationScopeCheck is false and membership + // is the only thing standing between the caller and a tenant no host named. + var attempt = Authenticated() with { ClaimTenantId = TenantB }; + + attempt.RequiresMembershipCheck.Should().BeTrue(); + attempt.RequiresOrganizationScopeCheck.Should().BeFalse(); + TenantContextFactory.Create(attempt).IsSuccess.Should().BeFalse(); + + var covered = TenantContextFactory.Create(attempt with { MembershipCovers = true }); + covered.IsSuccess.Should().BeTrue(); + covered.Value!.Origin.Should().Be(TenantContextOrigin.ClaimAndMembership); + covered.Value.OrganizationId.Should().BeNull(); + } + + [Theory] + [InlineData(true, false, "an organization claim with no tenant claim to scope it")] + [InlineData(false, true, "a tenant claim with no subject to attribute it to")] + public void A_Claim_Shape_The_Matrix_Has_No_Row_For_Is_Refused( + bool organizationWithoutTenant, bool tenantWithoutSubject, string shape) + { + // Both were measured answering — and answering generously. The first took the + // claim's organization under the anonymous HostOnly ceiling, which is row 11's + // forbidden scope change reached by omitting a field. The second minted + // ClaimAndMembership, the strongest ceiling there is, with a null user: a + // membership attributed to no member. + var attempt = new TenantResolutionAttempt + { + HostTenantId = organizationWithoutTenant ? TenantA : null, + HostOrganizationId = organizationWithoutTenant ? OrgOne : null, + ClaimOrganizationId = organizationWithoutTenant ? OrgTwo : null, + ClaimTenantId = tenantWithoutSubject ? TenantA : null, + HasValidatedPrincipal = true, + MembershipCovers = true, + ClaimedOrganizationBelongsToTenant = true, + }; + + attempt.HasIncoherentClaims.Should().BeTrue(shape); + attempt.RequiresMembershipCheck.Should().BeFalse( + "an incoherent claim must not reach a port either — the two ! dereferences " + + "in the resolver's calls are earned by this"); + attempt.RequiresOrganizationScopeCheck.Should().BeFalse(); + + // Asserted separately from the predicate: a variant that made the predicates + // false without refusing would pass a predicate-only assertion and fail open. + TenantContextFactory.Create(attempt).IsSuccess.Should().BeFalse(shape); + } + + [Fact] + public void Membership_Is_Asked_About_The_Organization_That_Will_Be_Granted() + { + // Row 10, where the question and the grant drifted: the resolver asked the + // strictly weaker tenant-level question while the factory granted the host's + // organization. ADR-0036 row 10 resolves (T, O) iff M covers (T, O). + var row10 = Authenticated() with + { + HostTenantId = TenantA, + HostOrganizationId = OrgOne, + ClaimTenantId = TenantA, + }; + + row10.MembershipQuestionOrganizationId.Should().Be(OrgOne); + TenantContextFactory.Create(row10 with { MembershipCovers = true }) + .Value!.OrganizationId.Should().Be(row10.MembershipQuestionOrganizationId); + + // Rows 7 and 14 were self-consistent only because the host names no + // organization on either — which is exactly why the row-10 slip was invisible. + var row7 = Authenticated() with + { + HostTenantId = TenantA, + ClaimTenantId = TenantA, + ClaimOrganizationId = OrgOne, + }; + row7.MembershipQuestionOrganizationId.Should().Be(OrgOne); + + var row14 = Authenticated() with { ClaimTenantId = TenantB, ClaimOrganizationId = OrgTwo }; + row14.MembershipQuestionOrganizationId.Should().Be(OrgTwo); + } + [Fact] public void Every_refusal_carries_the_same_error() { @@ -304,6 +408,56 @@ public void The_factory_refuses_a_null_attempt_rather_than_inventing_one() act.Should().Throw(); } + [Fact] + public async Task The_Registered_Membership_Reader_Denies_Everything() + { + // Nothing in the suite instantiated this type, so the only membership + // behaviour the corpus exhibited was a permissive test double — and flipping + // the shipped reader to return true left all 1069 tests green. Its own doc + // says it exists "so that nobody makes the default permissive to unblock a + // demo"; this is the line that would notice. + var reader = new DenyAllTenantMembershipReader(); + + (await reader.CoversAsync(Actor, TenantA, OrgOne, CancellationToken.None)) + .Should().BeFalse(); + (await reader.CoversAsync(Actor, TenantA, null, CancellationToken.None)) + .Should().BeFalse("the tenant-level question is denied too"); + } + + [Fact] + public void An_Implementation_That_States_No_Origin_Carries_None() + { + // The default is null, and null is fail-closed ONLY under an allow-list. The + // pipeline's ceiling check must ask "is this origin one of the ones permitted + // here?" — written as `Origin != HostOnly` it passes for null and hands an + // unstated context the run of the API. That obligation belongs to the step + // that writes the check; this pins the value it will be reading. + ITenantContext silent = new OriginlessContext(); + + silent.Origin.Should().BeNull(); + // Through the interface, because Origin is a default interface member and a + // type that does not restate it has no such member of its own. That is the + // cost of the small diff, and it is worth knowing: a consumer holding the + // concrete type cannot read the ceiling at all. + ((ITenantContext)UnresolvedTenantContext.Instance).Origin.Should().BeNull( + "an unresolved context resolved nothing, so it carries no authority either"); + } + + private sealed class OriginlessContext : ITenantContext + { + public bool IsResolved => true; + + public TenantId TenantId => TenantA; + + public OrganizationId? OrganizationId => null; + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => null; + } + private static TenantResolutionAttempt Authenticated() => new() { HasValidatedPrincipal = true, UserId = Actor }; } diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index 1ea4d925..8c2ac2c8 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -616,9 +616,11 @@ this ADR actually shipped. > column, and the paragraph immediately below this table says so — "the authenticated > tier is dormant before Phase 02b — there is no `UseAuthentication` to be ordered > after". Shown by `grep -rn UseAuthentication backend/src`, whose only hit is a comment -> saying it does not exist yet. The rows Packet 7 makes live are **2, 3 and 13**, and it -> makes **16** reachable for the first time — the assertion comparison shipped in Packet -> 4 with nothing resolved to compare against. Rows 6, 9 and 10 become live in **Phase +> saying it does not exist yet. The rows Packet 7 makes live are **1, 2, 3 and 13**, and +> it makes **16** reachable for the first time — the assertion comparison shipped in +> Packet 4 with nothing resolved to compare against. Row 1 is on the list because host +> classification is itself Packet 7's; it is decided before a `TenantResolutionAttempt` +> exists, which is why the factory's seventeen-row suite does not cover it. Rows 6, 9 and 10 become live in **Phase > 02b**; 7 and 14 need Phase 02b to be reachable at all and Phase 03 to stop failing > closed. The table's own Packet 4 row draws exactly this distinction — "unreachable in > traffic" — and the Packet 7 row did not. Nothing about what the rows *decide* changes; @@ -924,8 +926,10 @@ precisely this distinction. in `TenantAssertionMiddleware` noting that there is none to be ordered after. The Auth column of rows 6, 9 and 10 reads `(T, —)`, `(T, O)` and `(T, —)` — a claim in every case. -**The corrected reading.** Packet 7 makes rows **2, 3 and 13** live and row **16** -reachable. Row 16 is the one worth naming: the assertion comparison shipped in Packet 4 +**The corrected reading.** Packet 7 makes rows **1, 2, 3 and 13** live and row **16** +reachable. Row 1 — an unknown host, 404 at classification — is Packet 7's own: before +this packet the pipeline ran from the OpenAPI document straight to the assertion +comparison, with no classification step at all. Row 16 is the one worth naming: the assertion comparison shipped in Packet 4 against a context that never resolved, so every comparison was vacuous; Packet 7 is what gives it a resolved value to disagree with. @@ -940,7 +944,8 @@ claim. **Every carrier changed.** This ADR — the inline erratum beside the staging table, and this amendment. No other document reproduces the row list; [Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) points here rather than restating it, -and the Packet 7 delivery record states the corrected set directly. +and the Packet 7 delivery record, when it is written at packet close, states the +corrected set directly rather than the staging table's original. **The Decision is unchanged.** The matrix, the signals, the ceiling and the staging order all stand; only the claim about which rows traffic can reach in Packet 7 is diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 295d2fd3..c98341df 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -287,7 +287,6 @@ yet: | `TransactionBehavior` | ambient | — the general case | | The integration-event transport, per delivery | ambient — it opens it | There is no MediatR request: `InProcessEventBus` invokes the handler directly, so no behavior runs. It opens the ambient transaction itself, from the delivery's `EventTenantContext` | | `IOrganizationScopeValidator` | its own short read-only one | The organization assertion is validated in the request edge, before the pipeline reaches step 6 ([ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)) | - | `IIdempotencyStore` (durable) | its own short one | A claim is taken **before** the pipeline reaches step 6 ([ADR-0037](../decisions/0037-idempotency-key-contract.md)) | | `IAuditStore.WriteStandaloneAsync` | its own short one | An audit row that must survive the rollback of the operation it describes cannot share that operation's transaction ([ADR-0033](../decisions/0033-audit-durability-model.md)) | | `IAuditStore.WriteBestEffortAsync` | its own short one | Same shape, SHOULD/MAY class; failures are logged and dropped | diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index f2f57064..91709e3f 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -2034,15 +2034,15 @@ structural test proves — and what it does not. #### `TenantContext_Is_Constructed_Only_By_The_Factory` -- **Asserts:** `TenantContext` is sealed with no public constructor and `TenantContextFactory.Create` is its only entry point. Four conjuncts, and they need **two instruments** — which is why this is written out rather than expressed as one NetArchTest chain. Reflection covers sealedness, the absent public constructor, the absence of any `InternalsVisibleTo` on `LearnStack.SharedKernel` (one attribute would hand a whole assembly the constructor), and the single member whose return type mentions `TenantContext`. It cannot cover the fourth: a `new` expression is a call site, not a type reference. That one is a source scan — `TenantContext_Is_Instantiated_In_One_File` — banning `new TenantContext(` everywhere in the kernel but the factory's own file, which is exactly the residual an `internal` constructor leaves. **`internal` and not `private`:** C# has no friend types, so a private constructor and a top-level `TenantContextFactory` — the name ADR-0036, the glossary and two roadmap lines all carry — are mutually exclusive, and both normative carriers say only *public*. The factory returns `Result.Fail` on any disagreement and never a partially populated context. +- **Asserts:** `TenantContext` is sealed with no public constructor and `TenantContextFactory.Create` is its only entry point. Five conjuncts, and they need **two instruments** — which is why this is written out rather than expressed as one NetArchTest chain. Reflection covers sealedness, the absent public constructor, the absence of any `InternalsVisibleTo` on `LearnStack.SharedKernel` (one attribute would hand a whole assembly the constructor), and the single member whose return type mentions `TenantContext`. It cannot cover the fifth: a `new` expression is a call site, not a type reference. That one is a source scan — `TenantContext_Is_Instantiated_In_One_File` — banning `new TenantContext(` everywhere in the kernel but the factory's own file, which is exactly the residual an `internal` constructor leaves. **`internal` and not `private`:** C# has no friend types, so a private constructor and a top-level `TenantContextFactory` — the name ADR-0036, the glossary and two roadmap lines all carry — are mutually exclusive, and both normative carriers say only *public*. The factory returns `Result.Fail` on any disagreement and never a partially populated context. - **Source:** ADR-0036 § The reconciliation matrix. -- **Type:** xUnit + NetArchTest. **Kind:** structural. +- **Type:** xUnit + reflection. **Kind:** structural. - **Status:** **Implemented** (`TenantContextConstructionTests`, Packet 7 step 5). - **Phase:** 02a Packet 7. #### `TenantContext_Is_Instantiated_In_One_File` -- **Asserts:** the literal `new TenantContext(` appears in exactly one file under `backend/src` — `TenantContextFactory.cs`. Comments and whitespace are stripped first, because the files these rules cover argue in prose about the very literal they may not write. +- **Asserts:** the literal `new TenantContext(` appears in exactly one file under `backend/src/LearnStack.SharedKernel` — which is every file that can compile the call, since the constructor is `internal` and the assembly has no `InternalsVisibleTo` — `TenantContextFactory.cs`. Comments and whitespace are stripped first, because the files these rules cover argue in prose about the very literal they may not write. - **Why it matters:** the second instrument `TenantContext_Is_Constructed_Only_By_The_Factory` needs and cannot be. `internal` blocks every other assembly, and nothing but a scan blocks a second caller inside the kernel itself — which would be a second entry point producing a context the matrix never decided. - **Source:** [ADR-0036 § Rules](../decisions/0036-tenant-resolution-trusted-inputs.md). - **Type:** xUnit + source scan. **Kind:** structural. @@ -2054,7 +2054,7 @@ structural test proves — and what it does not. - **Asserts:** `ITenantContextAccessor.Current` is **written** only by `TenantResolverMiddleware`, `HubCorrelationMiddleware`, the Hangfire `JobActivator`, and the outbox / inbox handler scope. `EnterPlatformAdminScope` is not among them: it opens a second connection and sets no tenant context. Reads are unconstrained. - **Source:** ADR-0036 § Rules, second bullet, as corrected by its erratum and [Amendment 2](../decisions/0036-tenant-resolution-trusted-inputs.md). -- **Type:** Roslyn / IL call-site scan + xUnit. **Kind:** structural. +- **Type:** xUnit + source scan. **Kind:** structural. - **Status:** **Implemented** (`TenantContextConstructionTests`, Packet 7 step 5). - **Phase:** 02a Packet 7. - **Note:** the name predates the correction and is kept. `ITenantContextAccessor` @@ -2062,12 +2062,19 @@ structural test proves — and what it does not. this row used to name has never existed; ADR-0036 Amendment 2 fixes the ADR and keeps the test's spelling, because § Canonical names makes a rename its own liability and the name describes the caller set, which is what ADR-0036 decides. -- **Note:** call-site scan rather than NetArchTest: NetArchTest resolves *type* +- **Note:** a source scan rather than NetArchTest: NetArchTest resolves *type* references and cannot see a write to a property, which is the whole assertion — - the same reason `Effective_Host_Computed_In_One_Place` is a scan. -- **Note:** in Packet 7 the rule can assert only the **negative** — no writer outside - the four. `HubCorrelationMiddleware` is Phase 02c and the Hangfire `JobActivator` - is Phase 02b, so two of the four callers do not exist yet. + the same reason `Effective_Host_Computed_In_One_Place` is a scan. The needle + (`.Current =`) is receiver-agnostic, so an unrelated `Activity.Current =` would trip + it; that is a false positive to exempt by path, never a reason to filter by folder. +- **Note:** **two of the four callers exist** — `TenantResolverMiddleware` (Packet 7 + step 5) and the integration-event handler scope in `InProcessEventBus` (Packet 5). + `HubCorrelationMiddleware` is Phase 02c and the Hangfire `JobActivator` is Phase 02b. + Until they land the rule's live work is the **negative** — no writer outside the set. + The first version of the test scanned only files whose path contained `Tenancy`, + which deleted the `InProcessEventBus` writer from its own expectation *and* let a + fifth writer anywhere else in the tree pass green: a rule whose job is the negative + cannot be scoped to the folder its positives happen to live in. #### `PublicSurface_Marker_Set_Is_Enumerated` @@ -2095,7 +2102,7 @@ structural test proves — and what it does not. - **Asserts:** `IOrganizationScopeValidator` and every organization read resolve by the composite key `(tenant_id, id)`, never by `id` alone. `pk_organizations` is the surrogate id, so a lookup by it is a well-formed, index-served query that returns another tenant's row — for the policy to hide if the announcement was made, and to hand back if it was not. Two legs: the raw-SQL leg pins the validator's `WHERE` clause and its `set_config` announcement (scanned, because a command's text is a string literal no type-reference test can see), and the EF leg bans `Organizations.Find`/`FindAsync`, which take the primary key and therefore cannot express the composite one. **The EF leg is vacuous today** and deliberately kept: nothing reads `organizations` through a `DbContext` until Packet 7 step 9 writes the first command, and a scan added only once there is something to catch is a scan nobody adds. The runtime suite cannot substitute for either leg — with the announcement made, the policy makes both spellings behave identically, which is defence in depth working and is exactly why the rule has to be structural. - **Source:** ADR-0036 § The reconciliation matrix. -- **Type:** xUnit + NetArchTest. **Kind:** structural. +- **Type:** xUnit + source scan. **Kind:** structural. - **Status:** **Implemented** (`TenantContextConstructionTests`, Packet 7 step 5). - **Phase:** 02a Packet 7. From 9df48d6c27e232f3b2b2756e932cf385b4a7cdb2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 13:00:40 +0300 Subject: [PATCH 17/55] docs(tenancy): correct what the factory covers; pin what nothing held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sonnet round found no behavioural defect in reachable code. What it found was an overclaim I introduced two commits ago and three guards no test constrained. Amendment 5 said "the factory implements all seventeen and Packet 7 tests them as a pure function" — four lines after saying the factory's suite does not cover row 1. It decides twelve: the rows expressible as a TenantResolutionAttempt, 2, 3 and 6-15. Row 1 is host classification's, rows 4 and 5 belong to an authentication outcome nothing implements, row 16 to TenantAssertionMiddleware and row 17 to EventTenantContext.FromEnvelope. The sentence mattered more than an ordinary slip because that amendment's whole subject is honesty about what a green suite proves, and because it is what a Phase 02b author would read when deciding whether those four rows already have a home. Corrected in place — it is this branch's own dated text, unmerged and unreviewed — along with the same claim in the factory's own doc and in the glossary entry, which is where it had spread to. TenantContextBehavior's remarks said this behavior "adds a second rejection here" for an origin exceeding what the request type permits. Nothing anywhere reads Origin: grep over Application and Api returns nothing, Handle checks only IsResolved, and the file's own TODO forty lines below still asks for the discriminator. So the file asserted that ADR-0036's authority ceiling — the single control that makes a forged host harmless — was already mechanical. It is Step 6's, and the remark now says so, including that the check must be an allow-list because Origin is a nullable default member. SetTenant_Callers_Are_The_Enumerated_Four had a latent false positive of its own: whitespace is stripped before the search, so ".Current =" becomes ".Current=", which is a substring of ".Current == null" — the idiom tracing code uses, in the very middleware this rule is about, which now reads Activity.Current. Reproduced: a planted equality check failed the rule with an "unauthorized writer" message. The needle is narrowed, which is what the rule's own note prescribes; the folder is not, which is what broke it last round. Three guards no test constrained. The validator's uninitialized/all-zero refusal, whose comment promises the answer is "no" rather than "no, by accident" — deleting it left all five Docker cases green, and an uninitialized Vogen id reaching .Value is a 500 where a documented false was claimed. The two ClaimAgreesWithHost conjuncts, load-bearing not for the factory (which refuses row 11 on its own standalone check) but for the port economy the middleware reads them for. And SET TRANSACTION READ ONLY, which four documents already called this transaction. The read-only guard is worth naming, because the first test of it was the mistake this packet keeps making: it reproduced the statement sequence on its own connection, so it proved what PostgreSQL does and not what the validator does, and the production statement still survived deletion. It now has two legs — a scan that the file issues it and issues it before the announcement, and the Docker case that the statement has the effect claimed — and the test says out loud which half it is. 1084 green, zero skips. Each guard was re-measured against a mutation, and the read-only pair against two. ADR: 0036 Co-Authored-By: Claude Opus 5 (1M context) --- .../Pipeline/TenantContextBehavior.cs | 15 ++-- .../Tenancy/TenantContextFactory.cs | 16 +++- .../SourceScan.cs | 35 +++++++- .../TenantContextConstructionTests.cs | 25 +++++- .../OrganizationScopeValidatorTests.cs | 51 +++++++++++ .../OrganizationScopeValidatorGuardTests.cs | 90 +++++++++++++++++++ .../Tenancy/TenantContextFactoryTests.cs | 12 +++ .../0036-tenant-resolution-trusted-inputs.md | 24 +++-- docs/glossary.md | 2 +- 9 files changed, 249 insertions(+), 21 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/OrganizationScopeValidatorGuardTests.cs diff --git a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs index d6ee425e..c6732053 100644 --- a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs @@ -25,11 +25,16 @@ namespace LearnStack.Application.Pipeline; /// ; this behavior surfaces the fact /// loudly so no handler reads an unresolved context by accident. Packet 7's /// TenantResolverMiddleware writes the singleton -/// ITenantContextAccessor the injected context reads from, and adds a -/// second rejection here: a request whose TenantContextOrigin exceeds -/// what the request type permits — a host-only context reaches only -/// [PublicSurface] types — is refused at this step, which is what makes -/// ADR-0036's authority ceiling mechanical. The RLS session variables are still +/// ITenantContextAccessor the injected context reads from. +/// The authority ceiling is not enforced here yet. This behavior reads only +/// , and nothing in this assembly or the API +/// reads Origin at all. Packet 7 step 6 adds the second rejection — a request +/// whose TenantContextOrigin exceeds what the request type permits, a host-only +/// context reaching only [PublicSurface] types — failing with +/// lockey_not_found so the body matches an unresolvable host's. Until then +/// ADR-0036's ceiling is a decision and not yet a mechanism, and when it lands it must +/// be an allow-list over stated origins: Origin is a nullable default +/// interface member, so Origin != HostOnly passes for null. The RLS session variables are still /// issued by TransactionBehavior inside the transaction at step 6, never /// from this behavior. ///
diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs index 503ea9f6..4bc65668 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs @@ -12,8 +12,13 @@ namespace LearnStack.SharedKernel.Tenancy; /// Pure, total and synchronous. Every question that needs a database was /// answered before the attempt was assembled, so this is /// ADR-0036's -/// reconciliation matrix expressed as a function — which means all seventeen rows -/// are drivable from a unit test with no container, no HTTP and no clock. +/// reconciliation matrix expressed as a function — which means every row expressible +/// as a is drivable from a unit test with no +/// container, no HTTP and no clock. That is twelve of the seventeen: rows 2, 3 +/// and 6-15. The other five are decided elsewhere and belong there — row 1 at host +/// classification, rows 4 and 5 by an authentication outcome, row 16 by +/// TenantAssertionMiddleware, row 17 by EventTenantContext.FromEnvelope. +/// Pulling any of them in here would cost exactly the purity the rest depends on. ///
/// /// It never returns a partially populated context, which is the rule the @@ -60,8 +65,11 @@ public static Result Create(TenantResolutionAttempt attempt) { ArgumentNullException.ThrowIfNull(attempt); - // Rows 13 and 15. Refusing here would turn "this host serves no tenant" into - // an error, which is what a platform host legitimately is. + // Rows 13 and 15, defensively. Callers must not arrive here with NamesNoTenant + // set — the middleware leaves those requests on UnresolvedTenantContext, + // because "this host serves no tenant" is what a platform host legitimately is + // and not an error. Refused is simply the safe answer for a caller that ignores + // that contract; it is not the path traffic takes. if (attempt.NamesNoTenant) { return Result.Fail(Refused); diff --git a/backend/tests/LearnStack.Tests.Architecture/SourceScan.cs b/backend/tests/LearnStack.Tests.Architecture/SourceScan.cs index eedb4c95..4d5134f5 100644 --- a/backend/tests/LearnStack.Tests.Architecture/SourceScan.cs +++ b/backend/tests/LearnStack.Tests.Architecture/SourceScan.cs @@ -31,7 +31,16 @@ internal static class SourceScan /// both because one is exempt is how a rule quietly stops covering half of what /// it names. /// - public static List FilesContaining(string root, string literal, string? except) + /// + /// A character the match may not be followed by. Whitespace is stripped before the + /// search, so .Current = becomes .Current= — which is a substring of + /// .Current == null, an idiom this codebase uses on Activity.Current + /// in the very middleware the rule is about. Passing '=' separates the write + /// from the comparison. This is the needle being narrowed, which is what the rule + /// asks for when a false positive appears — never the folder. + /// + public static List FilesContaining( + string root, string literal, string? except, char? notFollowedBy = null) { var needle = SourceText.WithoutWhitespace(literal); var found = new List(); @@ -55,7 +64,7 @@ public static List FilesContaining(string root, string literal, string? var code = SourceText.WithoutWhitespace( SourceText.WithoutComments(File.ReadAllText(file))); - if (code.Contains(needle, StringComparison.Ordinal)) + if (Contains(code, needle, notFollowedBy)) { found.Add(relative); } @@ -63,4 +72,26 @@ public static List FilesContaining(string root, string literal, string? return found; } + + private static bool Contains(string code, string needle, char? notFollowedBy) + { + if (notFollowedBy is null) + { + return code.Contains(needle, StringComparison.Ordinal); + } + + for (var at = code.IndexOf(needle, StringComparison.Ordinal); + at >= 0; + at = code.IndexOf(needle, at + 1, StringComparison.Ordinal)) + { + var after = at + needle.Length; + + if (after >= code.Length || code[after] != notFollowedBy) + { + return true; + } + } + + return false; + } } diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs index 140254ff..cc1858d2 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs @@ -103,8 +103,14 @@ public void SetTenant_Callers_Are_The_Enumerated_Four() // in this packet is the NEGATIVE cannot be scoped to the folder the positives // happen to live in. If a false positive ever appears, narrow the needle or // exempt the one file by path; do not re-narrow by folder. + // notFollowedBy '=' because whitespace is stripped before the search, so + // ".Current =" becomes ".Current=" — a substring of ".Current == null", which + // is how tracing code reads Activity.Current and how TenantResolverMiddleware + // itself now reads it. Reproduced: an unrelated equality check failed this rule + // with an "unauthorized tenant-context writer" message. Narrowing the needle is + // what this rule's own note prescribes; narrowing the folder is what broke it. var writers = SourceScan.FilesContaining( - SourceScan.SourceRoot, ".Current =", except: null); + SourceScan.SourceRoot, ".Current =", except: null, notFollowedBy: '='); writers.Should().BeEquivalentTo( [ @@ -149,6 +155,23 @@ public void Organizations_Are_Read_By_Composite_Key() "the announcement is what makes the policy — not the WHERE clause — the " + "thing that decides, and it must come first"); + // The read-only statement, and its position. Four documents call this a short + // READ-ONLY transaction and learnstack_app holds write grants on the table, so + // one statement is the whole of what makes the claim true — and it survived + // deletion against the entire Docker suite, because a test that reproduces the + // sequence on its own connection proves what PostgreSQL does, not what this + // file does. The Docker case still earns its place: it proves the statement has + // the effect claimed. This proves the production code issues it. + var readOnly = code.IndexOf( + SourceText.WithoutWhitespace("SET TRANSACTION READ ONLY"), StringComparison.Ordinal); + var announcement = code.IndexOf( + SourceText.WithoutWhitespace("set_config('app.tenant_id'"), StringComparison.Ordinal); + + readOnly.Should().BeGreaterThan(-1, "the transaction the corpus calls read-only must be one"); + readOnly.Should().BeLessThan(announcement, + "SET TRANSACTION READ ONLY is only legal before the transaction's first " + + "query, so it has to precede the announcement rather than follow it"); + // Leg 2 — the same rule expressed in EF, which is how the NEXT organization // read will be written. Vacuous today and deliberately kept: nothing reads // organizations through a DbContext until Step 9 writes the first command, diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OrganizationScopeValidatorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OrganizationScopeValidatorTests.cs index d5fef518..53631a00 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OrganizationScopeValidatorTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OrganizationScopeValidatorTests.cs @@ -124,6 +124,57 @@ await Build(dataSource).BelongsToTenantAsync( "the setting was transaction-local and the transaction is over"); } + [Fact] + public async Task The_Validators_Own_Statement_Sequence_Cannot_Write() + { + // Four carriers call this a "short READ-ONLY transaction" — the port's doc, the + // Standards 11 setter table, the glossary and ADR-0040 Amendment 3 — and + // learnstack_app holds INSERT/UPDATE/DELETE on organizations, so one statement + // is the whole of what makes the claim true. Measured: deleting it left all + // five other cases here green, which makes it exactly the line a later refactor + // of the shared connection boilerplate drops without noticing. + // + // WHAT THIS PROVES, AND WHAT IT DOES NOT. The validator's connection and + // transaction are private locals with no seam, so this reproduces its sequence + // rather than intercepting it — which means it establishes that the statement + // has the effect claimed, and NOT that the validator issues it. Measured: + // deleting the statement from production left this case green, which is the + // failure this packet keeps finding, so it is named here rather than left for + // the next reader to discover. The other half — that the production file issues + // it, before the announcement — is asserted by + // Organizations_Are_Read_By_Composite_Key, which already scans this file's SQL. + // Neither leg is sufficient alone. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var connection = await dataSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + + await using (var readOnly = new NpgsqlCommand( + "SET TRANSACTION READ ONLY", connection, transaction)) + { + await readOnly.ExecuteNonQueryAsync(); + } + + await using (var announce = new NpgsqlCommand( + "SELECT set_config('app.tenant_id', @tenant, true)", connection, transaction)) + { + announce.Parameters.AddWithValue("tenant", SchemaFixture.TenantA.ToString()); + await announce.ExecuteNonQueryAsync(); + } + + await using var write = new NpgsqlCommand( + "UPDATE organizations SET slug = slug WHERE tenant_id = @tenant AND id = @id", + connection, + transaction); + write.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + write.Parameters.AddWithValue("id", SchemaFixture.OrgA1); + + var act = async () => await write.ExecuteNonQueryAsync(); + + (await act.Should().ThrowAsync()).Which.SqlState + .Should().Be("25006", "read_only_sql_transaction — the announcement is a " + + "read's setup and must not also license a write"); + } + [Fact] public async Task A_Soft_Deleted_Organization_Does_Not_Belong() { diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/OrganizationScopeValidatorGuardTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/OrganizationScopeValidatorGuardTests.cs new file mode 100644 index 00000000..38c225d2 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/OrganizationScopeValidatorGuardTests.cs @@ -0,0 +1,90 @@ +using FluentAssertions; +using LearnStack.Infrastructure.MultiTenancy; +using LearnStack.SharedKernel.Identifiers; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.MultiTenancy; + +/// +/// The refusal OrganizationScopeValidator makes before it opens anything. +/// +/// +/// +/// No Docker, deliberately: the guard returns before a connection exists, so a +/// Testcontainers case could not tell it from a query that found nothing. The +/// Lazy is what proves the distinction — it throws if forced, so a test that +/// completes at all is a test where no connection was attempted. +/// +/// +/// Measured before this file existed: deleting the whole guard left all five +/// Docker-bound cases green. Without it an uninitialized Vogen id reaching +/// tenantId.Value throws ValueObjectValidationException — a 500 at the +/// request edge — where the code's own comment promises a documented false. +/// +/// +public sealed class OrganizationScopeValidatorGuardTests +{ + private static readonly TenantId Tenant = + TenantId.From(Guid.Parse("018f4d40-0000-7000-8000-00000000a001")); + + private static readonly OrganizationId Organization = + OrganizationId.From(Guid.Parse("018f4d40-0000-7000-8000-0000000000a1")); + + [Fact] + public async Task An_Uninitialized_Tenant_Is_Refused_Without_Opening_A_Connection() + { + // Reached through an array element, because the analyzer refuses `default(TId)` + // outright — which is the point: the id that gets here comes from a field + // nobody assigned, not from a literal anyone would write. + var uninitialized = new TenantId[1]; + var dataSource = Unopenable(); + + var belongs = await new OrganizationScopeValidator(dataSource) + .BelongsToTenantAsync(uninitialized[0], Organization, CancellationToken.None); + + belongs.Should().BeFalse(); + dataSource.IsValueCreated.Should().BeFalse("the refusal precedes the connection"); + } + + [Fact] + public async Task An_Uninitialized_Organization_Is_Refused_The_Same_Way() + { + var uninitialized = new OrganizationId[1]; + var dataSource = Unopenable(); + + var belongs = await new OrganizationScopeValidator(dataSource) + .BelongsToTenantAsync(Tenant, uninitialized[0], CancellationToken.None); + + belongs.Should().BeFalse(); + dataSource.IsValueCreated.Should().BeFalse(); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task An_All_Zero_Id_Is_Refused_Explicitly_Rather_Than_By_Accident( + bool zeroTenant, bool zeroOrganization) + { + // Vogen validates the SHAPE of an id, not that it names anything, so + // TenantId.From(Guid.Empty) is a legal, initialized id — measured. It would be + // fail-closed anyway, because no row carries it; the guard is what makes the + // answer "no" rather than "no, by accident", and this is what holds the code to + // the comment that says so. + var dataSource = Unopenable(); + + var belongs = await new OrganizationScopeValidator(dataSource).BelongsToTenantAsync( + zeroTenant ? TenantId.From(Guid.Empty) : Tenant, + zeroOrganization ? OrganizationId.From(Guid.Empty) : Organization, + CancellationToken.None); + + belongs.Should().BeFalse(); + dataSource.IsValueCreated.Should().BeFalse(); + } + + /// A data source whose creation is itself the failure. + private static Lazy Unopenable() => + new(() => throw new InvalidOperationException( + "The guard must refuse before anything reaches the data source.")); +} diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs index d28e1f00..8e62bbd7 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs @@ -199,6 +199,18 @@ public void Row_11_A_claim_naming_a_different_organization_of_the_same_tenant_is + "outcome let the whole term be deleted, because the membership guard " + "then caught the row for an unrelated reason"); + // The two predicates the MIDDLEWARE reads to decide whether to spend a + // membership call and a validator transaction. Create refuses this row on its + // own standalone ClaimAgreesWithHost check before either is consulted, so + // forcing their `&& ClaimAgreesWithHost` conjuncts true left the whole suite + // green — measured. What the conjuncts are actually load-bearing for is the + // port economy: a request Create will refuse anyway must not first announce an + // unvouched tenant id to PostgreSQL. + attempt.RequiresMembershipCheck.Should().BeFalse( + "a disagreeing claim buys no membership call"); + attempt.RequiresOrganizationScopeCheck.Should().BeFalse( + "nor the transaction that would announce its tenant id"); + TenantContextFactory.Create(attempt).IsSuccess.Should().BeFalse(); // And it stays refused once Phase 03 can answer both questions. This is the diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index 8c2ac2c8..9b06e374 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -620,11 +620,15 @@ this ADR actually shipped. > it makes **16** reachable for the first time — the assertion comparison shipped in > Packet 4 with nothing resolved to compare against. Row 1 is on the list because host > classification is itself Packet 7's; it is decided before a `TenantResolutionAttempt` -> exists, which is why the factory's seventeen-row suite does not cover it. Rows 6, 9 and 10 become live in **Phase -> 02b**; 7 and 14 need Phase 02b to be reachable at all and Phase 03 to stop failing -> closed. The table's own Packet 4 row draws exactly this distinction — "unreachable in -> traffic" — and the Packet 7 row did not. Nothing about what the rows *decide* changes; -> the factory implements all seventeen and Packet 7 tests them as a pure function. +> exists, which is why the factory's suite does not cover it. Rows 6, 9 and 10 become +> live in **Phase 02b**; 7 and 14 need Phase 02b to be reachable at all and Phase 03 to +> stop failing closed. The table's own Packet 4 row draws exactly this distinction — +> "unreachable in traffic" — and the Packet 7 row did not. Nothing about what the rows +> *decide* changes: `TenantContextFactory` decides the **twelve** rows expressible as a +> `TenantResolutionAttempt` — 2, 3 and 6–15 — and Packet 7 tests every one of them as a +> pure function. The other five are decided elsewhere and always will be: row 1 at host +> classification, rows 4 and 5 by an authentication outcome nothing implements yet, row +> 16 by `TenantAssertionMiddleware`, row 17 by `EventTenantContext.FromEnvelope`. > Recorded in Amendment 5. The authenticated tier is dormant before Phase 02b — there is no `UseAuthentication` to @@ -937,9 +941,13 @@ gives it a resolved value to disagree with. later reader uses to decide whether a green suite is evidence. A packet that believes it made the authenticated rows live will read `DenyAllTenantMembershipReader`'s untouched code path as proof that rows 7 and 14 fail closed, when in fact nothing can reach the -call at all. Packet 7 tests all seventeen rows as a pure function of -`TenantResolutionAttempt`, which is the honest form of that evidence and is not the same -claim. +call at all. Packet 7 tests, as a pure function of `TenantResolutionAttempt`, the twelve rows that +are expressible as one — 2, 3 and 6–15 — which is the honest form of that evidence and +is not the same claim. The remaining five are not the factory's and never will be: row 1 +is decided at host classification, rows 4 and 5 by an authentication outcome, row 16 by +`TenantAssertionMiddleware` and row 17 by `EventTenantContext.FromEnvelope`. A later +reader deciding where rows 4, 5, 16 or 17 belong should not conclude from this amendment +that the factory already has them. **Every carrier changed.** This ADR — the inline erratum beside the staging table, and this amendment. No other document reproduces the row list; diff --git a/docs/glossary.md b/docs/glossary.md index 1ea37dcb..013bcc13 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -126,7 +126,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Tenant context** | The ambient resolved tenant for a request, job, or background task. | | **Query filter** | EF Core global query filter that injects `WHERE tenant_id = @current_tenant_id` automatically. | | **`TenantContextOrigin`** | The authority ceiling on a resolved context: `HostOnly`, `HostAndClaim`, `ClaimAndMembership`, `Ambient`. A `HostOnly` context reaches only request types marked `[PublicSurface]`, which is what makes a forged host harmless — it reaches exactly the pages that hostname already serves to anyone who types it. Per [ADR-0036 § The reconciliation matrix](decisions/0036-tenant-resolution-trusted-inputs.md). | -| **`TenantResolutionAttempt`** | Every signal the reconciliation matrix may look at, gathered before any context exists: what the host mapping named, what a validated claim named, and the **answers** — never the ports — to the two questions that need a database. Carrying answers is what lets `TenantContextFactory.Create` be pure, total and synchronous, so all seventeen matrix rows are drivable from a unit test. It deliberately carries no host string, no `X-Tenant-Id` / `X-Organization-Id` and no host class. Per [ADR-0036 § The reconciliation matrix](decisions/0036-tenant-resolution-trusted-inputs.md). | +| **`TenantResolutionAttempt`** | Every signal the reconciliation matrix may look at, gathered before any context exists: what the host mapping named, what a validated claim named, and the **answers** — never the ports — to the two questions that need a database. Carrying answers is what lets `TenantContextFactory.Create` be pure, total and synchronous, so every row expressible as one — the twelve the factory decides, rows 2, 3 and 6–15 — is drivable from a unit test. It deliberately carries no host string, no `X-Tenant-Id` / `X-Organization-Id` and no host class. Per [ADR-0036 § The reconciliation matrix](decisions/0036-tenant-resolution-trusted-inputs.md). | | **`TenantContextFactory`** | The single entry point that constructs the sealed `TenantContext`: it returns `Result.Fail` on any disagreement between the signals and never a partially populated context. `TenantContext` has no public constructor; `TenantContext_Is_Constructed_Only_By_The_Factory` enforces both halves. Per [ADR-0036 § The reconciliation matrix](decisions/0036-tenant-resolution-trusted-inputs.md). | | **`IOrganizationScopeValidator`** | The reader that answers "does this organization belong to this tenant", resolving `organizations` by the composite key `(tenant_id, id)` in its own short read-only transaction that sets `app.tenant_id` as its first statement — one of the sanctioned out-of-band setters of that GUC ([Security Standards § The out-of-band setters](standards/11-security.md), per [ADR-0040 Amendment 3](decisions/0040-ambient-unit-of-work.md)). A valid organization id from another tenant is a mismatch, not an override. | | **`DenyAllTenantMembershipReader`** | The Packet 7 `ITenantMembershipReader` that denies every membership question, so the reconciliation matrix's rows 7 and 14 fail closed until [Phase 03](roadmap/phase-03-identity-admin.md) ships `Membership`. It makes the Studio tenant switcher 404 for everyone in that window; that is correct and it will look like a bug. Per [ADR-0036](decisions/0036-tenant-resolution-trusted-inputs.md). | From 4b5a610c797e8a8003425fa4ca564de764d6f842 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 14:04:32 +0300 Subject: [PATCH 18/55] feat(tenancy): enforce the authority ceiling at pipeline step 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 7 step 6. ADR-0036's ceiling stops being a decision and becomes a mechanism: a tenant context assembled from the host alone reaches only request types marked [PublicSurface], which is what makes a forged Host harmless — it reaches exactly the pages that hostname already serves to anyone who types it. Two gates, nested rather than sequential, and the shape is the whole design. Gate 1 asserts a context exists and returns either way; [AllowsUnresolvedTenantContext] exempts a request from it and from nothing else. Gate 2 is an allow-list over stated origins. Three plausible shapes are wrong in three different directions and each now fails a test. A negation — `Origin != HostOnly` — passes for null, and Origin is a nullable default interface member, so it would hand every context that never considered its authority the run of the API. Fusing the gates so the marker skips both lets an anonymous caller reach a provisioning command by typing a live tenant's hostname. Sequencing them so an unresolved context falls into the ceiling 404s precisely the rows 13 and 15 requests the marker exists to admit, because an unresolved context states no origin and the allow-list is fail-closed on null. Ambient is on the list and that is not a judgement call: EventTenantContext resolves with exactly that origin and InProcessEventBus has written it into handler scopes since Packet 5, so omitting it stops every integration-event consumer. The ceiling's refusal reuses TenantContextFactory.Refused rather than minting a second lockey_not_found. The two refusals a caller can provoke — this one, and an unresolvable host — must be byte-identical, and sharing the Error makes that a compile-time fact. It is asserted at the wire anyway, on the same path, on the raw body with only the correlation id normalized: the two responses are written by different writers, one by UseStatusCodePages and one by MVC, and a media-type spelling once made two 404s tellable apart without reading a body. TestResolvedTenantContext went red the moment the ceiling landed, which is the gate working — a resolved context that never decided its authority must reach nothing. It states HostAndClaim now. That failure is worth naming because the tempting fix is to loosen the gate, and the suite could not have told the correct implementation from the forbidden one without A_Resolved_Context_That_States_No_Origin_Reaches_Nothing. Both markers ship with no users — there is not one production request type in the solution — and all three catalogue entries say so rather than reading as coverage. PublicSurface_Requests_Are_Never_ReadSensitive is the sharpest case: its catalogued instrument was an audit-catalogue cross-check against a catalogue that arrives in Packet 9, so its Type field is corrected and it lands as set-emptiness, which turns red the day a marked type appears before the cross-check exists. What is NOT vacuous is the reverse direction — Standards 04's table may not name a type that carries no marker — and the attributes' own AttributeUsage, since reading a marker with inherit: false against Inherited = true is a silent mismatch rather than an error. Tenant_Scope_Widening_Is_Never_Set_From_Request_Input stays Registered: nothing sets app.scope, and the catalogue already says the rule becomes non-vacuous in Phase 03. 1100 green, zero skips. Four gate shapes, two parity mutations and three rule mutations were each measured. ADR: 0036 Co-Authored-By: Claude Opus 5 (1M context) --- .../Pipeline/TenantContextBehavior.cs | 118 +++++++--- .../Tenancy/ITenantContext.cs | 11 +- .../Tenancy/RequestSurfaceMarkers.cs | 68 ++++++ .../Tenancy/TenantContextFactory.cs | 11 +- .../RequestSurfaceTests.cs | 204 ++++++++++++++++++ .../AuthorityCeilingHttpTests.cs | 192 +++++++++++++++++ .../CrossCuttingFoundationHttpTests.cs | 22 +- .../Pipeline/TenantContextBehaviorTests.cs | 198 ++++++++++++++++- .../Tenancy/TenantContextFactoryTests.cs | 5 +- docs/standards/04-api-design.md | 12 +- .../21-architecture-tests-catalogue.md | 32 ++- 11 files changed, 809 insertions(+), 64 deletions(-) create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs diff --git a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs index c6732053..39a7431c 100644 --- a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs @@ -8,8 +8,14 @@ namespace LearnStack.Application.Pipeline; /// /// MediatR pipeline behavior — step 4 of the canonical 8-step order /// (ADR-0032 § Sub-decision 2). Asserts that the upstream resolution stage -/// populated ; when it has not, short-circuits -/// the request with Result.Fail(tenant_mismatch). It does not +/// populated , and that the context it produced reaches +/// as far as this request type. Two refusals with two codes: an unresolved context +/// short-circuits with Result.Fail(tenant_mismatch) unless the request carries +/// [AllowsUnresolvedTenantContext], and a resolved context whose +/// TenantContextOrigin exceeds what the request type permits short-circuits +/// with Result.Fail(not_found) — a different code because the second refusal +/// must be indistinguishable on the wire from an unresolvable host, and +/// tenant_mismatch is the authenticated code. It does not /// set the PostgreSQL RLS session variables: SET LOCAL is transaction-local and /// this behavior runs at step 4, before any transaction exists. They are issued by /// TransactionBehavior as the first statement inside the transaction at step 6 @@ -20,23 +26,28 @@ namespace LearnStack.Application.Pipeline; /// gives that setter a tenant to write. /// /// -/// Phase 02a Packet 3 ships the assertion shell. Until -/// Packet 7 lands the resolver middleware every request runs against -/// ; this behavior surfaces the fact -/// loudly so no handler reads an unresolved context by accident. Packet 7's -/// TenantResolverMiddleware writes the singleton -/// ITenantContextAccessor the injected context reads from. -/// The authority ceiling is not enforced here yet. This behavior reads only -/// , and nothing in this assembly or the API -/// reads Origin at all. Packet 7 step 6 adds the second rejection — a request -/// whose TenantContextOrigin exceeds what the request type permits, a host-only -/// context reaching only [PublicSurface] types — failing with -/// lockey_not_found so the body matches an unresolvable host's. Until then -/// ADR-0036's ceiling is a decision and not yet a mechanism, and when it lands it must -/// be an allow-list over stated origins: Origin is a nullable default -/// interface member, so Origin != HostOnly passes for null. The RLS session variables are still -/// issued by TransactionBehavior inside the transaction at step 6, never -/// from this behavior. +/// +/// The ceiling is an allow-list, and that is load-bearing rather than stylistic. +/// is a nullable default interface member, +/// so an implementation that states nothing carries null — and +/// Origin != HostOnly is true for null. Written as a negation this +/// gate would hand exactly the contexts that never thought about authority the run of +/// the API. Written as an allow-list over stated origins, an unstated one reaches +/// nothing, and so does any member added later whose ceiling nobody decided. +/// +/// +/// The two gates are nested, not sequential. The unresolved branch returns; it +/// never falls through to the ceiling. It cannot: an unresolved context states no +/// origin, so the fail-closed allow-list would refuse it — and that would 404 +/// precisely the rows 13 and 15 requests [AllowsUnresolvedTenantContext] exists +/// to admit. Fusing them the other way is worse: a marked request exempted from +/// both gates lets an anonymous caller reach a provisioning command by typing a +/// live tenant's hostname. +/// +/// +/// The RLS session variables are still issued by TransactionBehavior inside the +/// transaction at step 6, never from this behavior. +/// /// public sealed class TenantContextBehavior( ITenantContext tenantContext) @@ -47,6 +58,21 @@ public sealed class TenantContextBehavior( private static readonly Error TenantMismatchError = new( new LocalizedMessage("lockey_tenant_mismatch")); + // Read once per closed generic rather than once per request: TRequest is fixed for + // the lifetime of this type, so the reflection runs in the type initializer and + // every request pays a static field read. + // + // inherit: false, matching the attributes' own Inherited = false. Reading with + // inherit: true against a non-inherited attribute is not an error and not a + // widening — it is a silent mismatch between what the marker declares and what the + // reader looks for, and the day someone makes the attribute inheritable the reader + // would not follow. + private static readonly bool AllowsUnresolved = typeof(TRequest) + .IsDefined(typeof(AllowsUnresolvedTenantContextAttribute), inherit: false); + + private static readonly bool IsPublicSurface = typeof(TRequest) + .IsDefined(typeof(PublicSurfaceAttribute), inherit: false); + public Task Handle( TRequest request, RequestHandlerDelegate next, @@ -54,9 +80,26 @@ public Task Handle( { ArgumentNullException.ThrowIfNull(next); - if (!tenantContext.IsResolved && !AllowsUnresolvedContext(typeof(TRequest))) + // Gate 1 — the assertion. Returns either way: an unresolved context states no + // origin, so there is no ceiling to apply, and falling through to one would + // refuse the very requests the marker admits. + if (!tenantContext.IsResolved) { - return Task.FromResult(Result.FailFor(TenantMismatchError)); + return AllowsUnresolved + ? next() + : Task.FromResult(Result.FailFor(TenantMismatchError)); + } + + // Gate 2 — the authority ceiling. [AllowsUnresolvedTenantContext] is not an + // exemption from this one: a provisioning command addressed to a live tenant's + // own hostname resolves HostOnly, and refusing it there is the whole point. + if (!PermittedUnder(tenantContext.Origin)) + { + // TenantContextFactory.Refused, not a second literal. The refusal must be + // byte-identical on the wire to an unresolvable host's 404, and sharing the + // one Error makes that a compile-time fact rather than a coincidence two + // tests happen to agree on. + return Task.FromResult(Result.FailFor(TenantContextFactory.Refused)); } // Nothing to do here for RLS, and nothing left undone elsewhere: @@ -78,21 +121,26 @@ public Task Handle( } /// - /// Opt-in escape hatch for commands that are explicitly platform-wide - /// (e.g. tenant provisioning). The default is "no exceptions"; opt-in - /// arrives in Packet 7 alongside the EnterPlatformAdminScope(reason) - /// surface. Until then the predicate is a stub returning false — - /// every request needs a resolved context to proceed. + /// Whether a context carrying may reach this request. /// /// - /// TODO(2026-05-21, @platform): Phase 02a Packet 7 — replace the stub - /// with a real discriminator. The intended seam is a marker attribute - /// ([AllowsUnresolvedTenantContext]) the predicate scans for - /// via reflection, paired with an architecture test that asserts the - /// attribute lives only on the narrow command-set that legitimately - /// runs before any tenant is resolved (e.g. ProvisionTenantCommand, - /// EnterPlatformAdminScopeCommand). Documenting the seam now - /// so Packet 7 doesn't reinvent it. + /// Exhaustive by construction. HostOnly is the one origin ADR-0036 narrows, + /// and it narrows to [PublicSurface]. The three that carry an authenticated + /// principal or an envelope reach an unmarked request type; what narrows those is + /// authorization at step 5, not this gate — and Ambient in particular must be + /// admitted or every integration-event consumer stops running, because + /// EventTenantContext resolves with exactly that origin. /// - private static bool AllowsUnresolvedContext(Type requestType) => false; + private static bool PermittedUnder(TenantContextOrigin? origin) => origin switch + { + TenantContextOrigin.HostOnly => IsPublicSurface, + TenantContextOrigin.HostAndClaim => true, + TenantContextOrigin.ClaimAndMembership => true, + TenantContextOrigin.Ambient => true, + + // null — an implementation that states no origin — and any member added later + // without deciding its ceiling. Fail-closed, which is the whole reason this is + // a switch over stated values rather than a comparison against one of them. + _ => false, + }; } diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs index 6bed5f92..9b2ccfd8 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs @@ -23,7 +23,11 @@ public interface ITenantContext /// true once the resolution pipeline has populated tenant + (where /// applicable) organization. TenantContextBehavior short-circuits /// the request with Result.Fail(tenant_mismatch) when this is - /// false. + /// false, unless the request carries + /// . A context that is + /// resolved then faces the second gate — see , whose refusal + /// carries not_found rather than tenant_mismatch because it must be + /// indistinguishable from an unresolvable host. /// bool IsResolved { get; } @@ -77,8 +81,9 @@ public interface ITenantContext /// gets no authority rather than the wrong one — but that only holds if the /// consumer asks "is this origin one of the ones permitted here?". A check /// written as Origin != HostOnly passes for null and hands an - /// unstated context the run of the API. The pipeline's ceiling enforcement is - /// the consumer that matters. + /// unstated context the run of the API. TenantContextBehavior at pipeline + /// step 4 is that consumer, and it is written as a switch over stated + /// origins for exactly this reason. /// TenantContextOrigin? Origin => null; diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs b/backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs new file mode 100644 index 00000000..e53f1835 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs @@ -0,0 +1,68 @@ +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Marks a request type that legitimately runs before any tenant is resolved. +/// +/// +/// +/// A deliberate hole, and the point is that it is counted. +/// TenantContextBehavior at pipeline step 4 refuses every request whose +/// is false; this marker is the only +/// exemption, and it exists for the narrow set of tenant-provisioning and +/// platform-admin commands that have no tenant to resolve because they are what +/// creates or spans one. AllowsUnresolvedTenantContext_Only_On_Provisioning_Commands +/// holds the set: a hole nobody counts becomes a hole everybody uses. +/// +/// +/// It exempts the assertion, never the ceiling. A marked request that arrives +/// on a resolved context is subject to the authority ceiling like any other — +/// a provisioning command addressed to a live tenant's own hostname resolves +/// and is refused, which is exactly the +/// confused deputy the ceiling exists to close. Fusing the two checks so the marker +/// skips both would let an anonymous caller reach provisioning by typing a tenant's +/// hostname. +/// +/// +/// It ships with no users. The first is ProvisionTenantCommand, in +/// Packet 7 step 9 — there is not one production request type in the solution today. +/// The marker lands ahead of it because the behavior that reads it lands now, and a +/// predicate with no attribute to look for is the stub this replaces. +/// +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class AllowsUnresolvedTenantContextAttribute : Attribute; + +/// +/// Marks a request type reachable by a caller LearnStack has not authenticated. +/// +/// +/// +/// The marker is the whole of the claim under a host-only context. A tenant +/// context assembled from the host alone carries +/// and reaches only request types marked +/// here; a type without it is unreachable from HostOnly whatever its route +/// looks like. That ceiling is what makes a forged Host harmless — with it, a +/// forged host reaches exactly the pages that hostname already serves to anyone who +/// types it, and only while the mapping row is publicly live. Without it the trusted +/// hop is a confused deputy, because the edge derives its own assertion from the same +/// string the visitor chose. +/// +/// +/// It is not a permit to run without a tenant. Rows 13 and 15 of ADR-0036's +/// reconciliation matrix — a platform host, with or without a token — resolve no +/// tenant at all, and those are governed by +/// . The two markers answer +/// different questions and neither implies the other. +/// +/// +/// The set is a table, and it ships empty. Every marked type is enumerated in +/// Standards 04 § Public +/// surface with its permitted methods — the default is GET/HEAD, +/// a mutating entry states why, no marked type performs a tenant-owned write, and +/// none may be classified MUST-class read-sensitive, which would turn an +/// anonymous GET into a durable standalone audit write. The first rows arrive +/// with Phase 02d's two anonymous read endpoints. +/// +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class PublicSurfaceAttribute : Attribute; diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs index 4bc65668..4a1e372f 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs @@ -39,13 +39,22 @@ namespace LearnStack.SharedKernel.Tenancy; /// public static class TenantContextFactory { - /// The one refusal. Deliberately the same for every failing row. + /// + /// The one refusal — for every failing row, and for the pipeline's ceiling. + /// /// /// One and not one per row: a caller who could tell row 8 /// (a tenant that exists, claimed by a token for another) from row 10 (an /// organization no membership covers) would have an oracle over which tenants /// and organizations exist. The distinction that matters to an operator is /// carried by the middleware's log line, not by the response. + /// + /// Shared with TenantContextBehavior's authority-ceiling gate, which + /// is not a row of the matrix. The coupling is deliberate and it is to the wire + /// result rather than to the matrix: both refusals must be byte-identical to an + /// unresolvable host's 404, and one makes that a + /// compile-time fact instead of two tests agreeing by coincidence. + /// /// public static Error Refused { get; } = new(new LocalizedMessage("lockey_not_found")); diff --git a/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs new file mode 100644 index 00000000..541bbcc5 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs @@ -0,0 +1,204 @@ +using System.Reflection; +using FluentAssertions; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// +/// What the step-4 authority ceiling admits: which request types may run without a +/// tenant, and which may be reached by a caller LearnStack has not authenticated. +/// +/// +/// +/// Both markers are deliberate holes in a control, and the value of each rule is that +/// it counts its hole. A hole nobody counts becomes a hole everybody uses. +/// +/// +/// They are vacuous today, and the vacuity is real rather than a formality. +/// There is not one production request type in the solution — +/// ProvisionTenantCommand arrives in Packet 7 step 9 and the first +/// [PublicSurface] types in Phase 02d — so the marked sets are empty and the +/// set-membership legs pass over nothing. What is not vacuous is the reverse +/// direction: the enumerated table in Standards 04 must not name a type that carries +/// no marker, and both attributes must keep the shape the pipeline reads them with. +/// Each leg below says which of the two it is. +/// +/// +public sealed class RequestSurfaceTests +{ + [Fact] + public void AllowsUnresolvedTenantContext_Only_On_Provisioning_Commands() + { + // Leg 1 — the set, vacuous today. Named provisioning and platform-admin + // commands only. The allow-list is a literal rather than a pattern on purpose: + // "any command whose name ends in ProvisionCommand" is a rule an author + // satisfies by naming, which is not a decision anybody reviewed. + var marked = RequestTypes() + .Where(type => type.IsDefined(typeof(AllowsUnresolvedTenantContextAttribute), inherit: false)) + .Select(type => type.Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + marked.Should().BeEquivalentTo( + PermittedUnresolved, + "the marker exempts a request from the tenant-context assertion at pipeline " + + "step 4, and the whole value of the set is that adding to it is an edit " + + "someone reviews"); + + // Leg 2 — live now. The behavior reads the attribute with inherit: false, so a + // change to Inherited = true would silently stop the reader following it: not + // an error, not a widening, just a marker the pipeline no longer sees. + AttributeShape(typeof(AllowsUnresolvedTenantContextAttribute)) + .Should().Be((AttributeTargets.Class, false, false)); + } + + [Fact] + public void PublicSurface_Marker_Set_Is_Enumerated() + { + // Leg 1 — vacuous today: every marked type appears in the Standards 04 table. + var marked = RequestTypes() + .Where(type => type.IsDefined(typeof(PublicSurfaceAttribute), inherit: false)) + .Select(type => type.Name) + .ToList(); + + var enumerated = EnumeratedPublicSurface(); + + marked.Should().BeSubsetOf(enumerated, + "a type reachable anonymously that the table does not name is a public " + + "endpoint nobody reviewed"); + + // Leg 2 — LIVE, and the half that is not vacuous. The table may not name a + // type that carries no marker: an entry there reads as a reviewed decision, + // and one with no attribute behind it is a decision the pipeline never + // enforces. It ships empty, so this asserts emptiness — and stops being an + // assertion about nothing the moment Phase 02d writes the first row. + enumerated.Should().BeSubsetOf(marked, + "the table is the enumeration of what carries the marker, not a wish list"); + + // Leg 3 — live. Same shape guard as its sibling. + AttributeShape(typeof(PublicSurfaceAttribute)) + .Should().Be((AttributeTargets.Class, false, false)); + } + + [Fact] + public void PublicSurface_Requests_Are_Never_ReadSensitive() + { + // Vacuous on BOTH sides today, and that is stated in the catalogue rather than + // left for a reader to infer from a green run: the marked set is empty, and + // there is no audit catalogue in code to classify anything against — IAuditStore + // and the operation catalogue arrive in Packet 9. What this can assert now is + // the emptiness that makes the claim trivially true, so that the day a marked + // type appears without the cross-check existing, this rule is the thing that + // has to be revisited rather than the thing that quietly passed. + var marked = RequestTypes() + .Where(type => type.IsDefined(typeof(PublicSurfaceAttribute), inherit: false)) + .ToList(); + + marked.Should().BeEmpty( + "no [PublicSurface] type may be MUST-class read-sensitive — an anonymous " + + "GET would become a durable standalone audit write — and until Packet 9 " + + "ships the audit catalogue there is nothing to check that against, so a " + + "marked type arriving before it is a decision this rule must be told about"); + } + + /// + /// The literal set of request types permitted to run before a tenant is resolved. + /// + /// + /// Empty, because no production request type exists yet. ProvisionTenantCommand + /// is the first, in Packet 7 step 9, per + /// ADR-0042. + /// + private static readonly string[] PermittedUnresolved = []; + + /// + /// The request types named in + /// Standards 04 § Public surface. + /// + /// + /// Reads the table's data rows rather than parsing Markdown generally: the section + /// holds one table under a fixed heading, and a general parser for a set the corpus + /// says ships empty would be more machinery than the rule it serves. + /// + private static List EnumeratedPublicSurface() + { + var path = Path.Combine(RepositoryPaths.RepoRoot(), "docs", "standards", "04-api-design.md"); + var lines = File.ReadAllLines(path); + + var start = Array.FindIndex(lines, line => + line.StartsWith("### Public surface", StringComparison.Ordinal)); + start.Should().BeGreaterThan(-1, "the section this rule reads must exist"); + + var header = Array.FindIndex(lines, start, line => + line.StartsWith("| Request type", StringComparison.Ordinal)); + header.Should().BeGreaterThan(-1, "the enumeration is a table with a named first column"); + + var rows = new List(); + + // Skip the header and its separator; stop at the first line that is not a row. + for (var at = header + 2; at < lines.Length && lines[at].StartsWith('|'); at++) + { + var cell = lines[at].Split('|', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault()?.Trim().Trim('`'); + + if (!string.IsNullOrWhiteSpace(cell)) + { + rows.Add(cell); + } + } + + return rows; + } + + /// + /// Every concrete MediatR request type in the production assemblies. + /// + /// + /// Fails loudly on an assembly it cannot load, rather than dropping it. The + /// shipped precedent filters unloadable assemblies away, which turns "I could not + /// read this code" into "this code is clean" — the one failure mode a rule counting + /// a security hole cannot afford. + /// + private static List RequestTypes() + { + var types = new List(); + + foreach (var name in ProductionAssemblies) + { + var assembly = Assembly.Load(name); + + types.AddRange(assembly.GetTypes() + .Where(type => type is { IsAbstract: false, IsInterface: false }) + .Where(type => type.GetInterfaces().Any(contract => + contract == typeof(IBaseRequest) + || (contract.IsGenericType + && contract.GetGenericTypeDefinition() == typeof(IRequest<>))))); + } + + return types; + } + + private static (AttributeTargets Targets, bool Inherited, bool AllowMultiple) AttributeShape( + Type attribute) + { + var usage = attribute.GetCustomAttribute(); + usage.Should().NotBeNull($"{attribute.Name} must declare its usage explicitly"); + + return (usage!.ValidOn, usage.Inherited, usage.AllowMultiple); + } + + private static readonly string[] ProductionAssemblies = + [ + "LearnStack.Application", + "LearnStack.SharedKernel", + "LearnStack.Modules.Tenancy.Application", + "LearnStack.Modules.Identity.Application", + "LearnStack.Modules.Customization.Application", + "LearnStack.Modules.Audit.Application", + "LearnStack.Modules.Content.Application", + "LearnStack.Modules.Media.Application", + "LearnStack.Modules.Education.Application", + ]; +} diff --git a/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs new file mode 100644 index 00000000..7e28767a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs @@ -0,0 +1,192 @@ +using System.Net; +using System.Text.RegularExpressions; +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace LearnStack.Tests.Integration; + +/// +/// The authority ceiling through the real pipeline, and the property that makes it +/// safe to have one: its refusal is indistinguishable from an unresolvable host's. +/// +/// +/// +/// No Docker. A step-4 refusal short-circuits before TransactionBehavior opens +/// anything at step 6, and the resolver is stubbed — what is under test is the +/// pipeline's decision and the bytes it produces, neither of which touches PostgreSQL. +/// +/// +/// Why the parity matters more than the refusal. A caller who can tell "this +/// tenant exists but you may not reach this" from "no such host" has an oracle over +/// which hostnames are live. The two refusals travel completely different routes — one +/// is a bodyless status filled in by UseStatusCodePages, the other an MVC +/// ObjectResult serialized by the framework — so their agreement is a property +/// to measure at the wire, not to derive from HttpStatusMap. +/// +/// +public sealed class AuthorityCeilingHttpTests(AuthorityCeilingFixture fixture) + : IClassFixture +{ + private readonly HttpClient _client = fixture.CreateClient(); + + [Fact] + public async Task A_PublicSurface_Request_Is_Reachable_Anonymously() + { + // Matrix rows 2 and 3 doing their job: the host named the tenant, nothing else + // spoke, and the marked request type is exactly what that reaches. + var response = await SendAsync("/api/v1/ceilingprobe/public"); + + var body = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK, body); + body.Should().Contain("reached"); + } + + [Fact] + public async Task An_Unmarked_Request_Is_Refused_Under_A_Host_Only_Context() + { + var response = await SendAsync("/api/v1/ceilingprobe/guarded"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound, + "a host-only context reaches only [PublicSurface] request types"); + } + + [Fact] + public async Task The_Ceiling_Refusal_Is_Indistinguishable_From_An_Unknown_Host() + { + // The SAME path both times, so `instance` cannot account for a difference: one + // request is refused because its host resolves to nothing, the other because + // the tenant its host named may not reach this request type. Only the + // correlation id may differ — it is per-request by design and is echoed in a + // header the caller already holds, so it carries no fact about either refusal. + const string Path = "/api/v1/ceilingprobe/guarded"; + + var ceiling = await SendAsync(Path); + var unknownHost = await SendAsync(Path, host: "stranger.example.com"); + + ceiling.StatusCode.Should().Be(unknownHost.StatusCode); + + // The full media type, charset included. Two spellings of it — one with + // `; charset=utf-8` and one without — once made a routing 404 tellable from an + // MVC 404 without reading the body at all, and this pair crosses exactly that + // boundary: one response is written by UseStatusCodePages, the other by MVC. + ceiling.Content.Headers.ContentType?.ToString() + .Should().Be(unknownHost.Content.Headers.ContentType?.ToString()); + + WithoutCorrelation(await ceiling.Content.ReadAsStringAsync()) + .Should().Be(WithoutCorrelation(await unknownHost.Content.ReadAsStringAsync()), + "the raw body, not a reparsed shape — property order and escaping are " + + "as tellable as a field, and the two bodies are serialized by " + + "different writers"); + } + + private async Task SendAsync( + string path, string host = AuthorityCeilingFixture.TenantHost) + { + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(path, UriKind.Relative)); + request.Headers.Host = host; + return await _client.SendAsync(request); + } + + private static string WithoutCorrelation(string body) => + Regex.Replace(body, "\"correlationId\":\"[^\"]*\"", "\"correlationId\":\"\""); +} + +/// A host whose resolver maps one tenant host and nothing else. +public sealed class AuthorityCeilingFixture : WebApplicationFactory +{ + public const string TenantHost = "school.example.com"; + + public static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-0000000000d1"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.UseEnvironment(Environments.Development); + builder.ConfigureTestServices(services => + { + services.AddControllers(options => + options.Conventions.Insert(0, new TestControllerFilter( + typeof(CeilingProbeController)))) + .AddApplicationPart(typeof(CeilingProbeController).Assembly); + + // Registered by hand rather than by re-running AddMediatR, which would + // double-register every behavior in the eight-step pipeline — the + // precedent CrossCuttingFoundationHttpTests set for the same reason. + services.AddTransient< + IRequestHandler>, CeilingGuardedHandler>(); + services.AddTransient< + IRequestHandler>, CeilingPublicHandler>(); + + services.RemoveAll(); + services.AddSingleton(new OneHostResolver()); + + // TransactionBehavior opens a real transaction on every request that + // reaches step 6, and this host has no database — the ceiling refusal + // never gets that far, but the [PublicSurface] request does, and it is + // the one that proves the marker admits rather than merely not-refuses. + // Same seam CrossCuttingFoundationHttpTests uses, for the same reason. + services.RemoveAll(); + services.AddScoped(); + }); + } + + private sealed class OneHostResolver : IHostToTenantResolver + { + public Task ResolveAsync( + string host, CancellationToken cancellationToken = default) => + Task.FromResult(host == TenantHost + ? new HostResolution(TenantId.From(Tenant), null) + : null); + } +} + +/// Two request types that differ only in whether they are marked. +public sealed record CeilingGuardedQuery : IRequest>; + +/// The same query, reachable from a host-only context. +[PublicSurface] +public sealed record CeilingPublicQuery : IRequest>; + +internal sealed class CeilingGuardedHandler : IRequestHandler> +{ + public Task> Handle(CeilingGuardedQuery request, CancellationToken cancellationToken) => + Task.FromResult(Result.Ok("reached")); +} + +internal sealed class CeilingPublicHandler : IRequestHandler> +{ + public Task> Handle(CeilingPublicQuery request, CancellationToken cancellationToken) => + Task.FromResult(Result.Ok("reached")); +} + +/// Drives the two request types through the real pipeline. +/// +/// Test-only and registered by the fixture: no production /api/v1 endpoint +/// ships in this packet, and the first real read endpoints are Phase 02d's. +/// +[Route("ceilingprobe")] +[ApiExplorerSettings(IgnoreApi = true)] +public sealed class CeilingProbeController(IMediator mediator) : ApiControllerBase, ITestOnlyController +{ + [HttpGet("guarded")] + public async Task Guarded(CancellationToken cancellationToken) => + (await mediator.Send(new CeilingGuardedQuery(), cancellationToken)).ToActionResult(); + + [HttpGet("public")] + public async Task Public(CancellationToken cancellationToken) => + (await mediator.Send(new CeilingPublicQuery(), cancellationToken)).ToActionResult(); +} diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index 6170120c..7c80d7b5 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -227,12 +227,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) TestValidationHandler>(); services.AddTransient, TestValidationValidator>(); - // TenantContextBehavior short-circuits when ITenantContext is - // not resolved. Until Packet 7 lands TenantResolverMiddleware, - // production has no way to flip IsResolved → true. For the - // integration test we replace the scoped registration with a - // fixed test tenant so MediatR's pipeline reaches the inner - // handler. + // TenantContextBehavior short-circuits when ITenantContext is not + // resolved, and again when the resolved context's origin does not reach + // the request type. Production resolves a real one through + // TenantResolverMiddleware from a host mapping; these cases have no host + // to map, so the scoped registration is replaced with a fixed test tenant + // that states an authenticated origin, and MediatR's pipeline reaches the + // inner handler. services.RemoveAll(); services.AddScoped(_ => TestResolvedTenantContext.Instance); @@ -321,6 +322,15 @@ internal sealed class TestResolvedTenantContext : ITenantContext public UserId? UserId { get; } public string? CorrelationId => null; public string? ModuleName => "integration-test"; + + // Stated, because the step-4 ceiling is an allow-list and an unstated origin + // reaches nothing. This double went red the moment the ceiling landed, which is + // the gate working: a resolved context that never decided its authority must not + // be handed the run of the API. HostAndClaim is what an authenticated request + // carries, which is what these cases are standing in for — not HostOnly, which + // would reach only [PublicSurface] types and is a narrower claim than any of them + // makes. + public TenantContextOrigin? Origin => TenantContextOrigin.HostAndClaim; } [Route("test")] diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs index e70dda0d..c62ad71b 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using LearnStack.Application.Pipeline; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using MediatR; @@ -8,32 +9,209 @@ namespace LearnStack.Tests.Unit.Application.Pipeline; /// -/// TenantContextBehavior shell contract — short-circuits with -/// Result.Fail(tenant_mismatch) when the resolution stage has not -/// populated ITenantContext. Until Packet 7 lands the resolver this is the -/// loud-fail guard for any handler executed without context. +/// Pipeline step 4's two gates: is there a tenant context, and does it reach this +/// request type. /// +/// +/// +/// The second gate is +/// ADR-0036's +/// authority ceiling, and it is the control that makes a forged Host harmless: +/// with it, a forged host reaches exactly the pages that hostname already serves to +/// anyone who types it. Everything below is about the shape of those two gates, +/// because three plausible shapes are wrong and each is wrong in a different +/// direction. +/// +/// +/// Driven against the behavior directly. The gates are a function of the injected +/// context and the request type, both of which a unit test controls exactly; routing +/// them through a host would add a resolver and a database to observe a decision that +/// touches neither. +/// +/// public sealed class TenantContextBehaviorTests { public sealed record DummyCommand : IRequest>; + [AllowsUnresolvedTenantContext] + public sealed record ProvisioningShapedCommand : IRequest>; + + [PublicSurface] + public sealed record AnonymousReadShapedQuery : IRequest>; + + // ---- gate 1: is there a context at all ----------------------------------- + [Fact] public async Task Short_Circuits_When_Context_Unresolved() { - var behavior = new TenantContextBehavior>( + var (result, called) = await RunAsync(UnresolvedTenantContext.Instance); + + called.Should().BeFalse(); + result.IsFailure.Should().BeTrue(); + result.Error!.Code.Should().Be("tenant_mismatch"); + } + + [Fact] + public async Task Unresolved_Context_Runs_A_Marked_Request() + { + // Rows 13 and 15: a platform host resolves no tenant, and the narrow set of + // provisioning and platform-admin commands is what may still run there. + var (result, called) = await RunAsync( UnresolvedTenantContext.Instance); + called.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task PublicSurface_Is_Not_A_Permit_To_Run_Unresolved() + { + // The two markers answer different questions and neither implies the other. + // [PublicSurface] says which origins may reach a type; it says nothing about + // running with no tenant at all. + var (result, called) = await RunAsync( + UnresolvedTenantContext.Instance); + + called.Should().BeFalse(); + result.Error!.Code.Should().Be("tenant_mismatch"); + } + + // ---- gate 2: does the context reach this request ------------------------- + + [Fact] + public async Task HostOnly_Reaches_A_PublicSurface_Request() + { + var (result, called) = await RunAsync( + Resolved(TenantContextOrigin.HostOnly)); + + called.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task HostOnly_Is_Refused_On_An_Unmarked_Request() + { + // The ceiling itself. An anonymous page load carries a real, resolved tenant + // context — the host named it — and that context must reach only the pages the + // hostname already serves. + var (result, called) = await RunAsync( + Resolved(TenantContextOrigin.HostOnly)); + + called.Should().BeFalse(); + result.Error!.Code.Should().Be("not_found", + "the refusal must be indistinguishable from an unresolvable host, and " + + "tenant_mismatch is the authenticated code"); + result.Error.Should().BeSameAs(TenantContextFactory.Refused, + "one Error for one wire-visible refusal makes the parity a compile-time " + + "fact rather than two tests agreeing by coincidence"); + } + + [Fact] + public async Task AllowsUnresolved_Is_Not_An_Exemption_From_The_Ceiling() + { + // The wrong shape this kills: a single fused gate where the marker skips both + // checks. A provisioning command addressed to a live tenant's own hostname + // resolves HostOnly, and under the fused shape an anonymous caller who typed + // that hostname reaches provisioning. + var (result, called) = await RunAsync( + Resolved(TenantContextOrigin.HostOnly)); + + called.Should().BeFalse(); + result.Error!.Code.Should().Be("not_found"); + } + + [Theory] + [InlineData(TenantContextOrigin.HostAndClaim)] + [InlineData(TenantContextOrigin.ClaimAndMembership)] + [InlineData(TenantContextOrigin.Ambient)] + public async Task Authenticated_And_Ambient_Origins_Reach_An_Unmarked_Request( + TenantContextOrigin origin) + { + // ADR-0036 narrows exactly one origin. What narrows an authenticated caller + // further is authorization at step 5, not this gate — and Ambient is not a + // judgement call at all: EventTenantContext resolves with exactly that origin, + // so omitting it stops every integration-event consumer. + var (result, called) = await RunAsync(Resolved(origin)); + + called.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task A_Resolved_Context_That_States_No_Origin_Reaches_Nothing() + { + // The case that separates the allow-list from the negation, and the only one + // that can: Origin is a nullable default interface member, so `Origin != + // HostOnly` is true for null and every other test here would still pass. + var (result, called) = await RunAsync(new OriginlessContext()); + + called.Should().BeFalse(); + result.Error!.Code.Should().Be("not_found"); + } + + [Fact] + public async Task An_Unstated_Origin_Is_Refused_Even_On_A_Marked_Request() + { + // Neither marker rescues it. [AllowsUnresolvedTenantContext] governs gate 1, + // which this context passes — it claims to be resolved — and [PublicSurface] + // admits HostOnly, which is not what null is. + (await RunAsync(new OriginlessContext())).Called + .Should().BeFalse(); + (await RunAsync(new OriginlessContext())).Called + .Should().BeFalse(); + } + + private static async Task<(Result Result, bool Called)> RunAsync( + ITenantContext context) + where TRequest : IRequest>, new() + { + var behavior = new TenantContextBehavior>(context); var called = false; + RequestHandlerDelegate> next = () => { called = true; - return Task.FromResult(Result.Ok("should not run")); + return Task.FromResult(Result.Ok("ran")); }; - var result = await behavior.Handle(new DummyCommand(), next, default); + var result = await behavior.Handle(new TRequest(), next, CancellationToken.None); + return (result, called); + } - called.Should().BeFalse(); - result.IsFailure.Should().BeTrue(); - result.Error!.Code.Should().Be("tenant_mismatch"); + private static StatedOriginContext Resolved(TenantContextOrigin origin) => new(origin); + + private sealed class StatedOriginContext(TenantContextOrigin origin) : ITenantContext + { + public bool IsResolved => true; + + public TenantId TenantId { get; } = + TenantId.From(Guid.Parse("018f4d40-0000-7000-8000-00000000a001")); + + public OrganizationId? OrganizationId => null; + + public UserId? UserId => null; + + public TenantContextOrigin? Origin => origin; + + public string? CorrelationId => null; + + public string? ModuleName => null; + } + + /// A resolved context that never restated Origin. + private sealed class OriginlessContext : ITenantContext + { + public bool IsResolved => true; + + public TenantId TenantId { get; } = + TenantId.From(Guid.Parse("018f4d40-0000-7000-8000-00000000a001")); + + public OrganizationId? OrganizationId => null; + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => null; } } diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs index 8e62bbd7..8da306d2 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs @@ -442,8 +442,9 @@ public void An_Implementation_That_States_No_Origin_Carries_None() // The default is null, and null is fail-closed ONLY under an allow-list. The // pipeline's ceiling check must ask "is this origin one of the ones permitted // here?" — written as `Origin != HostOnly` it passes for null and hands an - // unstated context the run of the API. That obligation belongs to the step - // that writes the check; this pins the value it will be reading. + // unstated context the run of the API. That check now exists; + // TenantContextBehaviorTests.A_Resolved_Context_That_States_No_Origin_Reaches_Nothing + // is what holds it to the allow-list form, and this pins the value it reads. ITenantContext silent = new OriginlessContext(); silent.Origin.Should().BeNull(); diff --git a/docs/standards/04-api-design.md b/docs/standards/04-api-design.md index 52ebfc5d..87079415 100644 --- a/docs/standards/04-api-design.md +++ b/docs/standards/04-api-design.md @@ -178,8 +178,16 @@ The set is this table and nothing else: Its first rows arrive with [Phase 02d](../roadmap/phase-02d-walking-skeleton.md)'s two anonymous read endpoints. Until then `PublicSurface_Marker_Set_Is_Enumerated` and `PublicSurface_Requests_Are_Never_ReadSensitive` -([21-architecture-tests-catalogue.md](21-architecture-tests-catalogue.md)) are vacuously -green over an empty set — the honest state of a marker no request type carries yet. +([21-architecture-tests-catalogue.md](21-architecture-tests-catalogue.md)) pass over an +empty set — the honest state of a marker no request type carries yet. + +**Adding a row here is half of an edit.** The rule reads this table in both directions, +so a row naming a request type that does not carry `[PublicSurface]` fails the build: +an entry here reads as a reviewed decision, and one with no attribute behind it is a +decision the pipeline never enforces. `PublicSurface_Requests_Are_Never_ReadSensitive` +is the one to watch when the first row lands — its audit-catalogue cross-check needs +`IAuditStore`, which arrives in Packet 9, so until then it asserts only that the set is +empty and a row landing before that is what forces the question. ## Pagination diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 91709e3f..cb82f217 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -934,8 +934,16 @@ first two rows are coverage checks; the last three are the proof. - **Source:** ADR-0003; ADR-0032 § Sub-decision 2; [02-backend-coding.md § Pipeline Behaviors](02-backend-coding.md). - **Type:** xUnit + reflection over `IRequest<>` implementations. **Kind:** structural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`RequestSurfaceTests`, Packet 7 step 6). - **Phase:** 02a (Packet 7). +- **Note:** the set leg is **vacuous today** and the shape leg is not. There is not one + production request type in the solution, so the permitted set is literally empty; + `ProvisionTenantCommand` is the first to carry the marker, in Packet 7 step 9. What runs now + is the guard on the attribute's own `AttributeUsage`: the behavior reads it with + `inherit: false`, and flipping the attribute to `Inherited = true` is not an error and not a + widening — it is a marker the pipeline silently stops following. +- **Note:** the permitted set is a **literal list of type names**, not a naming pattern. A rule + satisfied by what an author calls a class is a rule nobody reviewed. #### `TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA` @@ -2082,8 +2090,15 @@ structural test proves — and what it does not. - **Source:** ADR-0036 § The reconciliation matrix; [Standards 04 § Public surface](04-api-design.md). - **Type:** xUnit + reflection. **Kind:** structural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`RequestSurfaceTests`, Packet 7 step 6). - **Phase:** 02a Packet 7. +- **Note:** the two directions are not equally vacuous, and the existing note above covers + only one of them. **Marked set → table** is vacuous while no type carries the marker. + **Table → marked set** is live from the day it ships: the table may not name a type that + carries no attribute, because an entry there reads as a reviewed decision and one with + nothing behind it is a decision the pipeline never enforces. The table ships empty, so that + leg asserts emptiness — and becomes an assertion about something the moment Phase 02d + writes its first row. - **Note:** the set ships **empty** in Packet 7, which registers no `[PublicSurface]` request type, and takes its first rows in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). The rule is vacuously green @@ -2094,9 +2109,16 @@ structural test proves — and what it does not. - **Asserts:** no `[PublicSurface]` request type is classified MUST-class `read-sensitive`. Otherwise an anonymous `GET` becomes a durable standalone audit write. - **Source:** ADR-0036 § The reconciliation matrix; [Standards 04 § Public surface](04-api-design.md). -- **Type:** xUnit + audit-catalogue cross-check. **Kind:** structural. -- **Status:** **Registered.** -- **Phase:** 02a Packet 7. +- **Type:** xUnit + reflection (set-emptiness); the audit-catalogue cross-check from Packet 9. **Kind:** structural. +- **Status:** **Implemented** (`RequestSurfaceTests`, Packet 7 step 6) — as set-emptiness only. +- **Phase:** 02a Packet 7; the cross-check leg, Packet 9. +- **Note:** **vacuous on both sides today, and the Type field above said otherwise.** The + catalogued instrument was an audit-catalogue cross-check against a catalogue that does not + exist in code — `IAuditStore` and the operation catalogue are Packet 9 — so the leg that + runs is the emptiness of the marked set, which makes the claim trivially true rather than + checked. It is landed rather than deferred so that a marked type arriving before Packet 9 + turns this rule red and forces the question, instead of passing quietly under a rule whose + stated instrument was never built. #### `Organizations_Are_Read_By_Composite_Key` From ae161815ac15e98bb9334e0bb6baae58c782851f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 14:49:27 +0300 Subject: [PATCH 19/55] fix(tenancy): sweep the code the markers will live in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Step 6 review found the counting apparatus scoped to the wrong place, and it is the same failure the previous step's review found one commit earlier: a rule whose whole job is the negative cannot be scoped to where its positives happen to live. RequestSurfaceTests listed nine assemblies. Measured: an identically marked request type fails all three rules from LearnStack.Modules.Tenancy.Application and passes all three from LearnStack.Modules.Tenancy.Application.Contracts — which is where add-mediatr-handler tells an author to put a command record, and therefore where ProvisionTenantCommand, the first type to carry either marker, is scheduled to land in step 9. TenantContextBehavior reads both markers off typeof(TRequest) and knows nothing about assembly lists, so the pipeline would have granted the widest surface it can grant while the rule that exists to count that grant reported clean. The sweep is derived from the tree now, loads every project without a null filter, and has a leg asserting its own completeness — a narrowed sweep does not fail, it passes over less code. Two MediatR shapes were invisible to every rule and run with no pipeline at all. Measured against 12.4.1: IStreamRequest has no interfaces and is not assignable to IBaseRequest, so the request filter never saw it; and typeof(IRequestHandler<>) has no interfaces either — the void handler does not derive from IRequestHandler — while Unit does not implement IResultBase, which every behavior here is constrained on. So a stream or a void request reaches its handler with no authority ceiling, no validation, no audit classification and no TransactionBehavior, hence no SET LOCAL app.tenant_id. Row Level Security keeps EF reads fail-closed; what is exposed is every effect that is not an EF read. Requests_Are_Never_Streamed bans the request shape and Handlers_Return_Result now rejects both handler shapes. Handlers_Return_Result was also dropping assemblies it could not load, which turns "I could not read this code" into "this code is clean". It loads them. One test isolation defect, mine: AuthorityCeilingHttpTests refuses a host in its parity case, and learnstack_host_classification_rejected_total is process-wide, so a MeterListener asserting an exact count raced it. Green alone, green paired, red in a full parallel run — the shape of a race. The two suites share a collection now rather than the count being loosened to "at least one", which would have kept the suite green by asserting less. Four consecutive full runs green. 1105 green, zero skips. Co-Authored-By: Claude Opus 5 (1M context) --- .../CrossCuttingFoundationTests.cs | 48 +++++- .../RequestSurfaceTests.cs | 137 +++++++++++++++--- .../AuthorityCeilingHttpTests.cs | 19 +++ .../HostClassificationHttpTests.cs | 1 + .../21-architecture-tests-catalogue.md | 10 ++ 5 files changed, 186 insertions(+), 29 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 76831b02..72f498ce 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -270,11 +270,19 @@ public void Handlers_Return_Result() // now, while the pipeline contract is fresh. Vacuous today (no // handlers yet); active the moment they land. Standards 02 § MediatR // Use Cases (review-4 M1). - var applicationAssemblies = ModuleAssemblyShapes - .Where(n => n.EndsWith(".Application", StringComparison.Ordinal)) - .Append("LearnStack.Application") - .Select(TryLoadAssembly) - .Where(a => a is not null) + // Every LearnStack.* project under backend/src, derived rather than listed. + // A handler is wherever someone puts it, and the sibling rule in + // RequestSurfaceTests was measured passing a marked request type that sat in + // Application.Contracts — which is exactly where add-mediatr-handler tells an + // author to put one. Loaded without a null filter: an assembly this project + // cannot load is a missing ProjectReference, and dropping it turns "I could not + // read this code" into "this code is clean". + var applicationAssemblies = Directory + .EnumerateFiles( + RepositoryPaths.BackendSrc(), "LearnStack.*.csproj", SearchOption.AllDirectories) + .Select(Path.GetFileNameWithoutExtension) + .Where(name => !string.IsNullOrEmpty(name)) + .Select(name => Assembly.Load(name!)) .ToArray(); foreach (var assembly in applicationAssemblies) @@ -288,8 +296,34 @@ public void Handlers_Return_Result() foreach (var contract in type.GetInterfaces()) { - if (!contract.IsGenericType - || contract.GetGenericTypeDefinition() != typeof(IRequestHandler<,>)) + if (!contract.IsGenericType) + { + continue; + } + + var definition = contract.GetGenericTypeDefinition(); + + // The two shapes the arity-2 check below cannot see, both of which + // run with ZERO behaviors. Measured against MediatR 12.4.1: + // typeof(IRequestHandler<>).GetInterfaces() is empty — the void + // handler does not derive from IRequestHandler — and Unit + // does not implement IResultBase, so MediatR builds a chain of + // IPipelineBehavior and every LearnStack behavior, + // constrained on IResultBase, is excluded from it. No authority + // ceiling, no validation, no audit classification, and no + // TransactionBehavior — so no SET LOCAL app.tenant_id either. + definition.Should().NotBe(typeof(IRequestHandler<>), + $"{type.FullName} handles a void request, which MediatR runs with " + + "no pipeline at all. Declare it IRequest> instead. " + + "Standards 02 § MediatR Use Cases."); + + definition.Should().NotBe(typeof(IStreamRequestHandler<,>), + $"{type.FullName} handles a stream request, which MediatR routes " + + "through IStreamPipelineBehavior<,> — of which this solution " + + "registers none, deliberately. Requests_Are_Never_Streamed bans " + + "the shape; this is the handler half of the same ban."); + + if (definition != typeof(IRequestHandler<,>)) { continue; } diff --git a/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs index 541bbcc5..7b010486 100644 --- a/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs @@ -103,6 +103,70 @@ public void PublicSurface_Requests_Are_Never_ReadSensitive() + "marked type arriving before it is a decision this rule must be told about"); } + [Fact] + public void Requests_Are_Never_Streamed() + { + // MediatR dispatches a stream request through IStreamPipelineBehavior<,>, and + // this solution registers none — CanonicalBehaviorOrder registers only + // IPipelineBehavior<,>. So a stream request reaches its handler with no + // authority ceiling, no validation, no audit classification and no + // TransactionBehavior, which means no SET LOCAL app.tenant_id. Row Level + // Security keeps EF reads fail-closed, so the exposure is every effect that is + // not an EF read: the outbox, the cache, provider adapters — each under a + // context nothing checked. + // + // Banned rather than supported because nothing needs it and the alternative is + // a second parallel pipeline. Zero stream usage exists today, which is exactly + // what makes the ban cheap now and expensive later. + var streamed = RequestTypes() + .Where(type => type.GetInterfaces().Any(contract => + contract.IsGenericType + && contract.GetGenericTypeDefinition() == typeof(IStreamRequest<>))) + .Select(type => type.FullName!) + .ToList(); + + streamed.Should().BeEmpty( + "a stream request runs with no pipeline behaviors at all — declare it " + + "IRequest> and page with the cursor grammar instead"); + } + + [Fact] + public void The_Sweep_Covers_Every_Production_Assembly() + { + // The rules above are only as wide as this. Asserted separately because a + // narrowed sweep does not fail — it passes, over less code, which is the one + // outcome a rule that counts a security hole cannot afford. Loading each is + // part of the assertion: a project this test cannot load is one the test csproj + // has not referenced, and the fix is the reference, not a smaller scan. + var names = ProductionAssemblies().ToList(); + + names.Should().HaveCountGreaterThan(30, + "backend/src holds every module in four project shapes plus the core " + + "assemblies; a count this far below that means the enumeration broke"); + + names.Should().Contain("LearnStack.Modules.Tenancy.Application.Contracts", + "add-mediatr-handler puts command records here, so this is where the first " + + "marker carrier lands — and where the first version of this file did not look"); + + var unloadable = names + .Where(name => + { + try + { + Assembly.Load(name); + return false; + } + catch (FileNotFoundException) + { + return true; + } + }) + .ToList(); + + unloadable.Should().BeEmpty( + "add a ProjectReference for each of these to the architecture test project"); + } + /// /// The literal set of request types permitted to run before a tenant is resolved. /// @@ -153,33 +217,66 @@ private static List EnumeratedPublicSurface() } /// - /// Every concrete MediatR request type in the production assemblies. + /// Every concrete MediatR request type in every production assembly. /// /// - /// Fails loudly on an assembly it cannot load, rather than dropping it. The - /// shipped precedent filters unloadable assemblies away, which turns "I could not - /// read this code" into "this code is clean" — the one failure mode a rule counting - /// a security hole cannot afford. + /// + /// Derived from the tree, never a literal list. The first version named nine + /// assemblies. Measured: an identically marked request type failed all three rules + /// from LearnStack.Modules.Tenancy.Application and passed all three from + /// LearnStack.Modules.Tenancy.Application.Contracts — which is where + /// add-mediatr-handler tells an author to put a command record, and therefore + /// where ProvisionTenantCommand, the first type to carry either marker, is + /// scheduled to land. TenantContextBehavior reads both markers off + /// typeof(TRequest) and knows nothing about assembly lists, so the pipeline + /// would have granted the widest surface it can grant while the rule whose whole job + /// is to count that grant reported clean. This is the same failure the catalogue + /// already names for a sibling rule: a rule whose job is the negative cannot be + /// scoped to where the positives happen to live. + /// + /// + /// Fails loudly on an assembly it cannot load, rather than dropping it — and + /// not via GetReferencedAssemblies, which lists only assemblies whose types + /// the compiler actually emitted a reference to and would therefore shrink + /// the sweep. A project this test cannot load is a project the test csproj has not + /// referenced, and the right outcome is a red build naming it, not a smaller scan. + /// /// private static List RequestTypes() { var types = new List(); - foreach (var name in ProductionAssemblies) + foreach (var name in ProductionAssemblies()) { var assembly = Assembly.Load(name); types.AddRange(assembly.GetTypes() .Where(type => type is { IsAbstract: false, IsInterface: false }) - .Where(type => type.GetInterfaces().Any(contract => - contract == typeof(IBaseRequest) - || (contract.IsGenericType - && contract.GetGenericTypeDefinition() == typeof(IRequest<>))))); + .Where(IsRequest)); } return types; } + /// + /// Whether is something MediatR will dispatch. + /// + /// + /// IStreamRequest<> is checked explicitly and is not redundant: measured + /// against MediatR 12.4.1, typeof(IStreamRequest<string>).GetInterfaces() + /// is empty and IBaseRequest.IsAssignableFrom is false, so a stream + /// request is invisible to the ordinary test. It is worth catching here even though + /// Requests_Are_Never_Streamed bans the shape outright — this rule counts a + /// security hole, and counting it correctly must not depend on a second rule staying + /// green. + /// + private static bool IsRequest(Type type) => + type.GetInterfaces().Any(contract => + contract == typeof(IBaseRequest) + || (contract.IsGenericType + && (contract.GetGenericTypeDefinition() == typeof(IRequest<>) + || contract.GetGenericTypeDefinition() == typeof(IStreamRequest<>)))); + private static (AttributeTargets Targets, bool Inherited, bool AllowMultiple) AttributeShape( Type attribute) { @@ -189,16 +286,12 @@ private static (AttributeTargets Targets, bool Inherited, bool AllowMultiple) At return (usage!.ValidOn, usage.Inherited, usage.AllowMultiple); } - private static readonly string[] ProductionAssemblies = - [ - "LearnStack.Application", - "LearnStack.SharedKernel", - "LearnStack.Modules.Tenancy.Application", - "LearnStack.Modules.Identity.Application", - "LearnStack.Modules.Customization.Application", - "LearnStack.Modules.Audit.Application", - "LearnStack.Modules.Content.Application", - "LearnStack.Modules.Media.Application", - "LearnStack.Modules.Education.Application", - ]; + /// Every LearnStack.* project under backend/src. + private static IEnumerable ProductionAssemblies() => + Directory.EnumerateFiles( + RepositoryPaths.BackendSrc(), "LearnStack.*.csproj", SearchOption.AllDirectories) + .Select(Path.GetFileNameWithoutExtension) + .Where(name => !string.IsNullOrEmpty(name)) + .Select(name => name!) + .OrderBy(name => name, StringComparer.Ordinal); } diff --git a/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs index 7e28767a..35298f09 100644 --- a/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs @@ -38,6 +38,7 @@ namespace LearnStack.Tests.Integration; /// to measure at the wire, not to derive from HttpStatusMap. /// /// +[Collection(HostClassificationMeter.Name)] public sealed class AuthorityCeilingHttpTests(AuthorityCeilingFixture fixture) : IClassFixture { @@ -105,6 +106,24 @@ private static string WithoutCorrelation(string body) => Regex.Replace(body, "\"correlationId\":\"[^\"]*\"", "\"correlationId\":\"\""); } +/// +/// Serializes the suites that touch learnstack_host_classification_rejected_total. +/// +/// +/// The counter is process-wide and a MeterListener sees every increment from +/// every instrument of that name, whichever host produced it. So a suite asserting an +/// exact count cannot run beside one that refuses a host — and this one does, in the +/// parity case, by design. Measured: the two classes are green alone and green paired, +/// and red in a full parallel run, which is the shape of a race rather than a defect in +/// either. Serializing is the honest fix; loosening the count to "at least one" would +/// keep the suite green by asserting less. +/// +[CollectionDefinition(Name)] +public sealed class HostClassificationMeter : ICollectionFixture +{ + public const string Name = "host-classification-meter"; +} + /// A host whose resolver maps one tenant host and nothing else. public sealed class AuthorityCeilingFixture : WebApplicationFactory { diff --git a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs index 937f6520..93470d95 100644 --- a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs @@ -37,6 +37,7 @@ namespace LearnStack.Tests.Integration; /// Docker-free suites keep working with no database at all. /// /// +[Collection(HostClassificationMeter.Name)] public sealed class HostClassificationHttpTests(HostClassificationFixture fixture) : IClassFixture { diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index cb82f217..5a354676 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -2084,6 +2084,16 @@ structural test proves — and what it does not. fifth writer anywhere else in the tree pass green: a rule whose job is the negative cannot be scoped to the folder its positives happen to live in. +#### `Requests_Are_Never_Streamed` + +- **Asserts:** no production request type implements `IStreamRequest<>`, and (in `Handlers_Return_Result`) no type implements `IStreamRequestHandler<,>` or the void `IRequestHandler<>`. +- **Why it matters:** all three shapes run with **no pipeline behaviors at all**. MediatR routes a stream through `IStreamPipelineBehavior<,>`, of which this solution registers none; and measured against MediatR 12.4.1, `typeof(IRequestHandler<>).GetInterfaces()` is empty — the void handler does not derive from `IRequestHandler` — while `Unit` does not implement `IResultBase`, which every LearnStack behavior is constrained on. So each shape bypasses the authority ceiling, validation, audit classification and `TransactionBehavior` — and therefore the `SET LOCAL app.tenant_id` that makes Row Level Security non-`NULL`. RLS keeps EF reads fail-closed; what is exposed is every effect that is not an EF read. +- **Source:** ADR-0032 § Sub-decision 2; [02-backend-coding.md § MediatR Use Cases](02-backend-coding.md). +- **Type:** xUnit + reflection. **Kind:** structural. +- **Status:** **Implemented** (`RequestSurfaceTests` and `CrossCuttingFoundationTests`, Packet 7 step 6). +- **Phase:** 02a Packet 7. +- **Note:** vacuous today — nothing streams — and that is the point of landing it now. The shapes are invisible to the ordinary `IRequest<>` filter, so without this rule the first one to arrive would be counted as absent rather than caught. + #### `PublicSurface_Marker_Set_Is_Enumerated` - **Asserts:** every `[PublicSurface]` request type appears in the enumerated set in [Standards 04 § Public surface](04-api-design.md) with its permitted methods; the default is `GET`/`HEAD` and a mutating entry states why. No `[PublicSurface]` type performs a tenant-owned write. From 1589eabc0b748927b9d6618aef0742d6947bdc12 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 16:30:37 +0300 Subject: [PATCH 20/55] docs(tenancy): draw the line indistinguishability actually holds at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two review rounds of this step reached opposite conclusions about the same measurement, so I reproduced it against the shipped pipeline. Both were partly right and the disagreement is itself the finding: the corpus states the invariant more broadly than any mechanism here can deliver, which is why three of ten independent lens runs flagged it. Measured. PUT, DELETE and OPTIONS on a routed path answer 405 on a host the resolver mapped and the shared 404 on one it did not — same path, tellable apart. Routing runs ahead of every user middleware, so nothing in the pipeline sees the request. Not fixed by rewriting 405 to 404, and the reason is the flag's definition. The only hosts that reach routing are ones the resolver admitted, and it admits a row only under `is_active AND is_publicly_live` — which ADR-0036 defines as DNS pointing at LearnStack and the tenant's public site being served. A host without the flag resolves to nothing and answers the unknown-host 404 here too. So the disclosed bit is exactly the one that is public by definition, and once Phase 02d ships the first [PublicSurface] page a plain GET discloses it more directly by returning 200. Rewriting would cost every legitimate client its 405/415 diagnostics to hide something a GET hands out. What the invariant does cover is now stated separately from what it does not, and pinned: on the paths the ceiling controls, a live tenant host and an unknown one are byte-identical, and nothing anywhere names which tenant. A disallowed method on an unrouted path is identical on both — the shape an attacker probing for hostnames would actually use — and that half is asserted. The measurement also turned up something neither round reported: a platform host is separable from both by `code` rather than status. An unmarked request there resolves no tenant and fails gate 1 with tenant_mismatch, where a tenant host under the ceiling and an unknown host both carry not_found. That discloses membership of Tenancy:PlatformHosts — a short static list an operator publishes as its own entry point — and nothing about any tenant. Recorded rather than changed. One real gap closed: the request filter had no positive case. Every set the four rules produce is empty today, so deleting the IStreamRequest arm changed nothing any of them asserted — a detector nobody had run. It is driven against local types now, including the measurement it exists for, and deleting the arm fails. 1106 green, zero skips, two consecutive full runs. Co-Authored-By: Claude Opus 5 (1M context) --- .../RequestSurfaceTests.cs | 28 ++++++++++ .../AuthorityCeilingHttpTests.cs | 53 ++++++++++++++++++- docs/standards/04-api-design.md | 23 ++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs index 7b010486..300eabb2 100644 --- a/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs @@ -130,6 +130,34 @@ public void Requests_Are_Never_Streamed() + "IRequest> and page with the cursor grammar instead"); } + [Fact] + public void The_Request_Filter_Sees_Every_Shape_MediatR_Dispatches() + { + // The rules above are only as wide as this predicate, and today every set it + // produces is empty — so deleting an arm changes nothing any of them assert. + // Driven directly against local types for that reason: a detector with no + // positive case is a detector nobody has run. + // + // The stream arm is the one that matters. Measured against MediatR 12.4.1, + // typeof(IStreamRequest).GetInterfaces() is empty and + // IBaseRequest.IsAssignableFrom(IStreamRequest) is false, so a stream + // request is invisible to the ordinary IRequest<> test — which is exactly how + // it came to be invisible to all four rules. + IsRequest(typeof(ProbeQuery)).Should().BeTrue(); + IsRequest(typeof(ProbeStreamed)).Should().BeTrue( + "a stream request satisfies neither IBaseRequest nor IRequest<>"); + IsRequest(typeof(ProbeNotARequest)).Should().BeFalse(); + + typeof(IBaseRequest).IsAssignableFrom(typeof(IStreamRequest)) + .Should().BeFalse("the measurement the stream arm exists for"); + } + + private sealed record ProbeQuery : IRequest; + + private sealed record ProbeStreamed : IStreamRequest; + + private sealed record ProbeNotARequest; + [Fact] public void The_Sweep_Covers_Every_Production_Assembly() { diff --git a/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs index 35298f09..dd7a41cd 100644 --- a/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs @@ -94,10 +94,59 @@ public async Task The_Ceiling_Refusal_Is_Indistinguishable_From_An_Unknown_Host( + "different writers"); } + [Theory] + [InlineData("PUT")] + [InlineData("DELETE")] + [InlineData("OPTIONS")] + public async Task A_Disallowed_Method_Discloses_Only_That_The_Host_Is_Publicly_Live(string method) + { + // MEASURED, and pinned deliberately rather than fixed. A verb no action accepts + // is answered by routing, which runs ahead of every user middleware, so the + // mapped host gets 405 and the unmapped host gets the resolver's 404 — the two + // are tellable apart on the same path. + // + // Why that is acceptable, and why this test says so instead of a middleware + // rewriting 405 to 404: the only hosts that reach routing at all are ones the + // resolver admitted, and it admits a row only under `is_active AND + // is_publicly_live`. ADR-0036 defines the second flag as meaning DNS points at + // LearnStack and the tenant's public site is served — a host that is not + // publicly live resolves to nothing and answers the unmapped 404 here too. So + // the bit disclosed is `is_publicly_live`, which is by definition not a secret; + // once Phase 02d ships the first [PublicSurface] page, a plain GET discloses it + // more directly by returning 200. + // + // The invariant that DOES hold, and that the GET case above asserts: nothing + // distinguishes a live tenant host from an unknown one on the paths the ceiling + // controls, and nothing anywhere names WHICH tenant. Two review rounds reached + // opposite conclusions about this, which is why the boundary is written down + // here rather than left to be re-derived. + var mapped = await SendAsync("/api/v1/ceilingprobe/guarded", method: new HttpMethod(method)); + var unmapped = await SendAsync( + "/api/v1/ceilingprobe/guarded", host: "stranger.example.com", method: new HttpMethod(method)); + + mapped.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed, + "routing decides this before the pipeline, on a host that resolved"); + unmapped.StatusCode.Should().Be(HttpStatusCode.NotFound, + "an unresolvable host never reaches routing's method table"); + + // An unrouted path is identical on both, which is the half that must not drift: + // it is the shape an attacker probing for hostnames would actually use. + var mappedMiss = await SendAsync("/api/v1/nothing-here", method: new HttpMethod(method)); + var unmappedMiss = await SendAsync( + "/api/v1/nothing-here", host: "stranger.example.com", method: new HttpMethod(method)); + + mappedMiss.StatusCode.Should().Be(unmappedMiss.StatusCode); + WithoutCorrelation(await mappedMiss.Content.ReadAsStringAsync()) + .Should().Be(WithoutCorrelation(await unmappedMiss.Content.ReadAsStringAsync())); + } + private async Task SendAsync( - string path, string host = AuthorityCeilingFixture.TenantHost) + string path, + string host = AuthorityCeilingFixture.TenantHost, + HttpMethod? method = null) { - using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(path, UriKind.Relative)); + using var request = new HttpRequestMessage( + method ?? HttpMethod.Get, new Uri(path, UriKind.Relative)); request.Headers.Host = host; return await _client.SendAsync(request); } diff --git a/docs/standards/04-api-design.md b/docs/standards/04-api-design.md index 87079415..09b89b82 100644 --- a/docs/standards/04-api-design.md +++ b/docs/standards/04-api-design.md @@ -159,6 +159,29 @@ reconciliation matrix are the separate case — no tenant context resolves at al `HttpStatusMap.CanonicalCodeFor(404)` is `not_found` and `Error.Code` strips the `lockey_` prefix, both carry the same `type`, `title`, `status`, `code` and `messageKey`. + + **What indistinguishability covers, precisely, and what it does not.** It covers the + paths the ceiling controls: on the same path, with the same method, a live tenant host + and an unknown one produce byte-identical responses, and nothing anywhere names *which* + tenant. It does **not** extend to responses routing produces before the pipeline runs — + a method no action accepts is a `405` on a host that resolved and the shared `404` on + one that did not. That is measured and deliberate rather than an oversight: the only + hosts reaching routing are ones the resolver admitted, and it admits a row only under + `is_active AND is_publicly_live`, which + [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) defines as DNS + pointing at LearnStack and the tenant's public site being served. A host that is not + publicly live resolves to nothing and answers the unknown-host `404` here too, so the + bit disclosed is exactly the one that is public by definition — and once the table + above has a row, a plain `GET` discloses it more directly by returning `200`. + `A_Disallowed_Method_Discloses_Only_That_The_Host_Is_Publicly_Live` pins the boundary + so a later reader does not have to re-derive it; two independent reviews of this + mechanism reached opposite conclusions about it, which is why it is written down. + + A **platform host** is separable from both, by `code` rather than status: an unmarked + request there resolves no tenant and fails gate 1 with `tenant_mismatch`, where a + tenant host under the ceiling and an unknown host both carry `not_found`. That + discloses membership of `Tenancy:PlatformHosts` — a short static list an operator + publishes as its own entry point — and nothing about any tenant. - **Permitted methods default to `GET` / `HEAD`.** An entry declaring a mutating method states why, in the table. - **No `[PublicSurface]` type performs a tenant-owned write.** From 4d597a0dd5af6c65566fa5c98d0bf9960cb5dfe8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 18:16:28 +0300 Subject: [PATCH 21/55] feat(tenancy): the one sanctioned path to a cross-tenant connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 7 step 7. EnterAsync opens a learnstack_platform connection and transaction of its own and hands back a handle; disposing without committing rolls back. A second connection, never SET ROLE — ADR-0003 gives three reasons and each rules the alternative out alone: membership would make BYPASSRLS a standing capability of the application role, a plain SET ROLE survives COMMIT and rides a transaction-pooled connection into the next tenant's request, and per-role settings like statement_timeout are applied at login and do not follow a role switch. The corpus disagreed with itself about the shape and this resolves it rather than propagating it. Two editable carriers said the scope opens "a DI scope whose DbContext is built on that data source"; ADR-0003 and architecture/09, both Accepted, say "a second connection". The connection is also the only shape that compiles against what is already here — every module DbContext is bound to IUnitOfWork.Connection, which comes from the application data source the composition root guards by name — and it forecloses nothing, since EF can be built on the handle. Standards 05 and the glossary are corrected. The platform data source gets the INVERSE of the application guard. Reusing the application builder would have made the credential refuse its own first connection, because that builder rejects any role reaching BYPASSRLS and this role IS that role. The initializer here asserts the opposite, and the failure it catches is the one that looks like nothing at all: a learnstack_platform that lost the attribute still connects, and every cross-tenant query simply returns fewer rows. The entry gate ships refusing everyone, following DenyAllTenantMembershipReader exactly. There is no principal until Phase 02b and no permission until Phase 03, so "nobody holds a platform-scope permission" is the true answer rather than a placeholder, and it makes ADR-0036's "checked before the scope opens" a call rather than a sentence. Worth naming ahead of time: Packet 9's GDPR handler is the first real caller and inherits a closed gate. What ships is a log line, not an audit trail. Warning level, the reason and the call site, and deliberately no tenant id — TenantId leaves the platform sentinel unfixed and Packet 9 chooses it with the schema that stores it. The line sits between the connection open and the transaction begin because that is the position Packet 9's SecurityEvent row takes over, and it must be written before the operation runs so an operation that later fails is still recorded. The corpus calls this path audited; this packet does not, because it is not yet. The Docker suite is written as provenance rather than isolation. A bypass-role test asserting "I see both tenants" passes identically against an inert policy set, which is why CLAUDE.md forbids the shape; each case asserts instead that the same query on an application connection sees less, at the same moment, on the same data. Two measured gaps closed while building. The suite first created its own data source, so BuildPlatformDataSource never ran and swapping the inverted initializer for the application one left all six cases green — the guard had no test. It routes through the builder now, and a case takes BYPASSRLS off the real role to prove the guard fires, restoring it in a finally. And leg 2 of the resolution rule scanned for the word "PlatformAdmin", which matched the type names — a rule matching its own vocabulary rather than a credential. 1123 green, zero skips. Nine mutations measured: the gate, its ordering, the rollback, both data-source guards, and each of the four rules. ADR: 0003, 0036 Co-Authored-By: Claude Opus 5 (1M context) --- .../PersistenceCompositionExtensions.cs | 145 ++++++++++ .../MultiTenancy/PlatformAdminScope.cs | 185 ++++++++++++ .../Tenancy/IPlatformAdminGate.cs | 75 +++++ .../Tenancy/IPlatformAdminScope.cs | 109 +++++++ .../PlatformAdminScopeConventionTests.cs | 131 +++++++++ .../Database/PlatformAdminScopeTests.cs | 273 ++++++++++++++++++ .../Database/PostgresFixture.cs | 14 + .../MultiTenancy/PlatformAdminGateTests.cs | 105 +++++++ docs/glossary.md | 2 +- docs/standards/05-database.md | 14 +- .../21-architecture-tests-catalogue.md | 25 +- 11 files changed, 1071 insertions(+), 7 deletions(-) create mode 100644 backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminGate.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/PlatformAdminGateTests.cs diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 4390153e..27237b39 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -1,6 +1,8 @@ +using LearnStack.Infrastructure.MultiTenancy; using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Infrastructure.Persistence; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; @@ -91,6 +93,37 @@ public static IServiceCollection AddLearnStackPersistence( services.TryAddSingleton(_ => BuildApplicationDataSource(connectionString)); + // The platform credential — the second, separately-credentialed data source + // ADR-0003 requires, keyed so only PlatformAdminScope resolves it. Validated at + // boot when present, for the same reason the application one is: a credential + // naming the wrong role is a deployment mistake, and the first cross-tenant + // operation is a bad place to find it. + var platformConnectionString = configuration.GetConnectionString(PlatformConnectionName); + + if (!string.IsNullOrWhiteSpace(platformConnectionString)) + { + ValidatePlatformConnectionString(platformConnectionString); + } + + // Registered UNCONDITIONALLY, never gated on the key being present. A + // conditional registration turns an absent credential into the container's "no + // service for type NpgsqlDataSource has been registered", which names neither + // the key nor the file an operator has to edit. The Lazy is what lets a + // deployment that needs no platform admin — most of them — boot with none. + services.TryAddKeyedSingleton( + PlatformAdminScope.PlatformDataSourceKey, + (_, _) => BuildPlatformDataSource(platformConnectionString)); + + services.TryAddKeyedSingleton>( + PlatformAdminScope.PlatformDataSourceKey, + (provider, key) => new Lazy( + () => provider.GetRequiredKeyedService(key))); + + // The gate first, so a reader sees that entry is refused before a connection is + // opened rather than after. Both are stateless singletons. + services.TryAddSingleton(); + services.TryAddSingleton(); + // Scoped: one connection per request, owned by this, shared by every // context resolved in the scope (ADR-0040). services.TryAddScoped(); @@ -131,6 +164,82 @@ internal static NpgsqlDataSource BuildApplicationDataSource(string? connectionSt return builder.Build(); } + /// The configuration key the platform credential comes from. + public const string PlatformConnectionName = "PlatformAdmin"; + + /// + /// The platform-role data source. Not built by + /// . + /// + /// + /// That builder installs a physical-connection initializer refusing any role that can + /// reach BYPASSRLS — and learnstack_platform is that role, so + /// reusing it would make the credential refuse its own first connection. The + /// initializer here asserts the inverse. A platform credential that does not bypass + /// is not merely wrong: every cross-tenant read would come back filtered to nothing + /// and read as missing data rather than as a misconfiguration. + /// + internal static NpgsqlDataSource BuildPlatformDataSource(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + $"ConnectionStrings:{PlatformConnectionName} is not configured, so the " + + "platform-admin scope is unavailable. It is the only path to a " + + "cross-tenant connection and it does not fall back to the application " + + "role — a bypass that silently became a tenant-scoped read would return " + + "nothing and look like missing data. A deployment that performs no " + + "cross-tenant operation needs no such credential and may leave the key " + + "unset; one that does, sets it from the platform role's secret. " + + "See .env.example."); + } + + ValidatePlatformConnectionString(connectionString); + + var builder = new NpgsqlDataSourceBuilder(connectionString); + + builder.UsePhysicalConnectionInitializer( + connection => RequireBypassRole(connection, async: false).GetAwaiter().GetResult(), + connection => RequireBypassRole(connection, async: true)); + + return builder.Build(); + } + + /// + /// Everything about ConnectionStrings:PlatformAdmin checkable without a + /// connection. + /// + internal static void ValidatePlatformConnectionString(string? connectionString) + { + NpgsqlConnectionStringBuilder parsed; + + try + { + parsed = new NpgsqlConnectionStringBuilder(connectionString); + } + catch (ArgumentException failure) + { + throw new InvalidOperationException( + $"ConnectionStrings:{PlatformConnectionName} is not a valid Npgsql " + + "connection string. It must be key/value form, not a URI DSN.", failure); + } + + if (!string.Equals(parsed.Username, PlatformRole, StringComparison.Ordinal)) + { + var observed = string.IsNullOrEmpty(parsed.Username) ? "" : parsed.Username; + throw new InvalidOperationException( + $"ConnectionStrings:{PlatformConnectionName} names Username='{observed}'. " + + $"It must name '{PlatformRole}', the one role in the four-role model that " + + "holds BYPASSRLS. Any other role either cannot see across tenants — in " + + "which case every cross-tenant read comes back empty and looks like " + + "missing data — or is a wider credential than this path is allowed to " + + "hold."); + } + } + + /// The role the platform credential must name. + private const string PlatformRole = "learnstack_platform"; + /// /// Everything about ConnectionStrings:Default that can be checked /// without opening a connection. @@ -221,6 +330,42 @@ AND pg_has_role(current_user, r.oid, 'MEMBER')) } } + /// + /// Refuses a platform connection whose role does not bypass row security. + /// + /// + /// The mirror of RefuseBypassRole, and asked of the server for the same + /// reason: the name is not the privilege. A learnstack_platform that lost + /// BYPASSRLS — a re-created role, a restored dump, an ALTER ROLE — is + /// the failure that looks like nothing at all, because every cross-tenant query + /// simply returns fewer rows. + /// + private static async Task RequireBypassRole(NpgsqlConnection connection, bool async) + { + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT EXISTS ( + SELECT 1 FROM pg_roles r + WHERE r.rolname = current_user AND (r.rolbypassrls OR r.rolsuper)) + """; + + var bypasses = async + ? await command.ExecuteScalarAsync() + : command.ExecuteScalar(); + + if (bypasses is not true) + { + throw new InvalidOperationException( + $"ConnectionStrings:{PlatformConnectionName} connected as a role that does " + + "not bypass Row Level Security. The platform-admin scope exists only to " + + "cross tenant boundaries, and under a non-bypassing role every query it " + + "runs is silently filtered to the current tenant context — which, with " + + "no context set, is no rows at all. Grant BYPASSRLS to " + + $"{PlatformRole} or correct the credential."); + } + } + /// The connection string with its password removed. /// /// From the parsed builder, not by pattern-matching the raw text. diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs new file mode 100644 index 00000000..8840eb96 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs @@ -0,0 +1,185 @@ +using System.Data.Common; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace LearnStack.Infrastructure.MultiTenancy; + +/// +/// Opens a learnstack_platform connection, on its own, for one bounded operation. +/// +/// +/// +/// Stateless and singleton. Every EnterAsync returns an independent handle +/// owning its own connection and transaction, so two concurrent or nested entries share +/// nothing. Per-entry state on the singleton would put two callers on one +/// BYPASSRLS connection, which is the hazard IUnitOfWork already documents +/// for the ambient one — worse here, because the connection sees every tenant. +/// +/// +/// It never joins the ambient unit of work. No BeginTransactionAsync on +/// IUnitOfWork, no Database.UseTransaction, no SetTenantContextAsync. +/// The whole point is a second connection under a different role; enlisting would put +/// the bypass on the request's own connection and leave it there. +/// +/// +/// No set_config('app.tenant_id', …), and no SET TRANSACTION READ ONLY. +/// The first because there is no policy to announce to — the role bypasses them — which +/// is also why this is not an eighth out-of-band setter. The second because nothing calls +/// this path read-only: both named consumers, GDPR redaction and the retention purge, +/// write. +/// +/// +public sealed class PlatformAdminScope( + IPlatformAdminGate gate, + [FromKeyedServices(PlatformAdminScope.PlatformDataSourceKey)] Lazy dataSource, + ILogger logger) + : IPlatformAdminScope +{ + /// The DI key the platform data source is registered under. + /// + /// Public because the key is not the capability — GetKeyedServices with + /// KeyedService.AnyKey reaches a keyed registration whatever the key is + /// spelled, so hiding it buys nothing a reader can rely on. + /// Platform_DataSource_Resolved_Only_By_PlatformAdminScope is the boundary. + /// + public const string PlatformDataSourceKey = "PlatformAdmin"; + + private readonly IPlatformAdminGate _gate = gate ?? throw new ArgumentNullException(nameof(gate)); + + private readonly Lazy _dataSource = + dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + + private readonly ILogger _logger = + logger ?? throw new ArgumentNullException(nameof(logger)); + + /// + public async Task EnterAsync( + string reason, + CancellationToken cancellationToken = default, + string? callerMember = null, + string? callerFile = null, + int callerLine = 0) + { + // The order below is load-bearing, because Packet 9 inherits this call site and + // writes its SecurityEvent row where the log line sits. + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + + // 1. The gate, before anything opens. ADR-0036 asks for the permission to be + // "checked before the scope opens", and a check after the connection exists + // would already have spent a BYPASSRLS connection on a refused caller. + if (!await _gate.IsPermittedAsync(reason, cancellationToken)) + { + throw new PlatformAdminScopeDeniedException(reason); + } + + // 2. The credential. Touching Value here is where an absent + // ConnectionStrings:PlatformAdmin surfaces — on entry, at the first call, with + // a message naming the key rather than a container error naming a type. + var connection = await _dataSource.Value.OpenConnectionAsync(cancellationToken); + + try + { + // 3. Recorded between the open and the transaction. Not on dispose and not + // after the body: Packet 9's row must be written on this connection + // BEFORE the operation runs, so that an operation which then fails is + // still recorded, and this is the position it takes over. + LogEntered(_logger, reason, callerMember ?? "", ShortPath(callerFile), callerLine, null); + + var transaction = await connection.BeginTransactionAsync(cancellationToken); + return new Handle(connection, transaction); + } + catch + { + await connection.DisposeAsync(); + throw; + } + } + + /// + /// The last two segments of a compile-time path. + /// + /// + /// CallerFilePath is the absolute path on the machine that compiled the + /// assembly, so logging it whole puts a build-agent directory layout into every + /// forwarded line and tells a reader nothing the file name does not. + /// + private static string ShortPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return ""; + } + + var segments = path.Split('/', '\\', StringSplitOptions.RemoveEmptyEntries); + return segments.Length <= 2 ? string.Join('/', segments) : string.Join('/', segments[^2..]); + } + + // Warning, because a cross-tenant bypass is not an ordinary event and an operator + // filtering at Information must still see it. The reason and the call site and + // nothing else — deliberately no tenant id: TenantId leaves the platform sentinel's + // value unfixed, and Packet 9 chooses it with the schema that stores it, because a + // log line is not a one-way door and an identifier minted for a table that does not + // exist yet is. + private static readonly Action LogEntered = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(7001, nameof(LogEntered)), + "Platform-admin scope entered: {Reason} (from {Member} at {File}:{Line}). " + + "Cross-tenant access under learnstack_platform."); + + /// One entry's connection and transaction. + private sealed class Handle(NpgsqlConnection connection, DbTransaction transaction) + : IPlatformAdminScopeHandle + { + private bool _committed; + private bool _disposed; + + public DbConnection Connection + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + return connection; + } + } + + public DbTransaction Transaction + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + return transaction; + } + } + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + await transaction.CommitAsync(cancellationToken); + _committed = true; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + // Transaction first, then connection, and an uncommitted transaction rolls + // back: a frame that ended without committing has failed, and leaving its + // writes to a later decision is how a partial cross-tenant mutation ships. + if (!_committed) + { + await transaction.RollbackAsync(); + } + + await transaction.DisposeAsync(); + await connection.DisposeAsync(); + } + } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminGate.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminGate.cs new file mode 100644 index 00000000..a77189fb --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminGate.cs @@ -0,0 +1,75 @@ +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Decides whether the caller may enter the platform-admin scope at all. +/// +/// +/// A separate port, and not a check inlined into , +/// because +/// ADR-0036 +/// § The platform-admin override is not a resolution source requires the +/// permission to be "checked before the scope opens" — and with no principal anywhere in +/// the process, a collaborator is the only way to make that a real call rather than a +/// comment. Phase 03 replaces the shipped implementation with one that reads the actor's +/// platform-scope permission. +/// +public interface IPlatformAdminGate +{ + /// Whether entry is permitted, for the stated reason. + ValueTask IsPermittedAsync(string reason, CancellationToken cancellationToken = default); +} + +/// The gate that permits nobody. +/// +/// +/// This is correct, and it will look like a bug — the same shape and the same +/// argument as . There is no authenticated +/// principal until Phase 02b and no permission to hold until Phase 03, so "nobody holds +/// a platform-scope permission" is the true answer rather than a placeholder, and a gate +/// that were permissive in Development would reproduce exactly the configuration +/// inversion the composition root argues against elsewhere: the demo passes and +/// production refuses. +/// +/// +/// Nothing can reach it in Packet 7 — no production caller enters the scope. The +/// consequence worth naming ahead of time is Packet 9's: its GDPR redaction handler is +/// the first real caller, and it inherits a closed gate, so Packet 9 either lands a gate +/// implementation of its own or waits for Phase 03. +/// +/// +public sealed class DenyAllPlatformAdminGate : IPlatformAdminGate +{ + /// + public ValueTask IsPermittedAsync( + string reason, CancellationToken cancellationToken = default) => + ValueTask.FromResult(false); +} + +/// Thrown when refuses entry. +/// +/// An exception rather than a Result: a caller that asked for a cross-tenant +/// bypass and was refused has no second path to take, and there is no request pipeline +/// here to render a failure into. It carries no tenant, no actor and no connection +/// detail — only that entry was refused and why the caller said it wanted in. +/// +public sealed class PlatformAdminScopeDeniedException : Exception +{ + public PlatformAdminScopeDeniedException(string reason) + : base($"Platform-admin scope entry was refused for reason '{reason}'. " + + "No principal in this deployment holds a platform-scope permission: " + + "authentication arrives in Phase 02b and the permission itself in Phase 03.") => + Reason = reason; + + public PlatformAdminScopeDeniedException() + : base("Platform-admin scope entry was refused.") + { + } + + public PlatformAdminScopeDeniedException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// The reason the caller gave. Never a connection detail. + public string? Reason { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs new file mode 100644 index 00000000..8468ee20 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs @@ -0,0 +1,109 @@ +using System.Data.Common; +using System.Runtime.CompilerServices; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// The one sanctioned path to a connection that bypasses Row Level Security. +/// +/// +/// +/// A second connection, never SET ROLE. +/// ADR-0003 +/// gives three reasons and each rules the alternative out on its own: +/// learnstack_app is not a member of learnstack_platform, and a membership +/// grant would make the bypass a standing capability of the application role reachable +/// from any code path that emits raw SQL; a plain SET ROLE survives COMMIT +/// and would persist on a PgBouncer transaction-pooled server connection into the next +/// tenant's request; and per-role settings such as statement_timeout are applied +/// at login and do not follow a role switch. +/// +/// +/// It is not one of the tenant-context writers, and not a setter of +/// app.tenant_id. It sets no tenant context at all — there is no policy to +/// announce to, because the role bypasses them — so it is outside both closed sets: +/// the four writers of ITenantContextAccessor.Current +/// (ADR-0036 +/// § Rules, which names it as explicitly not one) and the seven out-of-band +/// setters (ADR-0040 +/// Amendment 3, whose closing property is that every one of them connects as +/// learnstack_app). +/// +/// +/// What Packet 7 ships is a log line, not an audit trail. Entry is recorded +/// through ILogger at Warning with the reason and the calling site. +/// audit_log and IAuditStore arrive in Packet 9, which replaces the log +/// line with a SecurityEvent row written as learnstack_platform before the +/// operation runs. Until then this path is logged and it is not audited; the +/// corpus calls it audited because that is what it will be, and a reader deciding +/// whether cross-tenant access is retained under audit retention today must not read +/// that word as a description of this packet. +/// +/// +public interface IPlatformAdminScope +{ + /// + /// Opens a platform-role connection and transaction, or refuses. + /// + /// + /// Why this cross-tenant access is happening. Required, non-blank, and the value + /// Packet 9 will write durably — so it is a short operator-authored slug naming the + /// operation, never a caller-supplied string and never anything carrying personal + /// data beyond the identifier the operation is already about. + /// + /// Cancels the open, not an operation already inside. + /// Supplied by the compiler; do not pass. + /// Supplied by the compiler; do not pass. + /// Supplied by the compiler; do not pass. + /// + /// The three Caller* parameters are how "the caller" is known at all. There is + /// no principal in the process — authentication is Phase 02b and + /// AuthorizationBehavior is still a pass-through — so the alternative to the + /// compiler filling these in is a log line that records only that someone + /// entered. These are the first use of the attributes anywhere in this solution. + /// + Task EnterAsync( + string reason, + CancellationToken cancellationToken = default, + [CallerMemberName] string? callerMember = null, + [CallerFilePath] string? callerFile = null, + [CallerLineNumber] int callerLine = 0); +} + +/// +/// An open platform-role connection and its transaction, for as long as it is held. +/// +/// +/// +/// A connection, not a DbContext. ADR-0003 and +/// architecture/09 +/// both say "a second connection"; two editable carriers said "a DI scope whose +/// DbContext is built on that data source" and have been corrected to match, +/// because only this shape compiles against what is already shipped — every module +/// DbContext is bound to IUnitOfWork.Connection, which comes from the +/// application data source that the composition root guards to be +/// learnstack_app by name. Nothing is foreclosed: EF can be built on this +/// connection by whoever needs it. +/// +/// +/// Disposing without committing rolls back. The same posture the ambient unit of +/// work takes, and it matters more here: a leaked handle holds a BYPASSRLS pooled +/// connection for the life of the request. +/// +/// +/// System.Data.Common types rather than Npgsql ones because this assembly +/// references no database driver — the same reason IUnitOfWork.Connection is a +/// . +/// +/// +public interface IPlatformAdminScopeHandle : IAsyncDisposable +{ + /// The open connection, authenticated as the platform role. + DbConnection Connection { get; } + + /// The transaction every command in this scope runs on. + DbTransaction Transaction { get; } + + /// Commits. Without it, disposal rolls back. + Task CommitAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs new file mode 100644 index 00000000..9f62c04b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs @@ -0,0 +1,131 @@ +using System.Reflection; +using FluentAssertions; +using LearnStack.SharedKernel.Tenancy; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// +/// The rules that keep the one BYPASSRLS credential reachable from one place. +/// +public sealed class PlatformAdminScopeConventionTests +{ + private const string ScopeFile = "LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs"; + + private const string CompositionFile = + "LearnStack.Api/Composition/PersistenceCompositionExtensions.cs"; + + [Fact] + public void Platform_DataSource_Resolved_Only_By_PlatformAdminScope() + { + // Three legs, because "only PlatformAdminScope may resolve it" is not one claim. + // + // Leg 1 — nothing but the scope names the keyed registration. This is the repo's + // first keyed DI registration anywhere, so there is no house pattern to lean on + // and the whole boundary is this scan. The key being a public const is not a + // weakness: GetKeyedServices(KeyedService.AnyKey) reaches a keyed registration + // whatever the key is spelled, so hiding the string would buy a reader nothing. + var resolvers = SourceScan.FilesContaining( + SourceScan.SourceRoot, "FromKeyedServices", except: null) + .Concat(SourceScan.FilesContaining( + SourceScan.SourceRoot, "GetRequiredKeyedService", except: null)) + .Concat(SourceScan.FilesContaining( + SourceScan.SourceRoot, "GetKeyedService", except: null)) + .Distinct() + .Order(StringComparer.Ordinal) + .ToList(); + + resolvers.Should().BeEquivalentTo( + [ScopeFile, CompositionFile], + "the scope injects the keyed source and the composition root registers it; a " + + "third resolver is a second path to a credential that sees every tenant"); + + // Leg 2 — every connection string in the solution is read in one file. A second + // reader of ConnectionStrings:PlatformAdmin would be a second data source, built + // without the initializer that asserts the role actually bypasses — and the + // symptom of that is not an error but fewer rows. + // + // The needle is the READ, not the word "PlatformAdmin": that word is also the + // type names, so scanning for it flagged the two SharedKernel contracts, which + // is a rule matching its own vocabulary rather than a credential. + SourceScan.FilesContaining(SourceScan.SourceRoot, "GetConnectionString", except: null) + .Should().BeEquivalentTo( + [CompositionFile], + "one file reads credentials, so one file decides what is done with them"); + + // Leg 3 — the scan itself found something. A two-path allow-list that matches + // nothing passes, which is the failure a sibling rule already records: a narrowed + // sweep does not fail, it passes over less code. + resolvers.Should().NotBeEmpty("a scan matching nothing would satisfy leg 1 vacuously"); + } + + [Fact] + public void No_IgnoreQueryFilters_Outside_PlatformAdminScope() + { + // A live negative. Nothing in backend/src calls IgnoreQueryFilters today, and the + // rule exists so the first call is a deliberate edit here rather than a quiet one + // there — the query filters are one of the four isolation layers, and a call site + // that removes them without going through the audited path removes a layer with + // no record that it happened. + // + // A path check, not a marker: there is deliberately no escape-hatch comment to + // write, because a comment is what a reviewer skims past. + SourceScan.FilesContaining(SourceScan.SourceRoot, "IgnoreQueryFilters", except: ScopeFile) + .Should().BeEmpty( + "cross-tenant reads go through IPlatformAdminScope, which uses a " + + "separately-credentialed connection rather than removing a filter"); + } + + [Fact] + public async Task PlatformAdminScope_Entry_Requires_Platform_Permission() + { + // Conjunct A — live. The gate port exists, the registered implementation refuses + // everyone, and the scope consults it. That last part is what makes ADR-0036's + // "checked before the scope opens" a call rather than a sentence; the ORDER — + // before the credential is touched — is asserted behaviourally in + // PlatformAdminGateTests, which a structural test cannot see. + typeof(IPlatformAdminGate).Should().BeAssignableTo(); + + (await new DenyAllPlatformAdminGate().IsPermittedAsync("any", CancellationToken.None)) + .Should().BeFalse("the shipped gate permits nobody until Phase 03"); + + SourceScan.FilesContaining(SourceScan.SourceRoot, "IsPermittedAsync", except: null) + .Should().Contain(ScopeFile, "the scope must actually consult the gate"); + + // Conjunct B — VACUOUS, and doubly so. The permission key itself does not exist: + // there is no permission system until Phase 03, and no production caller enters + // the scope at all. What can be asserted now is that no second gate + // implementation has appeared to sit beside the deny-all one, because that is how + // a permissive default arrives — registered somewhere else, for a demo. + var gates = typeof(IPlatformAdminGate).Assembly.GetTypes() + .Where(type => type is { IsAbstract: false, IsInterface: false }) + .Where(typeof(IPlatformAdminGate).IsAssignableFrom) + .Select(type => type.Name) + .ToList(); + + gates.Should().BeEquivalentTo( + [nameof(DenyAllPlatformAdminGate)], + "Phase 03 replaces this one; a second implementation appearing before then is " + + "how 'permissive to unblock a demo' gets shipped"); + } + + [Fact] + public void The_Platform_Scope_Writes_No_Tenant_Context_And_Sets_No_Session_Variable() + { + // Two closed sets it must stay outside of, both stated in Accepted ADRs. It is + // not one of the four writers of ITenantContextAccessor.Current — ADR-0036 names + // it as explicitly not one — and it is not an eighth out-of-band setter of + // app.tenant_id, because the role bypasses policies and there is nothing to + // announce to. SetTenant_Callers_Are_The_Enumerated_Four covers the first + // globally; this pins the second, which no rule covered. + var scope = Path.Combine(SourceScan.SourceRoot, ScopeFile.Replace('/', Path.DirectorySeparatorChar)); + var code = SourceText.WithoutWhitespace(SourceText.WithoutComments(File.ReadAllText(scope))); + + code.Should().NotContain(SourceText.WithoutWhitespace("set_config("), + "a BYPASSRLS connection has no policy to announce a tenant to, and announcing " + + "one would make this an eighth setter in a set two ADRs close at seven"); + code.Should().NotContain(SourceText.WithoutWhitespace("SetTenantContextAsync")); + code.Should().NotContain(SourceText.WithoutWhitespace("IUnitOfWork"), + "enlisting would put the bypass on the request's own connection"); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs new file mode 100644 index 00000000..585ccff0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs @@ -0,0 +1,273 @@ +using FluentAssertions; +using LearnStack.Infrastructure.MultiTenancy; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// PlatformAdminScope against a real database — the one sanctioned bypass. +/// +/// +/// +/// These are provenance tests, not isolation tests, and the distinction is the point. +/// CLAUDE.md forbids running an isolation test as a BYPASSRLS role because such a +/// test passes identically when every policy is inert — so "the scope sees both tenants" +/// proves nothing on its own. What each case below asserts instead is where the +/// visibility comes from: that the same query on an application connection sees less, +/// on the same data, at the same moment. A dropped policy would make both sides equal and +/// turn these red, which is the property an isolation-shaped assertion cannot offer. +/// +/// +/// The scope is constructed directly with a permissive gate double. The shipped gate +/// refuses everyone — correctly, there is no principal — and the property under test here +/// is the connection, not the gate; the gate's own behaviour is asserted separately and +/// without Docker. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class PlatformAdminScopeTests +{ + private readonly SchemaFixture _schema; + + public PlatformAdminScopeTests(SchemaFixture schema) => _schema = schema; + + [Fact] + public async Task The_Scope_Sees_What_An_Application_Connection_Cannot() + { + // Both counts, on the same table, in the same moment, from the two credentials. + // The application connection sets no tenant context, so its policy predicate is + // NULL and it sees nothing; the platform connection bypasses the policy entirely. + // Asserting the PAIR is what makes this evidence: if the policies were dropped, + // the application side would rise to match and this fails. + await using var platformSource = PlatformSource(_schema.Postgres.PlatformConnectionString); + await using var appSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + + await using var handle = await Build(platformSource).EnterAsync( + "test:cross-tenant-visibility", CancellationToken.None); + + var seenByPlatform = await CountOrganizationsAsync(handle.Connection, handle.Transaction); + + await using var appConnection = await appSource.OpenConnectionAsync(CancellationToken.None); + var seenByApplication = await CountOrganizationsAsync(appConnection, transaction: null); + + seenByApplication.Should().Be(0, + "with no tenant context the policy predicate is NULL and the row is filtered out"); + seenByPlatform.Should().BeGreaterThan(0, + "the platform role bypasses the policy that filtered the application connection"); + } + + [Fact] + public async Task The_Scope_Connects_As_The_Platform_Role_On_Its_Own_Connection() + { + // The claim ADR-0003 makes by name: a second connection, never SET ROLE. Asserted + // by asking the server who it thinks we are, and by checking the backend process + // id differs from the application connection's — a SET ROLE would share one. + await using var platformSource = PlatformSource(_schema.Postgres.PlatformConnectionString); + await using var appSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + + await using var appConnection = await appSource.OpenConnectionAsync(CancellationToken.None); + var appBackend = await ScalarAsync(appConnection, null, "SELECT pg_backend_pid()"); + var appUser = await ScalarAsync(appConnection, null, "SELECT current_user"); + + await using var handle = await Build(platformSource).EnterAsync( + "test:provenance", CancellationToken.None); + + var scopeUser = await ScalarAsync(handle.Connection, handle.Transaction, "SELECT current_user"); + var scopeBackend = await ScalarAsync(handle.Connection, handle.Transaction, "SELECT pg_backend_pid()"); + + appUser.Should().Be("learnstack_app"); + scopeUser.Should().Be("learnstack_platform"); + scopeBackend.Should().NotBe(appBackend, + "a second connection, not a role switch on the request's own — a SET ROLE " + + "would survive COMMIT and ride a pooled connection into the next tenant"); + } + + [Fact] + public async Task Disposing_Without_Committing_Rolls_Back() + { + // A frame that ended without committing has failed, and a half-applied + // cross-tenant mutation is the worst thing this path could leave behind. + await using var dataSource = PlatformSource(_schema.Postgres.PlatformConnectionString); + var scope = Build(dataSource); + var host = $"rollback-{Guid.NewGuid():N}.example.com"; + + await using (var handle = await scope.EnterAsync( + "test:rollback", CancellationToken.None)) + { + await using var insert = handle.Connection.CreateCommand(); + insert.Transaction = handle.Transaction; + insert.CommandText = + """ + INSERT INTO platform_host_to_tenant (host, tenant_id, is_active, is_publicly_live) + VALUES (@host, @tenant, true, true) + """; + insert.Parameters.Add(new NpgsqlParameter("host", host)); + insert.Parameters.Add(new NpgsqlParameter("tenant", SchemaFixture.TenantA)); + await insert.ExecuteNonQueryAsync(CancellationToken.None); + } + + await using var check = await scope.EnterAsync( + "test:rollback-check", CancellationToken.None); + var survivors = await ScalarAsync( + check.Connection, + check.Transaction, + "SELECT count(*) FROM platform_host_to_tenant WHERE host = @host", + [("host", (object)host)]); + + survivors.Should().Be(0, "disposal without a commit rolls the transaction back"); + } + + [Fact] + public async Task Two_Concurrent_Entries_Get_Independent_Connections() + { + // The scope is a stateless singleton, so per-entry state would put two callers on + // one BYPASSRLS connection — the one-command-at-a-time hazard the ambient unit of + // work already documents, made worse by a connection that sees every tenant. + await using var dataSource = PlatformSource(_schema.Postgres.PlatformConnectionString); + var scope = Build(dataSource); + + await using var first = await scope.EnterAsync("test:one", CancellationToken.None); + await using var second = await scope.EnterAsync("test:two", CancellationToken.None); + + first.Connection.Should().NotBeSameAs(second.Connection); + (await ScalarAsync(first.Connection, first.Transaction, "SELECT pg_backend_pid()")) + .Should().NotBe( + await ScalarAsync(second.Connection, second.Transaction, "SELECT pg_backend_pid()")); + } + + [Fact] + public async Task A_Correctly_Named_Role_That_Lost_Bypass_Is_Refused_On_Connect() + { + // The failure that looks like nothing at all. A learnstack_platform which lost + // BYPASSRLS — a re-created role, a restored dump, an ALTER ROLE — still passes + // the name check, still connects, and every cross-tenant query it runs comes back + // filtered to the current tenant context. With no context set that is no rows, + // so the symptom is missing data rather than a misconfiguration. + // + // Driven by taking the attribute off the real role and putting it back, because + // nothing weaker distinguishes the guard from its absence: measured, with the + // suite building its own data source, replacing this initializer with the + // application one — which refuses every bypassing role — left all six cases + // green. The shared-schema collection serializes its classes, so the window is + // this method, and the restore is in a finally. + // The superuser, because no four-role credential may alter a role's attributes — + // learnstack_migration holds neither CREATEROLE nor ADMIN on learnstack_platform, + // which is the four-role model working rather than a gap. Nothing here reads + // tenant data through it. + await using var admin = NpgsqlDataSource.Create(_schema.Postgres.SuperuserConnectionString); + await ExecuteAsync(admin, "ALTER ROLE learnstack_platform NOBYPASSRLS"); + + try + { + await using var dataSource = PlatformSource(_schema.Postgres.PlatformConnectionString); + + var act = async () => await dataSource.OpenConnectionAsync(CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Message.Should().Contain("does not bypass Row Level Security"); + } + finally + { + await ExecuteAsync(admin, "ALTER ROLE learnstack_platform BYPASSRLS"); + } + + // And it connects again once the attribute is back, so the guard is the thing + // that refused rather than the credential being broken by this test. + await using var restored = PlatformSource(_schema.Postgres.PlatformConnectionString); + await using var connection = await restored.OpenConnectionAsync(CancellationToken.None); + connection.State.Should().Be(System.Data.ConnectionState.Open); + } + + private static async Task ExecuteAsync(NpgsqlDataSource dataSource, string sql) + { + await using var command = dataSource.CreateCommand(sql); + await command.ExecuteNonQueryAsync(); + } + + [Fact] + public void A_Credential_Naming_Another_Role_Is_Refused_Before_Any_Connection() + { + // The name is not the privilege, so there are two guards and this is the cheap + // one — the mistake an operator actually makes, caught without a socket. The + // server-side half catches a correctly named role that LOST the attribute (a + // re-created role, a restored dump), which is the failure that looks like + // nothing at all: every cross-tenant query simply returns fewer rows. That half + // is evidenced by the cases above running green against the real credential. + var act = () => LearnStack.Api.Composition.PersistenceCompositionExtensions + .BuildPlatformDataSource(_schema.Postgres.AppConnectionString); + + act.Should().Throw() + .WithMessage("*learnstack_app*") + .And.Message.Should().Contain("learnstack_platform"); + } + + [Fact] + public void An_Absent_Credential_Names_The_Key_Rather_Than_Degrading() + { + // It must not fall back to the application role. A bypass that silently became a + // tenant-scoped read would return nothing and read as missing data — which is the + // degradation Database Standards rules out by name. + var act = () => LearnStack.Api.Composition.PersistenceCompositionExtensions + .BuildPlatformDataSource(null); + + act.Should().Throw() + .WithMessage("*ConnectionStrings:PlatformAdmin*"); + } + + /// + /// The platform data source as the composition root builds it. + /// + /// + /// Through BuildPlatformDataSource and never NpgsqlDataSource.Create: + /// the builder installs the physical-connection initializer that asserts the + /// connected role actually bypasses row security, and a suite that created its own + /// data source would never run it. Measured — with the suite creating its own, + /// swapping that initializer for the application one, which refuses every bypassing + /// role, left all six cases green. + /// + private static NpgsqlDataSource PlatformSource(string connectionString) => + LearnStack.Api.Composition.PersistenceCompositionExtensions + .BuildPlatformDataSource(connectionString); + + private static PlatformAdminScope Build(NpgsqlDataSource dataSource) => + new(new PermissiveGate(), + new Lazy(() => dataSource), + NullLogger.Instance); + + private static async Task CountOrganizationsAsync( + System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction? transaction) => + await ScalarAsync(connection, transaction, "SELECT count(*) FROM organizations"); + + private static async Task ScalarAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction? transaction, + string sql, + params (string Name, object Value)[] parameters) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + command.Transaction = transaction; + + foreach (var (name, value) in parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } + + return (T)(await command.ExecuteScalarAsync())!; + } + + /// Permits entry, so the connection is what the case observes. + private sealed class PermissiveGate : IPlatformAdminGate + { + public ValueTask IsPermittedAsync( + string reason, CancellationToken cancellationToken = default) => + ValueTask.FromResult(true); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs index 1780b270..8cfa668c 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -104,6 +104,20 @@ public sealed class PostgresFixture : IAsyncLifetime /// BYPASSRLS; only the outbox dispatcher's equivalent. public string OutboxConnectionString => For("learnstack_outbox_admin", OutboxPassword); + /// + /// The container's superuser. Not for any test that asserts about isolation. + /// + /// + /// It exists for the one thing no four-role credential can do: change a role's own + /// attributes, so a test can take BYPASSRLS off learnstack_platform and + /// prove the guard that refuses a non-bypassing platform credential actually fires. + /// learnstack_migration cannot — it holds neither CREATEROLE nor + /// ADMIN on the role, which is itself the four-role model working. Any test + /// that reads tenant data through this connection would pass with every policy + /// inert and prove nothing, which is the rule CLAUDE.md states by hand. + /// + public string SuperuserConnectionString => For("postgres", "postgres"); + public async Task InitializeAsync() { await _container.StartAsync(); diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/PlatformAdminGateTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/PlatformAdminGateTests.cs new file mode 100644 index 00000000..1d73052d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/PlatformAdminGateTests.cs @@ -0,0 +1,105 @@ +using FluentAssertions; +using LearnStack.Infrastructure.MultiTenancy; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.MultiTenancy; + +/// +/// What stands between a caller and a BYPASSRLS connection. +/// +/// +/// No Docker: every case here is refused before a connection is opened, which is the +/// property under test. The Lazy throws if forced, so a case that completes at all +/// is a case where nothing reached the credential. +/// +public sealed class PlatformAdminGateTests +{ + [Fact] + public async Task The_Registered_Gate_Permits_Nobody() + { + // Nothing else instantiates this type, so without this the only gate behaviour + // the corpus exhibits is the permissive double the Docker suite uses. Its own doc + // says it exists so nobody makes the default permissive to unblock a demo; this + // is the line that would notice. + var gate = new DenyAllPlatformAdminGate(); + + (await gate.IsPermittedAsync("anything", CancellationToken.None)).Should().BeFalse(); + (await gate.IsPermittedAsync("gdpr-redaction:some-user", CancellationToken.None)) + .Should().BeFalse("a plausible reason is not a permission"); + } + + [Fact] + public async Task Entry_Is_Refused_Before_The_Credential_Is_Touched() + { + // ADR-0036 asks for the permission to be "checked before the scope opens". A + // check after the connection exists would already have spent a BYPASSRLS + // connection on a caller who was never allowed one — and, in a deployment with no + // platform credential at all, would report the wrong failure entirely. + var scope = Build(new DenyAllPlatformAdminGate(), out var dataSource); + + var act = async () => await scope.EnterAsync("test:denied", CancellationToken.None); + + await act.Should().ThrowAsync(); + dataSource.IsValueCreated.Should().BeFalse("the gate runs before the credential"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task A_Blank_Reason_Is_Refused_Before_Anything_Else(string? reason) + { + // The reason is the value Packet 9 writes durably, and a blank one makes the + // record of a cross-tenant access say nothing about why it happened. Refused + // ahead of the gate so the failure names the caller's own mistake rather than a + // permission they may well hold. + var scope = Build(new PermissiveGate(), out var dataSource); + + var act = async () => await scope.EnterAsync(reason!, CancellationToken.None); + + await act.Should().ThrowAsync(); + dataSource.IsValueCreated.Should().BeFalse(); + } + + [Fact] + public async Task A_Permitted_Caller_Reaches_The_Credential_And_Fails_There_When_It_Is_Absent() + { + // The other side of the ordering: with the gate open, the next thing that can go + // wrong is the credential, and it must surface as the credential rather than as + // anything else. + var scope = Build(new PermissiveGate(), out var dataSource); + + var act = async () => await scope.EnterAsync("test:permitted", CancellationToken.None); + + // InvalidOperationException and not PlatformAdminScopeDeniedException — the two + // are unrelated types, so this assertion already distinguishes "refused entry" + // from "entry allowed, credential missing". + // The exception's TYPE is the evidence, not IsValueCreated: when a Lazy factory + // throws, Lazy caches the failure and leaves IsValueCreated false — measured. + // So "reached the credential" is shown by the failure being the credential's, + // which is exactly what distinguishes it from the refusal above. + (await act.Should().ThrowAsync()) + .Which.Message.Should().Contain("ConnectionStrings:PlatformAdmin"); + dataSource.IsValueCreated.Should().BeFalse( + "a Lazy whose factory threw never records a created value"); + } + + private static PlatformAdminScope Build( + IPlatformAdminGate gate, out Lazy dataSource) + { + dataSource = new Lazy(() => throw new InvalidOperationException( + "ConnectionStrings:PlatformAdmin is not configured in this test.")); + + return new PlatformAdminScope(gate, dataSource, NullLogger.Instance); + } + + private sealed class PermissiveGate : IPlatformAdminGate + { + public ValueTask IsPermittedAsync( + string reason, CancellationToken cancellationToken = default) => + ValueTask.FromResult(true); + } +} diff --git a/docs/glossary.md b/docs/glossary.md index 013bcc13..a565e2aa 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -130,7 +130,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`TenantContextFactory`** | The single entry point that constructs the sealed `TenantContext`: it returns `Result.Fail` on any disagreement between the signals and never a partially populated context. `TenantContext` has no public constructor; `TenantContext_Is_Constructed_Only_By_The_Factory` enforces both halves. Per [ADR-0036 § The reconciliation matrix](decisions/0036-tenant-resolution-trusted-inputs.md). | | **`IOrganizationScopeValidator`** | The reader that answers "does this organization belong to this tenant", resolving `organizations` by the composite key `(tenant_id, id)` in its own short read-only transaction that sets `app.tenant_id` as its first statement — one of the sanctioned out-of-band setters of that GUC ([Security Standards § The out-of-band setters](standards/11-security.md), per [ADR-0040 Amendment 3](decisions/0040-ambient-unit-of-work.md)). A valid organization id from another tenant is a mismatch, not an override. | | **`DenyAllTenantMembershipReader`** | The Packet 7 `ITenantMembershipReader` that denies every membership question, so the reconciliation matrix's rows 7 and 14 fail closed until [Phase 03](roadmap/phase-03-identity-admin.md) ships `Membership`. It makes the Studio tenant switcher 404 for everyone in that window; that is correct and it will look like a bug. Per [ADR-0036](decisions/0036-tenant-resolution-trusted-inputs.md). | -| **`EnterPlatformAdminScope`** | The explicit, scoped, audited entry to cross-tenant access: a DI scope whose `DbContext` is built on a second, separately-credentialed data source that connects as `learnstack_platform` — never `SET ROLE`, which would make the four-role separation a naming convention rather than a boundary. Not one of the `ITenantContextAccessor` writers. See [Database Standards § How `EnterPlatformAdminScope(reason)` reaches `learnstack_platform`](standards/05-database.md). | +| **`EnterPlatformAdminScope`** | The explicit, scoped entry to cross-tenant access: a connection and transaction opened on a second, separately-credentialed data source that connects as `learnstack_platform` — never `SET ROLE`, which would make the four-role separation a naming convention rather than a boundary. Not one of the `ITenantContextAccessor` writers, and not an out-of-band setter of `app.tenant_id` — a `BYPASSRLS` role has no policy to announce to. **Logged, not yet audited:** Packet 7 records entry through `ILogger` at `Warning`; the durable `SecurityEvent` row arrives with `audit_log` in Packet 9. The C# member is `IPlatformAdminScope.EnterAsync`; `EnterPlatformAdminScope(reason)` is how the corpus names the path. See [Database Standards § How `EnterPlatformAdminScope(reason)` reaches `learnstack_platform`](standards/05-database.md). | ## Extension Model diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index d99ba3dc..ce823bab 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -702,10 +702,20 @@ Four things the matrix cannot express, and one it must not be asked to: composition root registers a second, separately-credentialed `NpgsqlDataSource` from `ConnectionStrings:PlatformAdmin` as a keyed singleton whose only sanctioned consumer is `LearnStack.Infrastructure.MultiTenancy.PlatformAdminScope`. -`EnterPlatformAdminScope(reason)` opens a DI scope whose `DbContext` is built on that -data source and returns an `IAsyncDisposable` handle. Module code cannot resolve it; +`IPlatformAdminScope.EnterAsync(reason, …)` opens a **connection and a transaction** on +that data source and returns an `IAsyncDisposable` handle carrying both; disposing without +committing rolls back. Module code cannot resolve the data source; `Platform_DataSource_Resolved_Only_By_PlatformAdminScope` enforces that. +An earlier wording here — and in the glossary — said the scope opens "a DI scope whose +`DbContext` is built on that data source". A connection is what shipped, for a reason +worth keeping: every module `DbContext` is bound to `IUnitOfWork.Connection`, which comes +from the application data source the composition root guards by name to be +`learnstack_app`, so a DI-resolved context on the platform source cannot be built without +reopening that guard. Nothing is foreclosed — EF can be constructed on the handle's +connection by whoever needs it — and ADR-0003, which is the Accepted authority, says only +"a second connection". + `SET ROLE` is rejected on three grounds: 1. **Membership is a standing capability.** Once `learnstack_app` is a member of diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 5a354676..f3cbdb0b 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -857,8 +857,13 @@ first two rows are coverage checks; the last three are the proof. - **Source:** [05-database.md § How `EnterPlatformAdminScope(reason)` reaches `learnstack_platform`](05-database.md). - **Type:** NetArchTest + DI registration inspection. **Kind:** structural. -- **Status:** **Awaiting backfill.** **Phase:** 02a Packet 7. - +- **Status:** **Implemented** (`PlatformAdminScopeConventionTests`, Packet 7 step 7). **Phase:** 02a Packet 7. +- **Note:** three legs, all live. The keyed-resolution scan, a scan that connection + strings are read in exactly one file, and a self-check that the scan matched something + at all — a two-path allow-list matching nothing would pass vacuously. This is the + repository's first keyed DI registration, so the scan is the whole boundary: the key + is a public const because `GetKeyedServices(KeyedService.AnyKey)` reaches a keyed + registration whatever the key is spelled, so hiding the string buys nothing. #### `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` - **Asserts:** every entity marked `[TenantOwned]` (or implementing `ITenantOwned`) @@ -919,8 +924,12 @@ first two rows are coverage checks; the last three are the proof. [05-database.md § Forbidden](05-database.md). - **Type:** xUnit + source scan; the permitted paths are a list inside the scan, not a call-site marker. **Kind:** structural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`PlatformAdminScopeConventionTests`, Packet 7 step 7). - **Phase:** 02a (Packet 7). +- **Note:** a live negative — nothing under `backend/src` calls `IgnoreQueryFilters` + today, and the rule exists so the first call is a deliberate edit to the exemption + rather than a quiet one at a call site. A path check with no marker, deliberately: a + comment is what a reviewer skims past. #### `AllowsUnresolvedTenantContext_Only_On_Provisioning_Commands` @@ -2159,8 +2168,16 @@ structural test proves — and what it does not. - **Asserts:** `EnterPlatformAdminScope(reason)` cannot open without an authenticated principal holding a Platform-scope permission, and no handler carries both `[AllowsUnresolvedTenantContext]` and a platform-scope entry. - **Source:** ADR-0036 § The platform-admin override is not a resolution source. - **Type:** xUnit. **Kind:** behavioural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`PlatformAdminScopeConventionTests`, Packet 7 step 7) — conjunct A only. - **Phase:** 02a Packet 7. +- **Note:** **conjunct A is live, conjunct B is doubly vacuous, and the difference + matters.** Live: the gate port exists, the registered implementation refuses everyone, + the scope consults it, and no second implementation has appeared beside it — which is + how a permissive default actually arrives, registered elsewhere for a demo. The + ordering — gate before the credential is touched — is behavioural and asserted in + `PlatformAdminGateTests`, which a structural rule cannot see. Vacuous: there is no + permission to hold until Phase 03 and no production caller enters the scope, so + nothing exercises a permitted entry. - **Note:** only the second conjunct is live in Packet 7 — no handler carries both `[AllowsUnresolvedTenantContext]` and a platform-scope entry. The entry gate itself holds as a **negative** until [Phase 03](../roadmap/phase-03-identity-admin.md): From ba12b05c0b2d5688735bf767de563bc340accf49 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 19:10:01 +0300 Subject: [PATCH 22/55] fix(tenancy): always return the bypass connection; fence a spent handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real defects, all in the parts a later step inherits rather than the parts this step proves, and all measured. A COMMIT that faults stranded the one BYPASSRLS connection in the process. The handle marked itself committed AFTER the await, so a faulted commit left the flag false, disposal issued ROLLBACK on a transaction already over, that threw, and both disposals were skipped — measured, a pool of three exhausted after three entries, with the bookkeeping exception replacing the caller's real one. NpgsqlUnitOfWork states the rule twenty files away: resolve before the await, dispose in a finally, and leave a faulted commit Indeterminate rather than attempting to undo an outcome nobody knows. This now does the same. A resolved handle stayed usable. Measured: after a successful commit the connection was still open, a plain SELECT on it returned every tenant's rows in autocommit, and a write issued there survived DisposeAsync — the exact opposite of what the type's own doc promised. Both accessors are fenced; fencing Transaction alone would have left the autocommit hole open. The gate-uniqueness rule scanned only the assembly declaring the interface, so a permissive gate in Infrastructure or Api — precisely the "registered elsewhere for a demo" its own message names — was invisible. My mutation of it passed because I planted the probe in SharedKernel. It sweeps every production assembly now, and a probe in either of the two places it was blind to fails. Two coverage gaps closed. Gutting CommitAsync to a no-op left the whole suite green, because every case only ever checked that things did NOT survive; and deleting the absent-credential guard left its test green, because control fell through to a second message that also names the key — so the assertion is on text unique to the branch now. I also added a capability that already existed. PostgresFixture has ExecuteAsSuperuserAsync, whose own remarks name ALTER ROLE ... BYPASSRLS as the case it exists for; I added a raw superuser connection string beside it, which is strictly wider. Removed, and the one caller routes through the helper. Four documentation claims corrected, three of them written by this step. The Docker suite's class doc named DROP POLICY as its falsifier — measured, that leaves every case green, because FORCE RLS with no policy is default-deny and dropping it makes the application side see LESS; the mutations that do falsify are DISABLE ROW LEVEL SECURITY and a second permissive policy, the ADR-0003 Amendment 3 defect. Standards 05 still offered the audit row as a live mitigation four paragraphs below the section this step rewrote. The composition root's class remark still called the key it now reads "deliberately absent". And the catalogue carried two Notes assigning "live" to opposite conjuncts, one written when the rule was Registered and one when it landed. The fourth rule this step shipped had no catalogue row at all, so its canonical name lived only in the test file. One thing stated rather than fixed: with the rollback now guarded, the commit ordering is no longer independently observable — no test here fails if it is reverted, and none pretends to. What it still buys is the semantics. 1126 green, zero skips. Six mutations measured across the two fixes and the widened sweep. ADR: 0003, 0033, 0036, 0040 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-architecture-test/SKILL.md | 6 +- .../PersistenceCompositionExtensions.cs | 20 +- .../MultiTenancy/PlatformAdminScope.cs | 108 +++++++++-- .../Tenancy/IPlatformAdminScope.cs | 18 +- .../PlatformAdminScopeConventionTests.cs | 45 ++++- .../Database/PlatformAdminScopeTests.cs | 181 ++++++++++++++++-- .../Database/PostgresFixture.cs | 13 -- .../MultiTenancy/PlatformAdminGateTests.cs | 7 +- docs/architecture/09-tenant-isolation.md | 2 +- docs/standards/05-database.md | 10 +- .../21-architecture-tests-catalogue.md | 43 +++-- 11 files changed, 364 insertions(+), 89 deletions(-) diff --git a/.claude/skills/add-architecture-test/SKILL.md b/.claude/skills/add-architecture-test/SKILL.md index 21acef7f..21cfca80 100644 --- a/.claude/skills/add-architecture-test/SKILL.md +++ b/.claude/skills/add-architecture-test/SKILL.md @@ -110,7 +110,7 @@ Patterns to follow: ### Step 4: Common architecture-test families -**The shipped set is eight files, not a family per topic.** Add yours to the one whose +**The shipped set is ten files, not a family per topic.** Add yours to the one whose subject it shares: | File | What it covers | @@ -122,6 +122,8 @@ subject it shares: | `TenantContextConstructionTests.cs` | How a tenant context comes into existence and who may write it: the factory's single entry point, the constructor's one call site, the enumerated accessor writers, and the composite-key organization read. | | `ApiConventionTests.cs` | Live majors, forwarded headers, required `Deployment:Mode`, unversioned route prefixes. | | `CrossCuttingFoundationTests.cs` | Pipeline order, `Result` returns, topic naming, and the direct-reference bans (Sentry, `DeploymentMode`, `IEventBus`, provider SDK exceptions). | +| `RequestSurfaceTests.cs` | What the step-4 authority ceiling admits: the two request markers, their permitted sets, the shape of the attributes themselves, and the ban on request shapes MediatR runs with no pipeline. | +| `PlatformAdminScopeConventionTests.cs` | The single sanctioned `BYPASSRLS` path: who may resolve the keyed platform data source, where connection strings are read, the entry gate, and what the scope must not touch. | | `RepositoryLayoutTests.cs` | `No_Source_Folder_Named_Verticals` and the single-frontend-app rule. | Rules for surfaces no file covers yet — audit, permissions, entitlement, event bus, @@ -129,7 +131,7 @@ Hub contract — are **Registered** in [the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) against the phase that ships the code they inspect. Check its Status line before assuming a net is under you, and create a new file only when your rule's subject is not one of -the eight above. +the ten above. > **The tenancy rules live in three files, and the split is by subject, not by ADR.** > All three cite ADR-0036, so "put it with the other ADR-0036 rules" is not a usable diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 27237b39..7c249a64 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -51,12 +51,20 @@ namespace LearnStack.Api.Composition; /// first moment its absence means anything. /// /// -/// ConnectionStrings:PlatformAdmin and -/// ConnectionStrings:OutboxDispatcher are deliberately absent. They are -/// keyed data sources reachable only from PlatformAdminScope and the -/// outbox dispatcher, and they land with their consumers — Packet 7 and Phase -/// 02b — under -/// Platform_DataSource_Resolved_Only_By_PlatformAdminScope. +/// ConnectionStrings:PlatformAdmin is read here, and inversely guarded: +/// it names learnstack_platform, which is a bypassing role, so it gets +/// the mirror of the check above — a platform credential that does not bypass would +/// make every cross-tenant read come back filtered to nothing and look like missing +/// data. It is registered keyed and unconditionally, behind a +/// Lazy<NpgsqlDataSource>, so a deployment that performs no cross-tenant +/// operation boots with no such credential at all and the failure — when one is +/// finally needed — names the key rather than a container type. +/// Platform_DataSource_Resolved_Only_By_PlatformAdminScope holds the +/// resolution boundary. +/// +/// +/// ConnectionStrings:OutboxDispatcher is still deliberately absent; it lands +/// with its consumer in Phase 02b. /// /// public static class PersistenceCompositionExtensions diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs index 8840eb96..2673a39d 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs @@ -88,7 +88,7 @@ public async Task EnterAsync( LogEntered(_logger, reason, callerMember ?? "", ShortPath(callerFile), callerLine, null); var transaction = await connection.BeginTransactionAsync(cancellationToken); - return new Handle(connection, transaction); + return new Handle(connection, transaction, _logger); } catch { @@ -130,17 +130,18 @@ private static string ShortPath(string? path) + "Cross-tenant access under learnstack_platform."); /// One entry's connection and transaction. - private sealed class Handle(NpgsqlConnection connection, DbTransaction transaction) + private sealed class Handle( + NpgsqlConnection connection, DbTransaction transaction, ILogger logger) : IPlatformAdminScopeHandle { - private bool _committed; + private bool _resolved; private bool _disposed; public DbConnection Connection { get { - ObjectDisposedException.ThrowIf(_disposed, this); + EnsureUsable(); return connection; } } @@ -149,16 +150,35 @@ public DbTransaction Transaction { get { - ObjectDisposedException.ThrowIf(_disposed, this); + EnsureUsable(); return transaction; } } public async Task CommitAsync(CancellationToken cancellationToken = default) { - ObjectDisposedException.ThrowIf(_disposed, this); + EnsureUsable(); + + // Marked resolved BEFORE the await. Two things depend on the ordering and + // only one of them is observable, which is worth saying rather than + // implying. A COMMIT that faults at the server — + // a deferred constraint, a serialization failure — leaves the outcome + // genuinely unknown, and ADR-0033 calls that state Indeterminate rather + // than failed. Setting the flag afterwards would leave it false, so + // disposal would issue ROLLBACK on a transaction that is already over, + // which throws, which skips both disposals, which strands a BYPASSRLS + // connection outside the pool for the life of the process — measured, and + // it also replaces the caller's real PostgresException with a bookkeeping + // one. NpgsqlUnitOfWork nulls its transaction before the same await for the + // same reason. + // + // The catch in DisposeAsync makes that leak impossible on its own, so with + // both present this ordering is not independently observable — no test here + // fails if it is reverted, and none pretends to. What it still buys is the + // semantics: a faulted commit is Indeterminate, and attempting to undo an + // outcome nobody knows is a different claim from declining to. + _resolved = true; await transaction.CommitAsync(cancellationToken); - _committed = true; } public async ValueTask DisposeAsync() @@ -170,16 +190,76 @@ public async ValueTask DisposeAsync() _disposed = true; - // Transaction first, then connection, and an uncommitted transaction rolls - // back: a frame that ended without committing has failed, and leaving its - // writes to a later decision is how a partial cross-tenant mutation ships. - if (!_committed) + try { - await transaction.RollbackAsync(); + // Only an ABANDONED frame is rolled back. A frame that ended without + // resolving has failed, and leaving its writes for a later decision is + // how a partial cross-tenant mutation ships. A faulted commit is not + // that case and is deliberately left alone. + // + // The explicit rollback stays rather than relying on disposal: this + // field is a DbTransaction, whose base DisposeAsync delegates to an + // empty Dispose(bool). Rolling back on dispose is NpgsqlTransaction's + // own override, so dropping the line would make a cross-tenant rollback + // depend on the runtime type behind a base-class reference. + if (!_resolved) + { + try + { + await transaction.RollbackAsync(); + } + catch (Exception failure) when (failure is InvalidOperationException or NpgsqlException) + { + // A connection already broken by the failure being cleaned up + // after is the ordinary way here. Logged and swallowed, because + // throwing from disposal replaces whatever the caller was + // actually failing on. + LogRollbackFailed(logger, failure); + } + } + + await transaction.DisposeAsync(); } + finally + { + // In a finally. Nothing above may strand the one connection in this + // process that sees every tenant. + await connection.DisposeAsync(); + } + } - await transaction.DisposeAsync(); - await connection.DisposeAsync(); + /// + /// Refuses a handle that is disposed or already resolved. + /// + /// + /// Resolved is terminal, and fencing Connection is the half that + /// matters. Measured: after a successful commit the connection is still + /// open, so a statement issued on it runs in autocommit — on a + /// BYPASSRLS connection, with no transaction to undo it. A write there + /// survives DisposeAsync, which is the exact opposite of what this + /// type's contract promises. Fencing only Transaction would leave that + /// hole open. + /// + private void EnsureUsable() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_resolved) + { + throw new InvalidOperationException( + "This platform-admin scope has been resolved and is no longer usable. " + + "A caller needing further cross-tenant work takes a fresh scope: the " + + "connection bypasses Row Level Security, and a statement issued after " + + "the commit runs in autocommit, so disposal cannot roll it back."); + } } } + + private static readonly Action LogRollbackFailed = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(7002, nameof(LogRollbackFailed)), + "Platform-admin scope could not roll back an abandoned transaction. The " + + "connection is still returned to the pool; the server ends the transaction " + + "when it closes."); } diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs index 8468ee20..768f04ba 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs @@ -86,9 +86,10 @@ Task EnterAsync( /// connection by whoever needs it. /// /// -/// Disposing without committing rolls back. The same posture the ambient unit of -/// work takes, and it matters more here: a leaked handle holds a BYPASSRLS pooled -/// connection for the life of the request. +/// Disposing without committing rolls back, and the connection is returned whatever +/// happens. The same posture the ambient unit of work takes, and it matters more +/// here: a leaked handle holds the one BYPASSRLS connection in the process, and +/// stranding it outside the pool costs the process rather than the request. /// /// /// System.Data.Common types rather than Npgsql ones because this assembly @@ -104,6 +105,15 @@ public interface IPlatformAdminScopeHandle : IAsyncDisposable /// The transaction every command in this scope runs on. DbTransaction Transaction { get; } - /// Commits. Without it, disposal rolls back. + /// Commits, and ends the handle's usefulness. + /// + /// An abandoned frame — disposed without this — is rolled back. A + /// faulted commit is not: the server-side outcome is genuinely unknown, and + /// ADR-0033 calls that state Indeterminate rather than failed. Either way the handle + /// is finished: reading or + /// afterwards throws, because the connection is still open and a statement issued on + /// it would run in autocommit, on a credential that sees every tenant, with nothing + /// left to undo it. + /// Task CommitAsync(CancellationToken cancellationToken = default); } diff --git a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs index 9f62c04b..5392e8cb 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs @@ -10,6 +10,23 @@ namespace LearnStack.Tests.Architecture; /// public sealed class PlatformAdminScopeConventionTests { + /// + /// Every LearnStack.* assembly under backend/src, loaded without a + /// null filter. + /// + /// + /// Derived from the tree for the reason the sibling rules record: a listed sweep + /// silently stops asserting where the list stops, and a rule that counts a hole + /// cannot afford to. An assembly that fails to load is a missing project reference, + /// and the right outcome is a red build naming it rather than a smaller scan. + /// + private static IEnumerable ProductionAssemblies() => + Directory.EnumerateFiles( + RepositoryPaths.BackendSrc(), "LearnStack.*.csproj", SearchOption.AllDirectories) + .Select(Path.GetFileNameWithoutExtension) + .Where(name => !string.IsNullOrEmpty(name)) + .Select(name => Assembly.Load(name!)); + private const string ScopeFile = "LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs"; private const string CompositionFile = @@ -40,10 +57,13 @@ public void Platform_DataSource_Resolved_Only_By_PlatformAdminScope() "the scope injects the keyed source and the composition root registers it; a " + "third resolver is a second path to a credential that sees every tenant"); - // Leg 2 — every connection string in the solution is read in one file. A second - // reader of ConnectionStrings:PlatformAdmin would be a second data source, built - // without the initializer that asserts the role actually bypasses — and the - // symptom of that is not an error but fewer rows. + // Leg 2 — IConfiguration.GetConnectionString is called in exactly one file under + // backend/src. Not "every credential is read in one file": the two design-time + // DbContext factories read ConnectionStrings__Migration straight from the + // environment, deliberately, because dotnet ef builds no host. What this pins is + // that a second reader of ConnectionStrings:PlatformAdmin would be a second data + // source built without the initializer asserting the role actually bypasses — + // and the symptom of that is not an error but fewer rows. // // The needle is the READ, not the word "PlatformAdmin": that word is also the // type names, so scanning for it flagged the two SharedKernel contracts, which @@ -53,9 +73,10 @@ public void Platform_DataSource_Resolved_Only_By_PlatformAdminScope() [CompositionFile], "one file reads credentials, so one file decides what is done with them"); - // Leg 3 — the scan itself found something. A two-path allow-list that matches - // nothing passes, which is the failure a sibling rule already records: a narrowed - // sweep does not fail, it passes over less code. + // Leg 3 — the scan itself found something. An allow-list assertion is satisfied + // by an empty result, so a scan that silently stopped matching would read as + // compliance; this is the failure a sibling rule records in its own words, that a + // narrowed sweep does not fail but passes over less code. resolvers.Should().NotBeEmpty("a scan matching nothing would satisfy leg 1 vacuously"); } @@ -97,10 +118,18 @@ public async Task PlatformAdminScope_Entry_Requires_Platform_Permission() // the scope at all. What can be asserted now is that no second gate // implementation has appeared to sit beside the deny-all one, because that is how // a permissive default arrives — registered somewhere else, for a demo. - var gates = typeof(IPlatformAdminGate).Assembly.GetTypes() + // Swept across every production assembly, not just the one declaring the + // interface. The first version scanned typeof(IPlatformAdminGate).Assembly — + // SharedKernel — so a permissive gate in Infrastructure or Api, which is exactly + // where "registered elsewhere for a demo" would put one, was invisible to the + // rule whose own message names that scenario. The mutation that was supposed to + // prove this leg passed only because the probe was planted in SharedKernel too. + var gates = ProductionAssemblies() + .SelectMany(assembly => assembly.GetTypes()) .Where(type => type is { IsAbstract: false, IsInterface: false }) .Where(typeof(IPlatformAdminGate).IsAssignableFrom) .Select(type => type.Name) + .Distinct() .ToList(); gates.Should().BeEquivalentTo( diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs index 585ccff0..4a7eb4cf 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs @@ -17,8 +17,18 @@ namespace LearnStack.Tests.Integration.Database; /// test passes identically when every policy is inert — so "the scope sees both tenants" /// proves nothing on its own. What each case below asserts instead is where the /// visibility comes from: that the same query on an application connection sees less, -/// on the same data, at the same moment. A dropped policy would make both sides equal and -/// turn these red, which is the property an isolation-shaped assertion cannot offer. +/// on the same data, at the same moment — a property an isolation-shaped assertion +/// cannot offer. +/// +/// +/// What actually falsifies them, measured — and it is not a dropped policy. +/// Appending DROP POLICY organizations_isolation ON organizations leaves every case +/// here green, because the table is under FORCE ROW LEVEL SECURITY and a +/// policy-less table is default-deny: dropping it makes the application side see +/// less, never more. The two mutations that do turn these red are +/// DISABLE ROW LEVEL SECURITY and a second permissive USING (true) +/// policy — the exact ADR-0003 Amendment 3 defect, where PostgreSQL combines permissive +/// policies with OR. Naming the wrong falsifier is worse than naming none. /// /// /// The scope is constructed directly with a permissive gate double. The shipped gate @@ -154,12 +164,11 @@ public async Task A_Correctly_Named_Role_That_Lost_Bypass_Is_Refused_On_Connect( // application one — which refuses every bypassing role — left all six cases // green. The shared-schema collection serializes its classes, so the window is // this method, and the restore is in a finally. - // The superuser, because no four-role credential may alter a role's attributes — - // learnstack_migration holds neither CREATEROLE nor ADMIN on learnstack_platform, - // which is the four-role model working rather than a gap. Nothing here reads - // tenant data through it. - await using var admin = NpgsqlDataSource.Create(_schema.Postgres.SuperuserConnectionString); - await ExecuteAsync(admin, "ALTER ROLE learnstack_platform NOBYPASSRLS"); + // Through the fixture's existing single-statement helper, whose own remarks name + // ALTER ROLE … BYPASSRLS as the case it exists for. A raw superuser connection + // string in a fixture would be a strictly wider capability than this needs, and + // no four-role credential can do it: learnstack_migration owns tables, not roles. + await _schema.Postgres.ExecuteAsSuperuserAsync("ALTER ROLE learnstack_platform NOBYPASSRLS"); try { @@ -172,7 +181,8 @@ public async Task A_Correctly_Named_Role_That_Lost_Bypass_Is_Refused_On_Connect( } finally { - await ExecuteAsync(admin, "ALTER ROLE learnstack_platform BYPASSRLS"); + await _schema.Postgres.ExecuteAsSuperuserAsync( + "ALTER ROLE learnstack_platform BYPASSRLS"); } // And it connects again once the attribute is back, so the guard is the thing @@ -182,21 +192,145 @@ public async Task A_Correctly_Named_Role_That_Lost_Bypass_Is_Refused_On_Connect( connection.State.Should().Be(System.Data.ConnectionState.Open); } - private static async Task ExecuteAsync(NpgsqlDataSource dataSource, string sql) + [Fact] + public async Task A_Resolved_Handle_Cannot_Be_Used_Again() { - await using var command = dataSource.CreateCommand(sql); - await command.ExecuteNonQueryAsync(); + // Measured before the fence existed: after a successful commit the connection is + // still Open, so a plain SELECT on it returned every tenant's rows — in + // autocommit, on a BYPASSRLS credential, with no transaction left to undo + // anything. A write issued there SURVIVED DisposeAsync, which is the exact + // opposite of what this type's contract promises. + await using var dataSource = PlatformSource(_schema.Postgres.PlatformConnectionString); + await using var handle = await Build(dataSource).EnterAsync( + "test:terminal", CancellationToken.None); + + await handle.CommitAsync(CancellationToken.None); + + var readConnection = () => handle.Connection; + var readTransaction = () => handle.Transaction; + var commitAgain = async () => await handle.CommitAsync(CancellationToken.None); + + readConnection.Should().Throw( + "fencing Transaction alone would leave the autocommit hole open"); + readTransaction.Should().Throw(); + await commitAgain.Should().ThrowAsync(); + } + + [Fact] + public async Task Committing_Persists_And_Disposal_Leaves_It_Alone() + { + // The other half of the handle's contract, and the half both named Packet 9 + // consumers need. Measured: gutting CommitAsync to a no-op left the whole suite + // green, because every case here only ever checked that things did NOT survive. + await using var dataSource = PlatformSource(_schema.Postgres.PlatformConnectionString); + var scope = Build(dataSource); + var host = $"committed-{Guid.NewGuid():N}.example.com"; + + try + { + await using (var handle = await scope.EnterAsync( + "test:commit", CancellationToken.None)) + { + await using var insert = handle.Connection.CreateCommand(); + insert.Transaction = handle.Transaction; + insert.CommandText = + """ + INSERT INTO platform_host_to_tenant (host, tenant_id, is_active, is_publicly_live) + VALUES (@host, @tenant, true, true) + """; + insert.Parameters.Add(new NpgsqlParameter("host", host)); + insert.Parameters.Add(new NpgsqlParameter("tenant", SchemaFixture.TenantA)); + await insert.ExecuteNonQueryAsync(CancellationToken.None); + + await handle.CommitAsync(CancellationToken.None); + } + + await using var check = await scope.EnterAsync("test:check", CancellationToken.None); + (await ScalarAsync( + check.Connection, + check.Transaction, + "SELECT count(*) FROM platform_host_to_tenant WHERE host = @host", + [("host", (object)host)])) + .Should().Be(1, "a committed write survives the handle that made it"); + } + finally + { + // The schema fixture is shared and other classes assert exact row counts. + await using var cleanup = await scope.EnterAsync("test:cleanup", CancellationToken.None); + await using var delete = cleanup.Connection.CreateCommand(); + delete.Transaction = cleanup.Transaction; + delete.CommandText = "DELETE FROM platform_host_to_tenant WHERE host = @host"; + delete.Parameters.Add(new NpgsqlParameter("host", host)); + await delete.ExecuteNonQueryAsync(CancellationToken.None); + await cleanup.CommitAsync(CancellationToken.None); + } + } + + [Fact] + public async Task A_Faulted_Commit_Returns_The_Connection_And_Is_Left_Indeterminate() + { + // The leak. A COMMIT that faults leaves the outcome genuinely unknown — ADR-0033 + // calls that Indeterminate — and the obvious ordering, marking the handle + // resolved AFTER the await, leaves the flag false. Disposal then issues ROLLBACK + // on a transaction that is already over, which throws, which skips both + // disposals, which strands the one BYPASSRLS connection in the process outside + // the pool. + // + // The fault is produced by terminating our own backend: a killed connection is a + // real failure mode, it makes both the COMMIT and the following ROLLBACK throw, + // and unlike a constraint violation it is deterministic. A first version tried a + // deferred foreign key and proved nothing — the constraint is not DEFERRABLE, so + // the INSERT failed and the commit was never reached. + var builder = new NpgsqlConnectionStringBuilder(_schema.Postgres.PlatformConnectionString) + { + MaxPoolSize = 3, + Timeout = 5, + }; + + await using var dataSource = PlatformSource(builder.ConnectionString); + var scope = Build(dataSource); + + for (var attempt = 0; attempt < 5; attempt++) + { + var handle = await scope.EnterAsync("test:faulted", CancellationToken.None); + + await using (handle) + { + await using (var suicide = handle.Connection.CreateCommand()) + { + suicide.Transaction = handle.Transaction; + suicide.CommandText = "SELECT pg_terminate_backend(pg_backend_pid())"; + + try + { + await suicide.ExecuteNonQueryAsync(CancellationToken.None); + } + catch (PostgresException) + { + // The server hangs up mid-statement; that is the point. + } + catch (NpgsqlException) + { + } + } + + var commit = async () => await handle.CommitAsync(CancellationToken.None); + await commit.Should().ThrowAsync( + "the caller sees the connection's failure, not a bookkeeping one"); + } + } + + // Five entries through a pool of three: without the disposal in a finally, this + // line blocks until the pool timeout. + await using var afterwards = await scope.EnterAsync("test:after", CancellationToken.None); + afterwards.Connection.State.Should().Be(System.Data.ConnectionState.Open); } [Fact] public void A_Credential_Naming_Another_Role_Is_Refused_Before_Any_Connection() { // The name is not the privilege, so there are two guards and this is the cheap - // one — the mistake an operator actually makes, caught without a socket. The - // server-side half catches a correctly named role that LOST the attribute (a - // re-created role, a restored dump), which is the failure that looks like - // nothing at all: every cross-tenant query simply returns fewer rows. That half - // is evidenced by the cases above running green against the real credential. + // one — the mistake an operator actually makes, caught without a socket. var act = () => LearnStack.Api.Composition.PersistenceCompositionExtensions .BuildPlatformDataSource(_schema.Postgres.AppConnectionString); @@ -208,14 +342,19 @@ public void A_Credential_Naming_Another_Role_Is_Refused_Before_Any_Connection() [Fact] public void An_Absent_Credential_Names_The_Key_Rather_Than_Degrading() { - // It must not fall back to the application role. A bypass that silently became a - // tenant-scoped read would return nothing and read as missing data — which is the - // degradation Database Standards rules out by name. + // It must not fall back to the application role: a bypass that silently became a + // tenant-scoped read would return nothing and read as missing data. + // + // Asserted on text unique to THIS branch, not on the key prefix both messages + // share — measured, deleting the blank guard let control fall into + // ValidatePlatformConnectionString(null), whose message also names the key, so a + // prefix-only assertion passed against the mutant. var act = () => LearnStack.Api.Composition.PersistenceCompositionExtensions .BuildPlatformDataSource(null); act.Should().Throw() - .WithMessage("*ConnectionStrings:PlatformAdmin*"); + .WithMessage("*ConnectionStrings:PlatformAdmin*") + .And.Message.Should().Contain("is not configured").And.Contain(".env.example"); } /// diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs index 8cfa668c..c47f0bae 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -104,19 +104,6 @@ public sealed class PostgresFixture : IAsyncLifetime /// BYPASSRLS; only the outbox dispatcher's equivalent. public string OutboxConnectionString => For("learnstack_outbox_admin", OutboxPassword); - /// - /// The container's superuser. Not for any test that asserts about isolation. - /// - /// - /// It exists for the one thing no four-role credential can do: change a role's own - /// attributes, so a test can take BYPASSRLS off learnstack_platform and - /// prove the guard that refuses a non-bypassing platform credential actually fires. - /// learnstack_migration cannot — it holds neither CREATEROLE nor - /// ADMIN on the role, which is itself the four-role model working. Any test - /// that reads tenant data through this connection would pass with every policy - /// inert and prove nothing, which is the rule CLAUDE.md states by hand. - /// - public string SuperuserConnectionString => For("postgres", "postgres"); public async Task InitializeAsync() { diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/PlatformAdminGateTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/PlatformAdminGateTests.cs index 1d73052d..935df49a 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/PlatformAdminGateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/MultiTenancy/PlatformAdminGateTests.cs @@ -81,8 +81,11 @@ public async Task A_Permitted_Caller_Reaches_The_Credential_And_Fails_There_When // throws, Lazy caches the failure and leaves IsValueCreated false — measured. // So "reached the credential" is shown by the failure being the credential's, // which is exactly what distinguishes it from the refusal above. - (await act.Should().ThrowAsync()) - .Which.Message.Should().Contain("ConnectionStrings:PlatformAdmin"); + // The TYPE, not the message. The Lazy under test is built by this file, so an + // assertion on its text would be matching the double rather than production + // code; the production message is covered where it is produced, in + // PlatformAdminScopeTests.An_Absent_Credential_Names_The_Key_Rather_Than_Degrading. + await act.Should().ThrowAsync(); dataSource.IsValueCreated.Should().BeFalse( "a Lazy whose factory threw never records a created value"); } diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 4fcc41b7..fd12fcd7 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -235,7 +235,7 @@ table repeats the isolation-facing half of each. |------|---------| | `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` | Every entity marked `[TenantOwned]` has a **tenant key** (`TenantId`, or `Id` on the tenant-owned self-keyed class), an EF global query filter referencing it, and — in the migration that creates its table — `ENABLE` **and** `FORCE ROW LEVEL SECURITY` plus exactly one policy carrying both a `USING` and a `WITH CHECK` clause over `app.tenant_id`. A second **permissive** policy on the same table fails the test. | | `Every_OrgScoped_Entity_HasOrgIdAndFilter` | Every entity marked `[OrganizationScoped]` carries a **nullable** `OrganizationId`, an org-aware EF query filter, an organization term `AND`-ed into that same single policy — not a second permissive one — and, in the creating migration, both `AS RESTRICTIVE` write guards, `FOR UPDATE` and `FOR DELETE`. | -| `No_IgnoreQueryFilters_Outside_PlatformAdminScope` | Roslyn source scan: `IgnoreQueryFilters()` appears only inside the audited `EnterPlatformAdminScope(reason)` call path. No marker exempts a call site. | +| `No_IgnoreQueryFilters_Outside_PlatformAdminScope` | xUnit source scan: `IgnoreQueryFilters()` appears only inside the audited `EnterPlatformAdminScope(reason)` call path. No marker exempts a call site. | | `Hangfire_JobPayloads_IncludeTenantId` | Reflection: every `LearnStackJob` subclass's `TParams` has `TenantId`. | | `LearnStackJob_RunAsync_SetsTenantBeforeExecute` | Source-grep + reflection: `RunAsync` is non-virtual; the write to `ITenantContextAccessor.Current` precedes `ExecuteAsync(...)`. | | `No_DirectDaprClient_OutsideInfrastructure` | Roslyn source scan: `Dapr.Client.*` only in `LearnStack.Infrastructure.{Caching, Messaging, Secrets}`. | diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index ce823bab..aad04c7a 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -743,9 +743,13 @@ separate secret path (`learnstack/{deployment}/platform/db-password`) that a dep needing no platform admin simply does not provision, in which case `EnterPlatformAdminScope` throws **on entry** — on the first call, naming the missing `ConnectionStrings:PlatformAdmin`, so a host that never enters the scope still boots, -every test fixture included — rather than degrading to `learnstack_app`; and by an audit -row written **inside** the scope before the operation runs and committed on its own, so -an operation that later fails is still recorded. That row is written as +every test fixture included — rather than degrading to `learnstack_app`; and, **from +[Packet 9](../roadmap/phase-02a-kernel-tenancy.md)**, by an audit row written **inside** +the scope before the operation runs and committed on its own, so an operation that later +fails is still recorded. Until that packet the entry is recorded through `ILogger` at +`Warning` with the reason and the calling site: the path is **logged, not audited**, and +a reader deciding whether cross-tenant access is retained under audit retention today +must not read the third mitigation as already in force. That row is written as `learnstack_platform` and carries the sentinel platform tenant id, because a cross-tenant operation has no tenant of its own and `audit_log` is itself tenant-owned. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index f3cbdb0b..f0a60e85 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -2163,6 +2163,15 @@ structural test proves — and what it does not. then — nothing sets the flag, so nothing sets it from request input — and becomes non-vacuous in Phase 03. +#### `The_Platform_Scope_Writes_No_Tenant_Context_And_Sets_No_Session_Variable` + +- **Asserts:** `PlatformAdminScope.cs` contains none of `set_config(`, `SetTenantContextAsync` or `IUnitOfWork`, with comments and whitespace stripped first. +- **Why it matters:** it pins the complement of two closed sets, and getting either wrong reopens a set an ADR closed. `PlatformAdminScope` is **not** a fifth writer of `ITenantContextAccessor.Current` — [ADR-0036 § Rules](../decisions/0036-tenant-resolution-trusted-inputs.md) names it as explicitly not one, and `SetTenant_Callers_Are_The_Enumerated_Four` covers that globally. It is **not** an eighth out-of-band setter of `app.tenant_id` either: the role bypasses policies, so there is nothing to announce to, and [ADR-0040 Amendment 3](../decisions/0040-ambient-unit-of-work.md) closes that set at seven on the property that every one of them connects as `learnstack_app`. And it must not enlist on the ambient unit of work, which would put the bypass on the request's own connection and leave it there. +- **Source:** ADR-0003; ADR-0036 § Rules; ADR-0040 Amendment 3. +- **Type:** xUnit + source scan. **Kind:** structural. +- **Status:** **Implemented** (`PlatformAdminScopeConventionTests`, Packet 7 step 7). +- **Phase:** 02a Packet 7. + #### `PlatformAdminScope_Entry_Requires_Platform_Permission` - **Asserts:** `EnterPlatformAdminScope(reason)` cannot open without an authenticated principal holding a Platform-scope permission, and no handler carries both `[AllowsUnresolvedTenantContext]` and a platform-scope entry. @@ -2170,22 +2179,26 @@ structural test proves — and what it does not. - **Type:** xUnit. **Kind:** behavioural. - **Status:** **Implemented** (`PlatformAdminScopeConventionTests`, Packet 7 step 7) — conjunct A only. - **Phase:** 02a Packet 7. -- **Note:** **conjunct A is live, conjunct B is doubly vacuous, and the difference - matters.** Live: the gate port exists, the registered implementation refuses everyone, - the scope consults it, and no second implementation has appeared beside it — which is - how a permissive default actually arrives, registered elsewhere for a demo. The - ordering — gate before the credential is touched — is behavioural and asserted in - `PlatformAdminGateTests`, which a structural rule cannot see. Vacuous: there is no - permission to hold until Phase 03 and no production caller enters the scope, so - nothing exercises a permitted entry. -- **Note:** only the second conjunct is live in Packet 7 — no handler carries both - `[AllowsUnresolvedTenantContext]` and a platform-scope entry. The entry gate itself - holds as a **negative** until [Phase 03](../roadmap/phase-03-identity-admin.md): - `AuthorizationBehavior.Handle` is `return next()`, authentication arrives in +- **Note:** **the permission clause is live in its mechanism and vacuous in its subject; + the marker clause is vacuous outright.** Two Notes previously stood here assigning + "live" to opposite clauses — one written when the rule was Registered and one when it + landed — and this replaces both. + + *Mechanism, live:* the gate is a real port, the registered implementation refuses + everyone, `PlatformAdminScope` consults it, and no second implementation exists in any + production assembly — which is how a permissive default actually arrives, registered + elsewhere for a demo. The ordering, gate before the credential is touched, is + behavioural and asserted in `PlatformAdminGateTests`; a structural rule cannot see it. + + *Subject, vacuous:* there is no permission to hold. `AuthorizationBehavior.Handle` is + `return next()`, authentication arrives in [Phase 02b](../roadmap/phase-02b-events-auth.md), and the Platform-scope permission - arrives with the Identity module - ([Tenancy § Permission Matrix](../modules/tenancy/permissions.md)). Packet 7 ships no - caller of the scope, so a gate that refuses everyone blocks nothing this packet ships. + with the Identity module in [Phase 03](../roadmap/phase-03-identity-admin.md). So + nothing exercises a *permitted* entry, and the gate refusing everyone blocks nothing + this packet ships — Packet 9's GDPR redaction is the first real caller and inherits it. + + *Marker clause, vacuous:* no handler carries both `[AllowsUnresolvedTenantContext]` and + a platform-scope entry, because no production request type carries either. #### `Development_Only_Tenant_Header_Override_Is_Mode_Guarded` From a41853e2cfd8a196bc67e3b5ed9d9f5287d47c8f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 20:06:57 +0300 Subject: [PATCH 23/55] fix(tenancy): close the credential read the rules could not see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two majors, both about what a later step inherits rather than what this step proves. The keyed-resolution boundary had a hole an ordinary idiom walks through. GetConnectionString(name) is sugar for configuration[$"ConnectionStrings: {name}"], so the indexer form reads the identical value and carries none of the needle the rule scans for — and it is not a contrived evasion: the idiom is already used four times in this solution, for Telemetry:* and Deployment:Mode. A contributor reaching for the familiar pattern would have tripped none of the four rules, could have built a raw NpgsqlDataSourceBuilder with no RequireBypassRole initializer so even a wrong-role credential passed silently, and would have skipped the gate, the reason and the log line. A fifth leg catches it, asserted separately so a failure names the idiom. The corpus's only worked example of this scope would have raised 42501. The GDPR redaction handler in architecture/31 issues its UPDATE on the injected AuditDbContext, which ADR-0040 binds to IUnitOfWork.Connection and therefore keeps on the request's learnstack_app connection whatever scope surrounds it — while the comment directly above it says the redaction runs as learnstack_platform, and the same document revokes UPDATE on audit_log from learnstack_app. It was wrong before this step and this step made it reachable, since Packet 9 is the first real caller and will copy it. It runs on the handle now, commits explicitly, and the inbox write moved outside the scope so nothing reads as though it rides the cross-tenant transaction. Two paths had no test, and one of them was added by the previous fix round. The guarded rollback on an abandoned handle whose connection is already dead — its catch, its filter and its swallow — was unreachable, because the only case that kills a backend does so after the handle is resolved and the only case that abandons one rolls back a healthy connection; narrowing the filter to an unreachable type left all 1126 green. And the Warning line, which is this packet's entire record of a cross-tenant bypass until Packet 9, was observed by nothing: both suites passed NullLogger, so demoting it, renumbering it, or gutting the path shortener each passed. Writing that second test found a real defect. The [Caller*] attributes were on the interface only, and C# fills them from the static type of the receiver — so every caller holding the concrete PlatformAdminScope, which is every test that constructs it, logged at :0. Losing the provenance silently is exactly what the line exists to prevent. They are on the implementation now. And one of my own assertions was too specific: NotContain("/Users/") missed the mutation that keeps every path segment, because Split drops the leading slash and the result reads "Users/cemililik/...". It asserts the shape — at most two segments — which is the property the code actually claims. 1128 green, zero skips. Five mutations measured, including the two the previous round left uncovered. Co-Authored-By: Claude Opus 5 (1M context) --- .../MultiTenancy/PlatformAdminScope.cs | 15 ++- .../PlatformAdminScopeConventionTests.cs | 15 +++ .../Database/PlatformAdminScopeTests.cs | 124 ++++++++++++++++++ docs/architecture/31-audit-subsystem.md | 56 +++++--- 4 files changed, 187 insertions(+), 23 deletions(-) diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs index 2673a39d..f7b3e301 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using System.Runtime.CompilerServices; using LearnStack.SharedKernel.Tenancy; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -55,12 +56,20 @@ public sealed class PlatformAdminScope( logger ?? throw new ArgumentNullException(nameof(logger)); /// + /// + /// The [Caller*] attributes are restated here and not left to the interface. + /// C# fills them in from the static type of the receiver, so a caller holding + /// the concrete — every test that constructs it + /// directly, and anything resolving it by implementation type — would otherwise get + /// the bare defaults and log <unknown> at <unknown>:0. + /// Losing the provenance silently is exactly what this record exists to prevent. + /// public async Task EnterAsync( string reason, CancellationToken cancellationToken = default, - string? callerMember = null, - string? callerFile = null, - int callerLine = 0) + [CallerMemberName] string? callerMember = null, + [CallerFilePath] string? callerFile = null, + [CallerLineNumber] int callerLine = 0) { // The order below is load-bearing, because Packet 9 inherits this call site and // writes its SecurityEvent row where the log line sits. diff --git a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs index 5392e8cb..0a89dd10 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs @@ -73,6 +73,21 @@ public void Platform_DataSource_Resolved_Only_By_PlatformAdminScope() [CompositionFile], "one file reads credentials, so one file decides what is done with them"); + // Leg 2b — the OTHER way to read the same value. GetConnectionString(name) is + // sugar for configuration[$"ConnectionStrings:{name}"], so the indexer form + // carries none of Leg 2's needle — and it is not a contrived evasion: the idiom + // is already used four times in this solution, for Telemetry:* and for + // Deployment:Mode. A contributor reaching for the familiar pattern would trip + // none of these rules, could build a raw NpgsqlDataSourceBuilder with no + // RequireBypassRole initializer — so even a wrong-role credential would pass + // silently — and would skip the gate, the reason and the log line entirely. + // Asserted separately from Leg 2 so a failure names the idiom that caused it. + SourceScan.FilesContaining( + SourceScan.SourceRoot, "ConnectionStrings:PlatformAdmin", except: CompositionFile) + .Should().BeEmpty( + "the indexer form reads the same credential as GetConnectionString and " + + "is the one spelling the other legs cannot see"); + // Leg 3 — the scan itself found something. An allow-list assertion is satisfied // by an empty result, so a scan that silently stopped matching would read as // compliance; this is the failure a sibling rule records in its own words, that a diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs index 4a7eb4cf..1f2fd5a8 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using LearnStack.Infrastructure.MultiTenancy; using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; using Xunit; @@ -326,6 +327,129 @@ await commit.Should().ThrowAsync( afterwards.Connection.State.Should().Be(System.Data.ConnectionState.Open); } + [Fact] + public async Task An_Abandoned_Handle_On_A_Dead_Connection_Still_Returns_It() + { + // The branch the fix round ADDED and nothing reached: an abandoned frame whose + // rollback itself fails. The only other case that kills a backend does so after + // CommitAsync has already resolved the handle, and the only case that abandons + // one rolls back a healthy connection — so the catch, its filter, and the + // LogRollbackFailed swallow were all unexercised. Measured: narrowing the catch + // filter to an unreachable type left all 1126 tests green. + // + // A connection already broken by the failure being cleaned up after is the + // ordinary way to reach this, which is why it is worth a case rather than a + // comment. + var builder = new NpgsqlConnectionStringBuilder(_schema.Postgres.PlatformConnectionString) + { + MaxPoolSize = 3, + Timeout = 5, + }; + + await using var dataSource = PlatformSource(builder.ConnectionString); + var logger = new CapturingLogger(); + var scope = new PlatformAdminScope( + new PermissiveGate(), new Lazy(() => dataSource), logger); + + for (var attempt = 0; attempt < 5; attempt++) + { + var handle = await scope.EnterAsync("test:abandoned", CancellationToken.None); + + await using (var suicide = handle.Connection.CreateCommand()) + { + suicide.Transaction = handle.Transaction; + suicide.CommandText = "SELECT pg_terminate_backend(pg_backend_pid())"; + + try + { + await suicide.ExecuteNonQueryAsync(CancellationToken.None); + } + catch (Exception failure) when (failure is PostgresException or NpgsqlException) + { + } + } + + // No commit: the frame is abandoned, so disposal attempts a rollback that + // cannot succeed. It must not throw, and must not keep the connection. + var dispose = async () => await handle.DisposeAsync(); + await dispose.Should().NotThrowAsync( + "throwing from disposal replaces whatever the caller was actually failing on"); + } + + await using var afterwards = await scope.EnterAsync("test:after", CancellationToken.None); + afterwards.Connection.State.Should().Be(System.Data.ConnectionState.Open, + "every abandoned entry returned its connection through the finally"); + + logger.Entries.Should().Contain(entry => entry.EventId == 7002, + "a rollback that could not run is recorded rather than silently dropped"); + } + + [Fact] + public async Task Entry_Is_Recorded_At_Warning_With_The_Reason_And_The_Caller() + { + // The packet's ENTIRE record of a cross-tenant bypass until Packet 9 ships + // audit_log — and until now every test passed NullLogger, so demoting it to + // Debug, renumbering the EventId, or gutting ShortPath to a constant each left + // the whole suite green. + // + // Bound through the CONCRETE type deliberately. C# fills [Caller*] from the + // static type of the receiver, so the attributes have to be on the + // implementation as well as the interface — they were not, which meant every + // test constructing PlatformAdminScope directly logged "" and the + // provenance this case exists for was never exercised. + await using var dataSource = PlatformSource(_schema.Postgres.PlatformConnectionString); + var logger = new CapturingLogger(); + var scope = new PlatformAdminScope( + new PermissiveGate(), new Lazy(() => dataSource), logger); + + await using var handle = await scope.EnterAsync("test:recorded", CancellationToken.None); + + var entry = logger.Entries.Should().ContainSingle(line => line.EventId == 7001).Subject; + + entry.Level.Should().Be(LogLevel.Warning, + "an operator filtering at Information must still see a cross-tenant bypass"); + entry.Message.Should().Contain("test:recorded"); + entry.Message.Should().Contain(nameof(Entry_Is_Recorded_At_Warning_With_The_Reason_And_The_Caller), + "the calling member is how 'the caller' is known at all with no principal"); + // The property, not a substring of one machine's layout. CallerFilePath is the + // COMPILING machine's absolute path, so logging it whole puts a build-agent + // directory tree into every forwarded line. A first version asserted + // NotContain("/Users/") and missed the mutation that keeps every segment, because + // Split drops the leading slash and the result reads "Users/cemililik/..." — + // the assertion has to be on the shape, which is at most two segments. + var logged = entry.Message.Split(" at ")[1].Split(':')[0]; + + logged.Should().EndWith("PlatformAdminScopeTests.cs"); + logged.Split('/').Should().HaveCountLessThanOrEqualTo(2, + "the file is shortened to its last two segments"); + } + + /// Records what the scope logged, with its level and event id. + private sealed class CapturingLogger : ILogger + { + private readonly List _entries = []; + + public IReadOnlyList Entries => _entries; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + _entries.Add(new Captured(logLevel, eventId.Id, formatter(state, exception))); + } + + internal sealed record Captured(LogLevel Level, int EventId, string Message); + } + [Fact] public void A_Credential_Naming_Another_Role_Is_Refused_Before_Any_Connection() { diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index dd0d4c23..d94650ef 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -845,33 +845,49 @@ public sealed class UserGdprDeletedIntegrationEventHandler( if (await inboxGuard.IsAlreadyProcessedAsync(@event.EventId, ct)) return; // 2. learnstack_app holds no UPDATE privilege on audit_log, so the redaction runs - // as learnstack_platform. Entering the scope is itself a MUST-class audit event. - await using (await platformScope.EnterAsync( - reason: $"gdpr-redaction:{@event.UserId}", ct)) + // as learnstack_platform — on the HANDLE's connection. The injected + // AuditDbContext is bound to IUnitOfWork.Connection (ADR-0040) and stays on + // the request's learnstack_app connection whatever scope surrounds it, so + // issuing the UPDATE through `db` would raise 42501 rather than redact + // anything. Entering the scope is recorded: at Warning today, as a + // SecurityEvent row once Packet 9 ships audit_log. + await using var handle = await platformScope.EnterAsync( + reason: $"gdpr-redaction:{@event.UserId}", ct); + + // 3. Actor PII only. The payload columns are NOT touched here. A blanket + // jsonb_set('{redacted}', 'true') would (a) not redact anything — it adds a + // flag and leaves the PII in place — and (b) raise + // 'cannot set path in scalar' on any snapshot that is a JSON scalar or + // array. Payload redaction belongs to the per-module locator below, which + // knows which JSON paths in its own snapshots reference a user. + await using (var redact = handle.Connection.CreateCommand()) { - // 3. Actor PII only. The payload columns are NOT touched here. A blanket - // jsonb_set('{redacted}', 'true') would (a) not redact anything — it adds a - // flag and leaves the PII in place — and (b) raise - // 'cannot set path in scalar' on any snapshot that is a JSON scalar or - // array. Payload redaction belongs to the per-module locator below, which - // knows which JSON paths in its own snapshots reference a user. - await db.Database.ExecuteSqlInterpolatedAsync($@" + redact.Transaction = handle.Transaction; + redact.CommandText = @" UPDATE audit_log SET actor_email = '[REDACTED]', ip_address = NULL, user_agent = '[REDACTED]' - WHERE actor_user_id = {@event.UserId} - AND tenant_id = {@event.TenantId}", ct); + WHERE actor_user_id = @actor + AND tenant_id = @tenant"; + redact.Parameters.Add(new NpgsqlParameter("actor", @event.UserId)); + redact.Parameters.Add(new NpgsqlParameter("tenant", @event.TenantId)); + await redact.ExecuteNonQueryAsync(ct); + } - // 4. Payload references, per module. Each locator issues column-restricted - // UPDATEs against before_state / after_state / changes only. - foreach (var locator in userReferenceLocators) - await locator.RedactReferencesAsync(@event.UserId, @event.TenantId, ct); + // 4. Payload references, per module. Each locator issues column-restricted + // UPDATEs against before_state / after_state / changes only — on the same + // handle, because they need the same privilege for the same reason. + foreach (var locator in userReferenceLocators) + await locator.RedactReferencesAsync(handle, @event.UserId, @event.TenantId, ct); - // 5. Inbox: mark processed; SaveChanges. - inboxGuard.MarkAsProcessed(@event.EventId, @event.GetType().Name); - await db.SaveChangesAsync(ct); - } + await handle.CommitAsync(ct); + + // 5. Inbox: mark processed; SaveChanges. Ordinary learnstack_app work on the + // ambient transaction, deliberately outside the scope block — it is not part + // of the cross-tenant unit and must not read as though it rides it. + inboxGuard.MarkAsProcessed(@event.EventId, @event.GetType().Name); + await db.SaveChangesAsync(ct); // 6. Meta-audit. The redaction is itself a MUST-class security event, and a log // line is not an audit row — the previous version of this handler logged and From eb64b47af6c677dad295a88c98d5db83a1434d54 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 21:18:14 +0300 Subject: [PATCH 24/55] feat(tenancy): make an unannounced transaction fail loudly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 7 step 8. With app.tenant_id unset every Row Level Security predicate is NULL, so a tenant-owned read returns zero rows and a write is refused — fail-closed already, and quiet. The symptom reaching an operator is missing data, not a fault, and missing data gets investigated as a bug in the feature. TenantContextGuardInterceptor turns that silence into a TenantContextMissingException, and the first test asserts the silence itself so the guard's value is evidence rather than assertion. Keyed on the transaction, not on the table. Some corpus sentences describe it as guarding [TenantOwned] tables; the catalogued rule's own name and the packet plan describe the transaction. Matching table names would put a parser between every query and the database, wrong on the first CTE, to decide something the transaction already answers — every command from a module context belongs to a request that had a tenant to announce. Both standards are narrowed to what shipped. The exemption list is empty, and that is a property rather than an oversight. EF interception sees only commands EF issues, so the set_config pair the setter sends needs no self-exemption, and CachedHostToTenantResolver, OrganizationScopeValidator and PlatformAdminScope are invisible by construction. That last one matters most: it is a BYPASSRLS connection that announces no tenant by design, and a hand-written exemption for it is an exemption someone later widens. Only one of the seven sanctioned setters marks anything, and the catalogue says so rather than implying seven. Four do not exist in code yet; two issue raw NpgsqlCommands. The marker is read through a new IUnitOfWork member, which owes ADR-0040 Amendment 5 — that ADR enumerates the seam member by member, and its Amendment 1 exists because an earlier addition was nearly left silent. It takes the command's transaction rather than returning a flag because the reference check is load-bearing: measured, a pooled data source hands back the same NpgsqlTransaction instance across cycles, so a bare flag would vouch for a later transaction on an earlier one's announcement. One correction only Packet 7 could reveal. TenantContextMissingException carried lockey_tenant_mismatch, which maps to 404 — so the first wiring bug to trip this guard would have reached a client byte-identical to the deliberate refusal an unresolvable host gets, the one response Steps 4 through 6 spent three rounds making indistinguishable on purpose. A server fault hiding inside the anti-oracle 404 is invisible in monitoring. It carries internal_error now. Nothing threw the exception before, so nothing else changes. The blast radius was zero: every EF command in the existing suite already runs on an announced transaction. Which also meant nothing exercised the throw, so eight cases were written for it — and two of them exist because the first pass did not cover what it claimed. Commenting the guard out of the three synchronous overrides left everything green, because EF does not route the blocking APIs through the async ones; and dropping the ReferenceEquals check left everything green, because the reset at BEGIN hides it in every sequential case. Also corrected: a comment claiming UseApplicationServiceProvider is what lets a DI-registered interceptor be found. Measured false on EF Core 10 for both interceptor kinds; an interceptor reaches a context through AddInterceptors, which is the line now beside it. Stated rather than fixed: the flag is set after the round trip so a failed announcement vouches for nothing, and that ordering is not independently observable — making the announcement fail means breaking the connection under it, and the unit's disposal then throws before any assertion is reached. 1136 green, zero skips. Five mutations measured. ADR: 0040 Co-Authored-By: Claude Opus 5 (1M context) --- .../ModuleDbContextRegistration.cs | 16 +- .../Persistence/NpgsqlUnitOfWork.cs | 27 ++ .../TenantContextGuardInterceptor.cs | 127 ++++++++ .../Errors/TenantContextMissingException.cs | 28 +- .../Persistence/IUnitOfWork.cs | 28 ++ .../CrossCuttingFoundationHttpTests.cs | 6 + .../Database/TenantContextGuardTests.cs | 281 ++++++++++++++++++ .../Pipeline/TransactionBehaviorTests.cs | 5 + docs/decisions/0040-ambient-unit-of-work.md | 33 ++ docs/standards/05-database.md | 11 +- docs/standards/11-security.md | 27 +- .../21-architecture-tests-catalogue.md | 27 +- 12 files changed, 589 insertions(+), 27 deletions(-) create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/TenantContextGuardInterceptor.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs index a32b0b0a..7e3140be 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -135,9 +135,21 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle // context this helper registers — on a seam whose whole premise is // that a misconfigured context fails invisibly. Measured: the // model-validation warnings and the command-error lines carrying - // the failing SQL both disappear. It is also what lets a - // DI-registered interceptor be found, which Packet 9 needs. + // the failing SQL both disappear. + // + // It does NOT make a DI-registered interceptor discoverable — an + // earlier version of this comment said it did, and measured on EF + // Core 10 that is false for both interceptor kinds, whether + // registered as IInterceptor or by its own type. An interceptor + // reaches a context by AddInterceptors on the options, which is the + // line below. .UseApplicationServiceProvider(provider) + // Every module context, no opt-out: this is the single site that + // builds them, which is why the guard goes here rather than in each + // module's registration. The interceptor takes THIS scope's unit of + // work, so it compares each command's transaction against the one + // this request actually announced a tenant on. + .AddInterceptors(new TenantContextGuardInterceptor(unitOfWork)) .Options; // ActivatorUtilities, not Activator: a module context takes its diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index 08662f98..7348cb92 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -72,6 +72,8 @@ public sealed class NpgsqlUnitOfWork( private bool _rollbackOnly; private bool _commitRequested; + + private bool _tenantContextIssued; private bool _disposed; public DbConnection Connection @@ -130,6 +132,13 @@ public async Task BeginTransactionAsync( // frame nobody had opened — over whatever exception was already in // flight. _commitRequested = false; + + // Same reasoning, same block. This is the one place that runs exactly once + // per PHYSICAL transaction, so it is where per-transaction state is cleared — + // and nothing clears it on commit, rollback or dispose because those null + // _transaction, after which the reference check below fails for every + // argument. + _tenantContextIssued = false; } return new Frame(this, ++_depth, _generation); @@ -209,8 +218,26 @@ await ExecuteAsync( cancellationToken, ("tenant", tenant), ("organization", organization)); + + // After the round trip, never before: the flag means the announcement actually + // reached PostgreSQL, so a setter that threw leaves the transaction unmarked and + // the guard refuses the commands that would have run under it. + // + // Not independently observable, and said so rather than implied. Making the + // announcement fail means breaking the connection under it, and this unit's + // disposal then throws on the dead transaction before any assertion is reached — + // a pre-existing shape, not this flag's. What the ordering buys is that a + // half-announced transaction is refused rather than trusted; no test here fails + // if the line moves, and none pretends to. + _tenantContextIssued = true; } + /// + public bool IsTenantContextIssuedOn(DbTransaction? transaction) => + transaction is not null + && ReferenceEquals(transaction, _transaction) + && _tenantContextIssued; + private static readonly Action LogResolvedWithoutTenant = LoggerMessage.Define( LogLevel.Error, diff --git a/backend/src/LearnStack.Infrastructure/Persistence/TenantContextGuardInterceptor.cs b/backend/src/LearnStack.Infrastructure/Persistence/TenantContextGuardInterceptor.cs new file mode 100644 index 00000000..b69900d6 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/TenantContextGuardInterceptor.cs @@ -0,0 +1,127 @@ +using System.Data.Common; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace LearnStack.Infrastructure.Persistence; + +/// +/// Refuses any command a module DbContext issues on a transaction no sanctioned +/// setter announced the tenant on. +/// +/// +/// +/// It turns a silence into a failure, and it is not the isolation boundary. With +/// app.tenant_id unset every Row Level Security predicate is NULL, so a +/// tenant-owned read returns zero rows and a write is refused — fail-closed already. The +/// problem is that it is quiet: the symptom reaching an operator is missing data, not a +/// fault, and missing data gets investigated as a bug in the feature. Removing this +/// interceptor removes a diagnostic, never a protection. +/// +/// +/// Keyed on the transaction, not on the table. Some corpus sentences describe it +/// as guarding tenant-owned tables; the rule's own name — +/// Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction — and the packet +/// plan describe the transaction, and that is what shipped. Matching table names would +/// mean parsing command text to decide whether a guard applies, which is a parser +/// standing between every query and the database, wrong on the first CTE. Every command +/// from a module context belongs to a request that had a tenant to announce. +/// +/// +/// It never sees a raw NpgsqlCommand, and that is what keeps the exemption +/// list empty. EF interception covers commands EF itself issues. So the +/// set_config pair NpgsqlUnitOfWork sends needs no self-exemption, and +/// CachedHostToTenantResolver, OrganizationScopeValidator and +/// PlatformAdminScope are invisible by construction — which matters most for the +/// last of those: it is a BYPASSRLS connection that deliberately announces no +/// tenant, and an exemption written by hand is an exemption someone widens. +/// +/// +/// Six overrides because EF has no aggregate hook and does not route the +/// synchronous APIs through the asynchronous ones. A LINQ query and a +/// SaveChanges INSERT both arrive on ReaderExecuting; raw SQL arrives on +/// NonQueryExecuting. Covering only one pair would leave the other silent, which +/// is the failure this exists to end. +/// +/// +public sealed class TenantContextGuardInterceptor : DbCommandInterceptor +{ + private readonly IUnitOfWork _unitOfWork; + + public TenantContextGuardInterceptor(IUnitOfWork unitOfWork) + { + ArgumentNullException.ThrowIfNull(unitOfWork); + + _unitOfWork = unitOfWork; + } + + public override InterceptionResult ReaderExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + Guard(command); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Guard(command); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + + public override InterceptionResult NonQueryExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + Guard(command); + return base.NonQueryExecuting(command, eventData, result); + } + + public override ValueTask> NonQueryExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Guard(command); + return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); + } + + public override InterceptionResult ScalarExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + Guard(command); + return base.ScalarExecuting(command, eventData, result); + } + + public override ValueTask> ScalarExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Guard(command); + return base.ScalarExecutingAsync(command, eventData, result, cancellationToken); + } + + /// Throws unless a sanctioned setter announced this command's transaction. + private void Guard(DbCommand command) + { + if (_unitOfWork.IsTenantContextIssuedOn(command.Transaction)) + { + return; + } + + throw new TenantContextMissingException( + "A module DbContext issued a command on a transaction no sanctioned setter " + + "announced app.tenant_id on. Every Row Level Security predicate is NULL " + + "there, so this read would have returned zero rows and this write would " + + "have been refused — safely, and without saying so. The announcement is " + + "IUnitOfWork.SetTenantContextAsync, issued by TransactionBehavior as the " + + "first statement inside the ambient transaction at pipeline step 6; a " + + "command arriving here without it is on a transaction something else " + + "opened. See Security Standards § Tenant Context."); + } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs b/backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs index 6329d678..d4686d6f 100644 --- a/backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs +++ b/backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs @@ -4,16 +4,32 @@ namespace LearnStack.SharedKernel.Errors; /// -/// Thrown when application code requires a resolved tenant context but the -/// ambient ITenantContext.IsResolved is false. Reached only via -/// programmer-error paths — the request pipeline's TenantContextBehavior -/// asserts the context up front, so this exception escapes mainly from -/// background workers / outbox handlers that forgot to populate it. +/// Thrown when a command reaches PostgreSQL on a transaction no sanctioned setter +/// announced the tenant on. /// +/// +/// +/// A programmer error, not a business refusal, which is why it is an exception +/// and why it carries internal_error. Row Level Security already makes the state +/// safe — with app.tenant_id unset every policy predicate is NULL, so a +/// tenant-owned read returns zero rows and a write is refused — but safe and silent, and +/// an empty result set arriving from production is an outage. This is the diagnostic +/// above that, never the boundary itself. +/// +/// +/// It used to carry tenant_mismatch, and that was wrong in a way only Packet 7 +/// could reveal. Nothing threw it before the guard existed. That code maps to +/// 404, so a wiring bug would have reached a client byte-identical to the +/// deliberate refusal an unresolvable host gets — the one response this packet spent +/// two steps making indistinguishable on purpose. A server fault hiding inside the +/// anti-oracle 404 is invisible in monitoring and unactionable in a bug report. +/// internal_error maps to 500, which is what a fault is. +/// +/// public sealed class TenantContextMissingException : LearnStackException { private static readonly Error DefaultError = new( - new LocalizedMessage("lockey_tenant_mismatch")); + new LocalizedMessage("lockey_internal_error")); public TenantContextMissingException(string message, Exception? innerException = null) : base(DefaultError, message, innerException) diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index 03b32110..5645e2e6 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs @@ -97,6 +97,34 @@ public interface IUnitOfWork : IAsyncDisposable /// Task SetTenantContextAsync(ITenantContext context, CancellationToken cancellationToken = default); + /// + /// Whether a sanctioned setter has announced the tenant on + /// . + /// + /// + /// + /// Read by TenantContextGuardInterceptor, which refuses any command a module + /// DbContext issues on an unannounced transaction. Without it the failure is + /// an empty result set — safe, because the policy predicate is NULL, and + /// silent, which is the outage. + /// + /// + /// It takes the command's transaction rather than returning a bare flag, and + /// the reference check against this unit's own live transaction is the load-bearing + /// half. Measured on Npgsql 10: a pooled data source hands back the same + /// NpgsqlTransaction instance across sequential open/begin/dispose cycles, so + /// anything keyed on the transaction object would vouch for a later transaction on + /// the strength of an earlier one's announcement. + /// + /// + /// There is deliberately no writer on this interface. The only thing that may mark a + /// transaction is the code that issues the set_config pair, and it does so + /// after the round trip returns — a failed announcement leaves the transaction + /// unmarked. A module that could set the flag could silence the guard. + /// + /// + bool IsTenantContextIssuedOn(DbTransaction? transaction); + /// /// Resolves the innermost open frame. On the outermost frame this commits — /// unless the unit is marked rollback-only, in which case it throws rather diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index 7c80d7b5..db73fdd9 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -279,6 +279,12 @@ public Task BeginTransactionAsync(CancellationToken cancellati public Task SetTenantContextAsync( ITenantContext context, CancellationToken cancellationToken = default) => Task.CompletedTask; + // False, and not "true because nothing here has a database". This host has none, so + // no transaction is ever announced, and vouching for one would let the guard pass a + // command on a connection that does not exist. AddModuleDbContext refuses to build a + // context here anyway, so nothing asks — but the honest answer is the safe one. + public bool IsTenantContextIssuedOn(System.Data.Common.DbTransaction? transaction) => false; + public Task CommitAsync(CancellationToken cancellationToken = default) { HasActiveTransaction = false; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs new file mode 100644 index 00000000..b2250632 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs @@ -0,0 +1,281 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// The guard that turns an unannounced transaction from a silence into a failure. +/// +/// +/// +/// Connected as learnstack_app. The whole subject is what a request-path +/// connection does when nobody announced its tenant, and a bypass role would answer a +/// different question. +/// +/// +/// These are diagnostic tests, not isolation tests. Row Level Security already +/// makes the unannounced state safe — the first case below proves exactly that, and it +/// is the reason the guard is worth having: safe is not the same as visible, and an +/// empty result set arriving from production gets investigated as a bug in the feature. +/// Removing the interceptor removes a diagnostic, never a protection, and no assertion +/// here should be read as covering isolation. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class TenantContextGuardTests +{ + private readonly SchemaFixture _schema; + + public TenantContextGuardTests(SchemaFixture schema) => _schema = schema; + + [Fact] + public async Task Without_The_Guard_An_Unannounced_Read_Is_Silent_And_Empty() + { + // The state the guard exists for, observed directly rather than described. + // Issued as a raw command, which EF interception cannot see, so this is what the + // application would have got back: rows the tenant owns, filtered to nothing by a + // NULL predicate, with no error anywhere. Safe, and indistinguishable from a + // tenant that simply has no organizations. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var connection = await dataSource.OpenConnectionAsync(CancellationToken.None); + await using var transaction = await connection.BeginTransactionAsync(CancellationToken.None); + + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM organizations", connection, transaction); + + (await read.ExecuteScalarAsync(CancellationToken.None)).Should().Be(0L, + "with app.tenant_id unset every policy predicate is NULL — fail-closed, and " + + "the failure mode is an empty result set rather than an error"); + } + + [Fact] + public async Task An_Announced_Transaction_Is_Let_Through() + { + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await AnnounceAsync(scope.ServiceProvider, unitOfWork, SchemaFixture.TenantA); + + var context = scope.ServiceProvider.GetRequiredService(); + + (await context.Organizations.CountAsync()).Should().BeGreaterThan(0, + "the announcement is what makes the policy admit the tenant's own rows"); + + await unitOfWork.RollbackAsync(); + } + + [Fact] + public async Task An_Unannounced_Read_Throws_Instead_Of_Returning_Nothing() + { + // The same query as above, on a transaction nobody announced. Without the + // interceptor this returns zero rows and says nothing. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + + var context = scope.ServiceProvider.GetRequiredService(); + var act = async () => await context.Organizations.CountAsync(); + + (await act.Should().ThrowAsync()) + .Which.Message.Should().Contain("SetTenantContextAsync", + "the message names the announcement a reader has to go and find"); + + await unitOfWork.RollbackAsync(); + } + + [Fact] + public async Task An_Unannounced_Write_Throws_Too() + { + // EF routes a SaveChanges INSERT through ReaderExecuting rather than + // NonQueryExecuting, so a guard covering only the non-query arms would let every + // write past. Asserted separately from the read for that reason. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + + var context = scope.ServiceProvider.GetRequiredService(); + var act = async () => await context.Database.ExecuteSqlRawAsync( + "UPDATE organizations SET slug = slug WHERE false"); + + await act.Should().ThrowAsync(); + + await unitOfWork.RollbackAsync(); + } + + [Fact] + public async Task A_Nested_Frame_Inherits_The_Announcement() + { + // SetTenantContextAsync returns early for a joiner — re-issuing would let an + // inner frame retarget the outer frame's tenant. So the mark has to belong to the + // transaction, not to the frame, or every nested handler would trip the guard. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await AnnounceAsync(scope.ServiceProvider, unitOfWork, SchemaFixture.TenantA); + + await using (await unitOfWork.BeginTransactionAsync()) + { + var context = scope.ServiceProvider.GetRequiredService(); + var act = async () => await context.Organizations.CountAsync(); + + await act.Should().NotThrowAsync(); + } + + await unitOfWork.RollbackAsync(); + } + + [Fact] + public async Task A_Second_Transaction_Does_Not_Inherit_The_First_Ones_Announcement() + { + // The mark is per physical transaction. Measured on Npgsql 10, a pooled data + // source hands back the SAME NpgsqlTransaction instance across sequential + // cycles — so anything keyed on the transaction object, or a flag nobody cleared + // at BEGIN, would vouch for this second transaction on the strength of the + // first's announcement. That is the bug class the unit of work's own generation + // counter already records shipping once. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + // Committed rather than rolled back: a rollback marks the unit rollback-only for + // its life, so a second transaction on it is refused outright and this scenario + // would be unreachable. A commit is the path the unit's own reset comment + // describes — "a unit that committed and then opened a second transaction". + await using (await unitOfWork.BeginTransactionAsync()) + { + await AnnounceAsync(scope.ServiceProvider, unitOfWork, SchemaFixture.TenantA); + await unitOfWork.CommitAsync(); + } + + await unitOfWork.BeginTransactionAsync(); + + var context = scope.ServiceProvider.GetRequiredService(); + var act = async () => await context.Organizations.CountAsync(); + + await act.Should().ThrowAsync( + "a new transaction is unannounced until someone announces it"); + + await unitOfWork.RollbackAsync(); + } + + [Fact] + public async Task A_Synchronous_Read_Is_Guarded_Too() + { + // EF does not route the synchronous APIs through the asynchronous ones, so the + // six overrides are three independent pairs. Measured: commenting the guard out + // of the three synchronous arms left every other case here green, because they + // all await. A caller using the blocking API would have walked straight past. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + + var context = scope.ServiceProvider.GetRequiredService(); + +#pragma warning disable xUnit1031 // The blocking API is the subject, not an accident. + var read = () => context.Organizations.Count(); + var write = () => context.Database.ExecuteSqlRaw("UPDATE organizations SET slug = slug WHERE false"); +#pragma warning restore xUnit1031 + + read.Should().Throw(); + write.Should().Throw(); + + await unitOfWork.RollbackAsync(); + } + + [Fact] + public async Task An_Announcement_Vouches_For_Its_Own_Transaction_And_No_Other() + { + // The identity half of the check, which the reset at BEGIN hides in every + // sequential case — measured, dropping ReferenceEquals left the whole suite + // green. It matters because Npgsql recycles transaction objects: a pooled data + // source hands back the same NpgsqlTransaction instance across cycles, so a flag + // read without comparing against the unit's OWN live transaction would vouch for + // whatever came next. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await AnnounceAsync(scope.ServiceProvider, unitOfWork, SchemaFixture.TenantA); + + unitOfWork.IsTenantContextIssuedOn(unitOfWork.Transaction).Should().BeTrue(); + unitOfWork.IsTenantContextIssuedOn(null).Should().BeFalse( + "a command outside any transaction is announced by nothing"); + + // Some other transaction, on a connection this unit does not own. + await using var elsewhere = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var otherConnection = await elsewhere.OpenConnectionAsync(CancellationToken.None); + await using var otherTransaction = + await otherConnection.BeginTransactionAsync(CancellationToken.None); + + unitOfWork.IsTenantContextIssuedOn(otherTransaction).Should().BeFalse( + "the announcement is about one transaction, not about the unit's mood"); + + await unitOfWork.RollbackAsync(); + } + + private static async Task AnnounceAsync( + IServiceProvider scope, IUnitOfWork unitOfWork, Guid tenant) + { + var context = new AnnouncedContext(tenant); + scope.GetRequiredService().Current = context; + await unitOfWork.SetTenantContextAsync(context); + } + + private ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); + services.AddLogging(); + services.AddSingleton(); + services.AddTransient(sp => + sp.GetRequiredService().Current + ?? UnresolvedTenantContext.Instance); + services.AddScoped(); + services.AddModuleDbContext(); + return services.BuildServiceProvider(); + } + + private sealed class FlowingAccessor : ITenantContextAccessor + { + public ITenantContext? Current { get; set; } + } + + private sealed class AnnouncedContext(Guid tenant) : ITenantContext + { + public bool IsResolved => true; + + public TenantId TenantId { get; } = TenantId.From(tenant); + + public OrganizationId? OrganizationId => null; + + public UserId? UserId => null; + + public TenantContextOrigin? Origin => TenantContextOrigin.HostAndClaim; + + public string? CorrelationId => null; + + public string? ModuleName => null; + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs index fd60a98e..128477a0 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -313,6 +313,11 @@ private sealed class RecordingUnitOfWork : IUnitOfWork public DbTransaction? Transaction => null; + // This double records the context it was handed rather than announcing anything, + // and its Transaction is always null — so the honest answer tracks whether the + // announcement was made, which is what the behaviour under test drives. + public bool IsTenantContextIssuedOn(DbTransaction? transaction) => TenantContext is not null; + public bool HasActiveTransaction => _depth > 0; public Task BeginTransactionAsync(CancellationToken cancellationToken = default) diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index f3670010..f86adfd9 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -517,6 +517,39 @@ Nothing else changes. Cross-**module** writes remain forbidden with no exception which is the property ADR-0010's outbox boundary exists to protect, and the exception's holder is a literal allow-list of one. +### Amendment 5 — the seam gains a read member for the tenant-context guard (2026-09-02) + +**What changed.** § Decision enumerates the `IUnitOfWork` seam member by member. Packet 7 +step 8 adds one: `bool IsTenantContextIssuedOn(DbTransaction? transaction)`. Recorded +here for the same reason Amendment 1 exists — that sketch is the contract, and an +addition to it that goes unrecorded is an addition nobody reviewed. + +**Why it belongs on the seam and not beside it.** The guard — +`TenantContextGuardInterceptor`, registered on every module `DbContext` — has to ask +whether a sanctioned setter announced the transaction a command is about to run on. Put +on a side interface, a future `IUnitOfWork` implementation could omit it and be silently +unguarded; on the seam, the compiler makes every implementation answer, which is what the +addition buys. + +**Why it takes the transaction rather than returning a flag.** The check is +`ReferenceEquals(transaction, _transaction) && _tenantContextIssued`, and the reference +half is load-bearing: measured on Npgsql 10, a pooled data source hands back the **same** +`NpgsqlTransaction` instance across sequential open/begin/dispose cycles, so a bare flag — +or anything keyed on the transaction object — would vouch for a later transaction on the +strength of an earlier one's announcement. Keeping the comparison inside the type that +owns `_transaction` is what makes that impossible to get wrong at the call site. + +**There is deliberately no writer.** The only code that may mark a transaction is +`SetTenantContextAsync`, which sets the flag after the `set_config` round trip returns — +so a failed announcement vouches for nothing — and the flag is cleared in the one block +that runs once per physical transaction. A module able to set it could silence the guard. + +**The setter set is unchanged.** This adds a reader, not an eighth setter; Amendment 3's +seven stand. + +**The Decision is unchanged.** One connection per scope, owned by `IUnitOfWork`, with +every context and cross-cutting writer enlisted on it. + ## References - [ADR-0002 — Initial Architecture](0002-initial-architecture.md) diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index aad04c7a..aec2e1ee 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1166,8 +1166,8 @@ Forbidden: string interpolation with non-constant values. enforces this in the deployment config; deviation requires an ADR. - `app.tenant_id` (and `app.organization_id` when relevant) set **within the same transaction** as the work (`SET LOCAL ...`). -- A `DbCommandInterceptor` — **not** a connection-checkout interceptor — is to guard - the context. **Packet 7 ships it**: Packet 6 ships the setter and the policies it +- A `DbCommandInterceptor` — **not** a connection-checkout interceptor — guards + the context. **Packet 7 step 8 ships it**, as `TenantContextGuardInterceptor`: Packet 6 ships the setter and the policies it backs up, and the first tenant-owned read on a request path is Packet 7's, which is where the guard belongs. Checkout happens before `TransactionBehavior` opens the transaction that carries the `SET LOCAL` values, so a checkout hook would read an unset @@ -1175,8 +1175,11 @@ Forbidden: string interpolation with non-constant values. pooling it would sometimes read a *previous* transaction's leftover value, which is worse than throwing. The command interceptor instead checks the in-process marker **a sanctioned setter** stamps on the transaction it opens, once the `SET LOCAL` - pair is issued, and throws `TenantContextMissingException` when a command against a - `[TenantOwned]` table runs without it — no extra round trip. Both arms are asserted by + pair is issued, and throws `TenantContextMissingException` when any command a module + `DbContext` issues runs on an unmarked transaction — no extra round trip, and keyed on + the transaction rather than on the table, because deciding per table would mean parsing + command text and every command from a module context belongs to a request that had a + tenant to announce. Both arms are asserted by [`Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction`](21-architecture-tests-catalogue.md). The setters are a closed set, named in [Security Standards § The out-of-band setters](11-security.md), which is the placement authority: a guard keyed on `TransactionBehavior` alone would reject the diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index c98341df..d9ba9cb1 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -309,13 +309,26 @@ names by hand. Because every `current_setting` read is called with its missing-OK argument (`true`) **and** wrapped in `NULLIF(…, '')`, both an unset and a reset variable yield `NULL` and the policy predicate filters the row out. The failure mode is an empty result set, not a -leak — but an empty result set arriving from production is an outage, so a -`DbCommandInterceptor` is to assert that **a sanctioned setter** has already -issued the `SET LOCAL` pair on this transaction before any command against a -`[TenantOwned]` table runs — `TransactionBehavior` in the general case, or any of the -out-of-band setters above, each of which stamps the same marker on the transaction it -opens. Naming `TransactionBehavior` alone would make the guard reject every write the -idempotency store and the audit store legitimately make. It throws +leak — but an empty result set arriving from production is an outage, so +`TenantContextGuardInterceptor` asserts that **a sanctioned setter** has already issued +the `SET LOCAL` pair on this transaction before any command a module `DbContext` issues +runs on it. + +**Keyed on the transaction, not on the table**, and marked by one setter rather than +seven — both narrower than an earlier wording here, and both for reasons the shipped +mechanism makes plain. Matching `[TenantOwned]` table names would put a parser between +every query and the database to decide something the transaction already answers. +And of the seven out-of-band setters only `TransactionBehavior`, through +`IUnitOfWork.SetTenantContextAsync`, needs to mark anything: four do not exist in code +yet, and the two that do — `CachedHostToTenantResolver` and `IOrganizationScopeValidator` +— issue raw `NpgsqlCommand`s, which EF interception never sees. That is also why the +exemption list is empty, and why `PlatformAdminScope`, whose `BYPASSRLS` connection +announces no tenant by design, is invisible to the guard by construction rather than by a +hand-written exception. The concern the earlier wording had — that naming +`TransactionBehavior` alone would reject the writes the idempotency store and the audit +store legitimately make on their own short transactions — is real, and is answered by the +guard reading a marker rather than a behavior's name: when those setters land, each marks +the transaction it opens if and only if it reaches EF. It throws `TenantContextMissingException` when it has not, which [`Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction`](21-architecture-tests-catalogue.md) asserts in both directions. **Packet 7 owns it**: Packet 6 ships diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index f0a60e85..ed6e5314 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -998,19 +998,30 @@ the request path. #### `Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction` -- **Asserts:** both arms of the `DbCommandInterceptor` guard. A command against a - `[TenantOwned]` table on a transaction no sanctioned setter stamped throws - `TenantContextMissingException`; the same command on a transaction opened by any of - the seven sanctioned setters runs. One arm is not the rule: a guard keyed on - `TransactionBehavior` instead of on the marker passes the first arm and rejects the - writes the idempotency store and the audit store legitimately make on their own short - transactions. +- **Asserts:** both arms of the `DbCommandInterceptor` guard. A command a module `DbContext` issues on a transaction no sanctioned setter announced throws `TenantContextMissingException`; the same command on an announced transaction runs. One arm is not the rule: a guard keyed on `TransactionBehavior` instead of on the marker passes the first arm and rejects the writes the idempotency store and the audit store legitimately make on their own short transactions. +- **Keyed on the transaction, not on the table.** An earlier wording said "a command against a `[TenantOwned]` table", and the rule's own name says otherwise. What shipped is the name: matching table names would put a parser between every query and the database, wrong on the first CTE, to decide something every command from a module context already answers — such a command belongs to a request that had a tenant to announce. Nothing is lost, because a platform-scoped read from a module context is exactly as much of a wiring bug as a tenant-owned one. - **Runs as `learnstack_app`.** - **Source:** [11-security.md § The out-of-band setters](11-security.md); [05-database.md § Connection Management](05-database.md). - **Type:** **integration** test (Testcontainers + PostgreSQL). **Kind:** runtime. -- **Status:** **Registered.** +- **Status:** **Implemented** (`TenantContextGuardTests`, Packet 7 step 8). - **Phase:** 02a Packet 7. +- **Note:** the marker is a flag on `NpgsqlUnitOfWork`, read through the seam member + ADR-0040 Amendment 5 adds. **Only one of the seven sanctioned setters stamps it**, and + that is the honest count: `TransactionBehavior` via `SetTenantContextAsync`. Of the other + six, four do not exist in code yet and two — `CachedHostToTenantResolver` and + `IOrganizationScopeValidator` — issue raw `NpgsqlCommand`s, which EF interception cannot + see, so they need neither a mark nor an exemption. The exemption list is empty for the + same reason, which is why `PlatformAdminScope` — a `BYPASSRLS` connection that announces + no tenant by design — is invisible here by construction rather than by a hand-written + exception someone later widens. +- **Note:** the guard is a **diagnostic above Row Level Security, never the boundary**. + `Without_The_Guard_An_Unannounced_Read_Is_Silent_And_Empty` asserts the state it exists + to make visible: safe already, because the predicate is `NULL`, and silent, which is the + outage. It also does **not** close the unresolved-context case: `SetTenantContextAsync` + writes the empty string for an unresolved context by design, so such a transaction is + announced, passes the guard, and still reads nothing. `TenantContextBehavior` at pipeline + step 4 is what refuses that, and remains the only thing in front of it. #### `Db_Connection_String_Is_TransactionPooled` From ca50a284fd7617ffb71219c402e7cc259de428ca Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 21:57:25 +0300 Subject: [PATCH 25/55] fix(tenancy): count the setters, and guard the arms nothing reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review found no wrong result reaching a caller. It found a coverage ring around correct code, and one sentence of mine that would have misled the next implementer. The miscount is the part that mattered. I wrote that of the seven sanctioned setters "the two that do [exist] — CachedHostToTenantResolver and IOrganizationScopeValidator — issue raw NpgsqlCommands". CachedHostToTenantResolver is not one of the seven at all: it sets app.resolving_host, and the closed table forty lines above says so. What my sentence displaced is "the integration-event transport, per delivery — ambient, it opens it" — the one other setter that OPENS the ambient transaction and must therefore announce it. A Phase 02b implementer reading that is told the remaining in-code setters are exempt because they use raw commands, ships a transport that opens the transaction without announcing, and every module command in every event handler throws. That is the miscount ADR-0040 Amendment 3 already fixed once, re-entering through the document this packet names as the placement authority. Corrected in both carriers; ADR-0040 needs nothing, its own list was right. Three arms of the guard were asserted by nothing. Deleting Guard from BOTH Scalar overrides left 1136 green — a third of the surface, in a file whose own comment claimed the mutation standard had been applied to every arm. And replacing both NonQuery bodies with an unconditional throw ALSO left everything green, because the only let-through assertion was a reader: a guard that refused every write would have passed. Both directions are pinned now, on all three pairs. The `transaction is not null` term was unconstrained. After a commit _transaction is null and nothing clears the flag there, so without the term ReferenceEquals(null, null) would make the unit vouch for any command carrying no transaction at all. The existing null assertion ran while a transaction was live, where the reference check alone already answers false — it constrained the wrong half. And a comment named the one shape its body does not run: it reasoned about a SaveChanges INSERT arriving on ReaderExecuting while the case underneath issued raw SQL on the NonQuery arm. Writing the case it described turned up something Step 9 needs: EF wraps a failing SaveChanges in DbUpdateException, so an assertion written as a bare ThrowAsync fails on the path the first real handler will take. Pinned, so the next step writes the right assertion rather than discovering the wrapper. 1146 green, zero skips. Three mutations measured. Co-Authored-By: Claude Opus 5 (1M context) --- .../Database/TenantContextGuardTests.cs | 98 ++++++++++++++++++- docs/standards/11-security.md | 9 +- .../21-architecture-tests-catalogue.md | 11 ++- 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs index b2250632..524000f1 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs @@ -1,11 +1,15 @@ using FluentAssertions; using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Domain; using LearnStack.Modules.Tenancy.Infrastructure.Persistence; using LearnStack.SharedKernel.Errors; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Npgsql; @@ -74,6 +78,18 @@ public async Task An_Announced_Transaction_Is_Let_Through() (await context.Organizations.CountAsync()).Should().BeGreaterThan(0, "the announcement is what makes the policy admit the tenant's own rows"); + // The let-through direction on the other two pairs. Measured: replacing both + // NonQuery bodies with an unconditional throw left the whole suite green, + // because the only let-through assertion was a reader — so a guard that refused + // everything would have passed for two of the three command kinds. + var write = async () => await context.Database.ExecuteSqlRawAsync( + "UPDATE organizations SET slug = slug WHERE false"); + await write.Should().NotThrowAsync(); + + var creator = context.GetService(); + var scalar = async () => await creator.HasTablesAsync(CancellationToken.None); + await scalar.Should().NotThrowAsync(); + await unitOfWork.RollbackAsync(); } @@ -99,11 +115,12 @@ public async Task An_Unannounced_Read_Throws_Instead_Of_Returning_Nothing() } [Fact] - public async Task An_Unannounced_Write_Throws_Too() + public async Task An_Unannounced_Raw_Sql_Write_Throws_Too() { - // EF routes a SaveChanges INSERT through ReaderExecuting rather than - // NonQueryExecuting, so a guard covering only the non-query arms would let every - // write past. Asserted separately from the read for that reason. + // Raw SQL is the shape that reaches NonQueryExecuting — the read cases above + // cover the reader pair, and this is the only case that reaches this pair. An + // earlier version of this comment reasoned about a SaveChanges INSERT, which + // arrives on ReaderExecuting: it named the one shape the body does not run. await using var provider = BuildProvider(); await using var scope = provider.CreateAsyncScope(); var unitOfWork = scope.ServiceProvider.GetRequiredService(); @@ -166,6 +183,14 @@ public async Task A_Second_Transaction_Does_Not_Inherit_The_First_Ones_Announcem await unitOfWork.CommitAsync(); } + // Where the `transaction is not null` term earns its place. The commit nulled + // _transaction and nothing clears the flag there, so without that term + // ReferenceEquals(null, null) is true and the unit would vouch for any command + // carrying no transaction at all. The assertion below, taken while a transaction + // is live, cannot see this: the reference check alone already answers false there. + unitOfWork.IsTenantContextIssuedOn(null).Should().BeFalse( + "the announcement belonged to a transaction that is over"); + await unitOfWork.BeginTransactionAsync(); var context = scope.ServiceProvider.GetRequiredService(); @@ -235,6 +260,71 @@ public async Task An_Announcement_Vouches_For_Its_Own_Transaction_And_No_Other() await unitOfWork.RollbackAsync(); } + [Fact] + public async Task A_Scalar_Command_Is_Guarded_Too() + { + // The third pair, and the one nothing reached: deleting Guard from BOTH Scalar + // overrides left all 1136 tests green — a third of the guard's surface asserted + // by nothing, in a file whose own comment claimed the mutation standard had been + // applied to every arm. IRelationalDatabaseCreator is what reaches it from a + // module context without descending into EF internals. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + + var creator = scope.ServiceProvider.GetRequiredService() + .GetService(); + +#pragma warning disable xUnit1031 // The blocking arm is half the subject. + var blocking = () => creator.HasTables(); +#pragma warning restore xUnit1031 + var awaited = async () => await creator.HasTablesAsync(CancellationToken.None); + + blocking.Should().Throw(); + await awaited.Should().ThrowAsync(); + + await unitOfWork.RollbackAsync(); + } + + [Fact] + public async Task A_Save_Reports_The_Guard_Through_The_Wrapper_EF_Puts_Round_It() + { + // What a caller actually catches, which is not what the guard throws. EF wraps a + // failing SaveChanges in DbUpdateException, so an assertion written as a bare + // ThrowAsync fails on the path Step 9's first + // real handler will take. Pinned here so the next step writes the right + // assertion rather than discovering the wrapper. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + + var context = scope.ServiceProvider.GetRequiredService(); + context.Organizations.Add(NewOrganization()); + + var act = async () => await context.SaveChangesAsync(CancellationToken.None); + + var wrapper = (await act.Should().ThrowAsync()).Which; + + wrapper.InnerException.Should().BeOfType( + "the guard's exception is what EF wrapped, and a caller unwrapping one level " + + "finds it — an assertion written as a bare ThrowAsync would not"); + + await unitOfWork.RollbackAsync(); + } + + private static Organization NewOrganization() => + Organization.Create( + OrganizationId.From(Guid.NewGuid()), + TenantId.From(SchemaFixture.TenantA), + $"guard-{Guid.NewGuid():N}"[..24], + "Guard probe", + new FixedClock(new DateTimeOffset(2026, 9, 2, 9, 0, 0, TimeSpan.Zero)), + UserId.SystemActor); + private static async Task AnnounceAsync( IServiceProvider scope, IUnitOfWork unitOfWork, Guid tenant) { diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index d9ba9cb1..2ccc3f94 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -319,9 +319,12 @@ seven — both narrower than an earlier wording here, and both for reasons the s mechanism makes plain. Matching `[TenantOwned]` table names would put a parser between every query and the database to decide something the transaction already answers. And of the seven out-of-band setters only `TransactionBehavior`, through -`IUnitOfWork.SetTenantContextAsync`, needs to mark anything: four do not exist in code -yet, and the two that do — `CachedHostToTenantResolver` and `IOrganizationScopeValidator` -— issue raw `NpgsqlCommand`s, which EF interception never sees. That is also why the +`IUnitOfWork.SetTenantContextAsync`, marks anything today: five do not exist in code yet — the integration-event transport among them, and that +one matters most, because it is the other setter that *opens* the ambient transaction and +must therefore announce it when Phase 02b lands it — and the one that does exist, +`OrganizationScopeValidator`, issues raw `NpgsqlCommand`s, which EF interception never +sees. `CachedHostToTenantResolver` is not in this set at all: it sets +`app.resolving_host`, not `app.tenant_id`. That is also why the exemption list is empty, and why `PlatformAdminScope`, whose `BYPASSRLS` connection announces no tenant by design, is invisible to the guard by construction rather than by a hand-written exception. The concern the earlier wording had — that naming diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index ed6e5314..4872f220 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1008,10 +1008,13 @@ the request path. - **Phase:** 02a Packet 7. - **Note:** the marker is a flag on `NpgsqlUnitOfWork`, read through the seam member ADR-0040 Amendment 5 adds. **Only one of the seven sanctioned setters stamps it**, and - that is the honest count: `TransactionBehavior` via `SetTenantContextAsync`. Of the other - six, four do not exist in code yet and two — `CachedHostToTenantResolver` and - `IOrganizationScopeValidator` — issue raw `NpgsqlCommand`s, which EF interception cannot - see, so they need neither a mark nor an exemption. The exemption list is empty for the + that is the honest count: `TransactionBehavior` via `SetTenantContextAsync`. Of the + other six, **five do not exist in code yet** — including the integration-event + transport, which is the one other setter that *opens* the ambient transaction and will + have to announce it when Phase 02b lands it — and the one that does, + `OrganizationScopeValidator`, issues raw `NpgsqlCommand`s, which EF interception cannot + see, so it needs neither a mark nor an exemption. (`CachedHostToTenantResolver` is not + one of the seven: it sets `app.resolving_host`.) The exemption list is empty for the same reason, which is why `PlatformAdminScope` — a `BYPASSRLS` connection that announces no tenant by design — is invisible here by construction rather than by a hand-written exception someone later widens. From fad94b2cdd0b5ca9ec1b7217bdabd77bcffb9ff7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 2 Sep 2026 23:33:17 +0300 Subject: [PATCH 26/55] fix(tenancy): assert the guard's let-through half; correct Amendment 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, no production code, and one number of mine that was wrong. The synchronous let-through arm was asserted by nothing. Replacing all three blocking override bodies with an unconditional throw left every guard case green — so a guard that refused every blocking call would have shipped. This is the same defect class the previous round's own commit message says it closed ("a guard that refused everything would have passed"): that round fixed the refusal direction on all three pairs and the let-through direction on the async half only. Both directions are now asserted on all six overrides. The exception's code was pinned by nothing. Reverting lockey_internal_error to lockey_tenant_mismatch left the entire suite green — the 404-to-500 reclassification that is the headline of the previous commit had no test at all. Asserted on the CODE and not only the status, because internal_error falls into HttpStatusMap's default rather than an explicit arm, so a mistyped key would still yield 500 and go unnoticed while `code` is what an RFC 7807 client matches on. ADR-0040 Amendment 5 wrote the check with a term missing. It gives `ReferenceEquals(transaction, _transaction) && _tenantContextIssued`; the code that shipped in the same commit has `transaction is not null` first, and that term is load-bearing — remove it and exactly one case fails, because after a commit _transaction is null and ReferenceEquals(null, null) would vouch for any command carrying no transaction at all. The sentence never described the code, so it was false when it entered the record rather than having aged: an ADR-0041 inline erratum, disclosed by Amendment 6. And the count. The previous commit message says 1146; the tree at that commit had 1138. The error looks like the two rounds' new-test counts being summed rather than the second applied on top of the first. Commit messages are history and are not rewritten, so it is corrected here and the Packet 7 delivery record will cite the measured total rather than a computed one. 1139 green, zero skips, measured rather than derived. Three mutations killed. ADR: 0040 Co-Authored-By: Claude Opus 5 (1M context) --- .../Database/TenantContextGuardTests.cs | 16 ++++++++ .../Api/Common/HttpStatusMapTests.cs | 19 ++++++++++ docs/decisions/0040-ambient-unit-of-work.md | 37 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs index 524000f1..0f723ad4 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs @@ -90,6 +90,22 @@ public async Task An_Announced_Transaction_Is_Let_Through() var scalar = async () => await creator.HasTablesAsync(CancellationToken.None); await scalar.Should().NotThrowAsync(); + // And the same three, blocking. EF does not route the synchronous APIs through + // the asynchronous ones, so each pair is two independent arms — and the previous + // round fixed this asymmetry only for the refusal direction. Measured: replacing + // all three synchronous bodies with an unconditional throw left every case here + // green, so a guard that refused every blocking call would have shipped. +#pragma warning disable xUnit1031 // The blocking API is the subject, not an accident. + var syncRead = () => context.Organizations.Count(); + var syncWrite = () => context.Database.ExecuteSqlRaw( + "UPDATE organizations SET slug = slug WHERE false"); + var syncScalar = () => creator.HasTables(); +#pragma warning restore xUnit1031 + + syncRead.Should().NotThrow(); + syncWrite.Should().NotThrow(); + syncScalar.Should().NotThrow(); + await unitOfWork.RollbackAsync(); } diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs index b2ee25af..47679510 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs @@ -67,4 +67,23 @@ public void For_OperationCanceled_Maps_To_499() // the client disconnect. HttpStatusMap.For(new OperationCanceledException()).Should().Be(499); } + + [Fact] + public void The_Tenant_Context_Guard_Reports_A_Fault_Rather_Than_A_Not_Found() + { + // The whole point of the 404-to-500 reclassification, and nothing pinned it: + // measured, reverting the exception's key to lockey_tenant_mismatch left the + // entire suite green. A wiring bug that tripped the guard would then have + // reached a client as a 404 byte-comparable to the deliberate refusal an + // unresolvable host gets — the response Steps 4 through 6 spent three review + // rounds making indistinguishable on purpose, with a server fault hiding inside + // it, invisible in monitoring. + var failure = new TenantContextMissingException("a command on an unannounced transaction"); + + // The CODE, not only the status. `internal_error` is not an explicit arm of the + // map — it falls into the default — so a mistyped key would still yield 500 and + // go unnoticed, while `code` is the field an RFC 7807 client matches on. + failure.Error.Code.Should().Be("internal_error"); + HttpStatusMap.For(failure).Should().Be(500); + } } diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index f86adfd9..3929172b 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -531,6 +531,17 @@ on a side interface, a future `IUnitOfWork` implementation could omit it and be unguarded; on the seam, the compiler makes every implementation answer, which is what the addition buys. +> **Erratum — 2026-09-02.** The sentence below writes the check with **two** terms. The +> code that shipped in the same commit has **three**, and the missing one is load bearing: +> `transaction is not null && ReferenceEquals(transaction, _transaction) && +> _tenantContextIssued`. After a commit `_transaction` is null and nothing clears the flag +> there, so without the first term `ReferenceEquals(null, null)` is true and the unit +> vouches for any command carrying no transaction at all. Shown by removing it: exactly +> one case fails, `A_Second_Transaction_Does_Not_Inherit_The_First_Ones_Announcement`. +> The statement was false when it entered the record rather than having aged, which is +> what makes this an erratum. What the amendment decides — a read member on the seam, +> taking the transaction, with no writer — is unchanged. Recorded in Amendment 6. + **Why it takes the transaction rather than returning a flag.** The check is `ReferenceEquals(transaction, _transaction) && _tenantContextIssued`, and the reference half is load-bearing: measured on Npgsql 10, a pooled data source hands back the **same** @@ -550,6 +561,32 @@ seven stand. **The Decision is unchanged.** One connection per scope, owned by `IUnitOfWork`, with every context and cross-cutting writer enlisted on it. +### Amendment 6 — Amendment 5 wrote the check with a term missing (2026-09-02) + +**What was wrong.** Amendment 5's § Why it takes the transaction rather than returning a +flag gives the check as `ReferenceEquals(transaction, _transaction) && +_tenantContextIssued`. The shipped member has a third term first: +`transaction is not null`. + +**How it was shown.** Removing that term and running the guard suite produces exactly one +failure — `A_Second_Transaction_Does_Not_Inherit_The_First_Ones_Announcement`, "found +True". After a commit `_transaction` is null and nothing clears the flag there, so +`ReferenceEquals(null, null)` is true and the unit would vouch for any command carrying no +transaction at all. The formula appears in no other document; Standards 05 and 11 describe +the marker without writing it out. + +**Why it is an erratum rather than an amendment to a stale sentence.** Amendment 5 and the +three-term code landed in the same commit, `087b95c`. The sentence never described the +code, so it was false when it entered the record — ADR-0041's inline-erratum case, not the +supersede-what-has-aged case. + +**Every carrier changed.** This ADR: the inline erratum beside Amendment 5's formula, and +this amendment. The code is unchanged and was already correct; its test coverage was added +in `8ff3743`, which is what made the discrepancy visible. + +**The Decision is unchanged**, and so is Amendment 5's: one read member on the seam, taking +the transaction, with no writer. + ## References - [ADR-0002 — Initial Architecture](0002-initial-architecture.md) From 36db87928f4de57de002861016f4969552d56520 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 04:33:29 +0300 Subject: [PATCH 27/55] feat(tenancy): let a provisioning request announce the tenant it creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 9's crux, measured rather than reasoned. The policy on `tenants` is WITH CHECK (id = app.tenant_id), so creating a tenant means announcing the tenant being created — an id that names nothing resolvable, because it does not exist yet. Against a live PostgreSQL with the shipped policy transcribed verbatim: with app.tenant_id unset the INSERT fails 42501; with it set to the empty string, which is exactly what the unit of work writes for an unresolved context, it fails 42501 identically; with it set to the new tenant's own id the whole sequence — INSERT tenants, INSERT organizations, UPDATE the back reference — commits. A re-announce inside the transaction also works, and the value does not survive COMMIT, so nothing rides a pooled connection. That settles the collision Step 7's design pass flagged and left open, and it settles it against the corpus's standing sentence: learnstack_app provisions on its own and PlatformAdminScope is not needed. Which is fortunate, because Step 7 shipped a gate that refuses everyone, its handle hands back a raw DbConnection rather than a DbContext, and it opens a second connection — putting the two aggregate writes outside the single commit point ADR-0042 exists to guarantee. The announcement rides on the request, not on the handler. TransactionBehavior reads IProvisionsTenant and announces once. A handler announcing a second time would leave a window inside the ambient transaction where app.tenant_id is the empty string and every statement in it is silently fail-closed, and would hand every handler in the solution the ability to move the ambient tenant. This way TransactionBehavior stays the only caller, so ADR-0040's setter set is still closed at seven — the same setter announcing a different value for one request shape, not an eighth. The `!IsResolved` term is the load-bearing half. Without it a caller already authenticated for tenant A could send a provisioning request naming tenant B and announce B. With it, such a request falls through to the ordinary path, the transaction is announced with A, and B's insert is refused by the policy — the confused deputy closed by the database rather than by a check someone has to remember. Every misuse of the new setter throws rather than degrading: no transaction, a joiner, an already-announced transaction, an uninitialized or all-zero id. The joiner case is a throw and not the ambient setter's silent early return on purpose — a joiner that believed it announced surfaces as 42501 three frames away, on an INSERT that reads as a permissions problem. Also here because the handler cannot compile without it: IAggregateWriteStore, the first persistence abstraction in the solution. Application -> Infrastructure is a forbidden edge, every DbContext lives in Infrastructure, and Infrastructure already references Application, so the reverse is a cycle. Typed rather than named so the cross-aggregate rule can count it; write-only, because a provisioning transaction announces a tenant that does not exist yet and no read works inside it. 1139 green, zero skips. The aggregate promotion and the command follow. ADR: 0040, 0042 Co-Authored-By: Claude Opus 5 (1M context) --- .../Pipeline/TransactionBehavior.cs | 39 ++++++++++-- .../Persistence/NpgsqlUnitOfWork.cs | 60 +++++++++++++++++++ .../Persistence/IAggregateWriteStore.cs | 51 ++++++++++++++++ .../Persistence/IUnitOfWork.cs | 32 ++++++++++ .../Tenancy/IProvisionsTenant.cs | 42 +++++++++++++ .../CrossCuttingFoundationHttpTests.cs | 4 ++ .../Pipeline/TransactionBehaviorTests.cs | 12 ++++ 7 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/IProvisionsTenant.cs diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index 03ac8991..aee202e3 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -89,12 +89,39 @@ public async Task Handle( { // First statement inside the transaction, per ADR-0003 Amendment 3, and // inside the try so a failure to issue it fails the frame rather than - // leaving it open for the scope to clean up later. Until Packet 7's - // TenantResolverMiddleware populates ITenantContext this writes the - // empty string, and that is correct: the policies read - // NULLIF(current_setting(...), '')::uuid, so an unresolved context is a - // NULL predicate and every tenant-owned table returns zero rows. - await unitOfWork.SetTenantContextAsync(tenantContext, cancellationToken); + // leaving it open for the scope to clean up later. For an unresolved + // context this writes the empty string, and that is correct: the policies + // read NULLIF(current_setting(...), '')::uuid, so an unresolved context is + // a NULL predicate and every tenant-owned table returns zero rows. + // + // One exception, and it is the one write whose tenant no context can carry. + // `tenants` is self-keyed and its policy is WITH CHECK (id = app.tenant_id), + // so creating a tenant means announcing the tenant being created — an id + // that names nothing resolvable, because it does not exist yet. Measured + // against the shipped policy: unset and empty-string both fail 42501, and + // the new tenant's own id lets the whole provisioning sequence commit. + // + // The !IsResolved term is the load-bearing half, not a defensive one. A + // caller already authenticated for tenant A who sends a provisioning request + // naming tenant B falls through to the ordinary path, the transaction is + // announced with A, and B's insert is refused by the policy. The confused + // deputy is closed by the database rather than by a check somebody has to + // remember to write. + // + // Announced ONCE either way. A handler announcing a second time would leave + // a window inside this transaction where app.tenant_id is the empty string — + // every statement in it silently fail-closed — and would hand every handler + // in the solution the ability to move the ambient tenant. This stays the + // only caller, which is what keeps ADR-0040's setter set closed at seven. + if (!tenantContext.IsResolved && request is IProvisionsTenant provisioning) + { + await unitOfWork.SetProvisioningTenantContextAsync( + provisioning.ProvisioningTenantId, cancellationToken); + } + else + { + await unitOfWork.SetTenantContextAsync(tenantContext, cancellationToken); + } var response = await next(); diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index 7348cb92..92dcf5da 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; using Microsoft.Extensions.Logging; using LearnStack.SharedKernel.Tenancy; @@ -232,6 +233,65 @@ await ExecuteAsync( _tenantContextIssued = true; } + /// + public async Task SetProvisioningTenantContextAsync( + TenantId tenantId, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_transaction is null) + { + throw new InvalidOperationException( + "SetProvisioningTenantContextAsync requires an open transaction, for the " + + "reason its sibling does: set_config(..., true) is transaction-local."); + } + + // A throw, not the ambient setter's silent early return. That return exists so an + // inner frame cannot retarget an outer frame's tenant; here the caller believes it + // announced a tenant, and a joiner that quietly announced nothing surfaces as + // 42501 three frames away, on an INSERT that reads as a permissions problem. + if (_depth > 1) + { + throw new InvalidOperationException( + "A joining frame cannot provision. The tenant a provisioning request " + + "creates must be announced on the transaction that carries the write, " + + "and an inner frame joins a transaction another announcement already " + + "owns. Provisioning opens its own unit of work."); + } + + if (_tenantContextIssued) + { + throw new InvalidOperationException( + "This transaction has already been announced. The only transition this " + + "method permits is unannounced to the tenant being created; allowing a " + + "second announcement would let any caller move the ambient tenant " + + "mid-request, which is a wider capability than provisioning needs."); + } + + // Value under an IsInitialized() gate, never ToString(): measured on Vogen 7, an + // uninitialized id renders as "[UNINITIALIZED]" and reaches PostgreSQL as + // '[UNINITIALIZED]'::uuid, which raises 22P02 rather than filtering. Guid.Empty is + // refused beside it because Vogen validates the shape of a value, not that it + // names anything, and the domain refuses the all-zero tenant by hand. + if (!tenantId.IsInitialized() || tenantId.Value == Guid.Empty) + { + throw new ArgumentException( + "A provisioning tenant id must be a real, registry-assigned id.", + nameof(tenantId)); + } + + // app.organization_id is written explicitly as the empty string rather than left + // alone: a pooled connection must not carry a previous transaction's organization + // into a provisioning write. + await ExecuteAsync( + "SELECT set_config('app.tenant_id', @tenant, true), " + + "set_config('app.organization_id', '', true)", + cancellationToken, + ("tenant", tenantId.Value.ToString())); + + _tenantContextIssued = true; + } + /// public bool IsTenantContextIssuedOn(DbTransaction? transaction) => transaction is not null diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs b/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs new file mode 100644 index 00000000..e6a4fcbe --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs @@ -0,0 +1,51 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Persistence; + +/// +/// The write side of one aggregate root, for a handler that cannot see a +/// DbContext. +/// +/// +/// +/// It exists because the dependency rules leave no alternative. +/// Standards 01 lists +/// Application → Infrastructure under forbidden edges, every module's +/// DbContext and its DbSets live in that module's Infrastructure project, +/// and Infrastructure already references Application — so the reverse reference is a +/// project cycle the compiler refuses. A handler reaches persistence through a port +/// declared beside it and implemented across the boundary, which is what that standard's +/// own parenthetical prescribes. +/// +/// +/// Typed rather than named, so a rule can count it. +/// Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning counts how many +/// parameters of this shape a handler takes; a naming convention would be satisfied by +/// what an author calls a class, which is not a decision anybody reviewed. +/// +/// +/// Write-only, deliberately. There is no read member, because the first caller +/// cannot use one: a provisioning transaction announces the tenant it is about to create, +/// so every query filter and every Row Level Security predicate matches a tenant that +/// does not exist yet. A read surface invented for a caller that cannot use it is a +/// surface nobody reviewed. Reads arrive with the first handler that has something to +/// read. +/// +/// +/// This is the first persistence abstraction in the solution, and six modules +/// inherit its shape. Each method persists on its own rather than deferring to a shared +/// SaveChanges: the EF model carries no relationships between these aggregates, so +/// the order writes reach PostgreSQL is otherwise unspecified — and provisioning depends +/// on that order. +/// +/// +public interface IAggregateWriteStore + where TRoot : class, IAggregateRoot + where TId : struct, IStronglyTypedId +{ + /// Persists a newly created aggregate. + Task AddAsync(TRoot aggregate, CancellationToken cancellationToken = default); + + /// Persists a change to an aggregate already stored. + Task UpdateAsync(TRoot aggregate, CancellationToken cancellationToken = default); +} diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index 5645e2e6..8fc7fbba 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Tenancy; namespace LearnStack.SharedKernel.Persistence; @@ -125,6 +126,37 @@ public interface IUnitOfWork : IAsyncDisposable /// bool IsTenantContextIssuedOn(DbTransaction? transaction); + /// + /// Announces the tenant a provisioning request is about to create. + /// + /// + /// + /// The one write whose tenant no context can supply. tenants is + /// self-keyed and its policy is WITH CHECK (id = app.tenant_id), so creating a + /// tenant requires announcing the tenant being created. No resolved + /// ITenantContext can carry that id — it names a tenant that does not exist — + /// and the empty string an unresolved context writes fails the check identically to + /// announcing nothing: measured, 42501 both ways. + /// + /// + /// It does not widen the setter set. + /// ADR-0040 + /// Amendment 3 closes that set at seven, and TransactionBehavior remains + /// the only caller of this as it is of its sibling — this is the same setter + /// announcing a different value for one request shape, not an eighth. + /// + /// + /// Every way of misusing it throws rather than degrading. No open transaction, + /// a joiner (not the silent early return the ambient setter uses — a joiner + /// that thought it announced would fail three frames away with 42501), a transaction + /// already announced, or an id that is uninitialized or all-zero. The + /// already-announced guard is what keeps the only reachable transition + /// unannounced → the new tenant: nothing can retarget a live transaction. + /// + /// + Task SetProvisioningTenantContextAsync( + TenantId tenantId, CancellationToken cancellationToken = default); + /// /// Resolves the innermost open frame. On the outermost frame this commits — /// unless the unit is marked rollback-only, in which case it throws rather diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/IProvisionsTenant.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IProvisionsTenant.cs new file mode 100644 index 00000000..a78090c1 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IProvisionsTenant.cs @@ -0,0 +1,42 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// A request that creates the tenant it names, and therefore carries the tenant id the +/// ambient transaction must announce. +/// +/// +/// +/// Why the pipeline needs the value and not a flag. The policy on tenants is +/// WITH CHECK (id = app.tenant_id) — measured against a live PostgreSQL with the +/// shipped policy: inserting a tenant with app.tenant_id unset fails 42501, and +/// with it set to the empty string, which is exactly what the unit of work writes for an +/// unresolved context, it fails 42501 identically. With it set to the new tenant's own id +/// the insert, the organization insert and the back-reference update all commit. So the +/// transaction has to be announced with an id that belongs to no resolved context, and +/// the only place that id exists before the handler runs is the request. +/// +/// +/// The announcement stays with TransactionBehavior. It reads this and +/// announces once, rather than the handler announcing a second time — which would leave a +/// window inside the ambient transaction where app.tenant_id is the empty string +/// and any statement issued in it is silently fail-closed, and would hand every handler +/// in the solution the ability to retarget the ambient tenant. The setter set +/// ADR-0040 +/// Amendment 3 closes at seven stays closed. +/// +/// +/// It grants nothing on its own. The behavior honours it only when the context is +/// unresolved. A caller already authenticated for tenant A who sends a +/// provisioning request naming tenant B falls through to the ordinary path, the +/// transaction is announced with A, and B's insert is refused by the policy — the +/// confused deputy is closed by the database rather than by a check somebody has to +/// remember. +/// +/// +public interface IProvisionsTenant +{ + /// The registry-assigned id of the tenant this request creates. + TenantId ProvisioningTenantId { get; } +} diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index db73fdd9..2e422e9b 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -285,6 +285,10 @@ public Task SetTenantContextAsync( // context here anyway, so nothing asks — but the honest answer is the safe one. public bool IsTenantContextIssuedOn(System.Data.Common.DbTransaction? transaction) => false; + public Task SetProvisioningTenantContextAsync( + LearnStack.SharedKernel.Identifiers.TenantId tenantId, + CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task CommitAsync(CancellationToken cancellationToken = default) { HasActiveTransaction = false; diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs index 128477a0..ce581814 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using LearnStack.Application.Pipeline; using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; @@ -318,6 +319,17 @@ private sealed class RecordingUnitOfWork : IUnitOfWork // announcement was made, which is what the behaviour under test drives. public bool IsTenantContextIssuedOn(DbTransaction? transaction) => TenantContext is not null; + /// What a provisioning request announced, if it announced one. + public TenantId? ProvisionedTenantId { get; private set; } + + public Task SetProvisioningTenantContextAsync( + TenantId tenantId, CancellationToken cancellationToken = default) + { + Calls.Add("provision"); + ProvisionedTenantId = tenantId; + return Task.CompletedTask; + } + public bool HasActiveTransaction => _depth > 0; public Task BeginTransactionAsync(CancellationToken cancellationToken = default) From d29cc84fcc555df7e6ea5488bf43da5106252740 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 04:45:55 +0300 Subject: [PATCH 28/55] feat(tenancy): settle the aggregate boundary Packet 6 left open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 6 shipped four types with public factories, top-level DbSets and no navigation from Tenant, which is not the containment the module spec's ERD describes — and the two halves do not resolve the same way. Promotion: TenantDomain and TenantSetting become roots, one token each, because a surrogate Vogen id, an AuditableEntity base, a row_version and their own RLS policy are all already shipped. Containment: TenantLocale and TenantFeatureFlag become navigations, because a composite natural key with no surrogate id is not an IAggregateRoot under any reading. Tenancy has four roots. The row_version bump the roadmap requires for a locale or flag write is a consequence of that containment and not a second mechanism. Version advances only inside AuditableEntity.Touch, reached only from MarkUpdated — so routing every child write through a root method bumps it, and a caller holding a locale directly could not have produced one. The two factories are internal now and the DbSets are gone: a detached child write through DbSet would have left Tenant.row_version where it was, silently, because the root would never be tracked. Not owned types, and the reason is mechanical rather than stylistic. An owned mapping is the natural way to say "part of the root" and would silently remove both query filters — the filter builder skips entityType.IsOwned() — so the rows would lose the EF half of the four-layer isolation and the correspondence test would fail looking for a type no longer in the model on its own. The single-default invariant lands as a partial unique index AND an aggregate guard, because they answer different questions. The guard produces a readable error for a caller that asks twice in one unit of work; two transactions each promoting a different locale both pass it, neither able to see the other's uncommitted row, and one of them has to lose at the database. Both directions are pinned, including the partiality: measured, an unfiltered unique index on tenant_id passes the second-default case and silently allows only ONE LOCALE per tenant, which is not the invariant. Clear-then-set is not cosmetic either. EF emits one UPDATE per changed row, so a swap is two statements, and measured against the index the new-first order fails 23505 while old-first succeeds. The aggregate does it in that order and a test asserts both halves. The scaffolder laid the trap the design pass predicted: mapping the two navigations introduced the first relationships into a model that had none, so `dotnet ef migrations add` also emitted AddForeignKey for two constraints that already exist as raw SQL in the first tenancy migration, invisible to the snapshot. Deleted by hand — that failure lands at `make migrate` against an existing database, not at build, so a green local suite would have proved nothing. Twelve existing tests called the two factories directly. They go through the root now, which is the containment working rather than a cost: the guards are unchanged and run inside the same factories. 1142 green, zero skips, measured. Four mutations killed. ADR: 0042 Co-Authored-By: Claude Opus 5 (1M context) --- .../CompositeKeyedEntities.cs | 18 +- .../Tenant.cs | 153 +++++ .../TenantDomain.cs | 3 +- .../TenantSetting.cs | 3 +- .../Persistence/Configurations.cs | 44 ++ ...1_tenant_locale_single_default.Designer.cs | 524 ++++++++++++++++++ ...0903014131_tenant_locale_single_default.cs | 46 ++ .../TenancyDbContextModelSnapshot.cs | 32 ++ .../Persistence/TenancyDbContext.cs | 2 - .../Database/TenantLocaleDefaultTests.cs | 176 ++++++ .../Modules/Tenancy/TenancyAggregateTests.cs | 26 +- 11 files changed, 1009 insertions(+), 18 deletions(-) create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903014131_tenant_locale_single_default.Designer.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903014131_tenant_locale_single_default.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleDefaultTests.cs diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs index b7711e70..6067b065 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs @@ -54,7 +54,19 @@ public sealed class TenantLocale : ITenantOwned /// Display order in a language switcher. public short Sort { get; private set; } - public static TenantLocale Create( + /// Makes this the tenant's default locale. + /// + /// internal, and reached only from Tenant.PromoteDefault, which clears + /// the incumbent first. Exposed publicly it would be the one call that can put two + /// rows past the aggregate guard and into the partial unique index, where the failure + /// is a 23505 nobody wrote a message for. + /// + internal void MakeDefault() => IsDefault = true; + + /// Stops this being the default. + internal void ClearDefault() => IsDefault = false; + + internal static TenantLocale Create( TenantId tenantId, string locale, bool isDefault, bool isEnabled = true, short sort = 0) { ArgumentException.ThrowIfNullOrWhiteSpace(locale); @@ -113,7 +125,7 @@ private TenantFeatureFlag() public UserId UpdatedBy { get; private set; } - public static TenantFeatureFlag Create( + internal static TenantFeatureFlag Create( TenantId tenantId, string key, string value, DateTimeOffset at, UserId by) { ArgumentException.ThrowIfNullOrWhiteSpace(key); @@ -140,7 +152,7 @@ public static TenantFeatureFlag Create( }; } - public void SetValue(string value, DateTimeOffset at, UserId by) + internal void SetValue(string value, DateTimeOffset at, UserId by) { JsonValue.EnsureWellFormed(value, nameof(value)); AuditInput.EnsureValid(at, by); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index dc90dbcf..0436821a 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -148,6 +148,159 @@ public void AssignDefaultOrganization(OrganizationId organizationId, IClock cloc /// aggregate — "the schema would not object, so the aggregate is where the /// invariant lives". /// + private readonly List _locales = []; + + /// + /// The locales this tenant publishes in. + /// + /// + /// A navigation rather than its own aggregate: a locale row is + /// PRIMARY KEY (tenant_id, locale) with no surrogate id, and a composite + /// natural key is not an IAggregateRoot<TId> under any reading. It is + /// read-only here because the root is the only way in — see the mutators below, and + /// the reason they exist. + /// + public IReadOnlyCollection Locales => _locales.AsReadOnly(); + + private readonly List _featureFlags = []; + + /// This tenant's feature-flag overrides. + public IReadOnlyCollection FeatureFlags => _featureFlags.AsReadOnly(); + + /// Adds a locale this tenant publishes in. + /// + /// Every child write goes through a root method, and that is what bumps + /// row_version. The version advances only inside + /// AuditableEntity.Touch, which nothing but MarkUpdated and + /// SoftDelete reach — so the bump the roadmap requires for a locale or flag + /// write is a consequence of containment rather than a second mechanism someone has + /// to remember. A caller holding a locale directly could not have produced it. + /// + public void AddLocale( + string locale, + bool isDefault, + IClock clock, + UserId updatedBy, + bool isEnabled = true, + short sort = 0) + { + ArgumentNullException.ThrowIfNull(clock); + + var added = TenantLocale.Create(Id, locale, isDefault: false, isEnabled, sort); + + if (_locales.Any(existing => string.Equals( + existing.Locale, added.Locale, StringComparison.Ordinal))) + { + throw new InvalidOperationException( + $"This tenant already publishes in '{added.Locale}'. A locale row is " + + "identified by (tenant_id, locale), so a second one is a duplicate " + + "rather than a second locale."); + } + + MarkUpdated(clock.UtcNow, updatedBy); + _locales.Add(added); + + if (isDefault) + { + PromoteDefault(added); + } + } + + /// Makes an existing locale this tenant's default. + public void SetDefaultLocale(string locale, IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(locale); + + var canonical = LocaleTag.Canonicalize(locale); + var target = _locales.FirstOrDefault(existing => string.Equals( + existing.Locale, canonical, StringComparison.Ordinal)) + ?? throw new InvalidOperationException( + $"This tenant does not publish in '{canonical}', so it cannot be the default."); + + MarkUpdated(clock.UtcNow, updatedBy); + PromoteDefault(target); + } + + /// Removes a locale, which must not be the default. + public void RemoveLocale(string locale, IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(locale); + + var canonical = LocaleTag.Canonicalize(locale); + var target = _locales.FirstOrDefault(existing => string.Equals( + existing.Locale, canonical, StringComparison.Ordinal)) + ?? throw new InvalidOperationException( + $"This tenant does not publish in '{canonical}'."); + + if (target.IsDefault) + { + throw new InvalidOperationException( + $"'{canonical}' is this tenant's default locale. Promote another locale " + + "first: a tenant that publishes in nothing has no fallback to render."); + } + + MarkUpdated(clock.UtcNow, updatedBy); + _locales.Remove(target); + } + + /// Sets a feature-flag override, adding it when it is new. + public void SetFeatureFlag(string key, string value, IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + MarkUpdated(clock.UtcNow, updatedBy); + + var existing = _featureFlags.FirstOrDefault( + flag => string.Equals(flag.Key, key, StringComparison.Ordinal)); + + if (existing is null) + { + _featureFlags.Add( + TenantFeatureFlag.Create(Id, key, value, clock.UtcNow, updatedBy)); + return; + } + + existing.SetValue(value, clock.UtcNow, updatedBy); + } + + /// Removes a feature-flag override. + public void RemoveFeatureFlag(string key, IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + var target = _featureFlags.FirstOrDefault( + flag => string.Equals(flag.Key, key, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"No feature flag '{key}' is set."); + + MarkUpdated(clock.UtcNow, updatedBy); + _featureFlags.Remove(target); + } + + /// + /// Clears the incumbent default before setting the new one. + /// + /// + /// The order is not cosmetic. A partial unique index + /// UNIQUE (tenant_id) WHERE is_default backs this invariant in the database, + /// and EF emits one UPDATE per changed row: measured, setting the new default first + /// fails 23505, and clearing the incumbent first succeeds. The guard here exists for + /// the error message; the index exists because an aggregate invariant does not hold + /// across concurrent transactions. + /// + private void PromoteDefault(TenantLocale target) + { + foreach (var incumbent in _locales.Where(locale => locale.IsDefault)) + { + incumbent.ClearDefault(); + } + + target.MakeDefault(); + } + public void ChangeStatus(TenantStatus status, IClock clock, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs index 31873955..2bfe5bc4 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs @@ -28,7 +28,8 @@ namespace LearnStack.Modules.Tenancy.Domain; /// /// [TenantOwned] -public sealed class TenantDomain : AuditableEntity, ITenantOwned +public sealed class TenantDomain + : AuditableEntity, ITenantOwned, IAggregateRoot { private TenantDomain(TenantDomainId id) : base(id) => Host = null!; diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs index a8c83e83..6fc33683 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs @@ -32,7 +32,8 @@ namespace LearnStack.Modules.Tenancy.Domain; /// [TenantOwned] [OrganizationScoped] -public sealed class TenantSetting : AuditableEntity, IOrganizationScoped +public sealed class TenantSetting + : AuditableEntity, IOrganizationScoped, IAggregateRoot { private TenantSetting(TenantSettingId id) : base(id) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs index 0c586c16..449c9964 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs @@ -112,6 +112,37 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.Slug).IsUnique().HasDatabaseName("ux_tenants_slug"); builder.MapAuditColumns(); + + // The containment the aggregate boundary decides, expressed for EF — and NOT as + // owned types. An owned mapping looks like the natural way to say "part of the + // root" and silently removes both query filters: the filter builder skips + // entityType.IsOwned(), so the rows would lose the EF half of the four-layer + // isolation and the correspondence test would fail looking for a type that is no + // longer in the model on its own. + // + // OnDelete is explicit because EF defaults a required relationship to Cascade + // while the shipped DDL is ON DELETE RESTRICT; the constraint names match what + // the first migration already created as raw SQL, so no foreign key is scaffolded + // twice. + builder.HasMany(t => t.Locales) + .WithOne() + .HasForeignKey(l => l.TenantId) + .HasConstraintName("fk_tenant_locales_tenant") + .OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(t => t.FeatureFlags) + .WithOne() + .HasForeignKey(f => f.TenantId) + .HasConstraintName("fk_tenant_feature_flags_tenant") + .OnDelete(DeleteBehavior.Restrict); + + // Through the backing fields, not the read-only views: the views are + // AsReadOnly() wrappers EF cannot add to, and materialising into them would throw + // at the first query that loads a tenant with locales. + builder.Metadata.FindNavigation(nameof(Tenant.Locales))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + builder.Metadata.FindNavigation(nameof(Tenant.FeatureFlags))! + .SetPropertyAccessMode(PropertyAccessMode.Field); } } @@ -228,6 +259,19 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.IsDefault).IsRequired(); builder.Property(x => x.IsEnabled).HasDefaultValue(true).IsRequired(); builder.Property(x => x.Sort).HasDefaultValue((short)0).IsRequired(); + + // The single-default invariant, in the one place it holds across concurrent + // transactions. The aggregate guard on Tenant produces the error message; this + // produces the guarantee — two transactions each promoting a different locale + // both pass an in-memory check and one of them must lose here. + // + // The filter is raw SQL evaluated after the snake-case convention runs, so it is + // spelled is_default and not IsDefault — the same spelling the three shipped + // HasFilter("deleted_at IS NULL") calls use. + builder.HasIndex(x => x.TenantId) + .IsUnique() + .HasFilter("is_default") + .HasDatabaseName("ux_tenant_locales_tenant_id_is_default"); } } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903014131_tenant_locale_single_default.Designer.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903014131_tenant_locale_single_default.Designer.cs new file mode 100644 index 00000000..a946d5e8 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903014131_tenant_locale_single_default.Designer.cs @@ -0,0 +1,524 @@ +// +using System; +using LearnStack.Modules.Tenancy.Infrastructure.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 LearnStack.Modules.Tenancy.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(TenancyDbContext))] + [Migration("20260903014131_tenant_locale_single_default")] + partial class tenant_locale_single_default + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("CustomSubdomain") + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("custom_subdomain"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ReportingParentId") + .HasColumnType("uuid") + .HasColumnName("reporting_parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_organizations"); + + b.HasIndex("TenantId", "Id") + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_id"); + + b.HasIndex("TenantId", "ReportingParentId") + .HasDatabaseName("ix_organizations_tenant_id_reporting_parent_id"); + + b.HasIndex("TenantId", "Slug") + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_slug") + .HasFilter("deleted_at IS NULL"); + + b.ToTable("organizations", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.PlatformEntitlement", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Compliance") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("compliance"); + + b.Property("Features") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("features"); + + b.Property("Generation") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L) + .HasColumnName("generation"); + + b.Property("GraceUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("grace_until"); + + b.Property("Limits") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("limits"); + + b.Property("PlanCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("plan_code"); + + b.Property("RefreshedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("TenantId") + .HasName("pk_platform_entitlement_cache"); + + b.ToTable("platform_entitlement_cache", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.PlatformHostMapping", b => + { + b.Property("Host") + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("host"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsPubliclyLive") + .HasColumnType("boolean") + .HasColumnName("is_publicly_live"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Host") + .HasName("pk_platform_host_to_tenant"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_platform_host_to_tenant_tenant_id_organization_id"); + + b.ToTable("platform_host_to_tenant", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DefaultOrganizationId") + .HasColumnType("uuid") + .HasColumnName("default_organization_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenants"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_tenants_slug"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantDomain", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("host"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("LastVerificationError") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("last_verification_error"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("VerificationAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("verification_attempts"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenant_domains"); + + b.HasIndex("Host") + .IsUnique() + .HasDatabaseName("ux_tenant_domains_host") + .HasFilter("deleted_at IS NULL"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_tenant_domains_tenant_id"); + + b.ToTable("tenant_domains", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantFeatureFlag", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value"); + + b.HasKey("TenantId", "Key") + .HasName("pk_tenant_feature_flags"); + + b.ToTable("tenant_feature_flags", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantLocale", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Locale") + .HasMaxLength(35) + .HasColumnType("character varying(35)") + .HasColumnName("locale"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_enabled"); + + b.Property("Sort") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0) + .HasColumnName("sort"); + + b.HasKey("TenantId", "Locale") + .HasName("pk_tenant_locales"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ux_tenant_locales_tenant_id_is_default") + .HasFilter("is_default"); + + b.ToTable("tenant_locales", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantSetting", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenant_settings"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_tenant_settings_tenant_id_organization_id"); + + b.HasIndex("TenantId", "OrganizationId", "Key") + .IsUnique() + .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key") + .HasFilter("deleted_at IS NULL"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "OrganizationId", "Key"), false); + + b.ToTable("tenant_settings", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantFeatureFlag", b => + { + b.HasOne("LearnStack.Modules.Tenancy.Domain.Tenant", null) + .WithMany("FeatureFlags") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_feature_flags_tenant"); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantLocale", b => + { + b.HasOne("LearnStack.Modules.Tenancy.Domain.Tenant", null) + .WithMany("Locales") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_locales_tenant"); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Tenant", b => + { + b.Navigation("FeatureFlags"); + + b.Navigation("Locales"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903014131_tenant_locale_single_default.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903014131_tenant_locale_single_default.cs new file mode 100644 index 00000000..4d5de3cc --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903014131_tenant_locale_single_default.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence.Migrations +{ + /// + public partial class tenant_locale_single_default : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // ONE object, additive. The single-default invariant needs a database + // guarantee because an aggregate invariant does not hold across concurrent + // transactions: two transactions each promoting a different locale both pass + // the in-memory guard, and one of them must lose here. + // + // The scaffolder ALSO emitted AddForeignKey for fk_tenant_locales_tenant and + // fk_tenant_feature_flags_tenant, because mapping the two navigations + // introduced the first relationships into a model that had none. Both + // constraints already exist — created as raw SQL in the first tenancy + // migration, where the snapshot cannot see them — so applying those calls + // fails with "constraint already exists" against any database that has run + // it. Deleted by hand; the HasConstraintName on each relationship is what + // keeps the surviving snapshot agreeing with the live schema. This fails at + // `make migrate`, not at build, so a green local suite would have proved + // nothing. + migrationBuilder.CreateIndex( + name: "ux_tenant_locales_tenant_id_is_default", + table: "tenant_locales", + column: "tenant_id", + unique: true, + filter: "is_default"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // The index and nothing else, matching Up. Dropping either foreign key here + // would remove a constraint this migration never created. + migrationBuilder.DropIndex( + name: "ux_tenant_locales_tenant_id_is_default", + table: "tenant_locales"); + } + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs index 93bb58b0..07bb0617 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs @@ -410,6 +410,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("TenantId", "Locale") .HasName("pk_tenant_locales"); + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ux_tenant_locales_tenant_id_is_default") + .HasFilter("is_default"); + b.ToTable("tenant_locales", (string)null); }); @@ -483,6 +488,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("tenant_settings", (string)null); }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantFeatureFlag", b => + { + b.HasOne("LearnStack.Modules.Tenancy.Domain.Tenant", null) + .WithMany("FeatureFlags") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_feature_flags_tenant"); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantLocale", b => + { + b.HasOne("LearnStack.Modules.Tenancy.Domain.Tenant", null) + .WithMany("Locales") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_locales_tenant"); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Tenant", b => + { + b.Navigation("FeatureFlags"); + + b.Navigation("Locales"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs index 679f3347..e3385525 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs @@ -47,11 +47,9 @@ public sealed class TenancyDbContext( public DbSet TenantDomains => Set(); - public DbSet TenantLocales => Set(); public DbSet TenantSettings => Set(); - public DbSet TenantFeatureFlags => Set(); public DbSet PlatformEntitlements => Set(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleDefaultTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleDefaultTests.cs new file mode 100644 index 00000000..58028abd --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleDefaultTests.cs @@ -0,0 +1,176 @@ +using FluentAssertions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// The single-default locale invariant, in the one place it holds across concurrent +/// transactions. +/// +/// +/// +/// The aggregate guard and the index answer different questions, and only one of them +/// is a guarantee. Tenant.AddLocale and Tenant.SetDefaultLocale clear +/// the incumbent before promoting, which produces a readable error for the caller that +/// asks twice in one unit of work. Two transactions each promoting a different locale +/// both pass that guard — neither can see the other's uncommitted row — and one of them +/// has to lose at the database. That is what these cases are about. +/// +/// +/// Connected as learnstack_app: the invariant has to hold for the role that +/// actually writes, and a bypass role would answer a different question. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class TenantLocaleDefaultTests +{ + private readonly SchemaFixture _schema; + + public TenantLocaleDefaultTests(SchemaFixture schema) => _schema = schema; + + [Fact] + public async Task A_Second_Default_For_One_Tenant_Is_Refused() + { + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + + await using var connection = await dataSource.OpenConnectionAsync(CancellationToken.None); + await using var transaction = await connection.BeginTransactionAsync(CancellationToken.None); + await AnnounceAsync(connection, transaction, SchemaFixture.TenantA); + + // The fixture already publishes tr-TR as tenant A's default, so this IS the + // second one — no setup needed, and using the seeded incumbent means the case + // exercises the state a real tenant is in rather than one it builds for itself. + var second = async () => await InsertLocaleAsync( + connection, transaction, SchemaFixture.TenantA, "en-US", isDefault: true); + + (await second.Should().ThrowAsync()) + .Which.SqlState.Should().Be("23505", + "ux_tenant_locales_tenant_id_is_default is what makes one default a " + + "guarantee rather than a convention"); + } + + [Fact] + public async Task A_Non_Default_Locale_Is_Not_Constrained() + { + // The index is PARTIAL, and the partiality is the point: a tenant publishes in + // many locales and exactly one of them is the default. An unfiltered unique index + // on tenant_id would allow one locale per tenant, which is not the invariant. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + + await using var connection = await dataSource.OpenConnectionAsync(CancellationToken.None); + await using var transaction = await connection.BeginTransactionAsync(CancellationToken.None); + await AnnounceAsync(connection, transaction, SchemaFixture.TenantB); + + // Tenant B already publishes en-US as its default; these are additional + // non-default locales beside it. + await InsertLocaleAsync(connection, transaction, SchemaFixture.TenantB, "tr-TR", isDefault: false); + await InsertLocaleAsync(connection, transaction, SchemaFixture.TenantB, "de-DE", isDefault: false); + + // No exception is the assertion; the count confirms both landed beside the seed. + (await ScalarAsync(connection, transaction, + "SELECT count(*) FROM tenant_locales WHERE tenant_id = @tenant", + SchemaFixture.TenantB)).Should().Be(3); + } + + [Fact] + public async Task Clearing_The_Incumbent_First_Is_What_Makes_A_Swap_Possible() + { + // The order the aggregate uses, and why it is not cosmetic. EF emits one UPDATE + // per changed row, so a swap is two statements — and against a unique index the + // order decides whether the second one is legal. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + + await using var connection = await dataSource.OpenConnectionAsync(CancellationToken.None); + await using var transaction = await connection.BeginTransactionAsync(CancellationToken.None); + await AnnounceAsync(connection, transaction, SchemaFixture.TenantA); + + // tr-TR is the seeded default; en-US is the challenger. + await InsertLocaleAsync(connection, transaction, SchemaFixture.TenantA, "en-US", isDefault: false); + + // New-first: refused. + var newFirst = async () => await ExecuteAsync(connection, transaction, + "UPDATE tenant_locales SET is_default = true WHERE tenant_id = @tenant AND locale = 'en-US'", + SchemaFixture.TenantA); + (await newFirst.Should().ThrowAsync()).Which.SqlState.Should().Be("23505"); + + await transaction.RollbackAsync(CancellationToken.None); + + // Old-first: succeeds. Same two statements, opposite order. + await using var second = await connection.BeginTransactionAsync(CancellationToken.None); + await AnnounceAsync(connection, second, SchemaFixture.TenantA); + await InsertLocaleAsync(connection, second, SchemaFixture.TenantA, "en-US", isDefault: false); + + await ExecuteAsync(connection, second, + "UPDATE tenant_locales SET is_default = false WHERE tenant_id = @tenant AND locale = 'tr-TR'", + SchemaFixture.TenantA); + await ExecuteAsync(connection, second, + "UPDATE tenant_locales SET is_default = true WHERE tenant_id = @tenant AND locale = 'en-US'", + SchemaFixture.TenantA); + + (await ScalarAsync(connection, second, + "SELECT locale FROM tenant_locales WHERE tenant_id = @tenant AND is_default", + SchemaFixture.TenantA)).Should().Be("en-US"); + + // Rolled back: the fixture seeds exact row counts other classes assert on. + await second.RollbackAsync(CancellationToken.None); + } + + private static async Task AnnounceAsync( + NpgsqlConnection connection, NpgsqlTransaction transaction, Guid tenant) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = "SELECT set_config('app.tenant_id', @tenant, true)"; + command.Parameters.Add(new NpgsqlParameter("tenant", tenant.ToString())); + await command.ExecuteNonQueryAsync(CancellationToken.None); + } + + private static async Task InsertLocaleAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid tenant, + string locale, + bool isDefault) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = + """ + INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) + VALUES (@tenant, @locale, @isDefault, true, 0) + """; + command.Parameters.Add(new NpgsqlParameter("tenant", tenant)); + command.Parameters.Add(new NpgsqlParameter("locale", locale)); + command.Parameters.Add(new NpgsqlParameter("isDefault", isDefault)); + await command.ExecuteNonQueryAsync(CancellationToken.None); + } + + private static async Task ExecuteAsync( + NpgsqlConnection connection, NpgsqlTransaction transaction, + string sql, Guid tenant) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + command.Parameters.Add(new NpgsqlParameter("tenant", tenant)); + await command.ExecuteNonQueryAsync(CancellationToken.None); + } + + private static async Task ScalarAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string sql, + Guid tenant) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + var parameter = command.CreateParameter(); + parameter.ParameterName = "tenant"; + parameter.Value = tenant; + command.Parameters.Add(parameter); + return (T)(await command.ExecuteScalarAsync(CancellationToken.None))!; + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs index 8570dba7..70934186 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -223,7 +223,7 @@ public void A_setting_value_must_be_well_formed_json(string value, bool accepted [InlineData("yes", false)] public void A_feature_flag_value_must_be_well_formed_json(string value, bool accepted) { - var create = () => TenantFeatureFlag.Create(Tenant, "live-classroom", value, Clock.UtcNow, Actor); + var create = () => NewTenant().SetFeatureFlag("live-classroom", value, Clock, Actor); if (accepted) { @@ -377,7 +377,7 @@ public void A_feature_flag_refuses_the_audit_sentinels(bool sentinelClock, bool var at = sentinelClock ? default : Clock.UtcNow; var by = emptyActor ? UserId.From(Guid.Empty) : Actor; - var create = () => TenantFeatureFlag.Create(Tenant, "beta", "true", at, by); + var create = () => NewTenant().SetFeatureFlag("beta", "true", new FixedClock(at), by); create.Should().Throw(); } @@ -385,9 +385,11 @@ public void A_feature_flag_refuses_the_audit_sentinels(bool sentinelClock, bool [Fact] public void Setting_a_feature_flag_refuses_them_too() { - var flag = TenantFeatureFlag.Create(Tenant, "beta", "true", Clock.UtcNow, Actor); + var tenant = NewTenant(); + tenant.SetFeatureFlag("beta", "true", Clock, Actor); + var flag = tenant.FeatureFlags.Single(); - var set = () => flag.SetValue("false", default, Actor); + var set = () => tenant.SetFeatureFlag("false", "x", new FixedClock(default), Actor); set.Should().Throw(); } @@ -477,7 +479,7 @@ private static TenancyDomain.Tenant NewTenant() => [InlineData("en-Latn-US-scouse-scouse-scouse-scouse", false)] public void A_locale_is_bounded_by_the_length_its_column_holds(string locale, bool accepted) { - var create = () => TenantLocale.Create(Tenant, locale, isDefault: true); + var create = () => NewTenant().AddLocale(locale, isDefault: true, Clock, Actor); if (accepted) { @@ -501,8 +503,10 @@ public void A_locale_is_bounded_by_the_length_its_column_holds(string locale, bo [InlineData("tr", "tr")] public void A_locale_is_stored_in_canonical_case(string input, string expected) { - TenantLocale.Create(Tenant, input, isDefault: true) - .Locale.Should().Be(expected); + var tenant = NewTenant(); + tenant.AddLocale(input, isDefault: true, Clock, Actor); + + tenant.Locales.Should().ContainSingle().Which.Locale.Should().Be(expected); } [Theory] @@ -512,7 +516,7 @@ public void A_locale_is_stored_in_canonical_case(string input, string expected) [InlineData("zh-Hans-CN")] public void A_well_formed_locale_tag_is_accepted(string locale) { - var create = () => TenantLocale.Create(Tenant, locale, isDefault: true); + var create = () => NewTenant().AddLocale(locale, isDefault: true, Clock, Actor); create.Should().NotThrow(); } @@ -530,7 +534,7 @@ public void A_well_formed_locale_tag_is_accepted(string locale) [InlineData("123")] public void A_locale_that_is_not_a_bcp47_tag_is_refused(string locale) { - var create = () => TenantLocale.Create(Tenant, locale, isDefault: true); + var create = () => NewTenant().AddLocale(locale, isDefault: true, Clock, Actor); create.Should().Throw().WithMessage("*BCP-47*"); } @@ -560,8 +564,8 @@ public void A_setting_key_is_bounded_by_the_length_its_column_holds(int length, [InlineData(201, false)] public void A_feature_flag_key_is_bounded_by_the_length_its_column_holds(int length, bool accepted) { - var create = () => TenantFeatureFlag.Create( - Tenant, new string('k', length), "true", Clock.UtcNow, Actor); + var create = () => NewTenant().SetFeatureFlag( + new string('k', length), "\"v\"", Clock, Actor); if (accepted) { From 12866b25d3348407f49528edd71df0b0ff10a9a7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 09:19:52 +0300 Subject: [PATCH 29/55] feat(tenancy): provision a tenant and its default organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0042 sanctions exactly one operation to write two aggregate roots on one transaction, and this is it: the command, its handler, its validator, the two write ports, and the architecture rule that counts them. The rule counts PORT TYPES rather than names. The catalogue registered it as a scan for DbSet use in handlers, which under the shipped dependency rules can never fire — Application may not reference Infrastructure, so no handler can name a DbSet at all. A rule at Implemented status that cannot fire claims coverage the suite does not have. The validator shares the aggregates' guards rather than skipping them. Leaving slug shape and the mapped widths to the factories alone was measured wrong: ArgumentException has no entry in HttpStatusMap, so a mistyped slug became a 500 — raised after ValidationBehavior passed the command, after the transaction was opened, and after the tenant was announced. The shape and the two numbers are declared once in the domain and read by both layers, and Cascade(Stop) keeps the regex off a null a deserializer could supply. Two guards had no test until the mutation round said so. The ordinary SetTenantContextAsync survived being made session-scoped against all 292 integration cases, because Npgsql's DISCARD ALL cleans up after the bug — which a PgBouncer in transaction-pooling mode does not. Both setters now have a case that suppresses the reset and holds the pool at one. Module: Tenancy ADR: 0042, 0040, 0003 Co-Authored-By: Claude Opus 5 (1M context) --- .../PersistenceCompositionExtensions.cs | 8 + backend/src/LearnStack.Api/Program.cs | 9 +- .../LearnStack.Application.csproj | 5 + .../Pipeline/MediatRPipelineRegistration.cs | 9 + ...dules.Tenancy.Application.Contracts.csproj | 7 + .../Tenant/ProvisionTenantCommand.cs | 65 +++ .../Abstractions/TenancyWriteStores.cs | 21 + .../Tenant/ProvisionTenantCommandHandler.cs | 85 ++++ .../Tenant/ProvisionTenantCommandValidator.cs | 80 +++ .../CompositeKeyedEntities.cs | 39 +- .../Organization.cs | 9 +- .../Tenant.cs | 4 +- .../Persistence/TenancyWriteStores.cs | 50 ++ .../RequestSurfaceTests.cs | 59 ++- .../Database/TenantProvisioningTests.cs | 469 ++++++++++++++++++ .../Database/UnitOfWorkTests.cs | 55 +- .../Tenancy/ProvisionTenantCommandTests.cs | 336 +++++++++++++ 17 files changed, 1295 insertions(+), 15 deletions(-) create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 7c249a64..971758d9 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -1,5 +1,6 @@ using LearnStack.Infrastructure.MultiTenancy; using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Abstractions; using LearnStack.Modules.Tenancy.Infrastructure.Persistence; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Tenancy; @@ -142,6 +143,13 @@ public static IServiceCollection AddLearnStackPersistence( // table — silently. services.AddModuleDbContext(); + // The write side of the two Tenancy roots, beside the context they run on. A + // handler cannot name a DbSet — Application → Infrastructure is a forbidden edge + // and the reverse reference is already a cycle — so these ports are how the first + // production handler reaches persistence at all. + services.TryAddScoped(); + services.TryAddScoped(); + return services; } diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index f6238b3e..892b15b0 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -28,7 +28,14 @@ // X-Forwarded-For. builder.Configuration.RefuseAmbientForwardedHeaders(); -builder.AddLearnStackCrossCuttingFoundation(deploymentMode); +// The module assemblies MediatR scans for handlers. Empty until Packet 7 step 9, which +// ships the first production request type — and the parameter existed all along, so the +// change is one argument rather than a new seam. A module whose assembly is missing here +// has handlers nothing dispatches, which fails as "no handler for request" at the call +// site rather than at startup. +builder.AddLearnStackCrossCuttingFoundation( + deploymentMode, + typeof(LearnStack.Modules.Tenancy.Application.AssemblyMarker).Assembly); builder.Services.AddLearnStackTenancyEdge(builder.Configuration); builder.Services.AddLearnStackPersistence(builder.Configuration); builder.Services.AddLearnStackRateLimiting(); diff --git a/backend/src/LearnStack.Application/LearnStack.Application.csproj b/backend/src/LearnStack.Application/LearnStack.Application.csproj index 10a1017a..83a99e56 100644 --- a/backend/src/LearnStack.Application/LearnStack.Application.csproj +++ b/backend/src/LearnStack.Application/LearnStack.Application.csproj @@ -20,6 +20,11 @@ + + diff --git a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs index f40e6a95..9a28b17f 100644 --- a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs +++ b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs @@ -1,3 +1,4 @@ +using FluentValidation; using MediatR; using Microsoft.Extensions.DependencyInjection; @@ -64,6 +65,14 @@ public static IServiceCollection AddLearnStackMediatRPipeline( } }); + // The same assemblies, for the same reason, and it was missing. ValidationBehavior + // resolves IEnumerable> from the container, so without this + // every validator in the solution is a class nothing constructs — the behavior + // sees an empty array, short-circuits, and a command with a validator is refused + // by nothing. It shipped that way only because no validator existed yet; the + // first one would have been silently inert. + services.AddValidatorsFromAssemblies(assembliesToScan, includeInternalTypes: true); + return services; } } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/LearnStack.Modules.Tenancy.Application.Contracts.csproj b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/LearnStack.Modules.Tenancy.Application.Contracts.csproj index b3ff05e6..f7950d36 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/LearnStack.Modules.Tenancy.Application.Contracts.csproj +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/LearnStack.Modules.Tenancy.Application.Contracts.csproj @@ -4,6 +4,13 @@ LearnStack.Modules.Tenancy.Application.Contracts + + + + + diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs new file mode 100644 index 00000000..29a9c6ec --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs @@ -0,0 +1,65 @@ +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.Tenant; + +/// +/// Creates a tenant and the organization its content hangs from, in one transaction. +/// +/// +/// +/// The one operation sanctioned to write two aggregate roots at once +/// (ADR-0042), +/// by enumeration rather than by principle: a tenant whose default organization failed to +/// commit is a tenant no request can serve, and a second transaction is a window in which +/// exactly that state exists. The allow-list has one entry and +/// Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning is what keeps it at +/// one. +/// +/// +/// Both ids are inbound, and that is a policy consequence rather than a style +/// choice. tenants is self-keyed and its policy is +/// WITH CHECK (id = app.tenant_id), so the transaction must be announced with the +/// id before the insert — and a handler that minted its own could not satisfy its own +/// policy. This is the one place the ordinary rule against taking a tenant id from a +/// request does not apply: the id names a tenant that does not exist yet, so it grants +/// nothing, and is honoured only when the context is +/// unresolved. +/// +/// +/// [AllowsUnresolvedTenantContext] and not [PublicSurface]. The +/// request legitimately arrives with no tenant — there is none until it succeeds — but it +/// is emphatically not reachable by an unauthenticated caller. Phase 03's permission +/// check is what will gate it; until then its only callers are the seeder and the tests. +/// +/// +[AllowsUnresolvedTenantContext] +public sealed record ProvisionTenantCommand( + TenantId TenantId, + string Slug, + string DisplayName, + OrganizationId DefaultOrganizationId, + string DefaultOrganizationSlug, + string DefaultOrganizationDisplayName) + : IRequest>, IProvisionsTenant +{ + /// + /// + /// What TransactionBehavior announces on the ambient transaction, so the + /// policy admits the row this command is about to insert. + /// + public TenantId ProvisioningTenantId => TenantId; +} + +/// What provisioning produced. +/// +/// The ids are echoed rather than generated, so a caller that lost its correlation can +/// still tie the result to what it asked for. +/// +public sealed record ProvisionedTenantDto( + Guid TenantId, + string Slug, + Guid DefaultOrganizationId, + string DefaultOrganizationSlug); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs new file mode 100644 index 00000000..aaa320cf --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs @@ -0,0 +1,21 @@ +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; + +namespace LearnStack.Modules.Tenancy.Application.Abstractions; + +/// The write side of the Tenant aggregate. +/// +/// Declared here and implemented across the boundary: Application → Infrastructure +/// is a forbidden edge, and the reverse reference already exists, so a handler that named +/// TenancyDbContext would be a project cycle the compiler refuses. +/// +public interface ITenantWriteStore : IAggregateWriteStore; + +/// The write side of the Organization aggregate. +/// +/// A second port rather than one fused with the first, deliberately: the rule that +/// confines cross-aggregate writes counts how many of these a handler takes, and a +/// combined port would hide the very thing ADR-0042 exists to enumerate. +/// +public interface IOrganizationWriteStore : IAggregateWriteStore; diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs new file mode 100644 index 00000000..ecaca379 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs @@ -0,0 +1,85 @@ +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Tenant; + +/// +/// Creates the tenant and its default organization, in that order, on one transaction. +/// +/// +/// +/// The order is the design, not a preference. Measured against the shipped +/// policies: the tenant row must exist before the organization, because +/// organizations has a composite foreign key to (tenant_id, id); and the +/// back-reference has to be a separate update, because tenants.default_organization_id +/// points at a row that does not exist when the tenant is inserted. Three statements, one +/// transaction, one commit — which is the whole of what ADR-0042 sanctions and the reason +/// a second connection was not an option. +/// +/// +/// It never announces anything. TransactionBehavior has already announced +/// the tenant this command names, by reading IProvisionsTenant off the request at +/// step 6. A handler that announced would be an eighth setter of app.tenant_id +/// against a set two ADRs close at seven, and would hand every handler in the solution +/// the ability to move the ambient tenant. +/// +/// +/// Two ports, and the rule counts them. This is the only handler in the solution +/// permitted to take more than one IAggregateWriteStore, which is what +/// Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning asserts. A combined +/// port would hide the cross-aggregate write from the rule that exists to count it. +/// +/// +internal sealed class ProvisionTenantCommandHandler( + ITenantWriteStore tenants, + IOrganizationWriteStore organizations, + IClock clock) + : IRequestHandler> +{ + public async Task> Handle( + ProvisionTenantCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + // The registry-assigned actor, not a resolved user: provisioning runs before any + // membership exists, so there is nobody in the tenant to attribute it to. Phase + // 03's permission check is what will identify the operator who asked. + var actor = UserId.SystemActor; + + var tenant = Domain.Tenant.Create( + request.TenantId, request.Slug, request.DisplayName, clock, actor); + + // First, and on its own: the organization's composite foreign key names + // (tenant_id, id), so the tenant row has to be there before it. + await tenants.AddAsync(tenant, cancellationToken); + + var organization = Organization.Create( + request.DefaultOrganizationId, + request.TenantId, + request.DefaultOrganizationSlug, + request.DefaultOrganizationDisplayName, + clock, + actor); + + await organizations.AddAsync(organization, cancellationToken); + + // Third, and it cannot be folded into the first: default_organization_id points + // at a row that does not exist when the tenant is inserted, and the foreign key + // behind it is MATCH SIMPLE — which skips the check only while the column is + // null. Setting it on the insert would defeat that and fail. + tenant.AssignDefaultOrganization(request.DefaultOrganizationId, clock, actor); + await tenants.UpdateAsync(tenant, cancellationToken); + + return Result.Ok(new ProvisionedTenantDto( + request.TenantId.Value, + tenant.Slug, + request.DefaultOrganizationId.Value, + organization.Slug)); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs new file mode 100644 index 00000000..fb2a6d5e --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs @@ -0,0 +1,80 @@ +using FluentValidation; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Domain; + +namespace LearnStack.Modules.Tenancy.Application.Tenant; + +/// +/// What provisioning refuses before a transaction is opened. +/// +/// +/// +/// It shares the aggregates' guards rather than copying or skipping them. Slug +/// shape and the two mapped widths are declared once, in the domain — , , — and read here as well as at the factories. The +/// factories are still the authority and still throw; this is the layer that turns the +/// same refusal into an answer a caller can act on. +/// +/// +/// Why the duplication would have been worse than the gap. Leaving shape to the +/// factories alone was the first shape of this file, and it was measured wrong: +/// ArgumentException has no entry in HttpStatusMap, so a mistyped slug +/// became a 500 — after ValidationBehavior passed it, after TransactionBehavior opened a +/// transaction, and after the tenant was announced on the connection. Copying the regex +/// and the numbers here instead would have been a second place to change and a second +/// place to drift; a shared constant is neither. +/// +/// +/// The pipeline runs this at step 1, so a refusal costs no transaction and no +/// announcement. A failure here is Result.Fail(validation_failed) and never an +/// exception, per the shipped behavior's contract. +/// +/// +public sealed class ProvisionTenantCommandValidator : AbstractValidator +{ + public ProvisionTenantCommandValidator() + { + // Cascade(Stop), because the rules below are not independent of the first: the + // shape predicate runs a regex, and a regex against a null slug throws + // ArgumentNullException out of the validator itself — which is the 500 this file + // exists to prevent, relocated one step earlier. `string` being non-nullable in + // the command is a compile-time promise, and a deserializer is not bound by it. + RuleFor(command => command.Slug) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .MaximumLength(UrlSlug.MaxLength) + .Must(UrlSlug.IsUrlSafe!) + .WithMessage(SlugShape); + + RuleFor(command => command.DefaultOrganizationSlug) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .MaximumLength(UrlSlug.MaxLength) + .Must(UrlSlug.IsUrlSafe!) + .WithMessage(SlugShape); + + RuleFor(command => command.DisplayName) + .NotEmpty() + .MaximumLength(MappedLength.DisplayName); + + RuleFor(command => command.DefaultOrganizationDisplayName) + .NotEmpty() + .MaximumLength(MappedLength.DisplayName); + + // The one cross-field rule, and the reason it is here rather than in an + // aggregate: neither Tenant nor Organization can see the other's id, so neither + // can notice that a caller sent the same Guid for both. The two rows are + // different things in different tables and a shared id would read as a + // relationship that does not exist. + RuleFor(command => command) + .Must(command => command.TenantId.Value != command.DefaultOrganizationId.Value) + .WithMessage( + "A tenant and its default organization are separate rows and must not " + + "share an id."); + } + + private const string SlugShape = + "'{PropertyValue}' is not a URL-safe slug: lowercase letters, digits and single " + + "interior hyphens only."; +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs index 6067b065..f3898f91 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs @@ -172,8 +172,18 @@ internal void SetValue(string value, DateTimeOffset at, UserId by) /// it. The numbers here are the ones the EF configurations map; asserting them at /// the factory is what makes the failure say which field is wrong. /// -internal static class MappedLength +public static class MappedLength { + /// + /// The width the Tenancy schema maps for a human-facing display name. + /// + /// + /// Public for the same reason as : the validator + /// refuses at this bound and the factories throw at it, and one number is what keeps + /// the two answers the same. + /// + public const int DisplayName = 200; + public static void EnsureAtMost(string value, int maximum, string parameterName) { if (value.Length > maximum) @@ -249,11 +259,34 @@ public static void EnsureRealTenant(TenantId tenantId, string message, string pa /// normalization constraint, the slug tables do not — so a slug with a slash or /// an uppercase letter reached a hostname unchallenged. /// -internal static partial class UrlSlug +public static partial class UrlSlug { + /// + /// The width every slug column in the Tenancy schema maps — a DNS label. + /// + /// + /// Named rather than written at each call site because two layers read it: the + /// factories below, which throw, and ProvisionTenantCommandValidator, which + /// refuses. A number in both places is a number that drifts in one of them, and the + /// drift is invisible until a caller sends a 64-character slug and gets whichever + /// answer the two disagree on. + /// + public const int MaxLength = 63; + + /// Whether is a URL-safe slug. + /// + /// The predicate form exists so a validator can refuse the same shape this class + /// throws on. Application code cannot use the throwing form: an + /// ArgumentException escaping a handler has no entry in HttpStatusMap + /// and becomes a 500, which is the wrong answer for a caller who mistyped a slug — + /// and one that arrives only after the transaction was opened and the tenant + /// announced. + /// + public static bool IsUrlSafe(string value) => Pattern().IsMatch(value); + public static void EnsureUrlSafe(string value, string parameterName) { - if (!Pattern().IsMatch(value)) + if (!IsUrlSafe(value)) { throw new ArgumentException( $"'{value}' is not a URL-safe slug: lowercase letters, digits and single " diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs index 401fca3e..fbae3c94 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -156,13 +156,14 @@ public void Rename(string displayName, IClock clock, UserId updatedBy) } /// - /// The two bounds OrganizationConfiguration maps: 63 for the slug, - /// which is a DNS label, and 200 for the display name. + /// The two bounds OrganizationConfiguration maps, named once each so the + /// validator that refuses at them and the factory that throws at them read the same + /// number. /// private static void EnsureWithinMappedLengths(string slug, string displayName) { - MappedLength.EnsureAtMost(slug, 63, nameof(slug)); - MappedLength.EnsureAtMost(displayName, 200, nameof(displayName)); + MappedLength.EnsureAtMost(slug, UrlSlug.MaxLength, nameof(slug)); + MappedLength.EnsureAtMost(displayName, MappedLength.DisplayName, nameof(displayName)); } /// Moves the organization to a new lifecycle state. diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index 0436821a..6e903da5 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -95,8 +95,8 @@ public static Tenant Create( ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(slug); ArgumentException.ThrowIfNullOrWhiteSpace(displayName); - MappedLength.EnsureAtMost(slug, 63, nameof(slug)); - MappedLength.EnsureAtMost(displayName, 200, nameof(displayName)); + MappedLength.EnsureAtMost(slug, UrlSlug.MaxLength, nameof(slug)); + MappedLength.EnsureAtMost(displayName, MappedLength.DisplayName, nameof(displayName)); UrlSlug.EnsureUrlSafe(slug, nameof(slug)); TenantOwnership.EnsureRealTenant( diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs new file mode 100644 index 00000000..72a17cdd --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs @@ -0,0 +1,50 @@ +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Domain; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +/// +/// The Tenant aggregate's writes, against the module context. +/// +/// +/// Each method saves, and that is load-bearing rather than convenient. The EF model +/// carries no relationship between Tenant and Organization, so batching both +/// into one SaveChanges leaves the order EF sends them unspecified — and +/// provisioning depends on it: the organization's composite foreign key names +/// (tenant_id, id), so the tenant row has to land first. Saving per call is what +/// makes the handler's statement order the database's statement order. +/// +public sealed class TenantWriteStore(TenancyDbContext db) : ITenantWriteStore +{ + public async Task AddAsync(Tenant aggregate, CancellationToken cancellationToken = default) + { + db.Tenants.Add(aggregate); + await db.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(Tenant aggregate, CancellationToken cancellationToken = default) + { + db.Tenants.Update(aggregate); + await db.SaveChangesAsync(cancellationToken); + } +} + +/// The Organization aggregate's writes. Same shape, same reason. +/// +/// public only so the composition root can name it in a registration; nothing +/// outside that line should. The port is the type callers depend on. +/// +public sealed class OrganizationWriteStore(TenancyDbContext db) : IOrganizationWriteStore +{ + public async Task AddAsync(Organization aggregate, CancellationToken cancellationToken = default) + { + db.Organizations.Add(aggregate); + await db.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(Organization aggregate, CancellationToken cancellationToken = default) + { + db.Organizations.Update(aggregate); + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs index 300eabb2..8d7aed8a 100644 --- a/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using FluentAssertions; +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Tenancy; using MediatR; using Xunit; @@ -158,6 +159,54 @@ private sealed record ProbeStreamed : IStreamRequest; private sealed record ProbeNotARequest; + [Fact] + public void Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning() + { + // ADR-0042 sanctions ONE operation to write two aggregate roots in one + // transaction, by enumeration rather than by principle — a tenant whose default + // organization failed to commit is a tenant no request can serve, and a second + // transaction is a window in which exactly that state exists. + // + // Counted by PORT TYPE, not by name. The catalogue registered this as a scan for + // DbSet use in handlers, and under the shipped dependency rules that scan can + // never fire: Application → Infrastructure is forbidden, so no handler can name a + // DbSet at all. A rule at Implemented status that cannot fire is worse than one + // at Registered, because the catalogue then claims coverage it does not have. + // IAggregateWriteStore is a type, so renaming a port does not escape this. + var offenders = ProductionAssemblies() + .Select(Assembly.Load) + .SelectMany(assembly => assembly.GetTypes()) + .Where(type => type is { IsAbstract: false, IsInterface: false }) + .Where(type => type.GetInterfaces().Any(contract => + contract.IsGenericType + && contract.GetGenericTypeDefinition() == typeof(IRequestHandler<,>))) + .Where(type => type.GetConstructors() + .Any(constructor => constructor.GetParameters() + .Count(parameter => WritesAnAggregate(parameter.ParameterType)) > 1)) + .Select(type => type.Name) + .Distinct() + .ToList(); + + offenders.Should().BeEquivalentTo( + ["ProvisionTenantCommandHandler"], + "a handler taking two aggregate write ports writes across an aggregate " + + "boundary in one transaction, which ADR-0042 sanctions for exactly one " + + "operation — a second name here is a decision that needs its own record"); + } + + /// Whether a constructor parameter is a write port for some aggregate. + /// + /// Walks the interface's own hierarchy rather than matching a name: the ports modules + /// declare — ITenantWriteStore, IOrganizationWriteStore — derive from + /// the generic, and it is the derivation the rule counts. + /// + private static bool WritesAnAggregate(Type parameterType) => + IsAggregateWriteStore(parameterType) + || parameterType.GetInterfaces().Any(IsAggregateWriteStore); + + private static bool IsAggregateWriteStore(Type type) => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IAggregateWriteStore<,>); + [Fact] public void The_Sweep_Covers_Every_Production_Assembly() { @@ -199,11 +248,15 @@ public void The_Sweep_Covers_Every_Production_Assembly() /// The literal set of request types permitted to run before a tenant is resolved. /// /// - /// Empty, because no production request type exists yet. ProvisionTenantCommand - /// is the first, in Packet 7 step 9, per + /// One entry. ProvisionTenantCommand creates the tenant it names, so it + /// legitimately runs before any tenant is resolved — there is none until it succeeds. + /// It is emphatically not anonymous: what gates it is Phase 03's permission check, + /// and until then its only callers are the seeder and the tests. Adding a second + /// entry is an edit somebody reviews, which is the whole value of the list; the rule + /// went red the moment this command landed, which is the list working. See /// ADR-0042. /// - private static readonly string[] PermittedUnresolved = []; + private static readonly string[] PermittedUnresolved = ["ProvisionTenantCommand"]; /// /// The request types named in diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs new file mode 100644 index 00000000..99b6e7a2 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs @@ -0,0 +1,469 @@ +using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// Provisioning a tenant against the shipped policies, as learnstack_app. +/// +/// +/// +/// Why this cannot be a unit test. The whole of +/// ADR-0042 +/// is a claim about what one transaction may write under Row Level Security. Against +/// fakes, every case below passes with the announcement deleted, with the writes split +/// across two transactions, and with the policies disabled. +/// +/// +/// As learnstack_app, which is the point. The role connects +/// NOBYPASSRLS, so a row that commits here commits because a policy permitted it. +/// Run as learnstack_migration or learnstack_platform these cases would +/// pass with every policy inert, which is the failure mode the repository's hard rules +/// name by role. +/// +/// +/// Each case removes what it committed: the container is shared with the schema cases, +/// several of which assert exact row counts. Cleanup runs as learnstack_platform +/// because the rows belong to tenants that no longer have a context to announce. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class TenantProvisioningTests +{ + private static readonly FixedClock Clock = new( + new DateTimeOffset(2026, 9, 3, 9, 0, 0, TimeSpan.Zero)); + + private readonly SchemaFixture _schema; + + public TenantProvisioningTests(SchemaFixture schema) => _schema = schema; + + [Fact] + public async Task Provisioning_commits_the_tenant_its_default_organization_and_the_assignment() + { + // The claim ADR-0042 exists to make: three writes across two aggregate roots, + // one transaction, one commit, and a tenant that is never observable without a + // default organization. + var command = Command(); + + try + { + var result = await ProvisionAsync(command); + + result.IsSuccess.Should().BeTrue(); + + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM tenants WHERE id = @id", command.TenantId.Value)) + .Should().Be(1L); + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM organizations WHERE id = @id", + command.DefaultOrganizationId.Value)) + .Should().Be(1L); + (await ScalarAsPlatformAsync( + """ + SELECT count(*) FROM tenants + WHERE id = @id AND default_organization_id IS NOT NULL + """, + command.TenantId.Value)) + .Should().Be(1L, "the back-reference commits on the same transaction"); + } + finally + { + await CleanUpAsync(command); + } + } + + [Fact] + public async Task A_provisioning_that_fails_part_way_commits_nothing() + { + // The reason a second transaction was not an option. A tenant whose default + // organization failed to commit is a tenant no request can serve: every + // organization-scoped read filters on a column that is null, and nothing in the + // schema would ever repair it. Measured here by colliding the organization's id + // with a seeded row — the unique index fires regardless of RLS, so the second + // write raises 23505 while the first has already succeeded. + var command = Command() with { DefaultOrganizationId = OrganizationId.From(SchemaFixture.OrgA1) }; + + try + { + var provision = () => ProvisionAsync(command); + + await provision.Should().ThrowAsync(); + + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM tenants WHERE id = @id", command.TenantId.Value)) + .Should().Be(0L, + "the tenant insert had already succeeded when the organization failed, " + + "and it must roll back with it"); + } + finally + { + await CleanUpAsync(command); + } + } + + [Fact] + public async Task The_announcement_confines_the_transaction_to_the_tenant_being_created() + { + // The announcement is not a formality that unlocks writing: it is a value the + // policies compare against, so a provisioning transaction can write the tenant it + // named and nothing else. Without this the seam would be a hole — any request + // implementing IProvisionsTenant could open a transaction and write into a tenant + // it chose. + var command = Command(); + + try + { + var write = () => ProvisionAsync( + command, + handler: async (services, _) => + { + var unitOfWork = services.GetRequiredService(); + await ExecuteAsync( + unitOfWork, + """ + INSERT INTO organizations + (id, tenant_id, slug, display_name, status, + created_at, created_by, row_version) + VALUES (uuidv7(), @tenant, 'smuggled', 'Smuggled', 'Active', + now(), @actor, 0) + """, + ("tenant", SchemaFixture.TenantA), + ("actor", SchemaFixture.Actor)); + + return Result.Ok(Provisioned(command)); + }); + + (await write.Should().ThrowAsync()) + .Which.SqlState.Should().Be("42501", + "the policy compares against the announced tenant, and TenantA is not it"); + + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM organizations WHERE slug = 'smuggled'")) + .Should().Be(0L); + } + finally + { + await CleanUpAsync(command); + } + } + + [Fact] + public async Task The_announcement_does_not_survive_the_commit() + { + // The connection goes back to the pool the moment the scope disposes, and the + // next request to draw it is an ordinary tenant-facing one. Were the announcement + // session-scoped rather than transaction-scoped, that request would begin under + // the provisioned tenant's id — and any read issued before its own announcement + // would answer from the wrong tenant. + // + // NoResetOnClose and a pool of one are what make this case mean anything. + // Measured: without them the mutation to set_config(..., false) PASSES, because + // Npgsql sends DISCARD ALL when a connection returns to the pool and cleans up + // after the bug. That is the driver's behaviour, not this code's, and it is + // precisely what a PgBouncer in transaction-pooling mode does not do. With the + // reset suppressed the second borrow is provably the same physical connection, + // in the state provisioning left it. + var builder = new NpgsqlDataSourceBuilder(_schema.Postgres.AppConnectionString); + builder.ConnectionStringBuilder.MaxPoolSize = 1; + builder.ConnectionStringBuilder.NoResetOnClose = true; + await using var dataSource = builder.Build(); + + var command = Command(); + + try + { + await using (var provider = BuildProvider(dataSource)) + { + (await ProvisionAsync(provider, command)).IsSuccess.Should().BeTrue(); + } + + await using var connection = await dataSource.OpenConnectionAsync(); + await using var read = new NpgsqlCommand( + "SELECT NULLIF(current_setting('app.tenant_id', true), '')", connection); + + var leftBehind = await read.ExecuteScalarAsync(); + + (leftBehind is null or DBNull).Should().BeTrue( + "the announcement was transaction-local and the transaction is over"); + } + finally + { + await CleanUpAsync(command); + } + } + + [Fact] + public async Task A_request_that_does_not_provision_still_fails_closed_when_unresolved() + { + // The other half of the branch. Adding the provisioning path must not have + // widened what an unresolved context can do generally: a request that does not + // implement IProvisionsTenant still takes the ordinary path, which writes the + // empty string, and every tenant-owned write is refused. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + var behavior = new TransactionBehavior>( + unitOfWork, + UnresolvedTenantContext.Instance, + NullLogger>>.Instance); + + var write = () => behavior.Handle( + new Probe(), + async () => + { + await ExecuteAsync( + unitOfWork, + """ + INSERT INTO tenants (id, slug, display_name, status, created_at, + created_by, row_version) + VALUES (uuidv7(), 'unannounced', 'Unannounced', 'Trial', now(), + @actor, 0) + """, + ("actor", SchemaFixture.Actor)); + + return Result.Ok("unreachable"); + }, + CancellationToken.None); + + (await write.Should().ThrowAsync()) + .Which.SqlState.Should().Be("42501"); + + (await ScalarAsPlatformAsync("SELECT count(*) FROM tenants WHERE slug = 'unannounced'")) + .Should().Be(0L); + } + + [Fact] + public async Task A_resolved_caller_cannot_provision_a_tenant_it_did_not_authenticate_for() + { + // The confused deputy, closed by the database rather than by a check. A caller + // authenticated for TenantA who sends a provisioning command naming a new tenant + // takes the ORDINARY path — the branch requires !IsResolved — so the transaction + // is announced with A, and the insert of the new tenant's row is refused. + var command = Command(); + + try + { + var provision = () => ProvisionAsync( + command, context: new ResolvedContext(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + var failure = (await provision.Should().ThrowAsync()).Which; + + // Unwrapped, because EF wraps a failing SaveChanges in DbUpdateException and + // the SQLSTATE is the whole assertion — "some exception" would also pass with + // the tenant announced correctly and the schema missing. + Unwrap(failure).Should().BeOfType() + .Which.SqlState.Should().Be("42501", + "the announcement is A's, and the tenants policy checks id = app.tenant_id"); + + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM tenants WHERE id = @id", command.TenantId.Value)) + .Should().Be(0L); + } + finally + { + await CleanUpAsync(command); + } + } + + // ── Harness ────────────────────────────────────────────────────────────── + + /// + /// Runs one provisioning through the real , + /// the real handler and the real write stores. + /// + /// + /// The behavior is constructed rather than resolved because the full MediatR pipeline + /// would drag in seven other behaviors and prove nothing more; the handler IS + /// resolved, because its discoverability by assembly scan is what the composition + /// root depends on. + /// + private async Task> ProvisionAsync( + ProvisionTenantCommand command, + ITenantContext? context = null, + Func>>? handler = null) + { + await using var provider = BuildProvider(); + return await ProvisionAsync(provider, command, context, handler); + } + + private static async Task> ProvisionAsync( + ServiceProvider provider, + ProvisionTenantCommand command, + ITenantContext? context = null, + Func>>? handler = null) + { + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + + var behavior = new TransactionBehavior>( + services.GetRequiredService(), + context ?? UnresolvedTenantContext.Instance, + NullLogger>> + .Instance); + + return await behavior.Handle( + command, + () => handler is null + ? services + .GetRequiredService>>() + .Handle(command, CancellationToken.None) + : handler(services, command), + CancellationToken.None); + } + + private ServiceProvider BuildProvider(NpgsqlDataSource? dataSource = null) + { + // The composition root's shape on the fixture's container: the application + // role's data source, the ambient unit of work, the module context enlisted on + // it, and the two write stores the handler takes. + var services = new ServiceCollection(); + // A caller-owned data source when a case needs to control pooling; otherwise one + // per provider, disposed with it. + services.AddSingleton( + dataSource ?? NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); + services.AddLogging(); + services.AddSingleton(); + services.AddTransient(sp => + sp.GetRequiredService().Current + ?? UnresolvedTenantContext.Instance); + services.AddScoped(); + services.AddModuleDbContext(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(Clock); + services.AddMediatR(configuration => configuration.RegisterServicesFromAssembly( + typeof(ITenantWriteStore).Assembly)); + + return services.BuildServiceProvider(); + } + + private static ProvisionTenantCommand Command() + { + // Version-7 ids, distinct per case, because the container is shared and a fixed + // id would make two cases collide on a unique index rather than on the property + // under test. + var suffix = Guid.CreateVersion7().ToString("N")[..8]; + return new ProvisionTenantCommand( + TenantId.From(Guid.CreateVersion7()), + $"prov-{suffix}", + "Provisioned", + OrganizationId.From(Guid.CreateVersion7()), + $"prov-org-{suffix}", + "Head Office"); + } + + private static ProvisionedTenantDto Provisioned(ProvisionTenantCommand command) => + new(command.TenantId.Value, command.Slug, + command.DefaultOrganizationId.Value, command.DefaultOrganizationSlug); + + /// The innermost exception, since EF wraps a failing SaveChanges. + private static Exception Unwrap(Exception exception) + { + while (exception.InnerException is not null) + { + exception = exception.InnerException; + } + + return exception; + } + + private async Task CleanUpAsync(ProvisionTenantCommand command) + { + // As learnstack_platform: the rows belong to a tenant with no context to + // announce, and the organization has to go first — its foreign key names the + // tenant, and tenants.default_organization_id names it back. + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + + foreach (var statement in new[] + { + "UPDATE tenants SET default_organization_id = NULL WHERE id = @tenant", + "DELETE FROM organizations WHERE tenant_id = @tenant", + "DELETE FROM tenants WHERE id = @tenant", + }) + { + await using var cleanup = new NpgsqlCommand(statement, (NpgsqlConnection)platform); + cleanup.Parameters.AddWithValue("tenant", command.TenantId.Value); + await cleanup.ExecuteNonQueryAsync(); + } + } + + private async Task ScalarAsPlatformAsync(string sql, Guid? id = null) + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var query = new NpgsqlCommand(sql, (NpgsqlConnection)platform); + + if (id is not null) + { + query.Parameters.AddWithValue("id", id.Value); + } + + return (long)(await query.ExecuteScalarAsync())!; + } + + private static async Task ExecuteAsync( + IUnitOfWork unitOfWork, string sql, params (string Name, object Value)[] parameters) + { + await using var command = (NpgsqlCommand)unitOfWork.Connection.CreateCommand(); + command.CommandText = sql; + command.Transaction = (NpgsqlTransaction?)unitOfWork.Transaction; + + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + await command.ExecuteNonQueryAsync(); + } + + private static async Task ReadAsync(IUnitOfWork unitOfWork, string sql) + { + await using var command = (NpgsqlCommand)unitOfWork.Connection.CreateCommand(); + command.CommandText = sql; + command.Transaction = (NpgsqlTransaction?)unitOfWork.Transaction; + return (await command.ExecuteScalarAsync()) as string; + } + + /// A request that provisions nothing, for the fail-closed case. + public sealed record Probe : IRequest>; + + private sealed class MutableAccessor : ITenantContextAccessor + { + public ITenantContext? Current { get; set; } + } + + private sealed class ResolvedContext(Guid tenant, Guid organization) : ITenantContext + { + public bool IsResolved => true; + + public TenantId TenantId => SharedKernel.Identifiers.TenantId.From(tenant); + + public OrganizationId? OrganizationId => + SharedKernel.Identifiers.OrganizationId.From(organization); + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => "tenancy"; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index ca25981b..619d9962 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -897,16 +897,67 @@ await dispose.Should().NotThrowAsync( } } + [Fact] + public async Task The_session_variables_do_not_survive_the_transaction_that_set_them() + { + // The setter every request goes through, and the property that makes it safe to + // reuse a connection. Measured: with set_config's third argument flipped to + // false, all 292 integration cases passed — this is the one that fails. + // + // NoResetOnClose and a pool of one are what give it teeth. Npgsql sends + // DISCARD ALL when a connection returns to the pool, which cleans up after the + // bug and hides it; a PgBouncer in transaction-pooling mode — the deployment the + // corpus keeps naming — does not. With the reset suppressed the second borrow is + // provably the same physical connection, in the state the first left it, and a + // session-scoped write shows up as tenant A's id greeting whoever draws it next. + var builder = new NpgsqlDataSourceBuilder(_schema.Postgres.AppConnectionString); + builder.ConnectionStringBuilder.MaxPoolSize = 1; + builder.ConnectionStringBuilder.NoResetOnClose = true; + await using var dataSource = builder.Build(); + + await using (var provider = BuildProvider(dataSource)) + { + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync( + Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + // The value is there while the transaction is, or the case below would pass + // against a setter that never ran. + (await ReadAsync(unitOfWork, "SELECT current_setting('app.tenant_id', true)")) + .Should().Be(SchemaFixture.TenantA.ToString()); + + await unitOfWork.CommitAsync(); + } + + await using var connection = await dataSource.OpenConnectionAsync(); + + foreach (var variable in new[] { "app.tenant_id", "app.organization_id" }) + { + await using var read = new NpgsqlCommand( + $"SELECT NULLIF(current_setting('{variable}', true), '')", connection); + + (await read.ExecuteScalarAsync() is null or DBNull).Should().BeTrue( + $"{variable} was transaction-local, and the next borrower of this " + + "connection is another tenant's request"); + } + } + // ── Helpers ────────────────────────────────────────────────────────────── - private ServiceProvider BuildProvider() + private ServiceProvider BuildProvider(NpgsqlDataSource? dataSource = null) { // The real registration path: the shared helper, the real // NpgsqlUnitOfWork, and the application role's data source. Only the // connection string differs from the composition root, because the // fixture's container is not the one appsettings names. var services = new ServiceCollection(); - services.AddSingleton(NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); + // A caller-owned data source when a case needs to control pooling; otherwise one + // per provider. + services.AddSingleton( + dataSource ?? NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); services.AddLogging(); // The composition root's shape, not a shortcut: a singleton accessor and // a transient ITenantContext resolved from it on every access. The module diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs new file mode 100644 index 00000000..d35f7873 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs @@ -0,0 +1,336 @@ +using FluentAssertions; +using FluentValidation; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Application.Tenant; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Time; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using TenantAggregate = LearnStack.Modules.Tenancy.Domain.Tenant; + +namespace LearnStack.Tests.Unit.Modules.Tenancy; + +/// +/// The one operation +/// ADR-0042 +/// sanctions to write two aggregate roots on one transaction: what it writes, in +/// what order, and what it refuses before a transaction is ever opened. +/// +/// +/// +/// Driven through IRequestHandler resolved from a container rather than by +/// constructing the handler, for two reasons. The handler is internal, and +/// widening that to reach it from a test would be a visibility change made for the +/// test's convenience. And resolution is itself part of what needs proving: the +/// composition root discovers this handler by assembly scan, so a handler that +/// compiles but is not discoverable is a 500 at the first call, and a test holding +/// a hand-constructed instance would never see it. +/// +/// +/// The database half — that the three writes commit together as +/// learnstack_app under the announced tenant, and that a partial failure +/// leaves nothing — is in TenantProvisioningTests, because it is a claim +/// about policies and a transaction and cannot be made against fakes. +/// +/// +public sealed class ProvisionTenantCommandTests +{ + private static readonly FixedClock Clock = new( + new DateTimeOffset(2026, 9, 3, 9, 0, 0, TimeSpan.Zero)); + + private static readonly TenantId Tenant = + TenantId.From(Guid.Parse("0199a000-0000-7000-8000-000000000001")); + + private static readonly OrganizationId Organization = + OrganizationId.From(Guid.Parse("0199a000-0000-7000-8000-0000000000a1")); + + private static ProvisionTenantCommand Command() => new( + Tenant, "demo-english", "Demo English", + Organization, "hq", "Head Office"); + + [Fact] + public async Task Provisioning_writes_the_tenant_then_the_organization_then_the_assignment() + { + // The order is not stylistic. `organizations` carries a composite foreign key + // to (tenant_id, id), so the tenant row has to exist first; and + // `tenants.default_organization_id` points at a row that does not exist when + // the tenant is inserted, so the back-reference has to be a second write. A + // reordering here compiles and passes every fake-free assertion — it fails + // only against the real schema, which is why the sequence is pinned. + var (sender, writes) = Build(); + + var result = await sender.Send(Command()); + + result.IsSuccess.Should().BeTrue(); + writes.Should().Equal( + "tenant:add", "organization:add", "tenant:update"); + } + + [Fact] + public async Task The_tenant_carries_its_default_organization_by_the_time_it_is_updated() + { + // Asserting the sequence alone would pass with an update that wrote nothing + // new — the property that matters is the state the second write carries. + var (sender, _) = Build(out var stores); + + await sender.Send(Command()); + + stores.Updated.Should().ContainSingle().Which + .DefaultOrganizationId.Should().Be(Organization); + } + + [Fact] + public async Task The_organization_is_created_inside_the_tenant_being_provisioned() + { + // The default organization belongs to the new tenant, not to whatever tenant + // happened to be ambient. Under the shipped policies a mismatch is a 42501 + // rather than a cross-tenant write, so this is a fail-fast on the way to a + // refusal — but the refusal is a rollback of the whole provisioning, and the + // caller sees "provisioning failed" with no hint why. + var (sender, _) = Build(out var stores); + + await sender.Send(Command()); + + stores.Added.Should().ContainSingle().Which.TenantId.Should().Be(Tenant); + } + + [Fact] + public async Task The_result_names_both_rows_the_caller_now_has() + { + // The caller generated both ids, so what the response adds is confirmation of + // what was stored under them — including the slugs, which the aggregates accept + // verbatim rather than normalizing. Measured: `Tenant.Create` REFUSES + // "Demo-English" rather than lowercasing it, so a caller that expected + // normalization gets a refusal, not a surprise row. + var (sender, _) = Build(); + + var result = await sender.Send(Command()); + + result.IsSuccess.Should().BeTrue(); + var provisioned = result.Value!; + provisioned.TenantId.Should().Be(Tenant.Value); + provisioned.Slug.Should().Be("demo-english"); + provisioned.DefaultOrganizationId.Should().Be(Organization.Value); + provisioned.DefaultOrganizationSlug.Should().Be("hq"); + } + + [Fact] + public async Task Provisioning_is_attributed_to_the_system_actor() + { + // There is nobody in the tenant to attribute it to: provisioning runs before + // any membership exists. `created_by` is NOT NULL with a foreign key to + // `users`, so the alternative to the registry-assigned actor is a 23503 on + // the first insert. + var (sender, _) = Build(out var stores); + + await sender.Send(Command()); + + stores.Added.Should().ContainSingle().Which + .CreatedBy.Should().Be(UserId.SystemActor); + } + + [Theory] + [InlineData("", "hq", "a tenant slug is required")] + [InlineData("demo-english", "", "so is the default organization's")] + public void A_command_missing_a_required_name_is_refused_before_any_transaction( + string tenantSlug, string organizationSlug, string because) + { + var command = Command() with + { + Slug = tenantSlug, + DefaultOrganizationSlug = organizationSlug, + }; + + new ProvisionTenantCommandValidator().Validate(command) + .IsValid.Should().BeFalse(because); + } + + [Theory] + [InlineData("Demo-English", "an uppercase letter is not URL-safe")] + [InlineData("demo english", "nor is a space")] + [InlineData("demo--english", "nor is a doubled hyphen")] + [InlineData("-demo", "nor is a leading one")] + public void A_slug_the_aggregate_would_throw_on_is_refused_first( + string slug, string because) + { + // The gap this closes was measured, not imagined. ArgumentException has no entry + // in HttpStatusMap, so a slug the factory refuses used to surface as a 500 — + // raised inside the handler, which is after ValidationBehavior passed the + // command, after TransactionBehavior opened a transaction, and after the tenant + // was announced on the connection. The shape lives in one place and both layers + // read it; this is the layer that answers the caller. + new ProvisionTenantCommandValidator().Validate(Command() with { Slug = slug }) + .IsValid.Should().BeFalse(because); + + // And the aggregate still refuses it, so the validator is the first layer rather + // than the only one. A test that checked only the validator would pass with the + // factory guard deleted. + var direct = () => TenantAggregate.Create( + Tenant, slug, "Demo English", Clock, UserId.SystemActor); + direct.Should().Throw(); + } + + [Fact] + public void A_value_wider_than_its_column_is_refused_first() + { + // Same shape of defect, different guard: over the mapped width the factory + // throws, and without a rule here that throw is a 500 raised after the + // announcement. The two layers read one constant, so this cannot drift. + var validator = new ProvisionTenantCommandValidator(); + + validator.Validate(Command() with { Slug = new string('a', UrlSlug.MaxLength + 1) }) + .IsValid.Should().BeFalse(); + validator.Validate(Command() with + { + DisplayName = new string('a', MappedLength.DisplayName + 1), + }).IsValid.Should().BeFalse(); + + // The bound itself is legal — an off-by-one here would refuse a value the + // column holds, which no test asserting only the refusal would catch. + validator.Validate(Command() with { Slug = new string('a', UrlSlug.MaxLength) }) + .IsValid.Should().BeTrue(); + } + + [Fact] + public void A_null_slug_is_refused_rather_than_thrown_on() + { + // `string` is non-nullable in the command and a deserializer is not bound by + // that. Without Cascade(Stop) the shape predicate runs anyway and the regex + // throws ArgumentNullException out of the validator — the same 500, moved one + // step earlier. + var refuse = () => new ProvisionTenantCommandValidator() + .Validate(Command() with { Slug = null! }); + + refuse.Should().NotThrow(); + refuse().IsValid.Should().BeFalse(); + } + + [Fact] + public void A_tenant_and_its_default_organization_may_not_share_an_id() + { + // The one rule neither aggregate can enforce: `Tenant.Create` never sees the + // organization's id and `Organization.Create` never sees a reason to compare. + // The two rows live in different tables, so a shared Guid violates nothing the + // database checks — it simply reads, forever after, as a relationship that + // does not exist. + var shared = Guid.Parse("0199a000-0000-7000-8000-00000000dead"); + var command = Command() with + { + TenantId = TenantId.From(shared), + DefaultOrganizationId = OrganizationId.From(shared), + }; + + new ProvisionTenantCommandValidator().Validate(command) + .IsValid.Should().BeFalse(); + } + + [Fact] + public void A_well_formed_command_passes() + { + // Every case above is a refusal, and a validator that refused everything would + // satisfy all of them. + new ProvisionTenantCommandValidator().Validate(Command()) + .IsValid.Should().BeTrue(); + } + + [Fact] + public void The_validator_is_discoverable_where_the_pipeline_scans_for_it() + { + // ValidationBehavior resolves IValidator from the container, and the + // registration is an assembly scan with `includeInternalTypes`. A validator + // that exists but is not found is silence: the command runs unvalidated and + // the cross-field rule above never executes in production. + Build().Sender.Should().NotBeNull(); + + BuildProvider().GetService>() + .Should().BeOfType(); + } + + private static (ISender Sender, IReadOnlyList Writes) Build() => + Build(out _); + + private static (ISender Sender, IReadOnlyList Writes) Build( + out RecordingStores stores) + { + var provider = BuildProvider(out var recorded); + stores = recorded; + return (provider.GetRequiredService(), recorded.Writes); + } + + private static ServiceProvider BuildProvider() => BuildProvider(out _); + + private static ServiceProvider BuildProvider(out RecordingStores stores) + { + // MediatR and FluentValidation scan the same assembly the composition root + // hands them, so a handler or validator this container cannot find is one the + // application cannot find either. + var applicationAssembly = typeof(ProvisionTenantCommandValidator).Assembly; + var recording = new RecordingStores(); + stores = recording; + + var services = new ServiceCollection(); + services.AddMediatR(configuration => + configuration.RegisterServicesFromAssembly(applicationAssembly)); + services.AddValidatorsFromAssembly(applicationAssembly, includeInternalTypes: true); + services.AddSingleton(recording); + services.AddSingleton(recording); + services.AddSingleton(Clock); + + return services.BuildServiceProvider(); + } + + /// + /// Both write ports, recording what was written and in what order. + /// + /// + /// One object implementing two interfaces so the order across the two is a single + /// list. Two separate fakes would each record a correct-looking sequence while the + /// interleaving between them — the thing the real schema constrains — went + /// unobserved. + /// + private sealed class RecordingStores : ITenantWriteStore, IOrganizationWriteStore + { + private readonly List _writes = []; + + public IReadOnlyList Writes => _writes; + + public List Added { get; } = []; + + public List Updated { get; } = []; + + Task IAggregateWriteStore.AddAsync( + TenantAggregate aggregate, CancellationToken cancellationToken) + { + _writes.Add("tenant:add"); + return Task.CompletedTask; + } + + Task IAggregateWriteStore.UpdateAsync( + TenantAggregate aggregate, CancellationToken cancellationToken) + { + _writes.Add("tenant:update"); + Updated.Add(aggregate); + return Task.CompletedTask; + } + + Task IAggregateWriteStore.AddAsync( + Organization aggregate, CancellationToken cancellationToken) + { + _writes.Add("organization:add"); + Added.Add(aggregate); + return Task.CompletedTask; + } + + Task IAggregateWriteStore.UpdateAsync( + Organization aggregate, CancellationToken cancellationToken) + { + _writes.Add("organization:update"); + return Task.CompletedTask; + } + } +} From 4ac783f2212877a847feb9162af66697446fc646 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 09:22:52 +0300 Subject: [PATCH 30/55] docs(tenancy): correct provisioning's shape and the rule that guards it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0042 § Implementation Notes specified the cross-aggregate rule as a source scan for DbSet writes. That scan could never fire, and not because anything changed since: Application may not reference Infrastructure, so no handler can name a DbSet at all. An inline erratum marks the sentence and Amendment 1 records what ships — a reflection rule counting IAggregateWriteStore ports, which also survives a rename. The catalogue row moves to Implemented with the three mutations that turn it red. The module spec's sequence diagram had the handler opening the transaction and setting the context. It does neither, and the distinction is load-bearing: a BeginTransactionAsync from a handler is a joiner rather than a boundary, and an announcement from a handler would be an eighth setter of app.tenant_id against a set two ADRs close at seven. The diagram also carried `SET LOCAL app.tenant_id = `, which is not valid SQL. The two matrices said the module's operations do not exist yet. Two of them do now, so permissions.md records why provisioning is deliberately unauthorized — unresolved context by construction, SystemActor attribution, no HTTP endpoint — and audit.md records that it is unaudited until Packet 9, with the line it will be written on. Module: Tenancy ADR: 0042 Co-Authored-By: Claude Opus 5 (1M context) --- ...rovisioning-cross-aggregate-transaction.md | 35 +++++++++++- docs/modules/tenancy/README.md | 53 +++++++++++++------ docs/modules/tenancy/audit.md | 15 ++++-- docs/modules/tenancy/permissions.md | 25 ++++++--- .../21-architecture-tests-catalogue.md | 33 ++++++++---- 5 files changed, 122 insertions(+), 39 deletions(-) diff --git a/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md b/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md index df15381d..78c0e99e 100644 --- a/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md +++ b/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md @@ -231,6 +231,12 @@ containment either. implements `IAggregateRoot`, and holds a literal allow-list of exactly one handler type. + > **Erratum (2026-09-03, Amendment 1).** The scan described in the sentence + > above cannot fire, and could not on the day this was written: `Application` + > may not reference `Infrastructure`, so no handler can name a `DbSet`. The + > shipped rule counts constructor parameters deriving from + > `IAggregateWriteStore` instead. See § Amendments. + **What it proves and what it does not.** It catches the direct form, which is the form a handler is written in. It does not catch a write routed through a repository, a helper or a second `DbContext` reached indirectly — the same @@ -245,7 +251,34 @@ containment either. ## Amendments -None yet. +### Amendment 1 (2026-09-03): the rule counts ports, not `DbSet` use + +**What was false when it entered the record.** § Implementation Notes specifies +`Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` as a source scan for +`Add` / `AddRange` / `Update` / `Remove` against more than one `DbSet`. That scan +can never fire. `Application` may not reference `Infrastructure` — a shipped +dependency rule that predates this ADR — so no handler can name a `DbSet` at all, +and a rule that cannot fire while carrying Status **Implemented** claims coverage +the suite does not have. An inline erratum marks the sentence; the rationale, the +carve-out and everything else in the record stand. + +**What ships.** The rule reflects over the production assemblies and counts, per +`IRequestHandler<,>` implementation, the constructor parameters deriving from +`IAggregateWriteStore`. More than one is the cross-aggregate write, +and the literal allow-list holds exactly one name: +`ProvisionTenantCommandHandler`. Counting a **type** rather than a name means +renaming a port does not escape the rule, and fusing the two ports into one to +hide the write is itself caught — measured, as one of three mutations that each +turn the rule red. + +**Why two ports rather than one.** `ITenantWriteStore` and +`IOrganizationWriteStore` are separate interfaces deliberately. A single fused +port would have been less code and would have hidden the very thing this ADR +exists to enumerate. + +**What did not change.** The sanctioned operation, its three statements, the +one-entry allow-list, and the seeder's obligation to invoke the command rather +than write the two aggregates itself. ## References diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index d4d712f4..977f3666 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -160,38 +160,59 @@ Text fallback — **TenantDomain lifecycle**: ```mermaid sequenceDiagram participant R as Registry (Hub / config / fixture) + participant T as TransactionBehavior participant H as Handler participant U as IUnitOfWork participant D as TenancyDbContext participant P as PostgreSQL - R->>H: tenant id, slug, display name - H->>U: BeginTransactionAsync + R->>T: ProvisionTenantCommand (tenant id, slug, names) + T->>U: BeginTransactionAsync U->>P: BEGIN - H->>U: SetTenantContextAsync(id) - U->>P: SET LOCAL app.tenant_id = + T->>U: SetProvisioningTenantContextAsync(command.TenantId) + U->>P: SELECT set_config('app.tenant_id', , true) + T->>H: next() H->>D: INSERT tenants D->>P: WITH CHECK passes — app.tenant_id already equals id H->>D: INSERT organizations (default) H->>D: UPDATE tenants SET default_organization_id - H->>U: CommitAsync + T->>U: CompleteAsync U->>P: COMMIT ``` Text fallback — **provisioning a tenant**: the registry (Hub, config or fixture) -supplies the tenant id, slug and display name; the handler opens the ambient -transaction through `IUnitOfWork`, sets `app.tenant_id` to that id as the first -statement inside it, inserts `tenants`, inserts the default `organizations` row, -updates `tenants.default_organization_id`, and commits. One transaction, one -connection, one commit point. +sends a `ProvisionTenantCommand`; `TransactionBehavior` opens the ambient +transaction, announces `app.tenant_id` as the tenant being created — the first +statement inside it — and calls the handler; the handler inserts `tenants`, +inserts the default `organizations` row, updates +`tenants.default_organization_id`, and returns; the behavior commits. One +transaction, one connection, one commit point. + +**The handler opens nothing and announces nothing.** Both belong to +`TransactionBehavior`, and the distinction is not stylistic. A +`BeginTransactionAsync` from inside a handler is a *joiner* — [ADR-0040](../../decisions/0040-ambient-unit-of-work.md) +returns a nested frame on the same transaction, so it would be a no-op that +reads like a boundary. An announcement from inside a handler would be an eighth +setter of `app.tenant_id` against a set two ADRs close at seven, and would hand +every handler in the solution the ability to move the ambient tenant. Three statements, one transaction. The tenant id is **never minted in the -handler**: the registry assigns it and the transaction sets `app.tenant_id` to -that value *before* the insert, so the self-keyed policy's `WITH CHECK` passes. A -handler that generated its own could not satisfy its own policy. The -`default_organization_id` update is separate because the composite foreign key -has nothing to reference until the organization exists; `MATCH SIMPLE` skips the -check while the column is null, which is what makes the ordering legal. +handler**: the registry assigns it, and the behavior announces it *before* the +insert by reading `IProvisionsTenant` off the request, so the self-keyed policy's +`WITH CHECK` passes. A handler that generated its own could not satisfy its own +policy. Measured against the shipped policies: with `app.tenant_id` unset or set +to the empty string the `tenants` insert raises `42501` identically, and only +the new tenant's own id lets the sequence commit. + +The announcement requires an **unresolved** context, which is what closes the +confused deputy: a caller already authenticated for tenant A who sends a +provisioning command naming tenant B takes the ordinary path, the transaction +carries A, and B's insert is refused by the database rather than by a check +somebody has to remember to write. + +The `default_organization_id` update is separate because the composite foreign +key has nothing to reference until the organization exists; `MATCH SIMPLE` skips +the check while the column is null, which is what makes the ordering legal. ### Primary integration-event flow: host mapping changed diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index eaab2536..158c0787 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -3,8 +3,14 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). -The operations do not exist yet; the classification does. This matrix is not the -floor — [Audit Coverage § Baseline Coverage](../../standards/18-audit-coverage.md) +Two of the operations below now exist — `Tenant` create and `Organization` +create, written together by `ProvisionTenantCommand` +([ADR-0042](../../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)) +— and the rest are still classification ahead of code. Both are **MUST**, both +are written on the one transaction that provisioning is, so +[ADR-0033](../../decisions/0033-audit-durability-model.md)'s guarantee for them is +the ordinary one: the two rows commit with the two aggregates or nothing does. +This matrix is not the floor — [Audit Coverage § Baseline Coverage](../../standards/18-audit-coverage.md) is, and a module matrix "cannot remove anything in this list". This file adds rows beneath that baseline and classifies what the baseline leaves open; a tenant `AuditConfig` may then narrow SHOULD/MAY at runtime. Neither touches a baseline @@ -36,4 +42,7 @@ optional. The classification is inert until [Packet 9](../../roadmap/phase-02a-kernel-tenancy.md) lights up `AuditLogBehavior`, and Packet 9 transcribes its in-process catalogue -from this file. +from this file. **Provisioning is therefore unaudited today**, and that is a gap +with an owner rather than an accepted state: `TransactionBehavior` carries the +`TODO(2026-08-28, @platform, phase-02a-packet-9)` marking the line the MUST-class +write goes on, immediately before the commit. diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 6f382860..399c0578 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -3,16 +3,25 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). -**No permission keys yet** — Packet 6 ships no command or query handler, so there -is nothing to authorize. The matrix below is a forward declaration in the +**No permission keys yet.** The matrix below is a forward declaration in the `{module}.{resource}.{action}` form with the closed action set of -[Permission Standards](../../standards/19-permissions.md), not a Packet 7 -deliverable. Registration runs through -`IModule.RegisterPermissions(IPermissionRegistry)`, and neither type exists in -`backend/src` yet; the catalogue lands with the Identity module in +[Permission Standards](../../standards/19-permissions.md). Registration runs +through `IModule.RegisterPermissions(IPermissionRegistry)`, and neither type +exists in `backend/src` yet; the catalogue lands with the Identity module in [Phase 03](../../roadmap/phase-03-identity-admin.md), together with `Role`, -`Permission` and the lighting-up of the `AuthorizationBehavior` shell. Packet 7 -ships the aggregates and the seed, not the keys. +`Permission` and the lighting-up of the `AuthorizationBehavior` shell. + +**One handler exists and is deliberately unauthorized.** Packet 7 ships +`ProvisionTenantCommand`, and there is nothing for a permission check to read: it +runs with an **unresolved** tenant context by construction — that is what lets it +announce the tenant it is creating — and it attributes the write to +`UserId.SystemActor`, because provisioning precedes any membership in the tenant +being provisioned. What stands in for authorization today is reachability: the +command has no HTTP endpoint, so its only callers are the seeder and, from +[Phase 02c](../../roadmap/phase-02c-hub-foundation.md), the Hub over +`/api/internal/*` — a surface that takes `learnstack-hub` realm tokens and no +others. `tenancy.tenant.admin` is the key that will govern it, registered with +the rest in Phase 03. | Resource | read | write | delete | admin | Default role grants | |----------|:----:|:-----:|:------:|:-----:|---------------------| diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 4872f220..5f4c1234 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -668,20 +668,31 @@ otherwise). #### `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` -- **Asserts:** no MediatR handler issues `Add` / `AddRange` / `Update` / `Remove` - against more than one `DbSet` whose entity implements `IAggregateRoot`, except - the single handler on a literal allow-list — the handler for `ProvisionTenantCommand`, - which writes `Tenant` and its default `Organization` in one transaction per ADR-0042. +- **Asserts:** no MediatR handler takes more than one constructor parameter deriving + from `IAggregateWriteStore`, except the single handler on a literal + allow-list — `ProvisionTenantCommandHandler`, which writes `Tenant` and its default + `Organization` in one transaction per ADR-0042. - **Source:** [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md); [Architecture Standards § Aggregate Ownership](01-architecture-standards.md). -- **Type:** xUnit + source scan. **Kind:** structural. -- **Status:** **Registered.** +- **Type:** xUnit + reflection. **Kind:** structural. +- **Status:** **Implemented.** - **Phase:** 02a Packet 7. -- **Note:** the scan catches the direct form, which is the form a handler is written - in. It does not catch a write routed through a repository, a helper, or a second - `DbContext` reached indirectly. The binding control is that the allow-list has one - entry and growing it is a reviewed diff; the scan is what makes the ordinary mistake - loud. +- **Note:** the rule counts **ports**, not `DbSet` use, and the change is not + cosmetic. ADR-0042 § Implementation Notes specified a source scan for `Add` / + `Update` / `Remove` against more than one `DbSet`; under the shipped dependency + rules that scan can never fire, because `Application` may not reference + `Infrastructure` and so no handler can name a `DbSet` at all. A rule at + **Implemented** that cannot fire is worse than one at **Registered**, because the + catalogue then claims coverage the suite does not have. Counting a type also + survives renaming: `ITenantWriteStore` and `IOrganizationWriteStore` are matched by + their derivation, not their names. +- **What it does not catch:** a write routed through a helper that itself holds two + ports, or through a second `DbContext` reached indirectly — the same limit + [§ What a structural test proves](#what-a-structural-test-proves--and-what-it-does-not) states for every + structural rule. The binding control is that the allow-list has one entry and + growing it is a reviewed diff. +- **Mutation-checked.** Three mutations, three failures: a second handler taking two + write ports, the sanctioned handler renamed, and the two ports fused into one. ### Persistence: concurrency and the unit of work From 31c28a64f6e19642506ba3c3f1527c32fb14e33e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 09:24:39 +0300 Subject: [PATCH 31/55] docs(tenancy): correct what refuses a zeroed provisioning actor The comment claimed `created_by` carries a foreign key to `users` and that a zeroed actor would raise 23503. There is no such key, and its absence is deliberate: the audit subsystem depends on an erased actor becoming an orphan surrogate, which any ON DELETE action would make unreachable. What actually refuses the sentinel is AuditInput.EnsureValid, in the kernel. Co-Authored-By: Claude Opus 5 (1M context) --- .../Modules/Tenancy/ProvisionTenantCommandTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs index d35f7873..4353bfb7 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs @@ -123,9 +123,12 @@ public async Task The_result_names_both_rows_the_caller_now_has() public async Task Provisioning_is_attributed_to_the_system_actor() { // There is nobody in the tenant to attribute it to: provisioning runs before - // any membership exists. `created_by` is NOT NULL with a foreign key to - // `users`, so the alternative to the registry-assigned actor is a 23503 on - // the first insert. + // any membership exists. `created_by` is `NOT NULL` and carries no foreign + // key — deliberately, so an erased actor stays an orphan surrogate rather + // than becoming unreachable — so nothing in the database would object to a + // zeroed actor. What objects is `AuditableEntity.EnsureValidAuditInput`, + // which refuses `default(UserId)` and `Guid.Empty` alike, and the constant + // is what lets a non-request execution create an aggregate at all. var (sender, _) = Build(out var stores); await sender.Send(Command()); From 60a3e0f81267496b308806a46b9f07aefc84cef0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 10:04:23 +0300 Subject: [PATCH 32/55] fix(tenancy): answer a refused provisioning instead of crashing on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 of Step 9's review. Ten findings, each verified by measurement before being acted on. Every message the validator wrote was discarded. ValidationBehavior builds the response from `ErrorCode ?? ErrorMessage`, and FluentValidation always populates ErrorCode with the validator's own name — so a malformed slug and a tenant sharing its organization's id both reached the caller as `lockey_predicatevalidator`, the second under an empty field name because `RuleFor(command => command)` has no property. Every rule now carries an explicit key, and the cross-field rule hangs off the id it is about. The architecture rule had three escapes, all measured by dropping the shape into a production assembly and watching 76 cases pass: a fused port is one parameter reaching two roots, an INotificationHandler runs inside the same transaction and was not in the handler set, and GetConstructors is public-only. It now counts distinct aggregate roots across all three handler contracts. ADR-0042's Amendment 1 claimed the fused case was already caught; it was not, and the erratum did not follow the form Standards 13 mandates. Both corrected. A duplicate slug was a 500. Neither DbUpdateException nor PostgresException has an arm in HttpStatusMap, so a reused name crashed after the transaction opened and the tenant was announced — the same defect the validator was written to prevent, one layer down. It cannot be pre-checked: under the provisioning announcement a SELECT over `tenants` returns no rows by policy. The write store translates 23505 into AggregateConflictException — the adapter is the only layer permitted to name the provider's type — and the handler turns it into a Result naming which uniqueness was hit. UpdateAsync called DbSet.Update, which walks the graph. Two measurements narrowed the fix: EF skips already-tracked entries, so the defect is detached-only; and marking just the detached root is unsound because Version is the concurrency token and EF takes a detached entity's original values from its current ones. The port now requires a tracked aggregate and refuses anything else by name. Also: the tenant's own attribution was unconstrained — a tenant created by an arbitrary user id survived all 1166 cases; the organization's slug and width rules were untested and deleting them changed nothing; the atomicity case passed against a handler that wrote nothing at all; and the response DTO exposed raw Guid on a public contract. Module: Tenancy ADR: 0042, 0040, 0032 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/LearnStack.Api/LearnStack.Api.csproj | 5 +- .../LearnStack.Application.csproj | 9 +- .../Pipeline/MediatRPipelineRegistration.cs | 9 +- .../Pipeline/TransactionBehavior.cs | 5 +- .../Persistence/AggregateConflictException.cs | 67 +++++++++ .../Persistence/IAggregateWriteStore.cs | 13 +- .../Tenant/ProvisionTenantCommand.cs | 33 +++- .../Tenant/ProvisionTenantCommandHandler.cs | 59 +++++++- .../Tenant/ProvisionTenantCommandValidator.cs | 71 +++++---- .../Persistence/TenancyWriteStores.cs | 108 ++++++++++++-- .../AggregateWriteTests.cs | 123 +++++++++++++++ .../RequestSurfaceTests.cs | 68 ++------- .../Database/TenantProvisioningTests.cs | 139 +++++++++++++++-- .../Tenancy/ProvisionTenantCommandTests.cs | 141 ++++++++++++++---- ...rovisioning-cross-aggregate-transaction.md | 74 ++++++--- docs/modules/tenancy/README.md | 50 ++++--- .../21-architecture-tests-catalogue.md | 43 +++--- 17 files changed, 801 insertions(+), 216 deletions(-) create mode 100644 backend/src/LearnStack.SharedKernel/Persistence/AggregateConflictException.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs diff --git a/backend/src/LearnStack.Api/LearnStack.Api.csproj b/backend/src/LearnStack.Api/LearnStack.Api.csproj index f5d81ee3..8345df81 100644 --- a/backend/src/LearnStack.Api/LearnStack.Api.csproj +++ b/backend/src/LearnStack.Api/LearnStack.Api.csproj @@ -38,8 +38,9 @@ + FluentValidation.DependencyInjectionExtensions is kept for the composition + root's own use; the assembly scan itself lives in LearnStack.Application, + beside the ValidationBehavior that consumes what it registers. --> diff --git a/backend/src/LearnStack.Application/LearnStack.Application.csproj b/backend/src/LearnStack.Application/LearnStack.Application.csproj index 83a99e56..1f24b4e0 100644 --- a/backend/src/LearnStack.Application/LearnStack.Application.csproj +++ b/backend/src/LearnStack.Application/LearnStack.Application.csproj @@ -20,10 +20,11 @@ - + diff --git a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs index 9a28b17f..202f1195 100644 --- a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs +++ b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs @@ -71,7 +71,14 @@ public static IServiceCollection AddLearnStackMediatRPipeline( // sees an empty array, short-circuits, and a command with a validator is refused // by nothing. It shipped that way only because no validator existed yet; the // first one would have been silently inert. - services.AddValidatorsFromAssemblies(assembliesToScan, includeInternalTypes: true); + // + // The kernel assembly is always in the list, not only in the fallback: a shared + // validator placed beside the behavior that consumes it would otherwise be as + // inert as the ones this line exists to register, and inert in the one place + // nobody would think to check. + services.AddValidatorsFromAssemblies( + assembliesToScan.Append(typeof(AssemblyMarker).Assembly).Distinct(), + includeInternalTypes: true); return services; } diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index aee202e3..621889d7 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -98,7 +98,10 @@ public async Task Handle( // `tenants` is self-keyed and its policy is WITH CHECK (id = app.tenant_id), // so creating a tenant means announcing the tenant being created — an id // that names nothing resolvable, because it does not exist yet. Measured - // against the shipped policy: unset and empty-string both fail 42501, and + // against the shipped policy on a throwaway container: unset and empty-string + // both fail 42501 — the empty-string half is the one a case pins, in + // A_request_that_does_not_provision_still_fails_closed_when_unresolved, since + // that is the state this pipeline can actually produce — and // the new tenant's own id lets the whole provisioning sequence commit. // // The !IsResolved term is the load-bearing half, not a defensive one. A diff --git a/backend/src/LearnStack.SharedKernel/Persistence/AggregateConflictException.cs b/backend/src/LearnStack.SharedKernel/Persistence/AggregateConflictException.cs new file mode 100644 index 00000000..ec1409e9 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Persistence/AggregateConflictException.cs @@ -0,0 +1,67 @@ +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Persistence; + +/// +/// A write an could not perform because a +/// uniqueness the schema enforces already holds. +/// +/// +/// +/// Part of the port's contract, not an implementation detail. The alternative was +/// for a handler to catch the provider's own exception, and it is not available: the +/// repository forbids importing a provider SDK exception type outside the adapter's +/// namespace, and `Application` cannot reference the adapter's assembly in any case. An +/// adapter translates at the boundary; this is the type it translates to, so a handler +/// can answer a caller without ever naming a database. +/// +/// +/// It is the caller's fault, not a fault. A reused slug is an ordinary answer a +/// client can act on, and the carried business_rule_violation +/// by default — is what makes an uncaught one a 409 rather than the 500 a +/// bare DbUpdateException produces, since neither it nor +/// PostgresException has an entry in HttpStatusMap. +/// +/// +/// Catching it is the handler's job. An uncaught one still reaches the L1 +/// handler, which answers 409 correctly but also captures to +/// IErrorTrackingProvider, because ShouldCapture exempts only +/// ProviderException.IsClientError and a client-side BadHttpRequestException. +/// Adding a third arm is an edit to [ADR-0032](../../../../docs/decisions/0032-exception-handling-logging-and-observability.md) +/// § Sub-decision 7's table and is owed by the phase that first needs it — +/// [Phase 03](../../../../docs/roadmap/phase-03-identity-admin.md), which brings the +/// handlers that will use these ports in numbers. Today the one handler that can raise it +/// catches it. +/// +/// +public sealed class AggregateConflictException : LearnStackException +{ + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_business_rule_violation")); + + public AggregateConflictException( + string message, string? constraintName = null, Exception? innerException = null) + : this(DefaultError, message, constraintName, innerException) + { + } + + public AggregateConflictException( + Error error, + string message, + string? constraintName = null, + Exception? innerException = null) + : base(error, message, innerException) => ConstraintName = constraintName; + + /// + /// The database constraint that refused the write, when the adapter knows it. + /// + /// + /// Carried because the two halves of one command fail for different reasons and a + /// caller retrying blindly on the wrong one never succeeds: a taken slug needs a + /// different slug, a duplicate id needs a different id. A handler maps it to a key; + /// nothing outside a handler should read it. + /// + public string? ConstraintName { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs b/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs index e6a4fcbe..f3c96586 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs @@ -9,7 +9,7 @@ namespace LearnStack.SharedKernel.Persistence; /// /// /// It exists because the dependency rules leave no alternative. -/// Standards 01 lists +/// Standards 01 lists /// Application → Infrastructure under forbidden edges, every module's /// DbContext and its DbSets live in that module's Infrastructure project, /// and Infrastructure already references Application — so the reverse reference is a @@ -47,5 +47,16 @@ public interface IAggregateWriteStore Task AddAsync(TRoot aggregate, CancellationToken cancellationToken = default); /// Persists a change to an aggregate already stored. + /// Persists changes to an aggregate this scope already tracks. + /// + /// Tracked, and an implementation may refuse anything else. Under + /// ADR-0040 a + /// scope holds one connection, one transaction and one module context, so an aggregate + /// is loaded and saved on the same context by construction. A detached aggregate has no + /// correct silent handling: attaching the graph re-writes every child from a stale + /// in-memory copy, and marking only the root gets the concurrency token's original + /// value from its current one and fails the next save. Implementations therefore throw + /// rather than guess. + /// Task UpdateAsync(TRoot aggregate, CancellationToken cancellationToken = default); } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs index 29a9c6ec..84396367 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs @@ -11,7 +11,7 @@ namespace LearnStack.Modules.Tenancy.Application.Contracts.Tenant; /// /// /// The one operation sanctioned to write two aggregate roots at once -/// (ADR-0042), +/// (ADR-0042), /// by enumeration rather than by principle: a tenant whose default organization failed to /// commit is a tenant no request can serve, and a second transaction is a window in which /// exactly that state exists. The allow-list has one entry and @@ -24,9 +24,22 @@ namespace LearnStack.Modules.Tenancy.Application.Contracts.Tenant; /// WITH CHECK (id = app.tenant_id), so the transaction must be announced with the /// id before the insert — and a handler that minted its own could not satisfy its own /// policy. This is the one place the ordinary rule against taking a tenant id from a -/// request does not apply: the id names a tenant that does not exist yet, so it grants -/// nothing, and is honoured only when the context is -/// unresolved. +/// request does not apply, and the guarantee is narrower than "the tenant does not exist +/// yet" — nothing verifies that. What holds is that the only statement the +/// announcement authorises is an INSERT the primary key rejects when the tenant +/// already exists: an id naming a live tenant announces that tenant, then fails +/// pk_tenants and rolls back, having read and written nothing. +/// narrows it further by being honoured only when the +/// context is unresolved. +/// +/// +/// Anything added to that transaction inherits the assumption, so read this first. +/// Two things are already scheduled inside it: the MUST-class audit write ADR-0033 puts +/// immediately before the commit, and the outbox flush at pipeline step 7. Each would +/// execute under a caller-chosen tenant id, and neither is refused by pk_tenants. +/// Whatever lands there must not assume the announced tenant is new — or this command +/// must start refusing a tenant that already exists, which needs a read surface it does +/// not have today. /// /// /// [AllowsUnresolvedTenantContext] and not [PublicSurface]. The @@ -55,11 +68,19 @@ public sealed record ProvisionTenantCommand( /// What provisioning produced. /// +/// /// The ids are echoed rather than generated, so a caller that lost its correlation can /// still tie the result to what it asked for. +/// +/// +/// Strongly typed, not raw Guid: [Backend Coding Standards](../../../../../../docs/standards/02-backend-coding.md) +/// says never to expose a raw Guid on a public surface, and Packet 4 shipped the +/// OpenAPI mapping for these identifiers so that the response schema is unaffected by +/// keeping them. A DTO is exactly where the erosion starts. +/// /// public sealed record ProvisionedTenantDto( - Guid TenantId, + TenantId TenantId, string Slug, - Guid DefaultOrganizationId, + OrganizationId DefaultOrganizationId, string DefaultOrganizationSlug); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs index ecaca379..ba881d09 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs @@ -2,7 +2,9 @@ using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.Modules.Tenancy.Domain; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Errors; using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Time; using MediatR; @@ -30,6 +32,17 @@ namespace LearnStack.Modules.Tenancy.Application.Tenant; /// the ability to move the ambient tenant. /// /// +/// A name already taken is an answer, not a crash. Every uniqueness the schema +/// enforces here is reachable by an ordinary caller — a reused slug most of all — and +/// neither DbUpdateException nor PostgresException has an entry in +/// HttpStatusMap, so untranslated each one is a 500 raised after the transaction +/// was opened and the tenant announced. It cannot be pre-checked either: under the +/// provisioning announcement a SELECT over tenants returns zero rows by +/// policy, so the database's own answer is the only one available. The write store +/// translates the SQLSTATE — it is the adapter, and the only layer permitted to name the +/// provider's exception type — and this catches what it throws. +/// +/// /// Two ports, and the rule counts them. This is the only handler in the solution /// permitted to take more than one IAggregateWriteStore, which is what /// Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning asserts. A combined @@ -52,11 +65,29 @@ public async Task> Handle( // 03's permission check is what will identify the operator who asked. var actor = UserId.SystemActor; + try + { + return await ProvisionAsync(request, actor, cancellationToken); + } + catch (AggregateConflictException conflict) + { + // The transaction is aborted at this point, so nothing may be issued on it — + // returning a failure is what makes TransactionBehavior roll it back rather + // than try to commit. + return Result.FailFor>( + new Error(new LocalizedMessage(ConflictKeyFor(conflict.ConstraintName)))); + } + } + + /// The three writes, in the one order the schema permits. + private async Task> ProvisionAsync( + ProvisionTenantCommand request, UserId actor, CancellationToken cancellationToken) + { var tenant = Domain.Tenant.Create( request.TenantId, request.Slug, request.DisplayName, clock, actor); - // First, and on its own: the organization's composite foreign key names - // (tenant_id, id), so the tenant row has to be there before it. + // The tenant first: `organizations` carries a composite foreign key to + // (tenant_id, id), so its row has nothing to reference until this one lands. await tenants.AddAsync(tenant, cancellationToken); var organization = Organization.Create( @@ -77,9 +108,29 @@ public async Task> Handle( await tenants.UpdateAsync(tenant, cancellationToken); return Result.Ok(new ProvisionedTenantDto( - request.TenantId.Value, + request.TenantId, tenant.Slug, - request.DefaultOrganizationId.Value, + request.DefaultOrganizationId, organization.Slug)); } + + /// + /// Which uniqueness the caller collided with, as a localization key. + /// + /// + /// By constraint name rather than by a generic "already exists", because the two + /// halves of this command fail for different reasons and a caller retrying blindly on + /// the wrong one never succeeds: a taken slug needs a different slug, a duplicate id + /// needs a different id. An unrecognised constraint falls back to the generic key + /// rather than to a 500 — a new unique index is not a reason to start crashing. + /// + private static string ConflictKeyFor(string? constraintName) => constraintName switch + { + "ux_tenants_slug" => "lockey_tenant_slug_taken", + "pk_tenants" => "lockey_tenant_already_exists", + "ux_organizations_tenant_id_slug" => "lockey_organization_slug_taken", + "pk_organizations" or "ux_organizations_tenant_id_id" => + "lockey_organization_already_exists", + _ => "lockey_business_rule_violation", + }; } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs index fb2a6d5e..f61817d5 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs @@ -26,55 +26,64 @@ namespace LearnStack.Modules.Tenancy.Application.Tenant; /// place to drift; a shared constant is neither. /// /// +/// Every rule carries an explicit error code, and that is the whole of what a caller +/// receives. ValidationBehavior builds the response from +/// failure.ErrorCode ?? failure.ErrorMessage, and FluentValidation always +/// populates ErrorCode with the validator's own name — so a rule left to its +/// defaults reaches the caller as lockey_predicatevalidator, and anything passed +/// to WithMessage is never read at all. Without the codes below, a malformed slug +/// and a tenant sharing its organization's id were byte-identical on the wire. Hardcoded +/// English would have been worse than useless here: it would have been invisible. +/// +/// /// The pipeline runs this at step 1, so a refusal costs no transaction and no /// announcement. A failure here is Result.Fail(validation_failed) and never an /// exception, per the shipped behavior's contract. /// /// -public sealed class ProvisionTenantCommandValidator : AbstractValidator +internal sealed class ProvisionTenantCommandValidator : AbstractValidator { public ProvisionTenantCommandValidator() { - // Cascade(Stop), because the rules below are not independent of the first: the - // shape predicate runs a regex, and a regex against a null slug throws + // Cascade(Stop), because the rules are not independent of the first: the shape + // predicate runs a regex, and a regex against a null slug throws // ArgumentNullException out of the validator itself — which is the 500 this file // exists to prevent, relocated one step earlier. `string` being non-nullable in // the command is a compile-time promise, and a deserializer is not bound by it. - RuleFor(command => command.Slug) - .Cascade(CascadeMode.Stop) - .NotEmpty() - .MaximumLength(UrlSlug.MaxLength) - .Must(UrlSlug.IsUrlSafe!) - .WithMessage(SlugShape); - - RuleFor(command => command.DefaultOrganizationSlug) - .Cascade(CascadeMode.Stop) - .NotEmpty() - .MaximumLength(UrlSlug.MaxLength) - .Must(UrlSlug.IsUrlSafe!) - .WithMessage(SlugShape); + RuleForSlug(command => command.Slug); + RuleForSlug(command => command.DefaultOrganizationSlug); - RuleFor(command => command.DisplayName) - .NotEmpty() - .MaximumLength(MappedLength.DisplayName); - - RuleFor(command => command.DefaultOrganizationDisplayName) - .NotEmpty() - .MaximumLength(MappedLength.DisplayName); + RuleForDisplayName(command => command.DisplayName); + RuleForDisplayName(command => command.DefaultOrganizationDisplayName); // The one cross-field rule, and the reason it is here rather than in an // aggregate: neither Tenant nor Organization can see the other's id, so neither // can notice that a caller sent the same Guid for both. The two rows are // different things in different tables and a shared id would read as a // relationship that does not exist. - RuleFor(command => command) - .Must(command => command.TenantId.Value != command.DefaultOrganizationId.Value) - .WithMessage( - "A tenant and its default organization are separate rows and must not " - + "share an id."); + // + // Hung off the organization id rather than off the command, because the property + // name is what keys the RFC 7807 `errors` map — `RuleFor(command => command)` + // yields the empty string, and an error under a "" key names nothing a client + // can highlight. + RuleFor(command => command.DefaultOrganizationId) + .Must((command, organizationId) => command.TenantId.Value != organizationId.Value) + .WithErrorCode("lockey_tenant_and_organization_share_an_id"); } - private const string SlugShape = - "'{PropertyValue}' is not a URL-safe slug: lowercase letters, digits and single " - + "interior hyphens only."; + private void RuleForSlug( + System.Linq.Expressions.Expression> slug) => + RuleFor(slug) + .Cascade(CascadeMode.Stop) + .NotEmpty().WithErrorCode("lockey_slug_required") + .MaximumLength(UrlSlug.MaxLength).WithErrorCode("lockey_slug_too_long") + .Must(value => UrlSlug.IsUrlSafe(value)).WithErrorCode("lockey_slug_not_url_safe"); + + private void RuleForDisplayName( + System.Linq.Expressions.Expression> displayName) => + RuleFor(displayName) + .Cascade(CascadeMode.Stop) + .NotEmpty().WithErrorCode("lockey_display_name_required") + .MaximumLength(MappedLength.DisplayName) + .WithErrorCode("lockey_display_name_too_long"); } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs index 72a17cdd..72154a41 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs @@ -1,5 +1,9 @@ using LearnStack.Modules.Tenancy.Application.Abstractions; using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using static LearnStack.Modules.Tenancy.Infrastructure.Persistence.WriteStoreTracking; namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; @@ -7,44 +11,122 @@ namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; /// The Tenant aggregate's writes, against the module context. /// /// +/// /// Each method saves, and that is load-bearing rather than convenient. The EF model /// carries no relationship between Tenant and Organization, so batching both /// into one SaveChanges leaves the order EF sends them unspecified — and /// provisioning depends on it: the organization's composite foreign key names /// (tenant_id, id), so the tenant row has to land first. Saving per call is what /// makes the handler's statement order the database's statement order. +/// +/// +/// UpdateAsync takes a tracked aggregate and nothing else. Under +/// [ADR-0040](../../../../../../docs/decisions/0040-ambient-unit-of-work.md) a scope has +/// one connection, one transaction and one module context, so an aggregate is loaded and +/// saved on the same context by construction — and for a tracked aggregate change +/// tracking already holds the diff, so the save is the whole of the work. +/// +/// +/// Which is why it does not call DbSet.Update. That method traverses the +/// graph and marks what it reaches: on a DETACHED aggregate every child with a set key +/// becomes Modified and every child with an unset key becomes Added, so a +/// tenant carrying Locales and FeatureFlags re-UPDATEs every one of +/// them with whatever the in-memory copy holds — silently overwriting anything written +/// since it was loaded. Marking only the detached root instead is no better: Version +/// is the concurrency token, EF takes a detached entity's original values from its current +/// ones, and a caller that mutated the root first therefore issues +/// WHERE row_version = <the value it just incremented to>, matches nothing, and +/// gets DbUpdateConcurrencyException — measured. There is no correct silent +/// handling of a detached aggregate here, so it is refused. +/// +/// +/// +/// Both stores are public only so the composition root can name them in a +/// registration; nothing outside that line should. The port is the type callers depend +/// on. /// public sealed class TenantWriteStore(TenancyDbContext db) : ITenantWriteStore { - public async Task AddAsync(Tenant aggregate, CancellationToken cancellationToken = default) + public Task AddAsync(Tenant aggregate, CancellationToken cancellationToken = default) { db.Tenants.Add(aggregate); - await db.SaveChangesAsync(cancellationToken); + return SaveTranslatingConflictsAsync(db, cancellationToken); } - public async Task UpdateAsync(Tenant aggregate, CancellationToken cancellationToken = default) + public Task UpdateAsync(Tenant aggregate, CancellationToken cancellationToken = default) { - db.Tenants.Update(aggregate); - await db.SaveChangesAsync(cancellationToken); + EnsureTracked(db, aggregate); + return SaveTranslatingConflictsAsync(db, cancellationToken); } } -/// The Organization aggregate's writes. Same shape, same reason. +/// The Organization aggregate's writes. /// -/// public only so the composition root can name it in a registration; nothing -/// outside that line should. The port is the type callers depend on. +/// Same shape and the same three reasons as , which +/// carries them: save per call, no DbSet.Update, tracked aggregates only. /// public sealed class OrganizationWriteStore(TenancyDbContext db) : IOrganizationWriteStore { - public async Task AddAsync(Organization aggregate, CancellationToken cancellationToken = default) + public Task AddAsync(Organization aggregate, CancellationToken cancellationToken = default) { db.Organizations.Add(aggregate); - await db.SaveChangesAsync(cancellationToken); + return SaveTranslatingConflictsAsync(db, cancellationToken); + } + + public Task UpdateAsync( + Organization aggregate, CancellationToken cancellationToken = default) + { + EnsureTracked(db, aggregate); + return SaveTranslatingConflictsAsync(db, cancellationToken); + } +} + +/// Shared by both stores; see for why. +internal static class WriteStoreTracking +{ + /// + /// Saves, turning a uniqueness violation into the port's own conflict type. + /// + /// + /// The translation happens here because here is the only place allowed to name + /// PostgresException: the repository forbids importing a provider SDK + /// exception type outside an adapter's namespace, and `Application` cannot reference + /// this assembly regardless. Untranslated, a reused slug reaches the L1 handler as a + /// DbUpdateException, which HttpStatusMap has no arm for — a 500 for + /// something the caller can fix by choosing another slug. + /// + /// 23505 only. Every other SQLSTATE is a fault and stays one; a 42501 in particular + /// means a policy refused the write, which is never something to soften. + /// + internal static async Task SaveTranslatingConflictsAsync( + TenancyDbContext db, CancellationToken cancellationToken) + { + try + { + await db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException failure) + when (failure.InnerException is PostgresException { SqlState: "23505" } conflict) + { + throw new AggregateConflictException( + conflict.MessageText, conflict.ConstraintName, failure); + } } - public async Task UpdateAsync(Organization aggregate, CancellationToken cancellationToken = default) + internal static void EnsureTracked(TenancyDbContext db, T aggregate) + where T : class { - db.Organizations.Update(aggregate); - await db.SaveChangesAsync(cancellationToken); + ArgumentNullException.ThrowIfNull(aggregate); + + if (db.Entry(aggregate).State != EntityState.Detached) + { + return; + } + + throw new InvalidOperationException( + $"The {typeof(T).Name} passed to UpdateAsync is not tracked by this scope's " + + "context. Load it through the same context that saves it — under the " + + "ambient unit of work that is the ordinary case, and it is the only one " + + "with correct concurrency-token semantics."); } } diff --git a/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs b/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs new file mode 100644 index 00000000..59247916 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs @@ -0,0 +1,123 @@ +using System.Reflection; +using FluentAssertions; +using LearnStack.SharedKernel.Persistence; +using MediatR; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// +/// The one sanctioned cross-aggregate write, and the count that keeps it at one. +/// +/// +/// ADR-0042 +/// permits a single operation to write two aggregate roots on one transaction, by +/// enumeration rather than by principle: a tenant whose default organization failed to +/// commit is a tenant no request can serve, and a second transaction is a window in which +/// exactly that state exists. The value of the rule is that it counts the hole. A hole +/// nobody counts becomes a hole everybody uses. +/// +public sealed class AggregateWriteTests +{ + [Fact] + public void Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning() + { + // ADR-0042 sanctions ONE operation to write two aggregate roots in one + // transaction, by enumeration rather than by principle — a tenant whose default + // organization failed to commit is a tenant no request can serve, and a second + // transaction is a window in which exactly that state exists. + // + // Counted by AGGREGATE TYPE, not by parameter and not by name. The catalogue + // registered this as a scan for DbSet use in handlers, and under the shipped + // dependency rules that scan can never fire: Application → Infrastructure is + // forbidden, so no handler can name a DbSet at all. A rule at Implemented status + // that cannot fire is worse than one at Registered, because the catalogue then + // claims coverage it does not have. + var offenders = ProductionAssemblies() + .Select(Assembly.Load) + .SelectMany(assembly => assembly.GetTypes()) + .Where(type => type is { IsAbstract: false, IsInterface: false }) + .Where(IsMessageHandler) + .Where(type => type.GetConstructors( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Any(constructor => AggregatesWrittenBy(constructor).Count > 1)) + .Select(type => type.Name) + .Distinct() + .ToList(); + + offenders.Should().BeEquivalentTo( + ["ProvisionTenantCommandHandler"], + "a handler that can write two aggregate roots writes across an aggregate " + + "boundary in one transaction, which ADR-0042 sanctions for exactly one " + + "operation — a second name here is a decision that needs its own record"); + } + + /// + /// The distinct aggregate roots a constructor's write ports reach. + /// + /// + /// + /// Distinct closed constructions of + /// rather than a count of parameters, and the difference is an escape the first + /// version of this rule had. Measured: an interface deriving from the generic twice — + /// IFused : IAggregateWriteStore<Tenant, TenantId>, + /// IAggregateWriteStore<Organization, OrganizationId> — is ONE constructor + /// parameter, so a handler taking it wrote both roots and the rule stayed green. + /// Counting what the ports reach makes the fused shape indistinguishable from the two + /// it fuses, which is the point: the rule exists to see the write, not the wiring. + /// + /// + /// Two parameters over the SAME root are not a cross-aggregate write and do not + /// count twice — a handler holding one port for reading and one for writing is + /// still writing one aggregate. + /// + /// + private static HashSet AggregatesWrittenBy(ConstructorInfo constructor) => + [.. constructor.GetParameters() + .SelectMany(parameter => WriteStoreConstructions(parameter.ParameterType)) + .Select(store => store.GetGenericArguments()[0])]; + + private static IEnumerable WriteStoreConstructions(Type parameterType) => + parameterType.GetInterfaces() + .Append(parameterType) + .Where(candidate => candidate.IsGenericType + && candidate.GetGenericTypeDefinition() == typeof(IAggregateWriteStore<,>)); + + /// + /// Whether the type handles a message on the MediatR pipeline. + /// + /// + /// INotificationHandler is in the set deliberately. An intra-module domain + /// event is one of ADR-0010's four sanctioned mechanisms and its handler runs + /// inside the ambient transaction, so a notification handler holding two write + /// ports is the same cross-aggregate write as a command handler holding them — and + /// the likelier of the two to be written by someone who did not read ADR-0042, + /// because a domain-event handler does not look like a write boundary. Measured: with + /// only IRequestHandler<,> in the set, one dropped into a production + /// assembly passed all 76 architecture cases. + /// + private static bool IsMessageHandler(Type type) => + type.GetInterfaces().Any(contract => + contract.IsGenericType + && HandlerContracts.Contains(contract.GetGenericTypeDefinition())); + + private static readonly HashSet HandlerContracts = + [ + typeof(IRequestHandler<,>), + typeof(IRequestHandler<>), + typeof(INotificationHandler<>), + ]; + + /// Every production assembly, by name, from the project files on disk. + /// + /// Enumerated from the filesystem rather than from a literal list, for the reason + /// RequestSurfaceTests does the same: a list is a thing an author forgets to + /// grow, and a module added without its entry is a module the rule never scanned. + /// + private static IEnumerable ProductionAssemblies() => + Directory.EnumerateFiles( + RepositoryPaths.BackendSrc(), "LearnStack.*.csproj", SearchOption.AllDirectories) + .Select(Path.GetFileNameWithoutExtension) + .Where(name => !string.IsNullOrEmpty(name)) + .Select(name => name!); +} diff --git a/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs index 8d7aed8a..6794a721 100644 --- a/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs @@ -17,14 +17,14 @@ namespace LearnStack.Tests.Architecture; /// it counts its hole. A hole nobody counts becomes a hole everybody uses. /// /// -/// They are vacuous today, and the vacuity is real rather than a formality. -/// There is not one production request type in the solution — -/// ProvisionTenantCommand arrives in Packet 7 step 9 and the first -/// [PublicSurface] types in Phase 02d — so the marked sets are empty and the -/// set-membership legs pass over nothing. What is not vacuous is the reverse -/// direction: the enumerated table in Standards 04 must not name a type that carries -/// no marker, and both attributes must keep the shape the pipeline reads them with. -/// Each leg below says which of the two it is. +/// One hole is occupied and one is still empty. +/// ProvisionTenantCommand carries [AllowsUnresolvedTenantContext] as of +/// Packet 7, so that leg now counts a real entry; the first [PublicSurface] +/// types arrive in Phase 02d, so that set is empty and its membership leg passes over +/// nothing. What is not vacuous either way is the reverse direction: the +/// enumerated table in Standards 04 must not name a type that carries no marker, and +/// both attributes must keep the shape the pipeline reads them with. Each leg below +/// says which of the two it is. /// /// public sealed class RequestSurfaceTests @@ -32,8 +32,8 @@ public sealed class RequestSurfaceTests [Fact] public void AllowsUnresolvedTenantContext_Only_On_Provisioning_Commands() { - // Leg 1 — the set, vacuous today. Named provisioning and platform-admin - // commands only. The allow-list is a literal rather than a pattern on purpose: + // Leg 1 — the set, and no longer vacuous: ProvisionTenantCommand is in it. + // Named provisioning and platform-admin commands only. The allow-list is a literal rather than a pattern on purpose: // "any command whose name ends in ProvisionCommand" is a rule an author // satisfies by naming, which is not a decision anybody reviewed. var marked = RequestTypes() @@ -159,54 +159,6 @@ private sealed record ProbeStreamed : IStreamRequest; private sealed record ProbeNotARequest; - [Fact] - public void Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning() - { - // ADR-0042 sanctions ONE operation to write two aggregate roots in one - // transaction, by enumeration rather than by principle — a tenant whose default - // organization failed to commit is a tenant no request can serve, and a second - // transaction is a window in which exactly that state exists. - // - // Counted by PORT TYPE, not by name. The catalogue registered this as a scan for - // DbSet use in handlers, and under the shipped dependency rules that scan can - // never fire: Application → Infrastructure is forbidden, so no handler can name a - // DbSet at all. A rule at Implemented status that cannot fire is worse than one - // at Registered, because the catalogue then claims coverage it does not have. - // IAggregateWriteStore is a type, so renaming a port does not escape this. - var offenders = ProductionAssemblies() - .Select(Assembly.Load) - .SelectMany(assembly => assembly.GetTypes()) - .Where(type => type is { IsAbstract: false, IsInterface: false }) - .Where(type => type.GetInterfaces().Any(contract => - contract.IsGenericType - && contract.GetGenericTypeDefinition() == typeof(IRequestHandler<,>))) - .Where(type => type.GetConstructors() - .Any(constructor => constructor.GetParameters() - .Count(parameter => WritesAnAggregate(parameter.ParameterType)) > 1)) - .Select(type => type.Name) - .Distinct() - .ToList(); - - offenders.Should().BeEquivalentTo( - ["ProvisionTenantCommandHandler"], - "a handler taking two aggregate write ports writes across an aggregate " - + "boundary in one transaction, which ADR-0042 sanctions for exactly one " - + "operation — a second name here is a decision that needs its own record"); - } - - /// Whether a constructor parameter is a write port for some aggregate. - /// - /// Walks the interface's own hierarchy rather than matching a name: the ports modules - /// declare — ITenantWriteStore, IOrganizationWriteStore — derive from - /// the generic, and it is the derivation the rule counts. - /// - private static bool WritesAnAggregate(Type parameterType) => - IsAggregateWriteStore(parameterType) - || parameterType.GetInterfaces().Any(IsAggregateWriteStore); - - private static bool IsAggregateWriteStore(Type type) => - type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IAggregateWriteStore<,>); - [Fact] public void The_Sweep_Covers_Every_Production_Assembly() { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs index 99b6e7a2..50cc03eb 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs @@ -4,6 +4,7 @@ using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Application.Abstractions; using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Domain; using LearnStack.Modules.Tenancy.Infrastructure.Persistence; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; @@ -11,6 +12,7 @@ using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Time; using MediatR; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; @@ -24,7 +26,7 @@ namespace LearnStack.Tests.Integration.Database; /// /// /// Why this cannot be a unit test. The whole of -/// ADR-0042 +/// ADR-0042 /// is a claim about what one transaction may write under Row Level Security. Against /// fakes, every case below passes with the announcement deleted, with the writes split /// across two transactions, and with the policies disabled. @@ -94,16 +96,31 @@ public async Task A_provisioning_that_fails_part_way_commits_nothing() // The reason a second transaction was not an option. A tenant whose default // organization failed to commit is a tenant no request can serve: every // organization-scoped read filters on a column that is null, and nothing in the - // schema would ever repair it. Measured here by colliding the organization's id - // with a seeded row — the unique index fires regardless of RLS, so the second - // write raises 23505 while the first has already succeeded. - var command = Command() with { DefaultOrganizationId = OrganizationId.From(SchemaFixture.OrgA1) }; + // schema would ever repair it. + // + // The failure is provoked by colliding the organization's id with a seeded row — + // the unique index fires regardless of RLS, so the second write is refused while + // the first has already succeeded. That is the state ADR-0042 exists to make + // unobservable. + // + // It arrives as a Result rather than a throw, and that is the path worth testing: + // TransactionBehavior calls FailAsync on a failure response, so the rollback here + // is the one a business refusal takes, not the one an exception takes. A handler + // that had written the tenant and then returned Result.Fail without the behavior + // rolling back would leave exactly the orphan this ADR forbids. + var command = Command() with + { + DefaultOrganizationId = OrganizationId.From(SchemaFixture.OrgA1), + }; try { - var provision = () => ProvisionAsync(command); + var result = await ProvisionAsync(command); - await provision.Should().ThrowAsync(); + result.IsFailure.Should().BeTrue(); + result.Error!.Message.Key.Should().Be("lockey_organization_already_exists", + "the collision is on the organization's key, which proves the tenant " + + "insert had already gone through when the second write was refused"); (await ScalarAsPlatformAsync( "SELECT count(*) FROM tenants WHERE id = @id", command.TenantId.Value)) @@ -117,6 +134,53 @@ public async Task A_provisioning_that_fails_part_way_commits_nothing() } } + [Theory] + [InlineData("slug", "lockey_tenant_slug_taken")] + [InlineData("id", "lockey_tenant_already_exists")] + public async Task A_name_already_taken_is_an_answer_rather_than_a_crash( + string collideOn, string expectedKey) + { + // Reusing a slug is an ordinary thing a caller does, and untranslated it is a 500: + // neither DbUpdateException nor PostgresException has an arm in HttpStatusMap, so + // every one falls to InternalServerError — raised after ValidationBehavior passed + // the command, after the transaction opened and after the tenant was announced. + // + // It cannot be pre-checked, either. Under the provisioning announcement a SELECT + // over `tenants` returns zero rows by policy, so the database's answer is the only + // one there is; the adapter translates it and the handler turns it into a Result. + var first = Command(); + + try + { + (await ProvisionAsync(first)).IsSuccess.Should().BeTrue(); + + var second = collideOn == "slug" + ? Command() with { Slug = first.Slug } + : Command() with { TenantId = first.TenantId }; + + var result = await ProvisionAsync(second); + + result.IsFailure.Should().BeTrue("a taken name is a refusal, not a fault"); + result.Error!.Message.Key.Should().Be(expectedKey, + "a caller retrying blindly on the wrong half never succeeds — a taken slug " + + "needs a different slug, a duplicate id a different id"); + result.Error.Code.Should().Be( + collideOn == "slug" ? "tenant_slug_taken" : "tenant_already_exists"); + + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM tenants WHERE id = @id", second.TenantId.Value)) + .Should().Be(collideOn == "slug" ? 0L : 1L, + "the second provisioning rolled back; on the id collision the row that " + + "remains is the FIRST tenant's"); + + await CleanUpAsync(second); + } + finally + { + await CleanUpAsync(first); + } + } + [Fact] public async Task The_announcement_confines_the_transaction_to_the_tenant_being_created() { @@ -282,6 +346,63 @@ public async Task A_resolved_caller_cannot_provision_a_tenant_it_did_not_authent } } + [Fact] + public async Task Updating_a_detached_aggregate_is_refused_rather_than_guessed_at() + { + // `DbSet.Update` traverses the graph and marks what it reaches: on a detached + // aggregate every child with a set key becomes Modified and every child with an + // unset key becomes Added. `Tenant` carries Locales and FeatureFlags, so the + // obvious spelling of UpdateAsync re-UPDATEs every one of them from a stale + // in-memory copy — overwriting anything written since the load. + // + // Marking only the detached root is no better, and this is the measurement that + // settled it: `Version` is the concurrency token, EF takes a detached entity's + // original values from its current ones, so a caller that mutated the root first + // issues WHERE row_version = , matches nothing, + // and gets DbUpdateConcurrencyException. There is no correct silent handling, so + // the store refuses — loudly, at the call, naming the fix. + // + // Provisioning never hits this: its aggregate is tracked from the Add. The port is + // the one six modules inherit, and this is the contract they inherit with it. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + + services.GetRequiredService().Current = + new ResolvedContext(SchemaFixture.TenantA, SchemaFixture.OrgA1); + + var unitOfWork = services.GetRequiredService(); + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(services.GetRequiredService()); + + var db = services.GetRequiredService(); + var tenant = await db.Tenants + .Include(candidate => candidate.Locales) + .SingleAsync(candidate => candidate.Id == TenantId.From(SchemaFixture.TenantA)); + + tenant.Locales.Should().NotBeEmpty( + "a tenant with no children could not show the graph walk at all"); + + // The graph goes detached — a request that loaded in one scope and saved in + // another, or anything that round-tripped the aggregate. + db.ChangeTracker.Clear(); + tenant.ChangeStatus(TenantStatus.Suspended, Clock, UserId.SystemActor); + + var save = () => services.GetRequiredService().UpdateAsync(tenant); + + (await save.Should().ThrowAsync( + "a detached aggregate is a programmer error with no safe default")) + .WithMessage("*not tracked by this scope's context*"); + + // And nothing was written on the way to the refusal. + (await ReadAsync( + unitOfWork, + $"SELECT status FROM tenants WHERE id = '{SchemaFixture.TenantA}'")) + .Should().Be(nameof(TenantStatus.Trial)); + + await unitOfWork.RollbackAsync(); + } + // ── Harness ────────────────────────────────────────────────────────────── /// @@ -371,8 +492,8 @@ private static ProvisionTenantCommand Command() } private static ProvisionedTenantDto Provisioned(ProvisionTenantCommand command) => - new(command.TenantId.Value, command.Slug, - command.DefaultOrganizationId.Value, command.DefaultOrganizationSlug); + new(command.TenantId, command.Slug, + command.DefaultOrganizationId, command.DefaultOrganizationSlug); /// The innermost exception, since EF wraps a failing SaveChanges. private static Exception Unwrap(Exception exception) diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs index 4353bfb7..33e38630 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs @@ -11,13 +11,14 @@ using MediatR; using Microsoft.Extensions.DependencyInjection; using Xunit; +using OrganizationAggregate = LearnStack.Modules.Tenancy.Domain.Organization; using TenantAggregate = LearnStack.Modules.Tenancy.Domain.Tenant; namespace LearnStack.Tests.Unit.Modules.Tenancy; /// /// The one operation -/// ADR-0042 +/// ADR-0042 /// sanctions to write two aggregate roots on one transaction: what it writes, in /// what order, and what it refuses before a transaction is ever opened. /// @@ -113,9 +114,9 @@ public async Task The_result_names_both_rows_the_caller_now_has() result.IsSuccess.Should().BeTrue(); var provisioned = result.Value!; - provisioned.TenantId.Should().Be(Tenant.Value); + provisioned.TenantId.Should().Be(Tenant); provisioned.Slug.Should().Be("demo-english"); - provisioned.DefaultOrganizationId.Should().Be(Organization.Value); + provisioned.DefaultOrganizationId.Should().Be(Organization); provisioned.DefaultOrganizationSlug.Should().Be("hq"); } @@ -133,8 +134,14 @@ public async Task Provisioning_is_attributed_to_the_system_actor() await sender.Send(Command()); + // Both roots, because only the organization was captured at first and a tenant + // attributed to an arbitrary user id survived the entire 1166-case suite. + stores.AddedTenants.Should().ContainSingle().Which + .CreatedBy.Should().Be(UserId.SystemActor); stores.Added.Should().ContainSingle().Which .CreatedBy.Should().Be(UserId.SystemActor); + stores.Updated.Should().ContainSingle().Which + .UpdatedBy.Should().Be(UserId.SystemActor); } [Theory] @@ -149,7 +156,7 @@ public void A_command_missing_a_required_name_is_refused_before_any_transaction( DefaultOrganizationSlug = organizationSlug, }; - new ProvisionTenantCommandValidator().Validate(command) + Validator().Validate(command) .IsValid.Should().BeFalse(because); } @@ -167,15 +174,26 @@ public void A_slug_the_aggregate_would_throw_on_is_refused_first( // command, after TransactionBehavior opened a transaction, and after the tenant // was announced on the connection. The shape lives in one place and both layers // read it; this is the layer that answers the caller. - new ProvisionTenantCommandValidator().Validate(Command() with { Slug = slug }) - .IsValid.Should().BeFalse(because); - - // And the aggregate still refuses it, so the validator is the first layer rather + // + // BOTH slugs, because a rule is only as wide as the field it is written on: + // measured, with the organization's shape and width rules deleted the whole + // solution stayed green, and the defect was silently reintroduced for half the + // command. + Refuse(command => command with { Slug = slug }) + .Should().Be("lockey_slug_not_url_safe", because); + Refuse(command => command with { DefaultOrganizationSlug = slug }) + .Should().Be("lockey_slug_not_url_safe", because); + + // And the aggregates still refuse it, so the validator is the first layer rather // than the only one. A test that checked only the validator would pass with the - // factory guard deleted. - var direct = () => TenantAggregate.Create( + // factory guards deleted. + var createTenant = () => TenantAggregate.Create( Tenant, slug, "Demo English", Clock, UserId.SystemActor); - direct.Should().Throw(); + createTenant.Should().Throw(); + + var createOrganization = () => OrganizationAggregate.Create( + Organization, Tenant, slug, "Head Office", Clock, UserId.SystemActor); + createOrganization.Should().Throw(); } [Fact] @@ -184,19 +202,70 @@ public void A_value_wider_than_its_column_is_refused_first() // Same shape of defect, different guard: over the mapped width the factory // throws, and without a rule here that throw is a 500 raised after the // announcement. The two layers read one constant, so this cannot drift. - var validator = new ProvisionTenantCommandValidator(); - - validator.Validate(Command() with { Slug = new string('a', UrlSlug.MaxLength + 1) }) - .IsValid.Should().BeFalse(); - validator.Validate(Command() with - { - DisplayName = new string('a', MappedLength.DisplayName + 1), - }).IsValid.Should().BeFalse(); + var wideSlug = new string('a', UrlSlug.MaxLength + 1); + var wideName = new string('a', MappedLength.DisplayName + 1); + + Refuse(command => command with { Slug = wideSlug }) + .Should().Be("lockey_slug_too_long"); + Refuse(command => command with { DefaultOrganizationSlug = wideSlug }) + .Should().Be("lockey_slug_too_long"); + Refuse(command => command with { DisplayName = wideName }) + .Should().Be("lockey_display_name_too_long"); + Refuse(command => command with { DefaultOrganizationDisplayName = wideName }) + .Should().Be("lockey_display_name_too_long"); // The bound itself is legal — an off-by-one here would refuse a value the // column holds, which no test asserting only the refusal would catch. - validator.Validate(Command() with { Slug = new string('a', UrlSlug.MaxLength) }) + Validator().Validate(Command() with { Slug = new string('a', UrlSlug.MaxLength) }) .IsValid.Should().BeTrue(); + Validator().Validate(Command() with + { + DisplayName = new string('a', MappedLength.DisplayName), + }).IsValid.Should().BeTrue(); + } + + [Fact] + public void Each_refusal_reaches_the_caller_as_its_own_key() + { + // ValidationBehavior builds the response from `failure.ErrorCode ?? + // failure.ErrorMessage`, and FluentValidation ALWAYS populates ErrorCode with the + // validator's own name. Measured on the first shape of this validator: a + // malformed slug and a tenant sharing its organization's id both arrived as + // `lockey_predicatevalidator`, and every string passed to WithMessage was + // discarded unread. Distinct keys are the whole of what a caller can act on. + var keys = new[] + { + Refuse(command => command with { Slug = "" }), + Refuse(command => command with { Slug = new string('a', UrlSlug.MaxLength + 1) }), + Refuse(command => command with { Slug = "Demo-English" }), + Refuse(command => command with { DisplayName = "" }), + Refuse(command => command with + { + DisplayName = new string('a', MappedLength.DisplayName + 1), + }), + Refuse(command => command with + { + DefaultOrganizationId = OrganizationId.From(Tenant.Value), + }), + }; + + keys.Should().OnlyHaveUniqueItems("two refusals a caller must tell apart"); + keys.Should().AllSatisfy(key => key.Should().StartWith("lockey_") + .And.NotBe("lockey_predicatevalidator")); + } + + [Fact] + public void A_cross_field_refusal_names_a_field() + { + // `RuleFor(command => command)` yields the empty property name, and the + // RFC 7807 `errors` map is keyed on it — an error under a "" key names nothing a + // client can highlight. + var failure = Validator().Validate(Command() with + { + DefaultOrganizationId = OrganizationId.From(Tenant.Value), + }).Errors.Should().ContainSingle().Subject; + + failure.PropertyName.Should().Be(nameof(ProvisionTenantCommand.DefaultOrganizationId)); } [Fact] @@ -206,8 +275,7 @@ public void A_null_slug_is_refused_rather_than_thrown_on() // that. Without Cascade(Stop) the shape predicate runs anyway and the regex // throws ArgumentNullException out of the validator — the same 500, moved one // step earlier. - var refuse = () => new ProvisionTenantCommandValidator() - .Validate(Command() with { Slug = null! }); + var refuse = () => Validator().Validate(Command() with { Slug = null! }); refuse.Should().NotThrow(); refuse().IsValid.Should().BeFalse(); @@ -228,7 +296,7 @@ public void A_tenant_and_its_default_organization_may_not_share_an_id() DefaultOrganizationId = OrganizationId.From(shared), }; - new ProvisionTenantCommandValidator().Validate(command) + Validator().Validate(command) .IsValid.Should().BeFalse(); } @@ -237,7 +305,7 @@ public void A_well_formed_command_passes() { // Every case above is a refusal, and a validator that refused everything would // satisfy all of them. - new ProvisionTenantCommandValidator().Validate(Command()) + Validator().Validate(Command()) .IsValid.Should().BeTrue(); } @@ -248,12 +316,26 @@ public void The_validator_is_discoverable_where_the_pipeline_scans_for_it() // registration is an assembly scan with `includeInternalTypes`. A validator // that exists but is not found is silence: the command runs unvalidated and // the cross-field rule above never executes in production. - Build().Sender.Should().NotBeNull(); - BuildProvider().GetService>() - .Should().BeOfType(); + .Should().NotBeNull() + .And.Subject.GetType().Name.Should().Be("ProvisionTenantCommandValidator", + "the scan passes includeInternalTypes, and the validator is internal for " + + "the same reason the handler is"); } + /// The single error code one refused command produces. + private static string Refuse( + Func mutate) + { + var result = Validator().Validate(mutate(Command())); + + result.IsValid.Should().BeFalse(); + return result.Errors.Should().ContainSingle().Subject.ErrorCode; + } + + private static IValidator Validator() => + BuildProvider().GetRequiredService>(); + private static (ISender Sender, IReadOnlyList Writes) Build() => Build(out _); @@ -272,7 +354,7 @@ private static ServiceProvider BuildProvider(out RecordingStores stores) // MediatR and FluentValidation scan the same assembly the composition root // hands them, so a handler or validator this container cannot find is one the // application cannot find either. - var applicationAssembly = typeof(ProvisionTenantCommandValidator).Assembly; + var applicationAssembly = typeof(ITenantWriteStore).Assembly; var recording = new RecordingStores(); stores = recording; @@ -304,12 +386,15 @@ private sealed class RecordingStores : ITenantWriteStore, IOrganizationWriteStor public List Added { get; } = []; + public List AddedTenants { get; } = []; + public List Updated { get; } = []; Task IAggregateWriteStore.AddAsync( TenantAggregate aggregate, CancellationToken cancellationToken) { _writes.Add("tenant:add"); + AddedTenants.Add(aggregate); return Task.CompletedTask; } diff --git a/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md b/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md index 78c0e99e..e5b322a2 100644 --- a/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md +++ b/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md @@ -219,6 +219,20 @@ containment either. - **The carve-out** is one line in [Architecture Standards § Aggregate Ownership](../standards/01-architecture-standards.md), citing this ADR. The rule text itself is unchanged. +> **Erratum — 2026-09-03.** The bullet below says the test "scans MediatR handler +> sources for write calls (`Add`/`AddRange`/`Update`/`Remove`) against more than +> one `DbSet`". That scan cannot fire, and could not on the day this was written: +> a module's `Application` project may not reference its `Infrastructure` project, +> so no handler can name a `DbSet` at all. Shown by +> [Architecture Standards § Dependency direction](../standards/01-architecture-standards.md), +> which lists the edge as forbidden, and by the project graph — `Infrastructure` +> references `Application`, so the reverse is a cycle the compiler refuses. The +> shipped rule counts the distinct aggregate roots reachable through a handler's +> `IAggregateWriteStore` constructor parameters. The Decision is +> unchanged. Current authority: +> [the catalogue row](../standards/21-architecture-tests-catalogue.md). Recorded in +> Amendment 1. + - **The architecture test** is `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning`. The **rule** is registered in [the catalogue](../standards/21-architecture-tests-catalogue.md) @@ -231,12 +245,6 @@ containment either. implements `IAggregateRoot`, and holds a literal allow-list of exactly one handler type. - > **Erratum (2026-09-03, Amendment 1).** The scan described in the sentence - > above cannot fire, and could not on the day this was written: `Application` - > may not reference `Infrastructure`, so no handler can name a `DbSet`. The - > shipped rule counts constructor parameters deriving from - > `IAggregateWriteStore` instead. See § Amendments. - **What it proves and what it does not.** It catches the direct form, which is the form a handler is written in. It does not catch a write routed through a repository, a helper or a second `DbContext` reached indirectly — the same @@ -251,30 +259,50 @@ containment either. ## Amendments -### Amendment 1 (2026-09-03): the rule counts ports, not `DbSet` use +### Amendment 1 (2026-09-03): the rule counts aggregate roots, not `DbSet` use **What was false when it entered the record.** § Implementation Notes specifies `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` as a source scan for `Add` / `AddRange` / `Update` / `Remove` against more than one `DbSet`. That scan -can never fire. `Application` may not reference `Infrastructure` — a shipped -dependency rule that predates this ADR — so no handler can name a `DbSet` at all, -and a rule that cannot fire while carrying Status **Implemented** claims coverage -the suite does not have. An inline erratum marks the sentence; the rationale, the -carve-out and everything else in the record stand. - -**What ships.** The rule reflects over the production assemblies and counts, per -`IRequestHandler<,>` implementation, the constructor parameters deriving from -`IAggregateWriteStore`. More than one is the cross-aggregate write, -and the literal allow-list holds exactly one name: -`ProvisionTenantCommandHandler`. Counting a **type** rather than a name means -renaming a port does not escape the rule, and fusing the two ports into one to -hide the write is itself caught — measured, as one of three mutations that each -turn the rule red. +can never fire: a module's `Application` project may not reference its +`Infrastructure` project — a forbidden edge that predates this ADR, and a project +cycle besides — so no handler can name a `DbSet` at all. A rule that cannot fire +while carrying Status **Implemented** claims coverage the suite does not have. An +inline erratum marks the bullet; the rationale, the carve-out and everything else +in the record stand. + +**What ships.** The rule reflects over the production assemblies and, for each +type implementing `IRequestHandler<,>`, `IRequestHandler<>` or +`INotificationHandler<>`, counts the **distinct aggregate roots** reachable +through its constructor parameters' `IAggregateWriteStore` +derivations. More than one is the cross-aggregate write, and the literal +allow-list holds exactly one name: `ProvisionTenantCommandHandler`. + +Each element of that sentence closes an escape that was measured by putting the +shape into a production assembly and watching all 76 architecture cases pass: + +- **Distinct roots, not parameters.** One interface deriving from the generic + twice — `IFused : IAggregateWriteStore, + IAggregateWriteStore` — is a single constructor + parameter. Counting parameters, a handler taking it wrote both roots and the + rule stayed green. +- **`INotificationHandler` is in the set.** An intra-module domain event is one of + ADR-0010's four sanctioned mechanisms and its handler runs *inside* the ambient + transaction, so two write ports there are the same cross-aggregate write — and + the likelier one to be written by someone who has not read this ADR, because a + domain-event handler does not look like a write boundary. +- **Non-public constructors count.** `Type.GetConstructors()` is public-only. **Why two ports rather than one.** `ITenantWriteStore` and `IOrganizationWriteStore` are separate interfaces deliberately. A single fused -port would have been less code and would have hidden the very thing this ADR -exists to enumerate. +port would have been less code, and while the rule now sees through it, the +separation is what makes the sanctioned write legible at the constructor. + +**What it still does not catch.** A write routed through a helper that itself +holds two ports, or through a second `DbContext` reached indirectly — the same +limit [the catalogue](../standards/21-architecture-tests-catalogue.md) states for +every structural rule. The binding control remains that the allow-list has one +entry and growing it is a reviewed diff. **What did not change.** The sanctioned operation, its three statements, the one-entry allow-list, and the seeder's obligation to invoke the command rather diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 977f3666..67934d60 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -163,6 +163,7 @@ sequenceDiagram participant T as TransactionBehavior participant H as Handler participant U as IUnitOfWork + participant S as IUnitOfWorkScope participant D as TenancyDbContext participant P as PostgreSQL @@ -170,23 +171,24 @@ sequenceDiagram T->>U: BeginTransactionAsync U->>P: BEGIN T->>U: SetProvisioningTenantContextAsync(command.TenantId) - U->>P: SELECT set_config('app.tenant_id', , true) + U->>P: SELECT set_config('app.tenant_id', , true),
set_config('app.organization_id', '', true) T->>H: next() H->>D: INSERT tenants D->>P: WITH CHECK passes — app.tenant_id already equals id H->>D: INSERT organizations (default) H->>D: UPDATE tenants SET default_organization_id - T->>U: CompleteAsync - U->>P: COMMIT + T->>S: CompleteAsync + S->>P: COMMIT ``` Text fallback — **provisioning a tenant**: the registry (Hub, config or fixture) sends a `ProvisionTenantCommand`; `TransactionBehavior` opens the ambient -transaction, announces `app.tenant_id` as the tenant being created — the first -statement inside it — and calls the handler; the handler inserts `tenants`, +transaction, announces `app.tenant_id` as the tenant being created and blanks +`app.organization_id` in the same statement — the first inside the transaction — +and calls the handler; the handler inserts `tenants`, inserts the default `organizations` row, updates -`tenants.default_organization_id`, and returns; the behavior commits. One -transaction, one connection, one commit point. +`tenants.default_organization_id`, and returns; the behavior completes the scope, +which commits. One transaction, one connection, one commit point. **The handler opens nothing and announces nothing.** Both belong to `TransactionBehavior`, and the distinction is not stylistic. A @@ -200,15 +202,24 @@ Three statements, one transaction. The tenant id is **never minted in the handler**: the registry assigns it, and the behavior announces it *before* the insert by reading `IProvisionsTenant` off the request, so the self-keyed policy's `WITH CHECK` passes. A handler that generated its own could not satisfy its own -policy. Measured against the shipped policies: with `app.tenant_id` unset or set -to the empty string the `tenants` insert raises `42501` identically, and only -the new tenant's own id lets the sequence commit. +policy. Measured against the shipped policies on a throwaway container: with +`app.tenant_id` unset or set to the empty string the `tenants` insert raises +`42501` identically, and only the new tenant's own id lets the sequence commit. +Of the two, the empty-string case is the one a test pins — +`A_request_that_does_not_provision_still_fails_closed_when_unresolved` — because +it is the state this pipeline actually produces; nothing in the request path +leaves the variable unset. The announcement requires an **unresolved** context, which is what closes the confused deputy: a caller already authenticated for tenant A who sends a provisioning command naming tenant B takes the ordinary path, the transaction carries A, and B's insert is refused by the database rather than by a check -somebody has to remember to write. +somebody has to remember to write. Nothing verifies that the requested id is +unused, and the guarantee does not need it to be: the only statement the +announcement authorises is an `INSERT` the primary key rejects when the tenant +already exists. Anything later added inside that transaction — the MUST-class +audit write, the outbox flush — inherits that assumption and must not rely on +the announced tenant being new. The `default_organization_id` update is separate because the composite foreign key has nothing to reference until the organization exists; `MATCH SIMPLE` skips @@ -248,8 +259,9 @@ resolver reads `platform_host_to_tenant` and nothing else. flowchart LR subgraph Tenancy DOM[Domain
Tenant, Organization] - APP[Application] - INF[Infrastructure
TenancyDbContext] + CON[Application.Contracts
ProvisionTenantCommand] + APP[Application
handler, validator,
ITenantWriteStore, IOrganizationWriteStore] + INF[Infrastructure
TenancyDbContext,
TenantWriteStore, OrganizationWriteStore] end SK[SharedKernel
TenantId, OrganizationId, IUnitOfWork] CORE[Core Infrastructure
TenantScopedDbContext] @@ -258,17 +270,21 @@ flowchart LR OTHER[Other modules] DOM --> SK + CON --> SK + APP --> CON APP --> DOM INF --> APP INF --> CORE INF --> PG HUB -.-> APP - OTHER -.->|application contract only| APP + OTHER -.->|application contract only| CON ``` -Text fallback — **components**: Tenancy is three assemblies — `Domain` (the -`Tenant` and `Organization` aggregates), `Application`, and `Infrastructure` -(`TenancyDbContext`). `Domain` depends on `SharedKernel` for `TenantId`, +Text fallback — **components**: Tenancy is four assemblies — `Domain` (the +`Tenant` and `Organization` aggregates), `Application.Contracts` +(`ProvisionTenantCommand`, the first type to land there), `Application` (its +handler, its validator, and the `ITenantWriteStore` / `IOrganizationWriteStore` +ports) and `Infrastructure` (`TenancyDbContext` and the two write stores). `Domain` depends on `SharedKernel` for `TenantId`, `OrganizationId` and `IUnitOfWork`; `Application` on `Domain`; `Infrastructure` on `Application`, on core `LearnStack.Infrastructure` — where `TenantScopedDbContext`, the base `TenancyDbContext` derives from, applies the diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 5f4c1234..fbde6fe4 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -668,31 +668,38 @@ otherwise). #### `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` -- **Asserts:** no MediatR handler takes more than one constructor parameter deriving - from `IAggregateWriteStore`, except the single handler on a literal - allow-list — `ProvisionTenantCommandHandler`, which writes `Tenant` and its default - `Organization` in one transaction per ADR-0042. +- **Asserts:** no type implementing `IRequestHandler<,>`, `IRequestHandler<>` or + `INotificationHandler<>` can reach more than one **distinct aggregate root** through + its constructor parameters' `IAggregateWriteStore` derivations, except the + single handler on a literal allow-list — `ProvisionTenantCommandHandler`, which writes + `Tenant` and its default `Organization` in one transaction per ADR-0042. - **Source:** [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md); [Architecture Standards § Aggregate Ownership](01-architecture-standards.md). - **Type:** xUnit + reflection. **Kind:** structural. - **Status:** **Implemented.** - **Phase:** 02a Packet 7. -- **Note:** the rule counts **ports**, not `DbSet` use, and the change is not - cosmetic. ADR-0042 § Implementation Notes specified a source scan for `Add` / - `Update` / `Remove` against more than one `DbSet`; under the shipped dependency - rules that scan can never fire, because `Application` may not reference - `Infrastructure` and so no handler can name a `DbSet` at all. A rule at - **Implemented** that cannot fire is worse than one at **Registered**, because the - catalogue then claims coverage the suite does not have. Counting a type also - survives renaming: `ITenantWriteStore` and `IOrganizationWriteStore` are matched by - their derivation, not their names. +- **Note:** the rule counts **aggregate roots reached through ports**, not `DbSet` + use, and the change is not cosmetic. ADR-0042 § Implementation Notes specified a + source scan for `Add` / `Update` / `Remove` against more than one `DbSet`; under the + shipped dependency rules that scan can never fire, because a module's `Application` + may not reference its `Infrastructure` and so no handler can name a `DbSet` at all. A + rule at **Implemented** that cannot fire is worse than one at **Registered**, because + the catalogue then claims coverage the suite does not have. See + [ADR-0042 Amendment 1](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md). +- **Three escapes closed by measurement,** each found by putting the shape into a + production assembly and watching all 76 cases pass: a **fused port** + (`IFused : IAggregateWriteStore, IAggregateWriteStore`) is one parameter + reaching two roots, so roots are counted rather than parameters; an + **`INotificationHandler`** runs inside the ambient transaction and is the same write, + so it is in the handler set; and `Type.GetConstructors()` is public-only, so the + scan passes `NonPublic`. - **What it does not catch:** a write routed through a helper that itself holds two ports, or through a second `DbContext` reached indirectly — the same limit - [§ What a structural test proves](#what-a-structural-test-proves--and-what-it-does-not) states for every - structural rule. The binding control is that the allow-list has one entry and - growing it is a reviewed diff. -- **Mutation-checked.** Three mutations, three failures: a second handler taking two - write ports, the sanctioned handler renamed, and the two ports fused into one. + [§ What a structural test proves](#what-a-structural-test-proves--and-what-it-does-not) + states for every structural rule. The binding control is that the allow-list has one + entry and growing it is a reviewed diff. +- **Mutation-checked.** A second handler taking two write ports, the sanctioned handler + renamed, and the two ports fused into one — each turns the rule red. ### Persistence: concurrency and the unit of work From 91db8482f2abced9c2b34d70048ce8c98223d756 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 10:32:39 +0300 Subject: [PATCH 33/55] fix(tenancy): keep a provisioning conflict a 409 through the status map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 of Step 9's review. The first round's own fix was the worst finding. Translating 23505 into four module-specific error codes made a duplicate slug WORSE, not better. HttpStatusMap.For is a closed table of cross-cutting codes and falls through to InternalServerError, so `tenant_slug_taken` answered 500 — while the generic `business_rule_violation` the round replaced maps to 409. Nothing caught it because every test asserted the Result at the handler, and the status is decided one layer further out than the tests reached. The shape is now the one ValidationBehavior already uses: a canonical top-level code that maps, plus per-field details carrying the specificity. A module does not grow the global status table for its own vocabulary. Both integration cases now assert HttpStatusMap.For(error.Code) rather than the key alone, which is the leg that was missing. Also from the round: the shared write port carried two stacked tags, a leftover from the previous fix on the one persistence port every module will inherit. And the handler's comment claimed the transaction is aborted after a 23505 — it is not, and ADR-0040 § Frames, not savepoints already says why: EF wraps every SaveChanges inside a supplied transaction in a real SAVEPOINT. Confirmed by issuing a statement on the transaction after the catch. Two claims elsewhere went stale when ProvisionTenantCommand landed and were not updated with it: the marker attribute's "it ships with no users", and the catalogue's reason for one vacuous clause. Module: Tenancy ADR: 0042, 0040 Co-Authored-By: Claude Opus 5 (1M context) --- .../Persistence/IAggregateWriteStore.cs | 1 - .../Tenancy/RequestSurfaceMarkers.cs | 9 ++- .../Tenant/ProvisionTenantCommandHandler.cs | 73 ++++++++++++++----- .../Database/TenantProvisioningTests.cs | 32 +++++--- .../21-architecture-tests-catalogue.md | 6 +- 5 files changed, 86 insertions(+), 35 deletions(-) diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs b/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs index f3c96586..ad2b0649 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs @@ -46,7 +46,6 @@ public interface IAggregateWriteStore /// Persists a newly created aggregate. Task AddAsync(TRoot aggregate, CancellationToken cancellationToken = default); - /// Persists a change to an aggregate already stored. /// Persists changes to an aggregate this scope already tracks. /// /// Tracked, and an implementation may refuse anything else. Under diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs b/backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs index e53f1835..22e98c8e 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs @@ -23,10 +23,11 @@ namespace LearnStack.SharedKernel.Tenancy; /// hostname. /// /// -/// It ships with no users. The first is ProvisionTenantCommand, in -/// Packet 7 step 9 — there is not one production request type in the solution today. -/// The marker lands ahead of it because the behavior that reads it lands now, and a -/// predicate with no attribute to look for is the stub this replaces. +/// It has exactly one user. ProvisionTenantCommand, from Packet 7 step 9, +/// and AllowsUnresolvedTenantContext_Only_On_Provisioning_Commands holds the +/// allow-list at that one name. The marker shipped ahead of it, in the packet that +/// wrote the behavior reading it, because a predicate with no attribute to look for is +/// the stub this replaces. /// /// [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs index ba881d09..342d40f4 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs @@ -71,11 +71,23 @@ public async Task> Handle( } catch (AggregateConflictException conflict) { - // The transaction is aborted at this point, so nothing may be issued on it — - // returning a failure is what makes TransactionBehavior roll it back rather - // than try to commit. + // The transaction is still usable, not aborted: EF wraps every SaveChanges + // that runs inside a supplied transaction in a real SAVEPOINT, so a failed one + // rolls back to its savepoint and leaves the ambient transaction alive — + // [ADR-0040 § Frames, not savepoints](../../../../../../docs/decisions/0040-ambient-unit-of-work.md), + // confirmed by issuing a statement on it after this catch. Returning a failure + // is nonetheless the whole of what happens here: TransactionBehavior calls + // FailAsync on a failure response, and a partially provisioned tenant must not + // survive on the strength of the transaction still being open. + var (field, reason) = ConflictFor(conflict.ConstraintName); + return Result.FailFor>( - new Error(new LocalizedMessage(ConflictKeyFor(conflict.ConstraintName)))); + new Error( + new LocalizedMessage("lockey_business_rule_violation"), + new Dictionary>(StringComparer.Ordinal) + { + [field] = [new LocalizedMessage(reason)], + })); } } @@ -115,22 +127,45 @@ private async Task> ProvisionAsync( } /// - /// Which uniqueness the caller collided with, as a localization key. + /// Which field collided, and why, as an RFC 7807 errors entry. /// /// - /// By constraint name rather than by a generic "already exists", because the two - /// halves of this command fail for different reasons and a caller retrying blindly on - /// the wrong one never succeeds: a taken slug needs a different slug, a duplicate id - /// needs a different id. An unrecognised constraint falls back to the generic key - /// rather than to a 500 — a new unique index is not a reason to start crashing. + /// + /// The top-level code stays business_rule_violation, and the specificity + /// lives in the details. That is the shape ValidationBehavior already + /// uses — one canonical code plus a per-field map — and the reason is not symmetry: + /// HttpStatusMap is a closed table of cross-cutting codes, and a code missing + /// from it falls through to 500. Four module-specific keys at the top level + /// were measured doing exactly that, which made a "slug taken" answer *worse* than + /// the generic one it replaced — business_rule_violation maps to 409. + /// A module does not get to grow the global status table for its own vocabulary. + /// + /// + /// By constraint name rather than a bare "already exists", because the two halves of + /// this command fail for different reasons and a caller retrying blindly on the wrong + /// one never succeeds: a taken slug needs a different slug, a duplicate id a different + /// id. An unrecognised constraint names no field and says only that something is + /// taken — a new unique index is not a reason to start crashing. + /// /// - private static string ConflictKeyFor(string? constraintName) => constraintName switch - { - "ux_tenants_slug" => "lockey_tenant_slug_taken", - "pk_tenants" => "lockey_tenant_already_exists", - "ux_organizations_tenant_id_slug" => "lockey_organization_slug_taken", - "pk_organizations" or "ux_organizations_tenant_id_id" => - "lockey_organization_already_exists", - _ => "lockey_business_rule_violation", - }; + private static (string Field, string Reason) ConflictFor(string? constraintName) => + constraintName switch + { + "ux_tenants_slug" => + (nameof(ProvisionTenantCommand.Slug), "lockey_slug_taken"), + "pk_tenants" => + (nameof(ProvisionTenantCommand.TenantId), "lockey_identifier_taken"), + "pk_organizations" or "ux_organizations_tenant_id_id" => + (nameof(ProvisionTenantCommand.DefaultOrganizationId), "lockey_identifier_taken"), + + // Unreachable on this command's own write order — the organization is inserted + // under a tenant created moments earlier in the same transaction, so the + // composite key's tenant half is always fresh. Kept because the port it goes + // through is shared: the second caller of IOrganizationWriteStore.AddAsync + // will not be provisioning, and this is the arm it needs. + "ux_organizations_tenant_id_slug" => + (nameof(ProvisionTenantCommand.DefaultOrganizationSlug), "lockey_slug_taken"), + + _ => ("$", "lockey_business_rule_violation"), + }; } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs index 50cc03eb..cfe5b36c 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using LearnStack.Api.Composition; +using LearnStack.Api.Common; using LearnStack.Application.Pipeline; using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Application.Abstractions; @@ -12,6 +13,7 @@ using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Time; using MediatR; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -118,9 +120,11 @@ public async Task A_provisioning_that_fails_part_way_commits_nothing() var result = await ProvisionAsync(command); result.IsFailure.Should().BeTrue(); - result.Error!.Message.Key.Should().Be("lockey_organization_already_exists", + result.Error!.Details.Should().ContainKey( + nameof(ProvisionTenantCommand.DefaultOrganizationId), "the collision is on the organization's key, which proves the tenant " + "insert had already gone through when the second write was refused"); + HttpStatusMap.For(result.Error.Code).Should().Be(StatusCodes.Status409Conflict); (await ScalarAsPlatformAsync( "SELECT count(*) FROM tenants WHERE id = @id", command.TenantId.Value)) @@ -135,10 +139,10 @@ public async Task A_provisioning_that_fails_part_way_commits_nothing() } [Theory] - [InlineData("slug", "lockey_tenant_slug_taken")] - [InlineData("id", "lockey_tenant_already_exists")] + [InlineData("slug", "Slug", "lockey_slug_taken")] + [InlineData("id", "TenantId", "lockey_identifier_taken")] public async Task A_name_already_taken_is_an_answer_rather_than_a_crash( - string collideOn, string expectedKey) + string collideOn, string expectedField, string expectedReason) { // Reusing a slug is an ordinary thing a caller does, and untranslated it is a 500: // neither DbUpdateException nor PostgresException has an arm in HttpStatusMap, so @@ -161,11 +165,21 @@ public async Task A_name_already_taken_is_an_answer_rather_than_a_crash( var result = await ProvisionAsync(second); result.IsFailure.Should().BeTrue("a taken name is a refusal, not a fault"); - result.Error!.Message.Key.Should().Be(expectedKey, - "a caller retrying blindly on the wrong half never succeeds — a taken slug " - + "needs a different slug, a duplicate id a different id"); - result.Error.Code.Should().Be( - collideOn == "slug" ? "tenant_slug_taken" : "tenant_already_exists"); + + // The top-level code is what decides the HTTP status, and this is the leg + // nothing checked before: four module-specific keys at the top level were + // measured falling through HttpStatusMap's closed table to 500, which made a + // "slug taken" answer worse than the generic one it replaced. + HttpStatusMap.For(result.Error!.Code).Should().Be( + StatusCodes.Status409Conflict, + "a code absent from the map falls through to 500, and a module does not " + + "grow the global table for its own vocabulary"); + + // The specificity lives in the details, keyed by the field that collided — + // a caller retrying blindly on the wrong half never succeeds. + result.Error.Details.Should().ContainKey(expectedField) + .WhoseValue.Should().ContainSingle() + .Which.Key.Should().Be(expectedReason); (await ScalarAsPlatformAsync( "SELECT count(*) FROM tenants WHERE id = @id", second.TenantId.Value)) diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index fbde6fe4..0238918c 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -2229,8 +2229,10 @@ structural test proves — and what it does not. nothing exercises a *permitted* entry, and the gate refusing everyone blocks nothing this packet ships — Packet 9's GDPR redaction is the first real caller and inherits it. - *Marker clause, vacuous:* no handler carries both `[AllowsUnresolvedTenantContext]` and - a platform-scope entry, because no production request type carries either. + *Marker clause, still vacuous — but for a narrower reason since Packet 7 step 9:* no + handler carries both `[AllowsUnresolvedTenantContext]` and a platform-scope entry. + `ProvisionTenantCommand` now carries the first, and nothing carries the second, so the + conjunction is empty because one half of it is — not because both are. #### `Development_Only_Tenant_Header_Override_Is_Mode_Guarded` From 1646d72732aeddae48584f24c63f82aa54e540db Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 10:46:53 +0300 Subject: [PATCH 34/55] feat(tenancy): seed two demo tenants through the real command path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 10. demo-english and demo-yoga, each with two organizations and one host row, written by LearnStack.Tools.Seeder — a second composition root with no HTTP surface, sending the same commands a request sends. ADR-0042 requires that: a seeder inserting the tenant and its default organization itself would be a second copy of the one sanctioned cross-aggregate write, and the allow-list keeping that exception at one entry would stop describing the system. Sending the command also makes a successful seed evidence about the request path rather than only about the schema. Two new commands, because provisioning creates only the default organization and nothing could write a host row at all — PlatformHostMapping had no factory. CreateOrganizationCommand and MapHostToTenantCommand both take their tenant from the context, never from the request: a request that named its own tenant on the host mapping would let a caller point another tenant's domain at their content. Each tenant is seeded in two acts under two different contexts — provisioning unresolved, everything after as that tenant. The seeder never assigns ITenantContextAccessor.Current: writes to it are a closed set of four per ADR-0036 Amendment 2, and widening a security enumeration for a development tool is the wrong trade when each act can simply compose a scope around a StaticTenantContextAccessor instead. The first run found its own bug. Assigning the context only when non-null left the previous act's tenant in place, so the second tenant's provisioning ran announced as the first — refused 42501 by the tenants policy, which is the confused-deputy guard working on the seeder. Composing the value makes that state unreachable rather than caught. Three architecture rules saw the new assembly and each was answered on its merits rather than silenced: the connection census gains the seeder as a deliberate fifth entry and now qualifies filenames, since two Program.cs exist under backend/src and a set keyed on bare names would let one hide behind the other's entry. Module: Tenancy ADR: 0042, 0036, 0040 Co-Authored-By: Claude Opus 5 (1M context) --- backend/LearnStack.slnx | 1 + .../PersistenceCompositionExtensions.cs | 1 + .../LearnStack.Tools.Seeder.csproj | 26 +++ .../src/LearnStack.Tools.Seeder/Program.cs | 87 +++++++ .../src/LearnStack.Tools.Seeder/SeedData.cs | 86 +++++++ .../src/LearnStack.Tools.Seeder/SeedHost.cs | 17 ++ .../src/LearnStack.Tools.Seeder/SeedRunner.cs | 214 +++++++++++++++++ .../Tenant/CreateOrganizationCommand.cs | 33 +++ .../Tenant/MapHostToTenantCommand.cs | 44 ++++ .../Abstractions/TenancyWriteStores.cs | 13 ++ .../CreateOrganizationCommandHandler.cs | 102 ++++++++ .../Tenant/MapHostToTenantCommandHandler.cs | 78 +++++++ .../Tenant/TenancyCommandValidators.cs | 70 ++++++ .../PlatformProjections.cs | 68 ++++++ .../Persistence/TenancyWriteStores.cs | 19 +- .../LearnStack.Tests.Architecture.csproj | 5 + .../PersistenceConventionTests.cs | 28 ++- .../Database/SeederTests.cs | 219 ++++++++++++++++++ .../LearnStack.Tests.Integration.csproj | 3 + scripts/seed.sh | 67 +++--- 20 files changed, 1144 insertions(+), 37 deletions(-) create mode 100644 backend/src/LearnStack.Tools.Seeder/LearnStack.Tools.Seeder.csproj create mode 100644 backend/src/LearnStack.Tools.Seeder/Program.cs create mode 100644 backend/src/LearnStack.Tools.Seeder/SeedData.cs create mode 100644 backend/src/LearnStack.Tools.Seeder/SeedHost.cs create mode 100644 backend/src/LearnStack.Tools.Seeder/SeedRunner.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/CreateOrganizationCommand.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/MapHostToTenantCommand.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/CreateOrganizationCommandHandler.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenancyCommandValidators.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs diff --git a/backend/LearnStack.slnx b/backend/LearnStack.slnx index f5998543..01dd0d9a 100644 --- a/backend/LearnStack.slnx +++ b/backend/LearnStack.slnx @@ -10,6 +10,7 @@ + diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 971758d9..c12a6fd0 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -149,6 +149,7 @@ public static IServiceCollection AddLearnStackPersistence( // production handler reaches persistence at all. services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/backend/src/LearnStack.Tools.Seeder/LearnStack.Tools.Seeder.csproj b/backend/src/LearnStack.Tools.Seeder/LearnStack.Tools.Seeder.csproj new file mode 100644 index 00000000..496a0c75 --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/LearnStack.Tools.Seeder.csproj @@ -0,0 +1,26 @@ + + + + Exe + LearnStack.Tools.Seeder + + + + + + + + + + + + + + + + + diff --git a/backend/src/LearnStack.Tools.Seeder/Program.cs b/backend/src/LearnStack.Tools.Seeder/Program.cs new file mode 100644 index 00000000..25ae0668 --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/Program.cs @@ -0,0 +1,87 @@ +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using LearnStack.Tools.Seeder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; + +// The seeder is a second composition root, deliberately minimal: the module's handlers, +// the MediatR pipeline, the ambient unit of work and the module context. It shares those +// with the API because sharing them is the point — a seed that ran through its own write +// path would prove nothing about the request path. It shares nothing else; there is no +// HTTP surface here to configure. + +// `--connection-string ` wins; otherwise the same environment variable the compose +// stack and `.env.example` already define, so `make seed` needs no new configuration. +// Read from the flag or the environment, and NOT through IConfiguration's +// GetConnectionString: exactly one file in the solution reads credentials that way, and +// Platform_DataSource_Resolved_Only_By_PlatformAdminScope keeps it that way so one file +// decides what is done with them. A console tool taking an explicit argument needs no +// configuration stack at all. +var connectionString = ConnectionStringFrom(args) + ?? Environment.GetEnvironmentVariable("ConnectionStrings__Default"); + +if (string.IsNullOrWhiteSpace(connectionString)) +{ + Console.Error.WriteLine( + "seed: no connection string. Pass --connection-string, or set " + + "ConnectionStrings__Default (see .env.example)."); + return 2; +} + +// One data source for the whole run, shared by every per-act provider below: a seeder +// that opened a pool per command would spend more time connecting than writing. +var dataSource = NpgsqlDataSource.Create(connectionString); + +// A provider per act, each composed around the context that act runs under. The seeder +// never assigns ITenantContextAccessor.Current — writes to that member are a closed set +// of four (ADR-0036 Amendment 2), and a development tool is not a reason to widen a +// security enumeration when composition costs nothing. +ServiceProvider Compose(ITenantContext? context) +{ + var services = new ServiceCollection(); + + services.AddSingleton(dataSource); + services.AddLogging(logging => logging.AddSimpleConsole()); + services.AddSingleton(); + services.AddSingleton(new StaticTenantContextAccessor(context)); + services.AddTransient(provider => + provider.GetRequiredService().Current + ?? UnresolvedTenantContext.Instance); + + services.AddScoped(); + services.AddModuleDbContext(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddLearnStackMediatRPipeline(typeof(ITenantWriteStore).Assembly); + + return services.BuildServiceProvider(); +} + +using var loggerFactory = LoggerFactory.Create(logging => logging.AddSimpleConsole()); +var logger = loggerFactory.CreateLogger(); + +try +{ + return await new SeedRunner(Compose, logger).RunAsync(CancellationToken.None); +} +catch (Exception failure) +{ + // Non-zero, and the message on stderr: `make seed` is a gate, and a seeder that + // reported success after failing would hand the next step a database it cannot use. + SeedLog.Failed(logger, failure); + Console.Error.WriteLine($"seed: {failure.Message}"); + return 1; +} + +static string? ConnectionStringFrom(string[] args) +{ + var flag = Array.IndexOf(args, "--connection-string"); + return flag >= 0 && flag + 1 < args.Length ? args[flag + 1] : null; +} diff --git a/backend/src/LearnStack.Tools.Seeder/SeedData.cs b/backend/src/LearnStack.Tools.Seeder/SeedData.cs new file mode 100644 index 00000000..d5ec646b --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/SeedData.cs @@ -0,0 +1,86 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.Tools.Seeder; + +/// +/// The two demo tenants, in domains chosen to be unrelated. +/// +/// +/// +/// Two, and in unrelated domains, is the point rather than a convenience. LearnStack +/// claims one binary and one schema serve a language school and a yoga studio, and the +/// claim is only tested by data that differs. A second tenant in the same domain would +/// exercise isolation and nothing else; these exercise +/// [the genericity boundary](../../../docs/architecture/01-platform-vision.md) as well. +/// +/// +/// The ids are fixed literals, not generated. Re-running the seeder has to land on +/// the same rows or it is not idempotent, and a fixed id is what lets the second run +/// recognise its own first. They are version-7 shaped so they sort like every other +/// identifier in the system. +/// +/// +/// Each tenant gets two organizations, because an organization is where +/// organization-scoped isolation is actually observable: one is the tenant's own default, +/// created by provisioning, and the second is what makes +/// Org_X_cannot_read_Org_Y_within_TenantA a statement about seeded data rather than +/// about a fixture. +/// +/// +/// One host row each, and deliberately of different classes. `demo-english` maps +/// host → organization and `demo-yoga` maps host → tenant with a null organization, so both +/// live classifications are exercised by the seed rather than only by a test. +/// +/// +public static class SeedData +{ + public static readonly SeedTenant English = new( + TenantId.From(Guid.Parse("01930000-0000-7000-8000-000000000001")), + "demo-english", + "English Hero", + new SeedOrganization( + OrganizationId.From(Guid.Parse("01930000-0000-7000-8000-0000000000a1")), + "kadikoy", + "Kadıköy Branch"), + new SeedOrganization( + OrganizationId.From(Guid.Parse("01930000-0000-7000-8000-0000000000a2")), + "besiktas", + "Beşiktaş Branch"), + "demo-english.learnstack.local", + MapHostToDefaultOrganization: true); + + public static readonly SeedTenant Yoga = new( + TenantId.From(Guid.Parse("01930000-0000-7000-8000-000000000002")), + "demo-yoga", + "Anatolia Yoga", + new SeedOrganization( + OrganizationId.From(Guid.Parse("01930000-0000-7000-8000-0000000000b1")), + "studio-one", + "Studio One"), + new SeedOrganization( + OrganizationId.From(Guid.Parse("01930000-0000-7000-8000-0000000000b2")), + "studio-two", + "Studio Two"), + "demo-yoga.learnstack.local", + MapHostToDefaultOrganization: false); + + public static readonly IReadOnlyList All = [English, Yoga]; +} + +/// Created by provisioning, in the same transaction. +/// Created after, by an ordinary command. +/// +/// Whether the host row carries an organization id. One tenant sets it and one leaves it +/// null, so the seed covers both host classifications. +/// +public sealed record SeedTenant( + TenantId TenantId, + string Slug, + string DisplayName, + SeedOrganization DefaultOrganization, + SeedOrganization SecondOrganization, + string Host, + bool MapHostToDefaultOrganization); + +public sealed record SeedOrganization( + OrganizationId OrganizationId, string Slug, string DisplayName); diff --git a/backend/src/LearnStack.Tools.Seeder/SeedHost.cs b/backend/src/LearnStack.Tools.Seeder/SeedHost.cs new file mode 100644 index 00000000..fde7d697 --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/SeedHost.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Logging; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Tools.Seeder; + +/// Source-generated logging, per the house CA1848 rule. +public static partial class SeedLog +{ + [LoggerMessage(EventId = 7001, Level = LogLevel.Error, Message = "Seeding failed.")] + public static partial void Failed(ILogger logger, Exception exception); +} + +/// The accessor the runner writes between acts. +public sealed class SeedTenantContextAccessor : ITenantContextAccessor +{ + public ITenantContext? Current { get; set; } +} diff --git a/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs b/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs new file mode 100644 index 00000000..df5f173d --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs @@ -0,0 +1,214 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Tools.Seeder; + +/// +/// Writes the two demo tenants by sending the same commands a request would. +/// +/// +/// +/// It sends commands; it does not write rows. +/// [ADR-0042](../../../docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) +/// requires it: a seeder that inserted the tenant and its default organization itself +/// would be a second copy of the one sanctioned cross-aggregate write, and the allow-list +/// that keeps that exception at one entry would no longer describe the system. Sending the +/// command also means the seed exercises the pipeline production uses — validation, the +/// transaction, the announcement — so a seed that succeeds is evidence about the request +/// path and not only about the schema. +/// +/// +/// Each tenant is seeded in two acts, under two different contexts. Provisioning +/// runs unresolved, because the tenant it announces does not exist until it +/// commits. Everything after runs as that tenant: the second organization and the +/// host row are ordinary tenant-owned writes, and their policies check the row against the +/// announcement. Setting the accessor is how a non-request execution says which tenant it +/// is acting for — the same thing a background job does. +/// +/// +/// It never writes ITenantContextAccessor.Current. Writes to that member +/// are a closed, enumerated set of four +/// ([ADR-0036 Amendment 2](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md)), +/// because a writer of it can make work run under a tenant nothing resolved. A seeder +/// legitimately does that — it is the same shape as the Hangfire job activator — but +/// widening a security enumeration for a development tool is the wrong trade when the +/// alternative costs nothing: each act composes a scope around a +/// StaticTenantContextAccessor holding its context, so the seeder constructs +/// a context per unit of work instead of mutating an ambient one, and cannot move +/// the ambient tenant at all. +/// +/// +/// Idempotent by conflict, not by pre-check. Re-running is expected — make seed +/// is documented as safe to repeat — and a "does it exist already?" query cannot be asked: +/// under the provisioning announcement a SELECT over tenants returns no rows +/// by policy. So the seeder writes and treats a uniqueness refusal as "already seeded", +/// which is the same answer with one fewer round trip and no race. +/// +/// +public sealed class SeedRunner( + Func compose, ILogger logger) +{ + public async Task RunAsync(CancellationToken cancellationToken) + { + foreach (var tenant in SeedData.All) + { + await SeedTenantAsync(tenant, cancellationToken); + } + + return 0; + } + + private async Task SeedTenantAsync(SeedTenant tenant, CancellationToken cancellationToken) + { + // Act one, unresolved: the tenant and its default organization, on one + // transaction, announced with the id being created. + await SendAsync( + tenant, + context: null, + new ProvisionTenantCommand( + tenant.TenantId, + tenant.Slug, + tenant.DisplayName, + tenant.DefaultOrganization.OrganizationId, + tenant.DefaultOrganization.Slug, + tenant.DefaultOrganization.DisplayName), + "tenant", + cancellationToken); + + // Act two, as the tenant: writes the policies check against the announcement. + var asTenant = new SeedTenantContext( + tenant.TenantId, tenant.DefaultOrganization.OrganizationId); + + await SendAsync( + tenant, + asTenant, + new CreateOrganizationCommand( + tenant.SecondOrganization.OrganizationId, + tenant.SecondOrganization.Slug, + tenant.SecondOrganization.DisplayName), + "second organization", + cancellationToken); + + await SendAsync( + tenant, + asTenant, + new MapHostToTenantCommand( + tenant.Host, + tenant.MapHostToDefaultOrganization + ? tenant.DefaultOrganization.OrganizationId + : null, + IsActive: true, + IsPubliclyLive: true), + "host mapping", + cancellationToken); + } + + /// + /// Sends one command in a scope composed around . + /// + /// + /// + /// One scope per command, because a scope is one connection and one transaction under + /// [ADR-0040](../../../docs/decisions/0040-ambient-unit-of-work.md), and these three + /// commands are three units of work with different announcements. Sharing a scope + /// would put the second act on the transaction the first already committed. + /// + /// + /// The context is supplied by composition rather than assignment — see the class + /// remarks. A null one is an unresolved context, which is what provisioning needs and + /// what every other act must not get: an earlier version assigned only when non-null, + /// left the previous act's tenant in place, and the SECOND tenant's provisioning ran + /// announced as the FIRST. The database refused it 42501, which is the confused-deputy + /// guard working on the seeder's own bug; composing the value makes the state + /// unreachable rather than caught. + /// + /// + private async Task SendAsync( + SeedTenant tenant, + ITenantContext? context, + IRequest> command, + string what, + CancellationToken cancellationToken) + { + await using var provider = compose(context); + await using var scope = provider.CreateAsyncScope(); + + var result = await scope.ServiceProvider.GetRequiredService() + .Send(command, cancellationToken); + + if (result.IsSuccess) + { + SeedRunnerLog.Seeded(logger, what, tenant.Slug); + return; + } + + // A uniqueness refusal is what a second run looks like, and it is the expected + // outcome of one. Anything else — a validation failure, a policy denial — is a + // seed that did not do its job, and the process exits non-zero on it. + if (result.Error!.Code == "business_rule_violation") + { + SeedRunnerLog.AlreadyPresent(logger, what, tenant.Slug); + return; + } + + throw new InvalidOperationException( + $"Seeding the {what} for '{tenant.Slug}' failed with '{result.Error.Code}'. " + + "The seed is not idempotent past this point; fix the cause and re-run."); + } +} + +/// Source-generated logging, per the house CA1848 rule. +public static partial class SeedRunnerLog +{ + [LoggerMessage(EventId = 7002, Level = LogLevel.Information, + Message = "Seeded {What} for {Slug}.")] + public static partial void Seeded(ILogger logger, string what, string slug); + + [LoggerMessage(EventId = 7003, Level = LogLevel.Information, + Message = "{What} for {Slug} already present; leaving it alone.")] + public static partial void AlreadyPresent(ILogger logger, string what, string slug); +} + +/// +/// The tenant the seeder is currently acting for. +/// +/// +/// UserId is null, so every write is attributed to UserId.SystemActor by the +/// handlers — which is correct and not a shortcut: there is no user in a tenant the seeder +/// just created, and [Audit Coverage](../../../docs/standards/18-audit-coverage.md) puts +/// non-request execution under an actor of type system. +/// +public sealed class SeedTenantContext(TenantId tenantId, OrganizationId organizationId) + : ITenantContext +{ + public bool IsResolved => true; + + /// + /// — the origin for execution with no + /// request behind it. + /// + /// + /// Not decoration: TenantContextBehavior's second gate switches over stated + /// origins and fails closed on null, so a context that omitted this is refused + /// with the same 404 an unresolvable host gets — measured, as the seeder's first run. + /// Ambient is the same value EventTenantContext states, and for the same + /// reason: an integration-event consumer and a seeder are both LearnStack acting for a + /// tenant with no caller to authenticate. + /// + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + + public TenantId TenantId => tenantId; + + public OrganizationId? OrganizationId => organizationId; + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => "tenancy"; +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/CreateOrganizationCommand.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/CreateOrganizationCommand.cs new file mode 100644 index 00000000..d1f09d09 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/CreateOrganizationCommand.cs @@ -0,0 +1,33 @@ +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.Tenant; + +/// +/// Adds an organization — a branch, campus or studio — to a tenant that already exists. +/// +/// +/// +/// Not marked [AllowsUnresolvedTenantContext], and the omission is the design. +/// Unlike provisioning, this command writes into a tenant that is already resolvable, so +/// it runs under the ordinary announcement and the organization's tenant column is checked +/// against it by the policy. A caller authenticated for tenant A cannot add an +/// organization to tenant B, and nothing in this file has to remember that — the database +/// does. +/// +/// +/// One aggregate, one port. Provisioning is the single operation +/// [ADR-0042](../../../../../../docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) +/// permits to write two roots at once. This writes one, which is why it is an ordinary +/// command and not a second entry on that allow-list. +/// +/// +public sealed record CreateOrganizationCommand( + OrganizationId OrganizationId, + string Slug, + string DisplayName) : IRequest>; + +/// What the caller now has. +public sealed record OrganizationDto( + OrganizationId OrganizationId, TenantId TenantId, string Slug); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/MapHostToTenantCommand.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/MapHostToTenantCommand.cs new file mode 100644 index 00000000..b87c99d0 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/MapHostToTenantCommand.cs @@ -0,0 +1,44 @@ +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.Tenant; + +/// +/// Points a hostname at the ambient tenant, or at one of its organizations. +/// +/// +/// +/// It runs under the ordinary announcement, like any other tenant write. +/// platform_host_to_tenant is read platform-scoped — the resolver has no tenant yet +/// when it looks — but written tenant-keyed, so this command needs a resolved context and +/// the policy checks the row's tenant against it. That asymmetry is +/// [Database Standards § Table classes](../../../../../../docs/standards/05-database.md)'s, +/// not this command's. +/// +/// +/// The two flags are separate and both default to closed. A row exists before DNS +/// points anywhere, so IsActive says the tenant owns the mapping and +/// IsPubliclyLive says it may serve anonymous traffic +/// ([ADR-0036](../../../../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md)). +/// Collapsing them would serve an unlaunched tenant's catalog, pricing and branding to +/// anyone who guessed the hostname. +/// +/// +/// Never populated by calling the Hub +/// ([ADR-0034](../../../../../../docs/decisions/0034-hub-contract-surface-invariant.md)): +/// an anonymous page load must not depend on a control plane being reachable, so the row +/// arrives from configuration, from the seeder, or from +/// PUT /api/internal/tenants/{id}/host-mappings — never from a lookup at resolution +/// time. +/// +/// +public sealed record MapHostToTenantCommand( + string Host, + OrganizationId? OrganizationId = null, + bool IsActive = false, + bool IsPubliclyLive = false) : IRequest>; + +/// The stored mapping, with the host in the spelling the resolver compares. +public sealed record HostMappingDto( + string Host, TenantId TenantId, OrganizationId? OrganizationId, bool IsPubliclyLive); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs index aaa320cf..8eff963b 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs @@ -19,3 +19,16 @@ public interface ITenantWriteStore : IAggregateWriteStore public interface IOrganizationWriteStore : IAggregateWriteStore; + +/// The write side of platform_host_to_tenant. +/// +/// Not an , and the reason is the key: +/// PlatformHostMapping is identified by its host, a string, not by a strongly-typed +/// id — one answer per host, globally. It is also not an aggregate root: it is the +/// projection the resolver reads before any tenant is known. A port of its own keeps both +/// facts visible rather than forcing the shape. +/// +public interface IPlatformHostMappingStore +{ + Task AddAsync(PlatformHostMapping mapping, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/CreateOrganizationCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/CreateOrganizationCommandHandler.cs new file mode 100644 index 00000000..c5a5c39b --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/CreateOrganizationCommandHandler.cs @@ -0,0 +1,102 @@ +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Tenant; + +/// +/// Adds a second (or third) organization to the ambient tenant. +/// +/// +/// +/// The tenant comes from the context, never from the request. That is the ordinary +/// rule provisioning is the single exception to: a request that named its own tenant would +/// let a caller authenticated for tenant A write into tenant B, and the policy would catch +/// it only because the announcement is A's. Taking it from the context means there is +/// nothing to catch. +/// +/// +/// One aggregate, one port — which is what keeps this handler off ADR-0042's +/// allow-list and the cross-aggregate rule at one entry. +/// +/// +internal sealed class CreateOrganizationCommandHandler( + IOrganizationWriteStore organizations, + ITenantContext tenantContext, + IClock clock) + : IRequestHandler> +{ + public async Task> Handle( + CreateOrganizationCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + // TenantContextBehavior has already refused an unresolved context for this + // command — it carries no marker — so this is a guard against a future wiring + // change rather than a reachable state, and it fails closed rather than writing + // an organization under the all-zero tenant. + if (!tenantContext.IsResolved) + { + return Result.FailFor>( + new Error(new LocalizedMessage("lockey_tenant_context_missing"))); + } + + var organization = Organization.Create( + request.OrganizationId, + tenantContext.TenantId, + request.Slug, + request.DisplayName, + clock, + tenantContext.UserId ?? UserId.SystemActor); + + try + { + await organizations.AddAsync(organization, cancellationToken); + } + catch (AggregateConflictException conflict) + { + var (field, reason) = OrganizationConflict.For(conflict.ConstraintName); + + return Result.FailFor>( + new Error( + new LocalizedMessage("lockey_business_rule_violation"), + new Dictionary>(StringComparer.Ordinal) + { + [field] = [new LocalizedMessage(reason)], + })); + } + + return Result.Ok(new OrganizationDto( + organization.Id, organization.TenantId, organization.Slug)); + } +} + +/// +/// Which uniqueness an organization write collided with, as an RFC 7807 entry. +/// +/// +/// Shared with ProvisionTenantCommandHandler's organization arms so the two answer +/// a caller identically. The top-level code stays business_rule_violation: +/// HttpStatusMap is a closed table of cross-cutting codes and a module-specific key +/// there falls through to 500 — measured, in the round that introduced four of them. +/// +internal static class OrganizationConflict +{ + internal static (string Field, string Reason) For(string? constraintName) => + constraintName switch + { + "ux_organizations_tenant_id_slug" => + (nameof(CreateOrganizationCommand.Slug), "lockey_slug_taken"), + "pk_organizations" or "ux_organizations_tenant_id_id" => + (nameof(CreateOrganizationCommand.OrganizationId), "lockey_identifier_taken"), + _ => ("$", "lockey_business_rule_violation"), + }; +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs new file mode 100644 index 00000000..3dec8d7f --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs @@ -0,0 +1,78 @@ +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Tenant; + +/// +/// Writes the row that decides whose data an anonymous request sees. +/// +/// +/// +/// The tenant comes from the context. A request that named its own tenant here +/// would be the sharpest privilege escalation in the module: the host mapping is what an +/// unauthenticated page load resolves through, so a caller who could name another tenant +/// could point that tenant's domain at their own content — or their domain at that +/// tenant's. The policy checks the row's tenant against the announcement, and the +/// announcement comes from the context. +/// +/// +/// The host is normalized by the aggregate, not here. The resolver compares +/// ordinally against EffectiveHost.Normalize's output, so a row in any other +/// spelling matches nothing and the tenant 404s on its own domain. +/// +/// +internal sealed class MapHostToTenantCommandHandler( + IPlatformHostMappingStore hosts, + ITenantContext tenantContext) + : IRequestHandler> +{ + public async Task> Handle( + MapHostToTenantCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (!tenantContext.IsResolved) + { + return Result.FailFor>( + new Error(new LocalizedMessage("lockey_tenant_context_missing"))); + } + + var mapping = PlatformHostMapping.Create( + request.Host, + tenantContext.TenantId, + request.OrganizationId, + request.IsActive, + request.IsPubliclyLive); + + try + { + await hosts.AddAsync(mapping, cancellationToken); + } + catch (AggregateConflictException) + { + // The primary key IS the host, and it is global: one answer per host across + // every tenant. A collision therefore means the name is claimed — possibly by + // another tenant, which is why the answer says only that it is taken. Naming + // the holder would turn this endpoint into an oracle for which hostnames + // belong to which customers. + return Result.FailFor>( + new Error( + new LocalizedMessage("lockey_business_rule_violation"), + new Dictionary>(StringComparer.Ordinal) + { + [nameof(MapHostToTenantCommand.Host)] = + [new LocalizedMessage("lockey_host_taken")], + })); + } + + return Result.Ok(new HostMappingDto( + mapping.Host, mapping.TenantId, mapping.OrganizationId, mapping.IsPubliclyLive)); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenancyCommandValidators.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenancyCommandValidators.cs new file mode 100644 index 00000000..ac8bf1d6 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenancyCommandValidators.cs @@ -0,0 +1,70 @@ +using FluentValidation; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Tenancy.Application.Tenant; + +/// +/// What adding an organization refuses before a transaction is opened. +/// +/// +/// The same three guards as provisioning's, read from the same constants the aggregate +/// throws on — , , +/// . Without them a mistyped slug is an +/// ArgumentException from Organization.Create, which has no entry in +/// HttpStatusMap and therefore answers 500 for something the caller can fix. +/// +internal sealed class CreateOrganizationCommandValidator + : AbstractValidator +{ + public CreateOrganizationCommandValidator() + { + // Cascade(Stop) keeps the shape regex off a null a deserializer could supply; + // Pattern().IsMatch(null) throws out of the validator itself. + RuleFor(command => command.Slug) + .Cascade(CascadeMode.Stop) + .NotEmpty().WithErrorCode("lockey_slug_required") + .MaximumLength(UrlSlug.MaxLength).WithErrorCode("lockey_slug_too_long") + .Must(value => UrlSlug.IsUrlSafe(value)).WithErrorCode("lockey_slug_not_url_safe"); + + RuleFor(command => command.DisplayName) + .Cascade(CascadeMode.Stop) + .NotEmpty().WithErrorCode("lockey_display_name_required") + .MaximumLength(MappedLength.DisplayName) + .WithErrorCode("lockey_display_name_too_long"); + } +} + +/// +/// What mapping a host refuses before a transaction is opened. +/// +/// +/// +/// The shape check runs , not a regex of its +/// own. That function is what a request's Host header is put through at +/// resolution time, so "a host this validator accepts" and "a host the resolver can match" +/// are the same set by construction rather than by two rules kept in agreement. +/// +/// +/// Publicly-live-without-active is refused here as well as in the aggregate. The +/// schema has no CHECK for it, and the combination would serve anonymous traffic +/// for a mapping the tenant does not yet own. +/// +/// +internal sealed class MapHostToTenantCommandValidator : AbstractValidator +{ + public MapHostToTenantCommandValidator() + { + RuleFor(command => command.Host) + .Cascade(CascadeMode.Stop) + .NotEmpty().WithErrorCode("lockey_host_required") + .Must(host => EffectiveHost.Normalize(host) is not null) + .WithErrorCode("lockey_host_not_resolvable"); + + RuleFor(command => command) + .Must(command => command.IsActive || !command.IsPubliclyLive) + .WithErrorCode("lockey_host_live_before_active") + .OverridePropertyName(nameof(MapHostToTenantCommand.IsPubliclyLive)); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs index 0b53f729..242c76bb 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs @@ -1,4 +1,5 @@ using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Persistence; namespace LearnStack.Modules.Tenancy.Domain; @@ -104,6 +105,73 @@ public sealed class PlatformHostMapping { private PlatformHostMapping() => Host = null!; + /// Maps to a tenant, or to one of its organizations. + /// + /// + /// The host is normalized here, not by the caller. The resolver compares + /// ordinally against what produces from a + /// request's Host header, so a row stored in any other spelling — an uppercase + /// letter, a trailing dot, a port, an unpunycoded IDN — matches nothing and the tenant + /// is a 404 on its own domain. Normalizing at the factory is what makes that + /// unreachable rather than a review item. + /// + /// + /// Both flags default to false. The lifecycle + /// ADR-0036 + /// describes is submit → row → DNS instructions → activate, so a row that arrives + /// already serving anonymous traffic has skipped the two steps that decide whether it + /// should. A caller that wants a live host says so. + /// + /// + public static PlatformHostMapping Create( + string host, + TenantId tenantId, + OrganizationId? organizationId = null, + bool isActive = false, + bool isPubliclyLive = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(host); + + var normalized = EffectiveHost.Normalize(host) + ?? throw new ArgumentException( + $"'{host}' is not a usable host: it must normalize to a name the resolver " + + "can compare against a request's Host header.", + nameof(host)); + + TenantOwnership.EnsureRealTenant( + tenantId, + "A host mapping names the tenant it resolves to; the tenant id was never assigned.", + nameof(tenantId)); + + if (organizationId is { } organization + && (!organization.IsInitialized() || organization.Value == Guid.Empty)) + { + throw new ArgumentException( + "The organization id was never assigned. Pass null for a tenant-wide host.", + nameof(organizationId)); + } + + // Publicly live without being active is the combination the two flags exist to + // keep apart, inverted: it would serve anonymous traffic for a mapping the tenant + // does not yet own. The database has no CHECK for it — this is the guard. + if (isPubliclyLive && !isActive) + { + throw new ArgumentException( + "A host cannot be publicly live before it is active: the lifecycle is " + + "submit → row → DNS instructions → activate.", + nameof(isPubliclyLive)); + } + + return new PlatformHostMapping + { + Host = normalized, + TenantId = tenantId, + OrganizationId = organizationId, + IsActive = isActive, + IsPubliclyLive = isPubliclyLive, + }; + } + /// The normalized effective host. Primary key — one answer per host. public string Host { get; private set; } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs index 72154a41..b1400752 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs @@ -81,7 +81,24 @@ public Task UpdateAsync( } } -/// Shared by both stores; see for why. +/// The host-resolution index's writes. +/// +/// Add-only for now: nothing in the corpus updates a mapping in place — activation and +/// the publicly-live flip arrive with the Hub-side custom-domain lifecycle in +/// [Phase 02c](../../../../../../docs/roadmap/phase-02c-hub-foundation.md), which owns +/// that transaction and the cache invalidation that goes with it. +/// +public sealed class PlatformHostMappingStore(TenancyDbContext db) : IPlatformHostMappingStore +{ + public Task AddAsync( + PlatformHostMapping mapping, CancellationToken cancellationToken = default) + { + db.PlatformHostMappings.Add(mapping); + return SaveTranslatingConflictsAsync(db, cancellationToken); + } +} + +/// Shared by the stores; see for why. internal static class WriteStoreTracking { /// diff --git a/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj b/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj index a51e8b1f..b0756a03 100644 --- a/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj +++ b/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj @@ -26,6 +26,11 @@ + + diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index ed910057..f5b17e5f 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -145,11 +145,12 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() // SET LOCAL, so every read through it returns zero rows under the // corrected policy — silently. // - // Four files under backend/src may reach for a connection at all: the two + // Five files under backend/src may reach for a connection at all: the two // design-time factories, where a connection string is the point; the // shared helper, which passes a connection rather than a string; and the - // composition root, which builds the one application data source behind - // its credential guard. A fifth is a new decision. + // two composition roots — the API's, which builds the one application data + // source behind its credential guard, and the seeder's, which is the same act + // for a host with no HTTP surface. A sixth is a new decision. // // The scan covers the raw constructors as well as `UseNpgsql` and // `AddDbContext`, because a call site that opened its own @@ -162,16 +163,27 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() StringComparison.Ordinal)) .Where(file => ProviderTokens.Any(token => StripComments(File.ReadAllText(file)).Contains(token, StringComparison.Ordinal))) - .Select(Path.GetFileName) + // Qualified by the directory above the file, not the bare filename. There + // are now two `Program.cs` under backend/src — the API's and the seeder's — + // and a set keyed on filenames alone would let one of them acquire a + // connection under cover of the other's entry. + .Select(file => $"{Path.GetFileName(Path.GetDirectoryName(file))}/{Path.GetFileName(file)}") .Order(StringComparer.Ordinal) .ToList(); callSites.Should().BeEquivalentTo( [ - "ModuleDbContextRegistration.cs", - "PersistenceCompositionExtensions.cs", - "PlatformDbContextFactory.cs", - "TenancyDbContextFactory.cs", + "Persistence/ModuleDbContextRegistration.cs", + "Composition/PersistenceCompositionExtensions.cs", + "Persistence/PlatformDbContextFactory.cs", + "Persistence/TenancyDbContextFactory.cs", + + // The fifth, and a deliberate entry rather than a discovered one: the seeder + // is a second composition root, and building the one application data source + // is the same act PersistenceCompositionExtensions performs for the API. It + // is in the set — not exempted from it — so the next tool that reaches for a + // connection is still a reviewed diff. + "LearnStack.Tools.Seeder/Program.cs", ]); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs new file mode 100644 index 00000000..fcb4a64a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs @@ -0,0 +1,219 @@ +using FluentAssertions; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using LearnStack.Tools.Seeder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// The two seed tenants, written by the seeder against the shipped schema. +/// +/// +/// +/// The seeder's output is a Packet 7 deliverable, so it is asserted rather than +/// assumed. Two tenants in unrelated domains are what tests the genericity claim, and +/// [Phase 02d](../../../../docs/roadmap/phase-02d-walking-skeleton.md) renders both in a +/// browser — a seed that silently wrote one tenant, or wrote both host rows in the same +/// class, would be discovered there rather than here. +/// +/// +/// As learnstack_app, and through the real commands. The runner is the +/// production type, sending the production commands through the production pipeline; only +/// the connection string differs. Run as the migration or platform role this would pass +/// with every policy inert. +/// +/// +/// The container is shared, so each case removes what it wrote. Cleanup runs as +/// learnstack_platform: the rows belong to tenants with no context to announce. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class SeederTests : IAsyncLifetime +{ + private readonly SchemaFixture _schema; + + public SeederTests(SchemaFixture schema) => _schema = schema; + + public Task InitializeAsync() => Task.CompletedTask; + + public Task DisposeAsync() => CleanUpAsync(); + + [Fact] + public async Task The_seed_writes_two_tenants_each_with_two_organizations_and_one_host() + { + var exitCode = await Runner().RunAsync(CancellationToken.None); + + exitCode.Should().Be(0); + + foreach (var tenant in SeedData.All) + { + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM tenants WHERE id = @tenant", tenant.TenantId.Value)) + .Should().Be(1L, "each seed tenant is provisioned once"); + + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM organizations WHERE tenant_id = @tenant", + tenant.TenantId.Value)) + .Should().Be(2L, + "the default organization comes from provisioning and the second from " + + "an ordinary command — one organization would make " + + "organization-scoped isolation unobservable in the seed"); + + (await ScalarAsPlatformAsync( + """ + SELECT count(*) FROM tenants + WHERE id = @tenant AND default_organization_id IS NOT NULL + """, + tenant.TenantId.Value)) + .Should().Be(1L, "a tenant without a default organization serves nothing"); + + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM platform_host_to_tenant WHERE tenant_id = @tenant", + tenant.TenantId.Value)) + .Should().Be(1L, "one row per tenant, not one per organization"); + } + } + + [Fact] + public async Task The_two_host_rows_exercise_both_live_classifications() + { + // The reason the seed sets organization_id on one row and leaves it null on the + // other. `OrgHost` and `TenantHost` take different paths through the resolver and + // the factory, and a seed that produced only one of them would leave the other + // exercised by fixtures alone — which is what Packet 7 moved the seed earlier to + // avoid. + await Runner().RunAsync(CancellationToken.None); + + (await TextAsPlatformAsync( + "SELECT organization_id::text FROM platform_host_to_tenant WHERE host = @host", + SeedData.English.Host)) + .Should().Be(SeedData.English.DefaultOrganization.OrganizationId.Value.ToString(), + "one seed host resolves to an organization"); + + (await TextAsPlatformAsync( + "SELECT organization_id::text FROM platform_host_to_tenant WHERE host = @host", + SeedData.Yoga.Host)) + .Should().BeNull("and the other resolves to the tenant as a whole"); + + foreach (var tenant in SeedData.All) + { + (await TextAsPlatformAsync( + """ + SELECT (is_active AND is_publicly_live)::text + FROM platform_host_to_tenant WHERE host = @host + """, + tenant.Host)) + .Should().Be("true", + "a seed host that is not publicly live is a 404 in the browser Phase " + + "02d renders it in"); + } + } + + [Fact] + public async Task Running_the_seed_twice_changes_nothing_and_still_succeeds() + { + // `make seed` is documented as safe to repeat, and it runs on every `make dev`. + // The second run cannot pre-check: under the provisioning announcement a SELECT + // over `tenants` returns no rows by policy, so idempotency is a uniqueness + // refusal recognised as "already seeded" rather than a query. + (await Runner().RunAsync(CancellationToken.None)).Should().Be(0); + (await Runner().RunAsync(CancellationToken.None)).Should().Be(0, + "a second run is the ordinary case, not an error"); + + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM tenants WHERE id = ANY(@ids)", + SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) + .Should().Be(2L, "and it did not double anything"); + (await ScalarAsPlatformAsync( + "SELECT count(*) FROM organizations WHERE tenant_id = ANY(@ids)", + SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) + .Should().Be(4L); + } + + // ── Harness ────────────────────────────────────────────────────────────── + + private SeedRunner Runner() => + new(Compose, NullLogger.Instance); + + /// + /// The seeder's own composition, on the fixture's container. + /// + /// + /// A provider per act, around a StaticTenantContextAccessor — the same shape + /// Program.cs builds, and the reason the seeder never writes + /// ITenantContextAccessor.Current. Kept in step with it by hand; running the + /// executable instead would put a process boundary between the assertion and the + /// failure. + /// + private ServiceProvider Compose(ITenantContext? context) + { + var services = new ServiceCollection(); + services.AddSingleton(NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(new StaticTenantContextAccessor(context)); + services.AddTransient(provider => + provider.GetRequiredService().Current + ?? UnresolvedTenantContext.Instance); + services.AddScoped(); + services.AddModuleDbContext(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddLearnStackMediatRPipeline(typeof(ITenantWriteStore).Assembly); + + return services.BuildServiceProvider(); + } + + private async Task CleanUpAsync() + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + + var ids = SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray(); + + foreach (var statement in new[] + { + "DELETE FROM platform_host_to_tenant WHERE tenant_id = ANY(@ids)", + "UPDATE tenants SET default_organization_id = NULL WHERE id = ANY(@ids)", + "DELETE FROM organizations WHERE tenant_id = ANY(@ids)", + "DELETE FROM tenants WHERE id = ANY(@ids)", + }) + { + await using var cleanup = new NpgsqlCommand(statement, (NpgsqlConnection)platform); + cleanup.Parameters.AddWithValue("ids", ids); + await cleanup.ExecuteNonQueryAsync(); + } + } + + private async Task ScalarAsPlatformAsync(string sql, object parameter) + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var query = new NpgsqlCommand(sql, (NpgsqlConnection)platform); + query.Parameters.AddWithValue(parameter is Guid[]? "ids" : "tenant", parameter); + + return (long)(await query.ExecuteScalarAsync())!; + } + + private async Task TextAsPlatformAsync(string sql, string host) + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var query = new NpgsqlCommand(sql, (NpgsqlConnection)platform); + query.Parameters.AddWithValue("host", host); + + return (await query.ExecuteScalarAsync()) as string; + } + +} diff --git a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj index 80cf60cc..3c83e01e 100644 --- a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj +++ b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj @@ -15,6 +15,9 @@ + + diff --git a/backend/src/LearnStack.Tools.Seeder/Program.cs b/backend/src/LearnStack.Tools.Seeder/Program.cs index 25ae0668..8c7c6d32 100644 --- a/backend/src/LearnStack.Tools.Seeder/Program.cs +++ b/backend/src/LearnStack.Tools.Seeder/Program.cs @@ -1,28 +1,18 @@ -using LearnStack.Application.Pipeline; -using LearnStack.Infrastructure.Persistence; -using LearnStack.Modules.Tenancy.Application.Abstractions; -using LearnStack.Modules.Tenancy.Infrastructure.Persistence; -using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Tenancy; -using LearnStack.SharedKernel.Time; using LearnStack.Tools.Seeder; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Npgsql; -// The seeder is a second composition root, deliberately minimal: the module's handlers, -// the MediatR pipeline, the ambient unit of work and the module context. It shares those -// with the API because sharing them is the point — a seed that ran through its own write -// path would prove nothing about the request path. It shares nothing else; there is no -// HTTP surface here to configure. +// The seeder is a host without an HTTP surface, and it exists so the two demo tenants are +// written by the same commands a request writes them with — ADR-0042 requires that: a +// seeder inserting the tenant and its default organization itself would be a second copy +// of the one sanctioned cross-aggregate write. -// `--connection-string ` wins; otherwise the same environment variable the compose -// stack and `.env.example` already define, so `make seed` needs no new configuration. // Read from the flag or the environment, and NOT through IConfiguration's // GetConnectionString: exactly one file in the solution reads credentials that way, and // Platform_DataSource_Resolved_Only_By_PlatformAdminScope keeps it that way so one file -// decides what is done with them. A console tool taking an explicit argument needs no -// configuration stack at all. +// decides what is done with them. `make seed` passes it in the environment, because an +// argument carrying a database password is visible to any local user through `ps`. var connectionString = ConnectionStringFrom(args) ?? Environment.GetEnvironmentVariable("ConnectionStrings__Default"); @@ -34,49 +24,25 @@ return 2; } -// One data source for the whole run, shared by every per-act provider below: a seeder -// that opened a pool per command would spend more time connecting than writing. -var dataSource = NpgsqlDataSource.Create(connectionString); - -// A provider per act, each composed around the context that act runs under. The seeder -// never assigns ITenantContextAccessor.Current — writes to that member are a closed set -// of four (ADR-0036 Amendment 2), and a development tool is not a reason to widen a -// security enumeration when composition costs nothing. -ServiceProvider Compose(ITenantContext? context) -{ - var services = new ServiceCollection(); - - services.AddSingleton(dataSource); - services.AddLogging(logging => logging.AddSimpleConsole()); - services.AddSingleton(); - services.AddSingleton(new StaticTenantContextAccessor(context)); - services.AddTransient(provider => - provider.GetRequiredService().Current - ?? UnresolvedTenantContext.Instance); - - services.AddScoped(); - services.AddModuleDbContext(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddLearnStackMediatRPipeline(typeof(ITenantWriteStore).Assembly); - - return services.BuildServiceProvider(); -} - +// One data source for the whole run, shared by every per-act provider: a seeder that +// opened a pool per command would leave an idle connection behind for each one. +await using var dataSource = NpgsqlDataSource.Create(connectionString); using var loggerFactory = LoggerFactory.Create(logging => logging.AddSimpleConsole()); -var logger = loggerFactory.CreateLogger(); + +var runner = new SeedRunner( + context => SeedComposition.Build(dataSource, context, loggerFactory), + loggerFactory.CreateLogger()); try { - return await new SeedRunner(Compose, logger).RunAsync(CancellationToken.None); + return await runner.RunAsync(CancellationToken.None); } catch (Exception failure) { - // Non-zero, and the message on stderr: `make seed` is a gate, and a seeder that - // reported success after failing would hand the next step a database it cannot use. - SeedLog.Failed(logger, failure); - Console.Error.WriteLine($"seed: {failure.Message}"); + // Non-zero, and nothing else: `make seed` is a gate, and a seeder that reported + // success after failing would hand the next step a database it cannot use. The + // message reaches the operator through the logger, which is already on the console. + SeedLog.Failed(loggerFactory.CreateLogger(), failure); return 1; } diff --git a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs new file mode 100644 index 00000000..122e0cfc --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -0,0 +1,88 @@ +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.MultiTenancy; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace LearnStack.Tools.Seeder; + +/// +/// The seeder's service graph: one provider per act, around one shared data source. +/// +/// +/// +/// One file, because the alternative was three copies. The entry point and the +/// integration suite both need this graph, and a hand-maintained second copy is a second +/// thing to keep true — the copy is what drifts, and it had already drifted on the axis +/// that mattered: one built a data source per act where the other shared one. +/// +/// +/// A provider per act, and no writable ambient accessor anywhere. Writes to +/// ITenantContextAccessor.Current are a closed set of four +/// ([ADR-0036 Amendment 2](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md)), +/// because a writer of it can make work run under a tenant nothing resolved. Composing the +/// context into a per act means the seeder +/// cannot move the ambient tenant at all — not that it promises not to. +/// +/// +/// The data source is the caller's, not this method's. A pool per act would leave +/// one idle connection behind per command against a server with a connection ceiling, and +/// AddSingleton(instance) does not dispose what it did not create — measured — so +/// nothing would ever reclaim them. The owner disposes it once, after the run. +/// +/// +public static class SeedComposition +{ + public static ServiceProvider Build( + NpgsqlDataSource dataSource, ITenantContext? context, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(dataSource); + ArgumentNullException.ThrowIfNull(loggerFactory); + + var services = new ServiceCollection(); + + services.AddSingleton(dataSource); + services.AddSingleton(loggerFactory); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(new StaticTenantContextAccessor(context)); + services.AddTransient(provider => + provider.GetRequiredService().Current + ?? UnresolvedTenantContext.Instance); + + services.AddScoped(); + services.AddModuleDbContext(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Its own short read-only transaction on its own connection, which is why it takes + // a Lazy data source rather than the ambient unit of work: it answers "is this + // organization one of this tenant's?" before the write that would depend on the + // answer, and it must not be able to see uncommitted state from that write. + services.AddSingleton(new Lazy(() => dataSource)); + services.AddSingleton(); + + // The seeder reserves no hosts and fronts no cache: it is a one-shot process with + // no configuration bound and nothing in memory to go stale. Both defaults answer + // truthfully for that host rather than approximating the API's. + services.AddSingleton(NoReservedHosts.Instance); + services.AddSingleton(NullHostResolutionInvalidator.Instance); + services.AddLearnStackMediatRPipeline(typeof(ITenantWriteStore).Assembly); + + return services.BuildServiceProvider(); + } +} + +/// Source-generated logging, per the house CA1848 rule. +public static partial class SeedLog +{ + [LoggerMessage(EventId = 7001, Level = LogLevel.Error, Message = "Seeding failed.")] + public static partial void Failed(ILogger logger, Exception exception); +} diff --git a/backend/src/LearnStack.Tools.Seeder/SeedData.cs b/backend/src/LearnStack.Tools.Seeder/SeedData.cs index d5ec646b..ec79d70c 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedData.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedData.cs @@ -28,8 +28,12 @@ namespace LearnStack.Tools.Seeder; /// /// /// One host row each, and deliberately of different classes. `demo-english` maps -/// host → organization and `demo-yoga` maps host → tenant with a null organization, so both -/// live classifications are exercised by the seed rather than only by a test. +/// host → tenant with a null organization and `demo-yoga` maps host → organization, so both +/// live classifications are exercised by the seed rather than only by a test. Which tenant +/// takes which is arbitrary on the merits and therefore settled by the corpus: +/// [the seed-tenant skill](../../../.claude/skills/seed-tenant/SKILL.md) named this pairing +/// before the code existed, and two documents disagreeing about a seeded row is how a +/// Phase 02d assertion ends up chasing the wrong host. /// /// public static class SeedData @@ -47,7 +51,7 @@ public static class SeedData "besiktas", "Beşiktaş Branch"), "demo-english.learnstack.local", - MapHostToDefaultOrganization: true); + MapHostToDefaultOrganization: false); public static readonly SeedTenant Yoga = new( TenantId.From(Guid.Parse("01930000-0000-7000-8000-000000000002")), @@ -62,7 +66,7 @@ public static class SeedData "studio-two", "Studio Two"), "demo-yoga.learnstack.local", - MapHostToDefaultOrganization: false); + MapHostToDefaultOrganization: true); public static readonly IReadOnlyList All = [English, Yoga]; } diff --git a/backend/src/LearnStack.Tools.Seeder/SeedHost.cs b/backend/src/LearnStack.Tools.Seeder/SeedHost.cs deleted file mode 100644 index fde7d697..00000000 --- a/backend/src/LearnStack.Tools.Seeder/SeedHost.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.Extensions.Logging; -using LearnStack.SharedKernel.Tenancy; - -namespace LearnStack.Tools.Seeder; - -/// Source-generated logging, per the house CA1848 rule. -public static partial class SeedLog -{ - [LoggerMessage(EventId = 7001, Level = LogLevel.Error, Message = "Seeding failed.")] - public static partial void Failed(ILogger logger, Exception exception); -} - -/// The accessor the runner writes between acts. -public sealed class SeedTenantContextAccessor : ITenantContextAccessor -{ - public ITenantContext? Current { get; set; } -} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/MapHostToTenantCommand.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/MapHostToTenantCommand.cs index b87c99d0..c821a0b4 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/MapHostToTenantCommand.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/MapHostToTenantCommand.cs @@ -40,5 +40,14 @@ public sealed record MapHostToTenantCommand( bool IsPubliclyLive = false) : IRequest>; /// The stored mapping, with the host in the spelling the resolver compares. +/// +/// Both flags, not just the second. They are a pair — a row exists before DNS points +/// anywhere — and a response carrying only IsPubliclyLive would let a caller read +/// "false" as "not mine yet" when it means "mine, and not serving". +/// public sealed record HostMappingDto( - string Host, TenantId TenantId, OrganizationId? OrganizationId, bool IsPubliclyLive); + string Host, + TenantId TenantId, + OrganizationId? OrganizationId, + bool IsActive, + bool IsPubliclyLive); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/CreateOrganizationCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/CreateOrganizationCommandHandler.cs index c5a5c39b..91875a87 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/CreateOrganizationCommandHandler.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/CreateOrganizationCommandHandler.cs @@ -45,8 +45,12 @@ public async Task> Handle( // an organization under the all-zero tenant. if (!tenantContext.IsResolved) { + // `tenant_mismatch`, which is what TenantContextBehavior returns for this + // condition and what HttpStatusMap maps. A key of this module's own would + // fall through that closed table to a 500 — the one answer a fail-closed + // guard must not give. return Result.FailFor>( - new Error(new LocalizedMessage("lockey_tenant_context_missing"))); + new Error(new LocalizedMessage("lockey_tenant_mismatch"))); } var organization = Organization.Create( diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs index 3dec8d7f..6ea47ec6 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs @@ -30,6 +30,9 @@ namespace LearnStack.Modules.Tenancy.Application.Tenant; /// internal sealed class MapHostToTenantCommandHandler( IPlatformHostMappingStore hosts, + IOrganizationScopeValidator organizations, + IReservedHostRegistry reservedHosts, + IHostResolutionInvalidator resolutionCache, ITenantContext tenantContext) : IRequestHandler> { @@ -38,10 +41,35 @@ public async Task> Handle( { ArgumentNullException.ThrowIfNull(request); + // TenantContextBehavior has already refused an unresolved context for this + // command — it carries no marker — so this is a backstop against a future wiring + // change, not a reachable state. `tenant_mismatch` rather than a key of its own: + // it is the code the pipeline itself returns for this condition, it is in + // HttpStatusMap, and a module-specific key would fall through that closed table + // to a 500 — which is what a fail-closed guard must not answer. if (!tenantContext.IsResolved) + { + return Result.FailFor>(TenantContextMissing); + } + + // The organization must be one of this tenant's. Nothing else checks it before + // the insert: the composite foreign key does, but it raises 23503, which has no + // arm in HttpStatusMap and therefore answers 500 — after the transaction opened + // and the tenant was announced. Reading it here turns a caller's mistake into a + // refusal they can act on, and the foreign key stays as the layer that makes the + // race impossible rather than merely unlikely. + if (request.OrganizationId is { } organizationId + && !await organizations.BelongsToTenantAsync( + tenantContext.TenantId, organizationId, cancellationToken)) { return Result.FailFor>( - new Error(new LocalizedMessage("lockey_tenant_context_missing"))); + new Error( + new LocalizedMessage("lockey_business_rule_violation"), + new Dictionary>(StringComparer.Ordinal) + { + [nameof(MapHostToTenantCommand.OrganizationId)] = + [new LocalizedMessage("lockey_organization_not_in_tenant")], + })); } var mapping = PlatformHostMapping.Create( @@ -51,6 +79,24 @@ public async Task> Handle( request.IsActive, request.IsPubliclyLive); + // A host on Tenancy:PlatformHosts is classified before the resolver is called at + // all, so a row naming it would be inert — never read, never logged, never + // counted. ADR-0036 states that precedence is correct and that the losing row is + // SILENT, and assigns the check to whichever packet builds this writer. Checked + // after Create, because the comparison is against normalized hosts and Create is + // what normalizes. + if (reservedHosts.IsReserved(mapping.Host)) + { + return Result.FailFor>( + new Error( + new LocalizedMessage("lockey_business_rule_violation"), + new Dictionary>(StringComparer.Ordinal) + { + [nameof(MapHostToTenantCommand.Host)] = + [new LocalizedMessage("lockey_host_reserved")], + })); + } + try { await hosts.AddAsync(mapping, cancellationToken); @@ -72,7 +118,28 @@ [new LocalizedMessage("lockey_host_taken")], })); } + // The negative cache remembers hosts that resolved to nothing, and this host just + // stopped being one. Without it the TTL is the whole mechanism and a host loaded + // once before it existed keeps its 404 for the rest of that window — which is + // exactly what a developer meets after seeding. + // + // After the write, and deliberately not inside a transaction hook: the row is not + // visible to another connection until the commit, so forgetting earlier would let + // a concurrent request re-cache the miss it was about to fix. Forgetting a host + // that then fails to commit costs one extra database read. + resolutionCache.Invalidate(mapping.Host); + return Result.Ok(new HostMappingDto( - mapping.Host, mapping.TenantId, mapping.OrganizationId, mapping.IsPubliclyLive)); + mapping.Host, + mapping.TenantId, + mapping.OrganizationId, + mapping.IsActive, + mapping.IsPubliclyLive)); } + + /// + /// The pipeline's own answer for an unresolved context, reused rather than restated. + /// + private static readonly Error TenantContextMissing = + new(new LocalizedMessage("lockey_tenant_mismatch")); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs index fcb4a64a..e419d334 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs @@ -1,12 +1,17 @@ using FluentAssertions; +using LearnStack.Api.Common; using LearnStack.Application.Pipeline; using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Application.Abstractions; using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Tenancy; +using MediatR; using LearnStack.SharedKernel.Time; using LearnStack.Tools.Seeder; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; @@ -51,35 +56,30 @@ public sealed class SeederTests : IAsyncLifetime [Fact] public async Task The_seed_writes_two_tenants_each_with_two_organizations_and_one_host() { - var exitCode = await Runner().RunAsync(CancellationToken.None); + await using var dataSource = DataSource(); + + var exitCode = await Runner(dataSource).RunAsync(CancellationToken.None); exitCode.Should().Be(0); foreach (var tenant in SeedData.All) { - (await ScalarAsPlatformAsync( - "SELECT count(*) FROM tenants WHERE id = @tenant", tenant.TenantId.Value)) + (await ScalarAsPlatformAsync("SELECT count(*) FROM tenants WHERE id = @tenant", "tenant", tenant.TenantId.Value)) .Should().Be(1L, "each seed tenant is provisioned once"); - (await ScalarAsPlatformAsync( - "SELECT count(*) FROM organizations WHERE tenant_id = @tenant", - tenant.TenantId.Value)) + (await ScalarAsPlatformAsync("SELECT count(*) FROM organizations WHERE tenant_id = @tenant", "tenant", tenant.TenantId.Value)) .Should().Be(2L, "the default organization comes from provisioning and the second from " + "an ordinary command — one organization would make " + "organization-scoped isolation unobservable in the seed"); - (await ScalarAsPlatformAsync( - """ + (await ScalarAsPlatformAsync(""" SELECT count(*) FROM tenants WHERE id = @tenant AND default_organization_id IS NOT NULL - """, - tenant.TenantId.Value)) + """, "tenant", tenant.TenantId.Value)) .Should().Be(1L, "a tenant without a default organization serves nothing"); - (await ScalarAsPlatformAsync( - "SELECT count(*) FROM platform_host_to_tenant WHERE tenant_id = @tenant", - tenant.TenantId.Value)) + (await ScalarAsPlatformAsync("SELECT count(*) FROM platform_host_to_tenant WHERE tenant_id = @tenant", "tenant", tenant.TenantId.Value)) .Should().Be(1L, "one row per tenant, not one per organization"); } } @@ -92,18 +92,26 @@ public async Task The_two_host_rows_exercise_both_live_classifications() // the factory, and a seed that produced only one of them would leave the other // exercised by fixtures alone — which is what Packet 7 moved the seed earlier to // avoid. - await Runner().RunAsync(CancellationToken.None); + await using var dataSource = DataSource(); + await Runner(dataSource).RunAsync(CancellationToken.None); (await TextAsPlatformAsync( "SELECT organization_id::text FROM platform_host_to_tenant WHERE host = @host", - SeedData.English.Host)) - .Should().Be(SeedData.English.DefaultOrganization.OrganizationId.Value.ToString(), + SeedData.Yoga.Host)) + .Should().Be(SeedData.Yoga.DefaultOrganization.OrganizationId.Value.ToString(), "one seed host resolves to an organization"); - (await TextAsPlatformAsync( - "SELECT organization_id::text FROM platform_host_to_tenant WHERE host = @host", - SeedData.Yoga.Host)) - .Should().BeNull("and the other resolves to the tenant as a whole"); + // Existence AND nullity, in one count. `SELECT organization_id` returning null is + // ambiguous between "the column is NULL" and "there is no row", and the ambiguous + // form was measured passing with demo-yoga never seeded at all — which is the + // exact scenario this case exists to catch. + (await CountAsPlatformAsync( + """ + SELECT count(*) FROM platform_host_to_tenant + WHERE host = @host AND organization_id IS NULL + """, + SeedData.English.Host)) + .Should().Be(1L, "and the other resolves to the tenant as a whole"); foreach (var tenant in SeedData.All) { @@ -126,54 +134,116 @@ public async Task Running_the_seed_twice_changes_nothing_and_still_succeeds() // The second run cannot pre-check: under the provisioning announcement a SELECT // over `tenants` returns no rows by policy, so idempotency is a uniqueness // refusal recognised as "already seeded" rather than a query. - (await Runner().RunAsync(CancellationToken.None)).Should().Be(0); - (await Runner().RunAsync(CancellationToken.None)).Should().Be(0, + await using var dataSource = DataSource(); + + (await Runner(dataSource).RunAsync(CancellationToken.None)).Should().Be(0); + (await Runner(dataSource).RunAsync(CancellationToken.None)).Should().Be(0, "a second run is the ordinary case, not an error"); - (await ScalarAsPlatformAsync( - "SELECT count(*) FROM tenants WHERE id = ANY(@ids)", - SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) + (await ScalarAsPlatformAsync("SELECT count(*) FROM tenants WHERE id = ANY(@ids)", "ids", SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) .Should().Be(2L, "and it did not double anything"); - (await ScalarAsPlatformAsync( - "SELECT count(*) FROM organizations WHERE tenant_id = ANY(@ids)", - SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) + (await ScalarAsPlatformAsync("SELECT count(*) FROM organizations WHERE tenant_id = ANY(@ids)", "ids", SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) .Should().Be(4L); } - // ── Harness ────────────────────────────────────────────────────────────── + [Fact] + public async Task A_failure_that_is_not_a_conflict_stops_the_run() + { + // The seed's whole claim is that it is evidence about the request path, and that + // is only true if a refusal stops it. `make seed` gates on the exit code, so a + // seeder that swallowed a policy denial would hand the next step a database it + // cannot use while reporting success. + // + // Measured before this case existed: replacing the throw with a log-and-return — + // every failure swallowed, every run exiting 0 — left all three other cases green. + // A seeder that silently ignores a 42501 was indistinguishable from a correct one. + // + // Provoked by composing every act unresolved. Provisioning still succeeds, because + // it is the one command marked [AllowsUnresolvedTenantContext]; the second + // organization is then refused by the pipeline with a code that is NOT + // business_rule_violation, which is the only code the runner treats as + // "already seeded". + await using var dataSource = DataSource(); + + var alwaysUnresolved = new SeedRunner( + _ => SeedComposition.Build(dataSource, context: null, NullLoggerFactory.Instance), + NullLogger.Instance); + + var seed = async () => await alwaysUnresolved.RunAsync(CancellationToken.None); + + (await seed.Should().ThrowAsync( + "a refusal that is not a conflict means the seed did not do its job")) + .WithMessage("*second organization*"); + + // And it stopped where it failed rather than carrying on: the host row for the + // first tenant was never written. + (await CountAsPlatformAsync( + "SELECT count(*) FROM platform_host_to_tenant WHERE host = @host", + SeedData.English.Host)) + .Should().Be(0L); + } + + [Fact] + public async Task A_host_naming_another_tenants_organization_is_refused_not_crashed() + { + // The most consequential write in the module: `platform_host_to_tenant` is the row + // that decides whose data an anonymous request sees. The organization id is + // caller-supplied, and the only thing that checked it was the composite foreign + // key — which raises 23503, has no arm in HttpStatusMap, and therefore answered + // 500 after the transaction opened and the tenant was announced. Measured. + // + // The foreign key stays; it is what makes the race impossible rather than merely + // unlikely. What this adds is an answer the caller can act on. + await using var dataSource = DataSource(); + + (await Runner(dataSource).RunAsync(CancellationToken.None)).Should().Be(0); - private SeedRunner Runner() => - new(Compose, NullLogger.Instance); + var provider = SeedComposition.Build( + dataSource, + new SeedTenantContext( + SeedData.English.TenantId, SeedData.English.DefaultOrganization.OrganizationId), + NullLoggerFactory.Instance); + + await using (provider) + { + // SchemaFixture's OrgA1 belongs to a different tenant entirely. + var result = await provider.GetRequiredService().Send( + new MapHostToTenantCommand( + "smuggled.learnstack.local", + OrganizationId.From(SchemaFixture.OrgA1), + IsActive: true, + IsPubliclyLive: true)); + + result.IsFailure.Should().BeTrue("the organization is not this tenant's"); + HttpStatusMap.For(result.Error!.Code).Should().Be(StatusCodes.Status409Conflict, + "a 500 here is the defect; the caller can fix this input"); + result.Error.Details.Should().ContainKey( + nameof(MapHostToTenantCommand.OrganizationId)); + } + + (await CountAsPlatformAsync( + "SELECT count(*) FROM platform_host_to_tenant WHERE host = @host", + "smuggled.learnstack.local")) + .Should().Be(0L, "and nothing was written"); + } + + // ── Harness ────────────────────────────────────────────────────────────── /// - /// The seeder's own composition, on the fixture's container. + /// The seeder, composed exactly as Program.cs composes it. /// /// - /// A provider per act, around a StaticTenantContextAccessor — the same shape - /// Program.cs builds, and the reason the seeder never writes - /// ITenantContextAccessor.Current. Kept in step with it by hand; running the - /// executable instead would put a process boundary between the assertion and the - /// failure. + /// Through rather than a hand-copy of its registrations. + /// The copy this replaced had already drifted on the axis that mattered — it built a + /// data source per act where the entry point shares one — so the case that claimed to + /// exercise "the same shape Program.cs builds" was exercising a different one. /// - private ServiceProvider Compose(ITenantContext? context) - { - var services = new ServiceCollection(); - services.AddSingleton(NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); - services.AddLogging(); - services.AddSingleton(); - services.AddSingleton(new StaticTenantContextAccessor(context)); - services.AddTransient(provider => - provider.GetRequiredService().Current - ?? UnresolvedTenantContext.Instance); - services.AddScoped(); - services.AddModuleDbContext(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddLearnStackMediatRPipeline(typeof(ITenantWriteStore).Assembly); - - return services.BuildServiceProvider(); - } + private static SeedRunner Runner(NpgsqlDataSource dataSource) => + new(context => SeedComposition.Build(dataSource, context, NullLoggerFactory.Instance), + NullLogger.Instance); + + private NpgsqlDataSource DataSource() => + NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); private async Task CleanUpAsync() { @@ -196,12 +266,29 @@ private async Task CleanUpAsync() } } - private async Task ScalarAsPlatformAsync(string sql, object parameter) + /// A count, under the platform role, with one named parameter. + /// + /// The name is passed rather than inferred from the value's type. Inferring it bound + /// every shape but Guid[] as "tenant", so a caller adding a third + /// parameter shape got a silent mis-binding instead of a compile error. + /// + private async Task ScalarAsPlatformAsync( + string sql, string parameterName, object value) { await using var platform = await PostgresFixture.OpenAsync( _schema.Postgres.PlatformConnectionString); await using var query = new NpgsqlCommand(sql, (NpgsqlConnection)platform); - query.Parameters.AddWithValue(parameter is Guid[]? "ids" : "tenant", parameter); + query.Parameters.AddWithValue(parameterName, value); + + return (long)(await query.ExecuteScalarAsync())!; + } + + private async Task CountAsPlatformAsync(string sql, string host) + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var query = new NpgsqlCommand(sql, (NpgsqlConnection)platform); + query.Parameters.AddWithValue("host", host); return (long)(await query.ExecuteScalarAsync())!; } diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyCommandGuardTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyCommandGuardTests.cs new file mode 100644 index 00000000..e3423d1c --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyCommandGuardTests.cs @@ -0,0 +1,221 @@ +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using OrganizationAggregate = LearnStack.Modules.Tenancy.Domain.Organization; + +namespace LearnStack.Tests.Unit.Modules.Tenancy; + +/// +/// What the two tenant-owned commands do when the context they trust is not resolved. +/// +/// +/// +/// Unreachable through the pipeline, and tested anyway. Neither command carries +/// [AllowsUnresolvedTenantContext], so TenantContextBehavior refuses an +/// unresolved context three steps before either handler runs. The guards exist for the +/// wiring change that loses that behavior — and a guard no test kills is a comment, so +/// these drive the handlers directly, which is precisely the shape such a change takes. +/// +/// +/// The assertion is the HTTP status, not the key. The failure mode being prevented +/// is specific: HttpStatusMap is a closed table of cross-cutting codes, so a +/// module-specific key falls through it to 500 — and a fail-closed guard answering +/// 500 is the one answer it must not give. Measured: with the key changed to one of +/// this module's own, every other case in the solution still passed. +/// +/// +public sealed class TenancyCommandGuardTests +{ + private static readonly TenantId Tenant = + TenantId.From(Guid.Parse("0199b000-0000-7000-8000-000000000001")); + + [Fact] + public async Task Creating_an_organization_without_a_tenant_refuses_with_a_mapped_code() + { + var handler = Resolve(); + + var result = await handler.Handle( + new CreateOrganizationCommand( + OrganizationId.From(Guid.CreateVersion7()), "branch", "Branch"), + CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + HttpStatusMap.For(result.Error!.Code).Should().NotBe( + StatusCodes.Status500InternalServerError, + "a guard that fails closed must answer something the caller can read"); + result.Error.Code.Should().Be("tenant_mismatch", + "which is what TenantContextBehavior itself returns for this condition"); + } + + [Fact] + public async Task Mapping_a_host_without_a_tenant_refuses_with_a_mapped_code() + { + // The sharper of the two: this command writes the row that decides whose data an + // anonymous request sees. A guard here that answered 500 would turn a wiring + // mistake into an incident instead of a refusal. + var handler = Resolve(); + + var result = await handler.Handle( + new MapHostToTenantCommand("nowhere.example.com"), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + HttpStatusMap.For(result.Error!.Code).Should().NotBe( + StatusCodes.Status500InternalServerError); + result.Error.Code.Should().Be("tenant_mismatch"); + } + + [Fact] + public async Task A_host_the_deployment_reserved_is_refused_rather_than_written_inert() + { + // ADR-0036: a host on Tenancy:PlatformHosts classifies PlatformHost before the + // resolver is called at all, so a platform_host_to_tenant row naming it is inert — + // "never read, never logged, never counted". The precedence is correct; what is + // wrong is that the losing row is SILENT, so the deployment that created one gets + // no signal. ADR-0036 assigns the check to whichever packet builds the writer, + // and Packet 7 is that packet. + var handler = Resolve( + Tenant, reserved: "app.learnstack.dev"); + + var result = await handler.Handle( + new MapHostToTenantCommand("APP.learnstack.dev.", IsActive: true), + CancellationToken.None); + + result.IsFailure.Should().BeTrue( + "the row would exist and do nothing, which is worse than a refusal"); + result.Error!.Details.Should().ContainKey(nameof(MapHostToTenantCommand.Host)) + .WhoseValue.Should().ContainSingle() + .Which.Key.Should().Be("lockey_host_reserved"); + + // The comparison is against normalized hosts, which is why the check runs after + // the aggregate normalizes rather than on the raw request value. An uppercase, + // trailing-dot spelling of a reserved host is the same host. + } + + [Fact] + public async Task A_mapped_host_stops_being_negative_cached() + { + // The negative cache remembers hosts that resolved to nothing. Until this packet + // no writer of platform_host_to_tenant existed, so the TTL was the whole + // mechanism and a host loaded once before it existed kept its 404 for the rest of + // that window — which is precisely what a developer meets after seeding. + var invalidated = new RecordingInvalidator(); + var handler = Resolve( + Tenant, invalidator: invalidated); + + var result = await handler.Handle( + new MapHostToTenantCommand("TÜRKÇE.example.com", IsActive: true, IsPubliclyLive: true), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + invalidated.Hosts.Should().ContainSingle() + .Which.Should().Be("xn--trke-2oa7j.example.com", + "the cache is keyed on what a request's Host header normalizes to, so " + + "forgetting any other spelling forgets nothing"); + } + + /// + /// The handler alone, with no pipeline in front of it. + /// + /// + /// Resolved rather than constructed: both handlers are internal, and widening + /// that to reach them from a test would be a visibility change made for the test's + /// convenience. Resolution also proves they are discoverable by the assembly scan the + /// composition root relies on. + /// + private static IRequestHandler> Resolve( + TenantId? tenant = null, + string? reserved = null, + IHostResolutionInvalidator? invalidator = null) + where TCommand : IRequest> + { + var services = new ServiceCollection(); + services.AddMediatR(configuration => configuration.RegisterServicesFromAssembly( + typeof(ITenantWriteStore).Assembly)); + services.AddSingleton(new FixedClock( + new DateTimeOffset(2026, 9, 3, 9, 0, 0, TimeSpan.Zero))); + services.AddSingleton( + tenant is { } resolved ? new ResolvedContext(resolved) : UnresolvedTenantContext.Instance); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(new AcceptingHostStore()); + services.AddSingleton( + reserved is null ? NoReservedHosts.Instance : new OneReservedHost(reserved)); + services.AddSingleton(invalidator ?? NullHostResolutionInvalidator.Instance); + + return services.BuildServiceProvider() + .GetRequiredService>>(); + } + + /// + /// Every collaborator a handler could reach past its guard, each of which throws. + /// + /// + /// Throwing rather than recording: the property under test is that the guard returns + /// before any of them is touched, and a no-op fake would let a handler that skipped + /// the guard still produce a plausible-looking failure further down. + /// + private sealed class RefusingStore + : IOrganizationWriteStore, IPlatformHostMappingStore, IOrganizationScopeValidator + { + public Task AddAsync(OrganizationAggregate aggregate, CancellationToken ct = default) => + throw new InvalidOperationException("the guard should have returned first"); + + public Task UpdateAsync(OrganizationAggregate aggregate, CancellationToken ct = default) => + throw new InvalidOperationException("the guard should have returned first"); + + public Task AddAsync(PlatformHostMapping mapping, CancellationToken ct = default) => + throw new InvalidOperationException("the guard should have returned first"); + + public Task BelongsToTenantAsync( + TenantId tenantId, OrganizationId organizationId, CancellationToken ct = default) => + throw new InvalidOperationException("the guard should have returned first"); + } + + /// Accepts the write, so the cases past the guards can reach their subject. + private sealed class AcceptingHostStore : IPlatformHostMappingStore + { + public Task AddAsync(PlatformHostMapping mapping, CancellationToken ct = default) => + Task.CompletedTask; + } + + private sealed class OneReservedHost(string host) : IReservedHostRegistry + { + public bool IsReserved(string normalizedHost) => + string.Equals(normalizedHost, host, StringComparison.Ordinal); + } + + private sealed class RecordingInvalidator : IHostResolutionInvalidator + { + public List Hosts { get; } = []; + + public void Invalidate(string normalizedHost) => Hosts.Add(normalizedHost); + } + + private sealed class ResolvedContext(TenantId tenantId) : ITenantContext + { + public bool IsResolved => true; + + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + + public TenantId TenantId => tenantId; + + public OrganizationId? OrganizationId => null; + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => "tenancy"; + } +} diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 67934d60..1207d695 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -259,9 +259,9 @@ resolver reads `platform_host_to_tenant` and nothing else. flowchart LR subgraph Tenancy DOM[Domain
Tenant, Organization] - CON[Application.Contracts
ProvisionTenantCommand] - APP[Application
handler, validator,
ITenantWriteStore, IOrganizationWriteStore] - INF[Infrastructure
TenancyDbContext,
TenantWriteStore, OrganizationWriteStore] + CON[Application.Contracts
ProvisionTenant, CreateOrganization,
MapHostToTenant] + APP[Application
3 handlers + validators,
ITenantWriteStore, IOrganizationWriteStore,
IPlatformHostMappingStore] + INF[Infrastructure
TenancyDbContext,
3 write stores] end SK[SharedKernel
TenantId, OrganizationId, IUnitOfWork] CORE[Core Infrastructure
TenantScopedDbContext] @@ -281,10 +281,11 @@ flowchart LR ``` Text fallback — **components**: Tenancy is four assemblies — `Domain` (the -`Tenant` and `Organization` aggregates), `Application.Contracts` -(`ProvisionTenantCommand`, the first type to land there), `Application` (its -handler, its validator, and the `ITenantWriteStore` / `IOrganizationWriteStore` -ports) and `Infrastructure` (`TenancyDbContext` and the two write stores). `Domain` depends on `SharedKernel` for `TenantId`, +`Tenant` and `Organization` aggregates), `Application.Contracts` (three commands — +`ProvisionTenant`, `CreateOrganization`, `MapHostToTenant`), `Application` (their +handlers and validators, and the `ITenantWriteStore` / `IOrganizationWriteStore` / +`IPlatformHostMappingStore` ports) and `Infrastructure` (`TenancyDbContext` and the +three write stores). `Domain` depends on `SharedKernel` for `TenantId`, `OrganizationId` and `IUnitOfWork`; `Application` on `Domain`; `Infrastructure` on `Application`, on core `LearnStack.Infrastructure` — where `TenantScopedDbContext`, the base `TenancyDbContext` derives from, applies the diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 158c0787..48d723ca 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -3,13 +3,25 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). -Two of the operations below now exist — `Tenant` create and `Organization` +Four of the operations below now exist. `Tenant` create and `Organization` create, written together by `ProvisionTenantCommand` ([ADR-0042](../../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)) -— and the rest are still classification ahead of code. Both are **MUST**, both -are written on the one transaction that provisioning is, so +; a second `Organization` create, written alone by `CreateOrganizationCommand`; +and `platform_host_to_tenant` write, by `MapHostToTenantCommand`. The rest are +still classification ahead of code. + +All four are **MUST**. The two provisioning writes share one transaction, so [ADR-0033](../../decisions/0033-audit-durability-model.md)'s guarantee for them is -the ordinary one: the two rows commit with the two aggregates or nothing does. +the ordinary one — the rows commit with the aggregates or nothing does. The other +two are each their own transaction, and the guarantee is the same shape for each. + +**All four are unaudited today**, and the host mapping is the one that matters +most: it is described in the matrix below as "the row that decides whose data an +anonymous request sees", and nothing records who pointed a hostname where. +`AuditLogBehavior` lights up in +[Packet 9](../../roadmap/phase-02a-kernel-tenancy.md), and `TransactionBehavior` +carries the `TODO(2026-08-28, @platform, phase-02a-packet-9)` marking the line the +MUST-class write goes on, immediately before the commit. This matrix is not the floor — [Audit Coverage § Baseline Coverage](../../standards/18-audit-coverage.md) is, and a module matrix "cannot remove anything in this list". This file adds rows beneath that baseline and classifies what the baseline leaves open; a tenant @@ -42,7 +54,4 @@ optional. The classification is inert until [Packet 9](../../roadmap/phase-02a-kernel-tenancy.md) lights up `AuditLogBehavior`, and Packet 9 transcribes its in-process catalogue -from this file. **Provisioning is therefore unaudited today**, and that is a gap -with an owner rather than an accepted state: `TransactionBehavior` carries the -`TODO(2026-08-28, @platform, phase-02a-packet-9)` marking the line the MUST-class -write goes on, immediately before the commit. +from this file. diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 399c0578..1ade3a52 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -11,21 +11,34 @@ exists in `backend/src` yet; the catalogue lands with the Identity module in [Phase 03](../../roadmap/phase-03-identity-admin.md), together with `Role`, `Permission` and the lighting-up of the `AuthorizationBehavior` shell. -**One handler exists and is deliberately unauthorized.** Packet 7 ships -`ProvisionTenantCommand`, and there is nothing for a permission check to read: it -runs with an **unresolved** tenant context by construction — that is what lets it -announce the tenant it is creating — and it attributes the write to -`UserId.SystemActor`, because provisioning precedes any membership in the tenant -being provisioned. What stands in for authorization today is reachability: the -command has no HTTP endpoint, so its only callers are the seeder and, from +**Three handlers exist and all are deliberately unauthorized**, for two different +reasons. + +`ProvisionTenantCommand` has nothing for a permission check to read: it runs with +an **unresolved** tenant context by construction — that is what lets it announce +the tenant it is creating — and attributes the write to `UserId.SystemActor`, +because provisioning precedes any membership in the tenant being provisioned. + +`CreateOrganizationCommand` and `MapHostToTenantCommand` do run resolved, so the +first argument does not transfer to them. What stands in for authorization is the +same thing for all three: reachability. None has an HTTP endpoint, so their only +callers are the seeder and, from [Phase 02c](../../roadmap/phase-02c-hub-foundation.md), the Hub over `/api/internal/*` — a surface that takes `learnstack-hub` realm tokens and no -others. `tenancy.tenant.admin` is the key that will govern it, registered with -the rest in Phase 03. +others. Both take their tenant from the context and never from the request, so a +caller cannot name another tenant even without a permission check; the database +refuses the write. + +**`MapHostToTenantCommand` is the one to gate first.** It writes the row that +decides whose data an anonymous request sees, which makes it the highest-value +write in the module and the reason `tenancy.tenant.admin` — not +`tenancy.tenant.write` — is the key that will govern it. The three keys are +registered with the rest in Phase 03. | Resource | read | write | delete | admin | Default role grants | |----------|:----:|:-----:|:------:|:-----:|---------------------| | `Tenant` | ✓ | ✓ | – | ✓ | tenant-admin: read+write; platform operator: admin | +| `HostMapping` | ✓ | – | – | ✓ | platform operator: admin. **No `write`**: pointing a hostname at a tenant is an admin-scope act, and a `write` grant would put the resolution index inside the everyday tenant-admin role | | `Organization` | ✓ | ✓ | ✓ | ✓ | tenant-admin: all; org-admin: read+write (own) | | `TenantDomain` | ✓ | ✓ | ✓ | – | tenant-admin: all | | `TenantSetting` | ✓ | ✓ | ✓ | – | tenant-admin: all; org-admin: own organization only | diff --git a/docs/roadmap/phase-01-repository-tooling.md b/docs/roadmap/phase-01-repository-tooling.md index a0649986..ba781f3b 100644 --- a/docs/roadmap/phase-01-repository-tooling.md +++ b/docs/roadmap/phase-01-repository-tooling.md @@ -103,7 +103,7 @@ > | Where | What it says | What is true now | > |---|---|---| > | § Frontend Scaffold | The operator portal is `learnstack-hub-web` | The app is **`operator-portal`** (`frontend/apps/operator-portal` in the Hub repository, asserted by its `Frontend_Has_Only_The_OperatorPortal_App` test). The name was renamed corpus-wide; this line is left as the historical record | -> | § Deliverables | "`make seed` populating two demo tenants + one platform admin user" | `scripts/seed.sh` seeds **Keycloak identity only**. Application-level tenant seeding was always a documented drop-in for Phase 02a, and now lands in [Packet 7](phase-02a-kernel-tenancy.md) with two tenants — an English school and a **yoga studio** | +> | § Deliverables | "`make seed` populating two demo tenants + one platform admin user" | Landed in [Packet 7](phase-02a-kernel-tenancy.md) — two tenants, an English school and a **yoga studio**, each with two organizations and a host row. **Not** the platform-admin user: Packet 7 creates no `users` table (Phase 03's Identity migration owns it) and `UserId.SystemActor` is a CLR constant with no row behind it | > | § Completion Criteria | "CI passes on `main`" | True, but the frontend job passes with **zero tests** (`vitest run --passWithNoTests` against no test files). [Packet 3b](phase-02a-kernel-tenancy.md) makes a zero test count a failure; the first real tests arrive with [Phase 02d](phase-02d-walking-skeleton.md) | > | § Local Infrastructure | The 14-service compose stack is the development environment | Per [ADR-0035](../decisions/0035-demand-gated-infrastructure.md), **Dapr, Kafka, APISIX and Vault move behind a non-default compose profile** in [Packet 5](phase-02a-kernel-tenancy.md). Their ports ship with in-process defaults; the adapters land in [Phase 11](phase-11-production-hardening.md) against written triggers. The daily loop runs roughly seven services | > | § CI Baseline | OpenAPI diff activates in Phase 03; Lighthouse in Phase 04 | Both move earlier: [Phase 02d](phase-02d-walking-skeleton.md) ships the first real `/api/v1/*` endpoints **and** the first content-bearing public pages | diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 1757921a..90872cf2 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -555,8 +555,15 @@ tenants for isolation testing — the marginal cost is the second tenant's customization data, and the marginal benefit is that every phase from [Phase 02d](phase-02d-walking-skeleton.md) onward is tested against two shapes instead of one. Picks up the application-level seed drop-in deferred from -[Phase 01 Packet 8](phase-01-repository-tooling.md), wired through the Tenancy -module `DbContext` rather than the placeholder `scripts/seed.sh`. +[Phase 01 Packet 8](phase-01-repository-tooling.md). Shipped one layer above what +this sentence originally asked for: `LearnStack.Tools.Seeder` sends +`ProvisionTenantCommand`, `CreateOrganizationCommand` and `MapHostToTenantCommand` +rather than reaching for the module `DbContext`, because +[ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) +requires it — a seeder writing the tenant and its default organization itself +would be a second copy of the one sanctioned cross-aggregate write. `scripts/seed.sh` +is no longer a placeholder; it invokes the tool and refuses any role but +`learnstack_app`. The two are `demo-english` ("English Hero") and `demo-yoga` ("Anatolia Yoga"), on `demo-english.learnstack.local` and `demo-yoga.learnstack.local`. **Packet 7 writes @@ -1248,7 +1255,9 @@ land in Phase 02b. PostgreSQL Row Level Security template, and the four-role database model active for both dimensions. - **Two seed tenants in unrelated domains** (an English school and a yoga studio), each - with two organizations, seeded through the Tenancy module's `DbContext`. + with two organizations, seeded through the Tenancy module's **commands** — see the + Packet 7 entry above for why the command path replaced the `DbContext` this line + originally named. - `LearnStack.Modules.Customization` with `TenantContentType` and `TenantLevelTaxonomy` plus their runtime read paths. - `LearnStack.Modules.Audit` aggregates + `LearnStack.Infrastructure.Audit` pipeline @@ -1310,9 +1319,11 @@ land in Phase 02b. `WITH CHECK` (`Tenant_A_Cannot_Repoint_Tenant_B_Host`). - All ten tenancy tables report `relrowsecurity` **and** `relforcerowsecurity` true in `pg_class`, with no exception list. -- `make dev`, `make seed` and `make test` succeed on a clean checkout — `make seed` - currently exits non-zero on every run, and that is a completion blocker, not a - nuisance. +- `make dev`, `make seed` and `make test` succeed on a clean checkout. `make seed` now + writes the two demo tenants; it reads `ConnectionStrings__Default` from the + environment or `.env` — the Makefile exports neither into a recipe — and refuses any + role but `learnstack_app`, because seeding as the owner would succeed with every + policy inert and prove nothing. - Architecture tests for tenant + org ownership, RLS structure, module-boundary direction, domain-neutral module naming, and the audit pipeline are not skippable. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 0238918c..bc115473 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -795,16 +795,23 @@ rules that need a second `DbContext` are owed by Phase 03. - **Asserts:** two halves. The composition root's persistence registration is run, and every `DbContext` service in it is one `AddModuleDbContext` registered — scoped, from an implementation factory, never a type registration EF could give - its own connection. And under `backend/src`, exactly three files may mention - `UseNpgsql` or `AddDbContext` at all: the two design-time factories, where a - connection string is the point, and the shared helper, which passes a - *connection*. A fourth is a new decision. A context on its own connection never - saw `SET LOCAL`, so every read through it returns zero rows under the corrected - policy — silently. + its own connection. And under `backend/src`, exactly **five** files may reach for a + connection at all: the two design-time factories, where a connection string is the + point; the shared helper, which passes a *connection*; and the two composition roots — + `LearnStack.Api`'s, which builds the one application data source behind its credential + guard, and `LearnStack.Tools.Seeder`'s, which is the same act for a host with no HTTP + surface. A sixth is a new decision. A context on its own connection never saw the + announcement, so every read through it returns zero rows under the corrected policy — + silently. + + The set is keyed on `directory/filename`, not the bare filename: two `Program.cs` now + exist under `backend/src`, and a bare-name set would let the API's silently take the + seeder's slot. - **Source:** ADR-0040; [05-database.md § Forbidden](05-database.md). - **Type:** xUnit + DI registration inspection and a source scan. **Kind:** structural. -- **Status:** **Implemented** (Packet 6 step 6, - `LearnStack.Tests.Architecture`, `PersistenceConventionTests`). +- **Status:** **Implemented** (Packet 6 step 6; the allow-list widened to five and + keyed by directory in Packet 7 step 10, `LearnStack.Tests.Architecture`, + `PersistenceConventionTests`). #### `TransactionBehavior_Does_Not_Reference_A_Module_Assembly` diff --git a/scripts/seed.sh b/scripts/seed.sh index 44cbfa44..90a11fd1 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -196,19 +196,47 @@ cyan "▶ Step 3/3: seeding the two demo tenants" # The application role, not the migration role. The seeder writes through the # same policies a request does, so a seed that succeeds is evidence the request -# path works — and a seed run as the owner would pass with every policy inert. -SEED_CONNECTION="${ConnectionStrings__Default:-}" +# path works — and a seed run as the owner would pass with every policy inert, +# which is the failure mode ADR-0003 Amendment 3 names by role. +# +# Read from the environment, then from `.env`. The Makefile does not export +# `.env` into the recipe's environment — `--env-file` feeds docker compose's own +# variable substitution and nothing else — so on the documented first-run path +# (`make install` → `make dev` → `make migrate` → `make seed`) the variable is +# unset and only this fallback finds it. `make migrate` learned the same lesson +# and this mirrors its reader, CR-strip and quote-strip included: .env.example +# single-quotes the values. +seed_cs="${ConnectionStrings__Default:-}" +if [[ -z "$seed_cs" && -f .env ]]; then + seed_cs=$(sed -n "s/^ConnectionStrings__Default=//p" .env \ + | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//") +fi + +if [[ -z "$seed_cs" ]]; then + red "seed: ConnectionStrings__Default is not set and .env does not carry it." + red " It arrives with the four-role model in Phase 02a Packet 6: copy the" + red " 'four connection strings' block out of .env.example into your .env" + red " and re-run. A .env written before that packet has neither." + exit 1 +fi + +seed_role=$(printf '%s' "$seed_cs" | awk -f scripts/connection-string.awk -v field=user) +seed_redacted=$(printf '%s' "$seed_cs" | awk -f scripts/connection-string.awk -v field=redacted) -if [[ -z "$SEED_CONNECTION" ]]; then - red "seed: ConnectionStrings__Default is not set." - red " It lives in .env — copy .env.example to .env and re-run, or export it." +if [[ "$seed_role" != "learnstack_app" ]]; then + red "seed: ConnectionStrings__Default names Username='$seed_role', not learnstack_app:" + red " $seed_redacted" + red "Seeding runs through the runtime role on purpose. As learnstack_migration" + red "or learnstack_platform every policy is bypassed, the seed succeeds without" + red "proving anything, and the first real request is where you find out." exit 1 fi -# --nologo keeps the build banner out of a script whose output is read as a -# report; the exit code is what gates the step either way. -if ! dotnet run --project backend/src/LearnStack.Tools.Seeder --nologo -- \ - --connection-string "$SEED_CONNECTION"; then +# Passed in the environment, not on argv: the value carries the database +# password, and an argument is visible to any local user through `ps`. The +# seeder reads this variable when no --connection-string flag is given. +if ! ConnectionStrings__Default="$seed_cs" \ + dotnet run --project backend/src/LearnStack.Tools.Seeder --nologo; then red "seed: tenant seeding failed." red " Has the schema been applied? → make migrate" exit 1 @@ -224,8 +252,8 @@ cat <<'HOSTS' 127.0.0.1 demo-english.learnstack.local 127.0.0.1 demo-yoga.learnstack.local - demo-english's host maps to its default organization; demo-yoga's maps to - the tenant as a whole, so both live host classifications are exercised. + demo-english's host maps to the tenant as a whole; demo-yoga's maps to its + default organization, so both live host classifications are exercised. HOSTS From b8f723fb70fd9e188285adba5af04e960cd18d33 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 11:57:27 +0300 Subject: [PATCH 36/55] fix(tenancy): stop the seeder reading a refusal as "already seeded" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 of Step 10's review. Both findings were in round 1's own fixes. Idempotency-by-conflict keyed on the top-level error code, which was a safe proxy while provisioning was the only command: every cause of business_rule_violation really was "this row exists". MapHostToTenantCommand broke that in the same round — it returns the same code for a host already taken, an organization that is not this tenant's, and a host the deployment reserved — and only the first means there is nothing to do. So a wrong organization id in SeedData, the plausible copy-paste between two tenants declared side by side, made the seeder log "already present", exit 0, and never write the row that decides whose data an anonymous request sees. The classification now reads the field-level reason. That branch was unreachable from a test because the runner read SeedData directly, which is how the defect survived a round; RunAsync now takes the tenants, and SeedComposition takes the reserved hosts. The cache invalidation ran before the commit, not after, so its comment claimed a guarantee the placement does not give: TransactionBehavior commits after the handler returns, and a request arriving in between still misses the uncommitted row and re-caches the miss. What the call does guarantee is the case that actually happens — the request after the write. Closing the rest needs a post-commit seam on IUnitOfWork, whose surface ADR-0040 governs, so it is an amendment rather than an edit and it is named with the obligation that will owe it. Also: the local-dev-setup skill told the reader `make seed` does not apply migrations, in the same commit that made it depend on migrate. Module: Tenancy ADR: 0036, 0040 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/local-dev-setup/SKILL.md | 12 ++--- .../SeedComposition.cs | 17 ++++-- .../src/LearnStack.Tools.Seeder/SeedRunner.cs | 53 +++++++++++++++++-- .../Tenant/MapHostToTenantCommandHandler.cs | 25 ++++++--- .../Database/SeederTests.cs | 49 +++++++++++++++++ 5 files changed, 133 insertions(+), 23 deletions(-) diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 4c748951..da208f07 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -196,10 +196,9 @@ docker compose --env-file .env -f infra/compose/dev.yml config --format json ### Step 4: First-run bootstrap -`make seed` verifies the stack is healthy, checks the two Keycloak realms, and — -since Phase 02a Packet 7 — writes the two demo tenants. It does **not** apply -migrations: run `make migrate` first, or the seeder exits non-zero against a -database with no schema. +`make seed` brings the stack up, applies migrations (it depends on `migrate`), +checks the two Keycloak realms, and — since Phase 02a Packet 7 — writes the two +demo tenants. One command from a clean checkout. What it writes today: @@ -226,7 +225,8 @@ What it does not write yet, and which phase owns each: The seed is idempotent: a second run recognises its own first by the uniqueness refusal and exits 0. There is no separate reset target — for fresh local data use -the destructive `make clean`, then `make migrate` and `make seed`. +the destructive `make clean`, then `make seed`, which re-applies the migrations on +its way through. ### Step 5: Verify @@ -286,7 +286,7 @@ dotnet run --project backend/src/LearnStack.Api | `Bind for 127.0.0.1:5432 failed: port is already allocated` | Stop your local Postgres, or stop the other compose project holding the port — host ports are fixed in `dev.yml`, so two projects cannot both bind them. | | `relation "tenants" does not exist` | The owning Tenancy migrations have not landed or were not applied; check the active phase plan before adding an ad-hoc target. | | `unable to read app.tenant_id` | The `DbCommandInterceptor` tenant-context guard is unwired, or `TransactionBehavior` did not issue the `SET LOCAL` pair. It is deliberately **not** a connection-checkout interceptor — checkout precedes `BEGIN`. | -| Keycloak realm not found | Recreate local data with destructive `make clean`, then `make migrate` and `make seed`. The realms are imported at compose boot from `infra/keycloak/realms/`, not by the seeder. | +| Keycloak realm not found | Recreate local data with destructive `make clean`, then `make seed`. The realms are imported at compose boot from `infra/keycloak/realms/`, not by the seeder. | | Web app shows raw i18n keys | i18n bundle build skipped; `pnpm build:i18n`. | | Hub-backed mode hangs | The `learnstack-hub` repo's stack isn't up; start it or switch to `Development`. | | LiveKit join fails with TURN error | coturn not reachable from the browser; check firewall + container network. | diff --git a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index 122e0cfc..9b4c1ae2 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -39,8 +39,16 @@ namespace LearnStack.Tools.Seeder; /// public static class SeedComposition { + /// + /// The deployment's own hosts, which no tenant may map. Defaults to none: a one-shot + /// process binds no configuration, and a seeder that guessed at the API's list would + /// be asserting something it cannot know. A caller that does know passes it. + /// public static ServiceProvider Build( - NpgsqlDataSource dataSource, ITenantContext? context, ILoggerFactory loggerFactory) + NpgsqlDataSource dataSource, + ITenantContext? context, + ILoggerFactory loggerFactory, + IReservedHostRegistry? reservedHosts = null) { ArgumentNullException.ThrowIfNull(dataSource); ArgumentNullException.ThrowIfNull(loggerFactory); @@ -69,10 +77,9 @@ public static ServiceProvider Build( services.AddSingleton(new Lazy(() => dataSource)); services.AddSingleton(); - // The seeder reserves no hosts and fronts no cache: it is a one-shot process with - // no configuration bound and nothing in memory to go stale. Both defaults answer - // truthfully for that host rather than approximating the API's. - services.AddSingleton(NoReservedHosts.Instance); + // Fronts no cache: a one-shot process has nothing in memory to go stale, and the + // default answers truthfully for that host rather than approximating the API's. + services.AddSingleton(reservedHosts ?? NoReservedHosts.Instance); services.AddSingleton(NullHostResolutionInvalidator.Instance); services.AddLearnStackMediatRPipeline(typeof(ITenantWriteStore).Assembly); diff --git a/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs b/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs index df5f173d..f62b3af9 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs @@ -49,13 +49,33 @@ namespace LearnStack.Tools.Seeder; /// by policy. So the seeder writes and treats a uniqueness refusal as "already seeded", /// which is the same answer with one fewer round trip and no race. /// +/// +/// A uniqueness refusal, read from the field-level reason — not from the +/// top-level code. business_rule_violation was a safe proxy while provisioning +/// was the only command: every cause of it really was "this row exists". It stopped being +/// one when MapHostToTenantCommand landed, which returns the same top-level code +/// for a host already taken, an organization that is not this tenant's, and a host the +/// deployment reserved. Only the first is "already seeded". Measured: with the proxy in +/// place, a wrong organization id in SeedData — a plausible copy-paste between two +/// tenants declared side by side — made the seeder log "already present", exit 0, and +/// never write the row that decides whose data an anonymous request sees. +/// /// public sealed class SeedRunner( Func compose, ILogger logger) { - public async Task RunAsync(CancellationToken cancellationToken) + /// Seeds , defaulting to the two demo tenants. + /// + /// The list is a parameter rather than a direct read of so + /// the classification below can be driven with data that fails for a reason other + /// than uniqueness. Without it the only way to reach that branch was to edit the + /// shipped seed, and the branch went untested — which is how the masking defect it + /// now guards against survived a review round. + /// + public async Task RunAsync( + CancellationToken cancellationToken, IReadOnlyList? tenants = null) { - foreach (var tenant in SeedData.All) + foreach (var tenant in tenants ?? SeedData.All) { await SeedTenantAsync(tenant, cancellationToken); } @@ -148,9 +168,10 @@ private async Task SendAsync( } // A uniqueness refusal is what a second run looks like, and it is the expected - // outcome of one. Anything else — a validation failure, a policy denial — is a - // seed that did not do its job, and the process exits non-zero on it. - if (result.Error!.Code == "business_rule_violation") + // outcome of one. Anything else — a validation failure, a policy denial, an + // organization that is not this tenant's — is a seed that did not do its job, and + // the process exits non-zero on it. + if (IsAlreadySeeded(result.Error!)) { SeedRunnerLog.AlreadyPresent(logger, what, tenant.Slug); return; @@ -160,6 +181,28 @@ private async Task SendAsync( $"Seeding the {what} for '{tenant.Slug}' failed with '{result.Error.Code}'. " + "The seed is not idempotent past this point; fix the cause and re-run."); } + + /// + /// Whether says the row this act writes already exists. + /// + /// + /// Read from the field-level reasons rather than the top-level code, because the top + /// level says only business_rule_violation and three different conditions + /// produce it. These three are the uniqueness ones; a fourth reason under the same + /// code — lockey_organization_not_in_tenant, lockey_host_reserved — + /// deliberately falls through to the throw. + /// + private static bool IsAlreadySeeded(Error error) => + error.Details is { } details + && details.Values.SelectMany(reasons => reasons).Any(reason => + AlreadyExists.Contains(reason.Key)); + + private static readonly HashSet AlreadyExists = new(StringComparer.Ordinal) + { + "lockey_slug_taken", + "lockey_identifier_taken", + "lockey_host_taken", + }; } /// Source-generated logging, per the house CA1848 rule. diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs index 6ea47ec6..4c19d391 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs @@ -119,14 +119,25 @@ [new LocalizedMessage("lockey_host_taken")], } // The negative cache remembers hosts that resolved to nothing, and this host just - // stopped being one. Without it the TTL is the whole mechanism and a host loaded - // once before it existed keeps its 404 for the rest of that window — which is - // exactly what a developer meets after seeding. + // stopped being one. Without this call the TTL is the whole mechanism and a host + // loaded once before it existed keeps its 404 for the rest of that window — which + // is exactly what a developer meets after seeding. // - // After the write, and deliberately not inside a transaction hook: the row is not - // visible to another connection until the commit, so forgetting earlier would let - // a concurrent request re-cache the miss it was about to fix. Forgetting a host - // that then fails to commit costs one extra database read. + // It runs BEFORE the commit, and the guarantee is correspondingly narrower than + // "the window is closed". TransactionBehavior commits after the handler returns, + // so a concurrent request arriving between this line and that COMMIT still misses + // the uncommitted row and re-caches the miss with a fresh TTL. What this does + // guarantee is the case that actually happens: the request AFTER the write, which + // is the seeded-host-in-a-browser one. + // + // Closing the remainder needs a post-commit seam on IUnitOfWork, whose surface + // [ADR-0040](../../../../../../docs/decisions/0040-ambient-unit-of-work.md) + // governs — so it is an amendment, not an edit, and it is owed by the first + // obligation that cannot tolerate the gap. The outbox dispatch in + // [Phase 02b](../../../../../../docs/roadmap/phase-02b-events-auth.md) is that + // obligation; this call joins it there. Until then the residual window is bounded + // by the same TTL it exists to shorten, so the failure mode is the old one for a + // few milliseconds rather than a new one. resolutionCache.Invalidate(mapping.Host); return Result.Ok(new HostMappingDto( diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs index e419d334..82b988ae 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs @@ -227,6 +227,48 @@ public async Task A_host_naming_another_tenants_organization_is_refused_not_cras .Should().Be(0L, "and nothing was written"); } + [Fact] + public async Task A_conflict_that_is_not_a_uniqueness_refusal_still_stops_the_run() + { + // The sharp edge of idempotency-by-conflict. `business_rule_violation` was a safe + // proxy for "already seeded" while provisioning was the only command: every cause + // of it really was "this row exists". MapHostToTenantCommand broke that — it + // returns the same top-level code for a host already taken, an organization that + // is not this tenant's, and a host the deployment reserved — and only the first + // means there is nothing to do. + // + // Driven with a seed tenant whose host names an organization belonging to somebody + // else, which is the plausible mistake: the two tenants' organizations are + // declared side by side in SeedData, so a copy-paste puts one tenant's id under + // the other. With the top-level code as the test, the run logged "already + // present", exited 0, and never wrote the row that decides whose data an anonymous + // request sees. + await using var dataSource = DataSource(); + + // Driven through the reserved-host path, which reaches the same classification + // without breaking an earlier act: provisioning and the second organization both + // succeed, and only the host mapping is refused — with `lockey_host_reserved`, + // which is a `business_rule_violation` that emphatically does not mean the row is + // already there. + var runner = new SeedRunner( + context => SeedComposition.Build( + dataSource, context, NullLoggerFactory.Instance, + new OneReservedHost(SeedData.English.Host)), + NullLogger.Instance); + + var seed = async () => + await runner.RunAsync(CancellationToken.None, [SeedData.English]); + + (await seed.Should().ThrowAsync( + "a conflict that is not a uniqueness refusal means the seed did not do its job")) + .WithMessage("*host mapping*"); + + (await CountAsPlatformAsync( + "SELECT count(*) FROM platform_host_to_tenant WHERE host = @host", + SeedData.English.Host)) + .Should().Be(0L, "and the row was never written"); + } + // ── Harness ────────────────────────────────────────────────────────────── /// @@ -303,4 +345,11 @@ private async Task CountAsPlatformAsync(string sql, string host) return (await query.ExecuteScalarAsync()) as string; } + + /// A deployment that has reserved exactly one host. + private sealed class OneReservedHost(string host) : IReservedHostRegistry + { + public bool IsReserved(string normalizedHost) => + string.Equals(normalizedHost, host, StringComparison.Ordinal); + } } From 4182f13486577e0a32b7d240915931268d100de3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 12:07:05 +0300 Subject: [PATCH 37/55] test(tenancy): run the five isolation cases through a real request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 11. Packet 6 shipped all five against the schema, driven with set_config — statements about the migration and its policies. These drive the same five through HostClassificationMiddleware, TenantResolverMiddleware, TenantContextBehavior, TransactionBehavior's announcement and the EF query filters, which is the path a browser takes. A policy that holds under set_config and a resolver that never sets it would pass the first suite and fail every real request; only this one can tell them apart. Nothing here stubs ITenantContext. Every other HTTP fixture replaces it with a header-driven double, which is right for their subjects and fatal for this one: the tenant a request gets is the thing under test. The host header is the only input, and the data is what SeedRunner wrote, so these cases also answer whether the seed actually serves a request. Two design facts the framework taught rather than the plan anticipating them. A TenancyDbContext injected into a controller is refused at resolution — it would read zero rows from every tenant-owned table, silently — so the probe goes through ISender and the pipeline opens the transaction that announces the tenant. And the read query needs [PublicSurface]: a request carrying only a host resolves HostOnly, and the authority ceiling admits that origin for marked types alone. Without it every read returned 200 with an empty body, which is the ceiling working. That also settles the write case: an anonymous host-only request cannot create an organization at all, so the foreign tenant id in the body is refused twice over rather than once. The suite constrains the composite outcome, not any single layer, and says so: deleting both query filters leaves all five green because RLS alone holds. The filters are not unconstrained — the same mutation turns three other tests red — and the remark names them, so the next reader does not mistake defense in depth for a gap. Also swept the repository's Packet 7 comments: eight sites across src and tests claimed in the future tense what this packet has now shipped. Module: Tenancy ADR: 0003, 0036, 0040 Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/LearnStack.Api/Program.cs | 6 +- .../Tenancy/TenantAssertionMiddleware.cs | 9 +- .../Pipeline/TenantContextBehavior.cs | 10 +- .../Domain/AuditableEntity.cs | 4 +- .../Tenancy/IHostResolutionInvalidator.cs | 2 +- .../TenancyConventionTests.cs | 6 +- .../TenantAssertionHttpTests.cs | 6 +- .../TenantIsolationHttpTests.cs | 424 ++++++++++++++++++ .../Pipeline/TransactionBehaviorTests.cs | 7 +- 9 files changed, 451 insertions(+), 23 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Integration/TenantIsolationHttpTests.cs diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index 892b15b0..8ccb3d95 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -28,9 +28,9 @@ // X-Forwarded-For. builder.Configuration.RefuseAmbientForwardedHeaders(); -// The module assemblies MediatR scans for handlers. Empty until Packet 7 step 9, which -// ships the first production request type — and the parameter existed all along, so the -// change is one argument rather than a new seam. A module whose assembly is missing here +// The module assemblies MediatR scans for handlers. Tenancy's is here as of Packet 7, +// which shipped the first production request types — and the parameter existed all along, +// so the change was one argument rather than a new seam. A module whose assembly is missing here // has handlers nothing dispatches, which fails as "no handler for request" at the call // site rather than at startup. builder.AddLearnStackCrossCuttingFoundation( diff --git a/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs index 5a1bd796..6c1026f8 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs @@ -19,10 +19,11 @@ namespace LearnStack.Api.Tenancy; /// bypass a tenant boundary, and the rejection must precede handler work. /// /// -/// In Packet 4 nothing resolves a tenant — ITenantContext.IsResolved is -/// false until Packet 7's TenantResolverMiddleware — so the mismatch -/// path is unreachable in traffic and is exercised by tests over a stubbed -/// context. The comparison ships now because the binding does, and a binding +/// In Packet 4 nothing resolved a tenant, so the mismatch path was unreachable in +/// traffic and was exercised by tests over a stubbed context. Packet 7's +/// TenantResolverMiddleware made it reachable: ITenantContext.IsResolved +/// is now true for any request arriving on a mapped host. The comparison shipped +/// before the binding it guards because a binding /// whose rule arrives three packets later is a binding nobody wrote the rule /// for. /// diff --git a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs index 39a7431c..295f0445 100644 --- a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs @@ -103,11 +103,11 @@ public Task Handle( } // Nothing to do here for RLS, and nothing left undone elsewhere: - // TransactionBehavior issues the set_config pair at step 6. RLS is - // enforced today and fail-closed before Packet 7 — an unresolved context - // writes the empty string, so every predicate is NULL and every - // tenant-owned table returns zero rows. What Packet 7 supplies is a - // non-NULL predicate. + // TransactionBehavior issues the set_config pair at step 6. RLS was enforced and + // fail-closed before Packet 7 too — an unresolved context writes the empty + // string, so every predicate is NULL and every tenant-owned table returns zero + // rows. What Packet 7 added is a non-NULL predicate: TenantResolverMiddleware + // now gives that setter a tenant to write. // // Why it belongs there and not here: the GUCs are transaction-local // (set_config('app.tenant_id', ..., true)) and this behavior runs at diff --git a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs index 1c74fb52..7d2134dd 100644 --- a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs +++ b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs @@ -57,8 +57,8 @@ protected AuditableEntity() /// global query filters should gate on directly /// (e => e.DeletedAt == null) — is a /// computed CLR property and is NOT guaranteed to translate to SQL by - /// EF Core's expression translator. Packet 7 wires the filters - /// accordingly. + /// EF Core's expression translator. TenantQueryFilters wires them + /// accordingly, as of Packet 7. /// public bool IsDeleted => DeletedAt.HasValue; diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/IHostResolutionInvalidator.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IHostResolutionInvalidator.cs index 152fd7bc..53fb3eb8 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/IHostResolutionInvalidator.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IHostResolutionInvalidator.cs @@ -8,7 +8,7 @@ namespace LearnStack.SharedKernel.Tenancy; /// The invalidation /// ADR-0036 /// asks for "on the transaction that flips either flag". A host that resolved to -/// nothing is negative-cached, and until Packet 7 no writer of +/// nothing is negative-cached, and before Packet 7 no writer of /// platform_host_to_tenant existed, so the TTL was the whole of the mechanism — /// a host activated inside it kept its 404 for the rest of it. That is the exact symptom /// a developer meets when they load a seeded host once before running the seed. diff --git a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs index 66b8f2ed..a63910b4 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs @@ -15,9 +15,9 @@ namespace LearnStack.Tests.Architecture; /// /// Most of these are source scans, and that is a deliberate choice rather /// than a shortcut. Each rule is about a symbol not appearing outside one file — -/// a reflection or NetArchTest form would have to observe a call that has no -/// consumer yet, because the resolver that will read these values does not land -/// until Packet 7. A scan can hold the line from the day the symbol exists, +/// a reflection or NetArchTest form would have to observe a call, and these rules were +/// written before Packet 7's resolver gave the values a consumer. A scan holds the line +/// from the day the symbol exists, /// which is the day it can first be used wrongly. Where the type a rule names /// now exists, the rule adds a reflection check alongside the scan rather than /// replacing it: the two catch different mistakes. diff --git a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs index 365070d5..61743677 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs @@ -26,8 +26,10 @@ namespace LearnStack.Tests.Integration; /// Packet 4 resolves nothing, so the fixture substitutes a resolved /// ITenantContext — which is what makes the mismatch path reachable at /// all. That substitution is the test's subject, not a shortcut: ADR-0036's -/// staging table says this comparison is "unreachable in traffic … and -/// exercised by unit tests over a stubbed context" until Packet 7. +/// staging table said this comparison was "unreachable in traffic … and exercised by +/// unit tests over a stubbed context" before Packet 7's resolver existed. It is reachable +/// now; the substitution stays because this suite drives the mismatch deliberately, and +/// TenantIsolationHttpTests is where the real resolver answers. /// public sealed class TenantAssertionHttpTests(ResolvedTenantFixture fixture) : IClassFixture diff --git a/backend/tests/LearnStack.Tests.Integration/TenantIsolationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/TenantIsolationHttpTests.cs new file mode 100644 index 00000000..06864a6b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenantIsolationHttpTests.cs @@ -0,0 +1,424 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.Tests.Integration.Database; +using LearnStack.Tools.Seeder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.AspNetCore.Mvc.Testing; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration; + +/// +/// The five isolation cases, re-run through a real request. +/// +/// +/// +/// What Packet 7 owns that Packet 6 did not. Packet 6 shipped all five against the +/// schema, driving them with set_config in a test — statements about the migration +/// and its policies. These drive the same five through +/// HostClassificationMiddleware, TenantResolverMiddleware, +/// TenantContextBehavior, TransactionBehavior's announcement and the EF +/// query filters, which is the path a browser takes. A policy that holds under +/// set_config and a resolver that never sets it would pass the first suite and fail +/// every real request; only this one can tell them apart. +/// +/// +/// Nothing here stubs ITenantContext. Every other HTTP fixture in this +/// project replaces it with a header-driven double, which is right for their subjects and +/// fatal for this one: the tenant a request gets IS the thing under test. The host header +/// is the only input, exactly as in production. +/// +/// +/// The data is the seed, not a fixture. The two demo tenants and their host rows +/// come from SeedRunner — the same code make seed runs — so these cases also +/// answer "does what the seeder writes actually serve a request?", which is the question +/// [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) asks in a browser. +/// +/// +/// No production endpoint ships in this packet. The reads go through a test-only +/// controller registered in the fixture, which is the precedent IdempotencyFixture +/// set for /api/v1/sideeffectprobe. What is production is everything beneath it. +/// +/// +/// What these cases constrain, and what they do not. They constrain the composite +/// outcome — the answer a request gets — not any single layer, and that is a property of +/// defense in depth rather than a weakness here: measured, deleting BOTH EF query filters +/// leaves all five green, because Row Level Security alone still holds. The filters are +/// not thereby unconstrained; the same mutation turns +/// Every_TenantOwned_Entity_HasFilterAndRlsPolicy, +/// Every_OrgScoped_Entity_HasOrgIdAndFilter and +/// A_context_follows_the_accessor_after_it_was_built red. Layer-by-layer coverage +/// lives there and in Packet 6's schema suite; what lives only here is the statement that +/// the layers, the resolver and the pipeline compose into the right answer for a real +/// request. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class TenantIsolationHttpTests : IClassFixture +{ + private readonly TenantIsolationFixture _fixture; + + public TenantIsolationHttpTests(TenantIsolationFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Tenant_A_cannot_read_Tenant_B_data() + { + // The first case, and the one every other layer exists to make redundant. Two + // requests differing only in the host they arrive on must not see each other's + // rows — and neither request names a tenant anywhere, which is the point: the + // tenant comes from the host, and the filter and the policy come from the tenant. + var english = await ReadOrganizationsAsync(SeedData.English.Host); + var yoga = await ReadOrganizationsAsync(SeedData.Yoga.Host); + + english.Should().NotBeEmpty(); + yoga.Should().NotBeEmpty(); + + english.Should().NotIntersectWith(yoga, + "two hosts, two tenants, one binary and one database"); + english.Should().BeEquivalentTo( + [SeedData.English.DefaultOrganization.Slug, SeedData.English.SecondOrganization.Slug]); + yoga.Should().BeEquivalentTo( + [SeedData.Yoga.DefaultOrganization.Slug, SeedData.Yoga.SecondOrganization.Slug]); + } + + [Fact] + public async Task Org_X_cannot_read_Org_Y_within_TenantA() + { + // The second dimension, and the one a tenant filter alone does not give. The yoga + // host carries an organization id, so a request arriving on it is scoped to that + // organization and must not see the tenant's other one — even though both belong + // to the tenant the request resolved. + var scoped = await ReadOrganizationsInScopeAsync(SeedData.Yoga.Host); + + scoped.Should().BeEquivalentTo([SeedData.Yoga.DefaultOrganization.Slug], + "the host names one organization, and the scope is the row it names"); + scoped.Should().NotContain(SeedData.Yoga.SecondOrganization.Slug); + + // And the tenant-wide host sees both, or the case above would pass against a + // filter that simply returns nothing. + (await ReadOrganizationsInScopeAsync(SeedData.English.Host)) + .Should().HaveCount(2, "a host with no organization is scoped to the tenant"); + } + + [Fact] + public async Task TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA() + { + // The exact case the superseded RLS template leaked. Two permissive policies are + // combined with OR, so a tenant-wide row — one whose organization_id is NULL — + // was visible to every tenant. Here it is the host mapping itself: demo-english's + // row has a null organization_id, and demo-yoga must not see it. + var seenByYoga = await ReadHostsAsync(SeedData.Yoga.Host); + + seenByYoga.Should().NotContain(SeedData.English.Host, + "a tenant-wide row belongs to its tenant, not to everyone"); + seenByYoga.Should().BeEquivalentTo([SeedData.Yoga.Host]); + } + + [Fact] + public async Task Unsetting_tenant_context_returns_zero_rows_through_RLS() + { + // A host that resolves to nothing. The request never reaches a handler — the + // resolver refuses it first — and the answer must be the one an unmapped PATH + // gets, byte for byte: "no tenant" and "no such route" must be indistinguishable, + // or the status is an oracle for which hostnames exist. + // + // The same path both times, so `instance` cannot account for a difference. Only + // the correlation id may differ; it is per-request by design and carries no fact + // about either refusal. + using var unknownHost = _fixture.ClientFor("nobody.learnstack.local"); + using var knownHost = _fixture.ClientFor(SeedData.English.Host); + + var refused = await unknownHost.GetAsync( + new Uri("/api/v1/isolationprobe/organizations", UriKind.Relative)); + var unmapped = await knownHost.GetAsync( + new Uri("/api/v1/nothing-here", UriKind.Relative)); + + refused.StatusCode.Should().Be(HttpStatusCode.NotFound); + refused.StatusCode.Should().Be(unmapped.StatusCode); + refused.Content.Headers.ContentType?.MediaType + .Should().Be(unmapped.Content.Headers.ContentType?.MediaType); + + // And the tenant-owned read it was refused DOES return rows on a host that + // resolves, or this case would pass against an endpoint that is simply broken. + (await ReadOrganizationsAsync(SeedData.English.Host)).Should().NotBeEmpty(); + } + + [Fact] + public async Task Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck() + { + // The write half, and through a request it is refused twice over. + // + // First by the authority ceiling: this request carries a host and nothing else, so + // it resolves HostOnly, and TenantContextBehavior admits that origin only for a + // [PublicSurface] type. CreateOrganizationCommand is emphatically not one — an + // anonymous visitor may read a tenant's pages and may not create its branches. + // + // Second, and this is what the case is named for: had it got past the ceiling, the + // tenant would still have come from the context rather than the body, and the + // WITH CHECK predicate compares the row against the announced tenant. The body's + // tenantId is accepted by the DTO precisely so that "it changes nothing" is a + // statement this test can make. + using var client = _fixture.ClientFor(SeedData.English.Host); + + var response = await client.PostAsJsonAsync( + new Uri("/api/v1/isolationprobe/organizations", UriKind.Relative), + new { tenantId = SeedData.Yoga.TenantId.Value, slug = "smuggled" }); + + response.IsSuccessStatusCode.Should().BeFalse( + "an anonymous host-only request may not write at all"); + + // And it wrote nothing, to either tenant — not to the one it named, and not to + // the one it arrived on. + (await ReadOrganizationsAsync(SeedData.English.Host)) + .Should().NotContain("smuggled"); + (await ReadOrganizationsAsync(SeedData.Yoga.Host)) + .Should().NotContain("smuggled", + "a body-supplied tenant id must not be able to move a write"); + } + + private async Task> ReadOrganizationsAsync(string host) => + await GetAsync(host, "organizations"); + + private async Task> ReadOrganizationsInScopeAsync(string host) => + await GetAsync(host, "organizations-in-scope"); + + private async Task> ReadHostsAsync(string host) => + await GetAsync(host, "hosts"); + + private async Task> GetAsync(string host, string route) + { + using var client = _fixture.ClientFor(host); + + var response = await client.GetAsync( + new Uri($"/api/v1/isolationprobe/{route}", UriKind.Relative)); + + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync>())!; + } +} + +/// +/// The real application, on a real database, with the two seed tenants in it. +/// +/// +/// Its own container rather than the shared schema one: this fixture seeds through +/// SeedRunner and serves HTTP, and sharing would make the schema suite's exact row +/// counts depend on whether these tests ran first. +/// +public sealed class TenantIsolationFixture : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgresFixture _postgres = new(); + + public async Task InitializeAsync() + { + await _postgres.InitializeAsync(); + + await using (var tenancy = new TenancyDbContext( + new DbContextOptionsBuilder() + .UseNpgsql(_postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)) + .Options, + SharedKernel.Tenancy.StaticTenantContextAccessor.Unresolved)) + { + await tenancy.Database.MigrateAsync(); + } + + await using (var platform = new PlatformDbContext( + new DbContextOptionsBuilder() + .UseNpgsql(_postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable(PlatformDbContextFactory.HistoryTable)) + .Options)) + { + await platform.Database.MigrateAsync(); + } + + // The seeder, not a fixture INSERT: these cases are about what a request sees, and + // what a request sees should be what `make seed` wrote. + await using var dataSource = NpgsqlDataSource.Create(_postgres.AppConnectionString); + var runner = new SeedRunner( + context => SeedComposition.Build(dataSource, context, NullLoggerFactory.Instance), + NullLogger.Instance); + + (await runner.RunAsync(CancellationToken.None)).Should().Be(0); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await _postgres.DisposeAsync(); + await base.DisposeAsync(); + } + + /// A client whose requests arrive on . + /// + /// The host is the only input. No tenant header, no stubbed context — the resolver + /// reads platform_host_to_tenant and everything downstream follows from what it + /// finds, which is the whole point of running these through HTTP. + /// + public HttpClient ClientFor(string host) + { + var client = CreateClient(); + client.BaseAddress = new Uri($"http://{host}/"); + return client; + } + + /// Removes a row a write case created, as the platform role. + public async Task RemoveOrganizationAsync(string slug) + { + await using var platform = await PostgresFixture.OpenAsync( + _postgres.PlatformConnectionString); + await using var command = new NpgsqlCommand( + "DELETE FROM organizations WHERE slug = @slug", (NpgsqlConnection)platform); + command.Parameters.AddWithValue("slug", slug); + await command.ExecuteNonQueryAsync(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.UseEnvironment(Environments.Development); + + // UseSetting, not ConfigureAppConfiguration: the composition root reads these while + // building the host, and a source added later loses to the appsettings the app + // already read. Measured — the first shape produced "ConnectionStrings:Default is + // not configured" from the guard that exists to catch exactly this. + builder.UseSetting("ConnectionStrings:Default", _postgres.AppConnectionString); + builder.UseSetting("ConnectionStrings:PlatformAdmin", _postgres.PlatformConnectionString); + + builder.ConfigureTestServices(services => + { + services.AddControllers(options => options.Conventions.Insert( + 0, new TestControllerFilter(typeof(IsolationProbeController)))) + .AddApplicationPart(typeof(IsolationProbeController).Assembly); + + // The handler alone, not an assembly scan: a scan would re-register the + // pipeline behaviors the composition root already added, and a doubled + // TransactionBehavior is a nested frame on every request. + services.AddTransient< + MediatR.IRequestHandler>>, + ProbeQueryHandler>(); + }); + } +} + +/// +/// Reads and writes the same rows the isolation cases are about. +/// +/// +/// Everything goes through ISender, and that is not ceremony. A +/// TenancyDbContext injected into a controller is refused at resolution — +/// "resolved outside the ambient transaction ... it never saw SET LOCAL app.tenant_id" — +/// because a context obtained before TransactionBehavior opens the transaction +/// reads zero rows from every tenant-owned table and would do so silently. Measured: the +/// first version of this controller took the context directly and every case answered +/// 500. Going through the pipeline is what makes these cases statements about the request +/// path rather than about a context somebody assembled by hand. +/// +public sealed class IsolationProbeController(MediatR.ISender sender) + : ApiControllerBase, ITestOnlyController +{ + [HttpGet("organizations")] + public async Task Organizations() => + Ok((await sender.Send(new ProbeQuery(ProbeSubject.Organizations))).Value); + + [HttpGet("organizations-in-scope")] + public async Task OrganizationsInScope() => + Ok((await sender.Send(new ProbeQuery(ProbeSubject.OrganizationsInScope))).Value); + + [HttpGet("hosts")] + public async Task Hosts() => + Ok((await sender.Send(new ProbeQuery(ProbeSubject.Hosts))).Value); + + [HttpPost("organizations")] + public async Task Create([FromBody] CreateProbeRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + // The body's tenantId is deliberately ignored: CreateOrganizationCommand takes its + // tenant from the context. Accepting it in the DTO is what lets a test assert that + // a caller cannot move a write by naming a tenant. + var result = await sender.Send( + new Modules.Tenancy.Application.Contracts.Tenant.CreateOrganizationCommand( + OrganizationId.From(Guid.CreateVersion7()), request.Slug, "Probe")); + + return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error); + } + + public sealed record CreateProbeRequest(Guid TenantId, string Slug); +} + +/// What the probe reads. One query type, so one handler covers the three. +public enum ProbeSubject +{ + Organizations, + OrganizationsInScope, + Hosts, +} + +/// +/// [PublicSurface], and the marker is load-bearing rather than decoration. These +/// requests arrive on a host and nothing else, so TenantResolverMiddleware resolves +/// them HostOnly, and TenantContextBehavior's second gate admits that origin +/// only for a marked type. Measured: without it every read came back 200 with an +/// empty body — the ceiling refusing, exactly as designed. It is also the honest shape: +/// [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) renders both seed +/// tenants to anonymous visitors, so an anonymous host-only read is what production does. +/// +[SharedKernel.Tenancy.PublicSurface] +public sealed record ProbeQuery(ProbeSubject Subject) + : MediatR.IRequest>>; + +/// +/// Reads through the module context, inside the transaction the pipeline opened. +/// +/// +/// Registered by hand in the fixture rather than by an assembly scan: the scan would also +/// re-register the pipeline behaviors the composition root already added, and a doubled +/// TransactionBehavior is a nested frame on every request. +/// +public sealed class ProbeQueryHandler( + TenancyDbContext db, SharedKernel.Tenancy.ITenantContext tenantContext) + : MediatR.IRequestHandler>> +{ + public async Task>> Handle( + ProbeQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var rows = request.Subject switch + { + ProbeSubject.Organizations => + await db.Organizations + .Select(organization => organization.Slug) + .ToListAsync(cancellationToken), + + // The tenant's organizations narrowed to the request's organization scope. + // Explicit rather than a second query filter: an organization-scoped host + // scopes what a request may act on, and expressing it here is what makes the + // difference between the two routes observable. + ProbeSubject.OrganizationsInScope => + await db.Organizations + .Where(organization => tenantContext.OrganizationId == null + || organization.Id == tenantContext.OrganizationId) + .Select(organization => organization.Slug) + .ToListAsync(cancellationToken), + + _ => await db.PlatformHostMappings + .Select(mapping => mapping.Host) + .ToListAsync(cancellationToken), + }; + + return SharedKernel.Results.Result.Ok(rows); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs index ce581814..0bf384d2 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -214,9 +214,10 @@ await act.Should().ThrowAsync() [Fact] public async Task Passes_The_Ambient_Tenant_Context_Through() { - // Not a context of its own. Whatever the resolution stage populated is - // what reaches SET LOCAL — which, until Packet 7, is - // UnresolvedTenantContext, and that is correct and fail-closed. + // Not a context of its own. Whatever the resolution stage populated is what + // reaches the announcement — which, for a request no resolver touched, is + // UnresolvedTenantContext, and that is correct and fail-closed. Packet 7 gave the + // resolver something to populate; it did not change what this behavior reads. var unitOfWork = new RecordingUnitOfWork(); var behavior = Build(unitOfWork); From af705d9dc68d856ebb3ff2c5543c0e61e7ab4aec Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 12:36:56 +0300 Subject: [PATCH 38/55] fix(tenancy): make the isolation cases assert what their names claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 of Step 11's review, and the finding was that three of the five cases tested something other than their name — one of them nothing at all. Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck performed no INSERT. It asserted that an anonymous POST failed, which a 404 satisfies, so it passed against a DELETED endpoint and against a database with every policy dropped. It now issues the INSERT on the ambient connection, naming a tenant other than the announced one, and asserts the 42501 that WITH CHECK raises. Raw SQL deliberately: a write through EF carries the filter's tenant and could never name a foreign one. Org_X_cannot_read_Org_Y read `organizations`, which is the tenant-WIDE class — within a tenant every organization is visible to every other, by design — and the narrowing came from a Where the probe handler wrote itself. It now reads `tenant_settings`, the organization-scoped class, and narrows nothing. TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA read `platform_host_to_tenant`, which is platform-scoped and whose policy has no organization term, so it named the wrong mechanism entirely. It now reads the row shape the superseded template actually leaked: tenant-owned, organization_id IS NULL. Unsetting_tenant_context never reached a handler or read a table — it duplicated an existing 404-parity assertion more weakly. It now runs a tenant-owned SELECT under a PlatformHost request, which is the only way to put a real query in front of an unresolved context. The remark about what the suite constrains was half a measurement. Deleting both query filters leaves all five green because RLS holds; disabling RLS leaves the four reads green because the filters hold; removing both turns all five red. Both directions are now recorded, along with the write case being the one that observes a policy alone. Also: the file moved under Database/, where Standards 06 puts Docker-bound tests and where the trait guard can see it; the probe returns ToActionResult() so a refusal is a status rather than a 200 with an empty body; and two Packet 7 comments the sweep missed. Module: Tenancy ADR: 0003, 0036 Co-Authored-By: Claude Opus 5 (1M context) --- .../Database/TenantIsolationHttpTests.cs | 577 ++++++++++++++++++ .../Database/UnitOfWorkTests.cs | 10 +- .../TenantIsolationHttpTests.cs | 424 ------------- 3 files changed, 583 insertions(+), 428 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs delete mode 100644 backend/tests/LearnStack.Tests.Integration/TenantIsolationHttpTests.cs diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs new file mode 100644 index 00000000..3dca3479 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs @@ -0,0 +1,577 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.Tools.Seeder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.AspNetCore.Mvc.Testing; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// The five isolation cases, re-run through a real request. +/// +/// +/// +/// What Packet 7 owns that Packet 6 did not. Packet 6 shipped all five against the +/// schema, driving them with set_config in a test — statements about the migration +/// and its policies. These drive the same five through +/// HostClassificationMiddleware, TenantResolverMiddleware, +/// TenantContextBehavior, TransactionBehavior's announcement and the EF +/// query filters, which is the path a browser takes. Not because nothing else exercises +/// the resolver — a resolver that never populates the accessor turns twelve tests red +/// across three assemblies — but because those test the resolver, and these test what a +/// tenant-owned SELECT returns at the end of it. +/// +/// +/// Nothing here stubs ITenantContext. Every other HTTP fixture in this +/// project replaces it with a header-driven double, which is right for their subjects and +/// fatal for this one: the tenant a request gets IS the thing under test. The host header +/// is the only input, exactly as in production. +/// +/// +/// The data is the seed, not a fixture. The two demo tenants and their host rows +/// come from SeedRunner — the same code make seed runs — so these cases also +/// answer "does what the seeder writes actually serve a request?", which is the question +/// [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) asks in a browser. +/// +/// +/// No production endpoint ships in this packet. The reads go through a test-only +/// controller registered in the fixture, which is the precedent IdempotencyFixture +/// set for /api/v1/sideeffectprobe. What is production is everything beneath it. +/// +/// +/// What these cases constrain, measured in both directions. They constrain the +/// composite outcome — the answer a request gets — and each read is protected by two +/// independent layers, so no single-layer mutation breaks one. Delete BOTH EF query +/// filters and all five stay green, because Row Level Security alone holds; disable RLS on +/// every tenancy table instead and the four reads stay green, because the filters alone +/// hold. Remove both and all five go red. That is defense in depth behaving as designed +/// rather than a gap, and the two halves are separately constrained elsewhere: the filters +/// by Every_TenantOwned_Entity_HasFilterAndRlsPolicy, +/// Every_OrgScoped_Entity_HasOrgIdAndFilter and +/// A_context_follows_the_accessor_after_it_was_built, the policies by Packet 6's +/// TenancySchemaTests. +/// +/// +/// The write case is the exception, and deliberately so. It issues raw SQL on the +/// ambient connection, so no filter is in front of it and only WITH CHECK can +/// refuse it — disabling RLS turns it red on its own. It is the one case here that +/// observes a policy directly. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class TenantIsolationHttpTests : IClassFixture +{ + private readonly TenantIsolationFixture _fixture; + + public TenantIsolationHttpTests(TenantIsolationFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Tenant_A_cannot_read_Tenant_B_data() + { + // The first case, and the one every other layer exists to make redundant. Two + // requests differing only in the host they arrive on must not see each other's + // rows — and neither request names a tenant anywhere, which is the point: the + // tenant comes from the host, and the filter and the policy come from the tenant. + var english = await ReadOrganizationsAsync(SeedData.English.Host); + var yoga = await ReadOrganizationsAsync(SeedData.Yoga.Host); + + english.Should().NotBeEmpty(); + yoga.Should().NotBeEmpty(); + + english.Should().NotIntersectWith(yoga, + "two hosts, two tenants, one binary and one database"); + english.Should().BeEquivalentTo( + [SeedData.English.DefaultOrganization.Slug, SeedData.English.SecondOrganization.Slug]); + yoga.Should().BeEquivalentTo( + [SeedData.Yoga.DefaultOrganization.Slug, SeedData.Yoga.SecondOrganization.Slug]); + } + + [Fact] + public async Task Org_X_cannot_read_Org_Y_within_TenantA() + { + // Read from `tenant_settings`, the organization-scoped table class: `TenantSetting` + // implements IOrganizationScoped and its policy carries an organization term. + // `organizations` does not — it is tenant-owned and tenant-WIDE, so within a tenant + // every organization is visible to every other, by design. The first version of + // this case read that table and narrowed the rows with a `Where` the probe handler + // wrote itself, which tested the test. + // + // The yoga host names an organization, so a request arriving on it is scoped to + // that organization and must see its setting and not its sibling's. Nothing in the + // probe narrows anything; the query filter and the policy do. + var scoped = await ReadSettingsAsync(SeedData.Yoga.Host); + + scoped.Should().Contain(Setting("theme", SeedData.Yoga.DefaultOrganization.Slug)); + scoped.Should().NotContain(Setting("theme", SeedData.Yoga.SecondOrganization.Slug), + "the sibling organization's row belongs to a scope this request is not in"); + } + + [Fact] + public async Task TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA() + { + // The exact row shape the superseded RLS template leaked: a tenant-owned row with + // `organization_id IS NULL`. That template wrote two PERMISSIVE policies, which + // PostgreSQL combines with OR, so the tenant-wide arm matched for everyone and + // every such row was visible across tenants. + // + // Both seed tenants carry one under the same key, so a leak shows up as a request + // seeing the OTHER tenant's value — which makes this a statement about isolation + // rather than about a row count. + var english = await ReadSettingsAsync(SeedData.English.Host); + var yoga = await ReadSettingsAsync(SeedData.Yoga.Host); + + english.Should().Contain(Setting("tz", SeedData.English.Slug)); + english.Should().NotContain(Setting("tz", SeedData.Yoga.Slug), + "a tenant-wide row belongs to its tenant, not to everyone"); + + yoga.Should().Contain(Setting("tz", SeedData.Yoga.Slug)); + yoga.Should().NotContain(Setting("tz", SeedData.English.Slug)); + } + + [Fact] + public async Task Unsetting_tenant_context_returns_zero_rows_through_RLS() + { + // A request that reaches a handler with NO tenant, and reads. `localhost` is in + // `Tenancy:PlatformHosts`, so it classifies PlatformHost — the operator's own entry + // point — and the pipeline runs under UnresolvedTenantContext rather than refusing. + // The announcement is then the empty string, NULLIF makes every policy predicate + // NULL, and a tenant-owned read must come back empty. + // + // Not a 404 parity check. An unknown host never reaches a handler and never reads a + // table, and HostClassificationHttpTests already pins that answer more strictly + // than this file could. What only this case can say is what a SELECT returns when + // the context is unresolved and the query actually runs. + var unresolved = await ReadUnresolvedSettingsAsync("localhost"); + + unresolved.Should().BeEmpty( + "an unresolved context fails closed — the table returns nothing, not everything"); + + // And the same read on a resolved host is not empty, or this would pass against a + // probe that never queried anything. + (await ReadSettingsAsync(SeedData.English.Host)).Should().NotBeEmpty(); + } + + [Fact] + public async Task Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck() + { + // An actual INSERT, on the ambient transaction, carrying a tenant_id that is not + // the announced one — which is what the name says and what ADR-0003 Amendment 3 + // lists among the minimum cases. The first version asserted only that an anonymous + // POST failed, and passed against a DELETED endpoint and against a database with + // every policy dropped. + // + // The request arrives on demo-english's host, so the transaction is announced with + // demo-english; the row names demo-yoga. WITH CHECK compares the two and raises + // 42501. No layer above the database is involved — the statement is raw SQL on the + // connection the unit of work owns, so a query filter cannot account for the + // refusal. + using var client = _fixture.ClientFor(SeedData.English.Host); + + var response = await client.PostAsJsonAsync( + new Uri("/api/v1/isolationprobe/foreign-write", UriKind.Relative), + new { tenantId = SeedData.Yoga.TenantId.Value }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Trim('"').Should().Be("42501", + "the policy's WITH CHECK rejects a row whose tenant is not the announced one"); + + // And nothing landed, under either tenant. + (await ReadSettingsAsync(SeedData.Yoga.Host)).Should().NotContain( + value => value.StartsWith("smuggled", StringComparison.Ordinal)); + (await ReadSettingsAsync(SeedData.English.Host)).Should().NotContain( + value => value.StartsWith("smuggled", StringComparison.Ordinal)); + } + + private async Task> ReadOrganizationsAsync(string host) => + await GetAsync(host, "organizations"); + + /// One seeded setting, as the probe projects it: the stored jsonb scalar. + private static string Setting(string key, string scope) => $"{key}=\"{scope}\""; + + private async Task> ReadSettingsAsync(string host) => + await GetAsync(host, "settings"); + + /// The same read, on a request the pipeline runs with no tenant. + private async Task> ReadUnresolvedSettingsAsync(string host) => + await GetAsync(host, "settings-unresolved"); + + private async Task> GetAsync(string host, string route) + { + using var client = _fixture.ClientFor(host); + + var response = await client.GetAsync( + new Uri($"/api/v1/isolationprobe/{route}", UriKind.Relative)); + + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync>())!; + } +} + +/// +/// The real application, on a real database, with the two seed tenants in it. +/// +/// +/// Its own container rather than the shared schema one: this fixture seeds through +/// SeedRunner and serves HTTP, and sharing would make the schema suite's exact row +/// counts depend on whether these tests ran first. +/// +public sealed class TenantIsolationFixture : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgresFixture _postgres = new(); + + public async Task InitializeAsync() + { + await _postgres.InitializeAsync(); + + await using (var tenancy = new TenancyDbContext( + new DbContextOptionsBuilder() + .UseNpgsql(_postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)) + .Options, + SharedKernel.Tenancy.StaticTenantContextAccessor.Unresolved)) + { + await tenancy.Database.MigrateAsync(); + } + + await using (var platform = new PlatformDbContext( + new DbContextOptionsBuilder() + .UseNpgsql(_postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable(PlatformDbContextFactory.HistoryTable)) + .Options)) + { + await platform.Database.MigrateAsync(); + } + + // The seeder, not a fixture INSERT: these cases are about what a request sees, and + // what a request sees should be what `make seed` wrote. + await using var dataSource = NpgsqlDataSource.Create(_postgres.AppConnectionString); + var runner = new SeedRunner( + context => SeedComposition.Build(dataSource, context, NullLoggerFactory.Instance), + NullLogger.Instance); + + (await runner.RunAsync(CancellationToken.None)).Should().Be(0); + + await SeedSettingsAsync(); + } + + async Task IAsyncLifetime.DisposeAsync() + { + // The host first, then the container: shutting the server down under a live host + // leaves the data source disposing against a dead connection. + await base.DisposeAsync(); + await _postgres.DisposeAsync(); + } + + /// + /// One tenant-wide setting per tenant, and one per organization. + /// + /// + /// + /// These rows are what three of the five cases are about. `tenant_settings` is + /// the organization-scoped table class — TenantSetting implements + /// IOrganizationScoped and its policy carries an organization term — so it is + /// the only seeded table where "organization X cannot read organization Y" is a + /// question the platform answers. `organizations` is tenant-wide: within a tenant every + /// organization sees every other, by design. + /// + /// + /// Written as learnstack_app, one organization at a time. The + /// organization-scoped WITH CHECK admits a row only under its own organization's + /// context, so a single statement covering both would be refused — which is the guard + /// working, and the reason the announcement moves between inserts. + /// + /// + /// Not written by the seeder: settings are not a Packet 7 seed deliverable, and + /// inventing one to serve a test would put fixture data in front of every future + /// developer running make seed. + /// + /// + private async Task SeedSettingsAsync() + { + foreach (var tenant in SeedData.All) + { + await using var connection = await PostgresFixture.OpenAsync( + _postgres.AppConnectionString); + await using var command = new NpgsqlCommand( + $""" + BEGIN; + SELECT set_config('app.tenant_id', '{tenant.TenantId.Value}', true); + SELECT set_config('app.organization_id', '', true); + INSERT INTO tenant_settings + (id, tenant_id, organization_id, key, value, + created_at, created_by, row_version) + VALUES (uuidv7(), '{tenant.TenantId.Value}', NULL, + 'tz', '"{tenant.Slug}"', now(), '{Actor}', 0); + + SELECT set_config('app.organization_id', + '{tenant.DefaultOrganization.OrganizationId.Value}', true); + INSERT INTO tenant_settings + (id, tenant_id, organization_id, key, value, + created_at, created_by, row_version) + VALUES (uuidv7(), '{tenant.TenantId.Value}', + '{tenant.DefaultOrganization.OrganizationId.Value}', + 'theme', '"{tenant.DefaultOrganization.Slug}"', now(), '{Actor}', 0); + + SELECT set_config('app.organization_id', + '{tenant.SecondOrganization.OrganizationId.Value}', true); + INSERT INTO tenant_settings + (id, tenant_id, organization_id, key, value, + created_at, created_by, row_version) + VALUES (uuidv7(), '{tenant.TenantId.Value}', + '{tenant.SecondOrganization.OrganizationId.Value}', + 'theme', '"{tenant.SecondOrganization.Slug}"', now(), '{Actor}', 0); + COMMIT; + """, + (NpgsqlConnection)connection); + + await command.ExecuteNonQueryAsync(); + } + } + + /// The registry-assigned actor; every seeded row is attributed to it. + private static readonly Guid Actor = Guid.Parse("00000000-0000-7000-8000-000000000001"); + + /// A client whose requests arrive on . + /// + /// The host is the only input. No tenant header, no stubbed context — the resolver + /// reads platform_host_to_tenant and everything downstream follows from what it + /// finds, which is the whole point of running these through HTTP. + /// + public HttpClient ClientFor(string host) + { + var client = CreateClient(); + client.BaseAddress = new Uri($"http://{host}/"); + return client; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.UseEnvironment(Environments.Development); + + // UseSetting, not ConfigureAppConfiguration: the composition root reads these while + // building the host, and a source added later loses to the appsettings the app + // already read. Measured — the first shape produced "ConnectionStrings:Default is + // not configured" from the guard that exists to catch exactly this. + builder.UseSetting("ConnectionStrings:Default", _postgres.AppConnectionString); + builder.UseSetting("ConnectionStrings:PlatformAdmin", _postgres.PlatformConnectionString); + + builder.ConfigureTestServices(services => + { + services.AddControllers(options => options.Conventions.Insert( + 0, new TestControllerFilter(typeof(IsolationProbeController)))) + .AddApplicationPart(typeof(IsolationProbeController).Assembly); + + // The handler alone, not an assembly scan: a scan would re-register the + // pipeline behaviors the composition root already added, and a doubled + // TransactionBehavior is a nested frame on every request. + services.AddTransient< + MediatR.IRequestHandler>>, + ProbeQueryHandler>(); + services.AddTransient< + MediatR.IRequestHandler>>, + ProbeQueryHandler>(); + services.AddTransient< + MediatR.IRequestHandler>, + ForeignWriteHandler>(); + }); + } +} + +/// +/// Reads and writes the same rows the isolation cases are about. +/// +/// +/// Everything goes through ISender, and that is not ceremony. A +/// TenancyDbContext injected into a controller is refused at resolution — +/// "resolved outside the ambient transaction ... it never saw SET LOCAL app.tenant_id" — +/// because a context obtained before TransactionBehavior opens the transaction +/// reads zero rows from every tenant-owned table and would do so silently. Measured: the +/// first version of this controller took the context directly and every case answered +/// 500. Going through the pipeline is what makes these cases statements about the request +/// path rather than about a context somebody assembled by hand. +/// +public sealed class IsolationProbeController(MediatR.ISender sender) + : ApiControllerBase, ITestOnlyController +{ + [HttpGet("organizations")] + public async Task Organizations(CancellationToken cancellationToken) => + (await sender.Send(new ProbeQuery(ProbeSubject.Organizations), cancellationToken)) + .ToActionResult(); + + [HttpGet("settings")] + public async Task Settings(CancellationToken cancellationToken) => + (await sender.Send(new ProbeQuery(ProbeSubject.Settings), cancellationToken)) + .ToActionResult(); + + [HttpGet("settings-unresolved")] + public async Task SettingsUnresolved(CancellationToken cancellationToken) => + (await sender.Send(new UnresolvedProbeQuery(), cancellationToken)).ToActionResult(); + + [HttpPost("foreign-write")] + public async Task ForeignWrite( + [FromBody] ForeignWriteRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + return (await sender.Send( + new ForeignWriteCommand(request.TenantId), cancellationToken)).ToActionResult(); + } + + public sealed record ForeignWriteRequest(Guid TenantId); +} + +/// What the probe reads. +public enum ProbeSubject +{ + Organizations, + Settings, +} + +/// +/// [PublicSurface], and the marker is load-bearing rather than decoration. These +/// requests arrive on a host and nothing else, so TenantResolverMiddleware resolves +/// them HostOnly, and TenantContextBehavior's second gate admits that origin +/// only for a marked type. Measured: without it every read came back with an empty body — +/// the ceiling refusing, exactly as designed. It is also the honest shape: +/// [Phase 02d](../../../../docs/roadmap/phase-02d-walking-skeleton.md) renders both seed +/// tenants to anonymous visitors, so an anonymous host-only read is what production does. +/// +[SharedKernel.Tenancy.PublicSurface] +public sealed record ProbeQuery(ProbeSubject Subject) + : MediatR.IRequest>>; + +/// A read on a request the pipeline runs with no tenant at all. +/// +/// [AllowsUnresolvedTenantContext] because a PlatformHost request — one +/// arriving on an entry in Tenancy:PlatformHosts — resolves to no tenant by design +/// and must still be able to run. It is the only way to put a tenant-owned SELECT in front +/// of an unresolved context through a real request, which is what +/// Unsetting_tenant_context_returns_zero_rows_through_RLS is named for. The marker +/// on a test-assembly type is invisible to +/// AllowsUnresolvedTenantContext_Only_On_Provisioning_Commands, whose sweep +/// enumerates backend/src. +/// +[SharedKernel.Tenancy.AllowsUnresolvedTenantContext] +public sealed record UnresolvedProbeQuery + : MediatR.IRequest>>; + +/// An INSERT naming a tenant other than the announced one. +[SharedKernel.Tenancy.PublicSurface] +public sealed record ForeignWriteCommand(Guid TenantId) + : MediatR.IRequest>; + +/// +/// Reads through the module context, inside the transaction the pipeline opened. +/// +/// +/// Registered by hand in the fixture rather than by an assembly scan: the scan would also +/// re-register the pipeline behaviors the composition root already added, and a doubled +/// TransactionBehavior is a nested frame on every request. +/// +public sealed class ProbeQueryHandler(TenancyDbContext db) + : MediatR.IRequestHandler>>, + MediatR.IRequestHandler>> +{ + public async Task>> Handle( + ProbeQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + return request.Subject switch + { + ProbeSubject.Organizations => SharedKernel.Results.Result.Ok( + await db.Organizations + .Select(organization => organization.Slug) + .ToListAsync(cancellationToken)), + + ProbeSubject.Settings => SharedKernel.Results.Result.Ok( + await ReadSettingsAsync(db, cancellationToken)), + + // Exhaustive by construction, fail-closed on a member added without deciding + // what it reads — the house style, and the opposite of falling through to + // whichever subject happens to be last. + _ => throw new ArgumentOutOfRangeException( + nameof(request), request.Subject, "No probe reads that subject."), + }; + } + + public async Task>> Handle( + UnresolvedProbeQuery request, CancellationToken cancellationToken) => + SharedKernel.Results.Result.Ok(await ReadSettingsAsync(db, cancellationToken)); + + /// + /// Every setting the request can see, as key=scope. + /// + /// + /// The value is projected to the owning organization's slug — or the tenant's, for a + /// tenant-wide row — so an assertion names what it expects to see rather than a raw + /// setting value. No Where: what a request sees is the filters' and the + /// policies' answer, and narrowing it here would be the test testing itself. + /// + private static async Task> ReadSettingsAsync( + TenancyDbContext db, CancellationToken cancellationToken) => + await db.TenantSettings + .OrderBy(setting => setting.Key) + .Select(setting => setting.Key + "=" + setting.Value) + .ToListAsync(cancellationToken); +} + +/// +/// Issues the foreign-tenant INSERT on the connection the unit of work owns. +/// +/// +/// Raw SQL deliberately: a write through EF would carry the query filter's tenant and +/// could never name a foreign one, so the case would prove nothing about +/// WITH CHECK. This is the one place in the suite that reaches past every layer +/// above the database on purpose. +/// +public sealed class ForeignWriteHandler(IUnitOfWork unitOfWork) + : MediatR.IRequestHandler> +{ + public async Task> Handle( + ForeignWriteCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + await using var command = (NpgsqlCommand)unitOfWork.Connection.CreateCommand(); + command.Transaction = (NpgsqlTransaction?)unitOfWork.Transaction; + command.CommandText = + """ + INSERT INTO tenant_settings + (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) + VALUES (uuidv7(), @tenant, NULL, 'smuggled', '"x"', now(), @actor, 0) + """; + command.Parameters.AddWithValue("tenant", request.TenantId); + command.Parameters.AddWithValue( + "actor", Guid.Parse("00000000-0000-7000-8000-000000000001")); + + try + { + await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (PostgresException refused) + { + // Returned rather than rethrown: the SQLSTATE is the assertion, and an + // exception would reach the L1 handler as a 500 with the code buried. + return SharedKernel.Results.Result.Ok(refused.SqlState); + } + + return SharedKernel.Results.Result.Ok("committed"); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 619d9962..34a6d672 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -174,8 +174,9 @@ public void The_uninitialized_id_fixture_really_is_uninitialized() [Fact] public async Task An_unresolved_context_leaves_every_tenant_owned_table_empty() { - // Between Packet 6 and Packet 7 every request runs against - // UnresolvedTenantContext, and this is what that costs: the GUCs are set + // A request no resolver touched runs against UnresolvedTenantContext — before + // Packet 7 that was every request, and it is still what a PlatformHost request + // gets — and this is what that costs: the GUCs are set // to the empty string, NULLIF turns them into NULL, and a NULL predicate // is false for USING and WITH CHECK alike. Fail-closed by construction, // not by a filter that does not exist yet. @@ -1151,8 +1152,9 @@ private static T Zeroed() public sealed record Probe : MediatR.IRequest>; /// - /// A resolved context, standing in for what Packet 7's - /// TenantResolverMiddleware will populate. + /// A resolved context, standing in for what TenantResolverMiddleware populates + /// in traffic. The real one answers in TenantIsolationHttpTests; here the point + /// is the unit of work, and a stub keeps a database out of the question. /// private sealed class StubTenantContext(Guid tenant, Guid organization) : ITenantContext { diff --git a/backend/tests/LearnStack.Tests.Integration/TenantIsolationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/TenantIsolationHttpTests.cs deleted file mode 100644 index 06864a6b..00000000 --- a/backend/tests/LearnStack.Tests.Integration/TenantIsolationHttpTests.cs +++ /dev/null @@ -1,424 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using FluentAssertions; -using LearnStack.Api.Common; -using LearnStack.Infrastructure.Persistence; -using LearnStack.Modules.Tenancy.Infrastructure.Persistence; -using LearnStack.SharedKernel.Identifiers; -using LearnStack.Tests.Integration.Database; -using LearnStack.Tools.Seeder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.AspNetCore.Mvc.Testing; -using Npgsql; -using Xunit; - -namespace LearnStack.Tests.Integration; - -/// -/// The five isolation cases, re-run through a real request. -/// -/// -/// -/// What Packet 7 owns that Packet 6 did not. Packet 6 shipped all five against the -/// schema, driving them with set_config in a test — statements about the migration -/// and its policies. These drive the same five through -/// HostClassificationMiddleware, TenantResolverMiddleware, -/// TenantContextBehavior, TransactionBehavior's announcement and the EF -/// query filters, which is the path a browser takes. A policy that holds under -/// set_config and a resolver that never sets it would pass the first suite and fail -/// every real request; only this one can tell them apart. -/// -/// -/// Nothing here stubs ITenantContext. Every other HTTP fixture in this -/// project replaces it with a header-driven double, which is right for their subjects and -/// fatal for this one: the tenant a request gets IS the thing under test. The host header -/// is the only input, exactly as in production. -/// -/// -/// The data is the seed, not a fixture. The two demo tenants and their host rows -/// come from SeedRunner — the same code make seed runs — so these cases also -/// answer "does what the seeder writes actually serve a request?", which is the question -/// [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) asks in a browser. -/// -/// -/// No production endpoint ships in this packet. The reads go through a test-only -/// controller registered in the fixture, which is the precedent IdempotencyFixture -/// set for /api/v1/sideeffectprobe. What is production is everything beneath it. -/// -/// -/// What these cases constrain, and what they do not. They constrain the composite -/// outcome — the answer a request gets — not any single layer, and that is a property of -/// defense in depth rather than a weakness here: measured, deleting BOTH EF query filters -/// leaves all five green, because Row Level Security alone still holds. The filters are -/// not thereby unconstrained; the same mutation turns -/// Every_TenantOwned_Entity_HasFilterAndRlsPolicy, -/// Every_OrgScoped_Entity_HasOrgIdAndFilter and -/// A_context_follows_the_accessor_after_it_was_built red. Layer-by-layer coverage -/// lives there and in Packet 6's schema suite; what lives only here is the statement that -/// the layers, the resolver and the pipeline compose into the right answer for a real -/// request. -/// -/// -[Trait(RequiresDocker.Key, RequiresDocker.Value)] -public sealed class TenantIsolationHttpTests : IClassFixture -{ - private readonly TenantIsolationFixture _fixture; - - public TenantIsolationHttpTests(TenantIsolationFixture fixture) => _fixture = fixture; - - [Fact] - public async Task Tenant_A_cannot_read_Tenant_B_data() - { - // The first case, and the one every other layer exists to make redundant. Two - // requests differing only in the host they arrive on must not see each other's - // rows — and neither request names a tenant anywhere, which is the point: the - // tenant comes from the host, and the filter and the policy come from the tenant. - var english = await ReadOrganizationsAsync(SeedData.English.Host); - var yoga = await ReadOrganizationsAsync(SeedData.Yoga.Host); - - english.Should().NotBeEmpty(); - yoga.Should().NotBeEmpty(); - - english.Should().NotIntersectWith(yoga, - "two hosts, two tenants, one binary and one database"); - english.Should().BeEquivalentTo( - [SeedData.English.DefaultOrganization.Slug, SeedData.English.SecondOrganization.Slug]); - yoga.Should().BeEquivalentTo( - [SeedData.Yoga.DefaultOrganization.Slug, SeedData.Yoga.SecondOrganization.Slug]); - } - - [Fact] - public async Task Org_X_cannot_read_Org_Y_within_TenantA() - { - // The second dimension, and the one a tenant filter alone does not give. The yoga - // host carries an organization id, so a request arriving on it is scoped to that - // organization and must not see the tenant's other one — even though both belong - // to the tenant the request resolved. - var scoped = await ReadOrganizationsInScopeAsync(SeedData.Yoga.Host); - - scoped.Should().BeEquivalentTo([SeedData.Yoga.DefaultOrganization.Slug], - "the host names one organization, and the scope is the row it names"); - scoped.Should().NotContain(SeedData.Yoga.SecondOrganization.Slug); - - // And the tenant-wide host sees both, or the case above would pass against a - // filter that simply returns nothing. - (await ReadOrganizationsInScopeAsync(SeedData.English.Host)) - .Should().HaveCount(2, "a host with no organization is scoped to the tenant"); - } - - [Fact] - public async Task TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA() - { - // The exact case the superseded RLS template leaked. Two permissive policies are - // combined with OR, so a tenant-wide row — one whose organization_id is NULL — - // was visible to every tenant. Here it is the host mapping itself: demo-english's - // row has a null organization_id, and demo-yoga must not see it. - var seenByYoga = await ReadHostsAsync(SeedData.Yoga.Host); - - seenByYoga.Should().NotContain(SeedData.English.Host, - "a tenant-wide row belongs to its tenant, not to everyone"); - seenByYoga.Should().BeEquivalentTo([SeedData.Yoga.Host]); - } - - [Fact] - public async Task Unsetting_tenant_context_returns_zero_rows_through_RLS() - { - // A host that resolves to nothing. The request never reaches a handler — the - // resolver refuses it first — and the answer must be the one an unmapped PATH - // gets, byte for byte: "no tenant" and "no such route" must be indistinguishable, - // or the status is an oracle for which hostnames exist. - // - // The same path both times, so `instance` cannot account for a difference. Only - // the correlation id may differ; it is per-request by design and carries no fact - // about either refusal. - using var unknownHost = _fixture.ClientFor("nobody.learnstack.local"); - using var knownHost = _fixture.ClientFor(SeedData.English.Host); - - var refused = await unknownHost.GetAsync( - new Uri("/api/v1/isolationprobe/organizations", UriKind.Relative)); - var unmapped = await knownHost.GetAsync( - new Uri("/api/v1/nothing-here", UriKind.Relative)); - - refused.StatusCode.Should().Be(HttpStatusCode.NotFound); - refused.StatusCode.Should().Be(unmapped.StatusCode); - refused.Content.Headers.ContentType?.MediaType - .Should().Be(unmapped.Content.Headers.ContentType?.MediaType); - - // And the tenant-owned read it was refused DOES return rows on a host that - // resolves, or this case would pass against an endpoint that is simply broken. - (await ReadOrganizationsAsync(SeedData.English.Host)).Should().NotBeEmpty(); - } - - [Fact] - public async Task Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck() - { - // The write half, and through a request it is refused twice over. - // - // First by the authority ceiling: this request carries a host and nothing else, so - // it resolves HostOnly, and TenantContextBehavior admits that origin only for a - // [PublicSurface] type. CreateOrganizationCommand is emphatically not one — an - // anonymous visitor may read a tenant's pages and may not create its branches. - // - // Second, and this is what the case is named for: had it got past the ceiling, the - // tenant would still have come from the context rather than the body, and the - // WITH CHECK predicate compares the row against the announced tenant. The body's - // tenantId is accepted by the DTO precisely so that "it changes nothing" is a - // statement this test can make. - using var client = _fixture.ClientFor(SeedData.English.Host); - - var response = await client.PostAsJsonAsync( - new Uri("/api/v1/isolationprobe/organizations", UriKind.Relative), - new { tenantId = SeedData.Yoga.TenantId.Value, slug = "smuggled" }); - - response.IsSuccessStatusCode.Should().BeFalse( - "an anonymous host-only request may not write at all"); - - // And it wrote nothing, to either tenant — not to the one it named, and not to - // the one it arrived on. - (await ReadOrganizationsAsync(SeedData.English.Host)) - .Should().NotContain("smuggled"); - (await ReadOrganizationsAsync(SeedData.Yoga.Host)) - .Should().NotContain("smuggled", - "a body-supplied tenant id must not be able to move a write"); - } - - private async Task> ReadOrganizationsAsync(string host) => - await GetAsync(host, "organizations"); - - private async Task> ReadOrganizationsInScopeAsync(string host) => - await GetAsync(host, "organizations-in-scope"); - - private async Task> ReadHostsAsync(string host) => - await GetAsync(host, "hosts"); - - private async Task> GetAsync(string host, string route) - { - using var client = _fixture.ClientFor(host); - - var response = await client.GetAsync( - new Uri($"/api/v1/isolationprobe/{route}", UriKind.Relative)); - - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync>())!; - } -} - -/// -/// The real application, on a real database, with the two seed tenants in it. -/// -/// -/// Its own container rather than the shared schema one: this fixture seeds through -/// SeedRunner and serves HTTP, and sharing would make the schema suite's exact row -/// counts depend on whether these tests ran first. -/// -public sealed class TenantIsolationFixture : WebApplicationFactory, IAsyncLifetime -{ - private readonly PostgresFixture _postgres = new(); - - public async Task InitializeAsync() - { - await _postgres.InitializeAsync(); - - await using (var tenancy = new TenancyDbContext( - new DbContextOptionsBuilder() - .UseNpgsql(_postgres.MigrationConnectionString, npgsql => - npgsql.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)) - .Options, - SharedKernel.Tenancy.StaticTenantContextAccessor.Unresolved)) - { - await tenancy.Database.MigrateAsync(); - } - - await using (var platform = new PlatformDbContext( - new DbContextOptionsBuilder() - .UseNpgsql(_postgres.MigrationConnectionString, npgsql => - npgsql.MigrationsHistoryTable(PlatformDbContextFactory.HistoryTable)) - .Options)) - { - await platform.Database.MigrateAsync(); - } - - // The seeder, not a fixture INSERT: these cases are about what a request sees, and - // what a request sees should be what `make seed` wrote. - await using var dataSource = NpgsqlDataSource.Create(_postgres.AppConnectionString); - var runner = new SeedRunner( - context => SeedComposition.Build(dataSource, context, NullLoggerFactory.Instance), - NullLogger.Instance); - - (await runner.RunAsync(CancellationToken.None)).Should().Be(0); - } - - async Task IAsyncLifetime.DisposeAsync() - { - await _postgres.DisposeAsync(); - await base.DisposeAsync(); - } - - /// A client whose requests arrive on . - /// - /// The host is the only input. No tenant header, no stubbed context — the resolver - /// reads platform_host_to_tenant and everything downstream follows from what it - /// finds, which is the whole point of running these through HTTP. - /// - public HttpClient ClientFor(string host) - { - var client = CreateClient(); - client.BaseAddress = new Uri($"http://{host}/"); - return client; - } - - /// Removes a row a write case created, as the platform role. - public async Task RemoveOrganizationAsync(string slug) - { - await using var platform = await PostgresFixture.OpenAsync( - _postgres.PlatformConnectionString); - await using var command = new NpgsqlCommand( - "DELETE FROM organizations WHERE slug = @slug", (NpgsqlConnection)platform); - command.Parameters.AddWithValue("slug", slug); - await command.ExecuteNonQueryAsync(); - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.UseEnvironment(Environments.Development); - - // UseSetting, not ConfigureAppConfiguration: the composition root reads these while - // building the host, and a source added later loses to the appsettings the app - // already read. Measured — the first shape produced "ConnectionStrings:Default is - // not configured" from the guard that exists to catch exactly this. - builder.UseSetting("ConnectionStrings:Default", _postgres.AppConnectionString); - builder.UseSetting("ConnectionStrings:PlatformAdmin", _postgres.PlatformConnectionString); - - builder.ConfigureTestServices(services => - { - services.AddControllers(options => options.Conventions.Insert( - 0, new TestControllerFilter(typeof(IsolationProbeController)))) - .AddApplicationPart(typeof(IsolationProbeController).Assembly); - - // The handler alone, not an assembly scan: a scan would re-register the - // pipeline behaviors the composition root already added, and a doubled - // TransactionBehavior is a nested frame on every request. - services.AddTransient< - MediatR.IRequestHandler>>, - ProbeQueryHandler>(); - }); - } -} - -/// -/// Reads and writes the same rows the isolation cases are about. -/// -/// -/// Everything goes through ISender, and that is not ceremony. A -/// TenancyDbContext injected into a controller is refused at resolution — -/// "resolved outside the ambient transaction ... it never saw SET LOCAL app.tenant_id" — -/// because a context obtained before TransactionBehavior opens the transaction -/// reads zero rows from every tenant-owned table and would do so silently. Measured: the -/// first version of this controller took the context directly and every case answered -/// 500. Going through the pipeline is what makes these cases statements about the request -/// path rather than about a context somebody assembled by hand. -/// -public sealed class IsolationProbeController(MediatR.ISender sender) - : ApiControllerBase, ITestOnlyController -{ - [HttpGet("organizations")] - public async Task Organizations() => - Ok((await sender.Send(new ProbeQuery(ProbeSubject.Organizations))).Value); - - [HttpGet("organizations-in-scope")] - public async Task OrganizationsInScope() => - Ok((await sender.Send(new ProbeQuery(ProbeSubject.OrganizationsInScope))).Value); - - [HttpGet("hosts")] - public async Task Hosts() => - Ok((await sender.Send(new ProbeQuery(ProbeSubject.Hosts))).Value); - - [HttpPost("organizations")] - public async Task Create([FromBody] CreateProbeRequest request) - { - ArgumentNullException.ThrowIfNull(request); - - // The body's tenantId is deliberately ignored: CreateOrganizationCommand takes its - // tenant from the context. Accepting it in the DTO is what lets a test assert that - // a caller cannot move a write by naming a tenant. - var result = await sender.Send( - new Modules.Tenancy.Application.Contracts.Tenant.CreateOrganizationCommand( - OrganizationId.From(Guid.CreateVersion7()), request.Slug, "Probe")); - - return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error); - } - - public sealed record CreateProbeRequest(Guid TenantId, string Slug); -} - -/// What the probe reads. One query type, so one handler covers the three. -public enum ProbeSubject -{ - Organizations, - OrganizationsInScope, - Hosts, -} - -/// -/// [PublicSurface], and the marker is load-bearing rather than decoration. These -/// requests arrive on a host and nothing else, so TenantResolverMiddleware resolves -/// them HostOnly, and TenantContextBehavior's second gate admits that origin -/// only for a marked type. Measured: without it every read came back 200 with an -/// empty body — the ceiling refusing, exactly as designed. It is also the honest shape: -/// [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) renders both seed -/// tenants to anonymous visitors, so an anonymous host-only read is what production does. -/// -[SharedKernel.Tenancy.PublicSurface] -public sealed record ProbeQuery(ProbeSubject Subject) - : MediatR.IRequest>>; - -/// -/// Reads through the module context, inside the transaction the pipeline opened. -/// -/// -/// Registered by hand in the fixture rather than by an assembly scan: the scan would also -/// re-register the pipeline behaviors the composition root already added, and a doubled -/// TransactionBehavior is a nested frame on every request. -/// -public sealed class ProbeQueryHandler( - TenancyDbContext db, SharedKernel.Tenancy.ITenantContext tenantContext) - : MediatR.IRequestHandler>> -{ - public async Task>> Handle( - ProbeQuery request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - - var rows = request.Subject switch - { - ProbeSubject.Organizations => - await db.Organizations - .Select(organization => organization.Slug) - .ToListAsync(cancellationToken), - - // The tenant's organizations narrowed to the request's organization scope. - // Explicit rather than a second query filter: an organization-scoped host - // scopes what a request may act on, and expressing it here is what makes the - // difference between the two routes observable. - ProbeSubject.OrganizationsInScope => - await db.Organizations - .Where(organization => tenantContext.OrganizationId == null - || organization.Id == tenantContext.OrganizationId) - .Select(organization => organization.Slug) - .ToListAsync(cancellationToken), - - _ => await db.PlatformHostMappings - .Select(mapping => mapping.Host) - .ToListAsync(cancellationToken), - }; - - return SharedKernel.Results.Result.Ok(rows); - } -} From f1f6314cccb177e88622ba8cd31b1181341a9240 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 13:01:13 +0300 Subject: [PATCH 39/55] fix(kernel): keep ADR-0037's promise and prove the reset case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 of Step 11's review. No blockers; two long-standing promises the packet was the right place to settle. ADR-0037 said of the idempotency port's raw Guid tenant: "The strongly-typed TenantId lands with the tenancy schema in Packet 6, and both move together." They did not. Packet 6 typed ITenantContext.TenantId and left IIdempotencyStore on a raw Guid, with IdempotentAttribute carrying a comment naming the seam it crossed at a single call site — and nothing recorded the divergence, which is how a promise like that gets discovered three packets later by someone who trusted it. The three methods, the internal key and the census now take TenantId, the unwrapping site is gone, and Amendment 4 records it. The guard changed shape with the type, not just its signature. It refused Guid.Empty; a typed id has two ways to be unassigned, and reading .Value on the first throws from inside the id type. It now tests IsInitialized() before both sentinels, matching AuditInput.EnsureValid and the unit of work's setter, and its test drives the unassigned value from an array element because default(TenantId) does not compile. The Phase Exit Decision names a case that reads with app.tenant_id RESET rather than merely unset, and nothing asserted it. The distinction is the whole reason the policies use NULLIF: a never-set GUC reads NULL, a reset one reads the empty string, and ''::uuid raises 22P02 — so without NULLIF a reused connection would error rather than filter. Measured: removing NULLIF from the nineteen policy predicates turns the new case red with exactly that 22P02. Module: Tenancy ADR: 0037, 0003 Co-Authored-By: Claude Opus 5 (1M context) --- .../Idempotency/IdempotentAttribute.cs | 15 ++--- .../Idempotency/InMemoryIdempotencyStore.cs | 37 +++++++----- .../Idempotency/IIdempotencyStore.cs | 7 ++- .../Database/TenantIsolationHttpTests.cs | 15 ++++- .../Database/UnitOfWorkTests.cs | 58 +++++++++++++++++++ .../InMemoryIdempotencyStoreTests.cs | 24 ++++++-- .../0037-idempotency-key-contract.md | 31 ++++++++++ 7 files changed, 155 insertions(+), 32 deletions(-) diff --git a/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs b/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs index 18b30eac..5badf1db 100644 --- a/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs +++ b/backend/src/LearnStack.Api/Idempotency/IdempotentAttribute.cs @@ -4,6 +4,7 @@ using System.Text; using LearnStack.Api.Common; using LearnStack.SharedKernel.Idempotency; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; @@ -197,11 +198,11 @@ [new LocalizedMessage("lockey_idempotency_key_invalid")], return; } - // Value once, here: IIdempotencyStore's key space is (Guid, string) and - // that port is not part of this conversion, so the seam is crossed at a - // single site rather than at each of its five call sites. Safe — the - // IsResolved gate above has already returned. - var tenantId = tenantContext.TenantId.Value; + // No unwrapping: the port's key space is (TenantId, string) as of Packet 7, which + // is what ADR-0037 § What we punted on promised when it said the raw Guid and + // ITenantContext.TenantId "both move together". The IsResolved gate above has + // already returned, so this id is a real one. + var tenantId = tenantContext.TenantId; var cancellationToken = context.HttpContext.RequestAborted; var fingerprint = await ComputeFingerprintAsync(context.HttpContext, cancellationToken) .ConfigureAwait(false); @@ -266,7 +267,7 @@ await RunAndRecordAsync(context, next, tenantId, key, claim.Token, cancellationT private async Task RunAndRecordAsync( ResourceExecutingContext context, ResourceExecutionDelegate next, - Guid tenantId, + TenantId tenantId, string key, Guid token, CancellationToken cancellationToken) @@ -387,7 +388,7 @@ private Outcome Classify(ResourceExecutedContext executed, HttpResponse response return Outcome.Record; } - private Task AbandonAsync(Guid tenantId, string key, Guid token) => + private Task AbandonAsync(TenantId tenantId, string key, Guid token) => store.AbandonAsync(tenantId, key, token, CancellationToken.None); /// diff --git a/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs b/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs index 8945d03a..8797442f 100644 --- a/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs +++ b/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs @@ -1,3 +1,4 @@ +using LearnStack.SharedKernel.Identifiers; using System.Collections.Concurrent; using LearnStack.SharedKernel.Idempotency; using LearnStack.SharedKernel.Time; @@ -79,13 +80,13 @@ public sealed class InMemoryIdempotencyStore(IClock clock) : IIdempotencyStore /// public const int MaxEntriesPerTenant = 1_000; - private readonly ConcurrentDictionary<(Guid Tenant, string Key), Entry> _entries = new(); + private readonly ConcurrentDictionary<(TenantId Tenant, string Key), Entry> _entries = new(); private long _lastSweepTicks; private Census _census = Census.Empty; public Task TryClaimAsync( - Guid tenantId, string key, string fingerprint, CancellationToken cancellationToken) + TenantId tenantId, string key, string fingerprint, CancellationToken cancellationToken) { Guard(tenantId, key); ArgumentNullException.ThrowIfNull(fingerprint); @@ -141,7 +142,7 @@ public Task TryClaimAsync( } public Task CompleteAsync( - Guid tenantId, + TenantId tenantId, string key, Guid token, IdempotentResponse? response, @@ -176,7 +177,7 @@ current with } public Task AbandonAsync( - Guid tenantId, string key, Guid token, CancellationToken cancellationToken) + TenantId tenantId, string key, Guid token, CancellationToken cancellationToken) { Guard(tenantId, key); @@ -186,23 +187,29 @@ public Task AbandonAsync( if (_entries.TryGetValue((tenantId, key), out var current) && current.Token == token) { return Task.FromResult( - _entries.TryRemove(new KeyValuePair<(Guid, string), Entry>((tenantId, key), current))); + _entries.TryRemove(new KeyValuePair<(TenantId, string), Entry>((tenantId, key), current))); } return Task.FromResult(false); } - private static void Guard(Guid tenantId, string key) + private static void Guard(TenantId tenantId, string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - // The tenant is the key space. An empty one is not a tenant — it is a - // call site that forgot to scope, and accepting it would build exactly - // the flat space this store's contract exists to prevent. - if (tenantId == Guid.Empty) + // The tenant is the key space. An unassigned one is not a tenant — it is a call + // site that forgot to scope, and accepting it would build exactly the flat space + // this store's contract exists to prevent. + // + // IsInitialized() first: reading Value on an unset Vogen id throws from inside the + // id type, which is neither this guard's contract nor a message a caller can act + // on. Both sentinels are refused, because a struct nobody assigned and one + // assigned the all-zero Guid are the same mistake. + if (!tenantId.IsInitialized() || tenantId.Value == Guid.Empty) { throw new ArgumentException( - "An idempotency key is scoped to a tenant; Guid.Empty is not one.", nameof(tenantId)); + "An idempotency key is scoped to a tenant; an unassigned id is not one.", + nameof(tenantId)); } } @@ -238,7 +245,7 @@ private void Sweep(DateTimeOffset now) return; } - var counts = new Dictionary(); + var counts = new Dictionary(); var total = 0; foreach (var pair in _entries) @@ -274,11 +281,11 @@ private void Sweep(DateTimeOffset now) /// claims — a soft ceiling, which is what a ceiling that must never evict a /// live record has to be. /// - private sealed record Census(IReadOnlyDictionary PerTenant, int Total) + private sealed record Census(IReadOnlyDictionary PerTenant, int Total) { - public static readonly Census Empty = new(new Dictionary(), 0); + public static readonly Census Empty = new(new Dictionary(), 0); - public bool IsFull(Guid tenantId) => + public bool IsFull(TenantId tenantId) => Total >= MaxEntries || PerTenant.GetValueOrDefault(tenantId) >= MaxEntriesPerTenant; } diff --git a/backend/src/LearnStack.SharedKernel/Idempotency/IIdempotencyStore.cs b/backend/src/LearnStack.SharedKernel/Idempotency/IIdempotencyStore.cs index dc61a614..c302cf73 100644 --- a/backend/src/LearnStack.SharedKernel/Idempotency/IIdempotencyStore.cs +++ b/backend/src/LearnStack.SharedKernel/Idempotency/IIdempotencyStore.cs @@ -1,3 +1,4 @@ +using LearnStack.SharedKernel.Identifiers; namespace LearnStack.SharedKernel.Idempotency; /// @@ -121,7 +122,7 @@ public interface IIdempotencyStore /// /// Cancellation. Task TryClaimAsync( - Guid tenantId, + TenantId tenantId, string key, string fingerprint, CancellationToken cancellationToken); @@ -144,7 +145,7 @@ Task TryClaimAsync( /// silence. /// Task CompleteAsync( - Guid tenantId, + TenantId tenantId, string key, Guid token, IdempotentResponse? response, @@ -162,5 +163,5 @@ Task CompleteAsync( /// /// true when the key was released by this caller. Task AbandonAsync( - Guid tenantId, string key, Guid token, CancellationToken cancellationToken); + TenantId tenantId, string key, Guid token, CancellationToken cancellationToken); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs index 3dca3479..6244ca11 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs @@ -343,7 +343,11 @@ INSERT INTO tenant_settings } /// The registry-assigned actor; every seeded row is attributed to it. - private static readonly Guid Actor = Guid.Parse("00000000-0000-7000-8000-000000000001"); + /// + /// Read from rather than repeating the literal. The + /// value already has a home, and a second copy is a second thing to keep true. + /// + private static readonly Guid Actor = UserId.SystemActor.Value; /// A client whose requests arrive on . /// @@ -437,6 +441,12 @@ public sealed record ForeignWriteRequest(Guid TenantId); } /// What the probe reads. +/// +/// The unresolved-context read is a separate request type rather than a third member here, +/// and the asymmetry is deliberate: what distinguishes it is not which rows it wants but +/// which marker it carries, and a marker is a property of the type. Folding it in would +/// mean one request type wearing two ceilings. +/// public enum ProbeSubject { Organizations, @@ -558,8 +568,7 @@ INSERT INTO tenant_settings VALUES (uuidv7(), @tenant, NULL, 'smuggled', '"x"', now(), @actor, 0) """; command.Parameters.AddWithValue("tenant", request.TenantId); - command.Parameters.AddWithValue( - "actor", Guid.Parse("00000000-0000-7000-8000-000000000001")); + command.Parameters.AddWithValue("actor", UserId.SystemActor.Value); try { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 34a6d672..697bd2e2 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -1,3 +1,4 @@ +using System.Data.Common; using FluentAssertions; using LearnStack.Api.Composition; using LearnStack.Application.Pipeline; @@ -946,6 +947,63 @@ await unitOfWork.SetTenantContextAsync( } } + [Fact] + public async Task A_reset_tenant_context_reads_nothing_rather_than_everything() + { + // The case the Phase Exit Decision names: a read with `app.tenant_id` RESET + // rather than merely unset. The distinction is the whole reason the policies are + // written with NULLIF. + // + // Never-set and reset are different values. `current_setting(name, true)` returns + // NULL for a variable that was never set, and the EMPTY STRING for one that was + // set and then reset — which is the state a pooled connection is in after + // DISCARD ALL, and the state every request leaves behind. `''::uuid` raises + // 22P02, so a policy that cast without NULLIF would not filter: it would error + // on the first read of every reused connection. With NULLIF the empty string + // becomes NULL, the predicate is NULL, and NULL is false for both USING and + // WITH CHECK — the table returns nothing. + // + // Driven on one physical connection, because that is the only way the two states + // are distinguishable: set a real tenant, observe rows, reset, observe none. + var builder = new NpgsqlDataSourceBuilder(_schema.Postgres.AppConnectionString); + builder.ConnectionStringBuilder.MaxPoolSize = 1; + builder.ConnectionStringBuilder.NoResetOnClose = true; + await using var dataSource = builder.Build(); + + await using var connection = await dataSource.OpenConnectionAsync(); + + await ExecuteOnAsync(connection, + $"SELECT set_config('app.tenant_id', '{SchemaFixture.TenantA}', false)"); + + (await ScalarOnAsync(connection, "SELECT count(*) FROM organizations")) + .Should().NotBe("0", "the tenant is set, so its rows are visible"); + + // RESET, not "never set" — the variable exists and now holds ''. + await ExecuteOnAsync(connection, "RESET app.tenant_id"); + + (await ScalarOnAsync(connection, "SELECT current_setting('app.tenant_id', true)")) + .Should().BeEmpty( + "a reset GUC reads back as the empty string, which is exactly the value " + + "NULLIF exists to turn into NULL"); + + (await ScalarOnAsync(connection, "SELECT count(*) FROM organizations")) + .Should().Be("0", + "and the read fails closed — it returns nothing rather than everything, " + + "and does not raise 22P02 on the way"); + } + + private static async Task ExecuteOnAsync(DbConnection connection, string sql) + { + await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)connection); + await command.ExecuteNonQueryAsync(); + } + + private static async Task ScalarOnAsync(DbConnection connection, string sql) + { + await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)connection); + return (await command.ExecuteScalarAsync())?.ToString(); + } + // ── Helpers ────────────────────────────────────────────────────────────── private ServiceProvider BuildProvider(NpgsqlDataSource? dataSource = null) diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Idempotency/InMemoryIdempotencyStoreTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Idempotency/InMemoryIdempotencyStoreTests.cs index 16a87529..bf7a20aa 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Idempotency/InMemoryIdempotencyStoreTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Idempotency/InMemoryIdempotencyStoreTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using LearnStack.Infrastructure.Idempotency; using LearnStack.SharedKernel.Idempotency; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Time; using Xunit; @@ -22,8 +23,14 @@ public sealed class InMemoryIdempotencyStoreTests private static readonly DateTimeOffset Origin = new(2026, 8, 20, 9, 0, 0, TimeSpan.Zero); - private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); - private static readonly Guid OtherTenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000b"); + // Typed, as of Packet 7: the port's key space is (TenantId, string), which is what + // ADR-0037 promised when it said the raw Guid and ITenantContext.TenantId "both move + // together". A raw Guid no longer compiles here, which is the point of the change. + private static readonly TenantId Tenant = + TenantId.From(Guid.Parse("018f4d40-0000-7000-8000-00000000000a")); + + private static readonly TenantId OtherTenant = + TenantId.From(Guid.Parse("018f4d40-0000-7000-8000-00000000000b")); private const string Key = "01HXIDEMPOTENT0001"; private const string Fingerprint = "fingerprint-a"; @@ -94,10 +101,19 @@ public async Task An_Unscoped_Tenant_Is_Refused() { var (store, _) = NewStore(); - var claim = async () => await store.TryClaimAsync(Guid.Empty, Key, Fingerprint, default); + // default(TenantId) does not compile — Vogen's VOG009 analyzer prohibits it — so + // the unassigned value comes from an array element, which the analyzer cannot see + // and the runtime leaves zeroed. That is also how one reaches production: a struct + // field nobody assigned, a default(T) in a generic, a deserializer that skipped a + // member. Typing the port did not remove this guard's job; it changed the shape of + // the sentinel it has to refuse. + var slot = new TenantId[1]; + var unassigned = slot[0]; + + var claim = async () => await store.TryClaimAsync(unassigned, Key, Fingerprint, default); await claim.Should().ThrowAsync( - "Guid.Empty is not a tenant — it is a call site that forgot to scope"); + "an unassigned id is not a tenant — it is a call site that forgot to scope"); } // ---- fingerprint ------------------------------------------------------- diff --git a/docs/decisions/0037-idempotency-key-contract.md b/docs/decisions/0037-idempotency-key-contract.md index 282d2573..ee37dc37 100644 --- a/docs/decisions/0037-idempotency-key-contract.md +++ b/docs/decisions/0037-idempotency-key-contract.md @@ -568,6 +568,37 @@ Neither changes what this ADR decides. `PostgresIdempotencyStore` does not exist yet — it is demand-gated on the trigger Amendment 1 names — so both land while the table has no rows and the constraint validates instantly. +### Amendment 4 — the port takes `TenantId`, one packet later than promised (2026-09-03) + +§ What we punted on said of the raw `Guid` tenant: "The strongly-typed `TenantId` +lands with the tenancy schema in Packet 6, **and both move together**." They did +not. Packet 6 typed `ITenantContext.TenantId`; `IIdempotencyStore` kept the raw +`Guid` through Packet 6 and most of Packet 7, and `IdempotentAttribute` carried a +comment naming the seam it crossed at a single call site. Nothing recorded the +divergence, which is how a promise like this is usually discovered — three packets +later, by someone who trusted it. + +**What ships.** `TryClaimAsync`, `CompleteAsync` and `AbandonAsync` take +`TenantId`; the store's internal key becomes `(TenantId, string)` and its census +`Dictionary`. The single unwrapping site in `IdempotentAttribute` +is gone, so the port is now what [Standards 02](../standards/02-backend-coding.md) +requires of a public surface rather than the exception the original text +acknowledged. + +**What changed in the guard, and why it is not cosmetic.** The refusal was +`tenantId == Guid.Empty`. A typed id has two ways to be unassigned — a Vogen value +nobody constructed, and one constructed from the all-zero `Guid` — and reading +`.Value` on the first throws from inside the id type, which is neither this +guard's contract nor a message a caller can act on. The guard now tests +`IsInitialized()` first and both sentinels after, matching `AuditInput.EnsureValid` +and `NpgsqlUnitOfWork`'s setter. Its test drives the unassigned value from an array +element, because `default(TenantId)` does not compile — Vogen's VOG009 analyzer +prohibits it — and an array slot is also how the value reaches production. + +**What did not change.** The key space, the contract's states, the fingerprint +rule, and Amendment 1's gating of the durable store. This is the type of one +parameter, not a change to what the port does. + ## References - [ADR-0035: Demand-Gated Infrastructure](0035-demand-gated-infrastructure.md) From e1ed969a5b97e49aceaecdee9df51818324cb15a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 13:05:17 +0300 Subject: [PATCH 40/55] docs(roadmap): close Packet 7 with the record of what it got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivery record, the status blocks in three carriers, the three architecture rules this packet added and never registered, and the two isolation entries that promised a request-level case without naming one. Measured at close: 1187 tests green — counted from a run, not computed. An earlier commit message in this packet carried a total that was eight short because it summed two review rounds instead of applying the second on top. The record is long for the reason Packets 5 and 6's were. Eleven steps, each reviewed twice, and the second round repeatedly found the first round's fix: a conflict translation that turned a 409 into a 500, a seeder idempotency check that masked validation failures, a cache invalidation whose comment claimed a guarantee its placement did not give. The sharpest finding was in the last step — a test that asserted an anonymous POST failed, and therefore passed against a deleted endpoint and against a database with every policy dropped. It also records what the packet did not ship and who owns each: nothing is audited until Packet 9, nothing is authorized until Phase 03, PlatformAdminScope ships with no reachable caller, and the host-resolution cache is invalidated before the commit rather than after. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 25 ++++- README.md | 9 +- docs/roadmap/phase-02a-kernel-tenancy.md | 102 ++++++++++++++++-- .../21-architecture-tests-catalogue.md | 91 +++++++++++++--- 4 files changed, 198 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f663472..ce069130 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,11 +36,10 @@ repository holds only LearnStack's side of the boundary, in **Phase 01 complete. [Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress — -packets 0–3, 3b, 4, 5 and 6 shipped; packets 3b–10 were re-scoped on 2026-08-08 +packets 0–3, 3b, 4, 5, 6 and 7 shipped; packets 3b–10 were re-scoped on 2026-08-08 after a four-report audit of the corpus. -[Packet 7](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — host and -tenant resolution, the EF query filters, the request-level isolation suite and -the two seed tenants — is next.** +[Packet 8](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — the Tenant +Customization foundation — is next.** **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the @@ -109,6 +108,24 @@ is long because several of its defects were introduced by the *fix* for an earlier one, and because three tests were caught agreeing with the code instead of constraining it — the packet's most repeated lesson. +**Packet 7** shipped what a request does with a tenant: host classification and +resolution, `EffectiveHost` normalization, the negative host cache, the +four-origin `TenantContextFactory`, and `TenantContextBehavior`'s two-gate +authority ceiling with `[AllowsUnresolvedTenantContext]` and `[PublicSurface]` as +enumerated holes. With them: `ProvisionTenantCommand`, the one operation +[ADR-0042](docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) +sanctions to write two aggregate roots on one transaction; `CreateOrganizationCommand` +and `MapHostToTenantCommand`, both taking their tenant from the context and never +from the request; `LearnStack.Tools.Seeder` and the two seed tenants +(`demo-english`, `demo-yoga`) written through those commands, so `make seed` exercises +the request path rather than a second one; and the request-level isolation suite — +the first fixture pairing the real middleware chain with a real database. Its record, +[Delivery Record (Packet 7)](docs/roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-7), +is long for the reason Packets 5 and 6's were: eleven steps, each reviewed twice, +and the second round repeatedly found the first round's fix. The sharpest finding +was a test that asserted an anonymous POST failed and therefore passed against a +deleted endpoint. + **Packet 6** shipped the tenancy schema: the four database roles, ten tables in two independent migration chains, and every one of them under `ENABLE` **and** `FORCE ROW LEVEL SECURITY` with the corrected diff --git a/README.md b/README.md index 311ff7f1..593a6382 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,12 @@ for LearnStack's side of the boundary. ## Status **Phase 01 complete. [Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress — -packets 0–3, 3b, 4, 5 and 6 shipped; packets 4–10 re-scoped on 2026-08-08. -[Packet 7](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — host and tenant -resolution, the query filters, and the two seed tenants — is next.** +packets 0–3, 3b, 4, 5, 6 and 7 shipped; packets 4–10 re-scoped on 2026-08-08. +Packet 7 landed host and tenant resolution, the query filters, tenant provisioning +and the two seed tenants — `demo-english` and `demo-yoga`, which `make seed` writes +through the same commands a request uses. +[Packet 8](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — the Tenant +Customization foundation — is next.** Phase 01 shipped the .NET 10 solution scaffold, the `pnpm` frontend monorepo (`apps/web` + `packages/{config,ui,sdk}`), the local-dev `docker-compose` stack, and the diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 90872cf2..18e730f0 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -1,7 +1,7 @@ # Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets -> **Status (2026-08-28).** Phase 02a in progress. Packets 0–3, 3b, 4, 5 and 6 shipped; the -> 2026-08-08 restructure re-scoped packets 4–10 and added packet 3b. Each packet +> **Status (2026-09-03).** Phase 02a in progress. Packets 0–3, 3b, 4, 5, 6 and 7 shipped; +> the 2026-08-08 restructure re-scoped packets 4–10 and added packet 3b. Each packet > is independently reviewable in its own commit, matching the > [Phase 01 cadence](phase-01-repository-tooling.md). The order is dependency-driven: a > later packet may consume any earlier packet's deliverables, never the reverse. @@ -16,7 +16,7 @@ > | 4 | API conventions | ✅ [record](#delivery-record-packet-4) | > | 5 | Foundation ports and default implementations | ✅ [record](#delivery-record-packet-5) | > | 6 | Tenancy schema and the corrected RLS template | ✅ [record](#delivery-record-packet-6) | -> | 7 | Tenant and organization resolution, isolation, two tenants | ⏳ [scope](#packet-sequence) | +> | 7 | Tenant and organization resolution, isolation, two tenants | ✅ [record](#delivery-record-packet-7) | > | 8 | Tenant Customization foundation | ⏳ [scope](#packet-sequence) | > | 9 | Audit infrastructure and the entitlement socket | ⏳ [scope](#packet-sequence) | > | 10 | Architecture tests green and phase exit | ⏳ [scope](#packet-sequence) | @@ -30,7 +30,8 @@ > [`## Delivery Record (Packet 3b)`](#delivery-record-packet-3b), and Packet 4 in > [`## Delivery Record (Packet 4)`](#delivery-record-packet-4), Packet 5 in > [`## Delivery Record (Packet 5)`](#delivery-record-packet-5), and Packet 6 in -> [`## Delivery Record (Packet 6)`](#delivery-record-packet-6) — each kept separate +> [`## Delivery Record (Packet 6)`](#delivery-record-packet-6) and Packet 7 in +> [`## Delivery Record (Packet 7)`](#delivery-record-packet-7) — each kept separate > because the frozen one is scoped to packets 0–3.** ## Goal @@ -1400,8 +1401,6 @@ The remaining exit gates (tenant + organization resolution, isolation tests runn conventions, two seed tenants, architecture-test catalogue green) close as Packets 3b–10 ship. - - ## Delivery Record (Packets 0–3) Shipped history, kept verbatim. Packets 0–3 and the 2026-08-08 restructure annotation @@ -2423,3 +2422,94 @@ in a record rather than in production. > does not mistake a green suite for proof of them: a cross-module read inside the > ambient transaction returning rows, and an outer failure after an inner write > leaving zero rows in both modules. Both need a second module `DbContext`. + +## Delivery Record (Packet 7) + +Kept separate from the records above, and long for the reason Packets 5 and 6 were: +most of what follows is a defect this packet introduced and its own review rounds +found. Eleven steps, each reviewed twice — once by an Opus agent, once by a Sonnet +one — and the second round repeatedly found the first round's fix. + +> **Packet 7 — Tenant and organization resolution, isolation, two tenants ✅** +> +> **Measured at close: 1187 tests green** — 1 contract, 76 architecture, 802 unit, +> 308 integration. Counted from a run, not computed from a plan; an earlier commit +> message in this packet carried an arithmetic total that was eight short. + +### What shipped + +- **Host and tenant resolution.** `HostClassificationMiddleware`, + `TenantResolverMiddleware`, `CachedHostToTenantResolver`, `EffectiveHost` + normalization, the `UnknownHostCache`, and the four-origin `TenantContextFactory` + behind a pure, total, synchronous `Create`. +- **The authority ceiling.** `TenantContextBehavior`'s two nested gates, with + `[AllowsUnresolvedTenantContext]` and `[PublicSurface]` as enumerated holes and an + architecture rule counting each. +- **Provisioning.** `ProvisionTenantCommand` — the one operation + [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) + sanctions to write two aggregate roots on one transaction — with + `IProvisionsTenant`, the `SetProvisioningTenantContextAsync` announcement seam, and + `IAggregateWriteStore`, the codebase's first persistence port. +- **`CreateOrganizationCommand` and `MapHostToTenantCommand`,** both taking their + tenant from the context and never from the request, plus + `PlatformHostMapping.Create`, which did not exist and without which nothing could + write a host row at all. +- **Two seed tenants** — `demo-english` and `demo-yoga`, two organizations each, one + host row each of a different live class — written by `LearnStack.Tools.Seeder` + through the same commands a request sends, because ADR-0042 requires the seeder not + to hold a second copy of the sanctioned cross-aggregate write. `make seed` runs it. +- **The request-level isolation suite,** the first fixture pairing + `WebApplicationFactory` with a real Postgres container: five cases driven + by the host header alone, with no stubbed `ITenantContext`. + +### What this packet got wrong, and how it was found + +- **A `[Theory]` that agreed with the code.** Three separate tests were caught + asserting what the implementation did rather than what it owed. The sharpest was in + the final step: `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck` performed no + `INSERT` at all — it asserted that an anonymous POST failed, which a 404 satisfies, + so it passed against a **deleted endpoint** and against a database with every policy + dropped. `Org_X_cannot_read_Org_Y` read a tenant-wide table and narrowed the rows + with a `Where` the test's own probe handler wrote. +- **A validator whose every message was discarded.** `ValidationBehavior` builds its + response from `ErrorCode ?? ErrorMessage`, and FluentValidation always fills + `ErrorCode` with the validator's own name — so a malformed slug and a tenant sharing + its organization's id both reached the caller as `lockey_predicatevalidator`, the + second under an empty field key. +- **A fix that made things worse.** Translating a uniqueness conflict into four + module-specific error codes turned a duplicate slug from a 409 into a **500**: + `HttpStatusMap` is a closed table of cross-cutting codes and falls through to + `InternalServerError`. The shape that works is the one `ValidationBehavior` already + used — a canonical top-level code plus per-field details. +- **An architecture rule with three escapes.** The cross-aggregate rule missed a fused + port, every `INotificationHandler`, and any non-public constructor — each proven by + dropping the shape into a production assembly and watching 76 cases pass. Worse, the + ADR amendment written alongside it *claimed* the fused case was caught. +- **`make seed` could not run.** It read `ConnectionStrings__Default` from the process + environment, which nothing populates: the Makefile has no `include .env`. The error + it printed offered a remedy — copy `.env.example` to `.env` — that is exactly the + state that already fails. +- **An idempotency check that masked failures.** The seeder treated any + `business_rule_violation` as "already seeded", which was safe while provisioning was + the only command and stopped being safe the moment `MapHostToTenantCommand` returned + the same code for three different conditions. +- **Two obligations inherited without noticing.** ADR-0036 assigns the + `Tenancy:PlatformHosts` collision check to "whichever packet builds the host-mapping + writer", and `UnknownHostCache.Forget` had no caller because none existed. This + packet built that writer. +- **A promise from ADR-0037 that Packet 6 did not keep.** `IIdempotencyStore` still + took a raw `Guid` tenant after the ADR said it and `ITenantContext.TenantId` "both + move together". Amendment 4 records the conversion and the divergence. + +### What it did not ship, and who owns each + +- **Nothing is audited.** `AuditLogBehavior` lights up in Packet 9; `TransactionBehavior` + carries the `TODO` marking the line the MUST-class write goes on. +- **Nothing is authorized.** No permission key is registered; the three commands are + reachable only from the seeder and, from Phase 02c, the Hub over `/api/internal/*`. + Phase 03 ships the catalogue. +- **`PlatformAdminScope` has no reachable caller.** It ships with its gate closed; + Packet 9's GDPR handler is the first. +- **The host-resolution cache is invalidated before the commit, not after.** The + guarantee is therefore the request *after* the write, not the one racing it; closing + the rest needs a post-commit seam on `IUnitOfWork`, whose surface ADR-0040 governs. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index bc115473..1875e0fd 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -990,13 +990,19 @@ first two rows are coverage checks; the last three are the proof. - **Source:** ADR-0003 Amendment 3 § Test requirement. - **Type:** **integration** test (Testcontainers + PostgreSQL), not an architecture test. **Kind:** runtime. -- **Status:** **Implemented** (Packet 6 step 4, - `LearnStack.Tests.Integration`, `TenancySchemaTests`). It moved forward because - the schema's own assertions needed the two-tenant seed anyway: without rows for - both tenants, every count assertion in that class passed against dropped - policies. Packet 7 re-runs it through `TenantResolverMiddleware` and the EF - query filters rather than through `set_config`. -- **Phase:** 02a (Packet 6 ships the schema-level case; Packet 7 the request-level one). +- **Status:** **Implemented, twice.** The schema-level case is Packet 6 step 4 + (`TenancySchemaTests`); the request-level one is Packet 7 step 11 + (`Database/TenantIsolationHttpTests`), which drives it through + `HostClassificationMiddleware`, `TenantResolverMiddleware`, the announcement and the + EF query filters, with the host header as the only input and no stubbed + `ITenantContext`. The schema-level case moved forward because that class's own + assertions needed the two-tenant seed anyway: without rows for both tenants, every + count in it passed against dropped policies. +- **Reads `tenant_settings`, not `platform_host_to_tenant`.** The row shape the rule + names is tenant-owned with `organization_id IS NULL`; the host table is + platform-scoped and its policy has no organization term, so reading it would name the + wrong mechanism. The first version of the request-level case did exactly that. +- **Phase:** 02a (Packet 6 the schema-level case; Packet 7 the request-level one). #### `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck` @@ -1008,18 +1014,71 @@ first two rows are coverage checks; the last three are the proof. - **Source:** ADR-0003 Amendment 3 § Test requirement; [05-database.md](05-database.md). - **Type:** **integration** test (Testcontainers + PostgreSQL). **Kind:** runtime. -- **Status:** **Implemented** (Packet 6 step 4, - `LearnStack.Tests.Integration`, `TenancySchemaTests`) — as a `[Theory]` over - both halves, because `WITH CHECK` guards `INSERT` and `UPDATE` and a rule - covering one leaves the other open. -- **Phase:** 02a (Packet 6 ships the schema-level case; Packet 7 the request-level one). +- **Status:** **Implemented, twice.** Packet 6 step 4 (`TenancySchemaTests`) as a + `[Theory]` over both halves, because `WITH CHECK` guards `INSERT` and `UPDATE` and a + rule covering one leaves the other open. Packet 7 step 11 + (`Database/TenantIsolationHttpTests`) issues the write through a request: raw SQL on + the ambient connection, so no query filter is in front of it and only `WITH CHECK` can + refuse. The first version of that case asserted merely that an anonymous POST failed, + and passed against a **deleted endpoint** and against a database with every policy + dropped — which is why the entry now says what the case must observe rather than what + it must return. +- **Phase:** 02a (Packet 6 the schema-level case; Packet 7 the request-level one). `Tenant_A_cannot_read_Tenant_B_data`, `Org_X_cannot_read_Org_Y_within_TenantA` and `Unsetting_tenant_context_returns_zero_rows_through_RLS` are ordinary integration tests -named in the phase document rather than catalogue-governed rules; all three shipped -alongside the two rules above in Packet 6 step 4 and are listed in -[Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md), which re-runs them through -the request path. +named in the phase document rather than catalogue-governed rules. All three shipped +alongside the two rules above in Packet 6 step 4, and Packet 7 step 11 re-runs them +through the request path in `Database/TenantIsolationHttpTests`. + +Three things are worth recording about that second run, because each was a defect in its +first version. `Org_X_…` must read an **organization-scoped** table — `tenant_settings`, +whose policy carries an organization term — not `organizations`, which is tenant-wide and +where every organization is visible to every other by design; reading the latter and +narrowing the rows in the test's own handler tested the test. +`Unsetting_tenant_context_…` must actually run a query under an unresolved context, which +takes a `PlatformHost` request (a host in `Tenancy:PlatformHosts`); asserting a 404 for an +unknown host instead exercises the resolver and never reaches a table. And the suite as a +whole constrains the **composite** answer, not one layer: measured, deleting both EF query +filters leaves all five green because RLS holds, disabling RLS leaves the four reads green +because the filters hold, and removing both turns all five red. + +#### `Every_Scoping_Interface_Carries_Its_Marker` + +- **Asserts:** every entity implementing a scoping interface — `ITenantOwned`, + `IOrganizationScoped` — also carries the marker attribute the filter and policy + generators read. An entity that implements one and not the other is scoped in the type + system and unscoped everywhere it matters. +- **Source:** [ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md). +- **Type:** xUnit + reflection. **Kind:** structural. +- **Status:** **Implemented** (Packet 7, `LearnStack.Tests.Architecture`). +- **Phase:** 02a Packet 7. + +#### `The_Request_Filter_Sees_Every_Shape_MediatR_Dispatches` + +- **Asserts:** the predicate that enumerates request types covers every shape MediatR + dispatches, so a rule written over "all requests" is not silently blind to one of them. + Measured facts behind it: `IStreamRequest.GetInterfaces()` is empty and + `IBaseRequest.IsAssignableFrom(IStreamRequest<>)` is false, so a filter written the + obvious way misses streamed requests entirely. +- **Source:** [04-api-design.md](04-api-design.md). +- **Type:** xUnit + reflection. **Kind:** structural. +- **Status:** **Implemented** (Packet 7, `LearnStack.Tests.Architecture`). +- **Phase:** 02a Packet 7. + +#### `The_Sweep_Covers_Every_Production_Assembly` + +- **Asserts:** every `LearnStack.*` project under `backend/src` is loadable by the rules + that sweep production assemblies. A project the sweep cannot load is a project every + reflection rule silently skips, which is worse than a rule that fails: it reports green + over code it never read. +- **Note:** it is why adding a project — `LearnStack.Tools.Seeder` in Packet 7 step 10 — + requires a `ProjectReference` from the architecture test project. The rule names the + remedy in its own failure message. +- **Source:** [21-architecture-tests-catalogue.md § What a structural test proves](#what-a-structural-test-proves--and-what-it-does-not). +- **Type:** xUnit + reflection. **Kind:** structural. +- **Status:** **Implemented** (Packet 7, `LearnStack.Tests.Architecture`). +- **Phase:** 02a Packet 7. #### `Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction` From 150784a58a911e2ad4749516aa7709eb8c05443f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 15:35:53 +0300 Subject: [PATCH 41/55] fix(security): close three credential and transaction gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host resolver opened a transaction and announced app.resolving_host in it without SET TRANSACTION READ ONLY. Four carriers — Database Standards, Security Standards, the glossary and ADR-0040 — describe that transaction as read-only, and read-only is the property that makes an out-of-band setter acceptable at all: learnstack_app holds write grants on the tables the connection reaches, so nothing but the statement made the prose true. The sibling setter two files away had carried it since Packet 6. A behavioural test cannot see it — the transaction is opened, used and disposed inside one method — so the guard is a source scan over both setters, checking presence and position. SET TRANSACTION must precede the transaction's first statement or PostgreSQL refuses it, and the integration half pins the fact the design rests on: a read-only transaction admits set_config and refuses a write with 25006 while the grant is still held. The platform-admin guard accepted rolsuper. A superuser does bypass RLS, so it answered the literal question correctly — and that was the trap. It also bypasses the GRANT matrix that bounds the role, which 02-create-roles.sql writes NOSUPERUSER for on purpose. A deployment that promoted the role widened the platform credential from "reads across tenants" to "does anything" and the guard said nothing. An unparseable connection string was echoed through a redaction regex whose userinfo pattern could not cross a '/' or a second '@' inside a password. Both are legal password characters; either put the secret in a startup log. Measured with two canaries. The message no longer repeats the value at all — an unparseable one cannot be reliably redacted — and names the key and the expected form instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../PersistenceCompositionExtensions.cs | 61 ++++++----------- .../CachedHostToTenantResolver.cs | 13 ++++ .../TenancyConventionTests.cs | 47 +++++++++++++ .../Database/HostResolutionTests.cs | 68 +++++++++++++++++++ .../Database/PlatformAdminScopeTests.cs | 38 +++++++++++ .../ApplicationDataSourceGuardTests.cs | 19 +++++- 6 files changed, 203 insertions(+), 43 deletions(-) diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index c12a6fd0..bf9dc331 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -290,11 +290,16 @@ internal static void ValidateApplicationConnectionString(string? connectionStrin // operator who pasted a URI-style DSN — the form DATABASE_URL carries // on several hosts — otherwise gets a bare ArgumentException out of // System.Data.Common. + // The value is NOT echoed, redacted or otherwise. It failed to parse, so + // there is no field to be confident about: the userinfo pattern could not + // cross a '/' or a second '@' inside a password, and either one put the + // secret in a startup log. The message's job is to name the key and the + // expected form, and it does that without quoting anything. throw new InvalidOperationException( - $"ConnectionStrings:Default is not a valid connection string: " - + $"{RedactUnparsed(connectionString)}. " - + "The expected form is a semicolon-separated key/value list — Host, Port, " - + "Database, Username, Password — not a URI. See .env.example.", + "ConnectionStrings:Default is not a valid connection string. The expected " + + "form is a semicolon-separated key/value list — Host, Port, Database, " + + "Username, Password — not a URI. The value is not repeated here because " + + "an unparseable one cannot be reliably redacted. See .env.example.", exception); } @@ -357,6 +362,16 @@ AND pg_has_role(current_user, r.oid, 'MEMBER')) /// the failure that looks like nothing at all, because every cross-tenant query /// simply returns fewer rows. /// + /// + /// AND NOT rolsuper, not OR rolsuper. A superuser does bypass + /// RLS, so accepting it satisfies the literal question this guard asks — and that is + /// the trap. A superuser also bypasses the table and schema GRANT matrix that is what + /// BOUNDS this role: `02-create-roles.sql` writes it BYPASSRLS NOSUPERUSER + /// deliberately, and a deployment that promoted it would have widened the one + /// credential the platform path uses from "reads across tenants" to "does anything". + /// Refusing it here is how that misconfiguration fails at startup rather than at the + /// first incident. + /// private static async Task RequireBypassRole(NpgsqlConnection connection, bool async) { await using var command = connection.CreateCommand(); @@ -364,7 +379,7 @@ private static async Task RequireBypassRole(NpgsqlConnection connection, bool as """ SELECT EXISTS ( SELECT 1 FROM pg_roles r - WHERE r.rolname = current_user AND (r.rolbypassrls OR r.rolsuper)) + WHERE r.rolname = current_user AND r.rolbypassrls AND NOT r.rolsuper) """; var bypasses = async @@ -405,40 +420,4 @@ private static string Redact(NpgsqlConnectionStringBuilder parsed) return redacted.ConnectionString; } - /// - /// The same, for a value Npgsql could not parse — so there is no builder to - /// clear a field on. - /// - /// - /// - /// A regex is the only tool left here, so it covers both forms a rejected value - /// arrives in. The keyword pass knows every alias Npgsql accepts — - /// Password, PSW, PWD, measured, not the canonical - /// spelling alone. - /// - /// - /// The second pass is the one this branch exists for. Npgsql rejects a - /// URI-style DSN outright — measured — so postgres://user:secret@host/db - /// is not some exotic input here, it is the input, and it carries its password - /// in the userinfo where no password= appears. The first version of this - /// method echoed it whole into an exception message that a startup failure puts - /// in the log. - /// - /// - private static string RedactUnparsed(string connectionString) - { - var byKeyword = System.Text.RegularExpressions.Regex.Replace( - connectionString, - "(?i)\\b(password|pwd|psw)(\\s*=)[^;]*", - "$1$2***"); - - // The whole userinfo, not the password half: a value shaped like a URI - // failed to parse, so there is no field to be confident about, and the - // username is not what the operator needs from this message anyway — the - // message tells them the form is wrong, not which role they named. - return System.Text.RegularExpressions.Regex.Replace( - byKeyword, - "(?i)(://)[^/@\\s]*@", - "$1***@"); - } } diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs index 54599e01..cb29afc0 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs @@ -185,6 +185,19 @@ await _cache.SetAsync( await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + // READ ONLY first, before the announcement. This is a member of the closed set of + // app.tenant_id / app.resolving_host setters, and read-only is the property that + // makes an out-of-band setter uncontroversial: learnstack_app holds write grants + // on the tables this connection can reach, so nothing but this statement stops a + // future edit here from writing under an announcement no request made. + // OrganizationScopeValidator — the sibling setter — has carried it since Packet 6; + // this one shipped without it, and the corpus described both the same way. + await using (var readOnly = new NpgsqlCommand( + "SET TRANSACTION READ ONLY", connection, transaction)) + { + await readOnly.ExecuteNonQueryAsync(cancellationToken); + } + // set_config(..., true) is SET LOCAL's function form and is // transaction-local for the same reason. It has to be this form: // `SET LOCAL app.resolving_host = $1` is a syntax error — PostgreSQL's SET diff --git a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs index a63910b4..3661dc31 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs @@ -45,6 +45,53 @@ public void Effective_Host_Computed_In_One_Place() + "host and the trusted hop)"); } + [Fact] + public void Out_Of_Band_Setters_Open_Read_Only_Transactions() + { + // The two components that announce a session variable outside the ambient unit of + // work — the host resolver and the organization-scope validator. Four carriers + // describe both as a "short READ-ONLY transaction": Database Standards, Security + // Standards, the glossary and ADR-0040. Read-only is not decoration there; it is + // the property that makes an out-of-band setter of app.tenant_id / app.resolving_host + // acceptable at all, because `learnstack_app` holds write grants on the tables + // these connections reach. + // + // Measured: the resolver shipped without the statement while every carrier said it + // had one, and the validator two files away had carried it since Packet 6. A + // behavioural test cannot catch that — the transaction is opened, used and + // disposed inside one method, so nothing outside can observe its settings — which + // is why this is a scan. + // + // The ORDER matters as much as the presence: SET TRANSACTION must precede the + // first statement of the transaction or PostgreSQL refuses it outright, so a + // setter that issued it after its announcement would fail at runtime rather than + // quietly. Both positions are checked. + foreach (var file in new[] + { + Path.Combine("MultiTenancy", "CachedHostToTenantResolver.cs"), + Path.Combine("MultiTenancy", "OrganizationScopeValidator.cs"), + }) + { + // Comments stripped first, or the doc-comment that EXPLAINS set_config counts + // as its first use and the order check compares against prose. + var source = SourceText.WithoutComments(File.ReadAllText( + Directory.EnumerateFiles( + RepositoryPaths.BackendSrc(), "*.cs", SearchOption.AllDirectories) + .Single(candidate => candidate.EndsWith(file, StringComparison.Ordinal)))); + + var readOnly = source.IndexOf("SET TRANSACTION READ ONLY", StringComparison.Ordinal); + var announce = source.IndexOf("set_config(", StringComparison.Ordinal); + + readOnly.Should().BeGreaterThan(-1, + $"{file} announces a session variable on its own connection, and every " + + "carrier in the corpus calls that transaction READ ONLY"); + announce.Should().BeGreaterThan(-1, $"{file} is expected to announce something"); + readOnly.Should().BeLessThan(announce, + $"{file} must issue SET TRANSACTION before its first statement — after it, " + + "PostgreSQL refuses the statement outright"); + } + } + [Fact] public void Tenant_Headers_Are_Never_A_Resolution_Source() { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs index 3f6362fb..3fbdf191 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs @@ -64,6 +64,74 @@ public async Task Host_Resolves_With_No_Tenant_Context_Under_Rls() resolution.OrganizationId!.Value.Value.Should().Be(SchemaFixture.OrgA1); } + [Fact] + public async Task A_Read_Only_Transaction_Admits_The_Announcement_And_Refuses_A_Write() + { + // Why the resolver's lookup can be read-only at all, which is not obvious: it + // announces `app.resolving_host`, and an announcement looks like a write. It is + // not — `set_config(..., true)` is permitted inside a READ ONLY transaction — so + // the resolver gives up nothing by taking the restriction, and `learnstack_app` + // keeps write grants on this table, so the transaction is what refuses. + // + // This is the PostgreSQL fact the design rests on, driven on a transaction built + // the way the resolver builds its own. What it deliberately does NOT claim is + // that the resolver issues the statement: a replica cannot say that about the + // original, and asserting it here would be a test agreeing with itself. The + // source scan `Out_Of_Band_Setters_Open_Read_Only_Transactions` carries that half. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var probe = await dataSource.OpenConnectionAsync(); + await using var transaction = await probe.BeginTransactionAsync(); + + // First statement, before the announcement. SET TRANSACTION must precede any + // other statement or PostgreSQL refuses it outright, which is why the order in + // the resolver is part of what the scan checks. + await using (var readOnly = new NpgsqlCommand( + "SET TRANSACTION READ ONLY", probe, transaction)) + { + await readOnly.ExecuteNonQueryAsync(); + } + + await using (var announce = new NpgsqlCommand( + "SELECT set_config('app.resolving_host', @host, true)", probe, transaction)) + { + announce.Parameters.AddWithValue("host", SchemaFixture.HostA); + await announce.ExecuteNonQueryAsync(); + } + + await using (var setting = new NpgsqlCommand( + "SELECT current_setting('transaction_read_only')", probe, transaction)) + { + (await setting.ExecuteScalarAsync()).Should().Be("on", + "the announcement did not force the transaction to be writable"); + } + + // And the read the resolver actually performs still succeeds under it. + await using (var read = new NpgsqlCommand( + "SELECT count(*) FROM platform_host_to_tenant WHERE host = @host", probe, transaction)) + { + read.Parameters.AddWithValue("host", SchemaFixture.HostA); + (await read.ExecuteScalarAsync()).Should().Be(1L); + } + + var write = async () => + { + await using var refused = new NpgsqlCommand( + "INSERT INTO platform_host_to_tenant (host, tenant_id, is_active, " + + "is_publicly_live) VALUES ('x.example.com', @tenant, true, true)", + probe, + transaction); + refused.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + await refused.ExecuteNonQueryAsync(); + }; + + (await write.Should().ThrowAsync()) + .Which.SqlState.Should().Be("25006", + "read_only_sql_transaction — learnstack_app still holds the grant, so the " + + "restriction is what refuses rather than the privilege"); + + await transaction.RollbackAsync(); + } + [Fact] public async Task An_Unmapped_Host_Resolves_To_Nothing() { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs index 1f2fd5a8..46f0a978 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs @@ -193,6 +193,44 @@ await _schema.Postgres.ExecuteAsSuperuserAsync( connection.State.Should().Be(System.Data.ConnectionState.Open); } + [Fact] + public async Task A_Role_Promoted_To_Superuser_Is_Refused_On_Connect() + { + // The mirror failure, and the one the guard used to admit. A superuser DOES bypass + // Row Level Security, so `rolbypassrls OR rolsuper` answered the literal question + // correctly — and that was the trap: a superuser also bypasses the table and + // schema GRANT matrix, which is what actually BOUNDS this role. Its own creation + // script writes it `BYPASSRLS NOSUPERUSER` on purpose. + // + // So a deployment that promoted learnstack_platform — a restored dump, a hurried + // ALTER ROLE during an incident — widened the one credential the platform path + // uses from "reads across tenants" to "does anything", and the startup guard said + // nothing. The role keeps BYPASSRLS throughout, so nothing but the superuser term + // can account for the refusal. + await _schema.Postgres.ExecuteAsSuperuserAsync("ALTER ROLE learnstack_platform SUPERUSER"); + + try + { + await using var dataSource = PlatformSource(_schema.Postgres.PlatformConnectionString); + + var act = async () => await dataSource.OpenConnectionAsync(CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Message.Should().Contain("does not bypass Row Level Security"); + } + finally + { + await _schema.Postgres.ExecuteAsSuperuserAsync( + "ALTER ROLE learnstack_platform NOSUPERUSER"); + } + + // And it connects again once the promotion is undone, so the guard refused rather + // than the credential being broken by this test. + await using var restored = PlatformSource(_schema.Postgres.PlatformConnectionString); + await using var connection = await restored.OpenConnectionAsync(CancellationToken.None); + connection.State.Should().Be(System.Data.ConnectionState.Open); + } + [Fact] public async Task A_Resolved_Handle_Cannot_Be_Used_Again() { diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs index 419ae9c2..d34ab2af 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs @@ -112,6 +112,14 @@ public void No_message_carries_the_password() // first version of the redaction echoed it whole. "postgres://learnstack_app:hunter2@localhost:5432/learnstack", // leakwatch:ignore "postgresql://learnstack_app:hunter2@localhost/learnstack?sslmode=require", // leakwatch:ignore + + // The two shapes the userinfo pattern could not span. It was + // `(://)[^/@\s]*@`, and a character class excluding '/' and '@' stops at + // the first one inside the password — so a password containing either + // reached the message whole. Reserved characters in a password are legal + // and common; these are the canaries for it. + "postgres://learnstack_app:hunter2/extra@localhost:5432/learnstack", // leakwatch:ignore + "postgres://learnstack_app:hunter2@more@localhost:5432/learnstack", // leakwatch:ignore }) { try @@ -124,9 +132,16 @@ public void No_message_carries_the_password() } } - messages.Should().HaveCount(7, "every value is refused"); + messages.Should().HaveCount(9, "every value is refused"); messages.Should().OnlyContain(message => !message.Contains("hunter2", StringComparison.Ordinal)); - messages.Should().OnlyContain(message => message.Contains("***", StringComparison.Ordinal)); + + // No "***" assertion any more, and its absence is the fix. A redacted echo is + // only as good as the pattern that redacts it, and two of the values above + // defeated the pattern. The unparseable branch now repeats nothing at all — + // there is no field to be confident about — so what the message must carry is + // the key and the expected form, not a masked version of the secret. + messages.Should().OnlyContain( + message => message.Contains("ConnectionStrings:Default", StringComparison.Ordinal)); } [Theory] From da0a02955eb68fb66c3bfe395c9f95f14a87e7f3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 15:39:02 +0300 Subject: [PATCH 42/55] docs(decisions): put three ADR edits back inside their own rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0022 said its resolver cache was "(Dapr State / Valkey)". That was true when the ADR was accepted; ADR-0035 later demand-gated the adapter. Stale is not false, and Standards 13 is explicit that a statement true when written gets an amendment and never a rewrite — so the sentence is restored and a dated amendment records the current adapter, the trigger, and where the resolver now lives. ADR-0036 gained a paragraph in its Decision body assigning the Tenancy:PlatformHosts collision check to whichever packet builds the host-mapping writer. That is an obligation, not an explanation, and code now enforces it, so it belongs in a dated amendment rather than in the body of an Accepted ADR. Moved verbatim, with what Packet 7 did about it. ADR-0032's Amendments sat above its Decision Drivers, newest first — the section placement predates this packet, the newest-first entry did not. Standards 13 puts amendments at the bottom of the file. Moved there and ordered 1-2-3; verified the file's line multiset is unchanged, so nothing but position moved. ADR: 0022, 0032, 0036 Co-Authored-By: Claude Opus 5 (1M context) --- docs/decisions/0022-custom-domain-tls.md | 30 +- ...tion-handling-logging-and-observability.md | 260 +++++++++--------- .../0036-tenant-resolution-trusted-inputs.md | 37 ++- 3 files changed, 182 insertions(+), 145 deletions(-) diff --git a/docs/decisions/0022-custom-domain-tls.md b/docs/decisions/0022-custom-domain-tls.md index 6036ea78..16ac1b55 100644 --- a/docs/decisions/0022-custom-domain-tls.md +++ b/docs/decisions/0022-custom-domain-tls.md @@ -398,10 +398,7 @@ public sealed class TenantMiddleware } ``` -`_hostToTenantResolver` is backed by `ICacheService` — `InMemoryCacheService` today, -with the Valkey-via-Dapr adapter demand-gated to -[Phase 11](../roadmap/phase-11-production-hardening.md) per -[ADR-0035](0035-demand-gated-infrastructure.md); cache key +`_hostToTenantResolver` is backed by `ICacheService` (Dapr State / Valkey); cache key `hub:host:{host}` invalidated on `CustomDomainActivatedEvent` / `CustomDomainRevokedEvent`. ### Public suffix list validation @@ -580,6 +577,31 @@ gap rather than reopening it. The mechanism's live description is before any tenant context exists; certificate material moves by secret-store replication and is referenced by path. +### 2026-09-03 — the cache adapter behind `ICacheService`, and where the resolver now lives + +**Nothing above is wrong; it is history.** § Implementation notes says +`_hostToTenantResolver` is backed by `ICacheService` "(Dapr State / Valkey)", which was +the plan when this ADR was accepted. [ADR-0035](0035-demand-gated-infrastructure.md) later +demand-gated that adapter, so the sentence became stale rather than false — and a stale +statement gets an amendment, never a rewrite +([Documentation Standards § Correcting and Amending ADRs](../standards/13-documentation.md)). +An earlier draft of this packet edited the sentence in place; this records it instead. + +**What is true now.** `ICacheService` is the port, `InMemoryCacheService` is the registered +implementation, and the Valkey-via-Dapr adapter lands in +[Phase 11](../roadmap/phase-11-production-hardening.md) against ADR-0035's written trigger. +The cache key and its invalidation events are unchanged. + +**Where the resolver lives.** Packet 7 shipped `CachedHostToTenantResolver`, which reads +`platform_host_to_tenant` directly and never calls the Hub — an anonymous page load must +not depend on a control plane being reachable +([ADR-0034](0034-hub-contract-surface-invariant.md)). The worked example above predates it; +[ADR-0036](0036-tenant-resolution-trusted-inputs.md) and +[architecture/27 § Tenant runtime](../architecture/27-custom-domain-tls.md) are the current +authority for the mechanism. + +**The Decision is unchanged:** a custom domain resolves to a tenant at the edge, by host. + ## References - ADR-0014 — Adopt Dapr (CustomDomain* events via Dapr pub/sub). diff --git a/docs/decisions/0032-exception-handling-logging-and-observability.md b/docs/decisions/0032-exception-handling-logging-and-observability.md index e5ebd1e8..0cb3a6e8 100644 --- a/docs/decisions/0032-exception-handling-logging-and-observability.md +++ b/docs/decisions/0032-exception-handling-logging-and-observability.md @@ -7,136 +7,6 @@ Accepted **Date:** 2026-05-20 **Deciders:** @platform -## Amendments - -### Amendment 3 — `ITenantContext` is registered transient, not scoped (2026-09-01) - -Not a correction. § Sub-decision 10 and the `TenantContextSpanProcessor` code block both -call `ITenantContext` **request-scoped**, and § Sub-decision 10 adds that injecting it -into the singleton processor "would fail at startup with *Cannot consume scoped service -`ITenantContext` from singleton*". Both were true when written. Packet 5 changed the -registration, so the wording is now history rather than description. - -**What changed and why.** Commit `3c18f88` (2026-08-26) registers the default as -`TryAddTransient(sp => sp.GetRequiredService().Current -?? UnresolvedTenantContext.Instance)`. A *scoped* factory caches the first value it -produced for the rest of the scope, so a write to the accessor after a handler had -already resolved the context would never reach that handler — which is exactly what the -integration-event transport does when it restores a consumer's tenant. `Transient` makes -every access re-read the accessor. -`DeploymentModeCompositionTests.Tenant_Context_Resolution_Forwards_Each_Access_To_The_Accessor` -pins it, and the composition root carries a comment saying not to restore it to `Scoped`. - -**What does not change.** The decision — cross-cutting singletons read the tenant through -`ITenantContextAccessor` and never inject `ITenantContext` — is unchanged, and the -transient registration makes it *more* load-bearing rather than less. Under `Scoped` the -container refused the mistake at startup. Under `Transient` it does not: a singleton that -injects `ITenantContext` gets one instance captured for the life of the process, reading -whatever the accessor held at construction. The rule now has no container-level backstop, -so the accessor is the whole of it. - -**A second stale shape, same section.** § Sub-decision 10's -`TenantContextSpanProcessor` sketch writes `SetTag("tenant.id", context.TenantId)` -and `SetTag("organization.id", orgId)`. Both were correct when written on -2026-05-20, when those members were `Guid` and `Guid?`. Packet 7 step 2 made them -Vogen value objects, and the shipped processor now writes -`context.TenantId.Value.ToString()` under an `IsInitialized()` gate — because an -id's own `ToString()` renders `"[UNINITIALIZED]"` for an unassigned value and is -therefore not a wire format ([ADR-0023 Amendment 7](0023-strongly-typed-id-source-generator.md)). -The **decision** the sketch illustrates is untouched: cross-cutting singletons read -the tenant through the accessor and never inject `ITenantContext`. - -**Every carrier changed.** This amendment. The two "request-scoped" phrasings in -§ Sub-decision 10 and its code-block commentary stand as written, read against this -amendment. The carriers that state the lifetime and state it correctly are -`CrossCuttingFoundationExtensions` (the registration and the comment above it), the -Phase 02a and [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) roadmaps, and the -glossary's `ITenantContextAccessor` entry. Not -[Security Standards § Tenant Context](../standards/11-security.md): that section declares -itself the authority for session-variable **placement** only, and a container lifetime is -not a `SET LOCAL` concern. - -### Amendment 2 — Three corrections from the 2026-08-08 restructure - -None of the three changes a sub-decision; all three correct text that would mislead an implementer. - -1. **The `IProviderResilience` registration example did not compile.** - § Implementation Notes → `IProviderResilience` shape showed - `services.Decorate>()`; - C# forbids using a type parameter as a base type, so `ResilientProviderAdapter` - cannot satisfy `: TPort`. The **shipped** registration in - `LearnStack.Infrastructure.Resilience` is correct — it registers - `IProviderResilience` as a singleton that adapters take as a collaborator - rather than decorating the port itself. The example is corrected in place to the - shipped shape. [Documentation Standards](../standards/13-documentation.md) - allows an accepted ADR only typo fixes and dated Amendments, and a rewritten - registration example is neither — so the correction is recorded here, in this - Amendment, which is what carries it. The Decision section is untouched. Its - copy in `.claude/skills/wire-cross-cutting-foundation/SKILL.md` is corrected with - it — that copy is an executable instruction, so it was the one that would have - produced non-compiling code. The same § Implementation Notes `Resilience:` - sample carried `retry.maxAttempts`; the shipped option is - `ResilienceOptions.Retry.MaxRetryAttempts`, which maps 1:1 onto Polly v8's - retry count. `maxAttempts` read as a total and behaved as a retry count, so - every configured value issued one more call than the name promised. The - sample now uses the shipped key. -2. **The "Hub HTTPS contract is closed at four endpoints" decision driver is - superseded** by [ADR-0034](0034-hub-contract-surface-invariant.md), which replaces - the count with two invariants (the Hub stores no tenant content; every crossing goes - through a named adapter). The driver's substantive point is unaffected: inbound - `/api/internal/*` calls carry no tenant JWT, so their correlation comes from - `traceparent` plus the request envelope rather than from `ITenantContext`. -3. **Sub-decision 2's diagram puts the Row Level Security session variables one step - too early.** The step-4 annotation reads - `TenantContextBehavior (assert resolved; set RLS GUC)`. `SET LOCAL` / - `set_config(…, true)` is transaction-local, and step 4 runs before - `TransactionBehavior` opens the transaction at step 6 — so a value set at step 4 is - discarded before the query it protects ever runs. Step 4 asserts the context and - carries it forward; step 6 issues the `SET LOCAL` pair as the first statement inside - the transaction. The pipeline **order** this ADR fixes is unchanged; only the - annotation was wrong. - [Security Standards § Tenant Context](../standards/11-security.md) is the single - authority for the placement. - -Separately, note that the **audit durability contract** referenced throughout this ADR -now comes from [ADR-0033](0033-audit-durability-model.md), which supersedes ADR-0016. -The pipeline order fixed by this ADR is unchanged. What changed is where durability comes -from: `AuditLogBehavior` (step 3) classifies and declares a MUST-class intent on the way -in; `TransactionBehavior` (step 6) writes the complete row on the ambient transaction -immediately before `COMMIT` and then reports whether the commit succeeded; and -`AuditLogBehavior` re-writes the row standalone on the way out whenever the transaction -did not commit. The `AuditChangeTrackerInterceptor` captures ChangeTracker snapshots and -writes nothing — an earlier draft of this amendment named it as the writer, which was -never true of the component as specified. - -### Amendment 1 — Roslyn diagnostic id + CI severity (2026-05-22) - -Sub-decision 4 and the Standards 21 naming convention referred to the -analyzer's identifier as `LearnStackException-DomainExceptionThrow`. Roslyn -**diagnostic ids must be valid identifiers** (letters/digits, no hyphens); -reporting a hyphenated id throws `AD0001` at analysis time, which under CI's -`TreatWarningsAsErrors` would break the build the first time a -`DomainException` is thrown. This amendment clarifies, without changing the -intent of Sub-decision 4: - -- The analyzer's Roslyn **diagnostic id is `LS0001`**. The hyphenated - `LearnStackException-DomainExceptionThrow` is retained as the - human-readable **rule name** (analyzer title / help text), and remains the - catalogue/cross-link handle. -- The analyzer is referenced by the consuming `Domain` + `Application` - projects as an in-tree analyzer - (``), - not via a packed ``. -- The "Warning in Phase 02a, Error after Phase 03 exit" escalation is - honoured against CI by listing `LS0001` in `WarningsNotAsErrors` - (Directory.Build.props) until the Phase 03 exit gate, so a legitimate - aggregate-invariant throw does not fail CI before the documented - escalation point. At Phase 03 exit the analyzer's default severity flips - to Error and `LS0001` is removed from `WarningsNotAsErrors`. - -The Decision section below is unchanged; this amendment records the -implementation-level correction. - ## Decision Drivers - **Standards 09 ↔ Standards 02 ↔ ADR-0016 are out of step.** Standards 02 § @@ -824,3 +694,133 @@ SDK creates and disposes warm-up `Activity` instances during startup). - W3C Trace Context — - Polly v8 documentation — - OpenTelemetry .NET — + +## Amendments + +### Amendment 1 — Roslyn diagnostic id + CI severity (2026-05-22) + +Sub-decision 4 and the Standards 21 naming convention referred to the +analyzer's identifier as `LearnStackException-DomainExceptionThrow`. Roslyn +**diagnostic ids must be valid identifiers** (letters/digits, no hyphens); +reporting a hyphenated id throws `AD0001` at analysis time, which under CI's +`TreatWarningsAsErrors` would break the build the first time a +`DomainException` is thrown. This amendment clarifies, without changing the +intent of Sub-decision 4: + +- The analyzer's Roslyn **diagnostic id is `LS0001`**. The hyphenated + `LearnStackException-DomainExceptionThrow` is retained as the + human-readable **rule name** (analyzer title / help text), and remains the + catalogue/cross-link handle. +- The analyzer is referenced by the consuming `Domain` + `Application` + projects as an in-tree analyzer + (``), + not via a packed ``. +- The "Warning in Phase 02a, Error after Phase 03 exit" escalation is + honoured against CI by listing `LS0001` in `WarningsNotAsErrors` + (Directory.Build.props) until the Phase 03 exit gate, so a legitimate + aggregate-invariant throw does not fail CI before the documented + escalation point. At Phase 03 exit the analyzer's default severity flips + to Error and `LS0001` is removed from `WarningsNotAsErrors`. + +The Decision section below is unchanged; this amendment records the +implementation-level correction. + +### Amendment 2 — Three corrections from the 2026-08-08 restructure + +None of the three changes a sub-decision; all three correct text that would mislead an implementer. + +1. **The `IProviderResilience` registration example did not compile.** + § Implementation Notes → `IProviderResilience` shape showed + `services.Decorate>()`; + C# forbids using a type parameter as a base type, so `ResilientProviderAdapter` + cannot satisfy `: TPort`. The **shipped** registration in + `LearnStack.Infrastructure.Resilience` is correct — it registers + `IProviderResilience` as a singleton that adapters take as a collaborator + rather than decorating the port itself. The example is corrected in place to the + shipped shape. [Documentation Standards](../standards/13-documentation.md) + allows an accepted ADR only typo fixes and dated Amendments, and a rewritten + registration example is neither — so the correction is recorded here, in this + Amendment, which is what carries it. The Decision section is untouched. Its + copy in `.claude/skills/wire-cross-cutting-foundation/SKILL.md` is corrected with + it — that copy is an executable instruction, so it was the one that would have + produced non-compiling code. The same § Implementation Notes `Resilience:` + sample carried `retry.maxAttempts`; the shipped option is + `ResilienceOptions.Retry.MaxRetryAttempts`, which maps 1:1 onto Polly v8's + retry count. `maxAttempts` read as a total and behaved as a retry count, so + every configured value issued one more call than the name promised. The + sample now uses the shipped key. +2. **The "Hub HTTPS contract is closed at four endpoints" decision driver is + superseded** by [ADR-0034](0034-hub-contract-surface-invariant.md), which replaces + the count with two invariants (the Hub stores no tenant content; every crossing goes + through a named adapter). The driver's substantive point is unaffected: inbound + `/api/internal/*` calls carry no tenant JWT, so their correlation comes from + `traceparent` plus the request envelope rather than from `ITenantContext`. +3. **Sub-decision 2's diagram puts the Row Level Security session variables one step + too early.** The step-4 annotation reads + `TenantContextBehavior (assert resolved; set RLS GUC)`. `SET LOCAL` / + `set_config(…, true)` is transaction-local, and step 4 runs before + `TransactionBehavior` opens the transaction at step 6 — so a value set at step 4 is + discarded before the query it protects ever runs. Step 4 asserts the context and + carries it forward; step 6 issues the `SET LOCAL` pair as the first statement inside + the transaction. The pipeline **order** this ADR fixes is unchanged; only the + annotation was wrong. + [Security Standards § Tenant Context](../standards/11-security.md) is the single + authority for the placement. + +Separately, note that the **audit durability contract** referenced throughout this ADR +now comes from [ADR-0033](0033-audit-durability-model.md), which supersedes ADR-0016. +The pipeline order fixed by this ADR is unchanged. What changed is where durability comes +from: `AuditLogBehavior` (step 3) classifies and declares a MUST-class intent on the way +in; `TransactionBehavior` (step 6) writes the complete row on the ambient transaction +immediately before `COMMIT` and then reports whether the commit succeeded; and +`AuditLogBehavior` re-writes the row standalone on the way out whenever the transaction +did not commit. The `AuditChangeTrackerInterceptor` captures ChangeTracker snapshots and +writes nothing — an earlier draft of this amendment named it as the writer, which was +never true of the component as specified. + +### Amendment 3 — `ITenantContext` is registered transient, not scoped (2026-09-01) + +Not a correction. § Sub-decision 10 and the `TenantContextSpanProcessor` code block both +call `ITenantContext` **request-scoped**, and § Sub-decision 10 adds that injecting it +into the singleton processor "would fail at startup with *Cannot consume scoped service +`ITenantContext` from singleton*". Both were true when written. Packet 5 changed the +registration, so the wording is now history rather than description. + +**What changed and why.** Commit `3c18f88` (2026-08-26) registers the default as +`TryAddTransient(sp => sp.GetRequiredService().Current +?? UnresolvedTenantContext.Instance)`. A *scoped* factory caches the first value it +produced for the rest of the scope, so a write to the accessor after a handler had +already resolved the context would never reach that handler — which is exactly what the +integration-event transport does when it restores a consumer's tenant. `Transient` makes +every access re-read the accessor. +`DeploymentModeCompositionTests.Tenant_Context_Resolution_Forwards_Each_Access_To_The_Accessor` +pins it, and the composition root carries a comment saying not to restore it to `Scoped`. + +**What does not change.** The decision — cross-cutting singletons read the tenant through +`ITenantContextAccessor` and never inject `ITenantContext` — is unchanged, and the +transient registration makes it *more* load-bearing rather than less. Under `Scoped` the +container refused the mistake at startup. Under `Transient` it does not: a singleton that +injects `ITenantContext` gets one instance captured for the life of the process, reading +whatever the accessor held at construction. The rule now has no container-level backstop, +so the accessor is the whole of it. + +**A second stale shape, same section.** § Sub-decision 10's +`TenantContextSpanProcessor` sketch writes `SetTag("tenant.id", context.TenantId)` +and `SetTag("organization.id", orgId)`. Both were correct when written on +2026-05-20, when those members were `Guid` and `Guid?`. Packet 7 step 2 made them +Vogen value objects, and the shipped processor now writes +`context.TenantId.Value.ToString()` under an `IsInitialized()` gate — because an +id's own `ToString()` renders `"[UNINITIALIZED]"` for an unassigned value and is +therefore not a wire format ([ADR-0023 Amendment 7](0023-strongly-typed-id-source-generator.md)). +The **decision** the sketch illustrates is untouched: cross-cutting singletons read +the tenant through the accessor and never inject `ITenantContext`. + +**Every carrier changed.** This amendment. The two "request-scoped" phrasings in +§ Sub-decision 10 and its code-block commentary stand as written, read against this +amendment. The carriers that state the lifetime and state it correctly are +`CrossCuttingFoundationExtensions` (the registration and the comment above it), the +Phase 02a and [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) roadmaps, and the +glossary's `ITenantContextAccessor` entry. Not +[Security Standards § Tenant Context](../standards/11-security.md): that section declares +itself the authority for session-variable **placement** only, and a container lifetime is +not a `SET LOCAL` concern. diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index 9b06e374..31d8c790 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -286,17 +286,6 @@ host to disagree with — which grants only their own tenant. This is stated so reader does not treat a passing cross-check as evidence of attacker containment. The control is that no signal outside the intersection can select a tenant. -**`Tenancy:PlatformHosts` is checked first and wins outright.** A host on the static -list classifies `PlatformHost` before the resolver is called at all, so a row in -`platform_host_to_tenant` naming the same host is inert — never read, never logged, -never counted. The precedence is the right way round: the list is the operator's own -entry point, and a tenant that acquired that hostname must not be able to take it over. -What is worth stating is that the losing row is *silent*, so a deployment that creates -one gets no signal. There is no startup cross-check and no constraint, because the two -live in different places — one is application configuration, the other a table — and a -database constraint cannot see the first. Whichever packet builds the host-mapping -writer owns the check; until then the behaviour is pinned by a test. - **The anonymous organization scope is the host-mapping row**, not the tenant's organization count. A tenant that wants its default organization's content on its public site seeds `organization_id` into its `platform_host_to_tenant` row. That removes a code @@ -959,6 +948,32 @@ corrected set directly rather than the staging table's original. order all stand; only the claim about which rows traffic can reach in Packet 7 is corrected. +### 2026-09-03 — Amendment 6: `Tenancy:PlatformHosts` precedence, and who owns the check + +**Why this is an amendment and not body text.** An earlier draft of Packet 7 wrote this +into § Decision directly. It does not merely explain the decision — it assigns an +obligation, and code now enforces it — so it is a change to what this ADR requires, and +Accepted ADRs take those as dated amendments +([Documentation Standards § Correcting and Amending ADRs](../standards/13-documentation.md)). + +**The precedence.** A host on the static `Tenancy:PlatformHosts` list classifies +`PlatformHost` before the resolver is called at all, so a row in `platform_host_to_tenant` +naming the same host is inert — never read, never logged, never counted. That is the right +way round: the list is the operator's own entry point, and a tenant that acquired the +hostname must not be able to take it over. + +**The problem it leaves.** The losing row is *silent*, so a deployment that creates one +gets no signal. There is no startup cross-check and no constraint, because the two live in +different places — one is application configuration, the other a table — and a database +constraint cannot see the first. + +**Who owns the check.** Whichever packet builds the host-mapping writer. Packet 7 built it: +`MapHostToTenantCommandHandler` refuses a reserved host through `IReservedHostRegistry` — a +port, because the list is bound in the composition root and a module may not reference it — +and answers `lockey_host_reserved` rather than writing a row that would do nothing. + +**The Decision is unchanged.** The matrix, the signals and the ceiling all stand. + ## References - [ADR-0003 Tenant Isolation Defense in From 019d057b8276161ebfd6b26ccf1e81ad3aa0959a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 15:44:26 +0300 Subject: [PATCH 43/55] fix(tenancy): read the trust bit and refuse unassigned ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three majors from the review, each a guard that existed in name only. TenantResolutionAttempt declares HasValidatedPrincipal as the bit separating matrix rows 13 and 15 — "both resolve no tenant, and only one has a principal" — and TenantContextFactory never read it. An attempt carrying a claim with the bit clear produced a HostAndClaim context, so the matrix's own trust distinction was decorative. Unreachable today because authentication registers in Phase 02b and nothing populates a claim, which is exactly why it is worth closing before the first caller decides for itself. No validator checked an inbound strongly-typed id. Reading .Value on an uninitialized Vogen id raises from inside the id type, and the provisioning cross-field rule compares both — so a client's malformed id was an exception inside the validator, a 500 for an input the caller can fix. One shared rule now refuses both sentinels for every command, including the optional organization id on the host mapping. SetFeatureFlag stamped the root before the child validated. ChangeStatus stamps first for a stated reason — MarkUpdated is the only statement in it that can throw — and that reason does not transfer here, so a malformed JSON value moved UpdatedAt, UpdatedBy and row_version for a change that never happened. Since row_version is the concurrency token, the next writer would lose an update to a write that was rejected. Also: AuditableEntity claimed TenantQueryFilters gates on DeletedAt. It does not, and Database Standards makes that exclusion opt-in per aggregate rather than universal. Nothing soft-deletes yet; the comment now says so and names where it will bite first — ux_tenant_domains_host is partial on deleted_at IS NULL. Module: Tenancy ADR: 0036 Co-Authored-By: Claude Opus 5 (1M context) --- .../Domain/AuditableEntity.cs | 21 +++++++--- .../Tenancy/TenantContextFactory.cs | 19 +++++++++ .../Tenant/ProvisionTenantCommandValidator.cs | 12 +++++- .../Tenant/TenancyCommandValidators.cs | 40 ++++++++++++++++++ .../Tenant.cs | 25 ++++++++--- .../Tenancy/ProvisionTenantCommandTests.cs | 40 ++++++++++++++++++ .../Modules/Tenancy/TenancyAggregateTests.cs | 27 ++++++++++++ .../Tenancy/TenantContextFactoryTests.cs | 41 +++++++++++++++++++ 8 files changed, 212 insertions(+), 13 deletions(-) diff --git a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs index 7d2134dd..e114295e 100644 --- a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs +++ b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs @@ -53,12 +53,21 @@ protected AuditableEntity() /// /// Convenience projection of for in-process - /// callers (aggregate methods, application services, mappers). EF - /// global query filters should gate on directly - /// (e => e.DeletedAt == null) — is a - /// computed CLR property and is NOT guaranteed to translate to SQL by - /// EF Core's expression translator. TenantQueryFilters wires them - /// accordingly, as of Packet 7. + /// callers (aggregate methods, application services, mappers). A query filter that + /// needs to exclude deleted rows gates on directly + /// (e => e.DeletedAt == null) — is a computed CLR + /// property and is NOT guaranteed to translate to SQL by EF Core's expression + /// translator. + /// + /// TenantQueryFilters does not add that term, and that is the shipped + /// state. An earlier version of this comment said it did, which was false. + /// [Database Standards § Soft Delete](../../../../docs/standards/05-database.md) makes + /// the exclusion opt-in per aggregate — the columns are universal, the filtering is + /// not — and no aggregate exposes a soft delete today, so there is nothing to + /// exclude. The first one that does owns adding the term for its own entity, and + /// tenant_domains is where it will bite first: ux_tenant_domains_host is + /// partial on deleted_at IS NULL, so a released host frees the name in the + /// database while an unfiltered read would still return the row. /// public bool IsDeleted => DeletedAt.HasValue; diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs index 4a1e372f..41e56015 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs @@ -92,6 +92,25 @@ public static Result Create(TenantResolutionAttempt attempt) return Result.Fail(Refused); } + // A claim without a validated principal is not a claim. HasValidatedPrincipal is + // declared as the bit that separates rows 13 and 15 — "both resolve no tenant, and + // only one of them has a principal" — and until now nothing read it, so an attempt + // carrying ClaimTenantId with the bit clear produced a HostAndClaim context and the + // matrix's own trust distinction was decorative. + // + // Unreachable today: authentication registers in Phase 02b and the bit is constant + // false, so no caller populates a claim at all. That is exactly why it is worth + // closing now — the first caller that populates one will populate both or neither, + // and this decides which of those is a mistake rather than leaving it to whoever + // writes the middleware. + if (!attempt.HasValidatedPrincipal + && (attempt.ClaimTenantId is not null + || attempt.ClaimOrganizationId is not null + || attempt.UserId is not null)) + { + return Result.Fail(Refused); + } + // Rows 8, 11 and 12 — the cross-check. Evaluated before any port answer is // consulted, so a refused request never spends a database round trip. if (!attempt.ClaimAgreesWithHost) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs index f61817d5..2727d460 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs @@ -45,6 +45,13 @@ internal sealed class ProvisionTenantCommandValidator : AbstractValidator command.TenantId).MustBeAssigned(); + RuleFor(command => command.DefaultOrganizationId).MustBeAssigned(); + // Cascade(Stop), because the rules are not independent of the first: the shape // predicate runs a regex, and a regex against a null slug throws // ArgumentNullException out of the validator itself — which is the 500 this file @@ -67,7 +74,10 @@ public ProvisionTenantCommandValidator() // yields the empty string, and an error under a "" key names nothing a client // can highlight. RuleFor(command => command.DefaultOrganizationId) - .Must((command, organizationId) => command.TenantId.Value != organizationId.Value) + .Must((command, organizationId) => + !command.TenantId.IsInitialized() + || !organizationId.IsInitialized() + || command.TenantId.Value != organizationId.Value) .WithErrorCode("lockey_tenant_and_organization_share_an_id"); } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenancyCommandValidators.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenancyCommandValidators.cs index ac8bf1d6..486ab4bc 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenancyCommandValidators.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenancyCommandValidators.cs @@ -1,6 +1,7 @@ using FluentValidation; using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Tenancy; namespace LearnStack.Modules.Tenancy.Application.Tenant; @@ -20,6 +21,8 @@ internal sealed class CreateOrganizationCommandValidator { public CreateOrganizationCommandValidator() { + RuleFor(command => command.OrganizationId).MustBeAssigned(); + // Cascade(Stop) keeps the shape regex off a null a deserializer could supply; // Pattern().IsMatch(null) throws out of the validator itself. RuleFor(command => command.Slug) @@ -56,6 +59,11 @@ internal sealed class MapHostToTenantCommandValidator : AbstractValidator command.OrganizationId).MustBeAssignedWhenPresent(); + RuleFor(command => command.Host) .Cascade(CascadeMode.Stop) .NotEmpty().WithErrorCode("lockey_host_required") @@ -68,3 +76,35 @@ public MapHostToTenantCommandValidator() .OverridePropertyName(nameof(MapHostToTenantCommand.IsPubliclyLive)); } } + +/// +/// The rule every client-supplied strongly-typed id needs before anything reads it. +/// +/// +/// +/// Two sentinels, and reading .Value on the first throws. A Vogen id nobody +/// constructed is uninitialized, and touching Value raises from inside the id type +/// — so a validator that compares ids, or an aggregate factory that stores one, turns a +/// client's malformed input into a 500 rather than a refusal. The all-zero +/// Guid is the second sentinel: it constructs cleanly and means nothing. +/// +/// +/// Cascade(Stop) on every caller, because the rules that follow read the value. +/// +/// +internal static class IdentifierRules +{ + internal static IRuleBuilderOptions MustBeAssigned( + this IRuleBuilderInitial rule) + where TId : struct, IStronglyTypedId => + rule.Cascade(CascadeMode.Stop) + .Must(id => id.IsInitialized() && id.Value != Guid.Empty) + .WithErrorCode("lockey_identifier_required"); + + internal static IRuleBuilderOptions MustBeAssignedWhenPresent( + this IRuleBuilderInitial rule) + where TId : struct, IStronglyTypedId => + rule.Cascade(CascadeMode.Stop) + .Must(id => id is not { } value || (value.IsInitialized() && value.Value != Guid.Empty)) + .WithErrorCode("lockey_identifier_required"); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index 6e903da5..bcb659bc 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -251,19 +251,32 @@ public void SetFeatureFlag(string key, string value, IClock clock, UserId update ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(key); - MarkUpdated(clock.UtcNow, updatedBy); - var existing = _featureFlags.FirstOrDefault( flag => string.Equals(flag.Key, key, StringComparison.Ordinal)); - if (existing is null) + // Every guard the child would run, run here first — key width and JSON + // well-formedness for both paths, and the audit pair for the new-flag path via + // Create below. The stamp comes after, and that ordering is the point: an earlier + // version stamped first, matching ChangeStatus, whose comment explains that + // MarkUpdated is the only statement in it that can throw. That is not true here — + // the child validates too — so a rejected value left the tenant's UpdatedAt, + // UpdatedBy and row_version moved for a change that never happened. + MappedLength.EnsureAtMost(key, 200, nameof(key)); + JsonValue.EnsureWellFormed(value, nameof(value)); + + var created = existing is null + ? TenantFeatureFlag.Create(Id, key, value, clock.UtcNow, updatedBy) + : null; + + MarkUpdated(clock.UtcNow, updatedBy); + + if (created is not null) { - _featureFlags.Add( - TenantFeatureFlag.Create(Id, key, value, clock.UtcNow, updatedBy)); + _featureFlags.Add(created); return; } - existing.SetValue(value, clock.UtcNow, updatedBy); + existing!.SetValue(value, clock.UtcNow, updatedBy); } /// Removes a feature-flag override. diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs index 33e38630..af9568a1 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs @@ -281,6 +281,46 @@ public void A_null_slug_is_refused_rather_than_thrown_on() refuse().IsValid.Should().BeFalse(); } + [Theory] + [InlineData("tenant")] + [InlineData("organization")] + public void An_id_nobody_assigned_is_refused_rather_than_thrown_on(string which) + { + // Reading .Value on an uninitialized Vogen id raises from inside the id type. The + // cross-field rule below compares both ids, so without a guard ahead of it a + // client's malformed id became an exception inside the validator — a 500 for an + // input the caller can fix, which is the defect this file's remarks were written + // about in the first place. + // + // default(TenantId) does not compile — VOG009 prohibits it — so the unassigned + // value comes from an array element, which is also how it reaches production: a + // struct field nobody assigned, a default(T) in a generic, a deserializer that + // skipped a member. + var unassignedTenant = new TenantId[1]; + var unassignedOrganization = new OrganizationId[1]; + + var command = which == "tenant" + ? Command() with { TenantId = unassignedTenant[0] } + : Command() with { DefaultOrganizationId = unassignedOrganization[0] }; + + var validate = () => Validator().Validate(command); + + validate.Should().NotThrow("an unassigned id is refused, not dereferenced"); + validate().IsValid.Should().BeFalse(); + validate().Errors.Should().Contain(failure => + failure.ErrorCode == "lockey_identifier_required"); + } + + [Fact] + public void An_all_zero_id_is_refused_too() + { + // The second sentinel. It constructs cleanly, so nothing throws — it simply means + // nothing, and TenantOwnership.EnsureRealTenant would reject it three layers later + // as an ArgumentException, which has no HttpStatusMap entry. + Refuse(command => command with { TenantId = TenantId.From(Guid.Empty) }) + .Should().Be("lockey_identifier_required"); + } + [Fact] public void A_tenant_and_its_default_organization_may_not_share_an_id() { diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs index 70934186..94c326ca 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -32,6 +32,33 @@ public sealed class TenancyAggregateTests private static readonly TenantDomainId DomainId = TenantDomainId.From(Guid.Parse("dddddddd-1111-7111-8111-111111111111")); + [Theory] + [InlineData(true, "a flag that does not exist yet")] + [InlineData(false, "one that does")] + public void A_refused_feature_flag_leaves_the_tenant_untouched(bool isNew, string what) + { + // The root's audit state is a claim about what changed. An earlier version stamped + // it before the child validated, so a malformed JSON value moved UpdatedAt, + // UpdatedBy and row_version for a change that never happened — and row_version is + // the concurrency token, so the next writer would lose an update to a write that + // was rejected. + var tenant = TenancyDomain.Tenant.Create(Tenant, "acme", "Acme", Clock, Actor); + + if (!isNew) + { + tenant.SetFeatureFlag("beta", "true", Clock, Actor); + } + + var before = (tenant.Version, tenant.UpdatedAt, Count: tenant.FeatureFlags.Count); + + var refused = () => tenant.SetFeatureFlag("beta", "not json at all", Clock, Actor); + + refused.Should().Throw($"the value is malformed for {what}"); + + (tenant.Version, tenant.UpdatedAt, Count: tenant.FeatureFlags.Count) + .Should().Be(before, "a rejected write changes nothing, root included"); + } + [Fact] public void A_subdomain_is_verified_by_construction() { diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs index 8da306d2..d345d8c6 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Tenancy/TenantContextFactoryTests.cs @@ -86,6 +86,47 @@ public void Row_6_A_tenant_host_and_an_agreeing_claim_need_no_membership() result.Value!.UserId.Should().Be(Actor); } + [Theory] + [InlineData("tenant", "a claim naming a tenant")] + [InlineData("organization", "a claim naming an organization")] + [InlineData("user", "an actor")] + public void A_claim_without_a_validated_principal_is_not_a_claim(string signal, string what) + { + // HasValidatedPrincipal is declared as the bit that separates rows 13 and 15 — + // "both resolve no tenant, and only one of them has a principal" — and the factory + // did not read it. So an attempt carrying claim fields with the bit clear produced + // a HostAndClaim context, and the matrix's own trust distinction was decorative. + // + // Not reachable in traffic: authentication registers in Phase 02b and the bit is + // constant false, so nothing populates a claim yet. That is the reason to pin it + // now rather than later — the first caller to populate one will populate both or + // neither, and this says which of those is the mistake. + var attempt = new TenantResolutionAttempt { HostTenantId = TenantA }; + + attempt = signal switch + { + "tenant" => attempt with { ClaimTenantId = TenantA }, + "organization" => attempt with { ClaimOrganizationId = OrgOne }, + _ => attempt with { UserId = Actor }, + }; + + TenantContextFactory.Create(attempt).IsFailure.Should().BeTrue( + $"{what} without a validated principal is a signal nothing vouched for"); + + // The positive control is row 6's shape, which is the one combination known to + // resolve on its own: host and an agreeing tenant claim, with the principal set. + // The other two signals are refused for reasons of their own even when validated — + // an organization claim with no tenant claim is incoherent, a bare actor names no + // tenant — so asserting they resolve would be asserting the matrix wrong. + TenantContextFactory.Create(new TenantResolutionAttempt + { + HostTenantId = TenantA, + ClaimTenantId = TenantA, + UserId = Actor, + HasValidatedPrincipal = true, + }).IsFailure.Should().BeFalse("the bit is what the three refusals above turn on"); + } + [Fact] public void Row_7_A_claim_reaching_past_the_host_needs_both_answers() { From 03dbf0fd9b453059295ae05dc171948c832f7e3a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 15:47:59 +0300 Subject: [PATCH 44/55] fix(tenancy): verify a conflict is ours; default the first locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A uniqueness conflict told the seeder a name was taken, not that it was taken by us. platform_host_to_tenant's primary key is the host, globally, so "already present" covered both our own prior run and another tenant holding the name — and the seeder exited 0 with the demo host pointing at somebody else's data. It now verifies ownership under the tenant's own announcement, which is what makes the check cheap: RLS shows the row only if the row is ours. Scoped to the act that conflicted, because an earlier shape asked "do we own either?" and let the organization just created vouch for a foreign host. AddLocale accepted a first locale with isDefault:false. The partial unique index guarantees at most one default and nothing guarantees at least one, so a tenant could publish in a language and have no default — a state every reader of "the tenant's default locale" must handle and none expects. The first locale is now the default; a second still is not, or the caller's answer would never matter. The seeder took --connection-string. The value carries a database password and an argument is visible to every local user through ps for the life of the process. The flag is gone; the environment variable was already how make seed passes it. Module: Tenancy Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/seed-tenant/SKILL.md | 5 +- .../src/LearnStack.Tools.Seeder/Program.cs | 24 +++-- .../src/LearnStack.Tools.Seeder/SeedRunner.cs | 88 ++++++++++++++++++- .../Tenant.cs | 8 +- .../Database/SeederTests.cs | 50 +++++++++++ .../Modules/Tenancy/TenancyAggregateTests.cs | 23 +++++ scripts/seed.sh | 2 +- 7 files changed, 180 insertions(+), 20 deletions(-) diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 039fdd77..2ada6fe3 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -91,8 +91,9 @@ ConnectionStrings__Default="" \ **What the tenants are is data, not arguments.** The two live in `SeedData.cs`, so there is no `--tenants` flag and nothing to keep in step between a script and a source file. The connection string is the only input, and it arrives in the -environment rather than on `argv` because it carries a password that `ps` would -show; `--connection-string` exists for a caller running the tool by hand. +environment rather than on `argv` because it carries a password that `ps` would show for +as long as the process runs. There is no flag for it — a caller running the tool by hand +exports the variable too. `scripts/seed.sh` reads it from `ConnectionStrings__Default`, falling back to `.env` — the Makefile does not export `.env` into a recipe's environment — and diff --git a/backend/src/LearnStack.Tools.Seeder/Program.cs b/backend/src/LearnStack.Tools.Seeder/Program.cs index 8c7c6d32..ddaf766a 100644 --- a/backend/src/LearnStack.Tools.Seeder/Program.cs +++ b/backend/src/LearnStack.Tools.Seeder/Program.cs @@ -8,19 +8,20 @@ // seeder inserting the tenant and its default organization itself would be a second copy // of the one sanctioned cross-aggregate write. -// Read from the flag or the environment, and NOT through IConfiguration's -// GetConnectionString: exactly one file in the solution reads credentials that way, and -// Platform_DataSource_Resolved_Only_By_PlatformAdminScope keeps it that way so one file -// decides what is done with them. `make seed` passes it in the environment, because an -// argument carrying a database password is visible to any local user through `ps`. -var connectionString = ConnectionStringFrom(args) - ?? Environment.GetEnvironmentVariable("ConnectionStrings__Default"); +// The environment only. There is no --connection-string flag: the value carries a +// database password, and an argument is visible to every local user through `ps` for as +// long as the process runs. `make seed` already passes it this way. +// +// And NOT through IConfiguration's GetConnectionString: exactly one file in the solution +// reads credentials that way, and Platform_DataSource_Resolved_Only_By_PlatformAdminScope +// keeps it that way so one file decides what is done with them. +var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__Default"); if (string.IsNullOrWhiteSpace(connectionString)) { Console.Error.WriteLine( - "seed: no connection string. Pass --connection-string, or set " - + "ConnectionStrings__Default (see .env.example)."); + "seed: ConnectionStrings__Default is not set. Run `make seed`, which reads it " + + "from .env and checks the role, or export it yourself (see .env.example)."); return 2; } @@ -46,8 +47,3 @@ return 1; } -static string? ConnectionStringFrom(string[] args) -{ - var flag = Array.IndexOf(args, "--connection-string"); - return flag >= 0 && flag + 1 < args.Length ? args[flag + 1] : null; -} diff --git a/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs b/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs index f62b3af9..bfa65f84 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs @@ -1,7 +1,10 @@ using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Results; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; using MediatR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -111,7 +114,7 @@ await SendAsync( tenant.SecondOrganization.OrganizationId, tenant.SecondOrganization.Slug, tenant.SecondOrganization.DisplayName), - "second organization", + SecondOrganizationAct, cancellationToken); await SendAsync( @@ -124,7 +127,7 @@ await SendAsync( : null, IsActive: true, IsPubliclyLive: true), - "host mapping", + HostMappingAct, cancellationToken); } @@ -171,8 +174,24 @@ private async Task SendAsync( // outcome of one. Anything else — a validation failure, a policy denial, an // organization that is not this tenant's — is a seed that did not do its job, and // the process exits non-zero on it. + // + // "Taken" is not the same as "taken by us", and the difference matters most for + // the host: `platform_host_to_tenant`'s primary key is the host, globally, so a + // conflict is equally consistent with our own prior run and with another tenant + // holding the name. Verified rather than assumed — and RLS is what makes the + // verification cheap, because under this tenant's own announcement the row is + // visible only if the row is this tenant's. if (IsAlreadySeeded(result.Error!)) { + if (!await OwnsWhatConflictedAsync(tenant, context, what, cancellationToken)) + { + throw new InvalidOperationException( + $"Seeding the {what} for '{tenant.Slug}' hit a uniqueness conflict, and " + + "the row that holds the name is not this tenant's. The seed would " + + "report success while pointing at somebody else's data; fix the " + + "conflict and re-run."); + } + SeedRunnerLog.AlreadyPresent(logger, what, tenant.Slug); return; } @@ -182,6 +201,71 @@ private async Task SendAsync( + "The seed is not idempotent past this point; fix the cause and re-run."); } + /// The label for the act that adds a tenant's second organization. + private const string SecondOrganizationAct = "second organization"; + + /// The label for the act that points a host at the tenant. + private const string HostMappingAct = "host mapping"; + + /// + /// Whether the rows that conflicted belong to . + /// + /// + /// + /// Read under the tenant's own announcement, which is the whole trick: every table + /// this checks is tenant-owned or, for the host index, admits a row on + /// tenant_id = app.tenant_id. So "can I see it?" and "is it mine?" are the same + /// question, and the policies answer it without the seeder needing a cross-tenant + /// credential it should not have. + /// + /// + /// The provisioning act runs unresolved and cannot ask — but it does not need to: it + /// conflicts on its own registry-assigned id, which is a fixed literal in + /// , so a primary-key conflict there IS the prior run. Only the + /// acts that run as the tenant reach this. + /// + /// + private async Task OwnsWhatConflictedAsync( + SeedTenant tenant, ITenantContext? context, string what, CancellationToken cancellationToken) + { + if (context is null) + { + return true; + } + + await using var provider = compose(context); + await using var scope = provider.CreateAsyncScope(); + + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unitOfWork.BeginTransactionAsync(cancellationToken); + await unitOfWork.SetTenantContextAsync(context, cancellationToken); + + var db = scope.ServiceProvider.GetRequiredService(); + + // The act that conflicted, and only that act. An earlier version asked "do we own + // either?" and the OR let the organization we had just created vouch for a host + // another tenant held — the verification passing on the strength of an unrelated + // row is exactly the failure it exists to prevent. + var owned = what switch + { + HostMappingAct => await db.PlatformHostMappings + .AnyAsync(mapping => mapping.Host == tenant.Host, cancellationToken), + + SecondOrganizationAct => await db.Organizations + .AnyAsync( + organization => organization.Slug == tenant.SecondOrganization.Slug, + cancellationToken), + + // No other act runs with a resolved context, so nothing else reaches here. + _ => throw new ArgumentOutOfRangeException( + nameof(what), what, "No ownership check is defined for that act."), + }; + + await frame.FailAsync(CancellationToken.None); + + return owned; + } + /// /// Whether says the row this act writes already exists. /// diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index bcb659bc..d722e246 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -200,7 +200,13 @@ public void AddLocale( MarkUpdated(clock.UtcNow, updatedBy); _locales.Add(added); - if (isDefault) + // The FIRST locale is the default whether the caller asked for it or not. The + // partial unique index guarantees at most one default; nothing guarantees at + // least one, so a tenant whose only locale arrived with isDefault:false has a + // non-empty locale set and no default — a state every reader of "the tenant's + // default locale" has to handle and none of them expects. Promoting is the only + // answer that leaves the aggregate in a state the schema can also express. + if (isDefault || _locales.Count == 1) { PromoteDefault(added); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs index 82b988ae..822751cc 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs @@ -269,6 +269,56 @@ public async Task A_conflict_that_is_not_a_uniqueness_refusal_still_stops_the_ru .Should().Be(0L, "and the row was never written"); } + [Fact] + public async Task A_seed_host_another_tenant_already_holds_stops_the_run() + { + // "Taken" is not "taken by us". platform_host_to_tenant's primary key is the host, + // globally, so a conflict is equally consistent with our own prior run and with a + // different tenant holding the name — and the seeder treated both as "already + // present", exited 0, and left the demo host pointing at somebody else's data. + // + // RLS is what makes the discrimination cheap: under demo-english's own + // announcement the row is visible only if the row is demo-english's. + await using var dataSource = DataSource(); + + // The fixture's tenant A claims the seed host first, on its own announcement. + await using (var connection = await PostgresFixture.OpenAsync( + _schema.Postgres.AppConnectionString)) + await using (var claim = new NpgsqlCommand( + $""" + BEGIN; + SELECT set_config('app.tenant_id', '{SchemaFixture.TenantA}', true); + INSERT INTO platform_host_to_tenant + (host, tenant_id, organization_id, is_active, is_publicly_live) + VALUES ('{SeedData.English.Host}', '{SchemaFixture.TenantA}', NULL, true, true); + COMMIT; + """, + (NpgsqlConnection)connection)) + { + await claim.ExecuteNonQueryAsync(); + } + + try + { + var seed = async () => await Runner(dataSource) + .RunAsync(CancellationToken.None, [SeedData.English]); + + (await seed.Should().ThrowAsync( + "a host held by another tenant is not this seed's prior run")) + .WithMessage("*not this tenant's*"); + } + finally + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var cleanup = new NpgsqlCommand( + "DELETE FROM platform_host_to_tenant WHERE host = @host", + (NpgsqlConnection)platform); + cleanup.Parameters.AddWithValue("host", SeedData.English.Host); + await cleanup.ExecuteNonQueryAsync(); + } + } + // ── Harness ────────────────────────────────────────────────────────────── /// diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs index 94c326ca..83dc50d1 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -32,6 +32,29 @@ public sealed class TenancyAggregateTests private static readonly TenantDomainId DomainId = TenantDomainId.From(Guid.Parse("dddddddd-1111-7111-8111-111111111111")); + [Fact] + public void The_first_locale_is_the_default_whether_or_not_it_was_asked_for() + { + // ux_tenant_locales_tenant_id_is_default guarantees AT MOST one default. Nothing + // guarantees at least one — so a tenant whose only locale was added with + // isDefault:false published in a language and had no default, a state every + // reader of "the tenant's default locale" has to handle and none expects. + var tenant = TenancyDomain.Tenant.Create(Tenant, "acme", "Acme", Clock, Actor); + + tenant.AddLocale("tr-TR", isDefault: false, Clock, Actor); + + tenant.Locales.Should().ContainSingle().Which.IsDefault.Should().BeTrue( + "the first locale is the default by construction"); + + // And a second one is not promoted, or the promotion would be unconditional and + // the caller's answer would never matter. + tenant.AddLocale("en-US", isDefault: false, Clock, Actor); + + tenant.Locales.Should().HaveCount(2); + tenant.Locales.Count(locale => locale.IsDefault).Should().Be(1); + tenant.Locales.Single(locale => locale.IsDefault).Locale.Should().Be("tr-TR"); + } + [Theory] [InlineData(true, "a flag that does not exist yet")] [InlineData(false, "one that does")] diff --git a/scripts/seed.sh b/scripts/seed.sh index 90a11fd1..793afd67 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -234,7 +234,7 @@ fi # Passed in the environment, not on argv: the value carries the database # password, and an argument is visible to any local user through `ps`. The -# seeder reads this variable when no --connection-string flag is given. +# seeder reads this variable and takes no flag for it. if ! ConnectionStrings__Default="$seed_cs" \ dotnet run --project backend/src/LearnStack.Tools.Seeder --nologo; then red "seed: tenant seeding failed." From 6172a8b0ab6cc895a2c819f6819923a8d9779ef8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 15:52:19 +0300 Subject: [PATCH 45/55] docs: stop four documents claiming more than the code does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed-tenant skill listed tenant_domains, tenant_settings, tenant_locales and tenant_feature_flags among what the seeder writes. It writes none of them — three commands, covering tenant, organization and host — so the list is now split into what shipped and what a later packet owns, and says where a test that needs those rows gets them instead. The roadmap and the module spec said Packet 7 ships "the first command that touches" the promoted children. Nothing writes a TenantDomain or a TenantSetting; the promotion is a statement about the model — Tenant gained the two navigations, the child factories went internal — not about a command that exists. The add-architecture-test skill published a migration scan under the canonical name Every_TenantOwned_Entity_HasFilterAndRlsPolicy. The scan is file-granular: delete one table's policy from a migration that creates eight and it stays green, because a sibling's block satisfies the Contains. The shipped rule verifies per entity against the EF model. The example is renamed and says which it is. Two comments in CachedHostToTenantResolver contradicted each other about who populates the negative cache — the flight does, whatever the caller does — and UnknownHostCache still said nothing calls Forget, which Packet 7's host-mapping writer does. Also: the catalogue's census was Packet 6's (36 methods, 55 cases); a run says 59 and 77. The Packet 7 status marker was still ⏳ in the packet-sequence body. And Standards 01 and 11 gained material from ADR-0042, ADR-0036 and ADR-0040 without naming them in their Derives-from headers. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-architecture-test/SKILL.md | 28 ++++++++---- .claude/skills/seed-tenant/SKILL.md | 43 +++++++++++++------ .../CachedHostToTenantResolver.cs | 10 +++-- .../MultiTenancy/UnknownHostCache.cs | 12 +++--- docs/modules/tenancy/README.md | 4 +- docs/roadmap/phase-02a-kernel-tenancy.md | 13 ++++-- docs/standards/01-architecture-standards.md | 2 + docs/standards/11-security.md | 4 ++ .../21-architecture-tests-catalogue.md | 8 ++-- 9 files changed, 82 insertions(+), 42 deletions(-) diff --git a/.claude/skills/add-architecture-test/SKILL.md b/.claude/skills/add-architecture-test/SKILL.md index 21cfca80..679e5c05 100644 --- a/.claude/skills/add-architecture-test/SKILL.md +++ b/.claude/skills/add-architecture-test/SKILL.md @@ -154,9 +154,9 @@ Architecture tests are **non-skippable**. That means: When the rule is about migration content (RLS, partition): ```csharp -/// The migration-scan arm of the rule; see ADR-0003 Amendment 3. +/// A migration-scan sketch. NOT the shipped rule — see the note below. [Fact] -public void Every_TenantOwned_Entity_HasFilterAndRlsPolicy() +public void Migration_Files_Creating_Tenant_Owned_Tables_Carry_A_Policy_Block() { // RepositoryPaths.BackendSrc() — the shipped helper. A relative "backend/src" // is resolved against the TEST HOST's working directory (bin/Debug/net10.0), @@ -216,13 +216,23 @@ public void Every_TenantOwned_Entity_HasFilterAndRlsPolicy() } ``` -File granularity is what makes the predicate above safe: the two **table classes** -that key their policy on something else — `tenants` on `id`, `platform_host_to_tenant` -on `app.resolving_host` — ship in a file that also creates ordinary tenant-owned -tables, so the file-level `tenant_id` assertion holds. A per-table version of this -scan needs the table classes from -[Database Standards § Table classes](../../../docs/standards/05-database.md) before -it is correct. +**This sketch is deliberately not the canonical rule, and does not carry its name.** +`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` ships in +`LearnStack.Tests.Architecture/TenantScopingTests.cs` and verifies **per entity** +against the EF model — every marked type has a filter, and the migration carries that +table's policy. The scan above is file-granular: delete one table's policy block from a +migration that creates eight and it stays green, because a sibling table's block +satisfies the `Contains`. Read it as an illustration of the mechanics — path resolution, +the two `CREATE TABLE` spellings, the empty-classification guard — not as a rule to +copy. + +File granularity is what makes the predicate above safe *as an illustration*: the two +**table classes** that key their policy on something else — `tenants` on `id`, +`platform_host_to_tenant` on `app.resolving_host` — ship in a file that also creates +ordinary tenant-owned tables, so the file-level `tenant_id` assertion holds. A per-table +version needs the table classes from +[Database Standards § Table classes](../../../docs/standards/05-database.md), which is +what the shipped rule uses. ### Step 7: Test the test diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 2ada6fe3..b7037f77 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -126,25 +126,41 @@ default_organization_id` → `COMMIT`: itself, so the allow-list stays at one entry and the seed exercises the same path production does. -**The follow-on writes**, each its own command in its own transaction: +**The follow-on writes**, each its own command in its own transaction. Packet 7 ships +two of them; the rest are what a later packet adds, and this list says which is which. -- The second row in `organizations`. It is a third aggregate root, and the - exception covers the two named above and nothing else. -- Rows in `tenant_domains` and `tenant_settings`. Under Packet 7's promotion - `TenantDomain` and `TenantSetting` are aggregate roots in their own right, so - each is written the way any other root is. -- Rows in `tenant_locales` (exactly one `is_default`) and `tenant_feature_flags`. - These are navigations inside `Tenant` rather than roots, and ADR-0042's - enumeration names them among the rows it does **not** cover: neither carries an - atomicity invariant against the tenant row. -- One row in `platform_host_to_tenant` — **per tenant, not per organization**. It +**Shipped:** + +- The second row in `organizations`, through `CreateOrganizationCommand`. It is a third + aggregate root, and ADR-0042's exception covers the two written together above and + nothing else. +- One row in `platform_host_to_tenant`, through `MapHostToTenantCommand` — **per tenant, + not per organization**. It is a projection rather than an aggregate, outside the rule entirely, and it does not share the provisioning transaction. `demo-english` leaves `organization_id` NULL (a `TenantHost`); `demo-yoga` sets it (an `OrgHost`), so both live classification classes from [ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md) are exercised by the seed and not only by a fixture. Neither host belongs in - `Tenancy:PlatformHosts`, which lists hosts that map to **no** tenant. + `Tenancy:PlatformHosts`, which lists hosts that map to **no** tenant — and +`MapHostToTenantCommand` refuses one that does, rather than writing a row the resolver +would never read. + +**Not shipped, and owned by the packet that needs them:** + +- Rows in `tenant_domains` and `tenant_settings`. Under Packet 7's promotion + `TenantDomain` and `TenantSetting` are aggregate roots in their own right, so each + will be written the way any other root is — but no command writes either yet, and + the seeder writes neither. +- Rows in `tenant_locales` and `tenant_feature_flags`. These are navigations inside + `Tenant` rather than roots, and ADR-0042's enumeration names them among the rows it + does **not** cover: neither carries an atomicity invariant against the tenant row. + `Tenant` exposes mutators for both; nothing calls them outside tests. + +A seed that needs any of those today writes them as SQL in a fixture, which is what +`TenantIsolationHttpTests` does for `tenant_settings` — deliberately, because inventing +a seeder path to serve a test would put fixture data in front of every developer running +`make seed`. Two mechanics the seeder cannot skip: @@ -256,8 +272,7 @@ normal repair; a genuine reset drops the volumes and starts over: ```bash make clean # stops the stack and drops named volumes — destructive make dev # brings the stack back up -make migrate # applies both migration chains — `make seed` does not -make seed # reseeds +make seed # depends on `migrate`, so both chains are applied first ``` ### Step 8: Authoring a new showcase diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs index cb29afc0..4c3d5cb9 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs @@ -113,10 +113,12 @@ public sealed class CachedHostToTenantResolver( /// simultaneous first requests for one cold host would be N transactions. The /// flight runs on : one caller hanging up /// must not cancel the lookup the others are waiting on. - /// WaitAsync(cancellationToken) stops only this caller waiting, - /// and a caller that stops waiting never reaches the negative-cache write above - /// — so that structure is populated only by a request that survived its own - /// lookup. + /// WaitAsync(cancellationToken) stops only this caller waiting; the + /// flight itself runs to completion and publishes its answer — the positive cache or + /// the negative one — from inside, precisely so a hung-up caller does not throw away + /// a round trip the next request would repeat. An earlier version of this sentence + /// said the negative cache is "populated only by a request that survived its own + /// lookup", which the publication block below contradicts in the same file. /// private async Task ReadCoalescedAsync( string host, string key, CancellationToken cancellationToken) diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs index 54e281b6..9fa860d9 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs @@ -30,12 +30,12 @@ namespace LearnStack.Infrastructure.MultiTenancy; /// /// Eviction is oldest-first down to a low-water mark, and entries expire. /// Bounded so a flood cannot grow it without limit; expiring so a host that becomes -/// live is not denied for the life of the process. Nothing calls -/// yet, so the TTL is the whole of it today: the -/// invalidation ADR-0036 asks for on the transaction that flips either flag needs -/// a writer of platform_host_to_tenant, and the Hub-side lifecycle that -/// owns one is [Phase 02c](../../../../docs/roadmap/phase-02c-hub-foundation.md). -/// Until then a host activated inside the TTL keeps its 404 for the rest of it. +/// live is not denied for the life of the process. is called by +/// the host-mapping writer as of Packet 7 — MapHostToTenantCommandHandler, +/// through IHostResolutionInvalidator — so the TTL is the backstop rather than the +/// whole of it. The Hub-side custom-domain lifecycle in +/// [Phase 02c](../../../../docs/roadmap/phase-02c-hub-foundation.md) is the second +/// caller, for the activation half this packet does not write. /// A trim sweeps the lapsed entries on the way past, /// because nothing else does: a read only drops the one entry it looked at, so the /// map otherwise ratchets to its cap and stays there for the life of the process. diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 1207d695..6ea485ac 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -50,8 +50,8 @@ Tenancy owns **who a request belongs to** and nothing about what they do with it ## Entity-relationship diagram Aggregate roots in the shipped code are `Tenant` and `Organization` — the two -that implement `IAggregateRoot`; the promotion below adds two more with -Packet 7's first command. `PlatformHostMapping` and `PlatformEntitlement` are +that implement `IAggregateRoot`; the promotion below adds `TenantDomain` and +`TenantSetting`, which carry the shape of a root but which no command writes yet. `PlatformHostMapping` and `PlatformEntitlement` are projections rather than aggregates: nothing in this module mutates them through a root. diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 18e730f0..ad8a1ff7 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -420,7 +420,7 @@ exists: unit-of-work begin, commit on success-`Result`, rollback on failure, preserving the `ExceptionDispatchInfo` rethrow `AuditLogBehavior` owns one frame out. -**Packet 7 — Tenant and organization resolution, isolation, two tenants ⏳** +**Packet 7 — Tenant and organization resolution, isolation, two tenants ✅** `IHostToTenantResolver` backed by `platform_host_to_tenant` and **nothing else** — never the Hub, per [ADR-0034](../decisions/0034-hub-contract-surface-invariant.md); an anonymous @@ -526,14 +526,19 @@ not the containment [the module spec's ERD](../modules/tenancy/README.md) described — and the two halves do not resolve the same way: the first pair is root-shaped already, the second cannot be a root at all under `IAggregateRoot`. Packet 7 writes the -first command that touches any of them, which is the first evidence either reading -has, and it resolves as **promotion**: `TenantDomain` and `TenantSetting` become +first code that has to take a position on any of them, which is the first evidence +either reading has, and it resolves as **promotion**: `TenantDomain` and `TenantSetting` become aggregate roots in their own right — each already carries a surrogate Vogen id, an `AuditableEntity` base, a `row_version` and its own Row Level Security policy — while `TenantLocale` and `TenantFeatureFlag` become navigations inside the `Tenant` aggregate, because a composite natural key and no surrogate id is not an `IAggregateRoot` under any reading. Tenancy therefore has four roots: `Tenant`, -`Organization`, `TenantDomain` and `TenantSetting`. A write to `TenantLocale` or +`Organization`, `TenantDomain` and `TenantSetting`. **Two of them are written by a +command; two are not.** Packet 7 ships `ProvisionTenantCommand`, +`CreateOrganizationCommand` and `MapHostToTenantCommand` — nothing writes a +`TenantDomain` or a `TenantSetting` yet, and the promotion is a statement about the +model (`Tenant` gained the two navigations, `TenantLocale` and `TenantFeatureFlag` lost +their public factories) rather than about a command that exists. A write to `TenantLocale` or `TenantFeatureFlag` bumps `Tenant.row_version`; `TenantDomain` and `TenantSetting` carry their own. Provisioning writes two of those roots in one transaction, which [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) diff --git a/docs/standards/01-architecture-standards.md b/docs/standards/01-architecture-standards.md index f2cbe4b7..4d7f2ef0 100644 --- a/docs/standards/01-architecture-standards.md +++ b/docs/standards/01-architecture-standards.md @@ -6,6 +6,8 @@ (Amendment 1: outbox dispatch via Dapr pub/sub), [ADR-0038 Cross-Cutting Port and Event Contracts](../decisions/0038-cross-cutting-port-and-event-contracts.md) (scheduled by [ADR-0035 Demand-Gated Infrastructure](../decisions/0035-demand-gated-infrastructure.md)), +[ADR-0042 Tenant Provisioning as a Bounded Cross-Aggregate Transaction](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) +(§ Aggregate Ownership's carve-out), [ADR-0033 Audit Durability Model](../decisions/0033-audit-durability-model.md) (supersedes [ADR-0016 Audit Log Subsystem](../decisions/0016-audit-log-subsystem.md)), [ADR-0017 Tenant + Organization Hierarchy](../decisions/0017-tenant-organization-hierarchy.md), diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 2ccc3f94..0400db09 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -8,6 +8,10 @@ model, and session-variable placement**), (Amendment 1: `learnstack-hub` realm), [ADR-0015 API Gateway: APISIX](../decisions/0015-api-gateway-apisix.md), [ADR-0017 Tenant + Organization Hierarchy](../decisions/0017-tenant-organization-hierarchy.md), +[ADR-0036 Tenant Resolution and Trusted Inputs](../decisions/0036-tenant-resolution-trusted-inputs.md) +(the resolution matrix and the authority ceiling), +[ADR-0040 The Ambient Unit of Work](../decisions/0040-ambient-unit-of-work.md) +(§ Tenant Context's closed setter set), [ADR-0019 LearnStack Hub](../decisions/0019-learnstack-hub.md), [ADR-0020 Triple Deployment + Hybrid License](../decisions/0020-triple-deployment-hybrid-license.md), [ADR-0033 Audit Durability Model](../decisions/0033-audit-durability-model.md), diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 1875e0fd..3ff52816 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -93,11 +93,13 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -Thirty-six test methods exist in +Fifty-nine test methods exist in [`backend/tests/LearnStack.Tests.Architecture`](../../backend/tests/LearnStack.Tests.Architecture), shipped by [Phase 01](../roadmap/phase-01-repository-tooling.md), -[Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md), Packet 4 and -Packet 6 — 55 cases once the theories expand. Methods are not rows: a `[Theory]` +[Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md), Packet 4, +Packet 6 and Packet 7 — 77 cases once the theories expand. Counted from a run at +Packet 7's close; the previous figures were Packet 6's and were not updated when +Packet 7 added its rules. Methods are not rows: a `[Theory]` is one row and many cases, and several rows pair a rule with the companion assertion that stops it passing vacuously. From 13678008f457277d406ccda329c242ebbf8dd8a7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 19:20:47 +0300 Subject: [PATCH 46/55] fix(tenancy): detach what the database refused; bound the flights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EF keeps a failed entry in the state it had, so an Added row a uniqueness violation refused stays Added. A caller that turns the conflict into Result.Fail and carries on writing therefore has the rejected INSERT still queued, and the next SaveChanges on the same context re-sends it — the row is gone from the database, and the tracker is a claim that outlived its subject. Reachable through nesting, which ADR-0040 permits: an outer handler may absorb an inner failure and keep going on the same scope, and the scope is one DbContext. Added entries only — a Modified entry's original values are what the database still holds, so detaching it would discard a change the caller may retry. The resolver's coalescing remark now says what bounds the flights, because the dictionary does not: a flight is retired in the read's finally, so its size is lookups in flight rather than hosts ever seen, and the ceiling is the Npgsql pool. A distributed flood of novel hostnames degrades into queueing there — the rate limiter bounds one peer, the negative cache bounds repeats, neither bounds first sight — and the admission gate that would is named rather than built, because its trigger is a measurement nobody has taken. Module: Tenancy ADR: 0040 Co-Authored-By: Claude Opus 5 (1M context) --- .../CachedHostToTenantResolver.cs | 11 ++++ .../Persistence/TenancyWriteStores.cs | 22 ++++++++ .../Database/TenantProvisioningTests.cs | 56 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs index 4c3d5cb9..f3c4e7ac 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs @@ -119,6 +119,17 @@ public sealed class CachedHostToTenantResolver( /// a round trip the next request would repeat. An earlier version of this sentence /// said the negative cache is "populated only by a request that survived its own /// lookup", which the publication block below contradicts in the same file. + /// + /// What bounds the flights. The dictionary does not: a flight is registered + /// per host and retired in the read's finally, so its size is the number of + /// lookups in flight, never the number of hosts ever seen. The ceiling is the Npgsql + /// pool — each cold lookup takes a connection, and past the pool maximum the rest + /// queue in OpenConnectionAsync rather than opening more. A distributed flood + /// of novel hostnames therefore degrades into queueing on that pool: the anonymous + /// rate limiter bounds one peer, the negative cache bounds repeats, and neither + /// bounds first sight. A process-wide admission gate for cold lookups is the remedy + /// if that shows up; it is not built, because the trigger for it is a measurement + /// nobody has taken. /// private async Task ReadCoalescedAsync( string host, string key, CancellationToken cancellationToken) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs index b1400752..d20dc30e 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs @@ -125,6 +125,28 @@ internal static async Task SaveTranslatingConflictsAsync( catch (DbUpdateException failure) when (failure.InnerException is PostgresException { SqlState: "23505" } conflict) { + // Detach what the database refused, before the exception leaves. EF keeps a + // failed entry in the state it had — an Added row stays Added — so a caller + // that turns this into Result.Fail and carries on writing has the rejected + // INSERT still queued, and the NEXT SaveChanges on this context re-sends it. + // + // Reachable through nesting, which is the shape ADR-0040 permits: an outer + // handler may absorb an inner failure and keep going on the same scope, and + // the scope is one DbContext. The row is gone from the database either way — + // the statement was refused — so the tracker holding it is a claim that + // outlived its subject. + // + // Added only. A Modified entry's original values are what the database still + // holds, so leaving it tracked is correct; detaching it would discard a change + // the caller may legitimately retry. + foreach (var entry in failure.Entries) + { + if (entry.State == EntityState.Added) + { + entry.State = EntityState.Detached; + } + } + throw new AggregateConflictException( conflict.MessageText, conflict.ConstraintName, failure); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs index cfe5b36c..f856aef8 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs @@ -8,6 +8,7 @@ using LearnStack.Modules.Tenancy.Domain; using LearnStack.Modules.Tenancy.Infrastructure.Persistence; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Errors; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; @@ -417,6 +418,61 @@ public async Task Updating_a_detached_aggregate_is_refused_rather_than_guessed_a await unitOfWork.RollbackAsync(); } + [Fact] + public async Task A_refused_write_does_not_ride_along_on_the_next_save() + { + // EF keeps a failed entry in the state it had, so an Added row the database + // refused stays Added. A caller that turns the conflict into Result.Fail and + // carries on writing therefore has the rejected INSERT still queued, and the next + // SaveChanges on the same context re-sends it — the row is gone from the database, + // and the tracker is a claim that outlived its subject. + // + // Reachable through nesting, which ADR-0040 permits: an outer handler may absorb + // an inner failure and keep going on the same scope, and the scope is one + // DbContext. Driven directly here, because what is under test is the store's + // cleanup and not the pipeline that would carry it. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + + services.GetRequiredService().Current = + new ResolvedContext(SchemaFixture.TenantA, SchemaFixture.OrgA1); + + var unitOfWork = services.GetRequiredService(); + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(services.GetRequiredService()); + + var organizations = services.GetRequiredService(); + + // Refused: OrgA1 already exists under this tenant. + var refused = Organization.Create( + OrganizationId.From(SchemaFixture.OrgA1), + TenantId.From(SchemaFixture.TenantA), + "collides", + "Collides", + Clock, + UserId.SystemActor); + + var conflict = async () => await organizations.AddAsync(refused); + await conflict.Should().ThrowAsync(); + + // The caller absorbs it and writes something else on the same context. + var accepted = Organization.Create( + OrganizationId.From(Guid.CreateVersion7()), + TenantId.From(SchemaFixture.TenantA), + $"after-{Guid.CreateVersion7():N}"[..20], + "After", + Clock, + UserId.SystemActor); + + var second = async () => await organizations.AddAsync(accepted); + + await second.Should().NotThrowAsync( + "the refused row was detached, so the second save carries only the new one"); + + await unitOfWork.RollbackAsync(); + } + // ── Harness ────────────────────────────────────────────────────────────── /// From 44b2c9e986cd2db47bdccddb3b28812735b25806 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 23:53:14 +0300 Subject: [PATCH 47/55] fix(tenancy): promote a default locale in the order the index requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four high findings, each verified by measurement before it was acted on. Promoting a default locale through the aggregate raised 23505 every time. PromoteDefault clears the incumbent and then sets the target in memory, and EF does not preserve that order: same-table commands go out in the comparer's order, so the UPDATE that sets the new default can precede the one that clears the old. The composite key (tenant_id, locale) sorts en-US before tr-TR, which is exactly the seeded pair. Nothing caught it because the cases covering this index drive raw SQL in an order they choose — they pin what PostgreSQL does with two statements, not what EF emits for one save. The PR body cited this invariant as the migration's safety rationale; that claim was false. The store now saves in two passes: the promotion is lowered, the clears are saved, the promotion is raised and saved. A partial unique index permits zero defaults, so the intermediate state is one the schema allows, and both saves are inside the caller's transaction. Holding the property back with IsModified alone does not work — SaveChanges accepts current values, so the second pass believes the database already holds true and writes nothing, leaving no default at all. Two architecture rules had escapes. Effective_Host_Computed_In_One_Place banned X-Forwarded-Host, which appears nowhere in the source, and not the header the code actually reads — TrustedHopOptions.HostHeaderName, a public const carrying X-LearnStack-Host. A second file reading it skips IsTrustedHop's CIDR check and constant-time secret comparison, and the rule stayed green. Resolving_Host_Is_Set_In_One_Place exempted its sole setter by filename suffix, so NoopCachedHostToTenantResolver.cs would have been exempt too. Both proven by dropping the shape into the tree and watching the suite pass. The host-reclaim case asserted only that a released hostname can be re-claimed. Dropping ux_tenant_domains_host's uniqueness left it green, because nothing asked for a conflict — and that index is the schema's only guarantee that two tenants cannot hold one hostname at once, with RLS hiding the collision from both sides. Module: Tenancy ADR: 0003, 0036 Co-Authored-By: Claude Opus 5 (1M context) --- .../Persistence/TenancyWriteStores.cs | 68 ++++++++++- .../TenancyConventionTests.cs | 45 ++++++- .../Database/TenancySchemaTests.cs | 30 +++++ .../Database/TenantLocaleDefaultTests.cs | 111 ++++++++++++++++++ 4 files changed, 248 insertions(+), 6 deletions(-) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs index d20dc30e..8755faea 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs @@ -53,10 +53,10 @@ public Task AddAsync(Tenant aggregate, CancellationToken cancellationToken = def return SaveTranslatingConflictsAsync(db, cancellationToken); } - public Task UpdateAsync(Tenant aggregate, CancellationToken cancellationToken = default) + public async Task UpdateAsync(Tenant aggregate, CancellationToken cancellationToken = default) { EnsureTracked(db, aggregate); - return SaveTranslatingConflictsAsync(db, cancellationToken); + await SaveDefaultLocaleInTwoPassesAsync(db, cancellationToken); } } @@ -101,6 +101,70 @@ public Task AddAsync( /// Shared by the stores; see for why. internal static class WriteStoreTracking { + /// + /// Saves, clearing an outgoing default locale before setting the incoming one. + /// + /// + /// + /// The aggregate cannot order this, and it was wrong to assume it could. + /// Tenant.PromoteDefault clears the incumbent and then sets the target, in that + /// order, in memory — and EF does not preserve it. Same-table commands go out in the + /// order EF's comparer produces, so the UPDATE that SETS the new default can + /// precede the one that CLEARS the old, and + /// ux_tenant_locales_tenant_id_is_default refuses the pair with 23505. + /// + /// + /// Measured against the real schema: promoting en-US over a seeded tr-TR + /// through the aggregate raised 23505 every time, because the composite key + /// (tenant_id, locale) sorts the challenger first. Nothing caught it — the + /// cases that cover this index drive raw SQL in an order they choose, so they pin what + /// PostgreSQL does with two statements rather than what EF emits for one save. + /// + /// + /// Two passes, and the intermediate state is legal. The promotions are held + /// back with EF's own IsModified flag, the clears are saved, then the + /// promotions are released and saved. A partial unique index permits ZERO defaults — + /// it forbids two — so the state between the two saves is one the schema allows, and + /// both saves are inside the caller's transaction, so no one else observes it. + /// Domain state is never touched: only which properties EF considers pending. + /// + /// + internal static async Task SaveDefaultLocaleInTwoPassesAsync( + TenancyDbContext db, CancellationToken cancellationToken) + { + var promotions = db.ChangeTracker.Entries() + .Where(entry => entry.State == EntityState.Modified + && entry.Property(locale => locale.IsDefault).IsModified + && entry.Property(locale => locale.IsDefault).CurrentValue) + .ToList(); + + if (promotions.Count == 0) + { + await SaveTranslatingConflictsAsync(db, cancellationToken); + return; + } + + // Lowered, saved, raised, saved. The value is put back to false so the FIRST save + // carries a real delta for the incumbent and none for the challenger; raising it + // afterwards gives the SECOND save a real delta of its own. Holding the property + // back with IsModified alone does not work: SaveChanges accepts current values, so + // by the second pass EF believes the database already holds `true` and writes + // nothing — measured, and the row ended with no default at all. + foreach (var promotion in promotions) + { + promotion.Property(locale => locale.IsDefault).CurrentValue = false; + } + + await SaveTranslatingConflictsAsync(db, cancellationToken); + + foreach (var promotion in promotions) + { + promotion.Property(locale => locale.IsDefault).CurrentValue = true; + } + + await SaveTranslatingConflictsAsync(db, cancellationToken); + } + /// /// Saves, turning a uniqueness violation into the port's own conflict type. /// diff --git a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs index 3661dc31..d0f034eb 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs @@ -37,9 +37,25 @@ public void Effective_Host_Computed_In_One_Place() // predicate, header, normalization, all of it. A second reader of // Request.Host is a second answer, and the one that skips the accessor // is the one that skips the trust check. + // The banned list names the headers this code ACTUALLY uses, not only the + // conventional one. `X-Forwarded-Host` appears nowhere in the source — banning it + // alone made the rule green over the real hole: `TrustedHopOptions.HostHeaderName` + // is a public const carrying `X-LearnStack-Host`, and a second file reading it + // reads the forwarded host WITHOUT `IsTrustedHop`'s CIDR check and constant-time + // secret comparison. Both the literal and the const are banned, because either + // spelling reaches the same header. Offenders( except: Path.Combine("Tenancy", "EffectiveHostAccessor.cs"), - banned: ["Request.Host", "GetDisplayUrl", "GetEncodedUrl", "X-Forwarded-Host"]) + banned: + [ + "Request.Host", + "GetDisplayUrl", + "GetEncodedUrl", + "X-Forwarded-Host", + "X-LearnStack-Host", + "TrustedHopOptions.HostHeaderName", + ], + alsoExcept: [Path.Combine("Tenancy", "TrustedHopOptions.cs")]) .Should().BeEmpty( "only EffectiveHostAccessor reads a request host (ADR-0036 § Effective " + "host and the trusted hop)"); @@ -240,7 +256,10 @@ private static List Injectors() /// literal it is forbidden to write. /// private static List Offenders( - string? except, IReadOnlyList banned, string? folder = null) + string? except, + IReadOnlyList banned, + string? folder = null, + IReadOnlyList? alsoExcept = null) { var root = Path.Combine(RepositoryPaths.BackendSrc(), "LearnStack.Api"); if (folder is not null) @@ -269,6 +288,16 @@ private static List Offenders( continue; } + // A second exemption list, for the file that DECLARES a banned spelling as + // opposed to reading it. `TrustedHopOptions` owns the header names; banning + // the name without exempting its own declaration would make the rule + // unsatisfiable rather than strict. + if (alsoExcept is not null + && alsoExcept.Any(allowed => relative.Equals(allowed, StringComparison.Ordinal))) + { + continue; + } + var code = SourceText.WithoutWhitespace( SourceText.WithoutComments(File.ReadAllText(file))); @@ -301,13 +330,21 @@ public void Resolving_Host_Is_Set_In_One_Place() // fails on the migration's own policy DDL, which must name the variable in // order to read it. const string Setter = "set_config('app.resolving_host'"; - const string SoleSetter = "CachedHostToTenantResolver.cs"; + var SoleSetter = Path.Combine( + "LearnStack.Infrastructure", "MultiTenancy", "CachedHostToTenantResolver.cs"); var offenders = Directory .EnumerateFiles(RepositoryPaths.BackendSrc(), "*.cs", SearchOption.AllDirectories) .Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) .Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) - .Where(file => !file.EndsWith(SoleSetter, StringComparison.Ordinal)) + // The full relative path, not a filename suffix. `EndsWith` on the bare name + // exempts anything ending in it — `NoopCachedHostToTenantResolver.cs`, + // `TestCachedHostToTenantResolver.cs` — so a second setter could be added in a + // file the rule silently treats as the sole one. + .Where(file => !string.Equals( + Path.GetRelativePath(RepositoryPaths.BackendSrc(), file), + SoleSetter, + StringComparison.Ordinal)) .Where(file => SourceText.WithoutComments(File.ReadAllText(file)) .Contains(Setter, StringComparison.Ordinal)) .Select(file => Path.GetRelativePath(RepositoryPaths.BackendSrc(), file)) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs index db3e2ce7..214ed505 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs @@ -523,6 +523,36 @@ INSERT INTO tenant_domains ("tenant", SchemaFixture.TenantB), ("host", Host), ("actor", SchemaFixture.Actor)); await reclaim.Should().NotThrowAsync(); + + // The other half, and without it this case passes with the uniqueness dropped + // entirely: `unique: false` on ux_tenant_domains_host leaves everything above + // green, because nothing here ever asks for a CONFLICT. A live claim must still + // block a second one — that index is the schema's only guarantee that two tenants + // cannot hold the same hostname at once, and RLS hides the collision from both + // sides, so nothing else would notice it was gone. + const string Contested = "contested.example.com"; + + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.ExecuteAsync(connection, transaction, + """ + INSERT INTO tenant_domains + (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) + VALUES (uuidv7(), @tenant, @host, 'Custom', 'Requested', 0, now(), @actor, 0) + """, + ("tenant", SchemaFixture.TenantA), ("host", Contested), ("actor", SchemaFixture.Actor)); + + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantB); + var contest = async () => await SchemaQueries.ExecuteAsync(connection, transaction, + """ + INSERT INTO tenant_domains + (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) + VALUES (uuidv7(), @tenant, @host, 'Custom', 'Requested', 0, now(), @actor, 0) + """, + ("tenant", SchemaFixture.TenantB), ("host", Contested), ("actor", SchemaFixture.Actor)); + + (await contest.Should().ThrowAsync( + "a hostname that is live for one tenant is not available to another")) + .Which.SqlState.Should().Be("23505"); } [Fact] diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleDefaultTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleDefaultTests.cs index 58028abd..60d612a3 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleDefaultTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleDefaultTests.cs @@ -1,4 +1,14 @@ using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Npgsql; using Xunit; @@ -30,6 +40,53 @@ public sealed class TenantLocaleDefaultTests public TenantLocaleDefaultTests(SchemaFixture schema) => _schema = schema; + [Fact] + public async Task Promoting_A_Default_Through_The_Aggregate_Survives_The_Index() + { + // The path the shipped code actually takes, which nothing exercised: the other + // cases here drive raw SQL in an order they choose, so they pin what PostgreSQL + // does with two statements — not what EF emits for one SaveChanges. + // + // PromoteDefault clears the incumbent and then sets the target, in that order, in + // memory. EF does not have to preserve it: ModificationCommandComparer orders + // same-table commands, so the UPDATE that SETS the new default can precede the + // one that CLEARS the old, and the partial unique index refuses the pair. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + + services.GetRequiredService().Current = + new LocaleProbeContext(SchemaFixture.TenantA, SchemaFixture.OrgA1); + + var unitOfWork = services.GetRequiredService(); + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(services.GetRequiredService()); + + var db = services.GetRequiredService(); + var tenant = await db.Tenants + .Include(candidate => candidate.Locales) + .SingleAsync(candidate => candidate.Id == TenantId.From(SchemaFixture.TenantA)); + + // The fixture seeds tr-TR as the default. en-US is the challenger, and its + // composite primary key (tenant_id, locale) sorts BEFORE tr-TR — which is the + // whole point: if EF orders by key, the challenger is written first. + tenant.AddLocale("en-US", isDefault: false, Clock, UserId.SystemActor); + await services.GetRequiredService().UpdateAsync(tenant); + + tenant.SetDefaultLocale("en-US", Clock, UserId.SystemActor); + + var promote = async () => + await services.GetRequiredService().UpdateAsync(tenant); + + await promote.Should().NotThrowAsync( + "clearing the incumbent and setting the challenger is one SaveChanges, and the " + + "partial unique index refuses the pair if EF emits them in key order"); + + (await ReadDefaultAsync(unitOfWork)).Should().Be("en-US"); + + await unitOfWork.RollbackAsync(); + } + [Fact] public async Task A_Second_Default_For_One_Tenant_Is_Refused() { @@ -173,4 +230,58 @@ private static async Task ScalarAsync( command.Parameters.Add(parameter); return (T)(await command.ExecuteScalarAsync(CancellationToken.None))!; } + + private static readonly FixedClock Clock = new( + new DateTimeOffset(2026, 9, 3, 9, 0, 0, TimeSpan.Zero)); + + private ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); + services.AddLogging(); + services.AddSingleton(Clock); + services.AddSingleton(); + services.AddTransient(provider => + provider.GetRequiredService().Current + ?? UnresolvedTenantContext.Instance); + services.AddScoped(); + services.AddModuleDbContext(); + services.AddScoped(); + + return services.BuildServiceProvider(); + } + + private static async Task ReadDefaultAsync(IUnitOfWork unitOfWork) + { + await using var command = (NpgsqlCommand)unitOfWork.Connection.CreateCommand(); + command.CommandText = + "SELECT locale FROM tenant_locales WHERE tenant_id = @tenant AND is_default"; + command.Transaction = (NpgsqlTransaction?)unitOfWork.Transaction; + command.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + + return (await command.ExecuteScalarAsync()) as string; + } + + private sealed class MutableAccessor : ITenantContextAccessor + { + public ITenantContext? Current { get; set; } + } + + private sealed class LocaleProbeContext(Guid tenant, Guid organization) : ITenantContext + { + public bool IsResolved => true; + + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + + public TenantId TenantId => SharedKernel.Identifiers.TenantId.From(tenant); + + public OrganizationId? OrganizationId => + SharedKernel.Identifiers.OrganizationId.From(organization); + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => "locale-probe"; + } } From cd15f2237b0e2562c794c1aeaaebbff9b8472d4a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 3 Sep 2026 23:58:37 +0300 Subject: [PATCH 48/55] fix(tenancy): invalidate the answer that matters; follow live majors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host resolution cached both directions and invalidated one. Clearing only the negative cache covers activation — a host that starts resolving — which is the harmless half. The half that matters is a host deactivated, released or re-pointed at another tenant: it kept serving the previous tenant's answer for the whole positive TTL, which is a cross-tenant answer coming from a cache rather than from a policy. The resolver owns both caches, so it is the invalidator now, and the port says "any cached answer" rather than leaving it to a reader. Host classification was hardcoded to /api/v1 while ApiVersioningExtensions declares which majors are live. A second live major would have skipped classification on its whole surface — an unknown host reaching a handler instead of the bodyless 404, and a tenant-facing route running with no HostClassification feature at all. The prefixes follow LiveMajors, and the test asserts the correspondence rather than the current contents. TransactionBehavior's provisioning announcement is write-only and now says so. It announces the tenant to PostgreSQL and does not touch the accessor the EF query filters read, so an EF read inside a provisioning transaction would return zero rows silently. Nothing reads today — ADR-0042 enumerates three writes, and inserts are not filtered — and making a read safe would mean a fifth writer of a member ADR-0036 Amendment 2 closes at four, which is a decision rather than an edit. Module: Tenancy ADR: 0034, 0036 Co-Authored-By: Claude Opus 5 (1M context) --- .../Tenancy/HostClassificationMiddleware.cs | 18 +++++-- .../Tenancy/TenancyCompositionExtensions.cs | 9 ++-- .../Pipeline/TransactionBehavior.cs | 10 ++++ .../CachedHostToTenantResolver.cs | 22 ++++++++- .../MultiTenancy/UnknownHostCache.cs | 3 -- .../Tenancy/IHostResolutionInvalidator.cs | 16 ++++-- .../Tenant/MapHostToTenantCommandHandler.cs | 2 +- .../Database/HostResolutionTests.cs | 49 +++++++++++++++++++ .../Tenancy/HostClassificationScopeTests.cs | 22 +++++++++ .../Tenancy/TenancyCommandGuardTests.cs | 7 ++- 10 files changed, 142 insertions(+), 16 deletions(-) diff --git a/backend/src/LearnStack.Api/Tenancy/HostClassificationMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/HostClassificationMiddleware.cs index 6a1badee..805b25e3 100644 --- a/backend/src/LearnStack.Api/Tenancy/HostClassificationMiddleware.cs +++ b/backend/src/LearnStack.Api/Tenancy/HostClassificationMiddleware.cs @@ -1,3 +1,4 @@ +using LearnStack.Api.Versioning; using System.Diagnostics.Metrics; using LearnStack.SharedKernel.Tenancy; using Microsoft.AspNetCore.Http; @@ -28,8 +29,18 @@ namespace LearnStack.Api.Tenancy; /// public sealed class HostClassificationMiddleware { - /// The one prefix classification applies to. - public const string ClassifiedPrefix = "/api/v1"; + /// The prefixes classification applies to — one per live API major. + /// + /// Derived from , not written + /// here. A hardcoded /api/v1 silently stopped classifying the moment a + /// second major went live: every request to /api/v2 would skip host + /// classification, so an unknown host would reach a handler instead of the bodyless + /// 404, and a tenant-facing route would run with no HostClassification feature + /// at all. The versioning list is the one place a major is declared live, and this + /// follows it. + /// + public static readonly IReadOnlyList ClassifiedPrefixes = + [.. ApiVersioningExtensions.LiveMajors.Select(major => $"/api/v{major}")]; /// /// Prefixes classification does not apply to. @@ -151,7 +162,8 @@ public static bool ClassifiesPath(PathString path) } } - return path.StartsWithSegments(ClassifiedPrefix, StringComparison.OrdinalIgnoreCase); + return ClassifiedPrefixes.Any( + prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase)); } private async Task RejectAsync(HttpContext context, string host) diff --git a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs index f058a20e..15e76f1e 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs @@ -173,10 +173,13 @@ public static IServiceCollection AddLearnStackTenancyEdge( // cannot reference this file. services.AddSingleton(); - // The negative cache is the invalidator: the same instance, reached through the - // port a module is allowed to name. + // The RESOLVER is the invalidator, not the negative cache: it owns both sides, + // and clearing only the negative one covers activation while leaving a + // deactivated or re-pointed host serving its previous tenant for the positive + // TTL. Same instance, reached through the port a module is allowed to name. services.AddSingleton( - provider => provider.GetRequiredService()); + provider => (CachedHostToTenantResolver)provider + .GetRequiredService()); // Singleton, so the resolver's in-flight map is process-wide: a scoped one // gives every request its own and coalesces nothing. diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index 621889d7..fdda4596 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -116,6 +116,16 @@ public async Task Handle( // every statement in it silently fail-closed — and would hand every handler // in the solution the ability to move the ambient tenant. This stays the // only caller, which is what keeps ADR-0040's setter set closed at seven. + // + // WRITE-ONLY, and the asymmetry is the reason. This announces the tenant to + // PostgreSQL; it does not touch ITenantContextAccessor, which is what the EF + // query filters read. So inside a provisioning transaction the policies see + // the new tenant and the filters see the all-zero one, and any EF READ here + // returns zero rows — silently, the way a context on its own connection would. + // Nothing reads today: ADR-0042 enumerates the operation as three writes, and + // inserts are not filtered. A read added here needs the accessor set too, + // which would make this a fifth writer of a member ADR-0036 Amendment 2 closes + // at four — so it is a decision, not an edit. if (!tenantContext.IsResolved && request is IProvisionsTenant provisioning) { await unitOfWork.SetProvisioningTenantContextAsync( diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs index f3c4e7ac..7c6ef2eb 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs @@ -34,7 +34,7 @@ public sealed class CachedHostToTenantResolver( ICacheService cache, UnknownHostCache unknownHosts, HostResolutionOptions options, - Lazy dataSource) : IHostToTenantResolver + Lazy dataSource) : IHostToTenantResolver, IHostResolutionInvalidator { private readonly ConcurrentDictionary>> _flights = new(StringComparer.Ordinal); @@ -185,6 +185,26 @@ await _cache.SetAsync( } } + /// + /// + /// Both sides, because this type owns both. The negative cache covers activation — a + /// host that starts resolving — and the positive entry covers the direction that + /// actually matters: a host deactivated, released or re-pointed keeps serving the + /// previous tenant's answer for the whole positive TTL otherwise, which is a + /// cross-tenant answer coming from a cache rather than from a policy. + /// + /// The key is composed by the same factory the read path uses, never interpolated, so + /// the entry cleared here is provably the entry written there. + /// + public async Task InvalidateAsync( + string normalizedHost, CancellationToken cancellationToken = default) + { + _unknownHosts.Forget(normalizedHost); + + await _cache.RemoveAsync( + CacheKey.ForHostMapping(normalizedHost), cancellationToken); + } + private async Task ReadAsync(string host, CancellationToken cancellationToken) { // The policy on this table admits exactly the row the resolver ANNOUNCES. diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs index 9fa860d9..67f20a81 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs @@ -42,7 +42,6 @@ namespace LearnStack.Infrastructure.MultiTenancy; /// /// public sealed class UnknownHostCache(IClock clock, UnknownHostCacheOptions options) - : IHostResolutionInvalidator { private readonly ConcurrentDictionary _seen = new(StringComparer.Ordinal); @@ -103,8 +102,6 @@ public void Add(string host) /// public void Forget(string host) => _seen.TryRemove(host, out _); - /// - void IHostResolutionInvalidator.Invalidate(string normalizedHost) => Forget(normalizedHost); private void Trim() { diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/IHostResolutionInvalidator.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IHostResolutionInvalidator.cs index 53fb3eb8..7b94db8d 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/IHostResolutionInvalidator.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IHostResolutionInvalidator.cs @@ -22,7 +22,15 @@ namespace LearnStack.SharedKernel.Tenancy; public interface IHostResolutionInvalidator { /// Forgets any cached answer for . - void Invalidate(string normalizedHost); + /// + /// Any answer — the negative one and the positive one. Clearing only the + /// negative side covers activation, which is the harmless direction: a host that + /// starts resolving. The direction that matters is the other one — a host + /// deactivated, released, or re-pointed at a different tenant keeps serving the old + /// tenant's content for the whole positive TTL, which is a cross-tenant answer from a + /// cache rather than from a policy. + /// + Task InvalidateAsync(string normalizedHost, CancellationToken cancellationToken = default); } /// The invalidator for a host with nothing to invalidate. @@ -30,8 +38,8 @@ public sealed class NullHostResolutionInvalidator : IHostResolutionInvalidator { public static NullHostResolutionInvalidator Instance { get; } = new(); - public void Invalidate(string normalizedHost) - { + public Task InvalidateAsync( + string normalizedHost, CancellationToken cancellationToken = default) => // Nothing is cached, so nothing is stale. - } + Task.CompletedTask; } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs index 4c19d391..8628b4ab 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs @@ -138,7 +138,7 @@ [new LocalizedMessage("lockey_host_taken")], // obligation; this call joins it there. Until then the residual window is bounded // by the same TTL it exists to shorten, so the failure mode is the old one for a // few milliseconds rather than a new one. - resolutionCache.Invalidate(mapping.Host); + await resolutionCache.InvalidateAsync(mapping.Host, cancellationToken); return Result.Ok(new HostMappingDto( mapping.Host, diff --git a/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs index 3fbdf191..d80683b9 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs @@ -132,6 +132,55 @@ public async Task A_Read_Only_Transaction_Admits_The_Announcement_And_Refuses_A_ await transaction.RollbackAsync(); } + [Fact] + public async Task Invalidation_Clears_The_Positive_Entry_Not_Only_The_Negative_One() + { + // The direction that matters. Clearing only the negative cache covers activation — + // a host that starts resolving — which is the harmless half. A host deactivated, + // released, or re-pointed at a different tenant keeps serving the PREVIOUS + // tenant's answer for the whole positive TTL otherwise: a cross-tenant answer + // coming from a cache rather than from a policy. + await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + var resolver = BuildResolver(dataSource); + + (await resolver.ResolveAsync(SchemaFixture.HostA)).Should().NotBeNull(); + + // Cached now: a second lookup must not reach the database, which is what the + // sibling case pins. Re-point the row underneath it, as a host lifecycle would. + await using (var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString)) + await using (var repoint = new NpgsqlCommand( + "UPDATE platform_host_to_tenant SET is_publicly_live = false WHERE host = @host", + (NpgsqlConnection)platform)) + { + repoint.Parameters.AddWithValue("host", SchemaFixture.HostA); + await repoint.ExecuteNonQueryAsync(); + } + + try + { + // Still served from the positive cache — the row changed, the answer did not. + (await resolver.ResolveAsync(SchemaFixture.HostA)).Should().NotBeNull( + "the positive entry is still warm, which is what makes invalidation matter"); + + await ((IHostResolutionInvalidator)resolver).InvalidateAsync(SchemaFixture.HostA); + + (await resolver.ResolveAsync(SchemaFixture.HostA)).Should().BeNull( + "invalidation cleared the positive entry, so the next lookup re-read the " + + "row and found it no longer publicly live"); + } + finally + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var restore = new NpgsqlCommand( + "UPDATE platform_host_to_tenant SET is_publicly_live = true WHERE host = @host", + (NpgsqlConnection)platform); + restore.Parameters.AddWithValue("host", SchemaFixture.HostA); + await restore.ExecuteNonQueryAsync(); + } + } + [Fact] public async Task An_Unmapped_Host_Resolves_To_Nothing() { diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs index efb1af2f..3ca52a62 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/HostClassificationScopeTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using LearnStack.Api.Tenancy; +using LearnStack.Api.Versioning; using LearnStack.SharedKernel.Tenancy; using Microsoft.AspNetCore.Http; using Xunit; @@ -46,6 +47,27 @@ public void Classification_Does_Not_Apply_Anywhere_Else(string path) .Should().BeFalse(); } + [Fact] + public void Classification_Follows_Every_Live_Api_Major() + { + // The prefix was the literal "/api/v1". A second live major would have skipped + // host classification entirely on its whole surface: an unknown host reaching a + // handler instead of the bodyless 404, and a tenant-facing route running with no + // HostClassification feature at all. ApiVersioningExtensions.LiveMajors is the one + // place a major is declared live, so the prefixes follow it rather than restating + // it — and this asserts the correspondence, not the current contents. + HostClassificationMiddleware.ClassifiedPrefixes.Should().BeEquivalentTo( + ApiVersioningExtensions.LiveMajors.Select(major => $"/api/v{major}"), + "one classified prefix per live major, and no other source of truth"); + + foreach (var major in ApiVersioningExtensions.LiveMajors) + { + HostClassificationMiddleware + .ClassifiesPath(new PathString($"/api/v{major}/courses")) + .Should().BeTrue($"v{major} is live, so its surface is classified"); + } + } + [Fact] public void The_Exclusion_List_Is_Pinned() { diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyCommandGuardTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyCommandGuardTests.cs index e3423d1c..224233bd 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyCommandGuardTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyCommandGuardTests.cs @@ -199,7 +199,12 @@ private sealed class RecordingInvalidator : IHostResolutionInvalidator { public List Hosts { get; } = []; - public void Invalidate(string normalizedHost) => Hosts.Add(normalizedHost); + public Task InvalidateAsync( + string normalizedHost, CancellationToken cancellationToken = default) + { + Hosts.Add(normalizedHost); + return Task.CompletedTask; + } } private sealed class ResolvedContext(TenantId tenantId) : ITenantContext From 97fabdd471ccb0215264416751c2b33ecd0de48e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 4 Sep 2026 00:00:58 +0300 Subject: [PATCH 49/55] docs: correct three statements the code contradicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TenancyDbContext said `tenants` gets no query filter. It gets one of a different shape — self-keyed, comparing `Id` rather than a `TenantId` column — which the self-keyed branch in TenantQueryFilters has applied since Packet 7. One of the eight entity types genuinely gets none, not two. permissions.md named `tenancy.tenant.admin` as the key that will govern the host mapping, while the matrix directly above gives `HostMapping` its own resource with no `write`. The prose now names `tenancy.hostmapping.admin` and says why the resource is separate: pointing a hostname at a tenant is an admin-scope act, not something the everyday tenant-admin role should carry. ADR-0036's Decision still prints the normalization order Amendment 1 measured as wrong — rejecting IPv4 literals before stripping a port, which lets 1.2.3.4:8080 through because TryParse fails on the port-bearing string and never runs again. The statement was false when it entered the record, so it takes the default instrument: an inline erratum beside it, pointing at the amendment that corrects it. The Decision is untouched. Co-Authored-By: Claude Opus 5 (1M context) --- .../Persistence/TenancyDbContext.cs | 9 ++++++--- .../decisions/0036-tenant-resolution-trusted-inputs.md | 10 ++++++++++ docs/modules/tenancy/permissions.md | 9 ++++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs index e3385525..f04ac1b7 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs @@ -26,9 +26,12 @@ namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; /// The global query filters come from the base. /// TenantScopedDbContext owns the two members they close over and applies /// one to every entity implementing ITenantOwned; this context adds none -/// of its own. Two of its eight entity types deliberately get no filter: -/// , which is tenant-owned self-keyed — its id -/// is the tenant id, and its policy says so — and +/// of its own. gets a filter of a different SHAPE, not none: +/// it is tenant-owned self-keyed — its id is the tenant id, and its +/// policy says so — so the predicate compares Id rather than a +/// TenantId column. An earlier version of this paragraph said it got no +/// filter at all, which the self-keyed branch in TenantQueryFilters +/// contradicts. One of the eight entity types genuinely gets none: /// , which is platform-scoped and read /// in order to determine the tenant, so a tenant-keyed predicate on it would make /// host resolution return zero rows forever. Row Level Security remains the diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index 31d8c790..0ecd7da6 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -174,6 +174,16 @@ Load-bearing details: token and does not carry bare `key`, so existing Serilog and error-tracker redaction covers the header for free. +> **Erratum — 2026-09-04.** The order in the paragraph below rejects IPv4 literals +> *before* stripping a port, and that is the order Packet 4 measured as wrong: it +> lets `1.2.3.4:8080` through, because `IPAddress.TryParse` fails on the +> port-bearing string and the check never runs again after the port is stripped. +> The corrected order strips the port first and rejects the literal after; shown by +> § Amendments, Amendment 1, and narrowed further by its own 2026-09-02 erratum. +> The Decision is unchanged. Current authority: +> [Amendment 1](#2026-08-20--amendment-1-the-normalization-order-corrected-by-measurement) +> and `EffectiveHost.Normalize`. + **Normalization is a total pure function.** `EffectiveHost.Normalize(string) → string?` is the sole producer of both the lookup key and the `app.resolving_host` value. Every failure returns `null` (⇒ unresolved ⇒ 404); nothing throws. Do **not** use diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 1ade3a52..ed17422b 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -31,9 +31,12 @@ refuses the write. **`MapHostToTenantCommand` is the one to gate first.** It writes the row that decides whose data an anonymous request sees, which makes it the highest-value -write in the module and the reason `tenancy.tenant.admin` — not -`tenancy.tenant.write` — is the key that will govern it. The three keys are -registered with the rest in Phase 03. +write in the module — and the reason the matrix above gives `HostMapping` its own +resource with **no `write`**: pointing a hostname at a tenant is an admin-scope +act, so `tenancy.hostmapping.admin` is the key that will govern it rather than any +grant inside the everyday tenant-admin role. `tenancy.tenant.admin` governs +provisioning and `tenancy.organization.write` the second organization. All three +are registered with the rest in Phase 03. | Resource | read | write | delete | admin | Default role grants | |----------|:----:|:-----:|:------:|:-----:|---------------------| From 6c0551d5d9f3f46af2ccd870332171b2d663a2d3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 4 Sep 2026 00:04:30 +0300 Subject: [PATCH 50/55] build(hooks): check the commit subject before CI has to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three commits on this branch tripped the 72-character subject limit, each caught by CI after a push — and a subject is only fixable by rewriting history, so each one cost a force-push. The rule was never the problem; where the failure landed was. The commit-msg hook enforces exactly what CI's meta job enforces: Conventional Commits shape and the 72-character subject. No local-only rule, and nothing CI does not already have — the difference is that locally it is a retry. Merge, revert and fixup subjects are generated by git rather than authored and are skipped. The ADR: and Module: trailers are deliberately not checked: whether a commit owes one depends on judgement a hook does not have, and a hook that guessed is a hook people disable. Co-Authored-By: Claude Opus 5 (1M context) --- .githooks/commit-msg | 52 +++++++++++++++++++++++++++++++++++++++++ .github/CONTRIBUTING.md | 11 +++++++++ 2 files changed, 63 insertions(+) create mode 100755 .githooks/commit-msg diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 00000000..b0ba25f1 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# LearnStack commit-msg hook. +# +# Activated via `git config core.hooksPath .githooks` (run by `make install`). +# +# Enforces the two subject rules from +# `docs/standards/14-git-workflow.md` § Commit messages, and enforces exactly +# what CI's `meta` job enforces — no local-only rule, no rule CI does not have. +# The point is where the failure lands: CI catches this after a push, and a +# subject is a thing you can only fix by rewriting history. Locally it is a +# retry. +# +# Deliberately NOT checked here: the `ADR:` / `Module:` trailers. Whether a +# commit needs one depends on what it touches and on judgement a hook does not +# have, and a hook that guessed would be one people disable. + +set -eu -o pipefail + +message_file="$1" + +# The subject is the first line. Comment lines are stripped by git before this +# hook sees the file only for `-m`; for an editor session they are still here, +# so skip them and take the first line that is not one. +subject=$(grep -v '^#' "$message_file" | sed '/^[[:space:]]*$/d' | head -1) + +# A merge or fixup commit is generated by git, not authored, and its shape is +# not ours to police. +case "$subject" in + "Merge "*|"Revert "*|"fixup! "*|"squash! "*) exit 0 ;; +esac + +fail=0 + +if [ "${#subject}" -gt 72 ]; then + echo "commit-msg: subject is ${#subject} chars (limit 72):" >&2 + echo " $subject" >&2 + fail=1 +fi + +if ! printf '%s' "$subject" \ + | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9., /-]+\))?!?: .+'; then + echo "commit-msg: subject is not Conventional Commits:" >&2 + echo " $subject" >&2 + fail=1 +fi + +if [ "$fail" -ne 0 ]; then + echo >&2 + echo "Standards 14 § Commit messages: type(scope): subject, imperative, <= 72." >&2 + echo "Amend with: git commit --amend" >&2 + exit 1 +fi diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e0f69500..348a685c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -132,6 +132,17 @@ three commands above are mostly a sanity check. There is deliberately no runners, and CI re-runs every check as a hard gate, so a bypassed local commit will fail the PR build. +The **commit-msg** hook (same activation) checks the subject line against the two +rules in [Git Workflow § Commit messages](../docs/standards/14-git-workflow.md): +Conventional Commits shape, and 72 characters. It enforces exactly what CI's +`meta` job enforces and nothing more — the point is not a new rule but where the +failure lands. CI catches an over-long subject after a push, and a subject is +only fixable by rewriting history; locally it is a retry. Merge, revert and +fixup subjects are generated by git rather than authored, and are skipped. The +`ADR:` and `Module:` trailers are deliberately not checked: whether a commit owes +one depends on judgement a hook does not have, and a hook that guessed is a hook +people disable. + Prettier stops at the `frontend/` boundary, and the root `.prettierignore` is what enforces it in your editor — `.vscode/settings.json` maps `[markdown]` to the Prettier extension, which honours that file, so neither format-on-save From 55577bfcd2fa3ee27f3b404e8d3e0ef326378675 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 4 Sep 2026 00:23:11 +0300 Subject: [PATCH 51/55] fix(tenancy): refuse a default nobody serves; close two rule escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A locale could be the tenant's default while disabled. The partial unique index says at most one locale is default and says nothing about whether it is enabled, so both entry points produced it — AddLocale(isDefault: true, isEnabled: false) directly, and SetDefaultLocale by promoting a locale added disabled. Every reader of "the tenant's default locale" then holds a row that answers the question and cannot serve it. The guard is in PromoteDefault, which both doors go through — and putting it there exposed a second defect the test caught immediately: AddLocale adds the row and stamps the root BEFORE promoting, so a refused add left the locale added and the version moved for a call that threw. Same shape as SetFeatureFlag's ordering, fixed the same way. Modules_Do_Not_Inject_IEventBus_Directly compared the declared parameter type, so Lazy, Func and IEnumerable all escaped — each injects the port just as effectively, and the first is a shape this codebase already uses for NpgsqlDataSource. The rule now unwraps type arguments transitively, because the wrappers nest. The cross-aggregate census counts IAggregateWriteStore derivations, so a port that does not derive is invisible to it — and one already exists deliberately, IPlatformHostMappingStore, because PlatformHostMapping is a projection with a string key. That exemption is fine; being silent about it is not, because a second such port would join it with nothing to notice. Non-deriving write ports are now enumerated, detected by shape rather than name: an interface whose method takes a type from a module's Domain assembly. Module: Tenancy ADR: 0042 Co-Authored-By: Claude Opus 5 (1M context) --- .../Tenant.cs | 28 +++++++++++- .../AggregateWriteTests.cs | 43 ++++++++++++++++++ .../CrossCuttingFoundationTests.cs | 45 ++++++++++++++++--- .../Modules/Tenancy/TenancyAggregateTests.cs | 34 ++++++++++++++ 4 files changed, 142 insertions(+), 8 deletions(-) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index d722e246..27d687d0 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -197,6 +197,18 @@ public void AddLocale( + "rather than a second locale."); } + // Refused before anything is added or stamped. PromoteDefault below raises on a + // disabled default, and it runs AFTER the row is in the collection — so without + // this check a refused AddLocale left the locale added and the root stamped for a + // call that threw. Same shape as SetFeatureFlag's ordering: every guard runs + // before the first mutation. + if (isDefault && !isEnabled) + { + throw new InvalidOperationException( + $"'{added.Locale}' cannot be added as a disabled default: a default nobody " + + "serves is not a default. Add it enabled, or add it non-default."); + } + MarkUpdated(clock.UtcNow, updatedBy); _locales.Add(added); @@ -206,7 +218,7 @@ public void AddLocale( // non-empty locale set and no default — a state every reader of "the tenant's // default locale" has to handle and none of them expects. Promoting is the only // answer that leaves the aggregate in a state the schema can also express. - if (isDefault || _locales.Count == 1) + if (isDefault || (_locales.Count == 1 && isEnabled)) { PromoteDefault(added); } @@ -312,6 +324,20 @@ public void RemoveFeatureFlag(string key, IClock clock, UserId updatedBy) /// private void PromoteDefault(TenantLocale target) { + // A disabled default is a default nobody serves. The partial unique index says at + // most one locale is default and says nothing about whether it is enabled, so both + // entry points could produce a tenant whose fallback language is switched off — + // AddLocale(isDefault: true, isEnabled: false) directly, and SetDefaultLocale by + // promoting a locale that was added disabled. Every reader of "the tenant's + // default locale" then has a row that answers the question and cannot serve it. + if (!target.IsEnabled) + { + throw new InvalidOperationException( + $"'{target.Locale}' is disabled, so it cannot be this tenant's default. " + + "Enable it first, or choose a locale that is enabled."); + } + + foreach (var incumbent in _locales.Where(locale => locale.IsDefault)) { incumbent.ClearDefault(); diff --git a/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs b/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs index 59247916..543d722f 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs @@ -52,6 +52,49 @@ public void Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning() + "operation — a second name here is a decision that needs its own record"); } + [Fact] + public void Every_Write_Port_Is_Countable_Or_Enumerated() + { + // The rule above counts IAggregateWriteStore derivations. A port that does not + // derive is therefore invisible to it — and one already exists: + // IPlatformHostMappingStore, deliberately, because PlatformHostMapping is a + // projection with a string key rather than an aggregate root. + // + // That exemption is fine; being SILENT about it is not. A second non-deriving port + // would join the first with nothing to notice it, and the census that keeps + // ADR-0042's exception at one entry would stop describing the system. So the + // non-deriving ports are enumerated, and adding one is a reviewed diff. + // + // Detected by shape, not by name: an interface whose method takes a type from a + // module's Domain assembly is a port that writes domain objects, whatever it is + // called. A rule keyed on "ends in Store" is satisfied by renaming. + var domainAssemblies = ProductionAssemblies() + .Select(Assembly.Load) + .Where(assembly => assembly.GetName().Name?.EndsWith(".Domain", StringComparison.Ordinal) + is true) + .ToHashSet(); + + var writePorts = ProductionAssemblies() + .Select(Assembly.Load) + .SelectMany(assembly => assembly.GetTypes()) + .Where(type => type.IsInterface) + .Where(type => type.GetMethods().Any(method => + method.GetParameters().Any(parameter => + domainAssemblies.Contains(parameter.ParameterType.Assembly)))) + .Where(type => !WriteStoreConstructions(type).Any()) + .Select(type => type.Name) + .Distinct() + .Order(StringComparer.Ordinal) + .ToList(); + + writePorts.Should().BeEquivalentTo( + ["IPlatformHostMappingStore"], + "a port that takes a domain object and does not derive from " + + "IAggregateWriteStore is invisible to the cross-aggregate census — " + + "PlatformHostMapping is a projection with a string key and is the one " + + "sanctioned case, and a second name here needs its own decision"); + } + /// /// The distinct aggregate roots a constructor's write ports reach. /// diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 72f498ce..969bab5d 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -550,17 +550,24 @@ member.DeclaringType is null || ModuleAssemblyShapes.Contains( member.DeclaringType.Assembly.GetName().Name, StringComparer.Ordinal); + // Through wrappers, not only at the surface. `IEventBus.IsAssignableFrom` is false + // for `Lazy`, `Func`, `IEnumerable` and + // `Task` — each of which injects the port just as effectively, and the + // first of them is a shape this codebase already uses (`Lazy`). + // A rule that only looked at the declared type was satisfied by one type argument. + bool Banned(Type declared) => + Unwrap(declared).Any(inner => + forbidden.Any(candidate => candidate.IsAssignableFrom(inner))); + return type.GetConstructors(members).Where(InScope).Any(constructor => - constructor.GetParameters().Any(parameter => - forbidden.Any(candidate => candidate.IsAssignableFrom(parameter.ParameterType)))) + constructor.GetParameters().Any(parameter => Banned(parameter.ParameterType))) || type.GetMethods(members).Where(InScope).Any(method => - forbidden.Any(candidate => candidate.IsAssignableFrom(method.ReturnType)) - || method.GetParameters().Any(parameter => - forbidden.Any(candidate => candidate.IsAssignableFrom(parameter.ParameterType)))) + Banned(method.ReturnType) + || method.GetParameters().Any(parameter => Banned(parameter.ParameterType))) || type.GetFields(members).Where(InScope).Any(field => - forbidden.Any(candidate => candidate.IsAssignableFrom(field.FieldType))) + Banned(field.FieldType)) || type.GetProperties(members).Where(InScope).Any(property => - forbidden.Any(candidate => candidate.IsAssignableFrom(property.PropertyType))); + Banned(property.PropertyType)); } /// A type that breaks the rule, so the checker can be shown to catch it. @@ -652,4 +659,28 @@ private static WebApplication BuildMinimalApiHost() return null; } } + + /// A declared type and every type argument reachable through it. + /// + /// Transitive, because the wrappers nest: Func<Lazy<IEventBus>> is + /// two layers and injects the port at the bottom of both. Open generics are skipped — + /// a type parameter names no port. + /// + private static IEnumerable Unwrap(Type declared) + { + yield return declared; + + if (!declared.IsGenericType || declared.IsGenericTypeDefinition) + { + yield break; + } + + foreach (var argument in declared.GetGenericArguments()) + { + foreach (var inner in Unwrap(argument)) + { + yield return inner; + } + } + } } diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs index 83dc50d1..3308a7e1 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -32,6 +32,40 @@ public sealed class TenancyAggregateTests private static readonly TenantDomainId DomainId = TenantDomainId.From(Guid.Parse("dddddddd-1111-7111-8111-111111111111")); + [Fact] + public void A_disabled_locale_cannot_be_the_default() + { + // The index guarantees at most one default and says nothing about whether it is + // enabled, so both entry points could produce a tenant whose fallback language is + // switched off — and every reader of "the tenant's default locale" then holds a + // row that answers the question and cannot serve it. + var tenant = TenancyDomain.Tenant.Create(Tenant, "acme", "Acme", Clock, Actor); + + var addDisabledDefault = () => + tenant.AddLocale("tr-TR", isDefault: true, Clock, Actor, isEnabled: false); + + addDisabledDefault.Should().Throw() + .WithMessage("*disabled*"); + + // And it left nothing behind: PromoteDefault raises after the row is in the + // collection, so a guard placed only there would have added the locale and + // stamped the root for a call that threw. + tenant.Locales.Should().BeEmpty("a refused add adds nothing"); + tenant.Version.Should().Be(0, "and does not move the root's concurrency token"); + + // And the same through the other door: added disabled, promoted later. + tenant.AddLocale("tr-TR", isDefault: false, Clock, Actor); + tenant.AddLocale("en-US", isDefault: false, Clock, Actor, isEnabled: false); + + var promoteDisabled = () => tenant.SetDefaultLocale("en-US", Clock, Actor); + + promoteDisabled.Should().Throw() + .WithMessage("*disabled*"); + + tenant.Locales.Single(locale => locale.IsDefault).Locale.Should().Be("tr-TR", + "the refused promotion left the incumbent in place"); + } + [Fact] public void The_first_locale_is_the_default_whether_or_not_it_was_asked_for() { From cf2015512d1ba13b0140a70f6c9657708d34edfd Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 4 Sep 2026 00:30:50 +0300 Subject: [PATCH 52/55] test(kernel): pin pipeline idempotency; drain both pipes before waiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review said the pipeline registration used Add rather than TryAdd, so a second call would double every behaviour. I wrote a marker guard for it, then measured: MediatR's AddBehavior already deduplicates — seven behaviours and eleven registrations either way — and removing my guard changed nothing. So the guard went, because a guard no test can kill is a comment. The property is worth pinning even though it is MediatR's rather than ours. Every fixture in the repository registers its probe handler by hand specifically to avoid re-running AddMediatR; if deduplication ever stopped holding, that workaround would be load-bearing rather than cautious, and nothing would say so. RunMigrateTarget read stdout to the end and only then stderr, before waiting. That deadlocks whenever the child fills the second pipe's buffer while this side is blocked on the first — the classic Process pitfall. It has never happened because the role check is terse, which is exactly why it would land on whoever makes the target chattier. Both pipes now drain concurrently. Co-Authored-By: Claude Opus 5 (1M context) --- .../Pipeline/MediatRPipelineRegistration.cs | 1 + .../CrossCuttingFoundationTests.cs | 32 +++++++++++++++++++ .../PersistenceConventionTests.cs | 13 ++++++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs index 202f1195..8198a47c 100644 --- a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs +++ b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs @@ -82,4 +82,5 @@ public static IServiceCollection AddLearnStackMediatRPipeline( return services; } + } diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 969bab5d..105ed822 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -119,6 +119,38 @@ public void MediatR_Pipeline_Order_Matches_Canonical_Sequence() + "it must match the hardcoded ADR-0032 sequence."); } + [Fact] + public void Registering_The_Pipeline_Twice_Registers_It_Once() + { + // A doubled TransactionBehavior would be a nested frame on every request — the + // joiner path, taken for no reason, on the hot path — and a doubled + // AuditLogBehavior would catch and rethrow the same exception twice. + // + // The property holds, and it is MediatR's, not ours: `AddBehavior` deduplicates, + // so a second call adds nothing. Measured — seven behaviours and eleven total + // registrations either way. This pins it because it is a property we depend on + // and did not write: every test fixture in the repository registers its probe + // handler by hand specifically to avoid re-running AddMediatR, and if this ever + // stopped holding, that workaround would be load-bearing rather than cautious. + // + // A guard of our own was written for this and then removed: it changed nothing, + // and a guard no test can kill is a comment. + var once = new ServiceCollection(); + once.AddLearnStackMediatRPipeline(); + + var twice = new ServiceCollection(); + twice.AddLearnStackMediatRPipeline(); + twice.AddLearnStackMediatRPipeline(); + + static int Behaviors(IServiceCollection services) => services.Count(descriptor => + descriptor.ServiceType.IsGenericType + && descriptor.ServiceType.GetGenericTypeDefinition() == typeof(IPipelineBehavior<,>)); + + Behaviors(twice).Should().Be(Behaviors(once), + "the second call is a no-op, not a second pipeline"); + twice.Count.Should().Be(once.Count, "and it adds no other registration either"); + } + [Fact] public void IExceptionHandler_Registered_AtStartup() { diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index f5b17e5f..502aa3d4 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -496,10 +496,19 @@ private static (int ExitCode, string Output) RunMigrateTarget(string migrationCo using var _ = process; - var stdout = process.StandardOutput.ReadToEnd(); - var stderr = process.StandardError.ReadToEnd(); + // Both pipes drained concurrently, then the wait. Reading one to the end and only + // then the other deadlocks whenever the child fills the second pipe's buffer while + // this side is blocked on the first — the classic Process pitfall. The role check + // is terse enough that it has never happened here, which is exactly why it would + // land on whoever makes the target chattier. + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(milliseconds: 60_000).Should().BeTrue("the role check exits immediately"); + var stdout = stdoutTask.GetAwaiter().GetResult(); + var stderr = stderrTask.GetAwaiter().GetResult(); + return (process.ExitCode, stdout + stderr); } From 27740d491017e14f3a546bec69e8304d266a0032 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 4 Sep 2026 00:33:42 +0300 Subject: [PATCH 53/55] chore(tests): drop two dependencies nothing referenced, correct two docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration project carried PackageReferences to Testcontainers.Redis and Respawn and used neither, restoring both on every build for nothing. The PackageVersions stay: Directory.Packages.props already names a consumer for each — the Valkey-backed ICacheService adapter in Phase 11, and Phase 03's second module context — and a version binds nothing on its own. The comment there now says which of the two states each package is in, because "declared and used by nothing" did not distinguish them. The decisions README said a superseded ADR is "kept as a redirect". Two are not: 0014 and 0016 keep their status in place, because the record still explains why the decision was made and what replaced it. Redirect stubs under _redirects/ are the other case, where the number was reassigned and only the pointer is worth keeping. Both are readable at their original path; neither is deleted. Co-Authored-By: Claude Opus 5 (1M context) --- backend/Directory.Packages.props | 8 ++++++-- .../LearnStack.Tests.Integration.csproj | 2 -- docs/decisions/README.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 221b97d8..f52d4add 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -49,8 +49,12 @@ `[Trait("Requires","Docker")]` in CI's `backend-integration` job. --> - - -