diff --git a/.claude/skills/add-architecture-test/SKILL.md b/.claude/skills/add-architecture-test/SKILL.md index 80b12580..679e5c05 100644 --- a/.claude/skills/add-architecture-test/SKILL.md +++ b/.claude/skills/add-architecture-test/SKILL.md @@ -110,22 +110,35 @@ 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 ten 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 **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). | +| `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, +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 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 +> 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 @@ -141,8 +154,9 @@ Architecture tests are **non-skippable**. That means: When the rule is about migration content (RLS, partition): ```csharp +/// A migration-scan sketch. NOT the shipped rule — see the note below. [Fact] -public void Every_TenantOwned_Table_HasRls_With_AppTenantId() +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), @@ -151,32 +165,75 @@ 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))) + // 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, 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) { - 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."); } } ``` +**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 Before merging: 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 d9222b94..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,32 +169,64 @@ only three files under `backend/src` may mention `UseNpgsql` at all. In `LearnStack.Modules..Infrastructure/Persistence/DbContext.cs`: ```csharp +// `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, - ITenantContext tenantContext, - IPublisher publisher) - : DbContext(options) + DbContextOptions<DbContext> options, ITenantContextAccessor accessor) + : TenantScopedDbContext(options, 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) { - // 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); + + // 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); } } ``` +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. +`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 -`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 @@ -231,9 +271,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 @@ -252,5 +292,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-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index 8b8c5c84..960a0d96 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,29 @@ 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 + **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. - 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 +384,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-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-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index f7178522..90d087f0 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 @@ -165,9 +170,16 @@ 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. +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 +`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 +206,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 +298,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 +313,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-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/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index 078990b7..c611061b 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 | @@ -166,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 @@ -200,9 +220,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 @@ -309,35 +339,44 @@ 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 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) - -**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. +### Step 4: Architecture test (implemented for Tenancy; Packet 10 closes it) -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)). @@ -348,34 +387,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. @@ -389,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 @@ -401,5 +464,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/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index e7d7a528..da208f07 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -196,23 +196,37 @@ 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 and prints the demo credentials. The -steps below are its **Phase 02a** scope — `scripts/seed.sh` carries them as a -documented placeholder and does not run them yet: - -1. Applies every module's EF migrations. -2. Seeds Keycloak's `learnstack` realm with a platform admin and two demo tenants - (each with two organizations + 4 users covering each role). -3. Seeds Keycloak's `learnstack-hub` realm with an operator (`hub-operator`) and a - billing viewer. -4. Seeds the two demo tenants' customization data (`TenantContentType`, - `TenantPageBlock`, `TenantLevelTaxonomy`, …) so the page renderer has - something to render. -5. Creates the SeaweedFS buckets. -6. Creates the Meilisearch indexes. - -There is no separate reset target today. If the placeholder seed must be rerun -against fresh local data, use the destructive `make clean`, then `make seed`. +`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: + +1. `demo-english` ("English Hero") and `demo-yoga` ("Anatolia Yoga"), each with a + default organization and a second one, through `ProvisionTenantCommand` and + `CreateOrganizationCommand`. +2. One `platform_host_to_tenant` row per tenant — + `demo-english.learnstack.local` mapping to the tenant, and + `demo-yoga.learnstack.local` to its default organization, so both live host + classifications exist in the seed. + +What it does not write yet, and which phase owns each: + +3. Keycloak realm users beyond the ones the realm JSON imports at compose boot — + the `users` table arrives with + [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md)'s Identity + migration, and Packet 7 creates none. +4. Customization data (`TenantContentType`, `TenantPageBlock`, + `TenantLevelTaxonomy`, …) — the Customization module is empty until + [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md), which is what first needs them. +5. SeaweedFS buckets and Meilisearch indexes — both adapters are demand-gated to + [Phase 11](../../../docs/roadmap/phase-11-production-hardening.md) under + [ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md). + +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 seed`, which re-applies the migrations on +its way through. ### Step 5: Verify @@ -272,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 seed`; `scripts/seed.sh` is still a Phase 02a placeholder today. | +| 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/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 06a79383..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. @@ -48,7 +49,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 +98,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 +176,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`: @@ -185,11 +197,20 @@ 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 +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 +218,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..b7037f77 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,177 @@ 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 invokes the seeder: ```bash -dotnet run --project infra/seed -- \ - --showcase english \ - --slug demo-english \ - --hub-backed=false # true requires the local Hub +ConnectionStrings__Default="" \ + dotnet run --project backend/src/LearnStack.Tools.Seeder --nologo ``` -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. +**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 for +as long as the process runs. There is no flag for it — a caller running the tool by hand +exports the variable too. -#### 3.4 Education catalog +`scripts/seed.sh` reads it from `ConnectionStrings__Default`, falling back to +`.env` — the Makefile does not export `.env` into a recipe's environment — and +**refuses any role but `learnstack_app`**: seeding as the owner would succeed +with every policy inert and prove nothing. -- 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. +There is no platform-admin user to seed. 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. There is no `make seed-tenant` and no +`infra/seed/` tree. -#### 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 + +**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. +- 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. 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. Packet 7 ships +two of them; the rest are what a later packet adds, and this list says which is which. + +**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 — 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: + +- **`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 + 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 `TenantSetting`; 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 +225,119 @@ 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`. +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)). -### Step 5: Verify +### Step 6: Verify -```bash -# DB sanity -psql $DATABASE_URL -c "SELECT id, slug, display_name FROM tenants;" - -# 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, is_publicly_live 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 seed # depends on `migrate`, so both chains are applied first ``` -### 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. +- 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. ## 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/.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/.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 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/Makefile b/Makefile index f639f739..0bac5f17 100644 --- a/Makefile +++ b/Makefile @@ -261,7 +261,7 @@ typecheck: ## `pnpm -r typecheck` (tsc --noEmit across the monorepo). # ─── Seed ───────────────────────────────────────────────────────────────── .PHONY: seed -seed: dev ## Bring the stack up and seed demo data (idempotent). +seed: dev migrate ## Bring the stack up, apply migrations, seed demo data (idempotent). ./scripts/seed.sh # ─── Bootstrap ──────────────────────────────────────────────────────────── 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/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. --> - + 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.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index 180373b7..8ccb3d95 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. 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( + deploymentMode, + typeof(LearnStack.Modules.Tenancy.Application.AssemblyMarker).Assembly); builder.Services.AddLearnStackTenancyEdge(builder.Configuration); builder.Services.AddLearnStackPersistence(builder.Configuration); builder.Services.AddLearnStackRateLimiting(); @@ -87,11 +94,27 @@ // 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(); + +// 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/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..805b25e3 --- /dev/null +++ b/backend/src/LearnStack.Api/Tenancy/HostClassificationMiddleware.cs @@ -0,0 +1,204 @@ +using LearnStack.Api.Versioning; +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 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. + /// + /// + /// 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 ClassifiedPrefixes.Any( + prefix => path.StartsWithSegments(prefix, 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..e090fc33 --- /dev/null +++ b/backend/src/LearnStack.Api/Tenancy/PlatformHostOptions.cs @@ -0,0 +1,86 @@ +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() + { + // 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 => host is null || EffectiveHost.Normalize(host) != host) + .Select(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); + } + + /// + /// The validated set, as the port a module writes host mappings through. + /// + /// + /// The list is application configuration and a module may not reference the + /// composition root, so the check ADR-0036 assigns to the host-mapping writer needs a + /// port. This is its one implementation; a deployment with no configured hosts gets + /// an empty set, which answers false for everything — correct, not degraded. + /// + internal sealed class Registry(PlatformHostOptions options) : IReservedHostRegistry + { + private readonly HashSet _hosts = options.Validate(); + + public bool IsReserved(string normalizedHost) => _hosts.Contains(normalizedHost); + } +} diff --git a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs index 6d714274..15e76f1e 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs @@ -1,14 +1,25 @@ +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; /// -/// 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"; @@ -145,6 +156,61 @@ 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); + + // The same list, as the port the host-mapping writer checks against. ADR-0036 + // makes that check the writer's job because the list is configuration and the + // mapping is a table: a database constraint cannot see the first, and a module + // cannot reference this file. + services.AddSingleton(); + + // 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 => (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. + 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(); + + // 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/TenantAssertionMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs index 1b9fc782..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. /// @@ -114,16 +115,27 @@ 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)); // 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; } @@ -134,13 +146,30 @@ 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. + // 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 - && organization != tenantContext.OrganizationId) + && (tenantContext.OrganizationId is not { } resolvedOrganization + || !resolvedOrganization.IsInitialized() + || organization != resolvedOrganization.Value)) { return (TenantAssertionDimension.Organization, organization); } diff --git a/backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs new file mode 100644 index 00000000..32cfcf97 --- /dev/null +++ b/backend/src/LearnStack.Api/Tenancy/TenantResolverMiddleware.cs @@ -0,0 +1,214 @@ +using System.Diagnostics; +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-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 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 +/// 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. + // + // 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, + // 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) + { + attempt = attempt with + { + MembershipCovers = await memberships.CoversAsync( + attempt.UserId!.Value, + attempt.ClaimTenantId!.Value, + attempt.MembershipQuestionOrganizationId, + 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.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.Application/LearnStack.Application.csproj b/backend/src/LearnStack.Application/LearnStack.Application.csproj index 10a1017a..1f24b4e0 100644 --- a/backend/src/LearnStack.Application/LearnStack.Application.csproj +++ b/backend/src/LearnStack.Application/LearnStack.Application.csproj @@ -20,6 +20,12 @@ + + diff --git a/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs b/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs index efeaa169..a134100d 100644 --- a/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs @@ -85,8 +85,21 @@ 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. 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 + : 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.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs index f40e6a95..8198a47c 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,22 @@ 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. + // + // 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/TenantContextBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs index b94029de..295f0445 100644 --- a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs @@ -8,25 +8,46 @@ 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 /// — 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 step 5 added TenantResolverMiddleware, which is what now +/// 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 -/// 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. +/// +/// 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) @@ -37,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, @@ -44,14 +80,34 @@ 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)); } - // 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. + // 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: + // 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 @@ -65,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.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index 03ac8991..fdda4596 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -89,12 +89,52 @@ 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 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 + // 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. + // + // 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( + provisioning.ProvisioningTenantId, cancellationToken); + } + else + { + await unitOfWork.SetTenantContextAsync(tenantContext, cancellationToken); + } var response = await next(); 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.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..f977554b 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs @@ -35,13 +35,22 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) if (context.IsResolved) { - logEvent.AddOrUpdateProperty( - propertyFactory.CreateProperty("tenant.id", context.TenantId.ToString())); + // Value.ToString() — see TenantContextSpanProcessor for why the id's + // own ToString() is not a wire format. + // 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) + 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/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.Infrastructure.Observability/TenantContextSpanProcessor.cs b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs index ae5fc013..795fd2ee 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs @@ -44,10 +44,25 @@ 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. + // 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("organization.id", orgId.ToString()); + data.SetTag("tenant.id", context.TenantId.Value.ToString()); + } + + if (context.OrganizationId is { } orgId && orgId.IsInitialized()) + { + data.SetTag("organization.id", orgId.Value.ToString()); } // IsInitialized() before Value: UserId? being non-null says a diff --git a/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs b/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs index 8945d03a..8b851825 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); @@ -102,6 +103,20 @@ public Task TryClaimAsync( // protect. Refusing a NEW key costs the caller a retry and costs the // guarantee nothing. An existing key is always served, so a client // holding one is never locked out by another's flood. + // KNOWN GAP — the census this reads is refreshed at most once per SweepInterval, + // so admission decides on a snapshot up to a second old. Within that window the + // nominal caps do not bound anything: every new key is admitted because the count + // they are compared against has not moved. What limits the damage is throughput + // and the anonymous rate limiter, not this check, so the caps are a soft ceiling + // rather than the hard one their names suggest. + // + // Closing it means live counters — incremented on insert, decremented on every + // removal path including the sweep, the abandon and the completion expiry — which + // is a change to a concurrent structure where getting the decrements wrong is + // worse than the current softness. It belongs with the durable store, whose + // trigger [ADR-0037 Amendment 1](../../../../docs/decisions/0037-idempotency-key-contract.md) + // sets: once claims live in Postgres the in-memory ceiling stops being the thing + // that bounds a production host at all. if (!_entries.ContainsKey(composite) && _census.IsFull(tenantId)) { return Result(new IdempotencyClaimResult( @@ -141,7 +156,7 @@ public Task TryClaimAsync( } public Task CompleteAsync( - Guid tenantId, + TenantId tenantId, string key, Guid token, IdempotentResponse? response, @@ -176,7 +191,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 +201,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 +259,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 +295,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.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs new file mode 100644 index 00000000..7c6ef2eb --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs @@ -0,0 +1,298 @@ +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, IHostResolutionInvalidator +{ + 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. + // + // 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; + + try + { + key = CacheKey.ForHostMapping(host); + } + catch (ArgumentException) + { + return null; + } + + if (await _cache.GetAsync(key, cancellationToken) is { } cached) + { + return cached; + } + + if (_unknownHosts.Contains(host)) + { + return null; + } + + return await ReadCoalescedAsync(host, key, cancellationToken); + } + + /// + /// 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; 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. + /// + /// 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) + { + var flight = _flights.GetOrAdd( + host, + 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, 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 + // 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 + { + 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 + { + _flights.TryRemove(host, out _); + } + } + + /// + /// + /// 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. + // 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); + + // 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 + // 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/OrganizationScopeValidator.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs new file mode 100644 index 00000000..f721fc35 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs @@ -0,0 +1,131 @@ +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); + + // 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 + // 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.Infrastructure/MultiTenancy/PlatformAdminScope.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs new file mode 100644 index 00000000..f7b3e301 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs @@ -0,0 +1,274 @@ +using System.Data.Common; +using System.Runtime.CompilerServices; +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)); + + /// + /// + /// 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, + [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. + 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, _logger); + } + 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, ILogger logger) + : IPlatformAdminScopeHandle + { + private bool _resolved; + private bool _disposed; + + public DbConnection Connection + { + get + { + EnsureUsable(); + return connection; + } + } + + public DbTransaction Transaction + { + get + { + EnsureUsable(); + return transaction; + } + } + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + 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); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + // 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(); + } + } + + /// + /// 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.Infrastructure/MultiTenancy/UnknownHostCache.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs new file mode 100644 index 00000000..67f20a81 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/UnknownHostCache.cs @@ -0,0 +1,177 @@ +using System.Collections.Concurrent; +using LearnStack.SharedKernel.Tenancy; +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 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. 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. +/// +/// +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. + /// + /// + /// Called by the host-mapping writerMapHostToTenantCommandHandler, + /// through , as of Packet 7. Until that writer + /// existed the TTL was the whole mechanism and a host activated inside it kept its 404 + /// for the rest of the window; ADR-0036 asks for the window to be closed on the + /// transaction that flips either flag instead. 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. + /// + public void Forget(string host) => _seen.TryRemove(host, out _); + + + private void Trim() + { + 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; + } + + // 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); + } + } +} + +/// +/// The cap and the lifetime of a negative answer. +/// +/// +/// 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 +{ + /// 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.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs index 06f8a73b..7e3140be 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -135,12 +135,31 @@ 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; - 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 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); context.Database.UseTransaction(unitOfWork.Transaction); return context; diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index b94d7ce2..92dcf5da 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -1,5 +1,7 @@ using System.Data.Common; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; +using Microsoft.Extensions.Logging; using LearnStack.SharedKernel.Tenancy; using Npgsql; @@ -38,11 +40,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; @@ -67,6 +73,8 @@ public sealed class NpgsqlUnitOfWork(NpgsqlDataSource dataSource) : IUnitOfWork private bool _rollbackOnly; private bool _commitRequested; + + private bool _tenantContextIssued; private bool _disposed; public DbConnection Connection @@ -125,6 +133,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); @@ -157,17 +172,139 @@ 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. + // 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.ToString() : string.Empty), - ("organization", - context.IsResolved && context.OrganizationId is { } organization - ? organization.ToString() - : string.Empty)); + ("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 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 + && ReferenceEquals(transaction, _transaction) + && _tenantContextIssued; + + 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/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.Infrastructure/Persistence/TenantQueryFilters.cs b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs new file mode 100644 index 00000000..78ca026f --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs @@ -0,0 +1,293 @@ +using System.Linq.Expressions; +using System.Reflection; +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. +/// +/// 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. 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 +{ + /// + /// 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, 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 }) + { + // 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)) + { + // 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; + } + + modelBuilder.Entity(clrType).HasQueryFilter(BuildFilter(clrType, context)); + } + + return modelBuilder; + } + + /// + /// 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, 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"); + 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, 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 => + Ambient is { IsResolved: true } context && context.TenantId.IsInitialized() + ? context.TenantId + : NoTenant; + + /// + public OrganizationId? CurrentOrganizationId => + Ambient is { IsResolved: true } context + && context.OrganizationId is { } organization + && organization.IsInitialized() + ? 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); + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyTenantQueryFilters(this); + } +} diff --git a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs index 1c74fb52..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. Packet 7 wires the filters - /// accordingly. + /// 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/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/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/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/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/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 new file mode 100644 index 00000000..ad2b0649 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Persistence/IAggregateWriteStore.cs @@ -0,0 +1,61 @@ +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 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/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index 03b32110..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; @@ -97,6 +98,65 @@ 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); + + /// + /// 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/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/LearnStack.SharedKernel/Tenancy/DenyAllTenantMembershipReader.cs b/backend/src/LearnStack.SharedKernel/Tenancy/DenyAllTenantMembershipReader.cs new file mode 100644 index 00000000..9ad54ee1 --- /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, + CancellationToken cancellationToken = default) => + Task.FromResult(false); +} 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/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs index f692ff86..23917d72 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) @@ -35,8 +35,18 @@ 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 Guid TenantId { get; } + public TenantId TenantId { get; } /// /// The organization the fact belongs to, when the envelope names one. @@ -54,7 +64,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 +107,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/IHostResolutionInvalidator.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IHostResolutionInvalidator.cs new file mode 100644 index 00000000..7b94db8d --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IHostResolutionInvalidator.cs @@ -0,0 +1,45 @@ +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Closes the window in which a host that just became resolvable still answers 404. +/// +/// +/// +/// The invalidation +/// ADR-0036 +/// asks for "on the transaction that flips either flag". A host that resolved to +/// 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. +/// +/// +/// A port because the cache is infrastructure and the writer is a module. Same +/// reason as ; the default implementation forgets +/// nothing, which is correct for a host that has no cache in front of it. +/// +/// +public interface IHostResolutionInvalidator +{ + /// Forgets any cached answer for . + /// + /// 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. +public sealed class NullHostResolutionInvalidator : IHostResolutionInvalidator +{ + public static NullHostResolutionInvalidator Instance { get; } = new(); + + public Task InvalidateAsync( + string normalizedHost, CancellationToken cancellationToken = default) => + // Nothing is cached, so nothing is stale. + Task.CompletedTask; +} 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/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/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..768f04ba --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs @@ -0,0 +1,119 @@ +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, 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 +/// 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, 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/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/src/LearnStack.SharedKernel/Tenancy/IReservedHostRegistry.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IReservedHostRegistry.cs new file mode 100644 index 00000000..593b1303 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IReservedHostRegistry.cs @@ -0,0 +1,52 @@ +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// The hosts a deployment has reserved for itself, which no tenant may map. +/// +/// +/// +/// The check ADR-0036 +/// assigns to whichever packet builds the host-mapping writer. A host on +/// Tenancy:PlatformHosts classifies PlatformHost before the resolver is +/// called at all, so a platform_host_to_tenant row naming the same host is inert — +/// "never read, never logged, never counted". The precedence is right: the list is the +/// operator's own entry point and a tenant must not be able to take it over. What is +/// wrong is that the losing row is silent, so the deployment that created one gets +/// no signal until someone wonders why a mapping does nothing. +/// +/// +/// A port because the two live in different places. The list is application +/// configuration bound in LearnStack.Api; the row is a table written from a module. +/// A module may not reference the composition root, and a database constraint cannot see +/// configuration — which is exactly why ADR-0036 says there is no startup cross-check and +/// no constraint, and makes it the writer's job instead. +/// +/// +public interface IReservedHostRegistry +{ + /// + /// true when is a deployment host. + /// + /// + /// The argument is compared ordinally against entries + /// produced, so a caller passes a normalized + /// host or gets a false negative — which is the answer that lets the silent row + /// through. + /// + bool IsReserved(string normalizedHost); +} + +/// +/// The registry for a deployment that reserves nothing. +/// +/// +/// Not a convenience: a host with no configured platform hosts is an ordinary deployment +/// — every host is then a tenant host or an unknown one — and the seeder and tests need +/// an answer without binding configuration they do not have. +/// +public sealed class NoReservedHosts : IReservedHostRegistry +{ + public static NoReservedHosts Instance { get; } = new(); + + public bool IsReserved(string normalizedHost) => false; +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs index 90def4da..9b2ccfd8 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), @@ -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; } @@ -32,14 +36,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 @@ -55,6 +71,22 @@ 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. 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; + /// /// W3C traceparent string ("00-<trace>-<span>-<flags>") /// that threads through HTTP / outbox / Hangfire / Hub envelopes. The 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/LearnStack.SharedKernel/Tenancy/ITenantMembershipReader.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantMembershipReader.cs new file mode 100644 index 00000000..b8cc9172 --- /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, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs b/backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs new file mode 100644 index 00000000..22e98c8e --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/RequestSurfaceMarkers.cs @@ -0,0 +1,69 @@ +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 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)] +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/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/LearnStack.SharedKernel/Tenancy/TenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContext.cs new file mode 100644 index 00000000..363371a1 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContext.cs @@ -0,0 +1,75 @@ +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 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 +/// 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. 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 new file mode 100644 index 00000000..41e56015 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantContextFactory.cs @@ -0,0 +1,173 @@ +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 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 +/// 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 — 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")); + + /// + /// 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, 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); + } + + // 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); + } + + // 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) + { + 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. + // 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, + 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..743bed25 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/TenantResolutionAttempt.cs @@ -0,0 +1,194 @@ +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 — 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 +/// 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)); + + /// + /// 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 => + !HasIncoherentClaims + && 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 => + !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 a7ac8049..23edaefc 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs @@ -11,10 +11,14 @@ 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 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 +/// wires its own, still arrives here by default. /// public sealed class UnresolvedTenantContext : ITenantContext { @@ -22,10 +26,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/src/LearnStack.Tools.Seeder/LearnStack.Tools.Seeder.csproj b/backend/src/LearnStack.Tools.Seeder/LearnStack.Tools.Seeder.csproj new file mode 100644 index 00000000..9c47c31c --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/LearnStack.Tools.Seeder.csproj @@ -0,0 +1,31 @@ + + + + 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..ddaf766a --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/Program.cs @@ -0,0 +1,49 @@ +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tools.Seeder; +using Microsoft.Extensions.Logging; +using Npgsql; + +// 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. + +// 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: 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; +} + +// 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 runner = new SeedRunner( + context => SeedComposition.Build(dataSource, context, loggerFactory), + loggerFactory.CreateLogger()); + +try +{ + return await runner.RunAsync(CancellationToken.None); +} +catch (Exception failure) +{ + // 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..9b4c1ae2 --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -0,0 +1,95 @@ +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 +{ + /// + /// 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, + IReservedHostRegistry? reservedHosts = null) + { + 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(); + + // 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); + + 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 new file mode 100644 index 00000000..ec79d70c --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/SeedData.cs @@ -0,0 +1,90 @@ +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 → 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 +{ + 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: false); + + 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: true); + + 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/SeedRunner.cs b/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs new file mode 100644 index 00000000..bfa65f84 --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs @@ -0,0 +1,341 @@ +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; + +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. +/// +/// +/// 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) +{ + /// 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 tenants ?? 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), + SecondOrganizationAct, + cancellationToken); + + await SendAsync( + tenant, + asTenant, + new MapHostToTenantCommand( + tenant.Host, + tenant.MapHostToDefaultOrganization + ? tenant.DefaultOrganization.OrganizationId + : null, + IsActive: true, + IsPubliclyLive: true), + HostMappingAct, + 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, 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; + } + + 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."); + } + + /// 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. + /// + /// + /// 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. +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/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/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..c821a0b4 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/MapHostToTenantCommand.cs @@ -0,0 +1,53 @@ +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. +/// +/// 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 IsActive, + bool IsPubliclyLive); 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..84396367 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs @@ -0,0 +1,86 @@ +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, 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 +/// 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. +/// +/// +/// 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( + TenantId TenantId, + string Slug, + OrganizationId 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..8eff963b --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs @@ -0,0 +1,34 @@ +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; + +/// 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..91875a87 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/CreateOrganizationCommandHandler.cs @@ -0,0 +1,106 @@ +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) + { + // `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_mismatch"))); + } + + 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..8628b4ab --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/MapHostToTenantCommandHandler.cs @@ -0,0 +1,156 @@ +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, + IOrganizationScopeValidator organizations, + IReservedHostRegistry reservedHosts, + IHostResolutionInvalidator resolutionCache, + ITenantContext tenantContext) + : IRequestHandler> +{ + public async Task> Handle( + MapHostToTenantCommand request, CancellationToken cancellationToken) + { + 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_business_rule_violation"), + new Dictionary>(StringComparer.Ordinal) + { + [nameof(MapHostToTenantCommand.OrganizationId)] = + [new LocalizedMessage("lockey_organization_not_in_tenant")], + })); + } + + var mapping = PlatformHostMapping.Create( + request.Host, + tenantContext.TenantId, + request.OrganizationId, + 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); + } + 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")], + })); + } + + // The negative cache remembers hosts that resolved to nothing, and this host just + // 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. + // + // 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. + await resolutionCache.InvalidateAsync(mapping.Host, cancellationToken); + + return Result.Ok(new HostMappingDto( + 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/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..342d40f4 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs @@ -0,0 +1,171 @@ +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.Errors; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; +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. +/// +/// +/// 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 +/// 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; + + try + { + return await ProvisionAsync(request, actor, cancellationToken); + } + catch (AggregateConflictException conflict) + { + // 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("lockey_business_rule_violation"), + new Dictionary>(StringComparer.Ordinal) + { + [field] = [new LocalizedMessage(reason)], + })); + } + } + + /// 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); + + // 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( + 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, + tenant.Slug, + request.DefaultOrganizationId, + organization.Slug)); + } + + /// + /// Which field collided, and why, as an RFC 7807 errors entry. + /// + /// + /// + /// 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 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/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..2727d460 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandValidator.cs @@ -0,0 +1,99 @@ +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. +/// +/// +/// 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. +/// +/// +internal sealed class ProvisionTenantCommandValidator : AbstractValidator +{ + public ProvisionTenantCommandValidator() + { + // Before anything reads .Value. The cross-field rule below compares both ids, and + // reading Value on an uninitialized Vogen id raises from inside the id type — so + // without these a client's malformed id is a 500 raised inside the validator, not + // a refusal. + RuleFor(command => 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 + // 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. + RuleForSlug(command => command.Slug); + RuleForSlug(command => command.DefaultOrganizationSlug); + + 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. + // + // 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.IsInitialized() + || !organizationId.IsInitialized() + || command.TenantId.Value != organizationId.Value) + .WithErrorCode("lockey_tenant_and_organization_share_an_id"); + } + + 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.Application/Tenant/TenancyCommandValidators.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenancyCommandValidators.cs new file mode 100644 index 00000000..486ab4bc --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenancyCommandValidators.cs @@ -0,0 +1,110 @@ +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; + +/// +/// 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() + { + 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) + .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() + { + // Optional by contract — a tenant-wide host carries none — but an id that IS + // supplied must be real, or PlatformHostMapping.Create throws where a refusal + // belongs. + RuleFor(command => command.OrganizationId).MustBeAssignedWhenPresent(); + + 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)); + } +} + +/// +/// 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/CompositeKeyedEntities.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs index 8490e659..f3898f91 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!; @@ -52,14 +54,26 @@ public sealed class TenantLocale /// 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); 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 +105,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() { @@ -110,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); @@ -124,7 +139,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 @@ -137,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); @@ -157,8 +172,18 @@ public 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) @@ -212,7 +237,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) { @@ -234,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 66896c97..fbae3c94 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)); @@ -154,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/PlatformProjections.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs index 50396c61..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,6 @@ using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Persistence; namespace LearnStack.Modules.Tenancy.Domain; @@ -33,7 +35,8 @@ namespace LearnStack.Modules.Tenancy.Domain; /// ships RefreshAsync for it to guard. /// /// -public sealed class PlatformEntitlement +[TenantOwned] +public sealed class PlatformEntitlement : ITenantOwned { private PlatformEntitlement() { @@ -102,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.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index fb9e955f..27d687d0 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) @@ -93,11 +95,11 @@ 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)); - TenantOwned.EnsureRealTenant( + TenantOwnership.EnsureRealTenant( id, "A tenant id is assigned by the registry that owns the tenant, never minted here.", nameof(id)); @@ -146,6 +148,204 @@ 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."); + } + + // 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); + + // 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 && isEnabled)) + { + 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); + + var existing = _featureFlags.FirstOrDefault( + flag => string.Equals(flag.Key, key, StringComparison.Ordinal)); + + // 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(created); + 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) + { + // 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(); + } + + 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 df14e3b1..2bfe5bc4 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,9 @@ 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, IAggregateRoot { private TenantDomain(TenantDomainId id) : base(id) => Host = null!; @@ -202,7 +205,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..6fc33683 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,10 @@ namespace LearnStack.Modules.Tenancy.Domain; /// tenant-wide — a scope, not "unknown". /// /// -public sealed class TenantSetting : AuditableEntity +[TenantOwned] +[OrganizationScoped] +public sealed class TenantSetting + : AuditableEntity, IOrganizationScoped, IAggregateRoot { 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/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/20260903213832_tenant_settings_org_write_guard.Designer.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903213832_tenant_settings_org_write_guard.Designer.cs new file mode 100644 index 00000000..bf7fe508 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903213832_tenant_settings_org_write_guard.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("20260903213832_tenant_settings_org_write_guard")] + partial class tenant_settings_org_write_guard + { + /// + 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/20260903213832_tenant_settings_org_write_guard.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903213832_tenant_settings_org_write_guard.cs new file mode 100644 index 00000000..f8b7d265 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260903213832_tenant_settings_org_write_guard.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence.Migrations +{ + /// + /// Narrows the two AS RESTRICTIVE write guards on tenant_settings so an + /// organization-scoped session cannot write a tenant-wide row. + /// + /// + /// + /// Policy-only: no table, column, index or data changes, which is why the model + /// snapshot is unchanged and both methods are raw SQL. + /// + /// + /// What was wrong. Each guard's first arm was a bare + /// organization_id IS NULL. It exists so a tenant-scope session — one with no + /// app.organization_id — can write the rows that belong to no organization. It + /// also admitted an ORGANIZATION-scoped session to those same rows, so one + /// organization could rewrite the tenant-wide fallback every other organization reads. + /// Measured on the shipped schema: a session announcing tenant A and organization A1 + /// updated tenant A's organization_id IS NULL row without refusal. + /// + /// + /// Intra-tenant rather than cross-tenant — the tenant term is untouched, and no row + /// crosses a tenant boundary — so this is a write-scope correction, not an isolation + /// fix. See ADR-0003 Amendment 4 and Database Standards § Tenant-Owned and + /// Organization-Scoped Tables, which carries the corrected template. + /// + /// + /// Reversible. Down restores the previous predicates exactly. Applying + /// either direction is a policy replacement and rewrites no rows. + /// + /// + public partial class tenant_settings_org_write_guard : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // DROP then CREATE rather than ALTER: PostgreSQL has no ALTER POLICY that + // replaces a USING clause without restating it, and restating it under ALTER + // reads as a smaller change than it is. + migrationBuilder.Sql(""" + DROP POLICY tenant_settings_org_write_guard ON tenant_settings; + DROP POLICY tenant_settings_org_delete_guard ON tenant_settings; + + CREATE POLICY tenant_settings_org_write_guard ON tenant_settings + AS RESTRICTIVE FOR UPDATE + USING ( + (organization_id IS NULL + AND NULLIF(current_setting('app.organization_id', true), '') IS NULL) + OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid + ); + + CREATE POLICY tenant_settings_org_delete_guard ON tenant_settings + AS RESTRICTIVE FOR DELETE + USING ( + (organization_id IS NULL + AND NULLIF(current_setting('app.organization_id', true), '') IS NULL) + OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid + ); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DROP POLICY tenant_settings_org_write_guard ON tenant_settings; + DROP POLICY tenant_settings_org_delete_guard ON tenant_settings; + + CREATE POLICY tenant_settings_org_write_guard ON tenant_settings + AS RESTRICTIVE FOR UPDATE + USING ( + organization_id IS NULL + OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid + ); + + CREATE POLICY tenant_settings_org_delete_guard ON tenant_settings + AS RESTRICTIVE FOR DELETE + USING ( + organization_id IS NULL + OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid + ); + """); + } + } +} 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 cfec26b3..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 @@ -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,26 @@ 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 request-scoped -/// 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. 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 +/// 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, ITenantContextAccessor accessor) + : TenantScopedDbContext(options, accessor) { public DbSet Tenants => Set(); @@ -40,11 +50,9 @@ public sealed class TenancyDbContext(DbContextOptions options) public DbSet TenantDomains => Set(); - public DbSet TenantLocales => Set(); public DbSet TenantSettings => Set(); - public DbSet TenantFeatureFlags => Set(); public DbSet PlatformEntitlements => Set(); @@ -56,6 +64,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..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 @@ -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, StaticTenantContextAccessor.Unresolved); } } 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..8755faea --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs @@ -0,0 +1,235 @@ +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; + +/// +/// 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 Task AddAsync(Tenant aggregate, CancellationToken cancellationToken = default) + { + db.Tenants.Add(aggregate); + return SaveTranslatingConflictsAsync(db, cancellationToken); + } + + public async Task UpdateAsync(Tenant aggregate, CancellationToken cancellationToken = default) + { + EnsureTracked(db, aggregate); + await SaveDefaultLocaleInTwoPassesAsync(db, cancellationToken); + } +} + +/// The Organization aggregate's writes. +/// +/// 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 Task AddAsync(Organization aggregate, CancellationToken cancellationToken = default) + { + db.Organizations.Add(aggregate); + return SaveTranslatingConflictsAsync(db, cancellationToken); + } + + public Task UpdateAsync( + Organization aggregate, CancellationToken cancellationToken = default) + { + EnsureTracked(db, aggregate); + return SaveTranslatingConflictsAsync(db, cancellationToken); + } +} + +/// 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 +{ + /// + /// 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. + /// + /// + /// 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) + { + // 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); + } + } + + internal static void EnsureTracked(TenancyDbContext db, T aggregate) + where T : class + { + 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..543d722f --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs @@ -0,0 +1,166 @@ +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"); + } + + [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. + /// + /// + /// + /// 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/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 76831b02..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() { @@ -270,11 +302,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 +328,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; } @@ -516,17 +582,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. @@ -618,4 +691,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.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/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/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 2ba42f7c..502aa3d4 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; @@ -144,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 @@ -161,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", ]); } @@ -483,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); } @@ -533,7 +555,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. + StaticTenantContextAccessor.Unresolved); } diff --git a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs new file mode 100644 index 00000000..0a89dd10 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs @@ -0,0 +1,175 @@ +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 +{ + /// + /// 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 = + "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 — 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 + // 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 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 + // narrowed sweep does not fail but 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. + // 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( + [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.Architecture/RequestSurfaceTests.cs b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs new file mode 100644 index 00000000..6794a721 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/RequestSurfaceTests.cs @@ -0,0 +1,330 @@ +using System.Reflection; +using FluentAssertions; +using LearnStack.SharedKernel.Persistence; +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. +/// +/// +/// 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 +{ + [Fact] + public void AllowsUnresolvedTenantContext_Only_On_Provisioning_Commands() + { + // 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() + .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"); + } + + [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_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() + { + // 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. + /// + /// + /// 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 = ["ProvisionTenantCommand"]; + + /// + /// 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 every production assembly. + /// + /// + /// + /// 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()) + { + var assembly = Assembly.Load(name); + + types.AddRange(assembly.GetTypes() + .Where(type => type is { IsAbstract: false, IsInterface: false }) + .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) + { + var usage = attribute.GetCustomAttribute(); + usage.Should().NotBeNull($"{attribute.Name} must declare its usage explicitly"); + + return (usage!.ValidOn, usage.Inherited, usage.AllowMultiple); + } + + /// 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.Architecture/SourceScan.cs b/backend/tests/LearnStack.Tests.Architecture/SourceScan.cs new file mode 100644 index 00000000..4d5134f5 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/SourceScan.cs @@ -0,0 +1,97 @@ +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. + /// + /// + /// 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(); + + 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 (Contains(code, needle, notFollowedBy)) + { + found.Add(relative); + } + } + + 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/TenancyConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs index 9db20431..d0f034eb 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. @@ -37,14 +37,77 @@ 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)"); } + [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() { @@ -193,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) @@ -222,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))); @@ -236,4 +312,46 @@ 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'"; + 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)) + // 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)) + .ToList(); + + offenders.Should().BeEmpty( + "CachedHostToTenantResolver is the sole setter of app.resolving_host " + + "(Security Standards § Tenant Context)"); + } } diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs new file mode 100644 index 00000000..cc1858d2 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.cs @@ -0,0 +1,205 @@ +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() + { + // 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, + // 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). 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. + // 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, notFollowedBy: '='); + + writers.Should().BeEquivalentTo( + [ + "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] + 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"); + + // 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, + // 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.Architecture/TenantScopingTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs new file mode 100644 index 00000000..5c577441 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs @@ -0,0 +1,351 @@ +using System.Linq.Expressions; +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. + var key = attribute.SelfKeyed ? "Id" : nameof(ITenantOwned.TenantId); + + 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); + } + } + + [Fact] + public void Every_OrgScoped_Entity_HasOrgIdAndFilter() + { + // 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( + "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"); + + // 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"); + members.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 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() + { + // 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(); + RowMembersRead(context.Model.FindEntityType(typeof(PlatformHostMapping))!) + .Should().BeEmpty("a tenant filter here would make host resolution impossible"); + } + + /// + /// 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 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 HashSet RowMembersRead(IReadOnlyEntityType entityType) + { + var members = new HashSet(StringComparer.Ordinal); + + foreach (var filter in entityType.GetDeclaredQueryFilters()) + { + if (filter.Expression is not { } lambda || lambda.Parameters.Count == 0) + { + continue; + } + + 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() => + 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, + StaticTenantContextAccessor.Unresolved); + + /// + /// 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/AuthorityCeilingHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs new file mode 100644 index 00000000..dd7a41cd --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs @@ -0,0 +1,260 @@ +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. +/// +/// +[Collection(HostClassificationMeter.Name)] +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"); + } + + [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, + HttpMethod? method = null) + { + using var request = new HttpRequestMessage( + method ?? 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\":\"\""); +} + +/// +/// 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 +{ + 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 2e56299b..2e422e9b 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); @@ -278,6 +279,16 @@ 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 SetProvisioningTenantContextAsync( + LearnStack.SharedKernel.Identifiers.TenantId tenantId, + CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task CommitAsync(CancellationToken cancellationToken = default) { HasActiveTransaction = false; @@ -316,11 +327,20 @@ 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"; + + // 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.Integration/Database/HostResolutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs new file mode 100644 index 00000000..d80683b9 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/HostResolutionTests.cs @@ -0,0 +1,553 @@ +using System.Diagnostics.Metrics; +using System.Globalization; +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 static readonly DateTimeOffset Origin = + new(2026, 9, 2, 9, 0, 0, TimeSpan.Zero); + + 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 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 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() + { + 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"); + } + + [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_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. + // + // 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 publishes = new PublishCountingCache(NewCache()); + var resolver = BuildResolver(dataSource, publishes); + + 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)) + { + await takeLock.ExecuteNonQueryAsync(); + } + + 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(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( + "with the database gone, only a published answer can serve this"); + } + + [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 () => + { + 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; + } + + 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 InMemoryCacheService NewCache() => + new(new FixedClock(Origin), MeterServices.GetRequiredService()); + + private static CachedHostToTenantResolver BuildResolver( + NpgsqlDataSource dataSource, ICacheService? cache = null) + { + var clock = new FixedClock(Origin); + + return new CachedHostToTenantResolver( + 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. + /// + /// + /// 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/Database/MigrationRollbackTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs index 68929f1e..ad2939b2 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, + StaticTenantContextAccessor.Unresolved); private PlatformDbContext CreatePlatform() => new(new DbContextOptionsBuilder() 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..53631a00 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/OrganizationScopeValidatorTests.cs @@ -0,0 +1,240 @@ +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 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() + { + // 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/Database/PlatformAdminScopeTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs new file mode 100644 index 00000000..46f0a978 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs @@ -0,0 +1,574 @@ +using FluentAssertions; +using LearnStack.Infrastructure.MultiTenancy; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.Logging; +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 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 +/// 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. + // 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 + { + 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 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); + } + + [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() + { + // 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 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() + { + // 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. + 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. + // + // 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*") + .And.Message.Should().Contain("is not configured").And.Contain(".env.example"); + } + + /// + /// 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..c47f0bae 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -104,6 +104,7 @@ public sealed class PostgresFixture : IAsyncLifetime /// BYPASSRLS; only the outbox dispatcher's equivalent. public string OutboxConnectionString => For("learnstack_outbox_admin", OutboxPassword); + public async Task InitializeAsync() { await _container.StartAsync(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs index 68ad6b66..8c0e2e09 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, + StaticTenantContextAccessor.Unresolved)) { await tenancy.Database.MigrateAsync(); } 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..822751cc --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs @@ -0,0 +1,405 @@ +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; +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() + { + 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", tenant.TenantId.Value)) + .Should().Be(1L, "each seed tenant is provisioned once"); + + (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(""" + SELECT count(*) FROM tenants + WHERE id = @tenant AND default_organization_id IS NOT NULL + """, "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", 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 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.Yoga.Host)) + .Should().Be(SeedData.Yoga.DefaultOrganization.OrganizationId.Value.ToString(), + "one seed host resolves to an organization"); + + // 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) + { + (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 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)", "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)", "ids", SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) + .Should().Be(4L); + } + + [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); + + 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"); + } + + [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"); + } + + [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 ────────────────────────────────────────────────────────────── + + /// + /// The seeder, composed exactly as Program.cs composes it. + /// + /// + /// 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 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() + { + 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(); + } + } + + /// 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(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())!; + } + + 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; + } + + + /// 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); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs index db3e2ce7..7850e135 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs @@ -418,14 +418,18 @@ await SchemaQueries.SetSettingAsync(connection, transaction, public async Task OrganizationIdIsImmutableAfterInsert() { // Tenant-wide to org-scoped: the NULL -> value move, which `<>` would miss - // because `<>` is NULL when either side is null. The restrictive UPDATE - // guard does not cover it either — it admits the row when the NEW - // organization_id is the caller's own, which is exactly this move. + // because `<>` is NULL when either side is null. The trigger is what refuses it, + // and this case exists because no policy does. + // + // Run as a TENANT-scope session — no app.organization_id. Since ADR-0003 + // Amendment 4 the restrictive UPDATE guard refuses an organization-scoped session + // the tenant-wide row outright, so attempting the move from one filters to zero + // rows and the trigger never fires: the case would pass while testing nothing. A + // tenant-scope session is the one that can still reach the row, which makes it + // the one that can still attempt the move. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); - await SchemaQueries.SetSettingAsync(connection, transaction, - "app.organization_id", SchemaFixture.OrgA1.ToString()); await using var command = new NpgsqlCommand( "UPDATE tenant_settings SET organization_id = @org WHERE key = 'tz'", @@ -523,6 +527,96 @@ 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] + public async Task An_Organization_Scoped_Session_Cannot_Write_A_Tenant_Wide_Row() + { + // Database Standards § Tenant-Owned and Organization-Scoped Tables: "a + // tenant-scope reporting query may read across organizations, but NOTHING may + // write outside its organization." A tenant-wide row belongs to no organization, + // so an organization-scoped session writing one is writing outside its own. + // + // Both AS RESTRICTIVE guards used a bare `organization_id IS NULL` first arm, + // which exists so a TENANT-scope session can write those rows — and admitted an + // org-scoped one to them as well. Measured before ADR-0003 Amendment 4: a session + // announcing tenant A and organization A1 rewrote tenant A's tenant-wide row. + // + // The refusal is silent by construction: a RESTRICTIVE USING clause on UPDATE + // FILTERS the rows the statement may target rather than raising, so what this + // asserts is zero rows affected and the value unchanged — not an exception. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.ExecuteAsync(connection, transaction, + "SELECT set_config('app.organization_id', @org, true)", + ("org", SchemaFixture.OrgA1.ToString())); + + await using (var update = new NpgsqlCommand( + """ + UPDATE tenant_settings SET value = '"hijacked"' + WHERE tenant_id = @tenant AND organization_id IS NULL + """, + (NpgsqlConnection)connection, + (NpgsqlTransaction)transaction)) + { + update.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + + (await update.ExecuteNonQueryAsync()).Should().Be(0, + "the tenant-wide row is outside this session's organization, so the " + + "restrictive guard does not let the statement target it"); + } + + // And the row is still there, unchanged — so the zero above is the guard + // filtering rather than the row being absent. + await using (var read = new NpgsqlCommand( + """ + SELECT count(*) FROM tenant_settings + WHERE tenant_id = @tenant AND organization_id IS NULL AND value <> '"hijacked"' + """, + (NpgsqlConnection)connection, + (NpgsqlTransaction)transaction)) + { + read.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + + // Read under the same org-scoped session: the main policy's USING admits a + // tenant-wide row to a reader, which is the asymmetry the standard states — + // read across, write only your own. + (await read.ExecuteScalarAsync()).Should().Be(1L); + } + + await transaction.RollbackAsync(); } [Fact] 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..0f723ad4 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantContextGuardTests.cs @@ -0,0 +1,387 @@ +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; +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"); + + // 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(); + + // 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(); + } + + [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_Raw_Sql_Write_Throws_Too() + { + // 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(); + + 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(); + } + + // 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(); + 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(); + } + + [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) + { + 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.Integration/Database/TenantIsolationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs new file mode 100644 index 00000000..6244ca11 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs @@ -0,0 +1,586 @@ +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. + /// + /// 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 . + /// + /// 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. +/// +/// 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, + 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", UserId.SystemActor.Value); + + 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/TenantLocaleDefaultTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleDefaultTests.cs new file mode 100644 index 00000000..60d612a3 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleDefaultTests.cs @@ -0,0 +1,287 @@ +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; + +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 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() + { + 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))!; + } + + 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"; + } +} 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..f856aef8 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs @@ -0,0 +1,660 @@ +using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Api.Common; +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.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; +using LearnStack.SharedKernel.Time; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +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. + // + // 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 result = await ProvisionAsync(command); + + result.IsFailure.Should().BeTrue(); + 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)) + .Should().Be(0L, + "the tenant insert had already succeeded when the organization failed, " + + "and it must roll back with it"); + } + finally + { + await CleanUpAsync(command); + } + } + + [Theory] + [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 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 + // 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"); + + // 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)) + .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() + { + // 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); + } + } + + [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(); + } + + [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 ────────────────────────────────────────────────────────────── + + /// + /// 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, command.Slug, + command.DefaultOrganizationId, 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 949481e6..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; @@ -10,6 +11,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; @@ -66,11 +68,116 @@ 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(); + + // 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)")) + .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] + [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() { - // 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. @@ -108,6 +215,139 @@ 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 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() + { + // 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() { @@ -120,7 +360,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, """ @@ -141,7 +382,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); @@ -657,22 +899,191 @@ 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"); + } + } + + [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() + 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 + // 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 + // 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(); } + /// + /// 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 + { + 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( @@ -743,20 +1154,77 @@ 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. + /// + /// + /// 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>; /// - /// 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 { + // 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/HostClassificationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs new file mode 100644 index 00000000..93470d95 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs @@ -0,0 +1,421 @@ +using System.Diagnostics.Metrics; +using System.Net; +using System.Net.Http.Json; +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. +/// +/// +[Collection(HostClassificationMeter.Name)] +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); + 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"); + } + + [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 async Task ReadProbeAsync(HttpResponseMessage response) => + (await response.Content.ReadFromJsonAsync())!; + + 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); + 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] + 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); + 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] + [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 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 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"); + } + + [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() + { + // /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"; + + /// 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"); + + public StubResolver Resolver { get; } = new(); + + 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 => + 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), + + // 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, + }); + } + } +} + +/// +/// 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(ITenantContext tenantContext) + : ApiControllerBase, ITestOnlyController +{ + [HttpGet] + public IActionResult Get() => + Ok(new HostProbe( + HttpContext.Features.Get()?.Class.ToString() ?? "none", + tenantContext.IsResolved, + tenantContext.IsResolved ? tenantContext.TenantId.Value : null, + tenantContext.OrganizationId?.Value, + tenantContext.Origin?.ToString(), + tenantContext.CorrelationId)); +} + +/// 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, + string? CorrelationId); 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/LearnStack.Tests.Integration.csproj b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj index 80cf60cc..8d917265 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 @@ + + - - 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` (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` 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 @@ -293,26 +341,39 @@ 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 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 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. 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/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 6a7c1e37..48d723ca 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -3,9 +3,30 @@ 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. +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)) +; 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 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 +`AuditConfig` may then narrow SHOULD/MAY at runtime. Neither touches a baseline +MUST. | Resource | Operation | Class | Why | |---|---|---|---| @@ -15,13 +36,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 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. -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. +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..ed17422b 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -3,14 +3,45 @@ 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 +**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). +[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. + +**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. 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 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 | |----------|:----:|:-----:|:------:|:-----:|---------------------| | `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 22fbbf62..ad8a1ff7 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 @@ -419,13 +420,14 @@ 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 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 @@ -433,6 +435,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 § 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 +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 +473,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 +502,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 +526,32 @@ 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 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`. **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) +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 @@ -488,8 +561,27 @@ 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 +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` @@ -512,6 +604,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 @@ -611,9 +710,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 @@ -924,11 +1025,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. @@ -990,6 +1094,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,8 +1104,16 @@ 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`. -- `ITenantContext` (request-scoped) exposing `TenantId`, `OrganizationId?`, `UserId?`. +- `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` (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). @@ -1093,11 +1208,14 @@ 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 [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`. @@ -1143,7 +1261,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 @@ -1205,9 +1325,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. @@ -1265,7 +1387,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 | |---|---|---|---| @@ -1277,14 +1399,13 @@ 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 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 @@ -2306,3 +2427,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/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-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/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 fce713b0..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), @@ -48,6 +50,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 +59,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 @@ -93,6 +116,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 +163,18 @@ 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]`. 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 + 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..a09176c8 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. @@ -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. @@ -258,7 +284,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..09b89b82 100644 --- a/docs/standards/04-api-design.md +++ b/docs/standards/04-api-design.md @@ -135,6 +135,83 @@ 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 — 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`. + + **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.** +- **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)) 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 Cursor pagination by default: diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index edc7d19f..0a4dcff2 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -129,18 +129,29 @@ CREATE POLICY courses_isolation ON courses -- selects the rows an UPDATE may target, and for DELETE it is the ONLY gate -- (PostgreSQL has no WITH CHECK for DELETE). Without the restrictive policy -- below, a tenant-scope session could delete another organization's rows, or --- reassign them to itself. +-- reassign them to itself. Each guard's first arm is AND-ed with "the session has no +-- organization", because the bare `organization_id IS NULL` admitted the reverse: an +-- ORG-scoped session rewriting the tenant-wide fallback every other organization reads. +-- Measured on the shipped schema before the correction. CREATE POLICY courses_org_write_guard ON courses AS RESTRICTIVE FOR UPDATE USING ( - organization_id IS NULL + -- A tenant-wide row (organization_id IS NULL) belongs to no organization, so a + -- session that HAS one is writing outside its own when it touches one. The + -- first arm therefore requires the session to be tenant-scope as well. + (organization_id IS NULL + AND NULLIF(current_setting('app.organization_id', true), '') IS NULL) OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid ); CREATE POLICY courses_org_delete_guard ON courses AS RESTRICTIVE FOR DELETE USING ( - organization_id IS NULL + -- A tenant-wide row (organization_id IS NULL) belongs to no organization, so a + -- session that HAS one is writing outside its own when it touches one. The + -- first arm therefore requires the session to be tenant-scope as well. + (organization_id IS NULL + AND NULLIF(current_setting('app.organization_id', true), '') IS NULL) OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid ); @@ -261,8 +272,17 @@ 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, 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 the second argument `true` (missing-OK), and always wrap the result in `NULLIF(…, '')`, @@ -361,6 +381,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` @@ -676,10 +713,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 @@ -705,9 +752,15 @@ 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, **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. @@ -1124,17 +1177,24 @@ 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. **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 +- 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 `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 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 + 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..fc2a1636 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 @@ -174,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 966379f8..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), @@ -57,8 +61,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 +210,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 @@ -224,7 +232,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 +244,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 +261,28 @@ 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, 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 +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,11 +290,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) @@ -280,20 +313,38 @@ 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 -`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)). +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`, 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 +`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 +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/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 c9a1fac7..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. @@ -666,6 +668,41 @@ otherwise). assemblies carry types. - **Phase:** 02a (Packet 6 introduces, Packet 10 closes). +#### `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` + +- **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 **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.** 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 Source: [ADR-0039](../decisions/0039-optimistic-concurrency-token.md), @@ -760,16 +797,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` @@ -815,6 +859,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` @@ -823,13 +884,19 @@ 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`) - 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. @@ -838,19 +905,42 @@ 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 + **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.** +- **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). #### `No_IgnoreQueryFilters_Outside_PlatformAdminScope` @@ -859,9 +949,14 @@ 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. -- **Status:** **Registered.** +- **Type:** xUnit + source scan; the permitted paths are a list inside the scan, not a + call-site marker. **Kind:** structural. +- **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` @@ -875,8 +970,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` @@ -889,13 +992,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` @@ -907,18 +1016,101 @@ 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` + +- **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:** **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, **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. +- **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` @@ -1815,11 +2007,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` @@ -1927,52 +2119,132 @@ 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:** **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` -- **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. 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. -- **Status:** **Registered.** +- **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/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. +- **Status:** **Implemented** (`TenantContextConstructionTests`, Packet 7 step 5). - **Phase:** 02a Packet 7. #### `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. -- **Status:** **Registered.** +- **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:** 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` + 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:** 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. 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. + +#### `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 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.** +- **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 + until then. #### `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. -- **Type:** xUnit + audit-catalogue cross-check. **Kind:** structural. -- **Status:** **Registered.** -- **Phase:** 02a Packet 7. +- **Source:** ADR-0036 § The reconciliation matrix; + [Standards 04 § Public surface](04-api-design.md). +- **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` -- **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.** +- **Type:** xUnit + source scan. **Kind:** structural. +- **Status:** **Implemented** (`TenantContextConstructionTests`, Packet 7 step 5). - **Phase:** 02a Packet 7. #### `Tenant_Scope_Widening_Is_Never_Set_From_Request_Input` @@ -1982,14 +2254,53 @@ 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**, 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 — 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. -- **Source:** ADR-0036 § The reconciliation matrix. +- **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:** **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 + 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, 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` 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. | diff --git a/scripts/seed.sh b/scripts/seed.sh index 7f20ba09..793afd67 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -8,12 +8,16 @@ # the two Keycloak realms are imported (`learnstack` + `learnstack-hub`), # print a session summary with the demo credentials. # -# Phase 02a scope (NOT YET WIRED): provision two application-level demo -# tenants + one platform-admin user via the `LearnStack.Tools.Seeder` -# console project against the real Tenancy module schema. The placeholder -# section at the bottom of this file lists the exact commands Phase 02a -# will swap the deferral notice for — leave it intact so the activation -# is a one-shot find-and-replace. +# Phase 02a Packet 7 scope (wired): provision the two demo tenants through +# `LearnStack.Tools.Seeder`, which sends the same commands a request sends — +# ProvisionTenantCommand, then CreateOrganizationCommand and +# MapHostToTenantCommand under each tenant's own announcement. Idempotent: a +# second run recognises its own first by the uniqueness refusal and exits 0. +# +# There is no platform-admin user to seed. This packet creates no `users` +# table — Phase 03's Identity migration owns it — and `UserId.SystemActor` is +# a CLR constant with no row behind it, deliberately, because the audit +# subsystem depends on an erased actor becoming an orphan surrogate. set -eu -o pipefail @@ -187,36 +191,71 @@ wait_for_realm() { wait_for_realm "$KEYCLOAK_REALM_TENANT" || exit 1 wait_for_realm "$KEYCLOAK_REALM_HUB" || exit 1 -# ─── Step 3: Packet 7 deferral notice ──────────────────────────────────── -cyan "▶ Step 3/3: application-level tenant seeding (deferred to Phase 02a Packet 7)" +# ─── Step 3: application-level tenant seeding ──────────────────────────── +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, +# 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 -cat <<'NOTICE' +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 - Packet 6 shipped the schema: the four database roles, both migration chains - and the Tenant + Organization aggregates. `make migrate` applies them, and - this script does not — it verifies the stack and prints credentials. +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) - What is still missing is a SEEDER: nothing writes the two demo tenants, and - the aggregates alone cannot be reached from a shell. Packet 7 ships the two - seed tenants (docs/roadmap/phase-02a-kernel-tenancy.md), and Phase 02d - renders both of them in a browser. +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 + +# 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 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." + red " Has the schema been applied? → make migrate" + exit 1 +fi - Phase 01 seeding therefore still stops at: +green " ✓ demo-english and demo-yoga present." - - Keycloak realms imported (done at compose boot, verified above) - - Demo users present in each realm (seeded by the realm JSON files) +cat <<'HOSTS' - Packet 7 swaps this section for: + Both tenants resolve by host. Add them to /etc/hosts to reach either in a + browser — Phase 02d is what renders them: - dotnet run --project backend/src/LearnStack.Tools.Seeder -- \ - --tenants demo-platform,demo-vertical \ - --platform-admin demo-admin@learnstack.test \ - --connection-string "$ConnectionStrings__Default" + 127.0.0.1 demo-english.learnstack.local + 127.0.0.1 demo-yoga.learnstack.local - The console project does not exist yet; the path is reserved so that packet - can drop the executable and edit this stub in one PR. + 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. -NOTICE +HOSTS cyan "▶ Demo identities ready" cat <<'IDENTITIES' @@ -232,4 +271,4 @@ cat <<'IDENTITIES' IDENTITIES -green "✓ Seed complete (Phase 01 scope)." +green "✓ Seed complete."