diff --git a/.claude/skills/add-mediatr-handler/SKILL.md b/.claude/skills/add-mediatr-handler/SKILL.md index 9df1b2344..981c62cb1 100644 --- a/.claude/skills/add-mediatr-handler/SKILL.md +++ b/.claude/skills/add-mediatr-handler/SKILL.md @@ -257,32 +257,33 @@ See [add-audit-coverage](../add-audit-coverage/SKILL.md). ### Step 5: Permission policy -> **Not wired yet.** No `IPermissionRegistry`, `registry.Tenant(...)` or `Roles` -> catalogue exists in `backend/src`, `LearnStack.Api` registers no authorization -> middleware, and `AuthorizationBehavior` passes every request through; the catalogue -> lands with the Identity module in -> [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md). Until then this step's -> registration, Step 7's `[Authorize(Policy = …)]` and the `403` check under -> § Validation have nothing to bind to. A module's `permissions.md` is a forward -> declaration, a handler with no HTTP route stands on reachability, as -> [the Tenancy permission matrix](../../../docs/modules/tenancy/permissions.md) -> records, and an anonymous endpoint carries `[AllowAnonymous]` with its reason -> ([Permissions Standards § HTTP endpoints](../../../docs/standards/19-permissions.md#http-endpoints)). - -The endpoint that invokes this handler is guarded by -`[Authorize(Policy = "enrollment.enrollment.write")]`. Register the permission in -the module: - -```csharp -registry.Tenant( - key: "enrollment.enrollment.write", - description: "Create or update enrollments", - defaultGrants: [Roles.TenantAdmin, Roles.OrgAdmin]); -``` - -`Roles.*` references come from the **Built-in Roles** catalogue authoritative -at [19-permissions.md § Built-in Roles](../../../docs/standards/19-permissions.md). -Do not invent role names; pick from that table or extend it via PR. +Declare the exact request through the module's `IOperationPolicySource`, using +the existing scoped `PermissionDefinition` and the admitted `OperationPolicy`. +`OperationPolicyCatalog` freezes those declarations independently of stored Role +grants. Registration grants nothing and seed reruns never adopt new permissions. +There is no `registry.Tenant`, generated `Roles` facade or implicit default grant. + +The canonical `AuthorizationBehavior` delegates to the scoped operation +coordinator at step 5, with its mandatory denial-evidence barrier. The unchanged +eight-step pipeline registers that owner and its frozen catalogue directly; +there is no staged replacement behavior. Unknown application requests default +deny before handler construction. Public, AccountSite and finite seed/event/job +classifications have different ceilings; none grants an implicit general +exception, and a SystemActor UUID supplies no bypass. Protected application +dispatch performs a fresh permission check independently of any earlier HTTP or +resolver result. The [phase Status block](../../../docs/roadmap/phase-03-identity-admin.md) +owns qualification and review state; registration or an attribute alone proves +no HTTP coverage. + +Tests of authorization must use the canonical registered behavior and actual +source owner for their subject. Do not replace its DI descriptor, restore a +pass-through adapter or treat a test-only policy as a production permission. + +When an HTTP operation is admitted, bind its exact application request metadata +and the named ASP.NET requirement generated from that canonical policy. Preserve +the [four-step authorization order](../../../docs/standards/11-security.md#live-authorization-contract) +and prove unknown/missing/conflicting declarations deny. Keep initial templates +and explicit grant adoption separate from definition composition. See [add-permission](../add-permission/SKILL.md). @@ -311,7 +312,8 @@ Endpoint controllers are thin: public sealed class EnrollmentsController(ISender mediator) : ApiControllerBase { [HttpPost] - [Authorize(Policy = "enrollment.enrollment.write")] + [Authorize(Policy = "LearnStackOperation:enrollment.enrollment.create")] + [OperationPolicy(typeof(CreateEnrollmentCommand))] public async Task Create( [FromBody] CreateEnrollmentRequest body, CancellationToken ct) { @@ -321,6 +323,11 @@ public sealed class EnrollmentsController(ISender mediator) : ApiControllerBase } ``` +This illustrates an already admitted operation whose module source uses policy +id `enrollment.enrollment.create`; it does not register that policy or license a +new endpoint. Use the current API classifier/requirement mapping and its exact +source census, rather than copying a policy string without its source. + ## Validation - `dotnet build` and `dotnet test` pass. diff --git a/.claude/skills/add-permission/SKILL.md b/.claude/skills/add-permission/SKILL.md index 61e2ad262..e4cc96df3 100644 --- a/.claude/skills/add-permission/SKILL.md +++ b/.claude/skills/add-permission/SKILL.md @@ -3,7 +3,7 @@ name: add-permission description: > Register a new permission key in a module — closed action set (`read | write | delete | admin`), explicit scope (Platform / Tenant / - Organization), default role grants, matrix entry, and `[Authorize(Policy = …)]` + Organization), finite grant manifests, matrix entry, and `[Authorize(Policy = …)]` on the relevant endpoint. USE FOR: adding a permission for a new resource, splitting an existing resource into separate read/write capabilities, or promoting a "platform admin only" gate into a tenant-scoped permission. DO NOT @@ -16,16 +16,21 @@ description: > ## Purpose -Add a permission to the module's -[IPermissionRegistry](../../../docs/standards/19-permissions.md) using the closed -naming convention, register it on the right endpoint, and update the per-module -permission matrix. - -> **Not wired yet.** `IPermissionRegistry`, `IModule.RegisterPermissions` and the -> `Roles` catalogue land with the Identity module in -> [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md); none exists in -> `backend/src`. Until then only Step 1 and Step 5 apply, as a forward declaration on -> [the Tenancy precedent](../../../docs/modules/tenancy/permissions.md). +Add an explicitly scoped definition through the module-owned source described in +[Permissions Standards](../../../docs/standards/19-permissions.md), bind the +admitted operation and update its matrix. Registration grants no Role authority. + +> **Current implementation boundary.** P03-1 supplies `PermissionDefinition` and +> the retained `IdentityFoundationManifest.Version1` with actual seed consumers. +> Jointly Accepted ADR-0076/0077 select the +> [live contract](../../../docs/standards/19-permissions.md#live-permission-enforcement-contract); +> live policy/evaluator and grant-edit/adoption commands are implemented. The +> [Phase 03 Status block](../../../docs/roadmap/phase-03-identity-admin.md) owns +> current qualification and review state; the +> [phase decision register](../../../docs/roadmap/phase-03-identity-admin.md#the-decision-register) +> owns gate state. No `IModule` registration API or generated Roles facade is +> implied. Read the current source and accepted operation contract before coding; +> do not introduce a module loader to match an illustrative sketch. ## When to use @@ -50,7 +55,7 @@ permission matrix. | Permission key | Yes | Dotted `{module}.{resource}.{action}`, snake_case multi-word resources. | | Scope | Yes | `Platform` / `Tenant` / `Organization`. | | Description | Yes | One short sentence shown in Studio's role editor. | -| Default role grants | Yes | Which built-in roles get this on tenant provisioning. | +| Initial/adopted grant manifest | Yes | Explicit finite version; registration and seed reruns grant nothing to existing Roles. | ## Workflow @@ -83,56 +88,40 @@ Rules: ### Step 2: Register in the module -In `.Application/Module.cs`: +Declare `(PermissionScope, PermissionKey)` through the admitted module definition +source. The current value-type shape is: ```csharp -public void RegisterPermissions(IPermissionRegistry registry) -{ - registry.Tenant( - key: "enrollment.course_access.read", - description: "View entitlements", - defaultGrants: [Roles.TenantAdmin, Roles.OrgAdmin, Roles.Instructor]); - - registry.Tenant( - key: "enrollment.course_access.write", - description: "Grant or revoke entitlements", - defaultGrants: [Roles.TenantAdmin]); - - registry.Organization( - key: "enrollment.enrollment.write", - description: "Create or update enrollments (org-scoped)", - defaultGrants: [Roles.OrgAdmin, Roles.OrgInstructor]); -} +new PermissionDefinition(PermissionScope.Organization, "enrollment.enrollment.write"); ``` -`registry.Tenant(...)` / `registry.Organization(...)` / `registry.Platform(...)` -declare scope explicitly; the three registries are disjoint, asserted at seed. +This example registers nothing by itself. The same key at Tenant and Organization +scope is two distinct definitions; Platform definitions cannot enter tenant Role +grants. Unknown/inactive pairs authorize nothing. -`Roles.*` references come from the **Built-in Roles** catalogue authoritative -at [19-permissions.md § Built-in Roles](../../../docs/standards/19-permissions.md) -(`TenantAdmin`, `Instructor`, `Learner`, `Guardian`, `Editor`, `OrgAdmin`, -`OrgInstructor`, `PortalPublic`, `HubOperator`, `PlatformAdmin`). Do not invent -role names; pick from that table or extend it via PR + standard update. +Pick the template/capability from the canonical +[Built-in Roles](../../../docs/standards/19-permissions.md#built-in-roles). +Keep definition composition separate from finite initial grants and explicit +adoption. Never extend retained v1 seed grants or the immutable admin kernel by +editing a registration's defaults. A new assignable pair also needs the actual +database registry guard/migration and approved grant contract before its writer. ### Step 3: Apply to the endpoint -```csharp -[ApiController] -[Route("enrollments")] // the versioned prefix is the convention's, not yours -public sealed class EnrollmentsController : ControllerBase -{ - [HttpGet] - [Authorize(Policy = "enrollment.enrollment.read")] - public Task List(...) => ...; - - [HttpPost] - [Authorize(Policy = "enrollment.enrollment.write")] - public Task Create(...) => ...; -} -``` +Bind the endpoint to the admitted named ASP.NET policy and exact application +request policy from the actual composition source. Scoped pair identity is +explicit: a same-key Tenant/Organization pair is not one permission. Do not +infer a functioning evaluator from an attribute; use the registered evaluator +and policy APIs. Unknown, missing or conflicting classifications deny +at runtime. Application step 5 repeats the policy on direct/nested dispatch. + +Public and special AccountSite classifications retain their independent +HostScope/physical mode boundaries. Platform and additional human-facing +operations remain gated by their owning phase; this workflow grants no route. -The 4-step auth order ([11-security.md § Authorization](../../../docs/standards/11-security.md)) -runs automatically: +The admitted surface must prove the 4-step auth order +([11-security.md § Authorization](../../../docs/standards/11-security.md)); +an attribute alone does not install it: 1. Authentication (valid JWT). 2. Tenant membership (Membership exists in resolved tenant). @@ -141,23 +130,15 @@ runs automatically: ### Step 4: Resource scope (when needed) -For "instructor edits only own courses", add an authorization handler: - -```csharp -public sealed class CourseOwnedByActorHandler - : IAuthorizationHandler -{ - public Task HandleAsync(AuthorizationHandlerContext ctx, ...) - { - if (resource.OwnerId == ctx.Actor.UserId) ctx.Succeed(); - return Task.CompletedTask; - } -} -``` +Use the admitted application-layer resource policy after capability admission +and visible-scope lookup. A missing or foreign-scope resource is masked 404; a +visible resource failing ownership is 403. The application policy consumes the +trusted actor and exact resource facts; module code acquires no ASP.NET types. -The application-layer policy class combines with the permission key: -permission grants the **capability** (`education.course.write`), policy decides -**on which rows** (own course only). +[Security's live policy contract](../../../docs/standards/11-security.md#live-authorization-contract) +owns this ordering. Declared tenant-wide reads use the immutable +[operation witness](../../../docs/standards/05-database.md#operation-bound-read-authority), +never a header/body Boolean, Role name or cleared organization write scope. ### Step 5: Update the matrix @@ -167,8 +148,8 @@ Open `docs/modules//permissions.md`. Add a row: | Entitlement | ✓ | ✓ | – | – | tenant-admin: read+write; instructor: read | ``` -Legend: ✓ = exists, – = N/A. The "Default role grants" column summarises -`defaultGrants` from the registration call. +Legend: ✓ = exists, – = N/A. The "Default role grants" column names the approved +finite initial manifest; it does not promise automatic grants on upgrade/rerun. ### Step 6: Tests @@ -189,22 +170,27 @@ Plus, for org-scoped permissions, a cross-org test: ```csharp [Fact] -public async Task Cross_org_actor_within_same_tenant_is_403() { ... } +public async Task Cross_org_actor_is_refused_without_disclosure() { ... } ``` -Architecture test `Permission_Registry_Has_DeniedTest` ensures every registered -key has at least one denied test. +Use the admitted surface's masking/denial contract for exact status assertions; +foreign resource scope and a visible ownership denial are different controls. +The canonical obligation `Permission_Registry_Has_DeniedTest` requires actual +denied coverage; verify its current catalogue status and nonempty subjects. ## Validation - `dotnet build` and `dotnet test` pass. -- The permission is visible in `IPermissionRegistry` (assert via a one-off - startup-test). +- The exact scoped pair is in the admitted composition source; a missing or + inactive definition denies, and registry additions do not mutate stored grants. - The endpoint rejects unauthenticated, wrong-tenant, and wrong-permission callers with the right status codes. - The permission appears in `docs/modules//permissions.md`. -- The architecture tests `Permission_Keys_Match_Convention` and - `Permission_Scope_Matches_Resource_Scope` are green. +- Check `Permission_Keys_Match_Convention`, + `Permission_Scope_Matches_Resource_Scope` and + `Permission_Registry_Has_DeniedTest` against their current catalogue status. + Their P03-2 controls are Registered at acceptance; run the actual implemented + owners before claiming green, and never promote an absent proof. ## Common pitfalls @@ -213,13 +199,12 @@ key has at least one denied test. - **Skipping scope.** A `Tenant`-scope permission checked against an `[OrganizationScoped]` resource without an org test is a bug; the matching architecture test catches it. -- **Hard-coding the key string in policy code.** Use the generated catalogue - (`Permissions.Education.Course.Write`); typos in raw strings silently pass at - compile time. +- **Duplicating key strings.** Reuse the owning compiled definition/constants; + do not invent a generated catalogue that is not present in the source. - **Frontend-only check.** Hidden buttons are not security. Every UI permission check has a server-side counterpart. -- **Storing the permission in Keycloak.** Keycloak handles authentication, not - LearnStack authorization. Permissions live in `IPermissionRegistry` only. +- **Storing the permission in Keycloak.** Keycloak handles authentication; + LearnStack's admitted definition/Role contracts own application authorization. - **Renaming an existing key.** A rename without a deprecation window invalidates every role assignment in the wild. Renaming is a deprecation cycle (one release with the old key warning-logged), not an in-place edit. diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 2538d8a8c..4088288aa 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -132,21 +132,49 @@ installed deployment authority, following [Identity deployment](../../../docs/modules/identity/README.md#global-implementation-and-deployment). Authority qualification is mandatory before the API accepts requests. The dispatcher credential belongs only to the API; every Seeder invocation uses -an explicit application-only environment, and the web launcher receives neither +an explicit application-role runtime environment, and the web launcher receives no database credential. Both empty hop lists remain valid in every mode; a network-only or secret-only configuration is refused. The application connection must name `learnstack_app`; the data-source guard also refuses direct or transitive BYPASSRLS/superuser access. Migration credentials never enter either application launch recipe. +**P03-2 source configuration.** The confined authorization/ +evidence sources use the same application role, not new credentials or roles. +[Standard 05's source ledger](../../../docs/standards/05-database.md#authorization-and-evidence-source-ledger) +owns the coupled backend configuration pair, Development-only direct-PostgreSQL +derivation and explicit non-Development requirement; +[Standard 12](../../../docs/standards/12-infrastructure.md#authorization-and-evidence-topology) +owns operational topology. Startup qualifies the installed authority and actual +database identity before admitting source use. Owned seed/demo runtime children +receive the selected authority and connection pair, with the existing +`Deployment__Mode=Development` for their direct local stack; their restore/build +and manifest children receive no runtime authority or database credentials. +The Seeder shares one process-owned evidence source across per-act providers. +Direct CLI use must supply the authority pair and explicit auxiliary pair unless +explicit Development permits supported direct-PostgreSQL derivation; the tool +does not load `.env`. Follow [seed-tenant](../seed-tenant/SKILL.md) for its exact +startup contract. Do not expose these values to the web, silently accept a +partial pair, or mistake extra client pools for PgBouncer backend reservation. +The local API configuration loader selects a complete explicit process +authorization/evidence pair atomically over the selected root-file pair. +When both process keys are absent, the root-file pair is used. Partial, empty or +whitespace process values refuse before the API child starts; sources never fill +one another's missing partner. The constructed runtime preserves selected strings +exactly and imports neither partner from its ambient prerequisite environment. +The owned demo separately retains its selected-stack conflict check: explicit +process values cannot disagree with values present in its selected private file. +The phase Status block owns current qualification and review state. + **`make migrate` runs as `learnstack_migration`, not as the API's role.** From Phase 02a Packet 6 the stack provisions four database roles on the first boot of a fresh `postgres-data` volume ([ADR-0003 Amendment 3](../../../docs/decisions/0003-tenant-isolation-defense-in-depth.md)): `learnstack_migration` owns every table, `learnstack_app` is what the API connects as, and `learnstack_platform` / `learnstack_outbox_admin` hold audited -bypasses. Four roles, four passwords, four connection strings — all in -`.env.example`, and none of them interchangeable. Running migrations as the +bypasses. The four roles/passwords and baseline connection strings are in +`.env.example`, and their privileges are not interchangeable. The accepted +P03-2 application-role sources do not expand this role set. Running migrations as the runtime role would make it the table owner, which is the arrangement `FORCE ROW LEVEL SECURITY` exists to defeat. diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 7c1e179b7..4508187aa 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -77,6 +77,27 @@ frontend/apps/web/scripts/ # Phase 06 owns full Playwright Test/axe flows. ``` +### Refresh an intentional OpenAPI contract change + +The Contract suite serves the actual API composition through its database-free +factory and compares it with `backend/openapi/v1.json`. For an authorized contract +change, explicitly update that snapshot from the served document, then regenerate +the SDK: + +```bash +LEARNSTACK_UPDATE_OPENAPI=1 dotnet test --configuration Release \ + backend/tests/LearnStack.Tests.Contract --no-restore \ + --filter FullyQualifiedName~PublicSurface_Contract_Matches_Served_OpenApi +make sdk +dotnet test --configuration Release backend/tests/LearnStack.Tests.Contract --no-restore +(cd frontend && pnpm --filter @learnstack/sdk typecheck && pnpm --filter @learnstack/sdk test) +``` + +Only the exact update value `1` writes the snapshot; CI refuses updates and always +compares. Review the snapshot, generated types and explicit GET ownership maps. +Adding a generated authenticated contract never extends the native BFF operation +allowlist or creates a transport wrapper implicitly. + ### Step 3: Run unit tests Use `--configuration Release` for repository qualification, matching CI. The diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 342f9969d..abfb159a3 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -48,7 +48,7 @@ or supplies enrollment, learner identity, commerce or live-session data. The data set is the input; there is no `--tenants` flag. The production entry point uses `SeedData.All`. `SeedRunner.RunAsync` accepts alternate declarations for tests. Connection configuration arrives in the environment, not command-line arguments. -Both `scripts/seed.sh` and the direct tool refuse a role other than `learnstack_app`. +Both the owned launcher and the direct tool refuse a role other than `learnstack_app`. The shared application data-source guard also refuses direct or transitive access to a BYPASSRLS/superuser role on every physical connection. @@ -69,7 +69,7 @@ make seed ``` This target starts the selected stack and waits for PostgreSQL before migration. -`scripts/seed.sh` then checks full Compose health and both realm issuers before +`scripts/seed.mjs` then checks full Compose health and both realm issuers before running the fresh Release Seeder DLL and then the shared development-only realm reconciler. Both combined entries reject an incomplete selected private realm source before infrastructure work; older files must explicitly add the BFF @@ -82,17 +82,40 @@ Seed preparation reports fixed stage progress and closed failure reasons rather than forwarding potentially sensitive child output. On cancellation/failure, wait for owned cleanup; follow [lock recovery](../../../infra/keycloak/README.md#lock-recovery) if the previous realm owner left its exclusive lock. -The direct tool runs -from `backend/`, where `global.json` pins the SDK: +The owned seed/demo launchers pass only the selected application connection, +identity authority and coupled auxiliary connection strings to runtime children, +with `Deployment__Mode=Development` for their owned local stack. Restore/build and +credential-free projection children receive process prerequisites without those +runtime values. Proxy and trust prerequisites remain available; migration, +platform, dispatcher and provider-admin credentials do not enter a Seeder child. + +The direct tool reads its process environment; it does not load `.env`. Supply +`ConnectionStrings__Default`, `Identity__AuthorityId` and +`Authentication__Tenant__Issuer` from the selected private configuration. The +authority must already be installed: `make seed` passes the stable pair through +`make migrate`, whose deployment installer runs before content seeding. The +Seeder never installs, rewrites or infers that anchor. A schema-only migration +without the pair is insufficient for application seeding. + +`ConnectionStrings__IdentityAuthorization` and +`ConnectionStrings__AuthorizationEvidence` form one guarded pair. Only explicit +`Deployment__Mode=Development` permits their omission for the supported direct +PostgreSQL topology; another or missing mode requires both. PgBouncer requires +the explicit pair and separately reserved backend lanes even in Development. +[The topology contract](../../../docs/standards/12-infrastructure.md#authorization-and-evidence-topology) +owns those requirements; matching connection strings or authority establish no +capacity qualification. + +After exporting the required values without printing them, run from `backend/`, +where `global.json` pins the SDK: ```bash -(cd backend && ConnectionStrings__Default="" \ - dotnet run --project src/LearnStack.Tools.Seeder --configuration Release) +(cd backend && dotnet run --project src/LearnStack.Tools.Seeder --configuration Release) ``` -Do not put a real connection string on `argv` or print it. The script reads the -environment first and falls back to the local `.env`; the agent should not expose -that file. There is no `make seed-tenant`, `infra/seed/`, or seed-reset command. +Do not put a real connection string on `argv` or print it. The owned launcher +reads its selected private source; the agent should not expose that file. There +is no `make seed-tenant`, `infra/seed/`, or seed-reset command. ### 2. Read the declaration @@ -131,6 +154,16 @@ roots, their translations and publication announce the exact root organization. The runner never writes `ITenantContextAccessor.Current`, opens a private transaction, calls a tenant setter, mutates EF state or executes seed SQL. +The canonical `AuthorizationBehavior` remains at step 5 of the unchanged eight-step +pipeline. Module policy sources and the finite `SeedOperationSource` classify each +act; an actor UUID alone supplies no bypass. The process owns one qualified, +two-slot evidence source shared by every per-act provider. Startup checks the +application role, installed authority and actual business/evidence database +identity before dispatch. The common evidence barrier records attributable finite +seed refusals independently of ordinary audit `Off`; no generic business-source +fallback supplies that evidence. The process disposes the source after all acts. +Startup qualification failure emits a fixed diagnostic without provider details. + ### 4. Converge or fail safely [SeedVerification](../../../backend/src/LearnStack.Tools.Seeder/SeedVerification.cs) @@ -192,6 +225,12 @@ possible computed-identity branch; the [Step 2 record](../../../docs/roadmap/phase-02d-delivery-log.md#p02d-7-step-2--genericity-and-exit-foundations-2026-10-11) owns their delivered scope and review evidence. +`SeedOperationSourceTests` checks canonical registration, finite provenance, +scope closure and explicit missing-factory evidence loss. The real-database +`SeedAuthorizationActivationTests` exercises finite refusal durability through +the shared qualified source. These focused subjects do not qualify production +capacity or complete the packet; the Phase 03 Status block owns that state. + ### 6. Reach the hosts The existing names remain under `*.learnstack.local`. P02d-5/G32 accepts manual diff --git a/.env.example b/.env.example index 75fe86e85..8220615b1 100644 --- a/.env.example +++ b/.env.example @@ -37,15 +37,16 @@ POSTGRES_DB=learnstack # ─── The four database roles (ADR-0003 Amendment 3) ────────────────────── # Read by infra/compose/postgres-init/02-create-roles.sql through psql's -# \getenv on first boot. Four roles, four separate login credentials, four -# connection strings — the model is closed and a fifth role requires an ADR. +# \getenv on first boot. Four roles and four separate login credentials; +# the role model is closed and a fifth role requires an ADR. Additional +# application-role sources do not create roles (ADR-0076/0077). # An unset variable aborts initdb rather than creating a passwordless role. LEARNSTACK_MIGRATION_PW=learnstack-migration-dev LEARNSTACK_APP_PW=learnstack-app-dev LEARNSTACK_PLATFORM_PW=learnstack-platform-dev LEARNSTACK_OUTBOX_PW=learnstack-outbox-dev -# ─── The four connection strings ───────────────────────────────────────── +# ─── Baseline connection strings ──────────────────────────────────────── # Double underscore is the .NET environment spelling of `ConnectionStrings:X`. # These are NOT interchangeable and must not be unified in any environment, # local development included — see Standards 05 § Database roles. @@ -54,13 +55,20 @@ LEARNSTACK_OUTBOX_PW=learnstack-outbox-dev # API or worker runtime configuration: the role owns every # table, and a runtime that is the owner is the arrangement # FORCE ROW LEVEL SECURITY exists to defeat. -# Default — every request-path and job-path DbContext. NOBYPASSRLS. +# Default — ordinary request/job business owners. NOBYPASSRLS. # PlatformAdmin — resolvable only by PlatformAdminScope. BYPASSRLS. # OutboxDispatcher — API-owned OutboxProcessor only. Never Seeder or web. ConnectionStrings__Migration='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_migration;Password=learnstack-migration-dev' ConnectionStrings__Default='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_app;Password=learnstack-app-dev' ConnectionStrings__PlatformAdmin='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_platform;Password=learnstack-platform-dev' ConnectionStrings__OutboxDispatcher='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_outbox_admin;Password=learnstack-outbox-dev' +# API/Seeder startup qualifies the coupled backend-only +# IdentityAuthorization / AuthorizationEvidence sources use the SAME app role. +# Development may derive both only for direct PostgreSQL; other modes require +# both explicit strings. Do not add one alone or expose either to the web. +# docs/standards/05-database.md#authorization-and-evidence-source-ledger owns +# source limits, actual cleanup and the independent PgBouncer backend lanes. +# The selected runtime child receives both keys; restore/build and web do not. # ─── Keycloak (two realms per ADR-0004 Amendment 1) ────────────────────── # The Development API defaults to the loopback tenant issuer declared in diff --git a/README.md b/README.md index 87ec91ed0..28a37f473 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,9 @@ for identity, membership and authorization work; the **Five modules contain domain implementations:** Tenancy, Customization, Audit, Education and Identity. Identity's [module specification](docs/modules/identity/README.md) -distinguishes implemented global/contextual foundations and seed admission from -remaining API activation and live authorization. Content and Media remain scaffolded. +distinguishes implemented global/contextual foundations, seed admission and live +authorization from remaining new-user entry and administration surfaces. Content +and Media remain scaffolded. The subsequent product milestones are CMS and media ([04](docs/roadmap/phase-04-cms-media-pages.md)), the full course catalog ([05](docs/roadmap/phase-05-education-learning-content.md)), diff --git a/backend/LearnStack.slnx b/backend/LearnStack.slnx index b322621e7..99f599be1 100644 --- a/backend/LearnStack.slnx +++ b/backend/LearnStack.slnx @@ -60,6 +60,7 @@ + diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 19eabd6c9..3c02d3061 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -31,6 +31,58 @@ ], "type": "object" }, + "DirectoryMembershipStatus": { + "enum": [ + "Active", + "Suspended" + ], + "type": "string" + }, + "MembershipDirectoryItem": { + "properties": { + "displayName": { + "type": [ + "null", + "string" + ] + }, + "membershipId": { + "format": "uuid", + "type": "string" + }, + "organizationId": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OrganizationId" + } + ] + }, + "preferredLocale": { + "type": [ + "null", + "string" + ] + }, + "status": { + "$ref": "#/components/schemas/DirectoryMembershipStatus" + }, + "userId": { + "$ref": "#/components/schemas/UserId" + } + }, + "required": [ + "membershipId", + "userId", + "organizationId", + "status", + "displayName", + "preferredLocale" + ], + "type": "object" + }, "OrganizationId": { "format": "uuid", "type": "string" @@ -64,6 +116,24 @@ ], "type": "object" }, + "PageOfMembershipDirectoryItem": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MembershipDirectoryItem" + }, + "type": "array" + }, + "pageInfo": { + "$ref": "#/components/schemas/PageInfo" + } + }, + "required": [ + "items", + "pageInfo" + ], + "type": "object" + }, "ProblemDetails": { "properties": { "code": { @@ -759,7 +829,7 @@ } } }, - "description": "dependency_unavailable from invalid stored public configuration or a sticky subject-registry cleanup fault.", + "description": "dependency_unavailable from live authorization storage, invalid public configuration or sticky subject-registry cleanup fault; refused audit writes preserve the original refusal.", "headers": { "Cache-Control": { "required": true, @@ -792,6 +862,290 @@ "x-version-introduced": "v1" } }, + "/api/v1/identity/memberships": { + "get": { + "description": "Tenant administrative membership directory requiring current Tenant identity.membership.admin. Rechecks live membership, lifecycle and capability for every page; accepts HostAndClaim or confirmed ClaimAndMembership, including a platform host. Returns only membership/user/organization identifiers, status, contextual displayName and preferredLocale. Active/Suspended undeleted memberships only, ordered by membership UUID. No search, sorting, editing, provider data, grants or global profile. No-store; the MUST ReadSensitive audit row commits before any result is disclosed. This operation is not admitted by the native BFF caller.", + "operationId": "ListMemberships", + "parameters": [ + { + "description": "Opaque canonical continuation bound to the actor, tenant, organization and operation; obtain it from pageInfo.nextCursor. Every page reauthorizes.", + "in": "query", + "name": "cursor", + "schema": { + "maxLength": 1024, + "minLength": 1, + "type": "string" + } + }, + { + "description": "Positive page size, default 20; values above 100 are clamped to 100. Repeated, empty or unknown query parameters are refused.", + "in": "query", + "name": "limit", + "schema": { + "default": 20, + "format": "int32", + "maximum": 2147483647, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageOfMembershipDirectoryItem" + } + } + }, + "description": "OK", + "headers": { + "Cache-Control": { + "required": true, + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "validation_failed for noncanonical/replayed/oversized cursors, invalid limit or unknown/repeated/empty query parameters; malformed assertion headers are also refused.", + "headers": { + "Cache-Control": { + "required": true, + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "unauthorized for present invalid or undecidable credentials on known hosts, including existing-binding failure; bare Bearer challenge without provider details.", + "headers": { + "Cache-Control": { + "required": true, + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + }, + "WWW-Authenticate": { + "required": true, + "schema": { + "enum": [ + "Bearer" + ], + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "forbidden for confirmed coverage without the current Tenant administrative capability; no directory lookup or disclosure.", + "headers": { + "Cache-Control": { + "required": true, + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "not_found for absent credentials, noncoverage or untrusted scope; tenant_mismatch for host/claim or header disagreement. Assertions do not select authority.", + "headers": { + "Cache-Control": { + "required": true, + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "method_not_allowed from routing after earlier credential, resolution and surface gates; GET only.", + "headers": { + "Allow": { + "required": true, + "schema": { + "enum": [ + "GET" + ], + "type": "string" + } + }, + "Cache-Control": { + "required": true, + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "payload_too_large from the existing decoded body bound; outer Kestrel framing refusals may omit application policy.", + "headers": { + "Cache-Control": { + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "rate_limited from visitor/subject admission or subject-registry capacity.", + "headers": { + "Cache-Control": { + "required": true, + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + }, + "Retry-After": { + "description": "Suggested retry delay in seconds for the first refusing admission boundary. A capacity hint does not promise an available registry slot; quota hints do not guarantee admission.", + "required": true, + "schema": { + "minimum": 0, + "type": "integer" + } + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "internal_error for unexpected failures, without exception/query details.", + "headers": { + "Cache-Control": { + "required": true, + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "dependency_unavailable for live membership/lifecycle storage, auxiliary admission or sticky subject-registry cleanup fault; audit_unavailable when the mandatory sensitive-read row cannot commit. No membership payload is disclosed.", + "headers": { + "Cache-Control": { + "required": true, + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + }, + "Retry-After": { + "description": "Suggested dependency retry delay in seconds; not a recovery guarantee.", + "required": true, + "schema": { + "minimum": 0, + "type": "integer" + } + } + } + } + }, + "security": [ + { + "TenantBearer": [] + } + ], + "tags": [ + "Memberships" + ], + "x-version-introduced": "v1" + } + }, "/api/v1/public/courses": { "get": { "description": "Anonymous host-scoped read through the read-only pipeline. Present invalid credentials are refused rather than treated as anonymous. Unknown query keys and repeated single-valued keys are refused. No response caching or validators.", @@ -2642,6 +2996,9 @@ { "name": "PublicSite" }, + { + "name": "Memberships" + }, { "name": "Account" } diff --git a/backend/src/LearnStack.Api/Account/AccountController.cs b/backend/src/LearnStack.Api/Account/AccountController.cs index 267c25738..02c95cc6b 100644 --- a/backend/src/LearnStack.Api/Account/AccountController.cs +++ b/backend/src/LearnStack.Api/Account/AccountController.cs @@ -1,18 +1,21 @@ using LearnStack.Api.Authentication; using LearnStack.Api.Common; using LearnStack.Modules.Tenancy.Application.Contracts.Account; +using LearnStack.Modules.Tenancy.Application.Authorization; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace LearnStack.Api.Account; -/// The one authenticated, host-backed nonsensitive read before Phase 03. -[Authorize(AuthenticationSchemes = TenantAuthenticationRegistration.Scheme)] +/// The authenticated nonsensitive read retains its independent matching HostScope ceiling. +[Authorize(AuthenticationSchemes = TenantAuthenticationRegistration.Scheme, + Policy = OperationPolicyRequirement.Prefix + TenancyOperationPolicySource.AccountSiteOperation)] [Route("account")] public sealed class AccountController(ISender sender) : ApiControllerBase { [HttpGet("site", Name = "GetAccountSite")] + [OperationPolicy(typeof(GetAccountSiteQuery))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task GetSite(CancellationToken cancellationToken) => (await sender.Send(new GetAccountSiteQuery(Request.Query.Count != 0), cancellationToken)).ToActionResult(); diff --git a/backend/src/LearnStack.Api/Authentication/ClaimContextAssertionRecorder.cs b/backend/src/LearnStack.Api/Authentication/ClaimContextAssertionRecorder.cs new file mode 100644 index 000000000..728339907 --- /dev/null +++ b/backend/src/LearnStack.Api/Authentication/ClaimContextAssertionRecorder.cs @@ -0,0 +1,30 @@ +using LearnStack.Api.Tenancy; +using LearnStack.Application.Audit; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Api.Authentication; + +internal sealed class ClaimContextAssertionRecorder(ConfirmedClaimAssertionSource source, + Func evidence, ILogger logger) : IClaimContextAssertionRecorder +{ + public async Task RecordAsync(ITenantContext context, TenantAssertionDimension dimension) + { + var assertion = source.Capture(context, dimension switch + { + TenantAssertionDimension.Tenant => ClaimAssertionDimension.Tenant, + TenantAssertionDimension.Organization => ClaimAssertionDimension.Organization, + _ => throw new ArgumentOutOfRangeException(nameof(dimension)), + }); + if (assertion is null) + { + LogCaptureFailure(logger, null); + return; + } + await evidence().RecordConfirmedAssertionAsync(assertion); + } + + private static readonly Action LogCaptureFailure = LoggerMessage.Define( + LogLevel.Critical, new EventId(1, "ClaimContextAssertionCaptureFailed"), + "Confirmed claim assertion evidence could not be captured. The request remains refused."); +} diff --git a/backend/src/LearnStack.Api/Authentication/ConfirmedClaimAssertionSource.cs b/backend/src/LearnStack.Api/Authentication/ConfirmedClaimAssertionSource.cs new file mode 100644 index 000000000..9682b92d3 --- /dev/null +++ b/backend/src/LearnStack.Api/Authentication/ConfirmedClaimAssertionSource.cs @@ -0,0 +1,42 @@ +using System.Net; +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Http.Features; + +namespace LearnStack.Api.Authentication; + +/// Staged adapter for successful claim-only resolution; consumes no host allowance and retains no assertion value. +internal sealed class ConfirmedClaimAssertionSource(IHttpContextAccessor accessor, + TenantAuthorizationCandidateSource candidates, OperationHttpClassifier classifier) + : ConfirmedClaimAssertion.MintingSource +{ + private readonly Dictionary _captured = []; + private HttpContext? _request; + + internal ConfirmedClaimAssertion? Capture(ITenantContext context, ClaimAssertionDimension dimension) + { + var http = accessor.HttpContext; + if (!CanIssue || http is null || (_request is not null && !ReferenceEquals(_request, http)) + || http.RequestAborted.IsCancellationRequested || !context.IsResolved + || context.Origin != TenantContextOrigin.ClaimAndMembership) return null; + try + { + if (candidates.ForValidatedScope(context.TenantId, context.OrganizationId) is not { IsActive: true } candidate + || candidate.Actor != context.UserId) return null; + } + catch (ObjectDisposedException) { return null; } + if (_captured.TryGetValue(dimension, out var prior)) return prior.IsActive ? prior : null; + var route = classifier.Classify(http.GetEndpoint(), out _) == HttpOperationClassification.Application + ? http.GetEndpoint()?.Metadata.GetMetadata()?.EndpointName : null; + IPAddress? address = null; + if (http.Features.Get() is { Refuse: false } visitor + && IPAddress.TryParse(visitor.VisitorKey, out var admitted)) address = admitted; + address ??= http.Features.Get()?.RemoteIpAddress; + var agent = http.Request.Headers.UserAgent; + var captured = ForAssertion(context, dimension, route, address, agent.Count == 0 ? null : agent.ToString()); + _request = http; + _captured.Add(dimension, captured); + return captured; + } +} diff --git a/backend/src/LearnStack.Api/Authentication/HttpAuthorizationTransportSource.cs b/backend/src/LearnStack.Api/Authentication/HttpAuthorizationTransportSource.cs new file mode 100644 index 000000000..a8de26d6c --- /dev/null +++ b/backend/src/LearnStack.Api/Authentication/HttpAuthorizationTransportSource.cs @@ -0,0 +1,30 @@ +using System.Net; +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Permissions; +using Microsoft.AspNetCore.Http.Features; + +namespace LearnStack.Api.Authentication; + +/// Reuses admitted visitor metadata and fixed endpoint identity; never reparses forwarded headers. +internal sealed class HttpAuthorizationTransportSource(IHttpContextAccessor accessor, OperationHttpClassifier classifier) + : OperationTransportProvenance.MintingSource, IAuthorizationTransportSource +{ + public OperationTransportProvenance? Capture(OperationInvocation invocation) + { + var http = accessor.HttpContext; + if (http is null || http.RequestAborted.IsCancellationRequested + || invocation.Candidate is not { IsActive: true, SourceKind: AuthorizationSourceKind.Http } + || !invocation.IsActive || http.Features.Get()?.Identity is not { } identity + || identity.ActorUserId != invocation.Actor || identity.TenantId != invocation.TenantId + || classifier.Classify(http.GetEndpoint(), out _) != HttpOperationClassification.Application + || http.GetEndpoint()?.Metadata.GetMetadata()?.EndpointName is not { } route) + return null; + IPAddress? address = null; + if (http.Features.Get() is { Refuse: false } visitor + && IPAddress.TryParse(visitor.VisitorKey, out var admitted)) address = admitted; + address ??= http.Features.Get()?.RemoteIpAddress; + var agent = http.Request.Headers.UserAgent; + return ForTransport(invocation, route, address, agent.Count == 0 ? null : agent.ToString()); + } +} diff --git a/backend/src/LearnStack.Api/Authentication/LiveAuthorizationRegistration.cs b/backend/src/LearnStack.Api/Authentication/LiveAuthorizationRegistration.cs new file mode 100644 index 000000000..b924d8934 --- /dev/null +++ b/backend/src/LearnStack.Api/Authentication/LiveAuthorizationRegistration.cs @@ -0,0 +1,111 @@ +using LearnStack.Application.Authorization; +using LearnStack.Application.Audit; +using LearnStack.Api.Tenancy; +using LearnStack.Api.Composition; +using LearnStack.Infrastructure.Messaging; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Customization.Application.Authorization; +using LearnStack.Modules.Education.Application.Authorization; +using LearnStack.Modules.Identity.Infrastructure.Authorization; +using LearnStack.Modules.Identity.Infrastructure.Bindings; +using LearnStack.Modules.Tenancy.Application.Authorization; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LearnStack.Api.Authentication; + +internal static class LiveAuthorizationRegistration +{ + internal static IServiceCollection AddLearnStackLiveAuthorization(this IServiceCollection services, + IConfiguration configuration, DeploymentMode deploymentMode) + { + services.TryAddEnumerable([ + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + ]); + services.AddIdentityAuthorizationReader(); + services.AddIdentityOperationPolicies(); + services.AddLearnStackOperationAuthorization(); + services.TryAddScoped>(provider => () => provider.GetRequiredService()); + services.TryAddScoped>(provider => () => provider.GetRequiredService()); + services.AddTrustedDeliveryOperations(); + services.AddHttpContextAccessor(); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddScoped(provider => provider.GetRequiredService()); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.Replace(ServiceDescriptor.Scoped()); + + // Each type owns a private pool. Neither alias resolves a raw datasource. + services.AddLearnStackAuthorizationSourceConfiguration(configuration, deploymentMode == DeploymentMode.Development); + services.TryAddSingleton(provider => new IdentityAuthorizationReadSource( + provider.GetRequiredService().IdentityAuthorization)); + services.TryAddSingleton(provider => new AuthorizationEvidenceSource( + provider.GetRequiredService().AuthorizationEvidence)); + services.TryAddSingleton>(provider => () => provider.GetRequiredService()); + services.TryAddSingleton>(provider => () => provider.GetRequiredService()); + services.TryAddScoped(provider => + new DeferredEvidenceSessions(() => provider.GetRequiredService())); + services.AddHostedService(); + + services.AddAuthorization(options => + { + var declared = new AuthorizationPolicy([new DeclaredOperationRequirement()], []); + options.DefaultPolicy = declared; + options.FallbackPolicy = declared; + }); + services.Replace(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + return services; + } + + private sealed class DeferredEvidenceSessions : IAuthorizationEvidenceSessionFactory + { + private readonly Func _source; + internal DeferredEvidenceSessions(Func source) => _source = source; + public Task OpenAsync(AuthorizationEvidenceOccurrence occurrence, + CancellationToken cancellationToken = default) => _source().OpenAsync(occurrence, cancellationToken); + } +} + +/// Qualifies the two independent lanes against the actual qualified business database. +public sealed class TenantAuthorizationSourcesStartupService(IdentityAuthorityQualification qualification, + Func authorization, Func evidence) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + await qualification.QualifyAsync(cancellationToken); + var registration = qualification.Current + ?? throw new InvalidOperationException("Identity authorization source qualification is unavailable."); + try + { + var authorizationSource = authorization(); + var evidenceSource = evidence(); + var proof = await qualification.OpenDatabaseQualificationProofAsync(cancellationToken); + var deadline = proof.DeadlineToken; + await using (proof) + { + await authorizationSource.QualifyAsync(registration.AuthorityId, registration.Issuer, proof, deadline); + await evidenceSource.QualifyAsync(registration.AuthorityId, registration.Issuer, proof, deadline); + if (authorizationSource.QualifiedDatabaseName != qualification.QualifiedDatabaseName + || evidenceSource.QualifiedDatabaseName != qualification.QualifiedDatabaseName) + throw new InvalidOperationException("Identity authorization sources must use the qualified business database."); + } + deadline.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception failure) when (failure is not (OutOfMemoryException or StackOverflowException)) + { + throw new InvalidOperationException("Identity authorization source qualification is unavailable or does not match deployment configuration."); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/backend/src/LearnStack.Api/Authentication/LiveTenantMembershipReader.cs b/backend/src/LearnStack.Api/Authentication/LiveTenantMembershipReader.cs new file mode 100644 index 000000000..29bfa1bd4 --- /dev/null +++ b/backend/src/LearnStack.Api/Authentication/LiveTenantMembershipReader.cs @@ -0,0 +1,25 @@ +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Api.Authentication; + +/// Confirms only the quota-admitted bound claim through a fresh committed reader. +internal sealed class LiveTenantMembershipReader(TenantAuthorizationCandidateSource candidates, + Func reader) : ITenantMembershipReader +{ + public async Task CoversAsync(UserId userId, TenantId tenantId, OrganizationId? organizationId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var candidate = candidates.ForValidatedScope(tenantId, organizationId); + if (candidate is not { IsActive: true } || candidate.Actor != userId) return false; + var result = await reader().ReadAsync(candidate, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (result.Outcome == IdentityAuthorizationOutcome.Unavailable) + throw new InfrastructureException("Identity authorization is unavailable."); + return candidate.IsActive && result.Covers && result.Actor == userId + && result.TenantId == tenantId && result.OrganizationId == organizationId; + } +} diff --git a/backend/src/LearnStack.Api/Authentication/OperationAuthorizationPolicyProvider.cs b/backend/src/LearnStack.Api/Authentication/OperationAuthorizationPolicyProvider.cs new file mode 100644 index 000000000..9a16a3c98 --- /dev/null +++ b/backend/src/LearnStack.Api/Authentication/OperationAuthorizationPolicyProvider.cs @@ -0,0 +1,65 @@ +using LearnStack.Application.Authorization; +using LearnStack.SharedKernel.Permissions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace LearnStack.Api.Authentication; + +internal sealed class DeclaredOperationRequirement : IAuthorizationRequirement; +internal sealed record OperationHttpRefusal(LearnStack.SharedKernel.Results.Error Error); + +/// Named requirements refer to the same frozen module-owned policies as application dispatch. +internal sealed class OperationAuthorizationPolicyProvider(IOptions options, + IOperationPolicyCatalog catalog) : DefaultAuthorizationPolicyProvider(options) +{ + public override Task GetPolicyAsync(string policyName) + { + ArgumentNullException.ThrowIfNull(policyName); + if (!policyName.StartsWith(OperationPolicyRequirement.Prefix, StringComparison.Ordinal)) + return base.GetPolicyAsync(policyName); + var policy = catalog.All.SingleOrDefault(policy => OperationPolicyRequirement.Prefix + policy.Id == policyName); + return Task.FromResult(policy is { Kind: OperationPolicyKind.Protected or OperationPolicyKind.AccountRead } + ? new AuthorizationPolicy([new OperationPolicyRequirement(policy)], [TenantAuthenticationRegistration.Scheme]) : null); + } +} + +internal sealed class OperationAuthorizationHandler(OperationHttpClassifier classifier, + Func operations) + : IAuthorizationHandler +{ + public async Task HandleAsync(AuthorizationHandlerContext context) + { + if (context.Resource is not HttpContext http) return; + var classification = classifier.Classify(http.GetEndpoint(), out var policy); + foreach (var requirement in context.PendingRequirements.ToArray()) + { + if (requirement is DeclaredOperationRequirement) + { + if (classification is HttpOperationClassification.Unmatched or HttpOperationClassification.Infrastructure) + context.Succeed(requirement); + else context.Fail(); + } + else if (requirement is OperationPolicyRequirement requested) + { + if (classification != HttpOperationClassification.Application || !ReferenceEquals(policy, requested.Policy)) + { + context.Fail(); + continue; + } + if (http.Features.Get()?.Identity is null) + { + http.Features.Set(new OperationHttpRefusal(new(new LearnStack.SharedKernel.Localization.LocalizedMessage("lockey_not_found")))); + context.Fail(); + continue; + } + var response = await operations().CheckHttpAsync(policy.RequestType, http.GetEndpoint()!, http.RequestAborted); + if (response.IsSuccess) context.Succeed(requirement); + else + { + http.Features.Set(new OperationHttpRefusal(response.Error!)); + context.Fail(); + } + } + } + } +} diff --git a/backend/src/LearnStack.Api/Authentication/OperationClassificationMiddleware.cs b/backend/src/LearnStack.Api/Authentication/OperationClassificationMiddleware.cs new file mode 100644 index 000000000..5502e8abf --- /dev/null +++ b/backend/src/LearnStack.Api/Authentication/OperationClassificationMiddleware.cs @@ -0,0 +1,58 @@ +using LearnStack.Api.Common; +using LearnStack.Api.PublicReads; +using LearnStack.Application.Authorization; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Api.Authentication; + +/// A matched handler requires a closed declaration even when it opts out of ASP.NET authorization. +internal sealed class OperationClassificationMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, OperationHttpClassifier classifier, + Func operations, ITenantContext tenant) + { + var classification = classifier.Classify(context.GetEndpoint(), out var policy); + if (classification == HttpOperationClassification.Refused) + { + await RefuseAsync(context, new Error(new LocalizedMessage( + tenant.IsResolved && context.Features.Get()?.Identity is not null + ? "lockey_forbidden" : "lockey_not_found"))); + return; + } + if (policy is { Kind: OperationPolicyKind.PublicRead }) + { + if (!tenant.IsResolved || tenant.HostScope is null) + { + // Preserve the generic unresolved MediatR refusal; a resolved + // claim-only context still cannot replace public host authority. + await RefuseAsync(context, new Error(new LocalizedMessage( + tenant.IsResolved ? "lockey_not_found" : "lockey_tenant_mismatch"))); + return; + } + var response = await operations().CheckHttpAsync(policy.RequestType, context.GetEndpoint()!, context.RequestAborted); + if (response.IsFailure) + { + await RefuseAsync(context, response.Error!); + return; + } + } + await next(context); + } + + internal static async Task RefuseAsync(HttpContext context, Error error) + { + PublicResponsePolicy.Apply(context.Response); + context.Response.StatusCode = HttpStatusMap.For(error); + await context.Response.WriteAsJsonAsync(ProblemDetailsFactory.For(error, context), options: null, + contentType: ProblemDetailsMediaType.Value, cancellationToken: context.RequestAborted); + } +} + +internal static class OperationClassificationExtensions +{ + internal static IApplicationBuilder UseLearnStackOperationClassification(this IApplicationBuilder app) => + app.UseMiddleware(); +} diff --git a/backend/src/LearnStack.Api/Authentication/OperationHttpClassification.cs b/backend/src/LearnStack.Api/Authentication/OperationHttpClassification.cs new file mode 100644 index 000000000..5a6e6a0c7 --- /dev/null +++ b/backend/src/LearnStack.Api/Authentication/OperationHttpClassification.cs @@ -0,0 +1,111 @@ +using LearnStack.Api.Account; +using LearnStack.Api.Identity; +using LearnStack.Api.PublicReads; +using LearnStack.Modules.Education.Application.Contracts.PublicReads; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Tenancy.Application.Contracts.Account; +using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; +using LearnStack.SharedKernel.Permissions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing.Matching; + +namespace LearnStack.Api.Authentication; + +/// Exact application classification shared with the live runtime guard. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +internal sealed class OperationPolicyAttribute(Type requestType) : Attribute +{ + public Type RequestType { get; } = requestType; +} + +/// A named ASP.NET requirement consumes the same canonical application policy. +internal sealed class OperationPolicyRequirement(OperationPolicy policy) : IAuthorizationRequirement +{ + internal const string Prefix = "LearnStackOperation:"; + internal OperationPolicy Policy { get; } = policy; + internal string Name => Prefix + Policy.Id; +} + +internal enum InfrastructureOperationKind { Health = 1, OpenApi = 2, Documentation = 3 } + +/// Only the three actual infrastructure mapping owners can attach these fixed declarations. +internal sealed class InfrastructureOperationMetadata +{ + private InfrastructureOperationMetadata(InfrastructureOperationKind kind) => Kind = kind; + internal InfrastructureOperationKind Kind { get; } + internal static InfrastructureOperationMetadata Health { get; } = new(InfrastructureOperationKind.Health); + internal static InfrastructureOperationMetadata OpenApi { get; } = new(InfrastructureOperationKind.OpenApi); + internal static InfrastructureOperationMetadata Documentation { get; } = new(InfrastructureOperationKind.Documentation); +} + +internal enum HttpOperationClassification { Unmatched = 0, Refused = 1, Application = 2, Infrastructure = 3 } + +/// Matched operations require an exact enumerated controller/action plus canonical metadata. +internal sealed class OperationHttpClassifier(IOperationPolicyCatalog catalog) +{ + internal HttpOperationClassification Classify(Endpoint? endpoint, out OperationPolicy? policy) + { + policy = null; + if (endpoint is null || IsFrameworkMethodRejection(endpoint)) return HttpOperationClassification.Unmatched; + var declarations = endpoint.Metadata.GetOrderedMetadata(); + var infrastructure = endpoint.Metadata.GetOrderedMetadata(); + if (infrastructure.Count != 0) + { + if (declarations.Count != 0 || infrastructure.Count != 1 + || endpoint.Metadata.GetMetadata() is not null) + return HttpOperationClassification.Refused; + return ReferenceEquals(infrastructure[0], InfrastructureOperationMetadata.Health) + || ReferenceEquals(infrastructure[0], InfrastructureOperationMetadata.OpenApi) + || ReferenceEquals(infrastructure[0], InfrastructureOperationMetadata.Documentation) + ? HttpOperationClassification.Infrastructure : HttpOperationClassification.Refused; + } + if (declarations.Count != 1 || endpoint.Metadata.GetMetadata() is not { } action + || endpoint.Metadata.GetMetadata() is not { } name + || endpoint.Metadata.GetMetadata() is not { } methods) + return HttpOperationClassification.Refused; + var expected = Expected(action.ControllerTypeInfo.AsType(), action.MethodInfo.Name); + if (expected is null || declarations[0].RequestType != expected.Value.Request + || name.EndpointName != expected.Value.Name || methods.HttpMethods.Count != 1 + || methods.HttpMethods[0] != expected.Value.Method || !catalog.TryGet(expected.Value.Request, out var found)) + return HttpOperationClassification.Refused; + var authorization = endpoint.Metadata.GetOrderedMetadata(); + var anonymous = endpoint.Metadata.GetOrderedMetadata(); + if (found.Kind == OperationPolicyKind.PublicRead) + { + if (anonymous.Count != 1 || authorization.Count != 0) return HttpOperationClassification.Refused; + } + else if (found.Kind is OperationPolicyKind.AccountRead or OperationPolicyKind.Protected) + { + if (anonymous.Count != 0 || authorization.Count != 1 + || authorization[0].AuthenticationSchemes != TenantAuthenticationRegistration.Scheme + || authorization[0].Policy != OperationPolicyRequirement.Prefix + found.Id + || !string.IsNullOrEmpty(authorization[0].Roles)) return HttpOperationClassification.Refused; + } + else return HttpOperationClassification.Refused; + policy = found; + return HttpOperationClassification.Application; + } + + // ASP.NET's method matcher creates a non-route Endpoint with an empty metadata + // collection and a private closure owned by this exact framework policy. Names + // and lookalike application endpoints supply no such ownership proof. + private static bool IsFrameworkMethodRejection(Endpoint endpoint) => endpoint.GetType() == typeof(Endpoint) + && endpoint.Metadata.Count == 0 + && endpoint.RequestDelegate?.Method.DeclaringType?.DeclaringType == typeof(HttpMethodMatcherPolicy); + + private static (Type Request, string Name, string Method)? Expected(Type controller, string method) => (controller, method) switch + { + var pair when pair == (typeof(PublicSiteController), nameof(PublicSiteController.Get)) => (typeof(GetPublicSiteQuery), "GetPublicSite", "GET"), + var pair when pair == (typeof(PublicSiteController), nameof(PublicSiteController.Head)) => (typeof(GetPublicSiteQuery), "HeadPublicSite", "HEAD"), + var pair when pair == (typeof(PublicEducationController), nameof(PublicEducationController.Catalog)) => (typeof(GetPublicCoursesQuery), "GetPublicCourses", "GET"), + var pair when pair == (typeof(PublicEducationController), nameof(PublicEducationController.HeadCatalog)) => (typeof(GetPublicCoursesQuery), "HeadPublicCourses", "HEAD"), + var pair when pair == (typeof(PublicEducationController), nameof(PublicEducationController.Course)) => (typeof(GetPublicCourseQuery), "GetPublicCourse", "GET"), + var pair when pair == (typeof(PublicEducationController), nameof(PublicEducationController.HeadCourse)) => (typeof(GetPublicCourseQuery), "HeadPublicCourse", "HEAD"), + var pair when pair == (typeof(PublicEducationController), nameof(PublicEducationController.Lesson)) => (typeof(GetPublicLessonQuery), "GetPublicLesson", "GET"), + var pair when pair == (typeof(PublicEducationController), nameof(PublicEducationController.HeadLesson)) => (typeof(GetPublicLessonQuery), "HeadPublicLesson", "HEAD"), + var pair when pair == (typeof(AccountController), nameof(AccountController.GetSite)) => (typeof(GetAccountSiteQuery), "GetAccountSite", "GET"), + var pair when pair == (typeof(MembershipsController), nameof(MembershipsController.List)) => (typeof(ListMembershipsQuery), "ListMemberships", "GET"), + _ => null, + }; +} diff --git a/backend/src/LearnStack.Api/Authentication/TenantAuthenticationDocumentTransformer.cs b/backend/src/LearnStack.Api/Authentication/TenantAuthenticationDocumentTransformer.cs index e83ddcc13..8b13f82d8 100644 --- a/backend/src/LearnStack.Api/Authentication/TenantAuthenticationDocumentTransformer.cs +++ b/backend/src/LearnStack.Api/Authentication/TenantAuthenticationDocumentTransformer.cs @@ -1,21 +1,25 @@ using LearnStack.Api.PublicReads; +using LearnStack.SharedKernel.Pagination; +using System.Text.Json.Nodes; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; namespace LearnStack.Api.Authentication; -/// Documents the one authenticated tenant read admitted by ADR-0064. +/// Documents the two closed authenticated reads and their distinct authority/disclosure bounds. internal sealed class TenantAuthenticationDocumentTransformer : IOpenApiDocumentTransformer { public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(document); - if (!document.Paths.TryGetValue(TenantSurfaceAdmissionMiddleware.AccountSitePath, out var path) - || path.Operations?.TryGetValue(HttpMethod.Get, out var operation) != true - || operation is null) return Task.CompletedTask; + var operation = Get(document, TenantSurfaceAdmissionMiddleware.AccountSitePath); + var directory = Get(document, TenantSurfaceAdmissionMiddleware.MembershipsPath); + if (operation is null && directory is null) return Task.CompletedTask; document.Components ??= new OpenApiComponents(); document.Components.Schemas ??= new Dictionary(); + if (document.Components.Schemas.TryGetValue("DirectoryMembershipStatus", out var status) && status is OpenApiSchema statusSchema) + statusSchema.Type = JsonSchemaType.String; document.Components.Schemas.TryAdd("ProblemDetails", PublicReadDocumentTransformer.Problem()); document.Components.SecuritySchemes ??= new Dictionary(); document.Components.SecuritySchemes[TenantAuthenticationRegistration.Scheme] = new OpenApiSecurityScheme @@ -25,19 +29,61 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC BearerFormat = "JWT", Description = "The configured learnstack realm's signed RS256 access token for learnstack-api, issued to learnstack-web. Authentication grants no membership or application permission." }; - operation.Security = [new OpenApiSecurityRequirement { [new OpenApiSecuritySchemeReference(TenantAuthenticationRegistration.Scheme, document)] = [] }]; - operation.Description = "Authenticated host-backed read through the physical read-only pipeline. Returns only the validated actor UUID, resolved tenant/organization UUIDs and existing public site projection. Accepts no query parameters or client-selected identifiers. No response caching or validators."; - PublicReadDocumentTransformer.DocumentResponses(document, operation, HttpMethod.Get, - [ - ("400", "validation_failed; no query parameters are accepted, and malformed or repeated assertion headers are refused."), + if (operation is not null) + { + operation.Security = [new OpenApiSecurityRequirement { [new OpenApiSecuritySchemeReference(TenantAuthenticationRegistration.Scheme, document)] = [] }]; + operation.Description = "Authenticated host-backed read through the physical read-only pipeline. Returns only the validated actor UUID, resolved tenant/organization UUIDs and existing public site projection. Accepts no query parameters or client-selected identifiers. No response caching or validators."; + PublicReadDocumentTransformer.DocumentResponses(document, operation, HttpMethod.Get, + [ + ("400", "validation_failed; no query parameters are accepted, and malformed or repeated assertion headers are refused."), ("401", "unauthorized; present invalid or undecidable credentials on a known host. Bare Bearer challenge; no credential or provider details."), ("404", "not_found for absent credentials, unknown hosts, membership refusals or missing host-backed authority; tenant_mismatch for authenticated host/claim or header disagreement, without disclosure."), ("405", "method_not_allowed from routing after credential and host-backed authority admission; this operation admits GET only. Absent credentials remain masked as 404."), ("413", "payload_too_large; the decoded body bound precedes host classification and authentication. Kestrel framing/backstop refusals may occur outside the application response policy and omit these headers/body."), ("429", "rate_limited from early visitor/peer admission, validated-subject quota or subject-registry capacity."), ("500", "internal_error from unexpected failures; no exception or query details."), - ("503", "dependency_unavailable from invalid stored public configuration or a sticky subject-registry cleanup fault.") - ], allowedMethods: "GET"); + ("503", "dependency_unavailable from live authorization storage, invalid public configuration or sticky subject-registry cleanup fault; refused audit writes preserve the original refusal.") + ], allowedMethods: "GET"); + } + if (directory is not null) DocumentDirectory(document, directory); return Task.CompletedTask; } + + private static OpenApiOperation? Get(OpenApiDocument document, string path) => + document.Paths.TryGetValue(path, out var item) && item.Operations?.TryGetValue(HttpMethod.Get, out var operation) == true + ? operation : null; + + private static void DocumentDirectory(OpenApiDocument document, OpenApiOperation operation) + { + operation.Security = [new OpenApiSecurityRequirement { [new OpenApiSecuritySchemeReference(TenantAuthenticationRegistration.Scheme, document)] = [] }]; + operation.Description = "Tenant administrative membership directory requiring current Tenant identity.membership.admin. Rechecks live membership, lifecycle and capability for every page; accepts HostAndClaim or confirmed ClaimAndMembership, including a platform host. Returns only membership/user/organization identifiers, status, contextual displayName and preferredLocale. Active/Suspended undeleted memberships only, ordered by membership UUID. No search, sorting, editing, provider data, grants or global profile. No-store; the MUST ReadSensitive audit row commits before any result is disclosed. This operation is not admitted by the native BFF caller."; + operation.Parameters = + [ + new OpenApiParameter + { + Name = "cursor", In = ParameterLocation.Query, Required = false, + Description = "Opaque canonical continuation bound to the actor, tenant, organization and operation; obtain it from pageInfo.nextCursor. Every page reauthorizes.", + Schema = new OpenApiSchema { Type = JsonSchemaType.String, MinLength = 1, MaxLength = 1024 }, + }, + new OpenApiParameter + { + Name = "limit", In = ParameterLocation.Query, Required = false, + Description = "Positive page size, default 20; values above 100 are clamped to 100. Repeated, empty or unknown query parameters are refused.", + Schema = new OpenApiSchema { Type = JsonSchemaType.Integer, Format = "int32", Minimum = "1", Maximum = "2147483647", Default = JsonValue.Create(CursorPagination.DefaultLimit) }, + }, + ]; + PublicReadDocumentTransformer.DocumentResponses(document, operation, HttpMethod.Get, + [ + ("400", "validation_failed for noncanonical/replayed/oversized cursors, invalid limit or unknown/repeated/empty query parameters; malformed assertion headers are also refused."), + ("401", "unauthorized for present invalid or undecidable credentials on known hosts, including existing-binding failure; bare Bearer challenge without provider details."), + ("403", "forbidden for confirmed coverage without the current Tenant administrative capability; no directory lookup or disclosure."), + ("404", "not_found for absent credentials, noncoverage or untrusted scope; tenant_mismatch for host/claim or header disagreement. Assertions do not select authority."), + ("405", "method_not_allowed from routing after earlier credential, resolution and surface gates; GET only."), + ("413", "payload_too_large from the existing decoded body bound; outer Kestrel framing refusals may omit application policy."), + ("429", "rate_limited from visitor/subject admission or subject-registry capacity."), + ("500", "internal_error for unexpected failures, without exception/query details."), + ("503", "dependency_unavailable for live membership/lifecycle storage, auxiliary admission or sticky subject-registry cleanup fault; audit_unavailable when the mandatory sensitive-read row cannot commit. No membership payload is disclosed."), + ], allowedMethods: "GET"); + } + } diff --git a/backend/src/LearnStack.Api/Authentication/TenantAuthorizationCandidateSource.cs b/backend/src/LearnStack.Api/Authentication/TenantAuthorizationCandidateSource.cs new file mode 100644 index 000000000..68b5068fb --- /dev/null +++ b/backend/src/LearnStack.Api/Authentication/TenantAuthorizationCandidateSource.cs @@ -0,0 +1,62 @@ +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.Api.Authentication; + +/// Mints only the exact claim/host candidate after quota-admitted existing-binding normalization. +internal sealed class TenantAuthorizationCandidateSource(IHttpContextAccessor accessor) + : AuthorizationCandidate.MintingSource, IAuthorizationCandidateSource +{ + AuthorizationCandidate? IAuthorizationCandidateSource.ForValidatedScope(TenantId tenant, OrganizationId? organization) => + ForValidatedScope(tenant, organization); + + internal AuthorizationCandidate? ForValidatedScope(TenantId tenant, OrganizationId? organization) + { + var http = accessor.HttpContext; + if (http is null || http.RequestAborted.IsCancellationRequested + || http.Features.Get() is not { } host + || http.Features.Get() is not { VerifiedSubject: { } subject, Identity: { } identity } + || http.Features.Get() is not { IsAcquired: true } decision + || http.Features.Get() is not { } provenance + || !provenance.Matches(subject) || !MatchesIdentity(identity, subject) + || identity.TenantId != tenant || organization != (identity.OrganizationId ?? host.OrganizationId) + || (host.TenantId is { } hostTenant && hostTenant != tenant) + || (host.OrganizationId is { } hostOrganization && hostOrganization != organization)) + return null; + + return ForCandidate(AuthorizationSourceKind.Http, identity.ActorUserId, tenant, organization, + new CredentialWitness(http, host, subject, identity, decision, provenance)); + } + + protected override bool IsEvidenceCurrent(object? evidence) => evidence is CredentialWitness witness + && ReferenceEquals(accessor.HttpContext, witness.Http) + && !witness.Http.RequestAborted.IsCancellationRequested + && ReferenceEquals(witness.Http.Features.Get(), witness.Host) + && witness.Http.Features.Get() is { } feature + && ReferenceEquals(feature.Identity, witness.Identity) + && ReferenceEquals(feature.VerifiedSubject, witness.Subject) + && ReferenceEquals(witness.Http.Features.Get(), witness.Decision) + && ReferenceEquals(witness.Http.Features.Get(), witness.Provenance) + && witness.Provenance.Matches(witness.Subject); + + private static bool MatchesIdentity(ValidatedTenantIdentity identity, VerifiedProviderSubject subject) => + identity.Issuer == subject.Issuer && identity.Subject == subject.Subject + && (subject.ClaimedActor is null || subject.ClaimedActor == identity.ActorUserId) + && identity.TenantId == subject.TenantId && identity.OrganizationId == subject.OrganizationId + && StronglyTypedId.IsAssigned(identity.ActorUserId) && identity.ActorUserId != UserId.SystemActor; + + private sealed class CredentialWitness(HttpContext http, HostClassification host, VerifiedProviderSubject subject, + ValidatedTenantIdentity identity, SubjectAdmissionDecision decision, + TenantSubjectAdmissionProvenance provenance) + { + internal HttpContext Http { get; } = http; + internal HostClassification Host { get; } = host; + internal VerifiedProviderSubject Subject { get; } = subject; + internal ValidatedTenantIdentity Identity { get; } = identity; + internal SubjectAdmissionDecision Decision { get; } = decision; + internal TenantSubjectAdmissionProvenance Provenance { get; } = provenance; + + public override string ToString() => nameof(CredentialWitness); + } +} diff --git a/backend/src/LearnStack.Api/Authentication/TenantAuthorizationResultHandler.cs b/backend/src/LearnStack.Api/Authentication/TenantAuthorizationResultHandler.cs index 56579a5c3..1b42c2164 100644 --- a/backend/src/LearnStack.Api/Authentication/TenantAuthorizationResultHandler.cs +++ b/backend/src/LearnStack.Api/Authentication/TenantAuthorizationResultHandler.cs @@ -14,6 +14,8 @@ public Task HandleAsync(RequestDelegate next, HttpContext context, Authorization { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(authorizeResult); + if (!authorizeResult.Succeeded && context.Features.Get() is { } refusal) + return OperationClassificationMiddleware.RefuseAsync(context, refusal.Error); if (authorizeResult.Challenged && context.Features.Get()?.Identity is null && context.Features.Get() is not null diff --git a/backend/src/LearnStack.Api/Authentication/TenantSurfaceAdmissionMiddleware.cs b/backend/src/LearnStack.Api/Authentication/TenantSurfaceAdmissionMiddleware.cs index 85b3ff85f..8551e43c3 100644 --- a/backend/src/LearnStack.Api/Authentication/TenantSurfaceAdmissionMiddleware.cs +++ b/backend/src/LearnStack.Api/Authentication/TenantSurfaceAdmissionMiddleware.cs @@ -1,4 +1,5 @@ using LearnStack.Api.Account; +using LearnStack.Api.Identity; using LearnStack.Api.Tenancy; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Tenancy; @@ -6,10 +7,20 @@ namespace LearnStack.Api.Authentication; -/// HTTP admission for the one host-backed authenticated read. +/// Preserves the distinct authority ceilings of the two authenticated reads. internal sealed class TenantSurfaceAdmissionMiddleware(RequestDelegate next) { internal const string AccountSitePath = "/api/v1/account/site"; + internal const string MembershipsPath = "/api/v1/identity/memberships"; + + internal static bool IsMembershipsPath(PathString path) => + path.Equals(MembershipsPath, StringComparison.OrdinalIgnoreCase) + || path.Equals(MembershipsPath + "/", StringComparison.OrdinalIgnoreCase); + + internal static bool IsMembershipsEndpoint(HttpContext context) => + context.GetEndpoint()?.Metadata.GetMetadata() is { } action + && action.ControllerTypeInfo.AsType() == typeof(MembershipsController) + && action.MethodInfo.Name == nameof(MembershipsController.List); internal static bool IsAccountSitePath(PathString path) => path.Equals(AccountSitePath, StringComparison.OrdinalIgnoreCase) @@ -24,6 +35,15 @@ public Task InvokeAsync(HttpContext context, ITenantContext tenantContext) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(tenantContext); + if (context.Features.Get() is not null + && (IsMembershipsEndpoint(context) || IsMembershipsPath(context.Request.Path)) + && (context.Features.Get()?.Identity is null + || !tenantContext.IsResolved + || tenantContext.Origin is not (TenantContextOrigin.HostAndClaim or TenantContextOrigin.ClaimAndMembership))) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + } if (context.Features.Get() is not null && (IsAccountSiteEndpoint(context) || IsAccountSitePath(context.Request.Path)) diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 44a9191b9..8d9b588c9 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -97,6 +97,20 @@ public static class PersistenceCompositionExtensions /// The role required by the application data source. internal const string RuntimeRole = ApplicationDataSource.RuntimeRole; + /// Captures the coupled app-role source configuration at the existing credential boundary. + internal static IServiceCollection AddLearnStackAuthorizationSourceConfiguration( + this IServiceCollection services, IConfiguration configuration, bool allowDevelopmentDerivation) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + var business = configuration.GetConnectionString("Default"); + var authorization = configuration.GetConnectionString("IdentityAuthorization"); + var evidence = configuration.GetConnectionString("AuthorizationEvidence"); + services.TryAddSingleton(_ => AuthorizationSourceConfiguration.Resolve( + business, authorization, evidence, allowDevelopmentDerivation)); + return services; + } + public static IServiceCollection AddLearnStackPersistence( this IServiceCollection services, IConfiguration configuration) { diff --git a/backend/src/LearnStack.Api/Identity/MembershipsController.cs b/backend/src/LearnStack.Api/Identity/MembershipsController.cs new file mode 100644 index 000000000..c90c4f263 --- /dev/null +++ b/backend/src/LearnStack.Api/Identity/MembershipsController.cs @@ -0,0 +1,32 @@ +using LearnStack.Api.Authentication; +using LearnStack.Api.Common; +using LearnStack.Api.Pagination; +using LearnStack.Modules.Identity.Application; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Pagination; +using LearnStack.SharedKernel.Results; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LearnStack.Api.Identity; + +/// Tenant administrative directory; publication requires the common MUST audit commit. +[Authorize(AuthenticationSchemes = TenantAuthenticationRegistration.Scheme, + Policy = OperationPolicyRequirement.Prefix + IdentityOperationPolicySource.MembershipDirectoryOperation)] +[Route("identity/memberships")] +public sealed class MembershipsController(ISender sender) : ApiControllerBase +{ + [HttpGet(Name = "ListMemberships")] + [OperationPolicy(typeof(ListMembershipsQuery))] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task List([FromQuery] CursorPaginationRequest pagination, CancellationToken cancellationToken) + { + if (Request.Query.Any(pair => pair.Key is not ("cursor" or "limit") || pair.Value.Count != 1 + || string.IsNullOrWhiteSpace(pair.Value[0]))) + return Result.Fail>(new Error(new LocalizedMessage("lockey_validation_failed"))).ToActionResult(); + return (await sender.Send(new ListMembershipsQuery(pagination.Cursor, + pagination.Limit ?? CursorPagination.DefaultLimit), cancellationToken)).ToActionResult(); + } +} diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index 8240b30c7..b1c874f11 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -55,7 +55,7 @@ TenantAuthenticationOptions.FromConfiguration(builder.Configuration, deploymentMode)); builder.Services.AddLearnStackTenantSubjectAdmission(); builder.Services.AddLearnStackTenantIdentityBindingAdmission(); -builder.Services.AddAuthorization(); +builder.Services.AddLearnStackLiveAuthorization(builder.Configuration, deploymentMode); builder.Services.AddSingleton(); // The outer half of the body bound, and deliberately NOT the same number. @@ -141,11 +141,13 @@ // and before the endpoints so no handler runs on a request that lost it. app.UseLearnStackTenantAssertions(); +app.UseLearnStackOperationClassification(); app.UseAuthorization(); app.UseLearnStackTenantSurfaceAdmission(); app.MapGet("/healthz", () => Results.Ok(new { status = "healthy" })) - .WithName("HealthCheck"); + .WithName("HealthCheck") + .WithMetadata(InfrastructureOperationMetadata.Health); app.MapControllers(); diff --git a/backend/src/LearnStack.Api/PublicReads/PublicEducationController.cs b/backend/src/LearnStack.Api/PublicReads/PublicEducationController.cs index 30d230175..63d1f815f 100644 --- a/backend/src/LearnStack.Api/PublicReads/PublicEducationController.cs +++ b/backend/src/LearnStack.Api/PublicReads/PublicEducationController.cs @@ -1,4 +1,5 @@ using LearnStack.Api.Common; +using LearnStack.Api.Authentication; using LearnStack.Modules.Education.Application.Contracts.PublicReads; using MediatR; using Microsoft.AspNetCore.Authorization; @@ -12,26 +13,32 @@ namespace LearnStack.Api.PublicReads; public sealed class PublicEducationController(ISender sender) : ApiControllerBase { [HttpGet(Name = "GetPublicCourses")] + [OperationPolicy(typeof(GetPublicCoursesQuery))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task Catalog(CancellationToken cancellationToken) => (await sender.Send(new GetPublicCoursesQuery(PublicReadTransport.Input(HttpContext, false, false)), cancellationToken)).ToActionResult(); [HttpHead(Name = "HeadPublicCourses")] + [OperationPolicy(typeof(GetPublicCoursesQuery))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task HeadCatalog(CancellationToken cancellationToken) => (await sender.Send(new GetPublicCoursesQuery(PublicReadTransport.Input(HttpContext, false, false)), cancellationToken)).ToActionResult(); [HttpGet("{slug}", Name = "GetPublicCourse")] + [OperationPolicy(typeof(GetPublicCourseQuery))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task Course(string slug, CancellationToken cancellationToken) => (await sender.Send(new GetPublicCourseQuery(slug, PublicReadTransport.Input(HttpContext, true, false)), cancellationToken)).ToActionResult(); [HttpHead("{slug}", Name = "HeadPublicCourse")] + [OperationPolicy(typeof(GetPublicCourseQuery))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task HeadCourse(string slug, CancellationToken cancellationToken) => (await sender.Send(new GetPublicCourseQuery(slug, PublicReadTransport.Input(HttpContext, true, false)), cancellationToken)).ToActionResult(); [HttpGet("{slug}/lessons/{lessonSlug}", Name = "GetPublicLesson")] + [OperationPolicy(typeof(GetPublicLessonQuery))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task Lesson(string slug, string lessonSlug, CancellationToken cancellationToken) => (await sender.Send(new GetPublicLessonQuery(slug, lessonSlug, PublicReadTransport.Input(HttpContext, false, true)), cancellationToken)).ToActionResult(); [HttpHead("{slug}/lessons/{lessonSlug}", Name = "HeadPublicLesson")] + [OperationPolicy(typeof(GetPublicLessonQuery))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task HeadLesson(string slug, string lessonSlug, CancellationToken cancellationToken) => (await sender.Send(new GetPublicLessonQuery(slug, lessonSlug, PublicReadTransport.Input(HttpContext, false, true)), cancellationToken)).ToActionResult(); diff --git a/backend/src/LearnStack.Api/PublicReads/PublicResponsePolicy.cs b/backend/src/LearnStack.Api/PublicReads/PublicResponsePolicy.cs index b226c5d98..374a3b468 100644 --- a/backend/src/LearnStack.Api/PublicReads/PublicResponsePolicy.cs +++ b/backend/src/LearnStack.Api/PublicReads/PublicResponsePolicy.cs @@ -13,6 +13,8 @@ public static IApplicationBuilder UseLearnStackPublicResponsePolicy(this IApplic { if (!TenantSurfaceAdmissionMiddleware.IsAccountSitePath(context.Request.Path) && !TenantSurfaceAdmissionMiddleware.IsAccountSiteEndpoint(context) + && !TenantSurfaceAdmissionMiddleware.IsMembershipsPath(context.Request.Path) + && !TenantSurfaceAdmissionMiddleware.IsMembershipsEndpoint(context) && !ApiVersioningExtensions.LiveMajors.Any(major => context.Request.Path.StartsWithSegments($"/api/v{major}/public", StringComparison.OrdinalIgnoreCase))) { diff --git a/backend/src/LearnStack.Api/PublicReads/PublicSiteController.cs b/backend/src/LearnStack.Api/PublicReads/PublicSiteController.cs index eb664d3d0..1fd73045b 100644 --- a/backend/src/LearnStack.Api/PublicReads/PublicSiteController.cs +++ b/backend/src/LearnStack.Api/PublicReads/PublicSiteController.cs @@ -1,4 +1,5 @@ using LearnStack.Api.Common; +using LearnStack.Api.Authentication; using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; using MediatR; using Microsoft.AspNetCore.Authorization; @@ -12,11 +13,13 @@ namespace LearnStack.Api.PublicReads; public sealed class PublicSiteController(ISender sender) : ApiControllerBase { [HttpGet(Name = "GetPublicSite")] + [OperationPolicy(typeof(GetPublicSiteQuery))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task Get(CancellationToken cancellationToken) => (await sender.Send(new GetPublicSiteQuery(Request.Query.Count != 0), cancellationToken)).ToActionResult(); [HttpHead(Name = "HeadPublicSite")] + [OperationPolicy(typeof(GetPublicSiteQuery))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task Head(CancellationToken cancellationToken) => (await sender.Send(new GetPublicSiteQuery(Request.Query.Count != 0), cancellationToken)).ToActionResult(); diff --git a/backend/src/LearnStack.Api/Tenancy/IClaimContextAssertionRecorder.cs b/backend/src/LearnStack.Api/Tenancy/IClaimContextAssertionRecorder.cs new file mode 100644 index 000000000..f00e85821 --- /dev/null +++ b/backend/src/LearnStack.Api/Tenancy/IClaimContextAssertionRecorder.cs @@ -0,0 +1,9 @@ +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Api.Tenancy; + +/// Records only the fixed mismatch dimension under a previously confirmed claim context. +public interface IClaimContextAssertionRecorder +{ + Task RecordAsync(ITenantContext context, TenantAssertionDimension dimension); +} diff --git a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs index a2d8046a2..6adc47f9a 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs @@ -203,9 +203,9 @@ public static IServiceCollection AddLearnStackTenancyEdge( // 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. + // does, and the demo would pass while production 404'd. The joint live + // authorization composition replaces this closed edge default in every + // mode before the actual host starts; partial edge-only graphs stay denied. services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs index 63f5c6ac0..03388f6b5 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenantAssertionMiddleware.cs @@ -40,11 +40,13 @@ public sealed class TenantAssertionMiddleware(RequestDelegate next) public async Task InvokeAsync( HttpContext context, ITenantContext tenantContext, - ITenantAssertionRecorder recorder) + ITenantAssertionRecorder recorder, + IClaimContextAssertionRecorder claimRecorder) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(tenantContext); ArgumentNullException.ThrowIfNull(recorder); + ArgumentNullException.ThrowIfNull(claimRecorder); if (!HostClassificationMiddleware.ClassifiesPath(context.Request.Path)) { @@ -108,12 +110,15 @@ public async Task InvokeAsync( if (mismatch is not null) { var identity = context.Features.Get()?.Identity; - await recorder.RecordRejectionAsync(new TenantAssertionRejection( - tenantContext.TenantId.Value, mismatch.Value.Dimension, mismatch.Value.Asserted, - IsAuthenticated: identity is not null, TenantAssertionSource.Header, - identity?.ActorUserId, tenantContext.CorrelationId, - tenantContext.OrganizationId is { } organization && StronglyTypedId.IsAssigned(organization) - ? organization : null)); + if (tenantContext.Origin == TenantContextOrigin.ClaimAndMembership) + await claimRecorder.RecordAsync(tenantContext, mismatch.Value.Dimension); + else + await recorder.RecordRejectionAsync(new TenantAssertionRejection( + tenantContext.TenantId.Value, mismatch.Value.Dimension, mismatch.Value.Asserted, + IsAuthenticated: identity is not null, TenantAssertionSource.Header, + identity?.ActorUserId, tenantContext.CorrelationId, + tenantContext.OrganizationId is { } organization && StronglyTypedId.IsAssigned(organization) + ? organization : null)); if (identity is not null) { diff --git a/backend/src/LearnStack.Api/Versioning/ApiVersioningExtensions.cs b/backend/src/LearnStack.Api/Versioning/ApiVersioningExtensions.cs index 7ffd84942..468d8da5d 100644 --- a/backend/src/LearnStack.Api/Versioning/ApiVersioningExtensions.cs +++ b/backend/src/LearnStack.Api/Versioning/ApiVersioningExtensions.cs @@ -184,7 +184,8 @@ public static WebApplication MapLearnStackOpenApi(this WebApplication app) // in the one Problem Details shape. var documentNames = string.Join( "|", app.Services.GetRequiredService().Names); - app.MapOpenApi($"/openapi/{{documentName:regex(^({documentNames})$)}}.json"); + app.MapOpenApi($"/openapi/{{documentName:regex(^({documentNames})$)}}.json") + .WithMetadata(Authentication.InfrastructureOperationMetadata.OpenApi); // The console lives at /docs, not under /openapi. Scalar maps its own // `{documentName?}` inside the prefix it is given, so a prefix under @@ -204,7 +205,7 @@ public static WebApplication MapLearnStackOpenApi(this WebApplication app) // SelfHostedAirGapped deployment, where nothing outside the // network is reachable by design ([ADR-0020]). options.WithProxy(string.Empty); - }); + }).WithMetadata(Authentication.InfrastructureOperationMetadata.Documentation); return app; } diff --git a/backend/src/LearnStack.Application/Audit/AuditExecutionCoordinator.cs b/backend/src/LearnStack.Application/Audit/AuditExecutionCoordinator.cs index a487d6f1a..68bd2791c 100644 --- a/backend/src/LearnStack.Application/Audit/AuditExecutionCoordinator.cs +++ b/backend/src/LearnStack.Application/Audit/AuditExecutionCoordinator.cs @@ -38,7 +38,8 @@ public sealed class AuditExecutionCoordinator( IClock clock, IGuidFactory guidFactory, ILogger logger, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + AuthorizationEvidenceCoordinator? authorizationEvidence = null) { /// /// Classifies the declared operation before invoking its deferred work. A direct @@ -135,6 +136,7 @@ public async Task ExecuteGlobalIdentityAsync( private async Task ExecuteDeclaredAsync(Func declare, Func> next, bool globalIdentity) where TResponse : IResultBase { + await using var evidenceScope = authorizationEvidence?.BeginExecution(); var outermost = !capture.HasOpenFrame; // A frame per invocation, outermost or nested, so each intent belongs to the @@ -153,6 +155,8 @@ private async Task ExecuteDeclaredAsync(Func declare JobApplicationSourceOwner.Current?.RequireBusinessReady(); var response = await next().ConfigureAwait(false); + if (response.IsSuccess && authorizationEvidence is not null) + await authorizationEvidence.BeforeSuccessAsync(CancellationToken.None).ConfigureAwait(false); // Recorded rather than acted on. Every exit reconciles in one place below, so a // refusal, an exception and a cancellation cannot drift apart — and the @@ -192,6 +196,8 @@ private async Task ExecuteDeclaredAsync(Func declare } finally { + if (authorizationEvidence is not null) + await authorizationEvidence.ReconcileBeforeCaptureClearAsync().ConfigureAwait(false); // The capture owns both attribution and frame ownership. Cleanup is // scoped to this execution's capture, so another DI scope on the same // async flow cannot suppress its own reconciliation. diff --git a/backend/src/LearnStack.Application/Audit/AuthorizationEvidenceCoordinator.cs b/backend/src/LearnStack.Application/Audit/AuthorizationEvidenceCoordinator.cs new file mode 100644 index 000000000..ab369d6a5 --- /dev/null +++ b/backend/src/LearnStack.Application/Audit/AuthorizationEvidenceCoordinator.cs @@ -0,0 +1,413 @@ +using System.Data.Common; +using LearnStack.Application.Authorization; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Time; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Application.Audit; + +/// Independent per-occurrence MUST state; business capture completion never acknowledges these events. +public sealed class AuthorizationEvidenceCoordinator( + IAuditStore store, IAuditStateCapture capture, IUnitOfWork unit, IClock clock, IGuidFactory guids, + Func current, IEnumerable transportSources, + ILogger logger) + : AuthorizationAuditContext.MintingSource, IAuthorizationRefusalReporter, IDisposable +{ + private readonly Dictionary _entries = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _contexts = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _assertions = new(ReferenceEqualityComparer.Instance); + private readonly IAuthorizationTransportSource[] _transports = [.. transportSources]; + private readonly SemaphoreSlim _writes = new(1, 1); + private int _depth; + private int _closed; + private bool _compositionFailed; + + public ExecutionScope BeginExecution() + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _closed) != 0, this); + if (_depth == 0 && (_entries.Count != 0 || _compositionFailed)) + throw new InvalidOperationException("Authorization evidence still belongs to an earlier execution."); + return new ExecutionScope(this, ++_depth); + } + + public void Observe(OperationInvocation invocation, ProtectedOperationDecision? decision) + { + if (!HasAttribution(invocation) && !(invocation.IsActive + && invocation.TrustedSource is { SourceKind: AuthorizationSourceKind.Seed } + && invocation.Operation is LearnStack.SharedKernel.Tenancy.IProvisionsTenant)) return; + try + { + var context = Snapshot(invocation, decision); + capture.AttachAuthorization(context); + _contexts[invocation] = context; + } + catch (Exception failure) when (failure is not OutOfMemoryException and not StackOverflowException) + { CompositionFailed(); } + } + + public void ReportRecheck(ProtectedOperationDecision decision) + { + ArgumentNullException.ThrowIfNull(decision); + if (decision.Outcome != ProtectedOperationOutcome.Allowed) + throw new ArgumentException("A successful recheck requires an allowed current decision.", nameof(decision)); + Observe(decision.Invocation, decision); + if (_compositionFailed) throw new AuditWriteFailedException("Trusted authorization evidence could not be composed."); + } + + public void ReportResourceRefusal(ProtectedOperationDecision decision, AuthorizationRefusalReason reason) + { + ArgumentNullException.ThrowIfNull(decision); + if (!Enum.IsDefined(reason)) throw new ArgumentOutOfRangeException(nameof(reason)); + RecordDenial(decision.Invocation, decision, decision.Outcome == ProtectedOperationOutcome.NotCovered + ? AuthorizationRefusalReason.Coverage : reason, + decision.Outcome == ProtectedOperationOutcome.NotCovered ? "lockey_not_found" : "lockey_forbidden"); + } + + public void RecordPolicyRefusal(OperationInvocation invocation, ProtectedOperationDecision? decision, Error error) + { + ArgumentNullException.ThrowIfNull(error); + if (error.Code is not ("forbidden" or "not_found" or "resource_scope_violation" or "feature_disabled" or "limit_exceeded")) return; + RecordDenial(invocation, decision, error.Code == "not_found" ? AuthorizationRefusalReason.Coverage + : error.Code == "resource_scope_violation" ? AuthorizationRefusalReason.ResourceScope : AuthorizationRefusalReason.Capability, + error.Message.Key, _contexts.GetValueOrDefault(invocation)); + } + + /// Retains only audit observation of this admitted request across its handler await. + internal RefusalObservation? CaptureRefusalObservation(ExecutionScope? scope, Type? operationType, object? operation) + { + if (scope is null || !scope.IsOpen(this) || operationType is null || operation is null + || current() is not OperationAuthorizationCoordinator execution + || execution.CurrentInvocation is not { EntryKind: OperationEntryKind.Application } invocation + || invocation.Policy.RequestType != operationType || operation.GetType() != operationType + || !ReferenceEquals(invocation.Operation, operation) || !HasAttribution(invocation) + || !_contexts.ContainsKey(invocation)) return null; + return new RefusalObservation(this, scope, execution, invocation, operationType, operation); + } + + /// Consumes the actual result once; cancellation does not erase the admitted act's frozen audit context. + internal void RecordRefusalResult(RefusalObservation? observation, Type? operationType, object? operation, IResultBase response) + { + ArgumentNullException.ThrowIfNull(response); + if (observation is null || !observation.TryConsume(this, operationType, operation) + || !response.IsFailure || response.Error is not { } error + || error.Code is not ("forbidden" or "not_found" or "resource_scope_violation" or "feature_disabled" or "limit_exceeded") + || !_contexts.TryGetValue(observation.Invocation, out var context)) + return; + try + { + RecordObservedDenial(observation.Invocation, context, + error.Code == "not_found" ? AuthorizationRefusalReason.Coverage + : error.Code == "resource_scope_violation" ? AuthorizationRefusalReason.ResourceScope : AuthorizationRefusalReason.Capability, + error.Message.Key); + } + catch (Exception failure) when (failure is not OutOfMemoryException and not StackOverflowException) + { CompositionFailed(); } + } + + /// The API has already decided its masked refusal; audit failure never replaces that refusal. + public async Task RecordConfirmedAssertionAsync(ConfirmedClaimAssertion assertion) + { + ArgumentNullException.ThrowIfNull(assertion); + await _writes.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + if (!_assertions.TryGetValue(assertion, out var entry)) + { + if (!assertion.IsActive) { CompositionFailed(); return; } + var authorization = "{\"version\":1,\"policy\":\"tenancy.assertion.reject_claim_context\",\"source\":\"http\",\"memberships\":[],\"roles\":[]" + + (assertion.Route is { } route ? ",\"route\":" + AuditJson.Quote(route) : string.Empty) + "}"; + var draft = new AuditEntryDraft + { + Id = AuditEntryId.From(guids.NewUuidV7()), + TenantId = assertion.TenantId, + OrganizationId = assertion.OrganizationId, + ActorUserId = assertion.Actor, + ActorEmail = null, + ModuleName = "tenancy", + Operation = "tenancy.assertion.reject_claim_context", + OperationType = OperationType.SecurityEvent, + OperationClass = OperationClass.Must, + EntityType = null, + EntityId = null, + Outcome = AuditOutcome.Denied, + ErrorKey = "lockey_tenant_mismatch", + Reason = null, + BeforeState = null, + AfterState = null, + Changes = null, + CorrelationId = assertion.CorrelationId, + IpAddress = assertion.Address, + UserAgent = assertion.UserAgent, + Timestamp = clock.UtcNow, + Metadata = "{\"authorization\":" + authorization + + ",\"assertion_source\":\"membership_confirmed_header\",\"mismatch_reason\":\"conflicts_with_confirmed_context\",\"dimension\":" + + AuditJson.Quote(assertion.Dimension == ClaimAssertionDimension.Tenant ? "tenant" : "organization") + "}", + }; + entry = new Entry(ForOccurrence(AuthorizationEvidenceKind.ClaimAssertion, draft)); + _assertions.Add(assertion, entry); + } + if (entry.Acknowledged || entry.StandaloneFailed) return; + try + { + await store.WriteAuthorizationStandaloneAsync(entry.Occurrence, CancellationToken.None).ConfigureAwait(false); + entry.Acknowledged = true; + } + catch (Exception failure) when (failure is not OutOfMemoryException and not StackOverflowException) + { entry.StandaloneFailed = true; /* The MUST store reports the explicit loss. */ } + } + catch (Exception failure) when (failure is not OutOfMemoryException and not StackOverflowException) + { CompositionFailed(); } + finally { _writes.Release(); } + } + + private void RecordDenial(OperationInvocation invocation, ProtectedOperationDecision? decision, AuthorizationRefusalReason reason, + string errorKey = "lockey_forbidden", AuthorizationAuditContext? observedContext = null) + { + if (_entries.ContainsKey(invocation) || !HasAttribution(invocation)) return; + try + { + var context = observedContext ?? Snapshot(invocation, decision); + RecordObservedDenial(invocation, context, reason, errorKey); + } + catch (Exception failure) when (failure is not OutOfMemoryException and not StackOverflowException) + { CompositionFailed(); } + } + + private void RecordObservedDenial(OperationInvocation invocation, AuthorizationAuditContext context, + AuthorizationRefusalReason reason, string errorKey) + { + if (_entries.ContainsKey(invocation)) return; + capture.AttachAuthorization(context); + var metadata = "{\"authorization\":" + context.ToJson() + + ",\"denial_reason\":" + AuditJson.Quote(reason switch + { + AuthorizationRefusalReason.Capability => "capability", + AuthorizationRefusalReason.ResourceScope => "resource_scope", + AuthorizationRefusalReason.Delegation => "delegation", + AuthorizationRefusalReason.Ownership => "ownership", + AuthorizationRefusalReason.Coverage => "coverage", + _ => throw new InvalidOperationException("Authorization refusal is unknown."), + }) + "}"; + var draft = new AuditEntryDraft + { + Id = AuditEntryId.From(guids.NewUuidV7()), + TenantId = context.TenantId, + OrganizationId = context.OrganizationId, + ActorUserId = context.Actor, + ActorEmail = null, + ModuleName = "identity", + Operation = "identity.authorization.deny", + OperationType = OperationType.SecurityEvent, + OperationClass = OperationClass.Must, + EntityType = null, + EntityId = null, + Outcome = AuditOutcome.Denied, + ErrorKey = errorKey, + Reason = null, + BeforeState = null, + AfterState = null, + Changes = null, + CorrelationId = invocation.Context.CorrelationId, + IpAddress = context.Address, + UserAgent = context.UserAgent, + Timestamp = clock.UtcNow, + Metadata = metadata, + }; + _entries.Add(invocation, new Entry(ForOccurrence(AuthorizationEvidenceKind.Denial, draft))); + } + + private AuthorizationAuditContext Snapshot(OperationInvocation invocation, ProtectedOperationDecision? decision) + { + if (_depth == 0 || !ReferenceEquals(current().CurrentInvocation, invocation)) + throw new InvalidOperationException("Authorization evidence requires the actual current invocation."); + var transports = _transports.Select(source => source.Capture(invocation)).Where(value => value is not null).Take(2).ToArray(); + if (transports.Length > 1) throw new InvalidOperationException("Authorization transport provenance is ambiguous."); + return ForAuditContext(invocation, decision, transports.SingleOrDefault()); + } + + private static bool HasAttribution(OperationInvocation invocation) => invocation.IsActive + && invocation.TenantId is { } tenant && tenant != LearnStack.SharedKernel.Identifiers.TenantId.PlatformSentinel + && (invocation.Candidate is { IsActive: true } || invocation.TrustedSource is { IsActive: true }); + + /// Called only by the actual owner after draining domain work and before final commit admission. + public async Task BeforeOwnerCompletionAsync(IUnitOfWork owner, CancellationToken cancellationToken) + { + if (!ReferenceEquals(owner, unit) || _depth == 0 || !owner.HasActiveTransaction || owner.IsRollbackOnly) + throw new InvalidOperationException("Authorization evidence requires its actual completion owner."); + if (_compositionFailed) throw new AuditWriteFailedException("Trusted authorization evidence could not be composed."); + if (!_entries.Values.Any(entry => !entry.Acknowledged)) return null; + if (owner.Mode == TransactionMode.ReadOnly) + { + await AcknowledgeStandaloneAsync(cancellationToken).ConfigureAwait(false); + return null; + } + var transaction = owner.Transaction ?? throw new InvalidOperationException("Authorization evidence lost its owner transaction."); + var announcement = owner.GetTenantContextAnnouncement(transaction) + ?? throw new InvalidOperationException("Authorization evidence owner has no current announcement."); + var entries = _entries.Values.Where(entry => !entry.Acknowledged).ToArray(); + var batch = new CommitBatch(this, owner, transaction, announcement.Generation, entries); + foreach (var entry in entries) + { + if (entry.Pending is not null) throw new InvalidOperationException("A joiner cannot rewrite pending authorization evidence."); + await store.WriteAuthorizationPendingAsync(owner, entry.Occurrence, cancellationToken).ConfigureAwait(false); + if (!ReferenceEquals(owner.Transaction, batch.Transaction) + || owner.GetTenantContextAnnouncement(batch.Transaction)?.Generation != batch.Generation) + { + owner.MarkRollbackOnly(); + throw new AuditWriteFailedException("Authorization evidence lost its original physical owner generation."); + } + entry.Pending = batch; + } + return batch; + } + + public void RecordOwnerOutcome(CommitBatch? batch, bool committed) + { + if (batch is null) return; + if (!ReferenceEquals(batch.Coordinator, this) || !ReferenceEquals(batch.Unit, unit)) + throw new InvalidOperationException("Authorization completion does not belong to this owner generation."); + if (batch.Finished) return; + if (committed && unit.HasActiveTransaction) + throw new InvalidOperationException("A live transaction cannot acknowledge authorization evidence."); + batch.Finished = true; + foreach (var entry in batch.Entries) + { + if (!ReferenceEquals(entry.Pending, batch)) continue; + entry.Pending = null; + if (committed) entry.Acknowledged = true; + } + } + + /// An outer execution without a physical owner cannot publish an absorbed refusal before acknowledgement. + public async Task BeforeSuccessAsync(CancellationToken cancellationToken) + { + if (_compositionFailed) throw new AuditWriteFailedException("Trusted authorization evidence could not be composed."); + // Empty or already-acknowledged evidence owes no further I/O. In particular, + // post-COMMIT cancellation cannot turn a positively acknowledged act into + // a cleanup failure merely by waiting on the standalone-write semaphore. + if (!unit.HasActiveTransaction && _entries.Values.Any(entry => !entry.Acknowledged)) + await AcknowledgeStandaloneAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task AcknowledgeStandaloneAsync(CancellationToken cancellationToken) + { + await _writes.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + foreach (var entry in _entries.Values.Where(entry => !entry.Acknowledged)) + { + if (entry.Pending is not null) throw new InvalidOperationException("A live business transaction still owns authorization evidence."); + if (entry.StandaloneFailed) throw new AuditWriteFailedException("Authorization evidence was not acknowledged."); + try + { + await store.WriteAuthorizationStandaloneAsync(entry.Occurrence, cancellationToken).ConfigureAwait(false); + entry.Acknowledged = true; + } + catch { entry.StandaloneFailed = true; throw; } + } + } + finally { _writes.Release(); } + } + + private async ValueTask EndExecutionAsync(int depth) + { + if (_depth != depth) throw new InvalidOperationException("Authorization evidence execution scopes must close in order."); + if (--_depth != 0) return; + try + { + await ReconcileBeforeCaptureClearAsync().ConfigureAwait(false); + } + finally { _entries.Clear(); _contexts.Clear(); _compositionFailed = false; } + } + + /// Reconciles a completed physical owner before ordinary capture cleanup; a prepared live owner retains its events. + public async Task ReconcileBeforeCaptureClearAsync() + { + if (unit.HasActiveTransaction) return; + // Already-refused or failed executions retain their primary result. + // A failed standalone attempt is not retried by this same finally. + await _writes.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + foreach (var entry in _entries.Values.Where(entry => !entry.Acknowledged && !entry.StandaloneFailed)) + { + entry.Pending = null; // A closed failed/uncertain owner never acknowledges an event. + try + { + await store.WriteAuthorizationStandaloneAsync(entry.Occurrence, CancellationToken.None).ConfigureAwait(false); + entry.Acknowledged = true; + } + catch (Exception failure) when (failure is not OutOfMemoryException and not StackOverflowException) + { entry.StandaloneFailed = true; /* The MUST store reports this explicit refusal-audit loss. */ } + } + } + finally { _writes.Release(); } + } + + private void CompositionFailed() + { + if (!_compositionFailed) LogCompositionFailure(logger, null); + _compositionFailed = true; + } + + public void Dispose() + { + if (_depth != 0) throw new InvalidOperationException("Authorization evidence cleanup still has an execution owner."); + if (Interlocked.Exchange(ref _closed, 1) == 0) { _writes.Dispose(); _assertions.Clear(); } + GC.SuppressFinalize(this); + } + + internal sealed class Entry(AuthorizationEvidenceOccurrence occurrence) + { + internal AuthorizationEvidenceOccurrence Occurrence { get; } = occurrence; + internal bool Acknowledged { get; set; } + internal bool StandaloneFailed { get; set; } + internal CommitBatch? Pending { get; set; } + } + + /// Opaque audit-only lease; it exposes no execution, read or candidate authority. + internal sealed class RefusalObservation(AuthorizationEvidenceCoordinator owner, ExecutionScope scope, + OperationAuthorizationCoordinator execution, OperationInvocation invocation, Type operationType, object operation) + { + private int _consumed; + internal OperationInvocation Invocation { get; } = invocation; + internal bool TryConsume(AuthorizationEvidenceCoordinator candidateOwner, Type? candidateType, object? candidateOperation) => + ReferenceEquals(owner, candidateOwner) && scope.IsOpen(candidateOwner) + && operationType == candidateType && ReferenceEquals(operation, candidateOperation) + && execution.HasOpenObservationFrame(Invocation, operation) + && Interlocked.CompareExchange(ref _consumed, 1, 0) == 0; + } + +#pragma warning disable CA1034 // Private tokens bind completion and cleanup to the actual common owner. + public sealed class CommitBatch + { + internal CommitBatch(AuthorizationEvidenceCoordinator coordinator, IUnitOfWork unit, DbTransaction transaction, + long generation, Entry[] entries) + { Coordinator = coordinator; Unit = unit; Transaction = transaction; Generation = generation; Entries = entries; } + internal AuthorizationEvidenceCoordinator Coordinator { get; } + internal IUnitOfWork Unit { get; } + internal DbTransaction Transaction { get; } + internal long Generation { get; } + internal Entry[] Entries { get; } + internal bool Finished { get; set; } + } + + public sealed class ExecutionScope : IAsyncDisposable + { + private readonly AuthorizationEvidenceCoordinator _owner; + private readonly int _depth; + private int _closed; + internal ExecutionScope(AuthorizationEvidenceCoordinator owner, int depth) { _owner = owner; _depth = depth; } + internal bool IsOpen(AuthorizationEvidenceCoordinator owner) => ReferenceEquals(_owner, owner) && Volatile.Read(ref _closed) == 0; + public ValueTask DisposeAsync() => Interlocked.Exchange(ref _closed, 1) == 0 ? _owner.EndExecutionAsync(_depth) : ValueTask.CompletedTask; + } +#pragma warning restore CA1034 + + private static readonly Action LogCompositionFailure = LoggerMessage.Define( + LogLevel.Critical, new EventId(77, "AuthorizationEvidenceCompositionFailed"), + "Trusted authorization evidence could not be composed. An otherwise-successful execution cannot complete."); +} diff --git a/backend/src/LearnStack.Application/Authorization/BackgroundJobOperationPolicySource.cs b/backend/src/LearnStack.Application/Authorization/BackgroundJobOperationPolicySource.cs new file mode 100644 index 000000000..8d36604aa --- /dev/null +++ b/backend/src/LearnStack.Application/Authorization/BackgroundJobOperationPolicySource.cs @@ -0,0 +1,20 @@ +using LearnStack.Application.Jobs; +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.Application.Authorization; + +/// Framework checkpoints require their actual retained job request frame, never a SystemActor UUID. +public sealed class BackgroundJobOperationPolicySource : IOperationPolicySource +{ + public IEnumerable Definitions => []; + public IEnumerable Policies => + [ + OperationPolicy.ForTrusted("kernel.job.read_envelope", AuthorizationSourceKind.BackgroundJob), + OperationPolicy.ForTrusted("kernel.job.admit_attempt", AuthorizationSourceKind.BackgroundJob), + OperationPolicy.ForTrusted("kernel.job.read_external", AuthorizationSourceKind.BackgroundJob), + OperationPolicy.ForTrusted("kernel.job.record_arm", AuthorizationSourceKind.BackgroundJob), + OperationPolicy.ForTrusted("kernel.job.record_outcome", AuthorizationSourceKind.BackgroundJob), + OperationPolicy.ForTrusted("kernel.job.record_cleanup", AuthorizationSourceKind.BackgroundJob), + OperationPolicy.ForTrusted("kernel.job.submit_recipient", AuthorizationSourceKind.BackgroundJob), + ]; +} diff --git a/backend/src/LearnStack.Application/Authorization/OperationAuthorizationCoordinator.cs b/backend/src/LearnStack.Application/Authorization/OperationAuthorizationCoordinator.cs new file mode 100644 index 000000000..580f2ad1d --- /dev/null +++ b/backend/src/LearnStack.Application/Authorization/OperationAuthorizationCoordinator.cs @@ -0,0 +1,211 @@ +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Application.Audit; + +namespace LearnStack.Application.Authorization; + +/// Owns one fresh policy act and its nested lifetime; declaration and source identity never replace grants. +public sealed class OperationAuthorizationCoordinator( + IOperationPolicyCatalog policies, + IProtectedOperationAuthorizer authorizer, + ITenantContext context, + IEnumerable candidateSources, + IEnumerable trustedSources, + AuthorizationEvidenceCoordinator evidence) + : OperationInvocation.MintingSource, IOperationInvocationContext, IDisposable +{ + private static readonly Error Forbidden = new(new LocalizedMessage("lockey_forbidden")); + private static readonly Error Unavailable = new(new LocalizedMessage("lockey_dependency_unavailable")); + private readonly AsyncLocal _current = new(); + private readonly IAuthorizationCandidateSource[] _candidateSources = [.. candidateSources]; + private readonly ITrustedOperationSource[] _trustedSources = [.. trustedSources]; + private int _closed; + + public OperationInvocation? CurrentInvocation => _current.Value is { Closed: false } frame && frame.Invocation.IsActive + ? frame.Invocation : null; + + public OperationReadWitness? CurrentReadWitness => _current.Value is { Closed: false, Decision: { } decision } + && decision.Outcome == ProtectedOperationOutcome.Allowed && decision.IsActive ? decision.ReadWitness : null; + + public ProtectedOperationDecision? CurrentDecision => _current.Value is { Closed: false, Decision: { IsActive: true } decision } + ? decision : null; + + public Task ExecuteRequestAsync(TRequest request, + Func> next, CancellationToken cancellationToken = default) + where TRequest : notnull where TResponse : IResultBase + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(next); + return Volatile.Read(ref _closed) == 0 && policies.TryGet(request.GetType(), out var policy) + ? ExecuteAsync(request, policy, next, cancellationToken) + : Task.FromResult(Result.FailFor(Forbidden)); + } + + /// The HTTP adapter supplies an enumerated endpoint, then application dispatch performs a new act. + public Task> CheckHttpAsync(Type requestType, object endpoint, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(endpoint); + return Volatile.Read(ref _closed) == 0 && policies.TryGet(requestType, out var policy) + ? ExecuteAsync(endpoint, policy, () => Task.FromResult(Result.Ok(None.Value)), cancellationToken, OperationEntryKind.Http) + : Task.FromResult(Result.Fail(Forbidden)); + } + + private async Task ExecuteAsync(object operation, OperationPolicy policy, + Func> next, CancellationToken cancellationToken, OperationEntryKind entryKind = OperationEntryKind.Application) + where TResponse : IResultBase + { + cancellationToken.ThrowIfCancellationRequested(); + AuthorizationCandidate? candidate = null; + TrustedOperationDescriptor? trusted = null; + Error? sourceFailure = null; + if (!policies.TryGet(policy.RequestType, out var canonical) || !ReferenceEquals(canonical, policy)) + return Result.FailFor(Forbidden); + + switch (policy.Kind) + { + case OperationPolicyKind.PublicRead: + if (!HasMatchingHostScope(allowAnonymous: true) + || policy.RequiredPermission is not { IsActive: true } permission + || !policies.TryGetDefinition(permission.Scope, permission.Key, out var registered) + || registered.Assignable || !registered.Definition.IsActive) + sourceFailure = TenantContextFactory.Refused; + break; + case OperationPolicyKind.FiniteTrusted: + var captured = _trustedSources.Select(source => source.Capture(policy.RequestType, context)) + .Where(descriptor => descriptor is not null).Cast().Take(2).ToArray(); + if (captured.Length != 1 || !captured[0].Matches(policy.RequestType, context) + || !policy.AllowedSources.Contains(captured[0].SourceKind)) + sourceFailure = Forbidden; + else trusted = captured[0]; + break; + case OperationPolicyKind.AccountRead: + case OperationPolicyKind.Protected: + if (!HasProtectedContext() || (policy.Kind == OperationPolicyKind.AccountRead && !HasMatchingHostScope(allowAnonymous: false))) + { + sourceFailure = TenantContextFactory.Refused; + break; + } + var candidates = _candidateSources.Select(source => source.ForValidatedScope(context.TenantId, context.OrganizationId)) + .Where(value => value is not null).Cast().Take(2).ToArray(); + if (candidates.Length != 1 || !Matches(candidates[0])) sourceFailure = TenantContextFactory.Refused; + else candidate = candidates[0]; + break; + default: + sourceFailure = Forbidden; + break; + } + + var invocation = ForInvocation(operation, policy, context, candidate, trusted, cancellationToken, entryKind); + var previous = _current.Value; + var frame = new Frame(invocation, previous); + _current.Value = frame; + await using var evidenceScope = evidence.BeginExecution(); + try + { + if (sourceFailure is not null) + { + evidence.RecordPolicyRefusal(invocation, null, sourceFailure); + return Result.FailFor(sourceFailure); + } + if (candidate is not null) + { + var decision = await authorizer.AuthorizeAsync(invocation, policy, candidate, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (decision is null || !ReferenceEquals(decision.Invocation, invocation) || !decision.IsActive) + return Result.FailFor(Unavailable); + frame.Decision = decision; + evidence.Observe(invocation, decision); + var error = decision.Outcome switch + { + ProtectedOperationOutcome.Allowed when policy.ReadScope == OperationReadScope.Tenant + && decision.ReadWitness is null => Unavailable, + ProtectedOperationOutcome.Allowed => null, + ProtectedOperationOutcome.NotCovered => TenantContextFactory.Refused, + ProtectedOperationOutcome.Forbidden => Forbidden, + _ => Unavailable, + }; + if (error is not null) + { + evidence.RecordPolicyRefusal(invocation, decision, error); + return Result.FailFor(error); + } + if (policy.RequiresOrglessWrite && context.OrganizationId is not null) + { + evidence.RecordPolicyRefusal(invocation, decision, Forbidden); + return Result.FailFor(Forbidden); + } + } + else evidence.Observe(invocation, null); + if (!invocation.IsActive) return Result.FailFor(Unavailable); + var refusalObservation = evidence.CaptureRefusalObservation(evidenceScope, policy.RequestType, operation); + var response = await next(); + evidence.RecordRefusalResult(refusalObservation, policy.RequestType, operation, response); + if (response.IsSuccess) await evidence.BeforeSuccessAsync(cancellationToken); + return response; + } + finally + { + // Close the shared node before restoring its parent. Inherited execution + // contexts retain the node reference and cannot revive it after this exit. + frame.Close(); + _current.Value = previous; + } + } + + protected override bool IsCurrent(OperationInvocation invocation) + { + if (Volatile.Read(ref _closed) != 0) return false; + var found = false; + for (var frame = _current.Value; frame is not null; frame = frame.Parent) + { + if (frame.Closed) return false; + found |= ReferenceEquals(frame.Invocation, invocation); + } + return found; + } + + // Audit-only frame identity deliberately excludes cancellation, while public + // CurrentInvocation and every execution/read authority keep their active checks. + internal bool HasOpenObservationFrame(OperationInvocation invocation, object operation) => + Volatile.Read(ref _closed) == 0 && _current.Value is { Closed: false } frame + && ReferenceEquals(frame.Invocation, invocation) && ReferenceEquals(invocation.Operation, operation) + && IsCurrent(invocation); + + public void Dispose() + { + Interlocked.Exchange(ref _closed, 1); + GC.SuppressFinalize(this); + } + + private bool Matches(AuthorizationCandidate candidate) => candidate.IsActive + && candidate.Actor == context.UserId && candidate.TenantId == context.TenantId + && candidate.OrganizationId == context.OrganizationId; + + private bool HasProtectedContext() => context.IsResolved && StronglyTypedId.IsAssigned(context.TenantId) + && context.TenantId != TenantId.PlatformSentinel && context.UserId is { } actor + && StronglyTypedId.IsAssigned(actor) && actor != UserId.SystemActor + && context.Origin is TenantContextOrigin.HostAndClaim or TenantContextOrigin.ClaimAndMembership; + + private bool HasMatchingHostScope(bool allowAnonymous) => context.IsResolved + && (allowAnonymous ? context.Origin is TenantContextOrigin.HostOnly or TenantContextOrigin.HostAndClaim + : context.Origin == TenantContextOrigin.HostAndClaim) + && context.HostScope is { } host && StronglyTypedId.IsAssigned(host.TenantId) + && host.TenantId != TenantId.PlatformSentinel && host.TenantId == context.TenantId + && (host.OrganizationId is null || (StronglyTypedId.IsAssigned(host.OrganizationId.Value) + && host.OrganizationId == context.OrganizationId)); + + private sealed class Frame(OperationInvocation invocation, Frame? parent) + { + private int _closed; + internal OperationInvocation Invocation { get; } = invocation; + internal Frame? Parent { get; } = parent; + internal ProtectedOperationDecision? Decision { get; set; } + internal bool Closed => Volatile.Read(ref _closed) != 0; + internal void Close() => Interlocked.Exchange(ref _closed, 1); + } +} diff --git a/backend/src/LearnStack.Application/Authorization/OperationAuthorizationRegistration.cs b/backend/src/LearnStack.Application/Authorization/OperationAuthorizationRegistration.cs new file mode 100644 index 000000000..9e5f2ffda --- /dev/null +++ b/backend/src/LearnStack.Application/Authorization/OperationAuthorizationRegistration.cs @@ -0,0 +1,26 @@ +using LearnStack.Application.Audit; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Permissions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LearnStack.Application.Authorization; + +/// Joint composition of the frozen policy catalogue, actual invocation owner and mandatory evidence barrier. +public static class OperationAuthorizationRegistration +{ + public static IServiceCollection AddLearnStackOperationAuthorization(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddSingleton(provider => + new OperationPolicyCatalog(provider.GetServices())); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped>(provider => + () => provider.GetRequiredService()); + services.TryAddScoped(provider => provider.GetRequiredService()); + services.TryAddScoped(provider => provider.GetRequiredService()); + return services; + } +} diff --git a/backend/src/LearnStack.Application/Authorization/OperationPolicyCatalog.cs b/backend/src/LearnStack.Application/Authorization/OperationPolicyCatalog.cs new file mode 100644 index 000000000..3f93a7a80 --- /dev/null +++ b/backend/src/LearnStack.Application/Authorization/OperationPolicyCatalog.cs @@ -0,0 +1,67 @@ +using System.Collections.Frozen; +using System.Collections.Immutable; +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.Application.Authorization; + +/// One frozen exact request/definition snapshot, independent of any caller's current Roles. +public sealed class OperationPolicyCatalog : IOperationPolicyCatalog +{ + private readonly FrozenDictionary _policies; + private readonly FrozenDictionary<(PermissionScope Scope, string Key), PermissionRegistration> _definitions; + private readonly ImmutableArray _all; + private readonly ImmutableArray _allDefinitions; + + public OperationPolicyCatalog(IEnumerable sources) + { + ArgumentNullException.ThrowIfNull(sources); + var policies = new Dictionary(); + var definitions = new Dictionary<(PermissionScope, string), PermissionRegistration>(); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var source in sources) + { + ArgumentNullException.ThrowIfNull(source); + foreach (var registration in source.Definitions) + { + if (registration?.Definition is not { } definition || definition.Scope == PermissionScope.Platform + || !definitions.TryAdd((definition.Scope, definition.Key), registration)) + throw new InvalidOperationException("Operation permission definitions are missing, conflicting or unsupported."); + } + foreach (var policy in source.Policies) + { + if (policy is null || !policies.TryAdd(policy.RequestType, policy) || !ids.Add(policy.Id)) + throw new InvalidOperationException("Operation policy declarations are missing or conflicting."); + } + } + foreach (var policy in policies.Values) + { + if (policy.RequiredPermission is { } required + && (!definitions.TryGetValue((required.Scope, required.Key), out var registered) + || registered.Definition != required + || registered.Assignable != (policy.Kind == OperationPolicyKind.Protected) + || (policy.Kind == OperationPolicyKind.PublicRead + && (required.Scope != PermissionScope.Tenant || !required.Key.EndsWith(".read", StringComparison.Ordinal))))) + throw new InvalidOperationException("Operation policy references an unregistered or incompatible permission."); + } + if (policies.Values.Where(policy => policy.TrustedEventHandlerType is not null) + .GroupBy(policy => policy.TrustedEventHandlerType).Any(group => group.Count() != 1)) + throw new InvalidOperationException("Event initializer policy declarations are ambiguous."); + _policies = policies.ToFrozenDictionary(); + _definitions = definitions.ToFrozenDictionary(); + _all = [.. policies.Values.OrderBy(policy => policy.RequestType.FullName, StringComparer.Ordinal)]; + _allDefinitions = [.. definitions.Values.OrderBy(registration => registration.Definition.Scope) + .ThenBy(registration => registration.Definition.Key, StringComparer.Ordinal)]; + } + + public IReadOnlyCollection All => _all; + public IReadOnlyCollection Definitions => _allDefinitions; + + public bool TryGet(Type requestType, out OperationPolicy policy) + { + ArgumentNullException.ThrowIfNull(requestType); + return _policies.TryGetValue(requestType, out policy!); + } + + public bool TryGetDefinition(PermissionScope scope, string key, out PermissionRegistration definition) => + _definitions.TryGetValue((scope, key), out definition!); +} diff --git a/backend/src/LearnStack.Application/Jobs/BackgroundJobExecutionAdmission.OperationSource.cs b/backend/src/LearnStack.Application/Jobs/BackgroundJobExecutionAdmission.OperationSource.cs new file mode 100644 index 000000000..a54cd152a --- /dev/null +++ b/backend/src/LearnStack.Application/Jobs/BackgroundJobExecutionAdmission.OperationSource.cs @@ -0,0 +1,74 @@ +using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Application.Jobs; + +public sealed partial class BackgroundJobExecutionAdmission +{ + /// Captures the already admitted request frame; it never reclaims a root. + internal static object? CaptureOperationEvidence(Type requestType, ITenantContext context) + { + if (JobApplicationSourceOwner.Current is not { } source || CurrentFrame.Value is not { } frame + || CurrentRequest.Value is not { } request || request.RequestType != requestType + || !ReferenceEquals(request.Execution, frame)) return null; + var evidence = new OperationEvidence(frame, request, source, context); + return IsOperationEvidenceCurrent(evidence) ? evidence : null; + } + + internal static bool IsOperationEvidenceCurrent(object? evidence) + { + if (evidence is not OperationEvidence captured || !ReferenceEquals(CurrentFrame.Value, captured.Execution) + || !ReferenceEquals(JobApplicationSourceOwner.Current, captured.SourceOwner) + || !captured.SourceOwner.IsCurrentBinding || !IsLive(captured.Execution) + || !captured.Request.IsOpen) return false; + var found = false; + for (var request = CurrentRequest.Value; request is not null; request = request.Parent) + if (ReferenceEquals(request, captured.Request)) found = true; + if (!found) return false; + var frame = captured.Execution; + if (frame.StartGate?.Invoke() == false || frame.CapacityCleanup?.Gate.IsClosed == true + || frame.CapacityCleanup?.KnownRollback == true || frame.CapacityCleanup?.CommitAcknowledged == true + || frame.ExternalCheckpoint?.Authority.Run.IsClosed == true + || frame.ExternalCheckpoint?.Gate?.IsClosed == true || frame.Recipient?.Gate.IsClosed == true + || frame.Unit?.IsRollbackOnly == true) return false; + var owner = frame.Receipt?.SourceOwner ?? frame.CapacityCleanup?.SourceOwner + ?? frame.ExternalCheckpoint?.Authority.SourceOwner ?? frame.Recipient?.SourceOwner; + if (owner is not null && !ReferenceEquals(owner, captured.SourceOwner)) return false; + try + { + RequireContext(captured.Context); + return true; + } + catch (InvalidOperationException) + { + return false; + } + } + + private sealed class OperationEvidence(Frame execution, RequestFrame request, + JobApplicationSourceOwner sourceOwner, ITenantContext context) + { + internal Frame Execution { get; } = execution; + internal RequestFrame Request { get; } = request; + internal JobApplicationSourceOwner SourceOwner { get; } = sourceOwner; + internal ITenantContext Context { get; } = context; + public override string ToString() => nameof(OperationEvidence); + } +} + +/// The scoped adapter exposes only the current job owner's closed request evidence. +public sealed class BackgroundJobOperationSource : TrustedOperationDescriptor.MintingSource, ITrustedOperationSource +{ + public TrustedOperationDescriptor? Capture(Type requestType, ITenantContext context) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(context); + if (!CanIssue || BackgroundJobExecutionAdmission.CaptureOperationEvidence(requestType, context) is not { } evidence) + return null; + return ForOperation(AuthorizationSourceKind.BackgroundJob, requestType, context, evidence); + } + + protected override bool IsEvidenceCurrent(object? evidence) => + BackgroundJobExecutionAdmission.IsOperationEvidenceCurrent(evidence); +} diff --git a/backend/src/LearnStack.Application/Jobs/BackgroundJobExecutionAdmission.cs b/backend/src/LearnStack.Application/Jobs/BackgroundJobExecutionAdmission.cs index fe39037a7..804c672e7 100644 --- a/backend/src/LearnStack.Application/Jobs/BackgroundJobExecutionAdmission.cs +++ b/backend/src/LearnStack.Application/Jobs/BackgroundJobExecutionAdmission.cs @@ -10,7 +10,7 @@ namespace LearnStack.Application.Jobs; /// Independent, immutable ordinary-job authority retained through actual cleanup. -public sealed class BackgroundJobExecutionAdmission( +public sealed partial class BackgroundJobExecutionAdmission( IBackgroundJobDefinitionCatalog definitions, IBackgroundJobRequestCatalog requests, IAuditCatalog audit) diff --git a/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs index 1e3ad19c9..6d71ec7e9 100644 --- a/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs +++ b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs @@ -1,4 +1,6 @@ using LearnStack.Application.Jobs; +using LearnStack.Application.Authorization; +using LearnStack.Application.Audit; using System.Data.Common; using LearnStack.Application.Messaging; using LearnStack.SharedKernel.Audit; @@ -24,7 +26,9 @@ public sealed partial class TransactionExecutionCoordinator( IAuditStateCapture capture, ProducerExecution producer, DomainEventCollector collector, - ILogger logger) + ILogger logger, + OperationAuthorizationCoordinator? authorization = null, + AuthorizationEvidenceCoordinator? authorizationEvidence = null) { /// /// Announces before dispatch, retains producer ancestry through completion, @@ -36,7 +40,7 @@ internal Task ExecuteRequestAsync(TRequest reque ExecuteCoreAsync(request as IProvisionsTenant, mode, typeof(TRequest).Name, next, typeof(TRequest), request as AdmitBackgroundJobAttemptCommand, request as RecordBackgroundJobCapacityCleanupCommand, BackgroundJobExternalCheckpointOwner.For(request), - request as SubmitBackgroundJobRecipientCommand, cancellationToken); + request as SubmitBackgroundJobRecipientCommand, cancellationToken, operation: request); public Task ExecuteAsync(IProvisionsTenant? provisioning, TransactionMode mode, string operationName, Func> next, CancellationToken cancellationToken = default) @@ -50,7 +54,7 @@ private async Task ExecuteCoreAsync( RecordBackgroundJobCapacityCleanupCommand? capacityCleanup, BackgroundJobExternalCheckpointOwner? externalCheckpoint, SubmitBackgroundJobRecipientCommand? recipient, - CancellationToken cancellationToken) + CancellationToken cancellationToken, object? operation = null) where TResponse : IResultBase { ArgumentNullException.ThrowIfNull(next); @@ -84,7 +88,8 @@ private async Task ExecuteCoreAsync( } return await ExecuteFrameAsync(scope, provisioning, operationName, next, - null, null, null, operationType, admission, jobCompletion, capacityCleanup, externalCheckpoint, recipient, cancellationToken); + null, null, null, operationType, admission, jobCompletion, capacityCleanup, externalCheckpoint, recipient, cancellationToken, + operation: operation); } /// @@ -139,9 +144,11 @@ private async Task ExecuteFrameAsync( SubmitBackgroundJobRecipientCommand? recipient, CancellationToken cancellationToken, PreparedGlobalIdentityOwner? globalOwner = null, - GlobalIdentityCreationReference? creation = null) + GlobalIdentityCreationReference? creation = null, object? operation = null) where TResponse : IResultBase { + await using var evidenceScope = authorizationEvidence?.BeginExecution(); + AuthorizationEvidenceCoordinator.CommitBatch? evidenceBatch = null; var committing = false; var failed = false; @@ -168,8 +175,11 @@ await unitOfWork.SetProvisioningTenantContextAsync( } else { - await unitOfWork.SetTenantContextAsync( - BackgroundJobExecutionAdmission.AnnouncementContext(tenantContext, operationType), cancellationToken); + var announcementContext = BackgroundJobExecutionAdmission.AnnouncementContext(tenantContext, operationType); + if (authorization?.CurrentReadWitness is { } readWitness && readWitness.Policy.RequestType == operationType) + await unitOfWork.SetTenantContextAsync(announcementContext, readWitness, cancellationToken); + else + await unitOfWork.SetTenantContextAsync(announcementContext, cancellationToken); } // Capture causal identity at the trusted entry. Nested invocations inherit it; @@ -189,9 +199,13 @@ await BackgroundJobExecutionAdmission.BindOwnerAsync(jobCompletion, unitOfWork, incomingEventId ?? BackgroundJobExecutionAdmission.IncomingEventId, BackgroundJobExecutionAdmission.ProducerOperationType(operationType), BackgroundJobExecutionAdmission.ParentJobId); + var refusalObservation = authorizationEvidence?.CaptureRefusalObservation(evidenceScope, operationType, operation); var response = await next(); if (response.IsFailure) { + // A decided refusal must survive a faulted rollback or cancellation + // during cleanup, before the outer policy observer regains control. + authorizationEvidence?.RecordRefusalResult(refusalObservation, operationType, operation, response); // Joiners are not savepoints. Successful production cannot escape // merely because an ancestor absorbs this refusal. if (invocation.HasProducedWork) unitOfWork.MarkRollbackOnly(); @@ -228,6 +242,8 @@ await BackgroundJobExecutionAdmission.BindOwnerAsync(jobCompletion, unitOfWork, { await auditStore.WritePendingAsync(unitOfWork, cancellationToken); } + if (authorizationEvidence is not null && globalOwner is null) + evidenceBatch = await authorizationEvidence.BeforeOwnerCompletionAsync(unitOfWork, cancellationToken); } if (recipient is not null) @@ -283,6 +299,19 @@ await BackgroundJobExecutionAdmission.FenceOwnerAsync(jobCompletion, unitOfWork, if (unitOfWork.IsRollbackOnly) capture.MarkRolledBack(); else capture.MarkIndeterminate(commitFailure); } + authorizationEvidence?.RecordOwnerOutcome(evidenceBatch, committed: false); + + // The new audited tenant-wide read discloses nothing until its + // evidence commits. Keep the actual completion state/cause, but + // report unavailable evidence rather than a generic database 500. + // Ordinary mutations and cancellation retain their primary failure. + if (scope.IsOwner && commitFailure is DbException && globalOwner is null + && authorization?.CurrentReadWitness is { } readWitness + && readWitness.Policy.RequestType == operationType + && !invocation.HasProducedWork && capture.Changes.Count == 0 + && capture.Intents.Count != 0 && capture.Intents.All(intent => + intent.OperationClass == OperationClass.Must && intent.OperationType == OperationType.ReadSensitive)) + throw new AuditWriteFailedException("The sensitive read's MUST evidence could not be committed.", commitFailure); throw; } @@ -296,6 +325,7 @@ await BackgroundJobExecutionAdmission.FenceOwnerAsync(jobCompletion, unitOfWork, recipient?.Completion.RecordCommitted(unitOfWork); completion?.Gate.RecordCommitted(); capture.MarkCommitted(); + authorizationEvidence?.RecordOwnerOutcome(evidenceBatch, committed: true); } return response; } @@ -336,6 +366,8 @@ await BackgroundJobExecutionAdmission.FenceOwnerAsync(jobCompletion, unitOfWork, } finally { + if (evidenceBatch is not null && capture.State != AuditIntentState.Committed) + authorizationEvidence?.RecordOwnerOutcome(evidenceBatch, committed: false); globalOwner?.RecordOutcome(capture.State); await CleanupFrameAsync(scope, failed); } diff --git a/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs b/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs index 524aa80f9..fd86dc5a8 100644 --- a/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs @@ -1,3 +1,4 @@ +using LearnStack.Application.Authorization; using LearnStack.SharedKernel.Results; using MediatR; @@ -5,20 +6,13 @@ namespace LearnStack.Application.Pipeline; /// /// MediatR pipeline behavior — step 5 of the canonical 8-step order -/// (ADR-0032 § Sub-decision 2). The current shell passes requests through; -/// Phase 03 owns resource-aware permission checks and the -/// Result.Fail(forbidden) refusal path. +/// (ADR-0032 § Sub-decision 2), with live policy admission and mandatory denial evidence. /// /// -/// Phase 02a Packet 3 ships the shell: there is no -/// permission catalogue yet (it lands together with the Identity module in -/// Phase 03 + Standards 19). The shell passes every request through and -/// preserves the pipeline-order contract. When the permission catalogue -/// arrives the shell flips to consuming IAuthorizationService and -/// per-request [Authorize] metadata; the registration order does not -/// change. +/// The common Application layer consumes SharedKernel policy/decision contracts; +/// ASP.NET requirements remain in the API. The eight-step order is unchanged. /// -public sealed class AuthorizationBehavior +public sealed class AuthorizationBehavior(OperationAuthorizationCoordinator coordinator) : IPipelineBehavior where TRequest : notnull where TResponse : IResultBase @@ -30,11 +24,6 @@ public Task Handle( { ArgumentNullException.ThrowIfNull(next); - // TODO(2026-05-21, @platform): Phase 03 — resolve the request's - // [Authorize(Policy)] attribute, call IAuthorizationService.AuthorizeAsync - // with the tenant + organization-scoped resource, and return - // Result.FailFor(forbidden) on deny. - - return next(); + return coordinator.ExecuteRequestAsync(request, () => next(), cancellationToken); } } diff --git a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs index 41dc64674..26808f1f1 100644 --- a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs +++ b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs @@ -1,7 +1,10 @@ using FluentValidation; using LearnStack.Application.Audit; +using LearnStack.Application.Authorization; using LearnStack.Application.Messaging; using LearnStack.Application.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Permissions; using MediatR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -54,6 +57,7 @@ public static IServiceCollection AddLearnStackMediatRPipeline( // One scoped lifecycle for every entry and both API/Seeder compositions. // Behaviors adapt requests; direct owners reuse these same coordinators. services.TryAddScoped(); + services.AddLearnStackOperationAuthorization(); // A pre-context binding reader must not construct creation-only owners // and pin their transient tenant context before ordinary resolution. services.TryAddScoped>(provider => diff --git a/backend/src/LearnStack.Infrastructure.Audit/AuditStateCapture.cs b/backend/src/LearnStack.Infrastructure.Audit/AuditStateCapture.cs index 1daf2dd25..aa903d442 100644 --- a/backend/src/LearnStack.Infrastructure.Audit/AuditStateCapture.cs +++ b/backend/src/LearnStack.Infrastructure.Audit/AuditStateCapture.cs @@ -1,5 +1,6 @@ using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; namespace LearnStack.Infrastructure.Audit; @@ -54,6 +55,7 @@ public sealed class AuditStateCapture : IAuditStateCapture, IAuditSubject /// The open frames, innermost last. private readonly List _openFrames = []; + private readonly Dictionary _authorizationFrames = []; private int _framesOpened; @@ -184,6 +186,28 @@ public void DeclareIntent(AuditIntent intent) } } + /// + public void AttachAuthorization(AuthorizationAuditContext context) + { + ArgumentNullException.ThrowIfNull(context); + if (!HasOpenFrame) return; // HTTP policy checks have no resource-intent frame. + var frame = CurrentFrame; + // A direct inner policy act can have no resource frame of its own. It + // still owns its denial occurrence, but cannot overwrite its parent's + // resource context. A post-lock recheck of the same act may refresh it. + if (_authorizationFrames.TryGetValue(frame, out var owner) && !ReferenceEquals(owner, context.Invocation)) return; + _authorizationFrames.TryAdd(frame, context.Invocation); + for (var index = 0; index < _intents.Count; index++) + { + var intent = _intents[index]; + if (_intentFrames[index] != frame) continue; + if (intent.GlobalIdentity is not null || intent.TenantId != context.TenantId + || intent.OrganizationId != context.OrganizationId || intent.ActorUserId != context.Actor) + throw new InvalidOperationException("Authorization evidence does not belong to the current resource frame."); + _intents[index] = intent with { Authorization = context }; + } + } + /// public void Designate(IAggregateRoot aggregate) where TId : struct, IStronglyTypedId @@ -261,6 +285,7 @@ public void MarkIndeterminate(Exception cause) /// public void Clear() { + _authorizationFrames.Clear(); _changes.Clear(); _changeFrames.Clear(); _intents.Clear(); diff --git a/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.AuthorizationEvidence.cs b/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.AuthorizationEvidence.cs new file mode 100644 index 000000000..2724c7c0a --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.AuthorizationEvidence.cs @@ -0,0 +1,172 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Persistence; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace LearnStack.Infrastructure.Audit; + +public sealed partial class PostgresAuditStore +{ + public async Task WriteAuthorizationPendingAsync(IUnitOfWork unitOfWork, + AuthorizationEvidenceOccurrence occurrence, CancellationToken cancellationToken = default) + { + JobApplicationSourceOwner.RefuseExternalEntry(); + ArgumentNullException.ThrowIfNull(unitOfWork); + var draft = RequireAuthorizationCatalog(occurrence); + if (_globalEnrollment.IsActive || unitOfWork is IGlobalIdentityUnitOfWork { IsGlobalIdentityTransaction: true } + || unitOfWork.Mode != TransactionMode.ReadWrite || !unitOfWork.HasActiveTransaction || unitOfWork.IsRollbackOnly + || unitOfWork.Transaction is not NpgsqlTransaction { IsolationLevel: IsolationLevel.ReadCommitted } transaction + || unitOfWork.Connection is not NpgsqlConnection connection || !ReferenceEquals(transaction.Connection, connection) + || unitOfWork.GetTenantContextAnnouncement(transaction) is not { Generation: > 0 } announced + || announced.TenantId != draft.TenantId || announced.OrganizationId != draft.OrganizationId) + throw new ArgumentException("Authorization evidence requires its matching live ReadWrite owner.", nameof(unitOfWork)); + try + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = InsertSql; + Bind(command.Parameters, draft); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbException) + { + throw new AuditWriteFailedException("Authorization evidence could not be written on its business owner."); + } + } + + public async Task WriteAuthorizationStandaloneAsync(AuthorizationEvidenceOccurrence occurrence, + CancellationToken cancellationToken = default) + { + JobApplicationSourceOwner.RefuseExternalEntry(); + var draft = RequireAuthorizationCatalog(occurrence); + try + { + if (_authorizationEvidence is null) + throw new AuditWriteFailedException("The authorization evidence source is unavailable."); + var session = await _authorizationEvidence.OpenAsync(occurrence, cancellationToken).ConfigureAwait(false) + ?? throw new AuditWriteFailedException("The authorization evidence source is at capacity."); + await using (session.ConfigureAwait(false)) + { + try + { + await InsertAuthorizationEvidenceAsync(session, draft).ConfigureAwait(false); + await session.CompleteAsync().ConfigureAwait(false); + } + catch (PostgresException duplicate) when (duplicate.SqlState == PostgresErrorCodes.UniqueViolation + && duplicate.SchemaName == "public" && duplicate.TableName == "audit_log" + && duplicate.ConstraintName == "audit_log_pkey") + { + await session.BeginVerificationAsync().ConfigureAwait(false); + if (!await HasAuthorizationEvidenceAsync(session, draft).ConfigureAwait(false)) + throw new AuditWriteFailedException("The duplicate authorization event has no exact durable evidence."); + await session.CompleteAsync().ConfigureAwait(false); + LogAuthorizationDuplicate(_logger, null); + _durableDuplicates.Add(1, new KeyValuePair("operation", draft.Operation)); + } + } + _health.ReportStandaloneWriteSucceeded(); + } + catch (Exception failure) when (failure is not (OutOfMemoryException or StackOverflowException)) + { + LogAuthorizationWriteFailed(_logger, null); + _standaloneWriteFailures.Add(1, new KeyValuePair("operation", draft.Operation)); + _health.ReportStandaloneWriteFailed(draft.Operation); + if (failure is OperationCanceledException) throw; + throw new AuditWriteFailedException("Authorization evidence could not be made durable."); + } + } + + private AuditEntryDraft RequireAuthorizationCatalog(AuthorizationEvidenceOccurrence occurrence) + { + ArgumentNullException.ThrowIfNull(occurrence); + var draft = occurrence.Draft; + var operation = occurrence.Kind switch + { + AuthorizationEvidenceKind.Denial => "identity.authorization.deny", + AuthorizationEvidenceKind.ClaimAssertion => "tenancy.assertion.reject_claim_context", + _ => throw new ArgumentException("Authorization evidence kind is invalid.", nameof(occurrence)), + }; + if (!_catalog.TryGetOffPath(operation, out var declared) + || declared.Operation != operation || declared.ModuleName != draft.ModuleName + || declared.OperationClass != OperationClass.Must || declared.OperationType != OperationType.SecurityEvent + || declared.EntityType is not null || draft.EntityType is not null || draft.EntityId is not null + || draft.Operation != declared.Operation || draft.OperationClass != declared.OperationClass + || draft.OperationType != declared.OperationType || draft.Outcome != AuditOutcome.Denied) + throw new ArgumentException("Authorization evidence requires its exact central MUST catalogue entry.", nameof(occurrence)); + return draft; + } + + private static async Task InsertAuthorizationEvidenceAsync(IAuthorizationEvidenceSession session, AuditEntryDraft draft) + { + var command = session.Unit.Connection.CreateCommand(); + try + { + command.Transaction = session.Unit.Transaction; + command.CommandText = InsertSql; + Bind(command.Parameters, draft); + await command.ExecuteNonQueryAsync(session.DeadlineToken).ConfigureAwait(false); + } + finally { await DisposeAuthorizationStatementAsync(session, command).ConfigureAwait(false); } + } + + private static async Task HasAuthorizationEvidenceAsync(IAuthorizationEvidenceSession session, AuditEntryDraft draft) + { + var command = session.Unit.Connection.CreateCommand(); + try + { + command.Transaction = session.Unit.Transaction; + // All persisted fields use the one INSERT's parameters. In particular the + // nullable JSON values are typed JSONB by Bind, never text or string equality. + // The nonnullable composite key uses equality so verification can seek its index. + command.CommandText = """ + SELECT EXISTS ( + SELECT 1 FROM public.audit_log + WHERE id = @id + AND tenant_id IS NOT DISTINCT FROM @tenant_id + AND organization_id IS NOT DISTINCT FROM @organization_id + AND actor_user_id IS NOT DISTINCT FROM @actor_user_id + AND actor_email IS NOT DISTINCT FROM @actor_email + AND module IS NOT DISTINCT FROM @module + AND operation IS NOT DISTINCT FROM @operation + AND operation_type IS NOT DISTINCT FROM @operation_type + AND operation_class IS NOT DISTINCT FROM @operation_class + AND entity_type IS NOT DISTINCT FROM @entity_type + AND entity_id IS NOT DISTINCT FROM @entity_id + AND outcome IS NOT DISTINCT FROM @outcome + AND error_key IS NOT DISTINCT FROM @error_key + AND reason IS NOT DISTINCT FROM @reason + AND before_state IS NOT DISTINCT FROM @before_state + AND after_state IS NOT DISTINCT FROM @after_state + AND changes IS NOT DISTINCT FROM @changes + AND correlation_id IS NOT DISTINCT FROM @correlation_id + AND ip_address IS NOT DISTINCT FROM @ip_address + AND user_agent IS NOT DISTINCT FROM @user_agent + AND timestamp = @timestamp + AND metadata IS NOT DISTINCT FROM @metadata) + """; + Bind(command.Parameters, draft); + return await command.ExecuteScalarAsync(session.DeadlineToken).ConfigureAwait(false) is true; + } + finally { await DisposeAuthorizationStatementAsync(session, command).ConfigureAwait(false); } + } + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "An actual statement cleanup fault permanently poisons the retained session.")] + private static async Task DisposeAuthorizationStatementAsync(IAuthorizationEvidenceSession session, DbCommand command) + { + try { await command.DisposeAsync().ConfigureAwait(false); } + catch { session.MarkCleanupFailed(); throw; } + } + + private static readonly Action LogAuthorizationDuplicate = LoggerMessage.Define( + LogLevel.Warning, new EventId(6, nameof(LogAuthorizationDuplicate)), + "An exact authorization event is already durable. This establishes no business transaction outcome."); + + private static readonly Action LogAuthorizationWriteFailed = LoggerMessage.Define( + LogLevel.Critical, new EventId(7, nameof(LogAuthorizationWriteFailed)), + "Authorization evidence could not be made durable. Audit health is unhealthy."); +} diff --git a/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.cs b/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.cs index 04286b1b3..303881ead 100644 --- a/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.cs +++ b/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.cs @@ -80,6 +80,7 @@ public sealed partial class PostgresAuditStore : IAuditStore private readonly IAuditCatalog _catalog; private readonly ITenantContextAccessor _tenantAccessor; private readonly IGlobalIdentityEnrollment _globalEnrollment; + private readonly IAuthorizationEvidenceSessionFactory? _authorizationEvidence; private readonly Counter _durableDuplicates; private readonly Counter _standaloneWriteFailures; @@ -91,7 +92,8 @@ public PostgresAuditStore( IAuditHealth health, IAuditCatalog catalog, ITenantContextAccessor tenantAccessor, - IGlobalIdentityEnrollment globalEnrollment) + IGlobalIdentityEnrollment globalEnrollment, + IAuthorizationEvidenceSessionFactory? authorizationEvidence = null) { ArgumentNullException.ThrowIfNull(meterFactory); @@ -104,6 +106,7 @@ public PostgresAuditStore( // from the caller's classified draft/intent, including unresolved provisioning. _tenantAccessor = tenantAccessor ?? throw new ArgumentNullException(nameof(tenantAccessor)); _globalEnrollment = globalEnrollment ?? throw new ArgumentNullException(nameof(globalEnrollment)); + _authorizationEvidence = authorizationEvidence; var meter = meterFactory.Create(MeterName); _durableDuplicates = meter.CreateCounter(DurableDuplicateCounterName); diff --git a/backend/src/LearnStack.Infrastructure/Jobs/BackgroundJobContractsRegistration.cs b/backend/src/LearnStack.Infrastructure/Jobs/BackgroundJobContractsRegistration.cs index 23f657e39..de9f3924b 100644 --- a/backend/src/LearnStack.Infrastructure/Jobs/BackgroundJobContractsRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Jobs/BackgroundJobContractsRegistration.cs @@ -2,6 +2,7 @@ using LearnStack.SharedKernel.Jobs; using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Permissions; using MediatR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -21,6 +22,7 @@ public static IServiceCollection AddLearnStackBackgroundJobContracts(this IServi services.TryAddSingleton(provider => provider.GetRequiredService()); services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddSingleton(); services.TryAddSingleton(TimeProvider.System); services.TryAddSingleton(); diff --git a/backend/src/LearnStack.Infrastructure/Messaging/DeliveryExecutor.cs b/backend/src/LearnStack.Infrastructure/Messaging/DeliveryExecutor.cs index aa0984f51..a9166f3f8 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/DeliveryExecutor.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/DeliveryExecutor.cs @@ -8,6 +8,7 @@ using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using Microsoft.Extensions.DependencyInjection; @@ -90,6 +91,7 @@ private async Task ExecuteOwnedAsync( TransactionExecutionCoordinator.PreparedDeliveryOwner? prepared = null; IUnitOfWork? unit = null; Activity? activity = null; + IDisposable? operationSource = null; try { // Routing validation/read owns no inherited caller identity. Module @@ -142,6 +144,9 @@ private async Task ExecuteOwnedAsync( outcome = claimed ? DeliveryExecutionOutcome.Completed : DeliveryExecutionOutcome.Duplicate; if (claimed) { + operationSource = services.GetServices() + .OfType().SingleOrDefault() + ?.Enter(prepared, subscription, envelope, gate, cancellationToken); var audit = services.GetRequiredService(); var response = await audit.ExecuteAsync(subscription.HandlerType, null, () => transaction.ExecutePreparedDeliveryAsync(prepared, completion, claim.EventId, @@ -207,6 +212,7 @@ await transaction.ExecutePreparedDeliveryAsync(prepared, completion, claim.Event } finally { + operationSource?.Dispose(); tenantAccessor.Current = previous; } } diff --git a/backend/src/LearnStack.Infrastructure/Messaging/DeliveryOperationSource.cs b/backend/src/LearnStack.Infrastructure/Messaging/DeliveryOperationSource.cs new file mode 100644 index 000000000..9c7f5b510 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/DeliveryOperationSource.cs @@ -0,0 +1,105 @@ +using System.Data.Common; +using LearnStack.Application.Messaging; +using LearnStack.Application.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LearnStack.Infrastructure.Messaging; + +/// Installed only by the retained, validated delivery owner; policy metadata supplies no owner. +internal sealed class DeliveryOperationSource(IOperationPolicyCatalog policies, IUnitOfWork unit, + ITenantContextAccessor accessor) : TrustedOperationDescriptor.MintingSource, ITrustedOperationSource +{ + private Frame? _frame; + + internal IDisposable Enter(TransactionExecutionCoordinator.PreparedDeliveryOwner prepared, + IntegrationEventSubscription subscription, IntegrationEventEnvelope envelope, + DeliveryCommitGate gate, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(prepared); + ArgumentNullException.ThrowIfNull(subscription); + ArgumentNullException.ThrowIfNull(envelope); + ArgumentNullException.ThrowIfNull(gate); + if (!CanIssue || _frame is not null || accessor.Current is not { } context + || unit.Transaction is not { } transaction + || unit.GetTenantContextAnnouncement(transaction) is not { Generation: > 0 } announcement + || subscription.EventType != envelope.Event.GetType() + || envelope.Event.EventId == Guid.Empty || envelope.Event.TenantId != announcement.TenantId.Value + || envelope.OrganizationId != announcement.OrganizationId?.Value) + throw new InvalidOperationException("Delivery operation provenance is invalid."); + var frame = new Frame(prepared, subscription, envelope.Event.EventId, context, transaction, + announcement, gate, cancellationToken); + _frame = frame; + if (!IsEvidenceCurrent(frame)) + { + frame.Close(); + throw new InvalidOperationException("Delivery operation provenance is invalid."); + } + return new Scope(frame); + } + + public TrustedOperationDescriptor? Capture(Type requestType, ITenantContext context) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(context); + if (!CanIssue || _frame is not { } frame || !ReferenceEquals(frame.Context, context) + || !IsEvidenceCurrent(frame) || !policies.TryGet(requestType, out var policy) + || policy.Kind != OperationPolicyKind.FiniteTrusted + || !policy.AllowedSources.Contains(AuthorizationSourceKind.IntegrationEvent) + || policy.TrustedEventHandlerType != frame.Subscription.HandlerType) return null; + return ForOperation(AuthorizationSourceKind.IntegrationEvent, requestType, context, frame); + } + + protected override bool IsEvidenceCurrent(object? evidence) => evidence is Frame frame + && ReferenceEquals(frame, _frame) && frame.IsOpen && !frame.CancellationToken.IsCancellationRequested + && !frame.Gate.IsClosed && !frame.Gate.CommitStarted && !frame.Gate.CommitAcknowledged + && ReferenceEquals(accessor.Current, frame.Context) + && unit.HasActiveTransaction && !unit.IsRollbackOnly && unit.Mode == TransactionMode.ReadWrite + && ReferenceEquals(unit.Transaction, frame.Transaction) + && unit.IsTenantContextIssuedOn(frame.Transaction) + && unit.GetTenantContextAnnouncement(frame.Transaction) == frame.Announcement + && frame.Context.IsResolved && frame.Context.Origin == TenantContextOrigin.Ambient + && frame.Context.HostScope is null && frame.Context.UserId == UserId.SystemActor + && frame.Context.TenantId == frame.Announcement.TenantId + && frame.Context.OrganizationId == frame.Announcement.OrganizationId; + + private sealed class Frame(TransactionExecutionCoordinator.PreparedDeliveryOwner prepared, + IntegrationEventSubscription subscription, Guid eventId, ITenantContext context, + DbTransaction transaction, TenantContextAnnouncement announcement, + DeliveryCommitGate gate, CancellationToken cancellationToken) + { + private int _closed; + internal TransactionExecutionCoordinator.PreparedDeliveryOwner Prepared { get; } = prepared; + internal IntegrationEventSubscription Subscription { get; } = subscription; + internal Guid EventId { get; } = eventId; + internal ITenantContext Context { get; } = context; + internal DbTransaction Transaction { get; } = transaction; + internal TenantContextAnnouncement Announcement { get; } = announcement; + internal DeliveryCommitGate Gate { get; } = gate; + internal CancellationToken CancellationToken { get; } = cancellationToken; + internal bool IsOpen => Volatile.Read(ref _closed) == 0; + internal void Close() => Interlocked.Exchange(ref _closed, 1); + public override string ToString() => nameof(Frame); + } + + private sealed class Scope(Frame frame) : IDisposable + { + public void Dispose() => frame.Close(); + } +} + +/// Registers the confined source without activating application authorization. +public static class DeliveryOperationSourceRegistration +{ + public static IServiceCollection AddTrustedDeliveryOperations(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + return services; + } +} diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs index c372540be..975392bf9 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs @@ -33,8 +33,8 @@ namespace LearnStack.Infrastructure.MultiTenancy; /// /// 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. P02b-5 supplies validated claims, -/// but the deny-all membership reader short-circuits this read until Phase 03. +/// caller is the reconciliation matrix's row 7. P03-2 supplies the live +/// membership reader; the separate organization lookup retains its own ceiling. /// 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 diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ApplicationDataSource.cs b/backend/src/LearnStack.Infrastructure/Persistence/ApplicationDataSource.cs index d86413a44..d3b126b3f 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ApplicationDataSource.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ApplicationDataSource.cs @@ -81,7 +81,12 @@ public static void Validate(string? connectionString) } } - private static async Task RefuseBypassRole(NpgsqlConnection connection, bool async) + /// The confined authorization checkout applies this same guard within its total I/O deadline. + internal static Task RefuseBypassRoleAsync(NpgsqlConnection connection, CancellationToken cancellationToken) => + RefuseBypassRole(connection, async: true, cancellationToken); + + private static async Task RefuseBypassRole(NpgsqlConnection connection, bool async, + CancellationToken cancellationToken = default) { await using var command = connection.CreateCommand(); @@ -101,7 +106,7 @@ AND pg_has_role(current_user, r.oid, 'MEMBER')) """; var bypasses = async - ? await command.ExecuteScalarAsync() + ? await command.ExecuteScalarAsync(cancellationToken) : command.ExecuteScalar(); if (bypasses is true) diff --git a/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationEvidenceSession.cs b/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationEvidenceSession.cs new file mode 100644 index 000000000..aa211e442 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationEvidenceSession.cs @@ -0,0 +1,265 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Npgsql; + +namespace LearnStack.Infrastructure.Persistence; + +/// One evidence connector, sequential transactions and retained actual cleanup. +internal sealed class AuthorizationEvidenceSession : IAuthorizationEvidenceSession +{ + private readonly AuthorizationEvidenceSource _source; + private readonly NpgsqlDataSource _dataSource; + private readonly AuditEntryDraft? _draft; + private readonly CancellationTokenSource _deadline; + private readonly CancellationToken _deadlineToken; + private readonly JobApplicationAuxiliaryResources? _jobResources; + private readonly TaskCompletionSource _actualCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly object _gate = new(); + private long _generation; + private NpgsqlConnection? _connection; + private NpgsqlTransaction? _transaction; + private TenantContextAnnouncement? _announcement; + private Task? _transactionCleanup; + private Task? _cleanup; + private bool _verificationStarted; + private bool _switching; + private bool _readOnly; + private bool _terminal; + private bool _committed; + private bool _cleanupFailed; + + internal AuthorizationEvidenceSession(AuthorizationEvidenceSource source, NpgsqlDataSource dataSource, + AuthorizationEvidenceOccurrence? occurrence, CancellationTokenSource deadline, long generation, + JobApplicationAuxiliaryResources? jobResources) + { + _source = source; + _dataSource = dataSource; + _draft = occurrence?.Draft; + _deadline = deadline; + _deadlineToken = deadline.Token; + _generation = generation; + _jobResources = jobResources; + Unit = new ConfinedUnit(this); + // The task owns both concrete transaction lifetimes, including the awaited + // disposal between them. The job lane separately retains their one connection. + _jobResources?.HoldActualTask(_actualCompletion.Task); + _ = _actualCompletion.Task.ContinueWith(static task => _ = task.Exception, + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + public IUnitOfWork Unit { get; } + public CancellationToken DeadlineToken => _deadlineToken; + internal Task ActualCompletion => _actualCompletion.Task; + + public void MarkCleanupFailed() + { + _cleanupFailed = true; + _announcement = null; + _jobResources?.MarkCleanupFailed(); + } + + public async Task BeginVerificationAsync() + { + EnsureLive(); + if (_verificationStarted || _draft is null) + throw new InvalidOperationException("Evidence verification cannot be entered again."); + _verificationStarted = true; + _switching = true; + _announcement = null; + // No fresh transaction, slot or deadline until actual INSERT transaction + // disposal succeeds. A failed disposal task is retained and never retried. + await DisposeTransactionAsync().ConfigureAwait(false); + _transaction = null; + _transactionCleanup = null; + DeadlineToken.ThrowIfCancellationRequested(); + _transaction = await _connection!.BeginTransactionAsync(IsolationLevel.ReadCommitted, DeadlineToken).ConfigureAwait(false); + await ExecuteSetupAsync("SET TRANSACTION READ ONLY").ConfigureAwait(false); + _readOnly = true; + _generation = checked(_generation + 1); + await AnnounceAsync().ConfigureAwait(false); + _switching = false; + } + + public async Task CompleteAsync() + { + EnsureLive(); + _terminal = true; + _announcement = null; + DeadlineToken.ThrowIfCancellationRequested(); + await _transaction!.CommitAsync(DeadlineToken).ConfigureAwait(false); + _committed = true; + } + + public ValueTask DisposeAsync() + { + lock (_gate) + { + _terminal = true; + _announcement = null; + return new ValueTask(_cleanup ??= CleanupAsync()); + } + } + + internal async Task OpenAsync() + { + DeadlineToken.ThrowIfCancellationRequested(); + _connection = _dataSource.CreateConnection(); + _jobResources?.HoldConnection(_connection); + await _connection.OpenAsync(DeadlineToken).ConfigureAwait(false); + await ApplicationDataSource.RefuseBypassRoleAsync(_connection, DeadlineToken).ConfigureAwait(false); + _transaction = await _connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, DeadlineToken).ConfigureAwait(false); + if (_draft is null) + { + await ExecuteSetupAsync("SET TRANSACTION READ ONLY").ConfigureAwait(false); + _readOnly = true; + return; + } + await AnnounceAsync().ConfigureAwait(false); + } + + internal async Task QualifyAsync(IdentityAuthorityId authorityId, string issuer, DatabaseQualificationProof? businessProof) + { + var command = new NpgsqlCommand(""" + SELECT current_user, current_database(), authority_id, issuer, + NOT @has_business_proof OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_locks + WHERE locktype = 'advisory' AND granted AND mode = 'ExclusiveLock' + AND database = (SELECT oid FROM pg_catalog.pg_database WHERE datname = current_database()) + AND pid = @business_pid AND objsubid = 2 + AND classid::bigint = @business_first AND objid::bigint = @business_second) + FROM public.identity_authority WHERE id = 1 + """, _connection, _transaction); + DbDataReader? reader = null; + try + { + DatabaseQualificationProof.Bind(command, businessProof); + reader = await command.ExecuteReaderAsync(DeadlineToken).ConfigureAwait(false); + if (!await reader.ReadAsync(DeadlineToken).ConfigureAwait(false) + || reader.GetString(0) != ApplicationDataSource.RuntimeRole + || string.IsNullOrEmpty(reader.GetString(1)) + || reader.GetGuid(2) != authorityId.Value + || !string.Equals(reader.GetString(3), issuer, StringComparison.Ordinal) || !reader.GetBoolean(4)) + throw new InvalidOperationException("Authorization evidence startup qualification is unavailable or does not match."); + businessProof?.RequireActive(); + return reader.GetString(1); + } + finally { await DisposeStatementAsync(reader, command).ConfigureAwait(false); } + } + + private async Task AnnounceAsync() + { + await ExecuteSetupAsync(""" + SELECT set_config('app.tenant_id', @tenant, true), + set_config('app.organization_id', @organization, true), + set_config('app.scope', '', true), set_config('app.resolving_host', '', true), + set_config('app.identity_authority_id', '', true), set_config('app.identity_subject_key', '', true) + """).ConfigureAwait(false); + DeadlineToken.ThrowIfCancellationRequested(); + _announcement = new TenantContextAnnouncement(_generation, _draft!.TenantId, _draft.OrganizationId); + } + + private async Task ExecuteSetupAsync(string statement) + { + var command = new NpgsqlCommand(statement, _connection, _transaction); + if (_draft is not null && statement.StartsWith("SELECT", StringComparison.Ordinal)) + { + command.Parameters.AddWithValue("tenant", _draft.TenantId.Value.ToString("D")); + command.Parameters.AddWithValue("organization", _draft.OrganizationId?.Value.ToString("D") ?? string.Empty); + } + try { await command.ExecuteNonQueryAsync(DeadlineToken).ConfigureAwait(false); } + finally { await DisposeStatementAsync(null, command).ConfigureAwait(false); } + } + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "Every concrete statement receives cleanup; any failure permanently retains the owner.")] + private async Task DisposeStatementAsync(DbDataReader? reader, DbCommand command) + { + ExceptionDispatchInfo? failure = null; + if (reader is not null) + { + try { await reader.DisposeAsync().ConfigureAwait(false); } + catch (Exception exception) { failure = ExceptionDispatchInfo.Capture(exception); } + } + try { await command.DisposeAsync().ConfigureAwait(false); } + catch (Exception exception) { failure ??= ExceptionDispatchInfo.Capture(exception); } + if (failure is not null) MarkCleanupFailed(); + failure?.Throw(); + } + + private async Task DisposeTransactionAsync() + { + if (_transaction is null) return; + try { await (_transactionCleanup ??= _transaction.DisposeAsync().AsTask()).ConfigureAwait(false); } + catch { MarkCleanupFailed(); throw; } + } + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "Actual transaction and connection cleanup is joined independently of I/O cancellation.")] + private async Task CleanupAsync() + { + ExceptionDispatchInfo? failure = null; + try { await DisposeTransactionAsync().ConfigureAwait(false); } + catch (Exception exception) { failure = ExceptionDispatchInfo.Capture(exception); } + if (_jobResources is not null) + { + try { await _jobResources.CleanupAsync().ConfigureAwait(false); } + catch (Exception exception) { failure ??= ExceptionDispatchInfo.Capture(exception); } + } + else if (_connection is not null) + { + try { await _connection.DisposeAsync().ConfigureAwait(false); } + catch (Exception exception) { failure ??= ExceptionDispatchInfo.Capture(exception); } + } + _deadline.Dispose(); + if (_cleanupFailed || failure is not null) + { + var exception = failure?.SourceException ?? new InvalidOperationException("Evidence resource cleanup is unconfirmed."); + _actualCompletion.TrySetException(exception); + ExceptionDispatchInfo.Capture(exception).Throw(); + } + _transaction = null; + _connection = null; + _source.Release(this); + _actualCompletion.TrySetResult(); + } + + private void EnsureLive() + { + DeadlineToken.ThrowIfCancellationRequested(); + JobApplicationSourceOwner.RefuseExternalEntry(); + if (_terminal || _cleanupFailed || _switching || _transaction is null) + throw new InvalidOperationException("The authorization evidence session is no longer live."); + } + + /// The store may insert/verify; only the session can resolve or replace this transaction. + private sealed class ConfinedUnit(AuthorizationEvidenceSession owner) : IUnitOfWork + { + public DbConnection Connection { get { owner.EnsureLive(); return owner._connection!; } } + public DbTransaction? Transaction => owner._terminal || owner._cleanupFailed || owner._switching + || owner.DeadlineToken.IsCancellationRequested ? null : owner._transaction; + public bool HasActiveTransaction => Transaction is not null; + public TransactionMode? Mode => HasActiveTransaction ? owner._readOnly ? TransactionMode.ReadOnly : TransactionMode.ReadWrite : null; + public bool IsRollbackOnly => owner._cleanupFailed || owner.DeadlineToken.IsCancellationRequested + || (owner._terminal && !owner._committed); + public bool IsTenantContextIssuedOn(DbTransaction? transaction) => GetTenantContextAnnouncement(transaction) is not null; + public TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction) => + transaction is not null && ReferenceEquals(transaction, Transaction) ? owner._announcement : null; + public Task BeginTransactionAsync(TransactionMode mode, CancellationToken cancellationToken = default) => throw Refused(); + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => throw Refused(); + public Task SetTenantContextAsync(ITenantContext context, CancellationToken cancellationToken = default) => throw Refused(); + public Task SetProvisioningTenantContextAsync(TenantId tenantId, CancellationToken cancellationToken = default) => throw Refused(); + public Task CommitAsync(CancellationToken cancellationToken = default) => throw Refused(); + public Task RollbackAsync(CancellationToken cancellationToken = default) => throw Refused(); + public void MarkRollbackOnly() => throw Refused(); + public ValueTask DisposeAsync() => throw Refused(); + private static InvalidOperationException Refused() => new("Only the confined evidence session controls this child unit."); + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationEvidenceSource.cs b/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationEvidenceSource.cs new file mode 100644 index 000000000..3c576a6c3 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationEvidenceSource.cs @@ -0,0 +1,183 @@ +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Audit; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace LearnStack.Infrastructure.Persistence; + +/// The two retained owners of the independent, guarded authorization evidence lane. +/// No datasource alias or caller-supplied statement/callback is published. +public sealed class AuthorizationEvidenceSource : IAuthorizationEvidenceSessionFactory, IAsyncDisposable +{ + private readonly object _gate = new(); + private readonly NpgsqlDataSource _source; + private readonly int _capacity; + private readonly HashSet _owners = []; + private Task? _qualification; + private DatabaseQualificationProof? _businessProof; + private Task? _disposal; + private IdentityAuthorityId? _authority; + private string? _issuer; + private bool _qualified; + private bool _closed; + private long _generation; + + public AuthorizationEvidenceSource(string connectionString) : this(connectionString, 2, null) { } + + internal AuthorizationEvidenceSource(string connectionString, int capacity, ILoggerFactory? loggerFactory = null) + { + if (capacity is < 1 or > 2) throw new ArgumentOutOfRangeException(nameof(capacity)); + AuthorizationSourceConfiguration.ValidateApplicationConnection(connectionString, "ConnectionStrings:AuthorizationEvidence"); + var parsed = new NpgsqlConnectionStringBuilder(connectionString) + { + Pooling = true, + MinPoolSize = 0, + MaxPoolSize = capacity, + Multiplexing = false, + Enlist = false, + }; + _capacity = capacity; + // Npgsql's physical initializer has no operation token. This private source instead + // applies the shared guard at every checkout, before any evidence transaction exists. + var builder = new NpgsqlDataSourceBuilder(parsed.ConnectionString); + if (loggerFactory is not null) builder.UseLoggerFactory(loggerFactory); + try { _source = builder.Build(); } + catch (Exception exception) when (exception is ArgumentException or FormatException or InvalidOperationException) + { + throw new InvalidOperationException("ConnectionStrings:AuthorizationEvidence must configure a valid guarded application-role connection string."); + } + } + + public bool IsQualified { get { lock (_gate) return _qualified && !_closed; } } + + /// The actual server database observed at qualification, independently of a PgBouncer alias. + public string? QualifiedDatabaseName { get; private set; } + + public Task QualifyAsync(IdentityAuthorityId authorityId, string issuer, CancellationToken cancellationToken = default) => + QualifyCoreEntryAsync(authorityId, issuer, null, cancellationToken); + + public Task QualifyAsync(IdentityAuthorityId authorityId, string issuer, DatabaseQualificationProof businessProof, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(businessProof); + businessProof.RequireActive(); + return QualifyCoreEntryAsync(authorityId, issuer, businessProof, cancellationToken); + } + + private Task QualifyCoreEntryAsync(IdentityAuthorityId authorityId, string issuer, + DatabaseQualificationProof? businessProof, CancellationToken cancellationToken) + { + if (!StronglyTypedId.IsAssigned(authorityId) || string.IsNullOrWhiteSpace(issuer)) + throw new ArgumentException("Authorization evidence qualification is invalid."); + JobApplicationSourceOwner.RefuseUnrelatedRead(); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_closed, this); + if (_qualification is not null) + { + if (_authority != authorityId || !string.Equals(_issuer, issuer, StringComparison.Ordinal) + || !ReferenceEquals(_businessProof, businessProof)) + throw new InvalidOperationException("Authorization evidence qualification cannot be retargeted."); + return _qualification; + } + _authority = authorityId; + _issuer = issuer; + _businessProof = businessProof; + return _qualification = QualifyCoreAsync(authorityId, issuer, businessProof, cancellationToken); + } + } + + public async Task OpenAsync(AuthorizationEvidenceOccurrence occurrence, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(occurrence); + JobApplicationSourceOwner.RefuseExternalEntry(); + var deadline = NewDeadline(cancellationToken); + AuthorizationEvidenceSession? owner; + try + { + deadline.Token.ThrowIfCancellationRequested(); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_closed, this); + if (!_qualified) throw new InvalidOperationException("Authorization evidence source is not qualified."); + deadline.Token.ThrowIfCancellationRequested(); + if (_owners.Count == _capacity) { deadline.Dispose(); return null; } + var jobResources = JobApplicationSourceOwner.Current?.BeginAuthorizationEvidence(); + owner = RetainOwner(occurrence, deadline, jobResources); + } + } + catch { deadline.Dispose(); throw; } + try + { + await owner.OpenAsync().ConfigureAwait(false); + return owner; + } + catch + { + try { await owner.DisposeAsync().ConfigureAwait(false); } + catch (Exception) + { + // Actual failed cleanup remains retained; preserve the original open failure. + } + throw; + } + } + + public ValueTask DisposeAsync() + { + lock (_gate) + { + _closed = true; + return new ValueTask(_disposal ??= DisposeCoreAsync([.. _owners])); + } + } + + internal void Release(AuthorizationEvidenceSession owner) + { + lock (_gate) _owners.Remove(owner); + } + + private AuthorizationEvidenceSession RetainOwner(AuthorizationEvidenceOccurrence? occurrence, CancellationTokenSource deadline, + JobApplicationAuxiliaryResources? jobResources) + { + var owner = new AuthorizationEvidenceSession(this, _source, occurrence, deadline, checked(++_generation), jobResources); + _owners.Add(owner); + return owner; + } + + private async Task QualifyCoreAsync(IdentityAuthorityId authorityId, string issuer, + DatabaseQualificationProof? businessProof, CancellationToken cancellationToken) + { + // Qualification is itself a retained owner, with no directory or business access. + var owner = RetainOwner(null, NewDeadline(cancellationToken), null); + await using (owner.ConfigureAwait(false)) + { + await owner.OpenAsync().ConfigureAwait(false); + QualifiedDatabaseName = await owner.QualifyAsync(authorityId, issuer, businessProof).ConfigureAwait(false); + await owner.CompleteAsync().ConfigureAwait(false); + } + businessProof?.RequireActive(); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_closed, this); + _qualified = true; + } + } + + private async Task DisposeCoreAsync(AuthorizationEvidenceSession[] owners) + { + // Neither deadline expiry nor a second Dispose supplies positive cleanup evidence. + // A failed actual cleanup faults this join and keeps the pool and retained owner alive. + await Task.WhenAll(owners.Select(owner => owner.ActualCompletion)).ConfigureAwait(false); + await _source.DisposeAsync().ConfigureAwait(false); + } + + private static CancellationTokenSource NewDeadline(CancellationToken cancellationToken) + { + var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(5)); + return deadline; + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationReadOwner.cs b/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationReadOwner.cs new file mode 100644 index 000000000..9eb27aacc --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationReadOwner.cs @@ -0,0 +1,233 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Npgsql; + +namespace LearnStack.Infrastructure.Persistence; + +/// One isolated committed-only candidate read and its retained actual cleanup. +public sealed class AuthorizationReadOwner : IAsyncDisposable +{ + private readonly IdentityAuthorizationReadSource _source; + private readonly NpgsqlDataSource _dataSource; + private readonly AuthorizationCandidate? _candidate; + private readonly CancellationTokenSource _deadline; + private readonly CancellationToken _deadlineToken; + private readonly long _generation; + private readonly JobApplicationAuxiliaryResources? _jobResources; + private readonly TaskCompletionSource _actualCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly object _gate = new(); + private NpgsqlConnection? _connection; + private NpgsqlTransaction? _transaction; + private TenantContextAnnouncement? _announcement; + private Task? _cleanup; + private bool _terminal; + private bool _committed; + private bool _cleanupFailed; + + internal AuthorizationReadOwner(IdentityAuthorizationReadSource source, NpgsqlDataSource dataSource, + AuthorizationCandidate? candidate, CancellationTokenSource deadline, long generation, + JobApplicationAuxiliaryResources? jobResources) + { + _source = source; + _dataSource = dataSource; + _candidate = candidate; + _deadline = deadline; + _deadlineToken = deadline.Token; + _generation = generation; + _jobResources = jobResources; + Unit = new ConfinedUnit(this); + _jobResources?.HoldActualTask(_actualCompletion.Task); + // A retained failure may have no remaining caller. Observe it without turning it into success. + _ = _actualCompletion.Task.ContinueWith(static task => _ = task.Exception, + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + public IUnitOfWork Unit { get; } + public CancellationToken DeadlineToken => _deadlineToken; + internal Task ActualCompletion => _actualCompletion.Task; + + /// Records a concrete command/reader disposal failure; elapsed time or SQL failure is not this witness. + public void MarkCleanupFailed() + { + _cleanupFailed = true; + _announcement = null; + _jobResources?.MarkCleanupFailed(); + } + + public async Task CompleteAsync() + { + EnsureLive(); + _terminal = true; + _announcement = null; + DeadlineToken.ThrowIfCancellationRequested(); + await _transaction!.CommitAsync(DeadlineToken).ConfigureAwait(false); + _committed = true; + } + + public ValueTask DisposeAsync() + { + lock (_gate) + { + _terminal = true; + _announcement = null; + return new ValueTask(_cleanup ??= CleanupAsync()); + } + } + + internal async Task OpenAsync() + { + DeadlineToken.ThrowIfCancellationRequested(); + _connection = _dataSource.CreateConnection(); + _jobResources?.HoldConnection(_connection); + await _connection.OpenAsync(DeadlineToken).ConfigureAwait(false); + await ApplicationDataSource.RefuseBypassRoleAsync(_connection, DeadlineToken).ConfigureAwait(false); + _transaction = await _connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, DeadlineToken).ConfigureAwait(false); + _jobResources?.HoldTransaction(_transaction); + await ExecuteSetupAsync("SET TRANSACTION READ ONLY").ConfigureAwait(false); + if (_candidate is null) return; + if (!_candidate.IsActive) throw new InvalidOperationException("Authorization candidate owner is closed."); + await ExecuteSetupAsync(""" + SELECT set_config('app.tenant_id', @tenant, true), + set_config('app.organization_id', @organization, true), + set_config('app.scope', '', true), set_config('app.resolving_host', '', true), + set_config('app.identity_authority_id', '', true), set_config('app.identity_subject_key', '', true) + """).ConfigureAwait(false); + DeadlineToken.ThrowIfCancellationRequested(); + if (!_candidate.IsActive) throw new InvalidOperationException("Authorization candidate owner is closed."); + _announcement = new TenantContextAnnouncement(_generation, _candidate.TenantId, _candidate.OrganizationId); + } + + internal async Task QualifyAsync(IdentityAuthorityId authorityId, string issuer, DatabaseQualificationProof? businessProof) + { + var command = new NpgsqlCommand(""" + SELECT current_user, current_database(), authority_id, issuer, + NOT @has_business_proof OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_locks + WHERE locktype = 'advisory' AND granted AND mode = 'ExclusiveLock' + AND database = (SELECT oid FROM pg_catalog.pg_database WHERE datname = current_database()) + AND pid = @business_pid AND objsubid = 2 + AND classid::bigint = @business_first AND objid::bigint = @business_second) + FROM public.identity_authority WHERE id = 1 + """, _connection, _transaction); + DbDataReader? reader = null; + try + { + DatabaseQualificationProof.Bind(command, businessProof); + reader = await command.ExecuteReaderAsync(DeadlineToken).ConfigureAwait(false); + if (!await reader.ReadAsync(DeadlineToken).ConfigureAwait(false) + || reader.GetString(0) != ApplicationDataSource.RuntimeRole + || string.IsNullOrEmpty(reader.GetString(1)) + || reader.GetGuid(2) != authorityId.Value + || !string.Equals(reader.GetString(3), issuer, StringComparison.Ordinal) || !reader.GetBoolean(4)) + throw new InvalidOperationException("Identity authorization startup qualification is unavailable or does not match."); + businessProof?.RequireActive(); + return reader.GetString(1); + } + finally { await DisposeStatementAsync(reader, command).ConfigureAwait(false); } + } + + private async Task ExecuteSetupAsync(string statement) + { + var command = new NpgsqlCommand(statement, _connection, _transaction); + if (_candidate is not null && statement.StartsWith("SELECT", StringComparison.Ordinal)) + { + command.Parameters.AddWithValue("tenant", _candidate.TenantId.Value.ToString("D")); + command.Parameters.AddWithValue("organization", _candidate.OrganizationId?.Value.ToString("D") ?? string.Empty); + } + try { await command.ExecuteNonQueryAsync(DeadlineToken).ConfigureAwait(false); } + finally { await DisposeStatementAsync(null, command).ConfigureAwait(false); } + } + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "Every concrete resource receives its cleanup attempt; any failure retains the owner.")] + private async Task DisposeStatementAsync(DbDataReader? reader, DbCommand command) + { + ExceptionDispatchInfo? failure = null; + if (reader is not null) + { + try { await reader.DisposeAsync().ConfigureAwait(false); } + catch (Exception exception) { failure = ExceptionDispatchInfo.Capture(exception); } + } + try { await command.DisposeAsync().ConfigureAwait(false); } + catch (Exception exception) { failure ??= ExceptionDispatchInfo.Capture(exception); } + if (failure is not null) MarkCleanupFailed(); + failure?.Throw(); + } + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "All actual cleanup is attempted and observed independently of the deadline and query outcome.")] + private async Task CleanupAsync() + { + ExceptionDispatchInfo? failure = null; + if (_jobResources is not null) + { + try { await _jobResources.CleanupAsync().ConfigureAwait(false); } + catch (Exception exception) { failure = ExceptionDispatchInfo.Capture(exception); } + } + else + { + if (_transaction is not null) + { + try { await _transaction.DisposeAsync().ConfigureAwait(false); } + catch (Exception exception) { failure = ExceptionDispatchInfo.Capture(exception); } + } + if (_connection is not null) + { + try { await _connection.DisposeAsync().ConfigureAwait(false); } + catch (Exception exception) { failure ??= ExceptionDispatchInfo.Capture(exception); } + } + } + _deadline.Dispose(); + if (_cleanupFailed || failure is not null) + { + var exception = failure?.SourceException ?? new InvalidOperationException("Authorization resource cleanup is unconfirmed."); + _actualCompletion.TrySetException(exception); + ExceptionDispatchInfo.Capture(exception).Throw(); + } + _transaction = null; + _connection = null; + _source.Release(this); + _actualCompletion.TrySetResult(); + } + + private void EnsureLive() + { + DeadlineToken.ThrowIfCancellationRequested(); + JobApplicationSourceOwner.RefuseExternalEntry(); + if (_terminal || _cleanupFailed || _transaction is null || (_candidate is not null && !_candidate.IsActive)) + throw new InvalidOperationException("The authorization read owner is no longer live."); + } + + /// Read-only evidence of this child; it cannot open, join, retarget or resolve any unit. + private sealed class ConfinedUnit(AuthorizationReadOwner owner) : IUnitOfWork + { + public DbConnection Connection { get { owner.EnsureLive(); return owner._connection!; } } + public DbTransaction? Transaction => owner._terminal || owner._cleanupFailed || owner.DeadlineToken.IsCancellationRequested + || owner._candidate?.IsActive == false ? null : owner._transaction; + public bool HasActiveTransaction => Transaction is not null; + public TransactionMode? Mode => HasActiveTransaction ? TransactionMode.ReadOnly : null; + public bool IsRollbackOnly => owner._cleanupFailed || owner.DeadlineToken.IsCancellationRequested + || (owner._terminal && !owner._committed); + public bool IsTenantContextIssuedOn(DbTransaction? transaction) => GetTenantContextAnnouncement(transaction) is not null; + public TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction) => + transaction is not null && ReferenceEquals(transaction, Transaction) && owner._candidate?.IsActive == true + ? owner._announcement : null; + public Task BeginTransactionAsync(TransactionMode mode, CancellationToken cancellationToken = default) => throw Refused(); + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => throw Refused(); + public Task SetTenantContextAsync(ITenantContext context, CancellationToken cancellationToken = default) => throw Refused(); + public Task SetProvisioningTenantContextAsync(TenantId tenantId, CancellationToken cancellationToken = default) => throw Refused(); + public Task CommitAsync(CancellationToken cancellationToken = default) => throw Refused(); + public Task RollbackAsync(CancellationToken cancellationToken = default) => throw Refused(); + public void MarkRollbackOnly() => throw Refused(); + public ValueTask DisposeAsync() => throw Refused(); + private static InvalidOperationException Refused() => new("Only the confined authorization owner controls this child unit."); + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationSourceConfiguration.cs b/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationSourceConfiguration.cs new file mode 100644 index 000000000..9aacfc915 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/AuthorizationSourceConfiguration.cs @@ -0,0 +1,54 @@ +using Npgsql; + +namespace LearnStack.Infrastructure.Persistence; + +/// The coupled auxiliary endpoints; their values never enter diagnostic text. +public sealed class AuthorizationSourceConfiguration +{ + private AuthorizationSourceConfiguration(string authorization, string evidence) + { + IdentityAuthorization = authorization; + AuthorizationEvidence = evidence; + } + + public string IdentityAuthorization { get; } + public string AuthorizationEvidence { get; } + + /// Selects an explicit pair, or derives the owned direct-PostgreSQL Development topology. + /// + /// The composition root alone supplies the derivation permission. Direct PostgreSQL is its + /// operational precondition, not something an arbitrary endpoint string can prove. PgBouncer + /// always needs the explicit pair and separately qualified backend reservations. + /// + public static AuthorizationSourceConfiguration Resolve(string? business, string? authorization, string? evidence, + bool allowDevelopmentDerivation) + { + ValidateApplicationConnection(business, "ConnectionStrings:Default"); + var hasAuthorization = !string.IsNullOrWhiteSpace(authorization); + var hasEvidence = !string.IsNullOrWhiteSpace(evidence); + if (hasAuthorization != hasEvidence) + throw new InvalidOperationException("IdentityAuthorization and AuthorizationEvidence connection strings must be supplied together."); + if (!hasAuthorization) + { + var parsed = new NpgsqlConnectionStringBuilder(business!); + if (!allowDevelopmentDerivation || parsed.Database is "learnstack_identity_authorization" or "learnstack_authorization_evidence") + throw new InvalidOperationException("Explicit authorization and evidence connection strings are required for this topology."); + authorization = business; + evidence = business; + } + ValidateApplicationConnection(authorization, "ConnectionStrings:IdentityAuthorization"); + ValidateApplicationConnection(evidence, "ConnectionStrings:AuthorizationEvidence"); + return new AuthorizationSourceConfiguration(authorization!, evidence!); + } + + internal static void ValidateApplicationConnection(string? connectionString, string key) + { + try { ApplicationDataSource.Validate(connectionString); } + catch (Exception exception) when (exception is ArgumentException or FormatException or InvalidOperationException) + { + throw new InvalidOperationException($"{key} must configure a valid guarded application-role connection string."); + } + } + + public override string ToString() => nameof(AuthorizationSourceConfiguration); +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/DatabaseQualificationProof.cs b/backend/src/LearnStack.Infrastructure/Persistence/DatabaseQualificationProof.cs new file mode 100644 index 000000000..cd34604d1 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/DatabaseQualificationProof.cs @@ -0,0 +1,122 @@ +using System.Buffers.Binary; +using System.Data; +using System.Security.Cryptography; +using LearnStack.SharedKernel.Jobs; +using Npgsql; + +namespace LearnStack.Infrastructure.Persistence; + +/// A bounded startup-only witness that auxiliary lanes reach the live business database. +/// +/// The two-int advisory key space is distinct from every bigint business fence. +/// No schema, grant, tenant announcement, business lock or row mutation is involved. +/// The actual business transaction keeps its unpredictable marker until qualification finishes. +/// +public sealed class DatabaseQualificationProof : IAsyncDisposable +{ + private readonly NpgsqlConnection _connection; + private readonly object _gate = new(); + private readonly NpgsqlTransaction _transaction; + private readonly CancellationTokenSource _deadline; + private readonly int _first; + private readonly int _second; + private readonly int _backendPid; + private int _closed; + private Task? _disposal; + + private DatabaseQualificationProof(NpgsqlConnection connection, NpgsqlTransaction transaction, + CancellationTokenSource deadline, int first, int second, int backendPid) + { + _connection = connection; _transaction = transaction; _deadline = deadline; + _first = first; _second = second; + _backendPid = backendPid; + } + + public CancellationToken DeadlineToken => _deadline.Token; + + public static async Task OpenAsync(NpgsqlDataSource source, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + JobApplicationSourceOwner.RefuseUnrelatedRead(); + var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(5)); + NpgsqlConnection? connection = null; + NpgsqlTransaction? transaction = null; + try + { + connection = await source.OpenConnectionAsync(deadline.Token).ConfigureAwait(false); + await ApplicationDataSource.RefuseBypassRoleAsync(connection, deadline.Token).ConfigureAwait(false); + transaction = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, deadline.Token).ConfigureAwait(false); + await using (var readOnly = new NpgsqlCommand("SET TRANSACTION READ ONLY", connection, transaction)) + await readOnly.ExecuteNonQueryAsync(deadline.Token).ConfigureAwait(false); + int backendPid; + await using (var identity = new NpgsqlCommand("SELECT current_user, pg_backend_pid()", connection, transaction)) + await using (var reader = await identity.ExecuteReaderAsync(deadline.Token).ConfigureAwait(false)) + { + if (!await reader.ReadAsync(deadline.Token).ConfigureAwait(false) + || reader.GetString(0) != ApplicationDataSource.RuntimeRole) + throw new InvalidOperationException("Business database qualification requires the guarded application role."); + // PgBouncer's protocol cancellation PID identifies its client; + // only SQL identifies the server that owns this transaction. + backendPid = reader.GetInt32(1); + } + var bytes = RandomNumberGenerator.GetBytes(8); + var first = BinaryPrimitives.ReadInt32BigEndian(bytes); + var second = BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(4)); + await using (var marker = new NpgsqlCommand("SELECT pg_advisory_xact_lock(@first, @second)", connection, transaction)) + { + marker.Parameters.AddWithValue("first", first); + marker.Parameters.AddWithValue("second", second); + await marker.ExecuteNonQueryAsync(deadline.Token).ConfigureAwait(false); + } + return new(connection, transaction, deadline, first, second, backendPid); + } + catch + { + try { if (transaction is not null) await transaction.DisposeAsync().ConfigureAwait(false); } + finally + { + try { if (connection is not null) await connection.DisposeAsync().ConfigureAwait(false); } + finally { deadline.Dispose(); } + } + throw; + } + } + + internal void RequireActive() + { + _deadline.Token.ThrowIfCancellationRequested(); + if (Volatile.Read(ref _closed) != 0 || _connection.State != ConnectionState.Open + || !ReferenceEquals(_transaction.Connection, _connection)) + throw new InvalidOperationException("Business database qualification proof is no longer active."); + } + + internal static void Bind(NpgsqlCommand command, DatabaseQualificationProof? proof) + { + proof?.RequireActive(); + command.Parameters.AddWithValue("has_business_proof", proof is not null); + command.Parameters.AddWithValue("business_pid", proof?._backendPid ?? 0); + command.Parameters.AddWithValue("business_first", proof is null ? 0L : unchecked((uint)proof._first)); + command.Parameters.AddWithValue("business_second", proof is null ? 0L : unchecked((uint)proof._second)); + } + + public ValueTask DisposeAsync() + { + lock (_gate) + { + Volatile.Write(ref _closed, 1); + return new ValueTask(_disposal ??= DisposeCoreAsync()); + } + } + + private async Task DisposeCoreAsync() + { + try { await _transaction.DisposeAsync().ConfigureAwait(false); } + finally + { + try { await _connection.DisposeAsync().ConfigureAwait(false); } + finally { _deadline.Dispose(); } + } + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/IdentityAuthorizationReadSource.cs b/backend/src/LearnStack.Infrastructure/Persistence/IdentityAuthorizationReadSource.cs new file mode 100644 index 000000000..5c35ebfd7 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/IdentityAuthorizationReadSource.cs @@ -0,0 +1,184 @@ +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Permissions; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace LearnStack.Infrastructure.Persistence; + +/// The four retained owners of the independent, guarded authorization lane. +/// No datasource alias or caller-supplied statement/callback is published. +public sealed class IdentityAuthorizationReadSource : IAsyncDisposable +{ + private readonly object _gate = new(); + private readonly NpgsqlDataSource _source; + private readonly int _capacity; + private readonly HashSet _owners = []; + private Task? _qualification; + private DatabaseQualificationProof? _businessProof; + private Task? _disposal; + private IdentityAuthorityId? _authority; + private string? _issuer; + private bool _qualified; + private bool _closed; + private long _generation; + + public IdentityAuthorizationReadSource(string connectionString) : this(connectionString, 4, null) { } + + internal IdentityAuthorizationReadSource(string connectionString, int capacity, ILoggerFactory? loggerFactory = null) + { + if (capacity is < 1 or > 4) throw new ArgumentOutOfRangeException(nameof(capacity)); + AuthorizationSourceConfiguration.ValidateApplicationConnection(connectionString, "ConnectionStrings:IdentityAuthorization"); + var parsed = new NpgsqlConnectionStringBuilder(connectionString) + { + Pooling = true, + MinPoolSize = 0, + MaxPoolSize = capacity, + Multiplexing = false, + Enlist = false, + }; + _capacity = capacity; + // Npgsql's physical initializer has no operation token. This private source instead + // applies the shared guard at every checkout, before any candidate transaction exists. + var builder = new NpgsqlDataSourceBuilder(parsed.ConnectionString); + if (loggerFactory is not null) builder.UseLoggerFactory(loggerFactory); + try { _source = builder.Build(); } + catch (Exception exception) when (exception is ArgumentException or FormatException or InvalidOperationException) + { + throw new InvalidOperationException("ConnectionStrings:IdentityAuthorization must configure a valid guarded application-role connection string."); + } + } + + public bool IsQualified { get { lock (_gate) return _qualified && !_closed; } } + + /// The actual server database observed at qualification, independently of a PgBouncer alias. + public string? QualifiedDatabaseName { get; private set; } + + public Task QualifyAsync(IdentityAuthorityId authorityId, string issuer, CancellationToken cancellationToken = default) => + QualifyCoreEntryAsync(authorityId, issuer, null, cancellationToken); + + public Task QualifyAsync(IdentityAuthorityId authorityId, string issuer, DatabaseQualificationProof businessProof, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(businessProof); + businessProof.RequireActive(); + return QualifyCoreEntryAsync(authorityId, issuer, businessProof, cancellationToken); + } + + private Task QualifyCoreEntryAsync(IdentityAuthorityId authorityId, string issuer, + DatabaseQualificationProof? businessProof, CancellationToken cancellationToken) + { + if (!StronglyTypedId.IsAssigned(authorityId) || string.IsNullOrWhiteSpace(issuer)) + throw new ArgumentException("Identity authorization qualification is invalid."); + JobApplicationSourceOwner.RefuseUnrelatedRead(); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_closed, this); + if (_qualification is not null) + { + if (_authority != authorityId || !string.Equals(_issuer, issuer, StringComparison.Ordinal) + || !ReferenceEquals(_businessProof, businessProof)) + throw new InvalidOperationException("Identity authorization qualification cannot be retargeted."); + return _qualification; + } + _authority = authorityId; + _issuer = issuer; + _businessProof = businessProof; + return _qualification = QualifyCoreAsync(authorityId, issuer, businessProof, cancellationToken); + } + } + + public async Task OpenAsync(AuthorizationCandidate candidate, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(candidate); + JobApplicationSourceOwner.RefuseExternalEntry(); + if (!candidate.IsActive) throw new InvalidOperationException("Authorization candidate owner is closed."); + var deadline = NewDeadline(cancellationToken); + AuthorizationReadOwner? owner; + try + { + deadline.Token.ThrowIfCancellationRequested(); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_closed, this); + if (!_qualified) throw new InvalidOperationException("Identity authorization source is not qualified."); + deadline.Token.ThrowIfCancellationRequested(); + if (_owners.Count == _capacity) { deadline.Dispose(); return null; } + var jobResources = JobApplicationSourceOwner.Current?.BeginAuthorizationRead(); + owner = RetainOwner(candidate, deadline, jobResources); + } + } + catch { deadline.Dispose(); throw; } + try + { + await owner.OpenAsync().ConfigureAwait(false); + return owner; + } + catch + { + try { await owner.DisposeAsync().ConfigureAwait(false); } + catch (Exception) + { + // Actual failed cleanup remains retained; preserve the original open failure. + } + throw; + } + } + + public ValueTask DisposeAsync() + { + lock (_gate) + { + _closed = true; + return new ValueTask(_disposal ??= DisposeCoreAsync([.. _owners])); + } + } + + internal void Release(AuthorizationReadOwner owner) + { + lock (_gate) _owners.Remove(owner); + } + + private AuthorizationReadOwner RetainOwner(AuthorizationCandidate? candidate, CancellationTokenSource deadline, + JobApplicationAuxiliaryResources? jobResources) + { + var owner = new AuthorizationReadOwner(this, _source, candidate, deadline, checked(++_generation), jobResources); + _owners.Add(owner); + return owner; + } + + private async Task QualifyCoreAsync(IdentityAuthorityId authorityId, string issuer, + DatabaseQualificationProof? businessProof, CancellationToken cancellationToken) + { + // Qualification is itself a retained owner, with no user-directory or candidate access. + var owner = RetainOwner(null, NewDeadline(cancellationToken), null); + await using (owner.ConfigureAwait(false)) + { + await owner.OpenAsync().ConfigureAwait(false); + QualifiedDatabaseName = await owner.QualifyAsync(authorityId, issuer, businessProof).ConfigureAwait(false); + await owner.CompleteAsync().ConfigureAwait(false); + } + businessProof?.RequireActive(); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_closed, this); + _qualified = true; + } + } + + private async Task DisposeCoreAsync(AuthorizationReadOwner[] owners) + { + // Neither deadline expiry nor a second Dispose supplies positive cleanup evidence. + // A failed actual cleanup faults this join and keeps the pool and retained owner alive. + await Task.WhenAll(owners.Select(owner => owner.ActualCompletion)).ConfigureAwait(false); + await _source.DisposeAsync().ConfigureAwait(false); + } + + private static CancellationTokenSource NewDeadline(CancellationToken cancellationToken) + { + var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(5)); + return deadline; + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs index 7008298de..e5b52434e 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using LearnStack.Infrastructure.Messaging; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; @@ -189,6 +190,8 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle // 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); + if (context is TenantScopedDbContext scoped && unitOfWork is IOperationReadScopeView readScope) + scoped.AttachOperationReadScope(readScope); if (globalAnnouncement is not null) global!.EnrollGlobalIdentityContext(context, globalAnnouncement.Descriptor); foreach (var interceptor in saveInterceptors.OfType()) diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index 9fa1d86e3..420835ab3 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -5,6 +5,7 @@ using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Identity; using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Persistence; using Microsoft.Extensions.Logging; using LearnStack.SharedKernel.Tenancy; @@ -46,7 +47,7 @@ namespace LearnStack.Infrastructure.Persistence; /// that a later BEGIN clears is not. /// /// -public sealed class NpgsqlUnitOfWork : IGlobalIdentityUnitOfWork +public sealed class NpgsqlUnitOfWork : IGlobalIdentityUnitOfWork, IOperationReadScopeView { private readonly NpgsqlDataSource _dataSource; @@ -96,6 +97,10 @@ public NpgsqlUnitOfWork(NpgsqlDataSource dataSource, ILogger l private bool _tenantContextIssued; private TenantContextAnnouncement? _tenantContextAnnouncement; + private readonly Dictionary _readWitnesses = []; + private UserId? _readActor; + private bool _tenantWideRead; + private bool _readScopeFaulted; private GlobalIdentityOwnerDescriptor? _globalIdentityDescriptor; private GlobalIdentityContextAnnouncement? _globalIdentityAnnouncement; private object? _globalIdentityContext; @@ -111,6 +116,7 @@ public DbConnection Connection { JobApplicationSourceOwner.RefuseExternalEntry(); ObjectDisposedException.ThrowIf(_disposed, this); + EnsureOperationReadScope(); if (_connection is null) { @@ -132,6 +138,7 @@ public DbTransaction? Transaction get { JobApplicationSourceOwner.RefuseExternalEntry(); + EnsureOperationReadScope(); return _transaction; } } @@ -140,6 +147,8 @@ public DbTransaction? Transaction public TransactionMode? Mode => _mode; + public bool IsTenantWideRead => _tenantWideRead && ValidateOperationReadScope(); + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => BeginTransactionAsync(TransactionMode.ReadWrite, cancellationToken); @@ -361,8 +370,15 @@ private void RequireGlobalIdentityOwner(GlobalIdentityOwnerDescriptor descriptor throw new InvalidOperationException("The global identity owner no longer matches its live transaction."); } - public async Task SetTenantContextAsync( - ITenantContext context, CancellationToken cancellationToken = default) + public Task SetTenantContextAsync(ITenantContext context, CancellationToken cancellationToken = default) => + SetTenantContextCoreAsync(context, null, tenantWideRead: false, cancellationToken); + + public Task SetTenantContextAsync(ITenantContext context, OperationReadWitness readWitness, + CancellationToken cancellationToken = default) => + SetTenantContextCoreAsync(context, readWitness, tenantWideRead: true, cancellationToken); + + private async Task SetTenantContextCoreAsync(ITenantContext context, OperationReadWitness? readWitness, + bool tenantWideRead, CancellationToken cancellationToken) { JobApplicationSourceOwner.RefuseExternalEntry(); ArgumentNullException.ThrowIfNull(context); @@ -382,14 +398,33 @@ public async Task SetTenantContextAsync( + "protects runs. Call BeginTransactionAsync first."); } + if (_readScopeFaulted || (_tenantContextIssued && _tenantWideRead != tenantWideRead) + || (tenantWideRead && readWitness is null)) + RefuseOperationReadScope(); + + if (_tenantWideRead && !ValidateOperationReadScope(requireCurrentFrame: false)) + RefuseOperationReadScope(); + if (_depth > 1) { // A joiner. Re-issuing would let an inner frame retarget the outer // frame's tenant — the same connection, the same transaction, a // different tenant for every statement after it. + if (tenantWideRead) + { + if (_tenantContextAnnouncement is not { } announced + || !MatchesReadWitness(context, readWitness!, announced.TenantId, announced.OrganizationId) + || _readWitnesses.ContainsKey(_depth) + || !readWitness!.TryBindTo(this, _transaction, _generation)) + RefuseOperationReadScope(); + _readWitnesses.Add(_depth, readWitness!); + } return; } + if (tenantWideRead && _tenantContextIssued) + RefuseOperationReadScope(); + // Clear before reading or sending a new announcement. A failed or canceled // attempt must not retain authority from an earlier successful one (ADR-0057). ClearTenantContextAnnouncement(); @@ -443,6 +478,11 @@ public async Task SetTenantContextAsync( ? normalizedOrganization.Value.ToString() : string.Empty; + if (tenantWideRead && (tenantId is not { } actualTenant + || !MatchesReadWitness(context, readWitness!, actualTenant, organizationId) + || !readWitness!.TryBindTo(this, _transaction, _generation))) + RefuseOperationReadScope(); + // 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 @@ -453,13 +493,29 @@ public async Task SetTenantContextAsync( LogResolvedWithoutTenant(_logger, context.GetType().Name, null); } - await ExecuteAsync( - "SELECT set_config('app.tenant_id', @tenant, true), " - + "set_config('app.organization_id', @organization, true), " - + "set_config('app.identity_authority_id', '', true), set_config('app.identity_subject_key', '', true)", - cancellationToken, - ("tenant", tenant), - ("organization", organization)); + try + { + await ExecuteAsync( + "SELECT set_config('app.tenant_id', @tenant, true), " + + "set_config('app.organization_id', @organization, true), set_config('app.scope', @scope, true), " + + "set_config('app.identity_authority_id', '', true), set_config('app.identity_subject_key', '', true)", + cancellationToken, + ("tenant", tenant), + ("organization", organization), + ("scope", tenantWideRead ? "tenant" : string.Empty)); + cancellationToken.ThrowIfCancellationRequested(); + if (tenantWideRead && !readWitness!.IsBoundTo(this, _transaction, _generation)) + RefuseOperationReadScope(); + } + catch + { + if (tenantWideRead) + { + _readScopeFaulted = true; + _rollbackOnly = true; + } + throw; + } // After the round trip, never before: the flag means the announcement actually // reached PostgreSQL, so a setter that threw leaves the transaction unmarked and @@ -468,6 +524,12 @@ await ExecuteAsync( _tenantContextAnnouncement = tenantId is { } announcedTenant ? new TenantContextAnnouncement(_generation, announcedTenant, organizationId) : null; + if (tenantWideRead) + { + _tenantWideRead = true; + _readActor = readWitness!.Actor; + _readWitnesses.Add(_depth, readWitness!); + } } /// @@ -477,6 +539,8 @@ public async Task SetProvisioningTenantContextAsync( JobApplicationSourceOwner.RefuseExternalEntry(); ObjectDisposedException.ThrowIf(_disposed, this); + if (_tenantWideRead || _readScopeFaulted) RefuseOperationReadScope(); + if (IsGlobalIdentityTransaction) { MarkRollbackOnly(); @@ -541,6 +605,7 @@ public async Task SetProvisioningTenantContextAsync( await ExecuteAsync( "SELECT set_config('app.tenant_id', @tenant, true), " + "set_config('app.organization_id', '', true), " + + "set_config('app.scope', '', true), " + "set_config('app.identity_authority_id', '', true), set_config('app.identity_subject_key', '', true)", cancellationToken, ("tenant", tenantId.Value.ToString())); @@ -553,16 +618,66 @@ await ExecuteAsync( public bool IsTenantContextIssuedOn(DbTransaction? transaction) => transaction is not null && ReferenceEquals(transaction, _transaction) - && _tenantContextIssued; + && _tenantContextIssued + && ValidateOperationReadScope(); /// public TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction) => IsTenantContextIssuedOn(transaction) ? _tenantContextAnnouncement : null; + private static bool MatchesReadWitness(ITenantContext context, OperationReadWitness witness, + TenantId tenantId, OrganizationId? organizationId) => + context.IsResolved && context.UserId == witness.Actor && context.TenantId == tenantId + && context.OrganizationId == organizationId && witness.TenantId == tenantId + && witness.OrganizationId == organizationId && witness.IsActive; + + private bool ValidateOperationReadScope(bool requireCurrentFrame = true) + { + if (_readScopeFaulted) return false; + if (!_tenantWideRead) return true; + var valid = !_disposed && !_rollbackOnly && _transaction is not null && _tenantContextIssued + && _tenantContextAnnouncement is not null && _readActor is not null && _depth > 0; + var expectedDepth = !requireCurrentFrame && _depth > 1 && !_readWitnesses.ContainsKey(_depth) ? _depth - 1 : _depth; + valid = valid && _readWitnesses.Count == expectedDepth; + for (var depth = 1; valid && depth <= expectedDepth; depth++) + { + valid = _readWitnesses.TryGetValue(depth, out var witness) + && witness.Actor == _readActor && witness.TenantId == _tenantContextAnnouncement!.TenantId + && witness.OrganizationId == _tenantContextAnnouncement.OrganizationId + && witness.IsBoundTo(this, _transaction!, _generation); + } + if (valid) return true; + _readScopeFaulted = true; + _rollbackOnly = true; + _tenantContextIssued = false; + _tenantContextAnnouncement = null; + return false; + } + + private void EnsureOperationReadScope() + { + if (!ValidateOperationReadScope()) + throw new InvalidOperationException("The operation read scope is no longer live; the unit is rollback-only."); + } + + [DoesNotReturn] + private void RefuseOperationReadScope() + { + _readScopeFaulted = true; + _rollbackOnly = true; + _tenantContextIssued = false; + _tenantContextAnnouncement = null; + throw new InvalidOperationException("A mixed or invalid operation read scope is forbidden; the unit is rollback-only."); + } + private void ClearTenantContextAnnouncement() { _tenantContextIssued = false; _tenantContextAnnouncement = null; + _tenantWideRead = false; + _readScopeFaulted = false; + _readActor = null; + _readWitnesses.Clear(); _globalIdentityDescriptor = null; _globalIdentityAnnouncement = null; _globalIdentityContext = null; @@ -690,6 +805,11 @@ public async ValueTask DisposeAsync() private async Task CommitFrameAsync(CancellationToken cancellationToken) { + if (!ValidateOperationReadScope()) + { + await RollbackCoreAsync(); + throw new InvalidOperationException("Operation read authority expired; the unit is rollback-only and was rolled back."); + } if (_globalIdentityDescriptor is not null && _mode == TransactionMode.ReadWrite && !_globalIdentitySealed) { MarkRollbackOnly(); @@ -698,6 +818,7 @@ private async Task CommitFrameAsync(CancellationToken cancellationToken) } _commitRequested = true; + _readWitnesses.Remove(_depth); if (--_depth > 0) { // A joiner's commit is not a commit. @@ -780,6 +901,7 @@ private async ValueTask DisposeCompletedTransactionAsync() private async Task RollbackFrameAsync() { + _readWitnesses.Remove(_depth); if (--_depth > 0) { // A joiner declining its own frame. It does NOT mark the unit: diff --git a/backend/src/LearnStack.Infrastructure/Persistence/TenantContextGuardInterceptor.cs b/backend/src/LearnStack.Infrastructure/Persistence/TenantContextGuardInterceptor.cs index f1f5191ea..879f6ccf9 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/TenantContextGuardInterceptor.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/TenantContextGuardInterceptor.cs @@ -12,12 +12,13 @@ namespace LearnStack.Infrastructure.Persistence; /// /// /// -/// It turns a silence into a failure, and it is not the isolation boundary. With +/// It checks the live owner as well as the SQL announcement. 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. +/// tenant-owned read returns zero rows and a write is refused. This interceptor makes +/// that missing announcement explicit. It also rejects an expired operation-bound +/// read witness: its transaction can still carry app.scope = 'tenant' until +/// cleanup. RLS enforces SQL scope; this guard additionally prevents retained EF +/// commands from using scope whose invocation has ended. /// /// /// Keyed on the transaction, not on the table. Some corpus sentences describe it @@ -107,7 +108,7 @@ public override ValueTask> ScalarExecutingAsync( return base.ScalarExecutingAsync(command, eventData, result, cancellationToken); } - /// Throws unless a sanctioned setter announced this command's transaction. + /// Throws unless this command's transaction has a current sanctioned announcement and read scope. private void Guard(DbCommand command, DbContext? context) { if (_unitOfWork is IGlobalIdentityUnitOfWork { IsGlobalIdentityTransaction: true } global) @@ -122,13 +123,10 @@ private void Guard(DbCommand command, DbContext? context) } 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."); + "A module DbContext command has no current sanctioned transaction announcement " + + "or operation-bound read scope. IUnitOfWork.SetTenantContextAsync announces " + + "the physical owner at pipeline step 6; any tenant-wide read witness must " + + "also remain bound to its active invocation and owner generation. " + + "See Security Standards § Tenant Context."); } } diff --git a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs index 8e816f4d0..de6257a8a 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using System.Reflection; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Tenancy; using Microsoft.EntityFrameworkCore; @@ -68,6 +69,9 @@ public interface ITenantScopedDbContext /// as the Row Level Security policy does with app.organization_id unset. /// OrganizationId? CurrentOrganizationId { get; } + + /// True only while this context's actual owner has live tenant-wide read authority. + bool CurrentTenantWideRead { get; } } /// @@ -201,22 +205,21 @@ private static LambdaExpression BuildFilter(Type clrType, ITenantScopedDbContext 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. + // The owner-bound instance member is re-evaluated for each query and mirrors + // only the policy's read hatch. The tenant predicate above remains mandatory. predicate = Expression.AndAlso( predicate, Expression.OrElse( - Expression.Equal( - rowOrganization, - Expression.Constant(null, typeof(OrganizationId?))), - Expression.Equal( - rowOrganization, - Expression.Property( - contextConstant, - nameof(ITenantScopedDbContext.CurrentOrganizationId))))); + Expression.Property(contextConstant, nameof(ITenantScopedDbContext.CurrentTenantWideRead)), + Expression.OrElse( + Expression.Equal( + rowOrganization, + Expression.Constant(null, typeof(OrganizationId?))), + Expression.Equal( + rowOrganization, + Expression.Property( + contextConstant, + nameof(ITenantScopedDbContext.CurrentOrganizationId)))))); } return Expression.Lambda(predicate, entity); @@ -224,7 +227,7 @@ private static LambdaExpression BuildFilter(Type clrType, ITenantScopedDbContext } /// -/// The base every module DbContext derives from: it owns the two members +/// The base every module DbContext derives from: it owns the scope members /// the filters close over and applies them. /// /// @@ -238,6 +241,18 @@ public abstract class TenantScopedDbContext( : DbContext(options), ITenantScopedDbContext { private static readonly TenantId NoTenant = TenantId.From(Guid.Empty); + private IOperationReadScopeView? _operationReadScope; + + internal void AttachOperationReadScope(IOperationReadScopeView scope) + { + ArgumentNullException.ThrowIfNull(scope); + if (_operationReadScope is not null) + throw new InvalidOperationException("The context's operation read owner is already attached."); + _operationReadScope = scope; + } + + /// + public bool CurrentTenantWideRead => _operationReadScope?.IsTenantWideRead == true; /// /// The ambient context, read fresh on every access. diff --git a/backend/src/LearnStack.SharedKernel/Audit/AuditDraftComposer.cs b/backend/src/LearnStack.SharedKernel/Audit/AuditDraftComposer.cs index f99f72758..bbedf67c7 100644 --- a/backend/src/LearnStack.SharedKernel/Audit/AuditDraftComposer.cs +++ b/backend/src/LearnStack.SharedKernel/Audit/AuditDraftComposer.cs @@ -1,7 +1,7 @@ namespace LearnStack.SharedKernel.Audit; /// -/// Turns one intent plus its request's captured changes into the row to insert. +/// Turns one ordinary resource intent plus its request's captured changes into the row to insert. /// /// /// @@ -155,8 +155,8 @@ private static AuditEntryDraft Compose( Changes = Diff(subject.OfType), CorrelationId = intent.CorrelationId, - IpAddress = null, - UserAgent = null, + IpAddress = intent.Authorization?.Address, + UserAgent = intent.Authorization?.UserAgent, Timestamp = timestamp, Metadata = ComposeMetadata(intent), }; @@ -172,7 +172,7 @@ private static AuditEntryDraft Compose( } if (intent.CausationId is null && intent.CausalActorUserId is null - && intent.JobId is null && intent.ParentJobId is null) return null; + && intent.JobId is null && intent.ParentJobId is null && intent.Authorization is null) return null; if (intent.CausationId == Guid.Empty || (intent.CausalActorUserId is { } actor && (!actor.IsInitialized() || actor.Value == Guid.Empty)) @@ -186,6 +186,13 @@ private static AuditEntryDraft Compose( // This closed identifier-only shape is shared by both writing paths; it // grants no arbitrary metadata channel to an intent or business handler. var fields = new List(4); + if (intent.Authorization is { } authorization) + { + if (intent.TenantId != authorization.TenantId || intent.OrganizationId != authorization.OrganizationId + || intent.ActorUserId != authorization.Actor) + throw new ArgumentException("Resource audit authorization attribution is invalid.", nameof(intent)); + fields.Add("\"authorization\":" + authorization.ToJson()); + } if (intent.CausationId is { } causation) { fields.Add("\"causation_id\":" + AuditJson.Quote(causation.ToString("D"))); diff --git a/backend/src/LearnStack.SharedKernel/Audit/AuditEntryDraft.cs b/backend/src/LearnStack.SharedKernel/Audit/AuditEntryDraft.cs index 508107a8b..bc07982b9 100644 --- a/backend/src/LearnStack.SharedKernel/Audit/AuditEntryDraft.cs +++ b/backend/src/LearnStack.SharedKernel/Audit/AuditEntryDraft.cs @@ -120,8 +120,10 @@ public sealed record AuditEntryDraft public required string? UserAgent { get; init; } /// - /// Always supplied by the store from IClock — the intent's DeclaredAt - /// for the in-transaction row, a fresh reading for a standalone re-write. The + /// Supplied from IClock — the ordinary intent's DeclaredAt + /// for the in-transaction row, a fresh reading for its standalone re-write. + /// A central authorization occurrence instead freezes one UTC microsecond + /// timestamp across all of its writes and exact reconciliation. The /// column's DEFAULT now() is a backstop for a row inserted by something other /// than the store, and never the ordinary source. /// diff --git a/backend/src/LearnStack.SharedKernel/Audit/AuditIntent.cs b/backend/src/LearnStack.SharedKernel/Audit/AuditIntent.cs index fe769f6ad..d952ed33c 100644 --- a/backend/src/LearnStack.SharedKernel/Audit/AuditIntent.cs +++ b/backend/src/LearnStack.SharedKernel/Audit/AuditIntent.cs @@ -100,6 +100,9 @@ public sealed record AuditIntent( Type? EntityType, DateTimeOffset DeclaredAt) { + /// Bounded source/decision context attached by the common owner to this exact invocation's intents. + public AuthorizationAuditContext? Authorization { get; init; } + /// Closed creation attribution minted by the common global audit entry. public GlobalIdentityCreationReference? GlobalIdentity { get; init; } diff --git a/backend/src/LearnStack.SharedKernel/Audit/AuthorizationAuditContext.cs b/backend/src/LearnStack.SharedKernel/Audit/AuthorizationAuditContext.cs new file mode 100644 index 000000000..6596d5618 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Audit/AuthorizationAuditContext.cs @@ -0,0 +1,103 @@ +using System.Collections.Immutable; +using System.Net; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.SharedKernel.Audit; + +/// The central composer's bounded immutable decision and source context, never handler metadata. +public sealed class AuthorizationAuditContext +{ + private readonly IPAddress? _address; + + private AuthorizationAuditContext(OperationInvocation invocation, ProtectedOperationDecision? decision, + OperationTransportProvenance? transport) + { + ArgumentNullException.ThrowIfNull(invocation); + var target = invocation.TenantId ?? (invocation.TrustedSource is { SourceKind: AuthorizationSourceKind.Seed } + && invocation.Operation is IProvisionsTenant provisioning ? provisioning.ProvisioningTenantId : (TenantId?)null); + if (!invocation.IsActive || target is not { } tenant || !StronglyTypedId.IsAssigned(tenant) + || tenant == TenantId.PlatformSentinel + || invocation.Actor is { } actor && !StronglyTypedId.IsAssigned(actor) + || invocation.Actor is null && invocation.TrustedSource is not { SourceKind: AuthorizationSourceKind.Seed } + || invocation.Candidate is { } candidate && invocation.Actor != candidate.Actor + || (decision is not null && (!ReferenceEquals(decision.Invocation, invocation) || !decision.IsActive)) + || (invocation.Candidate is null) == (invocation.TrustedSource is null) + || (transport is not null && !transport.Matches(invocation))) + throw new ArgumentException("Authorization audit context has no live source."); + TenantId = tenant; + Invocation = invocation; + OrganizationId = invocation.OrganizationId; + Actor = invocation.Actor; + PolicyId = invocation.Policy.Id; + SourceKind = invocation.Candidate?.SourceKind ?? invocation.TrustedSource!.SourceKind; + MembershipIds = decision?.MembershipIds ?? []; + RoleIds = decision?.RoleIds ?? []; + Route = transport?.Route; + _address = transport?.Address; + UserAgent = transport?.UserAgent; + if (invocation.TrustedSource is not null) + { + CausationId = invocation.Context switch + { + EventTenantContext delivery => delivery.EventId, + JobTenantContext job => job.IncomingEventId, + _ => null, + }; + CausalActor = invocation.Context.CausalActorUserId; + JobId = (invocation.Context as JobTenantContext)?.JobId; + ParentJobId = (invocation.Context as JobTenantContext)?.ParentJobId; + } + } + + public TenantId TenantId { get; } + /// Capture-frame attachment identity; it is not serialized into persisted metadata. + public OperationInvocation Invocation { get; } + public OrganizationId? OrganizationId { get; } + public UserId? Actor { get; } + public string PolicyId { get; } + public AuthorizationSourceKind SourceKind { get; } + public ImmutableArray MembershipIds { get; } + public ImmutableArray RoleIds { get; } + public string? Route { get; } + public IPAddress? Address => _address is null ? null : new IPAddress(_address.GetAddressBytes()); + public string? UserAgent { get; } + public Guid? CausationId { get; } + public UserId? CausalActor { get; } + public BackgroundJobId? JobId { get; } + public BackgroundJobId? ParentJobId { get; } + public override string ToString() => nameof(AuthorizationAuditContext); + + /// Closed versioned shape; no arbitrary dictionary, raw route or grant graph. + public string ToJson() + { + var fields = new List + { + "\"version\":1", "\"policy\":" + AuditJson.Quote(PolicyId), + "\"source\":" + AuditJson.Quote(SourceKind switch + { + AuthorizationSourceKind.Http => "http", AuthorizationSourceKind.Seed => "seed", + AuthorizationSourceKind.IntegrationEvent => "event", AuthorizationSourceKind.BackgroundJob => "job", + _ => throw new InvalidOperationException("Authorization source is unknown."), + }), + "\"memberships\":[" + string.Join(',', MembershipIds.Select(id => AuditJson.Quote(id.ToString("D")))) + "]", + "\"roles\":[" + string.Join(',', RoleIds.Select(id => AuditJson.Quote(id.ToString("D")))) + "]", + }; + if (Route is not null) fields.Add("\"route\":" + AuditJson.Quote(Route)); + if (CausationId is { } causation) fields.Add("\"causation_id\":" + AuditJson.Quote(causation.ToString("D"))); + if (CausalActor is { } causal) fields.Add("\"causal_actor_user_id\":" + AuditJson.Quote(causal.Value.ToString("D"))); + if (JobId is { } job) fields.Add("\"job_id\":" + AuditJson.Quote(job.Value.ToString("D"))); + if (ParentJobId is { } parent) fields.Add("\"parent_job_id\":" + AuditJson.Quote(parent.Value.ToString("D"))); + return "{" + string.Join(',', fields) + "}"; + } + +#pragma warning disable CA1034 // Only the central evidence owner composes source and decision context. + public abstract class MintingSource : AuthorizationEvidenceOccurrence.MintingSource +#pragma warning restore CA1034 + { + protected static AuthorizationAuditContext ForAuditContext(OperationInvocation invocation, + ProtectedOperationDecision? decision, OperationTransportProvenance? transport) => new(invocation, decision, transport); + } +} diff --git a/backend/src/LearnStack.SharedKernel/Audit/AuthorizationEvidenceOccurrence.cs b/backend/src/LearnStack.SharedKernel/Audit/AuthorizationEvidenceOccurrence.cs new file mode 100644 index 000000000..fd4bcbf12 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Audit/AuthorizationEvidenceOccurrence.cs @@ -0,0 +1,57 @@ +using System.Net; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Audit; + +public enum AuthorizationEvidenceKind { Denial = 1, ClaimAssertion = 2 } + +/// One centrally minted event, frozen across its writes and exact reconciliation. +public sealed class AuthorizationEvidenceOccurrence +{ + private readonly AuditEntryDraft _draft; + + private AuthorizationEvidenceOccurrence(AuthorizationEvidenceKind kind, AuditEntryDraft draft) + { + ArgumentNullException.ThrowIfNull(draft); + var operation = kind switch + { + AuthorizationEvidenceKind.Denial => "identity.authorization.deny", + AuthorizationEvidenceKind.ClaimAssertion => "tenancy.assertion.reject_claim_context", + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + if (!StronglyTypedId.IsAssigned(draft.Id) || draft.Id.Value.Version != 7 + || !StronglyTypedId.IsAssigned(draft.TenantId) || draft.TenantId == TenantId.PlatformSentinel + || draft.OrganizationId is { } organization && !StronglyTypedId.IsAssigned(organization) + || draft.ActorUserId is { } actor && !StronglyTypedId.IsAssigned(actor) + || kind == AuthorizationEvidenceKind.ClaimAssertion && draft.ActorUserId is null + || draft.Operation != operation || draft.ModuleName != operation[..operation.IndexOf('.', StringComparison.Ordinal)] + || draft.OperationClass != OperationClass.Must || draft.OperationType != OperationType.SecurityEvent + || draft.Outcome != AuditOutcome.Denied || draft.ActorEmail is not null + || draft.Timestamp == default || draft.Metadata is null + || draft.BeforeState is not null || draft.AfterState is not null || draft.Changes is not null) + throw new ArgumentException("Authorization evidence attribution is invalid.", nameof(draft)); + Kind = kind; + var utc = draft.Timestamp.ToUniversalTime(); + _draft = draft with + { + Timestamp = new DateTimeOffset(utc.Ticks - utc.Ticks % 10, TimeSpan.Zero), + IpAddress = CopyAddress(draft.IpAddress), + }; + } + + public AuthorizationEvidenceKind Kind { get; } + /// The immutable fields; copy the mutable IPAddress at either boundary. + public AuditEntryDraft Draft => _draft with { IpAddress = CopyAddress(_draft.IpAddress) }; + public override string ToString() => nameof(AuthorizationEvidenceOccurrence); + + private static IPAddress? CopyAddress(IPAddress? address) => address is null ? null + : address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 + ? new IPAddress(address.GetAddressBytes(), address.ScopeId) : new IPAddress(address.GetAddressBytes()); + +#pragma warning disable CA1034 // A compiled source census closes central occurrence minting. + public abstract class MintingSource +#pragma warning restore CA1034 + { + protected static AuthorizationEvidenceOccurrence ForOccurrence(AuthorizationEvidenceKind kind, AuditEntryDraft draft) => new(kind, draft); + } +} diff --git a/backend/src/LearnStack.SharedKernel/Audit/ConfirmedClaimAssertion.cs b/backend/src/LearnStack.SharedKernel/Audit/ConfirmedClaimAssertion.cs new file mode 100644 index 000000000..99388b3ba --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Audit/ConfirmedClaimAssertion.cs @@ -0,0 +1,60 @@ +using System.Net; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.SharedKernel.Audit; + +public enum ClaimAssertionDimension { Tenant = 1, Organization = 2 } + +/// A fixed mismatch observation under actual Membership-confirmed scope; never carries the asserted value. +public sealed class ConfirmedClaimAssertion +{ + private readonly MintingSource _source; + private readonly ITenantContext _context; + private readonly IPAddress? _address; + + private ConfirmedClaimAssertion(MintingSource source, ITenantContext context, ClaimAssertionDimension dimension, + string? route, IPAddress? address, string? userAgent) + { + if (!context.IsResolved || context.Origin != TenantContextOrigin.ClaimAndMembership + || !StronglyTypedId.IsAssigned(context.TenantId) || context.TenantId == TenantId.PlatformSentinel + || context.UserId is not { } actor || !StronglyTypedId.IsAssigned(actor) || actor == UserId.SystemActor + || context.OrganizationId is { } organization && !StronglyTypedId.IsAssigned(organization) + || !Enum.IsDefined(dimension) || route is { Length: > 128 } || route?.Any(char.IsControl) == true) + throw new ArgumentException("Claim assertion has no confirmed scope."); + _source = source; _context = context; + TenantId = context.TenantId; OrganizationId = context.OrganizationId; Actor = actor; + Dimension = dimension; Route = route; + CorrelationId = context.CorrelationId; + _address = address is null ? null : new IPAddress(address.GetAddressBytes()); + UserAgent = userAgent is null ? null : new string(userAgent.Where(character => !char.IsControl(character)).Take(256).ToArray()); + } + + public TenantId TenantId { get; } + public OrganizationId? OrganizationId { get; } + public UserId Actor { get; } + public ClaimAssertionDimension Dimension { get; } + public string? CorrelationId { get; } + public string? Route { get; } + public IPAddress? Address => _address is null ? null : new IPAddress(_address.GetAddressBytes()); + public string? UserAgent { get; } + public bool IsActive => _source.IsOpen && _context.IsResolved && _context.Origin == TenantContextOrigin.ClaimAndMembership + && _context.TenantId == TenantId && _context.OrganizationId == OrganizationId && _context.UserId == Actor; + public override string ToString() => nameof(ConfirmedClaimAssertion); + +#pragma warning disable CA1034 // Only the admitted API assertion adapter owns this source capability. + public abstract class MintingSource : IDisposable +#pragma warning restore CA1034 + { + private int _closed; + internal bool IsOpen => Volatile.Read(ref _closed) == 0; + protected bool CanIssue => IsOpen; + protected ConfirmedClaimAssertion ForAssertion(ITenantContext context, ClaimAssertionDimension dimension, + string? route, IPAddress? address, string? userAgent) + { + ObjectDisposedException.ThrowIf(!IsOpen, this); + return new(this, context, dimension, route, address, userAgent); + } + public void Dispose() { Interlocked.Exchange(ref _closed, 1); GC.SuppressFinalize(this); } + } +} diff --git a/backend/src/LearnStack.SharedKernel/Audit/IAuditHealth.cs b/backend/src/LearnStack.SharedKernel/Audit/IAuditHealth.cs index 6bd2d8785..561f2e3fc 100644 --- a/backend/src/LearnStack.SharedKernel/Audit/IAuditHealth.cs +++ b/backend/src/LearnStack.SharedKernel/Audit/IAuditHealth.cs @@ -43,9 +43,9 @@ public interface IAuditHealth /// Records that a MUST-class standalone write landed. /// /// - /// A duplicate-key refusal counts as one: it is positive evidence that the business - /// COMMIT landed and that the in-transaction row under this id is already - /// durable, which is the opposite of the state this check exists to report. + /// Verified durable duplicates count as success. ADR-0077's confined authorization + /// duplicate proves only that exact event is durable; it establishes no business + /// COMMIT outcome. This health signal never marks business capture committed. /// void ReportStandaloneWriteSucceeded(); } diff --git a/backend/src/LearnStack.SharedKernel/Audit/IAuditStateCapture.cs b/backend/src/LearnStack.SharedKernel/Audit/IAuditStateCapture.cs index 15bed9658..ac1b6f206 100644 --- a/backend/src/LearnStack.SharedKernel/Audit/IAuditStateCapture.cs +++ b/backend/src/LearnStack.SharedKernel/Audit/IAuditStateCapture.cs @@ -85,6 +85,10 @@ public interface IAuditStateCapture /// Declares an intent in the common coordinator's innermost frame. void DeclareIntent(AuditIntent intent); + /// Attaches only the innermost actual frame's matching trusted authorization context. + void AttachAuthorization(AuthorizationAuditContext context) => + throw new InvalidOperationException("This capture has no authorization attachment owner."); + /// /// The rows are on the ambient transaction. Not durable — the transaction has /// not committed. IAuditStore.WritePendingAsync. diff --git a/backend/src/LearnStack.SharedKernel/Audit/IAuditStore.cs b/backend/src/LearnStack.SharedKernel/Audit/IAuditStore.cs index e5c48a10a..c2b3b98e2 100644 --- a/backend/src/LearnStack.SharedKernel/Audit/IAuditStore.cs +++ b/backend/src/LearnStack.SharedKernel/Audit/IAuditStore.cs @@ -4,7 +4,7 @@ namespace LearnStack.SharedKernel.Audit; /// -/// The only sanctioned way to put a row in audit_log. Eight writes, and +/// The only sanctioned way to put a row in audit_log. Ten writes, and /// no update. /// /// @@ -34,6 +34,16 @@ namespace LearnStack.SharedKernel.Audit; /// public interface IAuditStore { + /// Writes one fixed authorization event on its matching live ReadWrite owner. + Task WriteAuthorizationPendingAsync(IUnitOfWork unitOfWork, AuthorizationEvidenceOccurrence occurrence, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("This audit store cannot write authorization evidence."); + + /// Durably writes or exactly verifies one fixed event through the independent evidence source. + Task WriteAuthorizationStandaloneAsync(AuthorizationEvidenceOccurrence occurrence, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("This audit store cannot write authorization evidence."); + /// Writes the enrolled global creation intent on its original live owner. Task WriteGlobalIdentityPendingAsync(IUnitOfWork unitOfWork, GlobalIdentityCreationReference creation, CancellationToken cancellationToken = default); diff --git a/backend/src/LearnStack.SharedKernel/Audit/IAuthorizationEvidenceSession.cs b/backend/src/LearnStack.SharedKernel/Audit/IAuthorizationEvidenceSession.cs new file mode 100644 index 000000000..ff2f2d75d --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Audit/IAuthorizationEvidenceSession.cs @@ -0,0 +1,20 @@ +using LearnStack.SharedKernel.Persistence; + +namespace LearnStack.SharedKernel.Audit; + +/// Only the central event writer borrows the independent two-slot evidence lane. +public interface IAuthorizationEvidenceSessionFactory +{ + Task OpenAsync(AuthorizationEvidenceOccurrence occurrence, + CancellationToken cancellationToken = default); +} + +/// One connector and shared deadline; duplicate verification replaces, never overlaps, its transaction. +public interface IAuthorizationEvidenceSession : IAsyncDisposable +{ + IUnitOfWork Unit { get; } + CancellationToken DeadlineToken { get; } + Task BeginVerificationAsync(); + Task CompleteAsync(); + void MarkCleanupFailed(); +} diff --git a/backend/src/LearnStack.SharedKernel/Audit/IAuthorizationRefusalReporter.cs b/backend/src/LearnStack.SharedKernel/Audit/IAuthorizationRefusalReporter.cs new file mode 100644 index 000000000..4d4dbfbf9 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Audit/IAuthorizationRefusalReporter.cs @@ -0,0 +1,12 @@ +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.SharedKernel.Audit; + +public enum AuthorizationRefusalReason { Capability = 1, ResourceScope = 2, Delegation = 3, Ownership = 4, Coverage = 5 } + +/// Records a resource refusal from its actual live decision; accepts no caller attribution or payload. +public interface IAuthorizationRefusalReporter +{ + void ReportRecheck(ProtectedOperationDecision decision); + void ReportResourceRefusal(ProtectedOperationDecision decision, AuthorizationRefusalReason reason); +} diff --git a/backend/src/LearnStack.SharedKernel/Audit/OperationTransportProvenance.cs b/backend/src/LearnStack.SharedKernel/Audit/OperationTransportProvenance.cs new file mode 100644 index 000000000..c9f4eaf4e --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Audit/OperationTransportProvenance.cs @@ -0,0 +1,52 @@ +using System.Net; +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.SharedKernel.Audit; + +/// The admitted HTTP adapter's descriptive transport snapshot for one exact act. +public sealed class OperationTransportProvenance +{ + private readonly OperationInvocation _invocation; + private readonly MintingSource _source; + private readonly IPAddress? _address; + + private OperationTransportProvenance(MintingSource source, OperationInvocation invocation, + string route, IPAddress? address, string? userAgent) + { + if (!invocation.IsActive || invocation.Candidate is not { IsActive: true, SourceKind: AuthorizationSourceKind.Http } + || string.IsNullOrWhiteSpace(route) || route.Length > 128 || route.Any(char.IsControl)) + throw new ArgumentException("HTTP authorization provenance is invalid."); + _source = source; + _invocation = invocation; + Route = route; + _address = Copy(address); + UserAgent = userAgent is null ? null : new string(userAgent.Where(character => !char.IsControl(character)).Take(256).ToArray()); + } + + public string Route { get; } + public IPAddress? Address => Copy(_address); + public string? UserAgent { get; } + public bool Matches(OperationInvocation invocation) => _source.IsOpen && ReferenceEquals(_invocation, invocation) && invocation.IsActive; + public override string ToString() => nameof(OperationTransportProvenance); + private static IPAddress? Copy(IPAddress? address) => address is null ? null : new IPAddress(address.GetAddressBytes()); + +#pragma warning disable CA1034 // The compiled HTTP source census owns this minting capability. + public abstract class MintingSource : IDisposable +#pragma warning restore CA1034 + { + private int _closed; + internal bool IsOpen => Volatile.Read(ref _closed) == 0; + protected OperationTransportProvenance ForTransport(OperationInvocation invocation, string route, + IPAddress? address, string? userAgent) + { + ObjectDisposedException.ThrowIf(!IsOpen, this); + return new(this, invocation, route, address, userAgent); + } + public void Dispose() { Interlocked.Exchange(ref _closed, 1); GC.SuppressFinalize(this); } + } +} + +public interface IAuthorizationTransportSource +{ + OperationTransportProvenance? Capture(OperationInvocation invocation); +} diff --git a/backend/src/LearnStack.SharedKernel/Jobs/JobApplicationSourceOwner.cs b/backend/src/LearnStack.SharedKernel/Jobs/JobApplicationSourceOwner.cs index 96814747b..31de5e321 100644 --- a/backend/src/LearnStack.SharedKernel/Jobs/JobApplicationSourceOwner.cs +++ b/backend/src/LearnStack.SharedKernel/Jobs/JobApplicationSourceOwner.cs @@ -9,7 +9,8 @@ namespace LearnStack.SharedKernel.Jobs; /// /// The executor retains one owner across framework and business scopes. A cache /// wait ending does not release its actual loader. No provider or work callback is -/// accepted here; the four named database helpers retain their concrete resources. +/// accepted here; the four business helpers and the confined authorization/evidence +/// lanes retain their concrete resources. /// [SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "Only the actual unit/helper cleanup can release retained resources; caller disposal supplies no witness.")] @@ -19,6 +20,8 @@ public sealed class JobApplicationSourceOwner private static readonly AsyncLocal External = new(); private readonly object _sync = new(); private JobApplicationAuxiliaryResources? _auxiliary; + private JobApplicationAuxiliaryResources? _authorization; + private JobApplicationAuxiliaryResources? _evidence; private IUnitOfWork? _mainUnit; private DbConnection? _mainConnection; private DbTransaction? _mainTransaction; @@ -38,6 +41,17 @@ private JobApplicationSourceOwner() { } /// A stale inherited binding remains visible and refuses new work. public static JobApplicationSourceOwner? Current => Ambient.Value?.Owner; + /// Current execution lifetime, independent of temporary auxiliary-lane readiness. + public bool IsCurrentBinding + { + get + { + lock (_sync) + return !IsExternal && !_closed && !_cleanupFailed + && Ambient.Value is { IsOpen: true } binding && ReferenceEquals(binding.Owner, this); + } + } + /// Denial remains visible in inherited tasks after their parent's scope restores. public static bool IsExternal => External.Value is not null; @@ -77,7 +91,9 @@ public bool CleanupConfirmed lock (_sync) { _auxiliary?.TryRelease(); - return !_cleanupFailed && _auxiliary is null && _mainUnit is null + _authorization?.TryRelease(); + _evidence?.TryRelease(); + return !_cleanupFailed && _auxiliary is null && _authorization is null && _evidence is null && _mainUnit is null && _mainConnection is null && _mainTransaction is null; } } @@ -91,7 +107,9 @@ public void RequireBusinessReady() { RequireBinding(); _auxiliary?.TryRelease(); - if (_closed || _cleanupFailed || _auxiliary is not null) throw Refused(); + _authorization?.TryRelease(); + _evidence?.TryRelease(); + if (_closed || _cleanupFailed || _auxiliary is not null || _authorization is not null || _evidence is not null) throw Refused(); } } @@ -186,8 +204,12 @@ public void CloseIssuance() public async Task JoinAuxiliaryCleanupAsync() { JobApplicationAuxiliaryResources? auxiliary; - lock (_sync) auxiliary = _auxiliary; + JobApplicationAuxiliaryResources? authorization; + JobApplicationAuxiliaryResources? evidence; + lock (_sync) { auxiliary = _auxiliary; authorization = _authorization; evidence = _evidence; } if (auxiliary is not null) await auxiliary.JoinActualTaskAsync().ConfigureAwait(false); + if (authorization is not null) await authorization.JoinActualTaskAsync().ConfigureAwait(false); + if (evidence is not null) await evidence.JoinActualTaskAsync().ConfigureAwait(false); return CleanupConfirmed; } @@ -196,6 +218,12 @@ public async Task JoinAuxiliaryCleanupAsync() public JobApplicationAuxiliaryResources BeginTenantFlagsLoad() => BeginAuxiliary(); public JobApplicationAuxiliaryResources BeginKillswitchLoad() => BeginAuxiliary(); + /// The globally bounded authorization source retains this independent lane; the main owner may stay open. + public JobApplicationAuxiliaryResources BeginAuthorizationRead() => BeginConfinedLane(authorization: true); + + /// The globally bounded evidence source retains this independent lane until actual completion. + public JobApplicationAuxiliaryResources BeginAuthorizationEvidence() => BeginConfinedLane(authorization: false); + /// Marks one genuinely new source before recovery's first physical read. public void BindExternalRecovery() { @@ -214,17 +242,38 @@ private JobApplicationAuxiliaryResources BeginAuxiliary() { RequireBinding(); _auxiliary?.TryRelease(); - if (_closed || _cleanupFailed || _auxiliary is not null) throw Refused(); + _authorization?.TryRelease(); + _evidence?.TryRelease(); + if (_closed || _cleanupFailed || _auxiliary is not null || _authorization is not null || _evidence is not null) throw Refused(); _used = true; return _auxiliary = new JobApplicationAuxiliaryResources(this); } } + private JobApplicationAuxiliaryResources BeginConfinedLane(bool authorization) + { + RefuseExternalEntry(); + lock (_sync) + { + RequireBinding(); + _auxiliary?.TryRelease(); + _authorization?.TryRelease(); + _evidence?.TryRelease(); + if (_closed || _cleanupFailed || _auxiliary is not null || _authorization is not null || _evidence is not null) + throw Refused(); + _used = true; + var resources = new JobApplicationAuxiliaryResources(this); + if (authorization) _authorization = resources; + else _evidence = resources; + return resources; + } + } + internal void ReportCleanupFailure(JobApplicationAuxiliaryResources auxiliary) { lock (_sync) { - if (!ReferenceEquals(_auxiliary, auxiliary)) throw Refused(); + if (!OwnsAuxiliary(auxiliary)) throw Refused(); _cleanupFailed = true; } } @@ -234,12 +283,17 @@ internal void Release(JobApplicationAuxiliaryResources auxiliary) lock (_sync) { if (auxiliary.IsReleased) return; - if (!ReferenceEquals(_auxiliary, auxiliary)) throw Refused(); - _auxiliary = null; + if (!OwnsAuxiliary(auxiliary)) throw Refused(); + if (ReferenceEquals(_auxiliary, auxiliary)) _auxiliary = null; + else if (ReferenceEquals(_authorization, auxiliary)) _authorization = null; + else _evidence = null; auxiliary.MarkReleased(); } } + private bool OwnsAuxiliary(JobApplicationAuxiliaryResources resources) => + ReferenceEquals(_auxiliary, resources) || ReferenceEquals(_authorization, resources) || ReferenceEquals(_evidence, resources); + private void RequireBinding() { if (Ambient.Value is not { IsOpen: true } binding || !ReferenceEquals(binding.Owner, this)) @@ -271,7 +325,7 @@ public void Dispose() } } -/// The concrete resources of one of the four closed job-origin helpers. +/// The concrete resources of a closed business helper or confined authorization/evidence lane. [SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "Actual cleanup is explicit and one-shot; disposing a returned caller handle cannot prove task completion.")] public sealed class JobApplicationAuxiliaryResources @@ -333,6 +387,13 @@ public void HoldActualTask(Task actualTask) CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } + /// Concrete caller-owned command/reader cleanup failed; later disposal cannot replace that witness. + public void MarkCleanupFailed() + { + _readerCleanupFailed = true; + _owner.ReportCleanupFailure(this); + } + /// A reader must finish before its helper issues COMMIT. public async ValueTask DisposeReaderAsync() { diff --git a/backend/src/LearnStack.SharedKernel/Permissions/AuthorizationCandidate.cs b/backend/src/LearnStack.SharedKernel/Permissions/AuthorizationCandidate.cs new file mode 100644 index 000000000..dc2155280 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Permissions/AuthorizationCandidate.cs @@ -0,0 +1,62 @@ +using LearnStack.SharedKernel.Identifiers; +using System.Diagnostics.CodeAnalysis; + +namespace LearnStack.SharedKernel.Permissions; + +public enum AuthorizationSourceKind { Http = 1, Seed = 2, IntegrationEvent = 3, BackgroundJob = 4 } + +/// A question from an enumerated trusted owner, never authority supplied by a UserId alone. +public sealed class AuthorizationCandidate +{ + private readonly MintingSource _source; + private readonly object? _evidence; + + private AuthorizationCandidate(MintingSource source, AuthorizationSourceKind kind, + UserId actor, TenantId tenant, OrganizationId? organization, object? evidence) + { + if (!Enum.IsDefined(kind) || !StronglyTypedId.IsAssigned(actor) || actor == UserId.SystemActor + || actor.Value.Version != 7 || !StronglyTypedId.IsAssigned(tenant) || tenant == TenantId.PlatformSentinel + || (organization is { } org && !StronglyTypedId.IsAssigned(org))) + throw new ArgumentException("Authorization candidate provenance is invalid."); + _source = source; + _evidence = evidence; + SourceKind = kind; + Actor = actor; + TenantId = tenant; + OrganizationId = organization; + } + + public AuthorizationSourceKind SourceKind { get; } + public UserId Actor { get; } + public TenantId TenantId { get; } + public OrganizationId? OrganizationId { get; } + public bool IsActive => _source.IsActive && _source.MatchesEvidence(_evidence); + + public override string ToString() => nameof(AuthorizationCandidate); + +#pragma warning disable CA1034 // Private construction and the compiled minter census close the trusted source set. + [SuppressMessage("Design", "CA1063:Implement IDisposable Correctly", + Justification = "Disposal only closes the shared provenance lifetime; derived sources cannot replace that authority transition.")] + public abstract class MintingSource : IDisposable +#pragma warning restore CA1034 + { + private int _closed; + internal bool IsActive => Volatile.Read(ref _closed) == 0; + internal bool MatchesEvidence(object? evidence) => IsEvidenceCurrent(evidence); + + protected virtual bool IsEvidenceCurrent(object? evidence) => true; + + protected AuthorizationCandidate ForCandidate(AuthorizationSourceKind kind, + UserId actor, TenantId tenant, OrganizationId? organization, object? evidence = null) + { + ObjectDisposedException.ThrowIf(!IsActive, this); + return new(this, kind, actor, tenant, organization, evidence); + } + + public void Dispose() + { + Interlocked.Exchange(ref _closed, 1); + GC.SuppressFinalize(this); + } + } +} diff --git a/backend/src/LearnStack.SharedKernel/Permissions/IOperationInvocationContext.cs b/backend/src/LearnStack.SharedKernel/Permissions/IOperationInvocationContext.cs new file mode 100644 index 000000000..58adb646a --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Permissions/IOperationInvocationContext.cs @@ -0,0 +1,8 @@ +namespace LearnStack.SharedKernel.Permissions; + +/// Read-only access to the actual current act; it cannot mint or replace authority. +public interface IOperationInvocationContext +{ + OperationInvocation? CurrentInvocation { get; } + ProtectedOperationDecision? CurrentDecision { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Permissions/OperationInvocation.cs b/backend/src/LearnStack.SharedKernel/Permissions/OperationInvocation.cs new file mode 100644 index 000000000..58f743efa --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Permissions/OperationInvocation.cs @@ -0,0 +1,101 @@ +using System.Data.Common; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.SharedKernel.Permissions; + +public enum OperationEntryKind { Application = 1, Http = 2 } + +/// One exact policy invocation; its private owner supplies lifetime and current-frame authority. +public sealed class OperationInvocation +{ + private readonly MintingSource _source; + private readonly CancellationToken _cancellationToken; + private readonly object _bindingGate = new(); + private ReadBinding? _readBinding; + private readonly bool _resolved; + private readonly TenantContextOrigin? _origin; + private readonly HostScope? _hostScope; + + private OperationInvocation(MintingSource source, object operation, OperationPolicy policy, + ITenantContext context, AuthorizationCandidate? candidate, TrustedOperationDescriptor? trustedSource, + OperationEntryKind entryKind, CancellationToken cancellationToken) + { + _source = source; + Operation = operation; + Policy = policy; + Context = context; + Candidate = candidate; + TrustedSource = trustedSource; + _cancellationToken = cancellationToken; + EntryKind = entryKind; + _resolved = context.IsResolved; + TenantId = _resolved ? context.TenantId : null; + OrganizationId = _resolved ? context.OrganizationId : null; + Actor = context.UserId; + _origin = context.Origin; + _hostScope = context.HostScope; + } + + public object Operation { get; } + public OperationEntryKind EntryKind { get; } + public OperationPolicy Policy { get; } + public ITenantContext Context { get; } + public TenantId? TenantId { get; } + public OrganizationId? OrganizationId { get; } + public UserId? Actor { get; } + public AuthorizationCandidate? Candidate { get; } + public TrustedOperationDescriptor? TrustedSource { get; } + public bool IsActive => !_cancellationToken.IsCancellationRequested && _source.MatchesCurrent(this) + && (Candidate?.IsActive ?? true) && (TrustedSource?.IsActive ?? true) + && Context.IsResolved == _resolved && Context.UserId == Actor && Context.Origin == _origin + && Context.HostScope == _hostScope + && (!_resolved || (Context.TenantId == TenantId && Context.OrganizationId == OrganizationId)); + + internal bool TryBindRead(IUnitOfWork unit, DbTransaction transaction, long generation) + { + lock (_bindingGate) + { + // The enumerated physical owner validates its private transaction and + // generation. Calling its public getters here would recurse through + // that same owner's live-witness validation. + if (!IsActive || _readBinding is not null || generation <= 0) return false; + _readBinding = new ReadBinding(unit, transaction, generation); + return true; + } + } + + internal bool IsReadBoundTo(IUnitOfWork unit, DbTransaction transaction, long generation) + { + lock (_bindingGate) + return IsActive && _readBinding is { } binding && ReferenceEquals(binding.Unit, unit) + && ReferenceEquals(binding.Transaction, transaction) && binding.Generation == generation; + } + + public override string ToString() => nameof(OperationInvocation); + + private sealed record ReadBinding(IUnitOfWork Unit, DbTransaction Transaction, long Generation); + +#pragma warning disable CA1034 // A closed compiled source census owns invocation construction. + public abstract class MintingSource +#pragma warning restore CA1034 + { + internal bool MatchesCurrent(OperationInvocation invocation) => IsCurrent(invocation); + + protected OperationInvocation ForInvocation(object operation, OperationPolicy policy, ITenantContext context, + AuthorizationCandidate? candidate, TrustedOperationDescriptor? trustedSource, + CancellationToken cancellationToken, OperationEntryKind entryKind = OperationEntryKind.Application) + { + ArgumentNullException.ThrowIfNull(operation); + ArgumentNullException.ThrowIfNull(policy); + ArgumentNullException.ThrowIfNull(context); + if ((candidate is not null && trustedSource is not null) || !Enum.IsDefined(entryKind) + || (entryKind == OperationEntryKind.Application && operation.GetType() != policy.RequestType)) + throw new ArgumentException("Operation invocation source is ambiguous."); + return new(this, operation, policy, context, candidate, trustedSource, entryKind, cancellationToken); + } + + protected abstract bool IsCurrent(OperationInvocation invocation); + } +} diff --git a/backend/src/LearnStack.SharedKernel/Permissions/OperationPolicy.cs b/backend/src/LearnStack.SharedKernel/Permissions/OperationPolicy.cs new file mode 100644 index 000000000..44788a21c --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Permissions/OperationPolicy.cs @@ -0,0 +1,102 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Permissions; + +public enum OperationPolicyKind { PublicRead = 1, AccountRead = 2, Protected = 3, FiniteTrusted = 4 } +public enum OperationReadScope { Scoped = 0, Tenant = 1 } + +/// Definition composition is separate from stored grants and operation admission. +public sealed record PermissionRegistration(PermissionDefinition Definition, bool Assignable); + +/// Immutable metadata for one exact request type; it grants no authority by itself. +public sealed class OperationPolicy +{ + private OperationPolicy(Type requestType, string id, OperationPolicyKind kind, + PermissionDefinition? permission, OperationReadScope readScope, + ImmutableArray allowedSources, bool requiresOrglessWrite, Type? eventHandler = null) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (requestType.IsAbstract || requestType.ContainsGenericParameters || id.Length > 128 + || id.Any(character => !(character is >= 'a' and <= 'z' or >= '0' and <= '9' or '.' or '_' or '-')) + || !Enum.IsDefined(kind) || !Enum.IsDefined(readScope) + || (kind is OperationPolicyKind.PublicRead or OperationPolicyKind.Protected) != (permission is not null) + || permission is { Scope: PermissionScope.Platform } + || (readScope == OperationReadScope.Tenant && (kind != OperationPolicyKind.Protected + || permission?.Scope != PermissionScope.Tenant || requiresOrglessWrite)) + || (kind == OperationPolicyKind.FiniteTrusted) != !allowedSources.IsDefaultOrEmpty + || allowedSources.Any(source => source is not (AuthorizationSourceKind.Seed + or AuthorizationSourceKind.IntegrationEvent or AuthorizationSourceKind.BackgroundJob)) + || allowedSources.Distinct().Count() != allowedSources.Length + || allowedSources.Contains(AuthorizationSourceKind.IntegrationEvent) != (eventHandler is not null) + || eventHandler is { IsAbstract: true } or { ContainsGenericParameters: true } + || (requiresOrglessWrite && kind != OperationPolicyKind.Protected)) + throw new ArgumentException("Operation policy metadata is invalid."); + RequestType = requestType; + Id = id; + Kind = kind; + RequiredPermission = permission; + ReadScope = readScope; + AllowedSources = allowedSources; + RequiresOrglessWrite = requiresOrglessWrite; + TrustedEventHandlerType = eventHandler; + } + + public Type RequestType { get; } + public string Id { get; } + public OperationPolicyKind Kind { get; } + public PermissionDefinition? RequiredPermission { get; } + public OperationReadScope ReadScope { get; } + public ImmutableArray AllowedSources { get; } + public bool RequiresOrglessWrite { get; } + public Type? TrustedEventHandlerType { get; } + + public static OperationPolicy ForPublic(string id, PermissionDefinition permission) => + new(typeof(TRequest), id, OperationPolicyKind.PublicRead, permission, OperationReadScope.Scoped, [], false); + + public static OperationPolicy ForAccount(string id) => + new(typeof(TRequest), id, OperationPolicyKind.AccountRead, null, OperationReadScope.Scoped, [], false); + + public static OperationPolicy ForProtected(string id, PermissionDefinition permission, + OperationReadScope readScope = OperationReadScope.Scoped, bool requiresOrglessWrite = false) => + new(typeof(TRequest), id, OperationPolicyKind.Protected, permission, readScope, [], requiresOrglessWrite); + + public static OperationPolicy ForTrusted(string id, params AuthorizationSourceKind[] allowedSources) => + new(typeof(TRequest), id, OperationPolicyKind.FiniteTrusted, null, OperationReadScope.Scoped, + [.. allowedSources ?? throw new ArgumentNullException(nameof(allowedSources))], false); + + public static OperationPolicy ForEventInitializer(string id) => + new(typeof(TRequest), id, OperationPolicyKind.FiniteTrusted, null, OperationReadScope.Scoped, + [AuthorizationSourceKind.Seed, AuthorizationSourceKind.IntegrationEvent], false, typeof(THandler)); + + public override string ToString() => nameof(OperationPolicy); +} + +/// Each source describes only its module's exact operations and scoped definitions. +public interface IOperationPolicySource +{ + IEnumerable Definitions { get; } + IEnumerable Policies { get; } +} + +public interface IOperationPolicyCatalog +{ + IReadOnlyCollection All { get; } + IReadOnlyCollection Definitions { get; } + bool TryGet(Type requestType, out OperationPolicy policy); + bool TryGetDefinition(PermissionScope scope, string key, out PermissionRegistration definition); +} + +/// Only the verified credential owner supplies a live human candidate. +public interface IAuthorizationCandidateSource +{ + AuthorizationCandidate? ForValidatedScope(TenantId tenant, OrganizationId? organization); +} + +/// Identity owns fresh scoped evaluation; common Application consumes only its decision. +public interface IProtectedOperationAuthorizer +{ + Task AuthorizeAsync(OperationInvocation invocation, OperationPolicy policy, + AuthorizationCandidate candidate, CancellationToken cancellationToken = default); +} diff --git a/backend/src/LearnStack.SharedKernel/Permissions/OperationReadWitness.cs b/backend/src/LearnStack.SharedKernel/Permissions/OperationReadWitness.cs new file mode 100644 index 000000000..15a716bb8 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Permissions/OperationReadWitness.cs @@ -0,0 +1,59 @@ +using System.Data.Common; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; + +namespace LearnStack.SharedKernel.Permissions; + +/// Immutable tenant-read authority; consumption state belongs to its exact invocation owner. +public sealed class OperationReadWitness +{ + private readonly OperationInvocation _invocation; + private readonly AuthorizationCandidate _candidate; + + private OperationReadWitness(OperationInvocation invocation, AuthorizationCandidate candidate) + { + _invocation = invocation; + _candidate = candidate; + } + + public UserId Actor => _candidate.Actor; + public TenantId TenantId => _candidate.TenantId; + public OrganizationId? OrganizationId => _candidate.OrganizationId; + public OperationPolicy Policy => _invocation.Policy; + public OperationInvocation Invocation => _invocation; + public bool IsActive => _invocation.IsActive && _candidate.IsActive; + + /// Only the actual ordinary transaction owner consumes this once, before announcement. + public bool TryBindTo(IUnitOfWork unit, DbTransaction transaction, long generation) => + IsActive && _invocation.TryBindRead(unit, transaction, generation); + + public bool IsBoundTo(IUnitOfWork unit, DbTransaction transaction, long generation) => + IsActive && _invocation.IsReadBoundTo(unit, transaction, generation); + + public override string ToString() => nameof(OperationReadWitness); + +#pragma warning disable CA1034 // The decision minter alone owns read-witness creation; exact callers are counted. + public abstract class MintingSource +#pragma warning restore CA1034 + { + protected static OperationReadWitness ForTenantRead(OperationInvocation invocation, AuthorizationCandidate candidate) + { + if (!invocation.IsActive || !candidate.IsActive || !ReferenceEquals(invocation.Candidate, candidate) + || invocation.Policy is not + { + Kind: OperationPolicyKind.Protected, ReadScope: OperationReadScope.Tenant, + RequiredPermission.Scope: PermissionScope.Tenant + } + || invocation.Actor != candidate.Actor || invocation.TenantId != candidate.TenantId + || invocation.OrganizationId != candidate.OrganizationId) + throw new InvalidOperationException("Operation read authority does not match its invocation."); + return new(invocation, candidate); + } + } +} + +/// Live physical-owner state only, never a writable context or request flag. +public interface IOperationReadScopeView +{ + bool IsTenantWideRead { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Permissions/ProtectedOperationDecision.cs b/backend/src/LearnStack.SharedKernel/Permissions/ProtectedOperationDecision.cs new file mode 100644 index 000000000..c660c3c26 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Permissions/ProtectedOperationDecision.cs @@ -0,0 +1,59 @@ +using System.Collections.Immutable; + +namespace LearnStack.SharedKernel.Permissions; + +public enum ProtectedOperationOutcome { Unavailable = 0, NotCovered = 1, Forbidden = 2, Allowed = 3 } + +/// A private-minted decision for one invocation, with only bounded audit identifiers. +public sealed class ProtectedOperationDecision +{ + private ProtectedOperationDecision(OperationInvocation invocation, ProtectedOperationOutcome outcome, + ImmutableArray membershipIds, ImmutableArray roleIds, OperationReadWitness? readWitness) + { + Invocation = invocation; + Outcome = outcome; + MembershipIds = membershipIds; + RoleIds = roleIds; + ReadWitness = readWitness; + } + + public OperationInvocation Invocation { get; } + public ProtectedOperationOutcome Outcome { get; } + public ImmutableArray MembershipIds { get; } + public ImmutableArray RoleIds { get; } + public OperationReadWitness? ReadWitness { get; } + public bool IsActive => Invocation.IsActive; + public override string ToString() => nameof(ProtectedOperationDecision); + +#pragma warning disable CA1034 // Only the enumerated Identity evaluator derives the decision source. + public abstract class MintingSource : OperationReadWitness.MintingSource +#pragma warning restore CA1034 + { + protected static ProtectedOperationDecision ForDecision(OperationInvocation invocation, AuthorizationCandidate candidate, + ProtectedOperationOutcome outcome, IEnumerable? membershipIds = null, IEnumerable? roleIds = null) + { + ArgumentNullException.ThrowIfNull(invocation); + ArgumentNullException.ThrowIfNull(candidate); + if (!Enum.IsDefined(outcome) || !ReferenceEquals(invocation.Candidate, candidate) + || invocation.Policy.Kind is not (OperationPolicyKind.AccountRead or OperationPolicyKind.Protected)) + throw new ArgumentException("Protected operation decision provenance is invalid."); + var members = Snapshot(membershipIds, 2); + var roles = Snapshot(roleIds, 8); + if (outcome == ProtectedOperationOutcome.Allowed && (!invocation.IsActive || !candidate.IsActive || members.IsEmpty)) + throw new InvalidOperationException("Protected operation admission has no active membership witness."); + var read = outcome == ProtectedOperationOutcome.Allowed && invocation.Policy.ReadScope == OperationReadScope.Tenant + ? ForTenantRead(invocation, candidate) : null; + return new(invocation, outcome, members, roles, read); + } + + private static ImmutableArray Snapshot(IEnumerable? values, int maximum) + { + if (values is null) return []; + var snapshot = values.Take(maximum + 1).ToArray(); + if (snapshot.Length > maximum || snapshot.Any(id => id == Guid.Empty || id.Version != 7) + || snapshot.Distinct().Count() != snapshot.Length) + throw new ArgumentException("Protected operation evidence identifiers are invalid."); + return [.. snapshot.Order()]; + } + } +} diff --git a/backend/src/LearnStack.SharedKernel/Permissions/TrustedOperationDescriptor.cs b/backend/src/LearnStack.SharedKernel/Permissions/TrustedOperationDescriptor.cs new file mode 100644 index 000000000..fd5a9e920 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Permissions/TrustedOperationDescriptor.cs @@ -0,0 +1,68 @@ +using System.Diagnostics.CodeAnalysis; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.SharedKernel.Permissions; + +/// Captures only the current finite seed, delivery or job owner. +public interface ITrustedOperationSource +{ + TrustedOperationDescriptor? Capture(Type requestType, ITenantContext context); +} + +/// Closed, lifetime-bound evidence for one exact finite application operation. +public sealed class TrustedOperationDescriptor +{ + private readonly MintingSource _source; + private readonly ITenantContext _context; + private readonly object? _evidence; + + private TrustedOperationDescriptor(MintingSource source, AuthorizationSourceKind kind, + Type requestType, ITenantContext context, object? evidence) + { + if (kind is not (AuthorizationSourceKind.Seed or AuthorizationSourceKind.IntegrationEvent + or AuthorizationSourceKind.BackgroundJob) || requestType.IsAbstract || requestType.ContainsGenericParameters) + throw new ArgumentException("Trusted operation provenance is invalid."); + _source = source; + _context = context; + _evidence = evidence; + SourceKind = kind; + RequestType = requestType; + } + + public AuthorizationSourceKind SourceKind { get; } + public Type RequestType { get; } + public bool IsActive => _source.IsActive && _source.MatchesEvidence(_evidence); + + public bool Matches(Type requestType, ITenantContext context) => + RequestType == requestType && ReferenceEquals(_context, context) && IsActive; + + public override string ToString() => nameof(TrustedOperationDescriptor); + +#pragma warning disable CA1034 // Private construction and the exact compiled minter/caller census close the source set. + [SuppressMessage("Design", "CA1063:Implement IDisposable Correctly", + Justification = "Disposal revokes the shared provenance lifetime; derived sources cannot replace that transition.")] + public abstract class MintingSource : IDisposable +#pragma warning restore CA1034 + { + private int _closed; + internal bool IsActive => Volatile.Read(ref _closed) == 0; + protected bool CanIssue => IsActive; + internal bool MatchesEvidence(object? evidence) => IsEvidenceCurrent(evidence); + protected abstract bool IsEvidenceCurrent(object? evidence); + + protected TrustedOperationDescriptor ForOperation(AuthorizationSourceKind kind, + Type requestType, ITenantContext context, object? evidence = null) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(context); + ObjectDisposedException.ThrowIf(!IsActive, this); + return new(this, kind, requestType, context, evidence); + } + + public void Dispose() + { + Interlocked.Exchange(ref _closed, 1); + GC.SuppressFinalize(this); + } + } +} diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index 08598c404..533498372 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs @@ -1,5 +1,6 @@ using System.Data.Common; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Tenancy; namespace LearnStack.SharedKernel.Persistence; @@ -110,6 +111,15 @@ Task BeginTransactionAsync( /// Task SetTenantContextAsync(ITenantContext context, CancellationToken cancellationToken = default); + /// Announces an invocation-bound tenant-wide read on the actual ordinary owner. + /// + /// The physical owner consumes the witness once and binds it to its transaction generation. + /// Units without that capability fail closed; the ordinary overload always requests narrow scope. + /// + Task SetTenantContextAsync(ITenantContext context, OperationReadWitness readWitness, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("This unit cannot bind operation read authority."); + /// /// Whether a sanctioned setter has announced the tenant on /// . @@ -163,7 +173,9 @@ Task BeginTransactionAsync( /// /// It does not widen the setter set. /// ADR-0040 - /// Amendment 7 closes that set at eight. The common + /// Amendment 7 closes the baseline at eight; ADR-0076 adds only the + /// confined authorization reader as the ninth. Provisioning remains part of + /// the existing general announcement path. The common /// TransactionExecutionCoordinator calls both methods; /// TransactionBehavior is its request adapter. This is the same logical /// setter announcing a different value for provisioning, not a ninth path. diff --git a/backend/src/LearnStack.Tools.Seeder/Program.cs b/backend/src/LearnStack.Tools.Seeder/Program.cs index 2bf54237b..9573e77d6 100644 --- a/backend/src/LearnStack.Tools.Seeder/Program.cs +++ b/backend/src/LearnStack.Tools.Seeder/Program.cs @@ -47,9 +47,9 @@ // 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. +// IConfiguration's GetConnectionString remains confined to the API persistence +// composition and its separate dispatcher composition. The global credential +// census pins each reader/key; this console root reads only its explicit environment. var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__Default"); if (string.IsNullOrWhiteSpace(connectionString)) @@ -70,30 +70,59 @@ using var hangup = OperatingSystem.IsWindows() ? null : PosixSignalRegistration.Create(PosixSignal.SIGHUP, signal => { signal.Cancel = true; cancellation.Cancel(); }); +var sourceStartupQualified = false; try { + if (!Guid.TryParseExact(Environment.GetEnvironmentVariable("Identity__AuthorityId"), "D", out var authorityId)) + throw new InvalidOperationException("Identity authority configuration is invalid."); + var registration = new IdentityAuthorityRegistration(IdentityAuthorityId.From(authorityId), + Environment.GetEnvironmentVariable("Authentication__Tenant__Issuer") ?? ""); + var auxiliary = AuthorizationSourceConfiguration.Resolve(connectionString, + Environment.GetEnvironmentVariable("ConnectionStrings__IdentityAuthorization"), + Environment.GetEnvironmentVariable("ConnectionStrings__AuthorizationEvidence"), + allowDevelopmentDerivation: string.Equals(Environment.GetEnvironmentVariable("Deployment__Mode"), + "Development", StringComparison.Ordinal)); await using var dataSource = ApplicationDataSource.Build(connectionString); + // The process owns one bounded evidence lane across every per-act provider. + // Qualification only reads the installed anchor; schema-only migration must + // install it before running this application writer. + var qualification = new IdentityAuthorityQualification(new Lazy(() => dataSource), registration); + await qualification.QualifyAsync(cancellation.Token); + await using var evidence = new AuthorizationEvidenceSource(auxiliary.AuthorizationEvidence); + var proof = await qualification.OpenDatabaseQualificationProofAsync(cancellation.Token); + var qualificationDeadline = proof.DeadlineToken; + await using (proof) + await evidence.QualifyAsync(registration.AuthorityId, registration.Issuer, proof, qualificationDeadline); + qualificationDeadline.ThrowIfCancellationRequested(); + if (!string.Equals(qualification.QualifiedDatabaseName, evidence.QualifiedDatabaseName, StringComparison.Ordinal)) + throw new InvalidOperationException("Authorization evidence must use the qualified application database."); + sourceStartupQualified = true; if (identityFoundation) { - if (!Guid.TryParseExact(Environment.GetEnvironmentVariable("Identity__AuthorityId"), "D", out var authorityId)) - throw new InvalidOperationException("Identity authority configuration is invalid."); - var registration = new IdentityAuthorityRegistration(IdentityAuthorityId.From(authorityId), - Environment.GetEnvironmentVariable("Authentication__Tenant__Issuer") ?? ""); var witness = await SeedProviderWitness.ReadAsync(Console.OpenStandardInput(), registration, cancellation.Token); var admission = new FixedSeedIdentityFoundationAdmission(witness); var identityRunner = new SeedIdentityFoundationRunner( context => SeedComposition.Build(dataSource, context, loggerFactory, - identityAuthority: registration, seedAdmission: admission), admission); + identityAuthority: registration, seedAdmission: admission, authorizationEvidence: evidence), admission); await identityRunner.RunAsync(cancellation.Token); return 0; } var runner = new SeedRunner( - context => SeedComposition.Build(dataSource, context, loggerFactory), + context => SeedComposition.Build(dataSource, context, loggerFactory, + identityAuthority: registration, authorizationEvidence: evidence), loggerFactory.CreateLogger()); return await runner.RunAsync(cancellation.Token); } catch (Exception failure) { + if (!sourceStartupQualified) + { + // Provider/authentication failures can contain endpoint or role details. + // The new startup gate owns a fixed diagnostic, including its cleanup; + // it never passes the provider exception into ordinary command logging. + Console.Error.WriteLine("seed: authorization source configuration or startup qualification was refused."); + return 1; + } if (identityFoundation) { Console.Error.WriteLine("seed: development identity foundation was refused."); diff --git a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index 6387ea307..5829a45df 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -1,4 +1,9 @@ using LearnStack.Infrastructure.Jobs; +using LearnStack.Application.Authorization; +using LearnStack.Modules.Identity.Infrastructure.Authorization; +using LearnStack.Modules.Tenancy.Application.Authorization; +using LearnStack.Modules.Customization.Application.Authorization; +using LearnStack.Modules.Education.Application.Authorization; using LearnStack.Modules.Identity.Application.Contracts.Seeding; using LearnStack.Modules.Identity.Infrastructure.Bindings; using LearnStack.Modules.Customization.Infrastructure; @@ -28,6 +33,7 @@ using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Caching; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Validation; @@ -78,7 +84,8 @@ public static ServiceProvider Build( ILoggerFactory loggerFactory, IReservedHostRegistry? reservedHosts = null, IdentityAuthorityRegistration? identityAuthority = null, - ISeedIdentityFoundationAdmission? seedAdmission = null) + ISeedIdentityFoundationAdmission? seedAdmission = null, + IAuthorizationEvidenceSessionFactory? authorizationEvidence = null) { ArgumentNullException.ThrowIfNull(dataSource); ArgumentNullException.ThrowIfNull(loggerFactory); @@ -87,6 +94,9 @@ public static ServiceProvider Build( services.AddSingleton(dataSource); services.AddSingleton(loggerFactory); + // Caller-owned process lifetime: every per-act provider borrows the same + // bounded, startup-qualified lane; instance registration never disposes it. + if (authorizationEvidence is not null) services.AddSingleton(authorizationEvidence); services.AddLogging(); services.AddSingleton(); @@ -98,6 +108,8 @@ public static ServiceProvider Build( services.AddTransient(provider => provider.GetRequiredService().Current ?? UnresolvedTenantContext.Instance); + services.AddScoped(provider => + new SeedOperationSource(provider.GetRequiredService())); services.TryAddScoped(); services.TryAddScoped(provider => @@ -248,6 +260,12 @@ public static ServiceProvider Build( if (seedAdmission is not null) services.AddSingleton(seedAdmission); services.AddIdentityGlobalFoundation(identityAuthority); services.AddIdentityContextualFoundation(); + services.AddIdentityOperationPolicies(); + services.TryAddEnumerable([ + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + ]); // 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. diff --git a/backend/src/LearnStack.Tools.Seeder/SeedOperationSource.cs b/backend/src/LearnStack.Tools.Seeder/SeedOperationSource.cs new file mode 100644 index 000000000..5370bbd86 --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/SeedOperationSource.cs @@ -0,0 +1,64 @@ +using System.Collections.Frozen; +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.Modules.Education.Application.Contracts.Courses; +using LearnStack.Modules.Education.Application.Contracts.Lessons; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Identity.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Branding; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Tools.Seeder; + +/// The per-act composition owns this closed seed capability and its disposal. +internal sealed class SeedOperationSource : TrustedOperationDescriptor.MintingSource, ITrustedOperationSource +{ + private static readonly FrozenSet Requests = new[] + { + typeof(GetTenantSeedStateQuery), typeof(GetOrganizationSeedStateQuery), typeof(GetHostMappingSeedStateQuery), typeof(GetSettingSeedStateQuery), + typeof(GetContentTypeSeedStateQuery), typeof(GetTaxonomySeedStateQuery), typeof(GetActiveContentTypeSeedRevisionQuery), typeof(GetActiveTaxonomySeedRevisionQuery), + typeof(GetCourseSeedStateQuery), typeof(GetLessonSeedStateQuery), typeof(GetRoleSeedStateQuery), typeof(GetMembershipSeedStateQuery), + typeof(InitializeSeedRoleCommand), typeof(CreateFirstSeedAdminMembershipCommand), typeof(CreateSeedMembershipCommand), typeof(SetSeedMembershipProfileCommand), + typeof(ProvisionTenantCommand), typeof(CreateOrganizationCommand), typeof(MapHostToTenantCommand), typeof(AddTenantLocaleCommand), + typeof(SetDefaultTenantLocaleCommand), typeof(SetTenantBrandingCommand), typeof(RegisterTenantContentTypeCommand), typeof(PublishTenantContentTypeCommand), + typeof(RegisterTenantLevelTaxonomyCommand), typeof(PublishTenantLevelTaxonomyCommand), typeof(InitializeTenantContentTypeDefaultCommand), typeof(InitializeTenantLevelTaxonomyDefaultCommand), + typeof(CreateCourseCommand), typeof(AddCourseTranslationCommand), typeof(PublishCourseCommand), typeof(CreateLessonCommand), typeof(AddLessonTranslationCommand), typeof(PublishLessonCommand), + }.ToFrozenSet(); + + private readonly ITenantContext _context; + private readonly bool _resolved; + private readonly TenantId? _tenant; + private readonly OrganizationId? _organization; + private readonly UserId? _actor; + + internal SeedOperationSource(ITenantContext context) + { + _context = context; + _resolved = context.IsResolved; + _tenant = _resolved ? context.TenantId : null; + _organization = _resolved ? context.OrganizationId : null; + _actor = context.UserId; + } + + public TrustedOperationDescriptor? Capture(Type requestType, ITenantContext context) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(context); + if (!CanIssue || !ReferenceEquals(context, _context) || !Requests.Contains(requestType) + || !IsEvidenceCurrent(_context) + || (!_resolved && requestType != typeof(ProvisionTenantCommand))) return null; + return ForOperation(AuthorizationSourceKind.Seed, requestType, context, _context); + } + + protected override bool IsEvidenceCurrent(object? evidence) => ReferenceEquals(evidence, _context) + && _context.IsResolved == _resolved && _context.HostScope is null + && _context.UserId == _actor && (_actor is null || _actor == UserId.SystemActor) + && (_resolved ? _context.Origin == TenantContextOrigin.Ambient + && _context.TenantId == _tenant && _context.OrganizationId == _organization + : _context.Origin is null); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Authorization/CustomizationOperationPolicySource.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Authorization/CustomizationOperationPolicySource.cs new file mode 100644 index 000000000..5101e6589 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Authorization/CustomizationOperationPolicySource.cs @@ -0,0 +1,24 @@ +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.Modules.Customization.Application.IntegrationEvents; +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.Modules.Customization.Application.Authorization; + +public sealed class CustomizationOperationPolicySource : IOperationPolicySource +{ + public IEnumerable Definitions => []; + public IEnumerable Policies => + [ + OperationPolicy.ForTrusted("customization.seed.read_content_type", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("customization.seed.read_taxonomy", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("customization.seed.read_content_revision", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("customization.seed.read_taxonomy_revision", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("customization.seed.register_content_type", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("customization.seed.publish_content_type", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("customization.seed.register_taxonomy", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("customization.seed.publish_taxonomy", AuthorizationSourceKind.Seed), + OperationPolicy.ForEventInitializer("customization.default.initialize_content_type"), + OperationPolicy.ForEventInitializer("customization.default.initialize_taxonomy"), + ]; +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Authorization/EducationOperationPolicySource.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Authorization/EducationOperationPolicySource.cs new file mode 100644 index 000000000..46cbf36cc --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Authorization/EducationOperationPolicySource.cs @@ -0,0 +1,29 @@ +using LearnStack.Modules.Education.Application.Contracts.Courses; +using LearnStack.Modules.Education.Application.Contracts.Lessons; +using LearnStack.Modules.Education.Application.Contracts.PublicReads; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.Modules.Education.Application.Authorization; + +public sealed class EducationOperationPolicySource : IOperationPolicySource +{ + public static PermissionDefinition PublicCourse { get; } = new(PermissionScope.Tenant, "education.public_course.read"); + public static PermissionDefinition PublicLesson { get; } = new(PermissionScope.Tenant, "education.public_lesson.read"); + public IEnumerable Definitions => + [new(PublicCourse, Assignable: false), new(PublicLesson, Assignable: false)]; + public IEnumerable Policies => + [ + OperationPolicy.ForPublic("education.public_course.list", PublicCourse), + OperationPolicy.ForPublic("education.public_course.read", PublicCourse), + OperationPolicy.ForPublic("education.public_lesson.read", PublicLesson), + OperationPolicy.ForTrusted("education.seed.read_course", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("education.seed.read_lesson", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("education.seed.create_course", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("education.seed.add_course_translation", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("education.seed.publish_course", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("education.seed.create_lesson", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("education.seed.add_lesson_translation", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("education.seed.publish_lesson", AuthorizationSourceKind.Seed), + ]; +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application.Contracts/Authorization/IIdentityAuthorizationReader.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application.Contracts/Authorization/IIdentityAuthorizationReader.cs new file mode 100644 index 000000000..48815cee4 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application.Contracts/Authorization/IIdentityAuthorizationReader.cs @@ -0,0 +1,31 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.Modules.Identity.Application.Contracts.Authorization; + +public enum IdentityAuthorizationOutcome { Unavailable = 0, NotCovered = 1, Covered = 2 } + +/// Module-local identifiers cross this contract as Guid; no profile or provider data is projected. +public sealed record AuthorizationRoleFacts(Guid RoleId, BuiltInRoleKey BuiltInKey, + PermissionScope Scope, ImmutableArray Grants); + +public sealed record AuthorizationMembershipFacts(Guid MembershipId, OrganizationId? OrganizationId, + ImmutableArray Roles); + +/// Immutable facts from one completed committed-state read, not a reusable permission decision. +public sealed record IdentityAuthorizationResult( + IdentityAuthorizationOutcome Outcome, + UserId Actor, + TenantId TenantId, + OrganizationId? OrganizationId, + ImmutableArray Memberships) +{ + public bool Covers => Outcome == IdentityAuthorizationOutcome.Covered; +} + +public interface IIdentityAuthorizationReader +{ + Task ReadAsync(AuthorizationCandidate candidate, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application.Contracts/Memberships/ListMembershipsQuery.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application.Contracts/Memberships/ListMembershipsQuery.cs new file mode 100644 index 000000000..a01a35968 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application.Contracts/Memberships/ListMembershipsQuery.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using LearnStack.SharedKernel.DataProtection; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Pagination; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Identity.Application.Contracts.Memberships; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DirectoryMembershipStatus { Active = 1, Suspended = 2 } + +/// The complete administrative directory projection; provider and global profile fields are excluded. +public sealed record MembershipDirectoryItem(Guid MembershipId, UserId UserId, OrganizationId? OrganizationId, + DirectoryMembershipStatus Status, [property: PiiSensitive] string? DisplayName, + [property: PiiSensitive] string? PreferredLocale); + +public sealed record ListMembershipsQuery(string? Cursor = null, int Limit = CursorPagination.DefaultLimit) + : IRequest>>; diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application.Contracts/Roles/RoleGrantCommands.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application.Contracts/Roles/RoleGrantCommands.cs new file mode 100644 index 000000000..c1bad3696 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application.Contracts/Roles/RoleGrantCommands.cs @@ -0,0 +1,18 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Identity.Application.Contracts.Roles; + +public sealed record ChangeRoleGrantsCommand(Guid RoleId, long ExpectedRoleVersion, ImmutableArray Grants) + : IRequest>, IIdentityPermissionRequest +{ + public PermissionDefinition RequiredPermission => new(PermissionScope.Tenant, "identity.role.admin"); +} + +public sealed record AdoptRoleGrantManifestCommand(Guid RoleId, long ExpectedRoleVersion, int ManifestVersion, string ManifestFingerprint) + : IRequest>, IIdentityPermissionRequest +{ + public PermissionDefinition RequiredPermission => new(PermissionScope.Tenant, "identity.role.admin"); +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Abstractions/IIdentityAuthorizationSession.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Abstractions/IIdentityAuthorizationSession.cs new file mode 100644 index 000000000..14bc418a7 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Abstractions/IIdentityAuthorizationSession.cs @@ -0,0 +1,22 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; + +namespace LearnStack.Modules.Identity.Application.Abstractions; + +/// Opens only a candidate-bound isolated authorization child, never a general query source. +public interface IIdentityAuthorizationSessionFactory +{ + Task OpenAsync(AuthorizationCandidate candidate, CancellationToken cancellationToken = default); +} + +/// One confined physical child shared by Identity and the separate Tenancy lifecycle statement. +public interface IIdentityAuthorizationSession : IAsyncDisposable +{ + IUnitOfWork Unit { get; } + CancellationToken DeadlineToken { get; } + Task> ReadMembershipsAsync(CancellationToken cancellationToken = default); + Task CompleteAsync(); + void MarkCleanupFailed(); +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Abstractions/IMembershipDirectoryReader.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Abstractions/IMembershipDirectoryReader.cs new file mode 100644 index 000000000..37efaec63 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Abstractions/IMembershipDirectoryReader.cs @@ -0,0 +1,11 @@ +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Domain; + +namespace LearnStack.Modules.Identity.Application.Abstractions; + +/// One bounded ordered page on the current ordinary owner and its live tenant-read witness. +public interface IMembershipDirectoryReader +{ + Task> ReadAsync(MembershipId? after, int take, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Audit/IdentityAuditCatalogSource.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Audit/IdentityAuditCatalogSource.cs index e870e0563..124263700 100644 --- a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Audit/IdentityAuditCatalogSource.cs +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Audit/IdentityAuditCatalogSource.cs @@ -1,5 +1,7 @@ using LearnStack.Modules.Identity.Domain; using LearnStack.Modules.Identity.Application.Contracts.Seeding; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Application.Contracts.Roles; using LearnStack.SharedKernel.Audit; namespace LearnStack.Modules.Identity.Application.Audit; @@ -17,6 +19,10 @@ public void Describe(IAuditCatalogBuilder builder) builder.MustAudit("identity.membership_profile.set_seed_profile", OperationType.Update, typeof(Membership)); builder.Off(); builder.Off(); + builder.MustAudit(IdentityOperationPolicySource.MembershipDirectoryOperation, OperationType.ReadSensitive, typeof(Membership)); + builder.MustAudit("identity.role.change_grants", OperationType.SecurityEvent, typeof(Role)); + builder.MustAudit("identity.role.adopt_manifest", OperationType.SecurityEvent, typeof(Role)); builder.DeclareOffPath("identity.user.create", OperationType.SecurityEvent, OperationClass.Must, typeof(User)); + builder.DeclareOffPath("identity.authorization.deny", OperationType.SecurityEvent, OperationClass.Must); } } diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Authorization/CommittedIdentityAuthorizationReader.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Authorization/CommittedIdentityAuthorizationReader.cs new file mode 100644 index 000000000..08c9c52e8 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Authorization/CommittedIdentityAuthorizationReader.cs @@ -0,0 +1,91 @@ +using System.Data; +using LearnStack.Modules.Identity.Application.Abstractions; +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.Modules.Tenancy.Application.Contracts.IdentityLifecycle; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; + +namespace LearnStack.Modules.Identity.Application.Authorization; + +/// Committed facts from separate module statements on one isolated child, closed before continuation. +public sealed class CommittedIdentityAuthorizationReader( + IIdentityAuthorizationSessionFactory sessions, IIdentityScopeLifecycleReader lifecycle) : IIdentityAuthorizationReader +{ + public async Task ReadAsync(AuthorizationCandidate candidate, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(candidate); + cancellationToken.ThrowIfCancellationRequested(); + IIdentityAuthorizationSession? owner = null; + var result = Empty(candidate, IdentityAuthorizationOutcome.Unavailable); + var cleaned = true; + try + { + if (candidate.IsActive) + { + owner = await sessions.OpenAsync(candidate, cancellationToken); + if (owner is not null) + { + var transaction = owner.Unit.Transaction; + var announced = RequireAnnouncement(owner.Unit, candidate, transaction); + var memberships = await owner.ReadMembershipsAsync(owner.DeadlineToken); + RequireSameAnnouncement(owner.Unit, candidate, transaction, announced); + // Tenancy owns this separate statement. READ COMMITTED gives each statement its own committed view. + var state = await lifecycle.ReadAuthorizationAsync(owner.Unit, candidate, owner.DeadlineToken); + RequireSameAnnouncement(owner.Unit, candidate, transaction, announced); + var covers = memberships.Length > 0 && state.TenantExistsAndNotDeleted && state.TenantAllowsOrdinaryWork + && (candidate.OrganizationId is null || (state.OrganizationBelongsToTenant && state.OrganizationAllowsOrdinaryWork)); + result = covers + ? new(IdentityAuthorizationOutcome.Covered, candidate.Actor, candidate.TenantId, candidate.OrganizationId, memberships) + : Empty(candidate, IdentityAuthorizationOutcome.NotCovered); + await owner.CompleteAsync(); + } + } + } + catch (IdentityScopeLifecycleReadException exception) + { + if (exception.CleanupFailed) owner?.MarkCleanupFailed(); + result = Empty(candidate, IdentityAuthorizationOutcome.Unavailable); + } + catch (Exception exception) when (exception is not (OutOfMemoryException or StackOverflowException)) + { + // Storage, internal deadline and owner faults expose no provider payload or false noncoverage. + result = Empty(candidate, IdentityAuthorizationOutcome.Unavailable); + } + finally + { + if (owner is not null) + { + try { await owner.DisposeAsync(); } + catch (Exception exception) when (exception is not (OutOfMemoryException or StackOverflowException)) { cleaned = false; } + } + } + + // Owned cleanup finishes (or retains its slot) before request cancellation leaves the adapter. + cancellationToken.ThrowIfCancellationRequested(); + return cleaned && candidate.IsActive && owner?.DeadlineToken.IsCancellationRequested != true + ? result : Empty(candidate, IdentityAuthorizationOutcome.Unavailable); + } + + private static TenantContextAnnouncement RequireAnnouncement(IUnitOfWork unit, AuthorizationCandidate candidate, + System.Data.Common.DbTransaction? transaction) + { + if (!candidate.IsActive || !unit.HasActiveTransaction || unit.Mode != TransactionMode.ReadOnly || unit.IsRollbackOnly + || transaction is not { IsolationLevel: IsolationLevel.ReadCommitted } + || unit.GetTenantContextAnnouncement(transaction) is not { Generation: > 0 } announced + || announced.TenantId != candidate.TenantId || announced.OrganizationId != candidate.OrganizationId) + throw new InfrastructureException("Identity authorization is unavailable."); + return announced; + } + + private static void RequireSameAnnouncement(IUnitOfWork unit, AuthorizationCandidate candidate, + System.Data.Common.DbTransaction? transaction, TenantContextAnnouncement announced) + { + if (RequireAnnouncement(unit, candidate, transaction) != announced) + throw new InfrastructureException("Identity authorization is unavailable."); + } + + private static IdentityAuthorizationResult Empty(AuthorizationCandidate candidate, IdentityAuthorizationOutcome outcome) => + new(outcome, candidate.Actor, candidate.TenantId, candidate.OrganizationId, []); +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Authorization/IdentityOperationAuthorizer.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Authorization/IdentityOperationAuthorizer.cs new file mode 100644 index 000000000..af779ef60 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Authorization/IdentityOperationAuthorizer.cs @@ -0,0 +1,72 @@ +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.Modules.Identity.Application.Authorization; + +/// One fresh committed-state evaluation per act; no fact or permission decision cache. +public sealed class IdentityOperationAuthorizer(Func reader, IOperationPolicyCatalog catalog) + : ProtectedOperationDecision.MintingSource, IProtectedOperationAuthorizer +{ + public async Task AuthorizeAsync(OperationInvocation invocation, OperationPolicy policy, + AuthorizationCandidate candidate, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(invocation); + ArgumentNullException.ThrowIfNull(policy); + ArgumentNullException.ThrowIfNull(candidate); + cancellationToken.ThrowIfCancellationRequested(); + if (invocation.Candidate is not { } original || invocation.Policy.Kind is not (OperationPolicyKind.AccountRead or OperationPolicyKind.Protected)) + throw new ArgumentException("Protected operation invocation provenance is invalid.", nameof(invocation)); + if (!ReferenceEquals(original, candidate)) + return ForDecision(invocation, original, ProtectedOperationOutcome.NotCovered); + if (!invocation.IsActive || !candidate.IsActive) + return ForDecision(invocation, candidate, ProtectedOperationOutcome.Unavailable); + if (!Matches(invocation, policy, candidate)) + return ForDecision(invocation, candidate, ProtectedOperationOutcome.NotCovered); + var facts = await reader().ReadAsync(candidate, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!invocation.IsActive || !candidate.IsActive) + return ForDecision(invocation, candidate, ProtectedOperationOutcome.Unavailable); + if (!Matches(invocation, policy, candidate) || facts.Actor != candidate.Actor || facts.TenantId != candidate.TenantId + || facts.OrganizationId != candidate.OrganizationId) + return ForDecision(invocation, candidate, ProtectedOperationOutcome.NotCovered); + if (facts.Outcome == IdentityAuthorizationOutcome.Unavailable) + return ForDecision(invocation, candidate, ProtectedOperationOutcome.Unavailable); + if (!facts.Covers || facts.Memberships.IsDefaultOrEmpty) + return ForDecision(invocation, candidate, ProtectedOperationOutcome.NotCovered); + var members = facts.Memberships.Where(member => member.OrganizationId is null || member.OrganizationId == candidate.OrganizationId).ToArray(); + if (members.Length != facts.Memberships.Length) + return ForDecision(invocation, candidate, ProtectedOperationOutcome.Unavailable); + if (policy.Kind == OperationPolicyKind.AccountRead) + return ForDecision(invocation, candidate, ProtectedOperationOutcome.Allowed, + members.Select(member => member.MembershipId), members.SelectMany(member => member.Roles).Select(role => role.RoleId).Distinct()); + var permission = policy.RequiredPermission!; + if (!permission.IsActive || !catalog.TryGetDefinition(permission.Scope, permission.Key, out var registration) + || !registration.Assignable || !registration.Definition.IsActive || registration.Definition != permission + || policy.RequiresOrglessWrite && candidate.OrganizationId is not null) + return ForDecision(invocation, candidate, ProtectedOperationOutcome.Forbidden); + var contributing = members.SelectMany(member => member.Roles + .Where(role => role.Scope == permission.Scope + && (permission.Scope == PermissionScope.Tenant ? member.OrganizationId is null + : candidate.OrganizationId is not null && member.OrganizationId == candidate.OrganizationId) + && role.Grants.Any(grant => grant.IsActive && grant == permission)) + .Select(role => (Member: member.MembershipId, Role: role.RoleId))).ToArray(); + return contributing.Length == 0 + ? ForDecision(invocation, candidate, ProtectedOperationOutcome.Forbidden) + : ForDecision(invocation, candidate, ProtectedOperationOutcome.Allowed, + contributing.Select(value => value.Member).Distinct(), contributing.Select(value => value.Role).Distinct()); + } + + private bool Matches(OperationInvocation invocation, OperationPolicy policy, AuthorizationCandidate candidate) => + ReferenceEquals(invocation.Policy, policy) + && ReferenceEquals(invocation.Candidate, candidate) + && (invocation.EntryKind == OperationEntryKind.Http + || invocation.EntryKind == OperationEntryKind.Application && invocation.Operation.GetType() == policy.RequestType) + && catalog.TryGet(policy.RequestType, out var registered) && ReferenceEquals(registered, policy) + && policy.Kind is OperationPolicyKind.AccountRead or OperationPolicyKind.Protected + && invocation.Context.IsResolved && invocation.Context.UserId == candidate.Actor + && candidate.Actor != UserId.SystemActor && candidate.TenantId != TenantId.PlatformSentinel + && invocation.Actor == candidate.Actor && invocation.TenantId == candidate.TenantId + && invocation.OrganizationId == candidate.OrganizationId && invocation.Context.TenantId == candidate.TenantId + && invocation.Context.OrganizationId == candidate.OrganizationId; +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Authorization/RoleGrantAuthorization.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Authorization/RoleGrantAuthorization.cs new file mode 100644 index 000000000..5508f0e93 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Authorization/RoleGrantAuthorization.cs @@ -0,0 +1,74 @@ +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.Modules.Identity.Application.Contracts.Roles; +using LearnStack.Modules.Identity.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Identity.Application.Authorization; + +/// Fresh committed authoring authority for one current grant act; no ambient facts or cached ceiling. +public sealed class RoleGrantAuthorization(IIdentityAuthorizationReader reader, IOperationPolicyCatalog catalog, + IOperationInvocationContext invocations, ITenantContext context, IAuthorizationRefusalReporter refusals) + : ProtectedOperationDecision.MintingSource +{ + internal bool Matches(object request, OperationInvocation invocation) => + ReferenceEquals(invocations.CurrentInvocation, invocation) && invocation.IsActive + && invocation.EntryKind == OperationEntryKind.Application && ReferenceEquals(invocation.Operation, request) + && ReferenceEquals(invocation.Context, context) && invocation.Candidate is { IsActive: true } candidate + && request is ChangeRoleGrantsCommand or AdoptRoleGrantManifestCommand + && catalog.TryGet(request.GetType(), out var policy) && ReferenceEquals(invocation.Policy, policy) + && policy is { Kind: OperationPolicyKind.Protected, ReadScope: OperationReadScope.Scoped, RequiresOrglessWrite: true } + && policy.RequiredPermission == IdentityOperationPolicySource.RoleAdministration + && context.IsResolved && context.OrganizationId is null && context.TenantId != TenantId.PlatformSentinel + && context.UserId is { } actor && StronglyTypedId.IsAssigned(actor) && actor != UserId.SystemActor + && context.Origin is TenantContextOrigin.HostAndClaim or TenantContextOrigin.ClaimAndMembership + && candidate.Actor == actor && candidate.TenantId == context.TenantId && candidate.OrganizationId is null + && invocation.Actor == actor && invocation.TenantId == context.TenantId && invocation.OrganizationId is null + && invocations.CurrentDecision is { Outcome: ProtectedOperationOutcome.Allowed, IsActive: true } admitted + && ReferenceEquals(admitted.Invocation, invocation); + + internal async Task RecheckAsync(object request, OperationInvocation invocation, Role target, + IReadOnlyCollection desired, CancellationToken cancellationToken) + { + var candidate = invocation.Candidate ?? throw new ArgumentException("Role grant invocation has no human source.", nameof(invocation)); + if (!Matches(request, invocation)) return Refused(ProtectedOperationOutcome.Unavailable); + var facts = await reader.ReadAsync(candidate, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!Matches(request, invocation)) return Refused(ProtectedOperationOutcome.Unavailable); + if (facts.Outcome == IdentityAuthorizationOutcome.Unavailable) return Refused(ProtectedOperationOutcome.Unavailable); + if (!facts.Covers || facts.Actor != candidate.Actor || facts.TenantId != candidate.TenantId + || facts.OrganizationId is not null || facts.Memberships.IsDefaultOrEmpty) + return Refused(ProtectedOperationOutcome.NotCovered); + if (facts.Memberships.Any(member => member.OrganizationId is not null)) return Refused(ProtectedOperationOutcome.Unavailable); + var members = facts.Memberships.Select(member => member.MembershipId).Distinct().ToArray(); + var roles = facts.Memberships.SelectMany(member => member.Roles).ToArray(); + var roleIds = roles.Select(role => role.RoleId).Distinct().ToArray(); + var tenantGrants = roles.Where(role => role.Scope == PermissionScope.Tenant).SelectMany(role => role.Grants) + .Where(Registered).Distinct().ToArray(); + var eligible = roles.Any(role => role.BuiltInKey == BuiltInRoleKey.TenantAdmin && role.Scope == PermissionScope.Tenant + && IdentityFoundationManifest.Version1.TenantAdminKernel.All(definition => role.Grants.Contains(definition) && Registered(definition))); + if (!eligible || !tenantGrants.Contains(IdentityOperationPolicySource.RoleAdministration)) + return Refused(ProtectedOperationOutcome.Forbidden, AuthorizationRefusalReason.Capability, members, roleIds); + if (!target.CanReplaceGrants(desired) || desired.Any(definition => !Registered(definition)) + || desired.Where(definition => !target.Grants.Any(grant => grant.PermissionScope == definition.Scope && grant.PermissionKey == definition.Key)) + .Any(definition => !tenantGrants.Any(held => held.Scope == PermissionScope.Tenant && held.Key == definition.Key))) + return Refused(ProtectedOperationOutcome.Forbidden, AuthorizationRefusalReason.Delegation, members, roleIds); + return new(ForDecision(invocation, candidate, ProtectedOperationOutcome.Allowed, members, roleIds), AuthorizationRefusalReason.Capability); + + RoleGrantAssessment Refused(ProtectedOperationOutcome outcome, AuthorizationRefusalReason reason = AuthorizationRefusalReason.Capability, + IEnumerable? membershipIds = null, IEnumerable? roleIdentifiers = null) => + new(ForDecision(invocation, candidate, outcome, membershipIds, roleIdentifiers), reason); + } + + internal void ReportRecheck(RoleGrantAssessment assessment) => refusals.ReportRecheck(assessment.Decision); + internal void ReportRefusal(RoleGrantAssessment assessment) => refusals.ReportResourceRefusal(assessment.Decision, assessment.Reason); + + private bool Registered(PermissionDefinition definition) => definition is { IsActive: true } + && IdentityFoundationManifest.Version1.IsActive(definition.Scope, definition.Key) + && catalog.TryGetDefinition(definition.Scope, definition.Key, out var registered) + && registered.Assignable && registered.Definition == definition && registered.Definition.IsActive; +} + +internal sealed record RoleGrantAssessment(ProtectedOperationDecision Decision, AuthorizationRefusalReason Reason); diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/IdentityOperationPolicySource.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/IdentityOperationPolicySource.cs new file mode 100644 index 000000000..14d9a1d98 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/IdentityOperationPolicySource.cs @@ -0,0 +1,31 @@ +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Application.Contracts.Seeding; +using LearnStack.Modules.Identity.Application.Contracts.Roles; +using LearnStack.Modules.Identity.Domain; +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.Modules.Identity.Application; + +/// Finite Identity definitions and exact internal operations; registration grants no stored Role authority. +public sealed class IdentityOperationPolicySource : IOperationPolicySource +{ + public const string MembershipDirectoryOperation = "identity.membership.list"; + public static PermissionDefinition MembershipAdministration { get; } = new(PermissionScope.Tenant, IdentityFoundationManifest.MembershipAdmin); + public static PermissionDefinition RoleAdministration { get; } = new(PermissionScope.Tenant, IdentityFoundationManifest.RoleAdmin); + + public IEnumerable Definitions => IdentityFoundationManifest.Version1.Definitions + .Select(definition => new PermissionRegistration(definition, Assignable: true)); + + public IEnumerable Policies => + [ + OperationPolicy.ForProtected(MembershipDirectoryOperation, MembershipAdministration, OperationReadScope.Tenant), + OperationPolicy.ForProtected("identity.role.change_grants", RoleAdministration, requiresOrglessWrite: true), + OperationPolicy.ForProtected("identity.role.adopt_manifest", RoleAdministration, requiresOrglessWrite: true), + OperationPolicy.ForTrusted("identity.role.initialize", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("identity.membership.create_first_seed_admin", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("identity.membership.create_seed", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("identity.membership_profile.set_seed_profile", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("identity.role.verify_seed", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("identity.membership.verify_seed", AuthorizationSourceKind.Seed), + ]; +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Memberships/ListMembershipsQueryHandler.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Memberships/ListMembershipsQueryHandler.cs new file mode 100644 index 000000000..3b1ba23af --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Memberships/ListMembershipsQueryHandler.cs @@ -0,0 +1,40 @@ +using LearnStack.Modules.Identity.Application.Abstractions; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Pagination; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Modules.Identity.Application.Memberships; + +public sealed class ListMembershipsQueryHandler(IMembershipDirectoryReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle(ListMembershipsQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved || context.TenantId == TenantId.PlatformSentinel + || context.UserId is not { } actor || actor == UserId.SystemActor) + return Result>.Fail(new Error(LocalizedMessage.Of("lockey_not_found"))); + if (request.Limit <= 0) return Invalid("limit"); + var pagination = new CursorPagination(request.Cursor, request.Limit); + var scope = MembershipDirectoryCursorCodec.Scope(actor, context.TenantId, context.OrganizationId); + MembershipId? after = null; + if (pagination.Cursor is { } cursor && !MembershipDirectoryCursorCodec.TryDecode(cursor, scope, out after)) + return Invalid("cursor"); + var fetched = await reader.ReadAsync(after, pagination.Limit + 1, cancellationToken); + var rows = fetched.Take(pagination.Limit).ToArray(); + var next = fetched.Count > pagination.Limit && rows.Length != 0 + ? MembershipDirectoryCursorCodec.Encode(scope, MembershipId.From(rows[^1].MembershipId)) : null; + return Result>.Ok(new(rows, new PageInfo(next, null, next is not null, false))); + } + + private static Result> Invalid(string field) => Result>.Fail( + new Error(LocalizedMessage.Of("lockey_validation_failed"), new Dictionary> + { + [field] = [LocalizedMessage.Of("lockey_invalid_value")], + })); +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Memberships/ListMembershipsQueryValidator.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Memberships/ListMembershipsQueryValidator.cs new file mode 100644 index 000000000..de3715606 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Memberships/ListMembershipsQueryValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; + +namespace LearnStack.Modules.Identity.Application.Memberships; + +public sealed class ListMembershipsQueryValidator : AbstractValidator +{ + public ListMembershipsQueryValidator() + { + RuleFor(request => request.Limit).GreaterThan(0).OverridePropertyName("limit").WithErrorCode("lockey_invalid_value"); + RuleFor(request => request.Cursor).Must(cursor => cursor is null || MembershipDirectoryCursorCodec.TryDecode(cursor, null, out _)) + .OverridePropertyName("cursor").WithErrorCode("lockey_invalid_value"); + } +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Memberships/MembershipDirectoryCursorCodec.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Memberships/MembershipDirectoryCursorCodec.cs new file mode 100644 index 000000000..a47390be8 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Memberships/MembershipDirectoryCursorCodec.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using LearnStack.Modules.Identity.Domain; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.Modules.Identity.Application.Memberships; + +/// Canonical resource-local seek protocol; it carries no authentication, secrecy or snapshot authority. +public static class MembershipDirectoryCursorCodec +{ + public const int MaximumEncodedLength = 1024; + public const int MaximumDecodedLength = 768; + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + private static readonly JsonDocumentOptions Options = new() { MaxDepth = 2 }; + + public static string Scope(UserId actor, TenantId tenant, OrganizationId? organization) => + Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new object?[] + { + 1, IdentityOperationPolicySource.MembershipDirectoryOperation, actor.Value.ToString("D"), + tenant.Value.ToString("D"), organization?.Value.ToString("D"), "membership_id:asc", + }))); + + public static string Encode(string scope, MembershipId after) => Base64Url(JsonSerializer.SerializeToUtf8Bytes(new + { + v = 1, + scope, + id = after.Value.ToString("D"), + })); + + public static bool TryDecode(string raw, string? expectedScope, out MembershipId? after) + { + after = null; + if (string.IsNullOrEmpty(raw) || raw.Length > MaximumEncodedLength + || raw.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not ('-' or '_'))) return false; + try + { + var bytes = Convert.FromBase64String(raw.Replace('-', '+').Replace('_', '/') + new string('=', (4 - raw.Length % 4) % 4)); + if (bytes.Length > MaximumDecodedLength || Base64Url(bytes) != raw) return false; + using var document = JsonDocument.Parse(StrictUtf8.GetString(bytes), Options); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.EnumerateObject().Select(property => property.Name).SequenceEqual(["v", "scope", "id"]) + || root.GetProperty("v").ValueKind != JsonValueKind.Number || !root.GetProperty("v").TryGetInt32(out var version) || version != 1 + || root.GetProperty("scope").ValueKind != JsonValueKind.String || root.GetProperty("id").ValueKind != JsonValueKind.String) + return false; + var scope = root.GetProperty("scope").GetString()!; + var text = root.GetProperty("id").GetString()!; + if (scope.Length != 64 || scope.Any(character => !char.IsAsciiHexDigit(character) || char.IsAsciiLetterUpper(character)) + || (expectedScope is not null && scope != expectedScope) + || !Guid.TryParseExact(text, "D", out var id) || id == Guid.Empty || id.Version != 7 || id.ToString("D") != text) + return false; + var anchor = MembershipId.From(id); + // Re-encoding also rejects alternate number spellings, whitespace and escaped canonical fields. + if (Encode(scope, anchor) != raw) return false; + after = anchor; + return true; + } + catch (Exception failure) when (failure is FormatException or JsonException or DecoderFallbackException or InvalidOperationException) + { + return false; + } + } + + private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Roles/RoleGrantCommandHandlers.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Roles/RoleGrantCommandHandlers.cs new file mode 100644 index 000000000..c6b0256c7 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Roles/RoleGrantCommandHandlers.cs @@ -0,0 +1,123 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Identity.Application.Abstractions; +using LearnStack.Modules.Identity.Application.Authorization; +using LearnStack.Modules.Identity.Application.Contracts.Roles; +using LearnStack.Modules.Identity.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identity; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Modules.Identity.Application.Roles; + +public sealed class ChangeRoleGrantsCommandHandler(IRoleWriteStore store, ITenantContext context, IIdentityMutationLocks locks, + IOperationInvocationContext invocations, Func authorization, IAuditSubject subject, IClock clock) + : RoleGrantCommandHandlerBase(store, context, locks, invocations, authorization, subject, clock), + IRequestHandler> +{ + public Task> Handle(ChangeRoleGrantsCommand request, CancellationToken cancellationToken) => ExecuteAsync(request, cancellationToken); +} + +public sealed class AdoptRoleGrantManifestCommandHandler(IRoleWriteStore store, ITenantContext context, IIdentityMutationLocks locks, + IOperationInvocationContext invocations, Func authorization, IAuditSubject subject, IClock clock) + : RoleGrantCommandHandlerBase(store, context, locks, invocations, authorization, subject, clock), + IRequestHandler> +{ + public Task> Handle(AdoptRoleGrantManifestCommand request, CancellationToken cancellationToken) => ExecuteAsync(request, cancellationToken); +} + +public abstract class RoleGrantCommandHandlerBase(IRoleWriteStore store, ITenantContext context, IIdentityMutationLocks locks, + IOperationInvocationContext invocations, Func authorization, IAuditSubject subject, IClock clock) +{ + protected async Task> ExecuteAsync(object request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + if (invocations.CurrentInvocation is not { IsActive: true, EntryKind: OperationEntryKind.Application } invocation + || !ReferenceEquals(invocation.Operation, request) + || invocations.CurrentDecision is not { Outcome: ProtectedOperationOutcome.Allowed, IsActive: true } admitted + || !ReferenceEquals(admitted.Invocation, invocation)) return Fail("lockey_forbidden"); + var gate = authorization(); + if (!gate.Matches(request, invocation)) return Fail("lockey_forbidden"); + var (rawId, version) = request switch + { + ChangeRoleGrantsCommand change => (change.RoleId, change.ExpectedRoleVersion), + AdoptRoleGrantManifestCommand adopt => (adopt.RoleId, adopt.ExpectedRoleVersion), + _ => throw new ArgumentException("Role grant request is invalid.", nameof(request)), + }; + if (!RoleGrantInput.ValidId(rawId) || version < 0) return Fail("lockey_validation_failed"); + if (request is AdoptRoleGrantManifestCommand manifest + && (manifest.ManifestVersion != IdentityFoundationManifest.Version1.Version + || manifest.ManifestFingerprint != IdentityFoundationManifest.Version1.Fingerprint)) return Fail("lockey_forbidden"); + if (request is ChangeRoleGrantsCommand { Grants.IsDefault: true }) return Fail("lockey_validation_failed"); + try + { + var id = RoleId.From(rawId); + var before = await store.FindAsync(id, cancellationToken); + if (!Visible(before)) return Fail("lockey_not_found"); + if (before!.Version != version) return Fail("lockey_concurrency_conflict"); + var desired = Desired(request, before); + var check = await gate.RecheckAsync(request, invocation, before, desired, cancellationToken); + if (check.Decision.Outcome != ProtectedOperationOutcome.Allowed) return Refused(gate, check); + var plan = new IdentityMutationLockPlan([], context.TenantId, RequiredMode(before, desired)); + await locks.AcquireAsync(plan, cancellationToken); + var target = await store.FindAsync(id, cancellationToken); + if (!Visible(target)) return Fail("lockey_not_found"); + if (target!.Version != version || target.BuiltInKey != before.BuiltInKey || target.PermissionScope != before.PermissionScope) + return Fail("lockey_concurrency_conflict"); + desired = Desired(request, target); + if (RequiredMode(target, desired) == IdentityTenantLockMode.Exclusive && plan.TenantMode != IdentityTenantLockMode.Exclusive) + return Fail("lockey_forbidden"); + check = await gate.RecheckAsync(request, invocation, target, desired, cancellationToken); + if (check.Decision.Outcome != ProtectedOperationOutcome.Allowed) return Refused(gate, check); + locks.RequireCurrent(plan); + gate.ReportRecheck(check); + subject.Designate(target); + var changed = target.ReplaceGrants(desired, clock.UtcNow, context.UserId!.Value); + if (changed.IsFailure) return Fail("lockey_forbidden"); + if (target.Version != version) + { + await store.UpdateAsync(target, cancellationToken); + await store.ValidateGraphAsync(cancellationToken); + } + return Result.Ok(target.Version); + } + catch (IdentityFoundationStorageException failure) + { + return Fail(failure.Failure == IdentityFoundationStorageFailure.Conflict ? "lockey_concurrency_conflict" + : failure.Failure == IdentityFoundationStorageFailure.Plan ? "lockey_dependency_unavailable" : "lockey_forbidden"); + } + } + + private bool Visible(Role? role) => role is { DeletedAt: null, DeletedBy: null } && role.TenantId == context.TenantId + && IdentityFoundationManifest.Version1.FindTemplate(role.BuiltInKey, role.PermissionScope) is not null; + + private static ImmutableArray Desired(object request, Role role) => request switch + { + ChangeRoleGrantsCommand change => change.Grants, + AdoptRoleGrantManifestCommand => IdentityFoundationManifest.Version1.FindTemplate(role.BuiltInKey, role.PermissionScope)!.Grants, + _ => throw new ArgumentException("Role grant request is invalid.", nameof(request)), + }; + + private static IdentityTenantLockMode RequiredMode(Role target, IEnumerable desired) => + target.BuiltInKey == BuiltInRoleKey.TenantAdmin || target.Grants.Any(grant => grant.PermissionKey == IdentityFoundationManifest.RoleAdmin) + || desired.Any(definition => definition?.Key == IdentityFoundationManifest.RoleAdmin) + ? IdentityTenantLockMode.Exclusive : IdentityTenantLockMode.Shared; + + private static Result Refused(RoleGrantAuthorization gate, RoleGrantAssessment check) + { + if (check.Decision.Outcome is ProtectedOperationOutcome.NotCovered or ProtectedOperationOutcome.Forbidden) gate.ReportRefusal(check); + return Fail(check.Decision.Outcome switch + { + ProtectedOperationOutcome.NotCovered => "lockey_not_found", + ProtectedOperationOutcome.Forbidden => "lockey_forbidden", + _ => "lockey_dependency_unavailable", + }); + } + + private static Result Fail(string key) => Result.Fail(new Error(LocalizedMessage.Of(key))); +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Roles/RoleGrantValidators.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Roles/RoleGrantValidators.cs new file mode 100644 index 000000000..8d11c546e --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/Roles/RoleGrantValidators.cs @@ -0,0 +1,34 @@ +using FluentValidation; +using LearnStack.Modules.Identity.Application.Contracts.Roles; +using LearnStack.Modules.Identity.Domain; + +namespace LearnStack.Modules.Identity.Application.Roles; + +public sealed class ChangeRoleGrantsCommandValidator : AbstractValidator +{ + public ChangeRoleGrantsCommandValidator() + { + RuleFor(request => request.RoleId).Must(RoleGrantInput.ValidId).WithErrorCode("lockey_invalid_value"); + RuleFor(request => request.ExpectedRoleVersion).GreaterThanOrEqualTo(0).WithErrorCode("lockey_concurrency_conflict"); + RuleFor(request => request.Grants).Must(grants => !grants.IsDefault && grants.Length <= IdentityFoundationManifest.Version1.Definitions.Length + && grants.All(definition => definition is not null) && grants.Distinct().Count() == grants.Length) + .WithErrorCode("lockey_invalid_value"); + } +} + +public sealed class AdoptRoleGrantManifestCommandValidator : AbstractValidator +{ + public AdoptRoleGrantManifestCommandValidator() + { + RuleFor(request => request.RoleId).Must(RoleGrantInput.ValidId).WithErrorCode("lockey_invalid_value"); + RuleFor(request => request.ExpectedRoleVersion).GreaterThanOrEqualTo(0).WithErrorCode("lockey_concurrency_conflict"); + RuleFor(request => request.ManifestVersion).GreaterThan(0).WithErrorCode("lockey_invalid_value"); + RuleFor(request => request.ManifestFingerprint).Must(value => value is { Length: 64 } + && value.All(character => character is >= '0' and <= '9' or >= 'a' and <= 'f')).WithErrorCode("lockey_invalid_value"); + } +} + +internal static class RoleGrantInput +{ + internal static bool ValidId(Guid value) => value != Guid.Empty && value.Version == 7; +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/Role.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/Role.cs index 87609c16e..f3b64cb73 100644 --- a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/Role.cs +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/Role.cs @@ -43,6 +43,35 @@ public bool MatchesInitialTemplate() && PermissionScope == PermissionScope.Tenant && IdentityFoundationManifest.Version1.TenantAdminKernel.All(definition => _grants.Any(grant => grant.PermissionScope == definition.Scope && grant.PermissionKey == definition.Key)); + + public bool CanReplaceGrants(IReadOnlyCollection definitions) + { + ArgumentNullException.ThrowIfNull(definitions); + var manifest = IdentityFoundationManifest.Version1; + return !IsDeleted && manifest.FindTemplate(BuiltInKey, PermissionScope) is not null + && definitions.Count <= manifest.Definitions.Length + && definitions.All(definition => definition is { IsActive: true } + && definition.Scope == PermissionScope && manifest.IsActive(definition.Scope, definition.Key) + && (definition.Key != IdentityFoundationManifest.RoleAdmin || BuiltInKey == BuiltInRoleKey.TenantAdmin)) + && definitions.Distinct().Count() == definitions.Count + && (BuiltInKey != BuiltInRoleKey.TenantAdmin || IsTenantAdministrator + && manifest.TenantAdminKernel.All(definition => definitions.Contains(definition))); + } + + /// Retains unchanged grant instances so EF tracks only the actual contained additions/removals. + public Result ReplaceGrants(IReadOnlyCollection definitions, DateTimeOffset at, UserId actor) + { + if (!CanReplaceGrants(definitions)) return IdentityFailures.BusinessRule(); + if (_grants.Count == definitions.Count && definitions.All(definition => _grants.Any(grant => + grant.PermissionScope == definition.Scope && grant.PermissionKey == definition.Key))) + return Result.Ok(None.Value); + MarkUpdated(at, actor); + _grants.RemoveAll(grant => !definitions.Any(definition => definition.Scope == grant.PermissionScope && definition.Key == grant.PermissionKey)); + foreach (var definition in definitions) + if (!_grants.Any(grant => grant.PermissionScope == definition.Scope && grant.PermissionKey == definition.Key)) + _grants.Add(new RolePermissionGrant(this, definition)); + return Result.Ok(None.Value); + } } [TenantOwned] diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Authorization/IdentityAuthorizationRegistration.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Authorization/IdentityAuthorizationRegistration.cs new file mode 100644 index 000000000..30fc8f0ad --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Authorization/IdentityAuthorizationRegistration.cs @@ -0,0 +1,36 @@ +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.Modules.Identity.Application; +using LearnStack.Modules.Identity.Application.Abstractions; +using LearnStack.Modules.Identity.Application.Authorization; +using LearnStack.Modules.Identity.Infrastructure.Persistence; +using LearnStack.SharedKernel.Permissions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LearnStack.Modules.Identity.Infrastructure.Authorization; + +public static class IdentityAuthorizationRegistration +{ + /// Registers the confined reader; source qualification and production admission are separate composition acts. + public static IServiceCollection AddIdentityAuthorizationReader(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped>(provider => () => provider.GetRequiredService()); + return services; + } + + /// Staged internal policy and directory adapters; installs no HTTP or native operation. + public static IServiceCollection AddIdentityOperationPolicies(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddScoped(provider => new IdentityOperationAuthorizer( + () => provider.GetRequiredService(), provider.GetRequiredService())); + services.TryAddScoped(); + services.TryAddScoped>(provider => () => provider.GetRequiredService()); + services.TryAddScoped(); + return services; + } +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Authorization/PreContextIdentityAuthorizationSessionFactory.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Authorization/PreContextIdentityAuthorizationSessionFactory.cs new file mode 100644 index 000000000..2afcc02cc --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Authorization/PreContextIdentityAuthorizationSessionFactory.cs @@ -0,0 +1,196 @@ +using System.Collections.Immutable; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Application.Abstractions; +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.Modules.Identity.Domain; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using Npgsql; +using NpgsqlTypes; + +namespace LearnStack.Modules.Identity.Infrastructure.Authorization; + +/// Owns the exact Identity statement and physical candidate-bound child; module orchestration stays in Application. +public sealed class PreContextIdentityAuthorizationSessionFactory : IIdentityAuthorizationSessionFactory +{ + private readonly IdentityAuthorizationReadSource _source; + + public PreContextIdentityAuthorizationSessionFactory(IdentityAuthorizationReadSource source) => + _source = source ?? throw new ArgumentNullException(nameof(source)); + + private static readonly string[] TenantRoleKeys = RoleKeys(PermissionScope.Tenant); + private static readonly string[] OrganizationRoleKeys = RoleKeys(PermissionScope.Organization); + private static readonly string[] TenantPermissionKeys = PermissionKeys(PermissionScope.Tenant); + private static readonly string[] OrganizationPermissionKeys = PermissionKeys(PermissionScope.Organization); + + // The two small over-read bounds detect excess facts; neither silently truncates authority. + private const string AuthorizationSql = """ + WITH eligible_memberships AS MATERIALIZED ( + SELECT member.id, member.organization_id + FROM public.memberships AS member + WHERE member.tenant_id = @tenant AND member.user_id = @actor + AND member.status = 'Active' AND member.deleted_at IS NULL AND member.deleted_by IS NULL + AND (member.organization_id IS NULL OR member.organization_id = @organization) + ORDER BY member.id + LIMIT 3 + ), eligible_roles AS MATERIALIZED ( + SELECT assignment.membership_id, role.id, role.built_in_key, role.permission_scope + FROM eligible_memberships AS member + JOIN public.membership_role_assignments AS assignment + ON assignment.tenant_id = @tenant AND assignment.membership_id = member.id + AND assignment.organization_id IS NOT DISTINCT FROM member.organization_id + JOIN public.roles AS role + ON role.tenant_id = @tenant AND role.id = assignment.role_id AND role.deleted_at IS NULL AND role.deleted_by IS NULL + WHERE (member.organization_id IS NULL AND role.permission_scope = 'Tenant' + AND role.built_in_key = ANY(@tenant_roles)) + OR (member.organization_id IS NOT NULL AND role.permission_scope = 'Organization' + AND role.built_in_key = ANY(@organization_roles)) + ORDER BY role.id, assignment.membership_id + LIMIT 9 + ) + SELECT member.id, member.organization_id, role.id, role.built_in_key, role.permission_scope, + grant_row.permission_scope, grant_row.permission_key, + current_setting('transaction_read_only') = 'on' + AND current_setting('transaction_isolation') = 'read committed' + AND current_user = 'learnstack_app' + AND NULLIF(current_setting('app.tenant_id', true), '')::uuid = @tenant + AND NULLIF(current_setting('app.organization_id', true), '')::uuid IS NOT DISTINCT FROM @organization + AND NULLIF(current_setting('app.scope', true), '') IS NULL + AND NULLIF(current_setting('app.resolving_host', true), '') IS NULL + AND NULLIF(current_setting('app.identity_authority_id', true), '') IS NULL + AND NULLIF(current_setting('app.identity_subject_key', true), '') IS NULL + FROM (VALUES (1)) AS query_anchor(value) + LEFT JOIN eligible_memberships AS member ON true + LEFT JOIN eligible_roles AS role ON role.membership_id = member.id + LEFT JOIN public.role_permission_grants AS grant_row + ON grant_row.tenant_id = @tenant AND grant_row.role_id = role.id + AND grant_row.permission_scope = role.permission_scope + AND ((grant_row.permission_scope = 'Tenant' AND grant_row.permission_key = ANY(@tenant_permissions)) + OR (grant_row.permission_scope = 'Organization' AND grant_row.permission_key = ANY(@organization_permissions))) + ORDER BY member.id, role.id, grant_row.permission_scope, grant_row.permission_key + """; + + public async Task OpenAsync(AuthorizationCandidate candidate, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(candidate); + var owner = await _source.OpenAsync(candidate, cancellationToken); + return owner is null ? null : new AuthorizationSession(owner, candidate); + } + + private static async Task> ReadMembershipsAsync( + AuthorizationReadOwner owner, AuthorizationCandidate candidate, CancellationToken cancellationToken) + { + NpgsqlCommand? command = null; + NpgsqlDataReader? rows = null; + try + { + command = new NpgsqlCommand(AuthorizationSql, (NpgsqlConnection)owner.Unit.Connection, + (NpgsqlTransaction)owner.Unit.Transaction!) + { CommandTimeout = 5 }; + command.Parameters.AddWithValue("tenant", candidate.TenantId.Value); + command.Parameters.AddWithValue("actor", candidate.Actor.Value); + command.Parameters.AddWithValue("organization", NpgsqlDbType.Uuid, + candidate.OrganizationId is { } organization ? organization.Value : DBNull.Value); + command.Parameters.AddWithValue("tenant_roles", TenantRoleKeys); + command.Parameters.AddWithValue("organization_roles", OrganizationRoleKeys); + command.Parameters.AddWithValue("tenant_permissions", TenantPermissionKeys); + command.Parameters.AddWithValue("organization_permissions", OrganizationPermissionKeys); + rows = await command.ExecuteReaderAsync(cancellationToken); + var memberships = new Dictionary(); + var roleIds = new HashSet(); + while (await rows.ReadAsync(cancellationToken)) + { + if (rows.IsDBNull(7) || !rows.GetBoolean(7)) throw Unavailable(); + if (rows.IsDBNull(0)) continue; + var membershipId = rows.GetGuid(0); + if (!memberships.TryGetValue(membershipId, out var membership)) + { + membership = new(membershipId, rows.IsDBNull(1) ? null : OrganizationId.From(rows.GetGuid(1))); + if (memberships.Values.Any(existing => existing.Organization == membership.Organization)) throw Unavailable(); + memberships.Add(membershipId, membership); + if (memberships.Count > 2) throw Unavailable(); + } + if (rows.IsDBNull(2)) continue; + var roleId = rows.GetGuid(2); + if (!membership.Roles.TryGetValue(roleId, out var role)) + { + if (!Enum.TryParse(rows.GetString(3), out var key) + || !Enum.TryParse(rows.GetString(4), out var scope) + || IdentityFoundationManifest.Version1.FindTemplate(key, scope) is null) throw Unavailable(); + if (membership.Roles.Values.Any(existing => existing.Key == key && existing.Scope == scope)) throw Unavailable(); + role = new(roleId, key, scope); + membership.Roles.Add(roleId, role); + roleIds.Add(roleId); + if (roleIds.Count > 8) throw Unavailable(); + } + if (rows.IsDBNull(5)) continue; + var permissionKey = rows.GetString(6); + if (rows.GetString(5) != role.Scope.ToString() + || !IdentityFoundationManifest.Version1.IsActive(role.Scope, permissionKey)) throw Unavailable(); + role.Grants.Add(new(role.Scope, permissionKey)); + } + return [.. memberships.Values.OrderBy(member => member.Id).Select(member => new AuthorizationMembershipFacts( + member.Id, member.Organization, [.. member.Roles.Values.OrderBy(role => role.Id).Select(role => new AuthorizationRoleFacts( + role.Id, role.Key, role.Scope, [.. role.Grants.OrderBy(grant => grant.Key, StringComparer.Ordinal)]))]))]; + } + finally + { + var readerDisposed = await TryDisposeAsync(rows); + var commandDisposed = await TryDisposeAsync(command); + if (!readerDisposed || !commandDisposed) owner.MarkCleanupFailed(); + } + } + + private static InfrastructureException Unavailable() => new("Identity authorization is unavailable."); + + private static string[] RoleKeys(PermissionScope scope) => [.. IdentityFoundationManifest.Version1.Templates + .Where(template => template.Scope == scope).Select(template => template.BuiltInKey.ToString())]; + + private static string[] PermissionKeys(PermissionScope scope) => [.. IdentityFoundationManifest.Version1.Definitions + .Where(definition => definition.IsActive && definition.Scope == scope).Select(definition => definition.Key)]; + + private static async ValueTask TryDisposeAsync(IAsyncDisposable? resource) + { + if (resource is null) return true; + try { await resource.DisposeAsync(); return true; } + catch (Exception exception) when (exception is not (OutOfMemoryException or StackOverflowException)) { return false; } + } + + private sealed class MembershipFacts(Guid id, OrganizationId? organization) + { + public Guid Id { get; } = id; + public OrganizationId? Organization { get; } = organization; + public Dictionary Roles { get; } = []; + } + + private sealed class RoleFacts(Guid id, BuiltInRoleKey key, PermissionScope scope) + { + public Guid Id { get; } = id; + public BuiltInRoleKey Key { get; } = key; + public PermissionScope Scope { get; } = scope; + public HashSet Grants { get; } = []; + } + + private sealed class AuthorizationSession : IIdentityAuthorizationSession + { + private readonly AuthorizationReadOwner _owner; + private readonly AuthorizationCandidate _candidate; + + public AuthorizationSession(AuthorizationReadOwner owner, AuthorizationCandidate candidate) + { + _owner = owner; + _candidate = candidate; + } + + public IUnitOfWork Unit => _owner.Unit; + public CancellationToken DeadlineToken => _owner.DeadlineToken; + public Task> ReadMembershipsAsync(CancellationToken cancellationToken = default) => + PreContextIdentityAuthorizationSessionFactory.ReadMembershipsAsync(_owner, _candidate, cancellationToken); + public Task CompleteAsync() => _owner.CompleteAsync(); + public void MarkCleanupFailed() => _owner.MarkCleanupFailed(); + public ValueTask DisposeAsync() => _owner.DisposeAsync(); + } +} diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Bindings/IdentityAuthorityQualification.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Bindings/IdentityAuthorityQualification.cs index 8b15caa41..6dc5add40 100644 --- a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Bindings/IdentityAuthorityQualification.cs +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Bindings/IdentityAuthorityQualification.cs @@ -1,3 +1,4 @@ +using LearnStack.Infrastructure.Persistence; using System.Data; using Npgsql; @@ -13,6 +14,14 @@ public sealed class IdentityAuthorityQualification( public IdentityAuthorityRegistration? Current => Volatile.Read(ref _current); + public string? QualifiedDatabaseName { get; private set; } + + public Task OpenDatabaseQualificationProofAsync(CancellationToken cancellationToken = default) + { + if (Current is null) throw Unavailable(); + return DatabaseQualificationProof.OpenAsync(dataSource.Value, cancellationToken); + } + public Task QualifyAsync(CancellationToken cancellationToken = default) { lock (_gate) return _qualification ??= QualifyCoreAsync(cancellationToken); @@ -27,8 +36,9 @@ private async Task QualifyCoreAsync(CancellationToken cancellationToken) await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken); await using (var readOnly = new NpgsqlCommand("SET TRANSACTION READ ONLY", connection, transaction)) await readOnly.ExecuteNonQueryAsync(cancellationToken); + string database; await using (var command = new NpgsqlCommand( - "SELECT current_user, authority_id, issuer FROM public.identity_authority WHERE id = 1", connection, transaction)) + "SELECT current_user, authority_id, issuer, current_database() FROM public.identity_authority WHERE id = 1", connection, transaction)) await using (var reader = await command.ExecuteReaderAsync(cancellationToken)) { if (!await reader.ReadAsync(cancellationToken) @@ -36,8 +46,10 @@ private async Task QualifyCoreAsync(CancellationToken cancellationToken) || reader.GetGuid(1) != registration.AuthorityId.Value || !string.Equals(reader.GetString(2), registration.Issuer, StringComparison.Ordinal)) throw Unavailable(); + database = reader.GetString(3); } await transaction.CommitAsync(cancellationToken); + QualifiedDatabaseName = database; Volatile.Write(ref _current, registration); } catch (NpgsqlException) diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Bindings/IdentityFoundationRegistration.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Bindings/IdentityFoundationRegistration.cs index f39fbe8e9..3d9612790 100644 --- a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Bindings/IdentityFoundationRegistration.cs +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Bindings/IdentityFoundationRegistration.cs @@ -2,6 +2,7 @@ using LearnStack.Modules.Identity.Application.Abstractions; using LearnStack.Modules.Identity.Application.Audit; using LearnStack.Modules.Identity.Application.Bindings; +using LearnStack.Modules.Identity.Application.Authorization; using LearnStack.Modules.Identity.Application.Contracts.Bindings; using LearnStack.Modules.Identity.Application.Contracts.Seeding; using LearnStack.Modules.Identity.Infrastructure.Persistence; @@ -25,6 +26,8 @@ public static IServiceCollection AddIdentityContextualFoundation(this IServiceCo services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped>(provider => () => provider.GetRequiredService()); return services; } diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Persistence/ContextualIdentityStores.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Persistence/ContextualIdentityStores.cs index caf5f06da..d0b0fce88 100644 --- a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Persistence/ContextualIdentityStores.cs +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Persistence/ContextualIdentityStores.cs @@ -6,7 +6,9 @@ using LearnStack.Modules.Identity.Domain; using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Errors; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Npgsql; namespace LearnStack.Modules.Identity.Infrastructure.Persistence; @@ -21,11 +23,30 @@ public void PrepareFreshRead() { if (_validated || db.ChangeTracker.Entries().Any(entry => entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)) throw new InvalidOperationException("An Identity graph cannot be reread after mutation or validation."); + EnrollCurrentTenantOwner(db, unit); // Pre/post-wait reads must materialize fresh facts; a cached tracked entity // would retain the earlier classification even after SQL returned new values. db.ChangeTracker.Clear(); } + internal static void EnrollCurrentTenantOwner(IdentityDbContext context, IUnitOfWork owner) + { + // Binding lookup may have created this scoped model on an earlier global + // owner. Re-enroll only the same connection's already-announced tenant + // transaction; this supplies no context-setting or read-scope authority. + if (!owner.HasActiveTransaction || owner.IsRollbackOnly + || owner is IGlobalIdentityUnitOfWork { IsGlobalIdentityTransaction: true } + || !owner.IsTenantContextIssuedOn(owner.Transaction) + || owner.GetTenantContextAnnouncement(owner.Transaction) is not { Generation: > 0 } announcement + || announcement.TenantId != context.CurrentTenantId || announcement.OrganizationId != context.CurrentOrganizationId + || !ReferenceEquals(context.Database.GetDbConnection(), owner.Connection) + || context.ChangeTracker.Entries().Any(entry => entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)) + throw new TenantContextMissingException("Identity storage requires its clean announced tenant owner."); + if (context.Database.CurrentTransaction is not { } current + || !ReferenceEquals(current.GetDbTransaction(), owner.Transaction)) + context.Database.UseTransaction(owner.Transaction); + } + internal static async Task ReadAsync(Func> read) { // Only provider failures cross this boundary. Cancellation and source @@ -164,7 +185,20 @@ public Task AddAsync(Role aggregate, CancellationToken cancellationToken = defau } public Task UpdateAsync(Role aggregate, CancellationToken cancellationToken = default) { - WriteStoreTracking.EnsureTracked(db, aggregate); + // The database keeps its restrictive parent FK. Explicitly mark only + // removed tracked children before EF detects the severed required link; + // retained instances stay tracked and new children are discovered at save. + var detectChanges = db.ChangeTracker.AutoDetectChangesEnabled; + try + { + db.ChangeTracker.AutoDetectChangesEnabled = false; + WriteStoreTracking.EnsureTracked(db, aggregate); + foreach (var grant in db.ChangeTracker.Entries().Where(entry => + entry.Entity.TenantId == aggregate.TenantId && entry.Entity.RoleId == aggregate.Id + && !aggregate.Grants.Contains(entry.Entity)).ToArray()) + grant.State = EntityState.Deleted; + } + finally { db.ChangeTracker.AutoDetectChangesEnabled = detectChanges; } return control.SaveAsync(cancellationToken); } } diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Persistence/MembershipDirectoryReader.cs b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Persistence/MembershipDirectoryReader.cs new file mode 100644 index 000000000..4fba5b404 --- /dev/null +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Persistence/MembershipDirectoryReader.cs @@ -0,0 +1,48 @@ +using LearnStack.Modules.Identity.Application.Abstractions; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Domain; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Identity.Infrastructure.Persistence; + +/// Uses the real model filters and physical tenant-read owner; no filter bypass or synthetic scope. +public sealed class MembershipDirectoryReader(IdentityDbContext db, IUnitOfWork unit, ITenantContext context) + : IMembershipDirectoryReader +{ + public async Task> ReadAsync(MembershipId? after, int take, + CancellationToken cancellationToken = default) + { + if (take is < 1 or > 101) throw new ArgumentOutOfRangeException(nameof(take)); + Admit(); + // Keep the seek on the converted UUID in SQL; the resulting entity source + // still composes with the real model filters and the six-field projection. + var source = after is { } anchor + ? db.Memberships.FromSqlInterpolated($"SELECT * FROM memberships WHERE id > {anchor.Value}") + : db.Memberships; + var rows = source.AsNoTracking().Where(member => member.TenantId == context.TenantId + && member.DeletedAt == null && (member.Status == MembershipStatus.Active || member.Status == MembershipStatus.Suspended)); + var result = await ContextualIdentityStorageControl.ReadAsync(() => rows.OrderBy(member => member.Id).Take(take) + .Select(member => new MembershipDirectoryItem(member.Id.Value, member.UserId, member.OrganizationId, + member.Status == MembershipStatus.Active ? DirectoryMembershipStatus.Active : DirectoryMembershipStatus.Suspended, + member.Profile.DisplayName, member.Profile.PreferredLocale)).ToArrayAsync(cancellationToken)); + Admit(); + return result; + } + + private void Admit() + { + if (!context.IsResolved || context.TenantId == TenantId.PlatformSentinel || context.UserId is null + || context.UserId == UserId.SystemActor || unit is not IOperationReadScopeView { IsTenantWideRead: true } + || unit.Mode != TransactionMode.ReadWrite || !unit.HasActiveTransaction || unit.IsRollbackOnly + || !unit.IsTenantContextIssuedOn(unit.Transaction) + || unit.GetTenantContextAnnouncement(unit.Transaction) is not { Generation: > 0 } announcement + || announcement.TenantId != context.TenantId || announcement.OrganizationId != context.OrganizationId) + throw new TenantContextMissingException("The membership directory requires its announced ordinary tenant-read owner."); + ContextualIdentityStorageControl.EnrollCurrentTenantOwner(db, unit); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/IdentityLifecycle/IIdentityScopeLifecycleReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/IdentityLifecycle/IIdentityScopeLifecycleReader.cs index 49153aba7..ec77aff8e 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/IdentityLifecycle/IIdentityScopeLifecycleReader.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/IdentityLifecycle/IIdentityScopeLifecycleReader.cs @@ -1,4 +1,7 @@ +using LearnStack.SharedKernel.Errors; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; namespace LearnStack.Modules.Tenancy.Application.Contracts.IdentityLifecycle; @@ -13,4 +16,15 @@ public interface IIdentityScopeLifecycleReader { Task ReadAsync(TenantId tenantId, OrganizationId? organizationId, CancellationToken cancellationToken = default); + + /// Reads on the authorization source's actual isolated child; it never uses the ambient business owner. + Task ReadAuthorizationAsync(IUnitOfWork child, AuthorizationCandidate candidate, + CancellationToken cancellationToken = default); +} + +/// A payload-free authorization read failure; failed actual disposal must retain its source owner. +public sealed class IdentityScopeLifecycleReadException(bool cleanupFailed) + : InfrastructureException("Identity scope lifecycle is unavailable.") +{ + public bool CleanupFailed { get; } = cleanupFailed; } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs index e845e3b4a..069648c7d 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs @@ -39,6 +39,7 @@ public sealed class TenancyAuditCatalogSource : IAuditCatalogSource /// public void Describe(IAuditCatalogBuilder builder) { + builder.DeclareOffPath("tenancy.assertion.reject_claim_context", OperationType.SecurityEvent, OperationClass.Must, entityType: null); ArgumentNullException.ThrowIfNull(builder); builder.Off(); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Authorization/TenancyOperationPolicySource.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Authorization/TenancyOperationPolicySource.cs new file mode 100644 index 000000000..2474b94dd --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Authorization/TenancyOperationPolicySource.cs @@ -0,0 +1,31 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.Account; +using LearnStack.Modules.Tenancy.Application.Contracts.Branding; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Permissions; + +namespace LearnStack.Modules.Tenancy.Application.Authorization; + +public sealed class TenancyOperationPolicySource : IOperationPolicySource +{ + public const string AccountSiteOperation = "tenancy.account_site.read"; + public static PermissionDefinition PublicSite { get; } = new(PermissionScope.Tenant, "tenancy.public_site.read"); + public IEnumerable Definitions => [new(PublicSite, Assignable: false)]; + public IEnumerable Policies => + [ + OperationPolicy.ForPublic("tenancy.public_site.read", PublicSite), + OperationPolicy.ForAccount(AccountSiteOperation), + OperationPolicy.ForTrusted("tenancy.seed.read_tenant", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tenancy.seed.read_organization", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tenancy.seed.read_host", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tenancy.seed.read_setting", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tenancy.seed.provision_tenant", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tenancy.seed.create_organization", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tenancy.seed.map_host", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tenancy.seed.add_locale", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tenancy.seed.set_locale", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tenancy.seed.set_branding", AuthorizationSourceKind.Seed), + ]; +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/IdentityScopeLifecycleReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/IdentityScopeLifecycleReader.cs index da6cdf09e..c894594f6 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/IdentityScopeLifecycleReader.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/IdentityScopeLifecycleReader.cs @@ -2,6 +2,7 @@ using LearnStack.Modules.Tenancy.Application.Contracts.IdentityLifecycle; using LearnStack.SharedKernel.Errors; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Persistence; using Npgsql; using NpgsqlTypes; @@ -11,6 +12,22 @@ namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; /// One uncached statement over Tenancy's own tables, on the caller's announced app transaction. public sealed class IdentityScopeLifecycleReader(IUnitOfWork unit) : IIdentityScopeLifecycleReader { + private const string LifecycleSql = """ + SELECT t.id IS NOT NULL AND t.deleted_at IS NULL, + t.id IS NOT NULL AND t.deleted_at IS NULL AND t.status IN ('Trial', 'Active'), + o.id IS NOT NULL, + o.id IS NOT NULL AND o.deleted_at IS NULL AND o.status = 'Active', + current_setting('transaction_isolation') = 'read committed', + current_setting('transaction_read_only') = 'on', + NULLIF(current_setting('app.tenant_id', true), '')::uuid = @tenant + AND NULLIF(current_setting('app.organization_id', true), '')::uuid IS NOT DISTINCT FROM @organization + AND NULLIF(current_setting('app.scope', true), '') IS NULL + FROM (VALUES (1)) AS query_anchor(value) + LEFT JOIN public.tenants AS t ON t.id = @tenant + LEFT JOIN public.organizations AS o + ON o.tenant_id = t.id AND o.id = @organization + """; + public async Task ReadAsync(TenantId tenantId, OrganizationId? organizationId, CancellationToken cancellationToken = default) { @@ -23,17 +40,9 @@ public async Task ReadAsync(TenantId tenantId, Organizat try { - await using var command = new NpgsqlCommand(""" - SELECT t.deleted_at IS NULL, - t.deleted_at IS NULL AND t.status IN ('Trial', 'Active'), - o.id IS NOT NULL, - o.id IS NOT NULL AND o.deleted_at IS NULL AND o.status = 'Active', - current_setting('transaction_isolation') = 'read committed' - FROM public.tenants AS t - LEFT JOIN public.organizations AS o - ON o.tenant_id = t.id AND o.id = @organization - WHERE t.id = @tenant - """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)transaction) { CommandTimeout = 10 }; + await using var command = new NpgsqlCommand(LifecycleSql, + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)transaction) + { CommandTimeout = 10 }; command.Parameters.AddWithValue("tenant", tenantId.Value); command.Parameters.AddWithValue("organization", NpgsqlDbType.Uuid, organizationId is { } org ? org.Value : DBNull.Value); @@ -47,4 +56,54 @@ LEFT JOIN public.organizations AS o throw new InfrastructureException("Identity scope lifecycle is unavailable."); } } + + public async Task ReadAuthorizationAsync(IUnitOfWork child, AuthorizationCandidate candidate, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(child); + ArgumentNullException.ThrowIfNull(candidate); + if (!candidate.IsActive || child.Mode != TransactionMode.ReadOnly || !child.HasActiveTransaction + || child.IsRollbackOnly || child.Transaction is not { IsolationLevel: IsolationLevel.ReadCommitted } transaction + || child.GetTenantContextAnnouncement(transaction) is not { Generation: > 0 } announced + || announced.TenantId != candidate.TenantId || announced.OrganizationId != candidate.OrganizationId) + throw new IdentityScopeLifecycleReadException(false); + + NpgsqlCommand? command = null; + NpgsqlDataReader? reader = null; + IdentityScopeLifecycle? facts = null; + var failed = false; + try + { + command = new NpgsqlCommand(LifecycleSql, (NpgsqlConnection)child.Connection, + (NpgsqlTransaction)transaction) + { CommandTimeout = 5 }; + command.Parameters.AddWithValue("tenant", candidate.TenantId.Value); + command.Parameters.AddWithValue("organization", NpgsqlDbType.Uuid, + candidate.OrganizationId is { } organization ? organization.Value : DBNull.Value); + reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) facts = new(false, false, false, false); + else if (!reader.GetBoolean(4) || !reader.GetBoolean(5) || reader.IsDBNull(6) || !reader.GetBoolean(6)) failed = true; + else facts = new(reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3)); + if (!candidate.IsActive || child.GetTenantContextAnnouncement(transaction) != announced) failed = true; + } + catch (Exception exception) when (exception is not (OutOfMemoryException or StackOverflowException)) + { + // No provider payload crosses the module boundary, including disposal failures. + failed = true; + } + + var readerDisposed = await TryDisposeAsync(reader); + var commandDisposed = await TryDisposeAsync(command); + if (!readerDisposed || !commandDisposed) throw new IdentityScopeLifecycleReadException(true); + cancellationToken.ThrowIfCancellationRequested(); + if (failed || facts is null) throw new IdentityScopeLifecycleReadException(false); + return facts; + } + + private static async ValueTask TryDisposeAsync(IAsyncDisposable? resource) + { + if (resource is null) return true; + try { await resource.DisposeAsync(); return true; } + catch (Exception exception) when (exception is not (OutOfMemoryException or StackOverflowException)) { return false; } + } } diff --git a/backend/tests/AuthorizationEvidenceProof/AuthorizationBarrierHarness.cs b/backend/tests/AuthorizationEvidenceProof/AuthorizationBarrierHarness.cs new file mode 100644 index 000000000..8964317e6 --- /dev/null +++ b/backend/tests/AuthorizationEvidenceProof/AuthorizationBarrierHarness.cs @@ -0,0 +1,368 @@ +using System.Data.Common; +using System.Diagnostics.Metrics; +using System.Text.Json; +using LearnStack.Application.Audit; +using LearnStack.Application.Authorization; +using LearnStack.Application.Messaging; +using LearnStack.Application.Persistence; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Audit; +using LearnStack.Infrastructure.Audit.Capture; +using LearnStack.Infrastructure.Caching; +using LearnStack.Infrastructure.Jobs; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; + +namespace LearnStack.Tests.AuthorizationEvidenceProof; + +public enum ResourcePopulation { Off, Should, May, OverrideOff, Plural } + +/// +/// Mechanism proof only: fixture policies supply bounded decisions, while the actual +/// common audit/operation/transaction owners, module entity, RLS, store and evidence +/// source enforce durability. These fixtures claim no production permission coverage. +/// Linked into a separate killable process so a finally cannot rescue its rows. +/// +internal sealed class AuthorizationBarrierHarness : IAsyncDisposable +{ + private readonly NpgsqlDataSource _business; + private readonly AuthorizationEvidenceSource _source; + private readonly NpgsqlUnitOfWork _physical; + private readonly CandidateSource _candidates; + private readonly Meters _meters = new(); + private readonly DomainEventCollector _collector; + private readonly ServiceProvider _contexts; + private readonly AuthorizationEvidenceCoordinator _evidence; + private readonly OperationAuthorizationCoordinator _authorization; + private readonly AuditExecutionCoordinator _audit; + private readonly TransactionExecutionCoordinator _transactions; + private readonly TenantContext _context; + private readonly IClock _clock = new SystemClock(); + private readonly CancellationTokenSource _innerCancellation = new(); + + private AuthorizationBarrierHarness(NpgsqlDataSource business, AuthorizationEvidenceSource source, + TenantContext context, ResourcePopulation population, bool compositionFault, + Func? commitBoundary, TenantSettingId? businessId) + { + _business = business; + _source = source; + _context = context; + BusinessId = businessId ?? TenantSettingId.From(Guid.CreateVersion7()); + _candidates = new CandidateSource(context); + _physical = new NpgsqlUnitOfWork(business, NullLogger.Instance); + Unit = commitBoundary is null ? _physical : new BoundaryUnit(_physical, commitBoundary); + Factory = new ControlledFactory(source); + Health = new AuditHealth(); + Capture = new AuditStateCapture(); + var catalog = new AuditCatalog([new Catalog(population)]); + var store = new PostgresAuditStore(Capture, new Lazy(() => business), + StoreLogs, _meters, Health, catalog, new StaticTenantContextAccessor(context), new GlobalIdentityEnrollment(), Factory); + Store = new ObservedStore(store, Capture); + OperationAuthorizationCoordinator? authorization = null; + _evidence = new AuthorizationEvidenceCoordinator(Store, Capture, Unit, _clock, new SystemGuidFactory(), + () => authorization ?? throw new InvalidOperationException("The proof invocation owner is unavailable."), + compositionFault ? [new FaultingTransport()] : [], CompositionLogs); + _authorization = authorization = new OperationAuthorizationCoordinator(new OperationPolicyCatalog([new PolicySource()]), + new Decisions(this), context, [_candidates], [], _evidence); + var producer = new ProducerExecution(Unit); + _collector = new DomainEventCollector(producer, new EmptyPublisher()); + _transactions = new TransactionExecutionCoordinator(Unit, context, Store, Capture, producer, _collector, + NullLogger.Instance, authorization, _evidence); + var cache = new InMemoryCacheService(_clock, _meters); + if (population == ResourcePopulation.OverrideOff) + cache.SetAsync>(AuditConfigService.CacheKeyFor(context.TenantId), + new Dictionary(StringComparer.Ordinal) { ["proof|proof.inner.read"] = false }).GetAwaiter().GetResult(); + var classifier = new AuditConfigService(cache, new Lazy(() => business), + NullLogger.Instance, new StaticTenantContextAccessor(context)); + _audit = new AuditExecutionCoordinator(catalog, classifier, Capture, Store, context, _clock, + new SystemGuidFactory(), NullLogger.Instance, Unit, _evidence); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Unit); + services.AddSingleton(new StaticTenantContextAccessor(context)); + services.AddSingleton(new AuditChangeTrackerInterceptor(Capture, Unit)); + services.AddModuleDbContext(); + _contexts = services.BuildServiceProvider(); + } + + internal IUnitOfWork Unit { get; } + internal AuditStateCapture Capture { get; } + internal AuditHealth Health { get; } + internal ControlledFactory Factory { get; } + internal ObservedStore Store { get; } + internal ProofLogs StoreLogs { get; } = new(); + internal ProofLogs CompositionLogs { get; } = new(); + internal ProtectedOperationOutcome InnerOutcome { get; set; } = ProtectedOperationOutcome.Forbidden; + internal TenantSettingId BusinessId { get; } + internal Result? InnerResult { get; private set; } + internal bool DeniedHandlerEntered { get; private set; } + internal bool CancelInnerBeforeObservation { get; set; } + internal bool InnerAuthorityRevoked { get; private set; } + internal bool OuterAuthorityStillLive { get; private set; } + internal bool InnerCancellationRequested => _innerCancellation.IsCancellationRequested; + + internal static async Task CreateAsync(string connectionString, IdentityAuthorityId authority, + string issuer, TenantId tenant, OrganizationId organization, UserId actor, string correlation, + ResourcePopulation population = ResourcePopulation.Off, bool compositionFault = false, + Func? commitBoundary = null, TenantSettingId? businessId = null) + { + var context = TenantContextFactory.Create(new TenantResolutionAttempt + { + HasValidatedPrincipal = true, + UserId = actor, + HostTenantId = tenant, + HostOrganizationId = organization, + ClaimTenantId = tenant, + ClaimOrganizationId = organization, + MembershipCovers = true, + ClaimedOrganizationBelongsToTenant = true, + CorrelationId = correlation, + }).Value ?? throw new InvalidOperationException("The fixture scope was refused."); + var business = ApplicationDataSource.Build(connectionString); + var source = new AuthorizationEvidenceSource(connectionString); + try + { + await source.QualifyAsync(authority, issuer); + return new(business, source, context, population, compositionFault, commitBoundary, businessId); + } + catch { await source.DisposeAsync(); await business.DisposeAsync(); throw; } + } + + internal Task> RunAsync(TransactionMode? mode = TransactionMode.ReadWrite) => + _audit.ExecuteAsync(typeof(Outer), null, () => _authorization.ExecuteRequestAsync>(new Outer(), + () => mode is { } transactionMode + ? _transactions.ExecuteAsync(null, transactionMode, "evidence-mechanism-proof", Handler, CancellationToken.None) + : Handler()), CancellationToken.None); + + internal Task> RefuseAsync() => DenyAsync(); + + private async Task> Handler() + { + if (Unit.Mode == TransactionMode.ReadWrite) + { + var db = _contexts.GetRequiredService(); + var setting = TenantSetting.Create(BusinessId, _context.TenantId, _context.OrganizationId, + "evidence-proof." + BusinessId.Value.ToString("N"), JsonSerializer.Serialize(_context.CorrelationId), + _clock, _context.UserId!.Value); + db.TenantSettings.Add(setting); + await db.SaveChangesAsync(); + } + else if (Unit.Mode == TransactionMode.ReadOnly) + { + var db = _contexts.GetRequiredService(); + _ = await db.TenantSettings.CountAsync(); + } + InnerResult = await DenyAsync(); + OuterAuthorityStillLive = _authorization.CurrentInvocation is { IsActive: true } current + && current.Policy.RequestType == typeof(Outer); + return Result.Ok(None.Value); + } + + private Task> DenyAsync() + { + var request = new Inner(); + var token = CancelInnerBeforeObservation ? _innerCancellation.Token : CancellationToken.None; + return _audit.ExecuteAsync(typeof(Inner), null, + () => _authorization.ExecuteRequestAsync>(request, + () => CancelInnerBeforeObservation && Unit.HasActiveTransaction + ? new TransactionBehavior>(_transactions, BackgroundJobDefinitionRegistry.CreateEmpty(), + new ExplicitReadOnlyRequestCatalog(Unit.Mode == TransactionMode.ReadOnly ? [new ExplicitReadOnlyRequest(typeof(Inner))] : [])) + .Handle(request, InnerHandler, token) + : InnerHandler(), token), token); + + Task> InnerHandler() + { + DeniedHandlerEntered = true; + if (!CancelInnerBeforeObservation) return Task.FromResult(Result.Ok(None.Value)); + var response = Task.FromResult(Result.Fail(new Error(new LocalizedMessage("lockey_forbidden")))); + _innerCancellation.Cancel(); + InnerAuthorityRevoked = _authorization.CurrentInvocation is null && _authorization.CurrentDecision is null; + return response; + } + } + + public async ValueTask DisposeAsync() + { + await _contexts.DisposeAsync(); + _authorization.Dispose(); + _evidence.Dispose(); + _candidates.Dispose(); + _collector.Dispose(); + await _physical.DisposeAsync(); + await _source.DisposeAsync(); + await _business.DisposeAsync(); + _innerCancellation.Dispose(); + _meters.Dispose(); + } + + private sealed class Outer; + private sealed class Inner; + private sealed class SecondResource; + + private sealed class PolicySource : IOperationPolicySource + { + private static readonly PermissionDefinition Capability = new(PermissionScope.Organization, "proof.evidence.write"); + public IEnumerable Definitions => [new(Capability, true)]; + public IEnumerable Policies => + [OperationPolicy.ForProtected("proof.evidence.outer", Capability), OperationPolicy.ForProtected("proof.evidence.inner", Capability)]; + } + + private sealed class Catalog(ResourcePopulation population) : IAuditCatalogSource + { + public string ModuleName => "proof"; + public void Describe(IAuditCatalogBuilder builder) + { + builder.DeclareOffPath("identity.authorization.deny", OperationType.SecurityEvent, OperationClass.Must); + if (population == ResourcePopulation.Plural) + builder.MustAudit("proof.setting.create", OperationType.Create, typeof(TenantSetting)) + .MustAudit("proof.second.read", OperationType.ReadSensitive, typeof(SecondResource)); + else builder.Off(); + if (population is ResourcePopulation.Should or ResourcePopulation.OverrideOff) + builder.ShouldAudit("proof.inner.read", OperationType.ReadSensitive, typeof(SecondResource)); + else if (population == ResourcePopulation.May) + builder.MayAudit("proof.inner.read", OperationType.ReadSensitive, typeof(SecondResource)); + else builder.Off(); + } + } + + private sealed class CandidateSource(TenantContext context) : AuthorizationCandidate.MintingSource, IAuthorizationCandidateSource + { + public AuthorizationCandidate? ForValidatedScope(TenantId tenant, OrganizationId? organization) => + tenant == context.TenantId && organization == context.OrganizationId + ? ForCandidate(AuthorizationSourceKind.Http, context.UserId!.Value, tenant, organization) : null; + } + + private sealed class Decisions(AuthorizationBarrierHarness owner) : ProtectedOperationDecision.MintingSource, IProtectedOperationAuthorizer + { + private readonly Guid _membership = Guid.CreateVersion7(); + public Task AuthorizeAsync(OperationInvocation invocation, OperationPolicy policy, + AuthorizationCandidate candidate, CancellationToken cancellationToken = default) => Task.FromResult(ForDecision(invocation, candidate, + policy.RequestType == typeof(Outer) ? ProtectedOperationOutcome.Allowed : owner.InnerOutcome, [_membership])); + } + + private sealed class FaultingTransport : IAuthorizationTransportSource + { + public OperationTransportProvenance? Capture(OperationInvocation invocation) => invocation.Policy.RequestType == typeof(Inner) + ? throw new InvalidOperationException("Deliberate fixture composer fault.") : null; + } + + private sealed class EmptyPublisher : IPublisher + { + public Task Publish(object notification, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification => Task.CompletedTask; + } + + private sealed class Meters : IMeterFactory + { + private readonly List _meters = []; + public Meter Create(MeterOptions options) { var meter = new Meter(options); _meters.Add(meter); return meter; } + public void Dispose() { foreach (var meter in _meters) meter.Dispose(); } + } + + internal sealed class ControlledFactory(IAuthorizationEvidenceSessionFactory source) : IAuthorizationEvidenceSessionFactory + { + internal TaskCompletionSource CommitEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal TaskCompletionSource CommitRelease { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal bool HoldCommit { get; set; } + internal bool FailAcknowledgement { get; set; } + internal int Opens { get; private set; } + public async Task OpenAsync(AuthorizationEvidenceOccurrence occurrence, CancellationToken cancellationToken = default) + { + Opens++; + var session = await source.OpenAsync(occurrence, cancellationToken); + return session is null ? null : new Session(session, this); + } + private sealed class Session(IAuthorizationEvidenceSession inner, ControlledFactory owner) : IAuthorizationEvidenceSession + { + public IUnitOfWork Unit => inner.Unit; + public CancellationToken DeadlineToken => inner.DeadlineToken; + public Task BeginVerificationAsync() => inner.BeginVerificationAsync(); + public async Task CompleteAsync() + { + owner.CommitEntered.TrySetResult(); + if (owner.HoldCommit) await owner.CommitRelease.Task.WaitAsync(DeadlineToken); + await inner.CompleteAsync(); + if (owner.FailAcknowledgement) throw new IOException("Deliberate evidence acknowledgement loss."); + } + public void MarkCleanupFailed() => inner.MarkCleanupFailed(); + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } + } + + internal sealed class ObservedStore(IAuditStore inner, IAuditStateCapture capture) : IAuditStore + { + internal List Pending { get; } = []; + internal List Standalone { get; } = []; + internal List ReconciliationStates { get; } = []; + public Task WriteAuthorizationPendingAsync(IUnitOfWork unit, AuthorizationEvidenceOccurrence occurrence, CancellationToken token = default) + { Pending.Add(occurrence); return inner.WriteAuthorizationPendingAsync(unit, occurrence, token); } + public Task WriteAuthorizationStandaloneAsync(AuthorizationEvidenceOccurrence occurrence, CancellationToken token = default) + { Standalone.Add(occurrence); ReconciliationStates.Add(capture.State); return inner.WriteAuthorizationStandaloneAsync(occurrence, token); } + public Task WritePendingAsync(IUnitOfWork unit, CancellationToken token = default) => inner.WritePendingAsync(unit, token); + public Task WriteStandaloneAsync(AuditEntryDraft entry, CancellationToken token = default) => inner.WriteStandaloneAsync(entry, token); + public Task WriteBestEffortAsync(AuditEntryDraft entry, CancellationToken token = default) => inner.WriteBestEffortAsync(entry, token); + public Task WritePlatformScopeAsync(AuditEntryDraft entry, DbConnection connection, DbTransaction transaction, CancellationToken token = default) => inner.WritePlatformScopeAsync(entry, connection, transaction, token); + public Task WriteEventTerminalAsync(AuditEntryDraft entry, EventTerminalTransition transition, DbConnection connection, DbTransaction transaction, CancellationToken token = default) => inner.WriteEventTerminalAsync(entry, transition, connection, transaction, token); + public Task WriteJobTerminalAsync(AuditEntryDraft entry, JobTerminalTransition transition, DbConnection connection, DbTransaction transaction, CancellationToken token = default) => inner.WriteJobTerminalAsync(entry, transition, connection, transaction, token); + public Task WriteGlobalIdentityPendingAsync(IUnitOfWork unit, GlobalIdentityCreationReference creation, CancellationToken token = default) => inner.WriteGlobalIdentityPendingAsync(unit, creation, token); + public Task ReconcileGlobalIdentityAsync(AuditIntent intent, IReadOnlyList changes, AuditIntentState state, DateTimeOffset timestamp, CancellationToken token = default) => inner.ReconcileGlobalIdentityAsync(intent, changes, state, timestamp, token); + } + + internal sealed class ProofLogs : ILogger + { + internal List<(LogLevel Level, string Text, Exception? Exception)> Entries { get; } = []; + 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) => + Entries.Add((logLevel, formatter(state, exception), exception)); + } + + private sealed class BoundaryUnit(IUnitOfWork inner, Func boundary) : IUnitOfWork + { + public DbConnection Connection => inner.Connection; + public DbTransaction? Transaction => inner.Transaction; + public bool HasActiveTransaction => inner.HasActiveTransaction; + public TransactionMode? Mode => inner.Mode; + public bool IsRollbackOnly => inner.IsRollbackOnly; + public bool IsTenantContextIssuedOn(DbTransaction? transaction) => inner.IsTenantContextIssuedOn(transaction); + public TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction) => inner.GetTenantContextAnnouncement(transaction); + public async Task BeginTransactionAsync(TransactionMode mode, CancellationToken token = default) => new Scope(await inner.BeginTransactionAsync(mode, token), boundary); + public Task BeginTransactionAsync(CancellationToken token = default) => BeginTransactionAsync(TransactionMode.ReadWrite, token); + public Task SetTenantContextAsync(ITenantContext context, CancellationToken token = default) => inner.SetTenantContextAsync(context, token); + public Task SetProvisioningTenantContextAsync(TenantId tenant, CancellationToken token = default) => inner.SetProvisioningTenantContextAsync(tenant, token); + public Task CommitAsync(CancellationToken token = default) => inner.CommitAsync(token); + public Task RollbackAsync(CancellationToken token = default) => inner.RollbackAsync(token); + public void MarkRollbackOnly() => inner.MarkRollbackOnly(); + public ValueTask DisposeAsync() => inner.DisposeAsync(); + private sealed class Scope(IUnitOfWorkScope inner, Func boundary) : IUnitOfWorkScope + { + public bool IsOwner => inner.IsOwner; + public async Task CompleteAsync(CancellationToken token = default) + { + if (IsOwner) await boundary(false); + await inner.CompleteAsync(token); + if (IsOwner) await boundary(true); + } + public Task FailAsync(CancellationToken token = default) => inner.FailAsync(token); + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs b/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs index d6171d61b..b1a42e35d 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs @@ -108,6 +108,12 @@ public void Write_Port_Census_Distinguishes_Typed_Read_Keys_From_Wrapped_Domain_ .Should().BeFalse("the exact untracked Role lookup is a read, not a second aggregate writer"); TakesDomainObject(typeof(LearnStack.Modules.Identity.Application.Abstractions.ISeedStateReader), identityDomains) .Should().BeFalse("the exact seed DTO lookups do not write their typed keys"); + var directory = typeof(LearnStack.Modules.Identity.Application.Abstractions.IMembershipDirectoryReader); + TakesDomainObject(directory, identityDomains).Should().BeFalse("the exact directory seek reads its typed Membership key"); + var read = directory.GetMethod("ReadAsync")!; + ReadMethods.Remove(read).Should().BeTrue(); + try { TakesDomainObject(directory, identityDomains).Should().BeTrue("removing this exact read-method entry must expose its typed key"); } + finally { ReadMethods.Add(read); } TakesDomainObject(typeof(IWrappedKeyReader), domains).Should().BeTrue("an unenumerated key-only port must not escape; Fix: enumerate genuine read methods explicitly"); foreach (var writer in new[] { typeof(IDirectWriter), typeof(IBulkWriter), typeof(IArrayWriter), typeof(IByRefWriter), typeof(IMixedWriter), typeof(IInheritedWriter), typeof(IIdOnlyWriteProbe), typeof(IValueReturningKeyWriter) }) { @@ -186,6 +192,7 @@ private static bool IsTypedId(Type type) => type.GetInterfaces().Any(contract => typeof(LearnStack.Modules.Customization.Application.Abstractions.ISeedStateReader).GetMethod("ReadContentTypeAsync")!, typeof(LearnStack.Modules.Customization.Application.Abstractions.ISeedStateReader).GetMethod("ReadTaxonomyAsync")!, typeof(LearnStack.Modules.Identity.Application.Abstractions.IRoleReadStore).GetMethod("ReadAsync")!, + typeof(LearnStack.Modules.Identity.Application.Abstractions.IMembershipDirectoryReader).GetMethod("ReadAsync")!, typeof(LearnStack.Modules.Identity.Application.Abstractions.ISeedStateReader).GetMethod("ReadRoleAsync")!, typeof(LearnStack.Modules.Identity.Application.Abstractions.ISeedStateReader).GetMethod("ReadMembershipAsync")!, ]; diff --git a/backend/tests/LearnStack.Tests.Architecture/AuditConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/AuditConventionTests.cs index b3c730f81..d258845e3 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AuditConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AuditConventionTests.cs @@ -305,7 +305,7 @@ public void Modules_Do_Not_Write_AuditLog_Directly() inserting.Should().Equal( ["LearnStack.Infrastructure.Audit/PostgresAuditStore.cs"], - "PostgresAuditStore's eight writes are the only SQL that adds an audit row"); + "PostgresAuditStore's ten writes reuse the only SQL that adds an audit row"); // The context's exemption is for the MAPPING: a change-tracker write placed inside // AuditDbContext would be invisible to the leg above, because its caller names only the @@ -515,14 +515,15 @@ public void The_AppendOnly_Sweep_Can_Actually_Fail() public void IAuditStore_Exposes_No_Update_Method() { // The other half of the same claim, and the one a reader checks first. ADR-0033 - // ADR-0046, ADR-0047 and ADR-0072 authorize eight writes and no update; another method would + // ADR-0046, ADR-0047, ADR-0072 and ADR-0077 authorize ten writes and no update; another method would // make the port contradict its closed write contract. typeof(IAuditStore).GetMethods() .Select(method => method.Name) .Should().BeEquivalentTo( "WritePendingAsync", "WriteStandaloneAsync", "WriteBestEffortAsync", "WritePlatformScopeAsync", "WriteEventTerminalAsync", "WriteJobTerminalAsync", - "WriteGlobalIdentityPendingAsync", "ReconcileGlobalIdentityAsync"); + "WriteGlobalIdentityPendingAsync", "ReconcileGlobalIdentityAsync", + "WriteAuthorizationPendingAsync", "WriteAuthorizationStandaloneAsync"); } /// diff --git a/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.AttachedMetadata.cs b/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.AttachedMetadata.cs index 28b3277af..732e64f55 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.AttachedMetadata.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.AttachedMetadata.cs @@ -38,7 +38,7 @@ private static void AssertAttachedMetadataControls(Dictionary type.FullName is Account or PublicSite or PublicEducation).ToArray()) + foreach (var controller in map.Values.Where(type => type.FullName is Account or Directory or PublicSite or PublicEducation).ToArray()) { var action = controller.Methods.First(IsAction); AssertClean(); diff --git a/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.ControllerMetadata.cs b/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.ControllerMetadata.cs new file mode 100644 index 000000000..715fd7711 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.ControllerMetadata.cs @@ -0,0 +1,85 @@ +using FluentAssertions; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class AuthenticatedHttpSurfaceTests +{ + private const string HttpClassifier = "LearnStack.Api.Authentication.OperationHttpClassifier"; + private const string ControllerPair = "System.ValueTuple`2"; + private const string OperationTriple = "System.ValueTuple`3"; + private const string OptionalOperationTriple = "System.Nullable`1<" + OperationTriple + ">"; + + // Only the literal controller operands in the fixed data table are + // descriptive. Controller fields, constructor arguments, locals, method + // targets and every other typeof occurrence retain their ordinary edges. + private static bool IsControllerTypeMetadata(TypeDefinition owner, MethodDefinition method, Instruction instruction) => + owner.Module.Assembly.Name.Name == "LearnStack.Api" && owner.FullName == HttpClassifier && method.Name == "Expected" + && instruction.OpCode == OpCodes.Ldtoken && instruction.Operand is TypeReference controller + && Endpoints.Any(endpoint => Key(controller) == "LearnStack.Api:" + endpoint.Controller) + && DataOnlyHttpTable(method); + + private static bool DataOnlyHttpTable(MethodDefinition method) + { + if (!method.IsPrivate || !method.IsStatic || method.Parameters.Count != 2 + || method.Parameters[0].ParameterType.FullName != "System.Type" || method.Parameters[1].ParameterType.FullName != "System.String" + || method.ReturnType.FullName != OptionalOperationTriple || !method.HasBody + || method.Body.Instructions.Any(instruction => instruction.OpCode == OpCodes.Calli) + || method.Body.Variables.Any(variable => variable.VariableType.FullName is not (ControllerPair or OptionalOperationTriple or "System.Type"))) return false; + var tokens = method.Body.Instructions.Where(instruction => instruction.OpCode == OpCodes.Ldtoken) + .Select(instruction => instruction.Operand).OfType().ToArray(); + var expectedTokens = Endpoints.SelectMany(endpoint => new[] { "LearnStack.Api:" + endpoint.Controller, + endpoint.Request.Assembly.GetName().Name + ":" + endpoint.Request.FullName }).ToArray(); + if (!tokens.Select(Key).SequenceEqual(expectedTokens, StringComparer.Ordinal)) return false; + var actual = method.Body.Instructions.Where(instruction => instruction.Operand is MethodReference) + .Select(instruction => instruction.OpCode.Name + ":" + ((MethodReference)instruction.Operand).FullName).Order(StringComparer.Ordinal); + var expected = Enumerable.Repeat("call:System.Type System.Type::GetTypeFromHandle(System.RuntimeTypeHandle)", Endpoints.Length * 2) + .Concat(Enumerable.Repeat("call:System.Boolean System.Type::op_Equality(System.Type,System.Type)", Endpoints.Length)) + .Concat(Enumerable.Repeat("call:System.Boolean System.String::op_Equality(System.String,System.String)", Endpoints.Length)) + .Append("call:System.Void " + ControllerPair + "::.ctor(!0,!1)") + .Concat(Enumerable.Repeat("newobj:System.Void " + OperationTriple + "::.ctor(!0,!1,!2)", Endpoints.Length)) + .Concat(Enumerable.Repeat("newobj:System.Void " + OptionalOperationTriple + "::.ctor(!0)", Endpoints.Length)).Order(StringComparer.Ordinal); + return actual.SequenceEqual(expected, StringComparer.Ordinal) + && method.Body.Instructions.Where(instruction => instruction.Operand is FieldReference) + .All(instruction => instruction.OpCode == OpCodes.Ldfld && instruction.Operand is FieldReference field + && field.DeclaringType.FullName == ControllerPair && field.Name is "Item1" or "Item2"); + } + + private static void AssertControllerMetadataControls(ModuleDefinition[] modules, CensusFacts facts) + { + var api = modules.Single(module => module.Assembly.Name.Name == "LearnStack.Api"); + var classifier = api.GetType(HttpClassifier); + var expected = classifier.Methods.Single(method => method.Name == "Expected"); + var account = api.GetType(Account); + void Dirty() => Violations(modules, facts).Should().Contain(value => value.StartsWith("Nested HTTP dispatch/helper handler:", StringComparison.Ordinal)); + var field = new FieldDefinition("ActualController", FieldAttributes.Private, account); + classifier.Fields.Add(field); + try { Dirty(); } finally { classifier.Fields.Remove(field); } + var constructor = classifier.Methods.Single(method => method.IsConstructor); + var argument = new ParameterDefinition("actualController", ParameterAttributes.None, account); + constructor.Parameters.Add(argument); + try { Dirty(); } finally { constructor.Parameters.Remove(argument); } + var local = new VariableDefinition(account); + expected.Body.Variables.Add(local); + try { Dirty(); } finally { expected.Body.Variables.Remove(local); } + var allocation = Instruction.Create(OpCodes.Newobj, account.Methods.Single(method => method.IsConstructor)); + expected.Body.Instructions.Add(allocation); + try { Dirty(); } finally { expected.Body.Instructions.Remove(allocation); } + foreach (var opcode in new[] { OpCodes.Callvirt, OpCodes.Ldftn }) + { + var call = Instruction.Create(opcode, account.Methods.Single(method => method.Name == "GetSite")); + expected.Body.Instructions.Add(call); + try { Dirty(); } finally { expected.Body.Instructions.Remove(call); } + } + var factory = new MethodDefinition("AcquireController", MethodAttributes.Private | MethodAttributes.Static, api.TypeSystem.Object); + factory.Body.Instructions.Add(Instruction.Create(OpCodes.Newobj, account.Methods.Single(method => method.IsConstructor))); + factory.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + classifier.Methods.Add(factory); + var delegated = Instruction.Create(OpCodes.Call, factory); + expected.Body.Instructions.Add(delegated); + try { Dirty(); } + finally { expected.Body.Instructions.Remove(delegated); classifier.Methods.Remove(factory); } + Violations(modules, facts).Should().BeEmpty("only the original fixed typeof table remains after real controller capabilities are removed"); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.Controls.cs b/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.Controls.cs index 622f93268..d87c90255 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.Controls.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.Controls.cs @@ -11,6 +11,28 @@ namespace LearnStack.Tests.Architecture; public sealed partial class AuthenticatedHttpSurfaceTests { + private static void AssertProtectedPolicyControls(Dictionary map) + { + foreach (var controller in map.Values.Where(type => type.FullName is Account or Directory)) + { + var action = controller.Methods.Single(IsAction); + var authorization = controller.CustomAttributes.Single(attribute => attribute.AttributeType.FullName == typeof(AuthorizeAttribute).FullName); + var policy = authorization.Properties.Single(property => property.Name == nameof(AuthorizeAttribute.Policy)); + AuthorizationOffenders(controller, action, map).Should().BeEmpty(); + authorization.Properties.Remove(policy); + try + { + AuthorizationOffenders(controller, action, map).Should().ContainSingle("the named live policy is required, not optional metadata"); + authorization.Properties.Add(new CustomAttributeNamedArgument(nameof(AuthorizeAttribute.Policy), + new CustomAttributeArgument(controller.Module.TypeSystem.String, "LearnStackOperation:unregistered.read"))); + try { AuthorizationOffenders(controller, action, map).Should().ContainSingle("another named policy cannot substitute for the exact operation"); } + finally { authorization.Properties.RemoveAt(authorization.Properties.Count - 1); } + } + finally { authorization.Properties.Add(policy); } + AuthorizationOffenders(controller, action, map).Should().BeEmpty("restoring the exact policy restores the approved action"); + } + } + private static void AssertPublicAuthorizationControls(Dictionary map) { foreach (var controller in map.Values.Where(type => type.FullName is PublicSite or PublicEducation).ToArray()) @@ -45,7 +67,7 @@ private static void AssertPublicAuthorizationControls(Dictionary map) { - foreach (var controller in map.Values.Where(type => type.FullName is Account or PublicSite or PublicEducation).ToArray()) + foreach (var controller in map.Values.Where(type => type.FullName is Account or Directory or PublicSite or PublicEducation).ToArray()) { var action = controller.Methods.First(IsAction); AuthorizationOffenders(controller, action, map, null).Should().BeEmpty(); @@ -60,7 +82,7 @@ private static void AssertAnonymousMetadataControls(Dictionary attribute.AttributeType.FullName == typeof(AllowAnonymousAttribute).FullName); diff --git a/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.PolicyMetadata.cs b/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.PolicyMetadata.cs new file mode 100644 index 000000000..9bb97658a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.PolicyMetadata.cs @@ -0,0 +1,209 @@ +using FluentAssertions; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class AuthenticatedHttpSurfaceTests +{ + private const string PolicyType = "LearnStack.SharedKernel.Permissions.OperationPolicy"; + private const string CustomizationPolicies = "LearnStack.Modules.Customization.Application.Authorization.CustomizationOperationPolicySource"; + private const string CustomizationAudit = "LearnStack.Modules.Customization.Application.Audit.CustomizationAuditCatalogSource"; + + // Exclude only literal handler type arguments in the two finite policy + // registrations and their two existing Off audit declarations. + // Replacing the instruction's MethodSpec, rather than its shared TypeReference, + // preserves every field/constructor/local/other operand naming the same handler. + private static TypeReference[] HandlerReferences(TypeDefinition type, Dictionary map) + { + var sites = Il.Methods(type).Where(pair => pair.Definition.HasBody).SelectMany(pair => + pair.Definition.Body.Instructions.Where(instruction => instruction.Operand is GenericInstanceMethod generic + && generic.GenericArguments.Select((_, index) => index).Any(index => + IsHandlerTypeMetadata(type, pair.Definition, instruction, index, map))) + .Select(instruction => (Instruction: instruction, Original: (GenericInstanceMethod)instruction.Operand, + Method: pair.Definition))).ToArray(); + var controllerTokens = Il.Methods(type).Where(pair => pair.Definition.HasBody).SelectMany(pair => + pair.Definition.Body.Instructions.Where(instruction => IsControllerTypeMetadata(type, pair.Definition, instruction)) + .Select(instruction => (Instruction: instruction, Original: instruction.Operand))).ToArray(); + try + { + foreach (var site in sites) + { + var original = site.Original; + var metadata = new GenericInstanceMethod(original.ElementMethod); + for (var index = 0; index < original.GenericArguments.Count; index++) + metadata.GenericArguments.Add(IsHandlerTypeMetadata(type, site.Method, site.Instruction, index, map) + ? type.Module.TypeSystem.Object : original.GenericArguments[index]); + site.Instruction.Operand = metadata; + } + foreach (var token in controllerTokens) token.Instruction.Operand = type.Module.TypeSystem.Object; + return Il.ReferencedTypeReferences(type).ToArray(); + } + finally + { + foreach (var site in sites) site.Instruction.Operand = site.Original; + foreach (var token in controllerTokens) token.Instruction.Operand = token.Original; + } + } + + private static bool IsHandlerTypeMetadata(TypeDefinition owner, MethodDefinition method, Instruction instruction, + int argument, Dictionary map) + { + if (argument == 0 && owner.FullName == CustomizationAudit && method.Name == "Describe" + && instruction.OpCode == OpCodes.Callvirt && instruction.Operand is GenericInstanceMethod + { Name: "Off", GenericArguments.Count: 1, Parameters.Count: 0 } off + && Key(off.DeclaringType) == "LearnStack.SharedKernel:LearnStack.SharedKernel.Audit.IAuditCatalogBuilder" + && Key(off.GenericArguments[0]) is "LearnStack.Modules.Customization.Application:LearnStack.Modules.Customization.Application.IntegrationEvents.InitializeContentTypeDefaultHandler" + or "LearnStack.Modules.Customization.Application:LearnStack.Modules.Customization.Application.IntegrationEvents.InitializeTaxonomyDefaultHandler") + return DataOnlyAuditRegistration(map); + if (argument != 1 || owner.FullName != CustomizationPolicies || method.Name != "get_Policies" + || instruction.OpCode != OpCodes.Call || instruction.Operand is not GenericInstanceMethod + { Name: "ForEventInitializer", GenericArguments.Count: 2, Parameters.Count: 1 } factory + || Key(factory.DeclaringType) != "LearnStack.SharedKernel:" + PolicyType + || instruction.Previous is not { OpCode.Code: Code.Ldstr, Operand: string id }) return false; + var prefix = "LearnStack.Modules.Customization.Application"; + var contract = "LearnStack.Modules.Customization.Application.Contracts:LearnStack.Modules.Customization.Application.Contracts.Customization."; + var handler = prefix + ":" + prefix + ".IntegrationEvents."; + var pair = (Key(factory.GenericArguments[0]), Key(factory.GenericArguments[1]), id); + return (pair == (contract + "InitializeTenantContentTypeDefaultCommand", handler + "InitializeContentTypeDefaultHandler", "customization.default.initialize_content_type") + || pair == (contract + "InitializeTenantLevelTaxonomyDefaultCommand", handler + "InitializeTaxonomyDefaultHandler", "customization.default.initialize_taxonomy")) + && DataOnlyPolicyFactory(map); + } + + private static bool DataOnlyAuditRegistration(Dictionary map) + { + const string builderName = "LearnStack.Infrastructure.Audit.AuditCatalogBuilder"; + const string contract = "LearnStack.SharedKernel.Audit.IAuditCatalogBuilder"; + const string registration = builderName + "/Registration"; + if (!map.TryGetValue("LearnStack.Infrastructure.Audit:" + builderName, out var builder) + || map.Values.Count(type => type.Interfaces.Any(item => item.InterfaceType.FullName == contract)) != 1) return false; + var off = builder.Methods.SingleOrDefault(method => method.Name == "Off"); + if (off is not { HasBody: true, GenericParameters.Count: 1, Parameters.Count: 0 }) return false; + var genericUses = off.Body.Instructions.Where(instruction => instruction.Operand is GenericParameter).ToArray(); + if (genericUses.Length != 1 || genericUses[0].OpCode != OpCodes.Ldtoken) return false; + var bodies = new[] { off, builder.Methods.Single(method => method.Name == "Claim"), + builder.Methods.Single(method => method.Name == "Source"), + builder.NestedTypes.Single(type => type.Name == "Registration").Methods.Single(method => method.Name == ".ctor" && method.Parameters.Count == 2), + builder.NestedTypes.Single(type => type.Name == "Registration").Methods.Single(method => method.Name == "get_Entries") }; + foreach (var body in bodies) + foreach (var instruction in body.Body.Instructions.Where(instruction => instruction.Operand is MethodReference)) + { + var target = (MethodReference)instruction.Operand; + if (instruction.OpCode.Code is not (Code.Call or Code.Callvirt or Code.Newobj)) return false; + if ((target.DeclaringType.FullName, target.Name) is ("System.Type", "GetTypeFromHandle") + or ("System.Object", ".ctor") or ("System.InvalidOperationException", ".ctor") + or ("System.Reflection.MemberInfo", "get_Name") or ("System.String", "Equals") or ("System.String", "Concat") + or ("LearnStack.SharedKernel.Audit.IAuditCatalogSource", "get_ModuleName")) continue; + if (target.DeclaringType.FullName == builderName && target.Name is "Source" or "Claim" + || target.DeclaringType.FullName == registration && target.Name is ".ctor" or "get_Entries" + || target.DeclaringType.FullName == "System.Runtime.CompilerServices.DefaultInterpolatedStringHandler" + && target.Name is ".ctor" or "AppendFormatted" or "AppendLiteral" or "ToStringAndClear" + || target.DeclaringType.FullName == "System.Collections.Generic.List`1" + && target.Name is ".ctor" or "get_Count" + || target.DeclaringType.FullName is "System.Collections.Generic.Dictionary`2" + or "System.Collections.Generic.Dictionary`2" && target.Name is "TryGetValue" or "set_Item") continue; + return false; + } + var moduleName = map.Values.Single(type => type.FullName == CustomizationAudit).Methods.Single(method => method.Name == "get_ModuleName"); + return moduleName.Body.Instructions.Count == 2 && moduleName.Body.Instructions[0].Operand as string == "customization" + && moduleName.Body.Instructions[1].OpCode == OpCodes.Ret; + } + + private static bool DataOnlyPolicyFactory(Dictionary map) + { + if (!map.TryGetValue("LearnStack.SharedKernel:" + PolicyType, out var policy)) return false; + var factories = policy.Methods.Where(method => method.Name == "ForEventInitializer").ToArray(); + var constructors = policy.Methods.Where(method => method.IsConstructor && !method.IsStatic).ToArray(); + if (factories.Length != 1 || constructors.Length != 1) return false; + var factory = factories[0]; + if (!factory.IsStatic || factory.GenericParameters.Count != 2 || factory.Parameters.Count != 1 + || factory.Parameters[0].ParameterType.FullName != "System.String" || factory.ReturnType.FullName != PolicyType + || !constructors[0].IsPrivate || !factory.HasBody || !constructors[0].HasBody) return false; + var tokens = factory.Body.Instructions.Where(instruction => instruction.OpCode == OpCodes.Ldtoken) + .Select(instruction => instruction.Operand).OfType().Select(parameter => parameter.Position).Order().ToArray(); + if (!tokens.SequenceEqual([0, 1])) return false; + var factoryCalls = factory.Body.Instructions.Where(instruction => instruction.Operand is MethodReference).ToArray(); + if (factoryCalls.Length != 4 || factoryCalls.Any(instruction => instruction.Operand is not MethodReference target + || !(instruction.OpCode == OpCodes.Call && target.DeclaringType.FullName == "System.Type" && target.Name == "GetTypeFromHandle" + || instruction.OpCode == OpCodes.Call && target.DeclaringType.FullName == "System.Runtime.InteropServices.ImmutableCollectionsMarshal" + && target.Name == "AsImmutableArray" + || instruction.OpCode == OpCodes.Newobj && target.FullName == constructors[0].FullName))) return false; + var constructorCalls = constructors[0].Body.Instructions.Where(instruction => instruction.Operand is MethodReference).ToArray(); + foreach (var instruction in constructorCalls) + { + var target = (MethodReference)instruction.Operand; + if (target.DeclaringType.FullName == PolicyType + "/<>c" && target.Name is "<.ctor>b__0_0" or "<.ctor>b__0_1") + { + if (instruction.OpCode != OpCodes.Ldftn || !map.TryGetValue(Key(target.DeclaringType), out var helper) + || helper.Methods.SingleOrDefault(method => method.FullName == target.FullName) is not { HasBody: true } predicate + || predicate.Body.Instructions.Any(item => item.Operand is MethodReference)) return false; + continue; + } + if (!((target.DeclaringType.FullName, target.Name) is ("System.Object", ".ctor") + or ("System.ArgumentNullException", "ThrowIfNull") or ("System.ArgumentException", "ThrowIfNullOrWhiteSpace") + or ("System.ArgumentException", ".ctor") or ("System.Type", "get_IsAbstract") or ("System.Type", "get_ContainsGenericParameters") + or ("System.String", "get_Length") or ("System.Enum", "IsDefined") + or ("LearnStack.SharedKernel.Permissions.PermissionDefinition", "get_Scope") + or ("System.Linq.Enumerable", "Any") or ("System.Linq.Enumerable", "Distinct") or ("System.Linq.Enumerable", "Count") + or ("System.Linq.ImmutableArrayExtensions", "Any")) + && !(target.DeclaringType.FullName is "System.Func`2" + or "System.Func`2" && target.Name == ".ctor") + && !(target.DeclaringType.FullName == "System.Collections.Immutable.ImmutableArray`1" + && target.Name is "get_IsDefaultOrEmpty" or "get_Length" or "Contains")) return false; + } + return true; + } + + private static void AssertPolicyMetadataControls(ModuleDefinition[] modules, CensusFacts facts) + { + var map = Types(modules).ToDictionary(Key, StringComparer.Ordinal); + var source = map.Values.Single(type => type.FullName == CustomizationPolicies); + var handler = map.Values.Single(type => type.FullName == "LearnStack.Modules.Customization.Application.IntegrationEvents.InitializeContentTypeDefaultHandler"); + var policies = source.Methods.Single(method => method.Name == "get_Policies"); + void Dirty() => Violations(modules, facts).Should().Contain(value => value.StartsWith("Nested HTTP dispatch/helper handler:", StringComparison.Ordinal)); + var field = new FieldDefinition("ActualHandler", FieldAttributes.Private, source.Module.ImportReference(handler)); + source.Fields.Add(field); + try { Dirty(); } finally { source.Fields.Remove(field); } + var local = new VariableDefinition(source.Module.ImportReference(handler)); + policies.Body.Variables.Add(local); + try { Dirty(); } finally { policies.Body.Variables.Remove(local); } + var constructor = source.Methods.Single(method => method.IsConstructor); + var argument = new ParameterDefinition("actualHandler", ParameterAttributes.None, source.Module.ImportReference(handler)); + constructor.Parameters.Add(argument); + try { Dirty(); } finally { constructor.Parameters.Remove(argument); } + var handlerConstructor = handler.Methods.Single(method => method.IsConstructor); + var invocation = Instruction.Create(OpCodes.Newobj, source.Module.ImportReference(handlerConstructor)); + policies.Body.Instructions.Add(invocation); + try { Dirty(); } finally { policies.Body.Instructions.Remove(invocation); } + var audit = map.Values.Single(type => type.FullName == CustomizationAudit); + var describe = audit.Methods.Single(method => method.Name == "Describe"); + var auditField = new FieldDefinition("ActualHandler", FieldAttributes.Private, audit.Module.ImportReference(handler)); + audit.Fields.Add(auditField); + try { Dirty(); } finally { audit.Fields.Remove(auditField); } + var auditLocal = new VariableDefinition(audit.Module.ImportReference(handler)); + describe.Body.Variables.Add(auditLocal); + try { Dirty(); } finally { describe.Body.Variables.Remove(auditLocal); } + var auditArgument = new ParameterDefinition("actualHandler", ParameterAttributes.None, audit.Module.ImportReference(handler)); + var auditConstructor = audit.Methods.Single(method => method.IsConstructor); + auditConstructor.Parameters.Add(auditArgument); + try { Dirty(); } finally { auditConstructor.Parameters.Remove(auditArgument); } + foreach (var (owner, method) in new[] { (source, policies), (audit, describe) }) + foreach (var opcode in new[] { OpCodes.Callvirt, OpCodes.Ldftn }) + { + var execute = Instruction.Create(opcode, owner.Module.ImportReference(handler.Methods.Single(item => item.Name == "HandleAsync"))); + method.Body.Instructions.Add(execute); + try { Dirty(); } finally { method.Body.Instructions.Remove(execute); } + } + var policy = map.Values.Single(type => type.FullName == PolicyType); + var factory = policy.Methods.Single(method => method.Name == "ForEventInitializer"); + var delegated = Instruction.Create(OpCodes.Newobj, policy.Module.ImportReference(handlerConstructor)); + factory.Body.Instructions.Add(delegated); + try { Dirty(); } finally { factory.Body.Instructions.Remove(delegated); } + var off = map.Values.Single(type => type.FullName == "LearnStack.Infrastructure.Audit.AuditCatalogBuilder") + .Methods.Single(method => method.Name == "Off"); + var executedRegistration = Instruction.Create(OpCodes.Newobj, off.Module.ImportReference(handlerConstructor)); + off.Body.Instructions.Add(executedRegistration); + try { Dirty(); } finally { off.Body.Instructions.Remove(executedRegistration); } + Violations(modules, facts).Should().BeEmpty("removing actual handler acquisition restores the metadata-only edge"); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.cs b/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.cs index 4a2f693dd..91cda08c5 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.cs @@ -1,9 +1,12 @@ using FluentAssertions; using LearnStack.Infrastructure.Audit; using LearnStack.Modules.Education.Application.Contracts.PublicReads; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Domain; using LearnStack.Modules.Tenancy.Application.Contracts.Account; using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Audit; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,12 +16,13 @@ namespace LearnStack.Tests.Architecture; -/// ADR-0064's complete HTTP ceiling while application authorization passes through. +/// ADR-0064/ADR-0076's complete read-only HTTP ceiling with live application authorization. public sealed partial class AuthenticatedHttpSurfaceTests { private const string PublicSite = "LearnStack.Api.PublicReads.PublicSiteController"; private const string PublicEducation = "LearnStack.Api.PublicReads.PublicEducationController"; private const string Account = "LearnStack.Api.Account.AccountController"; + private const string Directory = "LearnStack.Api.Identity.MembershipsController"; private const string Versioning = "LearnStack.Api.Versioning.ApiVersioningExtensions"; private const string Route = "Microsoft.AspNetCore.Mvc.RouteAttribute"; @@ -35,7 +39,8 @@ private sealed record Endpoint(string Controller, string Action, string Verb, st new(PublicEducation, "HeadCourse", "HEAD", "public/courses", "{slug}", "HeadPublicCourse", typeof(GetPublicCourseQuery)), new(PublicEducation, "Lesson", "GET", "public/courses", "{slug}/lessons/{lessonSlug}", "GetPublicLesson", typeof(GetPublicLessonQuery)), new(PublicEducation, "HeadLesson", "HEAD", "public/courses", "{slug}/lessons/{lessonSlug}", "HeadPublicLesson", typeof(GetPublicLessonQuery)), - new(Account, "GetSite", "GET", "account", "site", "GetAccountSite", typeof(GetAccountSiteQuery)) + new(Account, "GetSite", "GET", "account", "site", "GetAccountSite", typeof(GetAccountSiteQuery)), + new(Directory, "List", "GET", "identity/memberships", null, "ListMemberships", typeof(ListMembershipsQuery)) ]; // These are mapping call sites, not a path-wide exception. A second health @@ -52,7 +57,7 @@ private sealed record Endpoint(string Controller, string Action, string Verb, st [Fact] public void Authenticated_Http_Surface_Is_Enumerated_And_ReadOnly() => WithProduction((modules, facts) => Violations(modules, facts).Should().BeEmpty( - "Fix: retain only the eight approved public GET/HEAD actions, exact AccountSite GET and enumerated infrastructure mappings; dispatch the exact Off read through the existing pipeline")); + "Fix: retain only the eight approved public GET/HEAD actions, exact AccountSite and membership-directory GETs, and enumerated infrastructure mappings; dispatch each exact read through the canonical pipeline")); /// ADR-0064's controls mutate the same production route census. [Fact] @@ -70,6 +75,7 @@ public void Authenticated_http_census_detects_extra_routes_mutations_and_missing var api = modules.Single(module => module.Assembly.Name.Name == "LearnStack.Api"); var map = Types(modules).ToDictionary(Key, StringComparer.Ordinal); AssertPublicAuthorizationControls(map); + AssertProtectedPolicyControls(map); AssertAnonymousMetadataControls(map); AssertAttachedMetadataControls(map); AssertEndpointConventionControls(api); @@ -149,6 +155,9 @@ public void Authenticated_http_census_detects_extra_routes_mutations_and_missing var maps = MappingSites(modules).ToArray(); EndpointCompositionControls.Plant(api, (prefix, because) => Dirty(prefix, because)); + EndpointCompositionControls.PlantMetadata(api, (owner, because) => Violations(modules, facts).Should().Contain(error => + error.StartsWith("Unapproved endpoint mapping:", StringComparison.Ordinal) + && error.Contains(owner, StringComparison.Ordinal), because)); var health = maps.Single(site => site.Target.Name == "MapGet"); var duplicate = Instruction.Create(OpCodes.Call, health.Target); health.Owner.Body.Instructions.Add(duplicate); @@ -198,8 +207,10 @@ public void Authenticated_http_census_follows_production_helpers_and_nested_disp var map = Types(modules).ToDictionary(Key, StringComparer.Ordinal); var account = map.Values.Single(type => type.FullName == Account); var handlers = HandlerRoots(map).ToArray(); - handlers.Should().HaveCount(5, "the four public queries and authenticated query have real production handlers"); + handlers.Should().HaveCount(6, "the four public queries and two authenticated queries have real production handlers"); Violations(modules, facts).Should().BeEmpty(); + AssertPolicyMetadataControls(modules, facts); + AssertControllerMetadataControls(modules, facts); var publicSite = map.Values.Single(type => type.FullName == PublicSite); PublicSurfaceTests.PersistenceOffenders([publicSite], map).Should().BeEmpty( @@ -358,9 +369,20 @@ private static SortedSet Violations(ModuleDefinition[] modules, CensusFa var catalogue = facts.Catalogue; foreach (var request in requests) { - if (!catalogue.TryGet(request, out var registration) || !registration.WritesNoRow || registration.Entries.Count != 0) - errors.Add("HTTP request must be explicitly Off: " + request.FullName); - if (request.IsDefined(typeof(PublicSurfaceAttribute), false) != (request != typeof(GetAccountSiteQuery))) + if (request == typeof(ListMembershipsQuery)) + { + if (!catalogue.TryGet(request, out var directory) || directory.WritesNoRow || directory.Entries.Count != 1 + || directory.Entries[0] is not + { + Operation: "identity.membership.list", OperationType: OperationType.ReadSensitive, + OperationClass: OperationClass.Must, EntityType: { } entity + } || entity != typeof(Membership)) + errors.Add("Membership directory must retain its exact MUST ReadSensitive Membership declaration: " + request.FullName); + } + else if (!catalogue.TryGet(request, out var registration) || !registration.WritesNoRow || registration.Entries.Count != 0) + errors.Add("Existing public/account HTTP request must be explicitly Off: " + request.FullName); + if (request.IsDefined(typeof(PublicSurfaceAttribute), false) + != (request != typeof(GetAccountSiteQuery) && request != typeof(ListMembershipsQuery))) errors.Add("Only the existing four public queries carry PublicSurface: " + request.FullName); } var handlers = HandlerRoots(map).ToArray(); @@ -387,14 +409,17 @@ private static bool Authorized(TypeDefinition controller, MethodDefinition actio if (attributes.Any(attribute => AuthenticationMetadata.IsRequirementData(attribute.AttributeType, map))) return false; var authorizations = attributes.Where(attribute => (predicate ?? (type => AuthenticationMetadata.IsData(type, map)))(attribute.AttributeType)).ToArray(); var anonymous = attributes.Where(attribute => AuthenticationMetadata.IsAnonymous(attribute.AttributeType, map)).ToArray(); - if (controller.FullName != Account) return anonymous.Length == 1 && authorizations.Length == 0 + if (controller.FullName is not (Account or Directory)) return anonymous.Length == 1 && authorizations.Length == 0 && anonymous[0].AttributeType.FullName == typeof(AllowAnonymousAttribute).FullName && anonymous[0].ConstructorArguments.Count == 0 && anonymous[0].Properties.Count == 0 && anonymous[0].Fields.Count == 0; return anonymous.Length == 0 && authorizations.Length == 1 && authorizations[0].ConstructorArguments.Count == 0 && authorizations[0].AttributeType.FullName == typeof(AuthorizeAttribute).FullName - && authorizations[0].Properties.Count == 1 - && authorizations[0].Properties[0].Name == nameof(AuthorizeAttribute.AuthenticationSchemes) - && authorizations[0].Properties[0].Argument.Value as string == "TenantBearer"; + && authorizations[0].Fields.Count == 0 && authorizations[0].Properties.Count == 2 + && authorizations[0].Properties.Count(property => property.Name == nameof(AuthorizeAttribute.AuthenticationSchemes) + && property.Argument.Value as string == "TenantBearer") == 1 + && authorizations[0].Properties.Count(property => property.Name == nameof(AuthorizeAttribute.Policy) + && property.Argument.Value as string == "LearnStackOperation:" + (controller.FullName == Account + ? "tenancy.account_site.read" : "identity.membership.list")) == 1; } private static IEnumerable AuthorizationOffenders(TypeDefinition controller, MethodDefinition action, @@ -434,7 +459,7 @@ private static IEnumerable NestedDispatchOffenders(TypeDefinition[] root foreach (var implementation in implementations) queue.Enqueue((root, implementation)); } if (!referencesByType.TryGetValue(typeKey, out var references)) - referencesByType[typeKey] = references = Il.ReferencedTypeReferences(type).ToArray(); + referencesByType[typeKey] = references = HandlerReferences(type, map); if (!capabilities.TryGetValue(typeKey, out var capability)) capabilities[typeKey] = capability = HasHandlerCapability(type, map); if (references.Any(reference => reference.FullName is "MediatR.ISender" or "MediatR.IMediator" or "MediatR.Mediator" or "MediatR.IPublisher") @@ -475,7 +500,8 @@ static bool CapturedOwner(TypeReference reference, MethodDefinition method) FieldReference field => field.Name != "<>4__this" && Handler(field.FieldType), MethodReference target => (target.Name is "Handle" or ".ctor" && Handler(target.DeclaringType)) || Handler(target.ReturnType) || target.Parameters.Any(parameter => Handler(parameter.ParameterType)) - || target is GenericInstanceMethod generic && generic.GenericArguments.Any(Handler), + || target is GenericInstanceMethod generic && generic.GenericArguments.Where((_, index) => + !IsHandlerTypeMetadata(type, pair.Definition, instruction, index, map)).Any(Handler), _ => false }))); } @@ -530,7 +556,7 @@ private sealed record Mapping(MethodDefinition Owner, Instruction Instruction, M private static IEnumerable MappingSites(IEnumerable modules, Func? predicate = null) => Types(modules).SelectMany(type => type.Methods).Where(method => method.HasBody) .SelectMany(method => method.Body.Instructions.Where(instruction => instruction.Operand is MethodReference target - && (predicate ?? IsEndpointMapping)(target)) + && (predicate ?? IsEndpointMapping)(target) && !EndpointComposition.IsFixedInfrastructureMetadata(method, instruction)) .Select(instruction => new Mapping(method, instruction, (MethodReference)instruction.Operand))); private static bool IsEndpointMapping(MethodReference target) => EndpointComposition.IsCapability(target); diff --git a/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.Controls.cs b/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.Controls.cs index 364f6448f..d78d0055d 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.Controls.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.Controls.cs @@ -84,6 +84,78 @@ public void Authority_census_detects_missing_subjects_mutability_and_extra_trust verifiedField.IsInitOnly = false; Offenders(modules).Should().NotBeEmpty("verified evidence must remain immutable for cached quota provenance"); verifiedField.IsInitOnly = true; + var witness = api.GetType(CandidateSource).NestedTypes.Single(type => type.FullName == CredentialWitness); + var witnessField = witness.Fields.Single(candidate => Is(candidate.FieldType, Identity)); + witnessField.IsInitOnly = false; + Offenders(modules).Should().Contain("Candidate credential witness must retain its private immutable nonformatting shape."); + witnessField.IsInitOnly = true; + var witnessText = witness.Methods.Single(method => method.Name == "ToString").Body.Instructions + .Single(instruction => instruction.OpCode == OpCodes.Ldstr); + var fixedText = witnessText.Operand; + witnessText.Operand = "Unexpected credential text"; + Offenders(modules).Should().Contain("Candidate credential witness must retain its private immutable nonformatting shape."); + witnessText.Operand = fixedText; + var transport = api.GetType(TransportSource).Methods.Single(method => method.Name == "Capture"); + var formatting = transport.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target + && target.FullName == "System.String System.Object::ToString()"); + var constrained = formatting.Previous; + var headerType = constrained.Operand; + constrained.Operand = identity; + Offenders(modules).Should().Contain("Opaque typed identity/feature export: " + transport.FullName, + "only the constrained User-Agent value can use the transport formatting exception"); + constrained.Operand = headerType; + var headerRead = transport.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target + && target.FullName == "Microsoft.Extensions.Primitives.StringValues Microsoft.AspNetCore.Http.IHeaderDictionary::get_UserAgent()"); + var headerGetter = headerRead.Operand; + headerRead.OpCode = OpCodes.Nop; + headerRead.Operand = null; + Offenders(modules).Should().Contain("Opaque typed identity/feature export: " + transport.FullName, + "the formatter must retain the actual User-Agent acquisition, not just a StringValues local"); + headerRead.OpCode = OpCodes.Callvirt; + headerRead.Operand = headerGetter; + Offenders(modules).Should().BeEmpty("restoring actual header acquisition and formatting restores the clean graph"); + var classifier = api.GetType(Authentication + "OperationHttpClassifier").Methods.Single(method => method.Name == "Classify"); + var rolesRead = classifier.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target + && target.FullName == "System.String Microsoft.AspNetCore.Authorization.IAuthorizeData::get_Roles()"); + var emptyCheck = rolesRead.Next; + var predicate = emptyCheck.Operand; + emptyCheck.OpCode = OpCodes.Nop; + emptyCheck.Operand = null; + Offenders(modules).Should().Contain(value => value.StartsWith("Raw claim/role authority:", StringComparison.Ordinal), + "a role read is allowed only to refuse nonempty metadata immediately"); + emptyCheck.OpCode = OpCodes.Call; + emptyCheck.Operand = predicate; + var emptyBranch = emptyCheck.Next; + var admittedWhenEmpty = emptyBranch.OpCode; + emptyBranch.OpCode = OpCodes.Brfalse_S; + Offenders(modules).Should().Contain(value => value.StartsWith("Raw claim/role authority:", StringComparison.Ordinal), + "nonempty role metadata cannot enter the admitted branch"); + emptyBranch.OpCode = admittedWhenEmpty; + foreach (var type in new[] { Authentication + "OperationAuthorizationHandler", Authentication + "OperationClassificationMiddleware" }) + { + var operation = Methods([api.GetType(type)]).Single(method => method.Definition.Body.Instructions.Any(instruction => + instruction.Operand is MethodReference target && target.Name == "CheckHttpAsync")); + var check = operation.Definition.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target && target.Name == "CheckHttpAsync"); + var endpoint = check.Previous.Previous.Previous.Previous; + var acquisition = endpoint.Operand; + endpoint.OpCode = OpCodes.Ldnull; + endpoint.Operand = null; + Offenders(modules).Should().Contain("Opaque typed identity/feature export: " + operation.Site, + "the opaque operation argument must be the actual same-request endpoint"); + endpoint.OpCode = OpCodes.Call; + endpoint.Operand = acquisition; + var identityRead = operation.Definition.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target + && Is(target.DeclaringType, Feature) && target.Name == "get_Identity"); + var format = new[] { Instruction.Create(OpCodes.Dup), Instruction.Create(OpCodes.Callvirt, + api.ImportReference(typeof(object).GetMethod(nameof(ToString), Type.EmptyTypes)!)), Instruction.Create(OpCodes.Pop) }; + var processor = operation.Definition.Body.GetILProcessor(); + var previous = identityRead; + foreach (var instruction in format) { processor.InsertAfter(previous, instruction); previous = instruction; } + Offenders(modules).Should().Contain("Opaque typed identity/feature export: " + operation.Site, + "an approved endpoint forwarding site must still reject extra formatting of its actual identity"); + foreach (var instruction in format) processor.Remove(instruction); + Offenders(modules).Should().BeEmpty("restoring endpoint acquisition and removing identity formatting restores the exact owner"); + } var actor = identity.Properties.Single(property => property.Name == "ActorUserId").SetMethod; var init = actor.ReturnType; actor.ReturnType = api.TypeSystem.Void; @@ -178,6 +250,11 @@ public void Authority_census_detects_missing_subjects_mutability_and_extra_trust [InlineData("feature-property")] [InlineData("feature-field")] [InlineData("feature-constructor")] + [InlineData("witness-method")] + [InlineData("witness-property")] + [InlineData("witness-field")] + [InlineData("witness-constructor")] + [InlineData("candidate-opaque-forwarding")] [InlineData("generic-export")] [InlineData("object-export")] [InlineData("identity-constraint")] @@ -186,9 +263,27 @@ public void Authority_census_closes_shape_and_approved_owner_exports(string muta { Offenders(modules).Should().BeEmpty(); var api = modules.Single(module => module.Assembly.Name.Name == "LearnStack.Api"); - var owner = api.GetType(mutation.StartsWith("feature-", StringComparison.Ordinal) ? Feature - : mutation.StartsWith("verified-", StringComparison.Ordinal) ? Verified : Identity); + var owner = mutation.StartsWith("witness-", StringComparison.Ordinal) + ? api.GetType(CandidateSource).NestedTypes.Single(type => type.FullName == CredentialWitness) + : api.GetType(mutation.StartsWith("feature-", StringComparison.Ordinal) ? Feature + : mutation.StartsWith("verified-", StringComparison.Ordinal) ? Verified : Identity); var factory = api.GetType(Profile).Methods.Single(method => method.Name == "TryReadValidated"); + if (mutation == "candidate-opaque-forwarding") + { + var minter = api.GetType(CandidateSource).Methods.Single(method => method.Name == "ForValidatedScope"); + var forwarding = minter.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == "LearnStack.SharedKernel.Permissions.AuthorizationCandidate/MintingSource" + && target.Name == "ForCandidate"); + // Erasing any other object here must not inherit the one closed + // private witness constructor's exemption. + var construction = forwarding.Previous; + var original = construction.Operand; + construction.Operand = api.ImportReference(typeof(object).GetConstructor(Type.EmptyTypes)!); + Offenders(modules).Should().Contain(value => value.StartsWith("Opaque typed identity/feature export:", StringComparison.Ordinal)); + construction.Operand = original; + Offenders(modules).Should().BeEmpty("restoring the exact witness construction restores the clean graph"); + return; + } if (mutation.EndsWith("-property", StringComparison.Ordinal)) owner.Properties.Add(new PropertyDefinition("Extra", PropertyAttributes.None, api.TypeSystem.String)); else if (mutation.EndsWith("-field", StringComparison.Ordinal)) @@ -273,24 +368,31 @@ public void Authority_census_rejects_actual_identity_forwarding_to_opaque_helper var setter = validated.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target && Is(target.DeclaringType, Feature) && target.Name == "set_VerifiedSubject"); var identity = validated.Body.Variables.Single(variable => Is(variable.VariableType, Verified)); - var forwarding = new List(); - if (array) - forwarding.AddRange([Instruction.Create(OpCodes.Ldc_I4_1), Instruction.Create(OpCodes.Newarr, api.TypeSystem.Object), - Instruction.Create(OpCodes.Dup), Instruction.Create(OpCodes.Ldc_I4_0)]); - forwarding.Add(Instruction.Create(OpCodes.Ldloc, identity)); - if (array) forwarding.Add(Instruction.Create(OpCodes.Stelem_Ref)); - forwarding.Add(Instruction.Create(OpCodes.Call, helper)); - var processor = validated.Body.GetILProcessor(); - var previous = setter; - foreach (var instruction in forwarding) + var transport = api.GetType(TransportSource).Methods.Single(method => method.Name == "Capture"); + var transportIdentity = transport.Body.Variables.Single(variable => Is(variable.VariableType, Identity)); + var identityStore = transport.Body.Instructions.Single(instruction => StoresLocal(instruction, transportIdentity)); + foreach (var (owner, insertion, projection) in new[] + { (validated, setter, identity), (transport, identityStore, transportIdentity) }) { - processor.InsertAfter(previous, instruction); - previous = instruction; + var forwarding = new List(); + if (array) + forwarding.AddRange([Instruction.Create(OpCodes.Ldc_I4_1), Instruction.Create(OpCodes.Newarr, api.TypeSystem.Object), + Instruction.Create(OpCodes.Dup), Instruction.Create(OpCodes.Ldc_I4_0)]); + forwarding.Add(Instruction.Create(OpCodes.Ldloc, projection)); + if (array) forwarding.Add(Instruction.Create(OpCodes.Stelem_Ref)); + forwarding.Add(Instruction.Create(OpCodes.Call, helper)); + var processor = owner.Body.GetILProcessor(); + var previous = insertion; + foreach (var instruction in forwarding) + { + processor.InsertAfter(previous, instruction); + previous = instruction; + } + Offenders(modules).Should().Contain("Opaque typed identity/feature export: " + owner.FullName, + "the real approved caller forwarded its actual validated identity into " + (array ? "object[]" : "object")); + foreach (var instruction in forwarding) processor.Remove(instruction); + Offenders(modules).Should().BeEmpty("removing the forwarding restores the clean graph while retaining the helper"); } - Offenders(modules).Should().Contain(value => value.StartsWith("Opaque typed identity/feature export:", StringComparison.Ordinal), - "the real approved TokenValidated caller forwarded its actual validated identity into " + (array ? "object[]" : "object")); - foreach (var instruction in forwarding) processor.Remove(instruction); - Offenders(modules).Should().BeEmpty("removing the forwarding restores the clean graph while retaining the helper"); }); /// ADR-0064: erasing an identity through storage is an export even with a parameterless reader. diff --git a/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.Shapes.cs b/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.Shapes.cs index aae24b9c2..f7d896748 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.Shapes.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.Shapes.cs @@ -36,10 +36,23 @@ public sealed partial class AuthenticationAuthorityTests [BindingResolver + ".ResolveExistingAsync"] = Signature(BindingResolver, "ResolveExistingAsync", TaskType + "`1", Verified, "System.Threading.CancellationToken"), [Provenance + "..ctor"] = Signature(Provenance, ".ctor", "System.Void", Verified), [Provenance + ".Matches"] = Signature(Provenance, "Matches", "System.Boolean", Verified), + [CandidateSource + ".ForValidatedScope"] = Signature(CandidateSource, "ForValidatedScope", "LearnStack.SharedKernel.Permissions.AuthorizationCandidate", "LearnStack.SharedKernel.Identifiers.TenantId", Organization), + [CandidateSource + ".IsEvidenceCurrent"] = Signature(CandidateSource, "IsEvidenceCurrent", "System.Boolean", "System.Object"), + [CandidateSource + ".MatchesIdentity"] = Signature(CandidateSource, "MatchesIdentity", "System.Boolean", Identity, Verified), + [TransportSource + ".Capture"] = Signature(TransportSource, "Capture", "LearnStack.SharedKernel.Audit.OperationTransportProvenance", "LearnStack.SharedKernel.Permissions.OperationInvocation"), + [Authentication + "OperationClassificationMiddleware.InvokeAsync"] = Signature(Authentication + "OperationClassificationMiddleware", "InvokeAsync", TaskType, Http, + Authentication + "OperationHttpClassifier", "System.Func`1", Context), + [Authentication + "OperationAuthorizationHandler.HandleAsync"] = Signature(Authentication + "OperationAuthorizationHandler", "HandleAsync", TaskType, + "Microsoft.AspNetCore.Authorization.AuthorizationHandlerContext"), + [CredentialWitness + "..ctor"] = Signature(CredentialWitness, ".ctor", "System.Void", Http, Host, Verified, Identity, + Authentication + "SubjectAdmissionDecision", Provenance), + [CredentialWitness + ".get_Subject"] = Signature(CredentialWitness, "get_Subject", Verified), + [CredentialWitness + ".get_Identity"] = Signature(CredentialWitness, "get_Identity", Identity), [Resolver + ".BuildAttemptAsync"] = Signature(Resolver, "BuildAttemptAsync", TaskType + "`1<" + Attempt + ">", Http, Host, ScopeValidator, Membership, Identity), [Resolver + ".RecordRefusalAsync"] = Signature(Resolver, "RecordRefusalAsync", TaskType, Attempt, Host, Identity, Recorder), [Resolver + ".InvokeAsync"] = Signature(Resolver, "InvokeAsync", TaskType, Http, ScopeValidator, Membership, Recorder), - ["LearnStack.Api.Tenancy.TenantAssertionMiddleware.InvokeAsync"] = Signature("LearnStack.Api.Tenancy.TenantAssertionMiddleware", "InvokeAsync", TaskType, Http, Context, Recorder), + ["LearnStack.Api.Tenancy.TenantAssertionMiddleware.InvokeAsync"] = Signature("LearnStack.Api.Tenancy.TenantAssertionMiddleware", "InvokeAsync", TaskType, Http, Context, Recorder, + "LearnStack.Api.Tenancy.IClaimContextAssertionRecorder"), [Authentication + "TenantSurfaceAdmissionMiddleware.InvokeAsync"] = Signature(Authentication + "TenantSurfaceAdmissionMiddleware", "InvokeAsync", TaskType, Http, Context), [Authentication + "TenantSurfaceAdmissionMiddleware.HasHostAuthority"] = Signature(Authentication + "TenantSurfaceAdmissionMiddleware", "HasHostAuthority", "System.Boolean", Context, Identity), [Authentication + "TenantSubjectAdmissionMiddleware.InvokeAsync"] = Signature(Authentication + "TenantSubjectAdmissionMiddleware", "InvokeAsync", TaskType, Http), @@ -143,6 +156,36 @@ private static bool HasClosedProvenanceShape(TypeDefinition provenance) => prove && !provenance.HasProperties && !provenance.HasEvents && !provenance.HasInterfaces && !provenance.HasNestedTypes && !provenance.HasGenericParameters; + private static bool HasClosedCredentialWitnessShape(TypeDefinition witness) + { + var properties = new Dictionary(StringComparer.Ordinal) + { + ["Http"] = Http, + ["Host"] = Host, + ["Subject"] = Verified, + ["Identity"] = Identity, + ["Decision"] = Authentication + "SubjectAdmissionDecision", + ["Provenance"] = Provenance + }; + return witness.IsSealed && witness.IsNestedPrivate && witness.BaseType.FullName == "System.Object" + && !witness.HasInterfaces && !witness.HasEvents && !witness.HasNestedTypes && !witness.HasGenericParameters + && Same(witness.Methods.Select(method => method.FullName), properties.Select(property => + Signature(CredentialWitness, "get_" + property.Key, property.Value)) + .Append(ApprovedSites[CredentialWitness + "..ctor"]) + .Append(Signature(CredentialWitness, "ToString", "System.String"))) + && Same(witness.Properties.Select(property => property.Name), properties.Keys) + && witness.Properties.All(property => property.SetMethod is null && property.GetMethod.IsAssembly + && property.PropertyType.FullName == properties[property.Name]) + && Same(witness.Fields.Select(field => field.Name + ":" + field.FieldType.FullName), properties.Select(property => + "<" + property.Key + ">k__BackingField:" + property.Value)) + && witness.Fields.All(field => field.IsPrivate && field.IsInitOnly && !field.IsStatic) + && witness.Methods.Single(method => method.Name == "ToString").Body.Instructions + .Where(instruction => instruction.OpCode != OpCodes.Nop).Select(instruction => instruction.OpCode.Code) + .SequenceEqual([Code.Ldstr, Code.Ret]) + && witness.Methods.Single(method => method.Name == "ToString").Body.Instructions + .Single(instruction => instruction.OpCode == OpCodes.Ldstr).Operand as string == "CredentialWitness"; + } + private static bool IsOpaqueProjectionArgument(TypeReference type, MethodReference? method = null, TypeReference? owner = null) { if (type is GenericParameter parameter) @@ -176,7 +219,100 @@ private static bool ExportsProjection(Method method) => method.Definition.Body.I instruction.OpCode.Code is Code.Box or Code.Stelem_Ref || instruction.OpCode.Code is Code.Stfld or Code.Stsfld && instruction.Operand is FieldReference field && IsOpaqueProjectionArgument(field.FieldType, owner: field.DeclaringType) - || instruction.Operand is MethodReference target && ExportsProjection(target)); + || instruction.Operand is MethodReference target && ExportsProjection(target) + && !IsClosedWitnessForwarding(method, instruction, target) + && !IsClosedUserAgentFormatting(method, instruction, target) + && !IsClosedHttpEndpointForwarding(method, instruction, target)); + + private static bool IsClosedRolesAbsenceCheck(Call call) + { + if (call.Source.Site != Signature(Authentication + "OperationHttpClassifier", "Classify", Authentication + "HttpOperationClassification", + "Microsoft.AspNetCore.Http.Endpoint", "LearnStack.SharedKernel.Permissions.OperationPolicy&") + || call.Target.FullName != "System.String Microsoft.AspNetCore.Authorization.IAuthorizeData::get_Roles()") + return false; + var reads = call.Source.Definition.Body.Instructions.Where(instruction => instruction.Operand is MethodReference target + && target.FullName == call.Target.FullName).ToArray(); + return reads.Length == 1 && reads[0].OpCode == OpCodes.Callvirt + && reads[0].Next is { OpCode.Code: Code.Call, Operand: MethodReference empty } + && empty.FullName == "System.Boolean System.String::IsNullOrEmpty(System.String)" + && reads[0].Next.Next is { OpCode.Code: Code.Brtrue or Code.Brtrue_S, Operand: Instruction admitted } branch + && branch.Next?.OpCode == OpCodes.Ldc_I4_1 && branch.Next.Next?.OpCode == OpCodes.Ret + && admitted.OpCode == OpCodes.Ldarg_2 && admitted.Next?.OpCode.Code is Code.Ldloc or Code.Ldloc_S or Code.Ldloc_0 or Code.Ldloc_1 or Code.Ldloc_2 or Code.Ldloc_3 + && admitted.Next.Next?.OpCode == OpCodes.Stind_Ref && admitted.Next.Next.Next?.OpCode == OpCodes.Ldc_I4_2 + && admitted.Next.Next.Next.Next?.OpCode == OpCodes.Ret; + } + + // CheckHttp's opaque argument is exactly the selected Endpoint from the same + // HttpContext as its cancellation token, never a projection or caller object. + private static bool IsClosedHttpEndpointForwarding(Method method, Instruction instruction, MethodReference target) + { + if (method.Site != ApprovedSites[Authentication + "OperationAuthorizationHandler.HandleAsync"] + && method.Site != ApprovedSites[Authentication + "OperationClassificationMiddleware.InvokeAsync"] + || instruction.OpCode != OpCodes.Callvirt + || target.FullName != "System.Threading.Tasks.Task`1> LearnStack.Application.Authorization.OperationAuthorizationCoordinator::CheckHttpAsync(System.Type,System.Object,System.Threading.CancellationToken)" + || instruction.Previous?.Operand is not MethodReference cancellation + || cancellation.FullName != "System.Threading.CancellationToken Microsoft.AspNetCore.Http.HttpContext::get_RequestAborted()" + || instruction.Previous.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference http } + || http.FieldType.FullName != Http || instruction.Previous.Previous.Previous?.OpCode != OpCodes.Ldarg_0 + || instruction.Previous.Previous.Previous.Previous is not { } endpoint + || endpoint.Operand is not MethodReference acquisition + || acquisition.FullName != "Microsoft.AspNetCore.Http.Endpoint Microsoft.AspNetCore.Http.EndpointHttpContextExtensions::GetEndpoint(Microsoft.AspNetCore.Http.HttpContext)" + || endpoint.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference endpointHttp } + || endpointHttp.FullName != http.FullName || endpoint.Previous.Previous?.OpCode != OpCodes.Ldarg_0 + || endpoint.Previous.Previous.Previous?.Operand is not MethodReference policy) + return false; + return policy.FullName == "System.Type LearnStack.SharedKernel.Permissions.OperationPolicy::get_RequestType()"; + } + + // Release emits a constrained Object.ToString for the StringValues value. + // Permit only the actual User-Agent local, passed directly as ForTransport's + // final string argument; identity/object formatting elsewhere stays opaque. + private static bool IsClosedUserAgentFormatting(Method method, Instruction instruction, MethodReference target) + { + const string StringValues = "Microsoft.Extensions.Primitives.StringValues"; + if (method.Site != ApprovedSites[TransportSource + ".Capture"] + || target.FullName != "System.String System.Object::ToString()" + || instruction.Previous is not { OpCode.Code: Code.Constrained, Operand: TypeReference constrained } + || constrained.FullName != StringValues + || instruction.Previous.Previous is not { OpCode.Code: Code.Ldloca or Code.Ldloca_S, Operand: VariableDefinition local } receiver + || local.VariableType.FullName != StringValues) + return false; + var stores = method.Definition.Body.Instructions.Where(candidate => StoresLocal(candidate, local)).ToArray(); + if (stores.Length != 1 + || stores[0].Previous?.Operand is not MethodReference header + || header.FullName != "Microsoft.Extensions.Primitives.StringValues Microsoft.AspNetCore.Http.IHeaderDictionary::get_UserAgent()" + || stores[0].Previous.Previous?.Operand is not MethodReference headers + || headers.FullName != "Microsoft.AspNetCore.Http.IHeaderDictionary Microsoft.AspNetCore.Http.HttpRequest::get_Headers()" + || stores[0].Previous.Previous.Previous?.Operand is not MethodReference request + || request.FullName != "Microsoft.AspNetCore.Http.HttpRequest Microsoft.AspNetCore.Http.HttpContext::get_Request()") + return false; + return receiver.Previous is { OpCode.Code: Code.Brfalse or Code.Brfalse_S, Operand: Instruction absent } + && receiver.Previous.Previous?.Operand is MethodReference count + && count.FullName == "System.Int32 Microsoft.Extensions.Primitives.StringValues::get_Count()" + && receiver.Previous.Previous.Previous is { OpCode.Code: Code.Ldloca or Code.Ldloca_S, Operand: VariableDefinition counted } + && counted == local + && instruction.Next is { OpCode.Code: Code.Br or Code.Br_S, Operand: Instruction transport } jump + && jump.Next == absent && absent.OpCode == OpCodes.Ldnull && absent.Next == transport + && transport.Operand is MethodReference mint + && mint.FullName == "LearnStack.SharedKernel.Audit.OperationTransportProvenance LearnStack.SharedKernel.Audit.OperationTransportProvenance/MintingSource::ForTransport(LearnStack.SharedKernel.Permissions.OperationInvocation,System.String,System.Net.IPAddress,System.String)" + && transport.Next?.OpCode == OpCodes.Ret; + } + + private static bool StoresLocal(Instruction instruction, VariableDefinition local) => instruction.OpCode.Code switch + { + Code.Stloc or Code.Stloc_S => instruction.Operand == local, + Code.Stloc_0 => local.Index == 0, + Code.Stloc_1 => local.Index == 1, + Code.Stloc_2 => local.Index == 2, + Code.Stloc_3 => local.Index == 3, + _ => false + }; + + private static bool IsClosedWitnessForwarding(Method method, Instruction instruction, MethodReference target) => + method.Site == ApprovedSites[CandidateSource + ".ForValidatedScope"] + && target.FullName == "LearnStack.SharedKernel.Permissions.AuthorizationCandidate LearnStack.SharedKernel.Permissions.AuthorizationCandidate/MintingSource::ForCandidate(LearnStack.SharedKernel.Permissions.AuthorizationSourceKind,LearnStack.SharedKernel.Identifiers.UserId,LearnStack.SharedKernel.Identifiers.TenantId,System.Nullable`1,System.Object)" + && instruction.Previous is { OpCode.Code: Code.Newobj, Operand: MethodReference constructor } + && constructor.FullName == ApprovedSites[CredentialWitness + "..ctor"]; private static bool ExportsProjection(MethodReference target) => // At these few trust-boundary owners an untyped object/object[] diff --git a/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.cs b/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.cs index a918e63e8..269494bdb 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AuthenticationAuthorityTests.cs @@ -20,6 +20,9 @@ public sealed partial class AuthenticationAuthorityTests private const string BindingAdmission = Authentication + nameof(TenantIdentityBindingAdmissionMiddleware); private const string BindingResolver = Authentication + nameof(TenantIdentityBindingResolver); private const string Provenance = Authentication + nameof(TenantSubjectAdmissionProvenance); + private const string CandidateSource = Authentication + nameof(TenantAuthorizationCandidateSource); + private const string TransportSource = Authentication + nameof(HttpAuthorizationTransportSource); + private const string CredentialWitness = CandidateSource + "/CredentialWitness"; private const string Resolver = "LearnStack.Api.Tenancy.TenantResolverMiddleware"; private static readonly string[] IdentityProperties = ["ActorUserId", "Issuer", "OrganizationId", "Subject", "TenantId"]; private static readonly Dictionary IdentityTypes = new(StringComparer.Ordinal) @@ -66,7 +69,8 @@ private static SortedSet Offenders(ModuleDefinition[] modules, foreach (var call in calls.Where(call => (rawReader ?? IsRawAuthorityRead)(call.Target) && !(call.Source.Site == ExpectedSite(Events + ".TokenValidated") - && call.Target.FullName == "System.String Microsoft.IdentityModel.JsonWebTokens.JsonWebToken::get_EncodedToken()"))) + && call.Target.FullName == "System.String Microsoft.IdentityModel.JsonWebTokens.JsonWebToken::get_EncodedToken()") + && !IsClosedRolesAbsenceCheck(call))) errors.Add("Raw claim/role authority: " + call.Source.Site + " -> " + call.Target.FullName); // Object virtual dispatch and generic formatting can export a positional // record without an identity-declared call. Close acquisition, holders @@ -111,19 +115,22 @@ private static SortedSet Offenders(ModuleDefinition[] modules, // Issuer/sub may select a private accounting partition, never permission. // Scope/actor consumption remains explicit even inside the API assembly. - Consumers("Issuer"); - Consumers("Subject"); - VerifiedConsumers("Issuer", Limiter + ".AttemptAcquire", BindingResolver + ".ResolveExistingAsync", BindingAdmission + ".InvokeAsync"); - VerifiedConsumers("Subject", Limiter + ".AttemptAcquire", BindingResolver + ".ResolveExistingAsync", BindingAdmission + ".InvokeAsync"); - VerifiedConsumers("ClaimedActor", BindingResolver + ".ResolveExistingAsync"); - VerifiedConsumers("TenantId", BindingAdmission + ".InvokeAsync"); - VerifiedConsumers("OrganizationId", BindingAdmission + ".InvokeAsync"); + Consumers("Issuer", CandidateSource + ".MatchesIdentity"); + Consumers("Subject", CandidateSource + ".MatchesIdentity"); + VerifiedConsumers("Issuer", Limiter + ".AttemptAcquire", BindingResolver + ".ResolveExistingAsync", BindingAdmission + ".InvokeAsync", CandidateSource + ".MatchesIdentity"); + VerifiedConsumers("Subject", Limiter + ".AttemptAcquire", BindingResolver + ".ResolveExistingAsync", BindingAdmission + ".InvokeAsync", CandidateSource + ".MatchesIdentity"); + VerifiedConsumers("ClaimedActor", BindingResolver + ".ResolveExistingAsync", CandidateSource + ".MatchesIdentity"); + VerifiedConsumers("TenantId", BindingAdmission + ".InvokeAsync", CandidateSource + ".MatchesIdentity"); + VerifiedConsumers("OrganizationId", BindingAdmission + ".InvokeAsync", CandidateSource + ".MatchesIdentity"); Consumers("ActorUserId", Resolver + ".BuildAttemptAsync", Resolver + ".RecordRefusalAsync", "LearnStack.Api.Tenancy.TenantAssertionMiddleware.InvokeAsync", - Authentication + "TenantSurfaceAdmissionMiddleware.HasHostAuthority"); + Authentication + "TenantSurfaceAdmissionMiddleware.HasHostAuthority", CandidateSource + ".ForValidatedScope", CandidateSource + ".MatchesIdentity", + TransportSource + ".Capture"); Consumers("TenantId", Resolver + ".BuildAttemptAsync", Resolver + ".InvokeAsync", - Authentication + "TenantSurfaceAdmissionMiddleware.HasHostAuthority"); - Consumers("OrganizationId", Resolver + ".BuildAttemptAsync", Authentication + "TenantSurfaceAdmissionMiddleware.HasHostAuthority"); + Authentication + "TenantSurfaceAdmissionMiddleware.HasHostAuthority", CandidateSource + ".ForValidatedScope", CandidateSource + ".MatchesIdentity", + TransportSource + ".Capture"); + Consumers("OrganizationId", Resolver + ".BuildAttemptAsync", Authentication + "TenantSurfaceAdmissionMiddleware.HasHostAuthority", + CandidateSource + ".ForValidatedScope", CandidateSource + ".MatchesIdentity"); foreach (var method in methods) foreach (var instruction in method.Definition.Body.Instructions) @@ -168,6 +175,9 @@ private static SortedSet Offenders(ModuleDefinition[] modules, var provenance = types.Where(type => Is(type, Provenance)).ToArray(); if (provenance.Length != 1 || !HasClosedProvenanceShape(provenance[0])) errors.Add("Subject admission provenance must retain its closed immutable request-local shape."); + var witnesses = types.Where(type => Is(type, CredentialWitness)).ToArray(); + if (witnesses.Length != 1 || !HasClosedCredentialWitnessShape(witnesses[0])) + errors.Add("Candidate credential witness must retain its private immutable nonformatting shape."); return errors; void Require(Func target, params string[] owners) diff --git a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobAdmissionBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobAdmissionBoundaryTests.cs index 220484dbd..f81e731b6 100644 --- a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobAdmissionBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobAdmissionBoundaryTests.cs @@ -6,6 +6,7 @@ using LearnStack.SharedKernel.Jobs; using Mono.Cecil; using Mono.Cecil.Cil; +using Xunit; namespace LearnStack.Tests.Architecture; @@ -467,73 +468,191 @@ private static void CheckJobRoutingCredentials(SortedSet offenders, Prod accesses); } - private static void AssertJobStepThreeControls(ProductionCensus census) + public sealed partial class JobAuthorityCensusMutationTests { - var types = census.Types; - var application = census.Modules.Single(module => module.Assembly.Name.Name == DeliveryApplication); - var routing = types[(InfrastructureAssembly, JobRoutingStore)]; - foreach (var capability in routing.Methods.Where(method => method.Name is ".ctor" or "TryClaimAsync" or "TryClaimOwnedAsync")) - foreach (var opcode in new[] { OpCodes.Call, OpCodes.Callvirt, OpCodes.Ldftn, OpCodes.Ldvirtftn }) - AssertMutation(census, "a confined routing-store escape", () => - AddCallProbe(application, "ExtraRoutingStoreConsumer", capability, opcode), CredentialOffenders); - AssertMutation(census, "a routing-source forwarding overload", () => - AddBoundaryFieldGetter(routing, "TryClaimAsync", "_source"), CredentialOffenders); - foreach (var holder in new[] { JobClosedActivator, JobBoundInvocation }) - AssertMutation(census, "a closed activator routing-capability forwarding helper", () => - AddBoundaryFieldGetter(types[(InfrastructureAssembly, holder)], "UnexpectedRoutingAccessor", "P"), CredentialOffenders); - var activator = types[(InfrastructureAssembly, JobClosedActivator)]; - var bound = types[(InfrastructureAssembly, JobBoundInvocation)]; - var binding = types[(InfrastructureAssembly, JobInvocationContract)].Methods.Single(method => method.Name == "BindGeneration"); - var bindingCapabilities = new[] { activator.Methods.Single(method => method.Name == ".ctor"), - activator.Methods.Single(method => method.Name == "Bind"), bound.Methods.Single(method => method.Name == ".ctor"), binding } - .Concat(types[(InfrastructureAssembly, JobInvocationContract)].Methods.Where(method => method.Name == "CreateActivator")).ToArray(); - bindingCapabilities.Should().HaveCount(6, "both exact activator factory declarations join the actual binding capability chain"); - foreach (var capability in bindingCapabilities) + [Fact] + public void Background_job_routing_credentials_reject_store_and_holder_escapes() { - foreach (var opcode in new[] { OpCodes.Call, OpCodes.Callvirt, OpCodes.Ldftn, OpCodes.Ldvirtftn, OpCodes.Newobj }) - AssertMutation(census, "an extra generation-bound activator capability through " + opcode, () => - AddCallProbe(application, "ExtraBoundActivatorCapability", capability, opcode), CredentialOffenders); - AssertMutation(census, "an asynchronous generation-bound activator capability", () => - AddAsyncCallProbe(application, "AsyncBoundActivatorCapability", capability), CredentialOffenders); - AssertMutation(census, "an activator capability overload borrowing the approved name", () => - { - var method = AddCall(capability.DeclaringType, capability.Name, capability, OpCodes.Call); - return () => capability.DeclaringType.Methods.Remove(method); - }, CredentialOffenders); + using var census = ProductionCensus.Read(); + var types = census.Types; + var application = census.Modules.Single(module => module.Assembly.Name.Name == DeliveryApplication); + var routing = types[(InfrastructureAssembly, JobRoutingStore)]; + foreach (var capability in routing.Methods.Where(method => method.Name is ".ctor" or "TryClaimAsync" or "TryClaimOwnedAsync")) + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Callvirt, OpCodes.Ldftn, OpCodes.Ldvirtftn }) + AssertMutation(census, "a confined routing-store escape", () => + AddCallProbe(application, "ExtraRoutingStoreConsumer", capability, opcode), CredentialOffenders); + AssertMutation(census, "a routing-source forwarding overload", () => + AddBoundaryFieldGetter(routing, "TryClaimAsync", "_source"), CredentialOffenders); + foreach (var holder in new[] { JobClosedActivator, JobBoundInvocation }) + AssertMutation(census, "a closed activator routing-capability forwarding helper", () => + AddBoundaryFieldGetter(types[(InfrastructureAssembly, holder)], "UnexpectedRoutingAccessor", "P"), CredentialOffenders); } - var bindingSites = OwnedBodies(census).SelectMany(item => item.Body.Body.Instructions) - .Where(instruction => instruction.Operand is MethodReference target && bindingCapabilities.Any(capability => - SameType(target.DeclaringType, capability.DeclaringType) && target.Name == capability.Name)).ToArray(); - bindingSites.Should().HaveCount(7, "both controlled factory calls, two activator constructors, bound invocation and both binding seams must be live"); - foreach (var instruction in bindingSites) + + [Fact] + public void Background_job_generation_binding_rejects_extra_capabilities_and_missing_sites() { - AssertMutation(census, "a missing actual generation-bound construction or binding site", () => - ReplaceInstruction(instruction, OpCodes.Nop, null!), CredentialOffenders); - AssertMutation(census, "a delegate escape inside an approved binding caller", () => - ReplaceInstruction(instruction, OpCodes.Ldftn, instruction.Operand), CredentialOffenders); + using var census = ProductionCensus.Read(); + var types = census.Types; + var application = census.Modules.Single(module => module.Assembly.Name.Name == DeliveryApplication); + var activator = types[(InfrastructureAssembly, JobClosedActivator)]; + var bound = types[(InfrastructureAssembly, JobBoundInvocation)]; + var binding = types[(InfrastructureAssembly, JobInvocationContract)].Methods.Single(method => method.Name == "BindGeneration"); + var bindingCapabilities = new[] { activator.Methods.Single(method => method.Name == ".ctor"), + activator.Methods.Single(method => method.Name == "Bind"), bound.Methods.Single(method => method.Name == ".ctor"), binding } + .Concat(types[(InfrastructureAssembly, JobInvocationContract)].Methods.Where(method => method.Name == "CreateActivator")).ToArray(); + bindingCapabilities.Should().HaveCount(6, "both exact activator factory declarations join the actual binding capability chain"); + foreach (var capability in bindingCapabilities) + { + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Callvirt, OpCodes.Ldftn, OpCodes.Ldvirtftn, OpCodes.Newobj }) + AssertMutation(census, "an extra generation-bound activator capability through " + opcode, () => + AddCallProbe(application, "ExtraBoundActivatorCapability", capability, opcode), CredentialOffenders); + AssertMutation(census, "an asynchronous generation-bound activator capability", () => + AddAsyncCallProbe(application, "AsyncBoundActivatorCapability", capability), CredentialOffenders); + AssertMutation(census, "an activator capability overload borrowing the approved name", () => + { + var method = AddCall(capability.DeclaringType, capability.Name, capability, OpCodes.Call); + return () => capability.DeclaringType.Methods.Remove(method); + }, CredentialOffenders); + } + var bindingSites = OwnedBodies(census).SelectMany(item => item.Body.Body.Instructions) + .Where(instruction => instruction.Operand is MethodReference target && bindingCapabilities.Any(capability => + SameType(target.DeclaringType, capability.DeclaringType) && target.Name == capability.Name)).ToArray(); + bindingSites.Should().HaveCount(7, "both controlled factory calls, two activator constructors, bound invocation and both binding seams must be live"); + foreach (var instruction in bindingSites) + { + AssertMutation(census, "a missing actual generation-bound construction or binding site", () => + ReplaceInstruction(instruction, OpCodes.Nop, null!), CredentialOffenders); + AssertMutation(census, "a delegate escape inside an approved binding caller", () => + ReplaceInstruction(instruction, OpCodes.Ldftn, instruction.Operand), CredentialOffenders); + } + var cleanActivator = AddProbe(application, "CleanBoundInvocationIdentityObserver"); + AddCall(cleanActivator, "ObserveIdentity", bound.Methods.Single(method => method.Name == "get_Type"), OpCodes.Callvirt); + try { EvaluateDetector(census, CredentialOffenders).Should().BeEmpty("immutable invocation identity does not forward routing authority"); } + finally { application.Types.Remove(cleanActivator); } } - var cleanActivator = AddProbe(application, "CleanBoundInvocationIdentityObserver"); - AddCall(cleanActivator, "ObserveIdentity", bound.Methods.Single(method => method.Name == "get_Type"), OpCodes.Callvirt); - try { EvaluateDetector(census, CredentialOffenders).Should().BeEmpty("immutable invocation identity does not forward routing authority"); } - finally { application.Types.Remove(cleanActivator); } - var executor = types[(InfrastructureAssembly, JobExecutorType)]; - AssertMutation(census, "dynamic business construction in the framework executor", () => + + [Fact] + public void Background_job_framework_capabilities_reject_extra_dispatch_and_issuance() { - var method = AddCall(executor, "UnexpectedBusinessActivation", executor.Module.ImportReference( - typeof(Activator).GetMethod(nameof(Activator.CreateInstance), [typeof(Type)])!), OpCodes.Call); - return () => executor.Methods.Remove(method); - }, JobStepThreeOffenders); - var send = OwnedBodies(census).Where(item => item.Owner.DeclaringType == executor) - .SelectMany(item => MethodOperands(item.Body)).First(target => target.Name == "Send" && target.DeclaringType.FullName == "MediatR.ISender"); - AssertMutation(census, "an extra executor helper dispatching a request", () => + using var census = ProductionCensus.Read(); + var types = census.Types; + var application = census.Modules.Single(module => module.Assembly.Name.Name == DeliveryApplication); + var executor = types[(InfrastructureAssembly, JobExecutorType)]; + AssertMutation(census, "dynamic business construction in the framework executor", () => + { + var method = AddCall(executor, "UnexpectedBusinessActivation", executor.Module.ImportReference( + typeof(Activator).GetMethod(nameof(Activator.CreateInstance), [typeof(Type)])!), OpCodes.Call); + return () => executor.Methods.Remove(method); + }, JobStepThreeOffenders); + var send = OwnedBodies(census).Where(item => item.Owner.DeclaringType == executor) + .SelectMany(item => MethodOperands(item.Body)).First(target => target.Name == "Send" && target.DeclaringType.FullName == "MediatR.ISender"); + AssertMutation(census, "an extra executor helper dispatching a request", () => + { + var method = AddCall(executor, "UnexpectedFrameworkSend", send, OpCodes.Callvirt); + return () => executor.Methods.Remove(method); + }, JobStepThreeOffenders); + foreach (var field in new[] { "_run", "_admissionRun" }) + AssertMutation(census, "a receipt forwarding its original or bound run", () => + AddBoundaryFieldGetter(types[(DeliveryApplication, JobStartReceipt)], "ForwardReceiptRun", field), JobStepThreeOffenders); + var capabilities = JobAdmissionCapabilities(census); + capabilities.Should().NotBeEmpty("the dirty controls must start from live production authority"); + foreach (var capability in capabilities) + { + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Callvirt, OpCodes.Ldftn, OpCodes.Ldvirtftn }) + AssertMutation(census, "an extra job capability through " + opcode, () => + AddCallProbe(application, "ExtraJobCapability", capability, opcode), JobStepThreeOffenders); + AssertMutation(census, "an asynchronous job capability", () => + AddAsyncCallProbe(application, "AsyncJobCapability", capability), JobStepThreeOffenders); + AssertMutation(census, "an overload borrowing an admitted job capability name", () => + { + var method = AddCall(capability.DeclaringType, capability.Name, capability, OpCodes.Call); + return () => capability.DeclaringType.Methods.Remove(method); + }, JobStepThreeOffenders); + } + } + + [Fact] + public void Background_job_authority_provenance_rejects_forgery_and_missing_publication_gates() { - var method = AddCall(executor, "UnexpectedFrameworkSend", send, OpCodes.Callvirt); - return () => executor.Methods.Remove(method); - }, JobStepThreeOffenders); - foreach (var field in new[] { "_run", "_admissionRun" }) - AssertMutation(census, "a receipt forwarding its original or bound run", () => - AddBoundaryFieldGetter(types[(DeliveryApplication, JobStartReceipt)], "ForwardReceiptRun", field), JobStepThreeOffenders); - var capabilities = new[] + using var census = ProductionCensus.Read(); + var types = census.Types; + var application = census.Modules.Single(module => module.Assembly.Name.Name == DeliveryApplication); + var capabilities = JobAdmissionCapabilities(census); + foreach (var typeName in new[] { JobRoutingClaim, JobStartReceipt, JobOrigin }) + { + var type = types[(DeliveryApplication, typeName)]; + var constructor = type.Methods.Single(method => method.IsConstructor && !method.IsStatic); + AssertMutation(census, "an extra opaque-token constructor call", () => + AddCallProbe(application, "OpaqueJobTokenConstructor", constructor, OpCodes.Newobj), JobStepThreeOffenders); + AssertMutation(census, "an asynchronous opaque-token constructor call", () => + AddAsyncCallProbe(application, "AsyncOpaqueJobTokenConstructor", constructor), JobStepThreeOffenders); + AssertMutation(census, "a public opaque-token constructor", () => + ChangeAttributes(() => constructor.Attributes, value => constructor.Attributes = value, + (constructor.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public), JobStepThreeOffenders); + } + AssertMutation(census, "a second routing minting implementation", () => + { + var probe = AddProbe(application, "AdditionalJobClaimSource"); + probe.BaseType = application.ImportReference(types[(DeliveryApplication, JobClaimSource)]); + return () => application.Types.Remove(probe); + }, JobStepThreeOffenders); + foreach (var typeName in new[] { JobRoutingClaim, JobClaimSource, JobStartReceipt, JobOrigin, JobStartRequest, JobReadRequest }) + AssertMutation(census, "a missing live opaque job subject", () => + { + var type = types[(DeliveryApplication, typeName)]; + var collection = type.DeclaringType?.NestedTypes ?? type.Module.Types; + collection.Remove(type); + return () => collection.Add(type); + }, JobStepThreeOffenders); + var deliveryTypeSite = census.Types.Values.SelectMany(type => type.Methods).Where(method => method.HasBody && method.Name != "MoveNext" + && MethodOperands(method).Any(target => target.Name == "ExecutePreparedDeliveryAsync" + && target.DeclaringType.FullName == DeliveryCoordinator) + && MethodOperands(method).Any(target => target.Name == "get_HandlerType" + && target.DeclaringType.FullName == "LearnStack.Infrastructure.Messaging.IntegrationEventSubscription")) + .SelectMany(method => method.Body.Instructions).Single(instruction => instruction.Operand is MethodReference target + && target.Name == "get_HandlerType" && target.DeclaringType.FullName == "LearnStack.Infrastructure.Messaging.IntegrationEventSubscription"); + AssertMutation(census, "a missing actual subscription handler identity", () => + ReplaceInstruction(deliveryTypeSite, OpCodes.Ldstr, "missing subscription handler identity"), JobStepThreeOffenders); + var committedBody = OwnedBodies(census).Single(item => item.Owner.DeclaringType.FullName == DeliveryCoordinator + && item.Owner.Name == "ExecuteFrameAsync" && item.Body.Name == "MoveNext").Body.Body; + var committed = committedBody.Instructions.Single(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == JobStartRequest && target.Name == "RecordCommitted"); + AssertMutation(census, "a start receipt minted before the owner acknowledgement", () => + { + var index = committedBody.Instructions.IndexOf(committed); + committedBody.Instructions.Remove(committed); + committedBody.Instructions.Insert(0, committed); + return () => { committedBody.Instructions.Remove(committed); committedBody.Instructions.Insert(index, committed); }; + }, JobStepThreeOffenders); + var frameworkTokens = types[(DeliveryApplication, "LearnStack.Application.Jobs.BackgroundJobFrameworkRequests")].Methods + .Concat(types[(DeliveryApplication, JobAdmission)].Methods.Where(method => method.Name is "IsFramework" or "ModeFor" or "PermitsUnresolvedRead" or "AnnouncementContext")) + .Concat(types[(DeliveryApplication, DeliveryCoordinator)].Methods.Where(method => method.Name == "ExecuteRequestAsync")) + .Where(method => method.HasBody).SelectMany(method => method.Body.Instructions) + .Where(instruction => instruction.OpCode == OpCodes.Ldtoken && instruction.Operand is TypeReference).ToArray(); + frameworkTokens.Should().NotBeEmpty("the exact framework and producer type identities must exist in real production IL"); + foreach (var instruction in frameworkTokens) + AssertMutation(census, "a removed exact framework or trusted operation type", () => + ReplaceInstruction(instruction, OpCodes.Ldstr, "missing exact type identity"), JobStepThreeOffenders); + var realSites = OwnedBodies(census).SelectMany(item => item.Body.Body.Instructions) + .Where(instruction => instruction.Operand is MethodReference target && capabilities.Any(capability => SameType(target.DeclaringType, capability.DeclaringType) + && target.Name == capability.Name)).ToArray(); + realSites.Should().NotBeEmpty("the construction/install/use census must inspect actual production sites"); + foreach (var instruction in realSites) + AssertMutation(census, "a deleted actual job authority site", () => + ReplaceInstruction(instruction, OpCodes.Ldstr, "missing job authority site"), JobStepThreeOffenders); + var clean = AddProbe(application, "CleanJobIdentityObserver"); + foreach (var typeName in new[] { JobRoutingClaim, JobStartReceipt, JobOrigin }) + foreach (var getter in types[(DeliveryApplication, typeName)].Methods.Where(method => method.IsGetter && method.IsPublic)) + AddCall(clean, "Observe" + typeName.Split('/').Last().Split('.').Last() + getter.Name, getter, OpCodes.Callvirt); + try { EvaluateDetector(census, JobStepThreeOffenders).Should().BeEmpty("reading immutable identity does not install authority"); } + finally { application.Types.Remove(clean); } + } + } + + private static MethodDefinition[] JobAdmissionCapabilities(ProductionCensus census) + { + var types = census.Types; + return new[] { (JobAdmission, "EnterFramework"), (JobAdmission, "InstallValidatedEnvelope"), (JobRoutingClaim, "EnterOnce"), (JobAdmission, "PermitsUnresolvedRead"), (JobAdmission, "RequireRead"), (JobAdmission, "RequireStart"), @@ -545,87 +664,5 @@ private static void AssertJobStepThreeControls(ProductionCensus census) (DeliveryProducer, "CaptureJobOrigin"), (DeliveryProducer, "RequireJobOrigin"), (DeliveryCoordinator, "ExecuteRequestAsync"), }.Select(subject => types[(DeliveryApplication, subject.Item1)].Methods.Single(method => method.Name == subject.Item2)) .Append(AdmissionRevalidationMethod(census, new SortedSet(StringComparer.Ordinal))!).ToArray(); - capabilities.Should().NotBeEmpty("the dirty controls must start from live production authority"); - foreach (var capability in capabilities) - { - foreach (var opcode in new[] { OpCodes.Call, OpCodes.Callvirt, OpCodes.Ldftn, OpCodes.Ldvirtftn }) - AssertMutation(census, "an extra job capability through " + opcode, () => - AddCallProbe(application, "ExtraJobCapability", capability, opcode), JobStepThreeOffenders); - AssertMutation(census, "an asynchronous job capability", () => - AddAsyncCallProbe(application, "AsyncJobCapability", capability), JobStepThreeOffenders); - AssertMutation(census, "an overload borrowing an admitted job capability name", () => - { - var method = AddCall(capability.DeclaringType, capability.Name, capability, OpCodes.Call); - return () => capability.DeclaringType.Methods.Remove(method); - }, JobStepThreeOffenders); - } - foreach (var typeName in new[] { JobRoutingClaim, JobStartReceipt, JobOrigin }) - { - var type = types[(DeliveryApplication, typeName)]; - var constructor = type.Methods.Single(method => method.IsConstructor && !method.IsStatic); - AssertMutation(census, "an extra opaque-token constructor call", () => - AddCallProbe(application, "OpaqueJobTokenConstructor", constructor, OpCodes.Newobj), JobStepThreeOffenders); - AssertMutation(census, "an asynchronous opaque-token constructor call", () => - AddAsyncCallProbe(application, "AsyncOpaqueJobTokenConstructor", constructor), JobStepThreeOffenders); - AssertMutation(census, "a public opaque-token constructor", () => - ChangeAttributes(() => constructor.Attributes, value => constructor.Attributes = value, - (constructor.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public), JobStepThreeOffenders); - } - AssertMutation(census, "a second routing minting implementation", () => - { - var probe = AddProbe(application, "AdditionalJobClaimSource"); - probe.BaseType = application.ImportReference(types[(DeliveryApplication, JobClaimSource)]); - return () => application.Types.Remove(probe); - }, JobStepThreeOffenders); - foreach (var typeName in new[] { JobRoutingClaim, JobClaimSource, JobStartReceipt, JobOrigin, JobStartRequest, JobReadRequest }) - AssertMutation(census, "a missing live opaque job subject", () => - { - var type = types[(DeliveryApplication, typeName)]; - var collection = type.DeclaringType?.NestedTypes ?? type.Module.Types; - collection.Remove(type); - return () => collection.Add(type); - }, JobStepThreeOffenders); - var deliveryTypeSite = census.Types.Values.SelectMany(type => type.Methods).Where(method => method.HasBody && method.Name != "MoveNext" - && MethodOperands(method).Any(target => target.Name == "ExecutePreparedDeliveryAsync" - && target.DeclaringType.FullName == DeliveryCoordinator) - && MethodOperands(method).Any(target => target.Name == "get_HandlerType" - && target.DeclaringType.FullName == "LearnStack.Infrastructure.Messaging.IntegrationEventSubscription")) - .SelectMany(method => method.Body.Instructions).Single(instruction => instruction.Operand is MethodReference target - && target.Name == "get_HandlerType" && target.DeclaringType.FullName == "LearnStack.Infrastructure.Messaging.IntegrationEventSubscription"); - AssertMutation(census, "a missing actual subscription handler identity", () => - ReplaceInstruction(deliveryTypeSite, OpCodes.Ldstr, "missing subscription handler identity"), JobStepThreeOffenders); - var committedBody = OwnedBodies(census).Single(item => item.Owner.DeclaringType.FullName == DeliveryCoordinator - && item.Owner.Name == "ExecuteFrameAsync" && item.Body.Name == "MoveNext").Body.Body; - var committed = committedBody.Instructions.Single(instruction => instruction.Operand is MethodReference target - && target.DeclaringType.FullName == JobStartRequest && target.Name == "RecordCommitted"); - AssertMutation(census, "a start receipt minted before the owner acknowledgement", () => - { - var index = committedBody.Instructions.IndexOf(committed); - committedBody.Instructions.Remove(committed); - committedBody.Instructions.Insert(0, committed); - return () => { committedBody.Instructions.Remove(committed); committedBody.Instructions.Insert(index, committed); }; - }, JobStepThreeOffenders); - var frameworkTokens = types[(DeliveryApplication, "LearnStack.Application.Jobs.BackgroundJobFrameworkRequests")].Methods - .Concat(types[(DeliveryApplication, JobAdmission)].Methods.Where(method => method.Name is "IsFramework" or "ModeFor" or "PermitsUnresolvedRead" or "AnnouncementContext")) - .Concat(types[(DeliveryApplication, DeliveryCoordinator)].Methods.Where(method => method.Name == "ExecuteRequestAsync")) - .Where(method => method.HasBody).SelectMany(method => method.Body.Instructions) - .Where(instruction => instruction.OpCode == OpCodes.Ldtoken && instruction.Operand is TypeReference).ToArray(); - frameworkTokens.Should().NotBeEmpty("the exact framework and producer type identities must exist in real production IL"); - foreach (var instruction in frameworkTokens) - AssertMutation(census, "a removed exact framework or trusted operation type", () => - ReplaceInstruction(instruction, OpCodes.Ldstr, "missing exact type identity"), JobStepThreeOffenders); - var realSites = OwnedBodies(census).SelectMany(item => item.Body.Body.Instructions) - .Where(instruction => instruction.Operand is MethodReference target && capabilities.Any(capability => SameType(target.DeclaringType, capability.DeclaringType) - && target.Name == capability.Name)).ToArray(); - realSites.Should().NotBeEmpty("the construction/install/use census must inspect actual production sites"); - foreach (var instruction in realSites) - AssertMutation(census, "a deleted actual job authority site", () => - ReplaceInstruction(instruction, OpCodes.Ldstr, "missing job authority site"), JobStepThreeOffenders); - var clean = AddProbe(application, "CleanJobIdentityObserver"); - foreach (var typeName in new[] { JobRoutingClaim, JobStartReceipt, JobOrigin }) - foreach (var getter in types[(DeliveryApplication, typeName)].Methods.Where(method => method.IsGetter && method.IsPublic)) - AddCall(clean, "Observe" + typeName.Split('/').Last().Split('.').Last() + getter.Name, getter, OpCodes.Callvirt); - try { EvaluateDetector(census, JobStepThreeOffenders).Should().BeEmpty("reading immutable identity does not install authority"); } - finally { application.Types.Remove(clean); } } } diff --git a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobApplicationSourceBoundaryTests.Fields.cs b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobApplicationSourceBoundaryTests.Fields.cs new file mode 100644 index 000000000..b9ba78679 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobApplicationSourceBoundaryTests.Fields.cs @@ -0,0 +1,200 @@ +using FluentAssertions; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class OutboxDispatchConventionTests +{ + private static readonly string[] JobApplicationOwnerFields = + ["Ambient", "External", "_sync", "_auxiliary", "_authorization", "_evidence", "_mainUnit", "_mainConnection", + "_mainTransaction", "_closed", "_cleanupFailed", "_used", "_externalRecovery"]; + private static readonly string[] JobApplicationResourceFields = + ["_sync", "_owner", "_connection", "_transaction", "_commands", "_reader", "_actualTask", + "_readerDisposalAttempted", "_cleanupAttempted", "_cleanupConfirmed", "_readerCleanupFailed", "_released"]; + + private static void CheckJobApplicationRetainedFields(ProductionCensus census, TypeDefinition owner, + TypeDefinition resources, SortedSet offenders) + { + CheckSet(offenders, "closed job application-owner resource inventory", owner.Fields.Select(field => field.Name), JobApplicationOwnerFields); + CheckSet(offenders, "closed job auxiliary resource inventory", resources.Fields.Select(field => field.Name), JobApplicationResourceFields); + var bodies = OwnedBodies(census).ToArray(); + var fields = bodies.SelectMany(item => item.Body.Body.Instructions.Where(instruction => instruction.Operand is FieldReference) + .Select(instruction => (item.Owner, Instruction: instruction, Field: (FieldReference)instruction.Operand))).ToArray(); + + // The three slots remain mutually exclusive within one job; these exact reads include + // both readiness gates, acquisition gates and the uncancelable actual-cleanup join. + Lane("_auxiliary", [("get_CleanupConfirmed", 2), ("RequireBusinessReady", 2), ("JoinAuxiliaryCleanupAsync", 1), + ("BeginAuxiliary", 2), ("BeginConfinedLane", 2), ("Release", 1), ("OwnsAuxiliary", 1)], "BeginAuxiliary"); + Lane("_authorization", [("get_CleanupConfirmed", 2), ("RequireBusinessReady", 2), ("JoinAuxiliaryCleanupAsync", 1), + ("BeginAuxiliary", 2), ("BeginConfinedLane", 2), ("Release", 1), ("OwnsAuxiliary", 1)], "BeginConfinedLane"); + Lane("_evidence", [("get_CleanupConfirmed", 2), ("RequireBusinessReady", 2), ("JoinAuxiliaryCleanupAsync", 1), + ("BeginAuxiliary", 2), ("BeginConfinedLane", 2), ("OwnsAuxiliary", 1)], "BeginConfinedLane"); + + Field(owner, "_cleanupFailed", "System.Boolean", false, 3, + ["get_CleanupConfirmed", "get_IsCurrentBinding", "RequireBusinessReady", "BeginAuxiliary", "BeginConfinedLane", + "ReportMainTransactionCleanup", "ReportMainCleanup", "ReportCleanupFailure"]); + Field(owner, "_closed", "System.Boolean", false, 1, + ["Enter", "get_IsCurrentBinding", "RequireBusinessReady", "CloseIssuance", "BeginAuxiliary", "BeginConfinedLane"]); + Field(resources, "_owner", AppJobOwner, true, 1, + [".ctor", "MarkCleanupFailed", "DisposeReaderAsync", "CleanupAsync", "TryRelease"]); + Field(resources, "_connection", "System.Data.Common.DbConnection", false, 1, ["HoldConnection", "CleanupAsync"]); + Field(resources, "_transaction", "System.Data.Common.DbTransaction", false, 1, ["HoldTransaction", "CleanupAsync"]); + Field(resources, "_commands", "System.Collections.Generic.List`1", true, 1, + [".ctor", "HoldCommand", "CleanupAsync"]); + Field(resources, "_reader", "System.Data.Common.DbDataReader", false, 1, + ["HoldReader", "DisposeReaderAsync", "CleanupAsync"]); + Field(resources, "_actualTask", "System.Threading.Tasks.Task", false, 1, + ["HoldActualTask", "TryRelease", "JoinActualTaskAsync"]); + Field(resources, "_readerDisposalAttempted", "System.Boolean", false, 1, ["DisposeReaderAsync", "CleanupAsync"]); + Field(resources, "_cleanupAttempted", "System.Boolean", false, 1, + ["HoldConnection", "HoldTransaction", "HoldCommand", "HoldReader", "CleanupAsync"]); + Field(resources, "_cleanupConfirmed", "System.Boolean", false, 1, ["CleanupAsync", "TryRelease"]); + Field(resources, "_readerCleanupFailed", "System.Boolean", false, 2, + ["MarkCleanupFailed", "DisposeReaderAsync", "CleanupAsync"]); + Field(resources, "_released", "System.Boolean", false, 0, ["TryRelease", "get_IsReleased", "MarkReleased"]); + CheckSet(offenders, "one-way auxiliary release addresses", fields.Where(site => SameType(site.Field.DeclaringType, resources) + && site.Field.Name == "_released" && site.Instruction.OpCode == OpCodes.Ldflda).Select(site => site.Owner.Name), + ["get_IsReleased", "MarkReleased"]); + + RequireConstant(resources, ".ctor", 3); + RequireConstant(resources, "HoldCommand", 3); + RequireConstant(owner, "BeginAuthorizationRead", 1); + RequireConstant(owner, "BeginAuthorizationEvidence", 0); + foreach (var method in new[] { "get_CleanupConfirmed", "RequireBusinessReady", "BeginAuxiliary", "BeginConfinedLane" }) + { + var reconciliations = bodies.Where(item => item.Owner.DeclaringType == owner && item.Owner.Name == method) + .SelectMany(item => item.Body.Body.Instructions).Where(instruction => instruction.Operand is MethodReference target + && SameType(target.DeclaringType, resources) && target.Name == "TryRelease").ToArray(); + if (reconciliations.Length != 3 || reconciliations.Any(site => site.OpCode != OpCodes.Call && site.OpCode != OpCodes.Callvirt)) + offenders.Add("Each job readiness/acquisition gate reconciles all three completed lanes: " + method); + } + foreach (var type in new[] { "System.Data.Common.DbCommand", "System.Data.Common.DbTransaction", "System.Data.Common.DbConnection" }) + RequireCall("CleanupAsync", type, "DisposeAsync"); + RequireCall("DisposeReaderAsync", "System.Data.Common.DbDataReader", "DisposeAsync"); + RequireCall("TryRelease", "System.Threading.Tasks.Task", "get_IsCompleted"); + + void Lane(string name, (string Method, int Reads)[] expectedReads, string publisher) + { + Field(owner, name, AppJobResources, false, 2, [.. expectedReads.Select(item => item.Method), publisher, "Release"]); + var access = fields.Where(site => SameType(site.Field.DeclaringType, owner) && site.Field.Name == name).ToArray(); + CheckSet(offenders, "exact job lane readiness/acquisition/join reads " + name, + access.Where(site => site.Instruction.OpCode == OpCodes.Ldfld).Select(site => site.Owner.Name), + expectedReads.SelectMany(item => Enumerable.Repeat(item.Method, item.Reads))); + CheckSet(offenders, "exact job lane publication/release " + name, + access.Where(site => site.Instruction.OpCode == OpCodes.Stfld).Select(site => site.Owner.Name), [publisher, "Release"]); + } + + void Field(TypeDefinition subject, string name, string type, bool readOnly, int writes, string[] expectedOwners) + { + var matches = subject.Fields.Where(field => field.Name == name).ToArray(); + if (matches.Length != 1 || !matches[0].IsPrivate || matches[0].IsStatic + || matches[0].IsInitOnly != readOnly || matches[0].FieldType.FullName != type) + { offenders.Add("Missing or retyped job retained resource: " + subject.FullName + ":" + name); return; } + var access = fields.Where(site => SameType(site.Field.DeclaringType, subject) && site.Field.Name == name).ToArray(); + var expected = expectedOwners.Distinct(StringComparer.Ordinal).Select(methodName => + { + var methods = subject.Methods.Where(method => method.Name == methodName).ToArray(); + if (methods.Length == 1) return MethodId(methods[0]); + offenders.Add("Missing or overloaded retained-resource owner: " + subject.FullName + "::" + methodName); + return "missing:" + methodName; + }); + CheckSet(offenders, "closed job retained-resource callers " + subject.FullName + ":" + name, + access.Select(site => MethodId(site.Owner)).Distinct(StringComparer.Ordinal), expected); + if (access.Count(site => site.Instruction.OpCode == OpCodes.Stfld) != writes + || access.Any(site => site.Instruction.OpCode != OpCodes.Ldfld && site.Instruction.OpCode != OpCodes.Stfld + && !(name == "_released" && site.Instruction.OpCode == OpCodes.Ldflda))) + offenders.Add("Unexpected job retained-resource publication or address escape: " + subject.FullName + ":" + name); + } + + void RequireConstant(TypeDefinition subject, string method, int value) + { + if (bodies.Where(item => item.Owner.DeclaringType == subject && item.Owner.Name == method) + .SelectMany(item => item.Body.Body.Instructions).Count(instruction => JobApplicationConstant(instruction) == value) != 1) + offenders.Add("Missing or changed finite job helper bound/lane: " + subject.FullName + "::" + method); + } + + void RequireCall(string method, string type, string name) + { + if (bodies.Where(item => item.Owner.DeclaringType == resources && item.Owner.Name == method) + .SelectMany(item => MethodOperands(item.Body)).Count(target => target.DeclaringType.FullName == type && target.Name == name) != 1) + offenders.Add("Missing actual retained resource cleanup/completion call: " + method + "::" + type + "::" + name); + } + } + + private static void AssertJobApplicationRetainedFieldMutations(ProductionCensus census, TypeDefinition owner, + TypeDefinition resources, ModuleDefinition application) + { + var bodies = OwnedBodies(census).ToArray(); + foreach (var field in owner.Fields.Where(field => field.Name is "_authorization" or "_evidence") + .Concat(resources.Fields.Where(field => field.Name is "_connection" or "_transaction" or "_commands" + or "_reader" or "_actualTask" or "_cleanupConfirmed" or "_readerCleanupFailed"))) + { + AssertMutation(census, "a public retained job resource " + field.Name, () => + ChangeAttributes(() => field.Attributes, value => field.Attributes = value, + (field.Attributes & ~FieldAttributes.FieldAccessMask) | FieldAttributes.Public), JobApplicationSourceOffenders); + AssertMutation(census, "a private forwarding accessor for retained job resource " + field.Name, () => + { + var undo = AddBoundaryFieldGetter(field.DeclaringType, "ForwardJobApplicationResource", field.Name); + field.DeclaringType.Methods.Single(method => method.Name == "ForwardJobApplicationResource").IsPrivate = true; + return undo; + }, JobApplicationSourceOffenders); + } + var laneSites = bodies.Where(item => item.Owner.DeclaringType == owner).SelectMany(item => item.Body.Body.Instructions) + .Where(instruction => instruction.Operand is FieldReference field && SameType(field.DeclaringType, owner) + && field.Name is "_authorization" or "_evidence").ToArray(); + laneSites.Should().NotBeEmpty("both approved lanes have actual readiness, issuance and cleanup ownership"); + foreach (var site in laneSites) + AssertMutation(census, "a deleted actual authorization/evidence job lane gate or retained join", () => + ReplaceInstruction(site, OpCodes.Nop, null!), JobApplicationSourceOffenders); + var reconciliationSites = bodies.Where(item => item.Owner.DeclaringType == owner + && item.Owner.Name is "get_CleanupConfirmed" or "RequireBusinessReady" or "BeginAuxiliary" or "BeginConfinedLane") + .SelectMany(item => item.Body.Body.Instructions).Where(instruction => instruction.Operand is MethodReference target + && SameType(target.DeclaringType, resources) && target.Name == "TryRelease").ToArray(); + reconciliationSites.Should().HaveCount(12, "four readiness/acquisition gates each reconcile the three retained lanes"); + foreach (var site in reconciliationSites) + { + AssertMutation(census, "a deleted actual completed-lane reconciliation", () => + ReplaceInstruction(site, OpCodes.Nop, null!), JobApplicationSourceOffenders); + AssertMutation(census, "a delegated completed-lane reconciliation", () => + ReplaceInstruction(site, OpCodes.Ldftn, site.Operand), JobApplicationSourceOffenders); + } + foreach (var method in resources.Methods.Where(method => method.Name is ".ctor" or "HoldCommand")) + { + var bound = method.Body.Instructions.Single(instruction => JobApplicationConstant(instruction) == 3); + AssertMutation(census, "an enlarged existing business-helper command bound", () => + ReplaceInstruction(bound, OpCodes.Ldc_I4, 4), JobApplicationSourceOffenders); + } + foreach (var method in owner.Methods.Where(method => method.Name is "BeginAuthorizationRead" or "BeginAuthorizationEvidence")) + { + var lane = method.Body.Instructions.Single(instruction => JobApplicationConstant(instruction) is 0 or 1); + AssertMutation(census, "a swapped named job auxiliary lane", () => + ReplaceInstruction(lane, OpCodes.Ldc_I4, method.Name == "BeginAuthorizationRead" ? 0 : 1), JobApplicationSourceOffenders); + } + var cleanupSites = bodies.Where(item => item.Owner.DeclaringType == resources + && item.Owner.Name is "CleanupAsync" or "DisposeReaderAsync" or "TryRelease") + .SelectMany(item => item.Body.Body.Instructions).Where(instruction => instruction.Operand is MethodReference target + && (target.Name == "DisposeAsync" && target.DeclaringType.FullName.StartsWith("System.Data.Common.Db", StringComparison.Ordinal) + || target.Name == "get_IsCompleted" && target.DeclaringType.FullName == "System.Threading.Tasks.Task")).ToArray(); + cleanupSites.Should().HaveCount(5, "reader, commands, transaction, connection and actual task completion remain concrete"); + foreach (var site in cleanupSites) + AssertMutation(census, "a deleted actual job helper disposal or task-completion witness", () => + ReplaceInstruction(site, OpCodes.Nop, null!), JobApplicationSourceOffenders); + AssertMutation(census, "an extra retained job resource slot", () => + { + var extra = new FieldDefinition("_additionalAuthorization", FieldAttributes.Private, application.ImportReference(resources)); + owner.Fields.Add(extra); + return () => owner.Fields.Remove(extra); + }, JobApplicationSourceOffenders); + } + + private static int? JobApplicationConstant(Instruction instruction) => instruction.OpCode.Code switch + { + Code.Ldc_I4_0 => 0, + Code.Ldc_I4_1 => 1, + Code.Ldc_I4_3 => 3, + Code.Ldc_I4 => (int)instruction.Operand, + Code.Ldc_I4_S => Convert.ToInt32(instruction.Operand, System.Globalization.CultureInfo.InvariantCulture), + _ => null, + }; +} diff --git a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobApplicationSourceBoundaryTests.Qualification.cs b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobApplicationSourceBoundaryTests.Qualification.cs new file mode 100644 index 000000000..bfac4d3cf --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobApplicationSourceBoundaryTests.Qualification.cs @@ -0,0 +1,112 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class OutboxDispatchConventionTests +{ + private static readonly string[] AppJobUnboundQualificationParameters = + ["LearnStack.SharedKernel.Identifiers.IdentityAuthorityId", "System.String", BoundaryCancellation]; + private static readonly string[] AppJobBoundQualificationParameters = + ["LearnStack.SharedKernel.Identifiers.IdentityAuthorityId", "System.String", AppJobDatabaseProof, BoundaryCancellation]; + + private static void CheckJobQualificationEntries(ProductionCensus census, + Dictionary types, SortedSet offenders) + { + foreach (var name in new[] { AppJobAuthorizationSource, AppJobEvidenceSource }) + { + var source = types[name]; + var entries = source.Methods.Where(method => method.Name == "QualifyAsync").ToArray(); + if (entries.Length != 2) offenders.Add(name + ": qualification has exactly its two declared overloads"); + var expected = new List(); + foreach (var parameters in new[] { AppJobUnboundQualificationParameters, AppJobBoundQualificationParameters }) + { + var matches = entries.Where(method => ParametersMatch(method, parameters)).ToArray(); + if (matches.Length != 1 || !matches[0].IsPublic || matches[0].IsStatic || matches[0].HasGenericParameters + || matches[0].ReturnType.FullName != BoundaryTask) + offenders.Add(name + ": qualification requires its exact public instance signature (" + string.Join(",", parameters) + ")"); + else expected.Add(matches[0]); + } + var cores = source.Methods.Where(method => method.Name == "QualifyCoreEntryAsync").ToArray(); + if (cores.Length != 1 || !cores[0].IsPrivate || cores[0].IsStatic || cores[0].HasGenericParameters + || cores[0].ReturnType.FullName != BoundaryTask + || !ParametersMatch(cores[0], AppJobBoundQualificationParameters)) + { offenders.Add(name + ": qualification requires its exact shared private entry signature"); continue; } + var core = cores[0]; + CheckSet(offenders, name + " shared qualification entry callers", CallSites(census) + .Where(site => TargetsDeclaredType(site.Target, source) && site.Target.Name == "QualifyCoreEntryAsync") + .Select(site => site.Owner), expected.Select(MethodId)); + foreach (var entry in expected) + { + var forwards = entry.Body.Instructions.Where(instruction => instruction.Operand is MethodReference target + && TargetsDeclaredType(target, source) && target.Name == "QualifyCoreEntryAsync").ToArray(); + if (forwards.Length != 1 || forwards[0].OpCode != OpCodes.Call + || forwards[0].Operand is not MethodReference actualForward + || !ParametersMatch(actualForward, AppJobBoundQualificationParameters)) + offenders.Add(MethodId(entry) + ": qualification must directly invoke its actual shared guarded entry"); + } + CheckQualificationJobGuard(census, core, offenders); + } + var proof = types[AppJobDatabaseProof]; + var opens = proof.Methods.Where(method => method.Name == "OpenAsync").ToArray(); + if (opens.Length != 1 || !opens[0].IsPublic || !opens[0].IsStatic || opens[0].HasGenericParameters + || opens[0].ReturnType.FullName != "System.Threading.Tasks.Task`1<" + AppJobDatabaseProof + ">" + || !ParametersMatch(opens[0], ["Npgsql.NpgsqlDataSource", BoundaryCancellation])) + offenders.Add("Business qualification proof requires its exact guarded static startup entry"); + else CheckQualificationJobGuard(census, opens[0], offenders); + } + + private static void CheckQualificationJobGuard(ProductionCensus census, MethodDefinition method, SortedSet offenders) + { + var guards = OwnedBodies(census).Where(item => MethodId(item.Owner) == MethodId(method)) + .SelectMany(item => item.Body.Body.Instructions).Where(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == AppJobOwner && target.Name == "RefuseUnrelatedRead").ToArray(); + if (guards.Length != 1 || guards[0].OpCode != OpCodes.Call) + offenders.Add(MethodId(method) + ": actual startup entry retains one direct job-source refusal guard"); + } + + private static void AssertJobQualificationMutations(ProductionCensus census) + { + foreach (var name in new[] { AppJobAuthorizationSource, AppJobEvidenceSource }) + { + var source = census.Types.Values.Single(type => type.FullName == name); + var core = source.Methods.Single(method => method.Name == "QualifyCoreEntryAsync"); + foreach (var entry in source.Methods.Where(method => method.Name == "QualifyAsync").ToArray()) + { + var forward = entry.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == name && target.Name == "QualifyCoreEntryAsync"); + AssertMutation(census, "a qualification overload bypassing the shared job-source guard", () => + ReplaceInstruction(forward, OpCodes.Nop, null!), JobApplicationSourceOffenders); + AssertMutation(census, "a qualification overload exporting its shared entry as a delegate", () => + ReplaceInstruction(forward, OpCodes.Ldftn, forward.Operand), JobApplicationSourceOffenders); + } + AssertMutation(census, "an additional qualification overload", () => + { + var extra = new MethodDefinition("QualifyAsync", MethodAttributes.Public, core.ReturnType); + extra.Parameters.Add(new ParameterDefinition(source.Module.TypeSystem.String)); + extra.Body.Instructions.Add(Instruction.Create(OpCodes.Call, core)); + extra.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + source.Methods.Add(extra); + return () => source.Methods.Remove(extra); + }, JobApplicationSourceOffenders); + AssertMutation(census, "a different shared qualification entry signature", () => + { + var parameter = new ParameterDefinition(source.Module.TypeSystem.String); + core.Parameters.Add(parameter); + return () => core.Parameters.Remove(parameter); + }, JobApplicationSourceOffenders); + } + foreach (var name in new[] { AppJobAuthorizationSource, AppJobEvidenceSource, AppJobDatabaseProof }) + { + var owner = census.Types.Values.Single(type => type.FullName == name).Methods.Single(method => method.Name == + (name == AppJobDatabaseProof ? "OpenAsync" : "QualifyCoreEntryAsync")); + var guard = OwnedBodies(census).Where(item => MethodId(item.Owner) == MethodId(owner)) + .SelectMany(item => item.Body.Body.Instructions).Single(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == AppJobOwner && target.Name == "RefuseUnrelatedRead"); + AssertMutation(census, "an actual qualification entry losing its job-source refusal", () => + ReplaceInstruction(guard, OpCodes.Nop, null!), JobApplicationSourceOffenders); + AssertMutation(census, "an actual qualification entry delegating its job-source refusal", () => + ReplaceInstruction(guard, OpCodes.Ldftn, guard.Operand), JobApplicationSourceOffenders); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobApplicationSourceBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobApplicationSourceBoundaryTests.cs index c82f6851f..abfc83aee 100644 --- a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobApplicationSourceBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobApplicationSourceBoundaryTests.cs @@ -23,6 +23,11 @@ public sealed partial class OutboxDispatchConventionTests private const string AppJobRecovery = "LearnStack.Infrastructure.Jobs.BackgroundJobExecutionControl"; private const string AppJobTerminalNoStart = "LearnStack.Infrastructure.Jobs.BackgroundJobTerminalNoStartOperation"; private const string AppJobRecoveryReceipt = "LearnStack.Application.Jobs.BackgroundJobExternalRecoveryReceipt"; + private const string AppJobAuthorizationSource = "LearnStack.Infrastructure.Persistence.IdentityAuthorizationReadSource"; + private const string AppJobAuthorizationOwner = "LearnStack.Infrastructure.Persistence.AuthorizationReadOwner"; + private const string AppJobEvidenceSource = "LearnStack.Infrastructure.Persistence.AuthorizationEvidenceSource"; + private const string AppJobEvidenceOwner = "LearnStack.Infrastructure.Persistence.AuthorizationEvidenceSession"; + private const string AppJobDatabaseProof = "LearnStack.Infrastructure.Persistence.DatabaseQualificationProof"; private static SortedSet JobApplicationSourceOffenders(ProductionCensus census) { @@ -31,7 +36,8 @@ private static SortedSet JobApplicationSourceOffenders(ProductionCensus AppJobFlags, AppJobKillswitch, JobExecutorType, "LearnStack.Infrastructure.MultiTenancy.CachedHostToTenantResolver", "LearnStack.Infrastructure.MultiTenancy.OrganizationScopeValidator", - AppJobRecovery, AppJobTerminalNoStart, AppJobRecoveryReceipt, JobAdmission }; + AppJobRecovery, AppJobTerminalNoStart, AppJobRecoveryReceipt, JobAdmission, + AppJobAuthorizationSource, AppJobAuthorizationOwner, AppJobEvidenceSource, AppJobEvidenceOwner, AppJobDatabaseProof }; var types = new Dictionary(StringComparer.Ordinal); foreach (var name in subjects) { @@ -49,13 +55,14 @@ private static SortedSet JobApplicationSourceOffenders(ProductionCensus || resources.Methods.Count(method => method.IsConstructor && !method.IsStatic) != 1 || resources.Methods.Any(method => method.IsConstructor && !method.IsStatic && !method.IsAssembly)) offenders.Add("Job application-source constructors are closed"); - var ownerSurface = new[] { "Create", "get_Current", "Enter", "get_CleanupConfirmed", "RequireBusinessReady", + var ownerSurface = new[] { "Create", "get_Current", "get_IsCurrentBinding", "Enter", "get_CleanupConfirmed", "RequireBusinessReady", "RequireFreshScopeReady", "RequireOwnedMainUnit", "BindMainUnit", "HoldMainConnection", "HoldMainTransaction", "ReportMainTransactionCleanup", "ReportMainCleanup", "RefuseUnrelatedRead", "CloseIssuance", "JoinAuxiliaryCleanupAsync", "BeginAuditConfigLoad", "BeginStandaloneAudit", "BeginTenantFlagsLoad", "BeginKillswitchLoad", + "BeginAuthorizationRead", "BeginAuthorizationEvidence", "get_IsExternal", "RefuseExternalEntry", "EnterExternal", "BindExternalRecovery" }; var resourceSurface = new[] { "HoldConnection", "HoldTransaction", "HoldCommand", "HoldReader", "HoldActualTask", - "DisposeReaderAsync", "CleanupAsync" }; + "DisposeReaderAsync", "CleanupAsync", "MarkCleanupFailed" }; CheckSurface(owner, ownerSurface); CheckSurface(resources, resourceSurface); if (!owner.Methods.Any(method => method.Name == "JoinAuxiliaryCleanupAsync" && method.IsPublic @@ -91,24 +98,35 @@ private static SortedSet JobApplicationSourceOffenders(ProductionCensus Exact(owner, "BindExternalRecovery", [(AppJobRecoveryReceipt, "Bind")]); // This marker only denies app entry during provider construction/calls. Exact(owner, "EnterExternal", [(JobAdmission, "EnterExternal")]); - Exact(resources, "JoinActualTaskAsync", [(AppJobOwner, "JoinAuxiliaryCleanupAsync")]); + Exact(owner, "get_IsCurrentBinding", [(JobAdmission, "IsOperationEvidenceCurrent")]); + Exact(resources, "JoinActualTaskAsync", [(AppJobOwner, "JoinAuxiliaryCleanupAsync"), + (AppJobOwner, "JoinAuxiliaryCleanupAsync"), (AppJobOwner, "JoinAuxiliaryCleanupAsync")]); Exact(owner, "BeginAuditConfigLoad", [(AppJobAuditConfig, "StartLoadAsync")]); Exact(owner, "BeginStandaloneAudit", [(AppJobAuditStore, "WriteOwnTransactionAsync")]); Exact(owner, "BeginTenantFlagsLoad", [(AppJobFlags, "StartTenantFlagsLoadAsync")]); Exact(owner, "BeginKillswitchLoad", [(AppJobKillswitch, "StartLoadAsync")]); + Exact(owner, "BeginAuthorizationRead", [(AppJobAuthorizationSource, "OpenAsync")]); + Exact(owner, "BeginAuthorizationEvidence", [(AppJobEvidenceSource, "OpenAsync")]); Exact(owner, "BeginAuxiliary", [(AppJobOwner, "BeginAuditConfigLoad"), (AppJobOwner, "BeginStandaloneAudit"), (AppJobOwner, "BeginTenantFlagsLoad"), (AppJobOwner, "BeginKillswitchLoad")]); - Exact(resources, ".ctor", [(AppJobOwner, "BeginAuxiliary")]); + Exact(owner, "BeginConfinedLane", [(AppJobOwner, "BeginAuthorizationRead"), (AppJobOwner, "BeginAuthorizationEvidence")]); + Exact(resources, ".ctor", [(AppJobOwner, "BeginAuxiliary"), (AppJobOwner, "BeginConfinedLane")]); var cores = new[] { (AppJobAuditConfig, "LoadAsync"), (AppJobAuditStore, "WriteOwnTransactionCoreAsync"), (AppJobFlags, "LoadTenantFlagsAsync"), (AppJobKillswitch, "LoadAsync") }; Exact(resources, "HoldActualTask", [(AppJobAuditConfig, "StartLoadAsync"), (AppJobAuditStore, "WriteOwnTransactionAsync"), - (AppJobFlags, "StartTenantFlagsLoadAsync"), (AppJobKillswitch, "StartLoadAsync")]); - Exact(resources, "HoldConnection", cores); - Exact(resources, "HoldTransaction", cores.Take(3).ToArray()); + (AppJobFlags, "StartTenantFlagsLoadAsync"), (AppJobKillswitch, "StartLoadAsync"), (AppJobAuthorizationOwner, ".ctor"), + (AppJobEvidenceOwner, ".ctor")]); + Exact(resources, "HoldConnection", [.. cores, (AppJobAuthorizationOwner, "OpenAsync"), (AppJobEvidenceOwner, "OpenAsync")]); + Exact(resources, "HoldTransaction", [.. cores.Take(3), (AppJobAuthorizationOwner, "OpenAsync")]); Exact(resources, "HoldCommand", [cores[0], cores[0], cores[0], cores[1], cores[1], cores[2], cores[2], cores[2], cores[3]]); Exact(resources, "HoldReader", [cores[0], cores[2], cores[3]]); Exact(resources, "DisposeReaderAsync", [cores[0], cores[2], (AppJobResources, "CleanupAsync")]); - Exact(resources, "CleanupAsync", cores); + Exact(resources, "CleanupAsync", [.. cores, (AppJobAuthorizationOwner, "CleanupAsync"), (AppJobEvidenceOwner, "CleanupAsync")]); + Exact(resources, "MarkCleanupFailed", [(AppJobAuthorizationOwner, "MarkCleanupFailed"), (AppJobEvidenceOwner, "MarkCleanupFailed")]); + Exact(owner, "ReportCleanupFailure", [(AppJobResources, "MarkCleanupFailed"), (AppJobResources, "DisposeReaderAsync"), + (AppJobResources, "CleanupAsync")]); + Exact(owner, "Release", [(AppJobResources, "TryRelease")]); + Exact(resources, "MarkReleased", [(AppJobOwner, "Release")]); Exact(owner, "BindMainUnit", [(AppJobUnit, "BindJobMainUnit")]); Exact(owner, "HoldMainConnection", [(AppJobUnit, "get_Connection"), (AppJobUnit, "BeginTransactionCoreAsync")]); Exact(owner, "HoldMainTransaction", [(AppJobUnit, "BeginTransactionCoreAsync")]); @@ -116,7 +134,11 @@ private static SortedSet JobApplicationSourceOffenders(ProductionCensus (AppJobUnit, "DisposeCompletedTransactionAsync"), (AppJobUnit, "DisposeCompletedTransactionAsync"), (AppJobUnit, "RollbackCoreAsync"), (AppJobUnit, "RollbackCoreAsync")]); Exact(owner, "ReportMainCleanup", [(AppJobUnit, "DisposeAsync"), (AppJobUnit, "DisposeAsync")]); - Exact(owner, "RefuseUnrelatedRead", [(subjects[8], "ReadAsync"), (subjects[9], "BelongsToTenantAsync")]); + Exact(owner, "RefuseUnrelatedRead", [(subjects[8], "ReadAsync"), (subjects[9], "BelongsToTenantAsync"), + (AppJobAuthorizationSource, "QualifyCoreEntryAsync"), (AppJobEvidenceSource, "QualifyCoreEntryAsync"), + (AppJobDatabaseProof, "OpenAsync")]); + CheckJobQualificationEntries(census, types, offenders); + CheckJobApplicationRetainedFields(census, owner, resources, offenders); return offenders; void CheckSurface(TypeDefinition subject, string[] expected) @@ -167,11 +189,13 @@ public void Background_job_application_source_ownership_has_exact_nonempty_calle { using var census = ProductionCensus.Read(); EvaluateDetector(census, JobApplicationSourceOffenders).Should().BeEmpty(); + AssertJobQualificationMutations(census); var owner = census.Types.Values.Single(type => type.FullName == AppJobOwner); var resources = census.Types.Values.Single(type => type.FullName == AppJobResources); var application = census.Modules.Single(module => module.Assembly.Name.Name == DeliveryApplication); var guardedOwnerMethods = new[] { ".ctor", "Create", "Enter", "BeginAuditConfigLoad", "BeginStandaloneAudit", "BeginTenantFlagsLoad", - "BeginKillswitchLoad", "BindMainUnit", "HoldMainConnection", "HoldMainTransaction", "ReportMainTransactionCleanup", + "BeginKillswitchLoad", "BeginAuthorizationRead", "BeginAuthorizationEvidence", "BeginConfinedLane", + "BindMainUnit", "HoldMainConnection", "HoldMainTransaction", "ReportMainTransactionCleanup", "ReportMainCleanup", "RefuseUnrelatedRead", "JoinAuxiliaryCleanupAsync", "BindExternalRecovery", "EnterExternal" }; foreach (var target in owner.Methods.Where(method => guardedOwnerMethods.Contains(method.Name, StringComparer.Ordinal)) .Concat(resources.Methods.Where(method => method.IsPublic || method.IsConstructor @@ -281,6 +305,7 @@ public void Background_job_application_source_ownership_has_exact_nonempty_calle }, JobApplicationSourceOffenders); } } + AssertJobApplicationRetainedFieldMutations(census, owner, resources, application); } } diff --git a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobAuthorityBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobAuthorityBoundaryTests.cs index baef89a06..d8a109eb9 100644 --- a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobAuthorityBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobAuthorityBoundaryTests.cs @@ -25,12 +25,12 @@ public void BackgroundJob_Ordinary_Execution_Has_No_Platform_Authority() CheckJobFrameworkMetadata(); } - public sealed class JobAuthorityCensusMutationTests + public sealed partial class JobAuthorityCensusMutationTests { /// ADR-0047: prove the same production predicate detects removal and additional activation. [Fact] - public void Background_job_authority_census_detects_extra_activation_and_removed_live_guards() + public void Background_job_admission_rejects_extra_activators_and_removed_privileged_guards() { using var census = ProductionCensus.Read(); var admission = census.Types[(DeliveryApplication, JobAdmission)]; @@ -71,7 +71,6 @@ public void Background_job_authority_census_detects_extra_activation_and_removed foreach (var guard in guards) AssertMutation(census, "a missing actual job authority gate", () => ReplaceInstruction(guard, OpCodes.Ldstr, "missing job authority gate"), JobAuthorityOffenders); - AssertJobStepThreeControls(census); } } @@ -119,6 +118,7 @@ private static SortedSet JobAuthorityOffenders(ProductionCensus census) [Owner(DeliveryApplication, DeliveryCoordinator, "ExecuteCoreAsync"), Owner(DeliveryApplication, DeliveryCoordinator, "ExecuteFrameAsync"), Owner(DeliveryApplication, JobAdmission, "RequireOwnerEntry"), Owner(DeliveryApplication, JobAdmission, "RequireOwnerEntry"), Owner(DeliveryApplication, JobAdmission, "RequireOwnerEntry"), + Owner(DeliveryApplication, JobAdmission, "IsOperationEvidenceCurrent"), Owner(DeliveryApplication, JobAdmission, "RequireOwnerEntry"), Owner(InfrastructureAssembly, JobExecutorType, "ExecuteBusinessAsync"), Owner(InfrastructureAssembly, JobExecutorType, "ExecuteBusinessAsync"), Owner(InfrastructureAssembly, JobExecutorType, "ExecuteCapacityCleanupAsync"), diff --git a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobManifestBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobManifestBoundaryTests.cs index e6fbcde35..3b4b1800a 100644 --- a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobManifestBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobManifestBoundaryTests.cs @@ -14,6 +14,7 @@ private static readonly (string Controller, string[] Actions)[] JobManifestContr ("LearnStack.Api.PublicReads.PublicSiteController", ["Get", "Head"]), ("LearnStack.Api.PublicReads.PublicEducationController", ["Catalog", "HeadCatalog", "Course", "HeadCourse", "Lesson", "HeadLesson"]), ("LearnStack.Api.Account.AccountController", ["GetSite"]), + ("LearnStack.Api.Identity.MembershipsController", ["List"]), ]; /// ADR-0047: disk-discovered modules cannot hide declarations outside the live handler scan. @@ -24,6 +25,10 @@ public void BackgroundJob_Definitions_Stay_In_Their_Owning_Module() EvaluateDetector(census, JobManifestOffenders).Should().BeEmpty(); EndpointCompositionControls.Plant(census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Api"), (prefix, because) => AssertEndpointControl(EvaluateDetector(census, JobManifestOffenders), prefix, because)); + EndpointCompositionControls.PlantMetadata(census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Api"), + (owner, because) => EvaluateDetector(census, JobManifestOffenders).Should().Contain(error => + error.StartsWith("Invalid infrastructure metadata attachment:", StringComparison.Ordinal) + && error.Contains(owner, StringComparison.Ordinal), because)); Action unrelated = () => AssertEndpointControl(["Missing live job manifest subject: unrelated"], "Unexpected infrastructure path/handler/signature:", "an unrelated failure cannot satisfy the path control"); unrelated.Should().Throw(); @@ -93,6 +98,7 @@ public void BackgroundJob_Definitions_Stay_In_Their_Owning_Module() AddCallProbe(scaffold, "HiddenGenericDashboard", useDashboard, OpCodes.Call), JobManifestOffenders); foreach (var siteController in new[] { types[("LearnStack.Api", "LearnStack.Api.PublicReads.PublicSiteController")], types[("LearnStack.Api", "LearnStack.Api.Account.AccountController")], + types[("LearnStack.Api", "LearnStack.Api.Identity.MembershipsController")], types[("LearnStack.Api", "LearnStack.Api.Common.ApiControllerBase")] }) AssertMutation(census, "a new declared or inherited public action on an approved controller", () => { @@ -185,11 +191,14 @@ private static SortedSet JobManifestOffenders(ProductionCensus census) } // Scan every production body, not just the Development branch executed - // by the host proof. Exactly these four endpoint registration calls exist. + // by the host proof. Four mappings and their three fixed metadata + // attachments are the complete registration capability population. var api = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Api"); var main = Method(types[("LearnStack.Api", "Program")], "
$"); var openApi = Method(types[("LearnStack.Api", "LearnStack.Api.Versioning.ApiVersioningExtensions")], "MapLearnStackOpenApi"); var endpointBuilder = typeof(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder); + var attachMetadata = typeof(Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions).GetMethods() + .Single(method => method.Name == "WithMetadata" && method.IsGenericMethodDefinition); var approvedRoutes = new[] { (main, typeof(Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions).GetMethod("MapGet", [endpointBuilder, typeof(string), typeof(Delegate)])!), @@ -197,15 +206,33 @@ private static SortedSet JobManifestOffenders(ProductionCensus census) (openApi, typeof(Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions).GetMethod("MapOpenApi", [endpointBuilder, typeof(string)])!), (openApi, typeof(Scalar.AspNetCore.ScalarEndpointRouteBuilderExtensions).GetMethod("MapScalarApiReference", [endpointBuilder, typeof(string), typeof(Action)])!), + (main, attachMetadata.MakeGenericMethod(typeof(Microsoft.AspNetCore.Builder.RouteHandlerBuilder))), + (openApi, attachMetadata.MakeGenericMethod(typeof(Microsoft.AspNetCore.Builder.IEndpointConventionBuilder))), + (openApi, attachMetadata.MakeGenericMethod(typeof(Microsoft.AspNetCore.Builder.IEndpointConventionBuilder))), }; CheckSet(offenders, "exact endpoint registration capabilities", sites.Where(site => EndpointCapability(site.Target)) .Select(site => MethodId(site.Owner) + "|" + MethodId(site.Target) + "|" + site.Instruction.OpCode.Name), - approvedRoutes.Select(route => route.Item1 + "|" + MethodId(api.ImportReference(route.Item2)) + "|call")); + approvedRoutes.Select(route => route.Item1 + "|" + ExpectedEndpointMethod(route.Item2) + "|call")); foreach (var site in sites.Where(site => site.Target.Name == "MapGet" && EndpointCapability(site.Target))) if (!EndpointComposition.HasLiteralPath(site.Owner, site.Instruction, "/healthz")) offenders.Add("Only the direct literal /healthz infrastructure mapping is admitted: " + MethodId(site.Owner)); + foreach (var site in sites.Where(site => site.Target.Name == "WithMetadata" && EndpointCapability(site.Target))) + if (!EndpointComposition.IsFixedInfrastructureMetadata(site.Owner, site.Instruction)) + offenders.Add("Invalid infrastructure metadata attachment: " + MethodId(site.Owner)); return offenders; + string ExpectedEndpointMethod(System.Reflection.MethodInfo method) + { + var imported = api.ImportReference(method); + if (imported is GenericInstanceMethod { Name: "WithMetadata" } metadata) + { + // Reflection import names its method parameter TBuilder; emitted + // MemberRefs use !!0. Normalize only this exact known generic slot. + metadata.ElementMethod.GenericParameters[0].Name = "!!0"; + } + return MethodId(imported); + } + IEnumerable ControllerActions(TypeDefinition controller) { var current = controller; diff --git a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobOwnerBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobOwnerBoundaryTests.cs index 8aed086e3..329bf3fe7 100644 --- a/backend/tests/LearnStack.Tests.Architecture/BackgroundJobOwnerBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/BackgroundJobOwnerBoundaryTests.cs @@ -326,17 +326,14 @@ private static SortedSet JobOwnerOffenders(ProductionCensus census) foreach (var subject in JobOwnerSubjects()) if (!types.ContainsKey(subject)) offenders.Add("Missing business owner subject: " + subject.Type); if (offenders.Count != 0) return offenders; - var sites = CallSites(census).ToArray(); + var sites = OwnedCallSites(census).ToArray(); RecordDetachedTargets(offenders, sites); if (offenders.Count != 0) return offenders; var sitesByTargetName = sites.ToLookup(site => site.Target.Name, StringComparer.Ordinal); var bodies = OwnedBodies(census).ToArray(); - // Build once from this evaluation's live graph; each seam keeps its - // exact declaring-type and opcode checks inside the named bucket. - var instructionsByTargetName = bodies.SelectMany(item => item.Body.Body.Instructions) - .Where(instruction => instruction.Operand is MethodReference) - .Select(instruction => (Instruction: instruction, Target: (MethodReference)instruction.Operand)) - .ToLookup(site => site.Target.Name, StringComparer.Ordinal); + // Format only selected owners, within this read-only detector evaluation. + // The next mutation or restoration receives fresh identities and indexes. + var identities = new Dictionary(ReferenceEqualityComparer.Instance); var participant = types[(DeliveryApplication, JobParticipant)]; var postgres = types[(InfrastructureAssembly, JobPostgresParticipant)]; if (!participant.IsAbstract || !postgres.IsSealed) offenders.Add("Job completion has one closed abstract/implementation pair"); @@ -344,7 +341,7 @@ private static SortedSet JobOwnerOffenders(ProductionCensus census) CheckSet(offenders, "closed job participant registration", sites.Where(site => site.Target is GenericInstanceMethod generic && generic.Name == "TryAddScoped" && generic.GenericArguments.Count == 2 && generic.GenericArguments[0].FullName == JobParticipant && generic.GenericArguments[1].FullName == JobPostgresParticipant) - .Select(site => site.Owner), [JobOwner(offenders, types, InfrastructureAssembly, + .Select(site => Identity(site.Owner)), [JobOwner(offenders, types, InfrastructureAssembly, "LearnStack.Infrastructure.Jobs.BackgroundJobContractsRegistration", "AddLearnStackBackgroundJobContracts")]); foreach (var type in new[] { types[(DeliveryApplication, JobCommitGate)], types[(DeliveryApplication, JobPublication)], types[(KernelAssembly, JobTrustedContext)] }) if (!type.IsSealed) offenders.Add(type.FullName + " must remain sealed"); @@ -512,6 +509,14 @@ private static SortedSet JobOwnerOffenders(ProductionCensus census) CheckCommitBudget(); return offenders; + string Identity(MethodDefinition method) + { + if (identities.TryGetValue(method, out var identity)) return identity; + identity = MethodId(method); + identities.Add(method, identity); + return identity; + } + void Cap(string assembly, string typeName, string name, string returnType, string[] parameters, (string Assembly, string Type, string Method)[] owners, string[]? exactOwners = null) { @@ -520,9 +525,9 @@ void Cap(string assembly, string typeName, string name, string returnType, strin if (methods.Length != 1 || methods[0].ReturnType.FullName != returnType || !ParametersMatch(methods[0], parameters)) offenders.Add(typeName + "." + name + " requires its exact closed signature"); CheckSet(offenders, typeName + "." + name + " authority callers", - sitesByTargetName[name].Where(site => SameType(site.Target.DeclaringType, type)).Select(site => site.Owner), + sitesByTargetName[name].Where(site => SameType(site.Target.DeclaringType, type)).Select(site => Identity(site.Owner)), owners.Select(owner => JobOwner(offenders, types, owner.Assembly, owner.Type, owner.Method)).Concat(exactOwners ?? [])); - foreach (var site in instructionsByTargetName[name].Where(site => SameType(site.Target.DeclaringType, type))) + foreach (var site in census.MethodInstructions(name).Where(site => SameType(site.Target.DeclaringType, type))) if (site.Instruction.OpCode != OpCodes.Call && site.Instruction.OpCode != OpCodes.Callvirt && !(name == ".ctor" && site.Instruction.OpCode == OpCodes.Newobj)) offenders.Add(typeName + "." + name + " cannot escape as a delegate or constructor"); diff --git a/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs b/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs index 80c7a0e82..83f5803d5 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs @@ -403,6 +403,7 @@ private static Dictionary> DeclaredMethods() => /// The number words the index's summary sentence uses. private static readonly Dictionary Words = new(StringComparer.OrdinalIgnoreCase) { + ["zero"] = 0, ["one"] = 1, ["two"] = 2, ["three"] = 3, diff --git a/backend/tests/LearnStack.Tests.Architecture/EndpointComposition.Metadata.cs b/backend/tests/LearnStack.Tests.Architecture/EndpointComposition.Metadata.cs new file mode 100644 index 000000000..81d459e3d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/EndpointComposition.Metadata.cs @@ -0,0 +1,78 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace LearnStack.Tests.Architecture; + +internal static partial class EndpointComposition +{ + private const string FixedMetadata = "LearnStack.Api.Authentication.InfrastructureOperationMetadata"; + private const string VersioningOwner = "LearnStack.Api.Versioning.ApiVersioningExtensions"; + private const string ConventionExtensions = "Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions"; + private const string RouteBuilder = "Microsoft.AspNetCore.Builder.RouteHandlerBuilder"; + private const string ConventionBuilder = "Microsoft.AspNetCore.Builder.IEndpointConventionBuilder"; + + // This is a data-only attachment to an already enumerated mapping, not a + // general WithMetadata exception. The receiver and sole array element must + // come directly from the existing mapping and one exact fixed declaration. + internal static bool IsFixedInfrastructureMetadata(MethodDefinition owner, Instruction call) + { + if (owner.Module.Assembly.Name.Name != "LearnStack.Api" || !owner.HasBody + || call.OpCode != OpCodes.Call || call.Operand is not GenericInstanceMethod + { Name: "WithMetadata", GenericArguments.Count: 1, Parameters.Count: 2 } target + || target.DeclaringType.FullName != ConventionExtensions + || target.Parameters[1].ParameterType.FullName != "System.Object[]") return false; + + var health = owner.DeclaringType.FullName == "Program" && owner.Name == "
$"; + var versioning = owner.DeclaringType.FullName == VersioningOwner && owner.Name == "MapLearnStackOpenApi"; + if (!(health || versioning) || target.GenericArguments[0].FullName != (health ? RouteBuilder : ConventionBuilder)) return false; + var attachments = owner.Body.Instructions.Where(instruction => instruction.Operand is MethodReference + { Name: "WithMetadata", DeclaringType.FullName: ConventionExtensions }).ToArray(); + if (attachments.Length != (health ? 1 : 2)) return false; + + var store = call.Previous; + var declaration = store?.Previous; + var index = declaration?.Previous; + var duplicate = index?.Previous; + var array = duplicate?.Previous; + var size = array?.Previous; + if (store is null || declaration is null || index is null || duplicate is null || array is null || size is null) return false; + if (store.OpCode != OpCodes.Stelem_Ref || declaration.OpCode != OpCodes.Call + || declaration.Operand is not MethodReference { DeclaringType.FullName: FixedMetadata, Parameters.Count: 0 } getter + || getter.ReturnType.FullName != FixedMetadata + || index.OpCode != OpCodes.Ldc_I4_0 || duplicate.OpCode != OpCodes.Dup + || array.OpCode != OpCodes.Newarr || array.Operand is not TypeReference { FullName: "System.Object" } + || size.OpCode != OpCodes.Ldc_I4_1 + || ArgumentProducer(owner, call, 1) != array) return false; + // No branch may skip element initialization while retaining the array. + var interior = new[] { array, duplicate, index, declaration, store, call }; + if (owner.Body.Instructions.Any(instruction => instruction.Operand is Instruction branch && interior.Contains(branch) + || instruction.Operand is Instruction[] branches && branches.Any(interior.Contains))) return false; + if (owner.Body.Instructions.Count(instruction => instruction.Operand is MethodReference method + && method.DeclaringType.FullName == FixedMetadata && method.Name == getter.Name) != 1) return false; + + var receiver = ArgumentProducer(owner, call, 0); + if (health) + return getter.Name == "get_Health" && IsCall(receiver, ConventionExtensions, "WithName", 2) + && receiver!.Operand is GenericInstanceMethod { GenericArguments.Count: 1 } name + && name.GenericArguments[0].FullName == RouteBuilder + && HasLiteralArgument(owner, receiver, 1, "HealthCheck") + && ArgumentProducer(owner, receiver, 0) is { } mapping + && IsCall(mapping, "Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions", "MapGet", 3) + && HasLiteralPath(owner, mapping, "/healthz"); + return getter.Name switch + { + "get_OpenApi" => IsCall(receiver, "Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions", "MapOpenApi", 2) + && ArgumentProducer(owner, receiver!, 1) is { } path + && IsCall(path, "System.String", "Concat", 3) + && HasLiteralArgument(owner, path, 0, "/openapi/{documentName:regex(^(") + && HasLiteralArgument(owner, path, 2, ")$)}.json"), + "get_Documentation" => IsCall(receiver, "Scalar.AspNetCore.ScalarEndpointRouteBuilderExtensions", "MapScalarApiReference", 3) + && HasLiteralPath(owner, receiver!, "/docs"), + _ => false, + }; + } + + private static bool IsCall(Instruction? instruction, string declaringType, string name, int parameters) => + instruction is { OpCode.Code: Code.Call, Operand: MethodReference target } + && target.DeclaringType.FullName == declaringType && target.Name == name && target.Parameters.Count == parameters; +} diff --git a/backend/tests/LearnStack.Tests.Architecture/EndpointComposition.cs b/backend/tests/LearnStack.Tests.Architecture/EndpointComposition.cs index 242d4c6c3..ecdd7582f 100644 --- a/backend/tests/LearnStack.Tests.Architecture/EndpointComposition.cs +++ b/backend/tests/LearnStack.Tests.Architecture/EndpointComposition.cs @@ -4,7 +4,7 @@ namespace LearnStack.Tests.Architecture; /// Shared closed-surface capabilities and fail-closed call-argument provenance. -internal static class EndpointComposition +internal static partial class EndpointComposition { internal static bool IsMapping(MethodReference method) => method.Name.StartsWith("Map", StringComparison.Ordinal) && (method.DeclaringType.FullName.StartsWith("Microsoft.AspNetCore.", StringComparison.Ordinal) diff --git a/backend/tests/LearnStack.Tests.Architecture/EndpointCompositionControls.cs b/backend/tests/LearnStack.Tests.Architecture/EndpointCompositionControls.cs index 41f5504af..1aa61b66c 100644 --- a/backend/tests/LearnStack.Tests.Architecture/EndpointCompositionControls.cs +++ b/backend/tests/LearnStack.Tests.Architecture/EndpointCompositionControls.cs @@ -9,6 +9,50 @@ namespace LearnStack.Tests.Architecture; internal static class EndpointCompositionControls { + internal static void PlantMetadata(ModuleDefinition api, Action assertDirty) + { + var owners = new[] { api.GetType("Program").Methods.Single(method => method.Name == "
$"), + api.GetType("LearnStack.Api.Versioning.ApiVersioningExtensions").Methods.Single(method => method.Name == "MapLearnStackOpenApi") }; + foreach (var owner in owners) + foreach (var attachment in owner.Body.Instructions.Where(instruction => instruction.Operand is MethodReference + { Name: "WithMetadata", DeclaringType.FullName: "Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions" }).ToArray()) + { + var declaration = attachment.Previous.Previous; + var originalGetter = (MethodReference)declaration.Operand; + var otherGetter = api.GetType("LearnStack.Api.Authentication.InfrastructureOperationMetadata").Methods + .Single(method => method.Name == (originalGetter.Name == "get_Health" ? "get_OpenApi" : "get_Health")); + declaration.Operand = otherGetter; + try { assertDirty(owner.DeclaringType.FullName, "a fixed declaration cannot be borrowed by another mapping"); } + finally { declaration.Operand = originalGetter; } + + var size = declaration.Previous.Previous.Previous.Previous; + var originalSize = size.OpCode; + size.OpCode = OpCodes.Ldc_I4_2; + try { assertDirty(owner.DeclaringType.FullName, "the metadata attachment permits exactly one array element"); } + finally { size.OpCode = originalSize; } + + declaration.OpCode = OpCodes.Ldftn; + try { assertDirty(owner.DeclaringType.FullName, "an aliased declaration is not a fixed getter invocation"); } + finally { declaration.OpCode = OpCodes.Call; } + + var receiver = EndpointComposition.ArgumentProducer(owner, attachment, 0) + ?? throw new InvalidOperationException("The fixed attachment must have a direct receiver."); + var originalReceiver = (MethodReference)receiver.Operand; + var delegatedReceiver = new MethodReference("DelegateMapping", originalReceiver.ReturnType, + api.GetType("LearnStack.Api.Authentication.InfrastructureOperationMetadata")); + foreach (var parameter in originalReceiver.Parameters) + delegatedReceiver.Parameters.Add(new ParameterDefinition(parameter.ParameterType)); + receiver.Operand = delegatedReceiver; + try { assertDirty(owner.DeclaringType.FullName, "a delegated mapping cannot borrow the fixed metadata attachment"); } + finally { receiver.Operand = originalReceiver; } + + var duplicate = Instruction.Create(OpCodes.Call, (MethodReference)attachment.Operand); + owner.Body.Instructions.Add(duplicate); + try { assertDirty(owner.DeclaringType.FullName, "a second metadata call cannot borrow the original fixed composition site"); } + finally { owner.Body.Instructions.Remove(duplicate); } + } + } + // Mutate the real production mapping, then restore it before the next // detector runs. These instructions are inspected, never executed. internal static void Plant(ModuleDefinition api, Action assertDirty) diff --git a/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.AuthorizationEvidence.cs b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.AuthorizationEvidence.cs new file mode 100644 index 000000000..4a506e5e9 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.AuthorizationEvidence.cs @@ -0,0 +1,314 @@ +using FluentAssertions; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; +using EvidenceCensus = LearnStack.Tests.Architecture.TrustedOperationSourceConventionTests.Census; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class IdentityAuditConventionTests +{ + private const string Occurrence = "LearnStack.SharedKernel.Audit.AuthorizationEvidenceOccurrence"; + private const string AuditContext = "LearnStack.SharedKernel.Audit.AuthorizationAuditContext"; + private const string Transport = "LearnStack.SharedKernel.Audit.OperationTransportProvenance"; + private const string Assertion = "LearnStack.SharedKernel.Audit.ConfirmedClaimAssertion"; + private const string AssertionSource = "LearnStack.Api.Authentication.ConfirmedClaimAssertionSource"; + private const string CentralEvidence = "LearnStack.Application.Audit.AuthorizationEvidenceCoordinator"; + private const string HttpTransport = "LearnStack.Api.Authentication.HttpAuthorizationTransportSource"; + private const string TransportPort = "LearnStack.SharedKernel.Audit.IAuthorizationTransportSource"; + private const string InvocationContext = "LearnStack.SharedKernel.Permissions.IOperationInvocationContext"; + private const string PolicyOwner = "LearnStack.Application.Authorization.OperationAuthorizationCoordinator"; + private const string RoleAuthorization = "LearnStack.Modules.Identity.Application.Authorization.RoleGrantAuthorization"; + private const string RoleHandler = "LearnStack.Modules.Identity.Application.Roles.RoleGrantCommandHandlerBase"; + private const string StateCapture = "LearnStack.Infrastructure.Audit.AuditStateCapture"; + private const string FrameBindings = "System.Collections.Generic.Dictionary`2"; + private const string OrdinaryAuditOwner = "LearnStack.Application.Audit.AuditExecutionCoordinator"; + private static readonly string[] AuthorizationPrimaryKeyFilter = ["23505", "public", "audit_log", "audit_log_pkey"]; + + [Fact] + public void Authorization_Evidence_Uses_Trusted_Bounded_Provenance() + { + using var census = EvidenceCensus.Read(); + EvidenceProvenanceOffenders(census).Should().BeEmpty("only the central owner composes fixed events from live typed source and decision evidence"); + foreach (var name in new[] { Occurrence, AuditContext, Transport, Assertion }) + { + EvidenceDirty(census, EvidenceProvenanceOffenders, () => + { + var constructor = census.Type(name).Methods.Single(method => method.IsConstructor); + var attributes = constructor.Attributes; + constructor.IsPublic = true; + return () => constructor.Attributes = attributes; + }); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => + { + var field = census.Type(name).Fields.First(item => item.IsInitOnly); + field.IsInitOnly = false; + return () => field.IsInitOnly = true; + }); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => + { + var module = census.Type(CentralEvidence).Module; + var extra = new TypeDefinition("EvidenceControl", "Extra" + census.Type(name).Name + "Minter", TypeAttributes.NotPublic, + module.ImportReference(census.Type(name + "/MintingSource"))); + module.Types.Add(extra); + return () => module.Types.Remove(extra); + }); + } + foreach (var (type, method) in new[] { (Occurrence + "/MintingSource", "ForOccurrence"), + (AuditContext + "/MintingSource", "ForAuditContext"), (Transport + "/MintingSource", "ForTransport"), + (Assertion + "/MintingSource", "ForAssertion"), + ("LearnStack.SharedKernel.Audit.IAuditStore", "WriteAuthorizationPendingAsync"), + ("LearnStack.SharedKernel.Audit.IAuditStore", "WriteAuthorizationStandaloneAsync"), + ("LearnStack.SharedKernel.Audit.IAuditStateCapture", "AttachAuthorization"), + (CentralEvidence, "BeginExecution"), (CentralEvidence, "Observe"), + (CentralEvidence, "RecordPolicyRefusal"), (CentralEvidence, "CaptureRefusalObservation"), (CentralEvidence, "RecordRefusalResult"), + ("LearnStack.Api.Tenancy.IClaimContextAssertionRecorder", "RecordAsync"), + (CentralEvidence, "BeforeSuccessAsync") }) + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Ldftn }) EvidenceDirty(census, EvidenceProvenanceOffenders, + () => EvidenceExtraCall(census, census.Method(type, method), opcode)); + foreach (var (type, method, target, name) in new[] + { + (Occurrence, ".ctor", Occurrence, "CopyAddress"), (Occurrence, "get_Draft", Occurrence, "CopyAddress"), + (Transport, "Matches", Transport + "/MintingSource", "get_IsOpen"), + (Transport, "Matches", "LearnStack.SharedKernel.Permissions.OperationInvocation", "get_IsActive"), + (Transport + "/MintingSource", "Dispose", "System.Threading.Interlocked", "Exchange"), + (Assertion, "get_IsActive", Assertion + "/MintingSource", "get_IsOpen"), + (Assertion + "/MintingSource", "Dispose", "System.Threading.Interlocked", "Exchange"), + (AssertionSource, "Capture", "LearnStack.Api.Authentication.TenantAuthorizationCandidateSource", "ForValidatedScope"), + (CentralEvidence, "Snapshot", InvocationContext, "get_CurrentInvocation"), + (CentralEvidence, "RecordObservedDenial", "LearnStack.SharedKernel.Identifiers.IGuidFactory", "NewUuidV7"), + (CentralEvidence, "RecordObservedDenial", "LearnStack.SharedKernel.Time.IClock", "get_UtcNow"), + (HttpTransport, "Capture", "LearnStack.Api.Authentication.OperationHttpClassifier", "Classify"), + (EvidenceStore, "HasAuthorizationEvidenceAsync", EvidenceStore, "Bind"), + (StateCapture, "AttachAuthorization", FrameBindings, "TryGetValue"), + (StateCapture, "AttachAuthorization", FrameBindings, "TryAdd"), + (StateCapture, "Clear", FrameBindings, "Clear"), + (OrdinaryAuditOwner, "ExecuteDeclaredAsync", CentralEvidence, "ReconcileBeforeCaptureClearAsync"), + (CentralEvidence, "BeforeOwnerCompletionAsync", "LearnStack.SharedKernel.Persistence.IUnitOfWork", "MarkRollbackOnly"), + (CentralEvidence, "BeforeOwnerCompletionAsync", CentralEvidence + "/CommitBatch", "get_Generation"), + (CentralEvidence, "RecordOwnerOutcome", "LearnStack.SharedKernel.Persistence.IUnitOfWork", "get_HasActiveTransaction"), + }) EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceRemoveCall(census.Body(type, method), target, name)); + var verification = census.Body(EvidenceStore, "HasAuthorizationEvidenceAsync"); + var sql = verification.Body.Instructions.Single(instruction => instruction.Operand is string text && text.Contains("SELECT 1 FROM public.audit_log", StringComparison.Ordinal)); + foreach (var (before, after) in new[] + { + ("AND metadata IS NOT DISTINCT FROM @metadata", string.Empty), + ("WHERE id = @id", "WHERE TRUE"), + ("AND timestamp = @timestamp", string.Empty), + ("WHERE id = @id", "WHERE id IS NOT DISTINCT FROM @id"), + ("AND timestamp = @timestamp", "AND timestamp IS NOT DISTINCT FROM @timestamp"), + }) EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(sql, OpCodes.Ldstr, + ((string)sql.Operand).Replace(before, after, StringComparison.Ordinal))); + var standalone = census.Body(EvidenceStore, "WriteAuthorizationStandaloneAsync"); + var key = standalone.Body.Instructions.Single(instruction => instruction.Operand as string == "audit_log_pkey"); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(key, OpCodes.Ldstr, "unrelated_unique_key")); + foreach (var contract in new[] { InvocationContext, "LearnStack.SharedKernel.Audit.IAuthorizationRefusalReporter", + "LearnStack.Api.Tenancy.IClaimContextAssertionRecorder" }) + EvidenceDirty(census, EvidenceProvenanceOffenders, () => + { + var type = census.Type(PolicyOwner); + var extra = new TypeDefinition("EvidenceControl", "UnownedInvocationContext", TypeAttributes.NotPublic, type.Module.TypeSystem.Object); + extra.Interfaces.Add(new InterfaceImplementation(type.Module.ImportReference(census.Type(contract)))); + type.Module.Types.Add(extra); + return () => type.Module.Types.Remove(extra); + }); + var auditOwner = census.Body(OrdinaryAuditOwner, "ExecuteDeclaredAsync"); + var reconcile = EvidenceCalls(auditOwner, CentralEvidence, "ReconcileBeforeCaptureClearAsync").Single(); + var clear = EvidenceCalls(auditOwner, "LearnStack.SharedKernel.Audit.IAuditStateCapture", "Clear").Single(); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(auditOwner.Body.Instructions + .SkipWhile(instruction => instruction != reconcile).TakeWhile(instruction => instruction != clear).First(EvidenceAwaitAck), OpCodes.Nop, null)); + var captureBinding = census.Body(StateCapture, "AttachAuthorization"); + var comparison = EvidenceFrameIdentityComparisons(captureBinding).Single(); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(comparison, OpCodes.Nop, null)); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(comparison.Previous.Previous.Previous, OpCodes.Ldloc_0, null)); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => + { + var undo = EvidenceReplace(comparison, OpCodes.Nop, null); + var decoy = Instruction.Create(OpCodes.Beq_S, captureBinding.Body.Instructions[^1]); + captureBinding.Body.Instructions.Add(decoy); + return () => { captureBinding.Body.Instructions.Remove(decoy); undo(); }; + }); + var generationGuard = EvidencePendingGenerationGuards(census.Body(CentralEvidence, "BeforeOwnerCompletionAsync")).Single(); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(generationGuard.TransactionMismatch, OpCodes.Nop, null)); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(generationGuard.GenerationEqual, OpCodes.Nop, null)); + var prematureAck = EvidencePrematureAcknowledgementGuards(census.Body(CentralEvidence, "RecordOwnerOutcome")).Single(); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(prematureAck, OpCodes.Nop, null)); + EvidenceObservationDirtyControls(census); + } + + private static List EvidenceProvenanceOffenders(EvidenceCensus census) + { + var errors = new List(); + foreach (var name in new[] { Occurrence, AuditContext, Transport, Assertion }) + { + var type = census.Type(name); + if (!type.IsSealed || type.Methods.Count(method => method.IsConstructor) != 1 + || !type.Methods.Single(method => method.IsConstructor).IsPrivate + || type.Properties.Any(property => property.SetMethod is not null) + || type.Fields.Any(field => !field.IsPrivate || !field.IsInitOnly || field.IsStatic)) + errors.Add("Authorization evidence has private immutable construction: " + name); + } + EvidenceDerivers(Occurrence + "/MintingSource", [AuditContext + "/MintingSource"]); + EvidenceDerivers(AuditContext + "/MintingSource", [CentralEvidence]); + EvidenceDerivers(Transport + "/MintingSource", [HttpTransport]); + EvidenceDerivers(Assertion + "/MintingSource", [AssertionSource]); + EvidenceImplementers(census, errors, TransportPort, [HttpTransport]); + EvidenceImplementers(census, errors, InvocationContext, [PolicyOwner]); + EvidenceImplementers(census, errors, "LearnStack.SharedKernel.Audit.IAuthorizationRefusalReporter", [CentralEvidence]); + EvidenceCallers(census, errors, Occurrence, ".ctor", [(Occurrence + "/MintingSource", "ForOccurrence", 1)]); + EvidenceCallers(census, errors, AuditContext, ".ctor", [(AuditContext + "/MintingSource", "ForAuditContext", 1)]); + EvidenceCallers(census, errors, Transport, ".ctor", [(Transport + "/MintingSource", "ForTransport", 1)]); + EvidenceCallers(census, errors, Assertion, ".ctor", [(Assertion + "/MintingSource", "ForAssertion", 1)]); + EvidenceCallers(census, errors, Assertion + "/MintingSource", "ForAssertion", [(AssertionSource, "Capture", 1)]); + EvidenceCallers(census, errors, AssertionSource, "Capture", [("LearnStack.Api.Authentication.ClaimContextAssertionRecorder", "RecordAsync", 1)]); + EvidenceCallers(census, errors, CentralEvidence, "RecordConfirmedAssertionAsync", [("LearnStack.Api.Authentication.ClaimContextAssertionRecorder", "RecordAsync", 1)]); + EvidenceImplementers(census, errors, "LearnStack.Api.Tenancy.IClaimContextAssertionRecorder", ["LearnStack.Api.Authentication.ClaimContextAssertionRecorder"]); + EvidenceCallers(census, errors, "LearnStack.Api.Tenancy.IClaimContextAssertionRecorder", "RecordAsync", + [("LearnStack.Api.Tenancy.TenantAssertionMiddleware", "InvokeAsync", 1)]); + EvidenceCallers(census, errors, Occurrence + "/MintingSource", "ForOccurrence", [(CentralEvidence, "RecordObservedDenial", 1), + (CentralEvidence, "RecordConfirmedAssertionAsync", 1)]); + EvidenceCallers(census, errors, AuditContext + "/MintingSource", "ForAuditContext", [(CentralEvidence, "Snapshot", 1)]); + EvidenceCallers(census, errors, Transport + "/MintingSource", "ForTransport", [(HttpTransport, "Capture", 1)]); + EvidenceCallers(census, errors, TransportPort, "Capture", [(CentralEvidence, "Snapshot", 1)]); + EvidenceCallers(census, errors, "LearnStack.SharedKernel.Audit.IAuditStore", "WriteAuthorizationPendingAsync", [(CentralEvidence, "BeforeOwnerCompletionAsync", 1)]); + EvidenceCallers(census, errors, "LearnStack.SharedKernel.Audit.IAuditStore", "WriteAuthorizationStandaloneAsync", + [(CentralEvidence, "AcknowledgeStandaloneAsync", 1), (CentralEvidence, "ReconcileBeforeCaptureClearAsync", 1), (CentralEvidence, "RecordConfirmedAssertionAsync", 1)]); + EvidenceCallers(census, errors, CentralEvidence, "ReconcileBeforeCaptureClearAsync", + [(CentralEvidence, "EndExecutionAsync", 1), (OrdinaryAuditOwner, "ExecuteDeclaredAsync", 1)]); + EvidenceCallers(census, errors, CentralEvidence + "/CommitBatch", ".ctor", [(CentralEvidence, "BeforeOwnerCompletionAsync", 1)]); + EvidenceCallers(census, errors, CentralEvidence, "BeforeOwnerCompletionAsync", [("LearnStack.Application.Persistence.TransactionExecutionCoordinator", "ExecuteFrameAsync", 1)]); + EvidenceCallers(census, errors, CentralEvidence, "RecordOwnerOutcome", [("LearnStack.Application.Persistence.TransactionExecutionCoordinator", "ExecuteFrameAsync", 3)]); + EvidenceCallers(census, errors, CentralEvidence, "BeginExecution", [(PolicyOwner, "ExecuteAsync", 1), + (OrdinaryAuditOwner, "ExecuteDeclaredAsync", 1), ("LearnStack.Application.Persistence.TransactionExecutionCoordinator", "ExecuteFrameAsync", 1)]); + EvidenceCallers(census, errors, CentralEvidence, "Observe", [(PolicyOwner, "ExecuteAsync", 2), (CentralEvidence, "ReportRecheck", 1)]); + EvidenceCallers(census, errors, CentralEvidence, "RecordPolicyRefusal", [(PolicyOwner, "ExecuteAsync", 3)]); + EvidenceCallers(census, errors, CentralEvidence, "BeforeSuccessAsync", [(PolicyOwner, "ExecuteAsync", 1), (OrdinaryAuditOwner, "ExecuteDeclaredAsync", 1)]); + EvidenceCallers(census, errors, CentralEvidence, "Snapshot", [(CentralEvidence, "Observe", 1), (CentralEvidence, "RecordDenial", 1)]); + EvidenceCallers(census, errors, CentralEvidence, "RecordDenial", [(CentralEvidence, "ReportResourceRefusal", 1), (CentralEvidence, "RecordPolicyRefusal", 1)]); + EvidenceCallers(census, errors, "LearnStack.SharedKernel.Audit.IAuditStateCapture", "AttachAuthorization", + [(CentralEvidence, "Observe", 1), (CentralEvidence, "RecordObservedDenial", 1)]); + EvidenceCallers(census, errors, InvocationContext, "get_CurrentInvocation", [(CentralEvidence, "Snapshot", 1), (RoleAuthorization, "Matches", 1), (RoleHandler, "ExecuteAsync", 1)]); + EvidenceCallers(census, errors, InvocationContext, "get_CurrentDecision", [(RoleAuthorization, "Matches", 1), (RoleHandler, "ExecuteAsync", 1)]); + EvidenceCallers(census, errors, "LearnStack.SharedKernel.Audit.IAuthorizationRefusalReporter", "ReportRecheck", [(RoleAuthorization, "ReportRecheck", 1)]); + EvidenceCallers(census, errors, "LearnStack.SharedKernel.Audit.IAuthorizationRefusalReporter", "ReportResourceRefusal", [(RoleAuthorization, "ReportRefusal", 1)]); + foreach (var (type, method, target, name) in new[] + { + (Occurrence, ".ctor", Occurrence, "CopyAddress"), (Occurrence, "get_Draft", Occurrence, "CopyAddress"), + (Transport, "Matches", Transport + "/MintingSource", "get_IsOpen"), + (Transport, "Matches", "LearnStack.SharedKernel.Permissions.OperationInvocation", "get_IsActive"), + (Transport + "/MintingSource", "Dispose", "System.Threading.Interlocked", "Exchange"), + (Assertion, "get_IsActive", Assertion + "/MintingSource", "get_IsOpen"), + (Assertion + "/MintingSource", "Dispose", "System.Threading.Interlocked", "Exchange"), + (AssertionSource, "Capture", "LearnStack.Api.Authentication.TenantAuthorizationCandidateSource", "ForValidatedScope"), + (CentralEvidence, "RecordObservedDenial", "LearnStack.SharedKernel.Identifiers.IGuidFactory", "NewUuidV7"), + (CentralEvidence, "RecordObservedDenial", "LearnStack.SharedKernel.Time.IClock", "get_UtcNow"), + (HttpTransport, "Capture", "LearnStack.Api.Authentication.OperationHttpClassifier", "Classify"), + (EvidenceStore, "HasAuthorizationEvidenceAsync", EvidenceStore, "Bind"), + (StateCapture, "AttachAuthorization", FrameBindings, "TryGetValue"), + (StateCapture, "AttachAuthorization", FrameBindings, "TryAdd"), + (StateCapture, "Clear", FrameBindings, "Clear"), + (OrdinaryAuditOwner, "ExecuteDeclaredAsync", CentralEvidence, "ReconcileBeforeCaptureClearAsync"), + (CentralEvidence, "BeforeOwnerCompletionAsync", "LearnStack.SharedKernel.Persistence.IUnitOfWork", "MarkRollbackOnly"), + (CentralEvidence, "BeforeOwnerCompletionAsync", CentralEvidence + "/CommitBatch", "get_Generation"), + (CentralEvidence, "RecordOwnerOutcome", "LearnStack.SharedKernel.Persistence.IUnitOfWork", "get_HasActiveTransaction"), + }) EvidenceRequire(census, errors, type, method, target, name); + if (EvidencePendingGenerationGuards(census.Body(CentralEvidence, "BeforeOwnerCompletionAsync")).Count() != 1) + errors.Add("Each pending write is acknowledged before checking the exact captured transaction and fresh announcement generation; mismatch poisons that owner"); + if (EvidencePrematureAcknowledgementGuards(census.Body(CentralEvidence, "RecordOwnerOutcome")).Count() != 1) + errors.Add("Committed evidence is never acknowledged while that same captured physical unit remains active"); + EvidenceObservationOffenders(census, errors); + if (EvidenceFrameIdentityComparisons(census.Body(StateCapture, "AttachAuthorization")).Count() != 1) + errors.Add("Only the existing frame's actual stored invocation may refresh the same resource snapshot"); + var auditOwner = census.Body(OrdinaryAuditOwner, "ExecuteDeclaredAsync"); + var reconciliation = EvidenceCalls(auditOwner, CentralEvidence, "ReconcileBeforeCaptureClearAsync").ToArray(); + var clear = EvidenceCalls(auditOwner, "LearnStack.SharedKernel.Audit.IAuditStateCapture", "Clear").ToArray(); + if (reconciliation.Length != 1 || clear.Length != 1 || !EvidenceAwaited(auditOwner, reconciliation[0], clear[0])) + errors.Add("Actual authorization reconciliation acknowledges or records loss before ordinary capture clears the uncertain business state"); + var frameBindings = census.Type(StateCapture).Fields.SingleOrDefault(field => field.Name == "_authorizationFrames"); + if (frameBindings is not { IsPrivate: true, IsInitOnly: true, IsStatic: false } || frameBindings.FieldType.FullName != FrameBindings + || census.Methods.SelectMany(method => method.Body.Instructions.Select(instruction => (Method: method, Instruction: instruction))) + .Any(item => item.Instruction.Operand is FieldReference field && field.DeclaringType.FullName == StateCapture && field.Name == "_authorizationFrames" + && (item.Instruction.OpCode == OpCodes.Stfld ? census.Owner(item.Method) != StateCapture + "::.ctor" + : item.Instruction.OpCode != OpCodes.Ldfld || !(census.Owner(item.Method) == StateCapture + "::AttachAuthorization" + || census.Owner(item.Method) == StateCapture + "::Clear")))) + errors.Add("Only the actual resource frame binds one invocation, refreshes that same act and clears its private binding map"); + var sql = census.Body(EvidenceStore, "HasAuthorizationEvidenceAsync").Body.Instructions.Select(instruction => instruction.Operand) + .OfType().SingleOrDefault(text => text.Contains("SELECT 1 FROM public.audit_log", StringComparison.Ordinal)); + var fields = new[] { "id", "tenant_id", "organization_id", "actor_user_id", "actor_email", "module", "operation", "operation_type", "operation_class", + "entity_type", "entity_id", "outcome", "error_key", "reason", "before_state", "after_state", "changes", "correlation_id", "ip_address", "user_agent", "timestamp", "metadata" }; + var expectedSql = "SELECT EXISTS ( SELECT 1 FROM public.audit_log WHERE " + + string.Join(" AND ", fields.Select(field => field + (field is "id" or "timestamp" ? " = @" : " IS NOT DISTINCT FROM @") + field)) + ")"; + if (sql is null || SourceText.WithoutWhitespace(sql) != SourceText.WithoutWhitespace(expectedSql)) + errors.Add("A duplicate exactly matches both indexed nonnullable keys and all twenty nullable-safe persisted values under its scoped fresh transaction"); + var standalone = census.Body(EvidenceStore, "WriteAuthorizationStandaloneAsync"); + var filter = standalone.Body.Instructions.Select(instruction => instruction.Operand).OfType().ToArray(); + if (AuthorizationPrimaryKeyFilter.Any(value => filter.Count(actual => actual == value) != 1)) + errors.Add("Only the expected public.audit_log primary-key duplicate enters exact verification"); + foreach (var name in new[] { "WriteAuthorizationPendingAsync", "WriteAuthorizationStandaloneAsync", "InsertAuthorizationEvidenceAsync", "HasAuthorizationEvidenceAsync" }) + if (census.Body(EvidenceStore, name).Body.Instructions.Any(instruction => instruction.Operand is MethodReference target + && (target.DeclaringType.FullName is "System.DateTime" or "System.DateTimeOffset" && target.Name == "get_UtcNow" + || target.DeclaringType.FullName == "LearnStack.SharedKernel.Identifiers.IGuidFactory"))) + errors.Add("The store cannot replace the occurrence's frozen identity or decision time: " + name); + return errors; + + void EvidenceDerivers(string baseType, string[] expected) + { + if (!EvidenceSame(census.Types.Where(type => type.BaseType?.FullName == baseType).Select(type => type.FullName), expected)) + errors.Add("Authorization evidence minters are closed: " + baseType); + } + } + + // Release Roslyn lowers this ReferenceEquals to a reference beq. Bind its + // operands to the actual dictionary out-local and context.Invocation, and + // require both lookup-miss and identity-match to enter the same TryAdd path. + // An unrelated equality instruction supplies no frame ownership evidence. + private static IEnumerable EvidenceFrameIdentityComparisons(MethodDefinition body) + { + foreach (var comparison in body.Body.Instructions.Where(instruction => instruction.OpCode.Code is Code.Beq or Code.Beq_S)) + { + if (comparison.Previous is not { } invocation || !EvidenceIsCall(invocation, AuditContext, "get_Invocation") + || invocation.Previous?.OpCode != OpCodes.Ldarg_1 + || invocation.Previous.Previous is not { } owner || EvidenceLocal(owner) is not { } ownerIndex + || owner.Previous is not { OpCode.Code: Code.Brfalse or Code.Brfalse_S } miss + || miss.Previous is not { } lookup || !EvidenceIsCall(lookup, FrameBindings, "TryGetValue") + || lookup.Previous is not { OpCode.Code: Code.Ldloca or Code.Ldloca_S } captured + || EvidenceLocal(captured) != ownerIndex + || lookup.Previous.Previous is not { } key || EvidenceLocal(key) is not { } keyIndex + || lookup.Previous.Previous.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference dictionary } + || dictionary.DeclaringType.FullName != StateCapture || dictionary.Name != "_authorizationFrames" + || lookup.Previous.Previous.Previous.Previous?.OpCode != OpCodes.Ldarg_0 + || !body.Body.Instructions.Any(instruction => EvidenceIsCall(instruction, StateCapture, "get_CurrentFrame") + && instruction.Next is { } frameStore && EvidenceStoredLocal(frameStore) == keyIndex) + || comparison.Operand is not Instruction target || miss.Operand != target + || comparison.Next?.OpCode != OpCodes.Ret || comparison.Next.Next != target + || target.OpCode != OpCodes.Ldarg_0 + || target.Next is not { OpCode.Code: Code.Ldfld, Operand: FieldReference addDictionary } + || addDictionary.FullName != dictionary.FullName + || target.Next.Next is not { } addKey || EvidenceLocal(addKey) != keyIndex + || addKey.Next?.OpCode != OpCodes.Ldarg_1 || addKey.Next.Next is not { } addInvocation + || !EvidenceIsCall(addInvocation, AuditContext, "get_Invocation") + || addInvocation.Next is not { } add || !EvidenceIsCall(add, FrameBindings, "TryAdd")) continue; + yield return comparison; + } + } + + private static int? EvidenceLocal(Instruction instruction) => instruction.OpCode.Code switch + { + Code.Ldloc_0 => 0, + Code.Ldloc_1 => 1, + Code.Ldloc_2 => 2, + Code.Ldloc_3 => 3, + Code.Ldloc or Code.Ldloc_S or Code.Ldloca or Code.Ldloca_S when instruction.Operand is VariableDefinition variable => variable.Index, + _ => null, + }; + + private static int? EvidenceStoredLocal(Instruction instruction) => instruction.OpCode.Code switch + { + Code.Stloc_0 => 0, + Code.Stloc_1 => 1, + Code.Stloc_2 => 2, + Code.Stloc_3 => 3, + Code.Stloc or Code.Stloc_S when instruction.Operand is VariableDefinition variable => variable.Index, + _ => null, + }; +} diff --git a/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.AuthorizationEvidenceGeneration.cs b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.AuthorizationEvidenceGeneration.cs new file mode 100644 index 000000000..f80af788e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.AuthorizationEvidenceGeneration.cs @@ -0,0 +1,81 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class IdentityAuditConventionTests +{ + private const string EvidenceUnit = "LearnStack.SharedKernel.Persistence.IUnitOfWork"; + private const string EvidenceBatch = CentralEvidence + "/CommitBatch"; + private const string NullableGeneration = "System.Nullable`1"; + private const string EvidenceInvocation = "LearnStack.SharedKernel.Permissions.OperationInvocation"; + private const string EvidencePolicy = "LearnStack.SharedKernel.Permissions.OperationPolicy"; + private const string EvidenceTransactionOwner = "LearnStack.Application.Persistence.TransactionExecutionCoordinator"; + + private static IEnumerable EvidencePrematureAcknowledgementGuards(MethodDefinition body) + { + foreach (var active in EvidenceCalls(body, EvidenceUnit, "get_HasActiveTransaction")) + { + if (active.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference unit } + || unit.DeclaringType.FullName != CentralEvidence || unit.FieldType.FullName != EvidenceUnit + || active.Previous.Previous?.OpCode != OpCodes.Ldarg_0 + || active.Previous.Previous.Previous is not { OpCode.Code: Code.Brfalse or Code.Brfalse_S, Operand: Instruction accepted } committed + || committed.Previous?.OpCode != OpCodes.Ldarg_2 + || active.Next is not { OpCode.Code: Code.Brfalse or Code.Brfalse_S } closed + || closed.Operand != accepted + || closed.Next?.Operand as string != "A live transaction cannot acknowledge authorization evidence." + || closed.Next.Next?.OpCode != OpCodes.Newobj || closed.Next.Next.Next?.OpCode != OpCodes.Throw + || closed.Next.Next.Next.Next != accepted || accepted.OpCode != OpCodes.Ldarg_1 + || accepted.Next?.OpCode != OpCodes.Ldc_I4_1 || accepted.Next.Next is not { } finish + || !EvidenceIsCall(finish, EvidenceBatch, "set_Finished")) continue; + yield return closed; + } + } + + private static IEnumerable<(Instruction TransactionMismatch, Instruction GenerationEqual)> EvidencePendingGenerationGuards(MethodDefinition body) + { + var writes = EvidenceCalls(body, "LearnStack.SharedKernel.Audit.IAuditStore", "WriteAuthorizationPendingAsync").ToArray(); + if (writes.Length != 1) yield break; + foreach (var transaction in EvidenceCalls(body, EvidenceBatch, "get_Transaction")) + { + if (transaction.Next is not { OpCode.Code: Code.Bne_Un or Code.Bne_Un_S, Operand: Instruction failure } mismatch + || transaction.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference batch } + || batch.FieldType.FullName != EvidenceBatch || transaction.Previous.Previous?.OpCode != OpCodes.Ldarg_0 + || transaction.Previous.Previous.Previous is not { } currentTransaction + || !EvidenceIsCall(currentTransaction, EvidenceUnit, "get_Transaction") + || currentTransaction.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference owner } + || owner.FieldType.FullName != EvidenceUnit || currentTransaction.Previous.Previous?.OpCode != OpCodes.Ldarg_0 + || !EvidenceAwaited(body, writes[0], currentTransaction)) continue; + var announcement = EvidenceCalls(body, EvidenceUnit, "GetTenantContextAnnouncement").SingleOrDefault(call => + body.Body.Instructions.IndexOf(call) > body.Body.Instructions.IndexOf(mismatch)); + if (announcement is null || announcement.Previous is not { } capturedTransaction + || !EvidenceIsCall(capturedTransaction, EvidenceBatch, "get_Transaction") + || capturedTransaction.Previous?.Operand is not FieldReference sameBatch || sameBatch.FullName != batch.FullName + || capturedTransaction.Previous.Previous?.OpCode != OpCodes.Ldarg_0 + || capturedTransaction.Previous.Previous.Previous?.Operand is not FieldReference sameOwner || sameOwner.FullName != owner.FullName + || capturedTransaction.Previous.Previous.Previous.Previous?.OpCode != OpCodes.Ldarg_0) continue; + var generation = EvidenceCalls(body, EvidenceBatch, "get_Generation").SingleOrDefault(); + if (generation is null || generation.Previous?.Operand is not FieldReference generationBatch || generationBatch.FullName != batch.FullName + || generation.Next is not { } generationStore || EvidenceStoredLocal(generationStore) is not { } generationIndex) continue; + var snapshot = EvidenceCalls(body, "LearnStack.SharedKernel.Persistence.TenantContextAnnouncement", "get_Generation") + .SingleOrDefault(call => body.Body.Instructions.IndexOf(call) > body.Body.Instructions.IndexOf(announcement)); + if (snapshot is null || snapshot.Next?.OpCode != OpCodes.Newobj || snapshot.Next.Next is not { } nullableStore + || EvidenceStoredLocal(nullableStore) is not { } nullableIndex) continue; + var equality = body.Body.Instructions.FirstOrDefault(instruction => instruction.OpCode == OpCodes.Ceq + && instruction.Previous is { } loaded && EvidenceLocal(loaded) == generationIndex + && loaded.Previous is { } unwrap && EvidenceIsCall(unwrap, NullableGeneration, "GetValueOrDefault") + && unwrap.Previous is { } nullable && EvidenceLocal(nullable) == nullableIndex); + if (equality?.Next is not { } address || EvidenceLocal(address) != nullableIndex + || address.Next is not { } hasValue || !EvidenceIsCall(hasValue, NullableGeneration, "get_HasValue") + || hasValue.Next?.OpCode != OpCodes.And + || hasValue.Next.Next is not { OpCode.Code: Code.Brtrue or Code.Brtrue_S, Operand: Instruction accepted } + || hasValue.Next.Next.Next != failure || failure.OpCode != OpCodes.Ldarg_0 + || failure.Next?.Operand is not FieldReference failedOwner || failedOwner.FullName != owner.FullName + || failure.Next.Next is not { } poison || !EvidenceIsCall(poison, EvidenceUnit, "MarkRollbackOnly") + || poison.Next?.Operand as string != "Authorization evidence lost its original physical owner generation." + || poison.Next.Next?.OpCode != OpCodes.Ldnull || poison.Next.Next.Next?.OpCode != OpCodes.Newobj + || poison.Next.Next.Next.Next?.OpCode != OpCodes.Throw || poison.Next.Next.Next.Next.Next != accepted) continue; + yield return (mismatch, equality); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.AuthorizationEvidenceSource.cs b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.AuthorizationEvidenceSource.cs new file mode 100644 index 000000000..e0a99061b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.AuthorizationEvidenceSource.cs @@ -0,0 +1,253 @@ +using System.Text.RegularExpressions; +using FluentAssertions; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; +using EvidenceCensus = LearnStack.Tests.Architecture.TrustedOperationSourceConventionTests.Census; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class IdentityAuditConventionTests +{ + private const string EvidenceSource = "LearnStack.Infrastructure.Persistence.AuthorizationEvidenceSource"; + private const string EvidenceSession = "LearnStack.Infrastructure.Persistence.AuthorizationEvidenceSession"; + private const string EvidenceFactoryPort = "LearnStack.SharedKernel.Audit.IAuthorizationEvidenceSessionFactory"; + private const string EvidenceSessionPort = "LearnStack.SharedKernel.Audit.IAuthorizationEvidenceSession"; + private const string EvidenceStore = "LearnStack.Infrastructure.Audit.PostgresAuditStore"; + private const string EvidenceRoleGuard = "LearnStack.Infrastructure.Persistence.ApplicationDataSource"; + private const string DeferredEvidence = "LearnStack.Api.Authentication.LiveAuthorizationRegistration/DeferredEvidenceSessions"; + + [Fact] + public void Live_Authorization_Uses_Bounded_Independent_Owners() + { + using var census = EvidenceCensus.Read(); + CheckDatabaseProofControls(census); + EvidenceLaneOffenders(census).Should().BeEmpty("the evidence lane is a confined standalone-audit owner, never a caller SQL executor"); + foreach (var contract in new[] { EvidenceFactoryPort, EvidenceSessionPort }) EvidenceDirty(census, EvidenceLaneOffenders, () => + { + var module = census.Type(EvidenceSource).Module; + var extra = new TypeDefinition("EvidenceControl", "AdditionalEvidenceOwner", TypeAttributes.NotPublic | TypeAttributes.Sealed, + module.TypeSystem.Object); + extra.Interfaces.Add(new InterfaceImplementation(module.ImportReference(census.Type(contract)))); + module.Types.Add(extra); + return () => module.Types.Remove(extra); + }); + foreach (var (type, method) in new[] { (EvidenceFactoryPort, "OpenAsync"), (EvidenceSource, "OpenAsync"), (EvidenceSessionPort, "BeginVerificationAsync"), + (EvidenceSessionPort, "CompleteAsync"), (EvidenceSessionPort, "get_Unit"), (EvidenceSessionPort, "MarkCleanupFailed") }) + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Ldftn }) EvidenceDirty(census, EvidenceLaneOffenders, () => + EvidenceExtraCall(census, census.Method(type, method), opcode)); + foreach (var (type, method, target, name) in new[] + { + (EvidenceSession, "OpenAsync", EvidenceRoleGuard, "RefuseBypassRoleAsync"), + (EvidenceSession, "OpenAsync", "LearnStack.SharedKernel.Jobs.JobApplicationAuxiliaryResources", "HoldConnection"), + (EvidenceSession, ".ctor", "LearnStack.SharedKernel.Jobs.JobApplicationAuxiliaryResources", "HoldActualTask"), + (EvidenceSession, "BeginVerificationAsync", EvidenceSession, "DisposeTransactionAsync"), + (EvidenceSession, "CleanupAsync", EvidenceSession, "DisposeTransactionAsync"), + (EvidenceSession, "CleanupAsync", EvidenceSource, "Release"), + (EvidenceSession, "CleanupAsync", "System.Threading.Tasks.TaskCompletionSource", "TrySetResult"), + (EvidenceSession, "MarkCleanupFailed", "LearnStack.SharedKernel.Jobs.JobApplicationAuxiliaryResources", "MarkCleanupFailed"), + (EvidenceSource, "DisposeCoreAsync", "System.Threading.Tasks.Task", "WhenAll"), + (DeferredEvidence, "OpenAsync", EvidenceSource, "OpenAsync"), + }) EvidenceDirty(census, EvidenceLaneOffenders, () => EvidenceRemoveCall(census.Body(type, method), target, name)); + var verification = census.Body(EvidenceSession, "BeginVerificationAsync"); + var dispose = EvidenceCalls(verification, EvidenceSession, "DisposeTransactionAsync").Single(); + var begin = EvidenceCalls(verification, "Npgsql.NpgsqlConnection", "BeginTransactionAsync").Single(); + EvidenceDirty(census, EvidenceLaneOffenders, () => EvidenceReplace( + verification.Body.Instructions.SkipWhile(instruction => instruction != dispose).TakeWhile(instruction => instruction != begin) + .First(EvidenceAwaitAck), OpCodes.Nop, null)); + var announcement = census.Body(EvidenceSession, "AnnounceAsync").Body.Instructions.Single(instruction => + instruction.Operand is string text && text.StartsWith("SELECT set_config", StringComparison.Ordinal)); + EvidenceDirty(census, EvidenceLaneOffenders, () => EvidenceReplace(announcement, OpCodes.Ldstr, + (string)announcement.Operand + ", set_config('app.scope', 'tenant', true)")); + EvidenceDirty(census, EvidenceLaneOffenders, () => + { + var source = census.Type(EvidenceSource); + var field = source.Fields.Single(item => item.Name == "_source"); + var method = new MethodDefinition("BoxedSource", MethodAttributes.Public, source.Module.TypeSystem.Object); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ldarg_0)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ldfld, field)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + source.Methods.Add(method); + return () => source.Methods.Remove(method); + }); + EvidenceDirty(census, EvidenceLaneOffenders, () => + { + var source = census.Type(EvidenceSource); + var method = new MethodDefinition("RunSqlCallback", MethodAttributes.Public, source.Module.TypeSystem.Void); + method.Parameters.Add(new ParameterDefinition(source.Module.ImportReference(typeof(Action)))); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + source.Methods.Add(method); + return () => source.Methods.Remove(method); + }); + var constructor = census.Type(EvidenceSource).Methods.Single(method => method.IsConstructor && method.IsPublic); + var capacity = constructor.Body.Instructions.Single(instruction => instruction.OpCode == OpCodes.Ldc_I4_2); + EvidenceDirty(census, EvidenceLaneOffenders, () => EvidenceReplace(capacity, OpCodes.Ldc_I4_3, null)); + EvidenceDirty(census, EvidenceLaneOffenders, () => + { + var holder = census.Type(DeferredEvidence).Fields.Single(); + holder.IsInitOnly = false; + return () => holder.IsInitOnly = true; + }); + } + + private static List EvidenceLaneOffenders(EvidenceCensus census) + { + var errors = new List(); + EvidenceImplementers(census, errors, EvidenceFactoryPort, [EvidenceSource, DeferredEvidence]); + EvidenceImplementers(census, errors, EvidenceSessionPort, [EvidenceSession]); + EvidenceCallers(census, errors, EvidenceFactoryPort, "OpenAsync", [(EvidenceStore, "WriteAuthorizationStandaloneAsync", 1)]); + EvidenceCallers(census, errors, EvidenceSource, "OpenAsync", [(DeferredEvidence, "OpenAsync", 1)]); + EvidenceCallers(census, errors, EvidenceSessionPort, "BeginVerificationAsync", [(EvidenceStore, "WriteAuthorizationStandaloneAsync", 1)]); + EvidenceCallers(census, errors, EvidenceSessionPort, "CompleteAsync", [(EvidenceStore, "WriteAuthorizationStandaloneAsync", 2)]); + EvidenceCallers(census, errors, EvidenceSessionPort, "get_Unit", [(EvidenceStore, "InsertAuthorizationEvidenceAsync", 2), (EvidenceStore, "HasAuthorizationEvidenceAsync", 2)]); + EvidenceCallers(census, errors, EvidenceSessionPort, "get_DeadlineToken", [(EvidenceStore, "InsertAuthorizationEvidenceAsync", 1), (EvidenceStore, "HasAuthorizationEvidenceAsync", 1)]); + EvidenceCallers(census, errors, EvidenceSessionPort, "MarkCleanupFailed", [(EvidenceStore, "DisposeAuthorizationStatementAsync", 1)]); + EvidenceCallers(census, errors, EvidenceSource, "Release", [(EvidenceSession, "CleanupAsync", 1)]); + EvidenceCallers(census, errors, EvidenceSession, ".ctor", [(EvidenceSource, "RetainOwner", 1)]); + EvidenceRequire(census, errors, EvidenceSession, "OpenAsync", EvidenceRoleGuard, "RefuseBypassRoleAsync"); + EvidenceRequire(census, errors, EvidenceSession, "OpenAsync", "LearnStack.SharedKernel.Jobs.JobApplicationAuxiliaryResources", "HoldConnection"); + EvidenceRequire(census, errors, EvidenceSession, ".ctor", "LearnStack.SharedKernel.Jobs.JobApplicationAuxiliaryResources", "HoldActualTask"); + EvidenceRequire(census, errors, EvidenceSession, "BeginVerificationAsync", EvidenceSession, "DisposeTransactionAsync"); + EvidenceRequire(census, errors, EvidenceSession, "CleanupAsync", EvidenceSession, "DisposeTransactionAsync"); + EvidenceRequire(census, errors, EvidenceSession, "CleanupAsync", "System.Threading.Tasks.TaskCompletionSource", "TrySetResult"); + EvidenceRequire(census, errors, EvidenceSession, "MarkCleanupFailed", "LearnStack.SharedKernel.Jobs.JobApplicationAuxiliaryResources", "MarkCleanupFailed"); + EvidenceRequire(census, errors, EvidenceSource, "DisposeCoreAsync", "System.Threading.Tasks.Task", "WhenAll"); + + foreach (var name in new[] { EvidenceSource, EvidenceSession }) + { + var type = census.Type(name); + if (!type.IsSealed || type.Fields.Any(field => field.FieldType.FullName == "Npgsql.NpgsqlDataSource" && (!field.IsPrivate || !field.IsInitOnly))) + errors.Add("The evidence physical source and owner retain private readonly datasources: " + name); + foreach (var method in type.Methods.Where(method => method.IsPublic || method.IsAssembly)) + if (!method.IsConstructor && EvidenceRaw(method.ReturnType) + || method.Parameters.Any(parameter => EvidenceRaw(parameter.ParameterType) && !method.IsConstructor) + || method.Parameters.Any(parameter => EvidenceCallback(parameter.ParameterType))) + errors.Add("The evidence lane exposes no raw source or callback: " + method.FullName); + if (Il.ReferencedTypeNames(type).Intersect(["System.IServiceProvider", "MediatR.ISender", "MediatR.IMediator", + "Microsoft.Extensions.DependencyInjection.IServiceScopeFactory", "LearnStack.Modules.Identity.Domain.User", + "LearnStack.Modules.Identity.Domain.MembershipProfile"], StringComparer.Ordinal).Any()) + errors.Add("The evidence owner cannot dispatch business work or acquire a service locator: " + name); + } + foreach (var (name, readers) in new[] { (EvidenceSource, new[] { "RetainOwner", "DisposeCoreAsync" }), (EvidenceSession, new[] { "OpenAsync" }) }) + { + var field = census.Type(name).Fields.Single(item => item.FieldType.FullName == "Npgsql.NpgsqlDataSource"); + if (census.Methods.SelectMany(method => method.Body.Instructions.Select(instruction => (Method: method, Instruction: instruction))) + .Any(item => item.Instruction.Operand is FieldReference reference && reference.FullName == field.FullName + && item.Instruction.OpCode != OpCodes.Stfld + && (item.Instruction.OpCode != OpCodes.Ldfld || !readers.Contains(census.Owner(item.Method).Split("::", StringSplitOptions.None)[^1], StringComparer.Ordinal)))) + errors.Add("Only exact retained lifecycle members read the evidence datasource: " + name); + } + var constructor = census.Type(EvidenceSource).Methods.Where(method => method.IsConstructor && method.IsPublic).ToArray(); + if (constructor.Length != 1 || constructor[0].Parameters.Count != 1 || constructor[0].Parameters[0].ParameterType.FullName != "System.String" + || !constructor[0].Body.Instructions.Any(instruction => instruction.Operand is MethodReference target && target.DeclaringType.FullName == EvidenceSource + && target.Name == ".ctor" && instruction.Previous.OpCode == OpCodes.Ldnull && instruction.Previous.Previous.OpCode == OpCodes.Ldc_I4_2)) + errors.Add("Deployment callers cannot widen the two retained evidence owners"); + var open = census.Body(EvidenceSession, "OpenAsync"); + var guard = EvidenceCalls(open, EvidenceRoleGuard, "RefuseBypassRoleAsync").ToArray(); + var firstBegin = EvidenceCalls(open, "Npgsql.NpgsqlConnection", "BeginTransactionAsync").ToArray(); + if (guard.Length != 1 || firstBegin.Length != 1 || !EvidenceAwaited(open, guard[0], firstBegin[0]) + || !EvidenceIsCall(guard[0].Previous, EvidenceSession, "get_DeadlineToken")) + errors.Add("Every actual evidence checkout awaits the shared role guard with its deadline before BEGIN"); + var verify = census.Body(EvidenceSession, "BeginVerificationAsync"); + var disposal = EvidenceCalls(verify, EvidenceSession, "DisposeTransactionAsync").ToArray(); + var begin = EvidenceCalls(verify, "Npgsql.NpgsqlConnection", "BeginTransactionAsync").ToArray(); + var mode = verify.Body.Instructions.Where(instruction => instruction.Operand as string == "SET TRANSACTION READ ONLY").ToArray(); + var setup = EvidenceCalls(verify, EvidenceSession, "ExecuteSetupAsync").ToArray(); + var announce = EvidenceCalls(verify, EvidenceSession, "AnnounceAsync").ToArray(); + if (disposal.Length != 1 || begin.Length != 1 || mode.Length != 1 || setup.Length != 1 || announce.Length != 1 + || !EvidenceAwaited(verify, disposal[0], begin[0]) || !EvidenceAwaited(verify, begin[0], mode[0]) + || !EvidenceAwaited(verify, setup[0], announce[0])) + errors.Add("Duplicate verification awaits old disposal, fresh BEGIN and real READ ONLY before a fresh announcement"); + var announcement = census.Body(EvidenceSession, "AnnounceAsync"); + var sql = announcement.Body.Instructions.Select(instruction => instruction.Operand).OfType() + .SingleOrDefault(text => text.StartsWith("SELECT set_config", StringComparison.Ordinal)); + var settings = sql is null ? [] : Regex.Matches(sql, @"set_config\s*\(\s*'([^']+)'\s*,\s*([^,]+),\s*true\s*\)") + .Select(match => match.Groups[1].Value + ":" + match.Groups[2].Value.Trim()).ToArray(); + if (!EvidenceSame(settings, ["app.tenant_id:@tenant", "app.organization_id:@organization", "app.scope:''", + "app.resolving_host:''", "app.identity_authority_id:''", "app.identity_subject_key:''"])) + errors.Add("The evidence announcement uses only its captured tenant/org and exact empty authority resets"); + var publish = announcement.Body.Instructions.SingleOrDefault(instruction => instruction.OpCode == OpCodes.Newobj + && instruction.Operand is MethodReference target && target.DeclaringType.FullName == "LearnStack.SharedKernel.Persistence.TenantContextAnnouncement"); + var statement = EvidenceCalls(announcement, EvidenceSession, "ExecuteSetupAsync").ToArray(); + if (publish is null || statement.Length != 1 || !EvidenceAwaited(announcement, statement[0], publish)) + errors.Add("Only the actual acknowledged evidence announcement publishes generation-bound scope"); + var deferred = census.Type(DeferredEvidence); + var forward = census.Body(DeferredEvidence, "OpenAsync"); + var holderType = "System.Func`1<" + EvidenceSource + ">"; + if (!deferred.IsNestedPrivate || !deferred.IsSealed || deferred.BaseType.FullName != "System.Object" + || deferred.Fields.Count != 1 || deferred.Fields[0] is not { IsPrivate: true, IsInitOnly: true, IsStatic: false } + || deferred.Fields[0].FieldType.FullName != holderType || deferred.HasProperties || deferred.HasEvents || deferred.HasNestedTypes + || deferred.HasGenericParameters || deferred.Methods.Count != 2 + || deferred.Methods.SingleOrDefault(method => method.IsConstructor) is not { IsAssembly: true, Parameters.Count: 1 } factory + || factory.Parameters[0].ParameterType.FullName != holderType + || !forward.Body.Instructions.Select(instruction => instruction.OpCode.Code) + .SequenceEqual([Code.Ldarg_0, Code.Ldfld, Code.Callvirt, Code.Ldarg_1, Code.Ldarg_2, Code.Callvirt, Code.Ret]) + || forward.Body.Instructions[1].Operand is not FieldReference held || held.FullName != deferred.Fields[0].FullName + || !EvidenceIsCall(forward.Body.Instructions[2], holderType, "Invoke") + || !EvidenceIsCall(forward.Body.Instructions[5], EvidenceSource, "OpenAsync")) + errors.Add("The private deferred factory retains one immutable typed source accessor and forwards only the actual occurrence and cancellation token"); + var constructions = census.Methods.SelectMany(method => method.Body.Instructions.Where(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == DeferredEvidence && target.Name == ".ctor").Select(instruction => (Method: method, Instruction: instruction))).ToArray(); + if (constructions.Length != 1 || constructions[0].Instruction.OpCode != OpCodes.Newobj + || constructions[0].Method.DeclaringType.FullName != "LearnStack.Api.Authentication.LiveAuthorizationRegistration/<>c" + || !constructions[0].Method.Name.StartsWith("b__", StringComparison.Ordinal)) + errors.Add("Only the actual API registration may construct its private deferred evidence accessor"); + return errors; + } + + private static bool EvidenceRaw(TypeReference type) => Il.ExpandedTypeReferences(type).Any(item => item.FullName is + "Npgsql.NpgsqlDataSource" or "Npgsql.NpgsqlConnection" or "Npgsql.NpgsqlTransaction" or "System.Data.Common.DbConnection" or "System.Data.Common.DbTransaction"); + private static bool EvidenceCallback(TypeReference type) => Il.ExpandedTypeReferences(type).Any(item => item.FullName.StartsWith("System.Action", StringComparison.Ordinal) + || item.FullName.StartsWith("System.Func", StringComparison.Ordinal)); + private static bool EvidenceIsCall(Instruction instruction, string type, string name) => instruction.OpCode.Code is Code.Call or Code.Callvirt or Code.Newobj + && instruction.Operand is MethodReference target && target.DeclaringType.FullName == type && target.Name == name; + private static IEnumerable EvidenceCalls(MethodDefinition method, string type, string name) => method.Body.Instructions.Where(instruction => EvidenceIsCall(instruction, type, name)); + private static bool EvidenceAwaitAck(Instruction instruction) => instruction.Operand is MethodReference target + && target.Name == "GetResult" && target.DeclaringType.FullName.StartsWith("System.Runtime.CompilerServices.", StringComparison.Ordinal); + private static bool EvidenceAwaited(MethodDefinition method, Instruction call, Instruction next) => method.Body.Instructions + .Skip(method.Body.Instructions.IndexOf(call) + 1).TakeWhile(instruction => instruction != next).Any(EvidenceAwaitAck) + && method.Body.Instructions.IndexOf(call) < method.Body.Instructions.IndexOf(next); + private static bool EvidenceSame(IEnumerable actual, IEnumerable expected) => actual.Order(StringComparer.Ordinal).SequenceEqual(expected.Order(StringComparer.Ordinal), StringComparer.Ordinal); + private static void EvidenceImplementers(EvidenceCensus census, List errors, string contract, string[] expected) + { + if (!EvidenceSame(census.Types.Where(type => !type.IsInterface && type.Interfaces.Any(item => item.InterfaceType.FullName == contract)).Select(type => type.FullName), expected)) + errors.Add("Closed authorization evidence implementations changed: " + contract); + } + private static void EvidenceRequire(EvidenceCensus census, List errors, string type, string method, string target, string name) + { + if (EvidenceCalls(census.Body(type, method), target, name).Count() != 1) errors.Add("Missing actual evidence lifetime gate: " + type + "::" + method + "->" + target + "::" + name); + } + private static void EvidenceCallers(EvidenceCensus census, List errors, string type, string name, (string Owner, string Method, int Count)[] expected) + { + var sites = census.Methods.SelectMany(method => method.Body.Instructions.Where(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == type && target.Name == name).Select(instruction => (Method: method, Instruction: instruction))).ToArray(); + if (!EvidenceSame(sites.Select(item => census.Owner(item.Method)), expected.SelectMany(item => Enumerable.Repeat(item.Owner + "::" + item.Method, item.Count))) + || sites.Any(item => item.Instruction.OpCode.Code is not (Code.Call or Code.Callvirt or Code.Newobj))) + errors.Add("Closed evidence borrower/alias set changed: " + type + "::" + name); + } + private static void EvidenceDirty(EvidenceCensus census, Func> detector, Func mutation) + { + var undo = mutation(); + try { detector(census).Should().NotBeEmpty("an actual evidence boundary mutation must be detected"); } + finally { undo(); } + detector(census).Should().BeEmpty("restoring the actual evidence boundary restores the clean control"); + } + private static Action EvidenceReplace(Instruction instruction, OpCode opcode, object? value) + { + var original = instruction.OpCode; + var operand = instruction.Operand; + instruction.OpCode = opcode; + instruction.Operand = value; + return () => { instruction.OpCode = original; instruction.Operand = operand; }; + } + private static Action EvidenceRemoveCall(MethodDefinition method, string type, string name) => EvidenceReplace(EvidenceCalls(method, type, name).Single(), OpCodes.Nop, null); + private static Action EvidenceExtraCall(EvidenceCensus census, MethodDefinition target, OpCode opcode) + { + var type = census.Type(EvidenceSource); + var method = new MethodDefinition("UnownedEvidenceCall", MethodAttributes.Private | MethodAttributes.Static, type.Module.TypeSystem.Void); + method.Body.Instructions.Add(Instruction.Create(opcode, type.Module.ImportReference(target))); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + type.Methods.Add(method); + return () => type.Methods.Remove(method); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.DatabaseProof.cs b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.DatabaseProof.cs new file mode 100644 index 000000000..59b346a86 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.DatabaseProof.cs @@ -0,0 +1,78 @@ +using FluentAssertions; +using Mono.Cecil; +using Mono.Cecil.Cil; +using EvidenceCensus = LearnStack.Tests.Architecture.TrustedOperationSourceConventionTests.Census; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class IdentityAuditConventionTests +{ + private const string DatabaseProof = "LearnStack.Infrastructure.Persistence.DatabaseQualificationProof"; + private const string MainQualification = "LearnStack.Modules.Identity.Infrastructure.Bindings.IdentityAuthorityQualification"; + private const string SourcesStartup = "LearnStack.Api.Authentication.TenantAuthorizationSourcesStartupService"; + private const string AuthorizationSource = "LearnStack.Infrastructure.Persistence.IdentityAuthorizationReadSource"; + private const string AuthorizationOwner = "LearnStack.Infrastructure.Persistence.AuthorizationReadOwner"; + + private static void CheckDatabaseProofControls(EvidenceCensus census) + { + DatabaseProofOffenders(census).Should().BeEmpty(); + foreach (var name in new[] { "OpenAsync", "Bind" }) + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Ldftn }) + EvidenceDirty(census, DatabaseProofOffenders, () => EvidenceExtraCall(census, census.Method(DatabaseProof, name), opcode)); + foreach (var (owner, method, target, gate) in new[] + { + (DatabaseProof, "OpenAsync", EvidenceRoleGuard, "RefuseBypassRoleAsync"), + (DatabaseProof, "Bind", DatabaseProof, "RequireActive"), + (MainQualification, "OpenDatabaseQualificationProofAsync", DatabaseProof, "OpenAsync"), + (AuthorizationOwner, "QualifyAsync", DatabaseProof, "Bind"), + (EvidenceSession, "QualifyAsync", DatabaseProof, "Bind"), + }) EvidenceDirty(census, DatabaseProofOffenders, () => EvidenceRemoveCall(census.Body(owner, method), target, gate)); + foreach (var caller in census.Methods.Where(method => + census.Owner(method) == SourcesStartup + "::StartAsync" + || method.Module.Assembly.Name.Name == "LearnStack.Tools.Seeder" && census.Owner(method) == "Program::
$")) + foreach (var instruction in caller.Body.Instructions.Where(instruction => instruction.Operand is MethodReference target + && target.Name == "QualifyAsync" && target.Parameters.Any(parameter => parameter.ParameterType.FullName == DatabaseProof)).ToArray()) + { + var bound = (MethodReference)instruction.Operand; + var legacy = census.Type(bound.DeclaringType.FullName).Methods.Single(method => method.Name == "QualifyAsync" && method.Parameters.Count == 3); + EvidenceDirty(census, DatabaseProofOffenders, () => EvidenceReplace(instruction, instruction.OpCode, legacy)); + } + } + + private static List DatabaseProofOffenders(EvidenceCensus census) + { + var errors = new List(); + EvidenceCallers(census, errors, DatabaseProof, "OpenAsync", [(MainQualification, "OpenDatabaseQualificationProofAsync", 1)]); + EvidenceCallers(census, errors, DatabaseProof, "Bind", [(AuthorizationOwner, "QualifyAsync", 1), (EvidenceSession, "QualifyAsync", 1)]); + EvidenceCallers(census, errors, DatabaseProof, ".ctor", [(DatabaseProof, "OpenAsync", 1)]); + EvidenceRequire(census, errors, DatabaseProof, "OpenAsync", EvidenceRoleGuard, "RefuseBypassRoleAsync"); + EvidenceRequire(census, errors, DatabaseProof, "Bind", DatabaseProof, "RequireActive"); + foreach (var owner in new[] { AuthorizationOwner, EvidenceSession }) + EvidenceRequire(census, errors, owner, "QualifyAsync", DatabaseProof, "Bind"); + var openCallers = census.Methods.Where(method => method.Body.Instructions.Any(instruction => + EvidenceIsCall(instruction, MainQualification, "OpenDatabaseQualificationProofAsync"))).ToArray(); + if (openCallers.Length != 2 || !openCallers.Any(method => census.Owner(method) == SourcesStartup + "::StartAsync") + || !openCallers.Any(method => method.Module.Assembly.Name.Name == "LearnStack.Tools.Seeder" && census.Owner(method) == "Program::
$")) + errors.Add("Only the actual API/Seeder startup roots open the business database witness."); + var startup = census.Body(SourcesStartup, "StartAsync"); + foreach (var target in new[] { AuthorizationSource, EvidenceSource }) RequireBound(startup, target, 1); + var seeder = census.Methods.Single(method => method.Module.Assembly.Name.Name == "LearnStack.Tools.Seeder" + && census.Owner(method) == "Program::
$" && EvidenceCalls(method, EvidenceSource, "QualifyAsync").Any()); + RequireBound(seeder, EvidenceSource, 1); + var proof = census.Type(DatabaseProof); + if (!proof.IsSealed || proof.Methods.Where(method => method.IsConstructor).Any(method => !method.IsPrivate) + || proof.Fields.Where(field => field.FieldType.FullName is "Npgsql.NpgsqlConnection" or "Npgsql.NpgsqlTransaction") + .Any(field => !field.IsPrivate || !field.IsInitOnly) + || proof.Methods.Where(method => method.IsPublic).Any(method => EvidenceRaw(method.ReturnType))) + errors.Add("The startup witness exposes no raw connection/transaction or construction authority."); + return errors; + + void RequireBound(MethodDefinition method, string targetType, int count) + { + var calls = EvidenceCalls(method, targetType, "QualifyAsync").ToArray(); + if (calls.Length != count || calls.Any(call => call.Operand is not MethodReference target + || target.Parameters.Count != 4 || target.Parameters[2].ParameterType.FullName != DatabaseProof)) + errors.Add("Startup qualification must consume the actual business witness: " + targetType); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.RefusalObservation.cs b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.RefusalObservation.cs new file mode 100644 index 000000000..efd188c1e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.RefusalObservation.cs @@ -0,0 +1,272 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; +using EvidenceCensus = LearnStack.Tests.Architecture.TrustedOperationSourceConventionTests.Census; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class IdentityAuditConventionTests +{ + private const string RefusalObservation = CentralEvidence + "/RefusalObservation"; + private const string EvidenceScope = CentralEvidence + "/ExecutionScope"; + private const string ObservedContexts = "System.Collections.Generic.Dictionary`2<" + EvidenceInvocation + "," + AuditContext + ">"; + + private static void EvidenceObservationOffenders(EvidenceCensus census, List errors) + { + var observation = census.Type(RefusalObservation); + if (!observation.IsNestedAssembly || !observation.IsSealed + || observation.Methods.Count(method => method.IsConstructor) != 1 + // A primary constructor is Public in IL; the enclosing internal nested + // type and exact constructor callers keep its effective scope closed. + || !observation.Methods.Single(method => method.IsConstructor).IsPublic + || observation.Properties.Any(property => property.SetMethod is not null) + || !EvidenceSame(observation.Fields.Select(field => field.FieldType.FullName), + [CentralEvidence, EvidenceScope, PolicyOwner, EvidenceInvocation, "System.Type", "System.Object", "System.Int32"]) + || observation.Fields.Any(field => !field.IsPrivate || field.IsStatic) + || census.Methods.SelectMany(method => method.Body.Instructions.Select(instruction => (Method: method, Instruction: instruction))) + .Any(item => item.Instruction.Operand is FieldReference field && field.DeclaringType.FullName == RefusalObservation + && (item.Instruction.OpCode == OpCodes.Stfld && census.Owner(item.Method) != RefusalObservation + "::.ctor" + || item.Instruction.OpCode == OpCodes.Ldflda && (field.Name != "_consumed" || census.Owner(item.Method) != RefusalObservation + "::TryConsume")))) + errors.Add("The internal observation has only constructor-bound private audit references and its one-use counter"); + foreach (var name in new[] { "CaptureRefusalObservation", "RecordRefusalResult" }) + { + if (!census.Method(CentralEvidence, name).IsAssembly) errors.Add("Audit observation is internal to the common execution owner: " + name); + EvidenceCallers(census, errors, CentralEvidence, name, [(PolicyOwner, "ExecuteAsync", 1), (EvidenceTransactionOwner, "ExecuteFrameAsync", 1)]); + } + EvidenceCallers(census, errors, RefusalObservation, ".ctor", [(CentralEvidence, "CaptureRefusalObservation", 1)]); + EvidenceCallers(census, errors, RefusalObservation, "TryConsume", [(CentralEvidence, "RecordRefusalResult", 1)]); + EvidenceCallers(census, errors, RefusalObservation, "get_Invocation", [(RefusalObservation, "TryConsume", 1), (CentralEvidence, "RecordRefusalResult", 2)]); + EvidenceCallers(census, errors, EvidenceScope, "IsOpen", [(CentralEvidence, "CaptureRefusalObservation", 1), (RefusalObservation, "TryConsume", 1)]); + EvidenceCallers(census, errors, PolicyOwner, "HasOpenObservationFrame", [(RefusalObservation, "TryConsume", 1)]); + EvidenceCallers(census, errors, PolicyOwner, "get_CurrentInvocation", [(CentralEvidence, "CaptureRefusalObservation", 1)]); + EvidenceCallers(census, errors, CentralEvidence, "RecordObservedDenial", [(CentralEvidence, "RecordDenial", 1), (CentralEvidence, "RecordRefusalResult", 1)]); + EvidenceCallers(census, errors, CentralEvidence, "EndExecutionAsync", [(EvidenceScope, "DisposeAsync", 1)]); + var observationFrames = new[] { census.Body(PolicyOwner, "ExecuteAsync").DeclaringType.FullName, + census.Body(EvidenceTransactionOwner, "ExecuteFrameAsync").DeclaringType.FullName }; + if (!EvidenceSame(census.Types.SelectMany(type => type.Fields.Where(field => field.FieldType.FullName == RefusalObservation) + .Select(_ => type.FullName)), observationFrames)) + errors.Add("Only the two actual common handler awaits retain an opaque observation"); + foreach (var (type, method, target, name) in ObservationRequiredCalls) + EvidenceRequire(census, errors, type, method, target, name); + if (ObservationCaptureGates(census.Body(CentralEvidence, "CaptureRefusalObservation")).Length != 8 + || ObservationConsumeGates(census.Body(RefusalObservation, "TryConsume")).Length != 5 + || ObservationFrameGates(census.Body(PolicyOwner, "HasOpenObservationFrame")).Length != 5 + || ObservationScopeGates(census).Length != 2) + errors.Add("Audit observation keeps exact scope, request/type, current top-frame, closed ancestry and replay gates"); + foreach (var (type, method, transactional) in new[] { (PolicyOwner, "ExecuteAsync", false), (EvidenceTransactionOwner, "ExecuteFrameAsync", true) }) + if (ObservationFlow(census.Body(type, method), transactional) is null) + errors.Add("An actual scope captures before its handler await and consumes that exact observation/request/result before completion: " + type); + var record = census.Body(CentralEvidence, "RecordRefusalResult"); + var strings = record.Body.Instructions.Select(instruction => instruction.Operand).OfType() + .Where(value => value is "forbidden" or "not_found" or "resource_scope_violation" or "feature_disabled" or "limit_exceeded").Take(5); + if (!strings.SequenceEqual(["forbidden", "not_found", "resource_scope_violation", "feature_disabled", "limit_exceeded"], StringComparer.Ordinal) + || !ObservationLatestContext(record)) errors.Add("Only a closed actual refusal consumes the invocation's latest verified frozen context"); + } + + private static readonly (string Type, string Method, string Target, string Name)[] ObservationRequiredCalls = + [ + (CentralEvidence, "CaptureRefusalObservation", EvidenceScope, "IsOpen"), + (CentralEvidence, "CaptureRefusalObservation", CentralEvidence, "HasAttribution"), + (CentralEvidence, "CaptureRefusalObservation", ObservedContexts, "ContainsKey"), + (CentralEvidence, "CaptureRefusalObservation", EvidencePolicy, "get_RequestType"), + (CentralEvidence, "CaptureRefusalObservation", "System.Object", "GetType"), + (CentralEvidence, "RecordRefusalResult", RefusalObservation, "TryConsume"), + (CentralEvidence, "RecordRefusalResult", "LearnStack.SharedKernel.Results.IResultBase", "get_IsFailure"), + (CentralEvidence, "RecordRefusalResult", ObservedContexts, "TryGetValue"), + (CentralEvidence, "RecordDenial", CentralEvidence, "HasAttribution"), + (RefusalObservation, "TryConsume", "System.Threading.Interlocked", "CompareExchange"), + (RefusalObservation, "TryConsume", "System.Type", "op_Equality"), + (EvidenceScope, "IsOpen", "System.Threading.Volatile", "Read"), + (EvidenceScope, "DisposeAsync", "System.Threading.Interlocked", "Exchange"), + (PolicyOwner, "HasOpenObservationFrame", "System.Threading.Volatile", "Read"), + (PolicyOwner, "HasOpenObservationFrame", PolicyOwner + "/Frame", "get_Closed"), + (PolicyOwner, "HasOpenObservationFrame", EvidenceInvocation + "/MintingSource", "IsCurrent"), + ]; + + private static void EvidenceObservationDirtyControls(EvidenceCensus census) + { + foreach (var (type, method) in new[] { (RefusalObservation, ".ctor"), (RefusalObservation, "TryConsume"), (PolicyOwner, "HasOpenObservationFrame") }) + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Ldftn }) + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceExtraCall(census, census.Method(type, method), opcode)); + foreach (var (type, method, target, name) in ObservationRequiredCalls) + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceRemoveCall(census.Body(type, method), target, name)); + foreach (var branch in ObservationCaptureGates(census.Body(CentralEvidence, "CaptureRefusalObservation")) + .Concat(ObservationConsumeGates(census.Body(RefusalObservation, "TryConsume"))) + .Concat(ObservationFrameGates(census.Body(PolicyOwner, "HasOpenObservationFrame"))) + .Concat(ObservationScopeGates(census))) + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(branch, OpCodes.Nop, null)); + foreach (var (type, method, transactional) in new[] { (PolicyOwner, "ExecuteAsync", false), (EvidenceTransactionOwner, "ExecuteFrameAsync", true) }) + { + var body = census.Body(type, method); + var flow = ObservationFlow(body, transactional)!.Value; + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(flow.Observation, OpCodes.Ldnull, null)); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(flow.Operation, OpCodes.Ldnull, null)); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(flow.Await, OpCodes.Nop, null)); + } + var record = census.Body(CentralEvidence, "RecordRefusalResult"); + var code = record.Body.Instructions.First(instruction => instruction.Operand as string == "forbidden"); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => EvidenceReplace(code, OpCodes.Ldstr, "unrelated_failure")); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => + { + var type = census.Type(RefusalObservation); + var attributes = type.Attributes; + type.IsNestedPublic = true; + return () => type.Attributes = attributes; + }); + EvidenceDirty(census, EvidenceProvenanceOffenders, () => + { + var owner = census.Type(EvidenceSource); + var extra = new FieldDefinition("UnownedObservation", FieldAttributes.Private, owner.Module.ImportReference(census.Type(RefusalObservation))); + owner.Fields.Add(extra); + return () => owner.Fields.Remove(extra); + }); + } + + private static Instruction[] ObservationCaptureGates(MethodDefinition body) + { + var gates = new List(); + var current = EvidenceCalls(body, PolicyOwner, "get_CurrentInvocation").SingleOrDefault(); + if (current?.Next is not { } retain || EvidenceStoredLocal(retain) is not { } invocation + || !body.Body.Instructions.Any(instruction => instruction.OpCode == OpCodes.Isinst && instruction.Operand is TypeReference type && type.FullName == PolicyOwner)) return []; + foreach (var (type, name, branchOffset) in new[] { (EvidenceScope, "IsOpen", 1), (EvidenceInvocation, "get_EntryKind", 2), + (CentralEvidence, "HasAttribution", 1), (ObservedContexts, "ContainsKey", 1) }) + { + var call = EvidenceCalls(body, type, name).SingleOrDefault(); + var branch = call; + for (var step = 0; step < branchOffset; step++) branch = branch?.Next; + if (branch?.Operand is not Instruction target || target.OpCode != (name == "ContainsKey" ? OpCodes.Ldarg_0 : OpCodes.Ldnull)) return []; + gates.Add(branch); + } + foreach (var comparison in EvidenceCalls(body, "System.Type", "op_Inequality")) + { + if (comparison.Previous?.OpCode != OpCodes.Ldarg_2 || comparison.Next is not { OpCode.Code: Code.Brtrue or Code.Brtrue_S, Operand: Instruction rejected } + || rejected.OpCode != OpCodes.Ldnull) return []; + gates.Add(comparison.Next); + } + var operation = EvidenceCalls(body, EvidenceInvocation, "get_Operation").SingleOrDefault(); + if (operation?.Previous is not { } owner || EvidenceLocal(owner) != invocation || operation.Next?.OpCode != OpCodes.Ldarg_3 + || operation.Next.Next is not { OpCode.Code: Code.Bne_Un or Code.Bne_Un_S, Operand: Instruction refused } reference || refused.OpCode != OpCodes.Ldnull) return []; + gates.Add(reference); + var policy = EvidenceCalls(body, EvidencePolicy, "get_RequestType").SingleOrDefault(); + var actual = EvidenceCalls(body, "System.Object", "GetType").SingleOrDefault(); + if (policy?.Previous is not { } policyOwner || !EvidenceIsCall(policyOwner, EvidenceInvocation, "get_Policy") + || policyOwner.Previous is not { } policyInvocation || EvidenceLocal(policyInvocation) != invocation + || actual?.Previous?.OpCode != OpCodes.Ldarg_3) return []; + if (retain.Next?.Next is not { OpCode.Code: Code.Brfalse or Code.Brfalse_S, Operand: Instruction absent } present || absent.OpCode != OpCodes.Ldnull) return []; + gates.Add(present); + return [.. gates]; + } + + private static Instruction[] ObservationConsumeGates(MethodDefinition body) + { + var branches = body.Body.Instructions.Where(instruction => instruction.OpCode.Code is Code.Bne_Un or Code.Bne_Un_S or Code.Brfalse or Code.Brfalse_S).ToArray(); + if (branches.Length != 5 || branches.Any(branch => branch.Operand is not Instruction { OpCode.Code: Code.Ldc_I4_0 }) + || !branches.Take(1).All(branch => branch.Previous?.OpCode == OpCodes.Ldarg_1 + && branch.Previous.Previous?.Operand is FieldReference field && field.FieldType.FullName == CentralEvidence) + || branches[3].Previous?.OpCode != OpCodes.Ldarg_3 + || branches[3].Previous.Previous?.Operand is not FieldReference operation || operation.FieldType.FullName != "System.Object" + || branches[1].Previous is not { } scope || !EvidenceIsCall(scope, EvidenceScope, "IsOpen") + || branches[2].Previous is not { } type || !EvidenceIsCall(type, "System.Type", "op_Equality") + || branches[4].Previous is not { } frame || !EvidenceIsCall(frame, PolicyOwner, "HasOpenObservationFrame")) return []; + var replay = EvidenceCalls(body, "System.Threading.Interlocked", "CompareExchange").SingleOrDefault(); + if (replay?.Previous?.OpCode != OpCodes.Ldc_I4_0 || replay.Previous.Previous?.OpCode != OpCodes.Ldc_I4_1 + || replay.Previous.Previous.Previous is not { OpCode.Code: Code.Ldflda, Operand: FieldReference consumed } + || consumed.DeclaringType.FullName != RefusalObservation || consumed.Name != "_consumed" + || replay.Next?.OpCode != OpCodes.Ldc_I4_0 || replay.Next.Next?.OpCode != OpCodes.Ceq) return []; + return branches; + } + + private static Instruction[] ObservationFrameGates(MethodDefinition body) + { + var branches = body.Body.Instructions.Where(instruction => instruction.OpCode.Code is Code.Brtrue or Code.Brtrue_S or Code.Brfalse or Code.Brfalse_S or Code.Bne_Un or Code.Bne_Un_S).ToArray(); + if (branches.Length != 5 || branches.Any(branch => branch.Operand is not Instruction { OpCode.Code: Code.Ldc_I4_0 }) + || branches[3].Previous?.OpCode != OpCodes.Ldarg_1 || branches[4].Previous?.OpCode != OpCodes.Ldarg_2 + || branches[3].Previous.Previous is not { } invocation || !EvidenceIsCall(invocation, PolicyOwner + "/Frame", "get_Invocation") + || branches[4].Previous.Previous is not { } operation || !EvidenceIsCall(operation, EvidenceInvocation, "get_Operation")) return []; + var closed = EvidenceCalls(body, "System.Threading.Volatile", "Read").SingleOrDefault(); + var top = body.Body.Instructions.SingleOrDefault(instruction => instruction.Operand is MethodReference call + && call.DeclaringType.FullName == "System.Threading.AsyncLocal`1<" + PolicyOwner + "/Frame>" && call.Name == "get_Value"); + if (closed?.Previous is not { OpCode.Code: Code.Ldflda, Operand: FieldReference state } || state.Name != "_closed" || state.DeclaringType.FullName != PolicyOwner + || top?.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference current } || current.Name != "_current" || current.DeclaringType.FullName != PolicyOwner + || top.Next is not { } store || EvidenceStoredLocal(store) is not { } frame + || invocation.Previous is not { } candidate || EvidenceLocal(candidate) != frame + || operation.Previous?.OpCode != OpCodes.Ldarg_1) return []; + return branches; + } + + private static Instruction[] ObservationScopeGates(EvidenceCensus census) + { + var open = census.Body(EvidenceScope, "IsOpen"); + var dispose = census.Body(EvidenceScope, "DisposeAsync"); + var read = EvidenceCalls(open, "System.Threading.Volatile", "Read").SingleOrDefault(); + var close = EvidenceCalls(dispose, "System.Threading.Interlocked", "Exchange").SingleOrDefault(); + if (read?.Previous is not { OpCode.Code: Code.Ldflda, Operand: FieldReference closed } || closed.DeclaringType.FullName != EvidenceScope || closed.Name != "_closed" + || read.Next?.OpCode != OpCodes.Ldc_I4_0 || read.Next.Next?.OpCode != OpCodes.Ceq + || read.Previous.Previous.Previous is not { OpCode.Code: Code.Bne_Un or Code.Bne_Un_S } owner + || owner.Previous?.OpCode != OpCodes.Ldarg_1 || owner.Previous.Previous?.Operand is not FieldReference actualOwner + || actualOwner.DeclaringType.FullName != EvidenceScope || actualOwner.Name != "_owner" + || close?.Previous?.OpCode != OpCodes.Ldc_I4_1 || close.Previous.Previous?.Operand is not FieldReference sameClosed || sameClosed.FullName != closed.FullName + || close.Next is not { OpCode.Code: Code.Brfalse or Code.Brfalse_S, Operand: Instruction end } closedOnce + || !EvidenceCalls(dispose, CentralEvidence, "EndExecutionAsync").Any(call => dispose.Body.Instructions.IndexOf(call) > dispose.Body.Instructions.IndexOf(end))) return []; + return [owner, closedOnce]; + } + + private static bool ObservationLatestContext(MethodDefinition body) + { + var lookup = EvidenceCalls(body, ObservedContexts, "TryGetValue").SingleOrDefault(); + if (lookup?.Previous is not { OpCode.Code: Code.Ldloca or Code.Ldloca_S, Operand: VariableDefinition context } + || lookup.Previous.Previous is not { } key || !EvidenceIsCall(key, RefusalObservation, "get_Invocation") + || key.Previous?.OpCode != OpCodes.Ldarg_1 || key.Previous.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference contexts } + || contexts.Name != "_contexts" || contexts.DeclaringType.FullName != CentralEvidence + || lookup.Next is not { OpCode.Code: Code.Brtrue or Code.Brtrue_S } || lookup.Next.Next?.OpCode != OpCodes.Ret) return false; + var record = EvidenceCalls(body, CentralEvidence, "RecordObservedDenial").SingleOrDefault(); + return record is not null && body.Body.Instructions.SkipWhile(instruction => instruction != lookup).TakeWhile(instruction => instruction != record) + .Any(instruction => EvidenceIsCall(instruction, RefusalObservation, "get_Invocation") && instruction.Next is { } loaded && EvidenceLocal(loaded) == context.Index); + } + + private static (Instruction Observation, Instruction Operation, Instruction Await)? ObservationFlow(MethodDefinition body, bool transactional) + { + var capture = EvidenceCalls(body, CentralEvidence, "CaptureRefusalObservation").SingleOrDefault(); + var record = EvidenceCalls(body, CentralEvidence, "RecordRefusalResult").SingleOrDefault(); + if (capture?.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference operation } captureOperation + || operation.Name != "operation" || capture.Next is not { OpCode.Code: Code.Stfld, Operand: FieldReference retained } + || retained.FieldType.FullName != RefusalObservation || record?.Previous?.OpCode != OpCodes.Box + || record.Previous.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference response } + || record.Previous.Previous.Previous.Previous is not { OpCode.Code: Code.Ldfld, Operand: FieldReference consumedOperation } operationLoad + || consumedOperation.FullName != operation.FullName || !ObservationSameTypeInput(captureOperation.Previous.Previous, operationLoad.Previous.Previous)) return null; + var scopes = body.Body.Instructions.TakeWhile(instruction => instruction != capture).Reverse().Take(15) + .Where(instruction => instruction.OpCode == OpCodes.Ldfld && instruction.Operand is FieldReference field && field.FieldType.FullName == EvidenceScope).ToArray(); + var begin = EvidenceCalls(body, CentralEvidence, "BeginExecution").SingleOrDefault(); + if (scopes.Length != 1 || scopes[0].Operand is not FieldReference scope + || begin?.Next?.Operand is not FieldReference startedScope || begin.Next.OpCode != OpCodes.Stfld || startedScope.FullName != scope.FullName) return null; + var consumed = body.Body.Instructions.TakeWhile(instruction => instruction != record).Reverse().Take(20) + .SingleOrDefault(instruction => instruction.OpCode == OpCodes.Ldfld && instruction.Operand is FieldReference field && field.FullName == retained.FullName); + var next = body.Body.Instructions.SkipWhile(instruction => instruction != capture).TakeWhile(instruction => instruction != record) + .SingleOrDefault(instruction => instruction.Operand is MethodReference method && method.Name == "Invoke" + && instruction.Previous?.Operand is FieldReference field && field.Name == "next"); + var awaited = next is null ? null : body.Body.Instructions.SkipWhile(instruction => instruction != next).TakeWhile(instruction => instruction != record).FirstOrDefault(EvidenceAwaitAck); + if (consumed is null || awaited?.Next is not { } resultStore || EvidenceStoredLocal(resultStore) is not { } result + || !body.Body.Instructions.SkipWhile(instruction => instruction != resultStore).TakeWhile(instruction => instruction != record).Any(instruction => + instruction.OpCode == OpCodes.Stfld && instruction.Operand is FieldReference field && field.FullName == response.FullName + && instruction.Previous is { } loaded && EvidenceLocal(loaded) == result)) return null; + if (transactional) + { + var failure = EvidenceCalls(body, "LearnStack.SharedKernel.Results.IResultBase", "get_IsFailure").SingleOrDefault(call => + call.Previous?.OpCode == OpCodes.Constrained && call.Previous.Previous?.Operand is FieldReference field && field.FullName == response.FullName); + var rollback = EvidenceCalls(body, "LearnStack.SharedKernel.Persistence.IUnitOfWorkScope", "FailAsync").FirstOrDefault(call => + body.Body.Instructions.IndexOf(call) > body.Body.Instructions.IndexOf(record)); + if (failure?.Next is not { OpCode.Code: Code.Brfalse or Code.Brfalse_S, Operand: Instruction success } + || rollback is null || body.Body.Instructions.IndexOf(failure) >= body.Body.Instructions.IndexOf(record) + || body.Body.Instructions.IndexOf(rollback) >= body.Body.Instructions.IndexOf(success)) return null; + } + else if (EvidenceCalls(body, CentralEvidence, "BeforeSuccessAsync").SingleOrDefault() is not { } completion + || body.Body.Instructions.IndexOf(record) >= body.Body.Instructions.IndexOf(completion)) return null; + return (consumed, operationLoad, awaited); + } + + private static bool ObservationSameTypeInput(Instruction first, Instruction second) => + first.OpCode == OpCodes.Ldfld && second.OpCode == OpCodes.Ldfld + && first.Operand is FieldReference left && second.Operand is FieldReference right && left.FullName == right.FullName && left.FieldType.FullName == "System.Type" + || EvidenceIsCall(first, EvidencePolicy, "get_RequestType") && EvidenceIsCall(second, EvidencePolicy, "get_RequestType") + && first.Previous?.Operand is FieldReference leftPolicy && second.Previous?.Operand is FieldReference rightPolicy + && leftPolicy.FullName == rightPolicy.FullName && leftPolicy.FieldType.FullName == EvidencePolicy; +} diff --git a/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.cs index 9f5049e59..b1219c7ed 100644 --- a/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/IdentityAuditConventionTests.cs @@ -12,7 +12,7 @@ namespace LearnStack.Tests.Architecture; -public sealed class IdentityAuditConventionTests +public sealed partial class IdentityAuditConventionTests { private const string BindingService = "LearnStack.Modules.Identity.Infrastructure.Bindings.GlobalIdentityBindingService"; private const string InitialMigration = "LearnStack.Modules.Identity.Infrastructure.Persistence.Migrations.create_identity_schema"; diff --git a/backend/tests/LearnStack.Tests.Architecture/IdentityAuthorizationConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/IdentityAuthorizationConventionTests.cs new file mode 100644 index 000000000..c9e8b616b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/IdentityAuthorizationConventionTests.cs @@ -0,0 +1,569 @@ +using System.Text.RegularExpressions; +using FluentAssertions; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// ADR-0076 Step 2's compiled candidate/reader/source boundaries; later policy/evidence obligations remain Registered. +public sealed class IdentityAuthorizationConventionTests +{ + private const string Candidate = "LearnStack.SharedKernel.Permissions.AuthorizationCandidate"; + private const string Minter = Candidate + "/MintingSource"; + private const string HttpMinter = "LearnStack.Api.Authentication.TenantAuthorizationCandidateSource"; + private const string Source = "LearnStack.Infrastructure.Persistence.IdentityAuthorizationReadSource"; + private const string Owner = "LearnStack.Infrastructure.Persistence.AuthorizationReadOwner"; + private const string RoleGuard = "LearnStack.Infrastructure.Persistence.ApplicationDataSource"; + private const string Reader = "LearnStack.Modules.Identity.Infrastructure.Authorization.PreContextIdentityAuthorizationSessionFactory"; + private const string Orchestrator = "LearnStack.Modules.Identity.Application.Authorization.CommittedIdentityAuthorizationReader"; + private const string Session = Reader + "/AuthorizationSession"; + private const string SessionFactoryPort = "LearnStack.Modules.Identity.Application.Abstractions.IIdentityAuthorizationSessionFactory"; + private const string SessionPort = "LearnStack.Modules.Identity.Application.Abstractions.IIdentityAuthorizationSession"; + private const string ReaderPort = "LearnStack.Modules.Identity.Application.Contracts.Authorization.IIdentityAuthorizationReader"; + private const string Lifecycle = "LearnStack.Modules.Tenancy.Infrastructure.Persistence.IdentityScopeLifecycleReader"; + private const string LifecyclePort = "LearnStack.Modules.Tenancy.Application.Contracts.IdentityLifecycle.IIdentityScopeLifecycleReader"; + + [Fact] + public void Authorization_candidates_have_one_verified_minter_and_closed_lifetime_evidence() + { + using var census = Census.Read(); + CandidateOffenders(census).Should().BeEmpty("Fix: preserve the private candidate factory and the exact verified HTTP source; a UserId alone grants nothing (ADR-0076)"); + var candidate = census.Type(Candidate); + var constructor = candidate.Methods.Single(method => method.IsConstructor); + var original = constructor.Attributes; + Dirty(census, CandidateOffenders, () => + { + constructor.Attributes = (original & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public; + return () => constructor.Attributes = original; + }); + Dirty(census, CandidateOffenders, () => + { + var module = census.Type(HttpMinter).Module; + var extra = new TypeDefinition("AuthorizationControl", "AdditionalMinter", TypeAttributes.NotPublic, + module.ImportReference(census.Type(Minter))); + module.Types.Add(extra); + return () => module.Types.Remove(extra); + }); + Dirty(census, CandidateOffenders, () => RemoveCall(census.Body(Candidate, "get_IsActive"), Minter, "MatchesEvidence")); + Dirty(census, CandidateOffenders, () => RemoveCall(census.Body(Minter, "Dispose"), "System.Threading.Interlocked", "Exchange")); + Dirty(census, CandidateOffenders, () => RemoveInstruction(EvidenceComparisons(census.Body(HttpMinter, "IsEvidenceCurrent")).First())); + Dirty(census, CandidateOffenders, () => + { + var body = census.Body(HttpMinter, "IsEvidenceCurrent"); + var comparison = EvidenceComparisons(body).First(); + var branch = IsCall(comparison, "System.Object", "ReferenceEquals") ? comparison.Next : comparison; + return Replace(branch, branch.OpCode, body.Body.Instructions.First(instruction => instruction.OpCode == OpCodes.Ret)); + }); + Dirty(census, CandidateOffenders, () => RemoveCall(census.Body(HttpMinter, "ForValidatedScope"), + "LearnStack.Api.Authentication.TenantSubjectAdmissionProvenance", "Matches")); + } + + [Fact] + public void Authorization_borrowers_and_lifecycle_callers_are_exact_and_publish_no_raw_source_or_callback() + { + using var census = Census.Read(); + BorrowerOffenders(census).Should().BeEmpty("Fix: borrow only through the enumerated candidate reader and keep its datasource private (ADR-0076)"); + foreach (var target in new[] { census.Method(Source, "OpenAsync"), census.Method(LifecyclePort, "ReadAuthorizationAsync"), + census.Method(ReaderPort, "ReadAsync"), census.Method(SessionFactoryPort, "OpenAsync"), census.Method(SessionPort, "ReadMembershipsAsync") }) + Dirty(census, BorrowerOffenders, () => AddCall(census.Type(HttpMinter).Module, target)); + foreach (var port in new[] { ReaderPort, SessionFactoryPort, SessionPort }) + Dirty(census, BorrowerOffenders, () => + { + var module = census.Type(Reader).Module; + var extra = new TypeDefinition("AuthorizationControl", "AdditionalReaderOwner", TypeAttributes.NotPublic | TypeAttributes.Sealed, module.TypeSystem.Object); + extra.Interfaces.Add(new InterfaceImplementation(module.ImportReference(census.Type(port)))); + module.Types.Add(extra); + return () => module.Types.Remove(extra); + }); + foreach (var wrapped in new[] { false, true }) + Dirty(census, BorrowerOffenders, () => + { + var source = census.Type(Source); + var raw = source.Fields.Single(field => field.Name == "_source").FieldType; + var returned = wrapped ? new GenericInstanceType(source.Module.ImportReference(typeof(Task<>))) : raw; + if (returned is GenericInstanceType generic) generic.GenericArguments.Add(raw); + var method = new MethodDefinition("RawSourceAlias", MethodAttributes.Public, returned); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ldnull)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + source.Methods.Add(method); + return () => source.Methods.Remove(method); + }); + Dirty(census, BorrowerOffenders, () => + { + var source = census.Type(Source); + var method = new MethodDefinition("RunCallerSql", MethodAttributes.Public, source.Module.TypeSystem.Void); + method.Parameters.Add(new ParameterDefinition(source.Module.ImportReference(typeof(Action)))); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + source.Methods.Add(method); + return () => source.Methods.Remove(method); + }); + Dirty(census, BorrowerOffenders, () => + { + var source = census.Type(Source); + var method = new MethodDefinition("BoxedSourceAlias", MethodAttributes.Public, source.Module.TypeSystem.Object); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ldarg_0)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ldfld, source.Fields.Single(field => field.Name == "_source"))); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + source.Methods.Add(method); + return () => source.Methods.Remove(method); + }); + } + + [Fact] + public void Authorization_owner_awaits_the_real_mode_statement_before_exact_candidate_announcement() + { + using var census = Census.Read(); + SetupOffenders(census).Should().BeEmpty("Fix: await the real READ ONLY round trip before the six exact candidate/reset GUCs, through an executing helper (ADR-0076)"); + var open = census.Body(Owner, "OpenAsync"); + var mode = open.Body.Instructions.Single(instruction => instruction.Operand as string == "SET TRANSACTION READ ONLY"); + Dirty(census, SetupOffenders, () => Replace(mode, OpCodes.Ldstr, "SET TRANSACTION READ WRITE")); + var announcement = open.Body.Instructions.Single(instruction => instruction.Operand is string text && text.StartsWith("SELECT set_config", StringComparison.Ordinal)); + Dirty(census, SetupOffenders, () => Replace(announcement, OpCodes.Ldstr, + (string)announcement.Operand + ", set_config('app.unreviewed', 'authority', true)")); + var calls = open.Body.Instructions.Where(instruction => IsCall(instruction, Owner, "ExecuteSetupAsync")).ToArray(); + Dirty(census, SetupOffenders, () => RemoveInstruction(AwaitAck(open, calls[0], announcement))); + Dirty(census, SetupOffenders, () => RemoveCall(census.Body(Owner, "ExecuteSetupAsync"), "System.Data.Common.DbCommand", "ExecuteNonQueryAsync")); + var helper = census.Body(Owner, "ExecuteSetupAsync"); + var execution = helper.Body.Instructions.Single(instruction => IsCall(instruction, "System.Data.Common.DbCommand", "ExecuteNonQueryAsync")); + Dirty(census, SetupOffenders, () => RemoveInstruction(AwaitAck(helper, execution, + helper.Body.Instructions.First(instruction => instruction.Offset > execution.Offset && IsCall(instruction, Owner, "DisposeStatementAsync"))))); + var construction = helper.Body.Instructions.Single(instruction => instruction.OpCode == OpCodes.Newobj + && instruction.Operand is MethodReference target && target.DeclaringType.FullName == "Npgsql.NpgsqlCommand"); + Dirty(census, SetupOffenders, () => Replace(construction.Previous.Previous.Previous.Previous.Previous, OpCodes.Ldstr, "SELECT 1")); + } + + [Fact] + public void Authorization_checkout_awaits_the_shared_role_guard_with_its_deadline_before_begin() + { + using var census = Census.Read(); + GuardOffenders(census).Should().BeEmpty("Fix: every confined checkout awaits the shared role/reachable-bypass guard with DeadlineToken before BEGIN (ADR-0076; Standard 05)"); + var open = census.Body(Owner, "OpenAsync"); + var guard = open.Body.Instructions.Single(instruction => IsCall(instruction, RoleGuard, "RefuseBypassRoleAsync")); + var begin = open.Body.Instructions.Single(instruction => IsCall(instruction, "Npgsql.NpgsqlConnection", "BeginTransactionAsync")); + Dirty(census, GuardOffenders, () => RemoveInstruction(guard)); + Dirty(census, GuardOffenders, () => RemoveInstruction(AwaitAck(open, guard, begin))); + Dirty(census, GuardOffenders, () => RemoveInstruction(guard.Previous)); + Dirty(census, GuardOffenders, () => AddCall(census.Type(HttpMinter).Module, census.Method(RoleGuard, "RefuseBypassRoleAsync"))); + } + + [Fact] + public void Authorization_reader_executes_only_bounded_nonlocking_identity_and_same_child_lifecycle_statements() + { + using var census = Census.Read(); + QueryOffenders(census).Should().BeEmpty("Fix: use only the exact bounded Identity statement and Tenancy's separate statement on the same child; never profiles, Users, locks or an ambient owner (ADR-0076)"); + var read = census.Body(Reader, "ReadMembershipsAsync"); + var sql = read.Body.Instructions.Single(instruction => instruction.Operand is string text && text.StartsWith("WITH eligible_memberships", StringComparison.Ordinal)); + foreach (var dirty in new[] + { + ((string)sql.Operand).Replace("LIMIT 3", "LIMIT 2", StringComparison.Ordinal), + ((string)sql.Operand).Replace("LIMIT 9", "LIMIT 8", StringComparison.Ordinal), + ((string)sql.Operand).Replace("member.user_id = @actor", "true", StringComparison.Ordinal), + (string)sql.Operand + " JOIN public.users AS leaked ON true", + (string)sql.Operand + " JOIN public.membership_profiles AS leaked ON true", + (string)sql.Operand + " FOR UPDATE", + (string)sql.Operand + "; SELECT public.fn_identity_admin_admission_state(@actor,@tenant)", + }) Dirty(census, QueryOffenders, () => Replace(sql, OpCodes.Ldstr, dirty)); + var roleLimit = RoleMaterializationLimits(read).Single(); + Dirty(census, QueryOffenders, () => Replace(roleLimit.Bound, OpCodes.Ldc_I4, 9)); + Dirty(census, QueryOffenders, () => RemoveInstruction(roleLimit.Count)); + Dirty(census, QueryOffenders, () => RemoveInstruction(roleLimit.Branch)); + Dirty(census, QueryOffenders, () => RemoveInstruction(roleLimit.Throw)); + var lifecycle = census.Body(Lifecycle, "ReadAuthorizationAsync"); + var ambient = census.Type(Lifecycle).Fields.Single(field => field.FieldType.FullName == "LearnStack.SharedKernel.Persistence.IUnitOfWork"); + Dirty(census, QueryOffenders, () => + { + var injected = Instruction.Create(OpCodes.Ldfld, ambient); + lifecycle.Body.Instructions.Insert(0, injected); + return () => lifecycle.Body.Instructions.Remove(injected); + }); + } + + private static List CandidateOffenders(Census census) + { + var errors = new List(); + var candidate = census.Type(Candidate); + if (!candidate.IsSealed || candidate.Methods.Where(method => method.IsConstructor).Any(method => !method.IsPrivate) + || candidate.Methods.Count(method => method.IsConstructor) != 1 + || !candidate.Fields.Any(field => field.Name == "_source" && field.IsPrivate && field.IsInitOnly && field.FieldType.FullName == Minter) + || !candidate.Fields.Any(field => field.Name == "_evidence" && field.IsPrivate && field.IsInitOnly)) + errors.Add("Candidate construction and provenance fields are private and closed"); + Set(errors, "candidate minters", census.Types.Where(type => census.IsDerivedFrom(type, Minter)).Select(type => type.FullName), [HttpMinter]); + Callers(census, errors, Candidate, ".ctor", [Minter + ".ForCandidate"]); + Callers(census, errors, Minter, "ForCandidate", [HttpMinter + ".ForValidatedScope"]); + if (Calls(census.Body(Candidate, "get_IsActive"), Minter, "get_IsActive").Count() != 1 + || Calls(census.Body(Candidate, "get_IsActive"), Minter, "MatchesEvidence").Count() != 1 + || Calls(census.Body(Minter, "ForCandidate"), "System.ObjectDisposedException", "ThrowIf").Count() != 1 + || Calls(census.Body(Minter, "Dispose"), "System.Threading.Interlocked", "Exchange").Count() != 1) + errors.Add("A closed or replaced source cannot remain active or mint another candidate"); + var http = census.Type(HttpMinter); + var factory = census.Method(HttpMinter, "ForValidatedScope"); + if (!http.IsSealed || http.IsPublic || !factory.IsAssembly || factory.IsStatic + || Calls(census.Body(HttpMinter, "ForValidatedScope"), "LearnStack.Api.Authentication.TenantSubjectAdmissionProvenance", "Matches").Count() != 1 + || EvidenceComparisons(census.Body(HttpMinter, "IsEvidenceCurrent")).Count() != 6 + || Calls(census.Body(HttpMinter, "IsEvidenceCurrent"), "LearnStack.Api.Authentication.TenantSubjectAdmissionProvenance", "Matches").Count() != 1) + errors.Add("The HTTP source retains exact normalized binding, quota admission and current feature ownership"); + return errors; + } + + private static List BorrowerOffenders(Census census) + { + var errors = new List(); + Callers(census, errors, Source, "OpenAsync", [Reader + ".OpenAsync"]); + Callers(census, errors, ReaderPort, "ReadAsync", ["LearnStack.Modules.Identity.Application.Authorization.IdentityOperationAuthorizer.AuthorizeAsync", + "LearnStack.Modules.Identity.Application.Authorization.RoleGrantAuthorization.RecheckAsync", + "LearnStack.Api.Authentication.LiveTenantMembershipReader.CoversAsync"]); + Callers(census, errors, LifecyclePort, "ReadAuthorizationAsync", [Orchestrator + ".ReadAsync"]); + Callers(census, errors, SessionFactoryPort, "OpenAsync", [Orchestrator + ".ReadAsync"]); + Callers(census, errors, SessionPort, "ReadMembershipsAsync", [Orchestrator + ".ReadAsync"]); + Callers(census, errors, SessionPort, "get_Unit", [Orchestrator + ".ReadAsync"], distinct: true); + Callers(census, errors, SessionPort, "get_DeadlineToken", [Orchestrator + ".ReadAsync"], distinct: true); + Callers(census, errors, SessionPort, "CompleteAsync", [Orchestrator + ".ReadAsync"]); + if (Calls(census.Body(Orchestrator, "ReadAsync"), "System.IAsyncDisposable", "DisposeAsync").Count() != 1) + errors.Add("The committed-state orchestrator retains its real asynchronous session cleanup"); + Callers(census, errors, SessionPort, "MarkCleanupFailed", [Orchestrator + ".ReadAsync"]); + Callers(census, errors, Reader, "ReadMembershipsAsync", [Session + ".ReadMembershipsAsync"]); + Callers(census, errors, Session, ".ctor", [Reader + ".OpenAsync"]); + Callers(census, errors, Lifecycle, "ReadAuthorizationAsync", []); + Callers(census, errors, Owner, ".ctor", [Source + ".RetainOwner"]); + Callers(census, errors, Owner, "OpenAsync", [Source + ".OpenAsync", Source + ".QualifyCoreAsync"]); + Callers(census, errors, Owner, "CompleteAsync", [Source + ".QualifyCoreAsync", Session + ".CompleteAsync"]); + Callers(census, errors, Owner, "get_Unit", [Session + ".get_Unit", Reader + ".ReadMembershipsAsync"], distinct: true); + Set(errors, "authorization reader implementations", census.Types.Where(type => type.Interfaces.Any(item => item.InterfaceType.FullName == ReaderPort)) + .Select(type => type.FullName), [Orchestrator]); + Set(errors, "authorization session factories", census.Types.Where(type => type.Interfaces.Any(item => item.InterfaceType.FullName == SessionFactoryPort)) + .Select(type => type.FullName), [Reader]); + Set(errors, "authorization sessions", census.Types.Where(type => type.Interfaces.Any(item => item.InterfaceType.FullName == SessionPort)) + .Select(type => type.FullName), [Session]); + var session = census.Type(Session); + if (!session.IsNestedPrivate || !session.IsSealed || session.Fields.Count != 2 + || session.Fields.Any(field => !field.IsPrivate || !field.IsInitOnly || field.IsStatic) + || !session.Fields.Select(field => field.FieldType.FullName).Order(StringComparer.Ordinal) + .SequenceEqual(new[] { Owner, Candidate }.Order(StringComparer.Ordinal), StringComparer.Ordinal)) + errors.Add("The private session retains only its immutable actual owner and candidate"); + if (census.Type(Reader).Module.AssemblyReferences.Any(reference => reference.Name.StartsWith("LearnStack.Modules.Tenancy.", StringComparison.Ordinal)) + || Il.ReferencedTypeNames(census.Type(Orchestrator)).Any(name => name.StartsWith("Npgsql.", StringComparison.Ordinal) + || name.StartsWith("LearnStack.Infrastructure.", StringComparison.Ordinal))) + errors.Add("Only Application coordinates the cross-module lifecycle contract; provider ownership remains in Infrastructure"); + foreach (var type in new[] { census.Type(Source), census.Type(Owner) }) + { + if (!type.IsSealed || type.Fields.Any(field => field.FieldType.FullName == "Npgsql.NpgsqlDataSource" && (!field.IsPrivate || !field.IsInitOnly))) + errors.Add("The authorization source and actual owner are sealed and hold only private readonly datasource fields"); + foreach (var method in type.Methods.Where(method => method.IsPublic || method.IsAssembly)) + if ((!method.IsConstructor && (ExposesRawSource(method.ReturnType) || ExposesCallback(method.ReturnType) + || method.Parameters.Any(parameter => ExposesRawSource(parameter.ParameterType)))) + || method.Parameters.Any(parameter => ExposesCallback(parameter.ParameterType))) + errors.Add("No raw datasource alias or caller SQL/callback surface: " + method.FullName); + if (Il.ReferencedTypeNames(type).Intersect([ + "System.IServiceProvider", "Microsoft.Extensions.DependencyInjection.IServiceScopeFactory", "MediatR.ISender", + "LearnStack.Modules.Identity.Domain.User", "LearnStack.Modules.Identity.Domain.MembershipProfile"], StringComparer.Ordinal).Any()) + errors.Add("The confined source cannot locate/dispatch business work or read global/profile projections: " + type.FullName); + } + foreach (var (type, readers) in new[] + { + (Source, new[] { census.Method(Source, "RetainOwner").FullName, census.Method(Source, "DisposeCoreAsync").FullName }), + (Owner, new[] { census.Method(Owner, "OpenAsync").FullName }), + }) + { + var field = census.Type(type).Fields.Single(item => item.FieldType.FullName == "Npgsql.NpgsqlDataSource"); + if (census.Bodies.Any(item => item.Body.Body.Instructions.Any(instruction => instruction.Operand is FieldReference target + && target.FullName == field.FullName && instruction.OpCode != OpCodes.Stfld + && (instruction.OpCode != OpCodes.Ldfld || !readers.Contains(item.Owner.FullName, StringComparer.Ordinal))))) + errors.Add("Only exact lifecycle members may read the private physical source: " + field.FullName); + } + return errors; + } + + private static List SetupOffenders(Census census) + { + var errors = new List(); + var body = census.Body(Owner, "OpenAsync"); + var instructions = body.Body.Instructions; + var setup = instructions.Where(instruction => IsCall(instruction, Owner, "ExecuteSetupAsync")).ToArray(); + var mode = instructions.Where(instruction => instruction.Operand as string == "SET TRANSACTION READ ONLY").ToArray(); + var announcement = instructions.Where(instruction => instruction.Operand is string text && text.StartsWith("SELECT set_config", StringComparison.Ordinal)).ToArray(); + if (setup.Length != 2 || mode.Length != 1 || announcement.Length != 1 + || instructions.IndexOf(mode[0]) >= instructions.IndexOf(setup[0]) + || instructions.IndexOf(setup[0]) >= instructions.IndexOf(announcement[0]) + || instructions.IndexOf(announcement[0]) >= instructions.IndexOf(setup[1]) + || !HasAwaitAck(body, setup[0], announcement[0])) + errors.Add("The real mode round trip is awaited before announcing the candidate"); + if (announcement.Length == 1) + { + var settings = Regex.Matches((string)announcement[0].Operand, + @"set_config\s*\(\s*'([^']+)'\s*,\s*([^,]+),\s*true\s*\)", RegexOptions.IgnoreCase) + .Select(match => match.Groups[1].Value + ":" + match.Groups[2].Value.Trim()); + Set(errors, "candidate GUCs", settings, ["app.tenant_id:@tenant", "app.organization_id:@organization", "app.scope:''", + "app.resolving_host:''", "app.identity_authority_id:''", "app.identity_subject_key:''"]); + var publish = instructions.Where(instruction => instruction.OpCode == OpCodes.Newobj + && instruction.Operand is MethodReference target && target.DeclaringType.FullName == "LearnStack.SharedKernel.Persistence.TenantContextAnnouncement").ToArray(); + if (setup.Length != 2 || publish.Length != 1 || !HasAwaitAck(body, setup[1], publish[0])) + errors.Add("Announcement evidence is published only after the actual GUC acknowledgement"); + } + var helper = census.Body(Owner, "ExecuteSetupAsync"); + var executions = Calls(helper, "System.Data.Common.DbCommand", "ExecuteNonQueryAsync").ToArray(); + var disposals = Calls(helper, Owner, "DisposeStatementAsync").ToArray(); + var constructors = helper.Body.Instructions.Where(instruction => instruction.OpCode == OpCodes.Newobj + && instruction.Operand is MethodReference target && target.DeclaringType.FullName == "Npgsql.NpgsqlCommand" + && AssemblyOf(target.DeclaringType) == "Npgsql").ToArray(); + if (executions.Length != 1 || disposals.Length == 0 || !HasAwaitAck(helper, executions[0], disposals[0]) + || constructors.Length != 1 || constructors[0].Next?.OpCode != OpCodes.Stfld + || constructors[0].Next?.Operand is not FieldReference commandField + || commandField.FieldType.FullName != "Npgsql.NpgsqlCommand" + || constructors[0].Previous?.Previous?.Previous?.Previous?.Previous?.Operand is not FieldReference statement + || statement.Name != "statement" || statement.FieldType.FullName != "System.String" + || !IsCall(executions[0].Previous, Owner, "get_DeadlineToken") + || executions[0].Previous?.Previous?.Previous?.Operand is not FieldReference receiver + || receiver.FullName != commandField.FullName) + errors.Add("The setup helper executes and awaits its actual caller statement; a no-op/name-only helper is forbidden"); + return errors; + } + + private static List GuardOffenders(Census census) + { + var errors = new List(); + Callers(census, errors, RoleGuard, "RefuseBypassRoleAsync", [Owner + ".OpenAsync", + "LearnStack.Infrastructure.Persistence.DatabaseQualificationProof.OpenAsync", + "LearnStack.Infrastructure.Persistence.AuthorizationEvidenceSession.OpenAsync"]); + var body = census.Body(Owner, "OpenAsync"); + var guards = Calls(body, RoleGuard, "RefuseBypassRoleAsync").ToArray(); + var begins = Calls(body, "Npgsql.NpgsqlConnection", "BeginTransactionAsync").ToArray(); + if (guards.Length != 1 || begins.Length != 1 || !HasAwaitAck(body, guards[0], begins[0]) + || !IsCall(guards[0].Previous, Owner, "get_DeadlineToken")) + errors.Add("Actual checkout awaits the shared guard with DeadlineToken before BEGIN"); + var forward = census.Body(RoleGuard, "RefuseBypassRoleAsync"); + var bridge = census.Method(RoleGuard, "RefuseBypassRoleAsync"); + if (!bridge.IsAssembly || !bridge.IsStatic || bridge.Parameters.Count != 2 + || bridge.Parameters[0].ParameterType.FullName != "Npgsql.NpgsqlConnection" + || bridge.Parameters[1].ParameterType.FullName != "System.Threading.CancellationToken" + || Calls(forward, RoleGuard, "RefuseBypassRole").Count() != 1 + || !forward.Body.Instructions.Any(instruction => instruction.OpCode == OpCodes.Ldarg_1)) + errors.Add("The shared role-guard bridge forwards the actual operation token"); + var constructors = census.Type(Source).Methods.Where(method => method.IsConstructor && method.IsPublic).ToArray(); + if (constructors.Length != 1 || constructors[0].Parameters.Count != 1 + || constructors[0].Parameters[0].ParameterType.FullName != "System.String" + || !constructors[0].Body.Instructions.Any(instruction => IsCall(instruction, Source, ".ctor") + && instruction.Previous?.OpCode == OpCodes.Ldnull && instruction.Previous.Previous?.OpCode == OpCodes.Ldc_I4_4)) + errors.Add("Deployment callers cannot widen the four-owner source bound"); + return errors; + } + + private static List QueryOffenders(Census census) + { + var errors = new List(); + var reader = census.Body(Reader, "ReadMembershipsAsync"); + var lifecycle = census.Body(Lifecycle, "ReadAuthorizationAsync"); + var identitySql = Sql(reader).SingleOrDefault(text => text.StartsWith("WITH eligible_memberships", StringComparison.Ordinal)); + var lifecycleSql = Sql(lifecycle).SingleOrDefault(text => text.StartsWith("SELECT t.id", StringComparison.Ordinal)); + if (identitySql is null || !identitySql.Contains("LIMIT 3", StringComparison.Ordinal) || !identitySql.Contains("LIMIT 9", StringComparison.Ordinal) + || !identitySql.Contains("member.user_id = @actor", StringComparison.Ordinal) + || !identitySql.Contains("member.tenant_id = @tenant", StringComparison.Ordinal) + || !identitySql.Contains("member.organization_id = @organization", StringComparison.Ordinal) + || !identitySql.Contains("transaction_read_only", StringComparison.Ordinal) + || !identitySql.Contains("transaction_isolation", StringComparison.Ordinal)) + errors.Add("The Identity statement remains exact, committed-mode checked and bounded with excess detection"); + if (identitySql is not null) Set(errors, "Identity tables", Tables(identitySql), + ["memberships", "membership_role_assignments", "roles", "role_permission_grants"]); + if (RoleMaterializationLimits(reader).Count() != 1) + errors.Add("The actual role-ID set must reject more than eight materialized IDs with the fixed unavailable exception"); + if (lifecycleSql is null || !lifecycleSql.Contains("transaction_read_only", StringComparison.Ordinal) + || !lifecycleSql.Contains("transaction_isolation", StringComparison.Ordinal)) + errors.Add("Lifecycle validates the actual server transaction mode"); + if (lifecycleSql is not null) Set(errors, "Tenancy tables", Tables(lifecycleSql), ["tenants", "organizations"]); + foreach (var method in new[] { reader, lifecycle }) + { + if (Calls(method, "Npgsql.NpgsqlCommand", "ExecuteReaderAsync").Count() != 1 + || method.Body.Instructions.Any(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == "Npgsql.NpgsqlCommand" && target.Name is "ExecuteScalarAsync" or "ExecuteNonQueryAsync")) + errors.Add("Each module runs only its one exact statement: " + method.FullName); + foreach (var sql in Sql(method)) + if (Regex.IsMatch(sql, @"\b(?:INSERT|UPDATE|DELETE|pg_advisory\w*|pg_try_advisory\w*|fn_identity_\w*)\b|\bFOR\s+(?:SHARE|KEY|NO)\b", RegexOptions.IgnoreCase)) + errors.Add("Authorization SQL has no writes or business row/advisory/owner-function locks"); + } + if (lifecycle.Body.Instructions.Any(instruction => instruction.Operand is FieldReference field && field.DeclaringType.FullName == Lifecycle)) + errors.Add("The lifecycle authorization overload never reads its captured ambient business unit"); + var forbiddenTypes = new[] { "LearnStack.SharedKernel.Tenancy.ITenantContext", "Npgsql.NpgsqlDataSource", + "LearnStack.Modules.Identity.Domain.User", "LearnStack.Modules.Identity.Domain.MembershipProfile", "System.IServiceProvider", "MediatR.ISender" }; + foreach (var component in new[] { Reader, Orchestrator }) + if (Il.ReferencedTypeNames(census.Type(component)).Intersect(forbiddenTypes, StringComparer.Ordinal).Any()) + errors.Add("The reader has no profile/global identity, ambient context, raw datasource or dispatch capability: " + component); + foreach (var (type, expected) in new[] { (Reader, 1), (Source, 0), (Owner, 2), (Lifecycle, 2) }) + if (census.Bodies.Where(item => item.Owner.DeclaringType.FullName == type) + .Sum(item => item.Body.Body.Instructions.Count(instruction => instruction.OpCode == OpCodes.Newobj + && instruction.Operand is MethodReference target && target.DeclaringType.FullName == "Npgsql.NpgsqlCommand")) != expected) + errors.Add("No extra SQL helper is admitted on " + type); + return errors; + } + + private static IEnumerable Sql(MethodDefinition body) => body.Body.Instructions + .Where(instruction => instruction.OpCode == OpCodes.Ldstr).Select(instruction => (string)instruction.Operand) + .Where(text => text.StartsWith("SELECT", StringComparison.Ordinal) || text.StartsWith("WITH", StringComparison.Ordinal)); + + // All nine-role stored graphs necessarily repeat a finite template today. This + // independently pins the actual materialization guard without claiming its >8 path + // executes before the duplicate-template guard in those database controls. + private static IEnumerable RoleMaterializationLimits(MethodDefinition method) + { + const string RoleIds = "System.Collections.Generic.HashSet`1"; + foreach (var count in Calls(method, RoleIds, "get_Count")) + { + if (count.Previous?.OpCode != OpCodes.Ldfld || count.Previous.Operand is not FieldReference set + || set.DeclaringType.FullName != method.DeclaringType.FullName || set.FieldType.FullName != RoleIds + || !set.Name.StartsWith("", StringComparison.Ordinal) + || Calls(method, RoleIds, "Add").Count(add => add.Previous?.Previous?.OpCode == OpCodes.Ldfld + && add.Previous.Previous.Operand is FieldReference receiver && receiver.FullName == set.FullName) != 1) + continue; + var bound = count.Next; + var branch = bound?.Next; + var refused = branch?.Next; + var terminal = refused?.Next; + if (bound?.OpCode != OpCodes.Ldc_I4_8 || branch?.OpCode.Code is not (Code.Ble or Code.Ble_S) + || !IsCall(refused, Reader, "Unavailable") || terminal?.OpCode != OpCodes.Throw + || branch.Operand is not Instruction admitted || admitted != terminal.Next) + continue; + yield return new(count, bound, branch, terminal); + } + } + + private sealed record RoleMaterializationLimit(Instruction Count, Instruction Bound, Instruction Branch, Instruction Throw); + + // Release Roslyn lowers ReferenceEquals to reference-identity branches. Match each + // actual live getter, captured witness getter and rejecting comparison, not a method name. + private static IEnumerable EvidenceComparisons(MethodDefinition method) + { + var witness = HttpMinter + "/CredentialWitness"; + foreach (var (captured, liveType, liveGetter, feature) in new[] + { + ("get_Http", "Microsoft.AspNetCore.Http.IHttpContextAccessor", "get_HttpContext", ""), + ("get_Host", "Microsoft.AspNetCore.Http.Features.IFeatureCollection", "Get", "LearnStack.Api.Tenancy.HostClassification"), + ("get_Identity", "LearnStack.Api.Authentication.TenantCredentialFeature", "get_Identity", ""), + ("get_Subject", "LearnStack.Api.Authentication.TenantCredentialFeature", "get_VerifiedSubject", ""), + ("get_Decision", "Microsoft.AspNetCore.Http.Features.IFeatureCollection", "Get", "LearnStack.Api.Authentication.SubjectAdmissionDecision"), + ("get_Provenance", "Microsoft.AspNetCore.Http.Features.IFeatureCollection", "Get", "LearnStack.Api.Authentication.TenantSubjectAdmissionProvenance"), + }) + { + var matches = Calls(method, witness, captured).Where(getter => + IsCall(getter.Previous?.Previous, liveType, liveGetter) + && (feature.Length == 0 || getter.Previous!.Previous!.Operand is GenericInstanceMethod generic + && generic.GenericArguments.SingleOrDefault()?.FullName == feature)) + .Select(getter => getter.Next).Where(next => next is not null && RejectsChangedReference(next)).ToArray(); + if (matches.Length == 1) yield return matches[0]!; + } + } + + private static bool RejectsChangedReference(Instruction comparison) + { + var branch = IsCall(comparison, "System.Object", "ReferenceEquals") ? comparison.Next : comparison; + if (branch is null || !(comparison.OpCode.Code is Code.Bne_Un or Code.Bne_Un_S + || IsCall(comparison, "System.Object", "ReferenceEquals") && branch.OpCode.Code is Code.Brfalse or Code.Brfalse_S)) + return false; + return branch.Operand is Instruction rejected && rejected.OpCode == OpCodes.Ldc_I4_0 + && rejected.Next?.OpCode == OpCodes.Ret; + } + private static IEnumerable Tables(string sql) => Regex.Matches(sql, @"\b(?:FROM|JOIN)\s+public\.([a-z_]+)", RegexOptions.IgnoreCase) + .Select(match => match.Groups[1].Value).Distinct(StringComparer.Ordinal); + private static bool ExposesRawSource(TypeReference type) => type.FullName is "Npgsql.NpgsqlDataSource" or "System.Data.Common.DbDataSource" + or "Npgsql.NpgsqlConnection" or "System.Data.Common.DbConnection" + || type is GenericInstanceType generic && generic.GenericArguments.Any(ExposesRawSource) + || type is TypeSpecification specification && ExposesRawSource(specification.ElementType); + private static bool ExposesCallback(TypeReference type) => type.FullName.StartsWith("System.Action", StringComparison.Ordinal) + || type.FullName.StartsWith("System.Func", StringComparison.Ordinal) || type is GenericInstanceType generic && generic.GenericArguments.Any(ExposesCallback); + private static IEnumerable Calls(MethodDefinition body, string type, string method) => body.Body.Instructions.Where(instruction => IsCall(instruction, type, method)); + private static bool IsCall(Instruction? instruction, string type, string method) => instruction?.OpCode.FlowControl == FlowControl.Call + && instruction.Operand is MethodReference target && target.DeclaringType.FullName == type && target.Name == method; + private static bool HasAwaitAck(MethodDefinition body, Instruction start, Instruction end) => body.Body.Instructions + .Skip(body.Body.Instructions.IndexOf(start) + 1).TakeWhile(instruction => instruction != end).Any(IsAwaitAck) + && body.Body.Instructions.IndexOf(start) < body.Body.Instructions.IndexOf(end); + private static bool IsAwaitAck(Instruction instruction) => instruction.OpCode.FlowControl == FlowControl.Call && instruction.Operand is MethodReference target + && target.Name == "GetResult" && target.DeclaringType.FullName.StartsWith("System.Runtime.CompilerServices.", StringComparison.Ordinal) + && target.DeclaringType.Name.Contains("Awaiter", StringComparison.Ordinal); + private static Instruction AwaitAck(MethodDefinition body, Instruction start, Instruction end) => body.Body.Instructions + .Skip(body.Body.Instructions.IndexOf(start) + 1).TakeWhile(instruction => instruction != end).First(IsAwaitAck); + + private static void Callers(Census census, List errors, string type, string method, string[] expected, bool distinct = false) + { + var target = census.Method(type, method); + var sites = census.Bodies.SelectMany(item => item.Body.Body.Instructions.Where(instruction => instruction.Operand is MethodReference reference + && reference.DeclaringType.FullName == type && reference.Name == method) + .Select(instruction => (item.Owner, Instruction: instruction, Target: (MethodReference)instruction.Operand))).ToArray(); + var callers = sites.Select(site => site.Owner.DeclaringType.FullName + "." + site.Owner.Name); + Set(errors, "callers of " + type + "." + method, distinct ? callers.Distinct(StringComparer.Ordinal) : callers, expected); + if (sites.Any(site => site.Target.FullName != target.FullName || AssemblyOf(site.Target.DeclaringType) != target.DeclaringType.Module.Assembly.Name.Name + || site.Instruction.OpCode.FlowControl != FlowControl.Call)) errors.Add("Only actual calls target the declared capability: " + target.FullName); + } + + private static void Set(List errors, string description, IEnumerable actual, IEnumerable expected) + { + if (!actual.Order(StringComparer.Ordinal).SequenceEqual(expected.Order(StringComparer.Ordinal))) errors.Add("Unexpected " + description); + } + private static string AssemblyOf(TypeReference type) => type.Scope switch + { + AssemblyNameReference assembly => assembly.Name, + ModuleDefinition module => module.Assembly.Name.Name, + _ => type.Scope.Name, + }; + private static void Dirty(Census census, Func> detector, Func mutate) + { + var restore = mutate(); + try { detector(census).Should().NotBeEmpty("the same compiled detector must reject the planted violation"); } + finally { restore(); } + detector(census).Should().BeEmpty("the same detector accepts the restored compiled production control"); + } + private static Action RemoveCall(MethodDefinition body, string type, string method) => RemoveInstruction(Calls(body, type, method).First()); + private static Action RemoveInstruction(Instruction instruction) => Replace(instruction, OpCodes.Nop, null); + private static Action Replace(Instruction instruction, OpCode opcode, object? operand) + { + var originalCode = instruction.OpCode; + var originalOperand = instruction.Operand; + instruction.OpCode = opcode; + instruction.Operand = operand; + return () => { instruction.OpCode = originalCode; instruction.Operand = originalOperand; }; + } + private static Action AddCall(ModuleDefinition module, MethodReference target) + { + var type = new TypeDefinition("AuthorizationControl", "BorrowedCapability", TypeAttributes.NotPublic, module.TypeSystem.Object); + var method = new MethodDefinition("BorrowAsync", MethodAttributes.Public | MethodAttributes.Static, module.TypeSystem.Void); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Call, module.ImportReference(target))); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + type.Methods.Add(method); + module.Types.Add(type); + return () => module.Types.Remove(type); + } + + private sealed class Census : IDisposable + { + private Census(ModuleDefinition[] modules) => Modules = modules; + private ModuleDefinition[] Modules { get; } + public IEnumerable Types => Modules.SelectMany(module => module.GetTypes()); + public IEnumerable<(MethodDefinition Owner, MethodDefinition Body)> Bodies => Types.Where(type => !type.Name.Contains('<', StringComparison.Ordinal)) + .SelectMany(type => Il.Methods(type).Where(item => item.Definition.HasBody).Select(item => + (Origin(type, item.Definition, item.DeclaredAs), item.Definition))); + public TypeDefinition Type(string name) => Types.Single(type => type.FullName == name); + public MethodDefinition Method(string type, string name) => Type(type).Methods.Single(method => method.Name == name); + public MethodDefinition Body(string type, string name) => Il.Methods(Type(type)).Where(item => item.DeclaredAs == name && item.Definition.HasBody) + .Select(item => item.Definition).OrderByDescending(method => method.Name == "MoveNext").First(); + public bool IsDerivedFrom(TypeDefinition type, string expected) + { + for (var parent = type.BaseType; parent is not null;) + { + if (parent.FullName == expected) return true; + parent = Types.SingleOrDefault(item => item.FullName == parent.FullName)?.BaseType; + } + return false; + } + public static Census Read() => new([.. ProductionAssemblies.All().Select(assembly => ModuleDefinition.ReadModule(assembly.Location))]); + public void Dispose() { foreach (var module in Modules) module.Dispose(); } + + private static MethodDefinition Origin(TypeDefinition type, MethodDefinition body, string declaredAs) + { + if (body.DeclaringType == type) return body; + var actual = type.Methods.Where(method => method.CustomAttributes.Any(attribute => + attribute.AttributeType.FullName == "System.Runtime.CompilerServices.AsyncStateMachineAttribute" + && attribute.ConstructorArguments.SingleOrDefault().Value is TypeReference state + && state.FullName == body.DeclaringType.FullName)).ToArray(); + if (actual.Length == 1) return actual[0]; + var named = type.Methods.Where(method => method.Name == declaredAs).ToArray(); + return named.Length == 1 ? named[0] : body; + } + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/IdentityFoundationConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/IdentityFoundationConventionTests.cs index e39335b23..13e71215f 100644 --- a/backend/tests/LearnStack.Tests.Architecture/IdentityFoundationConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/IdentityFoundationConventionTests.cs @@ -93,6 +93,7 @@ public void Identity_Membership_Invariants_Use_Fresh_Ordered_Locks() { string[] admitted = [ + "LearnStack.Modules.Identity.Application.Roles.RoleGrantCommandHandlerBase.ExecuteAsync", "LearnStack.Modules.Identity.Application.Seeding.InitializeSeedRoleCommandHandler.Handle", "LearnStack.Modules.Identity.Application.Seeding.SeedMembershipCreation.CreateAsync", "LearnStack.Modules.Identity.Application.Seeding.SetSeedMembershipProfileCommandHandler.Handle", diff --git a/backend/tests/LearnStack.Tests.Architecture/IntegrationDeliveryLifecycleBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/IntegrationDeliveryLifecycleBoundaryTests.cs index 1f7e11961..c46d72f90 100644 --- a/backend/tests/LearnStack.Tests.Architecture/IntegrationDeliveryLifecycleBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/IntegrationDeliveryLifecycleBoundaryTests.cs @@ -20,6 +20,11 @@ public sealed partial class OutboxDispatchConventionTests private const string LifecycleScope = "LearnStack.SharedKernel.Persistence.IUnitOfWorkScope"; private const string LifecycleAdapter = "LearnStack.Infrastructure.Persistence.NpgsqlUnitOfWork"; private const string LifecycleFrame = LifecycleAdapter + "/Frame"; + private const string LifecycleAuthorizationOwner = "LearnStack.Infrastructure.Persistence.AuthorizationReadOwner"; + private const string LifecycleAuthorizationView = LifecycleAuthorizationOwner + "/ConfinedUnit"; + private const string LifecycleEvidenceOwner = "LearnStack.Infrastructure.Persistence.AuthorizationEvidenceSession"; + private const string LifecycleEvidenceView = LifecycleEvidenceOwner + "/ConfinedUnit"; + private const string LifecycleReadWitness = "LearnStack.SharedKernel.Permissions.OperationReadWitness"; private const string LifecycleGlobalPrepared = DeliveryCoordinator + "/PreparedGlobalIdentityOwner"; private const string LifecycleIdentityAssembly = "LearnStack.Modules.Identity.Infrastructure"; private const string LifecycleIdentityBindings = "LearnStack.Modules.Identity.Infrastructure.Bindings."; @@ -58,6 +63,7 @@ public void Integration_delivery_lifecycle_census_rejects_typed_helper_async_del var application = census.Modules.Single(module => module.Assembly.Name.Name == DeliveryApplication); var capabilities = new[] { unit, adapter }.SelectMany(type => type.Methods.Where(method => method.Name is "BeginTransactionAsync" or "CommitAsync" or "RollbackAsync" or "SetTenantContextAsync")) + .Concat(adapter.Methods.Where(method => method.Name == "SetTenantContextCoreAsync")) .Concat(new[] { scope, frame }.SelectMany(type => type.Methods.Where(method => method.Name is "CompleteAsync" or "FailAsync"))) .Concat(frame.Methods.Where(method => method.Name == "FailOwnedAsync")) .Cast().Concat(new[] { typeof(DbTransaction), typeof(IDbTransaction), typeof(NpgsqlTransaction), @@ -135,6 +141,10 @@ public void Integration_delivery_lifecycle_census_rejects_typed_helper_async_del new[] { "Npgsql.NpgsqlConnection", LifecycleIdentityBindings + "IdentityAuthorityRegistration", BoundaryCancellation }, "database.CommitAsync"), (LifecycleIdentityAssembly, LifecycleIdentityBindings + "IdentityAuthorityQualification", "QualifyCoreAsync", new[] { BoundaryCancellation }, "database.CommitAsync"), + (InfrastructureAssembly, LifecycleAuthorizationOwner, "CompleteAsync", + Array.Empty(), "database.CommitAsync"), + (InfrastructureAssembly, LifecycleEvidenceOwner, "CompleteAsync", + Array.Empty(), "database.CommitAsync"), (InfrastructureAssembly, "LearnStack.Infrastructure.Jobs.PostgresBackgroundJobControlStore", "CommitAsync", new[] { "LearnStack.Infrastructure.Jobs.BackgroundJobControlResources", BoundaryCancellation }, "database.CommitAsync"), (InfrastructureAssembly, "LearnStack.Infrastructure.Jobs.PostgresBackgroundJobTerminalStore", "CommitAsync", @@ -166,6 +176,35 @@ public void Integration_delivery_lifecycle_census_rejects_typed_helper_async_del foreach (var announcement in announcements) AssertMutation(census, "a missing real tenant announcement site", () => ReplaceInstruction(announcement, OpCodes.Ldstr, "missing tenant announcement"), IntegrationLifecycleOffenders); + var readAnnouncements = OwnedBodies(census).SelectMany(item => item.Body.Body.Instructions) + .Where(instruction => instruction.Operand is MethodReference target + && IntegrationLifecycleCapability(target, types) is "unit.announce-wide" or "owned-unit.announce").ToArray(); + readAnnouncements.Should().HaveCount(3, "the common owner has one witness announcement and both fixed public overloads share one private core"); + foreach (var announcement in readAnnouncements) + { + AssertMutation(census, "a missing actual operation read announcement path", () => + ReplaceInstruction(announcement, OpCodes.Nop, null!), IntegrationLifecycleOffenders); + AssertMutation(census, "a delegated actual operation read announcement path", () => + ReplaceInstruction(announcement, OpCodes.Ldftn, announcement.Operand), IntegrationLifecycleOffenders); + } + var defaultWitness = unit.Methods.Single(method => method.Name == "SetTenantContextAsync" && method.Parameters.Count == 3); + var defaultThrow = defaultWitness.Body.Instructions.Single(instruction => instruction.OpCode == OpCodes.Throw); + AssertMutation(census, "a witness-capable fallback on units without a physical binder", () => + ReplaceInstruction(defaultThrow, OpCodes.Ret, null!), IntegrationLifecycleOffenders); + foreach (var view in new[] { LifecycleAuthorizationView, LifecycleEvidenceView }) + { + var confined = types[(InfrastructureAssembly, view)]; + AssertMutation(census, "a witness override on a confined child", () => + { + var overload = new MethodDefinition("SetTenantContextAsync", Mono.Cecil.MethodAttributes.Public, defaultWitness.ReturnType); + foreach (var parameter in defaultWitness.Parameters) + overload.Parameters.Add(new ParameterDefinition(parameter.ParameterType)); + overload.Body.Instructions.Add(Instruction.Create(OpCodes.Ldnull)); + overload.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + confined.Methods.Add(overload); + return () => confined.Methods.Remove(overload); + }, IntegrationLifecycleOffenders); + } var clean = AddProbe(application, "CleanIntegrationLifecycleObserver"); foreach (var method in unit.Methods.Where(method => method.IsGetter || method.Name == "MarkRollbackOnly") .Concat(scope.Methods.Where(method => method.IsGetter))) @@ -200,14 +239,22 @@ private static SortedSet IntegrationLifecycleOffenders(ProductionCensus CheckSet(offenders, "lifecycle production assemblies", census.Modules.Select(module => module.Assembly.Name.Name), ProductionAssemblies.Names()); var types = census.Types; foreach (var subject in new[] { (KernelAssembly, LifecycleUnit), (KernelAssembly, LifecycleScope), - (InfrastructureAssembly, LifecycleAdapter), (InfrastructureAssembly, LifecycleFrame) }) + (InfrastructureAssembly, LifecycleAdapter), (InfrastructureAssembly, LifecycleFrame), + (InfrastructureAssembly, LifecycleAuthorizationView), (InfrastructureAssembly, LifecycleEvidenceView) }) if (!types.ContainsKey(subject)) offenders.Add("Missing lifecycle subject: " + subject.Item2); if (offenders.Count != 0) return offenders; var unit = types[(KernelAssembly, LifecycleUnit)]; var scope = types[(KernelAssembly, LifecycleScope)]; var adapter = types[(InfrastructureAssembly, LifecycleAdapter)]; var frame = types[(InfrastructureAssembly, LifecycleFrame)]; - CheckSet(offenders, "unit lifecycle implementations", types.Values.Where(type => !type.IsInterface && Derives(type, unit, types)).Select(TypeId), [TypeId(adapter)]); + var confined = types[(InfrastructureAssembly, LifecycleAuthorizationView)]; + var evidenceView = types[(InfrastructureAssembly, LifecycleEvidenceView)]; + CheckSet(offenders, "unit lifecycle implementations", types.Values.Where(type => !type.IsInterface && Derives(type, unit, types)).Select(TypeId), + [TypeId(adapter), TypeId(confined), TypeId(evidenceView)]); + foreach (var view in new[] { confined, evidenceView }) + if (!view.IsSealed || !view.IsNestedPrivate + || view.Methods.Any(method => method.Name == "SetTenantContextAsync" && method.Parameters.Count != 2)) + offenders.Add("The confined child retains its closed narrow-only unit view and inherits witness refusal: " + view.FullName); CheckSet(offenders, "frame lifecycle implementations", types.Values.Where(type => !type.IsInterface && Derives(type, scope, types)).Select(TypeId), [TypeId(frame)]); if (!adapter.IsSealed || !frame.IsSealed || !frame.IsNestedPrivate) offenders.Add("The unit adapter and its private frame must remain sealed"); var ownedFailures = frame.Methods.Where(method => method.Name == "FailOwnedAsync").ToArray(); @@ -222,8 +269,27 @@ private static SortedSet IntegrationLifecycleOffenders(ProductionCensus CheckLifecycleDeclarations(type, "CommitAsync", LifecycleTask, [[BoundaryCancellation]]); CheckLifecycleDeclarations(type, "RollbackAsync", LifecycleTask, [[BoundaryCancellation]]); CheckLifecycleDeclarations(type, "SetTenantContextAsync", LifecycleTask, - [["LearnStack.SharedKernel.Tenancy.ITenantContext", BoundaryCancellation]]); + [["LearnStack.SharedKernel.Tenancy.ITenantContext", BoundaryCancellation], + ["LearnStack.SharedKernel.Tenancy.ITenantContext", LifecycleReadWitness, BoundaryCancellation]]); } + var core = adapter.Methods.Where(method => method.Name == "SetTenantContextCoreAsync").ToArray(); + if (core.Length != 1 || !core[0].IsPrivate || core[0].IsStatic || core[0].HasGenericParameters + || core[0].ReturnType.FullName != LifecycleTask || !ParametersMatch(core[0], + ["LearnStack.SharedKernel.Tenancy.ITenantContext", LifecycleReadWitness, "System.Boolean", BoundaryCancellation])) + offenders.Add("The ordinary tenant setter retains one private witness-aware core"); + var defaultWide = unit.Methods.Where(method => method.Name == "SetTenantContextAsync" + && ParametersMatch(method, ["LearnStack.SharedKernel.Tenancy.ITenantContext", LifecycleReadWitness, BoundaryCancellation])).ToArray(); + if (defaultWide.Length != 1 || !defaultWide[0].HasBody + || !defaultWide[0].Body.Instructions.Where(instruction => instruction.OpCode != OpCodes.Nop).Select(instruction => instruction.OpCode.Code) + .SequenceEqual([Code.Ldstr, Code.Newobj, Code.Throw]) + || defaultWide[0].Body.Instructions.Count(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == "System.InvalidOperationException" && target.Name == ".ctor") != 1) + offenders.Add("Units without the physical read binding capability default-deny the witness overload"); + foreach (var instruction in OwnedBodies(census).SelectMany(item => item.Body.Body.Instructions) + .Where(instruction => instruction.Operand is MethodReference target + && IntegrationLifecycleCapability(target, types) is "unit.announce" or "unit.announce-wide" or "owned-unit.announce")) + if (instruction.OpCode != OpCodes.Call && instruction.OpCode != OpCodes.Callvirt) + offenders.Add("A tenant announcement cannot escape its actual caller as a delegate"); foreach (var type in new[] { scope, frame }) { CheckLifecycleDeclarations(type, "CompleteAsync", LifecycleTask, [[BoundaryCancellation]]); @@ -255,6 +321,11 @@ private static SortedSet IntegrationLifecycleCallsiteOffenders( ["unit.rollback"] = [], ["unit.announce"] = [Owner(DeliveryApplication, DeliveryCoordinator, "ExecuteFrameAsync"), Owner(DeliveryApplication, DeliveryPrepared, "CreateAsync"), Owner(InfrastructureAssembly, LifecycleAcceptance, "AcceptAsync")], + ["unit.announce-wide"] = [Owner(DeliveryApplication, DeliveryCoordinator, "ExecuteFrameAsync")], + ["owned-unit.announce"] = [Owner(InfrastructureAssembly, LifecycleAdapter, "SetTenantContextAsync", + ["LearnStack.SharedKernel.Tenancy.ITenantContext", BoundaryCancellation]), + Owner(InfrastructureAssembly, LifecycleAdapter, "SetTenantContextAsync", + ["LearnStack.SharedKernel.Tenancy.ITenantContext", LifecycleReadWitness, BoundaryCancellation])], ["scope.complete"] = [Owner(DeliveryApplication, DeliveryCoordinator, "ExecuteFrameAsync"), Owner(InfrastructureAssembly, LifecycleAcceptance, "AcceptAsync"), Owner(DeliveryApplication, LifecycleGlobalPrepared, "CompleteExistingAsync"), Owner(LifecycleIdentityAssembly, LifecycleIdentityBindings + "GlobalIdentityBindingService", "ResolveExistingAsync")], @@ -266,6 +337,8 @@ private static SortedSet IntegrationLifecycleCallsiteOffenders( ["database.CommitAsync"] = [ Owner(InfrastructureAssembly, LifecycleAdapter, "CommitFrameAsync"), + Owner(InfrastructureAssembly, LifecycleAuthorizationOwner, "CompleteAsync"), + Owner(InfrastructureAssembly, LifecycleEvidenceOwner, "CompleteAsync"), Owner(InfrastructureAssembly, Store, "AcquireAsync"), Owner(InfrastructureAssembly, Store, "TryTerminalizeAsync"), Owner(InfrastructureAssembly, DeliveryStore, "AcquireAsync"), Owner(InfrastructureAssembly, DeliveryStore, "UpdateAsync"), Owner(InfrastructureAssembly, DeliveryStore, "TryTerminalizeAsync"), Owner(InfrastructureAssembly, DeliveryStore, "ReplayAsync"), @@ -336,6 +409,9 @@ string Owner(string assembly, string typeName, string name, string[]? parameters { var name = target.Name[(target.Name.LastIndexOf('.') + 1)..]; var cancellation = ParametersMatch(target, [BoundaryCancellation]); + if (target.DeclaringType.FullName == LifecycleAdapter && name == "SetTenantContextCoreAsync") + return ParametersMatch(target, ["LearnStack.SharedKernel.Tenancy.ITenantContext", LifecycleReadWitness, "System.Boolean", BoundaryCancellation]) + && target.ReturnType.FullName == LifecycleTask ? "owned-unit.announce" : "invalid-owned-unit-announce"; if (types.TryGetValue((InfrastructureAssembly, LifecycleFrame), out var frame) && SameType(target.DeclaringType, frame) && name == "FailOwnedAsync") return target.Parameters.Count == 0 && target.ReturnType.FullName == LifecycleTask @@ -351,6 +427,8 @@ string Owner(string assembly, string typeName, string name, string[]? parameters "RollbackAsync" when cancellation && target.ReturnType.FullName == LifecycleTask => "unit.rollback", "SetTenantContextAsync" when ParametersMatch(target, ["LearnStack.SharedKernel.Tenancy.ITenantContext", BoundaryCancellation]) && target.ReturnType.FullName == LifecycleTask => "unit.announce", + "SetTenantContextAsync" when ParametersMatch(target, ["LearnStack.SharedKernel.Tenancy.ITenantContext", LifecycleReadWitness, BoundaryCancellation]) + && target.ReturnType.FullName == LifecycleTask => "unit.announce-wide", "BeginTransactionAsync" or "CommitAsync" or "RollbackAsync" or "SetTenantContextAsync" => "invalid-unit", _ => null, }; diff --git a/backend/tests/LearnStack.Tests.Architecture/OperationPermissionConventionTests.DeniedProofs.cs b/backend/tests/LearnStack.Tests.Architecture/OperationPermissionConventionTests.DeniedProofs.cs new file mode 100644 index 000000000..6fce36cbb --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/OperationPermissionConventionTests.DeniedProofs.cs @@ -0,0 +1,144 @@ +using FluentAssertions; +using LearnStack.Modules.Identity.Application.Contracts.Roles; +using LearnStack.SharedKernel.Permissions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class OperationPermissionConventionTests +{ + private const string PairFile = "LearnStack.Tests.Integration/Database/RoleGrantCommandIntegrationTests.Registry.cs"; + private const string PairMethod = "Every_assignable_pair_requires_a_legal_delegation_target_with_committed_denial_and_no_refused_mutation"; + private const string PairOwnerFile = "LearnStack.Tests.Integration/Database/RoleGrantCommandIntegrationTests.cs"; + private const string HarnessFile = "LearnStack.Tests.Integration/Database/RoleGrantPipelineHarness.cs"; + private static readonly DeniedProof[] PublicDeniedProofs = + [ + new("tenancy.public_site.read", "LearnStack.Tests.Integration/Database/PublicSiteHttpTests.cs", + "Unknown_and_platform_hosts_keep_their_generic_refusals", "fixture.ClientFor", "client.SendAsync", "HttpStatusCode.NotFound"), + new("education.public_course.read", "LearnStack.Tests.Integration/Database/PublicEducationHttpTests.cs", + "Course_draft_branch_foreign_and_unknown_are_uniformly_hidden", "fixture.ClientFor", "client.GetAsync", "HttpStatusCode.NotFound"), + new("education.public_lesson.read", "LearnStack.Tests.Integration/Database/PublicEducationHttpTests.cs", + "Lesson_masks_draft_parent_draft_restricted_scope_wrong_parent_and_foreign_rows", "fixture.ClientFor", "client.GetAsync", "HttpStatusCode.NotFound"), + ]; + private static readonly DeniedProof[] OtherDeniedProofs = + [ + new("human capability", "LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpTests.cs", + "Coverage_capability_and_store_failure_are_distinct_and_never_dispatch_the_directory", + "SendAsync", "HttpStatusCode.Forbidden", "\"identity.authorization.deny\""), + new("role administration", "LearnStack.Tests.Integration/Database/RoleGrantCommandIntegrationTests.cs", + "Organization_actor_and_orgless_reissue_never_promote_Organization_grants_to_Tenant_role_administration", + "new ChangeRoleGrantsCommand", "branch.Sender.Send", "Assert.Equal(\"forbidden\""), + new("missing delegation authority", "LearnStack.Tests.Integration/Database/RoleGrantCommandIntegrationTests.cs", + "Removed_profile_authority_cannot_self_restore_through_change_or_v1_adoption_and_kernel_cannot_be_removed", + "new ChangeRoleGrantsCommand", "new AdoptRoleGrantManifestCommand", "Assert.Equal(\"forbidden\""), + new("seed", "LearnStack.Tests.Integration/SeedOperationSourceTests.cs", + "Actual_seed_composition_captures_the_exact_finite_manifest_and_closes_it_with_its_scope", + "SeedComposition.Build", "source.Capture", "descriptor == null", "!descriptor.IsActive"), + new("delivery", "LearnStack.Tests.Unit/Infrastructure/Messaging/DeliveryOperationSourceTests.cs", + "Exact_validated_handler_binding_admits_only_its_command_and_cannot_be_installed_twice", + "new DeliveryOperationSource", "uninstalled.Capture", "Should().BeNull", "Throw"), + new("job", "LearnStack.Tests.Unit/Application/Jobs/TrustedJobOperationSourceTests.cs", + "Exact_framework_request_captures_its_existing_live_owner_without_readmission", + "new BackgroundJobOperationSource", "source.Capture", "Should().BeNull", "descriptor.IsActive.Should().BeFalse"), + ]; + + [Fact] + public void Permission_Registry_Has_DeniedTest() + { + var catalog = Catalog(); + catalog.TryGet(typeof(ChangeRoleGrantsCommand), out var consumer).Should().BeTrue(); + consumer.Kind.Should().Be(OperationPolicyKind.Protected); + var registrations = catalog.Definitions.ToArray(); + registrations.Should().HaveCount(8); + var files = PublicDeniedProofs.Concat(OtherDeniedProofs).Select(proof => proof.File) + .Append(PairFile).Append(HarnessFile).Distinct(StringComparer.Ordinal).ToDictionary(file => file, + file => CSharpSyntaxTree.ParseText(File.ReadAllText(Path.Combine(RepositoryPaths.RepoRoot(), "backend/tests", file))).GetCompilationUnitRoot(), + StringComparer.Ordinal); + DeniedProofOffenders(registrations, files).Should().BeEmpty( + "the actual assignable pairs have real delegation-target positive/denied cases; virtual and finite classes retain their distinct negative owners"); + foreach (var registration in registrations) + DeniedProofOffenders(registrations.Where(item => item != registration).ToArray(), files).Should().NotBeEmpty( + "removing a real definition cannot leave a vacuous proof map"); + var extra = new PermissionRegistration(new(PermissionScope.Tenant, "identity.unproved.write"), true); + DeniedProofOffenders([.. registrations, extra], files).Should().Contain("assignable pairs"); + + var pair = FindProof(files[PairFile], PairMethod)!; + foreach (var data in pair.AttributeLists.SelectMany(list => list.Attributes).Where(attribute => attribute.Name.ToString() == "InlineData")) + { + var dirty = new Dictionary(files, StringComparer.Ordinal) + { [PairFile] = files[PairFile].RemoveNode(data, SyntaxRemoveOptions.KeepNoTrivia)! }; + DeniedProofOffenders(registrations, dirty).Should().Contain("assignable pairs", "each exact scoped test case is required"); + } + foreach (var proof in PublicDeniedProofs.Concat(OtherDeniedProofs).Append(new("assignable", PairFile, PairMethod))) + { + var method = FindProof(files[proof.File], proof.Method)!; + var dirty = new Dictionary(files, StringComparer.Ordinal) + { [proof.File] = files[proof.File].RemoveNode(method, SyntaxRemoveOptions.KeepNoTrivia)! }; + DeniedProofOffenders(registrations, dirty).Should().NotBeEmpty("removing an executing owner must break its cross-reference"); + } + var denialAssertion = pair.DescendantNodes().OfType() + .Single(statement => Tokens(statement).Contains(Tokens("Assert.Equal(\"forbidden\", refused.Error!.Code)"), StringComparison.Ordinal)); + var withoutAssertion = new Dictionary(files, StringComparer.Ordinal) + { [PairFile] = files[PairFile].RemoveNode(denialAssertion, SyntaxRemoveOptions.KeepNoTrivia)! }; + DeniedProofOffenders(registrations, withoutAssertion).Should().Contain("assignable consumer assertions"); + } + + private static List DeniedProofOffenders(PermissionRegistration[] registrations, + Dictionary files) + { + var errors = new List(); + var pair = FindProof(files[PairFile], PairMethod); + var declared = pair?.AttributeLists.SelectMany(list => list.Attributes) + .Where(attribute => attribute.Name.ToString() == "InlineData") + .Select(attribute => attribute.ArgumentList?.Arguments) + .Where(arguments => arguments is { Count: 2 }) + .Select(arguments => (Scope: arguments!.Value[0].Expression.ToString().Replace("PermissionScope.", "", StringComparison.Ordinal), + Key: (arguments.Value[1].Expression as LiteralExpressionSyntax)?.Token.ValueText ?? "")) + .ToArray() ?? []; + var actual = registrations.Where(item => item.Assignable) + .Select(item => (Scope: item.Definition.Scope.ToString(), Key: item.Definition.Key)).ToArray(); + if (declared.Length != 5 || declared.Distinct().Count() != declared.Length + || !declared.ToHashSet().SetEquals(actual) || actual.Length != declared.Length) errors.Add("assignable pairs"); + var virtualPairs = registrations.Where(item => !item.Assignable).Select(item => item.Definition).ToArray(); + if (virtualPairs.Length != 3 || !virtualPairs.Select(item => item.Key).ToHashSet(StringComparer.Ordinal) + .SetEquals(PublicDeniedProofs.Select(proof => proof.Kind)) + || virtualPairs.Any(item => item.Scope != PermissionScope.Tenant || !item.IsActive)) errors.Add("virtual public pairs"); + if (pair is null || !Has(pair, + "new PermissionDefinition(permissionScope, permissionKey)", "new ChangeRoleGrantsCommand", + "Assert.True(positive.IsSuccess)", "Assert.Equal(\"forbidden\", refused.Error!.Code)", + "Assert.Equal(before.Version, after.Version)", "Assert.Equal(before.Grants, after.Grants)", + "AuditAsync(correlation, \"identity.authorization.deny\")", "Assert.Equal(\"denied\", denial.Outcome)", + "GetProperty(\"denial_reason\")", "SendAsync")) errors.Add("assignable consumer assertions"); + // The mapped pair case uses the real pipeline/reader/app-role store, not an affirmative evaluator double. + if (!Has(files[HarnessFile], "ApplicationDataSource.Build", "new IdentityAuthorizationReadSource", + "new AuthorizationEvidenceSource", "services.AddIdentityAuthorizationReader()", + "services.AddIdentityContextualFoundation()", "services.AddLearnStackOperationAuthorization()", + "services.AddLearnStackMediatRPipeline")) errors.Add("actual pair consumer harness"); + if (!Has(files[PairOwnerFile], "RoleGrantPipelineHarness.CreateAsync(fixture.AppConnectionString", + "await using var harness = await CreateAsync(correlation)", "return await harness.Sender.Send(request)", + "new NpgsqlCommand", "ScopeAsync(app, transaction, SchemaFixture.TenantA)", + "Assert.False(await rows.ReadAsync())")) errors.Add("actual pair dispatch and committed evidence"); + foreach (var proof in PublicDeniedProofs.Concat(OtherDeniedProofs)) + { + var method = FindProof(files[proof.File], proof.Method); + if (method is null || !Has(method, proof.Required)) errors.Add("proof: " + proof.Kind); + } + return errors; + } + + private static MethodDeclarationSyntax? FindProof(CompilationUnitSyntax root, string name) => + root.DescendantNodes().OfType().SingleOrDefault(method => method.Identifier.ValueText == name + && method.AttributeLists.SelectMany(list => list.Attributes).Any(attribute => attribute.Name.ToString() is "Fact" or "Theory") + && !method.AttributeLists.SelectMany(list => list.Attributes).SelectMany(attribute => attribute.ArgumentList?.Arguments ?? []) + .Any(argument => argument.NameEquals?.Name.Identifier.ValueText == "Skip")); + + private static bool Has(SyntaxNode node, params string[] required) => required.All(fragment => + Tokens(node).Contains(Tokens(fragment), StringComparison.Ordinal)); + private static string Tokens(SyntaxNode node) => string.Join(" ", node.DescendantTokens().Select(token => token.Text)); + private static string Tokens(string fragment) => string.Join(" ", SyntaxFactory.ParseTokens(fragment) + .Where(token => !token.IsKind(SyntaxKind.EndOfFileToken)).Select(token => token.Text)); + private sealed record DeniedProof(string Kind, string File, string Method, params string[] Required); +} diff --git a/backend/tests/LearnStack.Tests.Architecture/OperationPermissionConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/OperationPermissionConventionTests.cs new file mode 100644 index 000000000..ab70aa13e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/OperationPermissionConventionTests.cs @@ -0,0 +1,142 @@ +using System.Text.RegularExpressions; +using FluentAssertions; +using LearnStack.Application.Authorization; +using LearnStack.Modules.Customization.Application.Authorization; +using LearnStack.Modules.Education.Application.Authorization; +using LearnStack.Modules.Identity.Application; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Application.Contracts.Roles; +using LearnStack.Modules.Identity.Domain; +using LearnStack.Modules.Tenancy.Application.Authorization; +using LearnStack.SharedKernel.Permissions; +using Mono.Cecil; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// Actual module declarations are complete and finite; their registration grants no authority. +public sealed partial class OperationPermissionConventionTests +{ + [Fact] + public void Permission_Definitions_DeclareScope() + { + var catalog = Catalog(); + catalog.Definitions.Should().HaveCount(8); + catalog.Definitions.Should().OnlyContain(registration => Enum.IsDefined(registration.Definition.Scope) + && registration.Definition.Scope != PermissionScope.Platform); + catalog.Definitions.Where(registration => registration.Assignable).Select(registration => registration.Definition) + .Should().BeEquivalentTo(IdentityFoundationManifest.Version1.Definitions); + catalog.Definitions.Where(registration => !registration.Assignable).Select(registration => registration.Definition.Key) + .Should().BeEquivalentTo(["tenancy.public_site.read", "education.public_course.read", "education.public_lesson.read"]); + WithProduction(modules => + { + RequestOffenders(modules, catalog).Should().BeEmpty("Fix: every actual production request needs one exact policy (ADR-0076)"); + var module = modules.Single(item => item.Assembly.Name.Name == "LearnStack.Modules.Identity.Application.Contracts"); + var actual = module.GetType(typeof(ListMembershipsQuery).FullName); + var contract = actual.Interfaces.Single(item => item.InterfaceType is GenericInstanceType generic + && generic.ElementType.FullName == "MediatR.IRequest`1"); + var planted = new TypeDefinition("PermissionControl", "UnclassifiedQuery", TypeAttributes.NotPublic | TypeAttributes.Sealed, module.TypeSystem.Object); + planted.Interfaces.Add(new InterfaceImplementation(contract.InterfaceType)); + module.Types.Add(planted); + try { RequestOffenders(modules, catalog).Should().Contain(planted.FullName); } + finally { module.Types.Remove(planted); } + actual.Interfaces.Remove(contract); + try { RequestOffenders(modules, catalog).Should().Contain(actual.FullName, "a declaration whose actual request disappears must fail too"); } + finally { actual.Interfaces.Add(contract); } + var unexpectedSource = new TypeDefinition("PermissionControl", "UnregisteredSource", TypeAttributes.NotPublic | TypeAttributes.Sealed, module.TypeSystem.Object); + unexpectedSource.Interfaces.Add(new InterfaceImplementation(module.ImportReference(typeof(IOperationPolicySource)))); + module.Types.Add(unexpectedSource); + try { RequestOffenders(modules, catalog).Should().Contain(unexpectedSource.FullName); } + finally { module.Types.Remove(unexpectedSource); } + var alternateCatalog = new TypeDefinition("PermissionControl", "AlternateCatalog", TypeAttributes.NotPublic | TypeAttributes.Sealed, module.TypeSystem.Object); + alternateCatalog.Interfaces.Add(new InterfaceImplementation(module.ImportReference(typeof(IOperationPolicyCatalog)))); + module.Types.Add(alternateCatalog); + try { RequestOffenders(modules, catalog).Should().Contain(alternateCatalog.FullName); } + finally { module.Types.Remove(alternateCatalog); } + RequestOffenders(modules, catalog).Should().BeEmpty("both compiled subjects are restored"); + }); + } + + [Fact] + public void Permission_Keys_Match_Convention() + { + var keys = Catalog().Definitions.Select(registration => registration.Definition.Key).Distinct(StringComparer.Ordinal).ToArray(); + keys.Should().NotBeEmpty(); + foreach (var key in keys) ValidKey(key).Should().BeTrue("Fix: scoped definitions use the closed three-segment convention: " + key); + foreach (var dirty in new[] { "identity.membership.*", "identity.membership.publish", "Identity.membership.read", "identity..read", "identity.membership.read.extra" }) + ValidKey(dirty).Should().BeFalse(); + } + + [Fact] + public void Permission_Scope_Matches_Resource_Scope() + { + var catalog = Catalog(); + catalog.All.Where(policy => policy.Kind == OperationPolicyKind.Protected).Select(policy => policy.RequestType) + .Should().BeEquivalentTo([typeof(ListMembershipsQuery), typeof(ChangeRoleGrantsCommand), typeof(AdoptRoleGrantManifestCommand)]); + foreach (var policy in catalog.All) + { + if (policy.Kind == OperationPolicyKind.Protected) + { + if (policy.RequestType == typeof(ListMembershipsQuery)) + { + policy.RequiredPermission.Should().Be(IdentityOperationPolicySource.MembershipAdministration); + policy.ReadScope.Should().Be(OperationReadScope.Tenant, "the tenant-wide Membership read is an explicit read exception, never org write scope"); + policy.RequiresOrglessWrite.Should().BeFalse(); + } + else + { + policy.RequiredPermission.Should().Be(IdentityOperationPolicySource.RoleAdministration); + policy.ReadScope.Should().Be(OperationReadScope.Scoped, "finite grant writes never mint tenant-wide read authority"); + policy.RequiresOrglessWrite.Should().BeTrue(); + } + } + else + { + policy.ReadScope.Should().Be(OperationReadScope.Scoped, "public, AccountSite and finite owners cannot mint tenant-read authority"); + if (policy.Kind == OperationPolicyKind.PublicRead) + { + policy.RequiredPermission!.Scope.Should().Be(PermissionScope.Tenant); + catalog.TryGetDefinition(policy.RequiredPermission.Scope, policy.RequiredPermission.Key, out var registration).Should().BeTrue(); + registration.Assignable.Should().BeFalse(); + } + else policy.RequiredPermission.Should().BeNull("finite source authority and AccountSite coverage are not stored grants"); + } + } + Action dirty = () => OperationPolicy.ForProtected("identity.control.read", + new(PermissionScope.Organization, "identity.membership.admin"), OperationReadScope.Tenant); + dirty.Should().Throw("an Organization pair must never mint the Tenant read exception"); + } + + private static bool ValidKey(string value) => Regex.IsMatch(value, @"\A[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\.(read|write|delete|admin)\z", RegexOptions.CultureInvariant); + + private static SortedSet RequestOffenders(ModuleDefinition[] modules, OperationPolicyCatalog catalog) + { + var types = modules.SelectMany(module => module.Types.SelectMany(Flatten)).ToArray(); + var actual = types.Where(type => !type.IsAbstract + && type.Interfaces.Any(contract => contract.InterfaceType is GenericInstanceType generic + && generic.ElementType.FullName == "MediatR.IRequest`1")) + .Select(type => type.FullName).ToHashSet(StringComparer.Ordinal); + actual.SymmetricExceptWith(catalog.All.Select(policy => policy.RequestType.FullName!)); + var sources = types.Where(type => type.Interfaces.Any(contract => contract.InterfaceType.FullName == typeof(IOperationPolicySource).FullName)) + .Select(type => type.FullName).ToHashSet(StringComparer.Ordinal); + sources.SymmetricExceptWith(new[] { typeof(TenancyOperationPolicySource), typeof(CustomizationOperationPolicySource), + typeof(EducationOperationPolicySource), typeof(IdentityOperationPolicySource), typeof(BackgroundJobOperationPolicySource) }.Select(type => type.FullName!)); + actual.UnionWith(sources); + var catalogs = types.Where(type => type.Interfaces.Any(contract => contract.InterfaceType.FullName == typeof(IOperationPolicyCatalog).FullName)) + .Select(type => type.FullName).ToHashSet(StringComparer.Ordinal); + catalogs.SymmetricExceptWith([typeof(OperationPolicyCatalog).FullName!]); + actual.UnionWith(catalogs); + return new(actual, StringComparer.Ordinal); + } + + private static IEnumerable Flatten(TypeDefinition type) => new[] { type }.Concat(type.NestedTypes.SelectMany(Flatten)); + private static OperationPolicyCatalog Catalog() => new([ + new TenancyOperationPolicySource(), new CustomizationOperationPolicySource(), new EducationOperationPolicySource(), + new IdentityOperationPolicySource(), new BackgroundJobOperationPolicySource()]); + private static void WithProduction(Action action) + { + var modules = ProductionAssemblies.All().Select(assembly => ModuleDefinition.ReadModule(assembly.Location)).ToArray(); + try { action(modules); } + finally { foreach (var module in modules) module.Dispose(); } + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/OperationPolicyOwnershipTests.Activation.cs b/backend/tests/LearnStack.Tests.Architecture/OperationPolicyOwnershipTests.Activation.cs new file mode 100644 index 000000000..9a1ade59b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/OperationPolicyOwnershipTests.Activation.cs @@ -0,0 +1,109 @@ +using FluentAssertions; +using LearnStack.Api.Authentication; +using LearnStack.Application.Audit; +using LearnStack.Application.Authorization; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Application.Authorization; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class OperationPolicyOwnershipTests +{ + // Inspect the real registrations without resolving a pool, an unqualified + // reader or a physical unit. Missing configuration remains untouched. + private static void AssertLiveComposition() + { + var services = new ServiceCollection(); + services.AddLearnStackMediatRPipeline(); + services.AddLearnStackLiveAuthorization(new ConfigurationBuilder().Build(), DeploymentMode.SelfHostedOnline); + LiveCompositionOffenders(services).Should().BeEmpty(); + foreach (var service in new[] { typeof(IAuthorizationCandidateSource), typeof(IOperationInvocationContext), + typeof(IAuthorizationRefusalReporter), typeof(ITenantMembershipReader), typeof(IProtectedOperationAuthorizer), + typeof(IAuthorizationEvidenceSessionFactory), typeof(IAuthorizationPolicyProvider) }) + { + var descriptor = services.Single(item => item.ServiceType == service); + var index = services.IndexOf(descriptor); + services.RemoveAt(index); + try { LiveCompositionOffenders(services).Should().Contain(service.FullName!); } + finally { services.Insert(index, descriptor); } + services.Insert(services.Count, descriptor); + try { LiveCompositionOffenders(services).Should().Contain(service.FullName!, "an additional alias/registration changes actual resolution"); } + finally { services.RemoveAt(services.Count - 1); } + } + LiveCompositionOffenders(services).Should().BeEmpty("restoring the actual composition restores its exact aliases"); + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + options.FallbackPolicy.Should().BeSameAs(options.DefaultPolicy); + options.DefaultPolicy.AuthenticationSchemes.Should().BeEmpty(); + options.DefaultPolicy.Requirements.Should().ContainSingle().Which.Should().BeOfType(); + } + + private static List LiveCompositionOffenders(ServiceCollection services) + { + var errors = new List(); + Alias(typeof(IAuthorizationCandidateSource), typeof(TenantAuthorizationCandidateSource)); + Alias(typeof(IOperationInvocationContext), typeof(OperationAuthorizationCoordinator)); + Alias(typeof(IAuthorizationRefusalReporter), typeof(AuthorizationEvidenceCoordinator)); + Direct(typeof(ITenantMembershipReader), typeof(LiveTenantMembershipReader), ServiceLifetime.Scoped); + Direct(typeof(IAuthorizationPolicyProvider), typeof(OperationAuthorizationPolicyProvider), ServiceLifetime.Singleton); + Factory(typeof(IProtectedOperationAuthorizer), typeof(IdentityOperationAuthorizer), ServiceLifetime.Scoped); + var deferred = typeof(LiveAuthorizationRegistration).GetNestedType("DeferredEvidenceSessions", System.Reflection.BindingFlags.NonPublic)!; + Factory(typeof(IAuthorizationEvidenceSessionFactory), deferred, ServiceLifetime.Scoped); + foreach (var source in new[] { typeof(IdentityAuthorizationReadSource), typeof(AuthorizationEvidenceSource) }) + Factory(source, source, ServiceLifetime.Singleton); + if (services.Count(descriptor => descriptor.ServiceType == typeof(IAuthorizationHandler) + && descriptor.ImplementationType == typeof(OperationAuthorizationHandler) && descriptor.Lifetime == ServiceLifetime.Scoped) != 1) + errors.Add(typeof(OperationAuthorizationHandler).FullName!); + if (services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService) + && descriptor.ImplementationType == typeof(TenantAuthorizationSourcesStartupService) && descriptor.Lifetime == ServiceLifetime.Singleton) != 1) + errors.Add(typeof(TenantAuthorizationSourcesStartupService).FullName!); + return errors; + + void Direct(Type service, Type implementation, ServiceLifetime lifetime) + { + var descriptors = services.Where(descriptor => descriptor.ServiceType == service).ToArray(); + if (descriptors.Length != 1 || descriptors[0].Lifetime != lifetime || descriptors[0].ImplementationType != implementation) + errors.Add(service.FullName!); + } + void Alias(Type service, Type implementation) + { + var descriptors = services.Where(descriptor => descriptor.ServiceType == service).ToArray(); + if (descriptors.Length != 1 || descriptors[0].Lifetime != ServiceLifetime.Scoped + || !FactoryBody(descriptors[0], body => body.Body.Instructions.Select(instruction => instruction.OpCode.Code) + .SequenceEqual([body.IsStatic ? Code.Ldarg_0 : Code.Ldarg_1, Code.Call, Code.Ret]) + && body.Body.Instructions[1].Operand is GenericInstanceMethod call + && call.DeclaringType.FullName == "Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions" + && call.Name == "GetRequiredService" && call.GenericArguments.Count == 1 + && call.GenericArguments[0].FullName == implementation.FullName)) + errors.Add(service.FullName!); + } + void Factory(Type service, Type implementation, ServiceLifetime lifetime) + { + var descriptors = services.Where(descriptor => descriptor.ServiceType == service).ToArray(); + if (descriptors.Length != 1 || descriptors[0].Lifetime != lifetime + || !FactoryBody(descriptors[0], body => body.Body.Instructions.Count(instruction => instruction.OpCode == OpCodes.Newobj + && instruction.Operand is MethodReference constructor && constructor.DeclaringType.FullName == implementation.FullName!.Replace('+', '/')) == 1)) + errors.Add(service.FullName!); + } + } + + private static bool FactoryBody(ServiceDescriptor descriptor, Func predicate) + { + if (descriptor.ImplementationFactory is not { } factory) return false; + using var module = ModuleDefinition.ReadModule(factory.Method.Module.FullyQualifiedName); + var owner = module.GetType(factory.Method.DeclaringType!.FullName!.Replace('+', '/')); + return owner.Methods.SingleOrDefault(method => method.Name == factory.Method.Name) is { HasBody: true } body && predicate(body); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/OperationPolicyOwnershipTests.cs b/backend/tests/LearnStack.Tests.Architecture/OperationPolicyOwnershipTests.cs new file mode 100644 index 000000000..39d7f10f5 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/OperationPolicyOwnershipTests.cs @@ -0,0 +1,262 @@ +using FluentAssertions; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; +using Census = LearnStack.Tests.Architecture.TrustedOperationSourceConventionTests.Census; + +namespace LearnStack.Tests.Architecture; + +/// Operation authority is constructed only by the exact policy/evaluator/physical owner chain. +public sealed partial class OperationPolicyOwnershipTests +{ + private const string Permissions = "LearnStack.SharedKernel.Permissions."; + private const string Invocation = Permissions + "OperationInvocation"; + private const string Decision = Permissions + "ProtectedOperationDecision"; + private const string Witness = Permissions + "OperationReadWitness"; + private const string Candidate = Permissions + "AuthorizationCandidate"; + private const string Coordinator = "LearnStack.Application.Authorization.OperationAuthorizationCoordinator"; + private const string Frame = Coordinator + "/Frame"; + private const string Evaluator = "LearnStack.Modules.Identity.Application.Authorization.IdentityOperationAuthorizer"; + private const string RoleEvaluator = "LearnStack.Modules.Identity.Application.Authorization.RoleGrantAuthorization"; + private const string HttpSource = "LearnStack.Api.Authentication.TenantAuthorizationCandidateSource"; + private const string Unit = "LearnStack.Infrastructure.Persistence.NpgsqlUnitOfWork"; + private const string AuthorizationBehavior = "LearnStack.Application.Pipeline.AuthorizationBehavior`2"; + + [Fact] + public void Protected_Operations_Require_Explicit_Live_Policies() + { + AssertLiveComposition(); + using var census = Census.Read(); + Offenders(census).Should().BeEmpty("Fix: preserve the exact invocation/evaluator/read-owner authority chain and lifetime (ADR-0076)"); + foreach (var typeName in new[] { Invocation, Decision, Witness }) + { + var type = census.Type(typeName); + Dirty(census, () => + { + type.IsSealed = false; + return () => type.IsSealed = true; + }); + Dirty(census, () => + { + var constructor = type.Methods.Single(method => method.IsConstructor); + var attributes = constructor.Attributes; + constructor.IsPublic = true; + return () => constructor.Attributes = attributes; + }); + Dirty(census, () => + { + var field = type.Fields.First(field => field.IsInitOnly); + var attributes = field.Attributes; + field.IsInitOnly = false; + return () => field.Attributes = attributes; + }); + Dirty(census, () => + { + var module = census.Type(Coordinator).Module; + var extra = new TypeDefinition("AuthorityControl", "Extra" + type.Name + "Minter", TypeAttributes.NotPublic, + module.ImportReference(census.Type(typeName + "/MintingSource"))); + module.Types.Add(extra); + return () => module.Types.Remove(extra); + }); + } + foreach (var (type, method, target, name) in new[] + { + (Invocation, "get_IsActive", Invocation + "/MintingSource", "MatchesCurrent"), + (Invocation, "get_IsActive", Candidate, "get_IsActive"), + (Invocation, "get_IsActive", Permissions + "TrustedOperationDescriptor", "get_IsActive"), + (Invocation, "get_IsActive", "System.Threading.CancellationToken", "get_IsCancellationRequested"), + (Invocation, "TryBindRead", Invocation, "get_IsActive"), + (Invocation, "IsReadBoundTo", Invocation, "get_IsActive"), + (Invocation, "IsReadBoundTo", Invocation + "/ReadBinding", "get_Unit"), + (Invocation, "IsReadBoundTo", Invocation + "/ReadBinding", "get_Transaction"), + (Invocation, "IsReadBoundTo", Invocation + "/ReadBinding", "get_Generation"), + (Witness, "get_IsActive", Invocation, "get_IsActive"), + (Witness, "get_IsActive", Candidate, "get_IsActive"), + (Decision, "get_IsActive", Invocation, "get_IsActive"), + (Witness, "TryBindTo", Invocation, "TryBindRead"), + (Witness, "IsBoundTo", Invocation, "IsReadBoundTo"), + (Coordinator, "Dispose", "System.Threading.Interlocked", "Exchange"), + (Coordinator, "IsCurrent", Frame, "get_Closed"), + (Coordinator, "ExecuteAsync", Frame, "Close"), + (Frame, "get_Closed", "System.Threading.Volatile", "Read"), + (Frame, "Close", "System.Threading.Interlocked", "Exchange"), + (Evaluator, "AuthorizeAsync", "LearnStack.Modules.Identity.Application.Contracts.Authorization.IIdentityAuthorizationReader", "ReadAsync"), + (RoleEvaluator, "RecheckAsync", "LearnStack.Modules.Identity.Application.Contracts.Authorization.IIdentityAuthorizationReader", "ReadAsync"), + ("LearnStack.Api.Authentication.OperationAuthorizationHandler", "HandleAsync", Coordinator, "CheckHttpAsync"), + ("LearnStack.Api.Authentication.OperationClassificationMiddleware", "InvokeAsync", Coordinator, "CheckHttpAsync"), + ("LearnStack.Api.Authentication.LiveTenantMembershipReader", "CoversAsync", HttpSource, "ForValidatedScope"), + }) Dirty(census, () => RemoveCall(census.Body(type, method), target, name)); + var bind = census.Body(Invocation, "TryBindRead"); + Dirty(census, () => RemoveInstruction(bind.Body.Instructions.Single(instruction => instruction.OpCode == OpCodes.Stfld + && instruction.Operand is FieldReference field && field.Name == "_readBinding"))); + var closed = census.Body(Frame, "Close").Body.Instructions.Single(instruction => + IsCall(instruction, "System.Threading.Interlocked", "Exchange")); + Dirty(census, () => Replace(closed.Previous, OpCodes.Ldc_I4_0, null)); + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Ldftn }) Dirty(census, () => + { + var type = census.Type(Coordinator); + var method = new MethodDefinition("UnownedReadBinding", MethodAttributes.Private | MethodAttributes.Static, type.Module.TypeSystem.Void); + method.Body.Instructions.Add(Instruction.Create(opcode, type.Module.ImportReference(census.Method(Witness, "TryBindTo")))); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + type.Methods.Add(method); + return () => type.Methods.Remove(method); + }); + } + + private static List Offenders(Census census) + { + var errors = new List(); + foreach (var name in new[] { Invocation, Decision, Witness }) + { + var type = census.Type(name); + if (!type.IsPublic || !type.IsSealed || type.Methods.Count(method => method.IsConstructor) != 1 + || !type.Methods.Single(method => method.IsConstructor).IsPrivate + || type.Properties.Any(property => property.SetMethod is not null) + || type.Fields.Any(field => !field.IsPrivate || field.IsStatic || (!field.IsInitOnly && + !(name == Invocation && field.Name == "_readBinding")))) + errors.Add("Private immutable operation authority shape changed: " + name); + } + Derivers(Invocation + "/MintingSource", [Coordinator]); + Derivers(Decision + "/MintingSource", [Evaluator, RoleEvaluator]); + Derivers(Witness + "/MintingSource", [Decision + "/MintingSource"]); + Derivers(Candidate + "/MintingSource", [HttpSource]); + Implementations(Permissions + "IProtectedOperationAuthorizer", [Evaluator]); + Implementations(Permissions + "IAuthorizationCandidateSource", [HttpSource]); + Implementations("Microsoft.AspNetCore.Authorization.IAuthorizationHandler", ["LearnStack.Api.Authentication.OperationAuthorizationHandler"]); + Exact(Invocation, ".ctor", [(Invocation + "/MintingSource", "ForInvocation", 1)]); + Exact(Decision, ".ctor", [(Decision + "/MintingSource", "ForDecision", 1)]); + Exact(Witness, ".ctor", [(Witness + "/MintingSource", "ForTenantRead", 1)]); + Exact(Invocation + "/MintingSource", "ForInvocation", [(Coordinator, "ExecuteAsync", 1)]); + Exact(Decision + "/MintingSource", "ForDecision", [(Evaluator, "AuthorizeAsync", 12), (RoleEvaluator, "RecheckAsync", 2)]); + Exact(Witness + "/MintingSource", "ForTenantRead", [(Decision + "/MintingSource", "ForDecision", 1)]); + Exact(Permissions + "IProtectedOperationAuthorizer", "AuthorizeAsync", [(Coordinator, "ExecuteAsync", 1)]); + Exact(Permissions + "IAuthorizationCandidateSource", "ForValidatedScope", [(Coordinator, "ExecuteAsync", 1)]); + Exact(Coordinator, "ExecuteRequestAsync", [(AuthorizationBehavior, "Handle", 1)]); + Exact(HttpSource, "ForValidatedScope", [(HttpSource, Permissions + "IAuthorizationCandidateSource.ForValidatedScope", 1), + ("LearnStack.Api.Authentication.ConfirmedClaimAssertionSource", "Capture", 1), + ("LearnStack.Api.Authentication.LiveTenantMembershipReader", "CoversAsync", 1)]); + Exact(Coordinator, "CheckHttpAsync", [("LearnStack.Api.Authentication.OperationAuthorizationHandler", "HandleAsync", 1), + ("LearnStack.Api.Authentication.OperationClassificationMiddleware", "InvokeAsync", 1)]); + Exact(Witness, "TryBindTo", [(Unit, "SetTenantContextCoreAsync", 2)]); + Exact(Witness, "IsBoundTo", [(Unit, "SetTenantContextCoreAsync", 1), (Unit, "ValidateOperationReadScope", 1)]); + Exact(Invocation, "TryBindRead", [(Witness, "TryBindTo", 1)]); + Exact(Invocation, "IsReadBoundTo", [(Witness, "IsBoundTo", 1)]); + Exact(Frame, "Close", [(Coordinator, "ExecuteAsync", 1)]); + Require(Invocation, "get_IsActive", Invocation + "/MintingSource", "MatchesCurrent"); + Require(Invocation, "get_IsActive", Candidate, "get_IsActive"); + Require(Invocation, "get_IsActive", Permissions + "TrustedOperationDescriptor", "get_IsActive"); + Require(Invocation, "get_IsActive", "System.Threading.CancellationToken", "get_IsCancellationRequested"); + Require(Invocation, "TryBindRead", Invocation, "get_IsActive"); + Require(Invocation, "IsReadBoundTo", Invocation, "get_IsActive"); + Require(Witness, "get_IsActive", Invocation, "get_IsActive"); + Require(Witness, "get_IsActive", Candidate, "get_IsActive"); + Require(Decision, "get_IsActive", Invocation, "get_IsActive"); + Require(Witness, "TryBindTo", Invocation, "TryBindRead"); + Require(Witness, "IsBoundTo", Invocation, "IsReadBoundTo"); + Require(Coordinator, "Dispose", "System.Threading.Interlocked", "Exchange"); + Require(Coordinator, "IsCurrent", Frame, "get_Closed"); + Require(Coordinator, "ExecuteAsync", Frame, "Close"); + Require(Frame, "get_Closed", "System.Threading.Volatile", "Read"); + Require(Frame, "Close", "System.Threading.Interlocked", "Exchange"); + Require(Evaluator, "AuthorizeAsync", "LearnStack.Modules.Identity.Application.Contracts.Authorization.IIdentityAuthorizationReader", "ReadAsync"); + Require(RoleEvaluator, "RecheckAsync", "LearnStack.Modules.Identity.Application.Contracts.Authorization.IIdentityAuthorizationReader", "ReadAsync"); + foreach (var property in new[] { "get_Unit", "get_Transaction", "get_Generation" }) + Require(Invocation, "IsReadBoundTo", Invocation + "/ReadBinding", property); + + var binding = census.Body(Invocation, "TryBindRead"); + var stores = census.Methods.SelectMany(method => method.Body.Instructions.Select(instruction => (Method: method, Instruction: instruction))) + .Where(item => item.Instruction.Operand is FieldReference field && field.DeclaringType.FullName == Invocation && field.Name == "_readBinding") + .ToArray(); + if (stores.Count(item => item.Instruction.OpCode == OpCodes.Stfld && item.Method == binding) != 1 + || stores.Any(item => item.Instruction.OpCode == OpCodes.Stfld && item.Method != binding) + || !binding.Body.Instructions.Any(instruction => instruction.OpCode == OpCodes.Ldfld + && instruction.Operand is FieldReference field && field.Name == "_readBinding") + || !Calls(binding).Any(call => call.DeclaringType.FullName == "System.Threading.Monitor" && call.Name == "Enter") + || !Calls(binding).Any(call => call.DeclaringType.FullName == "System.Threading.Monitor" && call.Name == "Exit")) + errors.Add("One-time read consumption is checked and stored only under its private invocation monitor"); + var frame = census.Type(Frame); + var closedField = frame.Fields.SingleOrDefault(field => field.Name == "_closed"); + var close = census.Body(Frame, "Close"); + var read = census.Body(Frame, "get_Closed"); + var closeCalls = close.Body.Instructions.Where(instruction => IsCall(instruction, "System.Threading.Interlocked", "Exchange")).ToArray(); + var readCalls = read.Body.Instructions.Where(instruction => IsCall(instruction, "System.Threading.Volatile", "Read")).ToArray(); + var cleanup = census.Body(Coordinator, "ExecuteAsync"); + var cleanupCalls = cleanup.Body.Instructions.Where(instruction => IsCall(instruction, Frame, "Close")).ToArray(); + if (closedField is not { IsPrivate: true, IsStatic: false, FieldType.FullName: "System.Int32" } + || frame.Properties.Single(property => property.Name == "Closed").SetMethod is not null + || closeCalls.Length != 1 || closeCalls[0].Previous.OpCode != OpCodes.Ldc_I4_1 + || !AddressesClosed(closeCalls[0].Previous.Previous) + || readCalls.Length != 1 || !AddressesClosed(readCalls[0].Previous) + || cleanupCalls.Length != 1 || !cleanup.Body.ExceptionHandlers.Any(handler => handler.HandlerType == ExceptionHandlerType.Finally + && cleanup.Body.Instructions.IndexOf(handler.HandlerStart) <= cleanup.Body.Instructions.IndexOf(cleanupCalls[0]) + && (handler.HandlerEnd is null || cleanup.Body.Instructions.IndexOf(cleanupCalls[0]) < cleanup.Body.Instructions.IndexOf(handler.HandlerEnd))) + || census.Methods.SelectMany(method => method.Body.Instructions.Select(instruction => (Method: method, Instruction: instruction))) + .Any(item => item.Instruction.Operand is FieldReference field && field.DeclaringType.FullName == Frame && field.Name == "_closed" + && !(item.Instruction.OpCode == OpCodes.Ldflda && (item.Method == close || item.Method == read)))) + errors.Add("Actual invocation cleanup publishes irreversible frame closure through its private atomic state"); + var boundTargets = new[] { (Invocation, ".ctor"), (Decision, ".ctor"), (Witness, ".ctor"), + (Invocation + "/MintingSource", "ForInvocation"), (Decision + "/MintingSource", "ForDecision"), + (Witness + "/MintingSource", "ForTenantRead"), (Witness, "TryBindTo"), (Witness, "IsBoundTo"), + (Invocation, "TryBindRead"), (Invocation, "IsReadBoundTo"), + (Permissions + "IProtectedOperationAuthorizer", "AuthorizeAsync"), + (Permissions + "IAuthorizationCandidateSource", "ForValidatedScope"), + (Coordinator, "ExecuteRequestAsync"), (Coordinator, "CheckHttpAsync"), (Frame, "Close") }; + if (census.Methods.SelectMany(method => method.Body.Instructions).Any(instruction => instruction.Operand is MethodReference target + && boundTargets.Contains((target.DeclaringType.FullName, target.Name)) + && instruction.OpCode.Code is not (Code.Call or Code.Callvirt or Code.Newobj))) + errors.Add("A private operation factory or read-binding entry cannot become an executable alias"); + return errors; + + static bool AddressesClosed(Instruction instruction) => instruction.OpCode == OpCodes.Ldflda + && instruction.Operand is FieldReference field && field.DeclaringType.FullName == Frame && field.Name == "_closed"; + + void Derivers(string baseType, string[] expected) + { + if (!Same(census.Types.Where(type => type.BaseType?.FullName == baseType).Select(type => type.FullName), expected)) + errors.Add("The operation minter set changed: " + baseType); + } + void Implementations(string contract, string[] expected) + { + if (!Same(census.Types.Where(type => type.Interfaces.Any(item => item.InterfaceType.FullName == contract)).Select(type => type.FullName), expected)) + errors.Add("The operation authority port implementations changed: " + contract); + } + void Exact(string type, string name, (string Owner, string Method, int Count)[] expected) + { + var actual = census.Methods.SelectMany(method => Calls(method).Where(target => target.DeclaringType.FullName == type && target.Name == name) + .Select(_ => census.Owner(method))); + if (!Same(actual, expected.SelectMany(item => Enumerable.Repeat(item.Owner + "::" + item.Method, item.Count)))) + errors.Add("Exact operation authority callers changed: " + type + "::" + name); + } + void Require(string type, string method, string target, string name) + { + if (Calls(census.Body(type, method)).Count(call => call.DeclaringType.FullName == target && call.Name == name) != 1) + errors.Add("Missing actual operation lifetime/reader gate: " + type + "::" + method + "->" + target + "::" + name); + } + } + + private static void Dirty(Census census, Func mutate) + { + var undo = mutate(); + try { Offenders(census).Should().NotBeEmpty("an actual authority factory/caller/binding mutation must fail"); } + finally { undo(); } + Offenders(census).Should().BeEmpty("the actual operation graph is restored after each control"); + } + private static Action RemoveCall(MethodDefinition method, string type, string name) => RemoveInstruction(method.Body.Instructions.Single(instruction => IsCall(instruction, type, name))); + private static Action RemoveInstruction(Instruction instruction) => Replace(instruction, OpCodes.Nop, null); + private static Action Replace(Instruction instruction, OpCode opcode, object? operand) + { + var original = instruction.OpCode; + var value = instruction.Operand; + instruction.OpCode = opcode; + instruction.Operand = operand; + return () => { instruction.OpCode = original; instruction.Operand = value; }; + } + private static bool IsCall(Instruction instruction, string type, string name) => + instruction.OpCode.Code is Code.Call or Code.Callvirt or Code.Newobj && instruction.Operand is MethodReference method + && method.DeclaringType.FullName == type && method.Name == name; + private static IEnumerable Calls(MethodDefinition method) => method.Body.Instructions + .Where(instruction => instruction.OpCode.Code is Code.Call or Code.Callvirt or Code.Newobj) + .Select(instruction => instruction.Operand).OfType(); + private static bool Same(IEnumerable actual, IEnumerable expected) => + actual.Order(StringComparer.Ordinal).SequenceEqual(expected.Order(StringComparer.Ordinal), StringComparer.Ordinal); +} diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.AuthorizationCredentials.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.AuthorizationCredentials.cs new file mode 100644 index 000000000..8b053f41e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.AuthorizationCredentials.cs @@ -0,0 +1,112 @@ +using LearnStack.Infrastructure.Persistence; +using Microsoft.Extensions.DependencyInjection; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class OutboxDispatchConventionTests +{ + private const string AuthorizationConfigurationRoot = "AddLearnStackAuthorizationSourceConfiguration"; + private const string AuthorizationConfiguration = "LearnStack.Infrastructure.Persistence.AuthorizationSourceConfiguration"; + private const string AuthorizationLiveRoot = "LearnStack.Api.Authentication.LiveAuthorizationRegistration"; + + private static MethodDefinition? CheckAuthorizationCredentialRegistration(SortedSet offenders, + ProductionCensus census, IEnumerable services) + { + var declarations = census.Types[(BoundaryApi, BoundaryPersistence)].Methods + .Where(method => method.Name == AuthorizationConfigurationRoot).ToArray(); + if (declarations.Length != 1) { offenders.Add("Authorization configuration requires its sole canonical registration method"); return null; } + var root = declarations[0]; + if (!root.IsStatic || !root.IsAssembly || root.HasGenericParameters + || root.ReturnType.FullName != "Microsoft.Extensions.DependencyInjection.IServiceCollection" + || !ParametersMatch(root, ["Microsoft.Extensions.DependencyInjection.IServiceCollection", + "Microsoft.Extensions.Configuration.IConfiguration", "System.Boolean"])) + offenders.Add(MethodId(root) + ": authorization configuration requires its exact internal static registration signature"); + var caller = census.Types[(BoundaryApi, AuthorizationLiveRoot)].Methods.Single(method => method.Name == "AddLearnStackLiveAuthorization"); + CheckSet(offenders, "authorization configuration callers", CallSites(census) + .Where(site => site.Target.DeclaringType.FullName == BoundaryPersistence && site.Target.Name == AuthorizationConfigurationRoot) + .Select(site => site.Owner), [MethodId(caller)]); + var descriptors = services.Where(descriptor => !descriptor.IsKeyedService + && descriptor.ServiceType == typeof(AuthorizationSourceConfiguration)).ToArray(); + if (descriptors.Length != 1 || descriptors[0].Lifetime != ServiceLifetime.Singleton + || descriptors[0].ImplementationFactory is not { } implementation) + { offenders.Add("Authorization configuration requires its actual sole singleton factory"); return root; } + var factory = FindRuntimeMethod(census, implementation.Method); + if (factory is null) { offenders.Add("Authorization configuration factory is missing"); return root; } + CheckSet(offenders, "authorization configuration delegates", DelegateTargets(root).Select(MethodId), [MethodId(factory)]); + var reads = root.Body.Instructions.Where(instruction => instruction.Operand is MethodReference call && IsConnectionStringRead(call)).ToArray(); + if (!reads.Select(instruction => instruction.Previous?.Operand as string) + .SequenceEqual(["Default", "IdentityAuthorization", "AuthorizationEvidence"]) + || reads.Any(instruction => instruction.Previous?.Previous?.OpCode != OpCodes.Ldarg_1)) + offenders.Add(MethodId(root) + ": authorization reads require exactly their three ordered literal keys"); + var captures = reads.Select(instruction => instruction.Next is { OpCode: var code, Operand: FieldReference field } + && code == OpCodes.Stfld && field.FieldType.FullName == "System.String" ? field : null).ToArray(); + var resolve = factory.Body.Instructions.Where(instruction => instruction.Operand is MethodReference call + && call.DeclaringType.FullName == AuthorizationConfiguration && call.Name == "Resolve").ToArray(); + var loads = factory.Body.Instructions.Where(instruction => instruction.OpCode == OpCodes.Ldfld) + .Select(instruction => instruction.Operand).OfType().ToArray(); + if (captures.Length != 3 || captures.Any(field => field is null) || resolve.Length != 1 + || loads.Length != 4 || !loads.Take(3).Select(field => field.FullName) + .SequenceEqual(captures.Select(field => field?.FullName)) + || loads[^1].FieldType.FullName != "System.Boolean" || MethodOperands(factory).Count() != 1) + { offenders.Add(MethodId(factory) + ": configuration must resolve from its unchanged ordered credential and mode captures"); return root; } + var argument = resolve[0].Previous; + foreach (var field in loads.Reverse()) + { + if (argument?.OpCode != OpCodes.Ldfld || argument.Operand is not FieldReference supplied + || supplied.FullName != field.FullName || argument.Previous?.OpCode != OpCodes.Ldarg_0) + { offenders.Add(MethodId(factory) + ": Resolve must consume only the four actual captured arguments"); break; } + argument = argument.Previous.Previous; + } + foreach (var field in loads) + { + var uses = census.Types.Values.SelectMany(type => type.Methods).Where(method => method.HasBody) + .SelectMany(method => method.Body.Instructions.Where(instruction => instruction.Operand is FieldReference referenced + && referenced.FullName == field.FullName).Select(instruction => (Method: method, Instruction: instruction))).ToArray(); + var writes = uses.Where(use => use.Instruction.OpCode != OpCodes.Ldfld).ToArray(); + if (writes.Length != 1 || writes[0].Method != root || writes[0].Instruction.OpCode != OpCodes.Stfld + || uses.Count(use => use.Method == factory && use.Instruction.OpCode == OpCodes.Ldfld) != 1 + || uses.Length != 2) + offenders.Add(field.FullName + ": authorization capture must have only its original write and actual factory read"); + else if (field.FieldType.FullName == "System.Boolean" && writes[0].Instruction.Previous?.OpCode != OpCodes.Ldarg_2) + offenders.Add(field.FullName + ": derivation permission must come unchanged from the composition argument"); + } + return root; + } + + private static void AssertAuthorizationCredentialMutations(ProductionCensus census) + { + var root = census.Types[(BoundaryApi, BoundaryPersistence)].Methods.Single(method => method.Name == AuthorizationConfigurationRoot); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + AssertMutation(census, "an extra authorization configuration locator", () => + AddCallProbe(application, "ExtraAuthorizationConfiguration", root, OpCodes.Call), CredentialOffenders); + var reads = root.Body.Instructions.Where(instruction => instruction.Operand is MethodReference call && IsConnectionStringRead(call)).ToArray(); + foreach (var read in reads) + foreach (var key in new[] { "PlatformAdmin", "OutboxDispatcher", "IdentityAuthorization" }) + { + if (read.Previous!.Operand as string == key) continue; + AssertMutation(census, "a different authorization source credential key", () => + ReplaceInstruction(read.Previous!, OpCodes.Ldstr, key), CredentialOffenders); + } + var factory = FindRuntimeMethod(census, BoundaryRegistrations().Single(descriptor => !descriptor.IsKeyedService + && descriptor.ServiceType == typeof(AuthorizationSourceConfiguration)).ImplementationFactory!.Method)!; + foreach (var field in factory.Body.Instructions.Where(instruction => instruction.OpCode == OpCodes.Ldfld) + .Select(instruction => instruction.Operand).OfType()) + AssertMutation(census, "an extra forwarding helper for a captured authorization setting", () => + AddBoundaryFieldGetter(factory.DeclaringType, "ForwardAuthorizationSetting", field.Name), CredentialOffenders); + var mode = root.Body.Instructions.Single(instruction => instruction.OpCode == OpCodes.Stfld + && instruction.Operand is FieldReference field && field.FieldType.FullName == "System.Boolean"); + AssertMutation(census, "a helper that enables derivation independently of its caller", () => + ReplaceInstruction(mode.Previous!, OpCodes.Ldc_I4_1, null!), CredentialOffenders); + var resolve = factory.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == AuthorizationConfiguration && target.Name == "Resolve"); + AssertMutation(census, "a factory substituting its actual Resolve argument", () => + ReplaceInstruction(resolve.Previous!, OpCodes.Ldc_I4_1, null!), CredentialOffenders); + var caller = census.Types[(BoundaryApi, AuthorizationLiveRoot)].Methods.Single(method => method.Name == "AddLearnStackLiveAuthorization"); + var call = caller.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == BoundaryPersistence && target.Name == AuthorizationConfigurationRoot); + AssertMutation(census, "missing canonical authorization configuration registration", () => + ReplaceInstruction(call, OpCodes.Nop, null!), CredentialOffenders); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs index 4e668c383..4c32059f8 100644 --- a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs @@ -178,6 +178,7 @@ public sealed class CredentialBindingMutationTests public void Credential_census_rejects_crossed_keys_changed_closures_and_missing_subjects() { using var census = ProductionCensus.Read(); + AssertAuthorizationCredentialMutations(census); foreach (var subject in new[] { (BoundaryApi, BoundaryWorker), (InfrastructureAssembly, BoundaryPlatform) }) { var parameter = census.Types[subject].Methods.Single(method => method.IsConstructor && !method.IsStatic) @@ -459,7 +460,9 @@ private static ServiceCollection BoundaryRegistrations() { var services = new ServiceCollection(); // Real composition, no provider build, pool construction or connection. - services.AddLearnStackPersistence(new ConfigurationBuilder().Build()); + var configuration = new ConfigurationBuilder().Build(); + services.AddLearnStackPersistence(configuration); + services.AddLearnStackAuthorizationSourceConfiguration(configuration, allowDevelopmentDerivation: false); return services; } @@ -495,6 +498,7 @@ private static SortedSet CredentialOffenders(ProductionCensus census) var outboxRoot = types[(BoundaryApi, BoundaryComposition)].Methods.Single(method => method.Name == "AddLearnStackOutboxDispatcher"); var platformRoot = types[(BoundaryApi, BoundaryPersistence)].Methods.Single(method => method.Name == "AddLearnStackPersistence"); + var authorizationRoot = CheckAuthorizationCredentialRegistration(offenders, census, services); var runtimeRoot = types[(InfrastructureAssembly, JobRuntimeRegistration)].Methods.Single(method => method.Name == "AddLearnStackBackgroundJobRuntime"); var roots = new[] { (Root: outboxRoot, Key: OutboxDispatcherDataSource.Key), (Root: platformRoot, Key: PlatformAdminScope.PlatformDataSourceKey) }; @@ -590,8 +594,10 @@ private static SortedSet CredentialOffenders(ProductionCensus census) && site.Target.Name == "Build").Select(site => site.Owner), outboxBuildFactory is null ? [] : [MethodId(outboxBuildFactory)]); CheckSet(offenders, "outbox configuration validation", sites.Where(site => site.Target.DeclaringType.FullName == BoundarySource && site.Target.Name == "Validate").Select(site => site.Owner), [MethodId(outboxRoot)]); + var credentialReaders = new List { MethodId(outboxRoot), MethodId(platformRoot), MethodId(platformRoot) }; + if (authorizationRoot is not null) credentialReaders.AddRange(Enumerable.Repeat(MethodId(authorizationRoot), 3)); CheckSet(offenders, "connection-string reader census", sites.Where(site => IsConnectionStringRead(site.Target)) - .Select(site => site.Owner), [MethodId(outboxRoot), MethodId(platformRoot), MethodId(platformRoot)]); + .Select(site => site.Owner), credentialReaders); var injections = new List(); foreach (var method in types.Values.SelectMany(type => type.Methods)) @@ -603,7 +609,7 @@ private static SortedSet CredentialOffenders(ProductionCensus census) + BoundaryAttributeValue(attribute.ConstructorArguments.FirstOrDefault())); if (!method.HasBody) continue; // GetConnectionString is the sole admitted credential-read member at - // the two roots above. Its indexer equivalent can compose the name, + // the enumerated roots above. Its indexer equivalent can compose the name, // leaving no complete dispatcher literal in IL. Conservatively refuse // a connection-string prefix alongside an explicit configuration indexer; // this does not classify arbitrary key dataflow or unrelated indexers. @@ -617,7 +623,8 @@ private static SortedSet CredentialOffenders(ProductionCensus census) { var key = instruction.Previous?.Operand as string; var expected = method == outboxRoot ? new[] { "OutboxDispatcher" } - : method == platformRoot ? ["Default", "PlatformAdmin"] : []; + : method == platformRoot ? ["Default", "PlatformAdmin"] + : method == authorizationRoot ? ["Default", "IdentityAuthorization", "AuthorizationEvidence"] : []; if (key is null || !expected.Contains(key, StringComparer.Ordinal)) offenders.Add(MethodId(method) + ": unauthorized connection-string read"); } @@ -741,10 +748,9 @@ private static void CheckSourceFieldAccess(SortedSet offenders, Producti { offenders.Add(TypeId(type) + ": requires its private readonly lazy source"); return; } // Close forwarding of an already injected capability as well as fresh keyed // resolution. Exact access counts also catch another use in an allowed method. - var actual = OwnedBodies(census).SelectMany(item => item.Body.Body.Instructions - .Where(instruction => instruction.Operand is FieldReference field && SameType(field.DeclaringType, type) - && field.Name == fields[0].Name) - .Select(instruction => MethodId(item.Owner) + ":" + instruction.OpCode.Name)); + var actual = census.FieldInstructions(fields[0].Name) + .Where(item => SameType(item.Target.DeclaringType, type)) + .Select(item => item.OwnerId + ":" + item.Instruction.OpCode.Name); var expected = new[] { constructor + ":stfld" }.Concat(readers.Select(reader => reader + ":ldfld")); CheckSet(offenders, TypeId(type) + " privileged source field access", actual, expected); } diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs index 0cc5473b1..7d5f25dc9 100644 --- a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs @@ -413,6 +413,7 @@ private static SortedSet ResolutionOffenders(ProductionCensus census) var pending = new Queue(ResolutionRoots.Select(name => types[(InfrastructureAssembly, name)])); var visited = new HashSet<(string Assembly, string Type)>(); + var expanded = new HashSet<(string Assembly, string Type)>(); while (pending.TryDequeue(out var type)) { if (!visited.Add(Key(type))) continue; @@ -426,7 +427,7 @@ private static SortedSet ResolutionOffenders(ProductionCensus census) { if (!types.TryGetValue(Key(reference), out var helper)) continue; pending.Enqueue(helper); - if (helper.IsSealed) continue; + if (helper.IsSealed || !expanded.Add(Key(helper))) continue; foreach (var implementation in types.Values.Where(candidate => !candidate.IsInterface && Derives(candidate, helper, types))) pending.Enqueue(implementation); diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.Enrollment.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.Enrollment.cs new file mode 100644 index 000000000..4819163c8 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.Enrollment.cs @@ -0,0 +1,119 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Mono.Cecil; +using Mono.Cecil.Cil; +using EnrollmentCensus = LearnStack.Tests.Architecture.TrustedOperationSourceConventionTests.Census; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class PersistenceConventionTests +{ + private const string ContextualEnrollment = "LearnStack.Modules.Identity.Infrastructure.Persistence.ContextualIdentityStorageControl"; + private const string EfEnrollment = "Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions"; + + private static void AssertExactEfEnrollment() + { + using var census = EnrollmentCensus.Read(); + EfEnrollmentOffenders(census).Should().BeEmpty("only the common factory and exact Identity owner reenrollment paths may bind EF to an existing transaction"); + var sites = census.Methods.SelectMany(method => method.Body.Instructions.Where(IsEfEnrollment) + .Select(instruction => (Method: method, Instruction: instruction))).ToArray(); + var guardedReads = census.Methods.SelectMany(method => method.Body.Instructions.Where(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == ContextualEnrollment && target.Name == "EnrollCurrentTenantOwner") + .Select(instruction => (Method: method, Instruction: instruction))).ToArray(); + foreach (var site in sites.Concat(guardedReads)) + { + var opcode = site.Instruction.OpCode; + var target = site.Instruction.Operand; + site.Instruction.OpCode = OpCodes.Nop; + site.Instruction.Operand = null; + try { EfEnrollmentOffenders(census).Should().NotBeEmpty("each actual enrollment is a required subject"); } + finally { site.Instruction.OpCode = opcode; site.Instruction.Operand = target; } + } + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Ldftn }) + { + var owner = census.Type(ContextualEnrollment); + var extra = new MethodDefinition("UnownedEnrollment", MethodAttributes.Private | MethodAttributes.Static, owner.Module.TypeSystem.Void); + extra.Body.Instructions.Add(Instruction.Create(opcode, (MethodReference)sites[0].Instruction.Operand)); + extra.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + owner.Methods.Add(extra); + try { EfEnrollmentOffenders(census).Should().NotBeEmpty("an extra enrollment or executable alias cannot borrow an approved type"); } + finally { owner.Methods.Remove(extra); } + } + var source = File.ReadAllText(Path.Combine(SourceScan.SourceRoot, + "Modules/Identity/LearnStack.Modules.Identity.Infrastructure/Persistence/ContextualIdentityStores.cs")); + ContextualEnrollmentIsGuarded(source).Should().BeTrue(); + foreach (var (before, after) in new[] + { + ("!owner.HasActiveTransaction", "false"), ("owner.IsRollbackOnly", "false"), + ("owner is IGlobalIdentityUnitOfWork { IsGlobalIdentityTransaction: true }", "false"), + ("!owner.IsTenantContextIssuedOn(owner.Transaction)", "false"), ("Generation: > 0", "Generation: >= 0"), + ("announcement.TenantId != context.CurrentTenantId", "false"), + ("announcement.OrganizationId != context.CurrentOrganizationId", "false"), + ("!ReferenceEquals(context.Database.GetDbConnection(), owner.Connection)", "false"), + ("context.ChangeTracker.Entries().Any(entry => entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)", "false"), + ("!ReferenceEquals(current.GetDbTransaction(), owner.Transaction)", "false"), + }) + { + var dirty = source.Replace(before, after, StringComparison.Ordinal); + dirty.Should().NotBe(source); + ContextualEnrollmentIsGuarded(dirty).Should().BeFalse("the same connection, fresh announcement, scope, mode and clean graph are required"); + } + EfEnrollmentOffenders(census).Should().BeEmpty("all actual compiled enrollment subjects are restored"); + } + + private static List EfEnrollmentOffenders(EnrollmentCensus census) + { + var sites = census.Methods.SelectMany(method => method.Body.Instructions.Where(IsEfEnrollment) + .Select(instruction => (Method: method, Instruction: instruction))).ToArray(); + var actual = sites.Select(site => site.Method.DeclaringType.DeclaringType?.FullName == "LearnStack.Infrastructure.Persistence.ModuleDbContextRegistration" + && site.Method.Name.StartsWith("", StringComparison.Ordinal) + ? "LearnStack.Infrastructure.Persistence.ModuleDbContextRegistration::AddModuleDbContext" : census.Owner(site.Method)).Order(StringComparer.Ordinal); + var expected = new[] { "LearnStack.Infrastructure.Persistence.ModuleDbContextRegistration::AddModuleDbContext", + "LearnStack.Modules.Identity.Infrastructure.Persistence.UserBindingStore::EnrollCurrentOwner", + ContextualEnrollment + "::EnrollCurrentTenantOwner" }.Order(StringComparer.Ordinal); + var errors = new List(); + if (!actual.SequenceEqual(expected, StringComparer.Ordinal) || sites.Any(site => site.Instruction.OpCode != OpCodes.Call + || site.Instruction.Operand is not MethodReference target + || target.FullName != "Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions::UseTransaction(Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade,System.Data.Common.DbTransaction)")) + errors.Add("EF enrollment callers/signatures must remain the exact common factory, global binding owner and guarded contextual reenrollment"); + var reenrollments = census.Methods.SelectMany(method => method.Body.Instructions.Where(instruction => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == ContextualEnrollment && target.Name == "EnrollCurrentTenantOwner") + .Select(instruction => (Method: method, Instruction: instruction))).ToArray(); + if (!reenrollments.Select(site => census.Owner(site.Method)).Order(StringComparer.Ordinal).SequenceEqual(new[] + { ContextualEnrollment + "::PrepareFreshRead", "LearnStack.Modules.Identity.Infrastructure.Persistence.MembershipDirectoryReader::Admit" }.Order(StringComparer.Ordinal), StringComparer.Ordinal) + || reenrollments.Any(site => site.Instruction.OpCode != OpCodes.Call)) + errors.Add("Only fresh contextual graph reads and the guarded directory may reenroll the same tenant owner"); + return errors; + } + + private static bool IsEfEnrollment(Instruction instruction) => instruction.Operand is MethodReference target + && target.DeclaringType.FullName == EfEnrollment && target.Name is "UseTransaction" or "UseTransactionAsync"; + + private static bool ContextualEnrollmentIsGuarded(string source) + { + var root = CSharpSyntaxTree.ParseText(source).GetRoot(); + var methods = root.DescendantNodes().OfType().Where(type => type.Identifier.ValueText == "ContextualIdentityStorageControl") + .SelectMany(type => type.Members.OfType()).Where(method => method.Identifier.ValueText == "EnrollCurrentTenantOwner").ToArray(); + if (root.ContainsDiagnostics || methods.Length != 1 || methods[0].Modifiers.Count != 2 + || !methods[0].Modifiers.Any(SyntaxKind.InternalKeyword) || !methods[0].Modifiers.Any(SyntaxKind.StaticKeyword) + || methods[0].ReturnType.ToString() != "void" + || !SyntaxFactory.AreEquivalent(methods[0].ParameterList, SyntaxFactory.ParseParameterList("(IdentityDbContext context, IUnitOfWork owner)")) + || methods[0].Body is not { Statements.Count: 2 } body || body.Statements[0] is not IfStatementSyntax guard + || body.Statements[1] is not IfStatementSyntax current) + return false; + return SyntaxFactory.AreEquivalent(guard.Condition, SyntaxFactory.ParseExpression(""" + !owner.HasActiveTransaction || owner.IsRollbackOnly + || owner is IGlobalIdentityUnitOfWork { IsGlobalIdentityTransaction: true } + || !owner.IsTenantContextIssuedOn(owner.Transaction) + || owner.GetTenantContextAnnouncement(owner.Transaction) is not { Generation: > 0 } announcement + || announcement.TenantId != context.CurrentTenantId || announcement.OrganizationId != context.CurrentOrganizationId + || !ReferenceEquals(context.Database.GetDbConnection(), owner.Connection) + || context.ChangeTracker.Entries().Any(entry => entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + """)) && guard.Statement is ThrowStatementSyntax { Expression: ObjectCreationExpressionSyntax exception } + && exception.Type.ToString() == "TenantContextMissingException" + && SyntaxFactory.AreEquivalent(current.Condition, SyntaxFactory.ParseExpression("context.Database.CurrentTransaction is not { } current || !ReferenceEquals(current.GetDbTransaction(), owner.Transaction)")) + && SyntaxFactory.AreEquivalent(current.Statement, SyntaxFactory.ParseStatement("context.Database.UseTransaction(owner.Transaction);")); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.ReadScope.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.ReadScope.cs new file mode 100644 index 000000000..a6f10e745 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.ReadScope.cs @@ -0,0 +1,96 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.SharedKernel.Permissions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class PersistenceConventionTests +{ + private const string ReadScopeContextFile = "LearnStack.Infrastructure/Persistence/TenantQueryFilters.cs"; + private const string ReadScopeRegistrationFile = "LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs"; + + private static void AssertOperationReadScopeAttachment() + { + var view = typeof(IOperationReadScopeView).GetProperties().Should().ContainSingle().Which; + view.Name.Should().Be(nameof(IOperationReadScopeView.IsTenantWideRead)); + view.PropertyType.Should().Be(); + view.SetMethod.Should().BeNull(); + var parameter = typeof(TenantScopedDbContext).GetProperty(nameof(TenantScopedDbContext.CurrentTenantWideRead))!; + parameter.PropertyType.Should().Be(); + parameter.SetMethod.Should().BeNull(); + (parameter.GetMethod!.IsVirtual && !parameter.GetMethod.IsFinal).Should().BeFalse( + "a module cannot override the live-owner filter carrier, including a final interface implementation"); + SourceScan.FilesContaining(SourceScan.SourceRoot, "AttachOperationReadScope", except: null) + .Should().BeEquivalentTo([ReadScopeContextFile, ReadScopeRegistrationFile], + "only the base declaration and common registration may name the attachment seam"); + var context = SourceText.WithoutComments(File.ReadAllText(Path.Combine(SourceScan.SourceRoot, ReadScopeContextFile))); + var registration = SourceText.WithoutComments(File.ReadAllText(Path.Combine(SourceScan.SourceRoot, ReadScopeRegistrationFile))); + OperationReadAttachmentIsClosed(context, registration).Should().BeTrue(); + foreach (var dirty in new[] + { + context.Replace("_operationReadScope?.IsTenantWideRead == true", "true", StringComparison.Ordinal), + context.Replace("internal void AttachOperationReadScope", "public void AttachOperationReadScope", StringComparison.Ordinal), + context.Replace("if (_operationReadScope is not null)", "if (false)", StringComparison.Ordinal), + }) + { + dirty.Should().NotBe(context); + OperationReadAttachmentIsClosed(dirty, registration).Should().BeFalse("the attachment is internal, one-shot and read from its actual owner"); + } + foreach (var dirty in new[] + { + registration.Replace("scoped.AttachOperationReadScope(readScope);", "", StringComparison.Ordinal), + registration.Replace("unitOfWork is IOperationReadScopeView readScope", + "provider.GetRequiredService() is IOperationReadScopeView readScope", StringComparison.Ordinal), + registration + "\ninternal class ForeignReadAttachment { void Bind(TenantScopedDbContext context, IOperationReadScopeView scope) { context.AttachOperationReadScope(scope); } }", + }) + { + dirty.Should().NotBe(registration); + OperationReadAttachmentIsClosed(context, dirty).Should().BeFalse("the existing factory attaches only its own unit's live view"); + } + } + + private static bool OperationReadAttachmentIsClosed(string context, string registration) + { + var syntax = CSharpSyntaxTree.ParseText(context).GetRoot(); + var contexts = syntax.DescendantNodes().OfType() + .Where(type => type.Identifier.ValueText == nameof(TenantScopedDbContext)).ToArray(); + if (syntax.ContainsDiagnostics || contexts.Length != 1) return false; + var methods = contexts[0].Members.OfType() + .Where(method => method.Identifier.ValueText == "AttachOperationReadScope").ToArray(); + var parameters = contexts[0].Members.OfType() + .Where(property => property.Identifier.ValueText == nameof(TenantScopedDbContext.CurrentTenantWideRead)).ToArray(); + if (methods.Length != 1 || methods[0].Modifiers.Count != 1 || !methods[0].Modifiers.Any(SyntaxKind.InternalKeyword) + || !SyntaxFactory.AreEquivalent(methods[0].ParameterList, SyntaxFactory.ParseParameterList("(IOperationReadScopeView scope)")) + || !SyntaxFactory.AreEquivalent(methods[0].Body, SyntaxFactory.ParseStatement(""" + { ArgumentNullException.ThrowIfNull(scope); + if (_operationReadScope is not null) + throw new InvalidOperationException("The context's operation read owner is already attached."); + _operationReadScope = scope; } + """)) + || parameters.Length != 1 || parameters[0].AccessorList is not null || parameters[0].ExpressionBody is not { } expression + || !SyntaxFactory.AreEquivalent(expression.Expression, SyntaxFactory.ParseExpression("_operationReadScope?.IsTenantWideRead == true"))) + return false; + var fields = contexts[0].Members.OfType().Where(field => + field.Declaration.Variables.Any(variable => variable.Identifier.ValueText == "_operationReadScope")).ToArray(); + if (fields.Length != 1 || fields[0].Modifiers.Count != 1 || !fields[0].Modifiers.Any(SyntaxKind.PrivateKeyword) + || fields[0].Declaration.Type.ToString() != "IOperationReadScopeView?" + || contexts[0].DescendantNodes().OfType().Count(name => name.Identifier.ValueText == "_operationReadScope") != 3) + return false; + var registry = CSharpSyntaxTree.ParseText(registration).GetRoot(); + var attachments = registry.DescendantNodes().OfType().Where(call => + call.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "AttachOperationReadScope" }).ToArray(); + if (registry.ContainsDiagnostics || attachments.Length != 1 + || attachments[0].Ancestors().OfType().FirstOrDefault()?.Identifier.ValueText != "AddModuleDbContext" + || attachments[0].Parent?.Parent is not IfStatementSyntax guard + || !SyntaxFactory.AreEquivalent(guard, SyntaxFactory.ParseStatement(""" + if (context is TenantScopedDbContext scoped && unitOfWork is IOperationReadScopeView readScope) + scoped.AttachOperationReadScope(readScope); + """))) return false; + var enrollments = guard.Parent!.DescendantNodes().OfType().Where(call => + call.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "UseTransaction" }).ToArray(); + return enrollments.Length == 1 && guard.Span.End < enrollments[0].SpanStart; + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 91c0794a8..b718e1d79 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -175,7 +175,7 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() // SET LOCAL, so every read through it returns zero rows under the // corrected policy — silently. // - // Eleven files under backend/src may reach for a connection at all: the six + // The exact files below may reach for a connection: the six // design-time factories, where a connection string is the point — one per // migration chain, and a module that ships a schema ships one; the shared // module helper, which passes a connection rather than a string; the API's @@ -223,8 +223,16 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() // Both runtime hosts delegate application-role construction to this // shared guard; the API retains its separate platform-role builder. "Persistence/ApplicationDataSource.cs", + // ADR-0076's private lane awaits the shared role guard with its total deadline before BEGIN. + // IdentityAuthorizationConventionTests checks the actual compiled guard/order and forbids raw aliases. + "Persistence/IdentityAuthorizationReadSource.cs", + // ADR-0077's two evidence owners have their own guarded pool and retain actual cleanup. + // IdentityAuditConventionTests checks the compiled source/session/store ownership boundary. + "Persistence/AuthorizationEvidenceSource.cs", "Messaging/OutboxDispatcherDataSource.cs", ]); + AssertOperationReadScopeAttachment(); + AssertExactEfEnrollment(); } /// @@ -311,7 +319,7 @@ public void Modules_Do_Not_Parallelize_Over_The_Ambient_Connection() "LearnStack.Application/Messaging/DomainEventCollector.cs", "LearnStack.Infrastructure.Audit/AuditStateCapture.cs", ], "the census includes shared scoped execution state, not only module callers"); - ParallelSites.Keys.Concat(RetainedLoaderSites.Keys).Concat(RecurrenceParallelSites.Keys).Should().BeSubsetOf(relativeFiles, + ParallelSites.Keys.Concat(RetainedLoaderSites.Keys).Concat(RecurrenceParallelSites.Keys).Concat(AuthorizationCleanupSites.Keys).Should().BeSubsetOf(relativeFiles, "an exception must name a real scanned source file"); var offenders = ParallelOffenders(sourceRoot, files); @@ -390,6 +398,27 @@ public void The_parallelism_census_reaches_shared_code_and_bounds_its_singleton_ ParallelOffenders(sourceRoot, [probe]).Should().Equal([relative], "a missing retained-loader subject cannot make the exception vacuous"); } + foreach (var relative in AuthorizationCleanupSites.Keys) + { + var cleanupProbe = Path.Combine(sourceRoot, relative); + Directory.CreateDirectory(Path.GetDirectoryName(cleanupProbe)!); + var authorization = File.ReadAllText(Path.Combine(RepositoryPaths.BackendSrc(), relative)); + File.WriteAllText(cleanupProbe, authorization); + ParallelOffenders(sourceRoot, [cleanupProbe]).Should().BeEmpty("the exact independent-owner cleanup join never runs business SQL concurrently"); + foreach (var dirty in new[] + { + authorization.Replace("owner.ActualCompletion", "Task.CompletedTask", StringComparison.Ordinal), + authorization.Replace("await Task.WhenAll(owners.Select(owner => owner.ActualCompletion)).ConfigureAwait(false);", "", StringComparison.Ordinal), + authorization.Replace("return owner;", "var borrowed = BorrowAsync(); return owner;", StringComparison.Ordinal), + authorization + "\ninternal sealed class ExtraCleanupProbe { private async Task RunAsync() { await Task.WhenAll(first, second); } }", + }) + { + dirty.Should().NotBe(authorization, "each control changes the actual approved cleanup source"); + File.WriteAllText(cleanupProbe, dirty); + ParallelOffenders(sourceRoot, [cleanupProbe]).Should().ContainSingle() + .Which.Should().Be(relative, "only the exact joined actual-cleanup expression is exempt; the rest of the source is scanned"); + } + } } finally { @@ -415,9 +444,39 @@ private static string[] ParallelOffenders(string sourceRoot, IEnumerable ? RetainedLoaderScanText(file.Code, site) is not { } scan || FansOut(scan) : RecurrenceParallelSites.TryGetValue(file.Path, out var recurrence) ? RecurrenceParallelScanText(file.Code, recurrence) is not { } retained || FansOut(retained) + : AuthorizationCleanupSites.TryGetValue(file.Path, out var cleanupSite) + ? AuthorizationCleanupScanText(file.Code, cleanupSite.Type, cleanupSite.Owner) is not { } cleanup || FansOut(cleanup) : FansOut(file.Code)) .Select(file => file.Path).ToArray(); + private static readonly Dictionary AuthorizationCleanupSites = + new(StringComparer.Ordinal) + { + ["LearnStack.Infrastructure/Persistence/IdentityAuthorizationReadSource.cs"] = ("IdentityAuthorizationReadSource", "AuthorizationReadOwner"), + ["LearnStack.Infrastructure/Persistence/AuthorizationEvidenceSource.cs"] = ("AuthorizationEvidenceSource", "AuthorizationEvidenceSession"), + }; + + // ADR-0076/0077 admit only these process-owned joins of independently retained physical owners. + // Removing that exact statement from the scan never exempts any other source method. + private static string? AuthorizationCleanupScanText(string code, string typeName, string ownerName) + { + var syntax = CSharpSyntaxTree.ParseText(code).GetRoot(); + var types = syntax.DescendantNodes().OfType() + .Where(type => TypeIdentity(type) == "LearnStack.Infrastructure.Persistence." + typeName + "`0").ToArray(); + if (syntax.ContainsDiagnostics || types.Length != 1) return null; + var methods = types[0].Members.OfType() + .Where(method => method.Identifier.ValueText == "DisposeCoreAsync").ToArray(); + if (methods.Length != 1 || !methods[0].Modifiers.Any(token => token.IsKind(SyntaxKind.PrivateKeyword)) + || !methods[0].Modifiers.Any(token => token.IsKind(SyntaxKind.AsyncKeyword)) + || !SyntaxFactory.AreEquivalent(methods[0].ParameterList, SyntaxFactory.ParseParameterList("(" + ownerName + "[] owners)")) + || !SyntaxFactory.AreEquivalent(methods[0].Body, SyntaxFactory.ParseStatement(""" + { await Task.WhenAll(owners.Select(owner => owner.ActualCompletion)).ConfigureAwait(false); + await _source.DisposeAsync().ConfigureAwait(false); } + """))) return null; + var join = methods[0].Body!.Statements[0]; + return code.Remove(join.SpanStart, join.Span.Length).Insert(join.SpanStart, "await Task.CompletedTask;"); + } + // These four private starters retain one actual auxiliary operation before a cache or // audit caller can observe its Task. They use an independent connection, never the // ambient unit. Only that validated initializer is omitted from the fan-out scan; diff --git a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs index ebc8284fc..e3c43aace 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs @@ -85,8 +85,8 @@ public void Platform_DataSource_Resolved_Only_By_PlatformAdminScope() File.ReadAllText(Path.Combine(SourceScan.SourceRoot, JobRuntimeRegistrationFile)) .Should().NotContain("PlatformAdmin"); - // 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 + // Leg 2 — IConfiguration.GetConnectionString is confined to the two enumerated + // API composition files. Not "every credential is read here": the 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 diff --git a/backend/tests/LearnStack.Tests.Architecture/PrivilegedDispatchCleanupBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/PrivilegedDispatchCleanupBoundaryTests.cs index e5bbf51b9..35c2ac4dd 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PrivilegedDispatchCleanupBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PrivilegedDispatchCleanupBoundaryTests.cs @@ -37,8 +37,8 @@ private static void CheckCleanupConnectionOwner(SortedSet offenders, Pro var sites = CallSites(census).ToArray(); RecordDetachedTargets(offenders, sites); - CheckSet(offenders, name + " gated acquisition callers", sites.Where(site => TargetsDeclaredType(site.Target, store) - && site.Target.Name == "OpenConnectionAsync").Select(site => site.Owner), callers); + CheckSet(offenders, name + " gated acquisition callers", census.MethodInstructions("OpenConnectionAsync") + .Where(site => TargetsDeclaredType(site.Target, store)).Select(site => site.OwnerId), callers); CheckCallers(store, "ReleaseConnection", [reader, cleanup]); CheckCallers(store, "RetainConnection", [cleanup]); CheckCallers(owner, ".ctor", [reader]); @@ -73,10 +73,9 @@ private static void CheckCleanupConnectionOwner(SortedSet offenders, Pro var fields = store.Fields.Where(field => field.Name == "_cleanupUnconfirmed").ToArray(); if (fields.Length != 1 || !fields[0].IsPrivate || fields[0].IsStatic || fields[0].FieldType.FullName != "System.Boolean") offenders.Add(store.FullName + ": cleanup seal must remain one private instance Boolean"); - var accesses = OwnedBodies(census).SelectMany(item => item.Body.Body.Instructions - .Where(instruction => instruction.Operand is FieldReference field && TargetsDeclaredType(field, store) - && field.Name == "_cleanupUnconfirmed") - .Select(instruction => (Owner: MethodId(item.Owner), Instruction: instruction))).ToArray(); + var accesses = census.FieldInstructions("_cleanupUnconfirmed") + .Where(item => TargetsDeclaredType(item.Target, store)) + .Select(item => (Owner: item.OwnerId, item.Instruction)).ToArray(); CheckSet(offenders, store.FullName + " monotonic cleanup seal access", accesses.Select(item => item.Owner + ":" + item.Instruction.OpCode.Name), [observation + ":ldfld", reader + ":ldfld", retain + ":stfld"]); @@ -97,9 +96,9 @@ private static void CheckCleanupConnectionOwner(SortedSet offenders, Pro } void CheckCallers(TypeDefinition type, string method, IEnumerable expected) => - CheckSet(offenders, name + " " + type.Name + "." + method + " callers", sites - .Where(site => TargetsDeclaredType(site.Target, type) && site.Target.Name == method) - .Select(site => site.Owner), expected); + CheckSet(offenders, name + " " + type.Name + "." + method + " callers", census.MethodInstructions(method) + .Where(site => TargetsDeclaredType(site.Target, type)) + .Select(site => site.OwnerId), expected); } public sealed class CleanupOwnerMutationTests diff --git a/backend/tests/LearnStack.Tests.Architecture/PublicSurfaceTests.cs b/backend/tests/LearnStack.Tests.Architecture/PublicSurfaceTests.cs index c6130240d..de77b1b93 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PublicSurfaceTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PublicSurfaceTests.cs @@ -56,6 +56,7 @@ public void PublicSurface_Endpoints_Dispatch_Through_The_Pipeline() MinimalBypasses(module).Should().BeEmpty("the only direct data-free MapGet admitted here is the existing healthz bootstrap"); EndpointCompositionControls.Plant(module, (prefix, because) => MinimalBypasses(module).Should().Contain( prefix == "Unapproved endpoint mapping:" ? "SurfaceProbe.HiddenEndpointComposition" : "Program", because)); + EndpointCompositionControls.PlantMetadata(module, (owner, because) => MinimalBypasses(module).Should().Contain(owner, because)); MinimalBypasses(module).Should().BeEmpty("restoring production composition restores the clean control"); } @@ -296,7 +297,8 @@ private static IEnumerable MinimalBypasses(ModuleDefinition module) => T { var methods = Il.Methods(type).Where(method => method.Definition.HasBody).ToArray(); var composition = methods.SelectMany(method => method.Definition.Body.Instructions - .Where(instruction => instruction.Operand is MethodReference call && EndpointComposition.IsCapability(call)) + .Where(instruction => instruction.Operand is MethodReference call && EndpointComposition.IsCapability(call) + && !EndpointComposition.IsFixedInfrastructureMetadata(method.Definition, instruction)) .Select(instruction => (method.Definition, Instruction: instruction, Call: (MethodReference)instruction.Operand))) .ToArray(); var calls = composition.Where(site => !(site.Instruction.OpCode == OpCodes.Call diff --git a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.ScopeCarrier.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.ScopeCarrier.cs new file mode 100644 index 000000000..d7f37758e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.ScopeCarrier.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class TenancyConventionTests +{ + private const string ScopeCarrierFile = "LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs"; + private const string ScopeCarrierSql = "SELECT set_config('app.tenant_id', @tenant, true), " + + "set_config('app.organization_id', @organization, true), set_config('app.scope', @scope, true), " + + "set_config('app.identity_authority_id', '', true), set_config('app.identity_subject_key', '', true)"; + + private static string WithoutOperationReadAnnouncement(string relative, string source) + { + if (relative != ScopeCarrierFile) return source; + var syntax = CSharpSyntaxTree.ParseText(source).GetRoot(); + var owners = syntax.DescendantNodes().OfType().Where(type => + type.Identifier.ValueText == "NpgsqlUnitOfWork" + && string.Join(".", type.Ancestors().OfType().Reverse().Select(item => item.Name.ToString())) + == "LearnStack.Infrastructure.Persistence").ToArray(); + if (syntax.ContainsDiagnostics || owners.Length != 1) return source; + var methods = owners[0].Members.OfType().ToArray(); + var core = methods.Where(method => method.Identifier.ValueText == "SetTenantContextCoreAsync").ToArray(); + var forwarders = methods.Where(method => method.Identifier.ValueText == "SetTenantContextAsync").ToArray(); + if (core.Length != 1 || !core[0].Modifiers.Any(SyntaxKind.PrivateKeyword) + || !core[0].Modifiers.Any(SyntaxKind.AsyncKeyword) || core[0].Modifiers.Any(SyntaxKind.StaticKeyword) + || !SyntaxFactory.AreEquivalent(core[0].ParameterList, SyntaxFactory.ParseParameterList( + "(ITenantContext context, OperationReadWitness? readWitness, bool tenantWideRead, CancellationToken cancellationToken)")) + || forwarders.Length != 2 + || forwarders.Count(method => Forwarder(method, + "(ITenantContext context, CancellationToken cancellationToken = default)", + "SetTenantContextCoreAsync(context, null, tenantWideRead: false, cancellationToken)")) != 1 + || forwarders.Count(method => Forwarder(method, + "(ITenantContext context, OperationReadWitness readWitness, CancellationToken cancellationToken = default)", + "SetTenantContextCoreAsync(context, readWitness, tenantWideRead: true, cancellationToken)")) != 1) + return source; + var calls = core[0].DescendantNodes().OfType() + .Where(call => call.Expression is IdentifierNameSyntax { Identifier.ValueText: "ExecuteAsync" } + && call.ArgumentList.Arguments.FirstOrDefault()?.Expression is { } sql + && ConstantScopeSql(sql) is { } value && ScopeSetter().IsMatch(value)).ToArray(); + if (calls.Length != 1 || ConstantScopeSql(calls[0].ArgumentList.Arguments[0].Expression) != ScopeCarrierSql) + return source; + var expected = (InvocationExpressionSyntax)SyntaxFactory.ParseExpression( + "ExecuteAsync(cancellationToken, (\"tenant\", tenant), (\"organization\", organization), (\"scope\", tenantWideRead ? \"tenant\" : string.Empty))"); + var arguments = calls[0].ArgumentList.Arguments.Skip(1).ToArray(); + if (arguments.Length != expected.ArgumentList.Arguments.Count || arguments.Where((argument, index) => + !SyntaxFactory.AreEquivalent(argument, expected.ArgumentList.Arguments[index])).Any()) return source; + var allowedSql = calls[0].ArgumentList.Arguments[0].Expression; + return source.Remove(allowedSql.SpanStart, allowedSql.Span.Length).Insert(allowedSql.SpanStart, "string.Empty"); + + static bool Forwarder(MethodDeclarationSyntax method, string parameters, string expression) => + method.Modifiers.Any(SyntaxKind.PublicKeyword) && !method.Modifiers.Any(SyntaxKind.StaticKeyword) + && method.Body is null && method.ExpressionBody is { } body + && SyntaxFactory.AreEquivalent(method.ParameterList, SyntaxFactory.ParseParameterList(parameters)) + && SyntaxFactory.AreEquivalent(body.Expression, SyntaxFactory.ParseExpression(expression)); + } + + private static string? ConstantScopeSql(ExpressionSyntax expression) => expression switch + { + LiteralExpressionSyntax literal when literal.IsKind(SyntaxKind.StringLiteralExpression) => literal.Token.ValueText, + BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.AddExpression) + && ConstantScopeSql(binary.Left) is { } left && ConstantScopeSql(binary.Right) is { } right => left + right, + ParenthesizedExpressionSyntax parenthesized => ConstantScopeSql(parenthesized.Expression), + _ => null, + }; + + private static void AssertOperationScopeCarrierControls() + { + var source = SourceText.WithoutComments(File.ReadAllText(Path.Combine(SourceScan.SourceRoot, ScopeCarrierFile))); + string Scan(string path, string code) => WithoutOperationReadAnnouncement(path, WithoutGlobalAuthorityClears(path, code, "app.scope")); + ScopeSetter().IsMatch(Scan(ScopeCarrierFile, source)).Should().BeFalse("the reviewed parameterized core is the one admitted carrier"); + foreach (var dirty in new[] + { + source.Replace("tenantWideRead: false", "tenantWideRead: true", StringComparison.Ordinal), + source.Replace("(\"scope\", tenantWideRead ? \"tenant\" : string.Empty)", "(\"scope\", context.CorrelationId)", StringComparison.Ordinal), + source.Replace("set_config('app.scope', @scope, true)", "set_config('app.scope', 'tenant', true)", StringComparison.Ordinal), + source.Replace("SetTenantContextCoreAsync", "UnreviewedScopeCoreAsync", StringComparison.Ordinal), + source + "\ninternal sealed class AnotherScopeWriter { void Run() { Execute(\"SET LOCAL app.scope = 'tenant'\"); } }", + }) + { + dirty.Should().NotBe(source, "each dirty control changes an actual reviewed carrier statement"); + ScopeSetter().IsMatch(Scan(ScopeCarrierFile, dirty)).Should().BeTrue("no whole-file scope-setter exemption is admitted"); + } + ScopeSetter().IsMatch(Scan("Unlisted.cs", source)).Should().BeTrue("copying the carrier into another owner remains forbidden"); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs index 2a7d060a9..85c15238d 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs @@ -263,7 +263,8 @@ public void The_Setter_Scan_Can_Actually_Fail() /// must write. /// private static readonly string[] WritingSetters = - ["LearnStack.Infrastructure.Audit/PostgresAuditStore.cs", "LearnStack.Infrastructure.Audit/PostgresAuditStore.GlobalIdentity.cs"]; + ["LearnStack.Infrastructure.Audit/PostgresAuditStore.cs", "LearnStack.Infrastructure.Audit/PostgresAuditStore.GlobalIdentity.cs", + "LearnStack.Infrastructure/Persistence/AuthorizationEvidenceSession.cs"]; /// /// The readers, each in a short read-only transaction of its own: the host resolver, the @@ -275,6 +276,7 @@ public void The_Setter_Scan_Can_Actually_Fail() "LearnStack.Infrastructure/MultiTenancy/OrganizationScopeValidator.cs", "LearnStack.Infrastructure.Audit/AuditConfigService.cs", "Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/FeatureFlags.cs", + "LearnStack.Infrastructure/Persistence/AuthorizationReadOwner.cs", ]; /// @@ -395,6 +397,8 @@ private static string ReaderAssembly(string reader) "LearnStack.Infrastructure.Audit.AuditConfigService", "Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/FeatureFlags.cs" => "LearnStack.Modules.Tenancy.Infrastructure.FeatureFlags", + "LearnStack.Infrastructure/Persistence/AuthorizationReadOwner.cs" => + "LearnStack.Infrastructure.Persistence.AuthorizationReadOwner", _ => throw new InvalidOperationException($"{reader} has no type mapped for the IL leg."), }; @@ -402,12 +406,9 @@ private static string ReaderAssembly(string reader) public void Tenant_Scope_Widening_Is_Never_Set_From_Request_Input() { // app.scope = 'tenant' widens an organization-scoped read to the whole tenant, and - // ADR-0036 § The reconciliation matrix derives it from the actor's role plus a - // declared tenant-wide operation — never from a header, a query parameter, a cookie - // or a body. The role arrives in Phase 03; until then nothing sets the variable at - // all, and "nothing sets it" is the strongest form of "nothing sets it from request - // input". The setter Phase 03 writes adds its own path to ScopeSetterSites, which is - // where the role derivation gets reviewed. + // ADR-0076 admits only the operation-bound witness overload of the existing logical + // setter. Its private core is recognized by exact signature, fixed forwarding flags + // and the one scope parameter expression; every other statement is still scanned. // // The premise first, or the rule guards a variable nothing reads: the policies do // read it. @@ -418,16 +419,16 @@ public void Tenant_Scope_Widening_Is_Never_Set_From_Request_Input() .Where(member => member.Name.Contains("Scope", StringComparison.OrdinalIgnoreCase)) .Where(member => member.Name is not (nameof(ITenantContext.HostScope) or "get_HostScope")) .Should().BeEmpty( - "Fix: no app.scope authority carrier is permitted; ADR-0052's immutable HostScope only narrows reads"); + "Fix: ITenantContext carries no writable read-scope authority; the operation witness and physical owner own it"); Directory.EnumerateFiles(SourceScan.SourceRoot, "*.cs", SearchOption.AllDirectories) .Where(file => !file.Split(Path.DirectorySeparatorChar).Any(segment => segment is "bin" or "obj")) - .Where(file => ScopeSetter().IsMatch(WithoutGlobalAuthorityClears(file, "app.scope"))) - .Select(file => Path.GetRelativePath(SourceScan.SourceRoot, file).Replace('\\', '/')) - .Where(file => !ScopeSetterSites.Contains(file)) + .Select(file => (Relative: Path.GetRelativePath(SourceScan.SourceRoot, file).Replace('\\', '/'), + Source: WithoutGlobalAuthorityClears(file, "app.scope"))) + .Where(file => ScopeSetter().IsMatch(WithoutOperationReadAnnouncement(file.Relative, file.Source))) + .Select(file => file.Relative) .Should().BeEmpty( - "nothing sets app.scope until Phase 03 derives it from a role " - + "(ADR-0036 § The reconciliation matrix)"); + "only the exact witness-consuming ordinary announcement sets app.scope (ADR-0076)"); } [Fact] @@ -448,11 +449,15 @@ public void The_Scope_Setter_Scan_Can_Actually_Fail() ScopeSetter().IsMatch(WithoutGlobalAuthorityClears(GlobalUnit, "SELECT set_config('app.scope', 'tenant', true)", "app.scope")).Should().BeTrue(); ScopeSetter().IsMatch(WithoutGlobalAuthorityClears("Unlisted.cs", Empty, "app.scope")).Should().BeTrue(); + const string AuthorizationOwner = "LearnStack.Infrastructure/Persistence/AuthorizationReadOwner.cs"; + ScopeSetter().IsMatch(WithoutGlobalAuthorityClears(AuthorizationOwner, Empty, "app.scope")).Should().BeFalse(); + ScopeSetter().IsMatch(WithoutGlobalAuthorityClears(AuthorizationOwner, + "SELECT set_config('app.scope', @scope, true)", "app.scope")).Should().BeTrue(); + ScopeSetter().IsMatch(WithoutGlobalAuthorityClears(AuthorizationOwner, + "SELECT set_config('app.scope', 'tenant', true)", "app.scope")).Should().BeTrue(); + AssertOperationScopeCarrierControls(); } - /// Files allowed to set app.scope, by path under backend/src. Empty until Phase 03. - private static readonly HashSet ScopeSetterSites = new(StringComparer.Ordinal); - private static string WithoutGlobalAuthorityClears(string file, string variable) { var source = SourceText.WithoutComments(File.ReadAllText(file)); @@ -466,6 +471,8 @@ private static string WithoutGlobalAuthorityClears(string relative, string sourc // tenant scope or host-lookup provenance. Nonempty/parameterized setters still fail. return relative is "LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs" or "LearnStack.Infrastructure.Audit/PostgresAuditStore.GlobalIdentity.cs" + or "LearnStack.Infrastructure/Persistence/AuthorizationReadOwner.cs" + or "LearnStack.Infrastructure/Persistence/AuthorizationEvidenceSession.cs" ? source.Replace("set_config('" + variable + "', '', true)", string.Empty, StringComparison.Ordinal) : source; } diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.Organizations.cs b/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.Organizations.cs index c8a4ef765..1ef582938 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.Organizations.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenantContextConstructionTests.Organizations.cs @@ -21,15 +21,19 @@ WHERE ROW(tenant_id,id)>ROW(@afterTenant,@afterOrganization) AND ROW(tenant_id,i ORDER BY tenant_id,id LIMIT @limit """; private const string OrganizationIdentityLifecycleSql = """ - SELECT t.deleted_at IS NULL, - t.deleted_at IS NULL AND t.status IN ('Trial', 'Active'), + SELECT t.id IS NOT NULL AND t.deleted_at IS NULL, + t.id IS NOT NULL AND t.deleted_at IS NULL AND t.status IN ('Trial', 'Active'), o.id IS NOT NULL, o.id IS NOT NULL AND o.deleted_at IS NULL AND o.status = 'Active', - current_setting('transaction_isolation') = 'read committed' - FROM public.tenants AS t + current_setting('transaction_isolation') = 'read committed', + current_setting('transaction_read_only') = 'on', + NULLIF(current_setting('app.tenant_id', true), '')::uuid = @tenant + AND NULLIF(current_setting('app.organization_id', true), '')::uuid IS NOT DISTINCT FROM @organization + AND NULLIF(current_setting('app.scope', true), '') IS NULL + FROM (VALUES (1)) AS query_anchor(value) + LEFT JOIN public.tenants AS t ON t.id = @tenant LEFT JOIN public.organizations AS o ON o.tenant_id = t.id AND o.id = @organization - WHERE t.id = @tenant """; private sealed record OrganizationSqlSubject(string Path, string Type, string Member, string Sql); @@ -44,7 +48,7 @@ private sealed record OrganizationSqlSubject(string Path, string Type, string Me new(OrganizationOccurrences, "LearnStack.Infrastructure.Jobs.PostgresBackgroundJobControlStore", "FreezeOccurrenceBoundsAsync", "SELECT tenant_id,id FROM organizations ORDER BY tenant_id DESC,id DESC LIMIT 1"), new(OrganizationIdentityLifecycle, "LearnStack.Modules.Tenancy.Infrastructure.Persistence.IdentityScopeLifecycleReader", - "ReadAsync", OrganizationIdentityLifecycleSql), + "LifecycleSql", OrganizationIdentityLifecycleSql), ]; private static (string Path, string Code)[] OrganizationSources() => @@ -114,6 +118,14 @@ private static string[] OrganizationReadOffenders(IEnumerable<(string Path, stri "command.Parameters.AddWithValue(\"tenant\", tenantId.Value)", "command.Parameters.AddWithValue(\"organization\", NpgsqlDbType.Uuid, organizationId is { } org ? org.Value : DBNull.Value)", "command.ExecuteReaderAsync(cancellationToken)"); + Require(OrganizationIdentityLifecycle, "IdentityScopeLifecycleReader", "ReadAuthorizationAsync", + "command.Parameters.AddWithValue(\"tenant\", candidate.TenantId.Value)", + "command.Parameters.AddWithValue(\"organization\", NpgsqlDbType.Uuid, candidate.OrganizationId is { } organization ? organization.Value : DBNull.Value)", + "command.ExecuteReaderAsync(cancellationToken)"); + if (roots.TryGetValue(OrganizationIdentityLifecycle, out var lifecycleRoot) + && lifecycleRoot.DescendantTokens().Count(token => token.IsKind(SyntaxKind.IdentifierToken) + && token.ValueText == "LifecycleSql") != 3) + offenders.Add(OrganizationIdentityLifecycle + ": the SQL constant must have exactly its declaration and two executed argument uses"); return offenders.Distinct(StringComparer.Ordinal).ToArray(); void Require(string path, string type, string method, params string[] calls) @@ -136,6 +148,7 @@ void Require(string path, string type, string method, params string[] calls) "CreateCommand" => ("string sql", "NpgsqlCommand", SyntaxKind.PrivateKeyword, false), "ReadLifecyclePageAsync" => ("BackgroundJobControlResources resources, OccurrenceCursor cursor, CancellationToken token", "Task>", SyntaxKind.PrivateKeyword, true), "ReadAsync" => ("TenantId tenantId, OrganizationId? organizationId, CancellationToken cancellationToken = default", "Task", SyntaxKind.PublicKeyword, true), + "ReadAuthorizationAsync" => ("IUnitOfWork child, AuthorizationCandidate candidate, CancellationToken cancellationToken = default", "Task", SyntaxKind.PublicKeyword, true), _ => throw new InvalidOperationException("Unknown organization binding owner."), }; if (ownerMethod.Body is null || ownerMethod.TypeParameterList is not null @@ -145,6 +158,21 @@ void Require(string path, string type, string method, params string[] calls) || ownerMethod.Modifiers.Any(token => token.IsKind(SyntaxKind.StaticKeyword)) || ownerMethod.Modifiers.Any(token => token.IsKind(SyntaxKind.AsyncKeyword)) != isAsync) { offenders.Add(path + ": changed binding owner signature " + method); return; } + if (path == OrganizationIdentityLifecycle) + { + var construction = method == "ReadAsync" + ? "new NpgsqlCommand(LifecycleSql, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)transaction) { CommandTimeout = 10 }" + : "new NpgsqlCommand(LifecycleSql, (NpgsqlConnection)child.Connection, (NpgsqlTransaction)transaction) { CommandTimeout = 5 }"; + var statement = SyntaxFactory.ParseStatement(method == "ReadAsync" + ? "await using var command = " + construction + ";" + : "command = " + construction + ";"); + var creations = ownerMethod.DescendantNodes().OfType() + .Where(item => SyntaxFactory.AreEquivalent(item.Type, SyntaxFactory.ParseTypeName("NpgsqlCommand"))).ToArray(); + if (creations.Length != 1 || !SyntaxFactory.AreEquivalent(creations[0], SyntaxFactory.ParseExpression(construction)) + || !SyntaxFactory.AreEquivalent(creations[0].Ancestors().OfType().FirstOrDefault(), statement) + || !ExecutedBinding(creations[0], ownerMethod)) + offenders.Add(path + ": the shared SQL must be the exact executed command argument in " + method); + } var actual = ownerMethod.DescendantNodes().OfType().ToArray(); foreach (var call in calls) if (actual.Count(item => SyntaxFactory.AreEquivalent(item, SyntaxFactory.ParseExpression(call)) @@ -173,19 +201,19 @@ void Require(string path, string type, string method, params string[] calls) } } - private static bool ExecutedBinding(InvocationExpressionSyntax call, MethodDeclarationSyntax owner) + private static bool ExecutedBinding(SyntaxNode call, MethodDeclarationSyntax owner) { - if (call.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "AddWithValue" } + if (call is InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax { Name.Identifier.ValueText: "AddWithValue" } } && (call.Parent is not ExpressionStatementSyntax expression || !ReferenceEquals(expression.Expression, call))) return false; // The returned parameter cannot be captured/rebound or conditionally evaluated. if (call.Ancestors().TakeWhile(node => !ReferenceEquals(node, owner)) .Any(node => node is LocalFunctionStatementSyntax or AnonymousFunctionExpressionSyntax)) return false; var statement = call.Ancestors().OfType().FirstOrDefault(); if (statement?.Parent == owner.Body) return true; - // This exact reader translates store failures around one unconditional + // Each exact reader translates store failures around one unconditional // command. Its bindings execute in that root try body, never its catch, // a nested conditional or a deferred helper. - if (owner.Identifier.ValueText == "ReadAsync" && owner.Parent is TypeDeclarationSyntax lifecycle + if (owner.Identifier.ValueText is "ReadAsync" or "ReadAuthorizationAsync" && owner.Parent is TypeDeclarationSyntax lifecycle && OrganizationTypeIdentity(lifecycle) == "LearnStack.Modules.Tenancy.Infrastructure.Persistence.IdentityScopeLifecycleReader" && statement?.Parent is BlockSyntax { Parent: TryStatementSyntax guarded } tryBody && guarded.Parent == owner.Body && ReferenceEquals(guarded.Block, tryBody)) return true; @@ -216,6 +244,18 @@ private static bool OrganizationSqlUseIsExact(string path, string member, Litera { if (path == OrganizationOccurrences && member == "OrganizationLifecyclePageSql") return literal.Parent is EqualsValueClauseSyntax; + if (path == OrganizationIdentityLifecycle && member == "LifecycleSql") + return literal.Parent is EqualsValueClauseSyntax + { + Parent: VariableDeclaratorSyntax + { Identifier.ValueText: "LifecycleSql", Parent: VariableDeclarationSyntax declaration } + } + && declaration.Variables.Count == 1 + && SyntaxFactory.AreEquivalent(declaration.Type, SyntaxFactory.ParseTypeName("string")) + && declaration.Parent is FieldDeclarationSyntax field + && field.Modifiers.Count == 2 + && field.Modifiers.Any(token => token.IsKind(SyntaxKind.PrivateKeyword)) + && field.Modifiers.Any(token => token.IsKind(SyntaxKind.ConstKeyword)); var sql = literal.ToString(); var expression = path switch { @@ -224,8 +264,6 @@ private static bool OrganizationSqlUseIsExact(string path, string member, Litera OrganizationHistory => "RecipientHistoryCommand(resources, claim, " + sql + ")", OrganizationOccurrences => "Command(resources, cursor.Organizations ? " + sql + " : \"SELECT id FROM tenants ORDER BY id DESC LIMIT 1\")", - OrganizationIdentityLifecycle => "new NpgsqlCommand(" + sql - + ", (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)transaction) { CommandTimeout = 10 }", _ => null, }; if (expression is null) return false; @@ -291,6 +329,42 @@ public void Organization_reader_census_rejects_missing_keys_extra_readers_and_un Dirty(history.Path, history.Code.Replace(TenantBinding, "if (false) { " + TenantBinding + " }", StringComparison.Ordinal)); Dirty(history.Path, history.Code + "\ninternal sealed class SplitReader { string Sql() => \"SELECT status FROM \" + \"organizations WHERE id=@organization\"; }"); var identity = production.Single(file => file.Path == OrganizationIdentityLifecycle); + var identityRoot = CSharpSyntaxTree.ParseText(identity.Code).GetRoot(); + var lifecycleField = identityRoot.DescendantNodes().OfType() + .Single(field => field.Declaration.Variables.Any(variable => variable.Identifier.ValueText == "LifecycleSql")); + Dirty(identity.Path, identity.Code.Remove(lifecycleField.SpanStart, lifecycleField.Span.Length)); + Dirty(identity.Path, identity.Code.Remove(lifecycleField.SpanStart, lifecycleField.Span.Length) + + "\ninternal static class UnusedLifecycleSql { " + lifecycleField + " }"); + Dirty(identity.Path, identity.Code.Replace("private const string LifecycleSql", "public const string LifecycleSql", StringComparison.Ordinal)); + Dirty(identity.Path, identity.Code.Replace("private const string LifecycleSql", "private readonly string LifecycleSql", StringComparison.Ordinal)); + foreach (var method in identityRoot.DescendantNodes().OfType() + .Where(method => method.Identifier.ValueText is "ReadAsync" or "ReadAuthorizationAsync")) + { + var creation = method.DescendantNodes().OfType() + .Single(item => item.Type.ToString() == "NpgsqlCommand"); + var statement = creation.Ancestors().OfType().First(); + Dirty(identity.Path, identityRoot.ReplaceNode(statement, + SyntaxFactory.ParseStatement("void UnusedCommand() { " + statement + " } ")).ToFullString()); + // Keep exactly two references: a constant mentioned by a deferred + // helper cannot justify another SQL argument on the live command. + var wrongArgument = identityRoot.ReplaceNode(creation, SyntaxFactory.ParseExpression( + creation.ToString().Replace("LifecycleSql", "string.Empty", StringComparison.Ordinal))); + var changedMethod = wrongArgument.DescendantNodes().OfType() + .Single(item => item.Identifier.ValueText == method.Identifier.ValueText); + Dirty(identity.Path, wrongArgument.ReplaceNode(changedMethod, changedMethod.WithBody(changedMethod.Body! + .AddStatements(SyntaxFactory.ParseStatement("void UnusedSql() { _ = LifecycleSql; }")))).ToFullString()); + if (method.Identifier.ValueText != "ReadAuthorizationAsync") continue; + foreach (var binding in method.DescendantNodes().OfType() + .Where(call => call.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "AddWithValue" })) + { + var bindingStatement = binding.Ancestors().OfType().First(); + Dirty(identity.Path, identityRoot.RemoveNode(bindingStatement, SyntaxRemoveOptions.KeepNoTrivia)!.ToFullString()); + Dirty(identity.Path, identityRoot.ReplaceNode(bindingStatement, + SyntaxFactory.ParseStatement("void UnusedBinding() { " + bindingStatement + " } ")).ToFullString()); + Dirty(identity.Path, identityRoot.ReplaceNode(bindingStatement, + SyntaxFactory.ParseStatement("if (false) { " + bindingStatement + " } ")).ToFullString()); + } + } const string IdentityTenantBinding = "command.Parameters.AddWithValue(\"tenant\", tenantId.Value);"; const string IdentityOrganizationBinding = "command.Parameters.AddWithValue(\"organization\", NpgsqlDbType.Uuid, organizationId is { } org\n ? org.Value : DBNull.Value);"; foreach (var binding in new[] { IdentityTenantBinding, IdentityOrganizationBinding }) @@ -302,7 +376,7 @@ public void Organization_reader_census_rejects_missing_keys_extra_readers_and_un } foreach (var (from, to) in new[] { - ("o.tenant_id = t.id AND ", ""), ("WHERE t.id = @tenant", "WHERE t.id IS NOT NULL"), + ("o.tenant_id = t.id AND ", ""), ("LEFT JOIN public.tenants AS t ON t.id = @tenant", "LEFT JOIN public.tenants AS t ON t.id IS NOT NULL"), ("\"tenant\", tenantId.Value", "\"tenant\", Guid.Empty"), ("? org.Value : DBNull.Value", "? Guid.Empty : DBNull.Value"), ("OrganizationId? organizationId", "OrganizationId organizationId"), @@ -310,6 +384,11 @@ public void Organization_reader_census_rejects_missing_keys_extra_readers_and_un ("(NpgsqlConnection)unit.Connection", "unit.Connection"), ("(NpgsqlTransaction)transaction", "transaction"), ("CommandTimeout = 10", "CommandTimeout = 20"), + ("CommandTimeout = 5", "CommandTimeout = 20"), + ("\"tenant\", candidate.TenantId.Value", "\"tenant\", Guid.Empty"), + ("? organization.Value : DBNull.Value", "? Guid.Empty : DBNull.Value"), + ("IUnitOfWork child, AuthorizationCandidate candidate", "IUnitOfWork child, object candidate"), + ("(NpgsqlConnection)child.Connection", "child.Connection"), ("command.ExecuteReaderAsync(cancellationToken)", "command.ExecuteReaderAsync(CancellationToken.None)"), }) Dirty(identity.Path, identity.Code.Replace(from, to, StringComparison.Ordinal)); Dirty(identity.Path, identity.Code.Replace(IdentityTenantBinding, IdentityTenantBinding diff --git a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs index f2b833935..17e5708d6 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenantScopingTests.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Text.RegularExpressions; using FluentAssertions; +using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Customization.Domain; using LearnStack.Modules.Customization.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Domain; @@ -155,6 +156,10 @@ public void Every_OrgScoped_Entity_HasOrgIdAndFilter() members.Should().Contain( nameof(IOrganizationScoped.OrganizationId), $"{table}'s filter must carry the organization term too"); + var readFilters = entityType.GetDeclaredQueryFilters().Select(filter => filter.Expression) + .OfType().Where(filter => IsOwnerBoundReadFilter(filter)).ToArray(); + readFilters.Should().ContainSingle($"{table} keeps the tenant AND (live owner read hatch OR narrow organization) filter"); + AssertReadFilterControls(readFilters[0]); // The organization term is AND-ed into the same single policy, never a // second permissive one. @@ -174,6 +179,58 @@ public void Every_OrgScoped_Entity_HasOrgIdAndFilter() } } + private static bool IsOwnerBoundReadFilter(LambdaExpression filter) + { + if (filter.Parameters.Count != 1 || filter.Body is not BinaryExpression { NodeType: ExpressionType.AndAlso } both + || both.Left is not BinaryExpression { NodeType: ExpressionType.Equal } tenant + || both.Right is not BinaryExpression { NodeType: ExpressionType.OrElse } widened + || widened.Right is not BinaryExpression { NodeType: ExpressionType.OrElse } scoped + || scoped.Left is not BinaryExpression { NodeType: ExpressionType.Equal } tenantWideRow + || scoped.Right is not BinaryExpression { NodeType: ExpressionType.Equal } ownOrganization) + return false; + var row = filter.Parameters[0]; + var context = ContextMember(tenant.Right, nameof(ITenantScopedDbContext.CurrentTenantId)); + return context is not null && RowMember(tenant.Left, nameof(ITenantOwned.TenantId)) + && ReferenceEquals(context, ContextMember(widened.Left, nameof(ITenantScopedDbContext.CurrentTenantWideRead))) + && RowMember(tenantWideRow.Left, nameof(IOrganizationScoped.OrganizationId)) + && tenantWideRow.Right is ConstantExpression { Value: null } + && RowMember(ownOrganization.Left, nameof(IOrganizationScoped.OrganizationId)) + && ReferenceEquals(context, ContextMember(ownOrganization.Right, nameof(ITenantScopedDbContext.CurrentOrganizationId))); + + bool RowMember(Expression expression, string name) => expression is MemberExpression member + && member.Expression == row && member.Member.Name == name; + static TenantScopedDbContext? ContextMember(Expression expression, string name) => + expression is MemberExpression { Expression: ConstantExpression { Value: TenantScopedDbContext context } } member + && member.Member.Name == name && member.Member.DeclaringType == typeof(TenantScopedDbContext) + ? context : null; + } + + private static void AssertReadFilterControls(LambdaExpression actual) + { + var captured = Expression.Property(Expression.Constant(new CapturedReadScope()), nameof(CapturedReadScope.CurrentTenantWideRead)); + foreach (var replacement in new Expression[] { Expression.Constant(true), Expression.Constant(false), captured }) + { + var dirty = (LambdaExpression)new ReadParameterReplacement(replacement).Visit(actual)!; + IsOwnerBoundReadFilter(dirty).Should().BeFalse("the scope hatch must remain a member parameter of this DbContext instance"); + } + var both = (BinaryExpression)actual.Body; + IsOwnerBoundReadFilter(Expression.Lambda(both.Right, actual.Parameters)).Should().BeFalse("tenant scope never removes the tenant predicate"); + var wide = (BinaryExpression)both.Right; + IsOwnerBoundReadFilter(Expression.Lambda(Expression.OrElse(wide.Left, both), actual.Parameters)) + .Should().BeFalse("the live read hatch cannot be OR-ed outside the tenant boundary"); + } + + private sealed class ReadParameterReplacement(Expression replacement) : ExpressionVisitor + { + protected override Expression VisitMember(MemberExpression node) => + node.Member.Name == nameof(ITenantScopedDbContext.CurrentTenantWideRead) ? replacement : base.VisitMember(node); + } + + private sealed class CapturedReadScope + { + public bool CurrentTenantWideRead { get; } = true; + } + [Fact] public void Every_Scoping_Interface_Carries_Its_Marker() { diff --git a/backend/tests/LearnStack.Tests.Architecture/TrustedOperationSourceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TrustedOperationSourceConventionTests.cs new file mode 100644 index 000000000..12f094c3d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/TrustedOperationSourceConventionTests.cs @@ -0,0 +1,200 @@ +using FluentAssertions; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// Finite source construction and installation remain closed independently of UUID/context shape. +public sealed class TrustedOperationSourceConventionTests +{ + private const string Descriptor = "LearnStack.SharedKernel.Permissions.TrustedOperationDescriptor"; + private const string Minter = Descriptor + "/MintingSource"; + private const string Seed = "LearnStack.Tools.Seeder.SeedOperationSource"; + private const string Delivery = "LearnStack.Infrastructure.Messaging.DeliveryOperationSource"; + private const string Job = "LearnStack.Application.Jobs.BackgroundJobOperationSource"; + private const string Admission = "LearnStack.Application.Jobs.BackgroundJobExecutionAdmission"; + private const string JobOwner = "LearnStack.SharedKernel.Jobs.JobApplicationSourceOwner"; + + [Fact] + public void Finite_operation_descriptors_have_exact_minters_private_construction_and_revocable_evidence() + { + using var census = Census.Read(); + Offenders(census).Should().BeEmpty("Fix: preserve the exact owned seed/delivery/job source set and its closed lifetime (ADR-0076)"); + var descriptor = census.Type(Descriptor); + Dirty(census, () => + { + var constructor = descriptor.Methods.Single(method => method.IsConstructor); + var attributes = constructor.Attributes; + constructor.IsPublic = true; + return () => constructor.Attributes = attributes; + }); + Dirty(census, () => + { + var field = descriptor.Fields.Single(field => field.Name == "_evidence"); + var attributes = field.Attributes; + field.IsInitOnly = false; + return () => field.Attributes = attributes; + }); + Dirty(census, () => + { + var module = census.Type(Seed).Module; + var extra = new TypeDefinition("AuthorityControl", "AnotherSource", TypeAttributes.NotPublic, + module.ImportReference(census.Type(Minter))); + module.Types.Add(extra); + return () => module.Types.Remove(extra); + }); + foreach (var (type, method, target, name) in new[] + { + (Descriptor, "get_IsActive", Minter, "MatchesEvidence"), + (Descriptor, "get_IsActive", Minter, "get_IsActive"), + (Minter, "Dispose", "System.Threading.Interlocked", "Exchange"), + (Delivery, "Capture", "LearnStack.SharedKernel.Permissions.OperationPolicy", "get_TrustedEventHandlerType"), + (Delivery, "IsEvidenceCurrent", "LearnStack.SharedKernel.Persistence.IUnitOfWork", "GetTenantContextAnnouncement"), + (Admission, "IsOperationEvidenceCurrent", JobOwner, "get_IsCurrentBinding"), + (Admission, "IsOperationEvidenceCurrent", Admission, "IsLive"), + }) Dirty(census, () => RemoveCall(census.Body(type, method), target, name)); + foreach (var (typeName, methodName) in new[] + { (Delivery, "Enter"), ("LearnStack.SharedKernel.Permissions.ITrustedOperationSource", "Capture") }) + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Ldftn }) Dirty(census, () => + { + var module = census.Type(Seed).Module; + var method = new MethodDefinition("UnownedDeliveryInstallation", MethodAttributes.Assembly | MethodAttributes.Static, + module.TypeSystem.Void); + method.Body.Instructions.Add(Instruction.Create(opcode, module.ImportReference(census.Method(typeName, methodName)))); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + census.Type(Seed).Methods.Add(method); + return () => census.Type(Seed).Methods.Remove(method); + }); + } + + private static List Offenders(Census census) + { + var errors = new List(); + var descriptor = census.Type(Descriptor); + var constructors = descriptor.Methods.Where(method => method.IsConstructor).ToArray(); + if (!descriptor.IsSealed || !descriptor.IsPublic || constructors.Length != 1 || !constructors[0].IsPrivate + || descriptor.Fields.Count != 5 || descriptor.Fields.Any(field => !field.IsPrivate || !field.IsInitOnly || field.IsStatic) + || descriptor.Properties.Any(property => property.SetMethod is not null) + || !Same(descriptor.Fields.Select(field => field.Name), ["_source", "_context", "_evidence", + "k__BackingField", "k__BackingField"])) + errors.Add("The immutable descriptor has only private closed construction and exact evidence fields"); + if (!Same(census.Types.Where(type => type.BaseType?.FullName == Minter).Select(type => type.FullName), [Seed, Delivery, Job])) + errors.Add("Only the three enumerated owners derive the finite descriptor source"); + foreach (var instruction in census.Methods.SelectMany(method => method.Body.Instructions)) + if (instruction.Operand is MethodReference target && (target.DeclaringType.FullName == Descriptor && target.Name == ".ctor" + || target.DeclaringType.FullName == Minter && target.Name == "ForOperation" + || target.DeclaringType.FullName == Delivery && target.Name == "Enter" + || target.DeclaringType.FullName == Admission && target.Name == "CaptureOperationEvidence" + || target.DeclaringType.FullName == "LearnStack.SharedKernel.Permissions.ITrustedOperationSource" && target.Name == "Capture") + && instruction.OpCode.Code is not (Code.Call or Code.Callvirt or Code.Newobj)) + errors.Add("A finite authority constructor/entry cannot become an executable alias"); + Exact(Descriptor, ".ctor", [Minter + "::ForOperation"]); + Exact(Seed, ".ctor", ["LearnStack.Tools.Seeder.SeedComposition::Build"]); + Exact(Delivery, ".ctor", []); + Exact(Job, ".ctor", []); + Exact(Minter, "ForOperation", [Seed + "::Capture", Delivery + "::Capture", Job + "::Capture"]); + Exact(Delivery, "Enter", ["LearnStack.Infrastructure.Messaging.DeliveryExecutor::ExecuteOwnedAsync"]); + Exact(Admission, "CaptureOperationEvidence", [Job + "::Capture"]); + Exact(Admission, "IsOperationEvidenceCurrent", [Admission + "::CaptureOperationEvidence", Job + "::IsEvidenceCurrent"]); + Exact("LearnStack.SharedKernel.Permissions.ITrustedOperationSource", "Capture", + ["LearnStack.Application.Authorization.OperationAuthorizationCoordinator::ExecuteAsync"]); + Require(Descriptor, "get_IsActive", Minter, "MatchesEvidence"); + Require(Descriptor, "get_IsActive", Minter, "get_IsActive"); + Require(Minter, "Dispose", "System.Threading.Interlocked", "Exchange"); + Require(Delivery, "Capture", "LearnStack.SharedKernel.Permissions.OperationPolicy", "get_TrustedEventHandlerType"); + Require(Delivery, "IsEvidenceCurrent", "LearnStack.SharedKernel.Persistence.IUnitOfWork", "GetTenantContextAnnouncement"); + Require(Admission, "IsOperationEvidenceCurrent", JobOwner, "get_IsCurrentBinding"); + Require(Admission, "IsOperationEvidenceCurrent", Admission, "IsLive"); + if (Calls(census.Body(Admission, "CaptureOperationEvidence")).Any(target => target.DeclaringType.FullName == Admission + && target.Name is "TryEnterRequest" or "PermitsRequest")) + errors.Add("Capturing job evidence never repeats root or nested request admission"); + return errors; + + void Exact(string type, string name, string[] owners) + { + var actual = census.Methods.SelectMany(method => Calls(method).Where(target => target.DeclaringType.FullName == type + && target.Name == name).Select(_ => census.Owner(method))); + if (!Same(actual, owners)) errors.Add("Closed finite authority callers changed: " + type + "::" + name); + } + + void Require(string type, string method, string target, string name) + { + if (Calls(census.Body(type, method)).Count(call => call.DeclaringType.FullName == target && call.Name == name) != 1) + errors.Add("Missing executing finite authority gate: " + type + "::" + method + "->" + target + "::" + name); + } + } + + private static void Dirty(Census census, Func mutate) + { + var undo = mutate(); + try { Offenders(census).Should().NotBeEmpty("an actual construction/caller/lifetime mutation must be detected"); } + finally { undo(); } + Offenders(census).Should().BeEmpty("the actual graph is restored after each dirty control"); + } + + private static Action RemoveCall(MethodDefinition method, string type, string name) + { + var instruction = method.Body.Instructions.Single(instruction => instruction.Operand is MethodReference call + && call.DeclaringType.FullName == type && call.Name == name); + var opcode = instruction.OpCode; + var operand = instruction.Operand; + instruction.OpCode = OpCodes.Nop; + instruction.Operand = null; + return () => { instruction.OpCode = opcode; instruction.Operand = operand; }; + } + + private static IEnumerable Calls(MethodDefinition method) => method.Body.Instructions + .Where(instruction => instruction.OpCode.Code is Code.Call or Code.Callvirt or Code.Newobj) + .Select(instruction => instruction.Operand).OfType(); + private static bool Same(IEnumerable actual, IEnumerable expected) => + actual.Order(StringComparer.Ordinal).SequenceEqual(expected.Order(StringComparer.Ordinal), StringComparer.Ordinal); + + internal sealed class Census : IDisposable + { + private readonly ModuleDefinition[] _modules; + private readonly Dictionary _asyncOwners; + private Census(ModuleDefinition[] modules) + { + _modules = modules; + _asyncOwners = Methods.SelectMany(method => method.CustomAttributes.Where(attribute => + attribute.AttributeType.FullName == "System.Runtime.CompilerServices.AsyncStateMachineAttribute") + .Select(attribute => (State: ((TypeReference)attribute.ConstructorArguments[0].Value).GetElementType().FullName, + Owner: method.DeclaringType.FullName + "::" + method.Name))) + .ToDictionary(item => item.State, item => item.Owner, StringComparer.Ordinal); + } + internal TypeDefinition[] Types => _modules.SelectMany(module => module.Types.SelectMany(Flatten)).ToArray(); + internal MethodDefinition[] Methods => Types.SelectMany(type => type.Methods).Where(method => method.HasBody).ToArray(); + internal static Census Read() => new(ProductionAssemblies.All().Select(assembly => ModuleDefinition.ReadModule(assembly.Location)).ToArray()); + internal TypeDefinition Type(string name) => Types.Single(type => type.FullName == name); + internal MethodDefinition Method(string type, string name) => Type(type).Methods.Single(method => method.Name == name); + internal MethodDefinition Body(string type, string name) + { + var method = Method(type, name); + var state = method.CustomAttributes.SingleOrDefault(attribute => + attribute.AttributeType.FullName == "System.Runtime.CompilerServices.AsyncStateMachineAttribute"); + return state is null ? method : Type(((TypeReference)state.ConstructorArguments[0].Value).GetElementType().FullName) + .Methods.Single(candidate => candidate.Name == "MoveNext"); + } + internal string Owner(MethodDefinition method) + { + if (_asyncOwners.TryGetValue(method.DeclaringType.FullName, out var owner)) return owner; + if (method.DeclaringType.FullName.StartsWith("LearnStack.Tools.Seeder.SeedComposition/", StringComparison.Ordinal) + && method.Name.StartsWith("", StringComparison.Ordinal)) + return "LearnStack.Tools.Seeder.SeedComposition::Build"; + if (method.DeclaringType.FullName.StartsWith("LearnStack.Application.Authorization.OperationAuthorizationCoordinator/<>c__DisplayClass", StringComparison.Ordinal) + && method.Name.StartsWith("b__", StringComparison.Ordinal)) + return "LearnStack.Application.Authorization.OperationAuthorizationCoordinator::ExecuteAsync"; + if (method.DeclaringType.FullName.StartsWith("LearnStack.Application.Audit.AuthorizationEvidenceCoordinator/<>c__DisplayClass", StringComparison.Ordinal) + && method.Name.StartsWith("b__", StringComparison.Ordinal)) + return "LearnStack.Application.Audit.AuthorizationEvidenceCoordinator::Snapshot"; + if ((method.DeclaringType.FullName == "LearnStack.Modules.Identity.Application.Authorization.RoleGrantAuthorization" + || method.DeclaringType.FullName.StartsWith("LearnStack.Modules.Identity.Application.Authorization.RoleGrantAuthorization/<>c__DisplayClass", StringComparison.Ordinal)) + && method.Name.StartsWith("g__Refused|", StringComparison.Ordinal)) + return "LearnStack.Modules.Identity.Application.Authorization.RoleGrantAuthorization::RecheckAsync"; + return method.DeclaringType.FullName + "::" + method.Name; + } + public void Dispose() { foreach (var module in _modules) module.Dispose(); } + private static IEnumerable Flatten(TypeDefinition type) => new[] { type }.Concat(type.NestedTypes.SelectMany(Flatten)); + } +} diff --git a/backend/tests/LearnStack.Tests.AuthorizationEvidenceProbe/LearnStack.Tests.AuthorizationEvidenceProbe.csproj b/backend/tests/LearnStack.Tests.AuthorizationEvidenceProbe/LearnStack.Tests.AuthorizationEvidenceProbe.csproj new file mode 100644 index 000000000..278a447c8 --- /dev/null +++ b/backend/tests/LearnStack.Tests.AuthorizationEvidenceProbe/LearnStack.Tests.AuthorizationEvidenceProbe.csproj @@ -0,0 +1,15 @@ + + + Exe + false + false + false + + + + + + + + + diff --git a/backend/tests/LearnStack.Tests.AuthorizationEvidenceProbe/Program.cs b/backend/tests/LearnStack.Tests.AuthorizationEvidenceProbe/Program.cs new file mode 100644 index 000000000..8687f9cc6 --- /dev/null +++ b/backend/tests/LearnStack.Tests.AuthorizationEvidenceProbe/Program.cs @@ -0,0 +1,42 @@ +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.Tests.AuthorizationEvidenceProof; + +namespace LearnStack.Tests.AuthorizationEvidenceProbe; + +internal static class Program +{ + private static async Task Main(string[] args) + { + if (args.Length != 1 || args[0] is not ("precommit" or "postcommit")) return 78; + try + { + var postCommit = args[0] == "postcommit"; + await using var proof = await AuthorizationBarrierHarness.CreateAsync( + Required("APP"), IdentityAuthorityId.From(Guid.Parse(Required("AUTHORITY"))), Required("ISSUER"), + TenantId.From(Guid.Parse(Required("TENANT"))), OrganizationId.From(Guid.Parse(Required("ORGANIZATION"))), + UserId.From(Guid.Parse(Required("ACTOR"))), Required("CORRELATION"), + commitBoundary: committed => BoundaryAsync(committed, postCommit), + businessId: TenantSettingId.From(Guid.Parse(Required("BUSINESS")))); + try { await proof.RunAsync(); } + finally { Console.Out.WriteLine("LearnStackEvidence:finally"); Console.Out.Flush(); } + return 79; + } + catch (Exception failure) when (failure is not (OutOfMemoryException or StackOverflowException)) + { + Console.Error.WriteLine("LearnStackEvidence:probe_failed"); + return 80; + } + } + + private static async Task BoundaryAsync(bool committed, bool postCommit) + { + if (committed != postCommit) return; + Console.Out.WriteLine("LearnStackEvidenceBoundary:" + (postCommit ? "postcommit" : "precommit")); + Console.Out.Flush(); + await Task.Delay(Timeout.InfiniteTimeSpan); + } + + private static string Required(string key) => Environment.GetEnvironmentVariable("LEARNSTACK_EVIDENCE_PROOF_" + key) + ?? throw new InvalidOperationException("The proof environment is incomplete."); +} diff --git a/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs b/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs index e2affba55..0214bcc72 100644 --- a/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs +++ b/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs @@ -46,6 +46,7 @@ private static bool IsDatabaseStartupWorker(ServiceDescriptor descriptor) var implementation = descriptor.ImplementationType ?? descriptor.ImplementationFactory?.Method.ReturnType; return implementation == typeof(OutboxProcessor) || implementation == typeof(DeliveryProcessor) || implementation == typeof(BackgroundJobHostedService) - || implementation == typeof(TenantIdentityAuthorityStartupService); + || implementation == typeof(TenantIdentityAuthorityStartupService) + || implementation == typeof(TenantAuthorizationSourcesStartupService); } } diff --git a/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.Authentication.cs b/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.Authentication.cs index cf7b31343..5693a1377 100644 --- a/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.Authentication.cs +++ b/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.Authentication.cs @@ -17,6 +17,8 @@ public async Task Authenticated_contract_controls_detect_security_headers_parame var clean = await Served(); ContractErrors(clean).Should().BeEmpty(); AccountSchemaErrors(clean).Should().BeEmpty(); + DirectorySchemaErrors(clean).Should().BeEmpty(); + AssertDirectoryControls(clean); clean["paths"]!["/api/v1/public/site"]!["get"]!["responses"]!["400"]!["description"]!.GetValue() .Should().Contain("malformed or repeated assertion headers"); clean["paths"]!["/api/v1/account/site"]!["get"]!["responses"]!["405"]!["description"]!.GetValue() diff --git a/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.Directory.cs b/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.Directory.cs new file mode 100644 index 000000000..f3de75883 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.Directory.cs @@ -0,0 +1,114 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Pagination; + +namespace LearnStack.Tests.Contract; + +public sealed partial class OpenApiContractTests +{ + private const string DirectoryPath = "/api/v1/identity/memberships"; + private const string DirectoryPageSchema = "PageOfMembershipDirectoryItem"; + private static readonly string[] DirectorySchemaNames = + [DirectoryPageSchema, "MembershipDirectoryItem", "DirectoryMembershipStatus"]; + private static readonly string[] DirectoryFields = + ["membershipId", "userId", "organizationId", "status", "displayName", "preferredLocale"]; + + private static List DirectorySchemaErrors(JsonNode document) + { + ClrFields(typeof(MembershipDirectoryItem)).Should().BeEquivalentTo(DirectoryFields); + ClrFields(typeof(Page)).Should().BeEquivalentTo(["items", "pageInfo"]); + typeof(MembershipDirectoryItem).GetProperty(nameof(MembershipDirectoryItem.MembershipId))!.PropertyType.Should().Be(); + typeof(MembershipDirectoryItem).GetProperty(nameof(MembershipDirectoryItem.UserId))!.PropertyType.Should().Be(); + typeof(MembershipDirectoryItem).GetProperty(nameof(MembershipDirectoryItem.OrganizationId))!.PropertyType.Should().Be(); + typeof(MembershipDirectoryItem).GetProperty(nameof(MembershipDirectoryItem.Status))!.PropertyType.Should().Be(); + var errors = new List(); + var schemas = document["components"]?["schemas"]; + var item = schemas?["MembershipDirectoryItem"]; + var page = schemas?[DirectoryPageSchema]; + if (!HasExactFields(item, DirectoryFields)) errors.Add("directory-fields"); + if (item?["properties"] is JsonObject fields) + { + if (!JsonNode.DeepEquals(fields["membershipId"], new JsonObject { ["type"] = "string", ["format"] = "uuid" }) + || fields["userId"]?["$ref"]?.GetValue() != "#/components/schemas/UserId" + || fields["organizationId"]?["oneOf"] is not JsonArray { Count: 2 } organization + || organization[0]?["type"]?.GetValue() != "null" + || organization[1]?["$ref"]?.GetValue() != "#/components/schemas/OrganizationId") errors.Add("directory-uuid"); + if (fields["status"]?["$ref"]?.GetValue() != "#/components/schemas/DirectoryMembershipStatus" + || schemas?["DirectoryMembershipStatus"]?["type"]?.GetValue() != "string" + || schemas?["DirectoryMembershipStatus"]?["enum"] is not JsonArray statuses + || !statuses.Select(status => status!.GetValue()).SequenceEqual(["Active", "Suspended"])) errors.Add("directory-status"); + foreach (var field in new[] { "displayName", "preferredLocale" }) + if (fields[field]?["type"] is not JsonArray types + || !types.Select(type => type!.GetValue()).ToHashSet(StringComparer.Ordinal).SetEquals(["string", "null"]) + || fields[field]!.AsObject().Count != 1) errors.Add("directory-profile"); + } + if (!HasExactFields(page, ["items", "pageInfo"]) + || page?["properties"]?["items"]?["type"]?.GetValue() != "array" + || page?["properties"]?["items"]?["items"]?["$ref"]?.GetValue() != "#/components/schemas/MembershipDirectoryItem" + || page?["properties"]?["pageInfo"]?["$ref"]?.GetValue() != "#/components/schemas/PageInfo") errors.Add("directory-page"); + return errors; + } + + private static bool HasExactFields(JsonNode? schema, string[] fields) => schema?["type"]?.GetValue() == "object" + && schema["properties"] is JsonObject properties + && properties.Select(property => property.Key).ToHashSet(StringComparer.Ordinal).SetEquals(fields) + && schema["required"] is JsonArray required + && required.Select(field => field!.GetValue()).ToHashSet(StringComparer.Ordinal).SetEquals(fields) + && (schema["additionalProperties"] is null || schema["additionalProperties"] is JsonValue flag + && flag.TryGetValue(out var allowed) && !allowed); + + private static void AssertDirectoryControls(JsonNode clean) + { + foreach (var mutation in new Action[] + { + value => value["paths"]![DirectoryPath]!.AsObject().Remove("get"), + value => value["paths"]![DirectoryPath]!["head"] = value["paths"]![DirectoryPath]!["get"]!.DeepClone(), + value => value["paths"]![DirectoryPath]!["post"] = value["paths"]![DirectoryPath]!["get"]!.DeepClone(), + value => value["paths"]![DirectoryPath]!["get"]!["operationId"] = "GetAccountSite", + }) + { + var dirty = clean.DeepClone(); mutation(dirty); + ContractErrors(dirty).Should().Contain("operations"); + } + var changed = clean.DeepClone(); + changed["paths"]![DirectoryPath]!["get"]!.AsObject().Remove("security"); + ContractErrors(changed).Should().Contain("security"); + changed = clean.DeepClone(); + changed["paths"]![DirectoryPath]!["get"]!["responses"]!.AsObject().Remove("403"); + ContractErrors(changed).Should().Contain("responses"); + foreach (var (parameter, keyword, replacement) in new[] + { + (0, "maxLength", JsonValue.Create(1025)!), + (1, "default", JsonValue.Create(100)!), + (1, "minimum", JsonValue.Create(0)!), + (1, "maximum", JsonValue.Create(100)!), + }) + { + changed = clean.DeepClone(); + changed["paths"]![DirectoryPath]!["get"]!["parameters"]![parameter]!["schema"]![keyword] = replacement.DeepClone(); + ContractErrors(changed).Should().Contain("parameters"); + } + foreach (var field in new[] { "email", "subject", "roles", "grants", "provider", "tenantId" }) + { + changed = clean.DeepClone(); + changed["components"]!["schemas"]!["MembershipDirectoryItem"]!["properties"]![field] = new JsonObject { ["type"] = "string" }; + DirectorySchemaErrors(changed).Should().Contain("directory-fields"); + } + foreach (var (field, error) in new[] + { ("membershipId", "directory-uuid"), ("userId", "directory-uuid"), ("organizationId", "directory-uuid"), + ("status", "directory-status"), ("displayName", "directory-profile"), ("preferredLocale", "directory-profile") }) + { + changed = clean.DeepClone(); + changed["components"]!["schemas"]!["MembershipDirectoryItem"]!["properties"]![field] = new JsonObject { ["type"] = "object" }; + DirectorySchemaErrors(changed).Should().Contain(error); + } + changed = clean.DeepClone(); + changed["components"]!["schemas"]![DirectoryPageSchema]!["properties"]!["items"]!["items"]!["$ref"] = "#/components/schemas/AccountSite"; + DirectorySchemaErrors(changed).Should().Contain("directory-page"); + changed = clean.DeepClone(); + changed["components"]!["schemas"]![DirectoryPageSchema]!["additionalProperties"] = true; + DirectorySchemaErrors(changed).Should().Contain("directory-page"); + } +} diff --git a/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.cs b/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.cs index 6011a6935..fe6f0ee0b 100644 --- a/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.cs +++ b/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.cs @@ -24,6 +24,7 @@ public sealed partial class OpenApiContractTests(DevelopmentWebApplicationFactor ["/api/v1/public/courses/{slug}"] = ["GetPublicCourse", "HeadPublicCourse"], ["/api/v1/public/courses/{slug}/lessons/{lessonSlug}"] = ["GetPublicLesson", "HeadPublicLesson"], ["/api/v1/account/site"] = ["GetAccountSite"], + ["/api/v1/identity/memberships"] = ["ListMemberships"], }; private static readonly Dictionary Dtos = new(StringComparer.Ordinal) { @@ -51,7 +52,15 @@ public sealed partial class OpenApiContractTests(DevelopmentWebApplicationFactor public async Task PublicSurface_Contract_Matches_Served_OpenApi() { var served = await Served(); - var snapshot = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(RepositoryRoot(), "backend/openapi/v1.json")))!; + var snapshotPath = Path.Combine(RepositoryRoot(), "backend/openapi/v1.json"); + if (Environment.GetEnvironmentVariable("LEARNSTACK_UPDATE_OPENAPI") == "1") + { + if (string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase) + || Environment.GetEnvironmentVariable("CI") == "1") + throw new InvalidOperationException("OpenAPI snapshot updates are refused in CI."); + await File.WriteAllTextAsync(snapshotPath, served.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n"); + } + var snapshot = JsonNode.Parse(await File.ReadAllTextAsync(snapshotPath))!; JsonNode.DeepEquals(served, snapshot).Should().BeTrue("only object key order may differ from the served production composition"); ContractErrors(served).Should().BeEmpty(); } @@ -107,6 +116,7 @@ public async Task PublicSurface_Response_Schemas_Exclude_Internal_Fields() var served = await Served(); SchemaErrors(served).Should().BeEmpty(); AccountSchemaErrors(served).Should().BeEmpty(); + DirectorySchemaErrors(served).Should().BeEmpty(); foreach (var (name, descriptor) in Dtos) { ClrFields(descriptor.Type).Should().BeEquivalentTo(descriptor.Fields.Where(field => field != "state" @@ -162,7 +172,8 @@ private static List ContractErrors(JsonNode document) foreach (var (path, ids) in Operations) { if (paths[path] is not JsonObject methods) { errors.Add("operations"); continue; } - var authenticated = path == "/api/v1/account/site"; + var directory = path == "/api/v1/identity/memberships"; + var authenticated = path == "/api/v1/account/site" || directory; var expectedMethods = authenticated ? new[] { "get" } : ["get", "head"]; if (!methods.Select(method => method.Key).ToHashSet(StringComparer.Ordinal).SetEquals(expectedMethods)) errors.Add("operations"); foreach (var (index, method) in expectedMethods.Index()) @@ -177,17 +188,19 @@ private static List ContractErrors(JsonNode document) || requirement["TenantBearer"] is not JsonArray { Count: 0 }) errors.Add("security"); } else if (operation["security"] is JsonArray { Count: > 0 }) errors.Add("public-security"); - var expected = path.EndsWith("/site", StringComparison.Ordinal) ? Array.Empty() + var expected = directory ? ["cursor", "limit"] + : path.EndsWith("/site", StringComparison.Ordinal) ? Array.Empty() : path.EndsWith("/lessons/{lessonSlug}", StringComparison.Ordinal) ? new[] { "slug", "lessonSlug", "locale" } : path.EndsWith("/{slug}", StringComparison.Ordinal) ? new[] { "slug", "locale", "lessonCursor", "lessonLimit" } : ["locale", "cursor", "limit"]; var parameters = operation["parameters"]?.AsArray() ?? new JsonArray(); if (!parameters.Select(parameter => parameter!["name"]!.GetValue()).ToHashSet(StringComparer.Ordinal).SetEquals(expected) || parameters.Count != expected.Length - || parameters.Any(parameter => !ParameterMatches(parameter!))) errors.Add("parameters"); + || parameters.Any(parameter => !ParameterMatches(parameter!, directory))) errors.Add("parameters"); if (operation.ContainsKey("requestBody")) errors.Add("request-body"); var responses = operation["responses"]!.AsObject(); - if (!responses.Select(response => response.Key).ToHashSet(StringComparer.Ordinal).SetEquals(Statuses)) errors.Add("responses"); + if (!responses.Select(response => response.Key).ToHashSet(StringComparer.Ordinal) + .SetEquals(directory ? Statuses.Append("403") : Statuses)) errors.Add("responses"); foreach (var (status, response) in responses) { if (response!["headers"]?["Cache-Control"]?["schema"]?["enum"]?[0]?.GetValue() != "no-store") errors.Add("headers"); @@ -217,7 +230,7 @@ private static List ContractErrors(JsonNode document) else if (status != "200" && response["content"]?["application/problem+json"]?["schema"]?["$ref"]?.GetValue() != "#/components/schemas/ProblemDetails") errors.Add("problem"); else if (status == "200") { - var root = authenticated ? "AccountSite" : path.EndsWith("/site", StringComparison.Ordinal) ? "PublicSite" + var root = directory ? DirectoryPageSchema : authenticated ? "AccountSite" : path.EndsWith("/site", StringComparison.Ordinal) ? "PublicSite" : path.EndsWith("/lessons/{lessonSlug}", StringComparison.Ordinal) ? "PublicLessonDetail" : path.EndsWith("/{slug}", StringComparison.Ordinal) ? "PublicCourseDetail" : "PublicCourseCatalog"; if (response["content"]!.AsObject().Count != 1 @@ -226,7 +239,7 @@ private static List ContractErrors(JsonNode document) } } } - if (count != 9) errors.Add("operations"); + if (count != 10) errors.Add("operations"); if (document["security"] is JsonArray { Count: > 0 }) errors.Add("security"); var schemes = document["components"]?["securitySchemes"]?.AsObject(); if (schemes is not { Count: 1 } || schemes["TenantBearer"]?["type"]?.GetValue() != "http" @@ -258,7 +271,7 @@ private static List ContractErrors(JsonNode document) return errors; } - private static bool ParameterMatches(JsonNode parameter) + private static bool ParameterMatches(JsonNode parameter, bool directory) { var name = parameter["name"]?.GetValue(); var path = name is "slug" or "lessonSlug"; @@ -268,6 +281,8 @@ private static bool ParameterMatches(JsonNode parameter) "slug" or "lessonSlug" => new JsonObject { ["type"] = "string", ["minLength"] = 1, ["maxLength"] = 160 }, "locale" => new JsonObject { ["type"] = "string", ["minLength"] = 2, ["maxLength"] = 35 }, "cursor" or "lessonCursor" => new JsonObject { ["type"] = "string", ["minLength"] = 1, ["maxLength"] = 1024 }, + "limit" when directory => new JsonObject + { ["type"] = "integer", ["format"] = "int32", ["minimum"] = 1, ["maximum"] = int.MaxValue, ["default"] = 20 }, "limit" or "lessonLimit" => new JsonObject { ["type"] = "string" }, _ => null, }; @@ -295,7 +310,7 @@ private static void AssertParameterAndProblemControls(JsonNode clean) { value => value["in"] = "header", value => value["required"] = !(parameter!["required"]?.GetValue() ?? false), - value => value["schema"]!["type"] = "integer", + value => value["schema"]!["type"] = parameter!["schema"]!["type"]!.GetValue() == "integer" ? "string" : "integer", value => value["schema"]!["format"] = "uuid", }) { @@ -331,7 +346,8 @@ private static List SchemaErrors(JsonNode document) var errors = new List(); var schemas = document["components"]!["schemas"]!.AsObject(); var expectedNames = Dtos.Keys.Concat(["PublicCourseAccess", "PublicDisplayState", "ProblemDetails"]); - if (!schemas.Select(schema => schema.Key).ToHashSet(StringComparer.Ordinal).SetEquals(expectedNames.Concat(AccountSchemaNames))) errors.Add("schema-census"); + if (!schemas.Select(schema => schema.Key).ToHashSet(StringComparer.Ordinal) + .SetEquals(expectedNames.Concat(AccountSchemaNames).Concat(DirectorySchemaNames))) errors.Add("schema-census"); foreach (var (name, descriptor) in Dtos) { if (schemas[name] is not JsonObject schema) { errors.Add(name); continue; } diff --git a/backend/tests/LearnStack.Tests.Integration/Api/OperationHttpClassificationTests.cs b/backend/tests/LearnStack.Tests.Integration/Api/OperationHttpClassificationTests.cs new file mode 100644 index 000000000..1c4ce823b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Api/OperationHttpClassificationTests.cs @@ -0,0 +1,188 @@ +using LearnStack.Api.Account; +using LearnStack.Api.Authentication; +using LearnStack.Api.Versioning; +using LearnStack.Application.Authorization; +using LearnStack.Modules.Customization.Application.Authorization; +using LearnStack.Modules.Education.Application.Authorization; +using LearnStack.Modules.Identity.Application; +using LearnStack.Modules.Tenancy.Application.Authorization; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LearnStack.Tests.Integration.Api; + +public sealed class OperationHttpClassificationTests +{ + [Fact] + public async Task Unresolved_actual_public_operations_preserve_the_generic_refusal_without_authorization_or_dispatch() + { + await using var app = await CreateAsync(); + var classifier = new OperationHttpClassifier(Catalog()); + var endpoints = Endpoints(app).Where(endpoint => + endpoint.Metadata.GetMetadata() is not null + && endpoint.Metadata.GetMetadata() is not null).ToArray(); + Assert.Equal(8, endpoints.Length); + var dispatched = 0; + var authorizations = 0; + var middleware = new OperationClassificationMiddleware(_ => { dispatched++; return Task.CompletedTask; }); + foreach (var endpoint in endpoints) + { + var context = new DefaultHttpContext { RequestServices = app.Services }; + context.SetEndpoint(endpoint); + context.Request.Method = Assert.Single(endpoint.Metadata.GetRequiredMetadata().HttpMethods); + await using var body = new MemoryStream(); + context.Response.Body = body; + await middleware.InvokeAsync(context, classifier, + () => { authorizations++; throw new InvalidOperationException("Unresolved public operations must not resolve an authorization owner."); }, + UnresolvedTenantContext.Instance); + Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); + Assert.Contains("no-store", context.Response.Headers.CacheControl.ToString(), StringComparison.Ordinal); + body.Position = 0; + using var problem = await JsonDocument.ParseAsync(body); + Assert.Equal("tenant_mismatch", problem.RootElement.GetProperty("code").GetString()); + } + Assert.Equal(0, authorizations); + Assert.Equal(0, dispatched); + } + + [Fact] + public async Task Every_actual_built_controller_and_infrastructure_endpoint_has_one_closed_classification() + { + await using var app = await CreateAsync(); + var endpoints = Endpoints(app); + var catalog = Catalog(); + var classifier = new OperationHttpClassifier(catalog); + Assert.Equal(10, endpoints.Count(endpoint => endpoint.Metadata.GetMetadata() is not null)); + foreach (var endpoint in endpoints) + { + var classification = classifier.Classify(endpoint, out var policy); + if (endpoint.Metadata.GetMetadata() is not null) + { + Assert.Equal(HttpOperationClassification.Application, classification); + Assert.NotNull(policy); + Assert.True(catalog.TryGet(policy.RequestType, out var registered)); + Assert.Same(registered, policy); + var requirement = new OperationPolicyRequirement(policy); + Assert.Equal(OperationPolicyRequirement.Prefix + policy.Id, requirement.Name); + Assert.Same(policy, requirement.Policy); + var stripped = endpoint.Metadata.Where(value => value is not IAuthorizeData and not IAllowAnonymous).ToList(); + Assert.Equal(HttpOperationClassification.Refused, classifier.Classify(new Endpoint(endpoint.RequestDelegate, + new EndpointMetadataCollection(stripped), endpoint.DisplayName), out _)); + if (endpoint.Metadata.GetMetadata() is { } authorization) + { + foreach (var changed in new[] + { + new AuthorizeAttribute { AuthenticationSchemes = authorization.AuthenticationSchemes, Policy = "LearnStackOperation:unregistered.read" }, + new AuthorizeAttribute { AuthenticationSchemes = authorization.AuthenticationSchemes, Policy = authorization.Policy, Roles = "tenant-admin" }, + }) + { + var altered = endpoint.Metadata.Where(value => value is not IAuthorizeData).Append(changed); + Assert.Equal(HttpOperationClassification.Refused, classifier.Classify(new Endpoint(endpoint.RequestDelegate, + new EndpointMetadataCollection(altered), endpoint.DisplayName), out _)); + } + } + } + else + { + Assert.Equal(HttpOperationClassification.Infrastructure, classification); + Assert.Null(policy); + } + } + Assert.Equal(new[] { InfrastructureOperationKind.Health, InfrastructureOperationKind.OpenApi, InfrastructureOperationKind.Documentation }, + endpoints.Select(endpoint => endpoint.Metadata.GetMetadata()) + .Where(metadata => metadata is not null).Select(metadata => metadata!.Kind).Distinct().Order()); + } + + [Theory] + [InlineData("missing")] + [InlineData("duplicate")] + [InlineData("wrong-request")] + [InlineData("wrong-name")] + [InlineData("wrong-method")] + [InlineData("infrastructure-on-controller")] + public async Task Actual_endpoint_metadata_twins_refuse_before_a_handler_can_run(string defect) + { + await using var app = await CreateAsync(); + var original = Assert.Single(Endpoints(app), endpoint => endpoint.Metadata.GetMetadata()?.EndpointName == "GetPublicSite"); + var classifier = new OperationHttpClassifier(Catalog()); + Assert.Equal(HttpOperationClassification.Application, classifier.Classify(original, out _)); + var metadata = original.Metadata.ToList(); + var declaration = Assert.Single(metadata.OfType()); + switch (defect) + { + case "missing": metadata.Remove(declaration); break; + case "duplicate": metadata.Add(declaration); break; + case "wrong-request": metadata.Remove(declaration); metadata.Add(new OperationPolicyAttribute(typeof(LearnStack.Modules.Tenancy.Application.Contracts.Account.GetAccountSiteQuery))); break; + case "wrong-name": metadata.RemoveAll(value => value is IEndpointNameMetadata); metadata.Add(new EndpointNameMetadata("UnclassifiedAction")); break; + case "wrong-method": metadata.RemoveAll(value => value is IHttpMethodMetadata); metadata.Add(new HttpMethodMetadata(["POST"])); break; + case "infrastructure-on-controller": metadata.Add(InfrastructureOperationMetadata.Health); break; + } + var dirty = new Endpoint(original.RequestDelegate, new EndpointMetadataCollection(metadata), original.DisplayName); + Assert.Equal(HttpOperationClassification.Refused, classifier.Classify(dirty, out var refused)); + Assert.Null(refused); + Assert.Equal(HttpOperationClassification.Application, classifier.Classify(original, out _)); + } + + [Fact] + public async Task Matched_undeclared_endpoint_refuses_while_no_match_keeps_routing_ownership() + { + var classifier = new OperationHttpClassifier(Catalog()); + Assert.Equal(HttpOperationClassification.Unmatched, classifier.Classify(null, out _)); + var undeclared = new Endpoint(_ => Task.CompletedTask, EndpointMetadataCollection.Empty, "Undeclared endpoint"); + Assert.Equal(HttpOperationClassification.Refused, classifier.Classify(undeclared, out _)); + Endpoint? selected = null; + await using var app = await CreateAsync(context => selected = context.GetEndpoint()); + using var client = app.GetTestClient(); + using var rejected = await client.PostAsync("/api/v1/public/site", content: null); + Assert.Equal(System.Net.HttpStatusCode.MethodNotAllowed, rejected.StatusCode); + Assert.NotNull(selected); + Assert.Equal(HttpOperationClassification.Unmatched, classifier.Classify(selected, out _)); + var lookalike = new Endpoint(_ => Task.CompletedTask, EndpointMetadataCollection.Empty, selected.DisplayName); + Assert.Equal(HttpOperationClassification.Refused, classifier.Classify(lookalike, out _)); + using var unmatched = await client.GetAsync("/api/v1/unmatched-route-control"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, unmatched.StatusCode); + Assert.Null(selected); + } + + private static OperationPolicyCatalog Catalog() => new([ + new TenancyOperationPolicySource(), new CustomizationOperationPolicySource(), + new EducationOperationPolicySource(), new IdentityOperationPolicySource(), new BackgroundJobOperationPolicySource()]); + + private static Endpoint[] Endpoints(WebApplication app) => + [.. ((IEndpointRouteBuilder)app).DataSources.SelectMany(source => source.Endpoints)]; + + private static async Task CreateAsync(Action? observe = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + EnvironmentName = "Testing", + ApplicationName = typeof(AccountController).Assembly.GetName().Name, + }); + builder.WebHost.UseTestServer(); + builder.Services.AddLearnStackApiVersioning(); + builder.Services.AddControllers().ConfigureApplicationPartManager(manager => + { + manager.ApplicationParts.Clear(); + manager.ApplicationParts.Add(new AssemblyPart(typeof(AccountController).Assembly)); + }); + var app = builder.Build(); + if (observe is not null) + app.Use((context, next) => { observe(context); return next(context); }); + app.MapLearnStackOpenApi(); + app.MapGet("/healthz", () => Results.Ok()).WithName("HealthCheck").WithMetadata(InfrastructureOperationMetadata.Health); + app.MapControllers(); + await app.StartAsync(); + return app; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Api/UndeclaredHttpOperationTests.cs b/backend/tests/LearnStack.Tests.Integration/Api/UndeclaredHttpOperationTests.cs new file mode 100644 index 000000000..33948a332 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Api/UndeclaredHttpOperationTests.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Reflection; +using LearnStack.Api.Authentication; +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Api; + +/// The actual Program refuses a matched undeclared anonymous action before executing its handler. +[Collection(TenantAuthenticationDiagnosticsTestGroup.Name)] +public sealed class UndeclaredHttpOperationTests +{ + [Fact] + public async Task Actual_Program_refuses_an_undeclared_controller_for_anonymous_and_normalized_signed_requests() + { + await using var issuer = await TenantAuthenticationIssuer.StartAsync(); + await using var factory = new UndeclaredFactory(issuer); + using var client = factory.CreateClient(); + var endpoint = Assert.Single(factory.Services.GetRequiredService().Endpoints, + candidate => candidate.Metadata.GetMetadata()?.ControllerTypeInfo.AsType() == typeof(UndeclaredHttpProbeController)); + Assert.NotNull(endpoint.Metadata.GetMetadata()); + Assert.Null(endpoint.Metadata.GetMetadata()); + UndeclaredHttpProbeController.Reset(); + + using var anonymous = await TenantAuthenticationHttpRequest.Send(client, + TenantAuthenticationReconciliationHost.TenantHost, UndeclaredHttpProbeController.Path); + await TenantAuthenticationHttpTests.Problem(anonymous, HttpStatusCode.NotFound, "not_found"); + using var signed = await TenantAuthenticationHttpRequest.Send(client, + TenantAuthenticationReconciliationHost.TenantHost, UndeclaredHttpProbeController.Path, + token: issuer.Token(issuer.Without("organization_id"))); + await TenantAuthenticationHttpTests.Problem(signed, HttpStatusCode.Forbidden, "forbidden"); + + Assert.Equal(0, UndeclaredHttpProbeController.Dispatches); + Assert.Empty(factory.Ports.Memberships); + Assert.False(factory.Services.GetRequiredService>().IsValueCreated); + } + + private sealed class UndeclaredFactory(TenantAuthenticationIssuer issuer) : ConstructionOnlyApiFactory + { + internal ReconciliationPorts Ports { get; } = new(); + + protected override void ConfigureTestHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.UseSetting(TenantAuthenticationOptions.IssuerConfigurationKey, issuer.Issuer); + builder.ConfigureTestServices(services => + { + services.AddSingleton(new FixtureTenantIdentityBindingResolver( + issuer.Issuer, ["opaque-keycloak-subject"])); + services.AddSingleton(Ports); + services.AddSingleton(Ports); + services.AddSingleton(Ports); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddControllers().ConfigureApplicationPartManager(manager => manager.ApplicationParts.Add(new ProbePart())); + }); + } + } + + private sealed class ProbePart : ApplicationPart, IApplicationPartTypeProvider + { + public override string Name => nameof(ProbePart); + public IEnumerable Types => [typeof(UndeclaredHttpProbeController).GetTypeInfo()]; + } +} + +[ApiController] +[AllowAnonymous] +[Route("undeclared-operation-proof")] +public sealed class UndeclaredHttpProbeController : ControllerBase +{ + internal const string Path = "/api/v1/undeclared-operation-proof"; + private static int _dispatches; + internal static int Dispatches => Volatile.Read(ref _dispatches); + internal static void Reset() => Interlocked.Exchange(ref _dispatches, 0); + + [HttpGet] + public IActionResult Get() + { + Interlocked.Increment(ref _dispatches); + return Ok("undeclared-handler-executed"); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/ApiVersioningHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/ApiVersioningHttpTests.cs index 57af6d074..75c553713 100644 --- a/backend/tests/LearnStack.Tests.Integration/ApiVersioningHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/ApiVersioningHttpTests.cs @@ -143,7 +143,7 @@ public async Task Unversioned_Infrastructure_Endpoints_Are_Absent_From_The_Versi public async Task Scalar_Reference_Ui_Is_Served() { // Redirects are NOT followed here on purpose. The default - // WebApplicationFactory client follows them, which would have hidden + // browser client follows them, which would have hidden // that /openapi answers 302 -> /openapi/ and reported a green 200 for // a URL that does not serve the page. using var client = fixture.CreateClient( @@ -279,13 +279,13 @@ private async Task GetDocumentAsync(string major) /// declaring v2 — plus a second live major so the per-document /// filtering has something to filter. /// -public sealed class ApiVersioningFixture : ConstructionOnlyApiFactory +public sealed class ApiVersioningFixture : ComponentApiFactory { protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); - builder.ConfigureTestServices(services => + builder.ConfigureServices(services => { // Keep only this file's probes — the enforcement probes in // VersionedRouteEnforcementTests are deliberately broken and would diff --git a/backend/tests/LearnStack.Tests.Integration/AuthenticatedHttpSurfaceRuntimeTests.Metadata.cs b/backend/tests/LearnStack.Tests.Integration/AuthenticatedHttpSurfaceRuntimeTests.Metadata.cs index efc94eb47..0dae857fb 100644 --- a/backend/tests/LearnStack.Tests.Integration/AuthenticatedHttpSurfaceRuntimeTests.Metadata.cs +++ b/backend/tests/LearnStack.Tests.Integration/AuthenticatedHttpSurfaceRuntimeTests.Metadata.cs @@ -1,6 +1,8 @@ using FluentAssertions; using LearnStack.Api.Common; using LearnStack.Modules.Education.Application.Contracts.PublicReads; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.SharedKernel.Pagination; using LearnStack.Modules.Tenancy.Application.Contracts.Account; using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; using Microsoft.AspNetCore.Authorization; @@ -27,6 +29,7 @@ private static void AssertActionFilters(ControllerActionDescriptor action) { "Get" => typeof(PublicSite), "GetSite" => typeof(AccountSite), + "List" => typeof(Page), "Catalog" => typeof(PublicCourseCatalog), "Course" => typeof(PublicCourseDetail), "Lesson" => typeof(PublicLessonDetail), @@ -128,7 +131,7 @@ public async Task Attached_filter_conventions_change_effective_actions_without_c AssertFilterProviders(factory.Services.GetServices()); var actions = factory.Services.GetRequiredService().Endpoints .Select(endpoint => endpoint.Metadata.GetMetadata()).OfType().ToArray(); - actions.Should().HaveCount(9); + actions.Should().HaveCount(10); foreach (var action in actions) { Action assert = () => AssertActionFilters(action); @@ -152,7 +155,7 @@ public async Task Real_endpoint_conventions_cannot_augment_the_authorization_met using var client = factory.CreateClient(); var endpoints = factory.Services.GetRequiredService().Endpoints .Where(endpoint => endpoint.Metadata.GetMetadata() is not null).ToArray(); - endpoints.Should().HaveCount(9); + endpoints.Should().HaveCount(10); foreach (var endpoint in endpoints) { var action = endpoint.Metadata.GetMetadata()!; diff --git a/backend/tests/LearnStack.Tests.Integration/AuthenticatedHttpSurfaceRuntimeTests.cs b/backend/tests/LearnStack.Tests.Integration/AuthenticatedHttpSurfaceRuntimeTests.cs index 3f55487a3..3361ce062 100644 --- a/backend/tests/LearnStack.Tests.Integration/AuthenticatedHttpSurfaceRuntimeTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/AuthenticatedHttpSurfaceRuntimeTests.cs @@ -2,8 +2,11 @@ using FluentAssertions; using LearnStack.Api.Account; using LearnStack.Api.PublicReads; +using LearnStack.Api.Identity; +using LearnStack.Api.Pagination; using LearnStack.Api.Tenancy; using LearnStack.Modules.Education.Application.Contracts.PublicReads; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; using LearnStack.Modules.Tenancy.Application.Contracts.Account; using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; using MediatR; @@ -84,12 +87,24 @@ private static void AssertEndpointAuthorizationControls(ControllerActionDescript AssertEndpointAuthorization(action, metadata); Action extra = () => AssertEndpointAuthorization(action, metadata.Append(new UnexpectedAnonymousAttribute())); extra.Should().Throw("custom IAllowAnonymous metadata cannot augment any approved MVC endpoint"); - if (action.ControllerTypeInfo.AsType() != typeof(AccountController)) + if (action.ControllerTypeInfo.AsType() != typeof(AccountController) + && action.ControllerTypeInfo.AsType() != typeof(MembershipsController)) { Action substitute = () => AssertEndpointAuthorization(action, metadata.Where(item => item is not IAllowAnonymous).Append(new UnexpectedAnonymousAttribute())); substitute.Should().Throw("a public endpoint requires the exact standard AllowAnonymousAttribute"); } + else + { + var authorization = metadata.OfType().Single(); + foreach (var policy in new[] { null, "LearnStackOperation:unregistered.read" }) + { + var changed = new AuthorizeAttribute { AuthenticationSchemes = authorization.AuthenticationSchemes, Policy = policy }; + Action substitute = () => AssertEndpointAuthorization(action, + metadata.Where(item => !ReferenceEquals(item, authorization)).Append(changed)); + substitute.Should().Throw("the exact named live operation policy is required on the built endpoint"); + } + } AssertEndpointAuthorization(action, metadata); } @@ -100,13 +115,15 @@ private static void AssertEndpointAuthorization(ControllerActionDescriptor actio var authorizations = items.OfType().ToArray(); items.OfType().Should().BeEmpty("explicit policies are a separate framework authorization channel"); items.OfType().Should().BeEmpty("requirement metadata must not augment the approved authorization contract"); - if (action.ControllerTypeInfo.AsType() == typeof(AccountController)) + if (action.ControllerTypeInfo.AsType() == typeof(AccountController) + || action.ControllerTypeInfo.AsType() == typeof(MembershipsController)) { - anonymous.Should().BeEmpty("Account metadata must never bypass authorization"); + anonymous.Should().BeEmpty("authenticated endpoint metadata must never bypass authorization"); var authorization = authorizations.Should().ContainSingle().Which.Should().BeOfType().Subject; authorization.AuthenticationSchemes.Should().Be("TenantBearer"); authorization.Roles.Should().BeNull(); - authorization.Policy.Should().BeNull(); + authorization.Policy.Should().Be("LearnStackOperation:" + (action.ControllerTypeInfo.AsType() == typeof(AccountController) + ? "tenancy.account_site.read" : "identity.membership.list")); } else { @@ -206,8 +223,13 @@ private static async Task DispatchedRequest(ControllerActionDescriptor act var context = new DefaultHttpContext(); context.Features.Set(HostClassification.Platform("census.invalid")); controller.ControllerContext = new ControllerContext { HttpContext = context }; - var arguments = action.MethodInfo.GetParameters().Select(parameter => parameter.ParameterType == typeof(CancellationToken) - ? (object)CancellationToken.None : "census-slug").ToArray(); + var arguments = action.MethodInfo.GetParameters().Select(parameter => parameter.ParameterType switch + { + var type when type == typeof(CancellationToken) => (object)CancellationToken.None, + var type when type == typeof(CursorPaginationRequest) => new CursorPaginationRequest(), + var type when type == typeof(string) => "census-slug", + _ => throw new InvalidOperationException("Unclassified action parameter"), + }).ToArray(); var execution = (Task)action.MethodInfo.Invoke(controller, arguments)!; Func invoke = () => execution; await invoke.Should().ThrowAsync(); @@ -227,6 +249,7 @@ private static void AssertUnion(IEnumerable actual) Mvc("public/courses/{slug}/lessons/{lessonSlug}", "GET", typeof(PublicEducationController), "Lesson", typeof(GetPublicLessonQuery)), Mvc("public/courses/{slug}/lessons/{lessonSlug}", "HEAD", typeof(PublicEducationController), "HeadLesson", typeof(GetPublicLessonQuery)), Mvc("account/site", "GET", typeof(AccountController), "GetSite", typeof(GetAccountSiteQuery)), + Mvc("identity/memberships", "GET", typeof(MembershipsController), "List", typeof(ListMembershipsQuery)), }; var infrastructure = new[] { diff --git a/backend/tests/LearnStack.Tests.Integration/AuthenticatedRateLimitTests.cs b/backend/tests/LearnStack.Tests.Integration/AuthenticatedRateLimitTests.cs index 36da519ab..83ea993b7 100644 --- a/backend/tests/LearnStack.Tests.Integration/AuthenticatedRateLimitTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/AuthenticatedRateLimitTests.cs @@ -4,6 +4,8 @@ using FluentAssertions; using LearnStack.Api.Authentication; using LearnStack.Api.Tenancy; +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.SharedKernel.Permissions; using LearnStack.Tests.Integration.Database; using LearnStack.Tools.Seeder; using Microsoft.AspNetCore.Builder; @@ -128,11 +130,32 @@ public async Task Production_visitor_quota_blocks_validated_subjects_with_replen fixture.Observation.Reads.Should().Be(reads + RateLimitingExtensions.AnonymousPermitPerWindow); } - private static void AddQuotaBindings(IServiceCollection services, TenantAuthenticationIssuer issuer) => + private static void AddQuotaBindings(IServiceCollection services, TenantAuthenticationIssuer issuer) + { services.AddSingleton(new FixtureTenantIdentityBindingResolver(issuer.Issuer, ["opaque-keycloak-subject", "independent-subject-B", "fresh-subject", "admission-subject-A", "admission-subject-B", "admission-subject-C", "blocked-retiring-subject", "blocked-retained-subject", "blocked-competing-subject"])); + // These admission proofs own fixture bindings outside Identity. Substitute + // only their bounded live-coverage read; actual HTTP authority is exercised + // separately against committed Membership rows in IdentityAuthorizationHttpTests. + services.AddSingleton(); + } + + private sealed class AdmissionAuthorizationReader : IIdentityAuthorizationReader + { + public Task ReadAsync(AuthorizationCandidate candidate, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var covered = candidate.IsActive && candidate.Actor.Value == TenantAuthenticationIssuer.Actor + && candidate.OrganizationId is null + && (candidate.TenantId == SeedData.English.TenantId || candidate.TenantId == SeedData.Yoga.TenantId); + return Task.FromResult(new IdentityAuthorizationResult(covered ? IdentityAuthorizationOutcome.Covered + : IdentityAuthorizationOutcome.NotCovered, candidate.Actor, candidate.TenantId, candidate.OrganizationId, + covered ? [new AuthorizationMembershipFacts(Guid.Parse("019b24b6-0cf0-7000-8000-0000000000a1"), null, [])] : [])); + } + } private sealed class SubjectQuotaObservation : IStartupFilter, ILogEventSink { diff --git a/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs index f23cc741f..dca64de37 100644 --- a/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs @@ -21,7 +21,7 @@ namespace LearnStack.Tests.Integration; /// -/// The authority ceiling through the real pipeline, and the property that makes it +/// The authority ceiling through the canonical application pipeline in an isolated component host, and the property that makes it /// safe to have one: its refusal is indistinguishable from an unresolvable host's. /// /// @@ -55,6 +55,8 @@ public async Task A_PublicSurface_Request_Is_Reachable_Anonymously() var body = await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.OK, body); body.Should().Contain("reached"); + fixture.Policies.PublicLookups.Should().BeGreaterThan(0, + "the positive reaches the real canonical authorization stage with its exact public policy"); } [Fact] @@ -64,6 +66,8 @@ public async Task An_Unmarked_Request_Is_Refused_Under_A_Host_Only_Context() response.StatusCode.Should().Be(HttpStatusCode.NotFound, "a host-only context reaches only [PublicSurface] request types"); + fixture.Policies.GuardedLookups.Should().Be(0, + "the actual TenantContextBehavior refuses before canonical authorization consults any policy"); } [Fact] @@ -175,17 +179,19 @@ public sealed class HostClassificationMeter : ICollectionFixtureA host whose resolver maps one tenant host and nothing else. -public sealed class AuthorityCeilingFixture : ConstructionOnlyApiFactory +public sealed class AuthorityCeilingFixture : ComponentApiFactory { public const string TenantHost = "school.example.com"; public static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-0000000000d1"); + internal CeilingPolicyCatalog Policies { get; } = new(); + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); - builder.ConfigureTestServices(services => + builder.ConfigureServices(services => { services.AddControllers(options => options.Conventions.Insert(0, new TestControllerFilter( @@ -206,6 +212,7 @@ protected override void ConfigureTestHost(IWebHostBuilder builder) // Registered as audited-nothing: the pipeline refuses a request the catalogue // does not know, and these two exist only to exercise the authority ceiling. services.AddSingleton(); + ComponentOperationRegistration.AddCeilingOperations(services, Policies); // TransactionBehavior opens a real transaction on every request that // reaches step 6, and this host has no database — the ceiling refusal diff --git a/backend/tests/LearnStack.Tests.Integration/BackgroundJobProductionManifestTests.cs b/backend/tests/LearnStack.Tests.Integration/BackgroundJobProductionManifestTests.cs index 6cd9f07de..77f3eb0e3 100644 --- a/backend/tests/LearnStack.Tests.Integration/BackgroundJobProductionManifestTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/BackgroundJobProductionManifestTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using LearnStack.Api.Account; using LearnStack.Api.Jobs; +using LearnStack.Api.Identity; using LearnStack.Api.Messaging; using LearnStack.Api.PublicReads; using LearnStack.Application.Jobs; @@ -194,8 +195,9 @@ private static void AssertRoutes(IServiceProvider services) }).Order(StringComparer.Ordinal).ToArray(); var applicationRoutes = new[] { - // ADR-0064 adds one authenticated read, never a job operator route. + // The two authenticated reads add no job operator route. "account/site|GET|" + typeof(AccountController).FullName + ".GetSite", + "identity/memberships|GET|" + typeof(MembershipsController).FullName + ".List", "public/site|GET|" + typeof(PublicSiteController).FullName + ".Get", "public/site|HEAD|" + typeof(PublicSiteController).FullName + ".Head", "public/courses|GET|" + typeof(PublicEducationController).FullName + ".Catalog", diff --git a/backend/tests/LearnStack.Tests.Integration/ComponentApiFactory.cs b/backend/tests/LearnStack.Tests.Integration/ComponentApiFactory.cs new file mode 100644 index 000000000..8d99b8854 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/ComponentApiFactory.cs @@ -0,0 +1,177 @@ +using LearnStack.Api.Common; +using LearnStack.Api.Composition; +using LearnStack.Api.Tenancy; +using LearnStack.Api.Versioning; +using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace LearnStack.Tests.Integration; + +/// +/// Isolated host for legacy routing, MVC, error, body and tenancy component proofs. +/// This is not Program and makes no claim about the live HTTP authorization boundary. +/// Program's undeclared-operation refusal is covered by UndeclaredHttpOperationTests. +/// +/// +/// The production component registrations and canonical MediatR pipeline remain intact. +/// Tests that dispatch application requests must supply exact test-owned policies and +/// provenance; this host supplies neither a human candidate nor a permissive authorizer. +/// No probe is disguised as infrastructure or as a declared production HTTP operation. +/// +public class ComponentApiFactory : IDisposable, IAsyncDisposable +{ + private WebApplication? _app; + private bool _disposed; + + public IServiceProvider Services => Application.Services; + + public HttpClient CreateClient() => CreateClient(new WebApplicationFactoryClientOptions()); + + public HttpClient CreateClient(WebApplicationFactoryClientOptions options) + { + ArgumentNullException.ThrowIfNull(options); + // TestServer's client does not follow redirects; these component tests + // observe redirects directly rather than exercising a browser transport. + var client = Application.GetTestClient(); + client.BaseAddress = options.BaseAddress; + return client; + } + + public ComponentApiFactory WithWebHostBuilder(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + return new ConfiguredFactory(ConfigureComponentSettings, ConfigureTestHost, configure); + } + + protected virtual void ConfigureComponentSettings(WebApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + } + + protected virtual void ConfigureTestHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + } + + private WebApplication Application + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_app is not null) return _app; + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ApplicationName = typeof(Program).Assembly.FullName, + EnvironmentName = Environments.Development, + ContentRootPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../src/LearnStack.Api")), + }); + builder.WebHost.UseTestServer(); + // These offline component proofs own no network telemetry collector. + builder.Configuration["Telemetry:OtlpEndpoint"] = null; + ConfigureComponentSettings(builder); + builder.AddLearnStackCrossCuttingFoundation(DeploymentMode.Development, + typeof(LearnStack.Modules.Tenancy.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Education.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Identity.Application.AssemblyMarker).Assembly); + builder.Services.AddLearnStackTenancyEdge(builder.Configuration); + builder.Services.AddLearnStackPersistence(builder.Configuration); + builder.Services.AddLearnStackRateLimiting(); + builder.Services.AddLearnStackApiVersioning(); + builder.Services.AddControllers(options => + options.Conventions.Insert(0, new ComponentControllerFilter())); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + ConfigureTestHost(builder.WebHost); + ConstructionOnlyApiFactory.RemoveDispatcher(builder.Services); + _app = builder.Build(); + _app.UseExceptionHandler(); + _app.MapLearnStackClientErrors(); + _app.UseLearnStackCorrelationHeader(); + _app.UseRateLimiter(); + _app.UseLearnStackVisitorAdmission(); + _app.UseLearnStackRequestBodyLimit(); + _app.MapLearnStackOpenApi(); + _app.UseLearnStackHostClassification(); + _app.UseLearnStackTenantResolution(); + _app.UseLearnStackTenantAssertions(); + _app.MapGet("/healthz", () => Results.Ok(new { status = "healthy" })); + _app.MapControllers(); + _app.StartAsync().GetAwaiter().GetResult(); + return _app; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + if (disposing && _app is not null) _app.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _disposed = true; + } + + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore(); + Dispose(disposing: false); + GC.SuppressFinalize(this); + } + + protected virtual async ValueTask DisposeAsyncCore() + { + if (_disposed) return; + if (_app is not null) await _app.DisposeAsync(); + _disposed = true; + } + + private sealed class ComponentControllerFilter : IApplicationModelConvention + { + public void Apply(ApplicationModel application) + { + ArgumentNullException.ThrowIfNull(application); + foreach (var controller in application.Controllers.Where(controller => + !typeof(ITestOnlyController).IsAssignableFrom(controller.ControllerType)).ToArray()) + application.Controllers.Remove(controller); + } + } + + private sealed class ConfiguredFactory( + Action settings, + Action original, + Action additional) : ComponentApiFactory + { + protected override void ConfigureComponentSettings(WebApplicationBuilder builder) => settings(builder); + protected override void ConfigureTestHost(IWebHostBuilder builder) + { + original(builder); + additional(builder); + } + } + + private sealed class NoHumanAuthorization : IProtectedOperationAuthorizer + { + public Task AuthorizeAsync(OperationInvocation invocation, OperationPolicy policy, + AuthorizationCandidate candidate, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("The component host has no human authorization source."); + } + + private sealed class NoClaimAssertionRecorder : IClaimContextAssertionRecorder + { + public Task RecordAsync(ITenantContext context, TenantAssertionDimension dimension) => + throw new InvalidOperationException("The component host cannot attest a confirmed human claim assertion."); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistration.Database.cs b/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistration.Database.cs new file mode 100644 index 000000000..eeb64c88b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistration.Database.cs @@ -0,0 +1,63 @@ +using System.Diagnostics.Metrics; +using LearnStack.Api.Composition; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Caching; +using LearnStack.Infrastructure.MultiTenancy; +using LearnStack.Infrastructure.Validation; +using LearnStack.Modules.Customization.Application.Authorization; +using LearnStack.Modules.Education.Application.Authorization; +using LearnStack.Modules.Identity.Infrastructure.Authorization; +using LearnStack.Modules.Tenancy.Application.Authorization; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using LearnStack.SharedKernel.Validation; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace LearnStack.Tests.Integration; + +internal static partial class ComponentOperationRegistration +{ + /// + /// Database component composition through actual persistence registrations and all + /// canonical behaviors. It is neither Program nor the Seeder entry authority. + /// The caller owns the source and explicitly supplies each component capability. + /// + internal static IServiceCollection CreateDatabaseServices(NpgsqlDataSource dataSource, ITenantContext? context) + { + ArgumentNullException.ThrowIfNull(dataSource); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(dataSource); + services.AddSingleton(new Lazy(() => dataSource)); + services.AddSingleton(new StaticTenantContextAccessor(context)); + services.AddTransient(provider => + provider.GetRequiredService().Current ?? UnresolvedTenantContext.Instance); + // The real canonical registration supplies contexts, scoped transaction/audit + // state, interceptors, stores and app-role writers. Its unused credential owners + // remain lazy; all component data access borrows the caller-owned source above. + services.AddLearnStackPersistence(new ConfigurationBuilder().Build()); + services.AddSingleton(provider => new InMemoryCacheService( + provider.GetRequiredService(), provider.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(NoReservedHosts.Instance); + services.AddSingleton(NullHostResolutionInvalidator.Instance); + services.AddIdentityOperationPolicies(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddLearnStackMediatRPipeline( + typeof(LearnStack.Modules.Tenancy.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Education.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Identity.Application.AssemblyMarker).Assembly); + return services; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistration.ExactAuthority.cs b/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistration.ExactAuthority.cs new file mode 100644 index 000000000..ab7958824 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistration.ExactAuthority.cs @@ -0,0 +1,127 @@ +using System.Collections.Frozen; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LearnStack.Tests.Integration; + +internal static partial class ComponentOperationRegistration +{ + /// + /// Explicit authority for named component obligations, never a production permission + /// or HTTP admission proof. The canonical behavior and every unlisted policy remain intact. + /// + internal static void AddExactComponentAuthority(IServiceCollection services, params OperationPolicy[] exactPolicies) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(exactPolicies); + var policies = exactPolicies.ToArray(); + if (policies.Length == 0 || policies.Any(policy => policy is null + || policy.Kind != OperationPolicyKind.FiniteTrusted || policy.ReadScope != OperationReadScope.Scoped + || policy.TrustedEventHandlerType is not null || policy.RequiresOrglessWrite + || policy.AllowedSources.Length != 1 || policy.AllowedSources[0] != AuthorizationSourceKind.Seed + || !policy.Id.StartsWith("tests.component.", StringComparison.Ordinal)) + || policies.Select(policy => policy.RequestType).Distinct().Count() != policies.Length + || policies.Select(policy => policy.Id).Distinct(StringComparer.Ordinal).Count() != policies.Length + || services.Any(descriptor => descriptor.ServiceType == typeof(ComponentAuthority))) + throw new InvalidOperationException("Component authority requires one explicit finite request set."); + + var catalog = services.Single(descriptor => descriptor.ServiceType == typeof(IOperationPolicyCatalog)); + if (catalog.IsKeyedService || catalog.Lifetime != ServiceLifetime.Singleton + || (catalog.ImplementationInstance is not IOperationPolicyCatalog && catalog.ImplementationFactory is null)) + throw new InvalidOperationException("Component authority requires the existing frozen policy catalogue."); + + var authority = new ComponentAuthority(policies); + services.AddSingleton(authority); + services.Replace(ServiceDescriptor.Singleton(provider => + new ComponentCatalog(catalog.ImplementationInstance as IOperationPolicyCatalog + ?? (IOperationPolicyCatalog)catalog.ImplementationFactory!(provider), authority))); + // No ITenantContext dependency here: binding admission can resolve the graph + // before it installs the actual context. Bind only at the first exact capture. + services.AddScoped(_ => new ComponentSource(authority)); + } + + private sealed class ComponentAuthority(OperationPolicy[] policies) + { + internal FrozenDictionary Policies { get; } = policies.ToFrozenDictionary(policy => policy.RequestType); + } + + private sealed class ComponentCatalog : IOperationPolicyCatalog + { + private readonly IOperationPolicyCatalog _actual; + private readonly ComponentAuthority _authority; + + internal ComponentCatalog(IOperationPolicyCatalog actual, ComponentAuthority authority) + { + _actual = actual; + _authority = authority; + All = actual.All.Where(policy => !authority.Policies.ContainsKey(policy.RequestType)) + .Concat(authority.Policies.Values).ToArray(); + if (All.Select(policy => policy.Id).Distinct(StringComparer.Ordinal).Count() != All.Count) + throw new InvalidOperationException("Component policy identities must remain unambiguous."); + } + + public IReadOnlyCollection All { get; } + public IReadOnlyCollection Definitions => _actual.Definitions; + public bool TryGet(Type requestType, out OperationPolicy policy) => + _authority.Policies.TryGetValue(requestType, out policy!) || _actual.TryGet(requestType, out policy!); + public bool TryGetDefinition(PermissionScope scope, string key, out PermissionRegistration definition) => + _actual.TryGetDefinition(scope, key, out definition!); + } + + private sealed class ComponentSource(ComponentAuthority authority) + : TrustedOperationDescriptor.MintingSource, ITrustedOperationSource + { + private readonly object _gate = new(); + private ContextSnapshot? _snapshot; + private bool _invalidated; + + public TrustedOperationDescriptor? Capture(Type requestType, ITenantContext context) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(context); + lock (_gate) + { + if (!CanIssue || _invalidated || !authority.Policies.ContainsKey(requestType)) return null; + _snapshot ??= new ContextSnapshot(context); + if (!_snapshot.Matches(context)) + { + if (ReferenceEquals(_snapshot.Context, context)) _invalidated = true; + return null; + } + return ForOperation(AuthorizationSourceKind.Seed, requestType, context, _snapshot); + } + } + + protected override bool IsEvidenceCurrent(object? evidence) + { + lock (_gate) + { + if (_invalidated || !ReferenceEquals(evidence, _snapshot) || _snapshot is not { } snapshot) return false; + if (snapshot.Matches(snapshot.Context)) return true; + _invalidated = true; + return false; + } + } + } + + private sealed class ContextSnapshot(ITenantContext context) + { + private readonly bool _resolved = context.IsResolved; + private readonly TenantId? _tenant = context.IsResolved ? context.TenantId : null; + private readonly OrganizationId? _organization = context.OrganizationId; + private readonly UserId? _actor = context.UserId; + private readonly UserId? _causalActor = context.CausalActorUserId; + private readonly TenantContextOrigin? _origin = context.Origin; + private readonly HostScope? _host = context.HostScope; + internal ITenantContext Context { get; } = context; + + internal bool Matches(ITenantContext current) => ReferenceEquals(Context, current) + && current.IsResolved == _resolved && (!_resolved || current.TenantId == _tenant) + && current.OrganizationId == _organization && current.UserId == _actor + && current.CausalActorUserId == _causalActor && current.Origin == _origin + && ReferenceEquals(current.HostScope, _host); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistration.cs b/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistration.cs new file mode 100644 index 000000000..3b3262cff --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistration.cs @@ -0,0 +1,100 @@ +using LearnStack.Application.Authorization; +using LearnStack.Application.Messaging; +using LearnStack.Application.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Tests.Integration; + +/// Explicit, bounded authority for isolated component obligations; canonical authorization remains active. +internal static partial class ComponentOperationRegistration +{ + internal static void AddValidationOperation(IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + UseNoDatabaseCompletion(services, typeof(TestValidationCommand)); + } + + internal static void AddCeilingOperations(IServiceCollection services, CeilingPolicyCatalog policies) + { + services.Replace(ServiceDescriptor.Singleton(policies)); + UseNoDatabaseCompletion(services, typeof(CeilingPublicQuery), typeof(CeilingGuardedQuery)); + } + + private static void UseNoDatabaseCompletion(IServiceCollection services, params Type[] requests) + { + // Keep all seven actual behaviors and the actual slot-5 coordinator/evidence + // owner. These particular test operations are explicitly Off and own no DB; + // omit only the optional physical-completion evidence participant, rather + // than inventing a DbTransaction or a SQL announcement for this unit double. + services.Replace(ServiceDescriptor.Scoped(provider => + { + var catalog = provider.GetRequiredService(); + if (requests.Any(type => !catalog.TryGet(type, out var registration) + || !registration.WritesNoRow || registration.Entries.Count != 0) + || provider.GetRequiredService() is not NoDatabaseUnitOfWork) + throw new InvalidOperationException("Component completion requires explicit Off operations and the no-database unit."); + return new TransactionExecutionCoordinator( + provider.GetRequiredService(), provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService(), + provider.GetRequiredService>(), + provider.GetRequiredService()); + })); + } + + private sealed class ValidationPolicy : IOperationPolicySource + { + public IEnumerable Definitions => []; + public IEnumerable Policies => + [OperationPolicy.ForTrusted("tests.validation.execute", AuthorizationSourceKind.Seed)]; + } + + private sealed class ValidationSource : TrustedOperationDescriptor.MintingSource, ITrustedOperationSource + { + protected override bool IsEvidenceCurrent(object? evidence) => + ReferenceEquals(evidence, TestResolvedTenantContext.Instance); + + public TrustedOperationDescriptor? Capture(Type requestType, ITenantContext context) => + requestType == typeof(TestValidationCommand) && ReferenceEquals(context, TestResolvedTenantContext.Instance) + ? ForOperation(AuthorizationSourceKind.Seed, requestType, context, context) : null; + } +} + +/// Counts actual catalogue consultation so the step-4 refusal cannot pass merely at step 5. +internal sealed class CeilingPolicyCatalog : IOperationPolicyCatalog +{ + private readonly OperationPolicyCatalog _inner = new([new CeilingPolicies()]); + private int _publicLookups; + private int _guardedLookups; + public int PublicLookups => Volatile.Read(ref _publicLookups); + public int GuardedLookups => Volatile.Read(ref _guardedLookups); + public IReadOnlyCollection All => _inner.All; + public IReadOnlyCollection Definitions => _inner.Definitions; + public bool TryGet(Type requestType, out OperationPolicy policy) + { + if (requestType == typeof(CeilingPublicQuery)) Interlocked.Increment(ref _publicLookups); + if (requestType == typeof(CeilingGuardedQuery)) Interlocked.Increment(ref _guardedLookups); + return _inner.TryGet(requestType, out policy); + } + public bool TryGetDefinition(PermissionScope scope, string key, out PermissionRegistration definition) => + _inner.TryGetDefinition(scope, key, out definition); + + private sealed class CeilingPolicies : IOperationPolicySource + { + private static readonly PermissionDefinition Read = new(PermissionScope.Tenant, "tests.ceiling.read"); + private static readonly PermissionDefinition Admin = new(PermissionScope.Tenant, "tests.ceiling.admin"); + public IEnumerable Definitions => [new(Read, false), new(Admin, true)]; + public IEnumerable Policies => + [ + OperationPolicy.ForPublic("tests.ceiling.public", Read), + OperationPolicy.ForProtected("tests.ceiling.guarded", Admin), + ]; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistrationTests.cs b/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistrationTests.cs new file mode 100644 index 000000000..840a1fa62 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/ComponentOperationRegistrationTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using LearnStack.Application.Authorization; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Audit; +using LearnStack.Infrastructure.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration; + +/// Bounds the explicit component authority; this supplies no production or HTTP permission proof. +public sealed class ComponentOperationRegistrationTests +{ + [Fact] + public async Task Exact_component_authority_preserves_unlisted_policies_tenant_gates_and_scoped_provenance() + { + var original = new OperationPolicyCatalog([new OriginalPolicies()]); + var services = new ServiceCollection(); + services.AddSingleton(original); + var selected = OperationPolicy.ForTrusted("tests.component.boundary.selected", AuthorizationSourceKind.Seed); + OperationPolicy[] supplied = [selected]; + ComponentOperationRegistration.AddExactComponentAuthority(services, supplied); + supplied[0] = OperationPolicy.ForTrusted("tests.component.boundary.injected", AuthorizationSourceKind.Seed); + await using var provider = services.BuildServiceProvider(); + var decorated = provider.GetRequiredService(); + decorated.TryGet(typeof(Selected), out var admitted).Should().BeTrue(); + admitted.Should().BeSameAs(selected, "registration freezes the explicitly supplied array"); + original.TryGet(typeof(Selected), out var untouchedSelected).Should().BeTrue(); + untouchedSelected.Kind.Should().Be(OperationPolicyKind.Protected, "the underlying catalogue is never changed"); + original.TryGet(typeof(Unlisted), out var untouchedOther).Should().BeTrue(); + decorated.TryGet(typeof(Unlisted), out var unlisted).Should().BeTrue(); + unlisted.Should().BeSameAs(untouchedOther); + decorated.TryGet(typeof(Unknown), out _).Should().BeFalse(); + decorated.Definitions.Should().Equal(original.Definitions); + decorated.All.Should().HaveCount(original.All.Count); + decorated.TryGetDefinition(PermissionScope.Tenant, OriginalPolicies.Permission.Key, out var definition).Should().BeTrue(); + definition.Should().BeSameAs(original.Definitions.Single()); + + var context = new MutableContext(); + TrustedOperationDescriptor captured; + await using (var scope = provider.CreateAsyncScope()) + { + var source = scope.ServiceProvider.GetRequiredService(); + source.Capture(typeof(Unlisted), UnresolvedTenantContext.Instance).Should().BeNull( + "an unlisted request cannot bind the source before real context admission"); + captured = source.Capture(typeof(Selected), context)!; + captured.Should().NotBeNull(); + captured.Matches(typeof(Selected), context).Should().BeTrue(); + captured.Matches(typeof(Unlisted), context).Should().BeFalse(); + source.Capture(typeof(Unknown), context).Should().BeNull(); + source.Capture(typeof(Selected), new MutableContext()).Should().BeNull( + "equal scope values on another context are not the admitted context reference"); + captured.IsActive.Should().BeTrue(); + context.CorrelationId = "00-12345678901234567890123456789012-1234567890123456-01"; + captured.IsActive.Should().BeTrue("a nested logging span changes observability, not the admitted authority"); + context.OrganizationId = OrganizationId.From(Guid.CreateVersion7()); + captured.IsActive.Should().BeFalse(); + context.OrganizationId = null; + captured.IsActive.Should().BeFalse("an observed invalidated scope cannot be revived by restoring its values"); + source.Capture(typeof(Selected), context).Should().BeNull(); + } + captured.IsActive.Should().BeFalse(); + await using (var scope = provider.CreateAsyncScope()) + { + var source = scope.ServiceProvider.GetRequiredService(); + captured = source.Capture(typeof(Selected), context)!; + captured.IsActive.Should().BeTrue("a new scope has a new finite lifetime"); + } + captured.IsActive.Should().BeFalse("disposing the actual scope revokes its shared descriptor"); + + var entered = false; + Task> Work() + { + entered = true; + return Task.FromResult(Result.Ok(None.Value)); + } + var unresolved = await new TenantContextBehavior>(UnresolvedTenantContext.Instance) + .Handle(new Selected(), Work, CancellationToken.None); + unresolved.Error!.Code.Should().Be("tenant_mismatch"); + entered.Should().BeFalse("component policy does not bypass the unresolved-tenant gate"); + var hostOnly = TenantContextFactory.Create(new TenantResolutionAttempt { HostTenantId = context.TenantId }).Value!; + var ceiling = await new TenantContextBehavior>(hostOnly) + .Handle(new Selected(), Work, CancellationToken.None); + ceiling.Error!.Code.Should().Be("not_found"); + entered.Should().BeFalse("component policy does not bypass the authority ceiling"); + + foreach (var invalid in new[] + { + OperationPolicy.ForTrusted("tests.component.boundary.both", AuthorizationSourceKind.Seed, AuthorizationSourceKind.BackgroundJob), + OperationPolicy.ForProtected("tests.component.boundary.protected", OriginalPolicies.Permission), + OperationPolicy.ForTrusted("tests.noncomponent", AuthorizationSourceKind.Seed), + }) + { + var invalidServices = new ServiceCollection(); + invalidServices.AddSingleton(original); + var register = () => ComponentOperationRegistration.AddExactComponentAuthority(invalidServices, invalid); + register.Should().Throw(); + } + var duplicate = () => ComponentOperationRegistration.AddExactComponentAuthority(services, selected); + duplicate.Should().Throw(); + + // Construction proves these helpers retain actual registrations and every + // canonical behavior, without opening a server connection or replacing completion. + await using var data = NpgsqlDataSource.Create("Host=127.0.0.1;Username=learnstack_app;Database=component_no_io"); + var databaseServices = ComponentOperationRegistration.CreateDatabaseServices(data, context); + ComponentOperationRegistration.AddExactComponentAuthority(databaseServices, selected); + await using var database = databaseServices.BuildServiceProvider(); + await using var databaseScope = database.CreateAsyncScope(); + databaseScope.ServiceProvider.GetServices>>() + .Select(behavior => behavior.GetType().GetGenericTypeDefinition()) + .Should().Equal(MediatRPipelineRegistration.CanonicalBehaviorOrder); + databaseScope.ServiceProvider.GetRequiredService().Should().BeOfType(); + databaseScope.ServiceProvider.GetRequiredService().Should().BeOfType(); + } + + private sealed record Selected; + private sealed record Unlisted; + private sealed record Unknown; + + private sealed class OriginalPolicies : IOperationPolicySource + { + internal static PermissionDefinition Permission { get; } = new(PermissionScope.Tenant, "tests.component.read"); + public IEnumerable Definitions => [new(Permission, true)]; + public IEnumerable Policies => + [OperationPolicy.ForProtected("tests.original.selected", Permission), + OperationPolicy.ForProtected("tests.original.unlisted", Permission)]; + } + + private sealed class MutableContext : ITenantContext + { + public bool IsResolved => true; + public TenantId TenantId { get; } = TenantId.From(Guid.CreateVersion7()); + public OrganizationId? OrganizationId { get; set; } + public UserId? UserId { get; } = LearnStack.SharedKernel.Identifiers.UserId.From(Guid.CreateVersion7()); + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + public string? CorrelationId { get; set; } + public string? ModuleName => null; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs b/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs index b1b45861e..a5f6a50c4 100644 --- a/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs +++ b/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs @@ -39,7 +39,8 @@ internal static void RemoveIdentityQualification(IServiceCollection services) { foreach (var descriptor in services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService) && (descriptor.ImplementationType ?? descriptor.ImplementationFactory?.Method.ReturnType) - == typeof(TenantIdentityAuthorityStartupService)).ToArray()) + is { } implementation && (implementation == typeof(TenantIdentityAuthorityStartupService) + || implementation == typeof(TenantAuthorizationSourcesStartupService))).ToArray()) services.Remove(descriptor); } diff --git a/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactoryTests.cs b/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactoryTests.cs index 6e5e4ed6c..0f2cd11be 100644 --- a/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactoryTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactoryTests.cs @@ -20,10 +20,11 @@ public void Request_only_test_composition_removes_exactly_both_dispatchers_and_p services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); - services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().Be(4); + services.AddHostedService(); + services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().Be(5); ConstructionOnlyApiFactory.RemoveDispatcher(services); - services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().HaveCount(2); + services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().HaveCount(3); ConstructionOnlyApiFactory.RemoveIdentityQualification(services); services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService)) @@ -38,6 +39,7 @@ public async Task Construction_only_host_leaves_the_dispatcher_source_unresolved host.Services.GetServices().Should().NotContain(service => service is OutboxProcessor || service is DeliveryProcessor); host.Services.GetServices().Should().NotContain(service => service is TenantIdentityAuthorityStartupService); + host.Services.GetServices().Should().NotContain(service => service is TenantAuthorizationSourcesStartupService); host.Services.GetRequiredKeyedService(OutboxDispatcherDataSource.Key) .IsValueCreated.Should().BeFalse(); (await client.GetAsync("/healthz")).IsSuccessStatusCode.Should().BeTrue(); diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index 194bfb41b..d4d030bed 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -33,7 +33,7 @@ namespace LearnStack.Tests.Integration; /// -/// HTTP-level integration coverage for ADR-0032's L1 boundary + +/// Component HTTP coverage for ADR-0032's L1 boundary + /// ValidationBehavior. Backs the Standards 21 catalogue rows /// IExceptionHandler_Registered_AtStartup and /// ValidationBehavior_DoesNotThrow_ValidationException with the @@ -143,13 +143,13 @@ public async Task Malformed_Body_Returns_LearnStacks_ProblemDetails_Not_AspNets( } /// -/// The foundation sockets resolve from the real composition root. +/// The foundation sockets resolve from the production component registrations. /// /// /// A registration compiles whether or not it can be satisfied, so "it builds" is -/// not evidence that a caller can get one. These resolve through the host the -/// application actually starts, which is the only place the lifetimes and the -/// dependency graph are the real ones — InProcessEventBus takes an +/// not evidence that a caller can get one. These resolve through the isolated +/// component host using production registrations and their actual lifetimes — +/// InProcessEventBus takes an /// IServiceScopeFactory, an ITenantContextAccessor and an /// IPartitionSerializer, and a singleton depending on a scoped service is /// a startup failure rather than a compile error. @@ -284,19 +284,20 @@ public void The_Audit_Health_State_Is_A_Singleton_Across_Request_Scopes() } } -/// Shared that wires the -/// integration test's controllers + MediatR handler + validator into the -/// real LearnStack.Api host. Reuses the host's -/// AddLearnStackCrossCuttingFoundation wiring so the L1 handler + +/// +/// Wires the test controllers, MediatR handler and validator into an isolated +/// component host. It does not exercise Program's live HTTP authorization boundary. +/// Reuses the production +/// AddLearnStackCrossCuttingFoundation wiring so the L1 handler and /// ValidationBehavior tested here is the same code production runs. /// -public sealed class CrossCuttingHttpFixture : ConstructionOnlyApiFactory +public sealed class CrossCuttingHttpFixture : ComponentApiFactory { protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); - builder.ConfigureTestServices(services => + builder.ConfigureServices(services => { // The enforcement probes in VersionedRouteEnforcementTests are // deliberately broken; without this filter each one aborts this @@ -319,6 +320,7 @@ protected override void ConfigureTestHost(IWebHostBuilder builder) // catalogue does not know — silence included. Appended to the catalogue's // sources rather than replacing them, so the shipped modules' entries stay. services.AddSingleton(); + ComponentOperationRegistration.AddValidationOperation(services); // TenantContextBehavior short-circuits when ITenantContext is not // resolved, and again when the resolved context's origin does not reach diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteHttpTests.Audit.cs b/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteHttpTests.Audit.cs index 1dbbc82fa..325770f70 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteHttpTests.Audit.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteHttpTests.Audit.cs @@ -124,11 +124,13 @@ public async Task Anonymous_header_mismatch_and_malformed_assertions_keep_their_ /// Every observed audit read runs as learnstack_app with the defended tenant announced. internal static class AccountHttpAudit { - internal static async Task> Read(PublicReadFixture fixture, SeedTenant tenant, string correlation) + internal static async Task> Read(PublicReadFixture fixture, SeedTenant tenant, string correlation, OrganizationId? organization = null) { await using var connection = await PostgresFixture.OpenAsync(fixture.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); await SchemaQueries.SetTenantAsync(connection, transaction, tenant.TenantId.Value); + if (organization is { } scope) + await SchemaQueries.SetSettingAsync(connection, transaction, "app.organization_id", scope.Value.ToString("D")); await SchemaQueries.AssertApplicationRoleAsync(connection, transaction); await using var command = new NpgsqlCommand(""" SELECT tenant_id, organization_id, actor_user_id, operation, outcome, correlation_id, metadata::text diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteHttpTests.cs index 7beff7a90..41a86f2c3 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteHttpTests.cs @@ -6,7 +6,6 @@ using LearnStack.Api.Authentication; using LearnStack.Api.Common; using LearnStack.SharedKernel.Identifiers; -using LearnStack.SharedKernel.Tenancy; using LearnStack.Tools.Seeder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; @@ -25,7 +24,7 @@ public sealed partial class AccountSiteHttpTests(PublicReadFixture parent) public async Task Actual_account_HTTP_reads_English_and_Yoga_row_9_under_ReadOnly_without_query_audits() { await using var issuer = await TenantAuthenticationIssuer.StartAsync(); - await using var fixture = await IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer, + await using var fixture = await IdentityPublicReadFixture.CreateWithMembershipsAsync(parent, issuer.Issuer, new Dictionary { [UserId.From(TenantAuthenticationIssuer.Actor)] = "opaque-keycloak-subject" }); using var client = Client(fixture, issuer, SeedData.English.Host); var englishAudits = await fixture.AuditCountAsync(SeedData.English); @@ -52,7 +51,7 @@ public async Task Actual_account_HTTP_reads_English_and_Yoga_row_9_under_ReadOnl public async Task Anonymous_public_site_survives_an_unavailable_issuer_and_failed_credentials_never_fall_back() { await using var issuer = await TenantAuthenticationIssuer.StartAsync(); - await using var fixture = await IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer, + await using var fixture = await IdentityPublicReadFixture.CreateWithMembershipsAsync(parent, issuer.Issuer, new Dictionary { [UserId.From(TenantAuthenticationIssuer.Actor)] = "opaque-keycloak-subject" }); issuer.DiscoveryStatusCode = 503; using var client = Client(fixture, issuer, SeedData.English.Host); @@ -82,7 +81,7 @@ public async Task Anonymous_public_site_survives_an_unavailable_issuer_and_faile public async Task Claimless_signed_profiles_keep_the_public_ceiling_and_emit_one_fixed_Debug_event_per_admitted_request() { await using var issuer = await TenantAuthenticationIssuer.StartAsync(); - await using var fixture = await IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer, + await using var fixture = await IdentityPublicReadFixture.CreateWithMembershipsAsync(parent, issuer.Issuer, new Dictionary { [UserId.From(TenantAuthenticationIssuer.Actor)] = "opaque-keycloak-subject" }); var logs = new AccountHttpLogObservation(); using var client = Client(fixture, issuer, SeedData.Yoga.Host, services => services.AddSingleton(logs)); @@ -106,13 +105,12 @@ public async Task Claimless_signed_profiles_keep_the_public_ceiling_and_emit_one public async Task Actual_account_HTTP_rechecks_a_suspended_mapped_organization_after_host_cache_warmup() { await using var issuer = await TenantAuthenticationIssuer.StartAsync(); - await using var fixture = await IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer, + await using var fixture = await IdentityPublicReadFixture.CreateWithMembershipsAsync(parent, issuer.Issuer, new Dictionary { [UserId.From(TenantAuthenticationIssuer.Actor)] = "opaque-keycloak-subject" }); using var client = Client(fixture, issuer, SeedData.Yoga.Host); var token = Token(issuer, SeedData.Yoga, organization: true); using var warm = await Send(client, SeedData.Yoga, token); await Account(warm, SeedData.Yoga, organization: true); - var audits = await fixture.AuditCountAsync(SeedData.Yoga); await fixture.ExecuteAsync(SeedData.Yoga, "UPDATE organizations SET status='Suspended' WHERE tenant_id=@tenant AND id=@organization"); try @@ -120,7 +118,10 @@ await fixture.ExecuteAsync(SeedData.Yoga, using var refused = await Send(client, SeedData.Yoga, token); await TenantAuthenticationHttpTests.Problem(refused, HttpStatusCode.NotFound, "not_found"); TenantAuthenticationHttpTests.NoStore(refused); - (await fixture.AuditCountAsync(SeedData.Yoga)).Should().Be(audits); + (await AccountHttpAudit.Read(fixture, SeedData.Yoga, + TenantAuthenticationReconciliationTests.Correlation(refused), SeedData.Yoga.DefaultOrganization.OrganizationId)) + .Should().ContainSingle("live lifecycle refusal has one MUST event under the resolved branch scope") + .Which.Operation.Should().Be("identity.authorization.deny"); } finally { @@ -132,13 +133,12 @@ await fixture.ExecuteAsync(SeedData.Yoga, } [Fact] - public async Task Future_platform_membership_success_still_masks_AccountSite_before_configuration_access() + public async Task Real_platform_membership_success_still_masks_AccountSite_before_configuration_access() { await using var issuer = await TenantAuthenticationIssuer.StartAsync(); - await using var fixture = await IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer, + await using var fixture = await IdentityPublicReadFixture.CreateWithMembershipsAsync(parent, issuer.Issuer, new Dictionary { [UserId.From(TenantAuthenticationIssuer.Actor)] = "opaque-keycloak-subject" }); - var membership = new ConfirmedAccountMembership(); - using var client = Client(fixture, issuer, "localhost", services => services.AddSingleton(membership)); + using var client = Client(fixture, issuer, "localhost"); var reads = fixture.Observation.Reads; var audits = await fixture.AuditCountAsync(SeedData.Yoga); using var response = await TenantAuthenticationHttpRequest.Send(client, "localhost", @@ -146,7 +146,6 @@ public async Task Future_platform_membership_success_still_masks_AccountSite_bef await TenantAuthenticationHttpTests.Problem(response, HttpStatusCode.NotFound, "not_found"); TenantAuthenticationHttpTests.NoStore(response); - membership.Calls.Should().Be(1, "this is row 14 with a future affirmative membership answer"); fixture.Observation.Reads.Should().Be(reads, "ClaimAndMembership supplies no independently classified institution host"); (await fixture.AuditCountAsync(SeedData.Yoga)).Should().Be(audits); } @@ -161,7 +160,7 @@ public async Task Future_platform_membership_success_still_masks_AccountSite_bef public async Task Actual_account_HTTP_admits_only_the_enumerated_GET(string method) { await using var issuer = await TenantAuthenticationIssuer.StartAsync(); - await using var fixture = await IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer, + await using var fixture = await IdentityPublicReadFixture.CreateWithMembershipsAsync(parent, issuer.Issuer, new Dictionary { [UserId.From(TenantAuthenticationIssuer.Actor)] = "opaque-keycloak-subject" }); using var client = Client(fixture, issuer, SeedData.English.Host); var reads = fixture.Observation.Reads; @@ -191,7 +190,7 @@ public async Task Actual_account_HTTP_admits_only_the_enumerated_GET(string meth public async Task Application_body_limit_refusals_keep_no_store_before_credential_admission(string path) { await using var issuer = await TenantAuthenticationIssuer.StartAsync(); - await using var fixture = await IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer, + await using var fixture = await IdentityPublicReadFixture.CreateWithMembershipsAsync(parent, issuer.Issuer, new Dictionary { [UserId.From(TenantAuthenticationIssuer.Actor)] = "opaque-keycloak-subject" }); using var client = Client(fixture, issuer, SeedData.English.Host); var reads = fixture.Observation.Reads; @@ -209,7 +208,7 @@ public async Task Application_body_limit_refusals_keep_no_store_before_credentia public async Task Account_query_parameters_are_rejected_before_configuration_and_stay_no_store() { await using var issuer = await TenantAuthenticationIssuer.StartAsync(); - await using var fixture = await IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer, + await using var fixture = await IdentityPublicReadFixture.CreateWithMembershipsAsync(parent, issuer.Issuer, new Dictionary { [UserId.From(TenantAuthenticationIssuer.Actor)] = "opaque-keycloak-subject" }); using var client = Client(fixture, issuer, SeedData.English.Host); var reads = fixture.Observation.Reads; @@ -274,17 +273,6 @@ private static async Task Account(HttpResponseMessage response, See return account; } - private sealed class ConfirmedAccountMembership : ITenantMembershipReader - { - internal int Calls { get; private set; } - public Task CoversAsync(UserId userId, TenantId tenantId, OrganizationId? organizationId, - CancellationToken cancellationToken = default) - { - Calls++; - return Task.FromResult(true); - } - } - private sealed class AccountHttpLogObservation : ILogEventSink { internal ConcurrentQueue Entries { get; } = new(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteQueryTests.Transactions.cs b/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteQueryTests.Transactions.cs index c9c913bed..bfa7a5b85 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteQueryTests.Transactions.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteQueryTests.Transactions.cs @@ -82,6 +82,7 @@ public async Task Actual_account_query_cannot_join_a_writable_outer_owner_or_rea var transaction = unit.Transaction; var send = async () => await scope.ServiceProvider.GetRequiredService().Send(new GetAccountSiteQuery()); await send.Should().ThrowAsync().WithMessage("*mixed-mode*"); + observation.HandlerEntries.Should().Be(0, "the mixed-mode barrier must refuse before the actual handler"); observation.Reads.Should().Be(0); unit.Transaction.Should().BeSameAs(transaction); unit.Mode.Should().Be(TransactionMode.ReadWrite); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteQueryTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteQueryTests.cs index 23eb1e26b..c2e13f33b 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteQueryTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/AccountSiteQueryTests.cs @@ -2,6 +2,11 @@ using FluentAssertions; using LearnStack.Modules.Education.Application.Contracts.PublicReads; using LearnStack.Modules.Tenancy.Application.Contracts.Account; +using LearnStack.Modules.Tenancy.Application.Account; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; +using Npgsql; +using NpgsqlTypes; using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Identifiers; @@ -16,18 +21,28 @@ namespace LearnStack.Tests.Integration.Database; -/// The actual unrouted account request on the production API service graph and application role. +/// Unrouted account-handler and physical transaction component proofs on real app-role data. +/// Exact fixture admission is separate from the live HTTP binding/capability proofs. [Collection(PublicReadTestGroup.Name)] [Trait(RequiresDocker.Key, RequiresDocker.Value)] -public sealed partial class AccountSiteQueryTests(PublicReadFixture fixture) +public sealed partial class AccountSiteQueryTests(PublicReadFixture fixture) : IAsyncLifetime { + private ComponentAuthorizationEvidence _evidence = null!; + public async Task InitializeAsync() => _evidence = await ComponentAuthorizationEvidence.CreateAsync( + fixture.AppConnectionString, fixture.MigrationConnectionString); + public Task DisposeAsync() => _evidence.DisposeAsync().AsTask(); + private static readonly UserId Actor = UserId.From(Guid.Parse("8c9fac35-0386-4a1c-9086-38b0d9d34139")); [Fact] public async Task Actual_account_query_reads_contrasting_tenants_and_mapped_organization_without_query_audits() { - var beforeEnglish = await fixture.AuditCountAsync(SeedData.English); - var beforeYoga = await fixture.AuditCountAsync(SeedData.Yoga); + var englishContext = Context(SeedData.English, mappedOrganization: false); + var yogaContext = Context(SeedData.Yoga, mappedOrganization: false); + var yogaMappedContext = Context(SeedData.Yoga, mappedOrganization: true); + var beforeEnglish = await AuditCountsAsync(englishContext); + var beforeYoga = await AuditCountsAsync(yogaContext); + var beforeYogaMapped = await AuditCountsAsync(yogaMappedContext); var readsBefore = fixture.Observation.Reads; var english = await ReadSite(SeedData.English, mappedOrganization: false); var yoga = await ReadSite(SeedData.Yoga, mappedOrganization: true); @@ -59,8 +74,9 @@ public async Task Actual_account_query_reads_contrasting_tenants_and_mapped_orga } fixture.Observation.Reads.Should().Be(readsBefore + 3, "the production reader verifies the actual enlisted ReadOnly transaction and app role"); - (await fixture.AuditCountAsync(SeedData.English)).Should().Be(beforeEnglish); - (await fixture.AuditCountAsync(SeedData.Yoga)).Should().Be(beforeYoga); + (await AuditCountsAsync(englishContext)).Should().Be(beforeEnglish); + (await AuditCountsAsync(yogaContext)).Should().Be(beforeYoga); + (await AuditCountsAsync(yogaMappedContext)).Should().Be(beforeYogaMapped); typeof(GetAccountSiteQuery).IsDefined(typeof(PublicSurfaceAttribute), false).Should().BeFalse(); } @@ -90,7 +106,7 @@ public async Task Actual_account_query_rechecks_live_tenant_mapped_and_claim_sel await using var host = CreateHost(context); (await Send(host)).IsSuccess.Should().BeTrue("the same stored scope is visible before its lifecycle changes"); if (claimSelected) await AssertPublicConfigurationLiveness(host, visible: true); - var before = await fixture.AuditCountAsync(tenant); + var before = await AuditCountsAsync(context); var table = organization ? "organizations" : "tenants"; var predicate = organization ? "tenant_id=@tenant AND id=@organization" : "id=@tenant"; await fixture.ExecuteAsync(tenant, state == "deleted" @@ -100,7 +116,7 @@ await fixture.ExecuteAsync(tenant, state == "deleted" { Hidden(await Send(host)); if (claimSelected) await AssertPublicConfigurationLiveness(host, visible: false); - (await fixture.AuditCountAsync(tenant)).Should().Be(before); + (await AuditCountsAsync(context)).Should().Be((before.Business, before.Denials + 1)); } finally { @@ -109,7 +125,7 @@ await fixture.ExecuteAsync(tenant, } (await Send(host)).IsSuccess.Should().BeTrue("restoring the exact scope restores the live read"); if (claimSelected) await AssertPublicConfigurationLiveness(host, visible: true); - (await fixture.AuditCountAsync(tenant)).Should().Be(before); + (await AuditCountsAsync(context)).Should().Be((before.Business, before.Denials + 1)); } [Theory] @@ -118,16 +134,17 @@ await fixture.ExecuteAsync(tenant, public async Task Actual_account_query_hides_valid_empty_or_all_disabled_locale_configuration(bool delete) { var tenant = SeedData.English; - await using var host = CreateHost(Context(tenant, false)); + var context = Context(tenant, false); + await using var host = CreateHost(context); (await Send(host)).IsSuccess.Should().BeTrue(); - var before = await fixture.AuditCountAsync(tenant); + var before = await AuditCountsAsync(context); await fixture.ExecuteAsync(tenant, delete ? "DELETE FROM tenant_locales WHERE tenant_id=@tenant" : "UPDATE tenant_locales SET is_default=false,is_enabled=false WHERE tenant_id=@tenant"); try { Hidden(await Send(host)); - (await fixture.AuditCountAsync(tenant)).Should().Be(before); + (await AuditCountsAsync(context)).Should().Be((before.Business, before.Denials + 1)); } finally { @@ -161,10 +178,11 @@ public async Task Actual_account_query_refuses_foreign_tenant_or_sibling_organiz services.AddSingleton(reads); services.AddScoped(); }); - var before = await fixture.AuditCountAsync(tenant); + var before = await AuditCountsAsync(actual); Hidden(await Send(host)); + reads.HandlerEntries.Should().Be(1, "the actual query must reach its independent provenance guard"); reads.Reads.Should().Be(0, "the independent query guard must precede configuration access"); - (await fixture.AuditCountAsync(tenant)).Should().Be(before); + (await AuditCountsAsync(actual)).Should().Be((before.Business, before.Denials + 1)); } private static async Task AssertPublicConfigurationLiveness(WebApplicationFactory host, bool visible) @@ -207,6 +225,14 @@ private async Task ReadSite(SeedTenant tenant, bool mappedOrganizat private WebApplicationFactory CreateHost(ITenantContext context, Action? configure = null) => fixture.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => { + // Admit only this unrouted component request; keep the canonical slot 5 + // and the actual ExplicitReadOnlyRequest/handler/EF/app-role adapters. + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.account.read", AuthorizationSourceKind.Seed)); + _evidence.AddTo(services); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped>, ObservedAccountHandler>(); services.AddSingleton(context); services.AddSingleton(new StaticTenantContextAccessor(context)); configure?.Invoke(services); @@ -235,9 +261,53 @@ private static void Hidden(Result result) result.Error!.Code.Should().Be("not_found"); } + private async Task<(long Business, long Denials)> AuditCountsAsync(TenantContext context) + { + await using var connection = new NpgsqlConnection(fixture.AppConnectionString); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, context.TenantId.Value); + await SchemaQueries.SetSettingAsync(connection, transaction, "app.organization_id", + context.OrganizationId?.Value.ToString("D") ?? ""); + await SchemaQueries.AssertApplicationRoleAsync(connection, transaction); + await using var command = new NpgsqlCommand(""" + SELECT count(*) FILTER (WHERE operation <> 'identity.authorization.deny'), + count(*) FILTER (WHERE operation = 'identity.authorization.deny' + AND operation_class = 'Must' AND outcome = 'denied' + AND metadata->'authorization'->>'policy' = 'tests.component.account.read' + AND metadata->'authorization'->>'source' = 'seed') + FROM audit_log WHERE tenant_id = @tenant + AND organization_id IS NOT DISTINCT FROM @organization + """, connection, transaction); + command.Parameters.AddWithValue("tenant", context.TenantId.Value); + command.Parameters.Add(new NpgsqlParameter("organization", NpgsqlDbType.Uuid) + { Value = context.OrganizationId is { } organization ? organization.Value : DBNull.Value }); + await using var row = await command.ExecuteReaderAsync(); + (await row.ReadAsync()).Should().BeTrue(); + return (row.GetInt64(0), row.GetInt64(1)); + } + + private sealed class ObservedAccountHandler(GetAccountSiteQueryHandler inner, + IUnitOfWork unit, RefusedReadObservation observation) + : IRequestHandler> + { + public async Task> Handle(GetAccountSiteQuery request, CancellationToken cancellationToken) + { + observation.HandlerEntries++; + unit.Mode.Should().Be(TransactionMode.ReadOnly); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().NotBeNull(); + await SchemaQueries.AssertApplicationRoleAsync(unit.Connection, unit.Transaction); + await using var physical = new NpgsqlCommand("SHOW transaction_read_only", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction); + (await physical.ExecuteScalarAsync(cancellationToken)).Should().Be("on"); + return await inner.Handle(request, cancellationToken); + } + } + private sealed class RefusedReadObservation { public int Reads { get; set; } + public int HandlerEntries { get; set; } } private sealed class ForbiddenConfigurationReader(RefusedReadObservation observation) : IPublicTenantConfigurationReader diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuditWorkflowTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuditWorkflowTests.cs index 817e5dfc7..4b1193206 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/AuditWorkflowTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuditWorkflowTests.cs @@ -10,6 +10,7 @@ using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Time; using LearnStack.Tools.Seeder; @@ -38,10 +39,11 @@ namespace LearnStack.Tests.Integration.Database; /// tenant would send and reads the columns a reader would read. /// /// -/// Same composition root, same role, same disposable-database discipline as -/// : the seeder's graph is a real one, and the rows are -/// written by learnstack_app under the policies and read back as -/// learnstack_platform. +/// Production persistence registrations and the canonical pipeline run under explicit +/// test-owned authority for seven requests. These are audit component proofs, not human +/// permission or Seeder provenance proofs; retains the +/// actual Seeder entry. Rows are written by learnstack_app under the policies and +/// read back as learnstack_platform. /// /// [Trait(RequiresDocker.Key, RequiresDocker.Value)] @@ -459,6 +461,10 @@ public async Task A_refused_publication_is_recorded_against_its_subject_with_act { var refused = await SendAsync(provider, new PublishTenantContentTypeCommand(builtInId)); refused.IsFailure.Should().BeTrue("the precondition: that revision is already live"); + refused.Error!.Code.Should().Be("business_rule_violation", + "authorization must admit the command before its domain precondition refuses it"); + refused.Error.Details![nameof(PublishTenantContentTypeCommand.ContentTypeId)] + .Should().ContainSingle().Which.Key.Should().Be("lockey_customization_not_a_draft"); } var row = (await RowsAsync()).Single(row => @@ -550,12 +556,21 @@ private static async Task BuiltInIdAsync(NpgsqlDataSource source, bool tax return result.Value!.State!.Id; } - /// The seeder's graph, acting for the tenant as an authenticated principal would. - private static ServiceProvider Compose(NpgsqlDataSource dataSource) => - SeedComposition.Build( - dataSource, - new ActingContext(Tenant.TenantId, Tenant.DefaultOrganization.OrganizationId), - NullLoggerFactory.Instance); + /// Exact component authority preserves the human actor being audited without claiming a human grant. + private static ServiceProvider Compose(NpgsqlDataSource dataSource) + { + var services = ComponentOperationRegistration.CreateDatabaseServices(dataSource, + new ActingContext(Tenant.TenantId, Tenant.DefaultOrganization.OrganizationId)); + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.audit.register_content", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.audit.publish_content", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.audit.register_taxonomy", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.audit.publish_taxonomy", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.audit.map_host", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.audit.read_content_revision", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.audit.read_taxonomy_revision", AuthorizationSourceKind.Seed)); + return services.BuildServiceProvider(); + } /// /// with a principal and a trace — the two things a seed diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceBarrierTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceBarrierTests.cs new file mode 100644 index 000000000..a5d4a04be --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceBarrierTests.cs @@ -0,0 +1,416 @@ +using System.Diagnostics; +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.Tests.AuthorizationEvidenceProof; +using Microsoft.Extensions.Logging; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// Fixture-policy mechanism proof through the actual common owners and application-role module/store paths. +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class AuthorizationEvidenceBarrierTests(SchemaFixture schema) +{ + [Theory] + [InlineData("read-write")] + [InlineData("read-only")] + [InlineData("ownerless")] + public async Task An_absorbed_admitted_inner_refusal_survives_its_independent_token_cancellation_before_observation(string owner) + { + var correlation = Correlation(); + var writable = owner == "read-write"; + await using var proof = await ProofAsync(correlation, writable ? ResourcePopulation.Plural : ResourcePopulation.Off); + proof.InnerOutcome = ProtectedOperationOutcome.Allowed; + proof.CancelInnerBeforeObservation = true; + proof.Factory.HoldCommit = !writable; + var running = proof.RunAsync(writable ? TransactionMode.ReadWrite : owner == "read-only" ? TransactionMode.ReadOnly : null); + try + { + if (!writable) + { + try + { + await proof.Factory.CommitEntered.Task.WaitAsync(TimeSpan.FromSeconds(4)); + running.IsCompleted.Should().BeFalse("an absorbed canceled act still requires durable standalone evidence before disclosure"); + (await ObserveAsync(correlation, proof.BusinessId)).Rows.Should().BeEmpty(); + } + finally { proof.Factory.CommitRelease.TrySetResult(); } + } + (await running).IsSuccess.Should().BeTrue(); + proof.InnerResult!.Error!.Code.Should().Be("forbidden"); + proof.DeniedHandlerEntered.Should().BeTrue("this handler was admitted and itself decided the refusal"); + proof.InnerCancellationRequested.Should().BeTrue(); + proof.InnerAuthorityRevoked.Should().BeTrue(); + proof.OuterAuthorityStillLive.Should().BeTrue("the independent inner token cannot revoke the outer operation"); + var observed = await ObserveAsync(correlation, proof.BusinessId); + observed.Business.Should().Be(writable); + observed.Rows.Should().HaveCount(writable ? 3 : 1); + var row = observed.Rows.Should().ContainSingle(item => item.Operation == "identity.authorization.deny").Subject; + row.Outcome.Should().Be("denied"); + row.Policy.Should().Be("proof.evidence.inner"); + var occurrence = writable ? proof.Store.Pending.Should().ContainSingle().Subject : proof.Store.Standalone.Should().ContainSingle().Subject; + row.Id.Should().Be(occurrence.Draft.Id.Value); + row.Timestamp.Should().Be(occurrence.Draft.Timestamp); + if (writable) + { + proof.Store.Standalone.Should().BeEmpty(); + proof.Factory.Opens.Should().Be(0); + } + else proof.Store.Pending.Should().BeEmpty(); + proof.Unit.HasActiveTransaction.Should().BeFalse(); + } + finally + { + proof.Factory.CommitRelease.TrySetResult(); + await ((Task)running).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing | ConfigureAwaitOptions.ContinueOnCapturedContext); + await CleanupAsync(correlation, proof.BusinessId); + } + } + + [Theory] + [InlineData(ResourcePopulation.Off, 0)] + [InlineData(ResourcePopulation.Should, 1)] + [InlineData(ResourcePopulation.May, 1)] + [InlineData(ResourcePopulation.OverrideOff, 0)] + [InlineData(ResourcePopulation.Plural, 2)] + public async Task Authorization_Denials_Have_PreSuccess_Durability_And_Stable_Identity(ResourcePopulation population, int resources) + { + var correlation = Correlation(); + await using var proof = await ProofAsync(correlation, population); + try + { + var response = await proof.RunAsync(); + response.IsSuccess.Should().BeTrue(); + proof.InnerResult!.IsFailure.Should().BeTrue(); + proof.DeniedHandlerEntered.Should().BeFalse(); + var observed = await ObserveAsync(correlation, proof.BusinessId); + observed.Business.Should().BeTrue(); + observed.Rows.Should().HaveCount(resources + 1); + var denial = observed.Rows.Should().ContainSingle(row => row.Operation == "identity.authorization.deny").Subject; + denial.Outcome.Should().Be("denied"); + denial.Policy.Should().Be("proof.evidence.inner"); + var pending = proof.Store.Pending.Should().ContainSingle().Subject; + denial.Id.Should().Be(pending.Draft.Id.Value); + denial.Timestamp.Should().Be(pending.Draft.Timestamp); + proof.Store.Standalone.Should().BeEmpty("the event was acknowledged by the actual business commit"); + proof.Factory.Opens.Should().Be(0); + proof.Unit.HasActiveTransaction.Should().BeFalse(); + if (population == ResourcePopulation.Plural) + observed.Rows.Where(row => row.Operation.StartsWith("proof.", StringComparison.Ordinal)) + .Should().OnlyContain(row => row.Outcome == "success" && row.Policy == "proof.evidence.outer"); + if (population is ResourcePopulation.Should or ResourcePopulation.May) + observed.Rows.Should().ContainSingle(row => row.Operation == "proof.inner.read" && row.Outcome == "denied"); + } + finally { await CleanupAsync(correlation, proof.BusinessId); } + } + + [Fact] + public async Task A_real_denial_INSERT_failure_rolls_back_the_actual_module_write_and_refuses_outer_success() + { + var correlation = Correlation(); + await using var proof = await ProofAsync(correlation); + var trigger = await InstallFailureAsync(correlation); + try + { + Result? published = null; + var failure = await FluentActions.Awaiting(async () => published = await proof.RunAsync()) + .Should().ThrowAsync(); + HttpStatusMap.For(failure.Which).Should().Be(503); + published.Should().BeNull(); + var observed = await ObserveAsync(correlation, proof.BusinessId); + observed.Business.Should().BeFalse(); + observed.Rows.Should().BeEmpty(); + proof.Unit.HasActiveTransaction.Should().BeFalse(); + proof.Store.Pending.Should().ContainSingle(); + proof.Store.Standalone.Should().ContainSingle().Which.Should().BeSameAs(proof.Store.Pending[0]); + proof.Health.IsHealthy.Should().BeFalse(); + proof.StoreLogs.Entries.Should().ContainSingle(item => item.Level == LogLevel.Critical); + proof.StoreLogs.Entries.Should().OnlyContain(item => item.Exception == null); + } + finally { await RemoveFailureAsync(trigger); await CleanupAsync(correlation, proof.BusinessId); } + } + + [Fact] + public async Task Trusted_composition_failure_prevents_an_absorbing_RW_owner_from_committing_any_business_state() + { + var correlation = Correlation(); + await using var proof = await ProofAsync(correlation, compositionFault: true); + try + { + await FluentActions.Awaiting(() => proof.RunAsync()).Should().ThrowAsync(); + var observed = await ObserveAsync(correlation, proof.BusinessId); + observed.Business.Should().BeFalse(); + observed.Rows.Should().BeEmpty(); + proof.CompositionLogs.Entries.Should().ContainSingle(item => item.Level == LogLevel.Critical && item.Exception == null); + proof.Store.Pending.Should().BeEmpty(); + proof.Store.Standalone.Should().BeEmpty(); + } + finally { await CleanupAsync(correlation, proof.BusinessId); } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Physical_RO_and_ownerless_success_wait_for_standalone_COMMIT_acknowledgement_before_disclosure(bool readOnly) + { + var correlation = Correlation(); + await using var proof = await ProofAsync(correlation); + proof.Factory.HoldCommit = true; + var running = proof.RunAsync(readOnly ? TransactionMode.ReadOnly : null); + try + { + await proof.Factory.CommitEntered.Task.WaitAsync(TimeSpan.FromSeconds(4)); + running.IsCompleted.Should().BeFalse(); + proof.Unit.Mode.Should().Be(readOnly ? TransactionMode.ReadOnly : null); + proof.Store.Pending.Should().BeEmpty("physical ReadOnly cannot insert or upgrade its mode"); + (await ObserveAsync(correlation, proof.BusinessId)).Rows.Should().BeEmpty("the independent evidence transaction has not committed"); + } + finally { proof.Factory.CommitRelease.TrySetResult(); } + try + { + (await running).IsSuccess.Should().BeTrue(); + proof.Store.Standalone.Should().ContainSingle(); + var observed = await ObserveAsync(correlation, proof.BusinessId); + observed.Business.Should().BeFalse(); + observed.Rows.Should().ContainSingle(row => row.Operation == "identity.authorization.deny"); + proof.Unit.HasActiveTransaction.Should().BeFalse(); + } + finally { await CleanupAsync(correlation, proof.BusinessId); } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_lost_evidence_COMMIT_acknowledgement_refuses_otherwise_successful_RO_or_ownerless_disclosure(bool readOnly) + { + var correlation = Correlation(); + await using var proof = await ProofAsync(correlation); + proof.Factory.FailAcknowledgement = true; + try + { + Result? published = null; + var failure = await FluentActions.Awaiting(async () => published = await proof.RunAsync(readOnly ? TransactionMode.ReadOnly : null)) + .Should().ThrowAsync(); + HttpStatusMap.For(failure.Which).Should().Be(503); + published.Should().BeNull(); + proof.Factory.Opens.Should().Be(1, "finally cannot repeat a failed standalone attempt"); + proof.Store.Standalone.Should().ContainSingle(); + proof.Health.IsHealthy.Should().BeFalse(); + proof.Unit.HasActiveTransaction.Should().BeFalse(); + (await ObserveAsync(correlation, proof.BusinessId)).Rows.Should().ContainSingle( + "a committed row without an acknowledged write still cannot authorize disclosure"); + } + finally { await CleanupAsync(correlation, proof.BusinessId); } + } + + [Theory] + [InlineData(ProtectedOperationOutcome.Forbidden, 403)] + [InlineData(ProtectedOperationOutcome.NotCovered, 404)] + public async Task Already_refused_403_or_404_survives_evidence_acknowledgement_failure_without_dispatch(ProtectedOperationOutcome outcome, int status) + { + var correlation = Correlation(); + await using var proof = await ProofAsync(correlation); + proof.InnerOutcome = outcome; + proof.Factory.FailAcknowledgement = true; + try + { + var result = await proof.RefuseAsync(); + result.IsFailure.Should().BeTrue(); + HttpStatusMap.For(result.Error!).Should().Be(status); + proof.DeniedHandlerEntered.Should().BeFalse(); + proof.Factory.Opens.Should().Be(1); + proof.StoreLogs.Entries.Should().ContainSingle(item => item.Level == LogLevel.Critical); + proof.CompositionLogs.Entries.Should().BeEmpty(); + } + finally { await CleanupAsync(correlation, proof.BusinessId); } + } + + [Fact] + public async Task Exact_event_reconciliation_does_not_promote_an_unknown_business_COMMIT_to_success() + { + var correlation = Correlation(); + await using var proof = await ProofAsync(correlation, commitBoundary: committed => committed + ? throw new IOException("Deliberate business COMMIT acknowledgement loss.") : Task.CompletedTask); + try + { + Result? published = null; + await FluentActions.Awaiting(async () => published = await proof.RunAsync()).Should().ThrowAsync(); + published.Should().BeNull(); + proof.Store.ReconciliationStates.Should().Equal(AuditIntentState.Indeterminate); + var pending = proof.Store.Pending.Should().ContainSingle().Subject; + proof.Store.Standalone.Should().ContainSingle().Which.Should().BeSameAs(pending); + var observed = await ObserveAsync(correlation, proof.BusinessId); + observed.Business.Should().BeTrue("the deliberately lost acknowledgement followed the physical commit"); + var row = observed.Rows.Should().ContainSingle().Subject; + row.Id.Should().Be(pending.Draft.Id.Value); + row.Timestamp.Should().Be(pending.Draft.Timestamp); + row.Outcome.Should().Be("denied"); + proof.StoreLogs.Entries.Should().ContainSingle(item => item.Level == LogLevel.Warning + && item.Text.Contains("no business transaction outcome", StringComparison.Ordinal)); + } + finally { await CleanupAsync(correlation, proof.BusinessId); } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SIGKILL_before_or_immediately_after_physical_COMMIT_proves_atomic_denial_durability_without_finally(bool afterCommit) + { + var correlation = Correlation(); + var business = TenantSettingId.From(Guid.CreateVersion7()); + var childDll = Path.Combine(AppContext.BaseDirectory, "authorization-evidence-probe", "LearnStack.Tests.AuthorizationEvidenceProbe.dll"); + File.Exists(childDll).Should().BeTrue("the separately built evidence probe must be staged by the Integration project"); + var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + start.ArgumentList.Add(childDll); + start.ArgumentList.Add(afterCommit ? "postcommit" : "precommit"); + start.Environment.Clear(); + foreach (var key in new[] { "PATH", "DOTNET_ROOT", "DOTNET_ROOT_ARM64", "TMPDIR", "LANG" }) + if (Environment.GetEnvironmentVariable(key) is { } value) start.Environment[key] = value; + start.Environment["DOTNET_EnableDiagnostics"] = "0"; + start.Environment["DOTNET_DbgEnableMiniDump"] = "0"; + start.Environment["COMPlus_DbgEnableMiniDump"] = "0"; + start.Environment["LEARNSTACK_EVIDENCE_PROOF_APP"] = schema.Postgres.AppConnectionString; + start.Environment["LEARNSTACK_EVIDENCE_PROOF_AUTHORITY"] = IdentityMembershipFixture.Authority.AuthorityId.Value.ToString("D"); + start.Environment["LEARNSTACK_EVIDENCE_PROOF_ISSUER"] = IdentityMembershipFixture.Authority.Issuer; + start.Environment["LEARNSTACK_EVIDENCE_PROOF_TENANT"] = SchemaFixture.TenantA.ToString("D"); + start.Environment["LEARNSTACK_EVIDENCE_PROOF_ORGANIZATION"] = SchemaFixture.OrgA1.ToString("D"); + start.Environment["LEARNSTACK_EVIDENCE_PROOF_ACTOR"] = IdentityMembershipFixture.AdminA.ToString("D"); + start.Environment["LEARNSTACK_EVIDENCE_PROOF_BUSINESS"] = business.Value.ToString("D"); + start.Environment["LEARNSTACK_EVIDENCE_PROOF_CORRELATION"] = correlation; + using var child = new Process { StartInfo = start }; + child.Start().Should().BeTrue(); + var errors = DrainBoundedAsync(child.StandardError); + try + { + var marker = await child.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(20)); + marker.Should().Be("LearnStackEvidenceBoundary:" + (afterCommit ? "postcommit" : "precommit")); + child.HasExited.Should().BeFalse(); + var beforeKill = await ObserveAsync(correlation, business); + beforeKill.Business.Should().Be(afterCommit); + beforeKill.Rows.Should().HaveCount(afterCommit ? 1 : 0); + child.Kill(entireProcessTree: true); + await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + var tail = await DrainBoundedAsync(child.StandardOutput); + tail.Should().NotContain("finally"); + (await errors).Should().BeEmpty(); + child.ExitCode.Should().NotBe(0); + var afterKill = await ObserveAsync(correlation, business); + afterKill.Business.Should().Be(afterCommit); + afterKill.Rows.Should().HaveCount(afterCommit ? 1 : 0); + if (afterCommit) afterKill.Rows[0].Outcome.Should().Be("denied"); + } + finally + { + if (!child.HasExited) { child.Kill(entireProcessTree: true); await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); } + await errors; + await CleanupAsync(correlation, business); + } + } + + private Task ProofAsync(string correlation, ResourcePopulation population = ResourcePopulation.Off, + bool compositionFault = false, Func? commitBoundary = null) => AuthorizationBarrierHarness.CreateAsync( + schema.Postgres.AppConnectionString, IdentityMembershipFixture.Authority.AuthorityId, IdentityMembershipFixture.Authority.Issuer, + TenantId.From(SchemaFixture.TenantA), OrganizationId.From(SchemaFixture.OrgA1), UserId.From(IdentityMembershipFixture.AdminA), + correlation, population, compositionFault, commitBoundary); + + private async Task<(bool Business, List Rows)> ObserveAsync(string correlation, TenantSettingId business) + { + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(schema.Postgres.AppConnectionString); + await SchemaQueries.AssertApplicationRoleAsync(app); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction); + await using var setting = new NpgsqlCommand("SELECT EXISTS(SELECT 1 FROM tenant_settings WHERE tenant_id=@tenant AND id=@id)", app, transaction); + setting.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + setting.Parameters.AddWithValue("id", business.Value); + var exists = (bool)(await setting.ExecuteScalarAsync())!; + await using var audit = new NpgsqlCommand(""" + SELECT id,timestamp,operation,outcome,metadata->'authorization'->>'policy' + FROM audit_log WHERE tenant_id=@tenant AND correlation_id=@correlation ORDER BY operation + """, app, transaction); + audit.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + audit.Parameters.AddWithValue("correlation", correlation); + await using var reader = await audit.ExecuteReaderAsync(); + var rows = new List(); + while (await reader.ReadAsync()) rows.Add(new(reader.GetGuid(0), reader.GetFieldValue(1), + reader.GetString(2), reader.GetString(3), reader.IsDBNull(4) ? null : reader.GetString(4))); + return (exists, rows); + } + + private async Task CleanupAsync(string correlation, TenantSettingId business) + { + await using (var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(schema.Postgres.AppConnectionString)) + { + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction); + await using var command = new NpgsqlCommand("DELETE FROM tenant_settings WHERE tenant_id=@tenant AND id=@id", app, transaction); + command.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + command.Parameters.AddWithValue("id", business.Value); + await command.ExecuteNonQueryAsync(); + await transaction.CommitAsync(); + } + await using var platform = (NpgsqlConnection)await PostgresFixture.OpenAsync(schema.Postgres.PlatformConnectionString); + await using var audit = new NpgsqlCommand("DELETE FROM audit_log WHERE correlation_id=@correlation", platform); + audit.Parameters.AddWithValue("correlation", correlation); + await audit.ExecuteNonQueryAsync(); + } + + private async Task InstallFailureAsync(string correlation) + { + var name = "evidence_barrier_fault_" + Guid.NewGuid().ToString("N"); + await using var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(schema.Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand($$""" + CREATE FUNCTION public.{{name}}() RETURNS trigger LANGUAGE plpgsql AS $proof$ + BEGIN + IF NEW.operation='identity.authorization.deny' AND NEW.correlation_id='{{correlation}}' THEN + RAISE EXCEPTION 'Deliberate barrier proof refusal' USING ERRCODE='P0399'; + END IF; + RETURN NEW; + END $proof$; + CREATE TRIGGER {{name}} BEFORE INSERT ON public.audit_log + FOR EACH ROW EXECUTE FUNCTION public.{{name}}(); + """, owner); + await command.ExecuteNonQueryAsync(); + return name; + } + + private async Task RemoveFailureAsync(string name) + { + await using var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(schema.Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand($"DROP TRIGGER {name} ON public.audit_log; DROP FUNCTION public.{name}();", owner); + await command.ExecuteNonQueryAsync(); + } + + private static async Task ScopeAsync(NpgsqlConnection app, NpgsqlTransaction transaction) + { + await using var command = new NpgsqlCommand("SELECT set_config('app.tenant_id', @tenant, true), set_config('app.organization_id', @organization, true)", app, transaction); + command.Parameters.AddWithValue("tenant", SchemaFixture.TenantA.ToString("D")); + command.Parameters.AddWithValue("organization", SchemaFixture.OrgA1.ToString("D")); + await command.ExecuteNonQueryAsync(); + } + + private static async Task DrainBoundedAsync(StreamReader stream) + { + var buffer = new char[512]; + var result = new System.Text.StringBuilder(1024); + int read; + while ((read = await stream.ReadAsync(buffer)) != 0) + if (result.Length < 2048) result.Append(buffer, 0, Math.Min(read, 2048 - result.Length)); + return result.ToString(); + } + + private static string Correlation() => "evidence-barrier-" + Guid.NewGuid().ToString("N"); + private sealed record Row(Guid Id, DateTimeOffset Timestamp, string Operation, string Outcome, string? Policy); +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceFixture.cs new file mode 100644 index 000000000..f4a55215d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceFixture.cs @@ -0,0 +1,88 @@ +using System.Net; +using LearnStack.Infrastructure.Audit; +using LearnStack.Infrastructure.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using System.Diagnostics.Metrics; + +namespace LearnStack.Tests.Integration.Database; + +internal static class AuthorizationEvidenceFixture +{ + internal static AuthorizationEvidenceOccurrence Occurrence(AuditEntryDraft? draft = null, + AuthorizationEvidenceKind kind = AuthorizationEvidenceKind.Denial) => Source.Mint(kind, draft ?? Draft(kind)); + + internal static AuditEntryDraft Draft(AuthorizationEvidenceKind kind = AuthorizationEvidenceKind.Denial) => new() + { + Id = AuditEntryId.From(Guid.CreateVersion7()), + TenantId = TenantId.From(SchemaFixture.TenantA), + OrganizationId = OrganizationId.From(SchemaFixture.OrgA1), + ActorUserId = UserId.From(IdentityMembershipFixture.AdminA), + ActorEmail = null, + ModuleName = kind == AuthorizationEvidenceKind.Denial ? "identity" : "tenancy", + Operation = kind == AuthorizationEvidenceKind.Denial ? "identity.authorization.deny" : "tenancy.assertion.reject_claim_context", + OperationType = OperationType.SecurityEvent, + OperationClass = OperationClass.Must, + EntityType = null, + EntityId = null, + Outcome = AuditOutcome.Denied, + ErrorKey = "authorization.denied", + Reason = null, + BeforeState = null, + AfterState = null, + Changes = null, + CorrelationId = "00-12345678901234567890123456789012-1234567890123456-01", + IpAddress = IPAddress.Parse("192.0.2.1"), + UserAgent = "bounded-evidence-test", + Timestamp = new DateTimeOffset(2026, 10, 27, 12, 13, 14, TimeSpan.FromHours(3)).AddTicks(1234567), + Metadata = """{"authorization":{"version":1,"policy":"Tenant:identity.membership.read"}}""", + }; + + internal static async Task SourceAsync(SchemaFixture schema, int capacity = 2, + ILoggerFactory? logs = null) + { + var source = new AuthorizationEvidenceSource(schema.Postgres.AppConnectionString, capacity, logs); + await source.QualifyAsync(IdentityMembershipFixture.Authority.AuthorityId, IdentityMembershipFixture.Authority.Issuer); + return source; + } + + internal static PostgresAuditStore Store(NpgsqlDataSource business, IMeterFactory meters, + IAuthorizationEvidenceSessionFactory? evidence = null, AuditHealth? health = null, + ILogger? logger = null) => new(new AuditStateCapture(), new Lazy(() => business), + logger ?? NullLogger.Instance, meters, health ?? new AuditHealth(), + new AuditCatalog([new CatalogSource()]), StaticTenantContextAccessor.Unresolved, new GlobalIdentityEnrollment(), evidence); + + internal static async Task DeleteAsync(SchemaFixture schema, params AuditEntryId[] ids) + { + await using var connection = await PostgresFixture.OpenAsync(schema.Postgres.PlatformConnectionString); + await using var command = new NpgsqlCommand("DELETE FROM public.audit_log WHERE id = ANY(@ids)", (NpgsqlConnection)connection); + command.Parameters.AddWithValue("ids", ids.Select(id => id.Value).ToArray()); + await command.ExecuteNonQueryAsync(); + } + + private sealed class Source : AuthorizationEvidenceOccurrence.MintingSource + { + internal static AuthorizationEvidenceOccurrence Mint(AuthorizationEvidenceKind kind, AuditEntryDraft draft) => ForOccurrence(kind, draft); + } + + private sealed class CatalogSource : IAuditCatalogSource + { + public string ModuleName => "identity"; + public void Describe(IAuditCatalogBuilder builder) + { + builder.DeclareOffPath("identity.authorization.deny", OperationType.SecurityEvent, OperationClass.Must); + builder.DeclareOffPath("tenancy.assertion.reject_claim_context", OperationType.SecurityEvent, OperationClass.Must); + } + } + + internal sealed class Meters : IMeterFactory + { + internal Meter Meter { get; } = new(PostgresAuditStore.MeterName); + public Meter Create(MeterOptions options) => Meter; + public void Dispose() => Meter.Dispose(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceSourceTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceSourceTests.cs new file mode 100644 index 000000000..9331fa90e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceSourceTests.cs @@ -0,0 +1,181 @@ +using System.Data; +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Persistence; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class AuthorizationEvidenceSourceTests(SchemaFixture schema) +{ + [Fact] + public async Task Two_retained_evidence_connectors_refuse_excess_without_waiting_and_release_only_after_actual_cleanup() + { + await using var source = await AuthorizationEvidenceFixture.SourceAsync(schema); + var occurrence = AuthorizationEvidenceFixture.Occurrence(); + var first = (await source.OpenAsync(occurrence))!; + var second = (await source.OpenAsync(occurrence))!; + try + { + ((NpgsqlConnection)first.Unit.Connection).ProcessID.Should().NotBe(((NpgsqlConnection)second.Unit.Connection).ProcessID); + var excess = source.OpenAsync(occurrence); + excess.IsCompleted.Should().BeTrue("there is no admission queue"); + (await excess).Should().BeNull(); + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + await FluentActions.Awaiting(() => source.OpenAsync(occurrence, canceled.Token)).Should().ThrowAsync(); + await first.CompleteAsync(); + (await source.OpenAsync(occurrence)).Should().BeNull("commit does not confirm connector cleanup"); + await first.DisposeAsync(); + await using var replacement = await source.OpenAsync(occurrence); + replacement.Should().NotBeNull(); + } + finally { await first.DisposeAsync(); await second.DisposeAsync(); } + } + + [Fact] + public async Task Verification_disposes_the_aborted_INSERT_transaction_before_fresh_physical_READ_ONLY_on_the_same_connector() + { + await using var source = await AuthorizationEvidenceFixture.SourceAsync(schema, 1); + var occurrence = AuthorizationEvidenceFixture.Occurrence(); + await using var session = (await source.OpenAsync(occurrence))!; + var connection = (NpgsqlConnection)session.Unit.Connection; + var original = (NpgsqlTransaction)session.Unit.Transaction!; + var announcement = session.Unit.GetTenantContextAnnouncement(original)!; + var originalServerTransaction = await ScalarAsync(session, "SELECT pg_current_xact_id()::text"); + (await ScalarAsync(session, "SHOW transaction_read_only")).Should().Be("off"); + var failedInsert = () => ScalarAsync(session, "SELECT 1 / 0"); + await failedInsert.Should().ThrowAsync(); + await session.BeginVerificationAsync(); + // Npgsql reuses its transaction object: Connection and object identity + // cannot witness retirement. A fresh server transaction on this one + // connector after a real aborted statement proves the old one ended. + (await ScalarAsync(session, "SELECT pg_current_xact_id()::text")).Should().NotBe(originalServerTransaction); + ((NpgsqlConnection)session.Unit.Connection).Should().BeSameAs(connection); + session.Unit.Mode.Should().Be(TransactionMode.ReadOnly); + (await ScalarAsync(session, "SHOW transaction_read_only")).Should().Be("on"); + (await ScalarAsync(session, "SHOW transaction_isolation")).Should().Be("read committed"); + var fresh = session.Unit.GetTenantContextAnnouncement(session.Unit.Transaction)!; + fresh.Should().NotBe(announcement, "the retired announcement cannot vouch for the new transaction even when its driver object is reused"); + fresh.Generation.Should().BeGreaterThan(announcement.Generation); + fresh.TenantId.Should().Be(occurrence.Draft.TenantId); + fresh.OrganizationId.Should().Be(occurrence.Draft.OrganizationId); + foreach (var name in new[] { "scope", "resolving_host", "identity_authority_id", "identity_subject_key" }) + (await ScalarAsync(session, $"SELECT current_setting('app.{name}', true)")).Should().Be(string.Empty); + await FluentActions.Awaiting(session.BeginVerificationAsync).Should().ThrowAsync(); + await FluentActions.Awaiting(() => session.Unit.BeginTransactionAsync()).Should().ThrowAsync(); + await FluentActions.Awaiting(() => session.Unit.SetProvisioningTenantContextAsync(TenantId.From(SchemaFixture.TenantB))) + .Should().ThrowAsync(); + await session.CompleteAsync(); + session.Unit.GetTenantContextAnnouncement(session.Unit.Transaction).Should().BeNull(); + } + + [Fact] + public async Task Verification_reuses_the_original_deadline_and_expiry_retains_capacity_until_cleanup() + { + await using var source = await AuthorizationEvidenceFixture.SourceAsync(schema, 1); + var occurrence = AuthorizationEvidenceFixture.Occurrence(); + await using var session = (await source.OpenAsync(occurrence))!; + await ScalarAsync(session, "SELECT pg_sleep(2.6)"); + await session.BeginVerificationAsync(); + await FluentActions.Awaiting(() => ScalarAsync(session, "SELECT pg_sleep(3)")) + .Should().ThrowAsync(); + session.DeadlineToken.IsCancellationRequested.Should().BeTrue(); + session.Unit.HasActiveTransaction.Should().BeFalse(); + session.Unit.Transaction.Should().BeNull(); + (await source.OpenAsync(occurrence)).Should().BeNull(); + await FluentActions.Awaiting(session.CompleteAsync).Should().ThrowAsync(); + await session.DisposeAsync(); + await using var replacement = await source.OpenAsync(occurrence); + replacement.Should().NotBeNull(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Actual_connection_cleanup_spans_both_transactions_and_controls_source_and_job_shutdown(bool fault) + { + var source = await AuthorizationEvidenceFixture.SourceAsync(schema, 1); + var occurrence = AuthorizationEvidenceFixture.Occurrence(); + var job = JobApplicationSourceOwner.Create(); + using var binding = job.Enter(); + var session = (await source.OpenAsync(occurrence))!; + var originalServerTransaction = await ScalarAsync(session, "SELECT pg_current_xact_id()::text"); + await session.BeginVerificationAsync(); + (await ScalarAsync(session, "SELECT pg_current_xact_id()::text")).Should().NotBe(originalServerTransaction); + (await ScalarAsync(session, "SHOW transaction_read_only")).Should().Be("on"); + var connection = (NpgsqlConnection)session.Unit.Connection; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + StateChangeEventHandler disposal = (_, change) => + { + if (change.CurrentState != ConnectionState.Closed) return; + entered.TrySetResult(); + release.Task.GetAwaiter().GetResult(); + if (fault) throw new InvalidOperationException("Deliberate actual evidence connection cleanup fault."); + }; + connection.StateChange += disposal; + var cleanup = Task.Run(() => session.DisposeAsync().AsTask()); + Task? shutdown = null; + Task? jobJoin = null; + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cleanup.IsCompleted.Should().BeFalse(); + (await source.OpenAsync(occurrence)).Should().BeNull(); + job.CloseIssuance(); + jobJoin = job.JoinAuxiliaryCleanupAsync(); + shutdown = source.DisposeAsync().AsTask(); + jobJoin.IsCompleted.Should().BeFalse(); + shutdown.IsCompleted.Should().BeFalse(); + } + finally { release.TrySetResult(); } + if (fault) + { + await FluentActions.Awaiting(() => cleanup).Should().ThrowAsync(); + await FluentActions.Awaiting(() => shutdown!).Should().ThrowAsync(); + await FluentActions.Awaiting(() => source.DisposeAsync().AsTask()).Should().ThrowAsync(); + } + else { await cleanup; await shutdown!; } + (await jobJoin!).Should().Be(!fault); + job.CleanupConfirmed.Should().Be(!fault); + connection.StateChange -= disposal; + } + + [Fact] + public async Task Reported_statement_cleanup_fault_prevents_verification_and_retains_the_slot_after_other_cleanup() + { + var source = await AuthorizationEvidenceFixture.SourceAsync(schema, 1); + var occurrence = AuthorizationEvidenceFixture.Occurrence(); + var session = (await source.OpenAsync(occurrence))!; + session.MarkCleanupFailed(); + await FluentActions.Awaiting(session.BeginVerificationAsync).Should().ThrowAsync(); + await FluentActions.Awaiting(() => session.DisposeAsync().AsTask()).Should().ThrowAsync(); + (await source.OpenAsync(occurrence)).Should().BeNull(); + await FluentActions.Awaiting(() => source.DisposeAsync().AsTask()).Should().ThrowAsync(); + } + + [Fact] + public async Task Startup_qualification_refuses_another_authority_without_enabling_evidence_issuance() + { + await using var source = new AuthorizationEvidenceSource(schema.Postgres.AppConnectionString, 1); + await FluentActions.Awaiting(() => source.QualifyAsync(IdentityAuthorityId.From(Guid.CreateVersion7()), + IdentityMembershipFixture.Authority.Issuer)).Should().ThrowAsync(); + source.IsQualified.Should().BeFalse(); + await FluentActions.Awaiting(() => source.OpenAsync(AuthorizationEvidenceFixture.Occurrence())) + .Should().ThrowAsync(); + } + + private static async Task ScalarAsync(IAuthorizationEvidenceSession session, string sql) + { + await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)session.Unit.Connection, + (NpgsqlTransaction?)session.Unit.Transaction); + return await command.ExecuteScalarAsync(session.DeadlineToken); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceStoreTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceStoreTests.cs new file mode 100644 index 000000000..fc187cfee --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationEvidenceStoreTests.cs @@ -0,0 +1,320 @@ +using System.Diagnostics.Metrics; +using System.Net; +using FluentAssertions; +using LearnStack.Infrastructure.Audit; +using LearnStack.Infrastructure.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class AuthorizationEvidenceStoreTests(SchemaFixture schema) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Pending_evidence_is_atomic_with_its_actual_RW_owner_and_reconciliation_reuses_the_fixed_microsecond_draft(bool commit) + { + await using var source = await AuthorizationEvidenceFixture.SourceAsync(schema); + await using var business = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + using var meters = new AuthorizationEvidenceFixture.Meters(); + var store = AuthorizationEvidenceFixture.Store(business, meters, source); + var occurrence = AuthorizationEvidenceFixture.Occurrence(); + try + { + await using (var unit = new NpgsqlUnitOfWork(business, NullLogger.Instance)) + { + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(new Context(occurrence.Draft.TenantId, occurrence.Draft.OrganizationId)); + await store.WriteAuthorizationPendingAsync(unit, occurrence); + (await CountAsync(occurrence.Draft.Id)).Should().Be(0, "the row is invisible before actual business commit"); + if (commit) await frame.CompleteAsync(); + } + (await CountAsync(occurrence.Draft.Id)).Should().Be(commit ? 1 : 0); + await store.WriteAuthorizationStandaloneAsync(occurrence); + (await CountAsync(occurrence.Draft.Id)).Should().Be(1); + await using var observer = (NpgsqlConnection)await PostgresFixture.OpenAsync(schema.Postgres.PlatformConnectionString); + await using var command = new NpgsqlCommand("SELECT timestamp, outcome, metadata::text FROM audit_log WHERE id=@id", observer); + command.Parameters.AddWithValue("id", occurrence.Draft.Id.Value); + await using var reader = await command.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue(); + reader.GetFieldValue(0).Should().Be(occurrence.Draft.Timestamp); + occurrence.Draft.Timestamp.Offset.Should().Be(TimeSpan.Zero); + (occurrence.Draft.Timestamp.Ticks % 10).Should().Be(0); + reader.GetString(1).Should().Be("denied"); + reader.GetString(2).Should().Contain("authorization"); + } + finally { await AuthorizationEvidenceFixture.DeleteAsync(schema, occurrence.Draft.Id); } + } + + [Theory] + [InlineData("tenant")] + [InlineData("organization")] + [InlineData("readonly")] + [InlineData("unannounced")] + public async Task Pending_evidence_refuses_foreign_or_unannounced_scope_and_physical_read_only_owners(string mismatch) + { + await using var business = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + using var meters = new AuthorizationEvidenceFixture.Meters(); + var store = AuthorizationEvidenceFixture.Store(business, meters); + var occurrence = AuthorizationEvidenceFixture.Occurrence(); + await using var unit = new NpgsqlUnitOfWork(business, NullLogger.Instance); + await using var frame = await unit.BeginTransactionAsync(mismatch == "readonly" ? TransactionMode.ReadOnly : TransactionMode.ReadWrite); + if (mismatch != "unannounced") + await unit.SetTenantContextAsync(new Context(TenantId.From(mismatch == "tenant" ? SchemaFixture.TenantB : SchemaFixture.TenantA), + OrganizationId.From(mismatch == "organization" ? SchemaFixture.OrgA2 : SchemaFixture.OrgA1))); + await FluentActions.Awaiting(() => store.WriteAuthorizationPendingAsync(unit, occurrence)).Should().ThrowAsync(); + (await CountAsync(occurrence.Draft.Id)).Should().Be(0); + } + + [Theory] + [InlineData(AuthorizationEvidenceKind.Denial)] + [InlineData(AuthorizationEvidenceKind.ClaimAssertion)] + public async Task Exact_duplicate_evidence_uses_one_connector_and_fresh_RO_verification_with_JSONB_value_equality(AuthorizationEvidenceKind kind) + { + await using var source = await AuthorizationEvidenceFixture.SourceAsync(schema, 1); + await using var business = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + using var meters = new AuthorizationEvidenceFixture.Meters(); + var factory = new ObservedFactory(source); + var health = new AuditHealth(); + var store = AuthorizationEvidenceFixture.Store(business, meters, factory, health); + var occurrence = AuthorizationEvidenceFixture.Occurrence(kind: kind); + var reordered = occurrence.Draft with { Metadata = """{ "authorization": { "policy": "Tenant:identity.membership.read", "version": 1 } }""" }; + try + { + // A fixture write plants the same JSONB value with different source text. + // The new event seam never calls this generic path as a fallback. + await store.WriteStandaloneAsync(reordered); + await store.WriteAuthorizationStandaloneAsync(occurrence); + factory.Verifications.Should().Be(1); + factory.VerificationBackend.Should().Be(factory.InsertBackend); + factory.VerificationReadOnly.Should().Be("on"); + health.IsHealthy.Should().BeTrue(); + (await CountAsync(occurrence.Draft.Id)).Should().Be(1); + } + finally { await AuthorizationEvidenceFixture.DeleteAsync(schema, occurrence.Draft.Id); } + } + + [Theory] + [InlineData("tenant_id")] + [InlineData("organization_id")] + [InlineData("actor_user_id")] + [InlineData("actor_email")] + [InlineData("module")] + [InlineData("operation")] + [InlineData("operation_type")] + [InlineData("operation_class")] + [InlineData("entity_type")] + [InlineData("entity_id")] + [InlineData("outcome")] + [InlineData("error_key")] + [InlineData("reason")] + [InlineData("before_state")] + [InlineData("after_state")] + [InlineData("changes")] + [InlineData("correlation_id")] + [InlineData("ip_address")] + [InlineData("user_agent")] + [InlineData("metadata")] + public async Task A_primary_key_collision_with_any_other_persisted_field_changed_is_not_durable_evidence(string column) + { + await using var source = await AuthorizationEvidenceFixture.SourceAsync(schema, 1); + await using var business = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + using var meters = new AuthorizationEvidenceFixture.Meters(); + var health = new AuditHealth(); + var factory = new ObservedFactory(source); + var store = AuthorizationEvidenceFixture.Store(business, meters, factory, health); + var occurrence = AuthorizationEvidenceFixture.Occurrence(); + var draft = occurrence.Draft; + var changed = column switch + { + "tenant_id" => draft with { TenantId = TenantId.From(SchemaFixture.TenantB) }, + "organization_id" => draft with { OrganizationId = OrganizationId.From(SchemaFixture.OrgA2) }, + "actor_user_id" => draft with { ActorUserId = UserId.From(IdentityMembershipFixture.PersonA) }, + "actor_email" => draft with { ActorEmail = "private@example.invalid" }, + "module" => draft with { ModuleName = "tenancy" }, + "operation" => draft with { Operation = "tenancy.assertion.reject_claim_context" }, + "operation_type" => draft with { OperationType = OperationType.ReadSensitive }, + "operation_class" => draft with { OperationClass = OperationClass.Should }, + "entity_type" => draft with { EntityType = "OtherSubject" }, + "entity_id" => draft with { EntityId = Guid.CreateVersion7().ToString() }, + "outcome" => draft with { Outcome = AuditOutcome.Failed }, + "error_key" => draft with { ErrorKey = null }, + "reason" => draft with { Reason = "other.reason" }, + "before_state" => draft with { BeforeState = "{}" }, + "after_state" => draft with { AfterState = "{}" }, + "changes" => draft with { Changes = "[]" }, + "correlation_id" => draft with { CorrelationId = null }, + "ip_address" => draft with { IpAddress = IPAddress.Parse("192.0.2.2") }, + "user_agent" => draft with { UserAgent = null }, + "metadata" => draft with { Metadata = """{"authorization":{"version":2}}""" }, + _ => throw new InvalidOperationException("Unknown persisted field."), + }; + try + { + await store.WriteStandaloneAsync(changed); + await FluentActions.Awaiting(() => store.WriteAuthorizationStandaloneAsync(occurrence)).Should().ThrowAsync(); + factory.Verifications.Should().Be(1); + health.IsHealthy.Should().BeFalse(); + health.FailingOperation.Should().Be(draft.Operation); + } + finally { await AuthorizationEvidenceFixture.DeleteAsync(schema, draft.Id); } + } + + [Theory] + [InlineData("audit_log_pkey", 1)] + [InlineData("different_unique_key", 0)] + public async Task Missing_row_or_another_unique_constraint_never_turns_23505_into_durability(string constraint, int verifications) + { + await using var source = await AuthorizationEvidenceFixture.SourceAsync(schema, 1); + await using var business = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + using var meters = new AuthorizationEvidenceFixture.Meters(); + var factory = new ObservedFactory(source); + var health = new AuditHealth(); + var logger = new Logs(); + var store = AuthorizationEvidenceFixture.Store(business, meters, factory, health, logger); + var occurrence = AuthorizationEvidenceFixture.Occurrence(); + var suffix = Guid.NewGuid().ToString("N"); + var function = "evidence_fault_" + suffix; + var trigger = "evidence_fault_" + suffix; + await schema.Postgres.ExecuteAsSuperuserAsync($""" + CREATE FUNCTION public.{function}() RETURNS trigger LANGUAGE plpgsql AS $body$ + BEGIN + IF NEW.id = '{occurrence.Draft.Id.Value:D}'::uuid THEN + RAISE EXCEPTION 'private-evidence-provider-canary' USING ERRCODE = '23505', + CONSTRAINT = '{constraint}', SCHEMA = 'public', TABLE = 'audit_log'; + END IF; + RETURN NEW; + END; + $body$; + CREATE TRIGGER {trigger} BEFORE INSERT ON public.audit_log + FOR EACH ROW EXECUTE FUNCTION public.{function}(); + """); + try + { + var failure = await FluentActions.Awaiting(() => store.WriteAuthorizationStandaloneAsync(occurrence)) + .Should().ThrowAsync(); + failure.Which.InnerException.Should().BeNull(); + failure.Which.ToString().Should().NotContain("private-evidence-provider-canary"); + factory.Verifications.Should().Be(verifications); + health.IsHealthy.Should().BeFalse(); + logger.Items.Should().ContainSingle(item => item.Level == LogLevel.Critical); + logger.Items.Should().OnlyContain(item => item.Exception == null && !item.Text.Contains("private-evidence-provider-canary", StringComparison.Ordinal)); + (await CountAsync(occurrence.Draft.Id)).Should().Be(0); + } + finally + { + await schema.Postgres.ExecuteAsSuperuserAsync($"DROP TRIGGER {trigger} ON public.audit_log; DROP FUNCTION public.{function}();"); + } + } + + [Fact] + public async Task Lost_commit_acknowledgement_can_reconcile_the_same_event_without_claiming_a_business_outcome() + { + await using var source = await AuthorizationEvidenceFixture.SourceAsync(schema, 1); + await using var business = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + using var meters = new AuthorizationEvidenceFixture.Meters(); + var factory = new ObservedFactory(source) { LoseFirstCommitAcknowledgement = true }; + var logger = new Logs(); + var health = new AuditHealth(); + var store = AuthorizationEvidenceFixture.Store(business, meters, factory, health, logger); + var occurrence = AuthorizationEvidenceFixture.Occurrence(); + var failures = 0L; + using var listener = new MeterListener(); + listener.InstrumentPublished = (instrument, active) => + { + if (ReferenceEquals(instrument.Meter, meters.Meter) && instrument.Name == PostgresAuditStore.StandaloneWriteFailureCounterName) + active.EnableMeasurementEvents(instrument); + }; + listener.SetMeasurementEventCallback((_, count, _, _) => failures += count); + listener.Start(); + try + { + await FluentActions.Awaiting(() => store.WriteAuthorizationStandaloneAsync(occurrence)).Should().ThrowAsync(); + health.IsHealthy.Should().BeFalse(); + (await CountAsync(occurrence.Draft.Id)).Should().Be(1, "the physical evidence commit succeeded despite lost acknowledgement"); + await store.WriteAuthorizationStandaloneAsync(occurrence); + health.IsHealthy.Should().BeTrue(); + failures.Should().Be(1); + factory.Verifications.Should().Be(1); + logger.Items.Should().ContainSingle(item => item.Level == LogLevel.Critical); + logger.Items.Should().ContainSingle(item => item.Level == LogLevel.Warning && item.Text.Contains("no business transaction outcome", StringComparison.Ordinal)); + logger.Items.Should().OnlyContain(item => item.Exception == null && !item.Text.Contains("private-evidence-provider-canary", StringComparison.Ordinal)); + (await CountAsync(occurrence.Draft.Id)).Should().Be(1); + } + finally { await AuthorizationEvidenceFixture.DeleteAsync(schema, occurrence.Draft.Id); } + } + + private async Task CountAsync(AuditEntryId id) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(schema.Postgres.PlatformConnectionString); + await using var command = new NpgsqlCommand("SELECT count(*) FROM audit_log WHERE id=@id", connection); + command.Parameters.AddWithValue("id", id.Value); + return (long)(await command.ExecuteScalarAsync())!; + } + + private sealed record Context(TenantId TenantId, OrganizationId? OrganizationId) : ITenantContext + { + public bool IsResolved => true; + public UserId? UserId => LearnStack.SharedKernel.Identifiers.UserId.From(IdentityMembershipFixture.AdminA); + public string? CorrelationId => null; + public string? ModuleName => "identity"; + } + + private sealed class ObservedFactory(IAuthorizationEvidenceSessionFactory source) : IAuthorizationEvidenceSessionFactory + { + internal int Verifications { get; set; } + internal int InsertBackend { get; private set; } + internal int VerificationBackend { get; set; } + internal string? VerificationReadOnly { get; set; } + internal bool LoseFirstCommitAcknowledgement { get; init; } + private bool _lost; + public async Task OpenAsync(AuthorizationEvidenceOccurrence occurrence, CancellationToken cancellationToken = default) + { + var session = await source.OpenAsync(occurrence, cancellationToken); + if (session is null) return null; + InsertBackend = ((NpgsqlConnection)session.Unit.Connection).ProcessID; + return new ObservedSession(session, this); + } + + private sealed class ObservedSession(IAuthorizationEvidenceSession inner, ObservedFactory owner) : IAuthorizationEvidenceSession + { + public IUnitOfWork Unit => inner.Unit; + public CancellationToken DeadlineToken => inner.DeadlineToken; + public async Task BeginVerificationAsync() + { + await inner.BeginVerificationAsync(); + owner.Verifications++; + owner.VerificationBackend = ((NpgsqlConnection)Unit.Connection).ProcessID; + await using var command = new NpgsqlCommand("SHOW transaction_read_only", (NpgsqlConnection)Unit.Connection, (NpgsqlTransaction?)Unit.Transaction); + owner.VerificationReadOnly = (string?)await command.ExecuteScalarAsync(DeadlineToken); + } + public async Task CompleteAsync() + { + await inner.CompleteAsync(); + if (!owner.LoseFirstCommitAcknowledgement || owner._lost) return; + owner._lost = true; + throw new IOException("private-evidence-provider-canary"); + } + public void MarkCleanupFailed() => inner.MarkCleanupFailed(); + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } + } + + private sealed class Logs : ILogger + { + internal List<(LogLevel Level, string Text, Exception? Exception)> Items { get; } = []; + 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) => + Items.Add((logLevel, formatter(state, exception), exception)); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressFixture.cs new file mode 100644 index 000000000..3e93f6cc1 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressFixture.cs @@ -0,0 +1,252 @@ +using System.Text; +using Docker.DotNet.Models; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; +using Microsoft.Extensions.Logging.Abstractions; +using LearnStack.SharedKernel.Identifiers; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// Independent client and transaction-pool lanes over one fully migrated application database. +public sealed class AuthorizationPoolProgressFixture : IAsyncLifetime +{ + internal const int BusinessCapacity = 2; + internal const int AuthorizationCapacity = 4; + internal const int EvidenceCapacity = 2; + internal const string AuthorizationAlias = "learnstack_identity_authorization"; + internal const string EvidenceAlias = "learnstack_authorization_evidence"; + internal const string PgBouncerImage = "edoburu/pgbouncer:v1.26.0-p0"; + private const string PostgresAlias = "authorization-postgres"; + private const string ConfigurationPath = "/tmp/authorization-progress.ini"; + private const string AuthenticationPath = "/tmp/authorization-progress-users.txt"; + private readonly IdentityMembershipFixture _membership = new(); + private INetwork? _network; + private IContainer? _pgBouncer; + private bool _databaseAttached; + private string? _directConnectionString; + + internal string DirectConnectionString => _directConnectionString ?? _membership.Postgres.AppConnectionString; + internal bool DatabasePublicationWasRemapped { get; private set; } + + internal string ConnectionString(bool transactionPooled, string lane, string applicationName) + { + var builder = new NpgsqlConnectionStringBuilder(DirectConnectionString) + { + ApplicationName = applicationName, + Pooling = true, + MaxPoolSize = lane == "business" ? BusinessCapacity + : lane == "authorization" ? AuthorizationCapacity : EvidenceCapacity, + MinPoolSize = 0, + Multiplexing = false, + Enlist = false, + Timeout = 3, + CommandTimeout = 3, + CancellationTimeout = 1000, + SslMode = SslMode.Disable, + }; + if (transactionPooled) + { + var pooler = _pgBouncer ?? throw new InvalidOperationException("Pooler is unavailable."); + builder.Host = pooler.Hostname; + builder.Port = pooler.GetMappedPublicPort(6432); + builder.Database = lane switch + { + "business" => new NpgsqlConnectionStringBuilder(DirectConnectionString).Database, + "authorization" => AuthorizationAlias, + "evidence" => EvidenceAlias, + _ => throw new ArgumentOutOfRangeException(nameof(lane)), + }; + } + return builder.ConnectionString; + } + + public async Task InitializeAsync() + { + using var startup = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + var stage = "application-database"; + try + { + // This fixture applies every discovered migration chain and seeds actual committed memberships. + await _membership.InitializeAsync(); + await SeedLifecycleAsync(startup.Token); + stage = "network"; + _network = new NetworkBuilder().WithLogger(NullLogger.Instance).Build(); + await _network.CreateAsync(startup.Token); + using (var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()).Build()) + { + await docker.Networks.ConnectNetworkAsync(_network.Name, new NetworkConnectParameters + { + Container = _membership.Postgres.ContainerId, + EndpointConfig = new EndpointSettings { Aliases = [PostgresAlias] }, + }, startup.Token); + _databaseAttached = true; + // This external network operation may change a dynamic port mapping. + // Reinspect the owned container instead of relying on the earlier Testcontainers observation. + var database = await docker.Containers.InspectContainerAsync(_membership.Postgres.ContainerId, startup.Token); + if (!ContainerLoopbackPorts.IsExclusive(database, "5432/tcp")) + throw new InvalidOperationException("Application database publication is not exclusively loopback."); + if (database.NetworkSettings?.Ports is not { } ports + || !ports.TryGetValue("5432/tcp", out var bindings) || bindings is not { Count: 1 } + || !ushort.TryParse(bindings[0].HostPort, System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out var actualPort) || actualPort == 0) + throw new InvalidOperationException("Application database publication is invalid."); + var direct = new NpgsqlConnectionStringBuilder(_membership.Postgres.AppConnectionString); + DatabasePublicationWasRemapped = direct.Port != actualPort; + direct.Port = actualPort; + _directConnectionString = direct.ConnectionString; + } + + stage = "transaction-pooler"; + var app = new NpgsqlConnectionStringBuilder(DirectConnectionString); + _pgBouncer = new ContainerBuilder(PgBouncerImage) + .WithLogger(NullLogger.Instance) + .WithNetwork(_network) + .WithPortBinding(6432, true) + .WithCreateParameterModifier(parameters => ContainerLoopbackPorts.Configure(parameters, "6432/tcp")) + // The image's normal shell entrypoint prints generated credentials. Run only the pinned binary. + .WithEntrypoint("/usr/bin/pgbouncer") + .WithCommand(ConfigurationPath) + .WithResourceMapping(Encoding.UTF8.GetBytes(Configuration(app.Database!)), ConfigurationPath) + .WithResourceMapping(Encoding.UTF8.GetBytes($"\"{app.Username}\" \"{app.Password}\"\n"), AuthenticationPath) + .WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(6432)) + .Build(); + await _pgBouncer.StartAsync(startup.Token); + using (var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()).Build()) + { + var inspected = await docker.Containers.InspectContainerAsync(_pgBouncer.Id, startup.Token); + if (!ContainerLoopbackPorts.IsExclusive(inspected, "6432/tcp")) + throw new InvalidOperationException("Pooler publication is not exclusively loopback."); + } + + stage = "pinned-version"; + var version = await _pgBouncer.ExecAsync(["/usr/bin/pgbouncer", "--version"], startup.Token); + if (version.ExitCode != 0 || !version.Stdout.Contains("PgBouncer 1.26.0", StringComparison.Ordinal)) + throw new InvalidOperationException("Pooler binary does not match its fixture pin."); + + stage = "same-database-role"; + foreach (var pooled in new[] { false, true }) + foreach (var lane in new[] { "business", "authorization", "evidence" }) + { + stage = "same-database-role-" + (pooled ? "transaction-" : "direct-") + lane; + var probeConnection = new NpgsqlConnectionStringBuilder(ConnectionString(pooled, lane, "progress-topology-check")) + { Pooling = false }; + await using var connection = new NpgsqlConnection(probeConnection.ConnectionString); + stage += "-open"; + await connection.OpenAsync(startup.Token); + stage = "same-database-role-" + (pooled ? "transaction-" : "direct-") + lane + "-probe"; + await using var probe = new NpgsqlCommand(""" + SELECT current_database() = @database AND current_user = 'learnstack_app' + AND NOT r.rolsuper AND NOT r.rolbypassrls + FROM pg_roles AS r WHERE r.rolname = current_user + """, connection); + probe.Parameters.AddWithValue("database", app.Database!); + if (await probe.ExecuteScalarAsync(startup.Token) is not true) + throw new InvalidOperationException("Pool lanes do not share the guarded application database and role."); + } + } + catch (Exception exception) + { + var cleanup = "completed"; + try { await DisposeAsync(); } catch { cleanup = "failed"; } + // Container SDK failures may carry copied configuration, passwords or raw logs. + var reason = exception switch + { + PostgresException postgres => "postgres-" + postgres.SqlState, + NpgsqlException { InnerException: System.Net.Sockets.SocketException socket } => "socket-" + socket.SocketErrorCode, + NpgsqlException { InnerException: IOException { InnerException: System.Net.Sockets.SocketException socket } } + => "stream-socket-" + socket.SocketErrorCode, + NpgsqlException { InnerException: TimeoutException } => "database-timeout", + NpgsqlException { InnerException: IOException } => "database-stream", + NpgsqlException => "database-connection", + TimeoutException => "timeout", + OperationCanceledException => "cancelled", + InvalidOperationException => "invalid-topology", + _ => "unexpected-failure", + }; + throw new InvalidOperationException($"Authorization progress fixture failed at {stage}/{reason}; owned cleanup {cleanup}."); + } + } + + private async Task SeedLifecycleAsync(CancellationToken cancellationToken) + { + await using var app = new NpgsqlConnection(DirectConnectionString); + await app.OpenAsync(cancellationToken); + await using var transaction = await app.BeginTransactionAsync(cancellationToken); + await IdentityMembershipFixture.ScopeAsync(app, transaction, SchemaFixture.TenantA); + await using var seed = new NpgsqlCommand(""" + INSERT INTO public.tenants(id,slug,display_name,status,created_at,created_by,row_version) + VALUES(@tenant,'authorization-progress','Authorization progress','Trial',clock_timestamp(),@actor,0); + INSERT INTO public.organizations(id,tenant_id,slug,display_name,status,created_at,created_by,row_version) + VALUES(@organization,@tenant,'progress-branch','Progress branch','Active',clock_timestamp(),@actor,0); + """, app, transaction); + seed.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + seed.Parameters.AddWithValue("organization", SchemaFixture.OrgA1); + seed.Parameters.AddWithValue("actor", UserId.SystemActor.Value); + await seed.ExecuteNonQueryAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + + private static string Configuration(string database) => $$""" + [databases] + {{database}} = host={{PostgresAlias}} port=5432 dbname={{database}} user=learnstack_app pool_size={{BusinessCapacity}} max_db_connections={{BusinessCapacity}} + {{AuthorizationAlias}} = host={{PostgresAlias}} port=5432 dbname={{database}} user=learnstack_app pool_size={{AuthorizationCapacity}} max_db_connections={{AuthorizationCapacity}} + {{EvidenceAlias}} = host={{PostgresAlias}} port=5432 dbname={{database}} user=learnstack_app pool_size={{EvidenceCapacity}} max_db_connections={{EvidenceCapacity}} + + [pgbouncer] + listen_addr = 0.0.0.0 + listen_port = 6432 + unix_socket_dir = + auth_type = scram-sha-256 + auth_file = {{AuthenticationPath}} + pool_mode = transaction + max_client_conn = 16 + default_pool_size = 2 + reserve_pool_size = 0 + max_user_connections = {{BusinessCapacity + AuthorizationCapacity + EvidenceCapacity}} + query_wait_timeout = 10 + server_connect_timeout = 3 + server_login_retry = 1 + server_idle_timeout = 0 + ignore_startup_parameters = extra_float_digits + log_connections = 0 + log_disconnections = 0 + log_pooler_errors = 0 + log_stats = 0 + """; + + public async Task DisposeAsync() + { + var failures = new List(); + if (_pgBouncer is { } pooler) + { + _pgBouncer = null; + try { await pooler.DisposeAsync(); } catch (Exception exception) { failures.Add(exception); } + } + if (_databaseAttached && _network is { } attachedNetwork) + { + try + { + using var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()).Build(); + await docker.Networks.DisconnectNetworkAsync(attachedNetwork.Name, new NetworkDisconnectParameters + { + Container = _membership.Postgres.ContainerId, + Force = true, + }); + _databaseAttached = false; + } + catch (Exception exception) { failures.Add(exception); } + } + // Removing the owned database also releases its network endpoint if explicit detach failed. + try { await _membership.DisposeAsync(); } catch (Exception exception) { failures.Add(exception); } + if (_network is { } network) + { + _network = null; + try { await network.DisposeAsync(); } catch (Exception exception) { failures.Add(exception); } + } + if (failures.Count != 0) throw new InvalidOperationException("Authorization progress fixture owned cleanup failed."); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressTests.DatabaseProof.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressTests.DatabaseProof.cs new file mode 100644 index 000000000..49a007f0b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressTests.DatabaseProof.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +public sealed partial class AuthorizationPoolProgressTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Live_business_database_marker_qualifies_both_independent_lanes_through_direct_and_transaction_pooler_aliases( + bool transactionPooled) + { + var businessConnection = fixture.ConnectionString(transactionPooled, "business", "database-proof-business"); + var authorizationConnection = fixture.ConnectionString(transactionPooled, "authorization", "database-proof-authorization"); + var evidenceConnection = fixture.ConnectionString(transactionPooled, "evidence", "database-proof-evidence"); + var database = new NpgsqlConnectionStringBuilder(fixture.DirectConnectionString).Database; + if (transactionPooled) + { + new NpgsqlConnectionStringBuilder(authorizationConnection).Database.Should().NotBe(database); + new NpgsqlConnectionStringBuilder(evidenceConnection).Database.Should().NotBe(database); + } + await using var business = ApplicationDataSource.Build(businessConnection); + await using var authorization = new IdentityAuthorizationReadSource(authorizationConnection); + await using var evidence = new AuthorizationEvidenceSource(evidenceConnection); + await using var proof = await DatabaseQualificationProof.OpenAsync(business); + var authority = IdentityMembershipFixture.Authority; + + await authorization.QualifyAsync(authority.AuthorityId, authority.Issuer, proof, proof.DeadlineToken); + await evidence.QualifyAsync(authority.AuthorityId, authority.Issuer, proof, proof.DeadlineToken); + + authorization.IsQualified.Should().BeTrue(); + evidence.IsQualified.Should().BeTrue(); + authorization.QualifiedDatabaseName.Should().Be(database); + evidence.QualifiedDatabaseName.Should().Be(database); + await proof.DisposeAsync(); + authorization.IsQualified.Should().BeTrue(); + evidence.IsQualified.Should().BeTrue(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressTests.Evidence.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressTests.Evidence.cs new file mode 100644 index 000000000..fb4b47f4b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressTests.Evidence.cs @@ -0,0 +1,235 @@ +using System.Diagnostics.Metrics; +using FluentAssertions; +using LearnStack.Application.Audit; +using LearnStack.Application.Authorization; +using LearnStack.Infrastructure.Audit; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Application.Authorization; +using LearnStack.Modules.Identity.Application.Audit; +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Infrastructure.Authorization; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Identity; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +public sealed partial class AuthorizationPoolProgressTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Actual_denial_is_durable_before_readonly_or_ownerless_success_while_all_business_backends_are_held( + bool transactionPooled, bool readOnlyOwner) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + using var trusted = new CandidateSource(IdentityMembershipFixture.PersonA); + await using var authorization = new IdentityAuthorizationReadSource( + fixture.ConnectionString(transactionPooled, "authorization", "evidence-policy-progress")); + await authorization.QualifyAsync(IdentityMembershipFixture.Authority.AuthorityId, IdentityMembershipFixture.Authority.Issuer, deadline.Token); + await using var evidence = new AuthorizationEvidenceSource( + fixture.ConnectionString(transactionPooled, "evidence", "evidence-store-progress")); + await evidence.QualifyAsync(IdentityMembershipFixture.Authority.AuthorityId, IdentityMembershipFixture.Authority.Issuer, deadline.Token); + await using var parents = await HeldBusinessOwners.OpenAsync( + fixture.ConnectionString(transactionPooled, "business", "evidence-held-business"), deadline.Token, + readOnlyOwner ? TransactionMode.ReadOnly : TransactionMode.ReadWrite, orgless: !readOnlyOwner); + if (!readOnlyOwner) await PlantUncommittedDirectoryGrantAsync(parents.Units[0], deadline.Token); + await using var unusedSource = ApplicationDataSource.Build( + fixture.ConnectionString(transactionPooled, "business", "evidence-ownerless-unused")); + await using var ownerless = new NpgsqlUnitOfWork(unusedSource, NullLogger.Instance); + var observer = new LifecycleObserver(fixture.DirectConnectionString, parents); + var reader = new CommittedIdentityAuthorizationReader(new PreContextIdentityAuthorizationSessionFactory(authorization), observer); + var evidenceObserver = new EvidenceObserver(evidence, parents, fixture.DirectConnectionString); + using var execution = DirectoryExecution(reader, trusted, readOnlyOwner ? parents.Units[0] : ownerless, evidenceObserver); + var publishedSuccess = false; + + await using (execution.Evidence.BeginExecution()) + { + var denied = await execution.Operations.CheckHttpAsync(typeof(ListMembershipsQuery), new object(), deadline.Token); + denied.IsFailure.Should().BeTrue(); + denied.Error!.Code.Should().Be("forbidden", "the real Learner has membership coverage and lacks the directory capability"); + evidenceObserver.Occurrences.Should().BeEmpty("an inner refusal remains pending while the outer execution can absorb it"); + publishedSuccess.Should().BeFalse(); + if (readOnlyOwner) + { + (await execution.Evidence.BeforeOwnerCompletionAsync(parents.Units[0], deadline.Token)).Should().BeNull(); + } + else await execution.Evidence.BeforeSuccessAsync(deadline.Token); + + var occurrence = evidenceObserver.Occurrences.Should().ContainSingle().Which; + await AssertDenialDurableAsync(occurrence, deadline.Token); + await AssertParentsStillHeldAsync(parents, postLock: false, deadline.Token); + evidenceObserver.ChildPids.Should().ContainSingle().And.NotIntersectWith(parents.BackendPids); + evidenceObserver.HeldBusinessTransactions.Should().Be(AuthorizationPoolProgressFixture.BusinessCapacity); + evidenceObserver.ActualChildMode.Should().Be(TransactionMode.ReadWrite); + evidenceObserver.ActualChildRole.Should().Be("learnstack_app"); + observer.ChildWasDistinct.Should().BeTrue(); + observer.AuthorizationBackendPids.Should().NotIntersectWith(parents.BackendPids).And.NotIntersectWith(evidenceObserver.ChildPids); + if (readOnlyOwner) await parents.CompleteFirstAsync(deadline.Token); + else ownerless.HasActiveTransaction.Should().BeFalse("the otherwise-successful ownerless flow acquired no business server"); + publishedSuccess = true; + } + publishedSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Evidence_on_the_saturated_business_server_alias_blocks_success_and_the_same_source_recovers_after_release() + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(25)); + using var trusted = new CandidateSource(IdentityMembershipFixture.PersonA); + await using var authorization = new IdentityAuthorizationReadSource(fixture.ConnectionString(true, "authorization", "dirty-evidence-authorization")); + await authorization.QualifyAsync(IdentityMembershipFixture.Authority.AuthorityId, IdentityMembershipFixture.Authority.Issuer, deadline.Token); + // A separate Npgsql pool is insufficient when PgBouncer routes it into the same occupied server pool. + await using var evidence = new AuthorizationEvidenceSource(fixture.ConnectionString(true, "business", "dirty-evidence-source")); + await evidence.QualifyAsync(IdentityMembershipFixture.Authority.AuthorityId, IdentityMembershipFixture.Authority.Issuer, deadline.Token); + await using var unusedSource = ApplicationDataSource.Build(fixture.ConnectionString(true, "business", "dirty-evidence-unused")); + await using var ownerless = new NpgsqlUnitOfWork(unusedSource, NullLogger.Instance); + var parents = await HeldBusinessOwners.OpenAsync(fixture.ConnectionString(true, "business", "dirty-evidence-held"), deadline.Token); + try + { + var reader = new CommittedIdentityAuthorizationReader(new PreContextIdentityAuthorizationSessionFactory(authorization), + new LifecycleObserver(fixture.DirectConnectionString, parents)); + var observer = new EvidenceObserver(evidence, parents, fixture.DirectConnectionString); + using var execution = DirectoryExecution(reader, trusted, ownerless, observer); + await using (execution.Evidence.BeginExecution()) + { + (await execution.Operations.CheckHttpAsync(typeof(ListMembershipsQuery), new object(), deadline.Token)).Error!.Code.Should().Be("forbidden"); + using var blocked = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await FluentActions.Awaiting(() => execution.Evidence.BeforeSuccessAsync(blocked.Token)).Should().ThrowAsync(); + execution.Health.IsHealthy.Should().BeFalse(); + observer.ChildPids.Should().BeEmpty("the dirty lane cannot obtain a downstream backend while both business servers stay held"); + await AssertParentsStillHeldAsync(parents, postLock: false, deadline.Token); + } + } + finally { await parents.DisposeAsync(); } + + var recoveredReader = new CommittedIdentityAuthorizationReader(new PreContextIdentityAuthorizationSessionFactory(authorization), + new LearnStack.Modules.Tenancy.Infrastructure.Persistence.IdentityScopeLifecycleReader(ownerless)); + var recoveredObserver = new EvidenceObserver(evidence, null, fixture.DirectConnectionString); + using var recovered = DirectoryExecution(recoveredReader, trusted, ownerless, recoveredObserver); + await using (recovered.Evidence.BeginExecution()) + { + (await recovered.Operations.CheckHttpAsync(typeof(ListMembershipsQuery), new object(), deadline.Token)).Error!.Code.Should().Be("forbidden"); + await recovered.Evidence.BeforeSuccessAsync(deadline.Token); + await AssertDenialDurableAsync(recoveredObserver.Occurrences.Should().ContainSingle().Which, deadline.Token); + recovered.Health.IsHealthy.Should().BeTrue(); + } + } + + private async Task AssertDenialDurableAsync(AuthorizationEvidenceOccurrence occurrence, CancellationToken cancellationToken) + { + var draft = occurrence.Draft; + await using var connection = new NpgsqlConnection(fixture.DirectConnectionString); + await connection.OpenAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + await IdentityMembershipFixture.ScopeAsync(connection, transaction, SchemaFixture.TenantA, SchemaFixture.OrgA1); + await using var command = new NpgsqlCommand(""" + SELECT count(*) FROM public.audit_log + WHERE id = @id AND timestamp = @timestamp AND tenant_id = @tenant AND organization_id = @organization + AND actor_user_id = @actor AND operation = 'identity.authorization.deny' + AND operation_class = 'Must' AND outcome = 'denied' + AND metadata::jsonb IS NOT DISTINCT FROM @metadata::jsonb + """, connection, transaction); + command.Parameters.AddWithValue("id", draft.Id.Value); + command.Parameters.AddWithValue("timestamp", draft.Timestamp); + command.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + command.Parameters.AddWithValue("organization", SchemaFixture.OrgA1); + command.Parameters.AddWithValue("actor", IdentityMembershipFixture.PersonA); + command.Parameters.AddWithValue("metadata", draft.Metadata!); + (await command.ExecuteScalarAsync(cancellationToken)).Should().Be(1L, "another real application transaction already observes the fixed denial before success"); + } + + private static async Task PlantUncommittedDirectoryGrantAsync(NpgsqlUnitOfWork parent, CancellationToken cancellationToken) + { + // If authorization accidentally borrowed this parent, the Learner would + // appear to have the directory capability. The real child must ignore it. + await using var command = new NpgsqlCommand(""" + SELECT pg_advisory_xact_lock_shared(@tenant_key); + INSERT INTO public.role_permission_grants (tenant_id, role_id, permission_scope, permission_key) + VALUES (@tenant, @role, 'Tenant', 'identity.membership.admin'); + """, (NpgsqlConnection)parent.Connection, (NpgsqlTransaction)parent.Transaction!); + command.Parameters.AddWithValue("tenant_key", IdentityAdvisoryLockKey.ForTenant(TenantId.From(SchemaFixture.TenantA))); + command.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + command.Parameters.AddWithValue("role", IdentityMembershipFixture.RoleId(SchemaFixture.TenantA, BuiltInRoleKey.Learner, PermissionScope.Tenant)); + await command.ExecuteNonQueryAsync(cancellationToken); + command.CommandText = """ + SELECT count(*) FROM public.role_permission_grants + WHERE tenant_id = @tenant AND role_id = @role AND permission_key = 'identity.membership.admin' + """; + (await command.ExecuteScalarAsync(cancellationToken)).Should().Be(1L); + } + + private sealed class EvidenceObserver(AuthorizationEvidenceSource actual, HeldBusinessOwners? parents, string directConnection) + : IAuthorizationEvidenceSessionFactory + { + public List Occurrences { get; } = []; + public List ChildPids { get; } = []; + public long HeldBusinessTransactions { get; private set; } + public TransactionMode? ActualChildMode { get; private set; } + public string? ActualChildRole { get; private set; } + + public async Task OpenAsync(AuthorizationEvidenceOccurrence occurrence, CancellationToken cancellationToken = default) + { + var session = await actual.OpenAsync(occurrence, cancellationToken); + if (session is null) return null; + try + { + Occurrences.Add(occurrence); + ActualChildMode = session.Unit.Mode; + await using (var pid = new NpgsqlCommand("SELECT pg_backend_pid(), current_user", (NpgsqlConnection)session.Unit.Connection, + (NpgsqlTransaction)session.Unit.Transaction!)) + await using (var row = await pid.ExecuteReaderAsync(session.DeadlineToken)) + { + (await row.ReadAsync(session.DeadlineToken)).Should().BeTrue(); + ChildPids.Add(row.GetInt32(0)); + ActualChildRole = row.GetString(1); + } + if (parents is not null) + { + await using var observer = new NpgsqlConnection(directConnection); + await observer.OpenAsync(cancellationToken); + await using var held = new NpgsqlCommand("SELECT count(*) FROM pg_stat_activity WHERE pid = ANY(@pids) AND xact_start IS NOT NULL", observer); + held.Parameters.AddWithValue("pids", parents.BackendPids.ToArray()); + HeldBusinessTransactions = (long)(await held.ExecuteScalarAsync(cancellationToken))!; + } + return session; // The actual production session still owns its connector, transaction and cleanup. + } + catch { await session.DisposeAsync(); throw; } + } + } + + private sealed class PolicyExecution : IDisposable + { + private readonly ServiceProvider _metrics = new ServiceCollection().AddMetrics().BuildServiceProvider(); + public AuditHealth Health { get; } = new(); + public AuthorizationEvidenceCoordinator Evidence { get; } + public OperationAuthorizationCoordinator Operations { get; } + + public PolicyExecution(IOperationPolicyCatalog catalog, IIdentityAuthorizationReader reader, ITenantContext context, + CandidateSource trusted, IUnitOfWork unit, IAuthorizationEvidenceSessionFactory? evidence) + { + var capture = new AuditStateCapture(); + var store = new PostgresAuditStore(capture, + new Lazy(() => throw new InvalidOperationException("The evidence proof must not borrow the legacy business audit datasource.")), + NullLogger.Instance, _metrics.GetRequiredService(), Health, + new AuditCatalog([new IdentityAuditCatalogSource()]), new StaticTenantContextAccessor(context), new GlobalIdentityEnrollment(), evidence); + OperationAuthorizationCoordinator? current = null; + Evidence = new AuthorizationEvidenceCoordinator(store, capture, unit, new FixedClock(DateTimeOffset.UtcNow), new SystemGuidFactory(), + () => current ?? throw new InvalidOperationException("The real policy owner is not attached."), [], NullLogger.Instance); + Operations = current = new OperationAuthorizationCoordinator(catalog, new IdentityOperationAuthorizer(() => reader, catalog), context, [trusted], [], Evidence); + } + + public void Dispose() { Operations.Dispose(); Evidence.Dispose(); _metrics.Dispose(); } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressTests.cs new file mode 100644 index 000000000..0021ecff1 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressTests.cs @@ -0,0 +1,421 @@ +using FluentAssertions; +using LearnStack.Application.Authorization; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Application; +using LearnStack.Modules.Identity.Application.Authorization; +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Infrastructure.Authorization; +using LearnStack.Modules.Tenancy.Application.Contracts.IdentityLifecycle; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Identity; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; +using Xunit.Abstractions; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed partial class AuthorizationPoolProgressTests(AuthorizationPoolProgressFixture fixture, ITestOutputHelper output) + : IClassFixture +{ + [Fact] + public async Task Transaction_pooler_reserves_two_four_two_distinct_backends_for_the_same_database_and_app_role() + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await using var held = new RawBackendReservations(); + var pids = new List(); + var database = new NpgsqlConnectionStringBuilder(fixture.DirectConnectionString).Database; + foreach (var (lane, capacity) in new[] + { + ("business", AuthorizationPoolProgressFixture.BusinessCapacity), + ("authorization", AuthorizationPoolProgressFixture.AuthorizationCapacity), + ("evidence", AuthorizationPoolProgressFixture.EvidenceCapacity), + }) + { + for (var index = 0; index < capacity; index++) + { + var (connection, transaction) = await held.OpenAsync( + fixture.ConnectionString(true, lane, "reserved-" + lane), deadline.Token); + await using var command = new NpgsqlCommand(""" + SELECT pg_backend_pid(), current_database(), current_user, + NOT r.rolsuper AND NOT r.rolbypassrls + FROM pg_roles AS r WHERE r.rolname = current_user + """, connection, transaction); + await using var row = await command.ExecuteReaderAsync(deadline.Token); + (await row.ReadAsync(deadline.Token)).Should().BeTrue(); + pids.Add(row.GetInt32(0)); + row.GetString(1).Should().Be(database); + row.GetString(2).Should().Be("learnstack_app"); + row.GetBoolean(3).Should().BeTrue(); + } + } + pids.Should().HaveCount(AuthorizationPoolProgressFixture.BusinessCapacity + + AuthorizationPoolProgressFixture.AuthorizationCapacity + AuthorizationPoolProgressFixture.EvidenceCapacity) + .And.OnlyHaveUniqueItems(); + await using var observer = new NpgsqlConnection(fixture.DirectConnectionString); + await observer.OpenAsync(deadline.Token); + await using var active = new NpgsqlCommand(""" + SELECT count(*) FROM pg_stat_activity WHERE pid = ANY(@pids) + AND xact_start IS NOT NULL AND state = 'idle in transaction' + """, observer); + active.Parameters.AddWithValue("pids", pids.ToArray()); + (await active.ExecuteScalarAsync(deadline.Token)).Should().Be((long)pids.Count); + output.WriteLine("Observed 2+4+2 distinct reserved application backends; owned PostgreSQL publication remapped: " + + fixture.DatabasePublicationWasRemapped); + // The production evidence path is exercised below while these business backends remain occupied. + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Admitted_real_authorization_completes_while_every_business_server_transaction_is_held( + bool transactionPooled, bool postLock) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var trusted = new CandidateSource(); + var candidate = trusted.Candidate(); + var authorizationName = "authorization-progress-" + Guid.NewGuid().ToString("N"); + await using var authorization = new IdentityAuthorizationReadSource( + fixture.ConnectionString(transactionPooled, "authorization", authorizationName)); + await authorization.QualifyAsync(IdentityMembershipFixture.Authority.AuthorityId, + IdentityMembershipFixture.Authority.Issuer, deadline.Token); + await using var parents = await HeldBusinessOwners.OpenAsync( + fixture.ConnectionString(transactionPooled, "business", "business-progress"), deadline.Token); + if (postLock) await parents.HoldInvariantLocksAsync(deadline.Token); + var observer = new LifecycleObserver(fixture.DirectConnectionString, parents); + var reader = new CommittedIdentityAuthorizationReader(new PreContextIdentityAuthorizationSessionFactory(authorization), observer); + + IdentityAuthorizationResult result; + if (postLock) + { + // The same business owners still hold their actual User/Tenant invariant locks. + result = await reader.ReadAsync(candidate, deadline.Token); + } + else + { + await using var nested = await parents.Units[0].BeginTransactionAsync(deadline.Token); + nested.IsOwner.Should().BeFalse(); + result = await reader.ReadAsync(candidate, deadline.Token); + await nested.CompleteAsync(deadline.Token); + } + + // The affirmative facts come from the production Identity statement and real Tenancy reader. + result.Outcome.Should().Be(IdentityAuthorizationOutcome.Covered); + result.Memberships.Should().ContainSingle().Which.MembershipId.Should().Be(IdentityMembershipFixture.AdminMembershipA); + result.Memberships.SelectMany(membership => membership.Roles).SelectMany(role => role.Grants) + .Should().Contain(grant => grant.Scope == PermissionScope.Tenant && grant.Key == "identity.membership.admin"); + observer.Calls.Should().Be(1); + observer.ChildWasDistinct.Should().BeTrue(); + observer.ChildMode.Should().Be(TransactionMode.ReadOnly); + observer.AuthorizationBackendPids.Should().ContainSingle(); + observer.AuthorizationBackendPids.Should().NotIntersectWith(parents.BackendPids); + observer.AuthorizationTransactionsDuringChild.Should().Be(1); + observer.BusinessTransactionsDuringChild.Should().Be(AuthorizationPoolProgressFixture.BusinessCapacity); + await AssertParentsStillHeldAsync(parents, postLock, deadline.Token); + // Disposal below is the first parent release; completion above cannot borrow a released server. + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Actual_directory_policy_admission_completes_while_every_business_server_transaction_is_held( + bool transactionPooled, bool postLock) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var trusted = new CandidateSource(); + await using var authorization = new IdentityAuthorizationReadSource( + fixture.ConnectionString(transactionPooled, "authorization", "directory-policy-progress")); + await authorization.QualifyAsync(IdentityMembershipFixture.Authority.AuthorityId, + IdentityMembershipFixture.Authority.Issuer, deadline.Token); + await using var parents = await HeldBusinessOwners.OpenAsync( + fixture.ConnectionString(transactionPooled, "business", "directory-policy-business-progress"), deadline.Token); + if (postLock) await parents.HoldInvariantLocksAsync(deadline.Token); + var observer = new LifecycleObserver(fixture.DirectConnectionString, parents); + var reader = new CommittedIdentityAuthorizationReader(new PreContextIdentityAuthorizationSessionFactory(authorization), observer); + using var execution = DirectoryExecution(reader, trusted, parents.Units[0]); + var coordinator = execution.Operations; + + Result result; + if (postLock) + result = await coordinator.CheckHttpAsync(typeof(ListMembershipsQuery), new object(), deadline.Token); + else + { + await using var nested = await parents.Units[0].BeginTransactionAsync(deadline.Token); + nested.IsOwner.Should().BeFalse(); + result = await coordinator.CheckHttpAsync(typeof(ListMembershipsQuery), new object(), deadline.Token); + await nested.CompleteAsync(deadline.Token); + } + + // Only the actual policy/grant admission runs; no directory handler or sensitive result is published. + result.IsSuccess.Should().BeTrue(); + coordinator.CurrentInvocation.Should().BeNull(); + coordinator.CurrentReadWitness.Should().BeNull(); + observer.Calls.Should().Be(1); + observer.ChildWasDistinct.Should().BeTrue(); + observer.ChildMode.Should().Be(TransactionMode.ReadOnly); + observer.AuthorizationBackendPids.Should().ContainSingle(); + observer.AuthorizationBackendPids.Should().NotIntersectWith(parents.BackendPids); + observer.AuthorizationTransactionsDuringChild.Should().Be(1); + observer.BusinessTransactionsDuringChild.Should().Be(AuthorizationPoolProgressFixture.BusinessCapacity); + await AssertParentsStillHeldAsync(parents, postLock, deadline.Token); + } + + [Fact] + public async Task A_separate_client_pool_on_the_saturated_business_alias_fails_the_same_progress_control() + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var trusted = new CandidateSource(); + var name = "dirty-authorization-progress-" + Guid.NewGuid().ToString("N"); + // Deliberately route the separate authorization client pool into the business server pool. + await using var authorization = new IdentityAuthorizationReadSource( + fixture.ConnectionString(true, "business", name)); + await authorization.QualifyAsync(IdentityMembershipFixture.Authority.AuthorityId, + IdentityMembershipFixture.Authority.Issuer, deadline.Token); + var parents = await HeldBusinessOwners.OpenAsync( + fixture.ConnectionString(true, "business", "dirty-business-progress"), deadline.Token); + try + { + await parents.HoldInvariantLocksAsync(deadline.Token); + var observer = new LifecycleObserver(fixture.DirectConnectionString, parents); + var reader = new CommittedIdentityAuthorizationReader(new PreContextIdentityAuthorizationSessionFactory(authorization), observer); + using var execution = DirectoryExecution(reader, trusted, parents.Units[0]); + var coordinator = execution.Operations; + using (var refused = new CancellationTokenSource(TimeSpan.FromSeconds(2))) + { + await FluentActions.Awaiting(() => coordinator.CheckHttpAsync(typeof(ListMembershipsQuery), new object(), refused.Token)) + .Should().ThrowAsync(); + } + observer.Calls.Should().Be(0, "the saturated downstream pool cannot start the real authorization statements"); + coordinator.CurrentInvocation.Should().BeNull(); + coordinator.CurrentReadWitness.Should().BeNull(); + await AssertParentsStillHeldAsync(parents, postLock: true, deadline.Token); + } + finally { await parents.DisposeAsync(); } + + // The dirty source itself works once the occupied downstream business servers return. + await using var ambientSource = ApplicationDataSource.Build( + fixture.ConnectionString(true, "business", "dirty-recovery-ambient")); + await using var ambient = new NpgsqlUnitOfWork( + ambientSource, NullLogger.Instance); + var recovered = new CommittedIdentityAuthorizationReader(new PreContextIdentityAuthorizationSessionFactory(authorization), + new IdentityScopeLifecycleReader(ambient)); + using var recoveredExecution = DirectoryExecution(recovered, trusted, ambient); + (await recoveredExecution.Operations.CheckHttpAsync(typeof(ListMembershipsQuery), new object(), deadline.Token)).IsSuccess.Should().BeTrue(); + } + + private static PolicyExecution DirectoryExecution(IIdentityAuthorizationReader reader, CandidateSource trusted, + IUnitOfWork unit, LearnStack.SharedKernel.Audit.IAuthorizationEvidenceSessionFactory? evidence = null) + { + var catalog = new OperationPolicyCatalog([new IdentityOperationPolicySource()]); + catalog.Definitions.Should().HaveCount(5); + catalog.TryGet(typeof(ListMembershipsQuery), out var policy).Should().BeTrue(); + policy.Kind.Should().Be(OperationPolicyKind.Protected); + policy.ReadScope.Should().Be(OperationReadScope.Tenant); + policy.RequiredPermission.Should().Be(IdentityOperationPolicySource.MembershipAdministration); + var context = TenantContextFactory.Create(new TenantResolutionAttempt + { + HasValidatedPrincipal = true, + UserId = trusted.Actor, + ClaimTenantId = TenantId.From(SchemaFixture.TenantA), + ClaimOrganizationId = OrganizationId.From(SchemaFixture.OrgA1), + MembershipCovers = true, + ClaimedOrganizationBelongsToTenant = true, + }).Value ?? throw new InvalidOperationException("Claim-only policy progress context was refused."); + context.Origin.Should().Be(TenantContextOrigin.ClaimAndMembership); + context.HostScope.Should().BeNull(); + return new(catalog, reader, context, trusted, unit, evidence); + } + + private async Task AssertParentsStillHeldAsync(HeldBusinessOwners parents, bool postLock, CancellationToken cancellationToken) + { + parents.Units.Should().OnlyContain(unit => unit.HasActiveTransaction); + parents.BackendPids.Should().OnlyHaveUniqueItems().And.HaveCount(AuthorizationPoolProgressFixture.BusinessCapacity); + await using var observer = new NpgsqlConnection(fixture.DirectConnectionString); + await observer.OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand(""" + SELECT count(*) FROM pg_stat_activity + WHERE pid = ANY(@pids) AND usename = current_user + AND xact_start IS NOT NULL AND state = 'idle in transaction' + """, observer); + command.Parameters.AddWithValue("pids", parents.BackendPids.ToArray()); + (await command.ExecuteScalarAsync(cancellationToken)).Should().Be((long)AuthorizationPoolProgressFixture.BusinessCapacity); + if (!postLock) return; + command.CommandText = "SELECT count(*) FROM pg_locks WHERE pid = ANY(@pids) AND locktype = 'advisory' AND granted"; + (await command.ExecuteScalarAsync(cancellationToken)).Should().Be(2L * AuthorizationPoolProgressFixture.BusinessCapacity); + } + + private sealed class CandidateSource(Guid? actor = null) : AuthorizationCandidate.MintingSource, IAuthorizationCandidateSource + { + public UserId Actor => UserId.From(actor ?? IdentityMembershipFixture.AdminA); + public AuthorizationCandidate? ForValidatedScope(TenantId tenant, OrganizationId? organization) => + tenant == TenantId.From(SchemaFixture.TenantA) && organization == OrganizationId.From(SchemaFixture.OrgA1) + ? Candidate() : null; + + public AuthorizationCandidate Candidate() => ForCandidate(AuthorizationSourceKind.Http, + Actor, TenantId.From(SchemaFixture.TenantA), + OrganizationId.From(SchemaFixture.OrgA1)); + } + + /// Probes the actual child PID in this fixture, observes held transactions separately, then runs real lifecycle SQL. + private sealed class LifecycleObserver(string directConnection, HeldBusinessOwners parents) + : IIdentityScopeLifecycleReader + { + private readonly IdentityScopeLifecycleReader _actual = new(parents.Units[0]); + public int Calls { get; private set; } + public bool ChildWasDistinct { get; private set; } + public TransactionMode? ChildMode { get; private set; } + public List AuthorizationBackendPids { get; } = []; + public long AuthorizationTransactionsDuringChild { get; private set; } + public long BusinessTransactionsDuringChild { get; private set; } + + public Task ReadAsync(TenantId tenantId, OrganizationId? organizationId, + CancellationToken cancellationToken = default) => _actual.ReadAsync(tenantId, organizationId, cancellationToken); + + public async Task ReadAuthorizationAsync(IUnitOfWork child, AuthorizationCandidate candidate, + CancellationToken cancellationToken = default) + { + Calls++; + ChildWasDistinct = parents.Units.All(parent => !ReferenceEquals(parent, child)); + ChildMode = child.Mode; + // Fixture-only instrumentation identifies the backend held by the actual production child. + // PgBouncer application-name tracking supplies no identity or capacity evidence here. + await using (var pid = new NpgsqlCommand("SELECT pg_backend_pid()", (NpgsqlConnection)child.Connection, + (NpgsqlTransaction)child.Transaction!)) + AuthorizationBackendPids.Add((int)(await pid.ExecuteScalarAsync(cancellationToken))!); + await using var connection = new NpgsqlConnection(directConnection); + await connection.OpenAsync(cancellationToken); + await using (var command = new NpgsqlCommand(""" + SELECT count(*) FROM pg_stat_activity + WHERE pid = @child AND usename = current_user + AND datname = current_database() AND backend_type = 'client backend' + AND xact_start IS NOT NULL + """, connection)) + { + command.Parameters.AddWithValue("child", AuthorizationBackendPids.Single()); + AuthorizationTransactionsDuringChild = (long)(await command.ExecuteScalarAsync(cancellationToken))!; + } + await using (var command = new NpgsqlCommand(""" + SELECT count(*) FROM pg_stat_activity WHERE pid = ANY(@pids) + AND xact_start IS NOT NULL AND state = 'idle in transaction' + """, connection)) + { + command.Parameters.AddWithValue("pids", parents.BackendPids.ToArray()); + BusinessTransactionsDuringChild = (long)(await command.ExecuteScalarAsync(cancellationToken))!; + } + return await _actual.ReadAuthorizationAsync(child, candidate, cancellationToken); + } + } + + private sealed class HeldBusinessOwners : IAsyncDisposable + { + private readonly List _frames = []; + public NpgsqlDataSource Source { get; } + public List Units { get; } = []; + public List BackendPids { get; } = []; + private HeldBusinessOwners(string connectionString) => Source = ApplicationDataSource.Build(connectionString); + + public static async Task OpenAsync(string connectionString, CancellationToken cancellationToken, + TransactionMode mode = TransactionMode.ReadWrite, bool orgless = false) + { + var owners = new HeldBusinessOwners(connectionString); + try + { + for (var index = 0; index < AuthorizationPoolProgressFixture.BusinessCapacity; index++) + { + var unit = new NpgsqlUnitOfWork(owners.Source, NullLogger.Instance); + owners.Units.Add(unit); + owners._frames.Add(await unit.BeginTransactionAsync(mode, cancellationToken)); + await unit.SetTenantContextAsync(new ParentContext(orgless), cancellationToken); + await using var pid = new NpgsqlCommand("SELECT pg_backend_pid()", (NpgsqlConnection)unit.Connection, + (NpgsqlTransaction)unit.Transaction!); + owners.BackendPids.Add((int)(await pid.ExecuteScalarAsync(cancellationToken))!); + } + return owners; + } + catch + { + await owners.DisposeAsync(); + throw; + } + } + + public async Task HoldInvariantLocksAsync(CancellationToken cancellationToken) + { + for (var index = 0; index < Units.Count; index++) + { + var unit = Units[index]; + await using var command = new NpgsqlCommand(""" + SELECT pg_advisory_xact_lock(@user_key); + SELECT pg_advisory_xact_lock_shared(@tenant_key); + """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + command.Parameters.AddWithValue("user_key", IdentityAdvisoryLockKey.ForUser(UserId.From( + index == 0 ? IdentityMembershipFixture.AdminA : IdentityMembershipFixture.PersonA))); + command.Parameters.AddWithValue("tenant_key", IdentityAdvisoryLockKey.ForTenant(TenantId.From(SchemaFixture.TenantA))); + await command.ExecuteNonQueryAsync(cancellationToken); + } + } + + public Task CompleteFirstAsync(CancellationToken cancellationToken) => _frames[0].CompleteAsync(cancellationToken); + + public async ValueTask DisposeAsync() + { + var failed = false; + foreach (var frame in _frames.AsEnumerable().Reverse()) + try { await frame.DisposeAsync(); } catch (Exception) { failed = true; } + foreach (var unit in Units.AsEnumerable().Reverse()) + try { await unit.DisposeAsync(); } catch (Exception) { failed = true; } + try { await Source.DisposeAsync(); } catch (Exception) { failed = true; } + if (failed) throw new InvalidOperationException("Held business owner cleanup failed."); + } + } + + private sealed class RawBackendReservations : IAsyncDisposable + { + private readonly List _connections = []; + private readonly List _transactions = []; + + public async Task<(NpgsqlConnection Connection, NpgsqlTransaction Transaction)> OpenAsync( + string connectionString, CancellationToken cancellationToken) + { + var parsed = new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false }; + var connection = new NpgsqlConnection(parsed.ConnectionString); + _connections.Add(connection); + await connection.OpenAsync(cancellationToken); + var transaction = await connection.BeginTransactionAsync(cancellationToken); + _transactions.Add(transaction); + return (connection, transaction); + } + + public async ValueTask DisposeAsync() + { + var failed = false; + foreach (var transaction in _transactions.AsEnumerable().Reverse()) + try { await transaction.DisposeAsync(); } catch (Exception) { failed = true; } + foreach (var connection in _connections.AsEnumerable().Reverse()) + try { await connection.DisposeAsync(); } catch (Exception) { failed = true; } + if (failed) throw new InvalidOperationException("Raw backend reservation cleanup failed."); + } + } + + private sealed class ParentContext(bool orgless = false) : ITenantContext + { + public bool IsResolved => true; + public TenantId TenantId => TenantId.From(SchemaFixture.TenantA); + public OrganizationId? OrganizationId => orgless ? null : LearnStack.SharedKernel.Identifiers.OrganizationId.From(SchemaFixture.OrgA1); + public UserId? UserId => LearnStack.SharedKernel.Identifiers.UserId.From(IdentityMembershipFixture.AdminA); + public TenantContextOrigin? Origin => TenantContextOrigin.HostAndClaim; + public string? CorrelationId => null; + public string? ModuleName => "identity"; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationReadSourceTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationReadSourceTests.cs new file mode 100644 index 000000000..f70f2ae71 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuthorizationReadSourceTests.cs @@ -0,0 +1,341 @@ +using System.Data; +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class AuthorizationReadSourceTests(SchemaFixture schema) +{ + [Fact] + public async Task Four_retained_physical_owners_refuse_excess_immediately_and_recover_only_after_disposal() + { + await using var source = await SourceAsync(); + using var trusted = new TestSource(); + var candidate = trusted.Candidate(); + var owners = new List(); + try + { + for (var index = 0; index < 4; index++) owners.Add((await source.OpenAsync(candidate))!); + owners.Select(owner => ((NpgsqlConnection)owner.Unit.Connection).ProcessID).Distinct().Should().HaveCount(4); + var excess = source.OpenAsync(candidate); + excess.IsCompleted.Should().BeTrue("the source has no admission wait queue"); + (await excess).Should().BeNull(); + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + await FluentActions.Awaiting(() => source.OpenAsync(candidate, canceled.Token)) + .Should().ThrowAsync(); + await owners[0].CompleteAsync(); + (await source.OpenAsync(candidate)).Should().BeNull("COMMIT does not confirm resource cleanup"); + await owners[0].DisposeAsync(); + var replacement = (await source.OpenAsync(candidate))!; + owners.Add(replacement); + replacement.Should().NotBeNull(); + } + finally { foreach (var owner in owners) await owner.DisposeAsync(); } + } + + [Fact] + public async Task Candidate_owner_is_physically_read_only_exactly_announced_and_cannot_join_or_retarget() + { + await using var source = await SourceAsync(capacity: 1); + using var trusted = new TestSource(); + await using var owner = (await source.OpenAsync(trusted.Candidate()))!; + var transaction = owner.Unit.Transaction; + var announcement = owner.Unit.GetTenantContextAnnouncement(transaction)!; + announcement.Generation.Should().BePositive(); + announcement.TenantId.Should().Be(TenantId.From(SchemaFixture.TenantA)); + announcement.OrganizationId.Should().Be(OrganizationId.From(SchemaFixture.OrgA1)); + (await ScalarAsync(owner, "SHOW transaction_read_only")).Should().Be("on"); + (await ScalarAsync(owner, "SHOW transaction_isolation")).Should().Be("read committed"); + foreach (var name in new[] { "scope", "resolving_host", "identity_authority_id", "identity_subject_key" }) + (await ScalarAsync(owner, $"SELECT current_setting('app.{name}', true)")).Should().Be(string.Empty); + var join = () => owner.Unit.BeginTransactionAsync(TransactionMode.ReadOnly); + await join.Should().ThrowAsync(); + var provision = () => owner.Unit.SetProvisioningTenantContextAsync(TenantId.From(SchemaFixture.TenantB)); + await provision.Should().ThrowAsync(); + var independentCommit = () => owner.Unit.CommitAsync(); + await independentCommit.Should().ThrowAsync(); + await owner.CompleteAsync(); + owner.Unit.GetTenantContextAnnouncement(transaction).Should().BeNull(); + await owner.DisposeAsync(); + await using var next = (await source.OpenAsync(trusted.Candidate()))!; + next.Unit.GetTenantContextAnnouncement(next.Unit.Transaction)!.Generation.Should().BeGreaterThan(announcement.Generation); + owner.Unit.GetTenantContextAnnouncement(next.Unit.Transaction).Should().BeNull(); + } + + [Fact] + public async Task Cancellation_during_actual_setup_keeps_capacity_and_shutdown_join_until_the_operation_really_finishes() + { + using var pause = new SetupPause(); + using var logs = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Trace).AddProvider(pause)); + var source = await SourceAsync(1, logs); + using var trusted = new TestSource(); + using var cancellation = new CancellationTokenSource(); + pause.Arm(); + var opening = Task.Run(() => source.OpenAsync(trusted.Candidate(), cancellation.Token)); + Task? shutdown = null; + try + { + await pause.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + opening.IsCompleted.Should().BeFalse("the real driver setup is still retained"); + (await source.OpenAsync(trusted.Candidate())).Should().BeNull(); + shutdown = source.DisposeAsync().AsTask(); + shutdown.IsCompleted.Should().BeFalse("shutdown joins actual cleanup, regardless of request cancellation"); + } + finally { pause.Release(); } + await FluentActions.Awaiting(() => opening).Should().ThrowAsync(); + await (shutdown ?? source.DisposeAsync().AsTask()); + source.IsQualified.Should().BeFalse(); + } + + [Fact] + public async Task Reported_command_cleanup_failure_retains_capacity_even_after_transaction_and_connection_disposal() + { + var source = await SourceAsync(capacity: 1); + using var trusted = new TestSource(); + var owner = (await source.OpenAsync(trusted.Candidate()))!; + // This is the reader's denial-only report after its actual command/reader DisposeAsync failed. + owner.MarkCleanupFailed(); + await FluentActions.Awaiting(() => owner.DisposeAsync().AsTask()).Should().ThrowAsync(); + (await source.OpenAsync(trusted.Candidate())).Should().BeNull(); + await FluentActions.Awaiting(() => source.DisposeAsync().AsTask()).Should().ThrowAsync(); + await FluentActions.Awaiting(() => source.DisposeAsync().AsTask()).Should().ThrowAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Actual_connection_disposal_retains_source_and_job_ownership_until_its_positive_completion(bool dirty) + { + var source = await SourceAsync(capacity: 1); + using var trusted = new TestSource(); + var job = JobApplicationSourceOwner.Create(); + using var binding = job.Enter(); + var owner = (await source.OpenAsync(trusted.Candidate()))!; + var connection = (NpgsqlConnection)owner.Unit.Connection; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + StateChangeEventHandler disposal = (_, change) => + { + if (change.CurrentState != ConnectionState.Closed) return; + entered.TrySetResult(); + release.Task.GetAwaiter().GetResult(); + if (dirty) throw new InvalidOperationException("Deliberate actual connection disposal failure."); + }; + connection.StateChange += disposal; + var cleanup = Task.Run(() => owner.DisposeAsync().AsTask()); + Task? shutdown = null; + Task? jobJoin = null; + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cleanup.IsCompleted.Should().BeFalse(); + (await source.OpenAsync(trusted.Candidate())).Should().BeNull(); + job.CleanupConfirmed.Should().BeFalse(); + job.CloseIssuance(); + jobJoin = job.JoinAuxiliaryCleanupAsync(); + shutdown = source.DisposeAsync().AsTask(); + jobJoin.IsCompleted.Should().BeFalse(); + shutdown.IsCompleted.Should().BeFalse(); + } + finally { release.TrySetResult(); } + if (dirty) + { + await FluentActions.Awaiting(() => cleanup).Should().ThrowAsync(); + await FluentActions.Awaiting(() => shutdown!).Should().ThrowAsync(); + } + else { await cleanup; await shutdown!; } + (await jobJoin!).Should().Be(!dirty); + connection.StateChange -= disposal; + job.CleanupConfirmed.Should().Be(!dirty); + } + + [Fact] + public async Task One_deadline_covers_statements_and_completion_without_a_fresh_commit_budget() + { + await using var source = await SourceAsync(capacity: 1); + using var trusted = new TestSource(); + await using var owner = (await source.OpenAsync(trusted.Candidate()))!; + var transaction = owner.Unit.Transaction; + await ScalarAsync(owner, "SELECT pg_sleep(2.6)"); + var second = () => ScalarAsync(owner, "SELECT pg_sleep(3)"); + await second.Should().ThrowAsync(); + owner.DeadlineToken.IsCancellationRequested.Should().BeTrue(); + owner.Unit.HasActiveTransaction.Should().BeFalse(); + owner.Unit.Transaction.Should().BeNull(); + owner.Unit.GetTenantContextAnnouncement(transaction).Should().BeNull(); + var reuse = () => owner.Unit.Connection; + reuse.Should().Throw(); + (await source.OpenAsync(trusted.Candidate())).Should().BeNull("deadline expiry never releases the retained owner slot"); + await FluentActions.Awaiting(owner.CompleteAsync).Should().ThrowAsync(); + await owner.DisposeAsync(); + await using var replacement = (await source.OpenAsync(trusted.Candidate()))!; + replacement.Should().NotBeNull(); + } + + [Fact] + public async Task Qualification_refuses_another_installed_authority_and_never_enables_candidate_issuance() + { + await using var source = new IdentityAuthorizationReadSource(schema.Postgres.AppConnectionString, 1); + await FluentActions.Awaiting(() => source.QualifyAsync(IdentityAuthorityId.From(Guid.CreateVersion7()), + IdentityMembershipFixture.Authority.Issuer)).Should().ThrowAsync(); + source.IsQualified.Should().BeFalse(); + using var trusted = new TestSource(); + await FluentActions.Awaiting(() => source.OpenAsync(trusted.Candidate())).Should().ThrowAsync(); + } + + [Fact] + public async Task Cancellation_bounds_the_actual_role_guard_statement_before_any_candidate_owner_can_be_published() + { + await using var source = await SourceAsync(capacity: 1); + using var trusted = new TestSource(); + await using var warm = (await source.OpenAsync(trusted.Candidate()))!; + var backend = ((NpgsqlConnection)warm.Unit.Connection).ProcessID; + // Both clients exist before the shared catalog is locked. The source reuses its + // qualified physical connector, so this isolates guard SQL from login cancellation. + await using var observer = (NpgsqlConnection)await PostgresFixture.OpenAsync(schema.Postgres.AppConnectionString); + uint relation; + await using (var query = new NpgsqlCommand("SELECT 'pg_catalog.pg_authid'::regclass::oid", observer)) + relation = (uint)(await query.ExecuteScalarAsync())!; + // A granted shared reader fooled the old holder predicate. Exercise that + // real state and warm the exact observer query before exclusive acquisition. + await ScalarAsync(warm, "SELECT count(*) FROM pg_catalog.pg_roles"); + (await RoleCatalogLockAsync(observer, relation, backend, granted: true, CancellationToken.None)).Should().BeTrue(); + (await RoleCatalogLockAsync(observer, relation, backend, granted: true, CancellationToken.None, + "AccessExclusiveLock")).Should().BeFalse("a shared reader is not the exclusive blocker"); + (await RoleCatalogLockAsync(observer, relation, backend, granted: false, CancellationToken.None)).Should().BeFalse(); + await warm.DisposeAsync(); + var acquired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var heldLock = schema.Postgres.HoldRoleCatalogLockAsync(acquired, release.Task); + Task? blocked = null; + using var cancellation = new CancellationTokenSource(); + try + { + var holder = await acquired.Task.WaitAsync(TimeSpan.FromSeconds(5)); + heldLock.IsCompleted.Should().BeFalse("the acknowledged exclusive blocker awaits explicit release"); + blocked = source.OpenAsync(trusted.Candidate(), cancellation.Token); + await WaitForRoleCatalogWaiterAsync(observer, relation, backend); + blocked.IsCompleted.Should().BeFalse(); + cancellation.Cancel(); + await FluentActions.Awaiting(() => blocked.WaitAsync(TimeSpan.FromSeconds(3))) + .Should().ThrowAsync(); + using var observation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + (await RoleCatalogLockAsync(observer, relation, holder, granted: true, observation.Token, + "AccessExclusiveLock")).Should().BeTrue("SQL cancellation must finish while the actual blocker holds its lock"); + heldLock.IsCompleted.Should().BeFalse("the actual role-guard SQL must cancel before its blocker releases"); + } + finally + { + cancellation.Cancel(); + release.TrySetResult(); + try { await heldLock; } + finally + { + if (blocked is not null) + { + await ((Task)blocked).ConfigureAwait(ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.SuppressThrowing); + // A failed cancellation assertion can still leave a returned owner. + // The source joins its actual disposal, not cancellation alone. + if (blocked.IsCompletedSuccessfully) + { + var opened = await blocked; + if (opened is not null) await opened.DisposeAsync(); + } + } + } + } + await using var recovered = (await source.OpenAsync(trusted.Candidate()))!; + recovered.Should().NotBeNull(); + } + + private async Task SourceAsync(int capacity = 4, ILoggerFactory? logs = null) + { + var source = new IdentityAuthorizationReadSource(schema.Postgres.AppConnectionString, capacity, logs); + await source.QualifyAsync(IdentityMembershipFixture.Authority.AuthorityId, IdentityMembershipFixture.Authority.Issuer); + source.QualifiedDatabaseName.Should().Be(new NpgsqlConnectionStringBuilder(schema.Postgres.AppConnectionString).Database); + return source; + } + + private static async Task ScalarAsync(AuthorizationReadOwner owner, string sql) + { + await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)owner.Unit.Connection, + (NpgsqlTransaction?)owner.Unit.Transaction); + return await command.ExecuteScalarAsync(owner.DeadlineToken); + } + + private static async Task RoleCatalogLockAsync(NpgsqlConnection observer, uint relation, + int backend, bool granted, CancellationToken cancellationToken, string mode = "AccessShareLock") + { + await using var query = new NpgsqlCommand(""" + SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_locks + WHERE locktype = 'relation' AND relation = @relation + AND mode = @mode AND granted = @granted AND pid = @backend) + """, observer); + query.Parameters.AddWithValue("relation", NpgsqlDbType.Oid, relation); + query.Parameters.AddWithValue("granted", granted); + query.Parameters.AddWithValue("mode", mode); + query.Parameters.AddWithValue("backend", backend); + return await query.ExecuteScalarAsync(cancellationToken) is true; + } + + private static async Task WaitForRoleCatalogWaiterAsync(NpgsqlConnection observer, uint relation, int backend) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!await RoleCatalogLockAsync(observer, relation, backend, granted: false, deadline.Token)) + await Task.Delay(10, deadline.Token); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + throw new TimeoutException("Role-guard waiter observation timed out."); + } + } + + private sealed class TestSource : AuthorizationCandidate.MintingSource + { + public AuthorizationCandidate Candidate() => ForCandidate(AuthorizationSourceKind.Http, UserId.From(IdentityMembershipFixture.AdminA), + TenantId.From(SchemaFixture.TenantA), OrganizationId.From(SchemaFixture.OrgA1)); + } + + private sealed class SetupPause : ILoggerProvider, ILogger + { + private readonly ManualResetEventSlim _release = new(false); + private int _armed; + private int _fired; + private int _disposed; + internal TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal void Arm() => Volatile.Write(ref _armed, 1); + internal void Release() => _release.Set(); + public ILogger CreateLogger(string categoryName) => this; + public bool IsEnabled(LogLevel logLevel) => true; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (Volatile.Read(ref _armed) == 0 + || !formatter(state, exception).StartsWith("Executing command: SET TRANSACTION READ ONLY", StringComparison.Ordinal) + || Interlocked.Exchange(ref _fired, 1) != 0) return; + Entered.TrySetResult(); + if (!_release.Wait(TimeSpan.FromSeconds(10))) throw new TimeoutException("Setup gate was not released."); + } + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + _release.Set(); + _release.Dispose(); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobBusinessTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobBusinessTests.cs index b06f577bf..e5d652776 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobBusinessTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobBusinessTests.cs @@ -18,6 +18,7 @@ using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Time; @@ -53,7 +54,7 @@ public async Task Business_aggregate_child_job_outbox_and_MUST_audit_share_the_f { await using var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString); await SchemaQueries.ExecuteAsync(owner, null, - "ALTER TABLE audit_log ADD CONSTRAINT reject_job_business_audit CHECK (operation <> 'tenancy.organization.create')"); + "ALTER TABLE audit_log ADD CONSTRAINT reject_job_business_audit CHECK (operation <> 'jobfixture.organization.create')"); } var incoming = Guid.CreateVersion7(); var job = await BackgroundJobRuntimeRows.InsertAsync(database.AppConnectionString, @@ -88,7 +89,7 @@ await SchemaQueries.ExecuteAsync(owner, null, context.UserId.Should().Be(UserId.SystemActor); context.CausalActorUserId.Should().Be(UserId.From(BackgroundJobRuntimeFixture.Human)); (await BackgroundJobRuntimeRows.ScalarInUnitAsync(unit, "SELECT current_user")).Should().Be("learnstack_app"); - (await services.GetRequiredService().Send(new CreateOrganizationCommand( + (await services.GetRequiredService().Send(Fixture.OrganizationRequest( OrganizationId.From(business), "job-business-" + business.ToString("N")[..12], "Job business"), cancellation)) .IsSuccess.Should().BeTrue(); child = await Fixture.EnqueueAsync(services); @@ -158,7 +159,7 @@ await SchemaQueries.ExecuteAsync(owner, null, count.Parameters.AddWithValue("id", outgoing); ((long)(await count.ExecuteScalarAsync())!).Should().Be(success ? 1 : 0); } - await using var audit = new NpgsqlCommand("SELECT outcome, actor_user_id, metadata FROM audit_log WHERE entity_id=@id AND operation='tenancy.organization.create'", + await using var audit = new NpgsqlCommand("SELECT outcome, actor_user_id, metadata FROM audit_log WHERE entity_id=@id AND operation='jobfixture.organization.create'", (NpgsqlConnection)read, (NpgsqlTransaction)tx); audit.Parameters.AddWithValue("id", business.ToString()); await using var reader = await audit.ExecuteReaderAsync(); @@ -230,7 +231,9 @@ public async Task Preflight_refuses_stale_authority_before_constructing_the_hand { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await BackgroundJobSchemaSeed.SeedTenantsAsync(database.AppConnectionString); - var job = await BackgroundJobRuntimeRows.InsertAsync(database.AppConnectionString); + var trace = ActivityTraceId.CreateRandom(); + var job = await BackgroundJobRuntimeRows.InsertAsync(database.AppConnectionString, + traceparent: $"00-{trace}-{ActivitySpanId.CreateRandom()}-01"); var probe = new BackgroundJobFixtureProbe(); await using var host = Fixture.Host(database, probe); var admission = await AdmitAsync(host.Services, job); @@ -250,7 +253,24 @@ await SchemaQueries.ExecuteAsync(control, null, ownership_token=NULL, ownership_kind=NULL, ownership_until=NULL WHERE id=@id """, ("id", job.Value)); } + var preflightReads = 0; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Npgsql", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + if (activity.TraceId == trace && activity.GetTagItem("db.query.text") is string sql + && sql.StartsWith("SELECT ", StringComparison.Ordinal) + && sql.Contains(" FROM background_jobs WHERE ", StringComparison.Ordinal) + && sql.Contains("ownership_until > clock_timestamp()", StringComparison.Ordinal) + && sql.Contains("current_attempt_id = @attempt", StringComparison.Ordinal)) + Interlocked.Increment(ref preflightReads); + }, + }; + ActivitySource.AddActivityListener(listener); var result = await host.Services.GetRequiredService().ExecuteBusinessAsync(admission.Receipt!, admission.Receipt!.Run.OpenBoundary(BackgroundJobCheckpointKind.Transactional)); + preflightReads.Should().Be(1, "the negative must reach the actual app-role preflight SELECT, not an unclassified-request refusal"); result.CommitAcknowledged.Should().BeFalse(); result.KnownRollback.Should().BeTrue(); probe.Constructions.Should().BeEmpty("the unlocked preflight precedes MediatR handler construction"); @@ -272,6 +292,16 @@ public async Task Direct_and_fresh_scope_nested_escapes_refuse_before_any_second if (request == "Nested") { nested++; return Result.Ok(None.Value); } var unit = services.GetRequiredService(); var physical = unit.Transaction; + var rejectedOrganization = OrganizationId.From(Guid.CreateVersion7()); + var catalog = services.GetRequiredService(); + catalog.TryGet(typeof(CreateOrganizationCommand), out var seedPolicy).Should().BeTrue(); + seedPolicy.AllowedSources.Should().Equal(AuthorizationSourceKind.Seed); + (await services.GetRequiredService().Send(new CreateOrganizationCommand( + rejectedOrganization, "seed-only-job-control", "Must remain seed-only"), _)).Error!.Code.Should().Be("forbidden"); + services.GetRequiredService().Organizations.Local.Should() + .NotContain(organization => organization.Id == rejectedOrganization); + (await BackgroundJobRuntimeRows.CountOrganizationAsync(database.AppConnectionString, rejectedOrganization.Value)).Should().Be(0); + unit.Transaction.Should().BeSameAs(physical); var construct = false; var direct = () => services.GetRequiredService().ExecuteAsync(null, TransactionMode.ReadWrite, "direct-borrow", () => { construct = true; return Task.FromResult(Result.Ok(None.Value)); }); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobCapacityAdmissionTests.Completion.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobCapacityAdmissionTests.Completion.cs index 49813fe4d..4ba83e742 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobCapacityAdmissionTests.Completion.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobCapacityAdmissionTests.Completion.cs @@ -27,7 +27,7 @@ public async Task Business_effects_MUST_completion_and_exact_reservation_release { await using var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString); await SchemaQueries.ExecuteAsync(owner, null, ending == "audit" - ? "ALTER TABLE audit_log ADD CONSTRAINT reject_capacity_business_audit CHECK (operation <> 'tenancy.organization.create')" + ? "ALTER TABLE audit_log ADD CONSTRAINT reject_capacity_business_audit CHECK (operation <> 'jobfixture.organization.create')" : "ALTER TABLE background_job_reservations ADD CONSTRAINT reject_capacity_business_release CHECK (released_at IS NULL)"); } var organization = Guid.CreateVersion7(); @@ -39,7 +39,7 @@ await SchemaQueries.ExecuteAsync(owner, null, ending == "audit" { var unit = services.GetRequiredService(); await SchemaQueries.AssertApplicationRoleAsync(unit.Connection, unit.Transaction); - (await services.GetRequiredService().Send(new CreateOrganizationCommand( + (await services.GetRequiredService().Send(Fixture.OrganizationRequest( OrganizationId.From(organization), "capacity-" + organization.ToString("N")[..12], "Capacity atomicity"), token)) .IsSuccess.Should().BeTrue(); child = await Fixture.EnqueueAsync(services, dueAt: DateTimeOffset.UtcNow.AddHours(1)); @@ -73,7 +73,7 @@ await services.GetRequiredService().EnqueueAsync(new TenantCreatedInteg SELECT (SELECT count(*) FROM organizations WHERE id=@organization), (SELECT count(*) FROM outbox_messages WHERE id=@outgoing), (SELECT count(*) FROM background_jobs WHERE id=@child), - (SELECT count(*) FROM audit_log WHERE entity_id=@subject AND operation='tenancy.organization.create' AND outcome='success'), + (SELECT count(*) FROM audit_log WHERE entity_id=@subject AND operation='jobfixture.organization.create' AND outcome='success'), (SELECT count(*) FROM background_jobs WHERE id=@job AND phase='completed' AND processed_at IS NOT NULL), (SELECT count(*) FROM background_job_reservations WHERE job_id=@job AND attempt_id=@attempt AND released_at IS NOT NULL AND release_reason='completed') diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobExternalCheckpointStoreTests.Budgets.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobExternalCheckpointStoreTests.Budgets.cs index eb5a04013..479115059 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobExternalCheckpointStoreTests.Budgets.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobExternalCheckpointStoreTests.Budgets.cs @@ -39,6 +39,7 @@ public async Task Production_prepare_and_outcome_parameters_preserve_the_inclusi services.Insert(0, ServiceDescriptor.Transient(typeof(IPipelineBehavior<,>), typeof(FixtureDiagnosticsBehavior<,>))); services.RemoveAll(); services.AddSingleton(registry); + BackgroundJobFixtureOperations.AddJobs(services, definition.ProfileRegistration.RootRequestType, definition.ProfileRegistration.FinalizeRequestType!); services.AddSingleton(budget.AuditSource(oversizedOutcome)); services.AddSingleton(new PreparedArtifact(artifact)); services.AddTransient(typeof(IRequestHandler<,>).MakeGenericType(definition.ProfileRegistration.RootRequestType, diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobFixtureOperations.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobFixtureOperations.cs new file mode 100644 index 000000000..acf631bcf --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobFixtureOperations.cs @@ -0,0 +1,81 @@ +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Permissions; +using Microsoft.Extensions.DependencyInjection; + +namespace LearnStack.Tests.Integration.Database; + +/// Exact fixture declarations; execution still requires the production job request owner. +internal static class BackgroundJobFixtureOperations +{ + internal const string OrganizationOperation = "jobfixture.organization.create"; + + internal static OperationPolicy Trusted(Type type, AuthorizationSourceKind kind) => + (OperationPolicy)typeof(OperationPolicy).GetMethod(nameof(OperationPolicy.ForTrusted))! + .MakeGenericMethod(type).Invoke(null, + ["tests.component." + type.Assembly.GetName().Name!.Split('.')[2].ToLowerInvariant() + "." + type.Name.ToLowerInvariant(), + new[] { kind }])!; + + internal static void AddJobs(IServiceCollection services, params Type[] requests) => + services.AddSingleton(new DeclaredPolicies(requests.Select(type => Trusted(type, AuthorizationSourceKind.BackgroundJob)).ToArray())); + + internal static void AddPublic(IServiceCollection services, Type request) => + services.AddSingleton(new PublicPolicy(request)); + + internal static void AddOrganizationAudit(IServiceCollection services, params Type[] requests) => + services.AddSingleton(new OrganizationAudit(requests)); + + private sealed class DeclaredPolicies(OperationPolicy[] policies) : IOperationPolicySource + { + public IEnumerable Definitions => []; + public IEnumerable Policies => policies; + } + + private sealed class PublicPolicy(Type request) : IOperationPolicySource + { + private static readonly PermissionDefinition Read = new(PermissionScope.Tenant, "jobfixture.submission.read"); + public IEnumerable Definitions => [new(Read, false)]; + public IEnumerable Policies => + [(OperationPolicy)typeof(OperationPolicy).GetMethod(nameof(OperationPolicy.ForPublic))! + .MakeGenericMethod(request).Invoke(null, ["tests.component.jobfixture.public_submission", Read])!]; + } + + private sealed class OrganizationAudit(Type[] requests) : IAuditCatalogSource + { + public string ModuleName => "jobfixture"; + public void Describe(IAuditCatalogBuilder builder) + { + foreach (var request in requests) + typeof(IAuditCatalogBuilder).GetMethod(nameof(IAuditCatalogBuilder.MustAudit))! + .MakeGenericMethod(request).Invoke(builder, [OrganizationOperation, OperationType.Create, typeof(Organization)]); + } + } + + // These test-module commands exercise the real aggregate/store/audit owner. + // Production CreateOrganizationCommand remains Seed-only and is tested as refused. + internal static string OrganizationSource(bool component = false) + { + var name = component ? "ComponentCreateFixtureOrganization" : "CreateFixtureOrganization"; + return $$""" + public sealed record {{name}}(LearnStack.SharedKernel.Identifiers.OrganizationId OrganizationId, string Slug, string DisplayName) + : MediatR.IRequest>; + public sealed class {{name}}Handler( + LearnStack.Modules.Tenancy.Application.Abstractions.IOrganizationWriteStore store, + LearnStack.SharedKernel.Tenancy.ITenantContext context, + LearnStack.SharedKernel.Time.IClock clock) + : MediatR.IRequestHandler<{{name}}, LearnStack.SharedKernel.Results.Result> + { + public async System.Threading.Tasks.Task> Handle( + {{name}} request, System.Threading.CancellationToken token) + { + var organization = LearnStack.Modules.Tenancy.Domain.Organization.Create(request.OrganizationId, + context.TenantId, request.Slug, request.DisplayName, clock, + context.UserId ?? LearnStack.SharedKernel.Identifiers.UserId.SystemActor); + await store.AddAsync(organization, token); + return LearnStack.SharedKernel.Results.Result.Ok(new LearnStack.Modules.Tenancy.Application.Contracts.Tenant.OrganizationDto( + organization.Id, organization.TenantId, organization.Slug)); + } + } + """; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkFixture.Sources.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkFixture.Sources.cs index b5e9f16f8..7bb3360fb 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkFixture.Sources.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkFixture.Sources.cs @@ -40,7 +40,7 @@ public sealed class PrepareHandler(ISender sender) : IRequestHandler> Handle(Prepare request,CancellationToken token) { var p=request.Parameters; - var written=await sender.Send(new CreateOrganizationCommand(p.PreparedOrganizationId, + var written=await sender.Send(new CreateFixtureOrganization(p.PreparedOrganizationId, "prepared-"+p.PreparedOrganizationId.Value.ToString("N"),"Prepared provider fixture"),token); return written.IsFailure ? Result.Fail(written.Error) : Result.Ok(new Plan(p.ResourceId,p.FinalizedOrganizationId)); @@ -55,7 +55,7 @@ public async Task> Handle(Finalize request,CancellationToken token) if(f.Plan.ResourceId!=p.ResourceId || f.Plan.FinalizedOrganizationId!=p.FinalizedOrganizationId || f.Outcome is {} outcome && outcome.ResourceId!=p.ResourceId) throw new InvalidOperationException("Finalization identifiers were substituted."); - var written=await sender.Send(new CreateOrganizationCommand(p.FinalizedOrganizationId, + var written=await sender.Send(new CreateFixtureOrganization(p.FinalizedOrganizationId, "finalized-"+p.FinalizedOrganizationId.Value.ToString("N"),"Finalized provider "+f.Status),token); return written.IsFailure ? Result.Fail(written.Error) : Result.Ok(None.Value); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkFixture.cs index ed32c8ae1..dcdd68d84 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkFixture.cs @@ -5,6 +5,7 @@ using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Jobs; using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Tenancy; using MediatR; using Microsoft.AspNetCore.Hosting; @@ -33,7 +34,7 @@ internal sealed partial class BackgroundJobPositiveSdkFixture private BackgroundJobPositiveSdkFixture() { var contracts = BackgroundJobRuntimeFixture.Compile(Prefix + ".Application.Contracts", ContractsSource); - var application = BackgroundJobRuntimeFixture.Compile(Prefix + ".Application", ApplicationSource, contracts.Image); + var application = BackgroundJobRuntimeFixture.Compile(Prefix + ".Application", ApplicationSource + BackgroundJobFixtureOperations.OrganizationSource(), contracts.Image); var infrastructure = BackgroundJobRuntimeFixture.Compile(Prefix + ".Infrastructure", InfrastructureSource, contracts.Image); _application = application.Assembly; _parameters = contracts.Assembly.GetType(Prefix + ".Application.Contracts.Parameters", true)!; @@ -53,13 +54,13 @@ private BackgroundJobPositiveSdkFixture() Registry = BackgroundJobDefinitionRegistry.Create( [new(Key, RequestType("Declarations"), _parameters, BackgroundJobScope.TenantWide, BackgroundJobDefinitionReadiness.Active, "external", BackgroundJobProfileRegistration.External(RequestType("Prepare"), RequestType("Finalize"), plan, outcome, port), - [typeof(CreateOrganizationCommand)], Capacity)], + [RequestType("CreateFixtureOrganization")], Capacity)], BackgroundJobDefinitionRegistry.CreateEmpty().All.Concat(new[] { new BackgroundJobRequestDescriptor(RequestType("Submit"), BackgroundJobRequestClassification.Ordinary, BackgroundJobScope.TenantWide), new BackgroundJobRequestDescriptor(RequestType("Prepare"), BackgroundJobRequestClassification.Ordinary, BackgroundJobScope.TenantWide), new BackgroundJobRequestDescriptor(RequestType("Finalize"), BackgroundJobRequestClassification.Ordinary, BackgroundJobScope.TenantWide), - new BackgroundJobRequestDescriptor(typeof(CreateOrganizationCommand), BackgroundJobRequestClassification.Ordinary, BackgroundJobScope.TenantWide), + new BackgroundJobRequestDescriptor(RequestType("CreateFixtureOrganization"), BackgroundJobRequestClassification.Ordinary, BackgroundJobScope.TenantWide), }), modules, infrastructure: topology); } @@ -67,6 +68,9 @@ private BackgroundJobPositiveSdkFixture() internal object Parameters(Guid resource, OrganizationId prepared, OrganizationId finalized) => Activator.CreateInstance(_parameters, resource, prepared, finalized)!; + internal IRequest> OrganizationRequest(OrganizationId id, string slug, string name) => + (IRequest>)Activator.CreateInstance(RequestType("CreateFixtureOrganization"), id, slug, name)!; + internal async Task> SubmitAsync(IServiceProvider services, object parameters) { await using var scope = services.CreateAsyncScope(); @@ -76,7 +80,7 @@ internal async Task> SubmitAsync(IServiceProvider servic internal WebApplicationFactory Host(DisposableSchemaDatabase database, BackgroundJobProviderSettings provider, BackgroundJobPositiveProviderConstructionProbe? construction = null, Action? configure = null, - int? applicationPoolSize = null, BackgroundJobPositiveReconcileProbe? reconcileProbe = null) => + int? applicationPoolSize = null, BackgroundJobPositiveReconcileProbe? reconcileProbe = null, Type[]? componentRequests = null) => new ConstructionOnlyApiFactory().WithWebHostBuilder(builder => { builder.UseEnvironment(Environments.Development); @@ -90,6 +94,11 @@ internal WebApplicationFactory Host(DisposableSchemaDatabase database, services.AddSingleton(Registry); services.AddSingleton(new BackgroundJobFixtureAccessor(new BackgroundJobFixtureContext())); services.AddSingleton(new WrapperAuditSource(this)); + BackgroundJobFixtureOperations.AddJobs(services, RequestType("Prepare"), RequestType("Finalize"), RequestType("CreateFixtureOrganization")); + ComponentOperationRegistration.AddExactComponentAuthority(services, + [.. new[] { RequestType("Submit") }.Concat(componentRequests ?? []) + .Select(type => BackgroundJobFixtureOperations.Trusted(type, AuthorizationSourceKind.Seed))]); + BackgroundJobFixtureOperations.AddOrganizationAudit(services, RequestType("CreateFixtureOrganization")); services.AddSingleton(provider); services.AddSingleton(construction ?? new BackgroundJobPositiveProviderConstructionProbe()); services.AddSingleton(reconcileProbe ?? new BackgroundJobPositiveReconcileProbe()); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.AuditRollback.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.AuditRollback.cs index 00b01fcb7..74afa7846 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.AuditRollback.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.AuditRollback.cs @@ -38,7 +38,7 @@ CREATE FUNCTION sdk_reject_exact_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF {(terminalAudit ? $"NEW.operation='platform.job.dead_letter' AND NEW.metadata->>'job_id'='{job.Value:D}'" - : $"NEW.operation='tenancy.organization.create' AND NEW.entity_id='{finalized.Value:D}'")} THEN + : $"NEW.operation='jobfixture.organization.create' AND NEW.entity_id='{finalized.Value:D}'")} THEN RAISE EXCEPTION 'fixture rejects the exact MUST audit' USING ERRCODE='23514'; END IF; RETURN NEW; @@ -68,12 +68,12 @@ CREATE TRIGGER sdk_reject_audit BEFORE INSERT ON audit_log FOR EACH ROW await EducationSchemaSeed.AnnounceAsync(connection, transaction, SchemaFixture.TenantA, null); await using var command = new NpgsqlCommand(""" SELECT j.phase,j.terminal_reason,j.dead_lettered_at,j.final_outcome_attempt_id, - (SELECT count(*) FROM audit_log WHERE operation='tenancy.organization.create' AND entity_id=@prepared), - (SELECT count(*) FROM audit_log WHERE operation='tenancy.organization.create' AND entity_id=@finalized AND outcome='success'), + (SELECT count(*) FROM audit_log WHERE operation='jobfixture.organization.create' AND entity_id=@prepared), + (SELECT count(*) FROM audit_log WHERE operation='jobfixture.organization.create' AND entity_id=@finalized AND outcome='success'), (SELECT count(*) FROM audit_log WHERE operation='platform.job.dead_letter' AND metadata->>'job_id'=@job_text), (SELECT count(*) FROM sdk_transaction_observations WHERE label LIKE 'finalize-%'), (SELECT count(*) FROM sdk_transaction_observations WHERE label LIKE 'prepare-%'), - (SELECT count(*) FROM audit_log WHERE operation='tenancy.organization.create' AND entity_id=@finalized AND outcome='failed') + (SELECT count(*) FROM audit_log WHERE operation='jobfixture.organization.create' AND entity_id=@finalized AND outcome='failed') FROM background_jobs j WHERE id=@job """, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction) { CommandTimeout = 5 }; command.Parameters.AddWithValue("prepared", prepared.Value.ToString("D")); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.FinalizerPolicyWindow.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.FinalizerPolicyWindow.cs index 385b930ba..bc3e32b3d 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.FinalizerPolicyWindow.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.FinalizerPolicyWindow.cs @@ -269,7 +269,7 @@ public async Task> Handle(TRequest request, CancellationToken cance var actualTransaction = (string)(await transactionId.ExecuteScalarAsync(cancellationToken))!; if (probe.Observe(new(source, unit, unit.Connection, actualTransaction, input)) != 1) throw new InvalidOperationException("Unexpected second Finalize root."); - var written = await sender.Send(new CreateOrganizationCommand(finalized, + var written = await sender.Send(Fixture.OrganizationRequest(finalized, "finalized-" + finalized.Value.ToString("N"), "Finalizer policy window"), cancellationToken); if (written.IsFailure) throw new InvalidOperationException("Actual nested write failed."); probe.FirstWriteReady.TrySetResult(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.FinalizerRetry.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.FinalizerRetry.cs index ac36d83b7..8ade8eb5d 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.FinalizerRetry.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.FinalizerRetry.cs @@ -268,7 +268,7 @@ public async Task> Handle(TRequest request, CancellationToken cance probe.SecondEntered.TrySetResult(); await probe.AllowSecondWrite.Task.WaitAsync(TimeSpan.FromSeconds(45), cancellationToken); } - var written = await sender.Send(new CreateOrganizationCommand(finalized, + var written = await sender.Send(Fixture.OrganizationRequest(finalized, "finalized-" + finalized.Value.ToString("N"), "Finalized provider " + input.Status), cancellationToken); if (written.IsFailure) throw new InvalidOperationException("The actual nested organization write was refused."); if (ordinal == 1) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecovery.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecovery.cs index 98f613f7e..60aeb7f13 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecovery.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecovery.cs @@ -45,7 +45,7 @@ await SchemaQueries.ExecuteAsync(connection, null, """ var bounds = new BackgroundJobRuntimeOptions { LeaseDuration = TimeSpan.FromSeconds(15), - RenewalInterval = TimeSpan.FromSeconds(1), + RenewalInterval = NativeRecoveryTimeProvider.RenewalPacing, SqlTimeout = TimeSpan.FromSeconds(1), ProbeTimeout = TimeSpan.FromSeconds(30), ProgressTimeout = TimeSpan.FromSeconds(30), @@ -55,7 +55,7 @@ await SchemaQueries.ExecuteAsync(connection, null, """ commitsToPass: loseAcquisitionAck ? 0 : int.MaxValue, lifetime: TimeSpan.FromSeconds(90)); await using var source = new PrivilegedDataSource(() => OutboxDispatcherDataSource.Build(proxy.ConnectionString)); var runtime = new BackgroundJobRuntimeGate(Fixture.Registry.Infrastructure); - var time = new NativeLeaseTimerObservation(); + var time = new NativeRecoveryTimeProvider(); var control = new BackgroundJobControl(source, Fixture.Registry, recoveryHost.Services.GetRequiredService(), runtime, bounds, new SystemGuidFactory(), time); var generation = new HangfireGenerationWork(Fixture.Registry.Infrastructure.Pools["external"], runtime, bounds, time); @@ -162,10 +162,18 @@ async Task CleanupAsync(string at, Func cleanup) execution.Token.IsCancellationRequested.Should().BeFalse(); boundary = "provider_before"; (await provider.CountsAsync(flow.Operation)).Should().Be((1L, 0L, 1L)); - construction.Release(); boundary = "reconcile"; - clean = await actual.WaitAsync(TimeSpan.FromSeconds(15)); + using var settlement = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + // Renewal has proved both old windows. Hold its next actual pacing + // callback after the preceding SQL flight, so Outcome's own row lock + // cannot create an unrelated conservative renewal refusal. + await time.HoldNextCallbackAsync(settlement.Token); + execution.RunGate.IsClosed.Should().BeFalse(); + execution.Token.IsCancellationRequested.Should().BeFalse(); + construction.Release(); + clean = await actual.WaitAsync(settlement.Token); clean.Should().BeTrue(); + time.AssertHeld(); boundary = "row_facts"; (await BackgroundJobRuntimeRows.ReadAsync(database.AppConnectionString, identity.JobId))!.Phase.Should().Be("finalize_pending"); (await BackgroundJobRuntimeRows.CountAttemptsAsync(database.AppConnectionString, identity.JobId)).Should().Be(1); @@ -174,17 +182,26 @@ async Task CleanupAsync(string at, Func cleanup) catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error, boundary); } finally { + time.ReleaseAll(); await CleanupAsync("cleanup_actual", async () => { construction.Release(); if (actual is not null) clean = await actual; }); await CleanupAsync("cleanup_work", async () => await work.CompleteAsync(await execution.JoinAsync(clean))); + var controlJoined = false; + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(20)); await CleanupAsync("cleanup_control", async () => { generation.CloseStarts(); control.RequestStop(); - await control.Completion.WaitAsync(TimeSpan.FromSeconds(20)); + await control.Completion.WaitAsync(cleanup.Token); + controlJoined = true; + }); + await CleanupAsync("cleanup_control", async () => + { + await time.JoinAsync(controlJoined).WaitAsync(cleanup.Token); + time.ThrowIfFaulted(); }); if (cleanupFailure is not null) Observe("cleanup", cleanupBoundary!, cleanupFailure); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecoveryScheduling.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecoveryScheduling.cs new file mode 100644 index 000000000..bdbc96354 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecoveryScheduling.cs @@ -0,0 +1,167 @@ +using System.Diagnostics.CodeAnalysis; +using LearnStack.Infrastructure.Jobs; + +namespace LearnStack.Tests.Integration.Database; + +public sealed partial class BackgroundJobPositiveSdkTests +{ + /// Holds one actual renewal pacing callback after the old-lease proof; all clocks remain real. + [SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", + Justification = "The test joins control, the actual timer and its factory before disposing the barrier; an incomplete join retains it.")] + private sealed class NativeRecoveryTimeProvider : TimeProvider + { + internal static readonly TimeSpan RenewalPacing = TimeSpan.FromMilliseconds(1125); + private readonly object _sync = new(); + private readonly NativeLeaseTimerObservation _leases = new(); + private readonly TaskCompletionSource _selected = new(TaskCreationOptions.RunContinuationsAsynchronously); + private RecoveryPacingTimer? _held; + private bool _holdRequested, _closing; + private int _fault; + + internal ObservedNativeLeaseTimer Timer(BackgroundJobExecutionControl execution) => _leases.Timer(execution); + public override long TimestampFrequency => System.TimestampFrequency; + public override long GetTimestamp() => System.GetTimestamp(); + public override DateTimeOffset GetUtcNow() => System.GetUtcNow(); + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + // The distinct validated cadence cannot select the one-second SQL + // waits or typed lease/stage timers. Earlier pacing passes through. + if (state is BackgroundJobExecutionControl or HangfireGenerationWork.Invocation + || dueTime != RenewalPacing || period != Timeout.InfiniteTimeSpan) + return _leases.CreateTimer(callback, state, dueTime, period); + RecoveryPacingTimer? selected = null; + lock (_sync) + if (!_closing && _holdRequested && _held is null) + selected = _held = new(this, callback, state); + if (selected is null) return System.CreateTimer(callback, state, dueTime, period); + try + { + // RunRenewalAsync creates this delay only after the previous + // batch has returned, been applied and released its flight. + selected.Create(dueTime, period); + _selected.TrySetResult(selected); + return selected; + } + catch (Exception error) + { + RecordFault(); + _selected.TrySetException(error); + throw; + } + finally { selected.MarkFactoryReturned(); } + } + + internal async Task HoldNextCallbackAsync(CancellationToken token) + { + lock (_sync) + { + if (_closing || _holdRequested) throw new InvalidOperationException("Recovery pacing can be held only once."); + _holdRequested = true; + } + var selected = await _selected.Task.WaitAsync(token); + await selected.Entered.WaitAsync(token); + AssertHeld(); + } + + internal void AssertHeld() + { + lock (_sync) + if (_held is null || !_held.Entered.IsCompleted || _held.Released || _held.Forwarding) + RecordFault(); + ThrowIfFaulted(); + } + + internal void ReleaseAll() + { + RecoveryPacingTimer? held; + lock (_sync) { _closing = true; held = _held; } + // Seal selection before release; a concurrent factory remains rooted + // in _held and later factories return ordinary system timers. + held?.Release(); + } + + internal async Task JoinAsync(bool controlJoined) + { + ReleaseAll(); + RecoveryPacingTimer? held; + lock (_sync) held = _held; + if (held is null) return; + await held.JoinAsync(); + // Runtime completion closes the factory census. A failed control + // join retains the barrier even when this timer has been disposed. + if (controlJoined) held.DisposeBarrier(); + } + + internal void ThrowIfFaulted() + { + if (Volatile.Read(ref _fault) != 0) + throw new InvalidOperationException("Actual recovery renewal pacing observation failed."); + } + + private void RecordFault() => Interlocked.Exchange(ref _fault, 1); + + private sealed class RecoveryPacingTimer(NativeRecoveryTimeProvider owner, TimerCallback callback, + object? callbackState) : ITimer + { + private readonly object _disposeSync = new(); + private readonly ManualResetEventSlim _release = new(); + private readonly TaskCompletionSource _created = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _factoryReturned = new(TaskCreationOptions.RunContinuationsAsynchronously); + private ITimer? _actual; + private Task? _disposal; + private int _released, _forwarding, _returned; + internal Task Entered => _entered.Task; + internal bool Released => Volatile.Read(ref _released) != 0; + internal bool Forwarding => Volatile.Read(ref _forwarding) != 0; + + internal void Create(TimeSpan dueTime, TimeSpan period) + { + try + { + _actual = TimeProvider.System.CreateTimer(static state => ((RecoveryPacingTimer)state!).Invoke(), this, dueTime, period); + _created.TrySetResult(_actual); + } + catch (Exception error) { _created.TrySetException(error); throw; } + } + + private void Invoke() + { + _entered.TrySetResult(); + try + { + // This safety watchdog cannot extend the caller's shared + // 15-second settlement budget or prove callback disposal. + if (!_release.Wait(TimeSpan.FromSeconds(30))) owner.RecordFault(); + Volatile.Write(ref _forwarding, 1); + callback(callbackState); + } + catch (Exception error) when (NativeWaitCatchable(error)) { owner.RecordFault(); } + finally { Volatile.Write(ref _returned, 1); } + } + + internal void Release() { Volatile.Write(ref _released, 1); _release.Set(); } + internal void MarkFactoryReturned() => _factoryReturned.TrySetResult(); + public bool Change(TimeSpan dueTime, TimeSpan period) => _actual!.Change(dueTime, period); + // Task.Delay may dispose its timer inside this callback. Begin and + // retain actual asynchronous disposal without waiting on ourselves; + // the final join still owns this callback after synchronous Dispose. + public void Dispose() => _ = DisposeActual(); + public ValueTask DisposeAsync() => new(DisposeActual()); + private Task DisposeActual() + { + lock (_disposeSync) return _disposal ??= _actual!.DisposeAsync().AsTask(); + } + internal async Task JoinAsync() + { + await _factoryReturned.Task; + await _created.Task; + await DisposeActual(); + if (_entered.Task.IsCompleted && Volatile.Read(ref _returned) == 0) + throw new InvalidOperationException("Actual recovery pacing callback cleanup is incomplete."); + } + internal void DisposeBarrier() => _release.Dispose(); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NoPlanPolicyWindow.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NoPlanPolicyWindow.cs index 1195045b7..4ce110265 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NoPlanPolicyWindow.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NoPlanPolicyWindow.cs @@ -293,7 +293,7 @@ public async Task> Handle(TRequest request, CancellationToken canc probe.Attempt = new(source, unit, unit.Connection); var parameters = typeof(TRequest).GetProperty("Parameters")!.GetValue(request)!; var prepared = (OrganizationId)parameters.GetType().GetProperty("PreparedOrganizationId")!.GetValue(parameters)!; - var written = await sender.Send(new CreateOrganizationCommand(prepared, + var written = await sender.Send(Fixture.OrganizationRequest(prepared, "prepared-" + prepared.Value.ToString("N"), "Prepare policy window"), cancellationToken); if (written.IsFailure) throw new InvalidOperationException("Actual nested Prepare write failed."); probe.Written.TrySetResult(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.SourceContention.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.SourceContention.cs index d21fd8eef..c565cb474 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.SourceContention.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.SourceContention.cs @@ -65,7 +65,7 @@ public async Task Native_transactional_pool_completes_at_the_app_source_floor_wh entered.TrySetResult(new(owner, main.ProcessID, backend, actual)); await actual; owner.RequireBusinessReady(); - var written = await services.GetRequiredService().Send(new CreateOrganizationCommand( + var written = await services.GetRequiredService().Send(transactional.OrganizationRequest( unrelatedOrganization, "contention-" + unrelatedOrganization.Value.ToString("N"), "Independent transactional pool"), token); return written.IsFailure ? Result.Fail(written.Error) : Result.Ok(None.Value); @@ -85,7 +85,9 @@ public async Task Native_transactional_pool_completes_at_the_app_source_floor_wh services.AddSingleton(bounds); services.AddSingleton(probe); services.AddSingleton(new SourceContentionAuditSource(transactional)); - foreach (var name in SourceContentionRequestNames) + BackgroundJobFixtureOperations.AddJobs(services, transactional.RequestType("TenantRoot"), transactional.RequestType("CreateFixtureOrganization")); + BackgroundJobFixtureOperations.AddOrganizationAudit(services, transactional.RequestType("CreateFixtureOrganization")); + foreach (var name in SourceContentionRequestNames.Append("CreateFixtureOrganization")) { var request = transactional.RequestType(name); foreach (var handler in request.Assembly.GetTypes()) @@ -94,7 +96,7 @@ public async Task Native_transactional_pool_completes_at_the_app_source_floor_wh && candidate.GenericTypeArguments[0] == request)) services.AddTransient(contract, handler); } - }, applicationPoolSize: 32, reconcileProbe: reconcile); + }, applicationPoolSize: 32, reconcileProbe: reconcile, componentRequests: [transactional.RequestType("Submit")]); var lifetime = host.Services.GetRequiredService(); var privilegedConnection = new NpgsqlConnectionStringBuilder(database.OutboxConnectionString) { Pooling = true, MaxPoolSize = 52 }.ConnectionString; @@ -311,11 +313,12 @@ private static BackgroundJobDefinitionRegistry SourceContentionRegistry(Backgrou external.Readiness, external.Queue, external.ProfileRegistration, external.NestedRequestTypes, external.CapacityGroup), new(BackgroundJobRuntimeFixture.TenantKey, transactional.RequestType("Declarations"), transactional.ParameterType, BackgroundJobScope.TenantWide, BackgroundJobDefinitionReadiness.Active, "transactional", - BackgroundJobProfileRegistration.Transactional(transactional.RequestType("TenantRoot")), [typeof(CreateOrganizationCommand)]), + BackgroundJobProfileRegistration.Transactional(transactional.RequestType("TenantRoot")), [transactional.RequestType("CreateFixtureOrganization")]), ], Fixture.Registry.All.Concat(new[] { new BackgroundJobRequestDescriptor(transactional.RequestType("Submit"), BackgroundJobRequestClassification.Ordinary, BackgroundJobScope.TenantWide), new BackgroundJobRequestDescriptor(transactional.RequestType("TenantRoot"), BackgroundJobRequestClassification.Ordinary, BackgroundJobScope.TenantWide), + new BackgroundJobRequestDescriptor(transactional.RequestType("CreateFixtureOrganization"), BackgroundJobRequestClassification.Ordinary, BackgroundJobScope.TenantWide), }), modules, infrastructure: topology); } @@ -369,7 +372,7 @@ private static async Task AssertSourceContentionCommittedAsync(DisposableSchemaD await using var inspect = new NpgsqlCommand(""" SELECT j.phase,j.profile,j.normal_attempts,j.finalize_attempts,j.processed_at IS NOT NULL, (SELECT count(*) FROM organizations WHERE id=@organization), - (SELECT count(*) FROM audit_log WHERE entity_id=@subject AND operation='tenancy.organization.create' AND outcome='success') + (SELECT count(*) FROM audit_log WHERE entity_id=@subject AND operation='jobfixture.organization.create' AND outcome='success') FROM background_jobs j WHERE j.id=@id """, (NpgsqlConnection)app, (NpgsqlTransaction)transaction) { CommandTimeout = 5 }; inspect.Parameters.AddWithValue("id", job.Value); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.Transactions.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.Transactions.cs index a1e52b097..9a63ff4d2 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.Transactions.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.Transactions.cs @@ -28,9 +28,9 @@ CREATE FUNCTION sdk_observe_transaction() RETURNS trigger LANGUAGE plpgsql -- Reconciled failed attempts have their separate standalone -- transaction; they are not successful business writes. IF NEW.outcome<>'success' THEN RETURN NEW; END IF; - IF NEW.operation='tenancy.organization.create' AND NEW.entity_id='{prepared.Value:D}' THEN + IF NEW.operation='jobfixture.organization.create' AND NEW.entity_id='{prepared.Value:D}' THEN label:='prepare-business-must'; - ELSIF NEW.operation='tenancy.organization.create' AND NEW.entity_id='{finalized.Value:D}' THEN + ELSIF NEW.operation='jobfixture.organization.create' AND NEW.entity_id='{finalized.Value:D}' THEN label:='finalize-business-must'; ELSIF NEW.operation='platform.job.dead_letter' THEN label:='finalize-terminal-must'; END IF; ELSIF NEW.phase IN ('completed','terminal_pending') AND OLD.phase='finalizing' THEN diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.cs index d04b59df1..3fec4dfe4 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.cs @@ -113,8 +113,8 @@ public async Task Genuine_sdk_and_provider_preserve_one_logical_effect_and_commi j.terminal_reason,j.business_finalization_incomplete, (SELECT count(*) FROM background_job_reservations r WHERE r.job_id=j.id), (SELECT bool_and(r.released_at IS NOT NULL) FROM background_job_reservations r WHERE r.job_id=j.id), - (SELECT count(*) FROM audit_log a WHERE a.operation='tenancy.organization.create' AND a.entity_id=@prepared), - (SELECT count(*) FROM audit_log a WHERE a.operation='tenancy.organization.create' AND a.entity_id=@finalized), + (SELECT count(*) FROM audit_log a WHERE a.operation='jobfixture.organization.create' AND a.entity_id=@prepared), + (SELECT count(*) FROM audit_log a WHERE a.operation='jobfixture.organization.create' AND a.entity_id=@finalized), (SELECT count(*) FROM audit_log a WHERE a.operation='platform.job.dead_letter' AND a.metadata->>'job_id'=@job_text) FROM background_jobs j WHERE j.id=@job """, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.RecipientFaults.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.RecipientFaults.cs index 90ee45bd0..8d3a1b140 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.RecipientFaults.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.RecipientFaults.cs @@ -2,8 +2,10 @@ using FluentAssertions; using LearnStack.Application.Jobs; using LearnStack.Infrastructure.Jobs; +using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Infrastructure.Jobs; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Jobs; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Results; @@ -53,11 +55,13 @@ public async Task Corrupted_recipient_routing_cannot_reach_child_identity_creati BindingFlags.NonPublic | BindingFlags.Instance)!; field.Should().NotBeNull(); field.SetValue(claim, value); + await using var evidence = await RecipientEvidenceSourceAsync(database); var ids = new RecipientIdentityCounter(); await using var host = RecipientHost(database, new(), configure: services => { services.RemoveAll(); services.AddSingleton(ids); + services.Replace(ServiceDescriptor.Singleton(evidence)); }); var result = await host.Services.GetRequiredService().ExecuteRecipientAsync(claim, BackgroundJobLifecycleProjectionDeclaration.Create(), RecipientGate(), CancellationToken.None); @@ -66,13 +70,62 @@ public async Task Corrupted_recipient_routing_cannot_reach_child_identity_creati result.Command.ProvisionalChild.Should().BeNull(); result.CleanupFailed.Should().BeFalse(); result.SourceOwner.CleanupConfirmed.Should().BeTrue(); - ids.Count.Should().Be(0, "the actual stored route is checked before minting any child identity"); + if (mutation == "organization") + ids.Issued.Should().BeEmpty("the invalid announced organization fails before an attributable refusal"); + else + { + var denialId = ids.Issued.Should().ContainSingle("the sole minted identity must belong to the durable central denial, never a child").Subject; + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(database.AppConnectionString); + await using var readScope = await app.BeginTransactionAsync(); + await SchemaQueries.AssertApplicationRoleAsync(app, readScope); + await EducationSchemaSeed.AnnounceAsync(app, readScope, claim.TenantId.Value, claim.OrganizationId?.Value); + await using var denial = new NpgsqlCommand(""" + SELECT id,operation,operation_class,operation_type,outcome,actor_user_id, + metadata->'authorization'->>'source',entity_id + FROM audit_log WHERE id=@id + """, app, readScope); + denial.Parameters.AddWithValue("id", denialId); + await using var rows = await denial.ExecuteReaderAsync(); + (await rows.ReadAsync()).Should().BeTrue("a separate app-role connection must see the committed refusal"); + rows.GetGuid(0).Should().Be(denialId); + rows.GetString(1).Should().Be("identity.authorization.deny"); + rows.GetString(2).Should().Be("Must"); + rows.GetString(3).Should().Be("SecurityEvent"); + rows.GetString(4).Should().Be("denied"); + rows.GetGuid(5).Should().Be(UserId.SystemActor.Value); + rows.GetString(6).Should().Be("job"); + rows.IsDBNull(7).Should().BeTrue(); + (await rows.ReadAsync()).Should().BeFalse(); + } + ids.Count.Should().Be(ids.Issued.Count, "all identifier allocations are accounted for"); + host.Services.GetRequiredService().IsHealthy.Should().BeTrue(); await using var dispatcher = await PostgresFixture.OpenAsync(database.OutboxConnectionString); (await ReadAsync(dispatcher, "SELECT count(*) FROM background_jobs")).Should().Be(0); (await ReadAsync(dispatcher, "SELECT accepted_job_id IS NULL AND skipped_at IS NULL FROM background_job_recipients")) .Should().BeTrue(); } + private static async Task RecipientEvidenceSourceAsync(DisposableSchemaDatabase database) + { + var authority = IdentityMembershipFixture.Authority; + await using (var migration = (NpgsqlConnection)await PostgresFixture.OpenAsync(database.MigrationConnectionString)) + await using (var install = new NpgsqlCommand("INSERT INTO identity_authority(id,authority_id,issuer) VALUES(1,@id,@issuer)", migration)) + { + install.Parameters.AddWithValue("id", authority.AuthorityId.Value); + install.Parameters.AddWithValue("issuer", authority.Issuer); + await install.ExecuteNonQueryAsync(); + } + var evidence = new AuthorizationEvidenceSource(database.AppConnectionString); + try + { + await using var business = ApplicationDataSource.Build(database.AppConnectionString); + await using var proof = await DatabaseQualificationProof.OpenAsync(business); + await evidence.QualifyAsync(authority.AuthorityId, authority.Issuer, proof, proof.DeadlineToken); + return evidence; + } + catch { await evidence.DisposeAsync(); throw; } + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.RecipientHistory.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.RecipientHistory.cs index 012d3d666..4d5f008ca 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.RecipientHistory.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.RecipientHistory.cs @@ -216,9 +216,17 @@ await SchemaQueries.ExecuteAsync(app, transaction, "UPDATE tenants SET status=@s private sealed class RecipientIdentityCounter : IGuidFactory { private int _count; + internal List Issued { get; } = []; internal int Count => Volatile.Read(ref _count); internal Action? BeforeMint { get; set; } - public Guid NewUuidV7() { Interlocked.Increment(ref _count); BeforeMint?.Invoke(); return Guid.CreateVersion7(); } + public Guid NewUuidV7() + { + Interlocked.Increment(ref _count); + BeforeMint?.Invoke(); + var id = Guid.CreateVersion7(); + Issued.Add(id); + return id; + } public Guid NewUuidV4() => Guid.NewGuid(); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.Submission.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.Submission.cs index a08adb71a..d37dd67be 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.Submission.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.Submission.cs @@ -242,6 +242,9 @@ private static WebApplicationFactory RecipientHost(DisposableSchemaData { services.RemoveAll(); services.AddSingleton(Fixture.Definitions()); + BackgroundJobFixtureOperations.AddJobs(services, + Fixture.Module.Application.GetType("LearnStack.Modules.JobsRecurrenceFixture.Application.TenantRoot", true)!, + Fixture.Module.Application.GetType("LearnStack.Modules.JobsRecurrenceFixture.Application.OrganizationRoot", true)!); foreach (var type in Fixture.Module.Application.GetTypes()) foreach (var contract in type.GetInterfaces().Where(candidate => candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IRequestHandler<,>))) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.AuxiliaryCleanup.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.AuxiliaryCleanup.cs index ddba54e8e..56ef3dbca 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.AuxiliaryCleanup.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.AuxiliaryCleanup.cs @@ -74,7 +74,7 @@ public async Task Native_cache_waiter_timeout_joins_late_actual_auxiliary_cleanu followOnStarted.TrySetResult(); } var organization = originalJob ? firstOrganization : nextOrganization; - var written = await services.GetRequiredService().Send(new CreateOrganizationCommand( + var written = await services.GetRequiredService().Send(Fixture.OrganizationRequest( OrganizationId.From(organization), "job-aux-" + organization.ToString("N"), "Job auxiliary"), token); written.IsSuccess.Should().BeTrue(); if (!first) return Result.Ok(None.Value); @@ -172,7 +172,7 @@ await EventuallyAsync(async () => await VendorAsync(source, job) is { } vendor (await BackgroundJobRuntimeRows.CountAttemptsAsync(database.AppConnectionString, job)).Should().Be(1); await AssertAuxiliaryAuditAbsentAsync(database, firstOrganization); auditHealth.IsHealthy.Should().BeFalse("the real MUST standalone acquisition was refused"); - auditHealth.FailingOperation.Should().Be("tenancy.organization.create"); + auditHealth.FailingOperation.Should().Be("jobfixture.organization.create"); var alert = auditLogger.Entries.Should().ContainSingle().Subject; alert.Level.Should().Be(LogLevel.Critical); alert.EventId.Should().Be(new EventId(3, "LogStandaloneWriteFailed")); @@ -183,7 +183,7 @@ await EventuallyAsync(async () => await VendorAsync(source, job) is { } vendor var counted = auditFailures.Should().ContainSingle().Subject; counted.Count.Should().Be(1); counted.Tags.Should().ContainSingle().Which - .Should().Be(new KeyValuePair("operation", "tenancy.organization.create")); + .Should().Be(new KeyValuePair("operation", "jobfixture.organization.create")); AuxiliaryCleanupResources(owner).Should().BeSameAs(originalResources, "failed reconciliation cannot replace the held auxiliary acquisition"); AuxiliaryCleanupConnection(owner).Should().BeSameAs(borrowed); @@ -341,7 +341,7 @@ private static async Task AssertAuxiliaryAuditAbsentAsync(DisposableSchemaDataba await SchemaQueries.AssertApplicationRoleAsync(connection, transaction); await using var read = new NpgsqlCommand(""" SELECT count(*) FROM audit_log - WHERE operation='tenancy.organization.create' AND entity_id=@subject + WHERE operation='jobfixture.organization.create' AND entity_id=@subject """, connection, transaction); read.Parameters.AddWithValue("subject", organization.ToString()); (await read.ExecuteScalarAsync()).Should().Be(0L, @@ -351,11 +351,11 @@ SELECT count(*) FROM audit_log private static void AssertAuxiliaryAuditDiagnostic(AuxiliaryAuditLog entry, Guid organization) { entry.Exception.Should().BeNull("trusted job diagnostics never receive raw exception details"); - entry.Message.Should().Contain("tenancy.organization.create").And.NotContain(organization.ToString()) + entry.Message.Should().Contain("jobfixture.organization.create").And.NotContain(organization.ToString()) .And.NotContain(nameof(InvalidOperationException)).And.NotContain("refuses another acquisition") .And.NotContain("Deliberate actual auxiliary disposal failure"); entry.Properties.Where(property => property.Key != "{OriginalFormat}").Should().ContainSingle().Which - .Should().Be(new KeyValuePair("Operation", "tenancy.organization.create")); + .Should().Be(new KeyValuePair("Operation", "jobfixture.organization.create")); } private sealed record AuxiliaryAuditLog(LogLevel Level, EventId EventId, string Message, diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.Business.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.Business.cs index 73702fa79..5412e4729 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.Business.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.Business.cs @@ -56,7 +56,7 @@ await SchemaQueries.ExecuteAsync(owner, setup, """ if (entries > 1) (await BackgroundJobRuntimeRows.CountOrganizationAsync(database.AppConnectionString, organization)).Should().Be(0, "a new attempt must not see its predecessor's rolled-back business write"); - var written = await services.GetRequiredService().Send(new CreateOrganizationCommand( + var written = await services.GetRequiredService().Send(Fixture.OrganizationRequest( OrganizationId.From(organization), "job-fair-" + organization.ToString("N")[..12], "Job fairness"), token); written.IsSuccess.Should().BeTrue(); // The real nested write succeeds before this one controlled refusal. @@ -136,7 +136,7 @@ private static async Task SuccessfulBacklogBusinessAuditsAsync(DisposableS await EducationSchemaSeed.AnnounceAsync(connection, transaction, SchemaFixture.TenantA, null); await using var command = new NpgsqlCommand(""" SELECT count(*) FROM audit_log - WHERE entity_id=@id AND operation='tenancy.organization.create' AND operation_class='Must' + WHERE entity_id=@id AND operation='jobfixture.organization.create' AND operation_class='Must' AND outcome='success' AND metadata->>'job_id'=@id """, connection, transaction); command.Parameters.AddWithValue("id", job.Value.ToString()); @@ -155,7 +155,7 @@ public async Task Admitted_native_business_renews_across_local_drain_and_commits { Body = async (_, services, token) => { - var written = await services.GetRequiredService().Send(new CreateOrganizationCommand( + var written = await services.GetRequiredService().Send(Fixture.OrganizationRequest( OrganizationId.From(organization), "job-renew-" + organization.ToString("N")[..12], "Job renewal"), token); written.IsSuccess.Should().BeTrue(); entered.TrySetResult(); @@ -234,7 +234,7 @@ public async Task Native_business_overrun_and_private_callback_keep_both_sources { Body = async (_, services, token) => { - var written = await services.GetRequiredService().Send(new CreateOrganizationCommand( + var written = await services.GetRequiredService().Send(Fixture.OrganizationRequest( OrganizationId.From(organization), "job-stop-" + organization.ToString("N")[..12], "Job stop"), token); written.IsSuccess.Should().BeTrue(); using var registration = token.Register(() => diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.CombinedPhysicalCapacity.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.CombinedPhysicalCapacity.cs index 53b9c4ea3..ecd65034b 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.CombinedPhysicalCapacity.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.CombinedPhysicalCapacity.cs @@ -51,7 +51,7 @@ await SchemaQueries.ExecuteAsync(blocker, blockedTransaction, request.Should().Be("TenantRoot"); AssertCombinedHolds(outboxHold, deliveryHold, expiration, observedSource); bodyObserved.TrySetResult(PoolStatistics(observedSource!).Busy); - var result = await services.GetRequiredService().Send(new CreateOrganizationCommand( + var result = await services.GetRequiredService().Send(Fixture.OrganizationRequest( OrganizationId.From(organization), "combined-" + organization.ToString("N")[..12], "Combined capacity"), token); result.IsSuccess.Should().BeTrue(); return Result.Ok(None.Value); @@ -231,7 +231,7 @@ private static async Task AssertCombinedBusinessAsync(DisposableSchemaDatabase d await using var command = new NpgsqlCommand(""" SELECT (SELECT count(*) FROM organizations WHERE tenant_id=@tenant AND id=@organization)=1 AND (SELECT count(*) FROM audit_log WHERE tenant_id=@tenant - AND operation='tenancy.organization.create' AND operation_class='Must' + AND operation='jobfixture.organization.create' AND operation_class='Must' AND entity_id=@organization::text)=1 """, connection, transaction); command.Parameters.AddWithValue("tenant", SchemaFixture.TenantB); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.CommitFaults.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.CommitFaults.cs index b06ed768c..44b852b13 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.CommitFaults.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.CommitFaults.cs @@ -291,7 +291,7 @@ private static async Task StageNativeCommitEffectsAsync(IServiceProvider service { var unit = services.GetRequiredService(); (await BackgroundJobRuntimeRows.ScalarInUnitAsync(unit, "SELECT current_user")).Should().Be("learnstack_app"); - (await services.GetRequiredService().Send(new CreateOrganizationCommand( + (await services.GetRequiredService().Send(Fixture.OrganizationRequest( OrganizationId.From(effects.Organization), "job-native-" + effects.Organization.ToString("N")[..12], "Native job commit"), cancellationToken)).IsSuccess.Should().BeTrue(); effects.Child = await Fixture.EnqueueAsync(services, dueAt: DateTimeOffset.UtcNow.AddHours(1)); @@ -314,7 +314,7 @@ private static async Task AssertNativeCommitEffectsAsync(DisposableSchemaDatabas (SELECT count(*) FROM background_jobs WHERE id=@child), (SELECT count(*) FROM outbox_messages WHERE id=@outgoing), (SELECT count(*) FROM audit_log WHERE entity_id=@subject - AND operation='tenancy.organization.create' AND outcome='success') + AND operation='jobfixture.organization.create' AND outcome='success') """, connection, transaction); count.Parameters.AddWithValue("organization", effects.Organization); count.Parameters.AddWithValue("child", effects.Child!.Value.Value); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.ComposedLifetime.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.ComposedLifetime.cs index ead713d99..1ac820d3d 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.ComposedLifetime.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.ComposedLifetime.cs @@ -8,6 +8,7 @@ using LearnStack.Infrastructure.Messaging; using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; +using LearnStack.Modules.Identity.Infrastructure.Authorization; using LearnStack.SharedKernel.Audit; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -179,7 +180,8 @@ private static WebApplication ComposedApplication(DisposableSchemaDatabase datab builder.AddLearnStackCrossCuttingFoundation(builder.Configuration.RequireDeploymentMode(), typeof(LearnStack.Modules.Tenancy.Application.AssemblyMarker).Assembly, typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly, - typeof(LearnStack.Modules.Education.Application.AssemblyMarker).Assembly); + typeof(LearnStack.Modules.Education.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Identity.Application.AssemblyMarker).Assembly); builder.Services.AddLearnStackTenancyEdge(builder.Configuration); // These supported test bounds alter caller waiting/poll cadence only; // SqlTimeout and the actual guarded source's cancellation bounds remain. @@ -198,6 +200,11 @@ private static WebApplication ComposedApplication(DisposableSchemaDatabase datab // The actual persistence composition calls AddLearnStackOutboxDispatcher // once, preserving its three exact hosted aliases and source capture. builder.Services.AddLearnStackPersistence(builder.Configuration); + // Complete the actual authorizer graph without granting a human candidate + // or qualifying an unused read lane in this worker-lifetime component proof. + builder.Services.AddSingleton(_ => new IdentityAuthorizationReadSource(database.AppConnectionString)); + builder.Services.AddIdentityAuthorizationReader(); + builder.Services.AddIdentityOperationPolicies(); // Keep the genuine audit writer and all its production dependencies. // Only its real terminal scope gains a synthetic async cleanup hold. builder.Services.AddScoped(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.HandoffQualification.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.HandoffQualification.cs index 449beb010..e722ff55e 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.HandoffQualification.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFacadeTests.HandoffQualification.cs @@ -101,7 +101,7 @@ public async Task Native_completion_qualification_resends_the_locked_retry_gener var entry = Interlocked.Increment(ref calls); entered.TrySetResult(); (await BackgroundJobRuntimeRows.CountOrganizationAsync(database.AppConnectionString, job.Value)).Should().Be(0); - var written = await services.GetRequiredService().Send(new CreateOrganizationCommand( + var written = await services.GetRequiredService().Send(Fixture.OrganizationRequest( OrganizationId.From(job.Value), "job-handoff-" + job.Value.ToString("N")[..12], "Job handoff"), token); written.IsSuccess.Should().BeTrue(); return entry == 1 diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFixture.cs index 2a5e459b0..ec10ceda8 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRuntimeFixture.cs @@ -8,6 +8,7 @@ using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Jobs; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using MediatR; @@ -54,6 +55,8 @@ internal sealed class BackgroundJobRuntimeFixture private const string Prefix = "LearnStack.Modules.JobsSubmissionFixture"; private static readonly Lazy Instance = new(() => new()); private static readonly string[] RootRequests = ["TenantRoot", "OrganizationRoot", "PendingRoot", "CapacityRoot"]; + private static readonly string[] ExecutionRequests = [.. RootRequests, "Nested", "Prepare", "Finalize", "CreateFixtureOrganization"]; + private static readonly string[] ComponentRequests = ["Submit", "SubmitOrganization", "ComponentNested", "ComponentCreateFixtureOrganization"]; internal static BackgroundJobRuntimeFixture Shared => Instance.Value; internal const string TenantKey = "jobssubmissionfixture.run.v1"; internal const string OrganizationKey = "jobssubmissionfixture.organization.v1"; @@ -159,7 +162,8 @@ public Task> Handle(Submit request, CancellationToken cancellationT { Ordinary("TenantRoot"), Ordinary("OrganizationRoot", BackgroundJobScope.Organization), Ordinary("PendingRoot"), Ordinary("CapacityRoot"), Ordinary("Submit"), - Ordinary("SubmitOrganization", BackgroundJobScope.Organization), Ordinary("Nested"), Ordinary("Prepare"), Ordinary("Finalize"), + Ordinary("SubmitOrganization", BackgroundJobScope.Organization), Ordinary("Nested"), Ordinary("ComponentNested"), + Ordinary("CreateFixtureOrganization"), Ordinary("ComponentCreateFixtureOrganization"), Ordinary("Prepare"), Ordinary("Finalize"), new BackgroundJobRequestDescriptor(ForeignRequestType, BackgroundJobRequestClassification.Ordinary, BackgroundJobScope.TenantWide), new BackgroundJobRequestDescriptor(typeof(CreateOrganizationCommand), BackgroundJobRequestClassification.Ordinary, BackgroundJobScope.TenantWide), }), modules, infrastructure: topology); @@ -204,6 +208,12 @@ internal WebApplicationFactory Host(DisposableSchemaDatabase database, services.AddSingleton(probe); services.AddSingleton(new BackgroundJobFixtureAccessor(context ?? new BackgroundJobFixtureContext())); services.AddSingleton(new FixtureAuditSource(this)); + BackgroundJobFixtureOperations.AddJobs(services, [.. ExecutionRequests.Select(RequestType)]); + ComponentOperationRegistration.AddExactComponentAuthority(services, + [.. ComponentRequests.Select(RequestType) + .Append(ForeignRequestType).Select(type => BackgroundJobFixtureOperations.Trusted(type, AuthorizationSourceKind.Seed))]); + BackgroundJobFixtureOperations.AddPublic(services, RequestType("PublicSubmit")); + BackgroundJobFixtureOperations.AddOrganizationAudit(services, RequestType("CreateFixtureOrganization"), RequestType("ComponentCreateFixtureOrganization")); foreach (var assembly in new[] { _application, _foreignApplication }) foreach (var type in assembly.GetTypes()) foreach (var contract in type.GetInterfaces().Where(candidate => candidate.IsGenericType @@ -216,6 +226,12 @@ internal WebApplicationFactory Host(DisposableSchemaDatabase database, internal async Task> SendAsync(IServiceProvider services, string request = "Submit") => (Result)(await services.GetRequiredService().Send(Request(request)))!; + internal IRequest> OrganizationRequest(OrganizationId id, string slug, string name) => + (IRequest>)Activator.CreateInstance(RequestType("CreateFixtureOrganization"), id, slug, name)!; + + internal IRequest> ComponentOrganizationRequest(OrganizationId id, string slug, string name) => + (IRequest>)Activator.CreateInstance(RequestType("ComponentCreateFixtureOrganization"), id, slug, name)!; + internal Task EnqueueAsync(IServiceProvider services, string key = TenantKey, object? parameters = null, DateTimeOffset? dueAt = null) { @@ -233,7 +249,7 @@ private BackgroundJobDefinitionRegistration Definition(string key, string root, BackgroundJobDefinitionReadiness readiness = BackgroundJobDefinitionReadiness.Active, string? capacityGroup = null) => new(key, RequestType("Declarations"), ParameterType, scope, readiness, "transactional", BackgroundJobProfileRegistration.Transactional(RequestType(root)), - nestedRequestTypes: scope == BackgroundJobScope.TenantWide ? [RequestType("Nested"), typeof(CreateOrganizationCommand)] : [], + nestedRequestTypes: scope == BackgroundJobScope.TenantWide ? [RequestType("Nested"), RequestType("CreateFixtureOrganization"), typeof(CreateOrganizationCommand)] : [], capacityGroup: capacityGroup); private BackgroundJobRequestDescriptor Ordinary(string name, BackgroundJobScope scope = BackgroundJobScope.TenantWide) => @@ -256,6 +272,7 @@ public sealed class Declarations { } public sealed record Submit : IRequest>; public sealed record SubmitOrganization : IRequest>; public sealed record Nested : IRequest>; + public sealed record ComponentNested : IRequest>; [PublicSurface] public sealed record PublicSubmit : IRequest>; public sealed record Prepare : IRequest> { @@ -276,7 +293,7 @@ public sealed record {{root}} : IRequest> { Parameters = parameters; Interlocked.Increment(ref Constructions); OnConstruction?.Invoke(); } } """; - foreach (var request in new[] { "Submit", "SubmitOrganization", "Nested", "PublicSubmit", "TenantRoot", "OrganizationRoot", "PendingRoot", "CapacityRoot" }) + foreach (var request in new[] { "Submit", "SubmitOrganization", "Nested", "ComponentNested", "PublicSubmit", "TenantRoot", "OrganizationRoot", "PendingRoot", "CapacityRoot" }) source += $$""" public sealed class {{request}}Handler : IRequestHandler<{{request}}, Result> { @@ -288,7 +305,7 @@ public Task> Handle({{request}} request, CancellationToken cancella probe.ExecuteAsync("{{request}}", services, cancellationToken); } """; - return source; + return source + BackgroundJobFixtureOperations.OrganizationSource() + BackgroundJobFixtureOperations.OrganizationSource(component: true); } private sealed class FixtureAuditSource(BackgroundJobRuntimeFixture fixture) : IAuditCatalogSource @@ -299,7 +316,8 @@ public void Describe(IAuditCatalogBuilder builder) foreach (var request in fixture._application.GetTypes().Concat(fixture._foreignApplication.GetTypes()) .Where(type => type.GetInterfaces().Any(contract => contract.IsGenericType && contract.GetGenericTypeDefinition() == typeof(IRequest<>)))) - typeof(IAuditCatalogBuilder).GetMethod(nameof(IAuditCatalogBuilder.Off))! + if (request.Name is not ("CreateFixtureOrganization" or "ComponentCreateFixtureOrganization")) + typeof(IAuditCatalogBuilder).GetMethod(nameof(IAuditCatalogBuilder.Off))! .MakeGenericMethod(request).Invoke(builder, null); } } @@ -340,7 +358,7 @@ internal sealed class BackgroundJobFixtureContext(Guid? organization = null, Gui public OrganizationId? OrganizationId { get; set; } = organization is { } value ? LearnStack.SharedKernel.Identifiers.OrganizationId.From(value) : null; public UserId? UserId { get; set; } = LearnStack.SharedKernel.Identifiers.UserId.From(BackgroundJobRuntimeFixture.Human); public UserId? CausalActorUserId { get; set; } - public string? CorrelationId => Activity.Current?.Id; + public string? CorrelationId { get; } = Activity.Current?.Id; public bool IsResolved => true; public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; public HostScope? HostScope => null; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobSubmissionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobSubmissionTests.cs index 1604afa50..a5408d1e9 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobSubmissionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobSubmissionTests.cs @@ -84,7 +84,7 @@ public async Task BackgroundJob_Submission_Is_Transactional_And_Stamped(bool org (await BackgroundJobRuntimeRows.ScalarInUnitAsync(unit, "SELECT current_user")).Should().Be("learnstack_app"); (await BackgroundJobRuntimeRows.ScalarInUnitAsync(unit, "SELECT (rolsuper OR rolbypassrls)::text FROM pg_roles WHERE rolname=current_user")).Should().Be("false"); - (await services.GetRequiredService().Send(new CreateOrganizationCommand( + (await services.GetRequiredService().Send(Fixture.ComponentOrganizationRequest( OrganizationId.From(business), "job-proof-" + business.ToString("N")[..12], "Job proof"), CancellationToken.None)).IsSuccess.Should().BeTrue(); traceparent = Activity.Current!.Id; // Trusted enrollment must survive mutations to this ambient object. @@ -149,7 +149,7 @@ public async Task A_handler_refusal_or_exception_rolls_back_observed_job_busines await using var scope = host.Services.CreateAsyncScope(); probe.Body = async (_, services, _) => { - (await services.GetRequiredService().Send(new CreateOrganizationCommand( + (await services.GetRequiredService().Send(Fixture.ComponentOrganizationRequest( OrganizationId.From(business), "job-rollback-" + business.ToString("N")[..12], "Job rollback"), CancellationToken.None)).IsSuccess.Should().BeTrue(); job = await Fixture.EnqueueAsync(services); (await BackgroundJobRuntimeRows.ReadInUnitAsync(services.GetRequiredService(), job.Value)).Should().NotBeNull(); @@ -182,7 +182,7 @@ public async Task An_absorbed_descendant_refusal_poison_depends_on_actual_produc await using var scope = host.Services.CreateAsyncScope(); probe.Body = async (request, services, _) => { - if (request == "Nested") + if (request == "ComponentNested") { if (produce) { @@ -191,9 +191,9 @@ public async Task An_absorbed_descendant_refusal_poison_depends_on_actual_produc } return Result.Fail(Refusal); } - (await services.GetRequiredService().Send(new CreateOrganizationCommand( + (await services.GetRequiredService().Send(Fixture.ComponentOrganizationRequest( OrganizationId.From(business), "job-absorbed-" + business.ToString("N")[..12], "Absorbed refusal"), CancellationToken.None)).IsSuccess.Should().BeTrue(); - (await Fixture.SendAsync(services, "Nested")).Error.Should().BeSameAs(Refusal); + (await Fixture.SendAsync(services, "ComponentNested")).Error.Should().BeSameAs(Refusal); services.GetRequiredService().IsRollbackOnly.Should().Be(produce); observed = true; return Result.Ok(None.Value); @@ -313,7 +313,7 @@ private static async Task AssertBusinessAuditAsync(string connectionString, Guid await using var connection = await PostgresFixture.OpenAsync(connectionString); await using var transaction = await connection.BeginTransactionAsync(); await EducationSchemaSeed.AnnounceAsync(connection, transaction, SchemaFixture.TenantA, organization); - await using var read = new NpgsqlCommand("SELECT outcome FROM audit_log WHERE entity_id = @id AND operation = 'tenancy.organization.create'", + await using var read = new NpgsqlCommand("SELECT outcome FROM audit_log WHERE entity_id = @id AND operation = 'jobfixture.organization.create'", (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); read.Parameters.AddWithValue("id", id.ToString()); var outcomes = new List(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/ComponentAuthorizationEvidence.cs b/backend/tests/LearnStack.Tests.Integration/Database/ComponentAuthorizationEvidence.cs new file mode 100644 index 000000000..4834994ed --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/ComponentAuthorizationEvidence.cs @@ -0,0 +1,78 @@ +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Infrastructure.Bindings; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identity; +using LearnStack.SharedKernel.Tenancy; +using NpgsqlTypes; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// Caller-owned, real two-slot evidence lane for database component proofs. The +/// test qualification overload proves the installed anchor and application role; +/// it makes no production topology or HTTP authorization qualification claim. +/// +internal sealed class ComponentAuthorizationEvidence : IAsyncDisposable +{ + private ComponentAuthorizationEvidence(AuthorizationEvidenceSource source) => Source = source; + internal AuthorizationEvidenceSource Source { get; } + + internal static Task CreateAsync(DisposableSchemaDatabase database, + IdentityAuthorityRegistration? authority = null) => + CreateAsync(database.AppConnectionString, database.MigrationConnectionString, authority); + + internal static async Task CreateAsync(string appConnection, + string migrationConnection, IdentityAuthorityRegistration? authority = null) + { + var registration = authority ?? IdentityMembershipFixture.Authority; + await using (var owner = new NpgsqlConnection(migrationConnection)) + { + await owner.OpenAsync(); + // The deployment installer preserves an exact existing anchor and refuses + // a mismatch. It never rebinds another fixture's authority. + await IdentityAuthorityDeploymentInstaller.InstallAsync(owner, registration); + } + var source = new AuthorizationEvidenceSource(appConnection); + try + { + await source.QualifyAsync(registration.AuthorityId, registration.Issuer); + return new(source); + } + catch + { + await source.DisposeAsync(); + throw; + } + } + + internal void AddTo(IServiceCollection services) => + services.AddSingleton(Source); + + internal static async Task CountDenialsAsync(NpgsqlDataSource business, ITenantContext context, + string policyPrefix) + { + await using var connection = await business.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, context.TenantId.Value); + await SchemaQueries.SetSettingAsync(connection, transaction, "app.organization_id", + context.OrganizationId?.Value.ToString("D") ?? ""); + await SchemaQueries.AssertApplicationRoleAsync(connection, transaction); + await using var query = new NpgsqlCommand(""" + SELECT count(*) FROM audit_log + WHERE operation = 'identity.authorization.deny' AND operation_class = 'Must' + AND outcome = 'denied' AND tenant_id = @tenant + AND organization_id IS NOT DISTINCT FROM @organization + AND starts_with(metadata->'authorization'->>'policy', @prefix) + AND metadata->'authorization'->>'source' = 'seed' + """, connection, transaction); + query.Parameters.AddWithValue("tenant", context.TenantId.Value); + query.Parameters.Add(new NpgsqlParameter("organization", NpgsqlDbType.Uuid) + { Value = context.OrganizationId is { } organization ? organization.Value : DBNull.Value }); + query.Parameters.AddWithValue("prefix", policyPrefix); + return (long)(await query.ExecuteScalarAsync())!; + } + + public ValueTask DisposeAsync() => Source.DisposeAsync(); +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/ComponentDatabaseOperations.cs b/backend/tests/LearnStack.Tests.Integration/Database/ComponentDatabaseOperations.cs new file mode 100644 index 000000000..169052db1 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/ComponentDatabaseOperations.cs @@ -0,0 +1,72 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// Finite policies for isolated database-backed component transports. They grant +/// no production route or human authority; the actual canonical slot 5 remains active. +/// +internal static class ComponentDatabaseOperations +{ + internal static void AddIsolationOperations(IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(new ExplicitReadOnlyRequest(typeof(UnresolvedProbeQuery))); + services.AddSingleton(new ExplicitReadOnlyRequest(typeof(UnresolvedCustomizationProbeQuery))); + } + + internal static void AddProvisioningOperation(IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + } + + private sealed class IsolationPolicies : IOperationPolicySource + { + private static readonly PermissionDefinition Read = new(PermissionScope.Tenant, "tests.isolation.read"); + public IEnumerable Definitions => [new(Read, false)]; + public IEnumerable Policies => + [ + OperationPolicy.ForPublic("tests.isolation.read", Read), + // The deliberate INSERT probe is public solely to prove the physical RO + // barrier refuses it. The identical writable app-role controls are separate. + OperationPolicy.ForPublic("tests.isolation.foreign_write", Read), + OperationPolicy.ForTrusted("tests.isolation.unresolved_settings", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.isolation.unresolved_customization", AuthorizationSourceKind.Seed), + ]; + } + + private sealed class UnresolvedReadSource : TrustedOperationDescriptor.MintingSource, ITrustedOperationSource + { + protected override bool IsEvidenceCurrent(object? evidence) => + ReferenceEquals(evidence, UnresolvedTenantContext.Instance); + + public TrustedOperationDescriptor? Capture(Type requestType, ITenantContext context) => + CanIssue && ReferenceEquals(context, UnresolvedTenantContext.Instance) + && (requestType == typeof(UnresolvedProbeQuery) || requestType == typeof(UnresolvedCustomizationProbeQuery)) + ? ForOperation(AuthorizationSourceKind.Seed, requestType, context, context) : null; + } + + private sealed class ProvisioningPolicy : IOperationPolicySource + { + public IEnumerable Definitions => []; + public IEnumerable Policies => + [OperationPolicy.ForTrusted("tests.outbox.provision", AuthorizationSourceKind.Seed)]; + } + + private sealed class ProvisioningSource : TrustedOperationDescriptor.MintingSource, ITrustedOperationSource + { + protected override bool IsEvidenceCurrent(object? evidence) => + ReferenceEquals(evidence, UnresolvedTenantContext.Instance); + + public TrustedOperationDescriptor? Capture(Type requestType, ITenantContext context) => + CanIssue && requestType == typeof(ProvisionTenantCommand) + && ReferenceEquals(context, UnresolvedTenantContext.Instance) + ? ForOperation(AuthorizationSourceKind.Seed, requestType, context, context) : null; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationContentTypeQuotaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationContentTypeQuotaTests.cs index 58c4bc872..7c811b6ec 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationContentTypeQuotaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationContentTypeQuotaTests.cs @@ -9,6 +9,7 @@ using LearnStack.SharedKernel.Entitlements; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using LearnStack.Tools.Seeder; @@ -27,11 +28,15 @@ namespace LearnStack.Tests.Integration.Database; public sealed class CustomizationContentTypeQuotaTests(SchemaFixture schema, ConstructionOnlyApiFactory factory) : IClassFixture { + private ComponentAuthorizationEvidence _evidence = null!; + [Fact] public async Task The_real_feature_resolver_uses_its_five_key_catalog_floor_beside_the_builtin_then_refuses_the_sixth() { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var tenant = await ProvisionAsync(source); await using var host = Host(database, new Plan()); (await SendAsync(host.Services, tenant, new InitializeTenantContentTypeDefaultCommand())).IsSuccess.Should().BeTrue(); @@ -47,6 +52,7 @@ public async Task The_real_feature_resolver_uses_its_five_key_catalog_floor_besi System.Text.Json.JsonSerializer.Serialize(problem.Extensions["errors"]).Should() .Contain("limits.max_custom_content_types").And.Contain("\"limit\":\"5\""); (await UsageAsync(source, tenant)).Should().Be((5L, 6L, 6L)); + (await ComponentAuthorizationEvidence.CountDenialsAsync(source, tenant, "tests.component.quota.")).Should().Be(1); } [Fact] @@ -54,6 +60,8 @@ public async Task The_registered_null_provider_keeps_its_accepted_unlimited_auth { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var tenant = await ProvisionAsync(source); await using var host = Host(database); foreach (var key in new[] { "first", "second", "third", "fourth", "fifth", "sixth" }) @@ -66,6 +74,8 @@ public async Task Revisions_and_retired_history_share_one_slot_until_its_last_no { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var tenant = await ProvisionAsync(source); await using var host = Host(database, new Plan(1)); var first = Register("first"); @@ -95,6 +105,8 @@ public async Task The_numeric_or_feature_unlimited_contract_bypasses_the_ceiling { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var tenant = await ProvisionAsync(source); await using var host = Host(database, new Plan(limit, unlimited)); foreach (var key in new[] { "first", "second", "third" }) @@ -107,6 +119,8 @@ public async Task Zero_allows_the_builtin_and_existing_revisions_but_no_new_cust { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var tenant = await ProvisionAsync(source); await using (var allowed = Host(database, new Plan(1))) (await SendAsync(allowed.Services, tenant, Register("existing"))).IsSuccess.Should().BeTrue(); @@ -123,6 +137,8 @@ public async Task Different_key_registrations_serialize_at_the_last_slot_while_a { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var firstTenant = await ProvisionAsync(source); var otherTenant = await ProvisionAsync(source); var gate = new QuotaGate(); @@ -154,8 +170,29 @@ public async Task Different_key_registrations_serialize_at_the_last_slot_while_a (await UsageAsync(source, otherTenant)).Should().Be((1L, 1L, 1L)); } + private ServiceProvider BuildProvider(NpgsqlDataSource source, ITenantContext? context) + { + var services = ComponentOperationRegistration.CreateDatabaseServices(source, context); + AddComponentAuthority(services); + _evidence.AddTo(services); + return services.BuildServiceProvider(validateScopes: true); + } + + // Explicit admission for this component mechanism; this is not a live HTTP authorization proof. + private static void AddComponentAuthority(IServiceCollection services) => + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.quota.initialize", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.quota.register", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.quota.publish", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.quota.provision_tenant", AuthorizationSourceKind.Seed)); + private WebApplicationFactory Host(DisposableSchemaDatabase database, Plan? plan = null, QuotaGate? gate = null) => factory.WithWebHostBuilder(builder => builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) .ConfigureServices(services => { // Keep the real context-resolved FeatureFlags implementation; @@ -169,10 +206,10 @@ private WebApplicationFactory Host(DisposableSchemaDatabase database, P private static RegisterTenantContentTypeCommand Register(string key, int version = 1) => new(Guid.CreateVersion7(), key, version, new Dictionary { ["en"] = "Quota proof" }, BuiltInCustomizations.Card.JsonSchema, BuiltInCustomizations.Card.RendererKey); - private static async Task ProvisionAsync(NpgsqlDataSource source) + private async Task ProvisionAsync(NpgsqlDataSource source) { var id = TenantId.From(Guid.CreateVersion7()); - await using var provider = SeedComposition.Build(source, null, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, null); (await SendAsync(provider, null, new ProvisionTenantCommand(id, "quota-" + id.Value.ToString("N"), "Quota proof", OrganizationId.From(Guid.CreateVersion7()), "main", "Main"))).IsSuccess.Should().BeTrue(); return new(id); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationDefaultDeliveryTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationDefaultDeliveryTests.cs index 6aa8d2bcb..67468aad2 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationDefaultDeliveryTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationDefaultDeliveryTests.cs @@ -1,11 +1,18 @@ using FluentAssertions; +using System.Collections.Concurrent; +using LearnStack.Application.Authorization; using LearnStack.Api.Authentication; using LearnStack.Api.Messaging; using LearnStack.Infrastructure.Messaging; using LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Customization.Application.Authorization; +using LearnStack.Modules.Customization.Application.Contracts.Customization; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; using System.Text.Json; using LearnStack.Tools.Seeder; using MediatR; @@ -13,6 +20,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; @@ -29,6 +37,23 @@ public sealed class CustomizationDefaultDeliveryTests(SchemaFixture schema) private const string TaxonomySubscription = "customization.initialize-taxonomy-default.v1"; private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(45); + [Fact] + public async Task Both_real_initializers_capture_only_their_validated_delivery_and_revoke_evidence_after_completion() + { + await using var fixture = await Fixture.CreateAsync(schema); + await fixture.ProvisionAsync(); + fixture.StartApi(observeTrustedOperations: true); + await fixture.WaitForBothDeliveriesAsync(); + await fixture.AssertCreatedDefaultsAsync(); + fixture.AssertTrustedOperations(); + await using var scope = fixture.ApiServices.CreateAsyncScope(); + var fabricated = new SeedIdentityTenantContext(SeedData.English.TenantId, null); + scope.ServiceProvider.GetServices() + .Select(source => source.Capture(typeof(InitializeTenantContentTypeDefaultCommand), fabricated)) + .Should().OnlyContain(descriptor => descriptor == null, + "constructing an Ambient SystemActor context outside the delivery grants no initializer authority"); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -36,7 +61,7 @@ public async Task Stored_backlog_and_fresh_provisioning_reach_both_real_subscrib { await using var fixture = await Fixture.CreateAsync(schema); if (!fresh) await fixture.ProvisionAsync(); - fixture.StartApi(); + fixture.StartApi(allowComponentProvisioning: fresh); fixture.AssertProductionSubscriptions(); if (fresh) await fixture.ProvisionAsync(fixture.ApiServices); @@ -139,6 +164,7 @@ private sealed class Fixture : IAsyncDisposable { private readonly DisposableSchemaDatabase _database; private readonly NpgsqlDataSource _seedSource; + private readonly ComponentAuthorizationEvidence _evidence; private readonly SeedTenant _tenant; private ApiFactory? _api; private NpgsqlConnection? _gateOwner; @@ -147,25 +173,40 @@ private sealed class Fixture : IAsyncDisposable private string _gateTable = ""; private CancellationTokenSource? _seedCancellation; private Task? _seedTask; + private readonly TrustedObservation _trusted = new(); - private Fixture(DisposableSchemaDatabase database) + private Fixture(DisposableSchemaDatabase database, ComponentAuthorizationEvidence evidence) { _database = database; + _evidence = evidence; _seedSource = NpgsqlDataSource.Create(database.AppConnectionString); _tenant = SeedData.English with { Curriculum = null }; } public IServiceProvider ApiServices => _api?.Services ?? throw new InvalidOperationException("API has not started."); - public static async Task CreateAsync(SchemaFixture schema) => - new(await DisposableSchemaDatabase.CreateAsync(schema.Postgres)); + public static async Task CreateAsync(SchemaFixture schema) + { + var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + try { return new(database, await ComponentAuthorizationEvidence.CreateAsync(database)); } + catch { await database.DisposeAsync(); throw; } + } - public void StartApi() + public void StartApi(bool observeTrustedOperations = false, bool allowComponentProvisioning = false) { - _api = new ApiFactory(_database); + _api = new ApiFactory(_database, _evidence, allowComponentProvisioning, observeTrustedOperations ? _trusted : null); _ = _api.Services; } + public void AssertTrustedOperations() + { + var observations = _trusted.Items.ToArray(); + observations.Select(item => item.RequestType).Should().BeEquivalentTo( + [typeof(InitializeTenantContentTypeDefaultCommand), typeof(InitializeTenantLevelTaxonomyDefaultCommand)]); + observations.Should().OnlyContain(item => item.WasActive && item.WrongRequestRefused && item.ForeignContextRefused + && item.Descriptor.SourceKind == AuthorizationSourceKind.IntegrationEvent && !item.Descriptor.IsActive); + } + public void AssertProductionSubscriptions() { var registry = ApiServices.GetRequiredService(); @@ -185,7 +226,7 @@ public async Task ProvisionAsync(IServiceProvider? provider = null) { if (provider is null) { - await using var seed = SeedComposition.Build(_seedSource, null, NullLoggerFactory.Instance); + await using var seed = SeedComposition.Build(_seedSource, null, NullLoggerFactory.Instance, authorizationEvidence: _evidence.Source); await ProvisionInAsync(seed); } else await ProvisionInAsync(provider); @@ -205,7 +246,7 @@ public Task RunSeederAsync() { _seedCancellation = new CancellationTokenSource(Deadline); _seedTask = new SeedRunner( - context => SeedComposition.Build(_seedSource, context, NullLoggerFactory.Instance), + context => SeedComposition.Build(_seedSource, context, NullLoggerFactory.Instance, authorizationEvidence: _evidence.Source), NullLogger.Instance).RunAsync(_seedCancellation.Token, [_tenant]); return _seedTask; } @@ -410,11 +451,12 @@ public async ValueTask DisposeAsync() await _api.DisposeAsync(); } _seedCancellation?.Dispose(); + await _evidence.DisposeAsync(); await _seedSource.DisposeAsync(); await _database.DisposeAsync(); } - private sealed class ApiFactory(DisposableSchemaDatabase database) : WebApplicationFactory + private sealed class ApiFactory(DisposableSchemaDatabase database, ComponentAuthorizationEvidence evidence, bool allowComponentProvisioning, TrustedObservation? trusted = null) : WebApplicationFactory { protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -424,13 +466,64 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.UseSetting("ConnectionStrings:OutboxDispatcher", database.OutboxConnectionString); builder.ConfigureTestServices(services => { + // Only the fresh=true setup act dispatches provisioning through + // this worker host. Real delivery provenance proofs get no + // component minting source, so their wrong-request controls + // exercise every actual registered source without exclusions. + if (allowComponentProvisioning) AddComponentAuthority(services); + evidence.AddTo(services); + if (trusted is not null) + { + services.TryAddSingleton(_ => new OperationPolicyCatalog([new CustomizationOperationPolicySource()])); + services.AddTrustedDeliveryOperations(); + services.AddSingleton(trusted); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ObserveTrustedOperation<,>)); + } // This fixture owns real default delivery and provisioning, but no Identity authority. foreach (var descriptor in services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService) && (descriptor.ImplementationType ?? descriptor.ImplementationFactory?.Method.ReturnType) - == typeof(TenantIdentityAuthorityStartupService)).ToArray()) + is { } implementation && (implementation == typeof(TenantIdentityAuthorityStartupService) + || implementation == typeof(TenantAuthorizationSourcesStartupService))).ToArray()) services.Remove(descriptor); }); } } } + + // Explicit admission for this component mechanism; this is not a live HTTP authorization proof. + private static void AddComponentAuthority(IServiceCollection services) => + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.delivery_setup.provision", AuthorizationSourceKind.Seed)); + + private sealed class TrustedObservation + { + internal ConcurrentQueue Items { get; } = new(); + } + + private sealed record TrustedObservationItem(Type RequestType, TrustedOperationDescriptor Descriptor, + bool WasActive, bool WrongRequestRefused, bool ForeignContextRefused); + + private sealed class ObserveTrustedOperation(IEnumerable sources, + ITenantContext context, TrustedObservation observations) : IPipelineBehavior + where TRequest : notnull where TResponse : IResultBase + { + public Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (typeof(TRequest) == typeof(InitializeTenantContentTypeDefaultCommand) + || typeof(TRequest) == typeof(InitializeTenantLevelTaxonomyDefaultCommand)) + { + var owners = sources.ToArray(); + var descriptor = owners.Select(source => source.Capture(typeof(TRequest), context)) + .Where(value => value is not null).Cast().Should().ContainSingle().Subject; + var other = typeof(TRequest) == typeof(InitializeTenantContentTypeDefaultCommand) + ? typeof(InitializeTenantLevelTaxonomyDefaultCommand) : typeof(InitializeTenantContentTypeDefaultCommand); + var wrongRequestRefused = owners.All(source => source.Capture(other, context) is null) + && owners.All(source => source.Capture(typeof(ProvisionTenantCommand), context) is null); + var fabricated = new SeedIdentityTenantContext(context.TenantId, context.OrganizationId); + observations.Items.Enqueue(new(typeof(TRequest), descriptor, descriptor.IsActive, + wrongRequestRefused, owners.All(source => source.Capture(typeof(TRequest), fabricated) is null))); + } + return next(); + } + } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationDefaultInitializationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationDefaultInitializationTests.cs index 13a9b33a6..f75e651b3 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationDefaultInitializationTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationDefaultInitializationTests.cs @@ -9,6 +9,7 @@ using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Time; @@ -30,6 +31,8 @@ namespace LearnStack.Tests.Integration.Database; public sealed class CustomizationDefaultInitializationTests(SchemaFixture schema, ConstructionOnlyApiFactory factory) : IClassFixture { + private ComponentAuthorizationEvidence _evidence = null!; + [Theory] [InlineData(false)] [InlineData(true)] @@ -37,6 +40,8 @@ public async Task An_untouched_concept_is_created_active_with_one_save_generatio { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var tenant = await ProvisionAsync(source); var saves = new SaveProbe(); await using var host = Host(database, saves); @@ -110,6 +115,8 @@ public async Task Any_concept_history_preserves_every_field_and_generation_and_c { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var tenant = await ProvisionAsync(source); await ArrangeHistoryAsync(source, tenant, taxonomy, history); var before = await StateAsync(source, tenant, taxonomy); @@ -139,6 +146,8 @@ public async Task Must_audit_refusal_rolls_back_the_active_root_and_generation(b { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var tenant = await ProvisionAsync(source); await using (var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString)) { @@ -170,6 +179,8 @@ public async Task Registration_and_initialization_share_the_concept_lock_in_both { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var tenant = await ProvisionAsync(source); var gate = new ConceptGate(); await using var host = Host(database, gate: gate); @@ -222,6 +233,8 @@ public async Task Another_tenants_same_key_history_does_not_suppress_initializat { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var first = await ProvisionAsync(source); var second = await ProvisionAsync(source); await ArrangeHistoryAsync(source, first, taxonomy, "Deleted"); @@ -232,10 +245,32 @@ public async Task Another_tenants_same_key_history_does_not_suppress_initializat (await StateAsync(source, first, taxonomy)).Roots.Should().Equal(before.Roots); } + private ServiceProvider BuildProvider(NpgsqlDataSource source, ITenantContext? context) + { + var services = ComponentOperationRegistration.CreateDatabaseServices(source, context); + AddComponentAuthority(services); + _evidence.AddTo(services); + return services.BuildServiceProvider(validateScopes: true); + } + + // Explicit admission for this component mechanism; this is not a live HTTP authorization proof. + private static void AddComponentAuthority(IServiceCollection services) => + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.defaults.initialize_content", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.defaults.initialize_taxonomy", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.defaults.register_content", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.defaults.register_taxonomy", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.defaults.provision_tenant", AuthorizationSourceKind.Seed)); + private WebApplicationFactory Host(DisposableSchemaDatabase database, SaveProbe? saves = null, ConceptGate? gate = null) => factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) + .ConfigureServices(services => { if (saves is not null) services.AddSingleton(saves); if (gate is null) return; @@ -247,10 +282,10 @@ private WebApplicationFactory Host(DisposableSchemaDatabase database, S provider.GetRequiredService(), gate)); })); - private static async Task ProvisionAsync(NpgsqlDataSource source) + private async Task ProvisionAsync(NpgsqlDataSource source) { var tenant = TenantId.From(Guid.CreateVersion7()); - await using var provider = SeedComposition.Build(source, null, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, null); (await SendAsync(provider, null, new ProvisionTenantCommand(tenant, "default-" + tenant.Value.ToString("N"), "Default proof", OrganizationId.From(Guid.CreateVersion7()), "main", "Main"))).IsSuccess.Should().BeTrue(); return new(tenant); @@ -274,10 +309,10 @@ private static async Task RegisterAsync(IServiceProvider services, : await SendAsync(services, tenant, new RegisterTenantContentTypeCommand(id, "card", version, new Dictionary { ["en"] = "Authored content" }, BuiltInCustomizations.Card.JsonSchema, BuiltInCustomizations.Card.RendererKey)); - private static async Task ArrangeHistoryAsync(NpgsqlDataSource source, Context tenant, bool taxonomy, string history) + private async Task ArrangeHistoryAsync(NpgsqlDataSource source, Context tenant, bool taxonomy, string history) { var id = Guid.CreateVersion7(); - await using var provider = SeedComposition.Build(source, tenant, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, tenant); // This composition fixes its tenant; no accessor write or privileged setup is needed. (await RegisterAsync(provider, null, taxonomy, id, history == "Successor" ? 2 : 1)).IsSuccess.Should().BeTrue(); if (history is "Draft" or "Successor") return; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs index 476da32da..7456a1ea5 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs @@ -11,6 +11,7 @@ using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using LearnStack.Tools.Seeder; @@ -29,6 +30,8 @@ namespace LearnStack.Tests.Integration.Database; public sealed class CustomizationPublicationConcurrencyTests(SchemaFixture schema, ConstructionOnlyApiFactory factory) : IClassFixture { + private ComponentAuthorizationEvidence _evidence = null!; + [Theory] [InlineData(false, false)] [InlineData(false, true)] @@ -38,6 +41,8 @@ public async Task A_competitor_publishing_between_reads_is_refused_without_self_ { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var id = Guid.CreateVersion7(); await RegisterAsync(source, context, id, taxonomy, "race-definition"); @@ -46,6 +51,11 @@ public async Task A_competitor_publishing_between_reads_is_refused_without_self_ await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) + .ConfigureServices(services => { services.AddScoped(provider => new ControlledContentStore( new TenantContentTypeStore(provider.GetRequiredService(), provider.GetRequiredService()), gate)); @@ -98,6 +108,8 @@ public async Task An_absorbed_first_publication_save_failure_cannot_commit_the_d { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var id = Guid.CreateVersion7(); var later = Guid.CreateVersion7(); @@ -107,6 +119,11 @@ public async Task An_absorbed_first_publication_save_failure_cannot_commit_the_d await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) + .ConfigureServices(services => { services.AddScoped(provider => new ControlledContentStore( new TenantContentTypeStore(provider.GetRequiredService(), provider.GetRequiredService()), failure: concurrency)); @@ -139,6 +156,8 @@ public async Task Replacement_publication_rolls_back_both_revisions_and_generati { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var incumbent = Guid.CreateVersion7(); var successor = Guid.CreateVersion7(); @@ -170,20 +189,40 @@ async Task Publish() ((long)(await audit.ExecuteScalarAsync())!).Should().Be(1, "only the earlier registration committed, not publication"); } - private static async Task ProvisionAsync(NpgsqlDataSource source) + private ServiceProvider BuildProvider(NpgsqlDataSource source, ITenantContext? context) + { + var services = ComponentOperationRegistration.CreateDatabaseServices(source, context); + AddComponentAuthority(services); + _evidence.AddTo(services); + return services.BuildServiceProvider(validateScopes: true); + } + + // Explicit admission for this component mechanism; this is not a live HTTP authorization proof. + private static void AddComponentAuthority(IServiceCollection services) => + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.publication.publish_content", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.publication.publish_taxonomy", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.publication.register_content", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.publication.register_taxonomy", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.publication.outer", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.publication.get_content_type_seed_state", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.publication.get_taxonomy_seed_state", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.publication.provision_tenant", AuthorizationSourceKind.Seed)); + + private async Task ProvisionAsync(NpgsqlDataSource source) { var tenant = TenantId.From(Guid.CreateVersion7()); (await SendAsync(source, null, new ProvisionTenantCommand(tenant, "publication-proof", "Publication proof", OrganizationId.From(Guid.CreateVersion7()), "main", "Main"))).IsSuccess.Should().BeTrue(); return new(tenant, null); } - private static async Task> SendAsync(NpgsqlDataSource source, ITenantContext? context, IRequest> command) + private async Task> SendAsync(NpgsqlDataSource source, ITenantContext? context, IRequest> command) { - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); return await scope.ServiceProvider.GetRequiredService().Send(command); } - private static async Task RegisterAsync(NpgsqlDataSource source, ITenantContext context, Guid id, bool taxonomy, string key, int version = 1) + private async Task RegisterAsync(NpgsqlDataSource source, ITenantContext context, Guid id, bool taxonomy, string key, int version = 1) { if (taxonomy) (await SendAsync(source, context, Taxonomy(id, key, version))).IsSuccess.Should().BeTrue(); else (await SendAsync(source, context, ContentType(id, key, version))).IsSuccess.Should().BeTrue(); @@ -192,15 +231,15 @@ private static async Task RegisterAsync(NpgsqlDataSource source, ITenantContext new Dictionary { ["en"] = "Definition" }, BuiltInCustomizations.Card.JsonSchema, BuiltInCustomizations.Card.RendererKey); private static RegisterTenantLevelTaxonomyCommand Taxonomy(Guid id, string key, int version = 1) => new(id, key, version, new Dictionary { ["en"] = "Definition" }, [new("basic", new Dictionary { ["en"] = "Basic" }, 0)]); - private static async Task AssertStateAsync(NpgsqlDataSource source, ITenantContext context, Guid id, bool taxonomy, string status, long version) + private async Task AssertStateAsync(NpgsqlDataSource source, ITenantContext context, Guid id, bool taxonomy, string status, long version) { if (taxonomy) (await SendAsync(source, context, new GetTaxonomySeedStateQuery(id))).Value!.State!.Status.Should().Be(status); else (await SendAsync(source, context, new GetContentTypeSeedStateQuery(id))).Value!.State!.Status.Should().Be(status); (await VersionAsync(source, context, id, taxonomy)).Should().Be(version); } - private static async Task VersionAsync(NpgsqlDataSource source, ITenantContext context, Guid id, bool taxonomy) + private async Task VersionAsync(NpgsqlDataSource source, ITenantContext context, Guid id, bool taxonomy) { - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseQualificationProofTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseQualificationProofTests.cs new file mode 100644 index 000000000..b32402e65 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseQualificationProofTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Infrastructure.Bindings; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class DatabaseQualificationProofTests(SchemaFixture schema) +{ + [Fact] + public async Task Proof_factory_itself_refuses_raw_migration_and_platform_sources_without_a_builder_guard() + { + foreach (var connection in new[] + { + schema.Postgres.MigrationConnectionString, + schema.Postgres.PlatformConnectionString, + }) + { + // Deliberately omit ApplicationDataSource.Build: the proof factory must enforce its own role boundary. + await using var raw = NpgsqlDataSource.Create(connection); + await FluentActions.Awaiting(async () => + { + await using var unexpected = await DatabaseQualificationProof.OpenAsync(raw); + }).Should().ThrowAsync(); + } + await using var application = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var positive = await DatabaseQualificationProof.OpenAsync(application); + await using var source = new SourceLane(schema.Postgres.AppConnectionString, evidence: false); + await source.QualifyAsync(positive); + source.IsQualified.Should().BeTrue(); + } + + [Fact] + public async Task One_live_business_proof_qualifies_both_lanes_and_disposal_releases_its_actual_marker() + { + await using var business = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + await using var observer = (NpgsqlConnection)await PostgresFixture.OpenAsync(schema.Postgres.AppConnectionString); + await SchemaQueries.AssertApplicationRoleAsync(observer); + await using var proof = await DatabaseQualificationProof.OpenAsync(business); + await using var marker = MarkerQuery(observer, proof); + (await marker.ExecuteScalarAsync()).Should().Be(1L); + await using var read = new SourceLane(schema.Postgres.AppConnectionString, evidence: false); + await using var evidence = new SourceLane(schema.Postgres.AppConnectionString, evidence: true); + await read.QualifyAsync(proof); + await evidence.QualifyAsync(proof); + read.IsQualified.Should().BeTrue(); + evidence.IsQualified.Should().BeTrue(); + await proof.DisposeAsync(); + (await marker.ExecuteScalarAsync()).Should().Be(0L, "the transaction marker must not survive pooled connection return"); + // Startup proof closure does not revoke the already-qualified runtime sources. + read.IsQualified.Should().BeTrue(); + evidence.IsQualified.Should().BeTrue(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Qualification_cannot_upgrade_an_unbound_result_or_retarget_an_existing_proof(bool evidence, bool initiallyBound) + { + await using var business = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + await using var first = await DatabaseQualificationProof.OpenAsync(business); + await using var second = await DatabaseQualificationProof.OpenAsync(business); + await using var source = new SourceLane(schema.Postgres.AppConnectionString, evidence); + await source.QualifyAsync(initiallyBound ? first : null); + source.IsQualified.Should().BeTrue(); + await FluentActions.Awaiting(() => source.QualifyAsync(second)).Should().ThrowAsync(); + if (initiallyBound) + await FluentActions.Awaiting(() => source.QualifyAsync(null)).Should().ThrowAsync(); + await source.QualifyAsync(initiallyBound ? first : null); + } + + [Theory] + [InlineData("disposed")] + [InlineData("canceled")] + [InlineData("expired")] + public async Task Closed_or_expired_proof_cannot_qualify_either_lane(string closure) + { + await using var business = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + using var cancellation = new CancellationTokenSource(); + await using var proof = await DatabaseQualificationProof.OpenAsync(business, cancellation.Token); + if (closure == "disposed") await proof.DisposeAsync(); + else if (closure == "canceled") await cancellation.CancelAsync(); + else + { + // Observe the production five-second deadline, rather than inventing a shorter test clock. + await FluentActions.Awaiting(() => Task.Delay(Timeout.InfiniteTimeSpan, proof.DeadlineToken) + .WaitAsync(TimeSpan.FromSeconds(8))).Should().ThrowAsync(); + proof.DeadlineToken.IsCancellationRequested.Should().BeTrue(); + } + foreach (var evidence in new[] { false, true }) + { + await using var source = new SourceLane(schema.Postgres.AppConnectionString, evidence); + var qualify = FluentActions.Awaiting(() => source.QualifyAsync(proof)); + if (closure == "disposed") await qualify.Should().ThrowAsync(); + else await qualify.Should().ThrowAsync(); + source.IsQualified.Should().BeFalse(); + } + } + + [Fact] + public async Task Proof_from_another_database_cannot_qualify_either_lane_even_with_the_same_authority() + { + await using var other = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using (var migration = (NpgsqlConnection)await PostgresFixture.OpenAsync(other.MigrationConnectionString)) + await IdentityAuthorityDeploymentInstaller.InstallAsync(migration, IdentityMembershipFixture.Authority); + await using var business = ApplicationDataSource.Build(other.AppConnectionString); + await using var proof = await DatabaseQualificationProof.OpenAsync(business); + foreach (var evidence in new[] { false, true }) + { + await using var foreign = new SourceLane(schema.Postgres.AppConnectionString, evidence); + await FluentActions.Awaiting(() => foreign.QualifyAsync(proof)).Should().ThrowAsync(); + foreign.IsQualified.Should().BeFalse(); + await using var matching = new SourceLane(other.AppConnectionString, evidence); + await matching.QualifyAsync(proof); + matching.IsQualified.Should().BeTrue(); + } + } + + private static NpgsqlCommand MarkerQuery(NpgsqlConnection observer, DatabaseQualificationProof proof) + { + var command = new NpgsqlCommand(""" + SELECT count(*) FROM pg_catalog.pg_locks + WHERE locktype='advisory' AND granted AND pid=@business_pid + AND classid::bigint=@business_first AND objid::bigint=@business_second AND objsubid=2 + """, observer); + DatabaseQualificationProof.Bind(command, proof); + return command; + } + + private sealed class SourceLane : IAsyncDisposable + { + private readonly IdentityAuthorizationReadSource? _read; + private readonly AuthorizationEvidenceSource? _evidence; + + internal SourceLane(string connection, bool evidence) + { + if (evidence) _evidence = new(connection); + else _read = new(connection); + } + + internal bool IsQualified => _read?.IsQualified ?? _evidence!.IsQualified; + internal Task QualifyAsync(DatabaseQualificationProof? proof) + { + var authority = IdentityMembershipFixture.Authority; + if (_read is not null) + return proof is null ? _read.QualifyAsync(authority.AuthorityId, authority.Issuer) + : _read.QualifyAsync(authority.AuthorityId, authority.Issuer, proof); + return proof is null ? _evidence!.QualifyAsync(authority.AuthorityId, authority.Issuer) + : _evidence!.QualifyAsync(authority.AuthorityId, authority.Issuer, proof); + } + public ValueTask DisposeAsync() => _read?.DisposeAsync() ?? _evidence!.DisposeAsync(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DeliveryProcessorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DeliveryProcessorTests.cs index 68e85d760..dc2d779c0 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/DeliveryProcessorTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/DeliveryProcessorTests.cs @@ -55,7 +55,10 @@ public async Task Actual_API_registers_one_delivery_worker_with_shared_guarded_s { await using var fixture = await Fixture.CreateAsync(schema); fixture.Start(); - fixture.Services.GetServices().OfType().Should().ContainSingle(); + var hosted = fixture.Services.GetServices().ToArray(); + hosted.OfType().Should().ContainSingle(); + hosted.OfType().Should().BeEmpty(); + hosted.OfType().Should().BeEmpty(); await UntilAsync(() => fixture.Observation.IsHealthy); var source = fixture.Services.GetRequiredKeyedService(OutboxDispatcherDataSource.Key); source.IsValueCreated.Should().BeTrue(); @@ -867,6 +870,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) && (descriptor.ImplementationType ?? descriptor.ImplementationFactory?.Method.ReturnType) is { } implementation && (implementation == typeof(OutboxProcessor) || implementation == typeof(BackgroundJobHostedService) || implementation == typeof(TenantIdentityAuthorityStartupService) + || implementation == typeof(TenantAuthorizationSourcesStartupService) || removeWorker && implementation == typeof(DeliveryProcessor))).ToArray()) services.Remove(descriptor); services.RemoveAll(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs index 86ec0d97e..debfe9b4f 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs @@ -13,6 +13,7 @@ using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Validation; @@ -33,6 +34,8 @@ namespace LearnStack.Tests.Integration.Database; public sealed class EducationWriterTests(SchemaFixture schema, ConstructionOnlyApiFactory factory) : IClassFixture { + private ComponentAuthorizationEvidence _evidence = null!; + private const string StringSchema = """{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false}"""; private const string NumberSchema = """{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"text":{"type":"number"}},"required":["text"],"additionalProperties":false}"""; private static readonly string[] ExpectedOperations = ["education.course.create", "education.course.translation_add", "education.course.publish", "education.lesson.create", "education.lesson.translation_add", "education.lesson.publish"]; @@ -43,6 +46,8 @@ public async Task Composed_writer_bounds_instances_before_lookup_and_preserves_s { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); await TypeAsync(source, context, 1, StringSchema); var course = await CourseAsync(source, context, "bounded"); @@ -68,6 +73,8 @@ public async Task Six_commands_preserve_independent_roots_explicit_policy_and_co { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); await TypeAsync(source, context, 1, StringSchema); var course = await CourseAsync(source, context, "course", "enrollment_required"); @@ -96,6 +103,8 @@ public async Task Exact_active_bindings_and_existing_deprecated_pins_use_their_o { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var taxonomyId = Guid.CreateVersion7(); (await SendAsync(source, context, new RegisterTenantLevelTaxonomyCommand(taxonomyId, "levels", 1, @@ -139,6 +148,8 @@ public async Task Hidden_roots_are_not_found_and_visible_incompatible_scope_is_r { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var foreign = await ProvisionAsync(source); await TypeAsync(source, context, 1, StringSchema); @@ -187,6 +198,11 @@ public async Task Hidden_roots_are_not_found_and_visible_incompatible_scope_is_r (await CourseStateAsync(source, orgA, scoped.Id)).Version.Should().Be(0); (await LessonStateAsync(source, context, wideLesson.Id)).Should().BeEquivalentTo(wideLessonBefore); (await LessonStateAsync(source, orgA, child.Id)).Should().BeEquivalentTo(state); + // Each refused actual handler act has one independent durable MUST row. + (await ComponentAuthorizationEvidence.CountDenialsAsync(source, orgA, "tests.component.education.")).Should().Be(5); + (await ComponentAuthorizationEvidence.CountDenialsAsync(source, orgB, "tests.component.education.")).Should().Be(11); + (await ComponentAuthorizationEvidence.CountDenialsAsync(source, context, "tests.component.education.")).Should().Be(3); + (await ComponentAuthorizationEvidence.CountDenialsAsync(source, foreign, "tests.component.education.")).Should().Be(5); } [Fact] @@ -194,6 +210,8 @@ public async Task Locale_slug_lifecycle_and_stale_refusals_preserve_the_root_and { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var foreign = await ProvisionAsync(source); var tenant = (await SendAsync(source, context, new GetTenantSeedStateQuery())).Value!.State!; @@ -231,6 +249,8 @@ public async Task Publication_requires_durable_must_audit_and_rolls_back_on_audi { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); await TypeAsync(source, context, 1, StringSchema); var course = await CourseAsync(source, context, "course"); @@ -254,6 +274,8 @@ public async Task Slug_diagnostics_identify_only_visible_conflicting_roots(bool { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); await TypeAsync(source, context, 1, StringSchema); var orgA = context with { Organization = context.FirstOrganization }; @@ -291,6 +313,8 @@ public async Task Unknown_education_unique_constraints_remain_infrastructure_fau { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); await TypeAsync(source, context, 1, StringSchema); var course = await CourseAsync(source, context, "first"); @@ -316,11 +340,18 @@ public async Task Concurrent_publication_at_one_version_has_one_winner_and_one_m { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var course = await CourseAsync(source, context, "course"); var barrier = new ReadBarrier(); await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) .ConfigureServices(services => services.AddScoped(provider => new BarrierCourseStore(new CourseWriteStore(provider.GetRequiredService()), barrier)))); async Task> Publish() @@ -343,12 +374,19 @@ public async Task Concurrent_translation_inserts_reserve_one_tenant_slug_and_rol { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var first = await CourseAsync(source, context, "first"); var second = await CourseAsync(source, context, "second"); var barrier = new ReadBarrier(); await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) .ConfigureServices(services => services.AddScoped(provider => new BarrierCourseStore(new CourseWriteStore(provider.GetRequiredService()), barrier)))); async Task> Translate(Guid id) @@ -375,6 +413,8 @@ public async Task An_outer_handler_absorbing_a_post_save_refusal_cannot_commit_t { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var course = await CourseAsync(source, context, "course"); await TypeAsync(source, context, 1, StringSchema); @@ -384,6 +424,11 @@ public async Task An_outer_handler_absorbing_a_post_save_refusal_cannot_commit_t await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) + .ConfigureServices(services => { services.AddScoped(provider => new FailAfterSaveCourseStore( new CourseWriteStore(provider.GetRequiredService()), course.Id, concurrency)); @@ -410,7 +455,36 @@ public async Task An_outer_handler_absorbing_a_post_save_refusal_cannot_commit_t (await CountAuditsAsync(source, context, "education.course.create")).Should().Be(1); } - private static async Task ProvisionAsync(NpgsqlDataSource source) + private ServiceProvider BuildProvider(NpgsqlDataSource source, ITenantContext? context) + { + var services = ComponentOperationRegistration.CreateDatabaseServices(source, context); + AddComponentAuthority(services); + _evidence.AddTo(services); + return services.BuildServiceProvider(validateScopes: true); + } + + // Explicit admission for this component mechanism; this is not a live HTTP authorization proof. + private static void AddComponentAuthority(IServiceCollection services) => + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.education.publish_course", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.translate_course", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.publish_lesson", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.create_course", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.outer", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.add_lesson_translation", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.create_lesson", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.create_organization", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.get_course_seed_state", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.get_lesson_seed_state", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.get_tenant_seed_state", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.provision_tenant", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.add_tenant_locale", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.publish_tenant_content_type", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.publish_tenant_level_taxonomy", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.register_tenant_content_type", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.education.register_tenant_level_taxonomy", AuthorizationSourceKind.Seed)); + + private async Task ProvisionAsync(NpgsqlDataSource source) { var tenant = TenantId.From(Guid.CreateVersion7()); var first = OrganizationId.From(Guid.CreateVersion7()); @@ -423,7 +497,7 @@ private static async Task ProvisionAsync(NpgsqlDataSource source) return context; } - private static async Task TypeAsync(NpgsqlDataSource source, Context context, int version, string json) + private async Task TypeAsync(NpgsqlDataSource source, Context context, int version, string json) { var id = Guid.CreateVersion7(); (await SendAsync(source, context, new RegisterTenantContentTypeCommand(id, "shape", version, @@ -431,35 +505,35 @@ private static async Task TypeAsync(NpgsqlDataSource source, Context context, in (await SendAsync(source, context, new PublishTenantContentTypeCommand(id))).IsSuccess.Should().BeTrue(); } - private static async Task CourseAsync(NpgsqlDataSource source, Context context, string slug, string access = "public") + private async Task CourseAsync(NpgsqlDataSource source, Context context, string slug, string access = "public") { var result = await SendAsync(source, context, new CreateCourseCommand(Guid.CreateVersion7(), slug, access)); result.IsSuccess.Should().BeTrue(); return result.Value!; } - private static async Task LessonAsync(NpgsqlDataSource source, Context context, Guid course, int version = 1) + private async Task LessonAsync(NpgsqlDataSource source, Context context, Guid course, int version = 1) { var result = await SendAsync(source, context, new CreateLessonCommand(Guid.CreateVersion7(), course, 0, "shape", version)); result.IsSuccess.Should().BeTrue(); return result.Value!; } - private static async Task> SendAsync(NpgsqlDataSource source, Context? context, IRequest> command) + private async Task> SendAsync(NpgsqlDataSource source, Context? context, IRequest> command) { - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); return await scope.ServiceProvider.GetRequiredService().Send(command); } - private static async Task CourseStateAsync(NpgsqlDataSource source, Context context, Guid id) => + private async Task CourseStateAsync(NpgsqlDataSource source, Context context, Guid id) => (await SendAsync(source, context, new GetCourseSeedStateQuery(id))).Value!.State!; - private static async Task LessonStateAsync(NpgsqlDataSource source, Context context, Guid id) => + private async Task LessonStateAsync(NpgsqlDataSource source, Context context, Guid id) => (await SendAsync(source, context, new GetLessonSeedStateQuery(id))).Value!.State!; - private static async Task CountAuditsAsync(NpgsqlDataSource source, Context context, string operation, bool onlySuccessful = true) + private async Task CountAuditsAsync(NpgsqlDataSource source, Context context, string operation, bool onlySuccessful = true) { - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); @@ -472,9 +546,9 @@ private static async Task CountAuditsAsync(NpgsqlDataSource source, Contex return count; } - private static async Task AssertAuditAsync(NpgsqlDataSource source, Context context, Guid course, Guid lesson) + private async Task AssertAuditAsync(NpgsqlDataSource source, Context context, Guid course, Guid lesson) { - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/GlobalIdentityBootstrapTests.Lifecycle.cs b/backend/tests/LearnStack.Tests.Integration/Database/GlobalIdentityBootstrapTests.Lifecycle.cs index a7c46c835..90aea5476 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/GlobalIdentityBootstrapTests.Lifecycle.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/GlobalIdentityBootstrapTests.Lifecycle.cs @@ -5,6 +5,7 @@ using LearnStack.Modules.Identity.Application.Contracts.Seeding; using LearnStack.Modules.Identity.Domain; using LearnStack.Modules.Identity.Infrastructure.Bindings; +using LearnStack.SharedKernel.Permissions; using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Identifiers; @@ -28,6 +29,7 @@ public sealed partial class GlobalIdentityBootstrapTests public async Task Precontext_binding_lookup_does_not_pin_common_coordinators_before_tenant_resolution() { await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(_database, Registration); var seed = new SeedRunner( context => SeedComposition.Build(dataSource, context, NullLoggerFactory.Instance), NullLogger.Instance); @@ -45,6 +47,12 @@ public async Task Precontext_binding_lookup_does_not_pin_common_coordinators_bef new FixtureAdmission(Subject, Actor), services => { services.Replace(ServiceDescriptor.Singleton(accessor)); + // This unrouted component act proves post-binding coordinator context + // acquisition; it does not assert a human role or HTTP capability. + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted( + "tests.component.binding_lifecycle.create_organization", AuthorizationSourceKind.Seed)); + evidence.AddTo(services); services.AddMediatR(configuration => configuration.RegisterServicesFromAssembly( typeof(LearnStack.Modules.Tenancy.Application.AssemblyMarker).Assembly)); }); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/GlobalIdentityBootstrapTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/GlobalIdentityBootstrapTests.cs index f575a0e29..2f6c9d16e 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/GlobalIdentityBootstrapTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/GlobalIdentityBootstrapTests.cs @@ -8,6 +8,7 @@ using LearnStack.Modules.Identity.Application.Contracts.Seeding; using LearnStack.Modules.Identity.Domain; using LearnStack.Modules.Identity.Infrastructure.Bindings; +using LearnStack.Modules.Identity.Infrastructure.Authorization; using LearnStack.Modules.Identity.Infrastructure.Persistence; using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Caching; @@ -190,6 +191,10 @@ private static ServiceProvider BuildProvider(string appConnection, IdentityAutho services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Global binding still resolves the actual common coordinator graph. + // Its real protected authorizer remains lazy; global binding grants no + // tenant operation or membership authority. + services.AddIdentityOperationPolicies(); services.AddLearnStackMediatRPipeline(); services.AddSingleton(StaticTenantContextAccessor.Unresolved); services.AddTransient(provider => diff --git a/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpProof.cs b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpProof.cs new file mode 100644 index 000000000..1b4528079 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpProof.cs @@ -0,0 +1,211 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Modules.Identity.Application.Abstractions; +using LearnStack.Modules.Identity.Domain; +using LearnStack.Modules.Identity.Application.Authorization; +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.Modules.Identity.Application.Contracts.Bindings; +using LearnStack.Modules.Identity.Infrastructure.Bindings; +using LearnStack.SharedKernel.Identity; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.Tools.Seeder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Npgsql; +using static LearnStack.Tests.Integration.Database.IdentityMembershipFixture; + +namespace LearnStack.Tests.Integration.Database; + +/// Real Program, signed discovery-backed JWTs and fixed-v1 app-role membership data. +internal sealed class IdentityAuthorizationHttpProof : IAsyncDisposable +{ + private IdentityAuthorizationHttpProof(TenantAuthenticationIssuer issuer, IdentityPublicReadFixture database) + { + Issuer = issuer; + Database = database; + } + + internal const string DirectoryPath = "/api/v1/identity/memberships"; + internal TenantAuthenticationIssuer Issuer { get; } + internal IdentityPublicReadFixture Database { get; } + internal AuthorizationHttpObservation Observation { get; } = new(); + internal static UserId EnglishAdmin => SeedData.IdentityUsers[0].UserId; + internal static UserId EnglishLearner => SeedData.IdentityUsers[1].UserId; + internal static UserId YogaAdmin => SeedData.IdentityUsers[2].UserId; + + internal static async Task CreateAsync(PublicReadFixture parent) + { + var issuer = await TenantAuthenticationIssuer.StartAsync(); + try + { + var database = await IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer); + try + { + await database.SeedHttpFoundationAsync(); + return new(issuer, database); + } + catch { await database.DisposeAsync(); throw; } + } + catch { await issuer.DisposeAsync(); throw; } + } + + internal HttpClient Client(Action? configure = null) => Database.ClientFor(SeedData.English.Host, + services => + { + services.AddSingleton(Observation); + services.Replace(ServiceDescriptor.Scoped()); + services.AddScoped(); + services.Replace(ServiceDescriptor.Scoped()); + services.AddScoped(); + services.Replace(ServiceDescriptor.Scoped()); + configure?.Invoke(services); + }); + + internal string Token(UserId actor, SeedTenant tenant, bool organization = false, string? subject = null, + OrganizationId? claimedOrganization = null) => AccountSiteHttpTests.Token(Issuer, tenant, organization, + claimedOrganization: claimedOrganization?.Value, actor: actor.Value, + subject: subject ?? IdentityPublicReadFixture.FoundationSubject(actor)); + + internal static Task SendAsync(HttpClient client, string token, string? host = null, + string path = DirectoryPath, string? tenantAssertion = null, string? organizationAssertion = null, + string method = "GET") => TenantAuthenticationHttpRequest.Send(client, host ?? SeedData.English.Host, + path, token, tenantAssertion: tenantAssertion, organizationAssertion: organizationAssertion, method: method); + + internal static async Task PageAsync(HttpResponseMessage response) + { + response.StatusCode.Should().Be(HttpStatusCode.OK); + TenantAuthenticationHttpTests.NoStore(response); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.Clone(); + } + + internal static async Task ProblemAsync(HttpResponseMessage response, HttpStatusCode status, string code) + { + await TenantAuthenticationHttpTests.Problem(response, status, code); + TenantAuthenticationHttpTests.NoStore(response); + } + + internal async Task> AuditAsync(HttpResponseMessage response, SeedTenant tenant, + OrganizationId? organization = null) => await AuditAsync(TenantAuthenticationReconciliationTests.Correlation(response), tenant, organization); + + internal async Task> AuditAsync(string correlation, SeedTenant tenant, + OrganizationId? organization = null) + { + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(Database.AppConnectionString); + await SchemaQueries.AssertApplicationRoleAsync(app); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, tenant.TenantId.Value, organization?.Value); + await using var command = new NpgsqlCommand(""" + SELECT tenant_id,organization_id,actor_user_id,operation,outcome,correlation_id,metadata::text + FROM public.audit_log WHERE correlation_id=@correlation + """, app, transaction); + command.Parameters.AddWithValue("correlation", correlation); + await using var reader = await command.ExecuteReaderAsync(); + var rows = new List(); + while (await reader.ReadAsync()) + rows.Add(new(reader.GetGuid(0), reader.IsDBNull(1) ? null : reader.GetGuid(1), + reader.IsDBNull(2) ? null : reader.GetGuid(2), reader.GetString(3), reader.GetString(4), + reader.GetString(5), reader.GetString(6))); + return rows; + } + + internal async Task SetMembershipStatusAsync(SeedTenant tenant, UserId actor, Guid membership, + string status, OrganizationId? organization = null) + { + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(Database.AppConnectionString); + await SchemaQueries.AssertApplicationRoleAsync(app); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, tenant.TenantId.Value, organization?.Value); + await LockAsync(app, transaction, tenant.TenantId.Value, actor.Value, true); + await using var update = new NpgsqlCommand(""" + UPDATE public.memberships SET status=@status, + deleted_at=CASE WHEN @status='Revoked' THEN clock_timestamp() ELSE NULL END, + deleted_by=CASE WHEN @status='Revoked' THEN @actor ELSE NULL END + WHERE id=@member + """, app, transaction); + update.Parameters.AddWithValue("status", status); + update.Parameters.AddWithValue("actor", actor.Value); + update.Parameters.AddWithValue("member", membership); + (await update.ExecuteNonQueryAsync()).Should().Be(1); + await transaction.CommitAsync(); + } + + internal async Task OwnerAsync(string sql) + { + await using var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(Database.MigrationConnectionString); + await ExecuteAsync(owner, null, sql); + } + + public async ValueTask DisposeAsync() + { + try { await Database.DisposeAsync(); } + finally { await Issuer.DisposeAsync(); } + } + + private sealed class ObservedBindingReader(GlobalIdentityBindingService inner, + AuthorizationHttpObservation observation) : IIdentityBindingReader + { + public async Task ResolveExistingAsync(GlobalIdentitySubject subject, + UserId? claimedActor = null, CancellationToken cancellationToken = default) + { + observation.BindingRead(); + return await inner.ResolveExistingAsync(subject, claimedActor, cancellationToken); + } + } + + private sealed class ObservedAuthorizationReader(CommittedIdentityAuthorizationReader inner, + AuthorizationHttpObservation observation) : IIdentityAuthorizationReader + { + public async Task ReadAsync(AuthorizationCandidate candidate, + CancellationToken cancellationToken = default) + { + observation.AuthorizationRead(); + return await inner.ReadAsync(candidate, cancellationToken); + } + } + + private sealed class ObservedDirectoryReader(MembershipDirectoryReader inner, IUnitOfWork unit, + AuthorizationHttpObservation observation) : IMembershipDirectoryReader + { + public async Task> ReadAsync(MembershipId? after, int take, + CancellationToken cancellationToken = default) + { + observation.DirectoryRead(take); + unit.Mode.Should().Be(TransactionMode.ReadWrite); + await using (var command = new NpgsqlCommand(""" + SELECT current_user,rolsuper,rolbypassrls,current_setting('transaction_read_only'), + current_setting('app.scope',true) + FROM pg_roles WHERE rolname=current_user + """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction)) + await using (var row = await command.ExecuteReaderAsync(cancellationToken)) + { + (await row.ReadAsync(cancellationToken)).Should().BeTrue(); + row.GetString(0).Should().Be("learnstack_app"); + row.GetBoolean(1).Should().BeFalse(); + row.GetBoolean(2).Should().BeFalse(); + row.GetString(3).Should().Be("off"); + row.GetString(4).Should().Be("tenant"); + } + return await inner.ReadAsync(after, take, cancellationToken); + } + } +} + +internal sealed class AuthorizationHttpObservation +{ + private int _bindings; + private int _authorization; + private int _directory; + internal int BindingReads => Volatile.Read(ref _bindings); + internal void BindingRead() => Interlocked.Increment(ref _bindings); + internal int AuthorizationReads => Volatile.Read(ref _authorization); + internal int DirectoryReads => Volatile.Read(ref _directory); + internal void AuthorizationRead() => Interlocked.Increment(ref _authorization); + internal int LastTake { get; private set; } + internal void DirectoryRead(int take) { LastTake = take; Interlocked.Increment(ref _directory); } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpTests.Evidence.cs b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpTests.Evidence.cs new file mode 100644 index 000000000..4403438f7 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpTests.Evidence.cs @@ -0,0 +1,200 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.Tools.Seeder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Npgsql; +using Xunit; +using static LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpProof; +using static LearnStack.Tests.Integration.Database.IdentityMembershipFixture; + +namespace LearnStack.Tests.Integration.Database; + +public sealed partial class IdentityAuthorizationHttpTests +{ + [Fact] + public async Task Confirmed_claim_header_rejections_write_exact_private_evidence_without_spending_the_host_budget() + { + await using var proof = await CreateAsync(parent); + var budget = new TenantAssertionAuditBudget(new FixedBudgetClock()); + for (var index = 0; index < TenantAssertionAuditBudget.PermitPerWindow - 1; index++) + budget.TryAcquire(SeedData.English.TenantId.Value).Should().BeTrue(); + using var client = proof.Client(services => services.Replace(ServiceDescriptor.Singleton(budget))); + var token = proof.Token(EnglishAdmin, SeedData.English, organization: true); + using (var control = await SendAsync(client, token, "localhost")) await PageAsync(control); + var reads = proof.Observation.DirectoryReads; + var asserted = Guid.CreateVersion7().ToString("D"); + foreach (var dimension in new[] { "tenant", "organization" }) + { + using var response = await SendAsync(client, token, "localhost", + tenantAssertion: dimension == "tenant" ? asserted : null, + organizationAssertion: dimension == "organization" ? asserted : null); + await ProblemAsync(response, HttpStatusCode.NotFound, "tenant_mismatch"); + var row = (await proof.AuditAsync(response, SeedData.English, + SeedData.English.DefaultOrganization.OrganizationId)).Should().ContainSingle().Subject; + row.Operation.Should().Be("tenancy.assertion.reject_claim_context"); + row.Outcome.Should().Be("denied"); + row.Actor.Should().Be(EnglishAdmin.Value); + row.Tenant.Should().Be(SeedData.English.TenantId.Value); + row.Metadata.Should().NotContain(asserted).And.Contain("ListMemberships"); + using var metadata = JsonDocument.Parse(row.Metadata); + metadata.RootElement.GetProperty("assertion_source").GetString().Should().Be("membership_confirmed_header"); + if (dimension == "tenant") + { + budget.TryAcquire(SeedData.English.TenantId.Value).Should().BeTrue( + "the claim-only event must leave the final host allowance untouched"); + budget.TryAcquire(SeedData.English.TenantId.Value).Should().BeFalse(); + } + } + proof.Observation.DirectoryReads.Should().Be(reads, + "headers refuse before directory dispatch, even with an exhausted host recorder budget"); + } + + [Fact] + public async Task Resolver_noncoverage_preserves_row_7_host_evidence_row_10_silence_and_row_14_diagnostic_only() + { + await using var proof = await CreateAsync(parent); + var actor = UserId.From(Guid.CreateVersion7()); + const string Subject = "resolution-no-membership-subject"; + await proof.Database.AdoptAsync(actor, Subject); + using var client = proof.Client(); + foreach (var row in new[] { 7, 10, 14 }) + { + var tenant = row == 10 ? SeedData.Yoga : SeedData.English; + using var response = await SendAsync(client, proof.Token(actor, tenant, row != 10, Subject), + row == 14 ? "localhost" : tenant.Host); + await ProblemAsync(response, HttpStatusCode.NotFound, "not_found"); + var evidence = await proof.AuditAsync(response, tenant, tenant.DefaultOrganization.OrganizationId); + if (row == 7) + evidence.Should().ContainSingle().Which.Operation.Should().Be(AuditingTenantAssertionRecorder.RejectOperation); + else evidence.Should().BeEmpty(); + } + proof.Observation.DirectoryReads.Should().Be(0); + proof.Observation.AuthorizationReads.Should().Be(3); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Sensitive_HTTP_result_is_withheld_on_MUST_insert_or_deferred_COMMIT_failure(bool deferred) + { + await using var proof = await CreateAsync(parent); + using var client = proof.Client(); + var token = proof.Token(EnglishAdmin, SeedData.English); + using (var control = await SendAsync(client, token)) await PageAsync(control); + await InstallAuditTriggerAsync(proof, "identity.membership.list", deferred, gate: false); + try + { + using var response = await SendAsync(client, token); + await ProblemAsync(response, HttpStatusCode.ServiceUnavailable, "audit_unavailable"); + (await response.Content.ReadAsStringAsync()).Should().NotContain("items").And.NotContain("Demo Admin"); + (await proof.AuditAsync(response, SeedData.English)).Should().BeEmpty(); + proof.Observation.DirectoryReads.Should().Be(2, "the real result was materialized before the durable barrier refused it"); + } + finally { await RemoveAuditTriggerAsync(proof); } + using var recovered = await SendAsync(client, token); + await PageAsync(recovered); + (await proof.AuditAsync(recovered, SeedData.English)).Should().ContainSingle(); + } + + [Fact] + public async Task HTTP_success_waits_for_physical_sensitive_audit_COMMIT() + { + await using var proof = await CreateAsync(parent); + using var client = proof.Client(); + await InstallAuditTriggerAsync(proof, "identity.membership.list", deferred: true, gate: true); + await using var blocker = (NpgsqlConnection)await PostgresFixture.OpenAsync(proof.Database.AppConnectionString); + await SchemaQueries.AssertApplicationRoleAsync(blocker); + await using var transaction = await blocker.BeginTransactionAsync(); + await ExecuteAsync(blocker, transaction, "SELECT pg_advisory_xact_lock(@key)", ("key", HttpCommitGate)); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var request = SendAsync(client, proof.Token(EnglishAdmin, SeedData.English)); + try + { + await using var monitor = (NpgsqlConnection)await PostgresFixture.OpenAsync(proof.Database.AppConnectionString); + while (!await ScalarAsync(monitor, null, """ + SELECT EXISTS(SELECT 1 FROM pg_stat_activity + WHERE @blocker=ANY(pg_blocking_pids(pid)) AND query='COMMIT' AND state='active') + """, ("blocker", blocker.ProcessID))) + { + request.IsCompleted.Should().BeFalse("no HTTP result is released before the physical COMMIT barrier"); + await Task.Delay(20, deadline.Token); + } + request.IsCompleted.Should().BeFalse(); + await using var observer = (NpgsqlConnection)await PostgresFixture.OpenAsync(proof.Database.AppConnectionString); + await using var observation = await observer.BeginTransactionAsync(); + await ScopeAsync(observer, observation, SeedData.English.TenantId.Value); + (await ScalarAsync(observer, observation, + "SELECT count(*) FROM audit_log WHERE operation='identity.membership.list'")) + .Should().Be(0, "the pending MUST row is not yet committed"); + } + finally { await transaction.RollbackAsync(CancellationToken.None); } + using var response = await request.WaitAsync(TimeSpan.FromSeconds(20)); + await PageAsync(response); + (await proof.AuditAsync(response, SeedData.English)).Should().ContainSingle(); + await RemoveAuditTriggerAsync(proof); + } + + [Fact] + public async Task An_audit_outage_preserves_actual_HTTP_capability_and_coverage_refusals() + { + await using var proof = await CreateAsync(parent); + var stranger = UserId.From(Guid.CreateVersion7()); + const string Subject = "denial-audit-outage-subject"; + await proof.Database.AdoptAsync(stranger, Subject); + using var client = proof.Client(); + using (var control = await SendAsync(client, proof.Token(EnglishLearner, SeedData.English))) + { + await ProblemAsync(control, HttpStatusCode.Forbidden, "forbidden"); + (await proof.AuditAsync(control, SeedData.English)).Should().ContainSingle(); + } + await InstallAuditTriggerAsync(proof, "identity.authorization.deny", deferred: false, gate: false); + try + { + using var forbidden = await SendAsync(client, proof.Token(EnglishLearner, SeedData.English)); + await ProblemAsync(forbidden, HttpStatusCode.Forbidden, "forbidden"); + using var uncovered = await SendAsync(client, proof.Token(stranger, SeedData.English, subject: Subject)); + await ProblemAsync(uncovered, HttpStatusCode.NotFound, "not_found"); + (await proof.AuditAsync(forbidden, SeedData.English)).Should().BeEmpty(); + (await proof.AuditAsync(uncovered, SeedData.English)).Should().BeEmpty(); + proof.Observation.DirectoryReads.Should().Be(0); + } + finally { await RemoveAuditTriggerAsync(proof); } + } + + private const long HttpCommitGate = 7_601_077_005; + private static async Task InstallAuditTriggerAsync(IdentityAuthorizationHttpProof proof, string operation, bool deferred, bool gate) + { + // Fixed test-owned operation strings; no request data enters fixture DDL. + var action = gate ? $"PERFORM pg_catalog.pg_advisory_xact_lock({HttpCommitGate});" + : "RAISE EXCEPTION 'HTTP audit fixture refusal' USING ERRCODE='P0399';"; + await proof.OwnerAsync($$""" + CREATE FUNCTION public.fn_http_authorization_audit_proof() RETURNS trigger LANGUAGE plpgsql AS $proof$ + BEGIN + IF NEW.operation='{{operation}}' THEN {{action}} END IF; + RETURN NEW; + END $proof$; + """); + await proof.OwnerAsync(deferred ? """ + CREATE CONSTRAINT TRIGGER tg_http_authorization_audit_proof AFTER INSERT ON public.audit_log + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.fn_http_authorization_audit_proof() + """ : """ + CREATE TRIGGER tg_http_authorization_audit_proof BEFORE INSERT ON public.audit_log + FOR EACH ROW EXECUTE FUNCTION public.fn_http_authorization_audit_proof() + """); + } + + private static Task RemoveAuditTriggerAsync(IdentityAuthorizationHttpProof proof) => proof.OwnerAsync(""" + DROP TRIGGER IF EXISTS tg_http_authorization_audit_proof ON public.audit_log; + DROP FUNCTION IF EXISTS public.fn_http_authorization_audit_proof(); + """); + + private sealed class FixedBudgetClock : TimeProvider + { + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => 0; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpTests.Pagination.cs b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpTests.Pagination.cs new file mode 100644 index 000000000..8dd51a1ae --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpTests.Pagination.cs @@ -0,0 +1,94 @@ +using System.Net; +using FluentAssertions; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.Tools.Seeder; +using Xunit; +using static LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpProof; + +namespace LearnStack.Tests.Integration.Database; + +public sealed partial class IdentityAuthorizationHttpTests +{ + [Fact] + public async Task Directory_exposes_only_contextual_fields_with_live_tenant_wide_pagination_and_scope_bound_cursors() + { + await using var proof = await CreateAsync(parent); + var firstBranch = await proof.Database.AddMembershipAsync(SeedData.English, EnglishLearner, + SeedData.English.DefaultOrganization.OrganizationId, displayName: "suspended-contextual-profile"); + await proof.SetMembershipStatusAsync(SeedData.English, EnglishLearner, firstBranch, "Suspended", + SeedData.English.DefaultOrganization.OrganizationId); + var secondBranch = await proof.Database.AddMembershipAsync(SeedData.English, EnglishLearner, + SeedData.English.SecondOrganization.OrganizationId, displayName: "second-branch-contextual-profile"); + var otherAdmin = UserId.From(Guid.CreateVersion7()); + const string OtherSubject = "cursor-other-admin"; + await proof.Database.AdoptAsync(otherAdmin, OtherSubject); + var otherMembership = await proof.Database.AddMembershipAsync(SeedData.English, otherAdmin, + role: BuiltInRoleKey.TenantAdmin); + var revoked = await proof.Database.AddMembershipAsync(SeedData.English, otherAdmin, + SeedData.English.DefaultOrganization.OrganizationId, displayName: "revoked-profile-canary"); + await proof.SetMembershipStatusAsync(SeedData.English, otherAdmin, revoked, "Revoked", + SeedData.English.DefaultOrganization.OrganizationId); + using var client = proof.Client(); + var token = proof.Token(EnglishAdmin, SeedData.English, organization: true); + using var full = await SendAsync(client, token); + var page = await PageAsync(full); + proof.Observation.LastTake.Should().Be(21, "the default page has a 20-row limit plus one lookahead"); + var rows = page.GetProperty("items").EnumerateArray().ToArray(); + rows.Select(row => row.GetProperty("membershipId").GetGuid()).Should().BeEquivalentTo( + [SeedData.IdentityMemberships[0].MembershipId, SeedData.IdentityMemberships[1].MembershipId, + firstBranch, secondBranch, otherMembership]); + foreach (var row in rows) + row.EnumerateObject().Select(property => property.Name).Should().BeEquivalentTo( + ["membershipId", "userId", "organizationId", "status", "displayName", "preferredLocale"]); + page.GetRawText().Should().NotContain("revoked-profile-canary") + .And.NotContain(IdentityPublicReadFixture.FoundationSubject(EnglishAdmin)); + + using var capped = await SendAsync(client, token, path: DirectoryPath + "?limit=1000"); + await PageAsync(capped); + proof.Observation.LastTake.Should().Be(101, "a clamped 100-row page requests one lookahead"); + + using var first = await SendAsync(client, token, path: DirectoryPath + "?limit=1"); + var firstPage = await PageAsync(first); + var cursor = firstPage.GetProperty("pageInfo").GetProperty("nextCursor").GetString()!; + var ids = new List { firstPage.GetProperty("items")[0].GetProperty("membershipId").GetGuid() }; + var next = cursor; + while (next is not null) + { + using var response = await SendAsync(client, token, + path: DirectoryPath + "?limit=1&cursor=" + Uri.EscapeDataString(next)); + var current = await PageAsync(response); + ids.AddRange(current.GetProperty("items").EnumerateArray().Select(row => row.GetProperty("membershipId").GetGuid())); + next = current.GetProperty("pageInfo").GetProperty("nextCursor").GetString(); + ids.Count.Should().BeLessThanOrEqualTo(rows.Length, "seek pagination must make progress"); + } + ids.Should().Equal(rows.Select(row => row.GetProperty("membershipId").GetGuid())); + var replayPath = DirectoryPath + "?cursor=" + Uri.EscapeDataString(cursor); + foreach (var differentScope in new[] + { + proof.Token(EnglishAdmin, SeedData.English), + proof.Token(otherAdmin, SeedData.English, organization: true, subject: OtherSubject), + }) + { + using var refused = await SendAsync(client, differentScope, path: replayPath); + await ProblemAsync(refused, HttpStatusCode.BadRequest, "validation_failed"); + } + foreach (var suffix in new[] { "?limit=0", "?cursor=not-a-cursor", "?sort=displayName", "?limit=1&limit=2", + "?cursor=" + new string('a', 1025) }) + { + using var refused = await SendAsync(client, token, path: DirectoryPath + suffix); + await ProblemAsync(refused, HttpStatusCode.BadRequest, "validation_failed"); + } + + var ownToken = proof.Token(otherAdmin, SeedData.English, organization: true, subject: OtherSubject); + using var ownFirst = await SendAsync(client, ownToken, path: DirectoryPath + "?limit=1"); + var ownPage = await PageAsync(ownFirst); + var ownCursor = ownPage.GetProperty("pageInfo").GetProperty("nextCursor").GetString()!; + await proof.SetMembershipStatusAsync(SeedData.English, otherAdmin, otherMembership, "Revoked"); + var reads = proof.Observation.DirectoryReads; + using var afterRevocation = await SendAsync(client, ownToken, + path: DirectoryPath + "?cursor=" + Uri.EscapeDataString(ownCursor)); + await ProblemAsync(afterRevocation, HttpStatusCode.NotFound, "not_found"); + proof.Observation.DirectoryReads.Should().Be(reads, "a cursor supplies no reusable authorization"); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpTests.cs new file mode 100644 index 000000000..2b8885a16 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationHttpTests.cs @@ -0,0 +1,151 @@ +using System.Net; +using FluentAssertions; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.Tools.Seeder; +using Xunit; +using static LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpProof; + +namespace LearnStack.Tests.Integration.Database; + +[Collection(PublicReadTestGroup.Name)] +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed partial class IdentityAuthorizationHttpTests(PublicReadFixture parent) +{ + [Fact] + public async Task Actual_directory_resolves_rows_6_7_9_10_14_with_real_memberships_and_committed_sensitive_evidence() + { + await using var proof = await CreateAsync(parent); + var claimActor = UserId.From(Guid.CreateVersion7()); + const string ClaimSubject = "directory-only-confirmed-subject"; + await proof.Database.AdoptAsync(claimActor, ClaimSubject); + await proof.Database.AddMembershipAsync(SeedData.English, claimActor, role: BuiltInRoleKey.TenantAdmin); + using var client = proof.Client(); + foreach (var row in new[] { 6, 7, 9, 10, 14 }) + { + var tenant = row is 9 or 10 ? SeedData.Yoga : SeedData.English; + var actor = row == 14 ? claimActor : row is 9 or 10 ? YogaAdmin : EnglishAdmin; + var organization = row is 7 or 9; + var token = proof.Token(actor, tenant, organization, row == 14 ? ClaimSubject : null); + using var response = await SendAsync(client, token, row == 14 ? "localhost" : tenant.Host); + var page = await PageAsync(response); + page.GetProperty("items").GetArrayLength().Should().BeGreaterThan(0); + var audit = (await proof.AuditAsync(response, tenant, + row is 7 or 9 or 10 ? tenant.DefaultOrganization.OrganizationId : null)) + .Should().ContainSingle().Subject; + audit.Operation.Should().Be("identity.membership.list"); + audit.Outcome.Should().Be("success"); + audit.Actor.Should().Be(actor.Value); + audit.Metadata.Should().Contain("ListMemberships"); + } + proof.Observation.DirectoryReads.Should().Be(5); + proof.Observation.AuthorizationReads.Should().BeGreaterThanOrEqualTo(10, + "HTTP and application admission independently read committed authority"); + proof.Issuer.MetadataRequests.Should().Be(1); + proof.Issuer.JwksRequests.Should().Be(1); + + var reads = proof.Database.Observation.Reads; + using var account = await SendAsync(client, proof.Token(claimActor, SeedData.English, subject: ClaimSubject), + "localhost", TenantAuthenticationHttpRequest.AccountPath); + await ProblemAsync(account, HttpStatusCode.NotFound, "not_found"); + proof.Database.Observation.Reads.Should().Be(reads, + "confirmed claim authority does not manufacture AccountSite's independent institution host"); + } + + [Fact] + public async Task Coverage_capability_and_store_failure_are_distinct_and_never_dispatch_the_directory() + { + await using var proof = await CreateAsync(parent); + var stranger = UserId.From(Guid.CreateVersion7()); + const string StrangerSubject = "directory-bound-no-membership"; + await proof.Database.AdoptAsync(stranger, StrangerSubject); + using var client = proof.Client(); + using (var positive = await SendAsync(client, proof.Token(EnglishAdmin, SeedData.English))) + await PageAsync(positive); + var reads = proof.Observation.DirectoryReads; + using var forbidden = await SendAsync(client, proof.Token(EnglishLearner, SeedData.English)); + await ProblemAsync(forbidden, HttpStatusCode.Forbidden, "forbidden"); + (await proof.AuditAsync(forbidden, SeedData.English)).Should().ContainSingle() + .Which.Operation.Should().Be("identity.authorization.deny"); + using var uncovered = await SendAsync(client, proof.Token(stranger, SeedData.English, subject: StrangerSubject)); + await ProblemAsync(uncovered, HttpStatusCode.NotFound, "not_found"); + (await proof.AuditAsync(uncovered, SeedData.English)).Should().ContainSingle() + .Which.Operation.Should().Be("identity.authorization.deny"); + await proof.OwnerAsync("REVOKE SELECT ON public.memberships FROM learnstack_app"); + try + { + using var unavailable = await SendAsync(client, proof.Token(EnglishAdmin, SeedData.English)); + await ProblemAsync(unavailable, HttpStatusCode.ServiceUnavailable, "dependency_unavailable"); + (await unavailable.Content.ReadAsStringAsync()).Should().NotContain("items"); + } + finally { await proof.OwnerAsync("GRANT SELECT ON public.memberships TO learnstack_app"); } + proof.Observation.DirectoryReads.Should().Be(reads); + using var recovered = await SendAsync(client, proof.Token(EnglishAdmin, SeedData.English)); + await PageAsync(recovered); + } + + [Fact] + public async Task Matching_host_revocation_is_observed_by_directory_and_AccountSite_after_committed_membership_change() + { + await using var proof = await CreateAsync(parent); + var actor = UserId.From(Guid.CreateVersion7()); + const string Subject = "matching-host-revocation-subject"; + await proof.Database.AdoptAsync(actor, Subject); + var membership = await proof.Database.AddMembershipAsync(SeedData.Yoga, actor, role: BuiltInRoleKey.TenantAdmin); + using var client = proof.Client(); + var token = proof.Token(actor, SeedData.Yoga, organization: true, subject: Subject); + using (var directory = await SendAsync(client, token, SeedData.Yoga.Host)) await PageAsync(directory); + using (var account = await SendAsync(client, token, SeedData.Yoga.Host, TenantAuthenticationHttpRequest.AccountPath)) + account.StatusCode.Should().Be(HttpStatusCode.OK); + var directoryReads = proof.Observation.DirectoryReads; + var accountReads = proof.Database.Observation.Reads; + await proof.SetMembershipStatusAsync(SeedData.Yoga, actor, membership, "Revoked"); + foreach (var path in new[] { DirectoryPath, TenantAuthenticationHttpRequest.AccountPath }) + { + using var refused = await SendAsync(client, token, SeedData.Yoga.Host, path); + await ProblemAsync(refused, HttpStatusCode.NotFound, "not_found"); + (await proof.AuditAsync(refused, SeedData.Yoga, SeedData.Yoga.DefaultOrganization.OrganizationId)) + .Should().ContainSingle().Which.Operation.Should().Be("identity.authorization.deny"); + } + proof.Observation.DirectoryReads.Should().Be(directoryReads); + proof.Database.Observation.Reads.Should().Be(accountReads); + } + + [Fact] + public async Task Anonymous_public_read_reaches_application_SQL_without_identity_authorization_queries() + { + await using var proof = await CreateAsync(parent); + using var client = proof.Client(); + using var response = await TenantAuthenticationHttpRequest.Send(client, SeedData.English.Host, + TenantAuthenticationHttpRequest.PublicPath); + response.StatusCode.Should().Be(HttpStatusCode.OK); + proof.Database.Observation.Reads.Should().Be(1); + proof.Observation.AuthorizationReads.Should().Be(0); + proof.Observation.BindingReads.Should().Be(0); + proof.Issuer.MetadataRequests.Should().Be(0); + proof.Observation.DirectoryReads.Should().Be(0); + using var control = await SendAsync(client, proof.Token(EnglishAdmin, SeedData.English)); + await PageAsync(control); + proof.Observation.BindingReads.Should().Be(1); + proof.Observation.AuthorizationReads.Should().BeGreaterThanOrEqualTo(2, + "the observer must see the real authority reads on its positive control"); + } + + [Fact] + public async Task Directory_wrong_methods_retain_routing_semantics_without_handler_dispatch() + { + await using var proof = await CreateAsync(parent); + using var client = proof.Client(); + var token = proof.Token(EnglishAdmin, SeedData.English); + foreach (var method in new[] { "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS" }) + { + using var response = await SendAsync(client, token, method: method); + response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + response.Content.Headers.Allow.Should().BeEquivalentTo(["GET"]); + TenantAuthenticationHttpTests.NoStore(response); + } + proof.Observation.DirectoryReads.Should().Be(0); + using var unmatched = await SendAsync(client, token, path: "/api/v1/identity/not-a-route"); + unmatched.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationReaderFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationReaderFixture.cs new file mode 100644 index 000000000..2a00b5e4b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationReaderFixture.cs @@ -0,0 +1,104 @@ +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Infrastructure.Authorization; +using LearnStack.Modules.Identity.Application.Authorization; +using LearnStack.Modules.Tenancy.Application.Contracts.IdentityLifecycle; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; +using static LearnStack.Tests.Integration.Database.IdentityMembershipFixture; + +namespace LearnStack.Tests.Integration.Database; + +/// Actual contextual authority and Tenancy lifecycle rows, isolated from shared schema/persona fixtures. +public sealed class IdentityAuthorizationReaderFixture : IAsyncLifetime +{ + public IdentityMembershipFixture Membership { get; } = new(); + public string AppConnectionString => Membership.Postgres.AppConnectionString; + + public async Task InitializeAsync() + { + await Membership.InitializeAsync(); + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(AppConnectionString); + await SchemaQueries.AssertApplicationRoleAsync(app); + foreach (var tenant in new[] { SchemaFixture.TenantA, SchemaFixture.TenantB }) + { + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, tenant); + await ExecuteAsync(app, transaction, """ + INSERT INTO public.tenants(id,slug,display_name,status,created_at,created_by,row_version) + VALUES(@tenant,@slug,'Authorization proof','Trial',clock_timestamp(),@actor,0) + """, ("tenant", tenant), ("slug", "authorization-" + tenant.ToString("N")), ("actor", UserId.SystemActor.Value)); + var organizations = tenant == SchemaFixture.TenantA + ? new[] { SchemaFixture.OrgA1, SchemaFixture.OrgA2 } : [SchemaFixture.OrgB1]; + foreach (var organization in organizations) + await ExecuteAsync(app, transaction, """ + INSERT INTO public.organizations(id,tenant_id,slug,display_name,status,created_at,created_by,row_version) + VALUES(@organization,@tenant,@slug,'Authorization branch','Active',clock_timestamp(),@actor,0) + """, ("organization", organization), ("tenant", tenant), ("slug", "branch-" + organization.ToString("N")), + ("actor", UserId.SystemActor.Value)); + await transaction.CommitAsync(); + } + } + + public Task DisposeAsync() => Membership.DisposeAsync(); + + public async Task<(Guid Actor, Guid Membership)> AddMemberAsync(Guid? organization = null, + BuiltInRoleKey roleKey = BuiltInRoleKey.Learner) + { + var actor = Guid.CreateVersion7(); + var member = Guid.CreateVersion7(); + await InsertUserAsync(AppConnectionString, actor); + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(AppConnectionString); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, SchemaFixture.TenantA, organization); + await LockAsync(app, transaction, SchemaFixture.TenantA, actor, false); + await InsertMemberAsync(app, transaction, SchemaFixture.TenantA, organization, member, actor, + RoleId(SchemaFixture.TenantA, roleKey, organization is null ? PermissionScope.Tenant : PermissionScope.Organization)); + await transaction.CommitAsync(); + return (actor, member); + } +} + +internal sealed class AuthorizationReaderHarness : IAsyncDisposable +{ + private readonly NpgsqlDataSource _business; + private readonly NpgsqlUnitOfWork _ambient; + + private AuthorizationReaderHarness(IdentityAuthorizationReadSource source, NpgsqlDataSource business, + NpgsqlUnitOfWork ambient, IIdentityScopeLifecycleReader lifecycle) + { + Source = source; + _business = business; + _ambient = ambient; + Lifecycle = lifecycle; + Reader = new(new PreContextIdentityAuthorizationSessionFactory(source), lifecycle); + } + + public IdentityAuthorizationReadSource Source { get; } + public IIdentityScopeLifecycleReader Lifecycle { get; } + public CommittedIdentityAuthorizationReader Reader { get; } + + public static async Task CreateAsync(IdentityAuthorizationReaderFixture fixture, + Func? decorate = null) + { + var source = new IdentityAuthorizationReadSource(fixture.AppConnectionString); + await source.QualifyAsync(Authority.AuthorityId, Authority.Issuer); + var business = ApplicationDataSource.Build(fixture.AppConnectionString); + var ambient = new NpgsqlUnitOfWork(business, NullLogger.Instance); + var lifecycle = new IdentityScopeLifecycleReader(ambient); + return new(source, business, ambient, decorate?.Invoke(lifecycle) ?? lifecycle); + } + + public async ValueTask DisposeAsync() + { + try { await Source.DisposeAsync(); } + finally + { + await _ambient.DisposeAsync(); + await _business.DisposeAsync(); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationReaderTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationReaderTests.cs new file mode 100644 index 000000000..2d102b43e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/IdentityAuthorizationReaderTests.cs @@ -0,0 +1,666 @@ +using System.Data; +using System.Diagnostics; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Application.Contracts.Authorization; +using LearnStack.Modules.Identity.Domain; +using LearnStack.Modules.Tenancy.Application.Contracts.IdentityLifecycle; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; +using Xunit.Abstractions; +using static LearnStack.Tests.Integration.Database.IdentityMembershipFixture; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class IdentityAuthorizationReaderTests(IdentityAuthorizationReaderFixture fixture, ITestOutputHelper output) + : IClassFixture +{ + [Fact] + public async Task Exact_tenant_and_branch_compose_only_their_active_registered_facts_without_personal_projection() + { + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + var result = await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + result.Outcome.Should().Be(IdentityAuthorizationOutcome.Covered); + result.Memberships.Select(member => member.MembershipId).Should().BeEquivalentTo([LearnerMembershipA, BranchMembershipA1]); + result.Memberships.SelectMany(member => member.Roles).SelectMany(role => role.Grants).Should().BeEquivalentTo([ + new PermissionDefinition(PermissionScope.Tenant, IdentityFoundationManifest.ProfileWrite), + new PermissionDefinition(PermissionScope.Organization, IdentityFoundationManifest.ProfileWrite)]); + var encoded = JsonSerializer.Serialize(result); + encoded.Should().NotContain("profile-canary").And.NotContain("fixture-subject-") + .And.NotContain("DisplayName").And.NotContain("PreferredLocale"); + + var orgless = await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA)); + orgless.Memberships.Should().ContainSingle().Which.MembershipId.Should().Be(LearnerMembershipA); + var otherBranch = await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA, SchemaFixture.OrgA2)); + otherBranch.Memberships.Select(member => member.MembershipId).Should().BeEquivalentTo([LearnerMembershipA, BranchMembershipA2]); + + // Qualification reports the actual warm candidate-owner cost separately + // from fixture startup, token/provider work and HTTP/business execution. + // This small local sample is neither a CI wall bound nor production sizing. + var samples = new double[32]; + for (var index = 0; index < samples.Length; index++) + { + var started = Stopwatch.GetTimestamp(); + var measured = await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA, SchemaFixture.OrgA1)); + samples[index] = Stopwatch.GetElapsedTime(started).TotalMilliseconds; + measured.Outcome.Should().Be(IdentityAuthorizationOutcome.Covered); + measured.Memberships.Select(member => member.MembershipId).Should().BeEquivalentTo([LearnerMembershipA, BranchMembershipA1]); + } + Array.Sort(samples); + output.WriteLine(JsonSerializer.Serialize(new + { + population = "warm-real-app-role-candidate-owner", + samples = samples.Length, + p50Milliseconds = samples[(int)Math.Ceiling(samples.Length * .50) - 1], + p95Milliseconds = samples[(int)Math.Ceiling(samples.Length * .95) - 1], + maximumMilliseconds = samples[^1], + })); + } + + [Fact] + public async Task Foreign_actor_tenant_or_organization_never_supplies_coverage() + { + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + foreach (var candidate in new[] + { + minter.For(AdminB, SchemaFixture.TenantA), + minter.For(PersonA, SchemaFixture.TenantB), + minter.For(PersonA, SchemaFixture.TenantA, SchemaFixture.OrgB1), + minter.For(PersonA, Guid.CreateVersion7()), + }) + { + var result = await harness.Reader.ReadAsync(candidate); + result.Outcome.Should().Be(IdentityAuthorizationOutcome.NotCovered); + result.Memberships.Should().BeEmpty(); + } + } + + [Fact] + public async Task Membership_without_any_role_or_grant_still_confirms_coverage() + { + var (actor, member) = await fixture.AddMemberAsync(roleKey: BuiltInRoleKey.Instructor); + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + var emptyRole = await harness.Reader.ReadAsync(minter.For(actor, SchemaFixture.TenantA)); + emptyRole.Covers.Should().BeTrue(); + emptyRole.Memberships.Single().Roles.Single().Grants.Should().BeEmpty(); + await MutateAsync(null, actor, "DELETE FROM public.membership_role_assignments WHERE membership_id=@member", ("member", member)); + var noRole = await harness.Reader.ReadAsync(minter.For(actor, SchemaFixture.TenantA)); + noRole.Covers.Should().BeTrue(); + noRole.Memberships.Single().Roles.Should().BeEmpty(); + } + + [Fact] + public async Task Unknown_and_virtual_public_stored_grants_never_become_assignable_authority() + { + var role = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.Learner, PermissionScope.Tenant); + await using var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.Membership.Postgres.MigrationConnectionString); + // Deliberate corrupt/legacy rows: the act under test remains the real qualified application-role reader. + await ExecuteAsync(owner, null, "ALTER TABLE public.role_permission_grants DROP CONSTRAINT ck_role_permission_grants_registry"); + try + { + await MutateAsync(null, null, """ + INSERT INTO public.role_permission_grants(tenant_id,role_id,permission_scope,permission_key) + VALUES(@tenant,@role,'Tenant','identity.unknown.read'),(@tenant,@role,'Tenant','education.public_course.read') + """, ("tenant", SchemaFixture.TenantA), ("role", role)); + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + var result = await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA)); + result.Covers.Should().BeTrue(); + result.Memberships.Single().Roles.Single().Grants.Should().BeEquivalentTo([ + new PermissionDefinition(PermissionScope.Tenant, IdentityFoundationManifest.ProfileWrite)]); + } + finally + { + await MutateAsync(null, null, """ + DELETE FROM public.role_permission_grants WHERE role_id=@role + AND permission_key IN ('identity.unknown.read','education.public_course.read') + """, ("role", role)); + await ExecuteAsync(owner, null, $""" + ALTER TABLE public.role_permission_grants ADD CONSTRAINT ck_role_permission_grants_registry + CHECK ((permission_scope,permission_key) IN ({IdentityFoundationManifest.Version1.RegistrySqlValues})) + """); + } + } + + [Fact] + public async Task Excess_or_ambiguous_committed_memberships_fail_closed_instead_of_truncating_the_graph() + { + var (actor, _) = await fixture.AddMemberAsync(); + var firstExtra = Guid.CreateVersion7(); + var secondExtra = Guid.CreateVersion7(); + await using var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.Membership.Postgres.MigrationConnectionString); + await ExecuteAsync(owner, null, "DROP INDEX public.ux_memberships_current_scope"); + try + { + await using (var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString)) + await using (var transaction = await app.BeginTransactionAsync()) + { + await ScopeAsync(app, transaction, SchemaFixture.TenantA); + await LockAsync(app, transaction, SchemaFixture.TenantA, actor, false); + foreach (var member in new[] { firstExtra, secondExtra }) + await InsertMemberAsync(app, transaction, SchemaFixture.TenantA, null, member, actor, + RoleId(SchemaFixture.TenantA, BuiltInRoleKey.Learner, PermissionScope.Tenant)); + await transaction.CommitAsync(); + } + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + var result = await harness.Reader.ReadAsync(minter.For(actor, SchemaFixture.TenantA)); + result.Outcome.Should().Be(IdentityAuthorizationOutcome.Unavailable); + result.Memberships.Should().BeEmpty(); + } + finally + { + await MutateAsync(null, null, """ + UPDATE public.memberships SET status='Revoked',deleted_at=clock_timestamp(),deleted_by=@actor + WHERE id IN (@first,@second) + """, ("actor", UserId.SystemActor.Value), ("first", firstExtra), ("second", secondExtra)); + await ExecuteAsync(owner, null, """ + CREATE UNIQUE INDEX ux_memberships_current_scope ON public.memberships(tenant_id,user_id,organization_id) + NULLS NOT DISTINCT WHERE deleted_at IS NULL + """); + } + } + + [Fact] + public async Task Entire_finite_eight_role_population_is_returned_without_implicit_grants() + { + var added = IdentityFoundationManifest.Version1.Templates + .Where(template => template.BuiltInKey != BuiltInRoleKey.Learner).ToArray(); + try + { + foreach (var template in added) await ChangeAssignmentAsync(template, add: true); + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + var result = await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA, SchemaFixture.OrgA1)); + result.Covers.Should().BeTrue(); + var roles = result.Memberships.SelectMany(member => member.Roles).ToArray(); + roles.Should().HaveCount(8); + foreach (var role in roles) + role.Grants.Should().BeEquivalentTo(IdentityFoundationManifest.Version1.FindTemplate(role.BuiltInKey, role.Scope)!.Grants); + } + finally + { + foreach (var template in added.Reverse()) await ChangeAssignmentAsync(template, add: false); + } + } + + [Fact] + public Task Duplicate_eligible_role_template_below_eight_refuses_without_publishing_facts() => + AssertDuplicateRoleRefusesAsync(fullPopulation: false); + + [Fact] + public Task Nine_eligible_roles_with_a_duplicate_template_refuse_without_truncating_facts() => + AssertDuplicateRoleRefusesAsync(fullPopulation: true); + + [Theory] + [InlineData("Suspended")] + [InlineData("Revoked")] + public async Task Fresh_committed_suspended_or_revoked_membership_has_no_coverage(string status) + { + var (actor, member) = await fixture.AddMemberAsync(); + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + (await harness.Reader.ReadAsync(minter.For(actor, SchemaFixture.TenantA))).Covers.Should().BeTrue(); + await MutateAsync(null, actor, """ + UPDATE public.memberships SET status=@status, + deleted_at=CASE WHEN @status='Revoked' THEN clock_timestamp() ELSE NULL END, + deleted_by=CASE WHEN @status='Revoked' THEN @actor ELSE NULL END + WHERE id=@member + """, ("status", status), ("actor", UserId.SystemActor.Value), ("member", member)); + var result = await harness.Reader.ReadAsync(minter.For(actor, SchemaFixture.TenantA)); + result.Outcome.Should().Be(IdentityAuthorizationOutcome.NotCovered); + result.Memberships.Should().BeEmpty(); + } + + [Fact] + public async Task Retired_role_and_removed_assignment_grant_nothing_while_membership_coverage_remains() + { + var (actor, member) = await fixture.AddMemberAsync(); + var role = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.Learner, PermissionScope.Tenant); + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + try + { + await MutateAsync(null, null, + "UPDATE public.roles SET deleted_at=clock_timestamp(),deleted_by=@actor WHERE id=@role", + ("actor", UserId.SystemActor.Value), ("role", role)); + var retired = await harness.Reader.ReadAsync(minter.For(actor, SchemaFixture.TenantA)); + retired.Covers.Should().BeTrue(); + retired.Memberships.Single().Roles.Should().BeEmpty(); + } + finally + { + await MutateAsync(null, null, "UPDATE public.roles SET deleted_at=NULL,deleted_by=NULL WHERE id=@role", ("role", role)); + } + await MutateAsync(null, actor, "DELETE FROM public.membership_role_assignments WHERE membership_id=@member", ("member", member)); + (await harness.Reader.ReadAsync(minter.For(actor, SchemaFixture.TenantA))).Memberships.Single().Roles.Should().BeEmpty(); + } + + [Theory] + [InlineData("Trial", true)] + [InlineData("Active", true)] + [InlineData("Suspended", false)] + [InlineData("Archived", false)] + public async Task Actual_tenant_lifecycle_is_fresh_and_independent_of_membership(string status, bool covers) + { + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + try + { + await MutateAsync(null, null, "UPDATE public.tenants SET status=@status WHERE id=@tenant", + ("status", status), ("tenant", SchemaFixture.TenantA)); + (await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA))).Covers.Should().Be(covers); + } + finally + { + await MutateAsync(null, null, "UPDATE public.tenants SET status='Trial' WHERE id=@tenant", ("tenant", SchemaFixture.TenantA)); + } + } + + [Theory] + [InlineData("Active", true)] + [InlineData("Suspended", false)] + [InlineData("Archived", false)] + public async Task Requested_organization_must_currently_allow_work(string status, bool covers) + { + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + try + { + await MutateAsync(SchemaFixture.OrgA1, null, "UPDATE public.organizations SET status=@status WHERE id=@organization", + ("status", status), ("organization", SchemaFixture.OrgA1)); + (await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA, SchemaFixture.OrgA1))).Covers.Should().Be(covers); + } + finally + { + await MutateAsync(SchemaFixture.OrgA1, null, "UPDATE public.organizations SET status='Active' WHERE id=@organization", + ("organization", SchemaFixture.OrgA1)); + } + } + + [Fact] + public async Task Separate_lifecycle_statement_observes_a_commit_after_the_grant_statement() + { + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture, inner => new LifecycleProbe(inner, + async (_, _, _) => await MutateAsync(null, null, "UPDATE public.tenants SET status='Suspended' WHERE id=@tenant", + ("tenant", SchemaFixture.TenantA)))); + using var minter = new CandidateSource(); + try + { + (await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA))).Outcome.Should().Be(IdentityAuthorizationOutcome.NotCovered); + } + finally + { + await MutateAsync(null, null, "UPDATE public.tenants SET status='Trial' WHERE id=@tenant", ("tenant", SchemaFixture.TenantA)); + } + } + + [Fact] + public async Task Child_is_physically_read_only_and_its_exact_generation_is_the_lifecycle_generation() + { + TenantContextAnnouncement? observed = null; + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture, inner => new LifecycleProbe(inner, + async (child, candidate, cancellationToken) => + { + child.Mode.Should().Be(TransactionMode.ReadOnly); + observed = child.GetTenantContextAnnouncement(child.Transaction); + observed!.Generation.Should().BePositive(); + observed.TenantId.Should().Be(candidate.TenantId); + observed.OrganizationId.Should().Be(candidate.OrganizationId); + await using var command = new NpgsqlCommand("SELECT current_setting('transaction_read_only')", + (NpgsqlConnection)child.Connection, (NpgsqlTransaction)child.Transaction!); + (await command.ExecuteScalarAsync(cancellationToken)).Should().Be("on"); + })); + using var minter = new CandidateSource(); + (await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA, SchemaFixture.OrgA1))).Covers.Should().BeTrue(); + observed.Should().NotBeNull(); + } + + [Fact] + public async Task Stale_candidate_before_or_during_io_never_returns_facts() + { + using var minter = new CandidateSource(); + var candidate = minter.For(PersonA, SchemaFixture.TenantA); + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture, inner => new LifecycleProbe(inner, + (_, _, _) => { minter.Dispose(); return Task.CompletedTask; })); + (await harness.Reader.ReadAsync(candidate)).Outcome.Should().Be(IdentityAuthorizationOutcome.Unavailable); + (await harness.Reader.ReadAsync(candidate)).Memberships.Should().BeEmpty(); + } + + [Fact] + public async Task Candidate_lifetime_ending_during_actual_cleanup_withholds_already_prepared_facts() + { + using var minter = new CandidateSource(); + var candidate = minter.For(PersonA, SchemaFixture.TenantA); + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture, inner => new LifecycleProbe(inner, + (child, _, _) => + { + child.Connection.StateChange += (_, args) => + { + if (args.CurrentState == ConnectionState.Closed) minter.Dispose(); + }; + return Task.CompletedTask; + })); + var result = await harness.Reader.ReadAsync(candidate); + result.Outcome.Should().Be(IdentityAuthorizationOutcome.Unavailable); + result.Memberships.Should().BeEmpty(); + } + + [Fact] + public async Task Actual_committed_revocation_is_seen_while_parent_uncommitted_grant_and_locks_remain_open() + { + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + var tenantRole = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.Learner, PermissionScope.Tenant); + var branchRole = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.Learner, PermissionScope.Organization); + await using var parent = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var parentTransaction = await parent.BeginTransactionAsync(); + await ScopeAsync(parent, parentTransaction, SchemaFixture.TenantA); + await LockAsync(parent, parentTransaction, SchemaFixture.TenantA, null, false); + await ExecuteAsync(parent, parentTransaction, """ + INSERT INTO public.role_permission_grants(tenant_id,role_id,permission_scope,permission_key) + VALUES(@tenant,@role,'Organization',@key) + """, ("tenant", SchemaFixture.TenantA), ("role", branchRole), ("key", IdentityFoundationManifest.MembershipAdmin)); + try + { + // This separate app owner has committed its revocation before the candidate's grant query starts. + await MutateAsync(null, null, "DELETE FROM public.role_permission_grants WHERE role_id=@role AND permission_key=@key", + ("role", tenantRole), ("key", IdentityFoundationManifest.ProfileWrite)); + var result = await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA, SchemaFixture.OrgA1)); + result.Covers.Should().BeTrue(); + result.Memberships.SelectMany(member => member.Roles).SelectMany(role => role.Grants).Should().BeEquivalentTo([ + new PermissionDefinition(PermissionScope.Organization, IdentityFoundationManifest.ProfileWrite)]); + parentTransaction.Connection.Should().BeSameAs(parent); + } + finally + { + await parentTransaction.RollbackAsync(); + await MutateAsync(null, null, """ + INSERT INTO public.role_permission_grants(tenant_id,role_id,permission_scope,permission_key) + VALUES(@tenant,@role,'Tenant',@key) + """, ("tenant", SchemaFixture.TenantA), ("role", tenantRole), ("key", IdentityFoundationManifest.ProfileWrite)); + } + } + + [Fact] + public async Task Request_cancellation_propagates_after_owned_cleanup_and_the_slot_recovers() + { + using var cancellation = new CancellationTokenSource(); + var cancelOnce = true; + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture, inner => new LifecycleProbe(inner, + (_, _, _) => { if (cancelOnce) { cancelOnce = false; cancellation.Cancel(); } return Task.CompletedTask; })); + using var minter = new CandidateSource(); + await FluentActions.Awaiting(() => harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA), cancellation.Token)) + .Should().ThrowAsync(); + (await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA))).Covers.Should().BeTrue(); + } + + [Fact] + public async Task Internal_deadline_is_unavailable_with_no_authority_payload() + { + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture, inner => new LifecycleProbe(inner, + async (_, _, cancellationToken) => await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken))); + using var minter = new CandidateSource(); + var result = await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA)); + result.Outcome.Should().Be(IdentityAuthorizationOutcome.Unavailable); + result.Memberships.Should().BeEmpty(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Lifecycle_storage_or_reported_actual_cleanup_failure_never_publishes_prepared_facts(bool cleanupFailed) + { + var harness = await AuthorizationReaderHarness.CreateAsync(fixture, inner => new LifecycleProbe(inner, + (_, _, _) => throw new IdentityScopeLifecycleReadException(cleanupFailed))); + using var minter = new CandidateSource(); + try + { + var result = await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA)); + result.Outcome.Should().Be(IdentityAuthorizationOutcome.Unavailable); + result.Memberships.Should().BeEmpty(); + var owners = new List(); + try + { + for (var index = 0; index < (cleanupFailed ? 3 : 4); index++) + owners.Add((await harness.Source.OpenAsync(minter.For(PersonA, SchemaFixture.TenantA)))!); + (await harness.Source.OpenAsync(minter.For(PersonA, SchemaFixture.TenantA))).Should().BeNull(); + } + finally { foreach (var owner in owners) await owner.DisposeAsync(); } + } + finally + { + if (cleanupFailed) + await FluentActions.Awaiting(async () => await harness.DisposeAsync()).Should().ThrowAsync(); + else await harness.DisposeAsync(); + } + } + + [Fact] + public async Task Actual_connection_disposal_fault_withholds_prepared_facts_and_retains_capacity() + { + var harness = await AuthorizationReaderHarness.CreateAsync(fixture, inner => new LifecycleProbe(inner, + (child, _, _) => + { + child.Connection.StateChange += (_, args) => + { + if (args.CurrentState == ConnectionState.Closed) throw new IOException("cleanup-sensitive-canary"); + }; + return Task.CompletedTask; + })); + using var minter = new CandidateSource(); + try + { + var result = await harness.Reader.ReadAsync(minter.For(PersonA, SchemaFixture.TenantA)); + result.Outcome.Should().Be(IdentityAuthorizationOutcome.Unavailable); + result.Memberships.Should().BeEmpty(); + var candidate = minter.For(PersonA, SchemaFixture.TenantA); + var owners = new List(); + try + { + for (var index = 0; index < 3; index++) owners.Add((await harness.Source.OpenAsync(candidate))!); + (await harness.Source.OpenAsync(candidate)).Should().BeNull(); + } + finally { foreach (var owner in owners) await owner.DisposeAsync(); } + } + finally + { + await FluentActions.Awaiting(async () => await harness.DisposeAsync()).Should().ThrowAsync(); + } + } + + [Fact] + public async Task Lifecycle_authorization_port_refuses_foreign_announcement_and_stale_physical_transaction() + { + await using var business = ApplicationDataSource.Build(fixture.AppConnectionString); + await using var unit = new NpgsqlUnitOfWork(business, NullLogger.Instance); + await using var frame = await unit.BeginTransactionAsync(TransactionMode.ReadOnly); + await unit.SetTenantContextAsync(new Context(TenantId.From(SchemaFixture.TenantA), null)); + var reader = new IdentityScopeLifecycleReader(unit); + using var minter = new CandidateSource(); + await FluentActions.Awaiting(() => reader.ReadAuthorizationAsync(unit, minter.For(PersonA, SchemaFixture.TenantB))) + .Should().ThrowAsync(); + await frame.CompleteAsync(); + await FluentActions.Awaiting(() => reader.ReadAuthorizationAsync(unit, minter.For(PersonA, SchemaFixture.TenantA))) + .Should().ThrowAsync(); + } + + private async Task MutateAsync(Guid? organization, Guid? actor, string sql, params (string Name, object? Value)[] values) + { + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, SchemaFixture.TenantA, organization); + await LockAsync(app, transaction, SchemaFixture.TenantA, actor, false); + await ExecuteAsync(app, transaction, sql, values); + await transaction.CommitAsync(); + } + + private async Task AssertDuplicateRoleRefusesAsync(bool fullPopulation) + { + var duplicateRole = Guid.CreateVersion7(); + var organization = fullPopulation ? SchemaFixture.OrgA1 : (Guid?)null; + var templates = IdentityFoundationManifest.Version1.Templates + .Where(template => template.BuiltInKey != BuiltInRoleKey.Learner + && (fullPopulation || template is { BuiltInKey: BuiltInRoleKey.Instructor, Scope: PermissionScope.Tenant })) + .ToArray(); + var added = new List(); + await using var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.Membership.Postgres.MigrationConnectionString); + var indexDefinition = await ScalarAsync(owner, null, + "SELECT pg_get_indexdef('public.ux_roles_current_builtin_scope'::regclass)"); + var indexDropped = false; + await using var harness = await AuthorizationReaderHarness.CreateAsync(fixture); + using var minter = new CandidateSource(); + var candidate = minter.For(PersonA, SchemaFixture.TenantA, organization); + try + { + foreach (var template in templates) + { + await ChangeAssignmentAsync(template, add: true); + added.Add(template); + } + var before = await harness.Reader.ReadAsync(candidate); + before.Covers.Should().BeTrue(); + before.Memberships.SelectMany(member => member.Roles).Should().HaveCount(fullPopulation ? 8 : 2); + + // Only this unique index is removed. The corrupt duplicate is committed by + // learnstack_app through all remaining guards, then read by the real app-role source. + await ExecuteAsync(owner, null, "DROP INDEX public.ux_roles_current_builtin_scope"); + indexDropped = true; + await MutateAsync(null, PersonA, """ + INSERT INTO public.roles(id,tenant_id,built_in_key,permission_scope,created_at,created_by) + VALUES(@role,@tenant,'Instructor','Tenant',clock_timestamp(),@actor); + INSERT INTO public.membership_role_assignments(tenant_id,membership_id,role_id) + VALUES(@tenant,@member,@role) + """, ("role", duplicateRole), ("tenant", SchemaFixture.TenantA), + ("actor", UserId.SystemActor.Value), ("member", LearnerMembershipA)); + + await using (var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString)) + await using (var transaction = await app.BeginTransactionAsync()) + { + await SchemaQueries.AssertApplicationRoleAsync(app); + await ScopeAsync(app, transaction, SchemaFixture.TenantA, organization); + (await ScalarAsync(app, transaction, """ + SELECT count(DISTINCT role.id) + FROM public.memberships AS member + JOIN public.membership_role_assignments AS assignment + ON assignment.tenant_id=member.tenant_id AND assignment.membership_id=member.id + AND assignment.organization_id IS NOT DISTINCT FROM member.organization_id + JOIN public.roles AS role ON role.tenant_id=assignment.tenant_id AND role.id=assignment.role_id + WHERE member.tenant_id=@tenant AND member.user_id=@actor AND member.status='Active' + AND member.deleted_at IS NULL AND member.deleted_by IS NULL + AND (member.organization_id IS NULL OR member.organization_id=@organization) + AND role.deleted_at IS NULL AND role.deleted_by IS NULL + AND role.permission_scope=CASE WHEN member.organization_id IS NULL THEN 'Tenant' ELSE 'Organization' END + """, ("tenant", SchemaFixture.TenantA), ("actor", PersonA), ("organization", organization))) + .Should().Be(fullPopulation ? 9 : 3); + } + var result = await harness.Reader.ReadAsync(candidate); + result.Outcome.Should().Be(IdentityAuthorizationOutcome.Unavailable); + result.Memberships.Should().BeEmpty(); + // The ninth row necessarily repeats one of eight finite templates. This + // proves excess/ambiguous graph refusal; compiled controls independently pin the >8 guard. + } + finally + { + try + { + if (indexDropped) await RestoreDuplicateRoleAsync(owner, duplicateRole, indexDefinition); + } + finally + { + foreach (var template in added.AsEnumerable().Reverse()) await ChangeAssignmentAsync(template, add: false); + } + } + + (await ScalarAsync(owner, null, "SELECT pg_get_indexdef('public.ux_roles_current_builtin_scope'::regclass)")) + .Should().Be(indexDefinition); + var restored = await harness.Reader.ReadAsync(candidate); + restored.Covers.Should().BeTrue(); + restored.Memberships.SelectMany(member => member.Roles).Should().HaveCount(fullPopulation ? 2 : 1); + await using var verifier = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var verification = await verifier.BeginTransactionAsync(); + await ScopeAsync(verifier, verification, SchemaFixture.TenantA); + (await ScalarAsync(verifier, verification, "SELECT count(*) FROM public.roles WHERE id=@role", ("role", duplicateRole))) + .Should().Be(0); + (await ScalarAsync(verifier, verification, "SELECT count(*) FROM public.membership_role_assignments WHERE role_id=@role", ("role", duplicateRole))) + .Should().Be(0); + } + + private static async Task RestoreDuplicateRoleAsync(NpgsqlConnection owner, Guid duplicateRole, string indexDefinition) + { + await using var transaction = await owner.BeginTransactionAsync(); + await ScopeAsync(owner, transaction, SchemaFixture.TenantA); + // Physical removal of this case-owned corrupt row needs only these table-owner + // trigger changes. The transaction restores all triggers and the exact saved index together. + await ExecuteAsync(owner, transaction, """ + ALTER TABLE public.membership_role_assignments DISABLE TRIGGER tg_membership_role_assignments_mutation_guard; + ALTER TABLE public.membership_role_assignments DISABLE TRIGGER tg_membership_role_assignments_complete_graph; + ALTER TABLE public.roles DISABLE TRIGGER tg_roles_mutation_guard; + ALTER TABLE public.roles DISABLE TRIGGER tg_roles_complete_graph; + DELETE FROM public.membership_role_assignments WHERE tenant_id=@tenant AND role_id=@role; + DELETE FROM public.roles WHERE tenant_id=@tenant AND id=@role; + ALTER TABLE public.membership_role_assignments ENABLE TRIGGER tg_membership_role_assignments_mutation_guard; + ALTER TABLE public.membership_role_assignments ENABLE TRIGGER tg_membership_role_assignments_complete_graph; + ALTER TABLE public.roles ENABLE TRIGGER tg_roles_mutation_guard; + ALTER TABLE public.roles ENABLE TRIGGER tg_roles_complete_graph + """, ("tenant", SchemaFixture.TenantA), ("role", duplicateRole)); + await ExecuteAsync(owner, transaction, indexDefinition); + await transaction.CommitAsync(); + } + + private async Task ChangeAssignmentAsync(BuiltInRoleTemplate template, bool add) + { + var organization = template.Scope == PermissionScope.Organization ? SchemaFixture.OrgA1 : (Guid?)null; + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, SchemaFixture.TenantA, organization); + await LockAsync(app, transaction, SchemaFixture.TenantA, add ? PersonA : null, template.BuiltInKey == BuiltInRoleKey.TenantAdmin); + await ExecuteAsync(app, transaction, add ? """ + INSERT INTO public.membership_role_assignments(tenant_id,membership_id,role_id,organization_id) + VALUES(@tenant,@member,@role,@organization) + """ : "DELETE FROM public.membership_role_assignments WHERE membership_id=@member AND role_id=@role", + ("tenant", SchemaFixture.TenantA), ("member", organization is null ? LearnerMembershipA : BranchMembershipA1), + ("role", RoleId(SchemaFixture.TenantA, template.BuiltInKey, template.Scope)), ("organization", organization)); + await transaction.CommitAsync(); + } + + private sealed class CandidateSource : AuthorizationCandidate.MintingSource + { + public AuthorizationCandidate For(Guid actor, Guid tenant, Guid? organization = null) => + ForCandidate(AuthorizationSourceKind.Http, UserId.From(actor), TenantId.From(tenant), + organization is { } org ? OrganizationId.From(org) : null); + } + + private sealed class LifecycleProbe(IIdentityScopeLifecycleReader inner, + Func before) : IIdentityScopeLifecycleReader + { + public Task ReadAsync(TenantId tenantId, OrganizationId? organizationId, + CancellationToken cancellationToken = default) => inner.ReadAsync(tenantId, organizationId, cancellationToken); + public async Task ReadAuthorizationAsync(IUnitOfWork child, AuthorizationCandidate candidate, + CancellationToken cancellationToken = default) + { + await before(child, candidate, cancellationToken); + return await inner.ReadAuthorizationAsync(child, candidate, cancellationToken); + } + } + + private sealed record Context(TenantId TenantId, OrganizationId? OrganizationId) : ITenantContext + { + public bool IsResolved => true; + public UserId? UserId => null; + public string? CorrelationId => null; + public string? ModuleName => null; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/IdentityPublicReadFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/IdentityPublicReadFixture.cs index 4e93934ce..9ba48be88 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/IdentityPublicReadFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/IdentityPublicReadFixture.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FluentAssertions; using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Identity.Application.Contracts.Bindings; @@ -5,6 +6,7 @@ using LearnStack.Modules.Identity.Infrastructure.Bindings; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Identity; +using LearnStack.SharedKernel.Permissions; using LearnStack.Tools.Seeder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; @@ -44,6 +46,58 @@ internal static async Task CreateAsync(PublicReadFixt } } + internal static async Task CreateWithMembershipsAsync(PublicReadFixture parent, + string issuer, IReadOnlyDictionary subjects) + { + var fixture = await CreateAsync(parent, issuer, subjects); + try + { + await fixture.SeedHttpFoundationAsync(); + foreach (var actor in subjects.Keys) + foreach (var tenant in SeedData.All) + await fixture.AddMembershipAsync(tenant, actor); + return fixture; + } + catch { await fixture.DisposeAsync(); throw; } + } + + internal static string FoundationSubject(UserId actor) => "http-foundation-" + actor.Value.ToString("N"); + + internal async Task SeedHttpFoundationAsync() + { + await using var input = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(new + { + version = 1, + issuer = Registration.Issuer, + users = SeedData.IdentityUsers.Select(person => new + { + actorId = person.UserId.Value, + opaqueSub = FoundationSubject(person.UserId), + }), + })); + await SeedFoundationAsync(await SeedProviderWitness.ReadAsync(input, Registration)); + } + + internal async Task AddMembershipAsync(SeedTenant tenant, UserId actor, + OrganizationId? organization = null, BuiltInRoleKey role = BuiltInRoleKey.Learner, + string? displayName = "HTTP contextual profile") + { + var membership = Guid.CreateVersion7(); + var permissionScope = organization is null ? PermissionScope.Tenant : PermissionScope.Organization; + var roleId = SeedData.IdentityRoles.Single(value => value.Tenant == tenant + && value.BuiltInKey == role && value.Scope == permissionScope).RoleId; + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(AppConnectionString); + await SchemaQueries.AssertApplicationRoleAsync(app); + await using var transaction = await app.BeginTransactionAsync(); + await IdentityMembershipFixture.ScopeAsync(app, transaction, tenant.TenantId.Value, organization?.Value); + await IdentityMembershipFixture.LockAsync(app, transaction, tenant.TenantId.Value, actor.Value, + role == BuiltInRoleKey.TenantAdmin); + await IdentityMembershipFixture.InsertMemberAsync(app, transaction, tenant.TenantId.Value, + organization?.Value, membership, actor.Value, roleId, displayName); + await transaction.CommitAsync(); + return membership; + } + internal async Task SeedFoundationAsync(SeedProviderWitness witness) { await using var source = ApplicationDataSource.Build(AppConnectionString); @@ -81,6 +135,8 @@ internal async Task AdoptAsync(UserId actor, string opaqueSubject) protected override void ConfigureTestHost(IWebHostBuilder builder) { base.ConfigureTestHost(builder); + builder.UseSetting("ConnectionStrings:IdentityAuthorization", AppConnectionString); + builder.UseSetting("ConnectionStrings:AuthorizationEvidence", AppConnectionString); builder.UseSetting("Identity:AuthorityId", Registration.AuthorityId.Value.ToString("D")); builder.UseSetting("Authentication:Tenant:Issuer", Registration.Issuer); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/IdentitySeederTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/IdentitySeederTests.cs index 3718c1afa..3e20d5ea9 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/IdentitySeederTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/IdentitySeederTests.cs @@ -3,8 +3,11 @@ using FluentAssertions; using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Identity.Application.Contracts.Bindings; +using LearnStack.Modules.Identity.Application.Contracts.Roles; +using LearnStack.Modules.Identity.Domain; using LearnStack.Modules.Identity.Infrastructure.Bindings; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Secrets; using LearnStack.SharedKernel.Tenancy; using LearnStack.Tools.Seeder; @@ -131,24 +134,20 @@ public async Task Interrupted_committed_acts_resume_only_missing_declarations(in } [Fact] - public async Task Edited_or_upgraded_initial_grants_refuse_without_repair_or_additional_audit() + public async Task Legitimate_audited_grant_change_makes_fixed_v1_seed_refuse_without_repair_or_additional_audit() { await using var dataSource = ApplicationDataSource.Build(_database.AppConnectionString); await SeedContentAsync(dataSource); await RunAsync(dataSource); - var role = SeedData.IdentityRoles[0]; - await using (var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(_database.AppConnectionString)) - await using (var transaction = await app.BeginTransactionAsync()) + var role = SeedData.IdentityRoles.First(declaration => declaration.BuiltInKey == BuiltInRoleKey.TenantAdmin); + var administrator = SeedData.IdentityMemberships.First(member => member.IsFirstAdmin && member.Identity.Tenant == role.Tenant); + await using (var grants = await RoleGrantPipelineHarness.CreateAsync(_database.AppConnectionString, Registration, + role.Tenant.TenantId, administrator.Identity.UserId, "seed-legitimate-grant-change")) { - await IdentityMembershipFixture.ScopeAsync(app, transaction, role.Tenant.TenantId.Value); - await IdentityMembershipFixture.LockAsync(app, transaction, role.Tenant.TenantId.Value, null, true); - await IdentityMembershipFixture.ExecuteAsync(app, transaction, """ - DELETE FROM public.role_permission_grants WHERE tenant_id=@tenant AND role_id=@role - AND permission_key='identity.membership_profile.write'; - UPDATE public.roles SET row_version=row_version+1 WHERE tenant_id=@tenant AND id=@role; - SET CONSTRAINTS ALL IMMEDIATE; - """, ("tenant", role.Tenant.TenantId.Value), ("role", role.RoleId)); - await transaction.CommitAsync(); + var changed = await grants.Sender.Send(new ChangeRoleGrantsCommand(role.RoleId, 0, + IdentityFoundationManifest.Version1.TenantAdminKernel)); + changed.IsSuccess.Should().BeTrue(); + changed.Value.Should().Be(1); } var before = await AuditCountAsync(); var act = async () => await RunAsync(dataSource); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/MembershipDirectoryIntegrationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/MembershipDirectoryIntegrationTests.cs new file mode 100644 index 000000000..a0fe943a3 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/MembershipDirectoryIntegrationTests.cs @@ -0,0 +1,287 @@ +using LearnStack.Application.Authorization; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Application; +using LearnStack.Modules.Identity.Application.Authorization; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Application.Memberships; +using LearnStack.Modules.Identity.Domain; +using LearnStack.Modules.Identity.Infrastructure.Persistence; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Identity; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; +using static LearnStack.Tests.Integration.Database.IdentityMembershipFixture; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class MembershipDirectoryIntegrationTests(IdentityAuthorizationReaderFixture fixture) + : IClassFixture +{ + [Fact] + public async Task Actual_branch_admin_directory_pages_across_its_tenant_with_only_contextual_fields_on_ReadWrite() + { + var query = new ListMembershipsQuery(Limit: 2); + var items = new List(); + do + { + await using var harness = await DirectoryHarness.CreateAsync(fixture, query); + Assert.Equal(ProtectedOperationOutcome.Allowed, harness.Decision.Outcome); + await harness.EnterAsync(); + await SchemaQueries.AssertApplicationRoleAsync((NpgsqlConnection)harness.Unit.Connection); + Assert.Equal(TransactionMode.ReadWrite, harness.Unit.Mode); + Assert.Equal("off", await ScalarAsync((NpgsqlConnection)harness.Unit.Connection, + (NpgsqlTransaction)harness.Unit.Transaction!, "SHOW transaction_read_only")); + Assert.Equal("tenant", await ScalarAsync((NpgsqlConnection)harness.Unit.Connection, + (NpgsqlTransaction)harness.Unit.Transaction!, "SELECT current_setting('app.scope')")); + Assert.True(harness.Db.CurrentTenantWideRead); + var result = await harness.Handler.Handle(query, CancellationToken.None); + Assert.True(result.IsSuccess); + items.AddRange(result.Value.Items); + query = query with { Cursor = result.Value.PageInfo.NextCursor }; + Assert.Empty(harness.Db.ChangeTracker.Entries()); + } while (query.Cursor is not null); + Assert.Equal(new[] { AdminMembershipA, LearnerMembershipA, BranchMembershipA1, BranchMembershipA2 }.Order(), + items.Select(item => item.MembershipId)); + Assert.Equal(4, items.Select(item => item.MembershipId).Distinct().Count()); + Assert.All(items, item => + { + Assert.Equal("profile-canary", item.DisplayName); + Assert.Equal("en", item.PreferredLocale); + Assert.DoesNotContain(item.UserId.Value, new[] { AdminB, PersonB }); + }); + Assert.Equal(["MembershipId", "UserId", "OrganizationId", "Status", "DisplayName", "PreferredLocale"], + typeof(MembershipDirectoryItem).GetProperties().Select(property => property.Name)); + } + + [Fact] + public async Task Directory_includes_suspended_members_and_excludes_committed_revoked_rows() + { + var suspended = await fixture.AddMemberAsync(SchemaFixture.OrgA1); + var revoked = await fixture.AddMemberAsync(SchemaFixture.OrgA2); + try + { + await ChangeStatusAsync(suspended.Actor, suspended.Membership, SchemaFixture.OrgA1, "Suspended"); + await ChangeStatusAsync(revoked.Actor, revoked.Membership, SchemaFixture.OrgA2, "Revoked"); + var query = new ListMembershipsQuery(); + await using var harness = await DirectoryHarness.CreateAsync(fixture, query); + await harness.EnterAsync(); + var result = await harness.Handler.Handle(query, CancellationToken.None); + Assert.True(result.IsSuccess); + Assert.Equal(DirectoryMembershipStatus.Suspended, + Assert.Single(result.Value.Items, item => item.MembershipId == suspended.Membership).Status); + Assert.DoesNotContain(result.Value.Items, item => item.MembershipId == revoked.Membership); + } + finally + { + await ChangeStatusAsync(suspended.Actor, suspended.Membership, SchemaFixture.OrgA1, "Revoked"); + await ChangeStatusAsync(revoked.Actor, revoked.Membership, SchemaFixture.OrgA2, "Revoked"); + } + } + + [Fact] + public async Task Actual_coverage_without_Tenant_admin_and_foreign_actor_refuse_before_business_checkout() + { + await using var learner = await DirectoryHarness.CreateAsync(fixture, new ListMembershipsQuery(), PersonA); + Assert.Equal(ProtectedOperationOutcome.Forbidden, learner.Decision.Outcome); + Assert.False(learner.Unit.HasActiveTransaction); + Assert.Empty(learner.Decision.MembershipIds); + await using var foreign = await DirectoryHarness.CreateAsync(fixture, new ListMembershipsQuery(), AdminB); + Assert.Equal(ProtectedOperationOutcome.NotCovered, foreign.Decision.Outcome); + Assert.False(foreign.Unit.HasActiveTransaction); + Assert.Empty(foreign.Decision.MembershipIds); + } + + [Fact] + public async Task Adapter_refuses_narrow_owner_even_if_SQL_scope_is_tampered_and_real_EF_filter_stays_narrow() + { + await using var harness = await DirectoryHarness.CreateAsync(fixture, new ListMembershipsQuery()); + await harness.EnterAsync(wide: false); + Assert.False(harness.Db.CurrentTenantWideRead); + Assert.Equal(3, await harness.Db.Memberships.CountAsync(member => member.DeletedAt == null)); + await ExecuteAsync((NpgsqlConnection)harness.Unit.Connection, (NpgsqlTransaction)harness.Unit.Transaction!, + "SELECT set_config('app.scope','tenant',true)"); + Assert.Equal(4, await ScalarAsync((NpgsqlConnection)harness.Unit.Connection, (NpgsqlTransaction)harness.Unit.Transaction!, + "SELECT count(*) FROM memberships WHERE deleted_at IS NULL")); + Assert.Equal(3, await harness.Db.Memberships.CountAsync(member => member.DeletedAt == null)); + await Assert.ThrowsAsync(() => harness.DirectoryReader.ReadAsync(null, 21)); + } + + [Fact] + public async Task Modified_anchor_remains_a_seek_only_and_other_branch_cursor_is_validation_failure() + { + var scope = MembershipDirectoryCursorCodec.Scope(UserId.From(AdminA), TenantId.From(SchemaFixture.TenantA), + OrganizationId.From(SchemaFixture.OrgA1)); + var query = new ListMembershipsQuery(MembershipDirectoryCursorCodec.Encode(scope, + LearnStack.Modules.Identity.Domain.MembershipId.From(BranchMembershipA1))); + await using var harness = await DirectoryHarness.CreateAsync(fixture, query); + await harness.EnterAsync(); + var result = await harness.Handler.Handle(query, CancellationToken.None); + Assert.True(result.IsSuccess); + Assert.Equal([BranchMembershipA2], result.Value.Items.Select(item => item.MembershipId)); + var foreignScope = MembershipDirectoryCursorCodec.Scope(UserId.From(AdminA), TenantId.From(SchemaFixture.TenantA), + OrganizationId.From(SchemaFixture.OrgA2)); + var foreign = query with + { + Cursor = MembershipDirectoryCursorCodec.Encode(foreignScope, + LearnStack.Modules.Identity.Domain.MembershipId.From(BranchMembershipA1)) + }; + var refused = await harness.Handler.Handle(foreign, CancellationToken.None); + Assert.Equal("validation_failed", refused.Error!.Code); + Assert.True(refused.Error.Details!.ContainsKey("cursor")); + } + + [Fact] + public async Task Tenant_reenrollment_refuses_a_foreign_physical_connection_without_retargeting_either_owner() + { + await using var harness = await DirectoryHarness.CreateAsync(fixture, new ListMembershipsQuery()); + await harness.EnterAsync(); + Assert.NotEmpty(await harness.DirectoryReader.ReadAsync(null, 21)); + var transaction = harness.Unit.Transaction; + await using var foreign = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await SchemaQueries.AssertApplicationRoleAsync(foreign); + await using var db = new IdentityDbContext(new DbContextOptionsBuilder() + .UseNpgsql(foreign).Options, new StaticTenantContextAccessor(harness.Context), + new GlobalIdentityScopeAccessor(harness.Unit)); + await Assert.ThrowsAsync(() => + new MembershipDirectoryReader(db, harness.Unit, harness.Context).ReadAsync(null, 21)); + Assert.Throws(() => new ContextualIdentityStorageControl(db, harness.Unit).PrepareFreshRead()); + Assert.Null(db.Database.CurrentTransaction); + Assert.Same(transaction, harness.Unit.Transaction); + Assert.Same(foreign, db.Database.GetDbConnection()); + Assert.NotEmpty(await harness.DirectoryReader.ReadAsync(null, 21)); + } + + [Fact] + public async Task Tenant_reenrollment_refuses_pending_tracked_mutation_without_clearing_or_saving_it() + { + await using var harness = await DirectoryHarness.CreateAsync(fixture, new ListMembershipsQuery()); + await harness.EnterAsync(); + Assert.NotEmpty(await harness.DirectoryReader.ReadAsync(null, 21)); + var member = await harness.Db.Memberships.SingleAsync(value => value.Id == MembershipId.From(LearnerMembershipA)); + Assert.True(member.Suspend(DateTimeOffset.UtcNow, UserId.From(AdminA)).IsSuccess); + Assert.True(harness.Db.ChangeTracker.HasChanges()); + await Assert.ThrowsAsync(() => harness.DirectoryReader.ReadAsync(null, 21)); + Assert.Throws(() => new ContextualIdentityStorageControl(harness.Db, harness.Unit).PrepareFreshRead()); + Assert.Equal(EntityState.Modified, harness.Db.Entry(member).State); + Assert.Equal(MembershipStatus.Suspended, member.Status); + Assert.Equal("Active", await ScalarAsync((NpgsqlConnection)harness.Unit.Connection, + (NpgsqlTransaction)harness.Unit.Transaction!, "SELECT status FROM memberships WHERE id=@member", + ("member", LearnerMembershipA))); + } + + private async Task ChangeStatusAsync(Guid actor, Guid member, Guid organization, string status) + { + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, SchemaFixture.TenantA, organization); + await LockAsync(app, transaction, SchemaFixture.TenantA, actor, false); + await ExecuteAsync(app, transaction, """ + UPDATE memberships SET status=@status, + deleted_at=CASE WHEN @status='Revoked' THEN COALESCE(deleted_at,clock_timestamp()) ELSE NULL END, + deleted_by=CASE WHEN @status='Revoked' THEN @actor ELSE NULL END + WHERE id=@member AND status<>@status + """, ("status", status), ("actor", UserId.SystemActor.Value), ("member", member)); + await transaction.CommitAsync(); + } + + private sealed class DirectoryHarness : IAsyncDisposable + { + private readonly AuthorizationReaderHarness _authorization; + private readonly NpgsqlDataSource _data; + private readonly CandidateSource _source; + private readonly InvocationSource _invocations; + private ServiceProvider? _provider; + private IUnitOfWorkScope? _frame; + + private DirectoryHarness(AuthorizationReaderHarness authorization, NpgsqlDataSource data, NpgsqlUnitOfWork unit, + CandidateSource source, InvocationSource invocations, ITenantContext context, ProtectedOperationDecision decision) + { + _authorization = authorization; _data = data; Unit = unit; _source = source; + _invocations = invocations; Context = context; Decision = decision; + } + public NpgsqlUnitOfWork Unit { get; } + public ITenantContext Context { get; } + public ProtectedOperationDecision Decision { get; } + public IdentityDbContext Db => _provider!.GetRequiredService(); + public MembershipDirectoryReader DirectoryReader => new(Db, Unit, Context); + public ListMembershipsQueryHandler Handler => new(DirectoryReader, Context); + + public static async Task CreateAsync(IdentityAuthorizationReaderFixture fixture, ListMembershipsQuery query, + Guid? actor = null) + { + var authorization = await AuthorizationReaderHarness.CreateAsync(fixture); + var data = ApplicationDataSource.Build(fixture.AppConnectionString); + var unit = new NpgsqlUnitOfWork(data, NullLogger.Instance); + var context = TenantContextFactory.Create(new TenantResolutionAttempt + { + HasValidatedPrincipal = true, + UserId = UserId.From(actor ?? AdminA), + HostTenantId = TenantId.From(SchemaFixture.TenantA), + HostOrganizationId = OrganizationId.From(SchemaFixture.OrgA1), + ClaimTenantId = TenantId.From(SchemaFixture.TenantA), + ClaimOrganizationId = OrganizationId.From(SchemaFixture.OrgA1), + MembershipCovers = true, + ClaimedOrganizationBelongsToTenant = true, + }).Value; + Assert.NotNull(context); + var source = new CandidateSource(); + var candidate = source.Mint(context); + var catalog = new OperationPolicyCatalog([new IdentityOperationPolicySource()]); + Assert.True(catalog.TryGet(typeof(ListMembershipsQuery), out var policy)); + var invocations = new InvocationSource(); + var invocation = invocations.Mint(query, policy, context, candidate); + var decision = await new IdentityOperationAuthorizer(() => authorization.Reader, catalog).AuthorizeAsync(invocation, policy, candidate); + return new(authorization, data, unit, source, invocations, context, decision); + } + + public async Task EnterAsync(bool wide = true) + { + _frame = await Unit.BeginTransactionAsync(); + if (wide) await Unit.SetTenantContextAsync(Context, Decision.ReadWitness!); + else await Unit.SetTenantContextAsync(Context); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Unit); + services.AddSingleton(new StaticTenantContextAccessor(Context)); + services.AddSingleton(new GlobalIdentityScopeAccessor(Unit)); + services.AddModuleDbContext(); + _provider = services.BuildServiceProvider(); + } + + public async ValueTask DisposeAsync() + { + if (_provider is not null) await _provider.DisposeAsync(); + if (_frame is not null) await _frame.DisposeAsync(); + await Unit.DisposeAsync(); + await _data.DisposeAsync(); + _invocations.Close(); + _source.Dispose(); + await _authorization.DisposeAsync(); + } + } + + private sealed class CandidateSource : AuthorizationCandidate.MintingSource + { + public AuthorizationCandidate Mint(TenantContext context) => + ForCandidate(AuthorizationSourceKind.Http, context.UserId!.Value, context.TenantId, context.OrganizationId); + } + private sealed class InvocationSource : OperationInvocation.MintingSource + { + private OperationInvocation? _current; + public OperationInvocation Mint(object operation, OperationPolicy policy, ITenantContext context, AuthorizationCandidate candidate) + { + _current = ForInvocation(operation, policy, context, candidate, null, CancellationToken.None); + return _current; + } + protected override bool IsCurrent(OperationInvocation invocation) => ReferenceEquals(invocation, _current); + public void Close() => _current = null; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/MembershipDirectoryPipelineTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/MembershipDirectoryPipelineTests.cs new file mode 100644 index 000000000..4d4bd79ad --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/MembershipDirectoryPipelineTests.cs @@ -0,0 +1,314 @@ +using System.Diagnostics.Metrics; +using LearnStack.Api.Common; +using LearnStack.Application.Authorization; +using LearnStack.Application.Persistence; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Audit; +using LearnStack.Infrastructure.Caching; +using LearnStack.Infrastructure.Jobs; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Application; +using LearnStack.Modules.Identity.Application.Audit; +using LearnStack.Modules.Identity.Application.Contracts.Memberships; +using LearnStack.Modules.Identity.Infrastructure.Authorization; +using LearnStack.Modules.Identity.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Contracts.IdentityLifecycle; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Identity; +using LearnStack.SharedKernel.Pagination; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Xunit; +using static LearnStack.Tests.Integration.Database.IdentityMembershipFixture; + +namespace LearnStack.Tests.Integration.Database; + +/// Explicit staged proof composition; launch still keeps its slot-5 registration unchanged. +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class MembershipDirectoryPipelineTests(IdentityAuthorizationReaderFixture fixture) + : IClassFixture +{ + private const long CommitGate = 7_601_077_003; + + [Fact] + public async Task Actual_directory_result_waits_for_its_MUST_row_to_commit_before_disclosure() + { + const string Correlation = "directory-proof-commit-barrier"; + await InstallTriggerAsync(Correlation, "commit_gate"); + try + { + await using var proof = await PipelineProof.CreateAsync(fixture, Correlation); + await using var blocker = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await SchemaQueries.AssertApplicationRoleAsync(blocker); + await using var lockTransaction = await blocker.BeginTransactionAsync(); + await ExecuteAsync(blocker, lockTransaction, "SELECT pg_advisory_xact_lock(@key)", ("key", CommitGate)); + await using var observer = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var observation = await observer.BeginTransactionAsync(); + await ScopeAsync(observer, observation, SchemaFixture.TenantA, SchemaFixture.OrgA1); + await using var monitor = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var running = proof.Sender.Send(new ListMembershipsQuery(), deadline.Token); + try + { + await ObservePhysicalCommitWaitAsync(monitor, blocker.ProcessID, running, deadline.Token); + Assert.False(running.IsCompleted); + Assert.Equal(0, await CountRowsAsync(observer, observation, Correlation)); + } + finally + { + await lockTransaction.RollbackAsync(CancellationToken.None); + } + var result = await running; + Assert.True(result.IsSuccess); + Assert.Equal(4, result.Value.Items.Count); + Assert.Equal(1, await CountRowsAsync(observer, observation, Correlation)); + Assert.Equal("success:Must:ReadSensitive:Membership", await ScalarAsync(observer, observation, """ + SELECT outcome||':'||operation_class||':'||operation_type||':'||entity_type + FROM audit_log WHERE operation='identity.membership.list' AND correlation_id=@correlation + """, ("correlation", Correlation))); + Assert.False(proof.Unit.HasActiveTransaction); + Assert.False(proof.Unit.IsTenantWideRead); + } + finally { await RemoveTriggerAsync(); } + } + + [Fact] + public async Task Actual_MUST_insert_failure_withholds_the_materialized_directory_result() + { + const string Correlation = "directory-proof-insert-refused"; + await InstallTriggerAsync(Correlation, "insert_failure"); + try + { + await using var proof = await PipelineProof.CreateAsync(fixture, Correlation); + Result>? published = null; + var failure = await Assert.ThrowsAsync(async () => + published = await proof.Sender.Send(new ListMembershipsQuery())); + Assert.Equal("audit_unavailable", failure.Error.Code); + Assert.Null(published); + Assert.False(proof.Unit.HasActiveTransaction); + Assert.False(proof.Provider.GetRequiredService().IsHealthy); + Assert.Equal(0, await CountRowsAsync(Correlation)); + } + finally { await RemoveTriggerAsync(); } + } + + [Fact] + public async Task Actual_deferred_COMMIT_failure_answers_audit_unavailable_and_withholds_the_directory_result() + { + const string Correlation = "directory-proof-commit-refused"; + await InstallTriggerAsync(Correlation, "commit_failure"); + try + { + await using var proof = await PipelineProof.CreateAsync(fixture, Correlation); + Result>? published = null; + var failure = await Assert.ThrowsAsync(async () => + published = await proof.Sender.Send(new ListMembershipsQuery())); + Assert.Equal("P0399", Assert.IsType(failure.InnerException).SqlState); + Assert.Equal("audit_unavailable", failure.Error.Code); + Assert.Equal(503, HttpStatusMap.For(failure)); + Assert.Null(published); + Assert.False(proof.Unit.HasActiveTransaction); + Assert.False(proof.Provider.GetRequiredService().IsHealthy); + Assert.Equal(0, await CountRowsAsync(Correlation)); + } + finally { await RemoveTriggerAsync(); } + } + + [Fact] + public async Task Ordinary_owner_preserves_its_primary_COMMIT_failure_even_with_a_sensitive_read_intent() + { + const string Correlation = "ordinary-proof-commit-refused"; + await InstallTriggerAsync(Correlation, "commit_failure"); + try + { + await using var proof = await PipelineProof.CreateAsync(fixture, Correlation); + var failure = await Assert.ThrowsAsync(() => proof.RunOrdinaryAuditedOwnerAsync(Correlation)); + Assert.Equal("P0399", failure.SqlState); + Assert.Equal(500, HttpStatusMap.For(failure)); + Assert.Equal(AuditIntentState.Indeterminate, proof.Capture.State); + Assert.Same(failure, proof.Capture.IndeterminateCause); + Assert.False(proof.Unit.HasActiveTransaction); + Assert.Equal(0, await CountRowsAsync(Correlation)); + } + finally { await RemoveTriggerAsync(); } + } + + private async Task InstallTriggerAsync(string correlation, string mode) + { + await using var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.Membership.Postgres.MigrationConnectionString); + var action = mode == "commit_gate" ? $"PERFORM pg_catalog.pg_advisory_xact_lock({CommitGate});" + : "RAISE EXCEPTION 'directory proof audit refusal' USING ERRCODE='P0399';"; + await ExecuteAsync(owner, null, $$""" + CREATE FUNCTION public.fn_directory_audit_proof() RETURNS trigger LANGUAGE plpgsql AS $proof$ + BEGIN + IF NEW.operation='identity.membership.list' AND NEW.correlation_id='{{correlation}}' THEN + {{action}} + END IF; + RETURN NEW; + END $proof$; + """); + if (mode == "insert_failure") + await ExecuteAsync(owner, null, """ + CREATE TRIGGER tg_directory_audit_proof BEFORE INSERT ON public.audit_log + FOR EACH ROW EXECUTE FUNCTION public.fn_directory_audit_proof() + """); + else + await ExecuteAsync(owner, null, """ + CREATE CONSTRAINT TRIGGER tg_directory_audit_proof AFTER INSERT ON public.audit_log + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.fn_directory_audit_proof() + """); + } + + private async Task RemoveTriggerAsync() + { + await using var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.Membership.Postgres.MigrationConnectionString); + await ExecuteAsync(owner, null, """ + DROP TRIGGER IF EXISTS tg_directory_audit_proof ON public.audit_log; + DROP FUNCTION IF EXISTS public.fn_directory_audit_proof(); + """); + } + + private static async Task ObservePhysicalCommitWaitAsync(NpgsqlConnection observer, int blocker, Task running, + CancellationToken cancellationToken) + { + while (true) + { + await using var command = new NpgsqlCommand(""" + SELECT EXISTS(SELECT 1 FROM pg_stat_activity + WHERE @blocker=ANY(pg_blocking_pids(pid)) AND query='COMMIT' AND state='active') + """, observer); + command.Parameters.AddWithValue("blocker", blocker); + if ((bool)(await command.ExecuteScalarAsync(cancellationToken))!) return; + if (running.IsCompleted) + { + await running; + Assert.Fail("The directory completed before the physical COMMIT barrier was observed."); + } + await Task.Delay(20, cancellationToken); + } + } + + private async Task CountRowsAsync(string correlation) + { + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, SchemaFixture.TenantA, SchemaFixture.OrgA1); + return await CountRowsAsync(app, transaction, correlation); + } + + private static Task CountRowsAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, string correlation) => + ScalarAsync(connection, transaction, + "SELECT count(*) FROM audit_log WHERE operation='identity.membership.list' AND correlation_id=@correlation", + ("correlation", correlation)); + + private sealed class PipelineProof : IAsyncDisposable + { + private readonly IdentityAuthorizationReadSource _authorizationSource; + private readonly NpgsqlDataSource _businessSource; + private readonly CandidateSource _candidateSource; + private readonly AsyncServiceScope _scope; + + private PipelineProof(IdentityAuthorizationReadSource source, NpgsqlDataSource business, CandidateSource candidate, + ServiceProvider provider) + { + _authorizationSource = source; _businessSource = business; _candidateSource = candidate; + Provider = provider; _scope = provider.CreateAsyncScope(); + } + public ServiceProvider Provider { get; } + public ISender Sender => _scope.ServiceProvider.GetRequiredService(); + public NpgsqlUnitOfWork Unit => (NpgsqlUnitOfWork)_scope.ServiceProvider.GetRequiredService(); + public AuditStateCapture Capture => _scope.ServiceProvider.GetRequiredService(); + + public Task> RunOrdinaryAuditedOwnerAsync(string correlation) + { + Capture.DeclareIntent(new AuditIntent(AuditEntryId.From(Guid.CreateVersion7()), TenantId.From(SchemaFixture.TenantA), + OrganizationId.From(SchemaFixture.OrgA1), UserId.From(AdminA), correlation, "identity", "identity.membership.list", + OperationType.ReadSensitive, OperationClass.Must, typeof(LearnStack.Modules.Identity.Domain.Membership), + DateTimeOffset.UtcNow)); + return _scope.ServiceProvider.GetRequiredService() + .ExecuteAsync(null, TransactionMode.ReadWrite, "ordinary-proof", () => Task.FromResult(Result.Ok(None.Value))); + } + + public static async Task CreateAsync(IdentityAuthorizationReaderFixture fixture, string correlation) + { + var source = new IdentityAuthorizationReadSource(fixture.AppConnectionString); + await source.QualifyAsync(Authority.AuthorityId, Authority.Issuer); + var business = ApplicationDataSource.Build(fixture.AppConnectionString); + var context = TenantContextFactory.Create(new TenantResolutionAttempt + { + HasValidatedPrincipal = true, + UserId = UserId.From(AdminA), + HostTenantId = TenantId.From(SchemaFixture.TenantA), + HostOrganizationId = OrganizationId.From(SchemaFixture.OrgA1), + ClaimTenantId = TenantId.From(SchemaFixture.TenantA), + ClaimOrganizationId = OrganizationId.From(SchemaFixture.OrgA1), + MembershipCovers = true, + ClaimedOrganizationBelongsToTenant = true, + CorrelationId = correlation, + }).Value; + Assert.NotNull(context); + var candidateSource = new CandidateSource(context); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMetrics(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(business); + services.AddSingleton(new Lazy(() => business)); + services.AddSingleton(source); + services.AddSingleton(new StaticTenantContextAccessor(context)); + services.AddTransient(_ => context); + services.AddSingleton(candidateSource); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); + services.AddModuleDbContext(); + services.AddIdentityAuthorizationReader(); + services.AddIdentityOperationPolicies(); + services.AddScoped(); + services.AddSingleton(provider => new OperationPolicyCatalog(provider.GetServices())); + services.AddLearnStackOperationAuthorization(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddSingleton(new AuditCatalog([new IdentityAuditCatalogSource()])); + services.AddSingleton(); + services.AddSingleton(provider => new InMemoryCacheService(provider.GetRequiredService(), + provider.GetRequiredService())); + services.AddScoped(); + services.AddScoped(); + services.AddLearnStackBackgroundJobContracts(); + services.AddLearnStackMediatRPipeline(typeof(IdentityOperationPolicySource).Assembly); + Assert.Equal(MediatRPipelineRegistration.CanonicalBehaviorOrder, + services.Where(descriptor => descriptor.ServiceType == typeof(IPipelineBehavior<,>)) + .Select(descriptor => descriptor.ImplementationType)); + return new(source, business, candidateSource, services.BuildServiceProvider()); + } + + public async ValueTask DisposeAsync() + { + await _scope.DisposeAsync(); + await Provider.DisposeAsync(); + _candidateSource.Dispose(); + await _businessSource.DisposeAsync(); + await _authorizationSource.DisposeAsync(); + } + } + + private sealed class CandidateSource(TenantContext context) : AuthorizationCandidate.MintingSource, IAuthorizationCandidateSource + { + public AuthorizationCandidate? ForValidatedScope(TenantId tenant, OrganizationId? organization) => + tenant == context.TenantId && organization == context.OrganizationId + ? ForCandidate(AuthorizationSourceKind.Http, context.UserId!.Value, tenant, organization) : null; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OperationReadScopeTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OperationReadScopeTests.cs new file mode 100644 index 000000000..6195add8f --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/OperationReadScopeTests.cs @@ -0,0 +1,338 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class OperationReadScopeTests(SchemaFixture schema) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SQL_and_parameterized_EF_filters_share_the_exact_live_owner_read_scope(bool wide) + { + var context = Context(); + using var admission = new Admission(context); + await using var provider = Provider(context); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + if (wide) await unit.SetTenantContextAsync(context, admission.Witness); + else await unit.SetTenantContextAsync(context); + (await ScalarAsync(unit, "SELECT current_user")).Should().Be("learnstack_app"); + (await ScalarAsync(unit, "SELECT current_setting('app.scope', true)")).Should().Be(wide ? "tenant" : string.Empty); + ((IOperationReadScopeView)unit).IsTenantWideRead.Should().Be(wide); + (await ScalarAsync(unit, "SELECT count(*) FROM tenant_settings")).Should().Be(wide ? 3L : 2L); + var db = scope.ServiceProvider.GetRequiredService(); + db.CurrentTenantWideRead.Should().Be(wide); + db.TenantSettings.ToQueryString().Should().Contain("CurrentTenantWideRead", + "the model must retain the context-instance parameter rather than bake in the first scope"); + var rows = await db.TenantSettings.AsNoTracking().Select(row => new { row.TenantId, row.OrganizationId }).ToListAsync(); + rows.Should().HaveCount(wide ? 3 : 2); + rows.Should().OnlyContain(row => row.TenantId == context.TenantId); + rows.Any(row => row.OrganizationId == OrganizationId.From(SchemaFixture.OrgA2)).Should().Be(wide); + await frame.CompleteAsync(); + db.CurrentTenantWideRead.Should().BeFalse(); + } + + [Fact] + public async Task A_context_without_the_common_owner_attachment_remains_narrow_even_when_SQL_is_wide() + { + var context = Context(); + using var admission = new Admission(context); + await using var source = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + await using var unit = new NpgsqlUnitOfWork(source, NullLogger.Instance); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context, admission.Witness); + var options = new DbContextOptionsBuilder().UseNpgsql(unit.Connection) + .AddInterceptors(new TenantContextGuardInterceptor(unit)).Options; + await using var db = new TenancyDbContext(options, new StaticTenantContextAccessor(context)); + await db.Database.UseTransactionAsync(unit.Transaction); + (await ScalarAsync(unit, "SELECT count(*) FROM tenant_settings")).Should().Be(3L); + db.CurrentTenantWideRead.Should().BeFalse(); + (await db.TenantSettings.CountAsync()).Should().Be(2); + } + + [Theory] + [InlineData("actor")] + [InlineData("tenant")] + [InlineData("organization")] + [InlineData("missing")] + [InlineData("expired")] + public async Task A_missing_expired_or_mismatched_witness_poison_the_actual_owner(string mismatch) + { + var context = Context(); + using var admission = new Admission(context); + var supplied = mismatch switch + { + "actor" => context with { Actor = UserId.From(IdentityMembershipFixture.PersonA) }, + "tenant" => context with { TenantId = TenantId.From(SchemaFixture.TenantB) }, + "organization" => context with { OrganizationId = OrganizationId.From(SchemaFixture.OrgA2) }, + _ => context, + }; + if (mismatch == "expired") admission.Dispose(); + await using var provider = Provider(context); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + var announce = () => unit.SetTenantContextAsync(supplied, mismatch == "missing" ? null! : admission.Witness); + await announce.Should().ThrowAsync(); + unit.IsRollbackOnly.Should().BeTrue(); + ((IOperationReadScopeView)unit).IsTenantWideRead.Should().BeFalse(); + await FluentActions.Awaiting(() => frame.CompleteAsync()).Should().ThrowAsync(); + unit.HasActiveTransaction.Should().BeFalse(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Both_mixed_read_scope_joins_poison_outer_commit_even_after_inner_failure_is_absorbed(bool outerWide) + { + var context = Context(); + using var outerAdmission = new Admission(context); + using var innerAdmission = new Admission(context); + await using var provider = Provider(context); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var outer = await unit.BeginTransactionAsync(); + if (outerWide) await unit.SetTenantContextAsync(context, outerAdmission.Witness); + else await unit.SetTenantContextAsync(context); + await using var inner = await unit.BeginTransactionAsync(); + Func announce = outerWide ? () => unit.SetTenantContextAsync(context) + : () => unit.SetTenantContextAsync(context, innerAdmission.Witness); + await announce.Should().ThrowAsync().WithMessage("*read scope*rollback-only*"); + await inner.FailAsync(); + unit.IsRollbackOnly.Should().BeTrue(); + ((IOperationReadScopeView)unit).IsTenantWideRead.Should().BeFalse(); + await FluentActions.Awaiting(() => outer.CompleteAsync()).Should().ThrowAsync(); + unit.HasActiveTransaction.Should().BeFalse(); + } + + [Fact] + public async Task Compatible_wide_join_consumes_its_own_witness_and_restores_the_live_outer_frame() + { + var context = Context(); + using var outerAdmission = new Admission(context); + using var innerAdmission = new Admission(context); + await using var provider = Provider(context); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var outer = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context, outerAdmission.Witness); + var announcement = unit.GetTenantContextAnnouncement(unit.Transaction); + await using (var inner = await unit.BeginTransactionAsync()) + { + await unit.SetTenantContextAsync(context, innerAdmission.Witness); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeSameAs(announcement); + (await ScalarAsync(unit, "SELECT count(*) FROM tenant_settings")).Should().Be(3L); + await inner.CompleteAsync(); + } + innerAdmission.Dispose(); + ((IOperationReadScopeView)unit).IsTenantWideRead.Should().BeTrue(); + (await ScalarAsync(unit, "SELECT count(*) FROM tenant_settings")).Should().Be(3L); + await outer.CompleteAsync(); + } + + [Fact] + public async Task An_unbound_inner_frame_cannot_borrow_the_wide_outer_frames_read_authority() + { + var context = Context(); + using var admission = new Admission(context); + await using var provider = Provider(context); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var outer = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context, admission.Witness); + await using var inner = await unit.BeginTransactionAsync(); + var borrow = () => unit.Connection; + borrow.Should().Throw(); + ((IOperationReadScopeView)unit).IsTenantWideRead.Should().BeFalse(); + await inner.FailAsync(); + await FluentActions.Awaiting(() => outer.CompleteAsync()).Should().ThrowAsync(); + unit.HasActiveTransaction.Should().BeFalse(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_consumed_witness_cannot_rebind_to_another_unit_or_recycled_transaction_generation(bool sameUnit) + { + var context = Context(); + using var admission = new Admission(context); + await using var provider = Provider(context); + await using var firstScope = provider.CreateAsyncScope(); + var first = firstScope.ServiceProvider.GetRequiredService(); + await using (var frame = await first.BeginTransactionAsync()) + { + await first.SetTenantContextAsync(context, admission.Witness); + await frame.CompleteAsync(); + } + admission.Witness.IsActive.Should().BeTrue("liveness does not renew a consumed binding"); + await using var secondScope = provider.CreateAsyncScope(); + var next = sameUnit ? first : secondScope.ServiceProvider.GetRequiredService(); + await using var nextFrame = await next.BeginTransactionAsync(); + await FluentActions.Awaiting(() => next.SetTenantContextAsync(context, admission.Witness)) + .Should().ThrowAsync(); + next.IsRollbackOnly.Should().BeTrue(); + await nextFrame.FailAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Expired_or_canceled_invocation_invalidates_raw_unit_access_EF_and_commit_before_cleanup(bool cancel) + { + var context = Context(); + using var cancellation = new CancellationTokenSource(); + using var admission = new Admission(context, cancellation.Token); + await using var provider = Provider(context); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context, admission.Witness); + var transaction = unit.Transaction; + var db = scope.ServiceProvider.GetRequiredService(); + (await db.TenantSettings.CountAsync()).Should().Be(3); + var retainedQuery = db.TenantSettings.AsNoTracking(); + if (cancel) await cancellation.CancelAsync(); + else admission.Dispose(); + db.CurrentTenantWideRead.Should().BeFalse(); + unit.GetTenantContextAnnouncement(transaction).Should().BeNull(); + var raw = () => unit.Connection; + raw.Should().Throw(); + await FluentActions.Awaiting(() => retainedQuery.ToListAsync()).Should().ThrowAsync(); + await FluentActions.Awaiting(() => frame.CompleteAsync()).Should().ThrowAsync(); + unit.HasActiveTransaction.Should().BeFalse(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Actual_completion_and_rollback_clear_read_authority_on_the_same_unreset_pooled_connector(bool commit) + { + var context = Context(); + using var admission = new Admission(context); + var parsed = new NpgsqlConnectionStringBuilder(schema.Postgres.AppConnectionString) { MaxPoolSize = 1, NoResetOnClose = true }; + await using var source = ApplicationDataSource.Build(parsed.ConnectionString); + int backend; + await using (var first = new NpgsqlUnitOfWork(source, NullLogger.Instance)) + { + await using var frame = await first.BeginTransactionAsync(); + await first.SetTenantContextAsync(context, admission.Witness); + backend = ((NpgsqlConnection)first.Connection).ProcessID; + (await ScalarAsync(first, "SELECT current_setting('app.scope', true)")).Should().Be("tenant"); + if (commit) await frame.CompleteAsync(); + else await frame.FailAsync(); + first.IsTenantWideRead.Should().BeFalse(); + } + await using var next = new NpgsqlUnitOfWork(source, NullLogger.Instance); + await using var nextFrame = await next.BeginTransactionAsync(); + ((NpgsqlConnection)next.Connection).ProcessID.Should().Be(backend); + (await ScalarAsync(next, "SELECT NULLIF(current_setting('app.scope', true), '')")).Should().Be(DBNull.Value); + await ScalarAsync(next, "SELECT set_config('app.scope', 'tenant', false)"); + await next.SetTenantContextAsync(context); + (await ScalarAsync(next, "SELECT current_setting('app.scope', true)")).Should().Be(string.Empty, + "the ordinary announcement actively clears even a planted session-scoped hatch"); + (await ScalarAsync(next, "SELECT count(*) FROM tenant_settings")).Should().Be(2L); + } + + [Fact] + public async Task A_tenant_read_hatch_does_not_widen_sibling_organization_updates_or_deletes() + { + var context = Context(); + using var admission = new Admission(context); + await using var provider = Provider(context); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context, admission.Witness); + (await ScalarAsync(unit, "SELECT count(*) FROM tenant_settings")).Should().Be(3L); + foreach (var verb in new[] { "UPDATE tenant_settings SET value = value", "DELETE FROM tenant_settings" }) + { + await using var sibling = new NpgsqlCommand(verb + " WHERE organization_id = @organization", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction); + sibling.Parameters.AddWithValue("organization", SchemaFixture.OrgA2); + (await sibling.ExecuteNonQueryAsync()).Should().Be(0); + } + await using var own = new NpgsqlCommand("UPDATE tenant_settings SET value = value WHERE organization_id = @organization", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction); + own.Parameters.AddWithValue("organization", SchemaFixture.OrgA1); + (await own.ExecuteNonQueryAsync()).Should().Be(1, "the positive control retains ordinary branch writes"); + } + + private ServiceProvider Provider(ITenantContext context) + { + var services = new ServiceCollection(); + services.AddSingleton(_ => ApplicationDataSource.Build(schema.Postgres.AppConnectionString)); + services.AddLogging(); + services.AddSingleton(new StaticTenantContextAccessor(context)); + services.AddScoped(); + services.AddModuleDbContext(); + return services.BuildServiceProvider(); + } + + private static async Task ScalarAsync(IUnitOfWork unit, string sql) + { + await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction); + return await command.ExecuteScalarAsync(); + } + + private static ScopeContext Context() => new(TenantId.From(SchemaFixture.TenantA), + OrganizationId.From(SchemaFixture.OrgA1), UserId.From(IdentityMembershipFixture.AdminA)); + + private sealed record ScopeContext(TenantId TenantId, OrganizationId? OrganizationId, UserId Actor) : ITenantContext + { + public bool IsResolved => true; + public UserId? UserId => Actor; + public TenantContextOrigin? Origin => TenantContextOrigin.ClaimAndMembership; + public string? CorrelationId => null; + public string? ModuleName => "tenancy"; + } + + private sealed class Probe; + + private sealed class Admission : OperationReadWitness.MintingSource, IDisposable + { + private readonly CandidateMinter _candidate = new(); + private readonly InvocationMinter _invocation = new(); + internal OperationReadWitness Witness { get; } + internal Admission(ITenantContext context, CancellationToken cancellationToken = default) + { + var candidate = _candidate.Mint(context); + Witness = ForTenantRead(_invocation.Mint(context, candidate, cancellationToken), candidate); + } + public void Dispose() { _invocation.Close(); _candidate.Dispose(); } + } + + private sealed class CandidateMinter : AuthorizationCandidate.MintingSource + { + internal AuthorizationCandidate Mint(ITenantContext context) => ForCandidate(AuthorizationSourceKind.Http, + context.UserId!.Value, context.TenantId, context.OrganizationId); + } + + private sealed class InvocationMinter : OperationInvocation.MintingSource + { + private OperationInvocation? _current; + private bool _closed; + internal OperationInvocation Mint(ITenantContext context, AuthorizationCandidate candidate, CancellationToken cancellationToken) + { + var policy = OperationPolicy.ForProtected("identity.membership.directory", + new PermissionDefinition(PermissionScope.Tenant, "identity.membership.admin"), OperationReadScope.Tenant); + return _current = ForInvocation(new Probe(), policy, context, candidate, null, cancellationToken); + } + internal void Close() => _closed = true; + protected override bool IsCurrent(OperationInvocation invocation) => !_closed && ReferenceEquals(_current, invocation); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs index 4adc553ac..33e2aacf4 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs @@ -11,6 +11,7 @@ using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Tenancy; using MediatR; using Microsoft.AspNetCore.Hosting; @@ -831,6 +832,11 @@ public void Dispose() } } + // Explicit admission for this component mechanism; this is not a live HTTP authorization proof. + private static void AddComponentAuthority(IServiceCollection services) => + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.outbox_setup.provision", AuthorizationSourceKind.Seed)); + private sealed class WorkerApiFactory(WorkerDatabase database, string? credential, OutboxDispatchOptions? bounds = null, TimeProvider? time = null, bool controlPublication = true) : WebApplicationFactory @@ -845,11 +851,14 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.UseSetting("ConnectionStrings:OutboxDispatcher", credential); builder.ConfigureTestServices(services => { + AddComponentAuthority(services); + database.Evidence.AddTo(services); // This fixture owns real producer acceptance, but no business delivery or Identity authority. foreach (var descriptor in services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService) && (descriptor.ImplementationType ?? descriptor.ImplementationFactory?.Method.ReturnType) is { } implementation && (implementation == typeof(DeliveryProcessor) || implementation == typeof(BackgroundJobHostedService) - || implementation == typeof(TenantIdentityAuthorityStartupService))).ToArray()) + || implementation == typeof(TenantIdentityAuthorityStartupService) + || implementation == typeof(TenantAuthorizationSourcesStartupService))).ToArray()) services.Remove(descriptor); services.RemoveAll(); services.AddSingleton(bounds ?? Bounds); @@ -896,6 +905,7 @@ private sealed class WorkerDatabase : IAsyncDisposable private readonly DisposableSchemaDatabase _database; private readonly NpgsqlDataSource _appSource; private readonly NpgsqlDataSource _dispatchSource; + internal ComponentAuthorizationEvidence Evidence { get; private set; } = null!; private WorkerApiFactory? _factory; private HttpClient? _client; private int _insertSequence; @@ -921,6 +931,7 @@ public static async Task CreateAsync(PostgresFixture postgres, b var test = new WorkerDatabase(await DisposableSchemaDatabase.CreateAsync(postgres)); try { + test.Evidence = await ComponentAuthorizationEvidence.CreateAsync(test._database); if (seedTenant) await test.SeedTenantAsync(); return test; } @@ -1110,6 +1121,7 @@ public async ValueTask DisposeAsync() _client?.Dispose(); await _factory.DisposeAsync(); } + if (Evidence is not null) await Evidence.DisposeAsync(); await _dispatchSource.DisposeAsync(); await _appSource.DisposeAsync(); await _database.DisposeAsync(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerHttpTests.cs index 16530ba34..82f708f2b 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerHttpTests.cs @@ -9,6 +9,7 @@ using LearnStack.SharedKernel.Tenancy; using MediatR; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; @@ -33,21 +34,7 @@ public async Task An_HTTP_request_commits_the_enqueuing_trace_announced_scope_an { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); var observation = new HttpProducerObservation(); - await using var host = new ConstructionOnlyApiFactory().WithWebHostBuilder(builder => - { - builder.UseEnvironment(Environments.Development); - builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); - builder.ConfigureTestServices(services => - { - services.AddControllers(options => options.Conventions.Insert(0, - new TestControllerFilter(typeof(OutboxHttpProbeController)))) - .AddApplicationPart(typeof(OutboxHttpProbeController).Assembly); - services.AddSingleton(observation); - services.RemoveAll(); - services.AddScoped(); - services.AddScoped(); - }); - }); + await using var host = new HttpProducerFixture(database.AppConnectionString, observation); using var client = host.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("http://localhost"), @@ -77,6 +64,31 @@ public async Task An_HTTP_request_commits_the_enqueuing_trace_announced_scope_an persisted.TraceId.Should().Be(enqueuing.TraceId).And.Be(http.TraceId).And.Be(incoming.TraceId); } + private sealed class HttpProducerFixture(string connectionString, HttpProducerObservation observation) : ComponentApiFactory + { + protected override void ConfigureComponentSettings(WebApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Configuration["ConnectionStrings:Default"] = connectionString; + } + + protected override void ConfigureTestHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.ConfigureServices(services => + { + services.AddControllers(options => options.Conventions.Insert(0, + new TestControllerFilter(typeof(OutboxHttpProbeController)))) + .AddApplicationPart(typeof(OutboxHttpProbeController).Assembly); + services.AddSingleton(observation); + services.RemoveAll(); + services.AddScoped(); + services.AddScoped(); + ComponentDatabaseOperations.AddProvisioningOperation(services); + }); + } + } + private sealed class HttpObservedOutbox( PostgresOutbox actual, IUnitOfWork unit, HttpProducerObservation observation) : IOutbox { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs index ce01d6805..12cbd7461 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs @@ -16,10 +16,10 @@ using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Time; -using LearnStack.Tools.Seeder; using MediatR; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -31,7 +31,8 @@ namespace LearnStack.Tests.Integration.Database; /// -/// Immediate production through the real application role and production DI roots. +/// Immediate production through the real application role and persistence registrations. +/// The one nested command has exact component authority, not a human permission grant. /// Committing or deliberately breaking a database control uses an isolated database; /// shared-schema cases roll back every successful INSERT. /// @@ -751,8 +752,14 @@ public async Task The_complete_canonical_UTF8_payload_has_an_inclusive_256_KiB_l AssertClean(services); } - private static ServiceProvider Compose(NpgsqlDataSource dataSource, ITenantContext? context = null) => - SeedComposition.Build(dataSource, context ?? new ActingContext(), NullLoggerFactory.Instance); + private static ServiceProvider Compose(NpgsqlDataSource dataSource, ITenantContext? context = null) + { + var services = ComponentOperationRegistration.CreateDatabaseServices(dataSource, context ?? new ActingContext()); + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted( + "tests.component.outbox.create_organization", AuthorizationSourceKind.Seed)); + return services.BuildServiceProvider(); + } private static Task> RunAsync( IServiceProvider services, Func>> body, bool audited = false, diff --git a/backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs index f52d9017a..9dec3b02b 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs @@ -57,6 +57,9 @@ INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row } var sender = scope.ServiceProvider.GetRequiredService(); + await SchemaQueries.AssertApplicationRoleAsync(unit.Connection); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().NotBeNull( + "the finite seed component reaches the actual app-role SQL announcement"); var reader = scope.ServiceProvider.GetRequiredService(); var first = Guid.CreateVersion7(); var next = Guid.CreateVersion7(); @@ -292,7 +295,9 @@ private sealed class Context(Guid tenantId, Guid? organizationId = null) : ITena public OrganizationId? OrganizationId => organizationId is { } value ? LearnStack.SharedKernel.Identifiers.OrganizationId.From(value) : null; public UserId? UserId => null; - public TenantContextOrigin? Origin => TenantContextOrigin.HostAndClaim; + // These are finite seed/exact-reader components; no HTTP host or claim + // admission proposition is under test here. + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; public string? CorrelationId => null; public string? ModuleName => null; } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs index 659fc5bcc..ce1e1e1bc 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -120,6 +120,9 @@ public sealed class PostgresFixture : IAsyncLifetime /// BYPASSRLS; only the outbox dispatcher's equivalent. public string OutboxConnectionString => For("learnstack_outbox_admin", OutboxPassword); + // An owned auxiliary fixture may attach this database to its disposable Docker network. + internal string ContainerId => _container.Id; + internal async Task PublishedPortsAreLoopbackAsync() { using var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()).Build(); @@ -163,6 +166,38 @@ public async Task InitializeAsync() public async Task DisposeAsync() => await _container.DisposeAsync(); + // A positive LOCK acknowledgement, followed by caller-controlled release. + // The superuser connection stays private and the task joins actual rollback/disposal. + internal async Task HoldRoleCatalogLockAsync(TaskCompletionSource acquired, Task release) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var connectionString = new NpgsqlConnectionStringBuilder(_container.GetConnectionString()) + { + Pooling = false, + Enlist = false, + Timeout = 30, + CommandTimeout = 30, + CancellationTimeout = 2000, + }.ConnectionString; + try + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(deadline.Token); + await using var transaction = await connection.BeginTransactionAsync(deadline.Token); + await using (var command = new NpgsqlCommand( + "LOCK TABLE pg_catalog.pg_authid IN ACCESS EXCLUSIVE MODE", connection, transaction)) + await command.ExecuteNonQueryAsync(deadline.Token); + acquired.TrySetResult(connection.ProcessID); + await release.WaitAsync(deadline.Token); + } + catch (Exception exception) when (exception is NpgsqlException or OperationCanceledException) + { + var failure = new InvalidOperationException("Role-catalog lock control failed."); + acquired.TrySetException(failure); + throw failure; + } + } + // Outage qualification creates its own local fixture. Never call these on // SchemaFixture.Postgres: pausing that shared container would affect other proofs. internal Task PauseOwnedServerAsync(CancellationToken cancellationToken = default) => diff --git a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs index e7ee33c73..b10aac810 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs @@ -14,10 +14,10 @@ using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Time; -using LearnStack.Tools.Seeder; using MediatR; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -30,7 +30,8 @@ namespace LearnStack.Tests.Integration.Database; /// /// ADR-0057's shared execution boundary, including real outbox receipts across -/// direct and nested invocations. Domain save-promotion proofs live in +/// direct and nested invocations. Nested commands use explicit test component +/// authority; this is not a human permission proof. Domain save-promotion proofs live in /// DomainEventProductionTests. /// [Trait(RequiresDocker.Key, RequiresDocker.Value)] @@ -455,8 +456,11 @@ public async Task Both_compositions_reconcile_an_independent_scope_inside_anothe await using var host = new ConstructionOnlyApiFactory().WithWebHostBuilder(builder => { builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); - builder.ConfigureServices(services => services.AddSingleton( - new StaticTenantContextAccessor(TenantA))); + builder.ConfigureServices(services => + { + services.AddSingleton(new StaticTenantContextAccessor(TenantA)); + AddNestedComponentAuthority(services); + }); }); var root = api ? host.Services : seed; await using var outer = root.CreateAsyncScope(); @@ -586,8 +590,17 @@ await DirectAudit(services).ExecuteAsync(typeof(OffHandler), null, } } - private static ServiceProvider Compose(NpgsqlDataSource dataSource) => - SeedComposition.Build(dataSource, TenantA, NullLoggerFactory.Instance); + private static ServiceProvider Compose(NpgsqlDataSource dataSource) + { + var services = ComponentOperationRegistration.CreateDatabaseServices(dataSource, TenantA); + AddNestedComponentAuthority(services); + return services.BuildServiceProvider(); + } + + private static void AddNestedComponentAuthority(IServiceCollection services) => + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted( + "tests.component.producer.create_organization", AuthorizationSourceKind.Seed)); private static AuditExecutionCoordinator DirectAudit(IServiceProvider services, IAuditStateCapture? capture = null) => new(new ProbeCatalog(services.GetRequiredService()), diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs index 292ebc791..55b9352e3 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs @@ -50,6 +50,7 @@ private async Task AssertLauncherAsync(bool useDemoRuntime, bool isolateProfile File.WriteAllText(Path.Combine(temporary, "package.json"), "{\"type\":\"module\"}"); File.Copy(Path.Combine(repository, "scripts/public-local.mjs"), Path.Combine(temporary, "scripts/public-local.mjs")); File.Copy(Path.Combine(repository, "scripts/public-runtime.mjs"), Path.Combine(temporary, "scripts/public-runtime.mjs")); + File.Copy(Path.Combine(repository, "scripts/demo-database.mjs"), Path.Combine(temporary, "scripts/demo-database.mjs")); if (!isolateProfile) { // Deliberately remove only the profile boundary in the owned copy. diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs index db972ad65..9d29c639e 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs @@ -14,6 +14,7 @@ using LearnStack.Modules.Tenancy.Infrastructure.Persistence; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using LearnStack.Tools.Seeder; @@ -50,8 +51,9 @@ public sealed class PublicLocaleEvolutionHttpTests(SchemaFixture schema, Constru public async Task Third_locale_authored_through_api_commands_serves_exact_new_urls_without_changing_existing_responses_or_migrations() { await using var database = await SeededDatabaseAsync(); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); var observation = new PublicReadObservation(); - await using var host = ApiHost(database, observation); + await using var host = ApiHost(database, evidence, observation); using var client = Client(host, Yoga.Host); var context = new SeedTenantContext(Yoga.TenantId, null); var organizationContext = new SeedTenantContext(Yoga.TenantId, Yoga.DefaultOrganization.OrganizationId); @@ -156,8 +158,9 @@ public async Task Third_locale_authored_through_api_commands_serves_exact_new_ur public async Task Organization_branding_writer_is_refused_with_denied_audit_and_mapped_host_keeps_tenant_palette() { await using var database = await SeededDatabaseAsync(); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); var observation = new PublicReadObservation(); - await using var host = ApiHost(database, observation); + await using var host = ApiHost(database, evidence, observation); using var client = Client(host, Yoga.Host); var context = new SeedTenantContext(Yoga.TenantId, Yoga.DefaultOrganization.OrganizationId); var before = await JsonAsync(client, Site); @@ -169,6 +172,8 @@ public async Task Organization_branding_writer_is_refused_with_denied_audit_and_ JsonNode.DeepEquals(before["theme"], JsonNode.Parse(settingBefore.Value)).Should().BeTrue(); var countsBefore = await CountsAsync(database.AppConnectionString, context); var deniedBefore = await BrandingDenialsAsync(database.AppConnectionString, context); + await using var evidenceReads = NpgsqlDataSource.Create(database.AppConnectionString); + var authorizationBefore = await ComponentAuthorizationEvidence.CountDenialsAsync(evidenceReads, context, "tests.component.locale.branding"); await using (var scope = host.Services.CreateAsyncScope()) { var accessor = scope.ServiceProvider.GetRequiredService(); @@ -189,8 +194,9 @@ public async Task Organization_branding_writer_is_refused_with_denied_audit_and_ var countsAfter = await CountsAsync(database.AppConnectionString, context); countsAfter.Settings.Should().Be(countsBefore.Settings); countsAfter.SuccessfulBrandingAudits.Should().Be(countsBefore.SuccessfulBrandingAudits); - countsAfter.Audits.Should().Be(countsBefore.Audits + 1, "the normal MUST audit pipeline retains the denied authoring attempt"); + countsAfter.Audits.Should().Be(countsBefore.Audits + 2, "the normal resource audit and independent central MUST denial both persist"); (await BrandingDenialsAsync(database.AppConnectionString, context)).Should().Be(deniedBefore + 1); + (await ComponentAuthorizationEvidence.CountDenialsAsync(evidenceReads, context, "tests.component.locale.branding")).Should().Be(authorizationBefore + 1); observation.Reads.Should().BeGreaterThan(0); } @@ -198,9 +204,10 @@ public async Task Organization_branding_writer_is_refused_with_denied_audit_and_ public async Task Public_host_locale_and_organization_contracts_hold_together_with_exact_request_connection_translation_scope() { await using var database = await SeededDatabaseAsync(); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); var observation = new PublicReadObservation(); var scopes = new TranslationScopeObservation(); - await using var host = ApiHost(database, observation, scopes); + await using var host = ApiHost(database, evidence, observation, scopes); using var client = Client(host, Yoga.Host); var context = new SeedTenantContext(Yoga.TenantId, null); const string tenantHost = "yoga-tenant-scope.example.test"; @@ -294,13 +301,36 @@ private async Task SeededDatabaseAsync() } } - private WebApplicationFactory ApiHost(DisposableSchemaDatabase database, PublicReadObservation observation, + // Explicit admission for this component mechanism; this is not a live HTTP authorization proof. + private static void AddComponentAuthority(IServiceCollection services) => + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.locale.read_tenant", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.read_course", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.read_lesson", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.read_host", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.read_setting", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.add_locale", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.create_course", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.translate_course", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.publish_course", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.create_lesson", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.translate_lesson", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.publish_lesson", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.map_host", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.locale.branding", AuthorizationSourceKind.Seed)); + + private WebApplicationFactory ApiHost(DisposableSchemaDatabase database, ComponentAuthorizationEvidence evidence, PublicReadObservation observation, TranslationScopeObservation? scopes = null) => factory.WithWebHostBuilder(builder => builder .UseEnvironment(Environments.Development) .UseSetting("ConnectionStrings:Default", database.AppConnectionString) .UseSetting("ConnectionStrings:PlatformAdmin", database.PlatformConnectionString) .ConfigureTestServices(services => + { + AddComponentAuthority(services); + evidence.AddTo(services); + }) + .ConfigureTestServices(services => { services.AddSingleton(observation); services.AddHttpContextAccessor(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/RoleGrantCommandIntegrationTests.Registry.cs b/backend/tests/LearnStack.Tests.Integration/Database/RoleGrantCommandIntegrationTests.Registry.cs new file mode 100644 index 000000000..7b36f56fe --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/RoleGrantCommandIntegrationTests.Registry.cs @@ -0,0 +1,63 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Identity.Application.Contracts.Roles; +using LearnStack.Modules.Identity.Domain; +using LearnStack.SharedKernel.Permissions; +using Xunit; +using static LearnStack.Tests.Integration.Database.IdentityMembershipFixture; + +namespace LearnStack.Tests.Integration.Database; + +public sealed partial class RoleGrantCommandIntegrationTests +{ + [Theory] + [InlineData(PermissionScope.Tenant, "identity.membership.admin")] + [InlineData(PermissionScope.Organization, "identity.membership.admin")] + [InlineData(PermissionScope.Tenant, "identity.role.admin")] + [InlineData(PermissionScope.Tenant, "identity.membership_profile.write")] + [InlineData(PermissionScope.Organization, "identity.membership_profile.write")] + public async Task Every_assignable_pair_requires_a_legal_delegation_target_with_committed_denial_and_no_refused_mutation( + PermissionScope permissionScope, string permissionKey) + { + var roleAdministration = permissionKey == "identity.role.admin"; + var matchingKind = roleAdministration ? BuiltInRoleKey.TenantAdmin + : permissionScope == PermissionScope.Tenant ? BuiltInRoleKey.Instructor : BuiltInRoleKey.OrgInstructor; + var oppositeScope = roleAdministration ? PermissionScope.Tenant + : permissionScope == PermissionScope.Tenant ? PermissionScope.Organization : PermissionScope.Tenant; + var oppositeKind = oppositeScope == PermissionScope.Tenant ? BuiltInRoleKey.Instructor : BuiltInRoleKey.OrgInstructor; + var matchingRole = RoleId(SchemaFixture.TenantA, matchingKind, permissionScope); + var oppositeRole = RoleId(SchemaFixture.TenantA, oppositeKind, oppositeScope); + var pair = new PermissionDefinition(permissionScope, permissionKey); + // Role administration is confined to TenantAdmin and its inseparable kernel. + // Its positive mutation removes the optional profile grant while retaining that kernel. + ImmutableArray desired = roleAdministration ? IdentityFoundationManifest.Version1.TenantAdminKernel : [pair]; + var correlation = "registry-delegation-" + Guid.NewGuid().ToString("N"); + try + { + var matchingBefore = await ReadRoleAsync(matchingRole); + var positive = await SendAsync(new ChangeRoleGrantsCommand(matchingRole, matchingBefore.Version, desired), correlation + "-positive"); + Assert.True(positive.IsSuccess); + var matchingAfter = await ReadRoleAsync(matchingRole); + Assert.Equal(matchingBefore.Version + 1, matchingAfter.Version); + Assert.Equal(desired.Select(definition => definition.Scope + ":" + definition.Key).Order(StringComparer.Ordinal), matchingAfter.Grants); + Assert.Equal("success", (await AuditAsync(correlation + "-positive", "identity.role.change_grants")).Outcome); + + // This checks actual target-pair delegation, including the RoleAdmin template restriction. + // It does not invent an underprivileged administrator or a future profile editing route. + var before = await ReadRoleAsync(oppositeRole); + var refused = await SendAsync(new ChangeRoleGrantsCommand(oppositeRole, before.Version, [pair]), correlation); + Assert.Equal("forbidden", refused.Error!.Code); + var after = await ReadRoleAsync(oppositeRole); + Assert.Equal(before.Version, after.Version); + Assert.Equal(before.Grants, after.Grants); + var denial = await AuditAsync(correlation, "identity.authorization.deny"); + Assert.Equal("denied", denial.Outcome); + using var metadata = System.Text.Json.JsonDocument.Parse(denial.Metadata); + Assert.Equal("delegation", metadata.RootElement.GetProperty("denial_reason").GetString()); + } + finally + { + await RestoreAsync(matchingKind, permissionScope); + await RestoreAsync(oppositeKind, oppositeScope); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/RoleGrantCommandIntegrationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/RoleGrantCommandIntegrationTests.cs new file mode 100644 index 000000000..a84fee966 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/RoleGrantCommandIntegrationTests.cs @@ -0,0 +1,320 @@ +using System.Text.Json; +using LearnStack.Modules.Identity.Application.Contracts.Roles; +using LearnStack.Modules.Identity.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Results; +using MediatR; +using Npgsql; +using Xunit; +using static LearnStack.Tests.Integration.Database.IdentityMembershipFixture; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed partial class RoleGrantCommandIntegrationTests(IdentityAuthorizationReaderFixture fixture) + : IClassFixture +{ + [Fact] + public async Task Actual_orgless_admin_authors_Organization_template_under_Tenant_ceiling_with_one_MUST_Role_diff() + { + var role = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.OrgInstructor, PermissionScope.Organization); + const string Correlation = "grant-org-authoring"; + try + { + await using var harness = await CreateAsync(Correlation); + var result = await harness.Sender.Send(new ChangeRoleGrantsCommand(role, 0, + [new(PermissionScope.Organization, IdentityFoundationManifest.MembershipAdmin)])); + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value); + var graph = await ReadRoleAsync(role); + Assert.Equal(1, graph.Version); + Assert.Equal(["Organization:identity.membership.admin"], graph.Grants); + var audit = await AuditAsync(Correlation, "identity.role.change_grants"); + Assert.Equal("success", audit.Outcome); + Assert.Equal("Must:SecurityEvent:Role:" + role.ToString("D"), audit.Classification); + using var changes = JsonDocument.Parse(audit.Changes); + var grant = Assert.Single(changes.RootElement.EnumerateArray(), field => field.GetProperty("path").GetString() + == "/Role/" + role.ToString("D") + "/Grants/Organization~1identity.membership.admin/PermissionKey"); + Assert.Equal(JsonValueKind.Null, grant.GetProperty("before").ValueKind); + Assert.Equal(IdentityFoundationManifest.MembershipAdmin, grant.GetProperty("after").GetString()); + using var metadata = JsonDocument.Parse(audit.Metadata); + var authority = metadata.RootElement.GetProperty("authorization"); + Assert.Equal("identity.role.change_grants", authority.GetProperty("policy").GetString()); + Assert.Equal(AdminMembershipA.ToString("D"), Assert.Single(authority.GetProperty("memberships").EnumerateArray()).GetString()); + Assert.Equal(RoleId(SchemaFixture.TenantA, BuiltInRoleKey.TenantAdmin, PermissionScope.Tenant).ToString("D"), + Assert.Single(authority.GetProperty("roles").EnumerateArray()).GetString()); + Assert.DoesNotContain("provider_subject", audit.Metadata, StringComparison.Ordinal); + } + finally { await RestoreAsync(BuiltInRoleKey.OrgInstructor, PermissionScope.Organization); } + } + + [Fact] + public async Task Organization_actor_and_orgless_reissue_never_promote_Organization_grants_to_Tenant_role_administration() + { + var member = await fixture.AddMemberAsync(SchemaFixture.OrgA1, BuiltInRoleKey.OrgAdmin); + var role = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.Instructor, PermissionScope.Tenant); + await using var branch = await CreateAsync("grant-org-admin-branch", member.Actor, SchemaFixture.OrgA1); + var request = new ChangeRoleGrantsCommand(role, 0, []); + Assert.Equal("forbidden", (await branch.Sender.Send(request)).Error!.Code); + Assert.False(branch.Unit.HasActiveTransaction); + await using var orgless = await CreateAsync("grant-org-admin-orgless", member.Actor); + Assert.Equal("not_found", (await orgless.Sender.Send(request)).Error!.Code); + Assert.False(orgless.Unit.HasActiveTransaction); + Assert.Equal(0, (await ReadRoleAsync(role)).Version); + } + + [Fact] + public async Task Foreign_target_and_wrong_expected_version_are_refused_without_changes() + { + var foreign = RoleId(SchemaFixture.TenantB, BuiltInRoleKey.Instructor, PermissionScope.Tenant); + Assert.Equal("not_found", (await SendAsync(new ChangeRoleGrantsCommand(foreign, 0, []), "grant-foreign-target")).Error!.Code); + var local = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.Instructor, PermissionScope.Tenant); + Assert.Equal("concurrency_conflict", (await SendAsync(new ChangeRoleGrantsCommand(local, 99, []), "grant-target-version")).Error!.Code); + Assert.Equal(0, (await ReadRoleAsync(local)).Version); + } + + [Fact] + public async Task Removed_profile_authority_cannot_self_restore_through_change_or_v1_adoption_and_kernel_cannot_be_removed() + { + var role = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.TenantAdmin, PermissionScope.Tenant); + var secondAdministrator = await AddSecondAdministratorAsync(); + try + { + var kernel = IdentityFoundationManifest.Version1.TenantAdminKernel; + Assert.True((await SendAsync(new ChangeRoleGrantsCommand(role, 0, kernel), "grant-profile-removal")).IsSuccess); + var full = IdentityFoundationManifest.Version1.FindTemplate(BuiltInRoleKey.TenantAdmin, PermissionScope.Tenant)!.Grants; + Assert.Equal("forbidden", (await SendAsync(new ChangeRoleGrantsCommand(role, 1, full), "grant-self-restore")).Error!.Code); + Assert.Equal("forbidden", (await SendAsync(new AdoptRoleGrantManifestCommand(role, 1, 1, + IdentityFoundationManifest.Version1.Fingerprint), "grant-self-adopt")).Error!.Code); + Assert.Equal("forbidden", (await SendAsync(new ChangeRoleGrantsCommand(role, 1, []), "grant-kernel-removal")).Error!.Code); + Assert.Equal(1, (await ReadRoleAsync(role)).Version); + Assert.Equal(kernel.Select(definition => definition.Scope + ":" + definition.Key).Order(StringComparer.Ordinal), + (await ReadRoleAsync(role)).Grants); + } + finally + { + await RestoreAsync(BuiltInRoleKey.TenantAdmin, PermissionScope.Tenant); + await ChangeAdministratorAsync(secondAdministrator, "Revoked"); + } + } + + [Fact] + public async Task Finite_v1_adoption_is_a_separate_audited_act_and_unknown_manifest_never_repairs_the_target() + { + var role = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.Learner, PermissionScope.Organization); + try + { + Assert.True((await SendAsync(new ChangeRoleGrantsCommand(role, 0, []), "grant-before-adoption")).IsSuccess); + Assert.Equal("forbidden", (await SendAsync(new AdoptRoleGrantManifestCommand(role, 1, 2, + IdentityFoundationManifest.Version1.Fingerprint), "grant-unknown-manifest")).Error!.Code); + Assert.Empty((await ReadRoleAsync(role)).Grants); + var adopted = await SendAsync(new AdoptRoleGrantManifestCommand(role, 1, 1, IdentityFoundationManifest.Version1.Fingerprint), "grant-finite-adoption"); + Assert.True(adopted.IsSuccess); + Assert.Equal(2, adopted.Value); + Assert.Equal(["Organization:identity.membership_profile.write"], (await ReadRoleAsync(role)).Grants); + var row = await AuditAsync("grant-finite-adoption", "identity.role.adopt_manifest", "success"); + Assert.Equal("Must:SecurityEvent:Role:" + role.ToString("D"), row.Classification); + } + finally { await RestoreAsync(BuiltInRoleKey.Learner, PermissionScope.Organization); } + } + + [Fact] + public async Task Actual_committed_admin_suspension_during_the_exclusive_lock_wait_refuses_the_postwait_act() + { + var replacement = await AddSecondAdministratorAsync(); + var role = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.TenantAdmin, PermissionScope.Tenant); + try + { + await using var blocker = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var transaction = await blocker.BeginTransactionAsync(); + await ScopeAsync(blocker, transaction, SchemaFixture.TenantA); + await LockAsync(blocker, transaction, SchemaFixture.TenantA, null, true); + await using var harness = await CreateAsync("grant-postwait-revoked"); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var running = harness.Sender.Send(new ChangeRoleGrantsCommand(role, 0, IdentityFoundationManifest.Version1.TenantAdminKernel), deadline.Token); + var committed = false; + try + { + await ObserveTenantWaitAsync(harness.ApplicationName, blocker.ProcessID, running, deadline.Token); + await ExecuteAsync(blocker, transaction, """ + UPDATE memberships SET status='Suspended',row_version=row_version+1 + WHERE id=@member; + SET CONSTRAINTS ALL IMMEDIATE; + """, ("member", AdminMembershipA)); + await transaction.CommitAsync(); + committed = true; + var result = await running; + Assert.Equal("not_found", result.Error!.Code); + Assert.Equal(0, (await ReadRoleAsync(role)).Version); + var denial = await AuditAsync("grant-postwait-revoked", "identity.authorization.deny"); + using var metadata = JsonDocument.Parse(denial.Metadata); + Assert.Empty(metadata.RootElement.GetProperty("authorization").GetProperty("memberships").EnumerateArray()); + Assert.Empty(metadata.RootElement.GetProperty("authorization").GetProperty("roles").EnumerateArray()); + } + finally + { + if (!committed) await transaction.RollbackAsync(); + if (!running.IsCompleted) { deadline.Cancel(); try { await running; } catch (OperationCanceledException) { } } + } + } + finally + { + await ChangeAdministratorAsync(AdminMembershipA, "Active", restoreVersion: true); + await ChangeAdministratorAsync(replacement, "Revoked"); + await RestoreAsync(BuiltInRoleKey.TenantAdmin, PermissionScope.Tenant); + } + } + + [Fact] + public async Task Actual_MUST_failure_rolls_back_the_Role_version_and_contained_grant_without_a_result() + { + const string Correlation = "grant-audit-refused"; + var role = RoleId(SchemaFixture.TenantA, BuiltInRoleKey.Instructor, PermissionScope.Tenant); + await using var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.Membership.Postgres.MigrationConnectionString); + await ExecuteAsync(owner, null, """ + CREATE FUNCTION fn_role_grant_audit_proof() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.operation='identity.role.change_grants' AND NEW.correlation_id='grant-audit-refused' THEN + RAISE EXCEPTION 'fixed grant audit proof refusal' USING ERRCODE='23514'; + END IF; + RETURN NEW; + END $$; + CREATE TRIGGER tg_role_grant_audit_proof BEFORE INSERT ON audit_log + FOR EACH ROW EXECUTE FUNCTION fn_role_grant_audit_proof(); + """); + try + { + await using var harness = await CreateAsync(Correlation); + Result? published = null; + var failure = await Assert.ThrowsAsync(async () => published = await harness.Sender.Send( + new ChangeRoleGrantsCommand(role, 0, [new(PermissionScope.Tenant, IdentityFoundationManifest.ProfileWrite)]))); + Assert.Equal("audit_unavailable", failure.Error.Code); + Assert.Null(published); + var graph = await ReadRoleAsync(role); + Assert.Equal(0, graph.Version); + Assert.Empty(graph.Grants); + } + finally + { + await ExecuteAsync(owner, null, "DROP TRIGGER tg_role_grant_audit_proof ON audit_log; DROP FUNCTION fn_role_grant_audit_proof()"); + await RestoreAsync(BuiltInRoleKey.Instructor, PermissionScope.Tenant); + } + } + + private Task CreateAsync(string correlation, Guid? actor = null, Guid? organization = null) => + RoleGrantPipelineHarness.CreateAsync(fixture.AppConnectionString, Authority, TenantId.From(SchemaFixture.TenantA), + UserId.From(actor ?? AdminA), correlation, organization is { } branch ? OrganizationId.From(branch) : null); + + private async Task> SendAsync(IRequest> request, string correlation) + { + await using var harness = await CreateAsync(correlation); + return await harness.Sender.Send(request); + } + + private async Task<(long Version, string[] Grants)> ReadRoleAsync(Guid id) + { + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await SchemaQueries.AssertApplicationRoleAsync(app); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, SchemaFixture.TenantA); + var version = await ScalarAsync(app, transaction, "SELECT row_version FROM roles WHERE id=@id", ("id", id)); + await using var command = new NpgsqlCommand("SELECT permission_scope||':'||permission_key FROM role_permission_grants WHERE role_id=@id ORDER BY 1", app, transaction); + command.Parameters.AddWithValue("id", id); + await using var rows = await command.ExecuteReaderAsync(); + var grants = new List(); + while (await rows.ReadAsync()) grants.Add(rows.GetString(0)); + return (version, grants.ToArray()); + } + + private async Task RestoreAsync(BuiltInRoleKey key, PermissionScope scope) + { + var template = IdentityFoundationManifest.Version1.FindTemplate(key, scope)!; + var role = RoleId(SchemaFixture.TenantA, key, scope); + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, SchemaFixture.TenantA); + await LockAsync(app, transaction, SchemaFixture.TenantA, null, true); + await ExecuteAsync(app, transaction, "DELETE FROM role_permission_grants WHERE role_id=@role AND NOT(permission_key=ANY(@keys))", + ("role", role), ("keys", template.Grants.Select(definition => definition.Key).ToArray())); + foreach (var definition in template.Grants) + await ExecuteAsync(app, transaction, """ + INSERT INTO role_permission_grants(tenant_id,role_id,permission_scope,permission_key) + VALUES(@tenant,@role,@scope,@key) ON CONFLICT DO NOTHING + """, ("tenant", SchemaFixture.TenantA), ("role", role), ("scope", scope.ToString()), ("key", definition.Key)); + await ExecuteAsync(app, transaction, "UPDATE roles SET row_version=0,updated_at=NULL,updated_by=NULL WHERE id=@role; SET CONSTRAINTS ALL IMMEDIATE", ("role", role)); + await transaction.CommitAsync(); + } + + private async Task<(string Outcome, string Classification, string Changes, string Metadata)> AuditAsync( + string correlation, string operation, string? outcome = null) + { + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, SchemaFixture.TenantA); + await using var command = new NpgsqlCommand(""" + SELECT outcome,operation_class||':'||operation_type||':'||coalesce(entity_type,'')||':'||coalesce(entity_id,''), + coalesce(changes::text,''),metadata::text FROM audit_log + WHERE correlation_id=@correlation AND operation=@operation AND (@outcome IS NULL OR outcome=@outcome) + """, app, transaction); + command.Parameters.AddWithValue("correlation", correlation); + command.Parameters.AddWithValue("operation", operation); + command.Parameters.Add(new NpgsqlParameter("outcome", NpgsqlTypes.NpgsqlDbType.Text) { Value = (object?)outcome ?? DBNull.Value }); + await using var rows = await command.ExecuteReaderAsync(); + Assert.True(await rows.ReadAsync()); + var result = (rows.GetString(0), rows.GetString(1), rows.GetString(2), rows.GetString(3)); + Assert.False(await rows.ReadAsync()); + return result; + } + + private async Task AddSecondAdministratorAsync() + { + var actor = Guid.CreateVersion7(); + var member = Guid.CreateVersion7(); + await InsertUserAsync(fixture.AppConnectionString, actor); + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, SchemaFixture.TenantA); + await LockAsync(app, transaction, SchemaFixture.TenantA, actor, true); + await InsertMemberAsync(app, transaction, SchemaFixture.TenantA, null, member, actor, + RoleId(SchemaFixture.TenantA, BuiltInRoleKey.TenantAdmin, PermissionScope.Tenant)); + await transaction.CommitAsync(); + return member; + } + + private async Task ChangeAdministratorAsync(Guid member, string status, bool restoreVersion = false) + { + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString); + await using var transaction = await app.BeginTransactionAsync(); + await ScopeAsync(app, transaction, SchemaFixture.TenantA); + Guid? user = status == "Active" ? await ScalarAsync(app, transaction, + "SELECT user_id FROM memberships WHERE id=@member", ("member", member)) : null; + await LockAsync(app, transaction, SchemaFixture.TenantA, user, true); + await ExecuteAsync(app, transaction, """ + UPDATE memberships SET status=@status,row_version=CASE WHEN @restore THEN 0 ELSE row_version+1 END, + deleted_at=CASE WHEN @status='Revoked' THEN clock_timestamp() ELSE NULL END, + deleted_by=CASE WHEN @status='Revoked' THEN @actor ELSE NULL END + WHERE id=@member AND (status<>@status OR @restore); + SET CONSTRAINTS ALL IMMEDIATE; + """, ("member", member), ("status", status), ("actor", UserId.SystemActor.Value), ("restore", restoreVersion)); + await transaction.CommitAsync(); + } + + private async Task ObserveTenantWaitAsync(string application, int blocker, Task running, CancellationToken cancellationToken) + { + await using var observer = (NpgsqlConnection)await PostgresFixture.OpenAsync(fixture.AppConnectionString, cancellationToken); + while (true) + { + await using var command = new NpgsqlCommand(""" + SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE application_name=@application + AND @blocker=ANY(pg_blocking_pids(pid)) AND wait_event_type='Lock') + """, observer); + command.Parameters.AddWithValue("application", application); + command.Parameters.AddWithValue("blocker", blocker); + if ((bool)(await command.ExecuteScalarAsync(cancellationToken))!) return; + if (running.IsCompleted) { await running; Assert.Fail("The real Role mutation did not reach the observed Tenant lock wait."); } + await Task.Delay(20, cancellationToken); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/RoleGrantPipelineHarness.cs b/backend/tests/LearnStack.Tests.Integration/Database/RoleGrantPipelineHarness.cs new file mode 100644 index 000000000..b07da9a1e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/RoleGrantPipelineHarness.cs @@ -0,0 +1,136 @@ +using System.Diagnostics.Metrics; +using LearnStack.Application.Authorization; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Audit; +using LearnStack.Infrastructure.Audit.Capture; +using LearnStack.Infrastructure.Caching; +using LearnStack.Infrastructure.Jobs; +using LearnStack.Infrastructure.Messaging; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Application; +using LearnStack.Modules.Identity.Application.Audit; +using LearnStack.Modules.Identity.Infrastructure.Authorization; +using LearnStack.Modules.Identity.Infrastructure.Bindings; +using LearnStack.Modules.Identity.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Contracts.IdentityLifecycle; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Identity; +using LearnStack.SharedKernel.Permissions; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// Actual scoped authorization, committed rechecks, audited Role writes and independent evidence source. +internal sealed class RoleGrantPipelineHarness : IAsyncDisposable +{ + private readonly IdentityAuthorizationReadSource _authorization; + private readonly AuthorizationEvidenceSource _evidence; + private readonly NpgsqlDataSource _business; + private readonly CandidateSource _candidate; + private readonly ServiceProvider _provider; + private readonly AsyncServiceScope _scope; + + private RoleGrantPipelineHarness(IdentityAuthorizationReadSource authorization, AuthorizationEvidenceSource evidence, + NpgsqlDataSource business, CandidateSource candidate, ServiceProvider provider, string applicationName) + { + _authorization = authorization; _evidence = evidence; _business = business; _candidate = candidate; + _provider = provider; _scope = provider.CreateAsyncScope(); ApplicationName = applicationName; + } + + public string ApplicationName { get; } + public ISender Sender => _scope.ServiceProvider.GetRequiredService(); + public NpgsqlUnitOfWork Unit => (NpgsqlUnitOfWork)_scope.ServiceProvider.GetRequiredService(); + + public static async Task CreateAsync(string connectionString, IdentityAuthorityRegistration authority, + TenantId tenant, UserId actor, string correlation, OrganizationId? organization = null) + { + var authorization = new IdentityAuthorizationReadSource(connectionString); + var evidence = new AuthorizationEvidenceSource(connectionString); + await authorization.QualifyAsync(authority.AuthorityId, authority.Issuer); + await evidence.QualifyAsync(authority.AuthorityId, authority.Issuer); + var application = "role-grant-proof-" + Guid.NewGuid().ToString("N"); + var business = ApplicationDataSource.Build(new NpgsqlConnectionStringBuilder(connectionString) + { ApplicationName = application }.ConnectionString); + var context = TenantContextFactory.Create(new TenantResolutionAttempt + { + HasValidatedPrincipal = true, + UserId = actor, + HostTenantId = tenant, + ClaimTenantId = tenant, + ClaimOrganizationId = organization, + MembershipCovers = true, + ClaimedOrganizationBelongsToTenant = true, + CorrelationId = correlation, + }).Value; + Assert.NotNull(context); + var candidate = new CandidateSource(context); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMetrics(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(business); + services.AddSingleton(new Lazy(() => business)); + services.AddSingleton(authorization); + services.AddSingleton(evidence); + services.AddSingleton(new StaticTenantContextAccessor(context)); + services.AddTransient(_ => context); + services.AddSingleton(candidate); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); + services.AddModuleDbContext(); + services.AddIdentityContextualFoundation(); + services.AddIdentityAuthorizationReader(); + services.AddIdentityOperationPolicies(); + services.AddScoped(); + services.AddSingleton(provider => new OperationPolicyCatalog(provider.GetServices())); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(provider => new AuditCatalog(provider.GetServices())); + services.AddSingleton(); + services.AddSingleton(provider => new InMemoryCacheService(provider.GetRequiredService(), + provider.GetRequiredService())); + services.AddScoped(); + services.AddScoped(); + services.AddLearnStackBackgroundJobContracts(); + services.AddLearnStackMediatRPipeline(typeof(IdentityOperationPolicySource).Assembly); + services.AddLearnStackOperationAuthorization(); + Assert.Equal(MediatRPipelineRegistration.CanonicalBehaviorOrder, + services.Where(descriptor => descriptor.ServiceType == typeof(IPipelineBehavior<,>)) + .Select(descriptor => descriptor.ImplementationType)); + return new(authorization, evidence, business, candidate, services.BuildServiceProvider(), application); + } + + public async ValueTask DisposeAsync() + { + await _scope.DisposeAsync(); + await _provider.DisposeAsync(); + _candidate.Dispose(); + await _business.DisposeAsync(); + await _authorization.DisposeAsync(); + await _evidence.DisposeAsync(); + } + + private sealed class CandidateSource(TenantContext context) : AuthorizationCandidate.MintingSource, IAuthorizationCandidateSource + { + public AuthorizationCandidate? ForValidatedScope(TenantId tenant, OrganizationId? organization) => + tenant == context.TenantId && organization == context.OrganizationId + ? ForCandidate(AuthorizationSourceKind.Http, context.UserId!.Value, tenant, organization) : null; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SeedAuthorizationActivationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/SeedAuthorizationActivationTests.cs new file mode 100644 index 000000000..dd19fa3cd --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/SeedAuthorizationActivationTests.cs @@ -0,0 +1,84 @@ +using FluentAssertions; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.Tools.Seeder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using MediatR; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// Canonical finite-seed adapter and real independent app-role denial durability; no human grant lookup. +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class SeedAuthorizationActivationTests(SchemaFixture schema) +{ + [Theory] + [InlineData("lockey_forbidden", "forbidden")] + [InlineData("lockey_not_found", "not_found")] + public async Task Canonical_finite_seed_refusal_uses_one_shared_qualified_evidence_lane_across_per_act_providers( + string errorKey, string expectedCode) + { + await using var business = ApplicationDataSource.Build(schema.Postgres.AppConnectionString); + await using var source = await AuthorizationEvidenceFixture.SourceAsync(schema); + var observed = new ObservedFactory(source); + var context = new SeedTenantContext(TenantId.From(SchemaFixture.TenantA), null); + try + { + for (var act = 0; act < 2; act++) + { + await using var provider = SeedComposition.Build(business, context, NullLoggerFactory.Instance, + authorizationEvidence: observed); + await using var scope = provider.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService().Should().BeSameAs(observed); + var behavior = scope.ServiceProvider.GetServices>>>() + .OfType>>>().Single(); + var result = await behavior.Handle(new GetTenantSeedStateQuery(), + () => Task.FromResult(Result.Fail>(new Error(new LocalizedMessage(errorKey)))), + CancellationToken.None); + result.Error!.Code.Should().Be(expectedCode); + source.IsQualified.Should().BeTrue(); + observed.Events.Should().HaveCount(act + 1); + var occurrence = observed.Events[^1]; + occurrence.Draft.ActorUserId.Should().BeNull("finite seed attribution preserves the absent principal"); + occurrence.Draft.Metadata.Should().Contain("\"source\":\"seed\""); + occurrence.Draft.Operation.Should().Be("identity.authorization.deny"); + await using var verifier = (NpgsqlConnection)await PostgresFixture.OpenAsync(schema.Postgres.PlatformConnectionString); + await using var command = new NpgsqlCommand(""" + SELECT outcome, actor_user_id, operation_class, error_key + FROM public.audit_log WHERE id=@id AND timestamp=@timestamp + """, verifier); + command.Parameters.AddWithValue("id", occurrence.Draft.Id.Value); + command.Parameters.AddWithValue("timestamp", occurrence.Draft.Timestamp); + await using var reader = await command.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue("the canonical adapter cannot return its denial before the actual evidence COMMIT acknowledges"); + reader.GetString(0).Should().Be("denied"); + reader.IsDBNull(1).Should().BeTrue(); + reader.GetString(2).Should().Be("Must"); + reader.GetString(3).Should().Be(errorKey); + (await reader.ReadAsync()).Should().BeFalse(); + } + source.IsQualified.Should().BeTrue("disposing either per-act provider cannot close the process-owned lane"); + observed.Events.Select(occurrence => occurrence.Draft.Id).Should().OnlyHaveUniqueItems(); + } + finally { await AuthorizationEvidenceFixture.DeleteAsync(schema, observed.Events.Select(occurrence => occurrence.Draft.Id).ToArray()); } + } + + private sealed class ObservedFactory(IAuthorizationEvidenceSessionFactory source) : IAuthorizationEvidenceSessionFactory + { + internal List Events { get; } = []; + public Task OpenAsync(AuthorizationEvidenceOccurrence occurrence, + CancellationToken cancellationToken = default) + { + Events.Add(occurrence); + return source.OpenAsync(occurrence, cancellationToken); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs index 4de969d80..ed36c3d93 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs @@ -498,6 +498,14 @@ INSERT INTO platform_host_to_tenant [Fact] public async Task Seeder_process_returns_zero_on_repeat_and_nonzero_on_mismatch() { + var authority = IdentityMembershipFixture.Authority; + await using (var owner = new NpgsqlConnection(_database.MigrationConnectionString)) + { + await owner.OpenAsync(); + await LearnStack.Modules.Identity.Infrastructure.Bindings.IdentityAuthorityDeploymentInstaller + .InstallAsync(owner, authority); + } + async Task RunProcess() { var start = new System.Diagnostics.ProcessStartInfo("dotnet") @@ -514,6 +522,13 @@ async Task RunProcess() start.ArgumentList.Add(typeof(SeedRunner).Assembly.Location); // The test-owned password stays in the environment, never argv or a failure message. start.Environment["ConnectionStrings__Default"] = _database.AppConnectionString; + start.Environment["Identity__AuthorityId"] = authority.AuthorityId.Value.ToString("D"); + start.Environment["Authentication__Tenant__Issuer"] = authority.Issuer; + // This fixture owns direct PostgreSQL. Derive both auxiliary lanes from + // its Default rather than inheriting another deployment's endpoints. + start.Environment["Deployment__Mode"] = "Development"; + start.Environment.Remove("ConnectionStrings__IdentityAuthorization"); + start.Environment.Remove("ConnectionStrings__AuthorizationEvidence"); using var process = new System.Diagnostics.Process { StartInfo = start }; process.Start().Should().BeTrue(); var output = process.StandardOutput.ReadToEndAsync(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs index c1d1b1b63..84f0eb10f 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs @@ -11,6 +11,7 @@ using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Secrets; using LearnStack.SharedKernel.Tenancy; @@ -35,6 +36,8 @@ namespace LearnStack.Tests.Integration.Database; public sealed class TenancyWriterTests(SchemaFixture schema, ConstructionOnlyApiFactory factory) : IClassFixture { + private ComponentAuthorizationEvidence _evidence = null!; + private const string Theme = """{"primary":"#2345aa","background":"#ffffff","foreground":"#111111","muted":"#555555"}"""; private const string OtherTheme = """{"primary":"#663399","background":"#ffffff","foreground":"#000000","muted":"#444444"}"""; @@ -45,6 +48,8 @@ public async Task Unknown_unique_constraints_remain_database_faults_and_roll_bac { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var foreign = await ProvisionAsync(source); var initial = await ReadTenantAsync(source, context); @@ -86,6 +91,8 @@ public async Task Locale_commands_promote_first_enabled_then_switch_existing_and { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var before = await ReadTenantAsync(source, context); var disabled = await SendAsync(source, context, new AddTenantLocaleCommand(before.Version, "fr", false, false, 0)); @@ -121,6 +128,8 @@ public async Task Branding_is_create_or_exact_replace_only_and_cannot_touch_fore { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var foreign = await ProvisionAsync(source); var tenantBefore = await ReadTenantAsync(source, context); @@ -152,7 +161,7 @@ public async Task Branding_is_create_or_exact_replace_only_and_cannot_touch_fore JsonNode.DeepEquals(JsonNode.Parse(foreignState.Value), JsonNode.Parse(OtherTheme)).Should().BeTrue(); foreignState.Version.Should().Be(0); - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); @@ -177,6 +186,8 @@ public async Task Branding_refuses_missing_or_deleted_tenants_before_setting_acc { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); (await ReadTenantAsync(source, context)).Status.Should().Be(nameof(TenantStatus.Trial)); var id = Guid.CreateVersion7(); @@ -188,7 +199,7 @@ public async Task Branding_refuses_missing_or_deleted_tenants_before_setting_acc var auditsBefore = await CountSuccessfulWritesAsync(source, context, "tenancy.setting.write"); if (!missingTenant) { - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); @@ -203,6 +214,11 @@ public async Task Branding_refuses_missing_or_deleted_tenants_before_setting_acc var refusedContext = missingTenant ? context with { TenantId = TenantId.From(Guid.CreateVersion7()) } : context; await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) .ConfigureServices(services => services.AddScoped())); await using (var scope = host.Services.CreateAsyncScope()) { @@ -228,11 +244,13 @@ public async Task Whole_json_value_is_redacted_in_the_capture_and_durable_settin { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var id = Guid.CreateVersion7(); (await SendAsync(source, context, new SetTenantBrandingCommand(id, Theme, null))).IsSuccess.Should().BeTrue(); (await SendAsync(source, context, new SetTenantBrandingCommand(id, OtherTheme, 0))).IsSuccess.Should().BeTrue(); - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); @@ -281,6 +299,8 @@ public async Task A_failed_must_audit_rolls_back_the_already_saved_branding_root { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); await using (var owner = new NpgsqlConnection(database.MigrationConnectionString)) { @@ -301,6 +321,8 @@ public async Task Second_default_save_failure_rolls_back_the_first_clear_and_roo { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var initial = await ReadTenantAsync(source, context); var first = await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "tr-TR", true, true, 0)); @@ -308,6 +330,11 @@ public async Task Second_default_save_failure_rolls_back_the_first_clear_and_roo var before = await ReadTenantAsync(source, context); await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) .ConfigureServices(services => services.AddScoped())); await using (var scope = host.Services.CreateAsyncScope()) { @@ -326,12 +353,19 @@ public async Task Concurrent_palette_replacements_cannot_mix_colors_or_commit_tw { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var id = Guid.CreateVersion7(); (await SendAsync(source, context, new SetTenantBrandingCommand(id, Theme, null))).IsSuccess.Should().BeTrue(); var barrier = new ReadBarrier(); await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) .ConfigureServices(services => services.AddScoped(provider => new BarrierSettingStore(new TenantSettingWriteStore(provider.GetRequiredService()), barrier)))); async Task> Replace(string theme) @@ -356,10 +390,17 @@ public async Task Concurrent_palette_creates_leave_one_setting_and_one_successfu { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var barrier = new ReadBarrier(); await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) .ConfigureServices(services => services.AddScoped(provider => new BarrierSettingStore(new TenantSettingWriteStore(provider.GetRequiredService()), barrier)))); async Task> Create(string theme) @@ -393,11 +434,13 @@ public async Task Disabled_legacy_default_is_not_implicitly_repaired_by_either_l } await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var initial = await ReadTenantAsync(source, context); var first = await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "en", true, true, 0)); var second = await SendAsync(source, context, new AddTenantLocaleCommand(first.Value!.Version, "fr", true, false, 1)); - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); @@ -421,10 +464,12 @@ public async Task Enabled_without_default_is_refused_by_writers_before_root_or_n { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var initial = await ReadTenantAsync(source, context); var added = await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "en", true, true, 0)); - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); @@ -449,6 +494,8 @@ public async Task Soft_deleted_tenants_are_not_write_targets_and_live_lookup_kee { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var initial = await ReadTenantAsync(source, context); var added = await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "en", true, true, 0)); @@ -456,7 +503,7 @@ public async Task Soft_deleted_tenants_are_not_write_targets_and_live_lookup_kee var settingId = Guid.CreateVersion7(); (await SendAsync(source, context, new SetTenantBrandingCommand(settingId, Theme, null))).IsSuccess.Should().BeTrue(); var brandingBefore = (await SendAsync(source, context, new GetSettingSeedStateQuery(settingId))).Value!.State; - await using (var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance)) + await using (var provider = BuildProvider(source, context)) await using (var scope = provider.CreateAsyncScope()) { var unit = scope.ServiceProvider.GetRequiredService(); @@ -470,7 +517,7 @@ public async Task Soft_deleted_tenants_are_not_write_targets_and_live_lookup_kee await frame.CompleteAsync(); } long version; - await using (var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance)) + await using (var provider = BuildProvider(source, context)) await using (var scope = provider.CreateAsyncScope()) { var unit = scope.ServiceProvider.GetRequiredService(); @@ -485,7 +532,7 @@ public async Task Soft_deleted_tenants_are_not_write_targets_and_live_lookup_kee version = tenant.Version; await frame.CompleteAsync(); } - await using (var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance)) + await using (var provider = BuildProvider(source, context)) await using (var scope = provider.CreateAsyncScope()) { var unit = scope.ServiceProvider.GetRequiredService(); @@ -510,6 +557,8 @@ public async Task Locale_writers_use_the_current_tenant_and_never_change_foreign { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var foreign = await ProvisionAsync(source); var a = await ReadTenantAsync(source, context); @@ -521,7 +570,7 @@ public async Task Locale_writers_use_the_current_tenant_and_never_change_foreign var added = await SendAsync(source, context, new AddTenantLocaleCommand(own.Version, "fr", true, false, 1)); (await SendAsync(source, context, new SetDefaultTenantLocaleCommand(added.Value!.Version, "fr"))).IsSuccess.Should().BeTrue(); (await ReadTenantAsync(source, foreign)).Should().BeEquivalentTo(foreignBefore); - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); @@ -537,6 +586,8 @@ public async Task Concurrent_locale_changes_commit_only_one_exact_root_version(b { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; var context = await ProvisionAsync(source); var initial = await ReadTenantAsync(source, context); var first = await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "en", true, true, 0)); @@ -550,6 +601,11 @@ public async Task Concurrent_locale_changes_commit_only_one_exact_root_version(b var barrier = new ReadBarrier(); await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }) .ConfigureServices(services => services.AddScoped(provider => new BarrierTenantStore(new TenantWriteStore(provider.GetRequiredService()), barrier)))); async Task> Change(string locale, short sort) @@ -572,7 +628,25 @@ async Task> Change(string locale, short sort) (await CountSuccessfulWritesAsync(source, context, "tenancy.locale.write")).Should().Be(audits + 1); } - private static async Task ProvisionAsync(NpgsqlDataSource source) + private ServiceProvider BuildProvider(NpgsqlDataSource source, ITenantContext? context) + { + var services = ComponentOperationRegistration.CreateDatabaseServices(source, context); + AddComponentAuthority(services); + _evidence.AddTo(services); + return services.BuildServiceProvider(validateScopes: true); + } + + // Explicit admission for this component mechanism; this is not a live HTTP authorization proof. + private static void AddComponentAuthority(IServiceCollection services) => + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.tenancy.branding", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.tenancy.add_locale", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.tenancy.default_locale", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.tenancy.get_setting_seed_state", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.tenancy.get_tenant_seed_state", AuthorizationSourceKind.Seed), + OperationPolicy.ForTrusted("tests.component.tenancy.provision_tenant", AuthorizationSourceKind.Seed)); + + private async Task ProvisionAsync(NpgsqlDataSource source) { var id = TenantId.From(Guid.CreateVersion7()); var organization = OrganizationId.From(Guid.CreateVersion7()); @@ -582,19 +656,19 @@ private static async Task ProvisionAsync(NpgsqlDataSource source) return new Context(id, organization); } - private static async Task> SendAsync(NpgsqlDataSource source, Context? context, IRequest> request) + private async Task> SendAsync(NpgsqlDataSource source, Context? context, IRequest> request) { - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); return await scope.ServiceProvider.GetRequiredService().Send(request); } - private static async Task ReadTenantAsync(NpgsqlDataSource source, Context context) => + private async Task ReadTenantAsync(NpgsqlDataSource source, Context context) => (await SendAsync(source, context, new GetTenantSeedStateQuery())).Value!.State!; - private static async Task CountSuccessfulWritesAsync(NpgsqlDataSource source, Context context, string operation) + private async Task CountSuccessfulWritesAsync(NpgsqlDataSource source, Context context, string operation) { - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); @@ -607,9 +681,9 @@ private static async Task CountSuccessfulWritesAsync(NpgsqlDataSource sour return count; } - private static async Task AssertLocaleAuditAsync(NpgsqlDataSource source, Context context) + private async Task AssertLocaleAuditAsync(NpgsqlDataSource source, Context context) { - await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var provider = BuildProvider(source, context); await using var scope = provider.CreateAsyncScope(); var unit = scope.ServiceProvider.GetRequiredService(); await using var frame = await unit.BeginTransactionAsync(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs index e592eca82..01ac6dec6 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs @@ -10,6 +10,7 @@ using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Permissions; using LearnStack.SharedKernel.Tenancy; using LearnStack.Tools.Seeder; using MediatR; @@ -27,6 +28,8 @@ namespace LearnStack.Tests.Integration.Database; [Collection(SharedSchema.Name)] public sealed class TenantCreatedProducerTests(SchemaFixture schema) { + private ComponentAuthorizationEvidence _evidence = null!; + [Theory] [InlineData(false, false)] [InlineData(true, false)] @@ -43,10 +46,18 @@ public async Task Provisioning_business_outbox_and_both_MUST_rows_share_one_comm } await using var source = NpgsqlDataSource.Create(database.AppConnectionString); - await using var seed = SeedComposition.Build(source, null, NullLoggerFactory.Instance); + await using var evidence = await ComponentAuthorizationEvidence.CreateAsync(database); + _evidence = evidence; + await using var seed = SeedComposition.Build(source, null, NullLoggerFactory.Instance, + authorizationEvidence: evidence.Source); await using var host = new ConstructionOnlyApiFactory().WithWebHostBuilder(builder => { builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); + builder.ConfigureServices(services => + { + AddComponentAuthority(services); + _evidence.AddTo(services); + }); builder.ConfigureServices(services => services.AddSingleton( new StaticTenantContextAccessor(null))); }); @@ -138,6 +149,11 @@ FROM outbox_messages .Should().Be(2L); } + // Explicit admission for this component mechanism; this is not a live HTTP authorization proof. + private static void AddComponentAuthority(IServiceCollection services) => + ComponentOperationRegistration.AddExactComponentAuthority(services, + OperationPolicy.ForTrusted("tests.component.provisioning.provision", AuthorizationSourceKind.Seed)); + private static async Task ScalarAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, string sql) { await using var command = new NpgsqlCommand(sql, connection, transaction); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Diagnostics.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Diagnostics.cs index ebb1b9a2b..305f6d421 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Diagnostics.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Diagnostics.cs @@ -35,7 +35,7 @@ public async Task Value_bearing_binding_read_failure_has_fixed_HTTP_diagnostics_ var payload = JsonSerializer.Serialize(new { value = neutral, items = new[] { new { value = nested, token = sensitive } } }); var canaries = new[] { neutral, nested, sensitive }; await using var issuer = await TenantAuthenticationIssuer.StartAsync(); - await using var database = await IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer, + await using var database = await IdentityPublicReadFixture.CreateWithMembershipsAsync(parent, issuer.Issuer, new Dictionary { [Actor] = payload }); var subject = new GlobalIdentitySubject(database.Registration.AuthorityId, issuer.Issuer, payload); var observation = new BindingObservation(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Resources.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Resources.cs index 80086c384..23f905210 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Resources.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Resources.cs @@ -153,7 +153,7 @@ public async Task Small_real_HTTP_sample_reports_lookup_plan_statement_cost_and_ output.WriteLine("Local observational sample. The authenticated current-code counterfactual returns one previously witnessed Existing binding for the exact verified input; it is neither a historical nor a security-qualified baseline. No production percentile guarantee."); output.WriteLine("Bindings={0}; same authenticated public GET samples per lane={1}, interleaved after warmup; counterfactual median/range ms={2:F2}/{3:F2}-{4:F2}; production binding median/range ms={5:F2}/{6:F2}-{7:F2}; median difference ms={8:F2}.", - population, samples, Median(control), control.Min(), control.Max(), Median(bound), bound.Min(), bound.Max(), Median(bound) - Median(control)); + population + SeedData.IdentityUsers.Length, samples, Median(control), control.Min(), control.Max(), Median(bound), bound.Min(), bound.Max(), Median(bound) - Median(control)); output.WriteLine("Anonymous contextual comparison median/range ms={0:F2}/{1:F2}-{2:F2}; driver SQL statements anonymous/counterfactual/production={3}/{4}/{5}; actual production binding SELECTs={6}; counterfactual binding SELECTs=0; observer adds one role/GUC proof SELECT per production lookup.", Median(anonymous), anonymous.Min(), anonymous.Max(), anonymousStatements, counterfactualStatements, boundStatements, samples); output.WriteLine("Default planner nodes={0}; actual rows={1}; planning/execution ms={2:F3}/{3:F3}; concurrent HTTP=8; pool maximum/observed occupied=4/{4}; remaining active/transaction owners=0.", diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.SourceProof.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.SourceProof.cs new file mode 100644 index 000000000..7bbda1958 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.SourceProof.cs @@ -0,0 +1,99 @@ +using System.Net; +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Identity.Infrastructure.Bindings; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +public sealed partial class TenantExistingBindingAdmissionTests +{ + [Fact] + public async Task Actual_host_refuses_either_auxiliary_lane_on_another_cluster_with_the_same_database_name_and_authority() + { + await using var issuer = await TenantAuthenticationIssuer.StartAsync(); + await using var database = await DatabaseAsync(issuer); + var authority = database.Registration.AuthorityId.Value.ToString("D"); + var otherCluster = new PostgresFixture(); + try + { + await otherCluster.InitializeAsync(); + var name = new NpgsqlConnectionStringBuilder(database.AppConnectionString).Database!; + // The identifier comes only from our disposable database generator. + name.Should().MatchRegex("^scope_proof_[0-9a-f]{32}$"); + await otherCluster.ExecuteAsSuperuserAsync($"CREATE DATABASE {name} OWNER learnstack_migration"); + var otherApp = SameNamedDatabase(otherCluster.AppConnectionString, name); + var otherMigration = SameNamedDatabase(otherCluster.MigrationConnectionString, name); + await MigrationChains.ApplyAllAsync(otherMigration); + await using (var migration = (NpgsqlConnection)await PostgresFixture.OpenAsync(otherMigration)) + await IdentityAuthorityDeploymentInstaller.InstallAsync(migration, database.Registration); + + // Both isolated databases really satisfy the former role/anchor/name checks. + await using (var read = new IdentityAuthorizationReadSource(otherApp)) + await using (var evidence = new AuthorizationEvidenceSource(otherApp)) + { + await read.QualifyAsync(database.Registration.AuthorityId, issuer.Issuer); + await evidence.QualifyAsync(database.Registration.AuthorityId, issuer.Issuer); + read.IsQualified.Should().BeTrue(); + evidence.IsQualified.Should().BeTrue(); + read.QualifiedDatabaseName.Should().Be(name); + evidence.QualifiedDatabaseName.Should().Be(name); + } + var before = await CountsAsync(database); + var otherBefore = await SourceProofRowsAsync(otherApp, database.Registration); + await HealthySourceProofHostAsync(database.AppConnectionString, issuer.Issuer, authority); + await HealthySourceProofHostAsync(otherApp, issuer.Issuer, authority); + + foreach (var (authorization, evidence) in new[] + { + (otherApp, database.AppConnectionString), + (database.AppConnectionString, otherApp), + }) + { + await using var refused = new QualifiedStartupFactory(database.AppConnectionString, issuer.Issuer, + authority, authorization, evidence); + Action start = () => { using var client = refused.CreateClient(); }; + var failure = start.Should().Throw().Which; + failure.Message.Should().Be("Identity authorization source qualification is unavailable or does not match deployment configuration."); + failure.InnerException.Should().BeNull("the refusal cannot disclose database or provider details"); + } + + (await CountsAsync(database)).Should().Be(before); + (await SourceProofRowsAsync(otherApp, database.Registration)).Should().Be(otherBefore); + await HealthySourceProofHostAsync(database.AppConnectionString, issuer.Issuer, authority); + } + finally { await otherCluster.DisposeAsync(); } + } + + private static string SameNamedDatabase(string connection, string name) => + new NpgsqlConnectionStringBuilder(connection) { Database = name, Pooling = false }.ConnectionString; + + private static async Task HealthySourceProofHostAsync(string connection, string issuer, string authority) + { + await using var factory = new QualifiedStartupFactory(connection, issuer, authority); + using var client = factory.CreateClient(); + using var health = await client.GetAsync("/healthz"); + health.StatusCode.Should().Be(HttpStatusCode.OK); + } + + private static async Task<(long Anchors, long Users, long Audits)> SourceProofRowsAsync( + string connection, IdentityAuthorityRegistration registration) + { + await using var app = (NpgsqlConnection)await PostgresFixture.OpenAsync(connection); + await SchemaQueries.AssertApplicationRoleAsync(app); + await using var transaction = await app.BeginTransactionAsync(); + await AnnounceAsync(app, transaction, + new LearnStack.SharedKernel.Identity.GlobalIdentitySubject(registration.AuthorityId, registration.Issuer, Subject)); + await using var command = new NpgsqlCommand(""" + SELECT (SELECT count(*) FROM public.identity_authority), + (SELECT count(*) FROM public.users), + (SELECT count(*) FROM public.audit_log WHERE operation='identity.user.create') + """, app, transaction); + await using var rows = await command.ExecuteReaderAsync(); + (await rows.ReadAsync()).Should().BeTrue(); + var result = (rows.GetInt64(0), rows.GetInt64(1), rows.GetInt64(2)); + result.Should().Be((1L, 0L, 0L), "startup only qualifies the installed anchor and never adopts a subject"); + return result; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Startup.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Startup.cs index e066f4781..61b502cd1 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Startup.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.Startup.cs @@ -1,7 +1,9 @@ using System.Net; using FluentAssertions; +using LearnStack.Modules.Identity.Infrastructure.Bindings; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; +using Npgsql; using Xunit; namespace LearnStack.Tests.Integration.Database; @@ -53,13 +55,52 @@ public async Task Actual_host_startup_requires_the_installed_authority_exact_con private sealed record StartupVariant(string Name, string Connection, string Issuer, string? Authority); - private sealed class QualifiedStartupFactory(string connection, string issuer, string? authority) : ConstructionOnlyApiFactory + [Fact] + public async Task Actual_host_startup_refuses_auxiliary_role_anchor_and_actual_database_mismatch_without_repair() + { + await using var issuer = await TenantAuthenticationIssuer.StartAsync(); + await using var database = await DatabaseAsync(issuer); + var authority = database.Registration.AuthorityId.Value.ToString("D"); + await using var missing = await DisposableSchemaDatabase.CreateAsync(parent.Postgres); + await using var other = await DisposableSchemaDatabase.CreateAsync(parent.Postgres); + await using (var migration = (NpgsqlConnection)await PostgresFixture.OpenAsync(other.MigrationConnectionString)) + await IdentityAuthorityDeploymentInstaller.InstallAsync(migration, database.Registration); + var before = await CountsAsync(database); + foreach (var (authorization, evidence) in new[] + { + (missing.AppConnectionString, database.AppConnectionString), + (database.AppConnectionString, missing.AppConnectionString), + (other.AppConnectionString, database.AppConnectionString), + (database.AppConnectionString, other.AppConnectionString), + (database.MigrationConnectionString, database.AppConnectionString), + (database.AppConnectionString, database.MigrationConnectionString), + (database.AppConnectionString, ""), + }) + { + await using var refused = new QualifiedStartupFactory(database.AppConnectionString, issuer.Issuer, + authority, authorization, evidence); + Action start = () => { using var client = refused.CreateClient(); }; + var failure = start.Should().Throw().Which; + failure.Message.Should().Be("Identity authorization source qualification is unavailable or does not match deployment configuration."); + failure.InnerException.Should().BeNull("source refusal must not expose provider or credential payloads"); + } + (await CountsAsync(database)).Should().Be(before); + await using var restored = new QualifiedStartupFactory(database.AppConnectionString, issuer.Issuer, authority); + using var client = restored.CreateClient(); + using var response = await client.GetAsync("/healthz"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + private sealed class QualifiedStartupFactory(string connection, string issuer, string? authority, + string? authorization = null, string? evidence = null) : ConstructionOnlyApiFactory { protected override bool RetainIdentityQualification => true; protected override void ConfigureTestHost(IWebHostBuilder builder) { builder.UseEnvironment(Environments.Development); builder.UseSetting("ConnectionStrings:Default", connection); + builder.UseSetting("ConnectionStrings:IdentityAuthorization", authorization ?? connection); + builder.UseSetting("ConnectionStrings:AuthorizationEvidence", evidence ?? connection); builder.UseSetting("Authentication:Tenant:Issuer", issuer); builder.UseSetting("Identity:AuthorityId", authority); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.cs index aae4d3bf4..3ded2489b 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantExistingBindingAdmissionTests.cs @@ -172,7 +172,7 @@ public async Task Actual_store_permission_fault_is_uniform_401_with_a_fixed_priv } private Task DatabaseAsync(TenantAuthenticationIssuer issuer) => - IdentityPublicReadFixture.CreateAsync(parent, issuer.Issuer, new Dictionary { [Actor] = Subject }); + IdentityPublicReadFixture.CreateWithMembershipsAsync(parent, issuer.Issuer, new Dictionary { [Actor] = Subject }); private static HttpClient Client(IdentityPublicReadFixture database, BindingObservation observation, Action? configure = null) => database.ClientFor(SeedData.English.Host, services => diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs index 6e435e2a0..318106cdd 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs @@ -9,6 +9,7 @@ using LearnStack.SharedKernel.Tenancy; using LearnStack.Tools.Seeder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -51,7 +52,8 @@ namespace LearnStack.Tests.Integration.Database; /// /// 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. +/// set for /api/v1/sideeffectprobe. The production tenancy, transaction, +/// query-filter and RLS components run beneath it; HTTP authorization is separate. /// /// /// What these cases constrain, measured in both directions. They constrain the @@ -319,14 +321,15 @@ private async Task> GetAsync(string host, string route) } /// -/// The real application, on a real database, with the two seed tenants in it. +/// Production isolation components on a real database with both seeded tenants. +/// The isolated probe transport does not claim Program HTTP authorization admission. /// /// /// 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 : ConstructionOnlyApiFactory, IAsyncLifetime +public sealed class TenantIsolationFixture : ComponentApiFactory, IAsyncLifetime { private readonly PostgresFixture _postgres = new(); @@ -442,19 +445,19 @@ public HttpClient ClientFor(string host) return client; } + protected override void ConfigureComponentSettings(WebApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Configuration["ConnectionStrings:Default"] = _postgres.AppConnectionString; + builder.Configuration["ConnectionStrings:PlatformAdmin"] = _postgres.PlatformConnectionString; + } + protected override void ConfigureTestHost(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 => + builder.ConfigureServices(services => { services.AddControllers(options => options.Conventions.Insert( 0, new TestControllerFilter(typeof(IsolationProbeController)))) @@ -483,6 +486,7 @@ protected override void ConfigureTestHost(IWebHostBuilder builder) // does not know — so without this they would be refused before reaching the // policy at all, and the cases would pass for the wrong reason. services.AddSingleton(); + ComponentDatabaseOperations.AddIsolationOperations(services); }); } } diff --git a/backend/tests/LearnStack.Tests.Integration/ErrorShapeHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/ErrorShapeHttpTests.cs index 8a44ac33c..6d11882f1 100644 --- a/backend/tests/LearnStack.Tests.Integration/ErrorShapeHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/ErrorShapeHttpTests.cs @@ -345,13 +345,13 @@ private static async Task AssertProblemAsync( } } -public sealed class ErrorShapeFixture : ConstructionOnlyApiFactory +public sealed class ErrorShapeFixture : ComponentApiFactory { protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); - builder.ConfigureTestServices(services => + builder.ConfigureServices(services => services.AddControllers(options => options.Conventions.Insert(0, new TestControllerFilter( typeof(PaginationProbeController), diff --git a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs index 5468047d2..5e8145015 100644 --- a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs @@ -7,6 +7,7 @@ using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Tenancy; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Testing; @@ -305,7 +306,7 @@ public async Task An_Unclassified_Prefix_Is_Served_Whatever_Its_Host() } /// A host whose resolver is a stub, so no database is involved. -public sealed class HostClassificationFixture : ConstructionOnlyApiFactory +public sealed class HostClassificationFixture : ComponentApiFactory { public const string OrganizationHost = "branch.example.com"; public const string TenantHost = "school.example.com"; @@ -318,21 +319,19 @@ public sealed class HostClassificationFixture : ConstructionOnlyApiFactory public StubResolver Resolver { get; } = new(); + protected override void ConfigureComponentSettings(WebApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + // Applied before the real tenancy component snapshots its host options. + builder.Configuration[$"{PlatformHostOptions.SectionName}:1"] = PlatformHostWithAMappingRow; + } + protected override void ConfigureTestHost(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 => + builder.ConfigureServices(services => { services.AddControllers(options => options.Conventions.Insert(0, new TestControllerFilter( diff --git a/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs index ce9ae6c52..87c4bbb58 100644 --- a/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs @@ -508,7 +508,7 @@ private async Task PostAsync( /// which made every test in the class depend on the order the others ran in — /// Standards 06 § Forbidden bars exactly that. /// -public sealed class IdempotencyFixture : ConstructionOnlyApiFactory +public sealed class IdempotencyFixture : ComponentApiFactory { public static readonly Guid TenantA = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); public static readonly Guid TenantB = Guid.Parse("018f4d40-0000-7000-8000-00000000000b"); @@ -522,7 +522,7 @@ protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); - builder.ConfigureTestServices(services => + builder.ConfigureServices(services => { services.AddControllers(options => options.Conventions.Insert(0, new TestControllerFilter( diff --git a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj index 00e339184..c498fa3f1 100644 --- a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj +++ b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj @@ -21,6 +21,8 @@ + |"SHOULD / MAY"| Best["IAuditStore.WriteBestEffortAsync"] ``` +This diagram describes ordinary resource intents; the Accepted denial population +uses the separate pre-success barrier above. + Read as text — **decide → write → reconcile**. At step 3 the behavior classifies the request from in-process state and, for MUST, parks one intent per audited `(resource, operation)` in the scoped `IAuditStateCapture`; it opens no transaction and @@ -584,9 +625,10 @@ public sealed record CapturedEntityChange( public sealed record CapturedFieldChange(string Path, string? BeforeJson, string? AfterJson); ``` -`State` is the only durability signal in the system, and it is deliberately **not** -a "consumed" flag. `WrittenInTransaction` is not durable; only `Committed` is. This -interface is a `SharedKernel` abstraction and names no EF Core type — it lives in +`State` is the ordinary resource buffer's durability signal, deliberately **not** +a "consumed" flag. ADR-0077's separate denial implementation uses +independent per-event/actual-owner state; this global state cannot acknowledge it. +`WrittenInTransaction` is not durable; only `Committed` is. This interface is a `SharedKernel` abstraction and names no EF Core type — it lives in `LearnStack.SharedKernel.Audit` beside the other four ports, `IAuditStore`, `AuditEntryDraft`, `AuditIntent` and `AuditEntryId` ([ADR-0044 § 11](../decisions/0044-audit-write-path.md)). @@ -803,11 +845,11 @@ mirror. [Phase 03 G10](../roadmap/phase-03-identity-admin.md#the-decision-regist or an earlier proven consumer, must decide its first source/freshness contract; `actor_user_id` carries no foreign key. `ip_address` and `user_agent` need HTTP-layer enrichment `LearnStack.Application` cannot see — that project references no -`Microsoft.AspNetCore.*` package, and must not begin to — so they stay `NULL` until -[Phase 03](../roadmap/phase-03-identity-admin.md) adds the request-context port that -carries them — as `System.Net.IPAddress`, not `string`: the column is `inet`, Npgsql maps +`Microsoft.AspNetCore.*` package, and must not begin to. The trusted provenance +port carries them from the admitted HTTP transport — as `System.Net.IPAddress`, not `string`: the column is `inet`, Npgsql maps that type onto it with no configuration, and a `string` maps onto `text`, which -PostgreSQL will not assign to `inet`. When it does, that port's population sites are enumerated and counted the +PostgreSQL will not assign to `inet`. The accepted [Standard 18 contract](../standards/18-audit-coverage.md#trusted-authorization-evidence) +owns their bounded source/field rules. That port's population sites are enumerated the way [ADR-0036 Amendment 2](../decisions/0036-tenant-resolution-trusted-inputs.md) counts the ambient tenant context's four, and for the same reason: an ambient value anyone may write is an ambient value nobody can reason about. @@ -823,7 +865,7 @@ Key invariants enforced by the shared lifecycle: - **Ownership gates distinct duties.** A UoW joiner writes and signals nothing. A nested audit frame does not reconcile or clear; the outermost audit coordinator owns those duties (§ 4). -- **Only `Committed` counts.** The reconcile step branches on +- **Only `Committed` counts for ordinary resource intents.** The reconcile step branches on `IAuditStateCapture.State`, never on a "consumed" flag. **Any** state other than `Committed` produces standalone rows — `Pending` included, which is the state a short-circuit at step 4 or 5 leaves behind, and which the three-shapes sentence in § 1 @@ -870,7 +912,7 @@ Key invariants enforced by the shared lifecycle: writes the `failed` outcome through the standalone path — the business transaction has rolled back, so there is no transaction left to ride — then rethrows via `ExceptionDispatchInfo` so the original stack trace survives. -- **`Indeterminate` prefers a duplicate to a loss.** When `CommitAsync` faults, the rows' +- **Ordinary resource `Indeterminate` prefers a duplicate to a loss.** When `CommitAsync` faults, the rows' fate is genuinely unknown, so each standalone row is written anyway, carrying the same `AuditEntryId` as the in-transaction attempt and outcome `indeterminate`. The pair is legal under the composite primary key because `PostgresAuditStore` supplies a **fresh** @@ -961,8 +1003,9 @@ before dispatch and enrolls The invocation stays open through completion. Successful production records sticky receipts in its invocation and ancestors; a returned refusal with produced work marks rollback-only before frame resolution. Ordinary event-free absorbed -refusals retain ADR-0040's meaning. Producer frames remain distinct from audit -ownership and cannot make a raw transaction into an enrolled producer owner. +refusals retain ADR-0040's meaning; ADR-0077 additionally requires unresolved +denial evidence to pass its pre-success barrier before outer success. Producer +frames remain distinct from audit ownership and cannot make a raw transaction into an enrolled producer owner. Only the transaction owner writes pending MUST intents and reports durability. It refuses rollback-only or read-only-with-produced-work before composition; @@ -1000,7 +1043,8 @@ handle (`CompleteAsync` / `FailAsync`) rather than through the frame-blind otherwise turns the outer commit into a silent no-op. ADR-0057 adds rollback-only for a returned refusal whose invocation or descendants produced work; ordinary event-free absorbed refusals keep ADR-0040 § Nesting -semantics. The `committing` flag keeps a faulted `COMMIT` from being followed by +semantics, subject to ADR-0077's accepted denial-evidence pre-success barrier. +The `committing` flag keeps a faulted `COMMIT` from being followed by a rollback attempt. That same handle carries `IsOwner`, which is why the audit gating above is a property of @@ -1351,7 +1395,7 @@ refuses it in the factory. `tenants` still carries but a constraint is the **backstop**, not the control, because it cannot stop a GUC from being announced ([ADR-0044 Amendment 3 § 5](../decisions/0044-audit-write-path.md)). -**The row's identity and its clock.** `AuditEntryId` is minted **app-side** by +**The ordinary resource row's identity and its clock.** `AuditEntryId` is minted **app-side** by `AuditLogBehavior` at pipeline step 3 — the one high-volume append-only table whose id is ([ADR-0023 Amendment 9](../decisions/0023-strongly-typed-id-source-generator.md)) — because the `Indeterminate` pair has to carry a single identity across two connections and @@ -1362,7 +1406,9 @@ under the composite primary key instead of raising `23505`. `DEFAULT uuidv7()` a `DEFAULT now()` both stay on their columns for the same narrow purpose — a backstop for a row inserted by something other than the store — which is why the fence above carries both and Database Standards § Identifiers names `audit_log` as the table that takes **both** id -paths. +paths. ADR-0077 denial occurrences instead keep their exact fixed decision time +and complete draft across owned attempts, as defined in +[Standard 18](../standards/18-audit-coverage.md#trusted-authorization-evidence). ### Append-only enforcement @@ -1569,30 +1615,23 @@ shape, different table. ## 9. Retention -Default retention by operation class (per-tenant overridable within plan limits): - -| Class | Default | Override range | -|-------|---------|----------------| -| SecurityEvent | **7 years** | 1–10 years | -| Create / Update / Delete on financial / identity / enrollment data | **7 years** | 1–10 years | -| Create / Update / Delete on content / scheduling | **2 years** | 6 months – 5 years | -| ReadSensitive | **2 years** | 6 months – 5 years | -| Other Action | **1 year** | 3 months – 3 years | +[Standard 18 Retention](../standards/18-audit-coverage.md#retention) is the +single policy source for per-row OperationType/domain targets and permitted +bounds. Denial/assertion SecurityEvent rows target seven years; directory +ReadSensitive rows target two years, including their retained authorization +metadata. MUST is a durability class, not a blanket retention period. -Both retention jobs run **daily**. Three documents previously disagreed — daily in -[Audit Coverage Standards](../standards/18-audit-coverage.md) and -[ADR-0028](../decisions/0028-audit-log-partition-management.md), weekly here — and this -document was the outlier. Daily is correct: a weekly purge means a tenant's stated -retention can be exceeded by up to six days, which is a compliance answer nobody wants to -give. +Automatic purge, retention columns and tenant retention overrides remain +unimplemented. Rows can outlive policy targets until the separately admitted +Phase 11 machinery exists. The two planned daily jobs have distinct duties: -Both jobs land in [Phase 11](../roadmap/phase-11-production-hardening.md) alongside -partitioning; until then `audit_log` is a single table and nothing purges it. - -| Hangfire recurring job | Cadence | What it does | +| Hangfire recurring job | Target cadence | Planned duty | |---|---|---| -| `learnstack:audit:partition-management` | Daily | Creates next month's partition if absent; drops partitions older than the maximum retention window across all tenants (10y for safety) | -| `learnstack:audit:retention-purge` | **Daily** | Deletes individual rows per the tenant's configured retention, in batches. Runs as `learnstack_platform` through the audited platform-admin scope. The purge itself emits a `SecurityEvent` audit row summarising what was deleted | +| `learnstack:audit:partition-management` | Daily | Create future partitions and remove only partitions beyond the applicable platform maximum; ADR-0028 owns partition lifecycle. | +| `learnstack:audit:retention-purge` | Daily | Delete eligible individual rows in batches under the canonical policy; use audited Platform authority and a MUST result row. | + +Neither job is activated by the P03-2 evidence contract. A retention table or +declared job name is not proof of deletion or production capacity. ## 10. GDPR / PII redaction @@ -1624,10 +1663,12 @@ Phase 11 owns retention purge, not redaction. `actor_user_id`, `module`, `operation`, `operation_type`, `operation_class`, `outcome`, `correlation_id` and `timestamp` are **never** redacted; the trigger rejects an `UPDATE` that changes any of them. `metadata` is also outside the six-column redaction -grant and can retain causal actor references. Preserving an attributable history -is not proof of complete anonymisation. Phase 03 settles those references under +grant and can retain causal actor and authorization Membership/Role references. +Preserving an attributable history is not proof of complete anonymisation. Phase 03 settles those references under the [erasure first-writer prerequisites](23-data-protection.md#erasure-authority-and-first-writer-prerequisites) -before its redaction handler ships. +before its redaction handler ships. P03-10's Audit participant locates the +accepted authorization metadata through retained actor/subject attribution and +reports surviving references; no metadata UPDATE authority follows. Every module that stores user references in audit payloads registers an `IUserReferenceLocator`. [Phase 03 P03-10](../roadmap/phase-03-identity-admin.md#inherited-architecture-obligations) @@ -1723,16 +1764,11 @@ update it constrains. ahead of its command carries `(planned)` and is outside this direction — and a `(planned)` row whose command has since shipped **fails**, which is what stops the marker from becoming an escape. - - *Off the request path.* Operations that are not MediatR requests at all carry - `(off-path)` and are outside both directions. Nine rows carry it — Tenancy's - `platform.admin_scope.enter`, `platform.outbox.dead_letter`, - `platform.delivery.dead_letter`, `tenancy.killswitch.toggle`, - `tenancy.entitlement.refresh` and - [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)'s - `tenancy.tenant_assertion.reject` and `tenancy.tenant_assertion.anonymous_burst`, and - Audit's `audit.redaction.apply` and `audit.purge.apply`. They register by slug: the - five whose writer ships through `DeclareOffPath`, the other four — also - `(planned)` — when their writer lands. + - *Off the request path.* Non-MediatR operations carry `(off-path)` and + register by slug when their writer ships. Module matrices own the current + population, including P03-2's denial/assertion events. See + [Standard 18's join rules](../standards/18-audit-coverage.md#the-join); + no absent writer is made registered by its acceptance. - *An `Off` registration carries no slug*, so it raises no matrix row and is outside both directions too (§ 5). 2. `AuditEntry_Inherits_Entity_Not_AuditableEntity` — append-only by construction: an diff --git a/docs/architecture/33-cross-cutting-concerns.md b/docs/architecture/33-cross-cutting-concerns.md index af6a64603..dd412fe4f 100644 --- a/docs/architecture/33-cross-cutting-concerns.md +++ b/docs/architecture/33-cross-cutting-concerns.md @@ -8,6 +8,8 @@ [ADR-0066](../decisions/0066-bounded-tenant-subject-admission.md), [ADR-0072](../decisions/0072-application-owned-identity-bootstrap.md), [ADR-0073](../decisions/0073-scoped-membership-and-identity-data-foundation.md), +[ADR-0076](../decisions/0076-live-scoped-authorization.md), +[ADR-0077](../decisions/0077-trusted-authorization-evidence.md), [ADR-0020](../decisions/0020-triple-deployment-hybrid-license.md). For the day-to-day rules read [09-error-handling.md](../standards/09-error-handling.md), [10-observability.md](../standards/10-observability.md), and @@ -88,13 +90,13 @@ unhandled exception (Sentry-captured, 500 Problem Details). Per ## 2. MediatR Pipeline Order P02b-5's [ADR-0064](../decisions/0064-tenant-api-authentication-boundary.md) -adds HTTP authentication/admission before this unchanged pipeline. Only the -closed AccountSite GET gains authenticated reachability; its exact query is Off +supplies HTTP authentication/admission before this unchanged pipeline. The +closed AccountSite GET retains its separate boundary; its exact query is Off and uses the existing physical ReadOnly owner alongside PublicSurface reads. The HTTP middleware order and error precedence live in that ADR and [API Design](../standards/04-api-design.md#authentication); they do not add a ninth -MediatR stage or implement Phase 03 permission checks. Independent rejected -assertions keep the recorder's standalone audit path. Accepted +MediatR stage. The live permission extension is described below. Independent +rejected assertions keep the recorder's standalone audit path. Accepted [ADR-0065](../decisions/0065-bounded-tenant-assertion-diagnostics.md) adds a shared singleton host-tenant admission budget before authenticated store calls: 600 attempts per API instance per first-request-anchored monotonic minute, including @@ -145,6 +147,27 @@ User/audit reciprocity and exact non-success evidence use the canonical This extension adds no MediatR stage or HTTP/new-user BFF route. P03-3 owns that activation; the current complete native profile and ordinary pipeline remain. +Jointly Accepted ADR-0076/0077 define the live extension: independent +HTTP/application live policy checks and one administrative membership directory +GET, without changing the eight-stage order or native BFF allowlist. AccountSite +retains Off/physical ReadOnly and its real HostScope ceiling. The current +`AuthorizationBehavior` shown below enforces the canonical policy. Immutable +operation read authority is bound by the actual step-6 owner; [Standard 05](../standards/05-database.md#operation-bound-read-authority) +owns EF/RLS agreement and scope lifetime, including mixed-scope join refusal. + +The accepted central denial population is separate from ordinary plural resource +intents. Its actual owner inserts unresolved events atomically with successful +ReadWrite work, or acknowledges standalone evidence before ReadOnly/ownerless +success. Failure prevents outer success before COMMIT/disclosure while an actual +refused caller keeps its refusal. No outer Committed flag clears an unwritten +inner denial. Fixed occurrence identity and exact full-row reconciliation prove +only event durability, never unknown business COMMIT success. [Standard 18](../standards/18-audit-coverage.md#trusted-authorization-evidence) +owns these rules, typed provenance and retention targets; the source/capacity +ledger remains [Standard 05](../standards/05-database.md#authorization-and-evidence-source-ledger). +Runtime, fault/crash and pool-progress controls separately exercise these +boundaries; the [Phase 03 Status block](../roadmap/phase-03-identity-admin.md) +owns current source-bound qualification. + [ADR-0032 § Sub-decision 2](../decisions/0032-exception-handling-logging-and-observability.md) binds the ordinary pipeline order. Reading bottom-most as innermost: @@ -167,8 +190,8 @@ Request [4] TenantContextBehavior ← assert ITenantContext.IsResolved; preserve context; public requests require host ceiling ▼ -[5] AuthorizationBehavior ← current pass-through shell; - Phase 03 owns permission enforcement +[5] AuthorizationBehavior ← exact classification, fresh committed capability; + trusted denial and invocation-bound read authority ▼ [6] TransactionBehavior ← IUnitOfWork.BeginTransactionAsync(); announce mode setup, then tenant announcement before data; @@ -256,11 +279,13 @@ Why this order: [Security Standards § Tenant Context](../standards/11-security.md), the single authority for this placement. - **Authorization after tenant.** Phase 03's permission decision needs ambient - `(tenant_id, user_id, resource)`. The current behavior passes requests through; - the closed HTTP boundary above does not implement those permissions. + `(tenant_id, user_id, resource)`. The canonical coordinator and Identity + evaluator independently authorize each application invocation; resolver and + HTTP admission do not substitute for this check. - **Transaction after authorization.** This position lets Phase 03 refuse a permission-sensitive request before opening a business transaction; its - `denied` MUST-class row uses the standalone audit path. + independent denial event uses the standalone path when no business owner exists; + absorbed denials obey Standard 18's pre-success barrier. - **The MUST-class audit row is written inside the transaction, not after it.** `TransactionBehavior` calls `IAuditStore.WritePendingAsync` immediately before `COMMIT`, so the row commits with the state change it describes or not at all @@ -597,6 +622,7 @@ Two integration points | Sensitive exception-message analyzer | P02b-8 | `LS0002` covers bounded sensitive-name expressions across production C#; ADR-0032 Amendment 6 | | MUST-class audit write path (`IAuditStore`, `IAuditStateCapture`) | Phase 02a Packet 9 | Shape fixed by ADR-0033 + ADR-0044; `audit_log` ships plain, unpartitioned | | Public read-only transaction mode and host ceiling | P02d-4 | ADR-0052 Accepted; P02d-4 Step 1 delivers host admission, transaction modes and app-role write-refusal proofs; Step 2 adds site endpoints and structural dispatch/Off/persistence guards; Steps 3–4 deliver Education reads and contract/SDK/CI proofs | +| Live authorization and trusted denial evidence | P03-2 | Live reader, canonical policy/read authority, trusted denial evidence and HTTP directory implemented; source-bound qualification remains in Phase 03. Canonical rules in Standards 05/18. | | Outbox / background-job correlation propagation | Phase 02b | Trusted outbox/job rows and delivery/`JobExecutor` restoration; Hangfire hints remain opaque | | Hub HTTPS correlation middleware | Phase 02b / 02c | Cross-repo | | OTel Collector + Tempo + Loki + Prometheus deployment | Phase 11 | Production-side backends | diff --git a/docs/decisions/0033-audit-durability-model.md b/docs/decisions/0033-audit-durability-model.md index e2d86af08..a684e47e9 100644 --- a/docs/decisions/0033-audit-durability-model.md +++ b/docs/decisions/0033-audit-durability-model.md @@ -828,3 +828,36 @@ Existing MUST durability, refusal posture and independently owned cleanup remain Joint ADR-0072/0073 acceptance grants this bounded authority. P03-1 Step 3 still must prove same-transaction success, precommit refusal, unknown COMMIT and exact evidence under the actual app role. + +## Amendment 12 — Pre-success authorization evidence (2026-10-27) + +Accepted [ADR-0077](0077-trusted-authorization-evidence.md) adds the common typed +MUST `identity.authorization.deny` occurrence, separately from each operation's +existing plural resource intents. Normal Off/SHOULD/MAY classifications are not +promoted, and tenant overrides cannot remove the denial-event floor. HTTP and +application/resource adapters share a central occurrence owner; actual nested +acts remain distinct. + +An otherwise-successful ReadWrite owner carries unresolved events atomically +on its business transaction before final commit admission. ReadOnly or ownerless +success requires their acknowledged standalone write before completion/disclosure. +Failure prevents that outer success with `audit_unavailable`; an actually refused +caller retains its 403/404 with the existing Critical/counter/health posture. +A joiner neither acknowledges nor clears this population. A global business +Committed flag cannot suppress an event that owner never wrote. + +Each occurrence freezes one id, one microsecond-normalized decision timestamp +and complete draft. Its admitted pending/standalone/reconciliation attempts reuse +that exact key. Expected-primary-key duplicate verification requires a clean +captured-scope full-draft match and proves only event durability, never business +success. Ordinary resource reconciliation deliberately retains its different- +timestamp semantics. Unknown business COMMIT stays unknown. A crash before any +applicable event write commits can lose an uncompleted refusal; no durable +invocation/restart replay ledger is introduced. + +[Audit Coverage](../standards/18-audit-coverage.md#trusted-authorization-evidence) +owns the canonical populations, barrier and provenance contract. ADR-0077 also +adds the separately attributed per-occurrence claim-context assertion record; +ADR-0065's host allowance does not apply. P03-2 implements/proves this accepted +extension before activation. Other durability rules and historical Accepted +body/status/date remain unchanged. diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index 1bf954883..9f5eea2b2 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -1284,3 +1284,27 @@ Joint approval accepts these bounded contracts while preserving route invariance host authority and the existing resolution matrix. P03-1 Step 5 owns actual existing-binding admission proofs. New-user HTTP/BFF entry remains subject to P03-3 acceptance and activation. + +### Amendment 16 — Live candidate execution and evidence (2026-10-27) + +Accepted [ADR-0076](0076-live-scoped-authorization.md) specifies execution of the +previously admitted ninth candidate reader and fresh protected-act checks without +changing this resolution matrix or its four context writers. Matching rows 6/9 +still require independent live authorization for protected acts. AccountSite +retains its HostAndClaim and matching real HostScope ceiling; membership alone +cannot supply it. + +After successful ClaimAndMembership resolution, a conflicting header uses +[ADR-0077](0077-trusted-authorization-evidence.md)'s distinct +`tenancy.assertion.reject_claim_context` event under the Membership-confirmed +tenant. It is per-occurrence MUST and consumes no ADR-0065 host allowance. This +is not refused row-14 membership evidence. Row-7's host recorder, unaudited row 10, +refused row-14 diagnostics, malformed/unresolved assertions and host-backed +budgets retain their existing contracts. No asserted header selects a scope. + +The bounded administrative directory HTTP extension is owned by ADR-0076 and +ADR-0064's corresponding Amendment; it does not make resolution depend on the +endpoint. [Security](../standards/11-security.md) owns placement and the +[phase record](../roadmap/phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27) +owns implementation/proof progress. Acceptance is not activation. Other decisions +and historical Accepted body/status/date remain unchanged. diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index e054e97fd..84eece1b4 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -792,3 +792,34 @@ registration. [Security Standards](../standards/11-security.md) owns that census Joint approval accepts the authority. P03-1 Steps 3/5 retain the global owner and existing-binding runtime proofs; P03-2 retains the ninth setter's live proofs. + +### Amendment 15 — Confined live authorization owners (2026-10-27) + +Accepted [ADR-0076](0076-live-scoped-authorization.md) implements the contract +for the already enumerated ninth logical candidate-reader setter: a fresh, +physically READ ONLY app-role owner announces its verified actor/candidate scope +and generation without installing an ambient TenantContext. Its dedicated +four-slot authorization source cannot borrow a retained business connector or +consume the parent's uncommitted grant state. It executes only its confined +nonlocking Identity/Tenancy statements, releasing before business/audit work. + +The ordinary step-6 owner consumes immutable invocation-bound read authority +for SQL/EF scope. Same-scope joins preserve it; incompatible wider or narrower +read-scope joins poison rather than retarget. Existing transaction mode and +owner/connection rules remain. Neither this source nor the read witness creates +a context writer or tenth logical setter. + +[ADR-0077](0077-trusted-authorization-evidence.md)'s independent two-slot evidence +source establishes standalone events before otherwise-successful ReadOnly or +ownerless publication. Successful ReadWrite owners carry unresolved events on +their own transaction before final commit admission. Slots include opening and +actual cleanup, not merely timeout-delimited tasks. Job sources track both named +lanes without a second main unit or suppressed ambient ownership. PgBouncer +backend lanes/headroom are independently qualified, not inferred from two +Npgsql objects or application names. + +[Standard 05](../standards/05-database.md#authorization-and-evidence-source-ledger) +owns the exact source/capacity/configuration ledger and +[Security](../standards/11-security.md) owns placement. Acceptance is not runtime +implementation; P03-2 proves scope, progress and cleanup before activation. +Other decisions and historical Accepted body/status/date remain unchanged. diff --git a/docs/decisions/0044-audit-write-path.md b/docs/decisions/0044-audit-write-path.md index e5a04f735..f1941bf74 100644 --- a/docs/decisions/0044-audit-write-path.md +++ b/docs/decisions/0044-audit-write-path.md @@ -1262,3 +1262,35 @@ Fixed central witness SQL receives only installer/evidence exception. Existing append-only, redaction and retention authority remains unchanged. Joint ADR-0072/0073 approval accepts these bounds; P03-1 Step 3 still owns the actual writer, mode-refusal, guard and evidence proofs. + +## Amendment 15 — Typed denial events and immutable provenance (2026-10-27) + +Accepted [ADR-0077](0077-trusted-authorization-evidence.md) adds a centrally owned +MUST authorization-denial population, distinct from ordinary plural resource +intents. It supplies typed immutable actor/membership/role/source/route and +bounded transport provenance; handlers acquire no arbitrary metadata channel. +ActorEmail stays null until a separately admitted trustworthy source exists. + +The denial occurrence has one fixed id and microsecond-normalized decision time. +Its pending and standalone seams reuse the sole INSERT/binder, but do not inherit +ordinary standalone's broad 23505 shortcut or fresh reconciliation timestamp. +Only the expected composite-key conflict followed by a clean scoped full-draft +match proves this event durable; it proves no business outcome. Ordinary resource +reconciliation and its deliberate timestamp distinction remain unchanged. + +The enclosing successful ReadWrite commit atomically carries unresolved denial +events. ReadOnly/ownerless success requires their standalone acknowledgement first. +Audit failure therefore prevents otherwise-successful commit/disclosure; an +already-refused caller retains refusal. Per-event owner/generation state cannot +be replaced by the business capture's global Committed state, and joiners cannot +claim completion. Process loss before any applicable write commits can lose an +uncompleted refusal; no persistent invocation/replay ledger is added. + +`tenancy.assertion.reject_claim_context` is a separate Membership-confirmed +per-occurrence MUST event after successful claim-only resolution, outside the +host budget. The [canonical audit contract](../standards/18-audit-coverage.md#trusted-authorization-evidence) +owns exact populations, privacy, source and proof obligations. Its two-slot +standalone evidence lane remains app-role/tenant-scoped; no sentinel, bypass, +new table or redaction privilege follows. P03-2's new confined seams extend the +prior eight central seams only when implemented; acceptance does not claim they +exist. Other decisions and historical Accepted body/status/date remain unchanged. diff --git a/docs/decisions/0047-transactional-background-job-runtime.md b/docs/decisions/0047-transactional-background-job-runtime.md index b17b28de1..216d1cf4d 100644 --- a/docs/decisions/0047-transactional-background-job-runtime.md +++ b/docs/decisions/0047-transactional-background-job-runtime.md @@ -987,3 +987,26 @@ runtime, migration or passing proof claim; canonical SQL stays in Standard 05. - [Media Pipeline](../architecture/16-media-pipeline.md) - [Quartz.NET persistent job stores](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/job-stores.html) - [Quartz.NET execution/cancellation FAQ](https://www.quartz-scheduler.net/documentation/faq.html) + +### Amendment 5 — Tracked authorization and denial-evidence lanes (2026-10-27) + +Accepted [ADR-0076](0076-live-scoped-authorization.md) and +[ADR-0077](0077-trusted-authorization-evidence.md) add explicitly tracked +application authorization/evidence auxiliary lanes. Their separate global four/ +two caps, actual-cleanup retention/join and source lifetimes supplement the +existing main/auxiliary business ledger. An authorization child is not a second +main unit. No ambient-marker suppression, broader SystemActor permission, +external-profile capability or quiescence shortcut is admitted. + +For an otherwise-successful job owner, unresolved denial evidence satisfies the +same pre-success barrier as ordinary work: ReadWrite commits carry it atomically; +ReadOnly/ownerless success waits for standalone acknowledgement. The barrier +precedes final commit admission and preserves the existing no-await interval +before physical completion. Event identity, uncertain outcomes and actual cleanup +follow the canonical [Audit contract](../standards/18-audit-coverage.md#trusted-authorization-evidence) +and [source ledger](../standards/05-database.md#authorization-and-evidence-source-ledger). + +P03-2 implements/proves these tracked lanes and updates actual resource/caller +censuses before activation. This acceptance supplies no completed shutdown, +progress, cancellation or production-capacity evidence. Other decisions and the +historical Accepted body/status/date remain unchanged. diff --git a/docs/decisions/0064-tenant-api-authentication-boundary.md b/docs/decisions/0064-tenant-api-authentication-boundary.md index 5fe176292..5ad0082fa 100644 --- a/docs/decisions/0064-tenant-api-authentication-boundary.md +++ b/docs/decisions/0064-tenant-api-authentication-boundary.md @@ -618,3 +618,28 @@ Joint ADR-0072/0073 approval accepts this bounded authentication change. P03-1 Step 5 owns provider/app-role admission, lookup load, pool, cancellation and store-failure proofs. P03-3 needs separate acceptance before new-user HTTP/BFF activation; no new endpoint is authorized here. + +### Amendment 5 — Live authorization and one administrative read (2026-10-27) + +Accepted [ADR-0076](0076-live-scoped-authorization.md) narrowly supersedes this +record's AccountSite-only authenticated surface. It admits one additional +`GET /api/v1/identity/memberships`, under Tenant `identity.membership.admin`, +HostAndClaim or confirmed ClaimAndMembership and ordinary MUST sensitive-read +durability. This administrative read can cross branches within its tenant; +it does not relax PublicSurface or AccountSite's independent HostScope ceiling. +No Organization grant or HostOnly context admits the directory. + +AccountSite gains live Membership/lifecycle admission while retaining its +nonsensitive Off/physical READ ONLY shape, HostAndClaim and matching real host. +Present invalid/undecidable credentials retain fixed 401; absent protected +credentials/noncoverage remain masked 404; capability denial is 403. Candidate +store unavailability has fixed 503 dependency_unavailable, separately from +ADR-0072's binding-store uniform 401 contract. Resolution stays route-independent. + +The exact [API contract](../standards/04-api-design.md) and +[Security contract](../standards/11-security.md) own bounds, pagination and +precedence. [ADR-0077](0077-trusted-authorization-evidence.md) supplies denied/ +sensitive-read evidence before activation. No HTTP mutation, new-user bootstrap, +BFF proxy/allowlist expansion or generic authenticated read follows. P03-2 owns +implementation/qualification; approval alone changes no runtime. Other decisions +and historical Accepted body/status/date remain unchanged. diff --git a/docs/decisions/0065-bounded-tenant-assertion-diagnostics.md b/docs/decisions/0065-bounded-tenant-assertion-diagnostics.md index 95b145bb8..229b58c31 100644 --- a/docs/decisions/0065-bounded-tenant-assertion-diagnostics.md +++ b/docs/decisions/0065-bounded-tenant-assertion-diagnostics.md @@ -378,3 +378,17 @@ failed-attempt accounting and refusal behavior in this ADR remain unchanged. P03-1 Step 3 owns the new global audit proofs and Step 5 its admission integration; joint authority acceptance supplies neither runtime evidence nor an expanded mismatch budget. + +### Amendment 5 — Separate live-authorization evidence populations (2026-10-27) + +Accepted [ADR-0077](0077-trusted-authorization-evidence.md) adds authorization +denial and Membership-confirmed header-assertion events. Neither consumes this +record's independently defended host-tenant allowance. A successful claim-only +context followed by a conflicting header is separately classified and attributed; +it is neither a defended-host event nor a refused row-14 membership event. + +The host budget's key, suppression, counters, source population and evidence-loss +tradeoff remain unchanged. No general authorization budget is inferred here. +[Audit Coverage](../standards/18-audit-coverage.md#trusted-authorization-evidence) +owns the new populations; P03-2 must qualify their distinct paths before live +membership activation. Historical Accepted body/status/date remain unchanged. diff --git a/docs/decisions/0073-scoped-membership-and-identity-data-foundation.md b/docs/decisions/0073-scoped-membership-and-identity-data-foundation.md index c0fcb3b76..36672c5f5 100644 --- a/docs/decisions/0073-scoped-membership-and-identity-data-foundation.md +++ b/docs/decisions/0073-scoped-membership-and-identity-data-foundation.md @@ -245,3 +245,23 @@ preheld; it cannot first acquire a missing User key beneath tenant authority. owns the current algorithm, heldness limits and pending Step 4 proofs. No new scope, grant, Role variant, generic User reader or live endpoint follows. All other decisions and the Accepted body, status and date above remain unchanged. + +### Amendment 2 — Live scoped permission execution (2026-10-27) + +Accepted [ADR-0076](0076-live-scoped-authorization.md) completes the execution +contract for fresh live Membership/permission checks, operation-bound SQL/EF +read scope, finite source classification and explicit audited grant edits/ +release-manifest adoption outside the immutable kernel. Current Tenant grants +can bound same-key Organization-template authoring under the exact role-admin +contract, without scope-identity equivalence or reverse escalation. + +The five assignable foundation pairs, eight templates and fixed version-1 seed +manifest remain. Three virtual public definitions grant no stored Role authority. +Registry additions/seed reruns do not upgrade grants; the separately admitted +finite adoption act does. The bounded administrative directory uses the existing +Tenant membership-admin key; native editing/UI and orgless reissue remain P03-3. + +[Permissions](../standards/19-permissions.md) owns the detailed current contract, +and [ADR-0077](0077-trusted-authorization-evidence.md) owns evidence. P03-2 runtime +implementation/proofs remain required. Other decisions and historical Accepted +body/status/date remain unchanged. diff --git a/docs/decisions/0076-live-scoped-authorization.md b/docs/decisions/0076-live-scoped-authorization.md new file mode 100644 index 000000000..23910b9cb --- /dev/null +++ b/docs/decisions/0076-live-scoped-authorization.md @@ -0,0 +1,385 @@ +# ADR-0076: Live Scoped Authorization + +## Status + +Accepted — jointly with ADR-0077 on 2026-10-27. +Runtime implementation remains pending at acceptance. + +**Date:** 2026-10-27 +**Deciders:** @cemil + +**Relationship:** Completes P03-2 G2 execution, G5 enforcement and G6 policy +ordering alongside [ADR-0077](0077-trusted-authorization-evidence.md). Joint acceptance +of both records and the packet's decision package is complete. The bounded HTTP +extension replaces ADR-0064's AccountSite-only authenticated surface; its token, +resolution and independent AccountSite HostScope rules remain unchanged. + +**Maintainer acceptance — 2026-10-27.** The maintainer explicitly approves +the revised records and six-step P03-2 package at `725d255a`, including +bounded independent authority/evidence sources, pre-success denial +durability and fixed occurrence reconciliation. The [acceptance record](../roadmap/phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27) +owns the receipt and implementation/review progress. This acceptance grants +the named contract authority; it establishes no runtime, pool, crash or +production-capacity proof. + +## Decision Drivers + +- P03-1 stores Memberships and scoped grants, but production membership still + denies everyone and `AuthorizationBehavior` still passes requests through. +- Resolution rows 7/10/14 need a confirmed candidate before ambient tenant SQL; + matching rows 6/9 independently need live protected-act authorization. +- Permission revocation must affect subsequent admissions without relying on + provider claims, token refresh, a browser cache or a previous resolver result. +- SQL's existing tenant read hatch needs equally narrow EF authority and an + operation/transaction lifetime, without broadening organization writes. +- Real HTTP permission and sensitive-read proofs need a useful production + consumer; AccountSite deliberately has no permission key and is audit Off. + +## Considered Options + +1. **Fresh candidate reader, shared policy evaluator and operation-bound read + authority** (chosen). Preserve the accepted model and complete its execution. +2. **JWT/session-cached grants or resolver-only permission checks** (rejected). + Revocation and matching-host rows would retain stale authority. +3. **Synthetic TenantContext, bypass credentials or global directory reads** + (rejected). None is the ninth candidate-reader authority ADR-0072/0073 admit. +4. **Authentication-only fallback policy** (rejected). An authenticated actor + could still dispatch an endpoint with no declared capability policy. +5. **Keep every new HTTP read in P03-3** (not selected). This leaves P03-2's + required actual permission-denial and sensitive-disclosure proofs incomplete. + Pull forward only the administrative directory read described below. +6. **Register a new directory-read key and auto-upgrade Tenant Admin** (rejected). + The existing administrative capability suffices. Registration is not a grant; + fixed v1 seed state remains independently verifiable. +7. **Borrow another business-pool connection with only a timeout** (rejected). + Outer owners can hold every connector while awaiting their nested checks. + Timeout bounds waiting but does not remove that circular dependency. +8. **Reuse the outer transaction for nested/post-lock checks** (not selected). + READ COMMITTED sees that transaction's own uncommitted grant/lifecycle writes; + a writable owner also cannot supply physical READ ONLY confinement. A clean + ChangeTracker cannot establish committed-only authority after SaveChanges. + +## Decision + +- Every protected act independently checks current Membership, lifecycle and + registered scoped capability. HTTP and direct application dispatch enforce + explicit policy classification and default deny. +- `PreContextIdentityAuthorizationReader` uses the already accepted ninth + logical setter in a fresh isolated, physically READ ONLY app-role owner on a + dedicated authorization source with four retained-owner slots. + It publishes no TenantContext, directory/profile or new global User projection. +- Successful policy evaluation mints immutable, invocation-bound read authority. + Step 6 binds it to the actual transaction; EF and RLS consume the same scope. + The existing write restrictions and public/AccountSite host ceilings remain. +- P03-2 includes one useful administrative member-directory GET, governed by + existing Tenant `identity.membership.admin` and MUST sensitive-read audit. + Native BFF operations, editing and UI remain P03-3. +- Finite explicit grant edits/adoption remain separate audited acts. No registry, + seed rerun, realm role or SystemActor value implicitly grants authority. + +## Context + +Verified at `daf03a48`: the P03-1 foundation is merged. The existing five scoped +permission pairs, eight templates, two-pair admin kernel, contextual database +guards and subject-confined global binding are real subjects. There is no live +permission registry/evaluator, default-deny endpoint classifier or `app.scope` +carrier. The existing architectural prohibition on any such carrier is an +intentional pre-implementation fence, not a proof of its derivation. + +### Candidate ownership and freshness + +The API supplies normalized identity from the verified-subject/binding stage. +Direct application and asynchronous owners supply their separately enumerated +trusted execution descriptor; passing a UserId alone is insufficient. An exact +candidate includes actor, tenant and optional organization. An isolated child +scope opens READ COMMITTED, awaits physical READ ONLY, then announces that +candidate through a narrow UoW seam. It clears unrelated authority and closes +before the ordinary business owner. It never replaces the parent's unit or +installs an ambient context. Its physical owner/generation is also the one used +by Tenancy's existing `IIdentityScopeLifecycleReader` contract. + +#### Physical capacity and progress + +The authorization source is separate from the ordinary business source. Its +fixed cap is four owners and four physical connectors per API process, with no +waiting admission queue. Claim a slot before creating/opening a connection; +capacity refusal is immediate `503 dependency_unavailable`. Opening, statements, +cancellation and retained actual cleanup all occupy that slot. A five-second +deadline never releases capacity before confirmed disposal. Unconfirmed cleanup +retains its slot; source shutdown joins actual owners before disposal. Tests may +use a smaller cap; no deployment setting widens it. Four provides limited local +concurrency; one slot already removes the circular checkout dependency. Neither +number is a production throughput measurement or fairness guarantee. + +The type-confined source uses the same `learnstack_app` role/grant guard, database +and tenant/organization RLS as ordinary work. It has no raw datasource alias in +general DI and no caller-supplied SQL/callback API. Its owner runs only the exact +Identity grant statement and Tenancy lifecycle statement on the same child unit. +It never acquires the business/evidence source, dispatches a handler, writes an +audit row, invokes a locking owner function, or takes advisory/row locks. Release +it before audit or business continuation. A parent can therefore hold business +connectors and invariant locks while an admitted nested/post-lock read completes. +MVCC reads do not consume the parent's own uncommitted authority. + +The borrower census includes resolver, HTTP, application, nested and post-lock +checks. Trusted seed/event/job classifications remain finite. For job callers, +add an explicitly tracked authorization lane to the existing source owner and +actual-cleanup join; never suppress its ambient marker or masquerade as a second +main unit. Source lifetime and capacity ledgers include the additional four +connectors, independently of the existing business/job-source requirements. + +Client-pool separation alone is insufficient through PgBouncer: both sources +must not share a saturated downstream user/database server pool. The admitted +transaction-pooling topology provides distinct business, authorization and +ADR-0077 evidence database aliases targeting the same database and `learnstack_app` +role, with independent backend lanes. Direct development PostgreSQL uses the +three bounded client sources. Alias/backend limits, PgBouncer cross-alias user/ +database limits, PostgreSQL capacity and the deployed instance count must reserve +the authorization/evidence headroom outside business capacity. Application-name +differences or an extra client pool are not reservation evidence. No new database +login or BYPASSRLS privilege follows. + +Composition uses `ConnectionStrings:Default` for business, +`ConnectionStrings:IdentityAuthorization` for authorization and +`ConnectionStrings:AuthorizationEvidence` for ADR-0077's evidence lane. The two +new sources use explicit guarded strings together; partial input refuses. +Development alone may derive both from the existing direct-PostgreSQL Default +string while keeping physically distinct bounded sources. Outside Development, +both explicit lane strings are required; no silent same-endpoint fallback is +allowed. Through PgBouncer they use the distinct database aliases +`learnstack_identity_authorization` and `learnstack_authorization_evidence`, +mapped to the same actual business database. Qualification checks application +role and the same installed identity authority for all sources; credentials +remain environment-only. A matching authority/alias string is not proof of a +backend reserve: topology evidence remains separately required before production +activation. Modules never branch on deployment mode or choose these endpoints. + +P03-2 proves progress with small real PostgreSQL pools **and** a pinned +transaction-pooling PgBouncer fixture: hold every business connector, then run +admitted nested and post-lock checks before parents release anything. Hold all +authorization slots separately to prove immediate excess refusal and recovery +only after actual cleanup. A deliberate shared-downstream-pool dirty topology +must fail that progress control. These are correctness/activation proofs now; +through PgBouncer, hold actual open server transactions and verify distinct +backend PIDs, not merely checked-out client connectors that may own no server. +Phase 11 owns actual production topology/instance-capacity qualification before +deployment activation, not this packet's missing liveness test. General database +outage/DDL blocking and excess offered load still can refuse or time out. + +#### Committed reads and outcomes + +One Identity-owned statement reads active/nondeleted Memberships, assignments, +Roles and grants for that actor and exact tenant. NULL-org Tenant grants compose +with only the requested organization's grants. Unknown/inactive definitions, +retired Roles and Suspended/Revoked Memberships grant nothing. Coverage and +capability are separate results; membership without a grant can confirm a claim. +The query returns bounded authorization facts, never Profile or raw provider data. + +Tenancy's lifecycle statement is separate: Trial/Active tenant and, when named, +Active/nondeleted belonging organization admit ordinary work. This is **not one +atomic snapshot across modules**. Each statement sees its own committed state. +Revocation committed before an act's grant query affects that admission; +already-admitted work may finish. The earlier subject-binding lookup remains the +global User eligibility check. P03-3 owns lifecycle-transition races and P03-10 +owns closure-side fences before their writers activate. + +Resolver-only evidence cannot authorize a later HTTP/application act. Row-7's +existing organization validator remains a separate narrowing check. AccountSite +gains live membership/lifecycle admission on matching rows 6/9 while retaining +its nonsensitive Off/READ ONLY shape and matching real HostScope requirement. +ClaimAndMembership alone still cannot reach AccountSite. + +Store unavailability is a fixed `503 dependency_unavailable`, not false +noncoverage or an invalid-token 401. Actual noncoverage retains masked 404; +valid coverage without capability is 403. Request cancellation propagates through +owned cleanup. Binding failures retain ADR-0072's separate uniform 401 contract. +Candidate work has a five-second total admission deadline, including pool wait +and statements; cleanup retains ownership until actual completion. No new cache, +retry or configurable authority override is introduced. + +### Policy classification and denial precedence + +Composition freezes exact request/endpoint policies. Public reads, the special +AccountSite read, protected capabilities and finite trusted seed/system operations +are distinct classes. Unknown, conflicting or missing declarations never imply +authority. Application metadata remains in SharedKernel/contracts; module code +does not depend on ASP.NET attributes or API assemblies. HTTP policy metadata +uses registered named ASP.NET requirements and is checked against application +metadata. A missing-classification runtime guard supplements the census. +Infrastructure endpoints also need an explicit classification. No matched +endpoint means no handler to authorize; retain the existing routing 404/405 +instead of applying a fallback that changes every route miss into a challenge. + +Preserve early visitor/host/credential/subject/binding/assertion precedence. +On an admitted protected surface: absent credentials or noncoverage is masked +404; an admitted actor lacking capability gets 403 before handler/resource lookup. +Only an admitted capability may look up a resource. Missing/foreign-scope rows +are 404; a visible row failing an ownership policy is 403. Wrong-method requests +retain routing semantics after existing earlier refusal gates. No path changes +tenant resolution or turns HostOnly into protected authority. + +Direct MediatR dispatch repeats classification/capability checks at step 5; +an HTTP allow is not a reusable permission grant. Unrouted existing production +requests receive exact classifications before pass-through is removed. Seed +authority and transport/job execution retain their existing finite validated +source/request contracts, not a blanket SystemActor exemption. Unknown sources, +requests and Platform capabilities deny. P03-9 still owns Platform admission. + +Portal Public is virtual, with no stored Membership or Role template. Three +explicit Tenant definitions describe only existing anonymous reads: +`tenancy.public_site.read`, `education.public_course.read` and +`education.public_lesson.read`. They map to the four existing PublicSurface +queries, are unavailable as stored Role grants and cannot widen publication, +HostScope, tenant/org or physical READ ONLY bounds. Credential-free public +requests perform no identity/grant lookup. Registration of this virtual class +does not change the five-pair assignable foundation manifest. + +### Operation-bound read scope + +Only a declared tenant-wide read plus an eligible Tenant capability can mint the +tenant read hatch. Branch grants never mint it. The immutable witness binds the +actual actor, tenant/org, request policy and invocation; the step-6 owner binds +its generation and announced values before any data query. It is not an +`ITenantContext` setter, browser flag or handler-writable Boolean. + +EF's parameterized filters and the actual transaction announce the same narrow +or tenant-wide read state. Completion, failure, cancellation and pool reuse clear +it. Mixed read-scope nesting, including a narrower invocation inside a widened +owner, refuses and poisons the unit; a join never retargets scope. Transaction +mode compatibility and ownership remain ADR-0040's existing controls. + +### The minimum production consumer + +`GET /api/v1/identity/memberships` is an administrative directory, not a +self-service profile API. Tenant `identity.membership.admin` authorizes a declared +tenant-wide read, including from a branch session with an eligible NULL-org +Tenant Admin Membership. No Organization grant authorizes it. It returns only +MembershipId, UserId, OrganizationId, status, contextual DisplayName and +PreferredLocale; no provider subject, email, grant graph or global profile. +Only current undeleted Active/Suspended Memberships appear; retired/revoked rows +are outside this initial directory. Module-local IDs cross contracts as Guid +under ADR-0023; UserId/TenantId/OrganizationId retain SharedKernel types. + +Pagination uses an opaque versioned cursor bound to actor/tenant/org, the declared +operation and fixed ordering by MembershipId. Default limit is 20, clamped at 100. +The cursor is bounded to 1,024 encoded/768 decoded bytes with strict canonical +version/field validation, following the existing navigation-codec precedent. +It supplies neither authentication nor snapshot authority: every page rechecks +live policy and applies the actual scoped query, even for a modified anchor. +No search, arbitrary filters/sort or mutation is admitted. Responses are no-store; +success returns only after its MUST `ReadSensitive` row commits on the ordinary +ReadWrite owner. It is not added to the exact physical READ ONLY catalogue. +The fixed input/output contract is recorded in OpenAPI/generated SDK. The native +authenticated caller still permits only AccountSite; P03-3 owns its extension. +The directory admits HostAndClaim and ClaimAndMembership authority, including a +platform host with a confirmed claim. Its declared administrative read can cross +branches within that tenant; PublicSurface/AccountSite HostScope remains an +independent ceiling on those different operations. Host/claim disagreement still +refuses before authorization, and HostOnly never reaches this directory. + +Ordinary grant changes require current Tenant Admin/role-admin authority, +orgless write scope, current delegation ceiling, concurrency and immutable kernel +checks. They cannot grant the actor an authority it does not hold. An explicit +authoring ceiling permits an eligible Tenant Admin with Tenant +`identity.role.admin` to add an exact registered Organization pair only when it +currently holds that same key's Tenant pair. This is an authoring relation, not +permission-identity equivalence or an evaluation fallback. Organization-to-Tenant, +other-key and unregistered-pair implications are forbidden. This preserves +Tenant Admin authorship of Organization templates from an orgless session. + +A separately named release-manifest adoption act can apply only an approved finite +manifest/diff under the same admin gate and MUST audit, never a wildcard or +automatic upgrade. P03-2 proves this mechanism with the retained v1 manifest; +no v2 bundle or new assignable capability is selected here. Unknown manifests +refuse. Fixed v1 seed reruns refuse legitimate differing adopted state rather +than resetting it. Human-facing editing/adoption routes remain P03-3. +The actual production commands are `ChangeRoleGrantsCommand` and +`AdoptRoleGrantManifestCommand`, each with expected Role row version and one +Role root. Current actor/target authority is rechecked after the invariant-lock +wait and before mutation. Success and contained grant changes share their MUST +`identity.role.change_grants` / `identity.role.adopt_manifest` audit transaction. + +Reopen for cached authority, atomic cross-module authorization, new read scopes, +different nested-owner semantics, another public capability or broader HTTP/BFF +operations. Phase 11 owns production load/capacity qualification; local deadline +and query evidence is not a production p95 guarantee. + +## Consequences + +### Positive + +- Revocation and capability checks become real at both HTTP and application seams. +- An existing administrative grant supplies a useful sensitive-read proof. +- Read widening has one derived, disposable lifetime without broadening writes. + +### Negative + +- Protected acts depend on fresh database reads; edge and application checks may + require separate short transactions before the business owner. +- Four authorization and ADR-0077's two evidence connectors add at most six + retained auxiliary connectors per process, with explicit downstream reserves. + Excess admissions can fail immediately; split pools do not guarantee capacity + or fairness for every concurrent request. +- Blocked/failed cleanup can retain slots and delay joined shutdown indefinitely; + no elapsed timeout is evidence that its physical resource was released. +- Lifecycle, binding and grant checks do not share one atomic snapshot; work + admitted before a later revocation may finish. +- The directory exposes contextual personal data to an already privileged tenant + administrator; audit outage prevents otherwise-successful disclosure. +- Mixed-scope nested calls require separate ownership or fail closed. + +### Neutral + +- No new login role, global User reader, RLS template or provider write is added. +- New-user entry, membership editing, native UI and production onboarding retain + P03-3/P03-9 ownership. The admin kernel and fixed seed manifest remain intact. + +## Implementation Notes + +Acceptance appends dated navigation/extension Amendments to ADR-0036/0040/0073 +and ADR-0047's tracked job authorization/evidence lanes, plus a +bounded-supersession Amendment to ADR-0064 for the new directory surface +and AccountSite live admission. Accepted bodies/status/dates remain historical. +Reconcile Standards 04/05/11/19/21, Identity permission/audit contracts, +Tenancy/Education public permission matrices, Architecture 09/13/33 and glossary +in the same first step; recheck the independently repaired `add-permission` +guidance rather than reintroducing its former IModule/default-grants sketch. +The packet record owns exact proposed edit text, call-site census and proofs. +Update Standard 05's source/capacity ledger, Standard 12/Phase 11's production +pool-qualification trigger, and environment/Compose/Seeder setup documentation +for the two explicit lane keys without adding credentials to restore/build. + +## Tests and Proofs + +- Architecture: complete endpoint/request/source census, actual scoped registry, + generated grant/kernel parity and planted missing/forged declarations. +- PostgreSQL app role: exact candidate owner, physical READ ONLY, lifecycle, + coverage/grants, transaction cleanup and real revocation interleavings. +- Liveness: all business connectors held while nested/post-lock reads complete; + independent four-slot exhaustion/actual-cleanup recovery, no observation of + uncommitted parent grants, tracked job cleanup and downstream-PgBouncer dirty/ + clean topologies. A timeout-only result cannot pass the progress control. +- HTTP/direct application: actual directory positive/denied, real resolution + rows 6/7/9/10/14, retained AccountSite/public ceilings and unavailable storage. +- Real grant changes: Tenant Admin's same-key Tenant-to-Organization delegation + succeeds; absent source pair, other-key, Organization-to-Tenant, self-escalation, + kernel removal and post-wait revocation refuse. Finite v1 adoption is separate. +- EF/RLS: identical read widening, no cross-tenant access or org write widening, + hostile/missing/reused witnesses, nested scope mismatch and pool cleanup. +- Existing canonical catalogue statuses advance only after actual proof; proposed + tests do not become Implemented when this ADR is accepted. + +## References + +- [ADR-0036](0036-tenant-resolution-trusted-inputs.md), + [ADR-0040](0040-ambient-unit-of-work.md), + [ADR-0064](0064-tenant-api-authentication-boundary.md), + [ADR-0072](0072-application-owned-identity-bootstrap.md), + [ADR-0073](0073-scoped-membership-and-identity-data-foundation.md). +- [Security](../standards/11-security.md), [Database](../standards/05-database.md), + [Permissions](../standards/19-permissions.md), [Identity](../modules/identity/README.md). +- [Revised P03-2 package](../roadmap/phase-03-delivery-log.md#p03-2-maintainer-review-and-revised-package-2026-10-27). +- [ASP.NET Core 10 policy selection](https://learn.microsoft.com/en-us/aspnet/core/security/authorization/policies?view=aspnetcore-10.0). +- [PostgreSQL 18 READ COMMITTED](https://www.postgresql.org/docs/18/transaction-iso.html#XACT-READ-COMMITTED). +- [Npgsql data sources and pools](https://www.npgsql.org/doc/basic-usage.html), + [PgBouncer pool and database configuration](https://www.pgbouncer.org/config.html). diff --git a/docs/decisions/0077-trusted-authorization-evidence.md b/docs/decisions/0077-trusted-authorization-evidence.md new file mode 100644 index 000000000..b233f4aa4 --- /dev/null +++ b/docs/decisions/0077-trusted-authorization-evidence.md @@ -0,0 +1,364 @@ +# ADR-0077: Trusted Authorization Evidence + +## Status + +Accepted — jointly with ADR-0076 on 2026-10-27. +Runtime implementation remains pending at acceptance. + +**Date:** 2026-10-27 +**Deciders:** @cemil + +**Relationship:** Completes P03-2 G7 and successful claim-only assertion +attribution alongside [ADR-0076](0076-live-scoped-authorization.md). It preserves +ADR-0033/0044 durability and legitimate plural resource intents. It adds a named +denial-evidence population and a separately attributed assertion population, +without extending ADR-0065's host allowance to either. + +**Maintainer acceptance — 2026-10-27.** The maintainer explicitly approves +the revised records and six-step P03-2 package at `725d255a`, including +bounded independent authority/evidence sources, pre-success denial +durability and fixed occurrence reconciliation. The [acceptance record](../roadmap/phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27) +owns the receipt and implementation/review progress. This acceptance grants +the named contract authority; it establishes no runtime, pool, crash or +production-capacity proof. + +## Decision Drivers + +- HTTP policy denials precede the MediatR audit frame; application/resource + refusals may otherwise belong to Off, SHOULD or MAY operations. +- Live membership exposes successful row-14 resolution. Its tenant is confirmed + by Membership, not independently defended by the request host. +- Membership/Role/route/IP provenance must come from admitted execution owners, + not request fields, realm roles or arbitrary metadata dictionaries. +- A sensitive directory read needs durable evidence before response disclosure, + while a failed refusal audit must preserve the caller's existing refusal. + +## Considered Options + +1. **One typed central evidence owner, explicit populations and captured trusted + context** (chosen). Reuse the existing store and common audit lifecycle. +2. **Audit every outer HTTP 401/403/404** (rejected). This duplicates resource + evidence and contradicts deliberate validation/public/tenantless omissions. +3. **Rely on the ordinary operation's classification** (rejected). Off and + override-Off declare no intent; SHOULD/MAY cannot establish MUST evidence. +4. **Promote every normal operation to MUST** (rejected). Denied evidence does not + justify changing successful-read/mutation classification across the corpus. +5. **Reuse the host allowance for claim-only or authorization refusals** + (rejected). It has neither that attribution nor that accepted evidence-loss + policy. A new suppression budget would require a separate explicit decision. +6. **Diagnostic-only successful claim-only assertion mismatches** (not selected). + Confirmed Membership supplies a tenant attribution; this choice retains a + durable per-occurrence record and accepts its cost. +7. **Write absorbed denials only after the outer commit** (rejected). A process + crash loses the in-memory event despite successful business state. An audit + failure then has no precommit success to refuse. +8. **Durable cross-restart invocation ledger** (not selected). No admitted + request-replay owner currently persists/restores policy occurrences. The + pre-success barrier below removes unaudited outer success without claiming + recovery of every uncompleted refusal after a crash. + +## Decision + +The common audit infrastructure owns one typed denial event per refused policy +act, shared by HTTP, application and resource adapters. Its MUST security-event +record supplements, rather than replaces, separately declared resource intents. +The evidence carries immutable, bounded context from trusted execution owners. +Successful claim-only resolution followed by a conflicting assertion records a +distinct per-occurrence MUST event under its Membership-confirmed tenant. +Neither population consumes or inherits ADR-0065's host allowance. +An outer operation that absorbs a denial can succeed only after the required +event is durable: atomically with a ReadWrite commit, or acknowledged standalone +before ReadOnly completion/disclosure. Occurrence identity and decision time are +fixed across that event's admitted writes/reconciliation, with exact duplicate +verification; no persistent HTTP/restart replay identity is implied. + +## Context + +Verified at `daf03a48`: `AuditExecutionCoordinator.DeclareAsync` skips Off and +ordinary unresolved contexts. SHOULD/MAY reconciliation stays best-effort. +`TenantAuthorizationResultHandler` has no common evidence owner. The composer +sets email, IP and user agent to null; Membership/Role/route provenance has no +current capture port. `TenantAssertionMiddleware` routes every resolved header +mismatch into the host-budget recorder without a HostScope distinction. +The deny-all membership reader currently prevents the successful row-14 case. + +### Explicit evidence populations + +| Observation | Evidence owner and durability | +|---|---| +| HTTP protected-policy rejection with trusted resolved tenant | Central denial owner; one `identity.authorization.deny` MUST standalone record; handler never dispatches. | +| Application/resource capability or ownership rejection | Same owner; one denial event for that act, plus each normally declared resource intent with its existing classification/outcome. | +| HTTP allow followed by application/resource deny | Application/resource owner alone records the denial event. An allow is not a denial event. | +| Nested refused act absorbed by an outer success | Inner denial survives independently; outer success does not turn it into successful authorization. | +| Successful claim-only resolution, then conflicting tenant/org header | Central assertion adapter; one `tenancy.assertion.reject_claim_context` MUST standalone record under confirmed tenant; no host allowance debit. | +| Ordinary successful sensitive directory read | Common in-transaction MUST `identity.membership.list` `ReadSensitive` row, committed before disclosure. | + +Each policy invocation has its own identity, not a blanket once-per-HTTP-request +Boolean. Passing its same decided reference between adapters cannot mint a second +event; genuine nested acts retain separate evidence and plural resource intents. +The protected policy catalogue supplies fixed operation/policy IDs. A caller +cannot choose an audit slug, tenant, arbitrary resource payload or attribution. + +Normal operation classification remains unchanged. Thus a denied audited command +may yield its declared resource-attempt rows **and** one authorization event; +these are distinct classified facts, not two adapters recording the same denial. +An Off operation yields only the denial event. Tenant overrides cannot remove +that event. Central coordination retains it across rollback/absorbed nested +refusal and clears its captured state after the owning execution finishes. +The denial population has independent per-event state; the normal business +capture's Committed flag cannot suppress an unflushed event. Its actual enclosing +owner supplies the pre-success durability barrier below; a joiner neither commits +nor clears the population. It does not become an ordinary resource intent. + +### Pre-success barrier and crash boundary + +| Enclosing owner/outcome | Required durability before returning | +|---|---| +| ReadWrite owner would succeed after absorbing a denial | Insert every unresolved denial event on the same business transaction after domain-event draining, beside normal MUST intents and before final commit admission. Failure rolls back business state and refuses outer success with `503 audit_unavailable`. | +| Physical ReadOnly owner would succeed | Acknowledge every unresolved event through its own short evidence transaction before owner completion/disclosure. Failure refuses outer success with `503 audit_unavailable`; no audit INSERT or mode upgrade occurs on the read-only transaction. | +| No business owner; HTTP/application refusal | Attempt the standalone event before returning the refusal; its existing 403/404 survives write failure. | +| Owner refuses, throws, cancels or rolls back | Final reconciliation attempts unresolved events standalone, preserving the actual refusal/exception. | +| Business COMMIT acknowledgement is uncertain | Reconcile the same fixed event; its known denial stays `denied`. Exact evidence proves only the event, never the business COMMIT outcome. | + +Track written/pending, acknowledged, rolled-back and uncertain evidence per event +and actual owner generation. Only a confirmed enclosing commit acknowledges its +in-transaction event. A standalone acknowledgement survives a later outer failure. +The pre-success barrier runs before final event/job commit admission, preserving +the existing no-await gap between that admission and physical completion. +It must also cover an otherwise-successful outer execution without a transaction. + +An acknowledged ReadWrite commit carries the event even if the process exits +immediately afterward. A ReadOnly or ownerless success cannot publish before +the standalone acknowledgement. There is no successful-business-commit/finally- +only write window. A crash before any applicable write commits can still lose an +in-memory refusal whose enclosing operation never completed successfully; there +is no durable recovery queue for that uncompleted act. Already-refused paths keep +ADR-0033 Amendment 1's explicit audit-outage loss posture. This is a pre-success +durability guarantee, not crash-proof collection of every observed refusal. + +### Occurrence identity and exact reconciliation + +The central owner mints one opaque decided occurrence reference: one +`AuditEntryId`, one trusted UTC decision timestamp normalized once to PostgreSQL +microseconds, and frozen tenant/org, actor, policy/subject/reason and bounded +provenance. An in-transaction INSERT, standalone attempt and reconciliation of +that reference reuse the identical `(id,timestamp)` and complete draft. They +never use the ordinary resource intent's fresh reconciliation timestamp. + +The reference lives only for that captured policy occurrence and its owned +cleanup/reconciliation. Re-entry with the same reference is serialized and +idempotent; reevaluating a policy, a real nested invocation, new HTTP request or +new event/job execution is a new occurrence. Correlation/request/route IDs alone +are not an occurrence identity. A restart does not recreate a lost reference or +deduplicate later requests; no new persistent replay ledger is introduced. + +Confined denial-event methods on the existing `IAuditStore`/`PostgresAuditStore` +reuse its one INSERT/parameter binding, catalogue and failure signals. They do +not call generic `WriteStandaloneAsync` and accept its broad 23505 shortcut. +Only duplicate `audit_log_pkey` for the exact `(id,timestamp)` is eligible for +verification. Dispose the failed INSERT transaction, then use a clean READ ONLY +transaction under captured tenant/org RLS to compare every persisted draft field +with null-safe/typed JSONB equality, including fixed event discriminator and +outcome. Missing, mismatched or unreadable evidence is audit failure; an exact +match establishes this event's durability only. No global function, sentinel, +bypass or additional SELECT grant is needed. Ordinary resource reconciliation +keeps its deliberate different-timestamp semantics. + +This exact-match contract is bounded to the live occurrence. If later privileged +redaction changes a compared field, an attempted match fails closed; this is not +an indefinitely replayable original-row witness or an extra redaction privilege. +Unknown commit recovery retains uncertain business state even if event evidence +is found. A timer, returned INSERT count or a 23505 alone proves no commit. + +### Standalone evidence capacity + +The new event methods use a separate type-confined `learnstack_app` evidence +source with two retained-owner slots and at most two physical connectors per +process, independent of the business and four-slot authorization sources. An +admitted evidence operation owns at most one connector at a time, including +sequential duplicate verification. No unbounded admission queue or retry loop +is created. Full capacity is a fixed audit-unavailable failure with the same +refusal/pre-success rules above; it is not a suppression budget or permission +to discard an event and declare success. + +One admitted standalone operation has a shared five-second total I/O deadline, +including open, INSERT/COMMIT and any sequential duplicate verification; entering +verification does not reset it. Actual cancellation/disposal retains its slot +until confirmed cleanup. Failed cleanup retains capacity and shutdown joins +actual owners. The source never +acquires authorization/business resources, dispatches a handler or takes their +advisory/row locks; it only announces captured audit scope, inserts or verifies +the event, and completes. No writer retries against its own still-live transaction. +Job callers use a named tracked evidence lane, not an untracked second main unit. +The datasource/capacity/lifetime census includes this lane. Four/two are bounded +local policy choices, not production sizing. ADR-0076 owns downstream transaction- +pool isolation/reservation and the required small-pool/PgBouncer progress proof. + +Earlier credential/unknown-host/malformed assertion and validation refusals keep +their existing behavior; this is not a generic 404 audit. Preserve row-7's host +recorder, unaudited row-10 and refused row-14 diagnostic-only contracts. Public +anonymous and tenantless refusals do not invent a tenant or use the platform +sentinel. AccountSite remains Off on success; live membership denials attributable +to a resolved tenant use the new denial event. A resolver refusal before context +publication retains its existing row-specific evidence contract. + +Claim-only assertion attribution requires an actual successful +ClaimAndMembership context from the production reader, not a candidate tenant, +raw claim or asserted header. The source is fixed as membership-confirmed header +assertion; raw assertion values are not copied into diagnostics. Its new +evidence includes only the fixed tenant/org dimension and mismatch reason, +confirmed scope and admitted actor, not the caller's asserted UUID. Host-backed +rejections keep their existing budget/source contract. This new slug is neither +refused row-14 membership evidence nor a host-tenant event. + +### Immutable enrichment and its limits + +Trusted API, seed, event and job adapters supply a typed immutable provenance +snapshot; handlers cannot write a free-form context dictionary. The central +composer merges it with its own exact operation/subject facts before INSERT. +No enrichment UPDATE or new Identity audit table is introduced. + +- Actor comes from the verified binding or the enumerated finite source owner. + Membership/Role IDs come only from that act's live authorization result. + The current model permits at most two contributing Memberships and eight + contributing built-in Role IDs; excess/ambiguous evidence fails closed rather + than silently truncating authority. Sort canonical IDs for stable evidence. +- Store these references, fixed policy/route identifiers and source kind in a + typed versioned `metadata.authorization` object. Do not copy grants, bodies, + query strings, raw path values, provider claims or credentials. Retained IDs + remain personal linkage, not anonymous. Each row keeps its OperationType's + target retention policy under + [Standard 18](../standards/18-audit-coverage.md#retention): denial/assertion + SecurityEvent seven years, directory ReadSensitive two years. These are policy + targets; no automatic purge, retention column or tenant retention override is + implemented today. Phase 11 owns that machinery, so data can outlive the target + until the separately admitted purge/retention work exists. + Metadata is outside the existing six-column redaction privilege. P03-10's + Audit participant locates it through retained actor/subject attribution and + reports surviving references; no new metadata UPDATE privilege is implied. +- The already admitted visitor/transport snapshot supplies the trusted effective + address, falling back to the physical peer. Do not parse forwarded headers + again. Existing `inet`/user-agent columns carry these redactable fields. + User agent is untrusted descriptive data, sanitized to 256 characters with + controls removed; it supplies no authority. No raw user-agent enters log labels. +- Route identity is an admitted template/descriptor, at most 128 characters, + never a raw URL. Direct application dispatch has no invented HTTP route/IP/UA. + An inner operation retains its own policy/subject while preserving source + provenance. Child scopes cannot inherit another actor's evidence. +- ActorEmail remains null: P03-1 intentionally has no authoritative email mirror. + P03-6/G10 or an earlier separately accepted source/freshness contract owns it. + Platform/operator flags cannot become true from claims or a SystemActor UUID; + P03-9 owns their actual privileged population. +- Existing event/job IDs and causal actor remain separate from effective actor. + The source-kind catalogue enumerates HTTP, seed, event and job owners and their + cleanup sites; unknown/direct-untrusted source receives no forged provenance. + +The early audit frame may declare intents before authorization. Trusted evidence +is attached by the central owner when the act has an authorization result, not by +making the business handler populate or replace its actor. HTTP early denials +use the same typed composer through a short standalone owner; application +denials are retained by the common frame, included in its pre-success barrier +and reconciled independently of the business outcome on failure/uncertainty. +Legitimate plural intents remain associated with their exact invocation, not an +ambient last-result slot. + +### Failure and capacity cost + +An already-refused caller preserves its decided 403/404 on audit failure. An +outer execution that absorbs that refusal and would otherwise succeed instead +fails its pre-success barrier before business COMMIT/result disclosure if the +required event cannot become durable. There is no postcommit downgrade of an +otherwise-successful committed mutation merely because a finally write failed. +The existing MUST store owns Critical/counter/health reporting; an independent +composer refusal also has one fixed Critical signal. Do not attach a raw provider +or database exception. An otherwise-successful sensitive read fails closed with +503 and no result payload if its MUST row cannot commit. + +These new events have **no aggregate suppression budget**. Existing visitor and +subject quotas bound individual sources, not the total durable population. +At 600 denied acts per minute one subject can generate 864,000 denial rows/day, +in addition to any separately classified resource attempts. An illustrative +1,000 bytes/row is about 864 MB/day before indexes/WAL/replication; real size varies. +The existing security-event retention policy target applies; its automatic purge +remains unimplemented until Phase 11. This is an explicit operational +cost, not a production capacity measurement. Phase 11 owns measured capacity, +distributed admission and alert/runbook hardening; its work does not waive the +current durability rule. A suppression policy or reduced retention reopens this +decision before implementation. + +Reopen for larger/custom role populations, a new provenance source, new personal +metadata, privileged operator admission or a different denial-loss policy. + +## Consequences + +### Positive + +- Actual HTTP, application and resource denials have a named evidence owner. +- Off/best-effort ordinary operations cannot silently remove the denial floor. +- Claim-only attribution no longer masquerades as a defended host tenant. + +### Negative + +- Per-occurrence denial evidence can add substantial database/retention cost. +- Two evidence slots can saturate; an absorbed refusal then can prevent outer + success. A crash can lose an uncompleted refusal before any write commits; + there is no persistent invocation ledger or universal restart deduplication. +- Blocked/failed cleanup retains evidence capacity and can delay joined shutdown; + the five-second I/O budget is not a cleanup-completion guarantee. +- Retained Membership/Role linkage remains personal data; local BFF events still + have no durable LearnStack session ledger under ADR-0070. +- IP and sanitized user agent are contextual evidence, not proof of a person. + +### Neutral + +- General mutation/sensitive-read durability and plural resource intents survive. +- No Platform grant, broad sentinel writer, email mirror, cache or new audit + suppression budget is introduced. + +## Implementation Notes + +Acceptance appends dated extensions in ADR-0033/0036/0044 and a dated boundary +navigation note in ADR-0065, plus ADR-0047's tracked auxiliary-lane extension. +The ADR-0033/0044 additions explicitly delimit fixed-key denial reconciliation +from ordinary different-timestamp resource reconciliation, and name the enclosing +owner's pre-success barrier. No historical body/status/date is rewritten. +Reconcile Standards 10/11/18/21, Identity/Tenancy/Audit matrices, Architecture +13/23/31/33, glossary, enrichment/composer XML and the complete owner/caller +census before activation. ADR-0076's execution and this evidence path activate +together; accepting either record alone opens no protected surface. + +## Tests and Proofs + +- Real HTTP and direct application policy denials, resource refusals and nested + absorbed refusals produce their exact distinct event/resource populations. +- Off/SHOULD/MAY/override-Off operation cases retain the MUST denial floor; + replay/re-execution, rollback and capture cleanup preserve exact ownership. + An absorbed nested Off denial under a physical READ ONLY owner still writes + its standalone event before success without joining that read-only transaction + or losing it to the outer committed-state shortcut. +- Real ReadWrite outer success carries its absorbed event atomically; process + termination immediately after COMMIT cannot remove it. Event-store/composer + failure prevents outer commit; ReadOnly standalone failure prevents disclosure. +- Unknown COMMIT, exact/missing/mismatched duplicates, wrong constraint, stable + microseconds, two adapters/one reference versus two genuine occurrences, and + evidence-source saturation/cancellation/cleanup have separate dirty controls. +- Real app-role directory read succeeds only with committed MUST evidence; + forced store/composer/commit failures disclose no result and preserve refusals. +- Successful row 14 followed by tenant/org header mismatch writes only the + decided tenant/provenance and consumes no host-budget allowance. Existing + row-7/10/refused-14 and AccountSite/anonymous controls remain independent. +- Planted spoofed actor/roles/IP/operator/route and sensitive canaries, direct + non-HTTP execution, two scopes/nested frames and dirty source-call controls + establish provenance; a populated metadata column alone proves nothing. + +## References + +- [ADR-0033](0033-audit-durability-model.md), + [ADR-0036](0036-tenant-resolution-trusted-inputs.md), + [ADR-0044](0044-audit-write-path.md), + [ADR-0065](0065-bounded-tenant-assertion-diagnostics.md), + [ADR-0072](0072-application-owned-identity-bootstrap.md). +- [Audit coverage](../standards/18-audit-coverage.md), + [Audit subsystem](../architecture/31-audit-subsystem.md), + [Data Protection](../architecture/23-data-protection.md). +- [Revised P03-2 package](../roadmap/phase-03-delivery-log.md#p03-2-maintainer-review-and-revised-package-2026-10-27). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 288a81c90..4aee7b39c 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -118,6 +118,8 @@ records hosted qualification. Phase 11 owns production qualification. | 0073 | [Scoped Membership and Identity Data Foundation](0073-scoped-membership-and-identity-data-foundation.md) | Accepted jointly with ADR-0072 on 2026-10-25: contained Membership state, tenant-wide Role templates, finite manifests, fresh administration invariants and semantic PII census. No live authorization or production first-admin path is activated by acceptance. | | 0074 | [Membership Admission Under Ordered Locks](0074-membership-admission-under-ordered-locks.md) | Accepted 2026-10-26: creation/reactivation and every assignment addition retain User-before-tenant fences; the real mutation trigger freshly verifies eligibility under the complete preheld plan. No reader, scope or grant is added; the [Phase 03 Status block](../roadmap/phase-03-identity-admin.md) owns current implementation and qualification state. | | 0075 | [Ordinary CI Job Window](0075-ordinary-ci-job-window.md) | Accepted 2026-10-27: ordinary-only twenty-five-minute whole-job and expected-owner observation bounds, with own-record ADR-0062/0063 amendments. Other ceilings, counts, assignment, reserves and resource policy stay fixed; the [Phase 03 Status block](../roadmap/phase-03-identity-admin.md) owns implementation and fresh A/B qualification state. | +| 0076 | [Live Scoped Authorization](0076-live-scoped-authorization.md) | Accepted 2026-10-27: bounded independent authorization source, explicit HTTP/application policies, operation-bound SQL/EF read scope and one administrative directory GET. Runtime implementation/qualification remains P03-2. | +| 0077 | [Trusted Authorization Evidence](0077-trusted-authorization-evidence.md) | Accepted 2026-10-27: central denial owner, pre-success durability barrier, fixed occurrence reconciliation, trusted enrichment and separate claim-context assertions. Runtime implementation/qualification remains P03-2. | ## Proposed ADRs diff --git a/docs/glossary.md b/docs/glossary.md index f06f08c22..c0c04b1f5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -50,6 +50,10 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Subject quota** | Additional API request budget keyed on validated issuer and subject, stable across access-token rotation and tenant selection. It does not replace the earlier visitor/peer quotas or promise independent end-to-end throughput. Accepted [ADR-0064](decisions/0064-tenant-api-authentication-boundary.md) owns the decision; P02b-5 implements it. | | **Subject admission registry** | API-instance singleton retaining at most 4096 validated issuer/subject quota partitions, shared across tenants and hosts. Accepted [ADR-0066](decisions/0066-bounded-tenant-subject-admission.md) bounds idle-only retirement ownership and distinguishes spent quota, registry capacity and sticky cleanup fault. Acquisition can synchronously dispose one limiter; a blocked disposal can indefinitely delay its request and joined shutdown. Its implementation has local runtime/exported proofs; the [implementation record](roadmap/phase-02b-delivery-log.md#adr-0066-acceptance-and-implementation-2026-10-20) owns independent reviews and final-source CI qualification. | | **ExplicitReadOnlyRequestCatalog** | Singleton immutable exact-type set built from composition declarations. Every MediatR transaction behavior shares this snapshot; naming or a request-supplied flag never selects physical read-only mode. See [ADR-0064](decisions/0064-tenant-api-authentication-boundary.md#closed-authenticated-read). | +| **Authorization read owner** | Confined fresh app-role READ ONLY owner for verified candidate Membership/grants and lifecycle facts. Its independent bounded source and progress contract are defined in [Database Standards](standards/05-database.md#authorization-and-evidence-source-ledger), accepted under [ADR-0076](decisions/0076-live-scoped-authorization.md). | +| **Operation read authority** | Immutable permission-derived witness bound to one declared application invocation and its actual transaction; it can widen only that operation's read scope. [Database Standards](standards/05-database.md#operation-bound-read-authority) owns mint/consume, nesting and cleanup. | +| **Authorization denial occurrence** | One decided policy act with centrally minted identity, fixed decision time and captured attribution, distinct from its ordinary resource audit intents. [Audit Coverage](standards/18-audit-coverage.md#trusted-authorization-evidence) owns its pre-success durability and exact evidence contract; a new request/restarted execution is not automatically the same occurrence. | +| **Portal Public capability** | Virtual, nonassignable classification for the exact admitted anonymous public reads, with no stored Membership or Role. [Permissions](standards/19-permissions.md#live-permission-enforcement-contract) owns its finite definitions; it grants no protected or widened scope. | ## Content & Pages diff --git a/docs/modules/audit/audit.md b/docs/modules/audit/audit.md index 334fac033..e95ae37f9 100644 --- a/docs/modules/audit/audit.md +++ b/docs/modules/audit/audit.md @@ -67,9 +67,9 @@ describes does not already carry. `audit_log` is append-only, so a row's existen itself the record that it was written. **No row for a failed write.** When a MUST-class row cannot be written durably the -operation is **rejected** — the caller sees `audit_unavailable`, and the failure is a -log line and a health-check signal, not a row in the table that just proved it cannot -accept one ([ADR-0033](../../decisions/0033-audit-durability-model.md)). A matrix entry +otherwise-successful operation is **rejected** with `audit_unavailable` before +commit/disclosure; an actual refused caller retains its own refusal. The failure +is a log/health signal, not a row in the table that cannot accept one ([ADR-0033](../../decisions/0033-audit-durability-model.md)). A matrix entry here would describe a write that by construction cannot happen. **No row for reading `audit_config`.** The classifier reads it on every audited request @@ -102,12 +102,32 @@ when it does. ## Retention -Audit rows for this module take the same retention class as every other MUST-class row — -[Audit Subsystem § 9](../../architecture/31-audit-subsystem.md) is the authority, and no -class is defined here. The two `(off-path)` rows are the ones a retention policy must -never expire ahead of the rows they describe: a purge record that outlives nothing is a -record of a deletion whose subject is gone, which is the only durable evidence that the -deletion happened. +Each row takes its OperationType/domain's target from +[Standard 18 Retention](../../standards/18-audit-coverage.md#retention), not a blanket +MUST-class period. Automatic purge and retention overrides remain unimplemented +until Phase 11, so rows can outlive their targets. The planned purge/redaction +security events preserve evidence of the maintenance act; retention machinery +must apply the canonical policy when admitted. + + + +## Authorization evidence + +Jointly Accepted [ADR-0076](../../decisions/0076-live-scoped-authorization.md) / +[ADR-0077](../../decisions/0077-trusted-authorization-evidence.md) extend the common +store/composer, not this module's operation catalogue. The sole denial/directory/ +grant rows belong to [Identity](../identity/audit.md); the claim-context assertion +row belongs to [Tenancy](../tenancy/audit.md). [Standard 18](../../standards/18-audit-coverage.md#trusted-authorization-evidence) +owns their event/resource populations, fixed occurrence/time/full-row verification, +pre-success barriers, failure signals and immutable provenance. The common +store/composer implement these paths; +[Phase 03](../../roadmap/phase-03-identity-admin.md) owns source-bound qualification. + +P03-10's Audit participant locates authorization Membership/Role metadata through +retained actor/subject attribution and reports surviving references. Metadata +remains outside the six-column redaction grant; neither new UPDATE authority nor +complete anonymisation follows. The canonical [data-protection ownership](../../architecture/23-data-protection.md#erasure-authority-and-first-writer-prerequisites) +applies, with each row's own retention target. ## Mapped-property personal-data declarations diff --git a/docs/modules/audit/permissions.md b/docs/modules/audit/permissions.md index 45b864572..90dfc55a9 100644 --- a/docs/modules/audit/permissions.md +++ b/docs/modules/audit/permissions.md @@ -6,10 +6,10 @@ Part of the [module spec](README.md). **No permission keys are enforced 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). These Audit keys await -the separately admitted P03-2 registry/live-enforcement contract and the protected -query API in [Phase 03](../../roadmap/phase-03-identity-admin.md). The implemented -Identity foundation registry does not activate these forward declarations or -introduce an `IModule` interface. +their own admitted operation decisions and protected query API in +[Phase 03](../../roadmap/phase-03-identity-admin.md). The implemented +[live contract](../../standards/19-permissions.md#live-permission-enforcement-contract) +does not activate these forward declarations or introduce an `IModule` interface. | Resource | read | write | delete | admin | Default role grants | |----------|:----:|:-----:|:------:|:-----:|---------------------| @@ -36,11 +36,9 @@ Platform-scope key rather than a tenant-facing one. **That key's name is pending registers it with the permission registry and the erasure handler, in the closed action set [Permission Standards](../../standards/19-permissions.md) fixes, and Phase 11's purge takes the same key or a sibling Phase 03 names — so no writer of `audit_log` lands with a -permission nobody has declared. A -`write` on `event` would be a key a tenant admin could hold and a database grant would -refuse: `learnstack_app` holds `SELECT, INSERT` on `audit_log` and no request path -inserts through it. Two layers disagreeing about what is permitted is how a permission -matrix stops being read. +permission nobody has declared. Tenant-facing audit-row creation is not an admitted operation. +`learnstack_app` holds `SELECT, INSERT` on `audit_log` for the central store; that +database grant does not create an assignable `audit.event.write` capability. **Export is a sub-resource, because the action set is closed.** `export` is not one of `read | write | delete | admin`, and @@ -57,29 +55,24 @@ roles hold `SELECT` on `audit_config` in this packet; the `INSERT, UPDATE, DELET for `learnstack_app` lands in [Phase 06](../../roadmap/phase-06-renderer-admin-studio.md)'s migration, beside the Studio command that needs it ([Database Standards § GRANT matrix](../../standards/05-database.md)). The keys are -declared now because the matrix is what Phase 03's registry reads, and a key that -appears at the same time as its command is a key nobody reviewed. +proposed here for review; compiled definition sources register only the finite +pairs approved by each owning operation decision. **What `config` write cannot do.** A tenant admin holding `audit.config.write` can narrow a SHOULD or a MAY and cannot remove a MUST: `ClassifyAsync` applies the override and then re-applies the in-process catalogue's floor -([ADR-0033](../../decisions/0033-audit-durability-model.md)). This is the one place in -the corpus where a permission and a database grant are both present and still do not -add up to the capability the key's name suggests — deliberately, because an attacker who -compromises one tenant admin must not be able to switch off the detector that would -catch the next cross-tenant probe. +([ADR-0033](../../decisions/0033-audit-durability-model.md)). Any future permission +and database grant must preserve that MUST floor; neither may authorize disabling +baseline audit coverage. -**Scope.** `event_export` and `config` are **Tenant**-scope. `event.read` is registered -Tenant-scope too, for the investigator: a tenant admin reads the trail whole, because an -organization-scoped view would hide exactly the cross-organization act an investigation -looks for. **It is also grantable through an organization-scoped role**, which is how -[Security Standards](../../standards/11-security.md) and the Phase 06 audit viewer give an -`Org Admin` their own organization's rows: per -[Permission Standards § Scope](../../standards/19-permissions.md), an organization-scope -binding requires the holder's organization to match the row's `organization_id`, so that -reader sees its organization's rows and neither another organization's nor the tenant-wide -ones. That confinement is **authorization**, decided by the binding, never a filter the -caller chooses: an organization parameter on the query narrows what a tenant-scope reader -asks for and widens nothing for anyone. [Phase 03](../../roadmap/phase-03-identity-admin.md) -builds the read API and enforces both bindings; nothing reads `audit_log` from a request -until then. +**Scope.** The proposed `event_export` and `config` capabilities are Tenant-scope. +The planned audit reader must distinguish tenant-wide investigation from an +Org Admin's own-organization view. Its owning +[Phase 03](../../roadmap/phase-03-identity-admin.md) operation decision must approve +the exact scoped definitions, finite registrations and admission rules before the +protected query API ships. Under +[Permission Standards § Scope](../../standards/19-permissions.md#scope-platform--tenant--organization), +Tenant and Organization definitions are distinct; a Tenant definition cannot be +assigned to an Organization-capability Role. Query parameters may narrow an +admitted read and never supply authority. This matrix registers or grants no +Audit capability. diff --git a/docs/modules/customization/permissions.md b/docs/modules/customization/permissions.md index 6b777139e..fc37bb075 100644 --- a/docs/modules/customization/permissions.md +++ b/docs/modules/customization/permissions.md @@ -15,11 +15,11 @@ P02d-4. [The module contract](README.md#primary-read-flow-resolving-a-tenants-sh **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). 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. +[Permission Standards](../../standards/19-permissions.md). Its adopted module-source +contract registers scoped definitions separately from finite grants; no `IModule` +API is implied. Identity's foundation definitions/Role templates are implemented. +Customization keys and their live consumers require their owning operation +decision; these examples register or grant nothing. | Resource | read | write | delete | admin | Default role grants | |----------|:----:|:-----:|:------:|:-----:|---------------------| @@ -58,19 +58,20 @@ same operation as MUST-audit; a matrix with no `delete` disagreed with both. Publication has none, because unpublishing is not deletion: the successor's publish is what retires a revision. -**Six write commands are unrouted.** The current route set exposes none of -them, and permission enforcement remains Phase 03. The four authoring commands -are called by the Seeder. The two default-initialization commands are shared by -the Seeder and the transport-owned Customization subscriptions; they take tenant -authority only from the restored trusted context. Hub provisioning emits the -completed-tenant event and does not directly invoke a Customization HTTP writer. -The planned Studio editors below own human-facing authorization. +**Six write commands are unrouted and have finite source admission.** +`CustomizationOperationPolicySource` binds four authoring commands and four +verification queries to Seed authority. The two default initializers admit only +their declared Seed or transport-owned event-initializer source/request bindings +under the [live contract](../../standards/19-permissions.md#live-permission-enforcement-contract). +Hub provisioning emits the completed-tenant event and does not directly invoke a +Customization HTTP writer. The planned Studio editors below own human-facing +permission keys and admission. Every command takes its tenant from context rather than request input. RLS and -`Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck` protect the database write; -reachability is the current authorization boundary, not a new permission grant. -The [Step 5 record](../../roadmap/phase-02b-delivery-log.md#p02b-3-step-5--useful-defaults-and-semantic-seed-handoff-2026-10-13) -owns implementation validation/review status. +`Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck` independently protect the +database write. Finite source admission registers no assignable permission key. +The [Phase 03 Status block](../../roadmap/phase-03-identity-admin.md) owns current +source-bound qualification. **Every key in the `{module}.{resource}.{action}` form.** The resources above are written PascalCase as the template writes them; the keys they generate are diff --git a/docs/modules/education/permissions.md b/docs/modules/education/permissions.md index 07963d776..c6445ddc1 100644 --- a/docs/modules/education/permissions.md +++ b/docs/modules/education/permissions.md @@ -2,11 +2,12 @@ **Status:** Accepted design — 2026-09-14, with the [module spec](README.md). -P02d-2 Step 3 implements six unrouted commands and contextual verification queries. -These writers/verification queries have no HTTP endpoint or permission registration; -their reachability boundary follows -the [Tenancy precedent](../tenancy/permissions.md), with tenant and organization derived -from the execution context. Database isolation is already required for every table. +P02d-2 Step 3 implements six unrouted commands and two verification queries. +`EducationOperationPolicySource` binds these requests to finite Seed authority +through the [live contract](../../standards/19-permissions.md#live-permission-enforcement-contract). +They have no HTTP endpoint or assignable permission registration. Tenant and +organization derive from execution context; database isolation independently +protects every table. Human-facing authoring remains Phase 05. The current resource/action matrix uses the [Permission Standards template](../../standards/19-permissions.md#permission-matrix-template): @@ -16,19 +17,31 @@ The current resource/action matrix uses the | `Course` | — | — | — | — | None | None | | `Lesson` | — | — | — | — | None | None | -Here **—** means no registered Education permission exists. This describes the current -surface, not the actions or grants a later authoring decision must choose. +Here **—** means no assignable Education permission exists. This describes the +current authoring surface, not the actions or grants a later authoring decision +must choose. | Surface | Registration and reachability | |---|---| | Course and lesson seed writes | P02d-2; no HTTP route and no registered permission yet | -| Anonymous content reads | P02d-4 Step 3 implemented GET/HEAD marked reads; matching host ceiling intersected with normal filters/RLS, no permission key or grant; credentials never widen visibility | +| Anonymous content reads | P02d-4 Step 3 implemented GET/HEAD marked reads; accepted ADR-0076 virtual public definitions below preserve matching host ceiling, normal filters/RLS and publication predicates; credentials never widen visibility | | Authenticated authoring | Phase 05; uses the permission infrastructure from Phase 03 | The matrix gains permission keys, scopes and default-role grants with the corresponding command-surface decision, under [Permission Standards](../../standards/19-permissions.md), before any permission is registered. The reachability table grants no capability and -introduces no permission key ahead of its decision. +introduces no assignable permission key ahead of its decision. + +Accepted [ADR-0076](../../decisions/0076-live-scoped-authorization.md) classifies +the existing anonymous reads with Tenant `education.public_course.read` and +`education.public_lesson.read`. These Portal Public definitions are virtual and +unavailable as stored Role grants. They add no Membership, Role template or +authoring capability and require no identity/grant lookup for credential-free +reads. Their policy classification preserves the physical READ ONLY boundary. +[Permission Standards](../../standards/19-permissions.md) owns the virtual class; +the [Phase 03 Status block](../../roadmap/phase-03-identity-admin.md) owns its +runtime implementation and qualification. Acceptance changes no current route +or grant. The [accepted P02d-2 contract](README.md#p02d-2-accepted-writer-contract) names six implemented unrouted write commands and contextual verification queries; diff --git a/docs/modules/identity/README.md b/docs/modules/identity/README.md index d81385a55..3ff880993 100644 --- a/docs/modules/identity/README.md +++ b/docs/modules/identity/README.md @@ -7,8 +7,13 @@ and [ADR-0073](../../decisions/0073-scoped-membership-and-identity-data-foundati 2026-10-25. The global User/authority model, internal binding/adoption consumers and central creation owner exist. Contextual tables/commands/manifests below are implemented; the [Phase 03 Status block](../../roadmap/phase-03-identity-admin.md) -owns current qualification and review state. New -HTTP/native-user activation remains P03-3. +owns current qualification and review state. Jointly Accepted +[ADR-0076](../../decisions/0076-live-scoped-authorization.md) / +[ADR-0077](../../decisions/0077-trusted-authorization-evidence.md) define P03-2 live +authorization and one administrative directory GET. Its confined reader, +canonical policy/evaluator, central denial evidence and HTTP directory are wired +in the API composition. New-user/native editing +activation remains P03-3. The ordered admission timing/observation contract additionally derives from [ADR-0074](../../decisions/0074-membership-admission-under-ordered-locks.md), Accepted 2026-10-26. The phase Status block owns qualification and review state. @@ -57,8 +62,8 @@ The internal binding service uses explicit startup qualification and fresh, physically read-only existing-binding transactions; it never creates a missing binding. Fixed seed adoption separately requires its confined admission port. Real provider/app-role, schema/body/ACL/caller and physical outcome controls are -implemented. Closure, directory access, -production first-admin and issuer rebind remain their named later owners. +implemented. The directory contract appears below; closure, +production first-admin and issuer rebind retain their named later owners. ### Aggregate and storage contract @@ -232,12 +237,64 @@ Yoga admin `b001` keeps its existing provider claim for default studio `b1`. Its seed Membership declaration independently selects NULL organization, and the seed transaction derives NULL SQL organization scope from that trusted target; the authentication manifest does not choose the Membership write scope. -After live authorization ships, a branch session may consume eligible Tenant -grants for branch operations only. Tenant-wide Membership/Profile/template +A branch session may consume eligible Tenant grants for branch operations and +explicitly declared tenant-wide reads. Tenant-wide Membership/Profile/template writes require an explicitly selected orgless context through P03-3's validated reissue transition. Neither the existing branch claim nor `app.scope` promotes writes; no extra branch-admin Membership or provider-claim rewrite is required. +### Accepted live authorization and directory contract + +**Live policy, central evidence and the administrative directory are implemented.** +The policy/evaluator consumes the confined reader/source through the shared +decision port; common Application gains no Identity module dependency. The +directory uses the real EF read adapter and common MUST write path. API and +application checks each read fresh committed authority before dispatch. +`CommittedIdentityAuthorizationReader` coordinates the module statements from +Application; its Infrastructure session factory retains the exact Identity SQL +and physical child owner. The reader consumes an +exact verified actor/tenant/optional-organization candidate in an isolated child +owner. It returns bounded current Membership/Role/grant facts and consumes +Tenancy's lifecycle contract, without a profile/global directory, synthetic +TenantContext or parent transaction reuse. The grant and lifecycle statements +have separate committed snapshots; revocation committed before the grant query +affects that admission, while already-admitted work can finish. Binding remains +the earlier global User eligibility check. + +HTTP and direct application dispatch independently classify and authorize each +protected act. Explicit public, AccountSite, protected and finite seed/system +policies default deny unknown/missing declarations. Resolver membership confirms +scope but does not authorize a later protected act. Public requests remain free +of identity/grant lookups; AccountSite keeps its separate real HostScope ceiling +and Off/physical ReadOnly mode while gaining live membership/lifecycle admission. + +The accepted administrative directory uses existing Tenant +`identity.membership.admin`, with no new grant or v2 seed bundle. It admits +HostAndClaim or confirmed ClaimAndMembership, never HostOnly. Its declared +tenant-wide read can use an eligible NULL-org Tenant Admin Membership from a +branch session. It returns current undeleted Active/Suspended Memberships and +only MembershipId, UserId, OrganizationId, status, contextual DisplayName and +PreferredLocale. Provider subject, email, global profile and grant graph are +excluded. The [API contract](../../standards/04-api-design.md) owns exact route, +DTO/cursor/no-store rules; the native BFF operation allowlist remains P03-3 work. + +Its MUST `identity.membership.list` row commits on the ordinary ReadWrite owner +before disclosure; it is not a physical ReadOnly-catalogue query. The implemented +`ChangeRoleGrantsCommand` and `AdoptRoleGrantManifestCommand` each mutate one Role +with contained grants, expected version, current post-lock authority and same- +transaction MUST audit. [Permissions](permissions.md) owns their exact bindings +and delegation ceiling; unknown manifest, self-escalation or kernel removal +refuses. Fixed v1 seed reruns still refuse legitimate differing adopted state. +Human-facing grant-editing/adoption routes remain P03-3 work. + +[Standard 05](../../standards/05-database.md#authorization-and-evidence-source-ledger) +owns the dedicated four/two authorization/evidence lanes, tracked job ownership, +retained cleanup and required direct/PgBouncer progress proofs. Its +[read authority contract](../../standards/05-database.md#operation-bound-read-authority) +owns invocation/transaction lifetime and EF/RLS agreement. [Standard 18](../../standards/18-audit-coverage.md#trusted-authorization-evidence) +owns denial occurrences, immutable provenance and all pre-success barriers. +No pool, crash, permission or disclosure proof is claimed by acceptance alone. + ## Entity-relationship diagram ```mermaid @@ -334,13 +391,15 @@ framework domain-event dispatch does not imply a published contract. [Identity permissions](permissions.md) binds production seed commands to the exact [accepted Standard 19 manifest](../../standards/19-permissions.md#proposed-identity-foundation-contract). -They are implemented in Step 4. Capability metadata activates -neither live permission behavior nor HTTP routes. +The seed commands are implemented in P03-1 Step 4; the separately marked P03-2 +bindings are implemented. Capability metadata alone grants neither permission +nor a route; canonical admission and exact endpoint declarations enforce both. ## Audit coverage matrix [Identity audit](audit.md) declares implemented root/child classifications and -personal/reference paths. The central writer owns durability; Identity has no +personal/reference paths and the implemented authorization event/read/grant rows. +The central writer owns durability; Identity has no audit table/direct audit SQL. Global mutation audit follows ADR-0072 alone. ## Performance budget @@ -354,13 +413,15 @@ no production capacity. The distinct retains its own control and scope. Standard 05 owns bounded lock waits, cancellation and metrics. Timeout yields a fixed retryable failure without -partial grants. P03-2 measures candidate-reader/authorization costs before -activation; a global permission cache/session grant cannot replace a fresh check. +partial grants. Candidate-reader/authorization cost measurements belong to the +[Phase 03 qualification](../../roadmap/phase-03-identity-admin.md); a global +permission cache/session grant cannot replace a fresh check. ## Risks and open questions -Branch-specific Role configuration reopens ADR-0073. P03-2 owns live delegation, -default deny/revocation and reader handoff; P03-3 scope transitions/DTOs; P03-4 +Branch-specific Role configuration reopens ADR-0073. Accepted ADR-0076/0077 own +live delegation, default deny/revocation, reader handoff and trusted evidence; +P03-2 implements/proves them; P03-3 scope transitions/DTOs; P03-4 invitations; P03-5 dynamic values; P03-9 production first-admin/recovery; P03-10 closure/locators. These are named later writers, not implicit foundation authority. diff --git a/docs/modules/identity/audit.md b/docs/modules/identity/audit.md index 3a313c987..d72a6345a 100644 --- a/docs/modules/identity/audit.md +++ b/docs/modules/identity/audit.md @@ -1,15 +1,22 @@ # Identity — Audit Coverage Matrix -**Status: Global and contextual seed writers implemented.** Current qualification +**Status: Global/contextual seed writers, internal directory and finite Role grant acts implemented.** Current qualification and review state lives in the [phase Status block](../../roadmap/phase-03-identity-admin.md). The User off-path catalogue, contextual requests and persistence exist. Derived from jointly Accepted [ADR-0072](../../decisions/0072-application-owned-identity-bootstrap.md), [ADR-0073](../../decisions/0073-scoped-membership-and-identity-data-foundation.md) -and [Standard 18](../../standards/18-audit-coverage.md). Part of the [module spec](README.md). +and jointly Accepted [ADR-0076](../../decisions/0076-live-scoped-authorization.md) / +[ADR-0077](../../decisions/0077-trusted-authorization-evidence.md), with +[Standard 18](../../standards/18-audit-coverage.md). Part of the [module spec](README.md). The baseline MUST floor applies. `IdentityAuditCatalogSource` declares actual operations: catalogue-to-matrix coverage is total. Audit slugs are not -additional permission keys. +additional permission keys. The internal `ListMembershipsQuery` has a real MUST +catalogue entry, ordinary ReadWrite adapter and declared HTTP admission. +The two grant commands have real MUST catalogue entries and contained Role diffs. +`(off-path)` marks a central event without a MediatR request. +Catalogue → matrix remains total; the reverse request join applies only when +the request exists, and off-path events register by slug when their writer lands. | Resource | Operation | Class | Why | |---|---|---|---| @@ -18,6 +25,10 @@ additional permission keys. | Membership including Profile/Assignments | `identity.membership.create_first_seed_admin` | **MUST** (`security-event`) | `CreateFirstSeedAdminMembership`; one Membership transaction. | | Membership including Profile/Assignments | `identity.membership.create_seed` | **MUST** (`security-event`) | `CreateSeedMembership`; one Membership transaction. | | MembershipProfile, captured under Membership | `identity.membership_profile.set_seed_profile` | **MUST** (`update`) | `SetSeedMembershipProfile`; same Membership transaction. The slug matches the gated sub-resource while the row's subject remains its owning aggregate. | +| Authorization decision | `identity.authorization.deny` `(off-path)` | **MUST** (`security-event`) | Central HTTP/application/resource denial owner; one decided invocation event, separate from ordinary plural resource intents. No tenant override or host suppression budget. | +| Membership directory | `identity.membership.list` | **MUST** (`read-sensitive`) | Internal `ListMembershipsQuery` under existing Tenant `identity.membership.admin`; ordinary ReadWrite owner commits its row before disclosure. No HTTP activation follows from its catalogue entry. | +| Role including grants | `identity.role.change_grants` | **MUST** (`security-event`) | `ChangeRoleGrantsCommand`; one Role root and contained grant diff, expected row version and fresh post-lock authority recheck; success/audit share the transaction. | +| Role including grants | `identity.role.adopt_manifest` | **MUST** (`security-event`) | `AdoptRoleGrantManifestCommand`; separately admitted finite v1 manifest adoption, one Role root and contained grant diff; no automatic registry/seed upgrade. | **Explicit Off reads.** Contextual seed verification request types are registered Off before use; they inspect only the exact bounded declared seed facts, with no @@ -28,7 +39,8 @@ rows; no Off value is presented as an `OperationClass` or a MUST audit slug. No seed command mutates a second root. Role grants travel in Role diffs; assignments/Profile travel in Membership diffs. Instance-qualified paths include `/Membership/{id}/Profile`, `/Membership/{id}/RoleAssignments/{roleId}` -and `/Role/{id}/Grants/{scope}:{key}`. Collection snapshots appear only when +and `/Role/{id}/Grants/{scope}~1{key}`. The grant's composite local key uses the +canonical RFC 6901 escape for its slash separator. Collection snapshots appear only when actually complete; partial loads cannot claim a complete collection. Stable resource/subject keys are references, not safe diagnostic labels. Global paths and non-success reconciliation follow ADR-0072's canonical owner. @@ -70,7 +82,19 @@ missing-global-row and child-capture controls; canonical signatures/SQL stay in [Standard 05](../../standards/05-database.md#proposed-global-central-audit-branch). No runtime test is claimed here. -P03-2 adds normal authorization/denied/read enrichment; P03-5 extends revision-aware +P03-2's Accepted [trusted evidence contract](../../standards/18-audit-coverage.md#trusted-authorization-evidence) +owns immutable provenance, fixed denial occurrence/time and exact full-row +verification, pre-success barriers and refusal preservation. It keeps normal +resource classification unchanged and AccountSite success Off. The central denial +event and the internal directory/grant commands have actual catalogue entries; +the phase record owns their qualification. P03-5 extends revision-aware dynamic values; P03-10 delivers applicable user-reference locators and export/ erasure/closure under separate authority. A stub or retained hash is not completion or anonymisation. The module table records current versus planned proof status. + +Authorization Membership/Role IDs in `metadata.authorization` are retained +personal linkage, outside the six-column redaction grant. Each row follows its +own [retention target](../../standards/18-audit-coverage.md#retention): SecurityEvent +seven years, ReadSensitive two years. Purge remains Phase 11 work; P03-10's Audit +participant locates/reports surviving actor/subject linkage without metadata +UPDATE authority. No email mirror or Platform/operator flag population is added. diff --git a/docs/modules/identity/permissions.md b/docs/modules/identity/permissions.md index 2fdd4df56..d93e038fc 100644 --- a/docs/modules/identity/permissions.md +++ b/docs/modules/identity/permissions.md @@ -1,8 +1,12 @@ # Identity — Permission Matrix -**Status: Finite foundation definitions and seed commands implemented; live enforcement remains P03-2.** Derived from jointly Accepted [ADR-0072](../../decisions/0072-application-owned-identity-bootstrap.md) +**Status: Foundation definitions, finite seed admission and live enforcement implemented.** Derived from jointly Accepted [ADR-0072](../../decisions/0072-application-owned-identity-bootstrap.md) and [ADR-0073](../../decisions/0073-scoped-membership-and-identity-data-foundation.md). -No Identity HTTP route or live permission admission is implemented. The [module spec](README.md) +The live contract is jointly Accepted in +[ADR-0076](../../decisions/0076-live-scoped-authorization.md) / +[ADR-0077](../../decisions/0077-trusted-authorization-evidence.md). +The directory query/GET, finite unrouted Role grant commands, fresh evaluator +and exact policy source use canonical HTTP/application admission. The [module spec](README.md) owns storage/lifecycle; [Standard 19's accepted foundation contract](../../standards/19-permissions.md#proposed-identity-foundation-contract) owns the exact registry, grant manifests and kernel without a second copy here. @@ -36,7 +40,8 @@ adoption supplies identity facts; it does not independently grant seed authority Empty cells mean no foundation key exists. Bootstrap/seed verification reads do not invent directory/read keys; their exact owner/audit classification is -separate. P03-2/3 registers broader production operations before activation. +separate. P03-2 reuses the existing Tenant admin pairs for the directory and operations below; +new assignable definitions require their own approved finite manifest. ## Delegation and scope boundary @@ -45,16 +50,20 @@ separate. P03-2/3 registers broader production operations before activation. - Eligible Tenant grants compose with that organization's grants. Another branch or a no-org Tenant check cannot consume an Organization grant. Tenant association for discovery is not administrative authority. -- Branch-scoped sessions use eligible Tenant grants only for branch operations. +- Branch-scoped sessions use eligible Tenant grants for branch operations and + explicitly declared tenant-wide reads, including the accepted directory. Tenant-wide writes require the explicit orgless reissue transition owned by P03-3; `app.scope` widens reads only. The [seed handoff](README.md#first-admin-and-seed-sequence) preserves Yoga admin's branch claim while using its declared NULL-org target. - Org Admin cannot edit templates. Editing requires an eligible Tenant Admin binding and Tenant `identity.role.admin`; that capability is reserved to Tenant Admin and cannot be delegated to another built-in/Organization binding. -- Later live writes cannot delegate beyond current authority, cross scope, edit - self into higher authority or remove the immutable kernel. P03-2/G5 qualifies - these controls before a protected administration path activates. +- Live writes cannot exceed the current delegation ceiling, self-escalate or + remove the immutable kernel. The accepted authoring relation permits an eligible + Tenant Admin with Tenant `identity.role.admin` to add an exact registered + Organization pair only while holding that same key's Tenant pair. This is not + evaluation equivalence; other-key, Organization-to-Tenant and unregistered-pair + implications refuse. - The two-pair admin kernel is deliberately smaller than the three-pair initial Tenant Admin bundle. P03-2 owns explicit audited grant adoption/removal outside that kernel. A later adopted manifest does not make the fixed v1 seed an @@ -63,11 +72,34 @@ separate. P03-2/3 registers broader production operations before activation. already admitted work is not retroactively cancelled. JWT/session/UI caching cannot establish that boundary. P03-3 owns UI freshness/target transitions. +## Live operation bindings + +| Operation | Existing capability | Boundary | +|---|---|---| +| `ListMembershipsQuery` | Tenant `identity.membership.admin` | Exact protected policy with invocation-bound tenant-wide reads, six approved contextual fields and MembershipId continuation. Fresh committed authority is required for every act. Ordinary ReadWrite; MUST sensitive-read audit. | +| `GET /api/v1/identity/memberships` | Tenant `identity.membership.admin` | Declared tenant-wide administrative read; an eligible NULL-org Tenant Admin Membership can authorize it from a branch session. Organization grants cannot. MUST sensitive-read audit; no native BFF expansion. | +| `ChangeRoleGrantsCommand` | Eligible Tenant Admin and Tenant `identity.role.admin` | Orgless write, current delegation ceiling/kernel, expected Role row version; recheck actor/target authority after invariant-lock wait. No HTTP writer. | +| `AdoptRoleGrantManifestCommand` | Same admin gate | Separate approved finite v1 manifest/diff and same concurrency/audit rules; unknown manifests refuse, no v2 bundle or automatic adoption. No HTTP writer. | + +The five assignable scoped pairs, eight templates and fixed v1 seed remain. +Virtual Portal Public definitions are nonassignable and describe existing public +reads only; [Standard 19](../../standards/19-permissions.md) owns their exact set. +Default-deny HTTP and independent application checks consume fresh committed +policy facts. P03-9 retains Platform admission; a SystemActor UUID is no bypass. +`IdentityOperationPolicySource` classifies the six existing seed requests as +finite Seed operations, the internal directory and the two orgless Role grant +commands. The fresh evaluator distinguishes coverage from capability and +returns bounded Membership/Role references only. API composition registers the +HTTP gate; native transport remains limited to AccountSite. +The [Standard 05 read authority contract](../../standards/05-database.md#operation-bound-read-authority) +owns immutable invocation/transaction scope and EF/RLS agreement. + ## Proof ownership P03-1 Step 4 proves actual command metadata, the nonempty five-pair registry, finite grants, assignment guards, disabled new-key additions, fixed drift refusal and complete no-write rerun. Missing production subjects fail rather than leaving an empty population. Organization Learner has own-studio positive and other-studio -denied controls. Live permission/default-deny/delegation proof stays P03-2; -no live result is claimed here. +denied controls. P03-2 adds live HTTP/application default-deny, freshness, +delegation and pre-disclosure audit controls. The phase Status block owns +source-bound qualification; this matrix is not a test receipt. diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 5c0a23910..b5dfafa8f 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -590,23 +590,16 @@ request and are the only Tenancy work an anonymous visitor pays for. ## Risks and open questions -- **`app.scope` has no carrier.** `ITenantContext` exposes no scope member - ([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, 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. +- **`app.scope` has an operation-bound carrier.** Eligible Tenant capability and + an explicitly declared tenant-wide read supply the immutable witness consumed + by the transaction owner under + [Security Standards § Tenant Context](../../standards/11-security.md#tenant-context). + `ITenantContext` has no writable scope authority; organization writes never widen. + The two `AS RESTRICTIVE` write guards have direct controls: + `TheTenantScopeHatchWidensReadsAndNeitherWrite` sets the flag explicitly because + an ordinary organization-scoped session's base policy already refuses sibling + rows. Measured: with the hatch set and the delete guard dropped, a `DELETE` + removed another organization's row. - **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 diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 8dadef6b5..38b444abe 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -54,8 +54,9 @@ Organization provisioning, a separate Organization create, and host mapping crea P02d-2 adds two locale commands (SHOULD over Tenant) and one branding command (MUST over TenantSetting). P02b-2 Step 2 adds the producer terminal MUST security-event writer; P02b-3 Step 4 adds its independent subscriber terminal counterpart. -Thirteen rows remain `(planned)`; seven operations have no MediatR request and -carry `(off-path)`. +Rows without a writer remain `(planned)`; operations without a MediatR request +carry `(off-path)`. The claim-context assertion row below is registered off-path +through `TenancyAuditCatalogSource`. All four original writes are **MUST**. Their records commit with the business state, under [ADR-0033](../../decisions/0033-audit-durability-model.md). The branding writer @@ -154,6 +155,7 @@ unmarked row is one a shipped command already raises. | `platform_host_to_tenant` | `tenancy.hostmapping.delete` `(planned)` | **MUST** | The same row removed. The table carries no `deleted_at`, so the delete is a row gone and a host that resolves to nobody | | `TenantAssertion` | `tenancy.tenant_assertion.reject` `(off-path)` | **MUST** for admitted events (`security-event`) | A validated header/claim mismatch with an independently defended host tenant is recorded under that host tenant, never the asserted tenant or sentinel; row 14 is counted only. [ADR-0065](../../decisions/0065-bounded-tenant-assertion-diagnostics.md) admits 600 authenticated standalone write attempts per host tenant per API instance per first-request-anchored monotonic minute, shared across scoped recorders, actors, sources and dimensions. Failed writes consume permits; later occurrences make no audit-store call and retain their refusal, count and exactly one attributed Warning (4006 replaces normal 4001). An attacker can exhaust the allowance, so later actors lose durable evidence; aggregate log volume remains unbounded. Admitted events retain MUST failure/health behavior, tenant-wide organization scope, exact actor/correlation and trusted organization in metadata. No tenant `AuditConfig` override or independent tenant-registry cap exists. General mutation MUST durability is unchanged. Packet 9 owns the recorder/decorator path; P02b-5 Steps 3–4 implement JWT attribution and HTTP activation under ADR-0064. ADR-0065 acceptance precedes the dependent budget implementation; the [merge closeout](../../roadmap/phase-02b-delivery-log.md#p02b-5-merge-and-closeout-2026-10-20) owns its final delivery status | | `TenantAssertion` | `tenancy.tenant_assertion.anonymous_burst` `(off-path)` | **MUST** (`security-event`) | The anonymous half of the same detector, written once per `(resolved tenant, dimension, window)` when the mismatch counter crosses its threshold. An anonymous mismatch is not itself an audited operation; the burst is — the precedent the baseline's "login failure burst" already sets ([ADR-0036 Amendment 7](../../decisions/0036-tenant-resolution-trusted-inputs.md)). Same writer, and `(off-path)` for the same reason. The window is **in-process** and never `ICacheService` — a cache outage must not decide whether a MUST-class security event is recorded — so the effective threshold is per instance, which errs toward more auditing. `Tenancy:AssertionBurst` sets it; the defaults are ten occurrences in five minutes | +| Confirmed claim-context assertion | `tenancy.assertion.reject_claim_context` `(off-path)` | **MUST** (`security-event`) | A header conflicting with actual successful ClaimAndMembership resolution uses the confirmed tenant/actor, fixed dimension/reason and membership-confirmed source. No asserted UUID or host allowance. The production claim-context recorder and central evidence owner implement this path; [Phase 03](../../roadmap/phase-03-identity-admin.md) owns qualification. | | any | `platform.admin_scope.enter` `(off-path)` | **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`. [Packet 9](../../roadmap/phase-02a-kernel-tenancy.md) ships it: an admitted `PlatformAdminScope.EnterAsync` writes the row through `IAuditStore.WritePlatformScopeAsync` on the scope's own platform-role connection, in a transaction of its own that **commits before** the caller's begins — so work that later fails, throws or is abandoned cannot take the record with it — carrying `TenantId.PlatformSentinel` ([ADR-0044 § 1, § 10](../../decisions/0044-audit-write-path.md)). An entry the catalogue cannot classify, or whose row cannot be written, is **refused** — no transaction is handed out for it. The `Warning` log line stays beside the row, carrying its id, as the real-time signal the row is not. `EnterAsync` is a service method and not a MediatR request, and the catalogue is keyed by request type — so its catalogue entry is registered by slug rather than by request type. Its module segment is `platform` for the same reason: the scope belongs to no module's request path. The permission gate denies before opening a connection; those denied attempts write no entry row. Phase 03 must decide denied-entry evidence before its first privileged consumer | A feature flag that gates a **billed** capability is not a `tenant_feature_flags` @@ -179,26 +181,14 @@ classification and not drift — and a `(planned)` row whose command has since s fails, so the marker is a claim the rule re-checks on every run rather than an exemption. -Seven rows sit outside that join in both directions, each marked `(off-path)` for the -reason its own cell gives: `platform.admin_scope.enter`, written on the fourth write path -([ADR-0044 § 10](../../decisions/0044-audit-write-path.md)); -`tenancy.killswitch.toggle`, written inside `EnterPlatformAdminScope(reason)`; -`tenancy.entitlement.refresh`, whose writer is `IEntitlementProvider.RefreshAsync` and -whose refreshing implementation lands in Phase 02c; the two -`tenancy.tenant_assertion.*` keys, written by a non-MediatR caller reaching -`IAuditStore.WriteStandaloneAsync` -([ADR-0033 Amendment 2](../../decisions/0033-audit-durability-model.md)); -`platform.outbox.dead_letter`, written with its fenced producer terminal UPDATE -through `IAuditStore.WriteEventTerminalAsync`; and `platform.delivery.dead_letter`, -whose subscriber writer is delivered by P02b-3 Step 4. Five are -registered by slug through `DeclareOffPath` because their writer ships — -`platform.admin_scope.enter`, `platform.outbox.dead_letter`, -`platform.delivery.dead_letter` and the two -`tenancy.tenant_assertion.*` keys. -`tenancy.killswitch.toggle` and `tenancy.entitlement.refresh` also carry -`(planned)` and register by slug when Phase 03 and Phase 02c, -respectively, land their writers. Every other slug above is -joined in both directions the day its command lands. +Off-path rows use slug registration when their writer ships. Delivered +registrations are platform entry, producer/subscriber/job terminal transitions, +the two `tenancy.tenant_assertion.*` operations and +`tenancy.assertion.reject_claim_context`. The entitlement refresh and killswitch +rows carry both `(off-path)` and `(planned)` until their named writers exist. +They have no synthetic request type and sit outside the request-type join in +both directions. Every ordinary request +slug joins in both directions when its command/query lands. ## P02d-2 contextual verification reads @@ -223,9 +213,10 @@ internal ports, not separately dispatched or directly routed endpoints. [ADR-0064](../../decisions/0064-tenant-api-authentication-boundary.md#closed-authenticated-read). The non-public query, exposed only by AccountSite GET, returns its own actor/scope and the existing -public site projection. Normal success and refusal create no query intent, -operation slug or audit row; it is neither ReadSensitive nor MAY. Independent -rejected-assertion auditing retains its own path. The +public site projection. Normal success and refusal create no query resource intent +or slug; it is neither ReadSensitive nor MAY. Accepted ADR-0077 separately requires a central Identity +denial event for live membership denial attributable to a resolved tenant. +Independent rejected assertions retain their own path. The [module contract](README.md#p02b-5-authenticated-account-site-read) owns admission. @@ -252,6 +243,22 @@ keeps the two existing operation slugs and extends their captured evidence: - Existing anonymous burst limits and failure behavior remain: a failed audit preserves the refusal while the audit store reports Critical, failure count and unhealthy state. + + +## Claim-context assertion evidence + +[Standard 18](../../standards/18-audit-coverage.md#trusted-authorization-evidence) +owns the exact evidence population, trusted provenance and durability rules. +The new assertion event requires confirmed ClaimAndMembership resolution from +the production reader. It cannot use a candidate/raw claim or impersonate a +host-backed rejection. Host-backed events keep their own allowance; row 7 stays +host-recorded, row 10 unaudited, and refused row 14 diagnostic-only. A successful +row 14 followed by conflicting tenant/org header is the distinct new MUST event, +without an asserted UUID or host-budget debit. Actual refusal survives audit +failure with the existing failure signals. The +[Phase 03 Status block](../../roadmap/phase-03-identity-admin.md) owns source-bound +qualification. + ## Mapped-property personal-data declarations P03-1 Step 2 declares every current Tenancy scalar in its diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 70d62e3ba..67b5d9531 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -9,7 +9,11 @@ returns only the context's own actor/scope with the existing public site projection. Its independent `HostAndClaim`/host-scope guard precedes all readers; membership alone cannot admit it. [The module contract](README.md#p02b-5-authenticated-account-site-read) owns the boundary. P02b-5 Step 4 exposes only its authenticated GET route; -Phase 03 owns permission-sensitive disclosure and live membership. +Accepted [ADR-0076](../../decisions/0076-live-scoped-authorization.md) adds live +membership/lifecycle checks to its protected admission while preserving its +independent host ceiling and Off success classification. The +[Phase 03 Status block](../../roadmap/phase-03-identity-admin.md) owns execution +and qualification; acceptance is not activation. **P02b-1 Step 3 producer implemented — 2026-10-12.** The completed-provisioning domain translator runs only during owner completion, adds no endpoint or @@ -33,6 +37,12 @@ configuration/settings ports remain internal and unrouted. Independent rejected- assertion auditing remains unchanged. [The public contract](README.md#p02d-4-public-configuration-contract) owns allowlisting and lifecycle/locale checks. +Accepted ADR-0076 classifies that existing bootstrap through virtual Tenant +`tenancy.public_site.read`. This is Portal Public policy metadata, unavailable +as a stored Role grant; it adds no endpoint, Membership, template or visibility. +Credential-free public reads perform no identity/grant lookup. The +[Permission standard](../../standards/19-permissions.md) owns the virtual class. + **P02d-3 Step 1 implemented — 2026-10-02; both review rounds passed.** `ITenantSettingsAccessor` is internal and unrouted, uses trusted ambient scope and adds no HTTP endpoint or permission key. Public admission belongs to @@ -40,47 +50,48 @@ P02d-4. [The module contract](README.md#p02d-3-accepted-typed-settings-contract) **P02d-2 Step 2 writers — 2026-10-02.** The [locale and branding commands](README.md#p02d-2-accepted-locale-and-branding-contract) -are implemented unrouted tenant-wide operations, with no registered permission or -organization override. Their eventual identity-backed permission admission remains -Phase 03. This note grants no HTTP or Hub-internal reachability for the new commands. +are implemented unrouted tenant-wide operations, with no assignable permission or +organization override. Their finite Seed admission is described below. Human-facing +permissions require their owning Phase 03 decision; no HTTP or Hub-internal route +is admitted here. -**No permission keys yet.** The matrix below is a forward declaration in the +**No assignable Tenancy 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). 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. +[Permission Standards](../../standards/19-permissions.md). Its adopted module-source +contract registers scoped definitions separately from finite grants; no `IModule` +API is implied. Identity's foundation definitions/Role templates are implemented. +Tenancy's assignable keys and their human/operator consumers require their owning +Phase 03 decision; these examples register or grant nothing. The virtual public +definition above is separate from this assignable resource matrix. -**Six write handlers exist without identity-backed authorization**, for two -different reasons. Four contextual verification queries are explicitly audit Off. -P02d-2 adds no HTTP or Hub-internal route. +**Six write handlers and four verification queries have finite Seed admission.** +`TenancyOperationPolicySource` binds each request to its declared Seed source under +the [live contract](../../standards/19-permissions.md#live-permission-enforcement-contract). +The verification queries remain audit Off. These bindings add no HTTP or +Hub-internal route and no assignable permission key. -`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. +`ProvisionTenantCommand` runs with an **unresolved** tenant context so it can +announce the tenant it creates. Provisioning precedes Membership and attributes +the write to `UserId.SystemActor`; that value supplies no exemption from the +finite source/request gate. -`CreateOrganizationCommand`, `MapHostToTenantCommand` and the three P02d-2 writers do -run resolved, so the first argument does not transfer to them. What stands in for -authorization is the same thing for these writers: reachability. None of the six -writers has an HTTP route today; the seeder is their trusted production caller. +`CreateOrganizationCommand`, `MapHostToTenantCommand` and the three P02d-2 writers +run resolved and derive their tenant from context, never request input. Finite +source admission and database isolation independently constrain these writes. +The Seeder remains their production caller. [Phase 02c](../../roadmap/phase-02c-hub-foundation.md) owns the planned Hub-internal -routes: tenant creation invokes `ProvisionTenantCommand`, which also creates the default -organization, and host mapping invokes `MapHostToTenantCommand`. It adds no direct -`CreateOrganizationCommand` endpoint. That surface takes `learnstack-hub` realm tokens -and no others. Resolved writers 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. +routes: tenant creation invokes `ProvisionTenantCommand`, which also creates the +default organization, and host mapping invokes `MapHostToTenantCommand`. It adds +no direct `CreateOrganizationCommand` endpoint. That future surface accepts only +`learnstack-hub` realm tokens and needs its own admitted operation policy. -**`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. +**A future host-mapping route requires its own admission decision.** +`MapHostToTenantCommand` controls whose data an anonymous request sees. The proposed +`tenancy.hostmapping.admin` key therefore describes an admin-scope act, outside +ordinary tenant-admin grants; `HostMapping` has no `write` action. The proposed +`tenancy.tenant.admin` and `tenancy.organization.write` keys describe provisioning +and organization creation. Each requires its owning surface decision and approved +finite registration; the matrix alone registers or grants none of them. | Resource | read | write | delete | admin | Default role grants | |----------|:----:|:-----:|:------:|:-----:|---------------------| diff --git a/docs/roadmap/phase-03-delivery-log.md b/docs/roadmap/phase-03-delivery-log.md index bcff3f154..f2aabaadf 100644 --- a/docs/roadmap/phase-03-delivery-log.md +++ b/docs/roadmap/phase-03-delivery-log.md @@ -15,7 +15,7 @@ not acceptance of implementation choices. Navigate by the generated index below. - [P03-0 — Entry preparation](#p03-0--entry-preparation) — 83 lines - [Entry preparation (2026-10-25)](#entry-preparation-2026-10-25) — 81 lines -- [P03-1 — Identity authority and membership foundation](#p03-1--identity-authority-and-membership-foundation) — 2,709 lines +- [P03-1 — Identity authority and membership foundation](#p03-1--identity-authority-and-membership-foundation) — 2,768 lines - [P03-1 proposed decision package (2026-10-25)](#p03-1-proposed-decision-package-2026-10-25) — 130 lines - [P03-1 proposal review round 1 (2026-10-25)](#p03-1-proposal-review-round-1-2026-10-25) — 35 lines - [P03-1 proposal review round 2 (2026-10-25)](#p03-1-proposal-review-round-2-2026-10-25) — 36 lines @@ -79,7 +79,71 @@ not acceptance of implementation choices. Navigate by the generated index below. - [ADR-0075 Step 3 fresh hosted A/B evidence (2026-10-27)](#adr-0075-step-3-fresh-hosted-ab-evidence-2026-10-27) — 54 lines - [ADR-0075 Step 3 review round 1 (2026-10-27)](#adr-0075-step-3-review-round-1-2026-10-27) — 30 lines - [ADR-0075 Step 3 review round 2 correction (2026-10-27)](#adr-0075-step-3-review-round-2-correction-2026-10-27) — 28 lines - - [ADR-0075 three-step closeout (2026-10-27)](#adr-0075-three-step-closeout-2026-10-27) — 32 lines + - [ADR-0075 three-step closeout (2026-10-27)](#adr-0075-three-step-closeout-2026-10-27) — 33 lines + - [P03-1 merge and closeout (2026-10-27)](#p03-1-merge-and-closeout-2026-10-27) — 58 lines +- [P03-2 — Live membership and authorization](#p03-2--live-membership-and-authorization) — 2,628 lines + - [P03-2 proposed decision package (2026-10-27)](#p03-2-proposed-decision-package-2026-10-27) — 167 lines + - [P03-2 proposal review round 1 (2026-10-27)](#p03-2-proposal-review-round-1-2026-10-27) — 32 lines + - [P03-2 independent preflight carrier correction (2026-10-27)](#p03-2-independent-preflight-carrier-correction-2026-10-27) — 19 lines + - [P03-2 proposal review round 2 and decision readiness (2026-10-27)](#p03-2-proposal-review-round-2-and-decision-readiness-2026-10-27) — 35 lines + - [P03-2 maintainer review and revised package (2026-10-27)](#p03-2-maintainer-review-and-revised-package-2026-10-27) — 139 lines + - [P03-2 revised proposal review round 1 (2026-10-27)](#p03-2-revised-proposal-review-round-1-2026-10-27) — 21 lines + - [P03-2 revised proposal review round 2 and readiness (2026-10-27)](#p03-2-revised-proposal-review-round-2-and-readiness-2026-10-27) — 42 lines + - [P03-2 joint acceptance and Step 1 (2026-10-27)](#p03-2-joint-acceptance-and-step-1-2026-10-27) — 117 lines + - [P03-2 Step 1 review round 1 and setup correction (2026-10-27)](#p03-2-step-1-review-round-1-and-setup-correction-2026-10-27) — 26 lines + - [P03-2 Step 1 review round 2 and closeout (2026-10-27)](#p03-2-step-1-review-round-2-and-closeout-2026-10-27) — 26 lines + - [P03-2 Step 2 — Confined candidate and committed authority (2026-10-27)](#p03-2-step-2--confined-candidate-and-committed-authority-2026-10-27) — 89 lines + - [P03-2 Step 2 review round 1 and corrections (2026-10-27)](#p03-2-step-2-review-round-1-and-corrections-2026-10-27) — 43 lines + - [P03-2 Step 2 review round 2 and closeout (2026-10-27)](#p03-2-step-2-review-round-2-and-closeout-2026-10-27) — 32 lines + - [P03-2 Step 3 — Policy and operation-bound execution (2026-10-27)](#p03-2-step-3--policy-and-operation-bound-execution-2026-10-27) — 112 lines + - [P03-2 Step 3 review round 1 and corrections (2026-10-27)](#p03-2-step-3-review-round-1-and-corrections-2026-10-27) — 31 lines + - [P03-2 Step 3 review round 2 and closeout (2026-10-27)](#p03-2-step-3-review-round-2-and-closeout-2026-10-27) — 19 lines + - [P03-2 Step 4 — Trusted evidence and finite grant acts (2026-10-27)](#p03-2-step-4--trusted-evidence-and-finite-grant-acts-2026-10-27) — 96 lines + - [P03-2 Step 4 review round 1 (2026-10-27)](#p03-2-step-4-review-round-1-2026-10-27) — 41 lines + - [P03-2 Step 4 review corrections (2026-10-27)](#p03-2-step-4-review-corrections-2026-10-27) — 52 lines + - [P03-2 Step 4 review round 2 (2026-10-27)](#p03-2-step-4-review-round-2-2026-10-27) — 36 lines + - [P03-2 Step 4 correction verification and closeout (2026-10-27)](#p03-2-step-4-correction-verification-and-closeout-2026-10-27) — 57 lines + - [P03-2 Step 5 — Live HTTP and application activation (2026-10-27)](#p03-2-step-5--live-http-and-application-activation-2026-10-27) — 82 lines + - [P03-2 Step 5 review round 1 and corrections (2026-10-27)](#p03-2-step-5-review-round-1-and-corrections-2026-10-27) — 72 lines + - [P03-2 Step 5 review round 2 and closeout (2026-10-27)](#p03-2-step-5-review-round-2-and-closeout-2026-10-27) — 29 lines + - [P03-2 Step 6 — Complete populations and qualification (2026-10-27)](#p03-2-step-6--complete-populations-and-qualification-2026-10-27) — 51 lines + - [P03-2 Step 6 review round 1 and full-suite fixture diagnosis (2026-10-27)](#p03-2-step-6-review-round-1-and-full-suite-fixture-diagnosis-2026-10-27) — 43 lines + - [P03-2 Step 6 R1 corrections and regression verification (2026-10-27)](#p03-2-step-6-r1-corrections-and-regression-verification-2026-10-27) — 70 lines + - [P03-2 Step 6 review round 2 and final source corrections (2026-10-27)](#p03-2-step-6-review-round-2-and-final-source-corrections-2026-10-27) — 73 lines + - [P03-2 Step 6 measured-plan review and qualification freeze (2026-10-27)](#p03-2-step-6-measured-plan-review-and-qualification-freeze-2026-10-27) — 48 lines + - [P03-2 Step 6 failed qualification and owned cleanup correction (2026-10-28)](#p03-2-step-6-failed-qualification-and-owned-cleanup-correction-2026-10-28) — 50 lines + - [P03-2 Step 6 Architecture timing correction (2026-10-28)](#p03-2-step-6-architecture-timing-correction-2026-10-28) — 58 lines + - [P03-2 Step 6 scoped census performance correction (2026-10-28)](#p03-2-step-6-scoped-census-performance-correction-2026-10-28) — 39 lines + - [P03-2 census performance review round 1 (2026-10-28)](#p03-2-census-performance-review-round-1-2026-10-28) — 17 lines + - [P03-2 census performance review round 2 and source freeze (2026-10-28)](#p03-2-census-performance-review-round-2-and-source-freeze-2026-10-28) — 27 lines + - [P03-2 authority proof decomposition and preservation (2026-10-28)](#p03-2-authority-proof-decomposition-and-preservation-2026-10-28) — 40 lines + - [P03-2 decomposed authority review round 1 and measured plan (2026-10-28)](#p03-2-decomposed-authority-review-round-1-and-measured-plan-2026-10-28) — 40 lines + - [P03-2 authority decomposition review round 2 and final source freeze (2026-10-28)](#p03-2-authority-decomposition-review-round-2-and-final-source-freeze-2026-10-28) — 27 lines + - [P03-2 final-source A exhaustion and resolution profiling (2026-10-28)](#p03-2-final-source-a-exhaustion-and-resolution-profiling-2026-10-28) — 59 lines + - [P03-2 resolution correction review round 1 (2026-10-28)](#p03-2-resolution-correction-review-round-1-2026-10-28) — 17 lines + - [P03-2 resolution correction review round 2 and source freeze (2026-10-28)](#p03-2-resolution-correction-review-round-2-and-source-freeze-2026-10-28) — 19 lines + - [P03-2 failed provider startup and bounded diagnostics (2026-10-28)](#p03-2-failed-provider-startup-and-bounded-diagnostics-2026-10-28) — 47 lines + - [P03-2 startup diagnostic review round 1 (2026-10-28)](#p03-2-startup-diagnostic-review-round-1-2026-10-28) — 14 lines + - [P03-2 startup diagnostic review round 2 and source freeze (2026-10-28)](#p03-2-startup-diagnostic-review-round-2-and-source-freeze-2026-10-28) — 18 lines + - [P03-2 failed browser readiness and recovery settlement controls (2026-10-28)](#p03-2-failed-browser-readiness-and-recovery-settlement-controls-2026-10-28) — 46 lines + - [P03-2 browser readiness review round 1 (2026-10-28)](#p03-2-browser-readiness-review-round-1-2026-10-28) — 15 lines + - [P03-2 browser readiness review round 2 (2026-10-28)](#p03-2-browser-readiness-review-round-2-2026-10-28) — 10 lines + - [P03-2 native recovery positive settlement scheduling (2026-10-28)](#p03-2-native-recovery-positive-settlement-scheduling-2026-10-28) — 32 lines + - [P03-2 recovery scheduling review round 1 (2026-10-28)](#p03-2-recovery-scheduling-review-round-1-2026-10-28) — 15 lines + - [P03-2 recovery scheduling review round 2 (2026-10-28)](#p03-2-recovery-scheduling-review-round-2-2026-10-28) — 16 lines + - [P03-2 clean local managed browser proof (2026-10-28)](#p03-2-clean-local-managed-browser-proof-2026-10-28) — 21 lines + - [P03-2 browser and recovery correction source freeze (2026-10-28)](#p03-2-browser-and-recovery-correction-source-freeze-2026-10-28) — 17 lines + - [P03-2 zero-adopted standard census correction (2026-10-28)](#p03-2-zero-adopted-standard-census-correction-2026-10-28) — 25 lines + - [P03-2 zero-standard census review round 1 (2026-10-28)](#p03-2-zero-standard-census-review-round-1-2026-10-28) — 11 lines + - [P03-2 zero-standard census review round 2 and freeze (2026-10-28)](#p03-2-zero-standard-census-review-round-2-and-freeze-2026-10-28) — 17 lines + - [P03-2 Step 6 qualification and review handoff (2026-10-28)](#p03-2-step-6-qualification-and-review-handoff-2026-10-28) — 66 lines + - [P03-2 final documentation review and PR preparation (2026-10-28)](#p03-2-final-documentation-review-and-pr-preparation-2026-10-28) — 21 lines + - [P03-2 PR 39 low-priority review corrections (2026-10-28)](#p03-2-pr-39-low-priority-review-corrections-2026-10-28) — 31 lines + - [P03-2 PR 39 correction review round 1 (2026-10-28)](#p03-2-pr-39-correction-review-round-1-2026-10-28) — 17 lines + - [P03-2 PR 39 correction review round 2 and handoff (2026-10-28)](#p03-2-pr-39-correction-review-round-2-and-handoff-2026-10-28) — 23 lines + - [P03-2 PR 39 role-guard cancellation control (2026-10-28)](#p03-2-pr-39-role-guard-cancellation-control-2026-10-28) — 36 lines + - [P03-2 PR 39 cancellation control review round 1 (2026-10-28)](#p03-2-pr-39-cancellation-control-review-round-1-2026-10-28) — 18 lines + - [P03-2 PR 39 cancellation control review round 2 (2026-10-28)](#p03-2-pr-39-cancellation-control-review-round-2-2026-10-28) — 17 lines @@ -2874,3 +2938,2690 @@ remain mandatory before merge; actual results belong to that check run rather than a prediction here. PR #38 remains open for maintainer review and merge. No merge is performed. P03-2 is not started; P03-3 still owns new-user HTTP/BFF activation. This CI correction changes no application authority or deployment. + + +### P03-1 merge and closeout (2026-10-27) + +Read-only GitHub verification confirms +[PR #38](https://github.com/HodeTech/LearnStack/pull/38) merged at +`2026-10-27T05:43:45Z` (08:43:45 Europe/Istanbul), squash commit +`012e67cc9294cbeea7379bb7d9664ef4af640d8e`. The final reviewed PR head is +`58e5a1d939ddc9f6641eb2dffeef46a5707f3043`. Fetching the merge confirms both +commits have tree `bb74b3b7eae2e2c5e8e100e88c0f82b14de13b6b`; their file +contents are identical. Development incorporates main's squash history with a +same-tree merge, without switching branches or rewriting published history. + +The [ADR-0075 closeout](#adr-0075-three-step-closeout-2026-10-27) owns the fresh +implementation-source A/B qualification for `ea114d25` in +[run 40412041726](https://github.com/HodeTech/LearnStack/actions/runs/40412041726). +Each attempt completes 27 successful jobs and 6,490 Passed .NET cases with zero +skips, twenty natural execution owners and 25 retained original archives. The +final documentation head separately passes its normal current-head checks in +[run 40420048044](https://github.com/HodeTech/LearnStack/actions/runs/40420048044): +27 successful jobs, all seven required contexts and 6,490 Passed .NET cases. +The retained packages and verification keep these source identities separate. +This closeout rechecks merge/run metadata and tree equality; it does not rerun +tests or relabel pre-merge qualification as a post-merge execution. At this +verification, main's separate +[push run 40456966945](https://github.com/HodeTech/LearnStack/actions/runs/40456966945) +is queued; no result for that run is asserted here. + +All six implementation steps, both independent review rounds per step, the +verified PR correction bundles and ADR-0075's three-step correction are closed. +The shipped [Identity foundation](../modules/identity/README.md) supplies global +User/subject binding, atomic creation with central MUST audit, fixed seed +adoption, contextual Membership/Profile/Role/grant storage and database-backed +lifecycle/last-admin guards. Existing-session API admission checks the durable +binding after subject quota admission. Personal-data classification and actual +diagnostic/snapshot controls cover the delivered models. Accepted ADR-0072/0073 +and the ADR-0074 timing delta govern these boundaries; the linked step records +own their real PostgreSQL, Keycloak, HTTP and concurrency proofs. + +ADR-0075 changes only ordinary CI execution windows to twenty-five minutes; +other execution windows stay at twenty minutes, with the 203-second reserve and +test-operation caps unchanged. Its exact-source A/B evidence establishes +observed fit, not a future timing guarantee. Historical failed runs and their +limits remain recorded. + +P03-1 is complete; Phase 03 remains in progress. P03-2 is next: live membership +and permission readers, authorization/default-deny enforcement, scoped reads +and trusted audit enrichment. Its decision pass must close G2 execution, G5 +enforcement, G6 policy ordering and G7 denial/sensitive-read evidence before the +first affected writer or protected activation. The +[phase plan and decision register](phase-03-identity-admin.md#packets-and-decision-gates) +own that remaining scope. This closeout starts no P03-2 implementation. + +New-user HTTP bootstrap, explicit no-session/multiple-membership BFF entry and +management UI remain P03-3. Production first-admin/issuer-rebind authority +remains P03-9; DSAR/global closure remains P03-10. Invitations, custom fields, +provider ingress, back-channel logout, federation and phase-exit enforcement +retain their named packets. No phase-wide criterion is marked complete by this +foundation merge alone. + +## P03-2 — Live membership and authorization + +### P03-2 proposed decision package (2026-10-27) + +**Status: Proposed; runtime not started.** The maintainer requests P03-2 on +`development`, complete document review before implementation, upfront decisions, +and two fresh independent review rounds after each committed implementation step. +New ADRs require maintainer approval before acceptance/implementation. This entry +neither closes a gate nor starts P03-3. Baseline: `daf03a48`, following PR #38's +verified merge. Preexisting untracked `.claude/worktrees/` remains untouched. + +**Problem.** P03-1 supplies real bindings, Membership/Role storage and finite +seed authority. Ordinary API membership still denies everyone; application +authorization passes through, and neither the runtime classification gate nor +the authorized SQL/EF scope carrier exists. P03-2 makes those contracts executable +and supplies their trusted denial/disclosure evidence without activating new-user +entry, editing UI or Platform authority. + +#### Document and source review + +The initial review separates current facts from activation choices. Three +independent read-only reviewers examine candidate/scope ownership, permission/ +policy execution and audit/evidence; the parent checks their findings against +the current source. This is a preflight, not implementation qualification. + +| Surface reviewed | Verified baseline / disposition | +|---|---| +| CLAUDE, README, vision, MVP, roadmap/Decision Timing, principles and glossary | Current phase/packet ownership retained; English docs, no branch change or historical-log rewrite. | +| Phase 03 complete plan/register; P03-1 accepted/revised package, relevant Step 3–5/merge records; completed 02a/02d/02b handoffs | G2 execution/G5 enforcement/G6 policy/G7 remain open. Their foundation acceptance does not authorize live execution. | +| ADR-0003/0010/0017/0032/0033/0036/0040/0044/0052/0064/0065/0072–0074 and their relevant Amendments | Four-role RLS, canonical pipeline, ninth candidate-reader authority, fixed manifests, public/AccountSite ceiling and plural audit survive. New execution/HTTP/evidence choices need named vehicles. | +| Standards 04/05/10/11/18/19/21; Architecture 03/09/13/23/31/33; Identity/Tenancy/Education/Customization/Audit module contracts/matrices | Read scope, sensitive-read transactions and denied evidence have explicit pending owners; proposed carrier changes are listed below. | +| Actual resolver/factory/recorders, credential/binding admission, UoW/announcement/interceptor/filter, lifecycle port, contextual stores/DDL, manifest/seed source and audit coordinators/composer | Deny-all, absent carrier, Off/best-effort denial gaps and host-budget misuse after successful row 14 are verified latent activation gaps. | +| Endpoint/runtime census, public/AccountSite tests, framework-job/source admission and real Customization initializer consumers | Exact existing classifications must be backfilled; neither Ambient nor SystemActor grants a general exception. | +| implement-task/start-task dispatch, write-adr, add-permission, commit-and-pr | add-permission's IModule/default-grants sketch is stale against adopted Standard 19 and must be replaced on acceptance before use. | + +The obsolete IModule/default-grants sentence also appears in Tenancy and +Customization permission matrices. A separate preflight correction adopts the +already Accepted module-source/finite-manifest authority in these mutable +carriers; no new module-loader interface follows. Existing matrix +examples are not a declaration of every future permission in this packet. + +Primary-source checks: PostgreSQL 18 documents separate READ COMMITTED statement +snapshots; ASP.NET Core 10 documents that authenticated fallback alone is not a +missing-classification denial and that AllowAnonymous bypasses authorization +failure. [ADR-0076](../decisions/0076-live-scoped-authorization.md) links those +sources. No package upgrade is selected. + +#### Upfront decision surface + +| Gate | Proposed answer and vehicle | Remaining boundary | +|---|---|---| +| G2 execution | ADR-0076: exact verified candidate, fresh isolated READ ONLY app-role owner, ninth logical setter, per-check freshness and operation-bound tenant-read witness; ADR-0077 supplies successful claim-only assertion evidence | No new context writer, global User reader, cache, synthetic context or bypass. Lifecycle/closure transition races retain P03-3/10 ownership. | +| G5 enforcement | ADR-0076: explicit scoped policy registry, per-act committed grant query, exact finite system/seed sources, virtual Portal Public and separate ordinary grant-edit/release-manifest adoption | No wildcard, automatic new-key grants, custom role, implicit seed upgrade or Platform permission. | +| G6 policy ordering | ADR-0076: runtime HTTP classification plus independent step-5 application check; capability before resource lookup; one useful administrative member-directory GET moves forward from P03-3 | Native authenticated allowlist, write endpoints, editing UI and scope reissue remain P03-3. | +| G7 | ADR-0077: one central denial-event owner, separate existing resource intents, bounded immutable trusted provenance, MUST sensitive disclosure and per-occurrence claim-context assertion event | No generic audit-every-404 policy, new suppression budget, email mirror or sentinel route. Privileged operator evidence remains G13/P03-9. | + +The principal maintainer tradeoffs are fresh uncached database dependence; a +five-second candidate admission deadline; already-admitted work and separate +cross-module snapshots; administrative cross-branch directory disclosure; and +per-occurrence durable denial volume under existing retention. ADR-0077 gives +illustrative storage arithmetic, not a load qualification. Both proposals must +be accepted together with this package before code. + +The directory reuses Tenant `identity.membership.admin`; it needs no v2 grant or +auto-adoption. The three virtual-public definitions are nonassignable and preserve +existing anonymous visibility. Real grant editing/adoption uses only accepted v1 +pairs/manifest in P03-2. A newly registered assignable key would still need its +own approved finite manifest and database registry-guard migration. + +#### Acceptance instruments and carrier edits + +The acceptance step appends the following **dated** Amendment content with the +next free numbers at that time. These are proposed texts, not current authority: + +- **ADR-0036:** “ADR-0076 specifies the previously admitted candidate reader and + live protected-act checks without changing the resolution matrix. Successful + ClaimAndMembership resolution followed by a conflicting header uses ADR-0077's + separately classified Membership-confirmed assertion event. Refused row 14, + row 10, host-backed evidence and public/AccountSite host ceilings are unchanged.” +- **ADR-0040:** “ADR-0076 specifies the ninth logical candidate-reader setter + through an isolated physically READ ONLY owner with exact candidate/generation + announcement. The ordinary step-6 owner additionally consumes immutable + operation read authority for SQL/EF scope. Same read-scope joins preserve it; + incompatible joins poison rather than retarget. No new logical tenant setter, + context writer or bypass role is admitted.” +- **ADR-0064:** “ADR-0076 narrowly replaces the AccountSite-only authenticated + surface with one additional administrative GET /api/v1/identity/memberships. + It admits HostAndClaim or confirmed ClaimAndMembership under Tenant + identity.membership.admin and ordinary MUST sensitive-read durability. + AccountSite gains live membership/lifecycle admission but retains its separate + HostScope and Off/READ ONLY shape. Present invalid credentials remain uniform + 401; absent protected credentials/noncoverage remain masked 404; capability + denial is 403. No resolution-by-endpoint, mutation or native proxy follows.” +- **ADR-0073:** “ADR-0076 authorizes live execution and explicit audited edits/ + adoption outside the immutable kernel. The existing five assignable pairs, + eight templates and v1 seed manifest remain; virtual public definitions grant + no stored Role authority. Existing seed verification is not an upgrade writer.” +- **ADR-0033/0044, in each record:** “ADR-0077 adds the common typed authorization + denial event and trusted immutable enrichment. Its MUST security-event row is + distinct from each operation's existing plural resource intents and does not + promote their ordinary classification. HTTP and application adapters share + ownership; nested refused acts retain their own event. Refusal-audit failure + preserves refusal; successful sensitive disclosure still requires its own + same-transaction MUST row before response. Tenantless/validation omissions and + the two existing sentinel classes remain unchanged.” +- **ADR-0065:** “ADR-0077's authorization and Membership-confirmed header events + do not consume this host-tenant allowance. Its independently defended host key, + suppression semantics, counters and scope remain unchanged.” + +Update decisions index/phase Status/register only after explicit acceptance. +Standards 04/11/19 own exact policy/route/error/freshness rules; Standard 05 owns +candidate/ordinary announcement and EF/RLS witness lifetime without a copied +template; Standard 18 and module matrices own exact new evidence slugs/populations; +Standard 10 owns closed diagnostics. Standard 21 updates canonical Asserts/owners +and honest status. Architecture 09/13/23/31/33 and glossary link those sources. +ActorEmail stays null. Include current code XML and the obsolete permission skill/ +matrix carriers; no claim that an acceptance commit implements those controls. + +#### Six implementation steps + +Every step is committed, reviewed by fresh agents in two independent rounds, +fixed only for independently verified findings and revalidated before the next. +Review records name the source revision, scope, actual commands and limitations. +No invented review transcripts or count-only proof substitutes are recorded. + +| Step | Implementation and closure condition | +|---|---| +| 1 — Accept and reconcile | Accept both ADRs/package; append exact affected-record Amendments and canonical carrier updates; classify complete current production requests/endpoints/sources and register proof obligations without pretending implementation. Recheck the independently corrected skill/matrix guidance. Validate whole-tree docs and exact source census; no live activation. | +| 2 — Candidate and live authority | Implement narrow candidate announcement/UoW owner, bounded actual Identity query and Tenancy lifecycle handoff, trusted source descriptors and immutable results. Prove real app-role READ ONLY/no profile/global directory, wrong/stale/foreign candidate, lifecycle/coverage/grants, cancellation/outage/cleanup and grant-revocation races. Keep deny-all production registration until evidence is ready. | +| 3 — Policy and scoped execution | Implement immutable policy composition, independent HTTP/application classification and step-5 capability checks, finite public/seed/event/job classes, read-witness mint/consume and exact SQL/EF scope. Prove missing/forged/reused witnesses, nested mismatch poisoning, no org write widening, real system/default-initializer positives and unrelated-command negatives. Activation remains held for Step 5. | +| 4 — Evidence and grant acts | Implement typed central denial/event ownership and captured provenance, new assertion provenance path, contextual sensitive query/catalogue and audited grant-edit/v1-adoption commands. Preserve plural intents and kernel/last-admin/lock rules. Prove exact Off/SHOULD/MAY/override-Off and nested populations, MUST rollback, delegation/current-authority checks and hostile sink canaries. No human write route. | +| 5 — Useful HTTP activation | Add exact administrative directory controller/DTO/pagination/OpenAPI/SDK, runtime classification and live production wiring together with evidence. Qualify actual rows 6/7/9/10/14, revoked matching-host control, real claim-only directory positive/header mismatch, AccountSite's independent host ceiling, public zero-grant-lookup behavior and storage failure outcomes. Do not enlarge native BFF operation allowlist. | +| 6 — Complete qualification and handoff | Run relevant full backend architecture/unit/integration/contract populations, source-bound inventories and meaningful seeded/provider/native compatibility checks; regenerate SDK, shard plan/manifest as required by changed population, preserving ADR-0063/0075 bounds. Complete fresh hosted A/B where source/distribution requires it, preserve raw evidence, update catalogue/docs/packet state honestly, then open the reviewable development→main PR. | + +Workflow dispatch: add-permission (after its adopted-contract repair), +add-mediatr-handler, add-ef-migration if actual schema/registry-guard changes are +needed, standards-check/code-review, run-tests-locally and commit-and-pr. No +destructive migration is planned. Schema updates do not gain authority merely +because they are additive. + +#### Mandatory proof ownership + +- Extend `TenantAuthenticationReconciliationTests`, rejection/recorder/budget and + actual HTTP/runtime surface suites; use real Membership rows, not a substitute + affirmative membership reader. Use a distinct real directory subject for row 14. +- Add Identity authorization-reader/permission/HTTP suites; retain + `IdentityScopeLifecycleTests`, real app-role clean/dirty controls and explicit + two-connection revocation synchronization. Global binding eligibility is a + separate earlier point, not a new generic owner function. +- Extend `TenantQueryFilterTests`, UoW/transaction tests and + `Tenant_Scope_Widening_Is_Never_Set_From_Request_Input`; compare real SQL/EF and + pooled generation state. Catalogue statuses reflect actual tests only. +- Complete `Permission_Definitions_DeclareScope`, + `Permission_Scope_Matches_Resource_Scope`, `Permission_Keys_Match_Convention`, + `Permission_Registry_Has_DeniedTest` and the real authorization-denied clause of + `MustClass_Audit_Writes_Share_The_Business_Transaction`. Public virtual and + confined system/seed classifications have separate negative controls. +- Extend `AuditLogBehaviorTests` and real database/HTTP evidence tests for the + separate denial event/resource intents and same-transaction sensitive read. + Source/actor/role/IP/route spoofing and raw-value sink canaries need positive + event witnesses; no audit loss on cancellation is inferred from static scans. +- Real pinned Keycloak/native compatibility covers existing four personas, + independent host scope and no new browser bearer/API operation. Production + p95/full WCAG and later UI/closure/archive behaviors are not claimed here. + +**Initial review limits.** No runtime test or provider was run in this preflight. +No implementation step is complete. Two independent proposal-review rounds and +mechanical document validation must finish before the maintainer decision. + +### P03-2 proposal review round 1 (2026-10-27) + +**Reviewed source:** `0389defd`, against merged baseline `daf03a48`. Two fresh +independent `gpt-6-astra` reviewers at xhigh effort examine authority/execution +and evidence/privacy/corpus respectively. Both inspect actual adjacent source; +neither edits files or runs runtime/provider tests. The parent separately +verifies each disposition. No implementation approval is inferred. + +- **Major, fixed:** define the ordinary delegation ceiling for Organization + templates. Orgless Tenant Admin holds Tenant pairs, not Organization pairs. + ADR-0076 now explicitly admits same-key Tenant-to-Organization authoring under + current role-admin authority, without evaluation equivalence, reverse scope, + other-key implication or a general self-escalation exception. Its behavioral + positive/negative obligations are explicit. +- **Minor, fixed:** enrichment follows the owning row's retention, not uniformly + SecurityEvent. Retained metadata linkage remains outside six-column redaction; + P03-10's Audit participant locates/reports it, without a new UPDATE grant. +- **Minor, fixed:** use 1,000 bytes/row for the decimal 864 MB/day illustration. + Acceptance-time Amendment drafts say specifies/authorizes, not implemented. +- **Proof refinement, adopted:** the separate denial queue cannot enter the + normal pending MUST flush or be suppressed by the outer capture's Committed + state. Explicitly test an absorbed Off denial under a READ ONLY owner. +- **Parent clarifications:** name actual grant commands/audit slugs and post-wait + recheck; bound the navigation-only cursor; limit the directory to current + Active/Suspended rows; retain unmatched routing semantics and keep new + claim-only assertion evidence free of raw asserted UUIDs. + +The authority reviewer reports no further actionable finding in its slice; the +evidence reviewer approves its slice subject to the listed corrections. These +are document reviews, not proof of code, PostgreSQL or provider behavior. The +corrected proposal proceeds to a new independent second round before presentation. + +### P03-2 independent preflight carrier correction (2026-10-27) + +The already Accepted ADR-0073/Standard 19 contract, not either new proposal, +licenses this documentation repair. `add-permission`, Tenancy's permission +matrix and Customization's permission matrix still prescribed nonexistent +`IModule.RegisterPermissions` and registration-time automatic defaults. +The current `PermissionDefinition` and retained `IdentityFoundationManifest` +source verify the mismatch. They now describe scoped definition sources, +separate finite grant manifests and the implemented-foundation/live-enforcement +boundary. No registry, grants, endpoint, schema or runtime code changes. + +The skill also stops claiming a generated catalogue exists or an attribute +automatically installs authorization. Exact denial status follows the owning +admitted surface; current/registered test status is checked rather than assumed. +Both second-round proposal reviewers receive these additional mutable-carrier +diffs for inspection. Validation: whole-tree docs/structure/index, seven ADR +parser controls and `git diff --check` pass. This is not runtime qualification +or acceptance of ADR-0076/0077. + +### P03-2 proposal review round 2 and decision readiness (2026-10-27) + +**Reviewed source:** corrected proposal `7159c983`, plus the independent guidance +repair at `9f0edb66`, against `daf03a48`. Two new independent `gpt-6-astra` +reviewers at xhigh effort cover authority/execution and evidence/privacy/corpus. +Neither participated in the first proposal round. Both compare the proposals +with adjacent source/Accepted contracts, inspect the three guidance repairs, +and approve their slice with no remaining verified actionable finding. + +The authority review checks candidate ownership/deadline, source trust, +classification, virtual-public separation, witness nesting, directory/BFF staging, +revocation, grant adoption and acceptance instruments. The evidence review checks +the separate denial queue against the real pending/committed shortcuts, nested +refusals, plural intents, claim-only attribution, trusted address/UA, retention/ +redaction and sensitive disclosure. Their approval is of a Proposed design, +not implementation or a production safety/availability guarantee. + +**Validation.** Whole-tree Markdown fragments, roadmap structure and generated +indexes pass; the seven ADR parser controls pass, both new records parse +Proposed, and `git diff --check` passes. No backend/runtime/provider tests were +run for this documentation-only pass. No runtime, database privilege/schema, +provider configuration, BFF allowlist, protection rule or external service was +changed. Work stays on `development`; the preexisting worktree directory remains +untouched. The commits are local pending the maintainer's decision. + +**Decision requested.** Jointly approve ADR-0076, ADR-0077 and the six steps in +the proposed package above. This includes the minimum administrative directory +GET in P03-2, fresh database admission and separate per-occurrence denial evidence. +G2 execution/G5 enforcement/G6 policy/G7 remain Open until that acceptance. +The maintainer's explicit new-ADR approval requirement is the remaining gate; +the independent repairs already licensed by ADR-0073 are complete. After approval, +acceptance/reconciliation is Step 1, followed by the committed implementation and +two-fresh-review-round cycle for every step. There is no approval request for an +unspecified later redesign or extra runtime scope. + +### P03-2 maintainer review and revised package (2026-10-27) + +**Status: Proposed; acceptance withheld.** The maintainer reviews `b81ed063` +and requests correction of nested-owner liveness, absorbed-denial durability, +occurrence identity and retention wording. All four findings are valid against +the current proposal/code; none is a claim of an already exposed runtime flaw. +The previous readiness record describes its then-reviewed source, not closure +of these subsequently identified risks. The new ADRs remain Proposed and all +four P03-2 execution gates remain Open. No runtime implementation begins. + +#### Verified findings and choices + +| Finding | Source verification | Revised contract | +|---|---|---| +| F1 — nested/post-lock pool starvation | `PersistenceCompositionExtensions` supplies one app source; ordinary `NpgsqlUnitOfWork` retains its connector until disposal; fresh nested checks need another checkout | Dedicated authorization source: four owners/connectors, no admission wait queue, same app role and confined READ ONLY queries. Outer business owners cannot consume that lane. Excess work fails immediately; actual cleanup owns its slot. | +| F2 — outer COMMIT precedes finally-only denial write | `TransactionExecutionCoordinator` commits and marks capture before `AuditExecutionCoordinator` finally reconciles; a crash/fault in between leaves committed business with only an in-memory event | Pre-success barrier: successful ReadWrite owner inserts unresolved events on its own transaction; ReadOnly/ownerless success requires acknowledged standalone evidence first. Audit failure prevents otherwise-successful commit/disclosure; actual refused callers preserve their refusal. | +| F3 — occurrence identity/duplicate semantics undefined | Ordinary reconciliation uses a fresh time; `audit_log` key is `(id,timestamp)` and generic standalone accepts 23505 without exact row comparison | One central occurrence reference with fixed id/microsecond decision time and complete frozen draft. Confined store seams verify expected-key duplicates in a clean scoped read transaction. Exact event evidence never proves business success. No cross-restart/request deduplication is promised. | +| F4 — retention wording implies implemented purge | Standard 18's Retention section explicitly declares the schedule a policy target with Phase 11 mechanisms absent | Describe seven-/two-year targets beside metadata and capacity cost; automatic purge/override/partition lifecycle is unimplemented, so retained data can outlive its target. | + +Two additional read-only design investigations verify adjacent constraints: +an extra Npgsql pool can still share a saturated PgBouncer backend pool, and a +new candidate UoW cannot silently claim a job's existing main-unit slot. These +are included in F1's actual source/lifetime/capacity contract, not deferred to +generic load testing. The parent checks the actual source and primary Npgsql, +PostgreSQL 18 and PgBouncer documentation linked from ADR-0076. + +**Chosen resource layout.** Keep the ordinary business source. Add a confined +four-slot authorization source and two-slot standalone evidence source under +the same `learnstack_app` role, with no raw DI aliases/generic callbacks. At most +six additional physical connectors per process are owned, including opens and +retained cleanup. Neither auxiliary owner borrows from the other source or +business source while held. Authorization uses only nonlocking MVCC statements; +evidence uses only exact INSERT/verification, never business dispatch/locks. +Track both job auxiliary lanes and join actual cleanup before source disposal; +do not suppress trusted execution markers. The 4/2 caps are explicit bounded +concurrency choices, not a measured production requirement or guaranteed fairness. + +`ConnectionStrings:IdentityAuthorization` and +`ConnectionStrings:AuthorizationEvidence` are a coupled explicit source pair. +Only Development may derive both from direct-PostgreSQL Default without another +credential; each remains a physically separate bounded source. Other modes need +both guarded strings and matching installed identity authority. PgBouncer +transaction mode uses separate database aliases +`learnstack_identity_authorization` / `learnstack_authorization_evidence` for +those lanes, targeting the same actual database/app role. Cross-alias limits, +total PostgreSQL capacity and admitted instance count must preserve headroom +outside business capacity. Application names are not backend reservations. + +Small-pool progress through real PostgreSQL and a pinned transaction-pooling +PgBouncer fixture is mandatory **in P03-2** before activation. Hold every business +connector while admitted nested and post-lock authorization and ReadOnly +standalone evidence complete. A shared-backend dirty topology must fail the +same progress check. Separately prove auxiliary saturation, immediate excess +refusal, no use of the parent's uncommitted grants, delayed/failed cleanup and +tracked job shutdown. Phase 11 still owns actual production topology/capacity +qualification before production activation; that does not replace these tests. + +**Chosen denial guarantee.** Each event has per-owner written/acknowledged/ +rollback/uncertain state independent of the business capture flag. After +domain-event draining, the owner enforces the barrier before final commit +admission, retaining the existing no-await commit boundary. A joiner never +acknowledges or clears the event. An acknowledged ReadWrite commit carries it +atomically. ReadOnly success cannot disclose before its separate acknowledgement. +Unknown business COMMIT retains its uncertainty even if exact denied-event +evidence exists. A crash before any applicable write commits can lose an +uncompleted in-memory refusal; no durable recovery queue or universal replay +ledger is introduced. Refusal-audit outage remains an explicitly signaled loss. + +The fixed reference is reused only for that occurrence's owned attempts. A +new request/restarted delivery or genuine nested evaluation gets another one. +Normalize UTC to PostgreSQL microseconds once. The new denial seams reuse the +sole central INSERT/binder and exact captured app-role tenant/org announcement, +but not ordinary standalone's fresh timestamp/broad-23505 behavior. Only the +expected `audit_log_pkey` duplicate admits clean-transaction, null-safe full draft +comparison. Missing/foreign/mismatched/unreadable evidence fails closed. A later +legitimate redaction can prevent an exact match; no indefinite replay witness or +extra redaction privilege is promised. Existing resource intents keep their +ordinary different-timestamp reconciliation and plural attribution unchanged. + +#### Revised acceptance and six-step closure + +The original six steps, directory/public/BFF/grant scope and all retained P03-3/ +P03-9/P03-10 boundaries stand, with these binding additions on acceptance: + +- **Step 1:** canonical source/owner/lifetime/capacity census includes the 4/2 + lanes and two guarded keys. Extend Standard 05 Connection Management/job + ledger, Standards 10/11/12/18/21, environment/development/Compose/Seeder setup + and Phase 11's actual topology qualification trigger. New keys carry no extra + credential into restore/build. Include exact denial-method/occurrence/barrier + obligations; acceptance still claims no runtime proof. +- **Step 2:** candidate execution includes the dedicated source and complete + borrower/job tracking, bounded open/cleanup ownership and small-pool progress. + Its statements remain committed-only, physically READ ONLY and free of parent + lock/callback dependencies. Do not substitute an outer writer's snapshot. +- **Step 3:** nested/post-lock policies use that same confined lane, preserving + invocation/read-scope lifetime. Prove saturation does not create a circular + checkout dependency; excess admission remains an explicit 503. +- **Step 4:** implement the per-event barrier, fixed-reference denial store seams + and independent two-slot evidence lane. Prove ReadWrite commit/crash, rollback, + ReadOnly/ownerless disclosure, store/composer fault, exact/mismatched/unknown + COMMIT, stable timestamp and real distinct-versus-reentered invocation cases. +- **Step 5:** activation waits for those actual direct/PgBouncer progress and + crash/fault controls, not only a successful directory HTTP request. Preserve + real rows 6/7/9/10/14 and all earlier host/public/BFF ceilings. +- **Step 6:** full inventories include actual new sources/jobs/fixture images and + test populations with pinned receipts. Performance tuning remains distinct + from these correctness gates; no unrun topology or crash proof is claimed. + +The following exact Amendment additions supplement the original append-ready +acceptance text; use next free numbers only after maintainer acceptance: + +- **ADR-0040:** “ADR-0076's candidate owner uses its confined authorization source, + not a nested checkout from the retained business pool. ADR-0077's evidence lane + completes standalone events before otherwise-successful ReadOnly/ownerless + publication. Each lane has bounded retained owners, exact app-role scope and + downstream headroom; neither adds a logical tenant setter or context writer.” +- **ADR-0033 and ADR-0044, each:** “ADR-0077's denial occurrence has a fixed id and + decision timestamp across owned writes/reconciliation. Expected-key duplicate + evidence requires an exact scoped draft match and proves no business outcome. + The enclosing successful ReadWrite commit atomically carries unresolved denial + events; ReadOnly/ownerless success requires their standalone acknowledgement + first. Audit failure prevents outer success before commit/disclosure, while + an actual refused caller retains its refusal. Process loss before any event + commit can lose an uncompleted refusal; no durable invocation/replay ledger is + added. Ordinary plural resource reconciliation remains unchanged.” +- **ADR-0047:** “ADR-0076/0077 add explicitly tracked authorization/evidence + auxiliary lanes to the application-source owner. Their separate global caps, + actual-cleanup retention/join and physical source lifetimes supplement the + existing main/auxiliary business ledger. No second main unit, ambient-marker + suppression, broader SystemActor permission or quiescence shortcut is admitted.” + +**Tradeoffs for approval.** Six auxiliary connectors per process and production +backend reservations; immediate 503/audit-unavailable under auxiliary saturation; +absorbed denials can prevent outer success; and no universal restart deduplication +or recovery of an uncompleted refusal. The directory/grant/evidence volume and +retained-linkage tradeoffs in the initial package remain. These revisions are +Proposed; new independent review rounds and document validation precede renewed +maintainer acceptance. No runtime, schema, grant or protection setting changes. + +### P03-2 revised proposal review round 1 (2026-10-27) + +**Reviewed source:** `ead0a22b`, against the maintainer-reviewed `b81ed063`. +Two new independent `gpt-6-astra` reviewers at xhigh effort examine F1/resource +liveness and F2/F3/F4/evidence durability respectively. Neither performed the +preceding design investigations or original proposal reviews. Both inspect the +actual adjacent implementation/Accepted constraints and approve their scope +without a verified actionable decision gap. No runtime or database tests run. + +The liveness reviewer confirms retained ordinary connectors, explicit job-lane +ownership, separate four/two-slot sources and downstream PgBouncer reservations. +Its useful execution note is incorporated: the pool proof must hold actual open +server transactions and check backend PIDs, not only client connectors that a +transaction pool may leave unassigned. Dirty shared-backend topology remains +mandatory. The evidence reviewer confirms pre-success barriers for all owner +modes, fixed occurrence/time/full-field duplicate semantics, truthful retention +targets and accepted-carrier compatibility. These approvals concern the +Proposed contracts; source implementation, runtime progress/crash proofs and +production capacity remain unqualified. Parent edits otherwise only clarify +already-repaired guidance and reflow prose. A fresh second round follows. + +### P03-2 revised proposal review round 2 and readiness (2026-10-27) + +**Reviewed source:** `55662b56` against maintainer-reviewed `b81ed063`. Two new +independent `gpt-6-astra` reviewers at xhigh effort cover resource/liveness and +durability/identity/privacy. Neither performed the revised first round or design +investigations. Both independently inspect current adjacent source and Accepted +constraints, and approve their review scope without a verified actionable +decision/proof gap. No source implementation or runtime test is claimed. + +The resource review confirms independent four/two-slot lanes, no reverse +business/evidence/authorization acquisition, tracked job cleanup, explicit +configuration, downstream cross-alias/user/instance headroom and real server- +transaction/PID dirty-control requirements. The durability review confirms all +pre-success owner cases, event state versus capture state, stable microsecond +occurrence identity, clean scoped full-draft duplicate matching, unknown-COMMIT +limits, refusal preservation and truthful retention targets. It additionally +reads the parent's narrow wording clarification: one evidence operation shares +one five-second I/O budget across open/INSERT/COMMIT and duplicate verification; +verification never starts a fresh budget. Actual cleanup remains retained. + +**Maintainer findings disposition:** F1, F2, F3 and F4 are all valid and corrected +in the proposed contracts, acceptance carrier texts and mandatory proof plan. +None is skipped. The implementation gates remain tests to run, not documented +test passes. In particular, no small-pool, PgBouncer, process-crash, cancellation +or ambiguous-COMMIT runtime result exists for this proposed source yet. + +**Validation:** whole-tree Markdown fragment/roadmap/index checks, seven ADR +parser controls, both Proposed lifecycle declarations and `git diff --check` +pass. The two revised ADRs and six-step delta remain local on `development`; +the existing untracked worktree directory is untouched. No runtime/schema/grant, +provider, protection or external-service change is made. + +**Decision requested:** jointly accept the revised ADR-0076/0077 and the revised +package above. Added costs are bounded auxiliary sources/production headroom, +possible immediate 503 under saturation, retained cleanup/shutdown ownership, +and failure of otherwise-successful outer work when required denial evidence +cannot commit. No durable restart/replay ledger or automatic purge is implied. +The maintainer withheld acceptance in the preceding review; that decision is +not inferred from correction completion or reviewer approval. Until explicit +acceptance, G2 execution/G5 enforcement/G6 policy/G7 remain Open and runtime +implementation stays held under the maintainer's new-ADR approval instruction. + +### P03-2 joint acceptance and Step 1 (2026-10-27) + +**Acceptance receipt.** The maintainer explicitly accepts revised ADR-0076, +ADR-0077 and the six-step package at `725d255a`: “Revize ADR’leri ve planı +onaylıyorum; uygula”. This closes their contract approval gate, not an +implementation or production-readiness gate. Both ADRs become Accepted jointly +on 2026-10-27. Work remains on `development`; no new-user HTTP/BFF flow, native +operation allowlist expansion, mutation route or Platform admission is added. + +The approved F1–F4 corrections are binding: independent four-owner authorization +and two-owner evidence sources, actual downstream backend reservation/progress, +pre-success evidence for absorbed denials, fixed occurrence identity/time with +full-draft reconciliation, and policy-target retention without implemented purge. +The bounded directory uses the existing Tenant membership-admin grant. The +revised six-step package above remains the implementation plan; every step has +its own commit, two fresh independent review rounds and verified corrections. + +**Canonical reconciliation.** Own-record dated Amendments are appended to +ADR-0033/0036/0040/0044/0047/0064/0065/0073, preserving their historical bodies, +statuses and dates. Standards 04/05/10/11/12/18/19, the module matrices/specs, +architecture 09/13/23/31/33, glossary and operational/Phase 11 handoffs reconcile +with the approved scope. Standard 05 owns source/cleanup capacity and read +witness mechanics; Standard 11 owns placement; Standard 18 owns evidence and +retention; Standard 19 owns definitions/delegation; Standard 04 owns the directory +wire contract. Architecture and deployment notes link those owners. + +The phase register records accepted G2 execution/G5 enforcement/G6 policy and +directory/G7 evidence slices. P03-3 retains native/mutation/UI and new-user +activation decisions; G13 retains privileged population. Acceptance alone does +not replace deny-all membership wiring or the application pass-through shell. + +**Baseline source-classification census.** The inspected production source at +`725d255a` contains 46 concrete MediatR request declarations. This declaration +inventory scopes the complete classification implementation; it is neither a +compiled census proof nor an exemption from runtime default deny. Step 3 compares +the actual compiled requests/handlers and trusted owner call sites in both +directions and plants missing/extra/forged-source controls. + +| Population | Count | Required classification | +|---|---|---| +| Existing public queries | 4 | Exact virtual Portal Public policy; unchanged host/publication/READ ONLY ceiling | +| AccountSite query | 1 | Exact special host-backed protected policy, no assignable permission and Off success | +| Contextual seed verification queries | 12 | Exact finite seed-owner request authority, no human route | +| Identity seed commands | 4 | Existing private witness/owner admission, never SystemActor-by-value bypass | +| Other seed/system writers | 18 | Exact existing seed composition; two named initialization commands also have admitted delivery owners | +| Framework job requests | 7 | Existing exact framework source/profile gates; no general business-job authority | +| **Total** | **46** | Every concrete request accounted for once | + +- Public: `GetPublicSiteQuery`, `GetPublicCoursesQuery`, `GetPublicCourseQuery`, + `GetPublicLessonQuery`. Their three virtual definitions add no stored grants. +- AccountSite: `GetAccountSiteQuery`, retaining matching real HostScope and + HostAndClaim in addition to its new live membership/lifecycle check. +- Tenancy seed verification: `GetTenantSeedStateQuery`, + `GetOrganizationSeedStateQuery`, `GetHostMappingSeedStateQuery`, + `GetSettingSeedStateQuery`. +- Customization seed verification: `GetContentTypeSeedStateQuery`, + `GetTaxonomySeedStateQuery`, `GetActiveContentTypeSeedRevisionQuery`, + `GetActiveTaxonomySeedRevisionQuery`. +- Education seed verification: `GetCourseSeedStateQuery`, `GetLessonSeedStateQuery`. +- Identity seed verification: `GetRoleSeedStateQuery`, `GetMembershipSeedStateQuery`. +- Identity seed commands: `InitializeSeedRoleCommand`, + `CreateFirstSeedAdminMembershipCommand`, `CreateSeedMembershipCommand`, + `SetSeedMembershipProfileCommand`. +- Tenancy seed writers: `ProvisionTenantCommand`, `CreateOrganizationCommand`, + `MapHostToTenantCommand`, `AddTenantLocaleCommand`, + `SetDefaultTenantLocaleCommand`, `SetTenantBrandingCommand`. +- Customization writers: `RegisterTenantContentTypeCommand`, + `PublishTenantContentTypeCommand`, `RegisterTenantLevelTaxonomyCommand`, + `PublishTenantLevelTaxonomyCommand`, `InitializeTenantContentTypeDefaultCommand`, + `InitializeTenantLevelTaxonomyDefaultCommand`. +- Education seed writers: `CreateCourseCommand`, `AddCourseTranslationCommand`, + `PublishCourseCommand`, `CreateLessonCommand`, `AddLessonTranslationCommand`, + `PublishLessonCommand`. +- Framework jobs: `ReadBackgroundJobEnvelopeQuery`, + `AdmitBackgroundJobAttemptCommand`, `ReadBackgroundJobExternalStateQuery`, + `RecordBackgroundJobArmCommand`, `RecordBackgroundJobOutcomeCommand`, + `RecordBackgroundJobCapacityCleanupCommand`, `SubmitBackgroundJobRecipientCommand`. + +The baseline HTTP consumer inventory is eight public GET/HEAD actions and the +single AccountSite GET, plus explicitly classified infrastructure health/OpenAPI +surfaces. Routing misses keep 404/405 semantics. The planned directory is the only +additional routed operation. No current seed command becomes a human permission +operation merely because it is reachable through the Seeder. + +Non-MediatR trusted entries are also explicit: Customization's +`InitializeContentTypeDefaultHandler` and `InitializeTaxonomyDefaultHandler` +consume their exact two initialization commands through the validated delivery +owner; existing global binding/self lookup, seed adoption and creation/ +reconciliation retain the prepared global owner. These remain separate from the +ordinary tenant policy population and add neither Platform nor HTTP creation +capability. The compiled source/caller census must include these boundaries and +actual job auxiliary lanes, not rely on an ambient UUID or a comment. + +**Proof registration.** Standard 21 registers four new owner/policy/evidence/ +provenance obligations and moves three permission placeholders from Awaiting +backfill to Registered with named proof owners. Existing Implemented rules keep +their shipped subjects and explicitly pending P03-2 extensions; no new runtime +control is promoted. Five planned audit rows are present once in the owning +Identity/Tenancy matrices, with the central denial/assertion rows also off-path. + +**Step 1 review state.** Acceptance/carrier implementation is under validation; +the two fresh independent review rounds follow the initial committed source. +No authorization source, database privilege/schema, policy adapter, live membership +wiring or new endpoint is implemented by this documentation step. Runtime, +PostgreSQL/PgBouncer progress, cleanup, crash and ambiguous-COMMIT evidence remain +mandatory in the later steps. The preexisting untracked worktree directory is +untouched. + +**Step 1 initial validation.** `make docs-index` regenerates the log index; +`make lint-docs` passes with 2,729 resolved fragment links and all phase/index +structure checks. All seven ADR parser controls pass; both records parse +Accepted. Release `CorpusConsistencyTests` and `AuditCoverageTests` pass 20/20 +with zero skips, including the new planned/off-path matrix population. Changed +relative targets, private-scratch reference scan and `git diff --check` pass. +These tests validate documentation/catalogue compatibility, not the new runtime +contract. The two fresh implementation-review rounds remain next. + +### P03-2 Step 1 review round 1 and setup correction (2026-10-27) + +**Reviewed source:** `e8266bbb` against approved `725d255a`. Two new independent +`gpt-6-astra` reviewers at xhigh effort inspect authority/execution/census and +evidence/privacy/corpus. Neither authored the carrier changes. The authority +review independently reproduces the 46-request/nine-action baseline and checks +candidate/witness/delegation/HostScope and own-record Amendment boundaries. The +evidence review confirms the approved barrier, occurrence matching, crash limits, +retention and planned/off-path distinctions. These are documentation/source +reviews; no new runtime progress or durability proof is claimed. + +**Verified finding:** the environment template and local-development skill lack +the approved pending setup handoff. Their four-login-role baseline is preserved, +but their wording now distinguishes roles from additional app-role sources and +links the coupled source/topology ledger. Executable environment assignments +remain unchanged; API/Seeder projections change with qualified runtime wiring. +The parent also completes the approved permission-workflow reconciliation: +replace illustrative endpoint/resource-handler snippets with the accepted scoped +HTTP/application policy, resource ordering and operation-witness contract, without +inventing an implementation API. Registered proof status remains explicit. + +The authority slice approves its inspected contract scope; the evidence slice +approves with the setup minor above. The parent verifies/corrects that finding; +a second fresh independent round reviews the committed correction and complete +Step 1 delta. Work stays on `development`; no runtime wiring is changed. + +### P03-2 Step 1 review round 2 and closeout (2026-10-27) + +**Reviewed source:** `c7000844` against approved `725d255a`. Two new independent +`gpt-6-astra` reviewers at xhigh effort inspect the complete authority/security/ +source-classification and evidence/privacy/durability slices. Neither authored +Step 1 or performed its first review round. Both approve their read-only scope +with no verified actionable finding. They independently verify the setup/ +permission-guidance correction, unchanged executable environment assignments, +46-request/nine-action census, immutable historical prefixes, pending proof +statuses and the approved barrier/source/scope limits against adjacent code. + +**Validation:** both reviewers run the whole-tree docs/structure/index checks +(2,735 resolved fragment links) and range diff checks successfully. The initial +Release 20/20 catalogue/audit-matrix controls and seven parser controls remain +the applicable tests; no new runtime proof is inferred from their review. +No finding is left unresolved or silently deferred by this closeout. + +**Step 1 complete.** Joint acceptance, canonical reconciliation, baseline source +classification and proof registration have their commit and two fresh review +rounds. Step 2 proceeds automatically: implement the confined candidate reader, +trusted input/child-owner seam and independent bounded sources, then qualify +committed-only facts, physical READ ONLY, lifetime and direct/PgBouncer progress. +Production deny-all/live HTTP wiring remains held until the approved activation +step and its complete evidence paths. No further maintainer approval is needed +for the accepted six-step scope. + +### P03-2 Step 2 — Confined candidate and committed authority (2026-10-27) + +**Implementation.** The exact HTTP candidate minter consumes quota-admitted, +normalized existing-binding evidence. A raw principal/UserId, replaced subject, +quota/host/identity feature, canceled request or disposed source cannot vouch for +its captured candidate. Kernel construction stays private and its production +minter/caller set has compiled clean/dirty controls. No production membership +registration is changed: the API still registers the deny-all adapter until +Step 5's complete policy/evidence activation. + +`IdentityAuthorizationReadSource` owns four retained slots/connectors with no +waiting admission queue. A dedicated child is READ COMMITTED, physically READ +ONLY and exactly announced before data; it installs no TenantContext and exposes +no raw source or caller-supplied SQL/callback API. The immutable generation/view +cannot join, retarget, widen or independently resolve the owner. Expired/canceled +views stop vouching while the underlying cleanup remains retained. The coupled +configuration selector is implemented; production/launch composition and evidence +execution remain later steps. Direct-PostgreSQL derivation is a supported topology +precondition, never an endpoint-string/port inference. + +The actual Identity statement reads only current eligible Memberships, compatible +assignments, nonretired closed Role templates and active assignable grants. It +bounds facts to two Memberships/eight Roles, refuses excess/ambiguous evidence, +ignores unknown/virtual stored grants, and projects no Profile/User/provider data. +Tenancy's narrow authorization overload runs its separate lifecycle statement on +that same child/generation; its older seed/lifecycle entry remains compatible. +Both statements see committed state, not a parent's uncommitted authority. Store, +deadline or cleanup failure yields fixed Unavailable without prepared facts; +actual request cancellation propagates after owned cleanup. + +**Role-guard deadline correction.** Inspection of pinned +[Npgsql 10.0.0's connector initializer](https://github.com/npgsql/npgsql/blob/v10.0.0/src/Npgsql/Internal/NpgsqlConnector.cs#L500-L519) +shows its initializer delegate lacks an operation-token argument. Reusing the +ordinary builder would leave that guard query outside the accepted total budget. +The confined source instead awaits the existing shared role SQL explicitly on +every checkout with its operation token before BEGIN. The ordinary builder's +initializer is unchanged. A real blocked `pg_authid` control verifies cancellation +finishes while the catalog lock remains held; elapsed time releases no slot. + +Named job authorization/evidence lanes enter the actual retained-resource and +cleanup-join census, preserving the one main unit and exact four business helper +seams. Evidence has no production caller yet. No ambient marker is suppressed. +Concrete Npgsql connection-disposal delay/fault twins prove retained source/job +ownership; the separate reported-command-fault control proves the denial-only +reporting seam and does not pretend to inject an actual command disposal fault. + +**Progress proof.** The pinned +[PgBouncer fixture image](https://github.com/edoburu/docker-pgbouncer/releases/tag/v1.26.0-p0) +uses transaction pooling over the same actual app-role database. All business +server transactions stay held while actual nested/post-lock Identity/Tenancy reads +complete, directly and through independently reserved aliases. The fixture probes +the actual child backend PID and separately checks it and every parent through +app-role metadata; application names supply no reservation proof. The intentionally +shared saturated downstream alias fails the same progress control and recovers +only after parent release. A raw held 2/4/2-backend control proves configured alias +headroom only; production evidence-owner liveness remains Step 4. Phase 11's real +deployment/instance sizing is not claimed. + +**Validation.** Release builds use `CI=true`, with zero warnings/errors. The +complete unit suite passes 2,810/2,810 with zero skips. The 52-case scoped Docker +population comprises nine source, 29 reader, eight existing lifecycle and six +pool-progress cases. The initial combined run passes 50/52; only two PgBouncer +PID-observation assertions fail after successful Covered/grant results. Replacing +the fixture's application-name inference with its actual-child PID probe makes +all six progress cases pass on the corrected source. The other 46 unchanged +cases passed the combined run. Earlier fixture SystemActor/API-shape issues were +corrected without weakening production admission. + +The 71-case relevant architecture population passes 70/71 initially; its only +remaining job census still expects one cleanup task after the admitted two named +lanes add two more. The exact three-call census is corrected and that mutation +Fact passes independently. All five new compiled source/async/SQL controls pass; +the two earlier detector assumptions were corrected against actual Release IL +(reference equality is lowered to branches; the concrete command executes an +inherited DbCommand method), preserving planted failure controls. Standard 21's +method count becomes 308; the overall Registered live-policy/evidence obligations +are not promoted by this partial implementation. + +**Review state.** Step 2 implementation/validation is ready for its first committed +source and two fresh independent review rounds. No HTTP route, grant/schema, +provider configuration, native BFF allowlist or production policy wiring changes. + +**Commit gate.** Whole-tree docs/structure/index checks pass (2,737 fragments), +seven ADR parser controls pass, and relative-target/private-scratch/diff checks +pass. Leakwatch initially flags three nonfunctional connection-string canaries +in the configuration unit fixture. They are represented with the existing typed +Npgsql builder pattern instead of raw DSN literals; no scan is disabled or ignored. +All six affected configuration cases pass again. This changes no runtime value. + +### P03-2 Step 2 review round 1 and corrections (2026-10-27) + +**Reviewed source: `8ec7ee18`, against `5ee659b1`.** Two fresh independent +GPT-6 Astra reviewers at xhigh effort examine resource/configuration/job ownership +and candidate/query/trust boundaries. Both request changes. The parent reproduces +the existing authentication-authority architecture failure and verifies the job +gate and missing role-bound controls against source; all three findings remain +valid. No finding is dismissed as stale. + +- `BeginAuxiliary` reconciles positively completed authorization/evidence lanes + before its admission gate. Deterministic tests retain the owner's monitor while + completing the real task, so its queued release continuation cannot run first; + either lane then permits the ordinary helper. Incomplete or failed cleanup + still refuses. The exact field/call census and planted deletion controls grow + with this correction. +- The existing authentication-authority census enumerates the new minter's exact + signatures, typed getters and private retained witness. The witness is a sealed + immutable class with fixed text, avoiding a record's generated formatting and + equality members. Only the exact newly constructed witness may travel through + the candidate's opaque evidence argument. Shape, extra-member and substituted + constructor controls retain the existing export/forgery refusals; no type-wide + or assembly-wide exception is added. +- Real app-role dirty controls add a duplicate eligible template below the bound + and nine eligible Role IDs. Each refuses with empty facts and restores its exact + index/data. Nine roles necessarily repeat one of the eight finite templates; + these runtime controls do not pretend to isolate the numeric guard. Separate + compiled mutations remove/change the actual role-ID count/bound/branch/throw + path, independently exercising that guard. + +**Validation.** The corrected 35-case architecture population and 24-case +candidate/job unit population pass with zero skips. All 29 actual PostgreSQL +reader cases pass, including both new dirty Role graphs; the five compiled reader +controls pass again with the numeric-guard mutations. Release builds report zero +warnings/errors. No launch registration, HTTP operation, grant or schema changes +in this correction. A second fresh independent review round follows the commit. + +**Count correction.** The prior Step 2 record transposes two cases in its +population breakdown: the original 52 cases comprise nine source, **27 reader**, +**ten lifecycle** and six pool cases. Its 50/52 combined outcome and corrected +six-case progress rerun remain accurate. The reader population becomes 29 only +with this correction's two added controls. This is a reporting correction, not +additional execution evidence. + +### P03-2 Step 2 review round 2 and closeout (2026-10-27) + +**Reviewed source: `7a7bb306`, against `5ee659b1`.** Two new independent +GPT-6 Astra reviewers at xhigh effort review the complete step and its first-round +corrections. The trust/query/lifecycle/proof reviewer and the separate resource/ +configuration/job/pool reviewer both approve with no verified actionable findings. +Neither edits the repository or inspects preexisting worktrees. + +The trust reviewer verifies the exact closed witness/forwarding census, actual +app-role three/nine-role controls and restoration, independent materialization +guard mutations, query bounds, committed lifecycle handoff and fail-closed +publication. The resource reviewer verifies completed-lane reconciliation, +retained cleanup/capacity/deadline ownership and actual downstream reservation +controls. Its independent Release no-build/no-restore unit run passes all 13 +job-lane/configuration cases with zero skips. Both run diff checks; neither +reruns Docker, full suites or hosted qualification. Recorded parent runs remain +author evidence rather than independently repeated execution. + +**Closeout.** Step 2 and both review rounds are complete. Its confined sources, +candidate and reader are implemented; overall live-policy/evidence obligations +remain Registered and launch still uses deny-all membership and the application +shell. Step 3 proceeds automatically under the approved plan. + +**Step 3 dependency ordering.** Bring the already approved internal member-directory +query/handler/read port and its MUST catalogue entry forward from Step 4, so the +operation-bound read witness has a real production consumer in its implementation +and SQL/EF proofs. This changes sequencing only: the existing Tenant +`identity.membership.admin` pair, finite manifest, sensitive disclosure contract, +HTTP activation in Step 5 and unchanged native BFF allowlist remain. The shared +application coordinator uses a SharedKernel decision port; Identity retains its +own facts and evaluation. No common Application-to-module dependency is added. + +### P03-2 Step 3 — Policy and operation-bound execution (2026-10-27) + +**Implementation.** `OperationPolicyCatalog` freezes actual module definitions +and exact production request policies. It retains the five assignable foundation +pairs, adds only the three accepted virtual-public definitions, and distinguishes +public, AccountSite, protected and finite trusted acts. Missing/conflicting +declarations refuse; registration changes no Role grant or retained manifest. +The current census covers 47 requests, including the internal directory brought +forward from Step 4. Compiler-level missing/extra request and source controls +compare the whole production universe with the actual composition. + +The common scoped coordinator owns each act and its nested lifetime; Identity +evaluates fresh committed facts behind the SharedKernel decision port. Neither +the same request object, resolver outcome nor earlier decision is reused as a +grant. AccountSite retains matching real HostScope; public reads call no human +candidate or membership evaluator. Exact descriptor minters bind seed authority +to its owned per-act DI scope, event authority to validated delivery/subscription/ +prepared owner, and job authority to the existing retained Frame/RequestFrame. +The job adapter captures rather than re-admits an already claimed request. +Closed/faulted bindings and inherited tasks cannot revive those owners. + +Only an eligible Tenant capability and declared tenant-wide read mint immutable +`OperationReadWitness` authority. Its invocation retains one-time consumption +state; the actual ordinary unit binds unit/transaction/generation and compatible +per-frame scope. Both mixed nesting directions poison outer commit, even if a +handler absorbs the inner failure. Stale/canceled witnesses refuse raw unit +access, EF and commit. The common context registration attaches the actual live +view; EF's context-instance parameter and RLS consume the same read scope while +retaining the tenant predicate and organization write restrictions. Narrow and +provisioning setup explicitly clear `app.scope`, including no-reset pool reuse. +This extends the existing logical setter, not TenantContext writers. + +The real internal directory has a contract, validator/cursor, handler, EF adapter +and common MUST ReadSensitive catalogue entry. It returns only the approved +six-field projection with current Active/Suspended rows and bounded canonical +actor/tenant/org/operation-bound navigation. The bounded status enum serializes +as `Active`/`Suspended` through a type-local converter. Its ordinary writable +owner commits the read evidence before result disclosure. No directory controller +or native transport operation is activated. HTTP controller/infrastructure +metadata and named requirement values are prepared against the same policies; +an actual built endpoint census and corrupted metadata twins exercise the +classifier separately from future middleware activation. + +**Composition boundary.** The explicit proof graph replaces only slot 5 with +the actual scoped adapter and dispatches the actual directory through the common +eight-stage audit/transaction pipeline. Launch retains its previous shell and +deny-all membership until Steps 4/5 jointly activate trusted denial evidence. +The new handler's real read-store port is registered in ordinary Identity +composition so API/Seeder graphs remain complete; that adapter still requires +the actual bound witness. The handler workflow skill's obsolete no-auth/implicit- +grant instructions are reconciled with the accepted source contract. + +**Runtime validation.** Strict Release builds pass with zero warnings/errors. +The complete unit population passes 2,894/2,894 with zero skips. The first relevant +integration run passes 117/121: all read-scope, directory adapter, endpoint and +existing owner/filter cases pass. Three new pipeline cases first expose a missing +job catalogue in the proof graph; the real initializer control exposes the +new read-store dependency missing from ordinary DI. Both graphs are corrected +through their real registrations. Those four changed cases then pass together. +The directory's success test observes its actual COMMIT blocked at a deferred +audit gate with no visible row/result; release permits the row and result. +Real INSERT and deferred COMMIT faults withhold the result and preserve the +existing audit-error/primary-exception distinctions. + +All ten direct/PgBouncer pool cases pass: the four added cases execute the actual +coordinator and Identity evaluator while every business server transaction remains +held, nested and post-lock. The saturated shared downstream alias refuses the +same policy path and recovers only after parent release. This proves policy +progress, not directory data publication or production instance sizing. + +**Architecture validation.** The complete architecture run passes 575/586 cases +and identifies eleven failures. It catches a real dependency-direction +violation in the earlier combined reader: Infrastructure consumes another +module's lifecycle contract. Orchestration moves to Identity Application behind +its own confined session factory; SQL and physical ownership stay in +Infrastructure. The exact SQL bytes are unchanged, and no dependency rule is +relaxed. The related factory/session/caller controls follow the actual split. +A new descriptor-shape detector's +initial field list omits its two readonly auto-property backing fields; the exact +compiled five-field shape is corrected without a production exemption. The +actual generated coordinator lambda is normalized to its exact owner family, +preserving call multisets. The typed directory seek method joins the existing +exact read-method census; its MembershipId remains typed, with a removed-entry +control. This is not another aggregate writer exception. The organization SQL +census verifies the private shared literal at both real executed argument sites. + +HTTP traversal distinguishes only the two declared event-initializer type +arguments and their two existing Off catalogue registrations as data metadata. +Actual factory/builder bodies must be data-only; fields, constructors, locals, +handler calls, delegates and extra registrations retain dirty controls. Endpoint +composition counts the three fixed infrastructure metadata attachments in +addition to the four existing mappings, with exact object-array/getter/receiver +provenance. No general metadata, source-type or assembly exemption follows. + +The correction population passes 122/126; a subsequent seven-case run passes all +remaining failures and adjacent metadata/ownership controls. Every original +failure now has current green evidence. The unaffected cases are not represented +as another complete rerun. After the layer split, all 47 reader/directory/MUST/ +pool cases pass again, and the complete current unit population passes +2,894/2,894 again. Compiled architecture method count is 313; its self-count guard +passes. These local runs are not hosted CI/shard qualification, which remains +Step 6. + +**Review state.** Step 3 implementation and relevant validation are ready for its +commit and two fresh independent review rounds. The broader live-policy/evidence +obligations remain Registered until their remaining subjects and launch paths +are qualified; no Step 3 closeout or live activation is claimed here. + +The final lifetime check makes invocation-frame closure atomic across inherited +execution contexts. All 13 coordinator unit cases and the compiled ownership +rule pass after that change; the latter retains its corrupted closure controls. + +### P03-2 Step 3 review round 1 and corrections (2026-10-27) + +**Reviewed source: `a15716d3`, against `cf9454e5`.** Three fresh independent +GPT-6 Astra reviewers at xhigh effort inspect policy/trusted sources, +transaction/EF/RLS scope and directory/evaluator/HTTP metadata separately. +They make no edits or test runs; author receipts remain author evidence. +The policy/source reviewer finds no actionable defect. Two findings are verified +against the current implementation and corrected: + +- The directory's new deferred audit COMMIT test expects the ordinary primary + PostgreSQL exception, which maps to 500. ADR-0077 requires 503 and no disclosure + for this sensitive read. The physical completion catch now retains the actual + transaction outcome and original cause, then reports `audit_unavailable` only + for the matching active tenant-wide read witness with exclusively MUST + ReadSensitive intents and no domain changes/produced work. Cancellation and + ordinary mutation/unknown-business-COMMIT paths retain their original behavior. +- The interceptor XML/error text and Database standard still describe its role + as diagnostic only. They now state its live-witness protection for retained EF + commands while SQL scope remains announced until cleanup. No runtime guard is + removed or widened. + +**Validation.** All four real PostgreSQL directory pipeline cases pass, including +the mapped-503/no-result/cause check and a new ordinary-owner counterexample +with the same sensitive-read intent but no policy witness: its original exception +and Indeterminate cause remain intact. All 87 transaction/audit/coordinator unit +cases pass, including ordinary COMMIT and cancellation preservation. The compiled +operation-ownership rule and both lifecycle census/mutation cases pass. The first +architecture filter matches only the ownership rule; the two lifecycle cases are +then selected by their actual compiled names and run separately. No broader +rerun or second-round approval is implied. + +### P03-2 Step 3 review round 2 and closeout (2026-10-27) + +**Reviewed source: `489746d8`, against `cf9454e5`.** Three new independent +GPT-6 Astra reviewers at xhigh effort review execution/trust, Identity/directory +and HTTP/compiled-proof/documentation slices, including the first-round fixes. +All three approve within those scopes with no actionable verified findings. +They make no edits, run no tests/Docker and inspect no preexisting worktrees; +the execution reviewer independently runs a clean diff check. Supplied runtime +receipts remain author evidence rather than independently repeated execution. + +**Closeout.** Step 3 and both review rounds are complete. The policy catalogue, +finite source adapters, fresh evaluator, operation-bound SQL/EF execution and +internal audited directory are implemented in the staged proof composition. +The broader live-policy/evidence rules remain Registered; production launch +retains the previous shell/deny-all behavior pending Steps 4/5. Step 4 proceeds +under the approved plan with central fixed-occurrence evidence, its pre-success +barrier, trusted provenance and separate explicit Role grant acts. No new +maintainer decision or HTTP activation is implied by this closeout. + +### P03-2 Step 4 — Trusted evidence and finite grant acts (2026-10-27) + +**Implementation.** The common `AuthorizationEvidenceCoordinator` owns a separate +fixed occurrence per refused policy act. It freezes the complete row and UTC +microsecond decision time, keeps real nested acts distinct and serializes re-entry +with the same decided reference. Ordinary capture's Committed flag acknowledges +none of these events. Successful ReadWrite completion flushes unresolved events +after domain draining and before final commit admission; physical ReadOnly and +ownerless success require a standalone acknowledgement. Event rollback/uncertainty +retains the same denied occurrence. Acknowledging a live transaction refuses, and +each pending write rechecks the original physical reference and generation after +I/O. The existing final event/job admission-to-COMMIT gap gains no await. + +Audit, policy and actual transaction scopes retain the population through their +real enclosing owner. Failed/uncertain events reconcile before ordinary capture +clears; a still-live prepared owner retains its events for its own barrier. +Already-refused callers preserve 403/404 on audit loss. Failed standalone attempts +are signaled by the MUST store and not retried by the same finally. No restart +ledger or universal recovery promise is introduced. + +`AuthorizationEvidenceSource` owns two retained slots/connectors under the same +guarded application role and installed authority. One shared five-second budget +covers opening, role guard, setup, INSERT/COMMIT and sequential exact verification. +The failed INSERT transaction's actual disposal finishes before a fresh physical +ReadOnly verification transaction starts on that connector. Job accounting retains +the physical connection and actual session task across both concrete transactions; +cleanup faults retain source/job capacity and joined shutdown. The source publishes +no general datasource/SQL callback. The two confined `IAuditStore` methods reuse the +sole INSERT/binder; only the expected primary-key duplicate admits full null-safe +22-field/typed-JSONB comparison. Exact event evidence establishes no business result. + +Typed immutable HTTP, finite seed/event/job and live decision provenance reaches +the existing columns and versioned authorization metadata. Actor absence in an +actual finite seed owner remains null, including unresolved provisioning's ordinary +resource frame; no SystemActor is invented. A resource frame binds one exact +invocation, so a direct inner act without its own frame cannot replace its parent; +a fresh post-lock recheck of that act can refresh its context. Membership/Role IDs +remain bounded/sorted and contextual. HTTP sources reuse the admitted visitor, +fall back to the physical peer, sanitize/bound user agent and accept only canonical +endpoint identity. Claim-only assertion observations freeze confirmed scope and +dimension without asserted UUIDs or a host-budget debit. The adapters are staged; +real resolver/HTTP activation remains Step 5. + +`ChangeRoleGrantsCommand` and `AdoptRoleGrantManifestCommand` each own one Role, +expected version, current eligible admin/delegation ceiling and immutable kernel. +The complete strongest tenant lock mode is selected before waiting; fresh actor +and target checks precede mutation afterward. Orgless Tenant-to-Organization +same-key authoring is explicit; no evaluation fallback, new key, v2 manifest, +automatic seed upgrade or writer route follows. Contained grant changes retain +references and delete only actually removed tracked children under the existing +restrictive FK. Their actual common MUST rows carry the Role and its child diff. +The policy census is 49 requests: 41 finite, four public, one AccountSite and three +protected, with the same five assignable and three virtual definitions. + +**Validation and corrections.** Strict Release production builds pass with zero +warnings/errors. The complete unit population passes 2,961/2,961 with zero skips; +subsequent affected API-source/central-owner controls pass 34/34, including physical +generation, precommit acknowledgement and actual admission/transport canaries. +Actual SeedComposition provenance controls preserve null actors without opening +a database or claiming the fixture intent became durable. + +The first 80-case PostgreSQL/PgBouncer group passes 67/80. Three source assertions +incorrectly infer disposal from a null/new NpgsqlTransaction object; the corrected +controls observe distinct server transaction IDs, immutable announcement generations +and fresh physical ReadOnly state after an actual aborted transaction. Three grant +tests incorrectly reuse a poisoned/validated scoped owner; each act now uses a +fresh complete scope. The grant-diff assertion checks the actual RFC 6901-escaped +composite key and exact before/after values. The pool fixture now announces legal +orgless role writes for its uncommitted-authority dirty control and compares the +actual empty contributor metadata on denial. One uncertainty test exposes ordinary +capture clearing before event reconciliation; the owner now reconciles first. +All 30 correction cases pass. The final relevant group passes **84/84**, including +both actual precommit/postcommit SIGKILL controls, 22-field duplicate mismatches, +source/job cleanup faults, all 15 direct/PgBouncer pool cases and the directory's +physical MUST barrier. A separately staged test-only executable supplies the crash +boundary; no production test hook or existing job probe is changed. + +The legitimate seed-rerun control now changes grants through the actual audited +command, then proves the fixed v1 runner refuses without repair or another audit +write; that case passes separately. The architecture population initially passes +28/29; the new detector's fallback-throw and reference-comparison expectations are +aligned with actual Release IL, retaining dirty controls. A transitive HTTP graph +then exposes the classifier's fixed controller-type metadata as a false handler +dependency. Only its exact data-only table tokens are distinguished, while real +controller acquisition/execution/delegates remain covered by planted twins. +All six current metadata/provenance cases pass. Adjacent store, catalogue count, +aggregate and lifecycle checks pass except an incomplete job detector subject list; +its two already-enumerated evidence subjects are added and the exact ownership/ +escape test then passes, including its planted controls. No +complete architecture rerun or hosted qualification is claimed by these receipts. + +**Review state.** Step 4's targeted implementation validation is complete. Its +implementation commit precedes two fresh independent review rounds. Broader +rules remain Registered and launch still retains the prior shell/ +deny-all behavior; Step 5 owns joint production activation and its real HTTP matrix. + +### P03-2 Step 4 review round 1 (2026-10-27) + +**Reviewed source:** `c1a61dd4..6780597f`. Three fresh independent +`gpt-6-astra` reviewers at xhigh effort examine central barriers/provenance, +confined source/store ownership, and grant acts/architecture respectively. They +make no repository edits and run no builds. Their source review identifies two +Major findings and one subsequently verified Minor finding; the implementation +does not pass this round unchanged. + +- **Major — grant audit type.** Both successful grant changes and explicit + manifest adoption are MUST but incorrectly classified as `Update`. Standards + 19/18 require permission changes to be `SecurityEvent`. Correct the two + catalogue entries, matrix and unit/real-database assertions. Role snapshots + and contained diffs remain, and no retention mechanism is claimed. +- **Major — refusal observation precedes cleanup.** The outer authorization + observer receives a handler's failed Result only after transaction rollback. + A rollback exception prevents observation; cancellation during rollback closes + the invocation before later observation. The transaction owner must record + the exact current act's already-decided closed refusal before cleanup. It + must not classify an arbitrary thrown exception, 503, validation or concurrency + result as a denial. Fault/cancellation controls and invocation deduplication + are required before closure. +- **Minor — exact duplicate lookup.** The source/store reviewer reproduces the + nonnullable `(id,timestamp)` null-safe-predicate concern on an isolated pinned + PostgreSQL 18.4 fixture with 200,000 synthetic rows, the four audit indexes and + forced tenant/org RLS under `learnstack_app`. The complete comparison scans + 6,895 shared-hit blocks; equality for only the two nonnullable key fields uses + the primary-key index and four blocks. Both comparisons match. This is a + representative planner control, not the complete migrated application or a + production latency/timeout claim. Preserve null-safe comparison for the other + twenty fields, typed JSONB, scoped verification and the unchanged deadline. + Both owned temporary database fixtures are removed. + +The parent independently verifies the two source/control-flow findings and the +index predicates. Interpretation-sensitive observations about missing-resource +404s and contributing-role attribution do not establish an additional contract +violation and are not promoted into scope changes. The full architecture run is +also started against the committed implementation; its first failures identify +the exact new source/cleanup and Role-lock caller censuses that need extension. +Corrections, their validation receipts and a separate second review follow. + +### P03-2 Step 4 review corrections (2026-10-27) + +The parent accepts both Major findings and the representative PostgreSQL lookup +finding. `identity.role.change_grants` and `identity.role.adopt_manifest` now +classify as MUST `SecurityEvent`, preserving ordinary Role snapshots/contained +diffs. Only the two nonnullable duplicate-verification key predicates change to +equality; all twenty other persisted fields retain null-safe comparison and the +existing typed binder. + +The central evidence owner gains an exact transaction-result observer. It +requires the actual current Application invocation, matching canonical request +type and matching actual operation type. The transaction coordinator calls it +inside the failed-result branch before rollback or any later fallible cleanup. +The existing closed refusal filter and occurrence map prevent exception-text +classification and duplication by the outer observer. Twenty-two new controls +use the actual common audit/policy/transaction graph with a faulting/cancelling +unit: all closed refused codes retain their fixed pre-cleanup event, clean +cleanup keeps one occurrence, unrelated operations and non-policy failures mint +none. This is a deterministic unit fault control, not a new physical rollback +fault-injection claim. + +**Validation.** The rebuilt focused Unit population passes **55/55** +with zero skips. An isolated output layout first prevents all 54 selected +integration cases from locating the repository SQL fixture; no application +statement is tested by that failed setup. The correct ordinary working-tree +layout then passes **54/54**, including both grant audit types, complete duplicate +comparisons and actual precommit/postcommit process-kill barriers. No fixture +root rule is weakened to accommodate the incorrect test invocation. + +The long full-architecture diagnostic run is cancelled after its known failures +while corrections are being made; it is not a successful full-suite receipt or +a frozen final-source qualification. Its 33 failing cases reduce to five exact +census groups: the evidence datasource constructor, its retained-owner cleanup +join, the new Role lock caller, the two new protected Role policies, and the +transport adapter's typed-identity acquisition/getters with their dirty twins. +Only exact subjects are added. The cleanup proof keeps its actual-completion +join/body/parameter controls; type/witness/raw-claim/opaque-export guards remain. +The existing provenance fact also checks the actual failed-result branch and +exact invocation/type gates before the first rollback, with planted bypasses. +The first rebuilt architecture target passes 31 cases and still rejects all 29 +authentication-authority controls on one common clean baseline: Release IL +formats the User-Agent `StringValues` through a constrained `Object.ToString`. +The detector now distinguishes only the actual header's single-assignment local, +conditional value/null flow and immediate final string argument to the fixed +transport minter. It grants no general formatting or owner exemption. Planted +wrong receiver, missing header acquisition and actual identity/object/object-array +forwarding in that same approved method still fail. The rebuilt authentication +population then passes **29/29**, zero skips. The other 31 cases, including the +new pre-rollback/SQL predicate dirty controls, already pass; no production code +changes after their validation. The correction commit precedes a second fresh +review. Full final-source architecture qualification remains Step 6. + +### P03-2 Step 4 review round 2 (2026-10-27) + +**Reviewed source:** `c1a61dd4..16667e76`. Three new independent +`gpt-6-astra` reviewers at xhigh effort examine durability/provenance, confined +resource ownership, and grants/census respectively. None authored Step 4 or +reviewed it in the first round. They inspect the complete step and its fixes; +the resource and grant/census reviewers approve without an actionable finding. +The durability reviewer requests a correction for one further Major boundary. + +During that review the parent rebuilds and runs the complete Unit project: +**2,983/2,983 pass**, zero skips. This covers the new pre-cleanup observer's +generic transaction path alongside existing audit, job, producer, public and +identity controls. The 55-case focused Unit, 54-case corrected real-PostgreSQL, +31 non-authentication architecture and 29 authentication-authority receipts above +remain the step's completed targeted controls. Full architecture/integration/ +contract inventories and hosted A/B remain the later final-source qualification. + +**Major — cancellation before result observation.** The first correction captures +a failure before rollback, but still asks the live `CurrentInvocation` for it. +If an admitted handler returns a closed refusal and its own token cancels before +the transaction continuation consumes that Result, the live invocation is null +and both observers drop it. In the meaningful nested case the outer token stays +live, an event-free joiner does not poison its parent, and an absorbed failure +can therefore leave no denial for the outer commit barrier. The parent verifies +this source path; the reviewer runs no fault reproduction. + +This finding is accepted for correction. Retain a private observation of the +exact admitted invocation/context through consumption of its actual failed +Result, including cancellation before cleanup. Its lexical owner must reject +reuse after completion. This is audit attribution, not renewed permission to +execute, query or commit under cancelled authority: candidate/read-witness/ +`CurrentInvocation` checks remain live. Closed-code filtering, exception +exclusion, post-lock attribution and one occurrence per actual invocation remain +required. A deterministic pre-observer cancellation control and an absorbed +inner-token/live-outer control precede Step 4 closeout. + +### P03-2 Step 4 correction verification and closeout (2026-10-27) + +**Correction after `16667e76`.** The deterministic new Unit control first fails +against unchanged production: a completed handler refusal followed by immediate +inner-token cancellation reaches cleanup with zero recorded decision-time reads. +The correction introduces an internal opaque audit observation captured before +the actual handler await. It binds the existing execution scope, concrete policy +owner, exact invocation, request reference and request type. Its one-use +consumption accepts only the actual closed failed Result, uses that invocation's +latest already-verified immutable context, and keeps occurrence deduplication. +Current invocation, candidate and read-witness authority retain their live +cancellation checks. The internal audit-frame check retains current top-frame, +request, source-disposal and closed-ancestor checks. No historical execution +projection, tenant setter or additional async-local stack is introduced. + +The complete focused Unit population passes **33/33**, including cancellation +before observation, cleanup faults, ReadOnly/ownerless cases, latest post-lock +attribution, cancellation before admission, closed source/scope, foreign request/ +scope, changed current invocation and consumed-result replay controls. The full +rebuilt Unit project then passes **2,994/2,994**, zero skips. + +The current real-PostgreSQL store/grant/barrier group passes **57/57**. Three new +cases admit a real inner handler, form its refusal, cancel its independent token, +and prove the outer authority stays live. The ReadWrite owner atomically commits +its business row, two ordinary resource rows and one fixed denial without opening +the evidence lane. ReadOnly/ownerless cases cannot publish while the actual +standalone evidence COMMIT is held; they proceed only after acknowledgement. +The existing process-kill, uncertain-COMMIT and full duplicate-comparison controls +remain green. Test cleanup preserves xUnit's captured execution context. + +The existing closed invocation/read-witness ownership and canonical eight-step +pipeline facts pass. The canonical evidence/provenance fact is rebuilt after the +new observation census and passes with all planted scope/type/request/result/ +await/replay bypasses. Its first baseline shape assertion is corrected to the +actual CLR representation: the nested observation type is internal and sealed, +while its primary constructor is emitted Public. Exact construction remains +confined to the sole central capture site; a planted publicly exposed type, +nonconstructor reference write or other caller still fails. The indexed +twenty-two-field SQL and physical transaction/generation controls remain covered. + +The R2 durability reviewer independently verifies only this finding's correction, +inspects the reproduced failure and 33-case green receipt, and closes the Major +without another finding. This is a narrow correction check, not a third full +review round. The other two R2 reviewers already approve their complete slices. +The parent verifies the current 57-case PostgreSQL and 2,994-case Unit receipts, +whole-tree documentation lint and diff hygiene. Both requested independent +review rounds and their verified corrections are complete for Step 4. + +**Handoff.** Central evidence, confined resources, provenance adapters and finite +grant acts are implemented and verified in their explicit compositions. Launch +still retains its shell/deny-all registration until Step 5 jointly activates the +real HTTP/application boundary. Whole activation-dependent catalogue rules remain +Registered. Step 5 proceeds with the directory/controller, named/runtime policy, +live resolver/AccountSite admission, distinct claim-assertion source, OpenAPI/SDK +and real HTTP compatibility matrix. Step 6 owns complete final-source suites, +source-bound inventories, hosted A/B qualification and the reviewable PR. + +### P03-2 Step 5 — Live HTTP and application activation (2026-10-27) + +**Implementation.** Starting from Step 4 closeout `56f95547`, the API and Seeder +register the common frozen policy catalogue and actual canonical +`AuthorizationBehavior` at slot 5 of the unchanged eight-stage pipeline. The +staged replacement and pass-through invocation adapter are removed. Exact finite +seed, delivery and retained-job sources remain independent of SystemActor values. +Identity's human evaluator defers its reader until an actual protected act. + +The API replaces the deny-all membership port with the real committed reader and +qualifies its independent four-owner authorization and two-owner evidence lanes +against the installed authority and actual business database name. Both auxiliary +configuration keys remain coupled; Development-only direct-PostgreSQL derivation +preserves independent sources. Startup rejects missing anchors, owner credentials, +partial configuration and a second database carrying the same authority. Source +failure diagnostics contain no provider inner exception. The Seeder owns one +qualified evidence source across its per-act providers. Selected runtime children +receive authority/mode/auxiliary keys without exporting credentials to restore, +build, web or manifest projection. + +Runtime endpoint classification precedes dispatch, including AllowAnonymous +endpoints. Default/fallback authorization supplies no authenticated catch-all; +the two protected named requirements must match exact module policy metadata. +The sole added route is `GET /api/v1/identity/memberships`, with existing Tenant +administrative capability, six contextual fields, scope-bound UUID continuation, +strict query names and the common MUST ReadSensitive commit before disclosure. +AccountSite keeps its independent real HostScope and physical ReadOnly ceiling. +The routing-owned method-rejection endpoint preserves earlier 404/405 semantics; +lookalike application endpoints do not inherit that exception. + +A confirmed ClaimAndMembership assertion mismatch reaches its distinct central +MUST event through a confined API port, without host allowance or asserted UUID +metadata. Earlier host-backed assertion admission and resolver refusal populations +retain their separate boundaries. Fresh HTTP and application decisions do not +reuse resolver membership as a grant. + +**Actual integration correction.** The first real HTTP run passes 50 of 60 cases +and exposes nine directory failures caused by one shared integration defect: +binding lookup creates the scoped Identity context on a global transaction; the +later ordinary tenant owner must explicitly re-enroll that same context. The +contextual adapter now requires its clean, live, already-announced tenant owner, +matching dynamic tenant/org filters and the same physical connection before +attaching the current transaction. No GUC, setter, read-scope or filter authority +changes. The actual five-row directory matrix passes after this repair. Separate +PostgreSQL controls reject a foreign connection and pending tracked mutation +without retargeting, saving or clearing it. The remaining initial failure is an +incorrect test expectation that an unrelated routing miss carries directory +response policy; only that fixture assertion changes. + +**Validation before review.** The rebuilt strict Release integration group passes +**67/67**, zero skips: actual HTTP rows 6/7/9/10/14, capability/noncoverage/store +refusals, matching-host revocation, public zero-identity lookup, strict pagination, +claim-assertion budget independence, MUST insert/COMMIT failure and held-COMMIT +non-disclosure, AccountSite/binding/seed compatibility, and the seven directory +adapter controls. The additional actual-host startup test passes **1/1** across +both auxiliary lanes' wrong role, missing anchor, same-authority/different-database +and partial-pair variants, followed by healthy recovery with no repair/allocation. + +Served OpenAPI supplies the exact ten-operation union and normalized string +membership status. The snapshot refresh is an explicit local opt-in; CI refuses +that opt-in and leaves the snapshot unchanged. Normal Contract passes **5/5**; +SDK tests pass **103/103**, with typecheck, lint and native build. The SDK owns the +new generated contract without adding a native BFF method: AccountSite remains +the sole authenticated native operation. The six selected-runtime environment +controls pass. Current standards, module matrices, architecture and tooling skills +now describe this wiring; historical records and Accepted ADR bodies stay intact. + +The 49 unique targeted Architecture cases pass on the same strict Release +source: 29 closed-authority controls and 20 endpoint/policy/source/evidence/EF +ownership cases. The 62-case Docker-free HTTP regression group passes, including +a matched test-only undeclared controller under the actual Program: anonymous +404, normalized signed 403, zero handler dispatch and no business source creation. +The exact middleware-order census includes classification between assertions and +authorization; endpoint and role-metadata dirty controls remain executable. +Workspace typecheck also passes for SDK, UI and web. +The full rebuilt Unit project passes **2,994/2,994**, zero skips. + +The phase Status block owns packet state. Whole activation-dependent catalogue +obligations remain Registered until Step 6 completes final-source qualification. +Full backend/provider/native suites, source/image/shard inventories and fresh +hosted A/B qualification are not inferred from these focused receipts. + +### P03-2 Step 5 review round 1 and corrections (2026-10-27) + +Three new independent `gpt-6-astra` / `xhigh` reviewers inspect implementation +`02bce2a1` against base `56f95547`: HTTP/EF isolation (`p032_s5_r1_http`), +execution/startup/launch (`p032_s5_r1_execution`) and proofs/SDK/docs +(`p032_s5_r1_proofs`). All are read-only source reviews; none claims a test run. +The HTTP slice approves. The other slices request concrete corrections below; +the parent independently verifies each before changing it. + +- **Selected API credentials.** The API launch environment drops both explicit + auxiliary keys, unlike the new Seeder runtime helper. The correction preserves + the selected pair byte-for-byte, refuses partial/empty/control-bearing input + before spawning the API, and atomically selects a complete explicit process + pair before file fallback. It never combines one process key with a file key + or imports an unrelated ambient credential into the runtime helper. Demo's + pre-existing selected-stack conflict refusal remains. Nineteen new controls + include a real copied CLI/marker-child boundary: old source fails eighteen of + them, corrected source passes all nineteen. The four-file frontend group passes + **253/253**; web typecheck/native SDK compilation and targeted ESLint pass. +- **Common database execution.** Role, authority and database-name agreement alone + cannot distinguish a restored same-name database on another cluster. API and + Seeder startup now use the bounded live business-database witness documented + once in Standard 05. Both used lanes must see the exact active transaction + marker before qualification; runtime caps, roles, grants, schema and tenant + setter counts stay unchanged. Cleanup remains awaited and joined, with final + deadline checks after actual cleanup. This implements the Accepted common- + database requirement without claiming backend-reservation/topology proof. +- **Real protocol controls.** The same-name/same-authority second-cluster test + first proves that each clone is independently reachable/qualified and that a + fully self-consistent clone host can start. Crossing either lane into it then + refuses; original rows/anchor stay unchanged and the original host recovers. + Direct and pinned PgBouncer positives catch an initial driver-PID assumption; + qualification uses the actual SQL backend PID. A raw migration-source control + catches a missing exact-role check in the new factory. Those three prototype + cases initially yield one pass/two failures, then all pass after the correction. + The complete qualification group passes **14/14**, zero skips, including actual + marker cleanup, both lanes, disposal/cancellation/real five-second expiry, + foreign database, no unbound-result upgrade and no proof retargeting. +- **Legacy component fixtures.** The parent's broad Docker-free run exposes probe + controllers now correctly refused by production default deny; some old negative + assertions could consequently pass at the wrong boundary. That run is stopped + with known failures and supplies no completion receipt. Explicit component-only + hosts now assemble the production routing/MVC/body/error/tenancy components + these tests own, remove production controllers, and admit only their selected + test controllers. They make no Program/authentication claim. Application + dispatch retains canonical slot 5 and exact test-owned policy/provenance; + existing no-database tests invent neither physical transactions nor durability. + Actual Program/undeclared-controller/authorization proofs remain separate and + unchanged. The affected offline group passes **164/164**; real app-role tenant + isolation and HTTP outbox controls pass **9/9**. The full Docker-free integration + population subsequently passes **322/322**, zero skips. +- **Closed contracts.** The production job manifest adds only the exact tenth + membership-directory action; extra-route/dashboard controls remain. The existing + OpenAPI catalogue rule now says ten operations/six GET contracts, retaining four + public wrappers and the sole native AccountSite method. + +The startup witness's closed factory/binder/root callers, guarded role check and +bound production overloads extend the existing independent-owner Architecture +fact. Planted extra calls/function pointers, removed gates and substitution of an +unbound overload all refuse. That fact and the updated shared-role-guard caller +fact pass **2/2**. The original execution reviewer independently checks the narrow +correction, actual fourteen-case receipt and launcher controls and closes both +findings without another issue; this is correction verification, not a third full +review round. + +After corrections, the rebuilt full Unit project passes **2,994/2,994** and the +current real HTTP/directory/AccountSite/binding/seed selection passes **62/62**. +The current Contract suite passes **5/5**; documentation lint and diff hygiene +pass. These focused receipts complement, rather than replace, Step 6's complete +backend/provider/native populations, source/image/shard inventories and hosted +A/B qualification. R1 corrections are committed before the requested fresh R2. + +### P03-2 Step 5 review round 2 and closeout (2026-10-27) + +Three new independent `gpt-6-astra` / `xhigh` reviewers inspect current +`effc9c5a` over the complete Step 5 base `56f95547`, including the committed R1 +corrections: resources/startup (`p032_s5_r2_resources`), HTTP/application boundaries +(`p032_s5_r2_boundaries`) and proofs/docs/tooling (`p032_s5_r2_proofs`). All three +approve their source slices with no verified actionable finding. Each explicitly +runs no build/test and makes no changes; supplied test receipts remain the +parent's evidence, not independent reruns. + +Their checks cover the bounded live database witness and actual-cleanup lifetime, +production bound overloads, role/authority/source and job-resource ownership, +canonical default deny, exact HTTP/application metadata, candidate and read-scope +lifetimes, directory cursor/projection/pre-disclosure audit, distinct assertion +population and AccountSite/native ceilings. Proof review additionally verifies +real clone/PgBouncer counterfactuals, closed opcode/caller controls, explicit +component-versus-Program separation, atomic selected credentials and contract +closure. No third full review round is asserted. + +**Step 5 is closed.** Both requested fresh review rounds and all independently +verified R1 corrections are committed. Current focused/whole-offline validation +is recorded immediately above; this source review claims no additional runtime +qualification. Step 6 proceeds automatically with complete backend populations, +actual Keycloak/native compatibility, source-bound fixture/image/shard inventories +and fresh hosted A/B evidence, followed by the reviewable development-to-main PR. +Whole activation-dependent catalogue obligations remain Registered until that +qualification completes; no unrun provider, browser or production topology is +represented as delivered. + +### P03-2 Step 6 — Complete populations and qualification (2026-10-27) + +Step 6 adds actual-provider directory coverage for all four seed personas and +extends the real native learner journey through a committed Membership revocation. +The API refusal, local session invalidation, no-store response, absence of protected +rendering and retained provider SSO are independently observed. The selected real +provider/native/reader group passes **36/36**, zero skips. A separate 32-read warm +app-role candidate-owner sample reports p50 **4.3778 ms**, p95 **11.4101 ms**, maximum +**12.5116 ms**. This local reader sample excludes setup/provider/HTTP costs and +establishes neither request p95 nor production capacity. + +The fixture census grows from 131 to **144** actual shared-fixture classes. CI +preparation includes pinned PgBouncer `v1.26.0-p0` beside the existing PostgreSQL +and Keycloak images. The 110-second preparation budget, per-image bound, retained +cleanup reserve, VM cap and ordinary/native job windows do not change. Fixture +controls pass **40/40**, native-owner controls **89/89**, and runtime Seeder +environment controls **6/6**. The non-test evidence process remains explicitly +staged without joining the native-test class list. + +The first full Architecture diagnostic completes **588** cases: **575 pass and +13 fail**. Those failures identify three shared causes: auxiliary credential reads +outside the existing composition owner, a stale closed controller set, and a stale +qualification-overload caller census. It is a failed diagnostic, not qualification +or a passing timing source. Reads move into the existing persistence composition +file with frozen configuration captures; the two-file credential boundary remains +closed. Exact controller and guarded overload/proof callers gain corresponding +removed/extra/delegated controls. The corrected focused Architecture group passes +**4/4**. The complete Release solution builds with **zero warnings/errors**. + +Registry closeout also exposes a missing production denied-test cross-reference. +Five new real app-role cases prove each assignable pair's legal delegation target +and refused target, unchanged refused state and committed denial evidence. Role +administration remains confined to Tenant Admin with its kernel intact; no new +authority is introduced. All **5/5** pass. The canonical Architecture map checks +these exact cases, virtual-public and finite-owner controls, and removed +pairs/methods/assertions. Existing policy and durability proofs take their canonical +names. This adds one Architecture method/case and five Integration cases; actual +rediscovery, complete-suite receipts and measured planner refresh remain required. + +Guarded frontend execution passes **2,432** cases across both tested packages, +zero skips/todos; lint, typecheck and production build pass. Six initial lifecycle +failures identify a stale test allowlist for the runtime Seeder's installed +authority and explicit Development mode. Its exact argument-free application-seed +boundary now checks those selected values; restore/build environments stay confined. + +**Qualification remains open.** The initial discovery and receipts are local +working-tree observations, not hosted evidence or an exact committed-head claim. +Complete rebuilt populations, updated advisory weights and both fresh hosted +assignments are still required. Both Step 6 review rounds precede closeout; no +whole-obligation catalogue promotion or packet completion is claimed here. + +### P03-2 Step 6 review round 1 and full-suite fixture diagnosis (2026-10-27) + +The implementation is committed as `2e00aba9`. Three fresh independent +`gpt-6-astra` / `xhigh` reviewers inspect `070aa676..2e00aba9`: ownership/callers +(`p032_s6_r1_owners`), provider/native and denied-proof quality +(`p032_s6_r1_proofs`), and CI/inventory/documentation +(`p032_s6_r1_inventory`). Each approves the assigned source slice without a +verified actionable finding. They edit nothing and run no build/test; the +inventory reviewer additionally uses read-only Python census and diff checks. +The unrefreshed timing data and pending complete-suite/hosted qualification are +explicitly outside those approvals. Updated plan data receives separate narrow +verification before the fresh second review round. + +At this clean committed source the Release build has zero warnings/errors; +Unit **2,994/2,994**, Contract **5/5**, Docker-free Integration **322/322**, and +complete Architecture **589/589** pass with zero skips. The Architecture run takes +**29.0419 minutes** locally. Source/tree and all four test-assembly hashes are +captured before execution and verified unchanged afterward. This is local +implementation-source evidence, not a hosted job-fit claim. + +The following ordinary Docker diagnostic is deliberately stopped after **858 +reported passes and 314 reported failures**; it does not complete the population, +and neither its prefix nor its exit status supplies timing/qualification data. +The failures repeatedly hit stale test composition: dynamic job requests have +no explicit operation policy, and human audit-workflow actors no longer qualify +for production seed provenance. The isolated four-process test group receives +SIGINT and exits completely. The prior complete Architecture receipt remains +valid for its recorded source; the ordinary population still needs a complete +passing rerun. + +Read-only investigation also identifies component negatives that can pass at the +wrong early boundary. The correction therefore inventories the component roots, +including module writer/query, producer, submission and worker fixtures. It +retains real pipeline/audit/transaction/SQL, explicitly declares only each +fixture's selected requests, and separates these mechanism proofs from actual +Program/live-authorization proofs. No production seed permission, API allowlist, +role grant, job authority or startup qualifier is relaxed. Existing seed-only +organization administration remains refused from a job; fixture-owned aggregate +operations exercise the real store/audit mechanism separately. Actor/context +lifetime, intended handler/SQL/error boundaries and actual worker ownership are +part of correction verification. Both the correction and complete ordinary +qualification remain open at this record. + +### P03-2 Step 6 R1 corrections and regression verification (2026-10-27) + +The complete fixture audit produces explicit component admission for only the +named mechanism requests. The shared helper preserves every unlisted production +policy/definition, freezes the supplied list, binds scoped provenance at first +capture to the exact context and authority fields, and revokes it on scope +change/disposal. It retains all canonical behaviors and real persistence/audit +registrations. A new Docker-free Fact checks these boundaries and actual unit/store +construction without claiming a database or HTTP authorization proof. Actual +Program/live-authorization and undeclared-HTTP controls remain separate. + +Audit and producer fixtures no longer give a human actor production seed +provenance. Legitimate seed mechanisms retain the real Seeder. Module/query +fixtures opt into exact test requests, preserving handler entry, physical READ +ONLY, SQLSTATE, post-save/rollback and actual worker witnesses. Their 403/404 cases +use explicitly caller-owned, real app-role evidence sources with installed matching +authority and awaited disposal. Business Off remains separate from the central +MUST denial row. The AccountSite app-role readback initially misses branch rows; +the independent proof reviewer identifies its missing organization announcement. +Exact organization GUC/predicate and matching context call sites close that finding. +The corrected core group passes **48/48**, zero skips. + +Job fixtures classify only their actual dynamic requests and retain production +job provenance. Separate fixture commands exercise the real Organization/store/ +MUST audit; a real seed-only production organization command remains explicitly +refused from a job. Stale-authority negatives now witness the actual preflight +SQL. The broad affected run completes **844** cases, **814 passed / 30 failed**; +it is a diagnostic, not qualification or a timing source. Its remaining failures +identify cross-Application fixture ownership, mandatory Identity DI registration, +new audit UUIDs sharing a test counter, overinstalled component provisioning +provenance, and empty evidence completion. + +The queued fixture corrections preserve the original strict checks: the combined +source test uses the owning fixture's exact nested type; bare global/lifetime +roots register the real Identity authorizer graph; delivery provenance hosts have +no setup-only provisioning source; recipient negatives match the issued UUID to +an independently readable committed denial and retain zero child state. + +**Production correction.** An empty or fully acknowledged authorization-evidence +set owes no further I/O. The previous empty completion still demanded a tenant +announcement, and a post-COMMIT success could wait on an already-cancelled +standalone semaphore. This falsely marked acknowledged SDK work as cleanup +failure. The bounded guard retains owner/depth/active/rollback and composition +checks, then skips only the empty/fully-acknowledged path. Pending, failed, +rolled-back and uncertain evidence still requires the existing acknowledgement, +announcement, generation and cancellation checks. No decision/grant or durability +floor changes. Four new unit cases and the existing exact-COMMIT control cover +empty, acknowledged, pending/cancelled and missing-announcement outcomes. + +The evidence unit group passes **21/21**. The actual source-contention, positive +COMMIT/cancellation, recipient/no-child, lifecycle, delivery and outbox regression +group passes **43/43**, zero skips. The strict Release solution builds with zero +warnings/errors. Existing source/corpus/permission checks additionally pass +**10/10** before the production guard; refreshed relevant checks follow below. + +The original ownership reviewer independently verifies the bounded helper/job +correction and then the production empty-evidence and final fixture corrections. +The proof reviewer verifies module/evidence ownership, requests the exact-org +readback correction, and closes it after re-reading the actual diff. Both edit +nothing and run no build/test. These are R1 correction checks, not additional full +review rounds. Complete passing ordinary partitions, current rediscovery/weights, +fresh R2 and hosted A/B remain required before Step 6 closes. + +After the correction freeze, the complete Unit population passes **2,998/2,998**, +Docker-free Integration **323/323**, and Contract **5/5**. Refreshed Identity audit, +operation-policy, database-fixture and corpus Architecture controls pass **16/16**. +All four raw TRX receipts report zero failures/skips. Documentation/index checks +pass. These local receipts do not replace the remaining ordinary partitions or +final-source hosted A/B qualification. + +### P03-2 Step 6 review round 2 and final source corrections (2026-10-27) + +Three fresh independent reviewers, `p032_s6_r2_ownership`, +`p032_s6_r2_proofs` and `p032_s6_r2_inventory` (`gpt-6-astra`, `xhigh`), inspect +`070aa676..309e6d25` across ownership/liveness/audit, actual proof boundaries and +CI/inventory/corpus. Their read-only source reviews approve their slices; +qualification remains separate. A later narrow carrier verification checks +actual canonical owners, preserved anchors and the seven proposed promotions. +No reviewer builds, edits or substitutes source inspection for runtime evidence. + +**Clean predecessor receipts.** At `309e6d25`, the complete Architecture population +passes **589/589**, zero skips, and the affected ordinary group passes **844/844** +(including one Docker-free helper). Independent before/after source/tree and four +DLL observations agree. Actual post-completion Architecture process inspection +finds no surviving testhost. Complete no-build discovery then finds **6,922** +cases: 3,915 non-Docker and 3,007 Docker (2,976 ordinary / 31 native). Both A/B +layouts independently rediscover all 20 owners and exact unions; all 64 discovery +commands agree with their raw diagnostic/console evidence. These remain +predecessor-source observations after the correction below. + +The disjoint complementary run completes **2,133** cases: **2,108 passed / 25 +failed**, zero skips. It is a failed diagnostic, never a passing timing source. +All failures resolve to three verified causes: + +- A delivery-only worker fixture removes the old Identity startup owner but + retains the new authorization-source startup owner without an Identity anchor. + The exact removal list now includes both. Its existing registration proof + requires their absence and exactly one real DeliveryProcessor; the actual + worker, app-role SQL, source, audit, trace and lifetime checks remain intact. + A complete scan of the five old-startup removal sites identifies this sole + missing companion. +- The direct Seeder process Fact supplies only Default, omitting the installed + authority and approved runtime settings. It now installs the fixture-owned + anchor through the actual deployment installer, supplies the same authority/ + issuer and Development mode, and clears inherited auxiliary endpoints so the + two lanes derive from its owned direct PostgreSQL Default. Credentials remain + environment-only. Repeat/no-write and mismatch/snapshot assertions remain. + The external-process census identifies this sole stale writing Seeder child; + the runtime wrappers already supply the approved environment. +- The new public HTTP classifier changes unresolved platform public reads from + the generic `tenant_mismatch` to `not_found`. ADR-0064 and Standard 04 explicitly + preserve that generic result. The narrow correction restores it before any + authorization owner or dispatch; resolved contexts lacking public HostScope + still receive `not_found`, and unknown hosts still refuse in the resolver. + Existing real GET/HEAD cases remain unchanged. A new Docker-free Fact scans all + eight actual public controller endpoints and checks the generic code/no-store + with zero authorization-owner resolution or dispatch. + +The ownership reviewer independently approves the delivery correction and actual +public classifier/control; the proof reviewer approves the Seeder correction. +These are R2 correction verifications, not fresh full review rounds. The first +37-case correction group passes all 36 production regressions; its new inventory +Fact initially includes three infrastructure endpoints. The corrected scan names +only actual public controller endpoints, preserving the separate infrastructure +census. The complete corrected Docker-free Integration suite passes **324/324**; +Unit **2,998/2,998** and relevant Architecture **18/18** also pass, zero skips. +Release builds with zero warnings/errors. + +Active carriers remove stale pass-through/no-carrier/unregistered-runtime claims, +retaining historical ADR/delivery records, existing fragment anchors and phase +qualification ownership. Catalogue promotions and packet/gate completion remain +pending. The canonical proof-owner correction replaces three nonexistent-class +references only when its separately reviewed catalogue patch is applied. + +**Timing and final qualification.** ADR-0063 permits each advisory timing source's +own source/run/artifact identity. Passing predecessor Architecture/broad receipts +and a later corrected complement may supply separate observations covering +current selectors; matching IDs do not prove unchanged implementation or a +same-source local whole-suite run. Every input retains its own clean source/tree/ +DLL observers, raw all-Passed TRX hash and complete selector mapping. Failed +complement evidence is excluded. Fresh same-final-source/plan hosted A/B remains +the final qualification; no mixed-source local qualification is claimed. + +### P03-2 Step 6 measured-plan review and qualification freeze (2026-10-27) + +At corrected commit `f3b8d764`, the full complementary ordinary group passes +**2,133/2,133**, zero failures/skips, with unchanged clean source/tree and four +DLL hashes across the run. Contract additionally passes **5/5**. Actual rebuilt +discovery finds **6,923** cases: Unit 2,998, Integration 3,331 (324 offline / +2,976 ordinary / 31 native), Architecture 589 and Contract 5. The base owner has +3,327 cases; backend aggregate 3,916 and integration aggregate 3,007. All 64 +preweight list/diagnostic commands agree with their raw console/ID evidence. + +Independent R1 ownership and R2 inventory reviewers recompute all three passing +raw TRX hashes/counters, each source/tree/four-DLL binding, own/current case names +and IDs, decimal duration sums, ceiling milliseconds and historical floors. +The ordinary timing observations cover **843 + 2,133 = 2,976** distinct current +cases; exactly one offline helper is excluded from the 844-case predecessor +receipt. The private validator initially includes similarly named Unit classes +when predicting a command that actually executes only Integration. Its exact +Integration-assembly restriction corrects the prediction without weakening raw +receipt, selector or union checks. No failed diagnostic supplies timing data. + +The reviewed candidate adds three independently attributed local sources: +**98 sources / 1,717 weights**, **122 additions / 62 increases**, zero unknown +weighted selectors, and the existing **144** shared-fixture classes. Every other +static plan setting remains unchanged. Both reviewers approve the exact candidate +and independently reproduce its assignment differences and genuine A/B grouping. +The written plan matches the reviewed preview bytes exactly. Its digest is +`2cfb260303db173aa3c7f29139e570cf558f3359f629f13860161251ad2d6573`. +Architecture/broad observations remain attributed to predecessor `309e6d25`; +complement observations remain attributed to `f3b8d764`. Their complete four-path +code delta is retained. These are advisory observations covering current +selectors, not unchanged implementation or a mixed-source local qualification. + +A separate fresh output directory preserves all original binding artifacts while +actual weighted discovery reruns all **64** no-build commands. A/B each +independently rediscover all **20 owners**, exact filters and complete unions, +with zero unknown weights. The unchanged population digest is +`5abdc7f3f7d46c18ccd78ec856f2e3f1a9bc0b46e371cc7b9026c34b07e6a1ba`. +Maximum filter sizes are A **18,842** / B **28,198** bytes (limit 32,768); +maximum exact command argument sizes **19,193 / 28,550** bytes (limit 65,536). +The observer honestly records `f3b8d764` plus only the reviewed plan diff; source +and DLLs remain stable. It makes no clean-commit or hosted-fit claim. + +Weighted planner controls pass **83/83**, result/fixture controls **40/40**, +workflow topology is complete, and native-owner controls pass **89/89** before +the weight-only change. Review/source work is frozen for fresh full hosted A/B +qualification. Catalogue promotions, final gate/packet closeout and PR handoff +remain pending that actual evidence. + +### P03-2 Step 6 failed qualification and owned cleanup correction (2026-10-28) + +Full hosted A attempt [40577851910/1](https://github.com/HodeTech/LearnStack/actions/runs/40577851910) +at source `696671b5`, tree `33c27d67`, completes **25/27** jobs. Its twenty +producer archives select **6,923** distinct cases; **6,737** report Passed, +zero report Failed, and **186** have no result. Only ordinary-8 is incomplete: +**44/230** Passed, then the owner exhausts its existing deadline. Its receipt +honestly records `complete=false`, `forcedCleanup=true` and `dockerAbsent=false`. +The integration aggregate consequently fails. This is failed qualification; +no B attempt or catalogue promotion follows from it. + +All **24** actual artifacts are retained with API identity, exact byte sizes, +SHA-256 and ZIP CRC checks. The last completed provider cases do not identify +the stalled operation: all four selected shared-provider cases complete, +whereas SharedSchema completes only **15/181**. The old launcher retains +completion events but no active-case diagnostic, so neither a particular test +nor provider cleanup is established as the hosted root cause. The hosted SDK is +**10.0.403**, selected by the existing `latestFeature` roll-forward policy; +it is not the workstation's SDK or an exact 10.0.112 execution claim. + +Independent source inspection verifies a narrower cleanup defect in +`AuthorizationReadSourceTests`: a cancellation/assertion race can return an +owner that its test finally never disposes. Source shutdown correctly waits for +actual disposal, so this test failure can hide behind an indefinite join. +The repair always joins the pending open and disposes a returned owner, even +when the blocker task faults; the warm owner also has unwind coverage. Production +ownership rules and the original guard/cancellation/recovery assertions remain +unchanged. The original isolated role-guard test passes **1/1**. A temporary, +restored source overlay forces both successful-open/assertion-failure and +independent blocker failure: **2/2** deliberate failures complete promptly, +without a timeout. The normal source and DLL are rebuilt afterward. + +The launcher additionally retains optional, bounded start observations in project +measurements. It accumulates validated start deltas and removes actual results; +only selected assembly/ID/name values are exported. Missing/malformed start data +remains unknown and supplies no completion, ownership or qualification evidence. +Collection fixture initialization/disposal remains outside this diagnostic. +Five new controls cover concurrency, late starts, privacy and deadline retention. +Native-owner controls pass **94/94**, planner **83/83** and result/fixture +controls **40/40**. An independent reviewer approves the complete correction. +A local run of the exact 230-case A selection and fresh full final-source hosted +A/B remain required; the failed attempt is preserved rather than relabelled. + +The corrected local run of the exact failed owner's **230** selected cases now +passes **230/230**, zero failures/skips, including the role-guard cancellation +case. Its controlled fault twins and the clean rerun demonstrate repaired test +unwinding; they still do not establish the missing hosted active-case boundary. +Topology and documentation checks pass. The correction is frozen for a new full +hosted A/B pair, retaining the same plan, assignment counts and job ceilings. + +### P03-2 Step 6 Architecture timing correction (2026-10-28) + +The corrected source `84abac5e` completes full hosted A attempt +[40583371696/1](https://github.com/HodeTech/LearnStack/actions/runs/40583371696) +with **25/27** successful jobs. All ordinary producers, native, frontend, public +demo and the integration aggregate pass. Architecture-5 and the backend aggregate +fail. The twenty producers select **6,923** distinct cases; **6,895** report +Passed, zero Failed, and **28** lack results. All **24** actual artifacts are +preserved with API identity, exact size, SHA-256 and ZIP CRC verification. B does +not start. This attempt supplies no whole-workflow qualification. + +Architecture-5 completes **147/175** cases before `deadline_exhausted`. The new +optional diagnostic identifies recipient-census and recurrence mutation cases +without completed results. Source inspection finds finite Cecil graph mutations; +the named `memory_readiness_wait` control rearranges IL and executes no runtime +wait. This does not prove an implementation hang. The same 175-case selection +previously passed in **740.791** test-process seconds, but the new cutoff is +**704.669**. Pre-owner setup grows **190.610 -> 248.559** seconds and discovery +**33.763 -> 43.368**, together consuming **67.554** extra seconds before tests. +The prior passing provenance control took **357.260** seconds against its stale +**126.011**-second advisory weight. The failed producer's partial durations are +diagnostic only; they supply no new weights. + +Two independent reviewers verify the six complete, naturally cleaned Architecture +producers from prior run **40577851910/1**: exact disjoint **589 cases / 316 whole +selectors**, all Passed, with raw TRX, receipt and immutable artifact hashes. +That overall run failed elsewhere and remains failed qualification. Its passing +producer observations retain their actual source `696671b5`, run, artifact hash, +whole-selector scope and summed milliseconds. They do not claim current-source +wall-time bounds or unchanged implementation. + +The reviewed plan corrects **110** Architecture weights upward, retaining every +historical scalar floor and every selector. It removes exactly **17** originally +zero-reference source records from the active provenance map; their complete +values remain preserved in Git and the evidence archive. Six actual TRX sources +are added, giving **87 sources / 1,717 weights** within the unchanged 100-source +bound. All non-Architecture weights and ordinary/base/native assignments remain +unchanged. Counts remain **six Architecture / twelve ordinary / twenty execution +VMs**; two Architecture threads, job ceilings, **203-second** cleanup reserve, +whole-method grouping, salts, argument bounds and fixture census remain unchanged. + +The complete six-producer refresh is selected over a selective failed-shard-only +refresh. Reusing prior observed summed work, its maximum A projection changes +**1,346.572 -> 1,139.973** seconds; B changes **1,331.677 -> 1,379.367**. B's +projection is worse, so this is not a fit or guaranteed-headroom claim. Complete +measured weights are preferred to selecting a favorable subset; only new full +final-source A/B can qualify the revised plan. Its digest is +`c043f60588779307372be621e032dbc60e3437f525fab954136e15efb87027a0`. + +The rebuilt Release solution passes with zero warnings/errors. Actual no-build +filter discovery repeats all **64** requests and independently reconciles both +**20-owner** assignments to the same **6,923-case** population, with zero unknown +weights and unchanged manifest digest. Source and all four DLL hashes remain +stable across discovery. Native-owner controls pass **94/94**, planner **83/83**, +and result/fixture controls **40/40**. The second independent review recomputes +raw provenance, rounding, historical floors and candidate differences before the +new commit. New hosted A/B remains the qualification gate. + +### P03-2 Step 6 scoped census performance correction (2026-10-28) + +Full A attempt [40587983457/1](https://github.com/HodeTech/LearnStack/actions/runs/40587983457) +at reviewed plan source `72d76f20` completes **23/27** jobs. All ordinary +producers and the integration aggregate pass. Architecture 0/2/4 and the backend +aggregate fail. Exact producer unions contain **6,923** cases: **6,920** Passed, +zero Failed, and **three** without results. All **24** actual artifacts retain +API identity, exact bytes, SHA-256 and ZIP CRC checks. B never starts; no +qualification or catalogue promotion follows from this run. + +Each failed Architecture producer lacks one expensive Fact: authority census +(**79/80** complete), delivery credential mutation (**111/112**) and business-owner +census (**171/172**). Their execution windows are **720.243 / 727.732 / 730.834** +seconds. Matched completed-case durations grow **14.5% / 33.8% / 13.4%** against +the prior complete observations. The cgroup records substantial CPU usage; case +start times are unavailable, so this does not prove a hang or distinguish exact +late-starting from slow execution. No production or Architecture source changes +occur between those hosted attempts. Another weight-only rerun is not selected. + +Independent source inspection and local sampled-thread traces identify repeated +Cecil graph scans and identity formatting in the shared credential detector. +Sampling is diagnostic thread time, not an isolated CPU percentage or hosted +fit measurement. A three-file test-only correction uses existing per-detector +method/field-name instruction buckets, retains full declaring-type checks, and +formats only selected business-owner identities through an evaluation-local, +reference-keyed dictionary. It adds no cross-mutation cache. Exact inventories, +opcodes, duplicate occurrences, attribute-linked owners, detached targets and +every clean/dirty/restored detector evaluation remain. + +Release compilation passes with zero warnings/errors. The isolated authority +Fact passes before and after with the same actual case ID, zero failures/skips: +**226.070 -> 190.739** seconds (**15.6%** lower). The baseline is profiled; this +single local comparison is advisory and cannot prove the final Linux fit. +Other affected mutation/index controls and two fresh independent review rounds +remain open at this development commit. Whole-obligation final-source hosted +A/B remains required. The plan, six/twelve owner counts, two Architecture +threads, VM and time ceilings, cleanup reserve and all production code remain +unchanged by this correction. + +### P03-2 census performance review round 1 (2026-10-28) + +Two fresh independent reviewers (`gpt-6-astra`, xhigh) inspect +`72d76f20..6d1a43bf` across detector semantics and proof/evidence ownership. +Both approve without verified findings. They check exact assembly/type identity, +async owner mapping, duplicate and opcode preservation, detached targets and +per-evaluation cache lifetime. The proof reviewer independently recomputes the +24 failed-run archive checks, exact three missing Facts, timing comparisons and +both local TRX outcomes. No review-agent build, test or source edit occurs. + +The fresh Release solution build at `6d1a43bf` passes with zero warnings/errors. +The other affected delivery-credential/business-owner controls and the existing +raw/index/clean-dirty-restored cache control pass **3/3**, zero failures/skips. +Together with the separately recorded isolated authority pass, this validates +the focused correction; it is not a complete local or hosted qualification. +The next fresh review round and final-source full hosted A/B remain open. + +### P03-2 census performance review round 2 and source freeze (2026-10-28) + +Two new independent reviewers (`gpt-6-astra`, xhigh) inspect the complete scoped +performance correction through `c3adebe9`. Both approve without verified +findings. They independently check equivalent caller/field populations, exact +identity, attribute-linked owners, duplicate/opcode preservation, malformed and +detached targets, and fresh cache state for every clean/dirty/restored evaluation. +The proof reviewer recomputes all 24 failed-run archive hashes and exact case +unions, plus the focused before/after and **3/3** passing control receipts. +Neither reviewer edits files, builds or runs .NET tests. + +Actual discovery at `c3adebe9` repeats all **64** no-build requests against the +unchanged focused-test binaries. Both **20-owner** assignments rediscover and +reconcile the complete **6,923-case** population, with zero unknown weights; +source and all four DLL hashes stay stable across discovery. The manifest remains +`5abdc7f3f7d46c18ccd78ec856f2e3f1a9bc0b46e371cc7b9026c34b07e6a1ba` +and the plan remains +`c043f60588779307372be621e032dbc60e3437f525fab954136e15efb87027a0`. +The only delta from the freshly built/tested `6d1a43bf` is the first review's +documentation receipt; this is selection validation, not hosted qualification. + +Both fresh performance-review rounds and focused controls are closed. No +production code, test population, timing weight, owner count, thread setting, +job ceiling or cleanup reserve changes after the correction. Source work freezes +for new complete hosted A/B. Catalogue promotions, gate/packet closeout and PR +handoff still wait for that actual evidence. + +### P03-2 authority proof decomposition and preservation (2026-10-28) + +Full A attempt [40593558449/1](https://github.com/HodeTech/LearnStack/actions/runs/40593558449) +at source `33142a89` completes **25/27** jobs. Five Architecture producers, +all ordinary producers and the integration aggregate pass. Architecture-0 and +its backend aggregate fail. The complete producer union has **6,923** selected, +**6,922** Passed and exactly **one** without a result: the monolithic authority +mutation Fact. All **24** actual artifacts retain API/size/SHA-256/CRC checks. +No B starts. This remains failed qualification, with no inferred test start time +or claim that the standalone selector intrinsically cannot fit. + +Two independent source/contract assessments select a cohesive test-source +refactor under ADR-0063's versioned population/plan rules. Five independently +meaningful Facts replace the one orchestrating Fact in the same nested class: +admission activation/live guards; routing store/holder escapes; generation-bound +issuance/binding; executor/framework capability dispatch; opaque provenance and +publication gates. Each owns a fresh census. One unchanged 21-capability factory +serves the two related proof families. Every newly discovered method remains a +whole selection unit; execution never slices a method's rows or mutation loop. +No production code, detector, concurrency, time ceiling or reserve changes. + +A reproducible source-token ledger reconstructs all five moved blocks and the +complete old helper, preserving string/character literals, token order, callbacks, +undo actions, loop inventories and assertions. It finds the same **27** mutation +source sites, **21** capabilities and **two** explicit positive evaluations. +A separate temporary local instrumentation records the actual old/new runs: +**1/1 -> 5/5** passing Facts, zero failures/skips, with exactly equal multisets +of **374** mutation descriptions/detectors and **1,124** detector/result-count +evaluations. The source-token and runtime checks complement each other; repeated +descriptions alone would not prove the capability inventory. All temporary +instrumentation is restored byte-for-byte to the committed wrapper afterward. +Those diagnostic runs are not timing-weight sources or hosted qualification. + +The catalogue's compiled Fact/Theory method count changes **316 -> 320**; +Implemented-rule counts and the canonical ordinary-job authority rule remain +unchanged. Historical logs and the retired selector's weight remain historical. +Clean uninstrumented whole-Fact timing receipts, actual full-population discovery, +a reviewed plan revision, fresh reviews and complete final-source hosted A/B +remain required before closeout. + +### P03-2 decomposed authority review round 1 and measured plan (2026-10-28) + +Two fresh independent reviewers (`gpt-6-astra`, xhigh) approve +`33142a89..23262e95` without verified findings. They independently reconstruct +all moved source blocks, the unchanged capability factory, runtime mutation/result +multisets and failed-attempt case evidence. Fresh census ownership and whole-method +selection remain explicit; diagnostic instrumentation never ships or supplies +weights. + +A clean Release solution build at `23262e95` passes. With source/tree and all +four DLL hashes unchanged before/after, the uninstrumented run passes **7/7**: +five complete new Facts and both catalogue controls. Their actual whole-Fact +weights are **8,967 / 22,619 / 80,941 / 45,128 / 37,824 ms** (sum case duration, +round upward). The raw TRX SHA-256 is +`d944c6abf53848f6e9ae7f425e4397a712d885e4d91634cb2efae239976ca8bc`. +The proof reviewer independently joins actual TestMethod metadata to results, +checks both catalogue exclusions and the clean source/binary bindings, and +approves the candidate plan. No display-text parsing or division of a historical +combined duration produces these weights. + +The revised plan adds one explicitly local timing source and five weights: +**88 sources / 1,722 weights**. All **1,717** prior rows and **87** prior sources, +including the retired selector's **603,500 ms**, remain unchanged. The new digest +is `f5161ae946c9e3268c48be8d365cb653a190f218bbb1492731a0c7097e8d0c0d`. +Every other plan setting is unchanged. These are advisory local observations, +not hosted wall-time bounds. + +Actual discovery repeats all **64** requests, independently reconciles both +**20-owner** assignments, and finds exactly one removed/five added selectors. +All other case identities remain unchanged. The population is **6,927**: +Unit **2,998**, Integration **3,331** (offline **324**, ordinary **2,976**, native +**31**), Architecture **593**, Contract **5**. Backend has **3,920** cases; +integration has **3,007**. Both assignments have zero unknown weights. Maximum +filter sizes remain A **18,842** / B **28,198** bytes, below 32,768. The manifest +is `1fa6c70c812d63948a2e7adc0bc762907b4c3496130088f818061c56ba67964e`. +Comparison of old/new plans uses this same new population, not unlike historical +bins. Planner controls pass **83/83**, result/fixture **40/40**, native owner +**94/94**. The fresh second review round and complete final-source hosted A/B +remain open. + +### P03-2 authority decomposition review round 2 and final source freeze (2026-10-28) + +Two new independent reviewers (`gpt-6-astra`, xhigh) approve the complete +`33142a89..3ccdfc40` decomposition and measured-plan revision without verified +findings. They independently reconstruct all control bodies and the capability +factory; check fresh census/evaluation ownership, callbacks/undo, async and +detached-target semantics; recompute the **374 / 1,124** runtime multisets; +and join clean TRX method metadata to all five weights. They verify every prior +weight/source remains unchanged and distinguish instrumented diagnostics from +clean timing advice. + +Both reviewers independently reconcile all **64** actual discovery protocols, +exactly one removed/five added selectors and unchanged remaining identities. +The final inventory is **6,927 cases / 320 Architecture methods**, with two +complete, unique **20-owner** layouts and zero unknown weights. The plan remains +`f5161ae946c9e3268c48be8d365cb653a190f218bbb1492731a0c7097e8d0c0d`; +the manifest remains +`1fa6c70c812d63948a2e7adc0bc762907b4c3496130088f818061c56ba67964e`. +No reviewer edits source, builds or executes .NET tests. No production code, +detector, execution owner/count, concurrency, job ceiling or reserve changes. + +Both fresh decomposition review rounds are closed. Source work freezes for new +complete hosted A/B using this same source, toolchain, plan and population. +Catalogue implementation promotions, phase gate/packet closeout and PR handoff +remain open until actual successful qualification; historical failed attempts +remain preserved. + +### P03-2 final-source A exhaustion and resolution profiling (2026-10-28) + +Full A [40600994059/1](https://github.com/HodeTech/LearnStack/actions/runs/40600994059) +at `05a546348eac7f6d7104780fbd3cef875d219231`, tree +`fe4918f05f942c001a9d36be3af6402885f162d5`, completes **25/27** jobs. +Architecture-5 and its dependent backend aggregate fail; all other Architecture, +ordinary/native producers and the integration aggregate pass. The **20** original +producer archives reconcile **6,927** selected, **6,926** Passed and exactly +**one** missing result, with no recorded failed/skipped outcome. All **24** +actual archives retain original API metadata, size, SHA-256 and CRC verification. +Plan/manifest remain `f5161ae9` / `1fa6c70c`; no B starts and this is not +qualification. + +Architecture-5 selects **107**, completes **106**, and reports +`deadline_exhausted`. The missing Fact is +`Resolution_walk_detects_loading_construction_async_and_polymorphic_helpers`, +case ID `a3347c4a-fea0-ac0a-ce6d-983c34ba3096`; the five decomposed authority +Facts complete. Pre-owner/discovery take **243.380 / 42.636 seconds** and owned +cgroup CPU consumption is **1,383.118 seconds**. Empty `measurements.projects` +provides no case start, complete test-process wall duration or remaining tail; +CPU consumption alone neither excludes blocking nor attributes that work. + +An independent evidence assessment reconciles this and the three preceding +Architecture failures directly from all twenty producer ZIPs per attempt. +The missing Fact has prior complete observations of **321.649 / 330.988 / +364.338 / 348.666 seconds**; one belongs to an otherwise incomplete producer. +Those unchanged-body observations do not establish the latest start time or +future fit. A separate carrier assessment identifies the exact bounded policy +change that a longer Architecture window would require, including own-record +Amendments in ADR-0062/0063/0075. No such policy is accepted or implemented. + +The evidence assessment instead identifies a concrete source candidate: +`ResolutionOffenders` repeats its complete subtype/`Derives` scan for every +reference to the same nonsealed helper. Before changing a time ceiling, measure +one evaluation-local expansion set, separate from dequeue visitation. Preserve +the direct helper walk, transitive subtype semantics, all **15** mutations and +**45** clean/dirty/restored evaluations. The existing three whole resolution +controls, source/assembly bindings and unchanged control bodies are the focused +verification boundary; local timings cannot establish hosted fit. + +Both clean Release builds pass with zero warnings/errors on local SDK +**10.0.302**. The same **three** whole resolution controls pass before and after, +with identical case IDs, zero failures/skips and stable source/all four test +assembly hashes within each run. The mutation Fact changes from **122.297** to +**6.486 seconds** (approximately **94.7%** lower local case duration); focused +run wall changes **123.007 -> 7.179 seconds**. These are local advisory timings, +not hosted qualification or replacement plan weights. Raw TRX SHA-256 values are +`610f9162935249cb5a1138dd52062d939db9e6b83440e253757181d4cb771baf` +and `2051af1501b855c524ea72fcd7fd882d7169813932fc5d39c378998112ee9849`. + +The tracked source differs only by one method-local expansion set and its +repeated-expansion guard. A byte-for-byte reconstruction verifies every other +byte of the file is unchanged, including all control bodies, callbacks/undo, +`Derives`, dynamic-loading checks and direct helper enqueues. The expansion set +is separate from dequeue visitation and is recreated for every clean, dirty and +restored detector call. No production code, plan weight, selector, population, +concurrency, deadline or reserve changes. Fresh independent review rounds and +new complete hosted A/B remain open; no Architecture-window ADR is introduced. + +### P03-2 resolution correction review round 1 (2026-10-28) + +Two fresh independent reviewers (`gpt-6-astra`, xhigh) approve +`05a54634..c1f1d4c1` without verified findings. The semantic review proves the +separate expansion/visitation sets retain direct and transitive helper coverage +and fresh per-evaluation state. Both reviewers reconstruct every unchanged source +byte and independently check the raw three-case TRXs and stable binary bindings. +The evidence reviewer also reconciles all **24** failed-attempt archives, all +**20** producer unions and the four disclosed historical case durations. + +Fresh actual discovery at `c1f1d4c1` completes all **64** protocols with unchanged +source/four DLLs, no untracked backend/script source and the same **6,927** cases. +Both A/B layouts have **20** unique owners and zero unknown weights. Plan +`f5161ae9` and manifest `1fa6c70c` remain unchanged; no weight, selector, policy +or qualification requirement changes. The second fresh review round and complete +final-source hosted A/B remain open. + +### P03-2 resolution correction review round 2 and source freeze (2026-10-28) + +Two new independent reviewers (`gpt-6-astra`, xhigh) approve +`05a54634..ad7cb5e8` without verified findings. They reconstruct the exact +source change, per-evaluation graph semantics and all unchanged control bodies; +independently check raw TRX hashes, identical three-case outcomes and current +source/four-DLL bindings; and retain local timing as advisory. The evidence +reviewer independently parses all **64** discovery protocols, reproduces both +assignments, reconciles all **24** hosted archives and confirms the sole missing +case and four historical observations. + +Both fresh review rounds are closed. No further source or plan change follows: +**6,927** cases, **320** Architecture methods, **20** owners per layout, zero +unknown weights, plan `f5161ae9`, manifest `1fa6c70c`. Only the Architecture test +DLL changes across the intentional optimization. Production and all other test +binaries stay identical. The source freezes for new complete hosted A/B with the +existing job windows/reserve; catalogue/gate/packet closeout and PR handoff remain +open until successful qualification and independent evidence audit. + +### P03-2 failed provider startup and bounded diagnostics (2026-10-28) + +Full A [40605700294/1](https://github.com/HodeTech/LearnStack/actions/runs/40605700294) +at `82414331e7ceea875c5c426bddb9f0e075030700`, tree +`d7fa288a2f89a5c786d881afdd55438573868491`, completes **25/27** jobs. +All six Architecture producers and the backend aggregate pass. Ordinary-3 and +the integration aggregate fail. All **24** original archives retain API/size/ +SHA-256/CRC verification; the **20** producer unions reconcile **6,927** results: +**6,921 Passed, six Failed, zero missing/skipped**. No B starts. This is failed +qualification, despite completed Architecture and the earlier local speedup. + +Ordinary-3 completes all **237** assigned cases, **231 Passed / six Failed**. +Five closed startup records show `provider_start/readiness_timeout` and cleanup +`completed`; four share the realm collection fixture and one uses a separate +class fixture. Historical stock's sixth failure is only `invalid_state`: its +**121.342-second** case duration does not prove startup expiry. Two independent +read-only assessments verify these facts and identify its omission from the +finite diagnostic class list. The exact preceding ordinary-3 selection passes +**237/237** with matching SDK/console/adapter hashes; its **536.139-second** +test process and this attempt's **526.139** are not startup measurements. + +The failed receipt's `tests_failed` workload status explains +`owned_completion_missing`. For failed Docker work, `forcedCleanup=true` is +unconditional and `dockerAbsent=false` is not a retained absence census. These +fields do not prove a resource leak. `cleanupOwnershipComplete=true` witnesses +joined process cleanup; fixture cleanup `completed` means its disposal calls +returned, not an independent Docker-absence proof. Startup overlap, Docker +resource pressure and the provider cause remain unobserved; the changed runner +image/kernel is not established as causal. + +A bounded diagnostic correction includes the exact stock-baseline class, +provider `Created`/`Starting`/`Started` event milestones and startup cancellation +state captured at failure before cleanup. The closed parser accepts only the +finite labels and an optional literal boolean; legacy sentences leave it +unknown. Stages remain last-reached SDK observations, not exact failed operations. +No raw provider output, configuration, credentials, message or stack is exported; +case streams/TRX remain sanitized. No startup/job deadline, concurrency, fixture +sharing, selected case, assertion or successful ownership condition changes. + +Local Release build passes with zero warnings/errors. The **95** native-owner +controls pass, including stock-class coverage, malformed/ambiguous/oversized +messages, both cancellation values and private-canary/case-output controls. +The real HTTPS provider/owned-CA/loopback smoke passes **1/1**, zero skips. These +verify the diagnostic correction, not the unknown hosted cause or qualification. +Fresh independent review rounds and new complete same-source hosted A/B remain +required; no policy increase or new ADR is introduced. + +### P03-2 startup diagnostic review round 1 (2026-10-28) + +Two fresh independent reviewers (`gpt-6-astra` / `gpt-6.1-sol`, high) approve +`82414331..09ac7c85` without verified findings. They verify event-only stage +updates, cancellation capture before cleanup, exact finite class/vocabulary +admission, legacy omission and separation from completion authority. The +protocol reviewer independently passes **95/95** native-owner controls and +checks raw smoke TRX/hash, all **24** archive hashes/CRCs and the complete +**6,927**-case failure union. Documentation keeps the provider cause unknown. + +The second fresh review round and complete final-source hosted A/B remain open. +No readiness, startup/job bound, concurrency, sharing or acceptance condition +changes; a diagnostic observation is not a lifecycle correction or hosted fit. + +### P03-2 startup diagnostic review round 2 and source freeze (2026-10-28) + +Two new independent reviewers (`gpt-6.1-sol` / `gpt-6-astra`, high) approve +`82414331..01d8234b` without verified findings. They confirm event-only milestones, +pre-cleanup cancellation capture, exact bounded projection and unchanged lifecycle/ +completion authority. The protocol reviewer independently passes **95/95** +controls and reconciles raw smoke evidence, all **24** archived packages and +all **6,927** completed results. Both fresh diagnostic review rounds are closed. + +The source freezes for one new full A; B follows only after successful A and +original-archive verification. If provider startup fails again, the new milestone +and cancellation observations guide diagnosis before a lifecycle correction is +selected. Timeout increases, fixture serialization and a provider root cause are +not inferred. Expected population remains **6,927** with the unchanged plan +`f5161ae9`; actual hosted discovery and both exact unions must re-establish it. +No packet/gate/catalogue promotion or PR handoff precedes successful complete +same-source A/B and independent evidence audit. + +### P03-2 failed browser readiness and recovery settlement controls (2026-10-28) + +Full A [40609744140/1](https://github.com/HodeTech/LearnStack/actions/runs/40609744140) +at `9385c9e8c59f2496e760e08d4f539b992ece8981`, tree +`e50ef647890fd27c531992f9a5a05c2454b0ceca`, finishes **23/27** jobs successfully. +All six Architecture producers and the backend aggregate pass. Public demo, +ordinary-4/9 and the integration aggregate fail. All **24** original packages +retain API/size/SHA-256/CRC verification. Twenty producer unions reconcile +**6,927** results: **6,925 Passed / two Failed**, zero missing/skipped. The +preceding provider-startup failures do not recur; their cause remains unknown. +No B starts and this remains failed qualification. + +The demo receipt fails at `startup-port/port-invalid`, audit lane 2/catalog/run 1, +before the worker starts. Nine earlier audits, nine browser screenshots and +**62** HTTP-200 API completions survive; cleanup removes private profiles. The +original port bytes are unavailable. A read-only source/artifact assessment +finds an actual readiness gap: existence is followed by one numeric read, so an +existing empty file immediately refuses instead of using the remaining startup +allowance. Numeric prefixes without a completed first line and noncanonical +number forms can also be admitted. This is a verified control weakness, not a +recovered account of the original browser failure. + +The bounded correction reuses the direct-read parser and waits for a newline- +complete decimal port in `1..65535`. Missing/empty/incomplete publication stays +within the original **10-second** startup and **12-minute** total bounds. Every +observation checks owner/cancellation/native child status, including before +publishing a ready port. Invalid/unreadable files, deadline and cancellation +retain closed diagnostics; no private endpoint bytes enter artifacts. The shared +admission/BFF proof parser also rejects incomplete numeric prefixes. No product +route, browser trust, API traffic allowance or worker assertion changes. + +Twenty-six new readiness controls cover actual-file publication, canonical/range +checks, deadline, owner death, cancellation and private errors. Focused frontend +controls pass **110/110**, with typecheck/lint/format clean. The first full local +workspace run refuses an existing Python bytecode cache as unclassified source; +no census exemption or local-file deletion is used to hide it. Guarded full +frontend and real managed-demo proof move to a clean development copy. + +The two backend failures are browser discovery and a native-recovery persisted- +phase assertion. Their closed evidence is retained independently. The recovery +source permits a conservative renewal refusal before Outcome acknowledgement; +the exact CI interleaving is not recovered. Deterministic positive settlement +scheduling is investigated separately, preserving real timers, all assertions +and existing lease/stage bounds. Fresh reviews and complete final-source A/B +remain required before any completion promotion. + +### P03-2 browser readiness review round 1 (2026-10-28) + +Two fresh independent reviewers (`gpt-6-astra` / `gpt-6.1-sol`, high) approve +`9385c9e8..7dbf68f6` without verified findings. They verify actual coordinator +wiring, shared newline-complete parsing, before/after owner/cancellation/native +checks, existing deadlines, private diagnostics and all **26** new controls. +The controls reviewer independently repeats the guarded workspace run in the +clean development copy: **2,458 Passed / 73 files / two packages**, zero +skips/todos, unchanged source and clean tracked tree before/after. The raw log +SHA-256 is `9a3ada9d1f039de8e6911190eb385340428609ae9de2d09125da1b60311ce2a5`. + +Uncommitted native-recovery work is explicitly outside this browser review. +The second fresh browser review, actual managed-demo proof, native scheduling +validation and complete final-source hosted A/B remain open. + +### P03-2 browser readiness review round 2 (2026-10-28) + +Two new independent reviewers (`gpt-6.1-sol` / `gpt-6-astra`, high) approve +`9385c9e8..8a43eb9d` without verified findings. They confirm complete-line parsing, +actual coordinator wiring, before/after lifetime checks, fixed startup/overall +allowances, closed errors and unchanged cleanup. The controls reviewer verifies +the independent **2,458**-case raw log and meaningful counterexamples. Both +browser review rounds are closed; the actual managed-demo run uses the clean +`7dbf68f6` development copy. Native scheduling and final-source A/B remain separate. + +### P03-2 native recovery positive settlement scheduling (2026-10-28) + +Source review of the ordinary-9 failure finds a permitted positive-oracle race: +Outcome holds the job row, a concurrent `SKIP LOCKED` renewal can acknowledge +an empty result, and conservative pre-ACK refusal can cancel that Outcome. +The artifact proves a renewal loss and a later persisted-phase assertion failure, +not this exact interleaving. Production refusal behavior is unchanged. + +The test now allows real renewals through every original expired-window, +open-token and provider-count assertion, then holds the next actual pacing +callback after its preceding SQL flight completes. Construction releases only +after callback entry; Outcome settles while the same live lease remains held. +A distinct validated **1,125 ms** pacing cadence separates this test seam from +one-second waits. System clocks and all other timers stay real. Lease/SQL/probe/ +progress remain **15/1/30/30 seconds**; hold plus settlement share the existing +**15 seconds**, and control/timer/factory cleanup share the existing **20**. +All original assertions remain; stopped execution is not used as a pacing tool. + +Cleanup seals selection, releases every selected callback, joins the actual +factory/timer disposal and disposes its barrier only after confirmed control +completion. Release build passes without warnings/errors. Both recovery theory +rows plus publication controls pass **4/4**; unchanged Outcome/Prepare/Finalize +refusal controls pass **2/2**. An isolated development-copy dirty twin injects a +failure after callback entry: both expected failures retain their first fault and +emit confirmed control/generation/timer cleanup witnesses. Its source and all +four test DLL hashes restore exactly afterward. This intentionally failing +**two-case** counterfactual is never a passing test result or timing-weight input. + +Native scheduling's fresh review rounds, actual browser proof and complete +same-final-source hosted A/B remain open. No production code, selected case, +startup/job ceiling, database guard or permitted refusal is relaxed. + +### P03-2 recovery scheduling review round 1 (2026-10-28) + +Two fresh independent reviewers (`gpt-6-astra`, xhigh / `gpt-6.1-sol`, high) +approve `8a43eb9d..6ea5e67f` without verified findings. They verify previous-flight +selection, real clocks, original assertions/provider counts, shared existing +budgets and preserved conservative refusal. Lifetime review covers cancellation +before callback entry, synchronous disposal from the callback, factory/timer +join, selection closure, barrier disposal and first-failure preservation. + +The proof reviewer independently parses **4/4** clean and **2/2** refusal passes, +both expected dirty failures and cleanup witnesses, and matches both committed +source files and all four restored overlay DLLs. No original CI interleaving or +hosted qualification is claimed. The second fresh review and complete final- +source A/B remain open; the actual managed browser proof continues separately. + +### P03-2 recovery scheduling review round 2 (2026-10-28) + +Two new independent reviewers (`gpt-6.1-sol`, high / `gpt-6-astra`, xhigh) +approve `8a43eb9d..16614b3e` without verified findings. They confirm selection +only after all original renewal/provider assertions and a completed SQL flight, +unchanged real clocks and deadlines, callback-entry acknowledgement and retained +timer disposal. Cancellation before entry, synchronous disposal inside the +callback, factory/control joins and first-failure preservation remain covered. + +The proof reviewer independently reconciles **4/4** clean passes, **2/2** refusal +passes and the two expected dirty failures with joined cleanup witnesses. Both +source files and all four restored DLL hashes match their records. The dirty +counterfactual covers failure after callback entry; other lifetime paths receive +source review rather than a claimed dirty execution. Both fresh native review +rounds close without a production change or recovered historical CI cause. + +### P03-2 clean local managed browser proof (2026-10-28) + +The clean development copy at `7dbf68f689e44a3dcfe6202ca5533cc6fc093611`, tree +`388364446e3089ce1110b7acc582d5bc13e8030f`, completes `make demo DEMO_MODE=ci` +with exit zero and unchanged tracked source. Its original receipt SHA-256 is +`990b8b92af7f56c20643c2e0ffe89f71d070b6bf2409108c1ba13709da52a277`. +All **27** original JSON/HTML pairs match their receipt hashes. Nine browser +navigations, **24** visibility controls, two TLS refusals and the timeout +counterfactual pass. The exact completion union is **134 HTTP 200**, with no +429 or malformed/pending record. Proof duration is **566,466 ms**, within the +unchanged twelve-minute bound; the whole local command takes **637.557 seconds**. +Cleanup confirms stopped processes, empty Compose, removed private files, +environment and lock. All nine existing LCP advisories remain warnings, not +newly enforced performance claims. + +This local browser proof uses macOS, Node `22.23.1`, Playwright `1.64.0`, +Lighthouse `13.5.0` and managed Chromium revision `1248`. It does not substitute +for final-source hosted A/B or qualify the separate native scheduling change. +The complete same-source hosted pair and independent original-evidence audit +remain mandatory before packet/gate/catalogue promotion or PR handoff. + +### P03-2 browser and recovery correction source freeze (2026-10-28) + +The actual native admission/browser-fallback regression passes **1/1**, with no +skip, after the clean managed demo releases its local build/test window. Both +fresh review rounds for browser readiness and native positive settlement are +closed. Release build, focused controls, clean full frontend and original local +demo evidence precede the new source freeze. The unchanged plan `f5161ae9` and +manifest `1fa6c70c` still select **6,927** backend cases; no case, timing weight, +deadline, production refusal or resource limit changes in these corrections. + +One new complete hosted A follows this freeze; only its successful original- +archive verification permits a complete B rerun. The final pair must share +source, toolchain, plan and population. The prior failed attempts remain failed +evidence, and local controls do not substitute for this qualification. Packet, +gate and catalogue completion and PR handoff stay gated on the pair and its +independent original-evidence audit. + +### P03-2 zero-adopted standard census correction (2026-10-28) + +Final carrier preparation finds an actual corpus-control gap before promotion: +making Permissions Active gives **22 Active / zero Adopted**, but +`CorpusConsistencyTests` recognizes only number words one through twenty-two. +The existing status control therefore cannot validate the truthful final split. +The correction adds only `zero = 0` to that dictionary, without changing its +parser, assertions, population or refusal behavior. The pending six-file carrier +preview also updates the summary split and the obsolete Permissions sentence. + +An isolated development copy applies the future permission documents. Before +the code correction, **4/5** corpus controls pass; exactly the status-header/index +control fails on `zero`. With the one-line correction, **5/5** pass. Applying the +pending catalogue preview as well passes **5/5**, including the **251** implemented +rules, **146** Architecture / **105** elsewhere and unchanged **320** method census. +Both Release builds report zero warnings/errors. These future-document controls +are not qualification, delivered status or timing-weight evidence. + +Full A [40616215040/1](https://github.com/HodeTech/LearnStack/actions/runs/40616215040) +at `07a0431a` is deliberately cancelled during execution because this necessary +test-source correction invalidates its freeze. The **27**-job API record and all +**six** available original packages are retained; no complete population or fit +is claimed, no B starts, and cancellation is not classified as a new product +failure. Fresh review rounds precede one new complete same-source A/B freeze. + +### P03-2 zero-standard census review round 1 (2026-10-28) + +Two fresh independent reviewers (`gpt-6.1-sol` / `gpt-6-luna`, medium) approve +`07a0431a..fb7a8928` without verified findings. They confirm the one-line code +delta, unchanged parser/assertions/population, both future status sentences and +the before/after/full-preview **4+1 / 5 / 5** raw TRX results and hashes. The +evidence reviewer independently validates all six cancelled-attempt original +ZIP sizes, hashes and CRCs and the complete **27**-job API record. No cancelled +attempt is promoted to fit or qualification. The second fresh review round and +complete new-source A/B remain open. + +### P03-2 zero-standard census review round 2 and freeze (2026-10-28) + +Two new independent reviewers (`gpt-6.1-sol` / `gpt-6-luna`, medium) approve +`07a0431a..bf794047` without verified findings. They recheck the one-line +dictionary addition, unchanged parser/assertions/discovery, identical five test +identities, original TRX hashes and zero-warning/error builds. The evidence +reviewer independently reconciles the cancelled run's **27** jobs and all six +original ZIP sizes, hashes and CRCs. Both fresh census review rounds are closed. + +The source freezes again before one full A, original-archive verification and +one full B. No selected case, plan/manifest digest, weight, resource bound or +production policy changes. Final permission and catalogue promotion remains a +prepared documentation delta; its combined real corpus controls already pass +in the isolated future-document copy. Only complete successful same-source +hosted A/B and independent original-evidence audit permit that promotion and +PR handoff. No earlier failed or cancelled attempt substitutes for the pair. + +### P03-2 Step 6 qualification and review handoff (2026-10-28) + +Qualification uses implementation source +`c39ab614970b17c1edac25e36f5c6b7755ceec07`, tree +`f69d45a63125066301fb2d29c86da5f7f854e323`, on `development`. +[Full A and full B](https://github.com/HodeTech/LearnStack/actions/runs/40617050240) +are separate complete attempts 1/2, not failed-job reruns. Each passes **27/27** +jobs and all seven required names. Both select **6,927** backend cases and pass +every case with zero failures/skips: **2,998** Unit, **3,331** Integration, +**593** Architecture and **five** Contract. The selected/discovery/TRX/JSONL +unions are complete and disjoint within each attempt and identical across them; +A/B assignments differ. Backend **3,920** and Docker Integration **3,007** +aggregates reconcile exactly. + +All **50** original ZIPs retain API artifact identity, byte size, SHA-256 and +CRC verification. Both attempts bind plan `f5161ae9` and manifest `1fa6c70c`, +the same .NET `10.0.403`, VSTest `18.7.0-release-26523-112` and xUnit adapter +`2.8.2.0`, including observed toolchain binary hashes. All twenty execution +owners prove natural ownership/disposal, no forced cleanup, and positive +memory observations; observed VM memory is not an enforced quota. Existing job +ceilings, the **203-second** reserve, fixture count and **20** execution-VM +topology remain unchanged. +Maximum producer API job walls (queue time excluded) are **983 / 1,046 seconds** +for A's backend/Docker populations and **1,001 / 1,039** for B. Per-owner +validation preserves native/base/Architecture twenty-minute and ordinary +25-minute ceilings. The independent assignment replay moves **2,730** ordinary +and **427** Architecture cases between owners; the full population stays identical. + +Both hosted frontend jobs pass **2,458** tests across **73** files/two packages, +zero skips/todos, plus typecheck/lint/build and native ingress cleanup. Each +actual hosted demo retains **27** original JSON/HTML pairs, nine browser +navigations, **24** visibility controls, two TLS refusals, timeout refusal and +**134 HTTP 200** completions. Proof and cleanup finish within their existing +bounds. LCP remains advisory; raw medians and warnings remain visible in each +receipt. OpenAPI compares the frozen source with main `012e67cc`; original +comparison/fixture policy and secret-scan checks pass. + +Two independent reviewers (`gpt-6-astra` / `gpt-6.1-sol`, high) audit original +hosted evidence rather than only summaries. The backend reviewer reconstructs +both assignments, all case +unions and TRX identities/durations, resource receipts, aggregate/API associations +and cross-attempt toolchains. The web reviewer checks all required jobs, +OpenAPI committed byte identities, secret scan, real hosted frontend logs and +paired demo metrics/control/cleanup evidence. Both approve without verified +findings after B and the cross-pair comparison. Earlier failed/cancelled runs +remain retained separately and are not replaced by this pair. + +The final documentation delta promotes the seven qualified catalogue rules and +Permission Standard together with their index, matrices and phase gates. The +catalogue reports **251 Implemented = 146 Architecture + 105 elsewhere**, with +**320** compiled test methods unchanged. The standards split is **22 Active / +zero Adopted**. Actual post-update compiled corpus controls pass **5/5**, with +zero skips, after a zero-warning/error Release build. Documentation lint passes +with **2,796** resolved fragment links. Fresh documentation review remains +required before handoff. These documentation-only commits +follow the qualified implementation source; they are not described as that +source's hosted execution. + +All six implementation steps and their two fresh review rounds are closed. +G2 execution, G5 enforcement, G6 policy/directory and G7 runtime evidence are +qualified only within their named slices. P03-3 retains new-user HTTP/BFF entry, +validated scope transitions, native mutation/UI transport and delegation screens. +P03-9 retains Platform/production recovery authority; P03-10 retains closure, +redaction and applicable locator workflows. Phase 03 remains in progress. +The next packet is P03-3, after this packet's PR review; it is not started here. + +### P03-2 final documentation review and PR preparation (2026-10-28) + +Two fresh independent reviewers (`gpt-6-astra` / `gpt-6.1-sol`, high) approve +the actual nine-file `c39ab614..6c6747ec` documentation delta without verified +findings. Earlier conditional carrier reviews are now complemented by review of +the committed updates. Permission review checks finite source counts, actual +HTTP/application enforcement and exact Role scope; Audit definitions remain +future declarations. Catalogue review checks all seven promoted canonical +methods, **28** named proof-owner classes, **251 / 146 / 105** totals and the +unchanged **320** compiled method population. Both preserve P03-3/9/10 ownership, +Phase 03 in-progress and PR review pending, without claiming a merge. + +Reviewers distinguish the qualified implementation source from later +documentation commits. The actual final build and **5/5** corpus TRX are retained; +the latter's SHA-256 is +`85025f9f9b7d167103b9251e34bf15a855130be24f5aa75c63d8ac5c334fde52`. +They perform read-only code/document checks, not another hosted execution; the +separate original-ZIP backend/web audits supply that qualification evidence. +All implementation and correction review rounds are closed. The review handoff +uses `development` into `main`; no branch switch or merge occurs here. + +### P03-2 PR 39 low-priority review corrections (2026-10-28) + +Both reported findings are reproduced against `c37c3df4` and corrected within +their existing contracts. One shared auxiliary-source selector now enforces the +API's rule in seed and demo: both keys absent permit Development derivation; +explicit empty, whitespace, control-character or partial pairs refuse. Complete +selected values remain byte-exact and ambient partners supply no authority. +Seed and demo validate before stack startup, migrations or application seed. +Existing application-role/credential diagnostics retain their precedence. + +Eight shared-input controls exercise seed/API/demo projections; fifteen real +entry-owner controls exercise empty/partial rejection across seed, local demo +and CI demo before stateful work. The four affected frontend files pass +**246/246** tests. Typecheck, lint and changed-test formatting pass. An initial +new-test union-index typing error is corrected before commit; it changes no +runtime behavior. Complete guarded frontend execution and fresh correction +reviews follow this commit. + +Six mutable carriers now describe implemented operation witnesses, joint API +policy/evidence composition, actual Role grant commands and the authenticated +directory. The permission skill uses the registered policy/evaluator API; +related stale introduction/performance phrases are corrected in the same scope. +Packet state points to the Phase 03 Status block. Accepted ADRs, delivery history +and P03-3/9/10 obligations retain their boundaries. Documentation lint and +fragment/structure/index checks pass. + +Review scope remains these two findings. Earlier implementation-source A/B +evidence stays attributed to `c39ab614`; the follow-up's local/hosted checks +are recorded separately. The maintainer's broader review coverage limits remain +explicit rather than becoming an unconditional merge approval. + +### P03-2 PR 39 correction review round 1 (2026-10-28) + +Two fresh independent reviewers (`gpt-6.1-sol` / `gpt-6-luna`, high) approve +`c37c3df4..74254c8b` without verified findings. Tooling review checks the shared +pair, preserved role/credential precedence and API diagnostics, early real +entry-owner refusals and fixture dependency copies. The existing control-byte +predicate remains specifically CR/LF/NUL; this correction adds no broader +connection-string grammar. Documentation review verifies all six carriers +against actual composition and Accepted ADR-0076/0077, preserving later packet +ownership and the maintainer's review scope limits. + +Guarded full frontend execution in a clean development copy at `74254c8b` +passes **2,481** tests across **73** files/two packages, zero skips/todos and +unchanged source. The **23** new controls complement the prior population. +Typecheck, lint, format, targeted **246/246** tests and documentation checks +also pass. The second fresh review round remains required before PR update. + +### P03-2 PR 39 correction review round 2 and handoff (2026-10-28) + +Two new independent reviewers (`gpt-6.1-sol` / `gpt-6-luna`, high) approve +`c37c3df4..39c98e4f` without verified findings within the correction scope. +They verify the common selected-pair rule, actual preflight ordering, fixed +diagnostics and six current documentation carriers. Tooling review independently +matches the full frontend log SHA-256 +`e08534d67c1a1cc17a9dff4113de671462161dd2780345eb64807f0e086603a4`: +**2,481** passes, **73** files/two packages, zero skips/todos. + +The final dependency census additionally finds the real .NET launcher's isolated +copy of `public-runtime.mjs`. One `File.Copy` supplies `demo-database.mjs` beside +it, matching the two frontend fixture updates. The tooling reviewer independently +checks all tracked consumers and this one-line follow-up; no test declaration or +assertion changes. The actual PostgreSQL/API launcher cases pass **3/3**, including +the foreign-user-secret dirty control. Native seed environment controls pass +**6/6**. All changed frontend sources still match the fully tested `74254c8b` copy. + +Both fresh correction review rounds are closed. The follow-up checks qualify +these two findings; the maintainer's remaining full-PR review limits and pending +merge decision retain their stated scope. The original implementation A/B remains +bound to `c39ab614`; the updated PR head receives its own protected checks. + +### P03-2 PR 39 role-guard cancellation control (2026-10-28) + +[Run 40662644606/1](https://github.com/HodeTech/LearnStack/actions/runs/40662644606) +at head `6cf8c714`, merge source `5a2de796`, passes **25/27** jobs. Only +ordinary-8 and its dependent integration aggregate fail. All **24** available +original ZIPs pass API size/SHA-256/CRC verification. Twenty complete producer +unions contain **6,927** results: **6,926 Passed / one Failed**, zero missing or +skipped. The failed cancellation case executes for **8.088 seconds**; its closed +exception kind is `cancellation` and no source location was registered. The +aggregate correctly refuses `producer_not_successful`. Completion/forced-cleanup +flags on a failed worker do not independently prove a container leak. + +The unchanged case passes locally. Independent source diagnosis finds a concrete +test race: its holder predicate accepted any granted catalog lock, including a +shared reader, before the intended exclusive blocker acquired. An eight-second +sleep also released the blocker by elapsed time and obscured which five-second +monitor failed. The sanitized historical evidence cannot identify that exact +interleaving or establish a production cancellation defect. + +The fixture now acknowledges completion of the real exclusive `LOCK`, keeps its +privileged connection private, and releases/joins actual rollback and disposal +on explicit caller signal. Its existing thirty-second control bound fails closed. +The test warms an exact PID/mode/numeric-OID observer before locking, positively +exercises a shared reader and rejects it as an exclusive blocker. It observes the +actual source waiter, cancels it within the unchanged three-second assertion, +and checks that the acknowledged blocker's PID still holds its exclusive lock. +Cleanup retains the prior join/dispose path and capacity-recovery assertion. +Production's five-second admission deadline and all job ceilings stay unchanged. + +The corrected real PostgreSQL class passes **9/9**, zero skips. All **95** native +owner controls pass, including private-value canaries and the expanded finite +source-location census. The optional `authorization_read_source` code now retains +bounded file/line diagnostics; waiter observation has a fixed timeout diagnostic. +No case declaration, selector, timing weight, permission or production path +changes. Fresh correction reviews and updated-head CI remain required. + +### P03-2 PR 39 cancellation control review round 1 (2026-10-28) + +Two fresh independent reviewers (`gpt-6.1-sol` / `gpt-6-luna`, high) approve +`6cf8c714..6ed0f12b` without verified findings. Lifetime review checks positive +acquisition, exact PID/mode observation, all early-failure release/join paths, +unexpected returned-owner disposal and real capacity recovery. Evidence review +independently verifies all **24** ZIP hashes/CRCs, the complete failed-attempt +population, **9/9** corrected local cases and **95/95** native control results. +Both preserve the unknown historical interleaving and unchanged runtime limits. + +An isolated development-copy counterfactual then removes effective mode +qualification. The actual shared-reader negative control fails exactly as +expected, before the exclusive blocker starts. The single intentional failure +is not a passing result or historical reproduction. Its TRX SHA-256 is +`47a621c43ec82beceb953e5f546558a568c5448e8721e4b3fd6c904c0491af30`. +Source and Integration/Infrastructure/API DLL hashes restore exactly afterward. +The second fresh review round and updated-head hosted checks remain required. + +### P03-2 PR 39 cancellation control review round 2 (2026-10-28) + +Two new independent reviewers (`gpt-6.1-sol` / `gpt-6-luna`, high) approve +`6cf8c714..ca82e241` within their lifetime and evidence/diagnostic slices, without +verified findings. They recheck positive acquisition, exact holder/waiter +identity, all release/join paths, actual capacity recovery and bounded optional +diagnostics. The original **24** ZIPs, complete **6,927** outcomes, **9/9** corrected +cases, **95/95** native controls and expected wrong-mode counterfactual are +independently reconciled. The dirty source and three restored DLL hashes match. + +Both fresh correction review rounds are closed. The historical failure stage +remains unknown; the demonstrated predicate weakness and its deterministic +control are the correction's evidence. Production paths and deadlines, the +case population, weights and completion authority are unchanged. A new PR-head +workflow must establish hosted success; neither local passes nor the earlier +implementation-source A/B are attributed to that new head. diff --git a/docs/roadmap/phase-03-identity-admin.md b/docs/roadmap/phase-03-identity-admin.md index f74d5afd8..9b0889bde 100644 --- a/docs/roadmap/phase-03-identity-admin.md +++ b/docs/roadmap/phase-03-identity-admin.md @@ -1,32 +1,24 @@ # Phase 03: Identity Domain, Authorization, and Admin Foundation -> **Status (2026-10-27).** Entry preparation and P03-1's joint ADR approval -> are complete. P03-1's six implementation steps and both review rounds are -> complete; its implementation source has passed complementary full A/B -> qualification ([Step 6 closeout](phase-03-delivery-log.md#p03-1-step-6-qualification-closeout-and-pr-handoff-2026-10-26)). -> [PR review corrections](phase-03-delivery-log.md#p03-1-pr-38-review-corrections-2026-10-26) -> are committed with both independent review rounds complete. Correction source -> `7aabc6d8` passed A but left B incomplete. After accepted -> [ADR-0075](../decisions/0075-ordinary-ci-job-window.md), implementation source -> `ea114d25` passes fresh full A/B; the -> [three-step correction](phase-03-delivery-log.md#adr-0075-three-step-closeout-2026-10-27) -> and both independent review rounds per step are complete. Qualification is -> source-bound to `ea114d25`; later documentation heads retain their own normal -> protected checks before merge. PR review and merge remain required. The -> [ADR-0074 timing delta](../decisions/0074-membership-admission-under-ordered-locks.md) -> is Accepted; existing-binding API admission is implemented. This is the current -> critical-path phase after -> [Phase 02b](phase-02b-events-auth.md). -> The [decision register](#the-decision-register) owns gate state; -> the [P03-1 acceptance](phase-03-delivery-log.md#p03-1-joint-acceptance-and-step-1-2026-10-25) -> authorizes its six-step implementation. -> Dated records live in the [delivery log](phase-03-delivery-log.md). +> **Status (2026-10-28).** P03-0 and P03-1 are complete. P03-1 is merged through +> [PR #38](https://github.com/HodeTech/LearnStack/pull/38); its six implementation +> steps, review corrections and ADR-0075 correction are closed. The +> [merge closeout](phase-03-delivery-log.md#p03-1-merge-and-closeout-2026-10-27) +> distinguishes implementation-source A/B qualification from the final PR head's +> successful protected checks and records the identical squash-merge file tree. +> Phase 03 remains in progress; P03-2 implementation, both fresh review rounds per +> step and complete hosted A/B qualification are closed. PR review remains pending. +> Live Membership and permission enforcement are implemented; new-user HTTP/BFF +> activation stays in P03-3. This remains the critical-path phase after +> [Phase 02b](phase-02b-events-auth.md). The +> [decision register](#the-decision-register) owns gate state; dated records live +> in the [delivery log](phase-03-delivery-log.md). > > | Packet | Title | State | > |---|---|---| > | P03-0 | Entry preparation | Complete — [reconciliation](phase-03-delivery-log.md#entry-preparation-2026-10-25) | -> | P03-1 | Identity authority and membership foundation | Implemented; PR review/merge pending — [six-step closeout](phase-03-delivery-log.md#p03-1-step-6-qualification-closeout-and-pr-handoff-2026-10-26), [review corrections](phase-03-delivery-log.md#p03-1-pr-38-review-corrections-2026-10-26) and [ADR-0075 closeout](phase-03-delivery-log.md#adr-0075-three-step-closeout-2026-10-27); source `ea114d25` passes fresh full A/B, both independent review rounds per step complete; normal current-head protected checks remain mandatory; HTTP/new-user BFF activation belongs to P03-3 | -> | P03-2 | Live membership and authorization | Not started | +> | P03-1 | Identity authority and membership foundation | Complete and merged — [closeout](phase-03-delivery-log.md#p03-1-merge-and-closeout-2026-10-27) | +> | P03-2 | Live membership and authorization | Implemented and qualified — [Step 6 closeout](phase-03-delivery-log.md#p03-2-step-6-qualification-and-review-handoff-2026-10-28); all six steps and review rounds closed; PR review pending | > | P03-3 | Protected Studio and membership administration | Not started | > | P03-4 | Invitations and bounded delivery | Not started | > | P03-5 | Custom fields and privacy classification | Not started | @@ -76,14 +68,14 @@ backfill when that adapter introduces an actual SDK. Re-verify the subject before the first writer; direct HTTP alone does not qualify SDK coverage. The rule remains Awaiting backfill; the SDK exception rule remains enforced. -Before replacing deny-all membership, qualify affirmative row-7 organization -selection and row-14 claim-only resolution through real HTTP controls. Decide -header-mismatch evidence ownership after a successful claim-only platform-host -resolution: a confirmed claim/membership is not an independently defended tenant -host. Do not silently reuse the host-tenant audit allowance or row-14 membership -refusal signal for that distinct case. AccountSite retains its independent -matching HostScope ceiling. This decision belongs to the first live-membership -packet, before that currently unreachable path becomes exposed. +The live membership boundary qualifies affirmative row-7 organization selection +and row-14 claim-only resolution through real HTTP controls. Successful +claim-only platform-host header mismatch has its own per-occurrence evidence: +a confirmed Membership is not an independently defended tenant host, and its +event consumes no host-tenant allowance. Row-14 Membership refusal keeps its +distinct signal. AccountSite retains its independent matching HostScope ceiling. +The [trusted evidence contract](../standards/18-audit-coverage.md#trusted-authorization-evidence) +owns this separation. ## Scope @@ -99,7 +91,7 @@ activation is not the only decision boundary. |---|---|---|---| | P03-0 | Reconciled plan, packet boundaries, decision register and proof ownership | Completed 02a/02d/02b | Documentation preparation only; no new runtime authority | | P03-1 | Durable identity storage/common creation authority, existing-binding admission, membership/profile/role foundation and fixed seed adoption; no new HTTP | P03-0 | G1, G2 contract, G3, G4 storage/issuance implications, G5 schema/bootstrap, G19; resolve any invitation/closure invariant that changes these first tables under G8/G16 | -| P03-2 | Live membership, permission registry/behavior, runtime default-deny gate, scoped reads and audit enrichment | P03-1 | G2 execution, G5 enforcement, G6 policy ordering, G7; complete denied controls before protected activation | +| P03-2 | Live membership, permission registry/behavior, runtime default-deny gate, scoped administrative directory read and audit enrichment | P03-1 | G2 execution, G5 enforcement, G6 policy ordering, G7; complete denied controls before protected activation | | P03-3 | Bounded authenticated web operations, new-user bootstrap/no-session entry, tenant/org switching, member/role screens, organization endpoints and defined member settings | P03-2; production onboarding also waits for P03-9 | G1 activation, G4 transition, G6 transport/UI, G5 delegation; retain existing AccountSite ceiling | | P03-4 | Secure new/existing invitee acceptance, revocation/resend and usable bounded delivery | P03-1–3 | G8; G1 supplies bootstrap; move delivery/provider gates earlier if this mechanism requires them | | P03-5 | Customization-owned field definitions, revision-aware values, definition editor and membership form | P03-2–3 | G9; G19 covers diagnostic/PII baseline before any earlier personal-data writer | @@ -135,12 +127,12 @@ named part, and the register links each explicit acceptance and vehicle. | Gate | Question | Existing boundary / required answer | First consumer | Vehicle | Status | |---|---|---|---|---|---| | G1 | Who establishes issuer + opaque `sub` → stable `UserId` and proves actor association before an accepted login? | Preserve seeded/provider IDs, reject substitution/email merging, name trusted pre-membership admission, first User-writer audit attribution, concurrency and cross-system reconciliation; self-registration alone grants no membership/session; new-user HTTP activation also requires provider registration and durable creation budgets | P03-1 storage/seed; P03-3 activation | Identity authority ADR; bounded ADR-0064/0070 disclosures if affected; Identity spec | Accepted foundation; P03-3 activation Open | -| G2 | How do membership and permission readers reach data before resolved tenant SQL exists, and how does authorized `app.scope` reach EF/RLS? | Decide exact principal, grants, candidate-scope read and transaction/setter ownership before schema; implement step-5/step-6 handoff later; rows 6/9 still need live authorization; qualify rows 7/10/14 and successful claim-only header-mismatch evidence separately from host-tenant budgets | P03-1 contract; P03-2 execution | ADR-0036/0040 authority; Security/Database standards and closed-set tests | Accepted contract; P03-2 execution Open | +| G2 | How do membership and permission readers reach data before resolved tenant SQL exists, and how does authorized `app.scope` reach EF/RLS? | Exact principal, grants, candidate-scope owner and transaction/setter handoff; live authorization of rows 6/9; qualified rows 7/10/14 and distinct successful claim-only header-mismatch evidence outside host-tenant budgets | P03-1 contract; P03-2 execution | ADR-0036/0040 authority; Security/Database standards and closed-set tests | Accepted — 2026-10-27, [ADR-0076/0077 and package](phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27); execution implemented and qualified in the [Step 6 closeout](phase-03-delivery-log.md#p03-2-step-6-qualification-and-review-handoff-2026-10-28) | | G3 | What are membership/profile/role-binding boundaries, nullable natural keys, lifecycle and global-user projections? | Decide active uniqueness, tenant/org grant composition, profile granularity, archive/reinvite and last-admin/org invariants, same-module composite FKs and Tenancy lifecycle contracts; no cross-module Organization FK/join | P03-1 | Identity module spec; ADR for aggregate/ownership changes; database class declaration | Accepted foundation and [ADR-0074 timing delta](../decisions/0074-membership-admission-under-ordered-locks.md); foundation and per-act lifecycle proofs implemented/reviewed in Step 4; archive/closure transition activation remains P03-3/10 | | G4 | How are active tenant/org claims issued per session, discovered and switched safely? | Current mappers use user-global attributes and ordinary BFF refresh preserves scope; decide storage implications first, then token reissue, callbacks/hosts, no-membership entry and switch/logout/refresh races | P03-1 implications; P03-3 transition | Provider/session ADR and applicable ADR-0070 disclosure; auth architecture | Accepted storage boundary; P03-3 transition Open | -| G5 | What identifies a scoped permission, who may delegate it, and when do revocations take effect? | Reconcile disabled-default versus Tenant Admin seed expansion; decide registry shape, closed-role defaults, bootstrap/recovery, grant ceilings, last-admin concurrency, finite SystemActor permissions, anonymous Portal Public and API/UI freshness | P03-1 schema/bootstrap; P03-2 enforcement; P03-3 UI | ADR-0017/0064-derived permission contract; Standard 19 and module matrices | Accepted foundation; P03-2 enforcement Open | -| G6 | Which protected endpoint/application policies and native web/SDK operations are admitted? | Decide denial precedence/resource lookup first; then exact routes/methods, Origin/CSRF, input/deadline/no-store bounds, ETag/conflict, actor/scope-bound idempotent replay and cache cleanup; no generic proxy or Server Action implied | P03-2 policy ordering; P03-3 transport | API/BFF contract; ADR-0064/0070/0071 disclosures if affected; Standards 04/07 | Accepted P03-3 staging; policy/transport decisions Open | -| G7 | Who audits HTTP/application/resource denials and supplies trusted actor/request context? | One evidence owner per refusal, MUST sensitive reads, membership/role/platform/operator/route provenance, trusted IP and bounded user-agent; preserve refusal on audit failure; no generic ADR-0065 budget | P03-2 | ADR-0033/0044; Audit spec, enrichment port and matrices | Accepted global creation slice; denials/reads Open | +| G5 | What identifies a scoped permission, who may delegate it, and when do revocations take effect? | Reconcile disabled-default versus Tenant Admin seed expansion; decide registry shape, closed-role defaults, bootstrap/recovery, grant ceilings, last-admin concurrency, finite SystemActor permissions, anonymous Portal Public and API/UI freshness | P03-1 schema/bootstrap; P03-2 enforcement; P03-3 UI | ADR-0017/0064-derived permission contract; Standard 19 and module matrices | Accepted — 2026-10-27, [P03-2 enforcement contract](phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27); enforcement implemented and qualified; P03-3 UI/delegation surfaces remain Open | +| G6 | Which protected endpoint/application policies and native web/SDK operations are admitted? | Decide denial precedence/resource lookup first; then exact routes/methods, Origin/CSRF, input/deadline/no-store bounds, ETag/conflict, actor/scope-bound idempotent replay and cache cleanup; no generic proxy or Server Action implied | P03-2 policy ordering; P03-3 transport | API/BFF contract; ADR-0064/0070/0071 disclosures if affected; Standards 04/07 | Accepted — 2026-10-27, [policy/directory slice](phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27); policy/directory slice implemented and qualified; P03-3 native/mutation/UI transport remains Open | +| G7 | Who audits HTTP/application/resource denials and supplies trusted actor/request context? | One evidence owner per refusal, MUST sensitive reads, membership/role/platform/operator/route provenance, trusted IP and bounded user-agent; preserve refusal on audit failure; no generic ADR-0065 budget | P03-2 | ADR-0033/0044; Audit spec, enrichment port and matrices | Accepted — 2026-10-27, [ADR-0077 / P03-2 evidence](phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27); runtime implemented and qualified; privileged population remains G13 Open | | G8 | What makes invitation acceptance and delivery authoritative and recoverable? | Verified current provider email, hashed single-use token, GET inspection only, explicit mutating act, current delegation, accept/revoke/expiry/resend races, aggregate atomicity and bounded delivery independent of Phase 08a | P03-1 only if shared aggregate shape changes; P03-4 writer | Invitation contract; ADR-0042 successor if an exception changes; phase package and delivery handoff | Open | | G9 | How do field definitions and values preserve schema/classification history? | Decide revision pins/active uniqueness, scalar-field compatibility with the existing admitted object profile, nested data, required/default/retire/reclassify/key-reuse behavior, consent and supported localized forms; no raw JSONB bypass | P03-5 before its schema | Customization/privacy contract; ADR-0043 disclosure or successor if needed; module specs | Open | | G10 | What authenticated durable provider ingress is selected, and how are global events attributed? | Name producer, acknowledgement, stable provider-event dedup, ordering, tenantless/multi-membership routing, privacy and retention; enqueue through outbox, never direct business publication; no invented tenant or privileged app consumer | P03-6, or an earlier mechanism needing ingress | Provider-event ADR; ADR-0046/0057 boundaries, exact grants and Audit subscription | Open | @@ -441,14 +433,12 @@ ID tokens under ADR-0070; no application identity table stores them. ### Authorization -**Gate before the first permission-sensitive route.** P02b-5's -`AuthorizationBehavior` remains pass-through; its AccountSite admission middleware -and production census constrain that one nonsensitive read. They do not install a -general runtime policy for a newly added endpoint. This phase's first authorization -packet must decide and implement a deny-by-default runtime gate before exposing its -first protected route. Select the endpoint/application policy mechanism in that -packet's decision pass; do not infer a shipped fallback policy or authorize a route -merely by adding it to the census. Preserve explicit public anonymous reads. +**Runtime gate before protected dispatch.** The API runtime classifier and +`AuthorizationBehavior` enforce the explicit HTTP/application policies selected +by [ADR-0076](../decisions/0076-live-scoped-authorization.md). Missing classifications +and insufficient authority refuse before dispatch; the endpoint census remains an +independent structural check. Explicit public reads remain anonymous. The +Status block above owns final-source qualification. The gate's real HTTP controls must show that an unclassified protected endpoint and an authenticated principal without the required permission cannot dispatch the @@ -783,7 +773,7 @@ identifies observable proof; a later packet completes dependent end-to-end paths | ID | Delivered result | Packet | Completion criteria | |---|---|---|---| | D01 | Stable global `User` storage/common creation authority, existing-binding admission, scoped Membership/Profile and Role/Permission foundation, fixed seed adoption | P03-1 | C01–C03, C10 | -| D02 | Live membership/permission enforcement, runtime gate, `app.scope` carrier and real denied controls | P03-2 | C03–C05, C19 | +| D02 | Live membership/permission enforcement, runtime gate, `app.scope` carrier, bounded administrative directory GET and real denied controls | P03-2 | C03–C05, C19 | | D03 | Bounded authenticated native/SDK operations, usable new-user bootstrap/no-session entry and member/role management screens | P03-3 | C01, C06–C07, C20 | | D04 | Membership-aware tenant and organization switchers through newly validated scope | P03-3 | C07 | | D05 | Tenancy-owned organization CRUD/archive endpoints, member/profile preferences and identity-backed admission for named existing Tenancy commands | P03-3 | C02, C06, C16, C20 | @@ -811,7 +801,7 @@ Test-only endpoints, fake grants or empty scans do not establish shipped behavio | ID | Observable proof | Packet evidence | |---|---|---| | C01 | A real nonseed invitee obtains required actor claims and completes the permitted flow; concurrent first login and provider/DB retries preserve one mapping. Same-email/different-sub and conflicting/reserved actor values refuse; the four seed identities/credentials survive. | P03-1 internal foundation; P03-3 HTTP/native entry; P03-4 invitation path | -| C02 | Tenant/org directory and profile reads expose only authorized members/fields, including a guessed foreign global UserId. NULL-org uniqueness, archive/revoke/reinvite and profile granularity follow G3 with database race controls. | P03-1, P03-3, P03-5 | +| C02 | Tenant/org directory and profile reads expose only authorized members/fields, including a guessed foreign global UserId. NULL-org uniqueness, archive/revoke/reinvite and profile granularity follow G3 with database race controls. | P03-1–3, P03-5 | | C03 | Org/self/invite grant escalation fails; legitimate delegation and bootstrap work. Concurrent last-admin changes preserve the decided invariant; registry upgrades grant only the accepted defaults. API revocation meets its declared boundary and stale UI cannot mutate; SystemActor has finite permissions. | P03-1–3 | | C04 | Actual HTTP rejects an unclassified protected endpoint and an underprivileged valid principal before handler dispatch. Direct MediatR dispatch independently refuses; expected denials return `Result.Fail(forbidden)`. Explicit public reads remain anonymous. | P03-2 | | C05 | Real rows 7/10/14 succeed only with active membership. Protected rows 6/9 also enforce revocation. Successful claim-only header mismatch has its separately decided evidence. The role-derived tenant read hatch affects EF/RLS consistently and cannot widen org UPDATE/DELETE or be set from request input. | P03-2 | diff --git a/docs/roadmap/phase-11-production-hardening.md b/docs/roadmap/phase-11-production-hardening.md index 4c2029926..0db56f7ae 100644 --- a/docs/roadmap/phase-11-production-hardening.md +++ b/docs/roadmap/phase-11-production-hardening.md @@ -473,7 +473,20 @@ counters continue during suppression; their volume and retention need sizing too standalone write — the last on exactly the requests that are already failing. A pool sized for one connection per request exhausts under a burst of those at half the load it was sized for, so the pool size and its saturation alert are set here, against that - count. + count. ADR-0076 authorization and ADR-0077 denial evidence use separately + confined sources; they are not additional borrowers from this business pool. +- **Authorization/evidence production topology.** Before production activation, + qualify the actual deployment against + [Infrastructure Standards § Authorization and evidence topology](../standards/12-infrastructure.md#authorization-and-evidence-topology) + and the [database source ledger](../standards/05-database.md#authorization-and-evidence-source-ledger). + Include the coupled explicit source keys, same app role/database/installed + authority, fixed four/two auxiliary caps, independent PgBouncer backend lanes, + cross-alias limits, PostgreSQL headroom and admitted instance count. Exercise + saturation, retained cleanup and shutdown under that topology and set measured + capacity alerts/runbooks. P03-2 already owns the required small-pool direct + PostgreSQL and pinned PgBouncer progress/dirty controls before live activation; + this deployment qualification does not defer or replace them. Neither 4/2 nor + a local deadline is a production throughput or fairness guarantee. - Background job retry policy under multi-pod load. The single-process retry and dead-letter contract — producer-side, subscriber-side and job-side — is [Phase 02b](phase-02b-events-auth.md)'s; this phase verifies it holds across pods and @@ -720,6 +733,9 @@ new erasure permission. partitioning, query cost ceiling, Hangfire fair-share dispatch with per-tenant concurrency caps, and bounded `data_source` queries. - Production deployment pipeline, staging environment, rollback path. +- Deployment-specific authorization/evidence topology and admitted-instance + capacity qualification, with measured saturation alerts and cleanup/shutdown + runbooks against the accepted source ledger. - Qualified process supervision/restart/backoff, shutdown/termination and crash-dump policies, with crash-loop alerting and a recovery runbook. Record the worker-process topology decision against measured shared-failure/scaling triggers. @@ -799,6 +815,11 @@ new erasure permission. - No module references a Dapr, Kafka, Valkey or Vault client type. `Modules_Do_Not_Reference_DeploymentMode` is green. - Production deployment is repeatable and documented. +- The actual transaction-pooling deployment preserves authorization/evidence + backend headroom while business server transactions are saturated, at its + admitted application-instance count. Distinct aliases and backend ownership, + cross-alias/PostgreSQL limits and retained-cleanup behavior have source-bound + evidence; additional client pools or application names alone do not qualify it. - `GET /readyz` reads **both** registered checks — `audit` from Packet 9 and `outbox` from Phase 02b — and reports unhealthy when **either** is unhealthy; the orchestrator stops routing to that instance, and the deployment-level backstop stops serving once diff --git a/docs/standards/04-api-design.md b/docs/standards/04-api-design.md index 703d40e47..da2138a5d 100644 --- a/docs/standards/04-api-design.md +++ b/docs/standards/04-api-design.md @@ -16,6 +16,11 @@ Accepted — 2026-10-20. Implementation has local runtime/exported proofs; the owns independent reviews and final-source CI qualification. Existing-binding admission additionally derives from Accepted [ADR-0072](../decisions/0072-application-owned-identity-bootstrap.md). +Live policy and sensitive-directory contracts derive from Accepted +[ADR-0076](../decisions/0076-live-scoped-authorization.md) and +[ADR-0077](../decisions/0077-trusted-authorization-evidence.md). The API composition +activates live policy and the administrative directory; the +[phase Status block](../roadmap/phase-03-identity-admin.md) owns qualification. REST conventions for LearnStack public and admin APIs. @@ -152,8 +157,10 @@ optional wire actor is consistency evidence; quota precedes the uncached existin binding read, which alone supplies a stored actor. Unknown/conflicting/Closed or unavailable bindings return uniform 401 without allocation. Ordinary resolution remains route-independent. Credential-free public reads and AccountSite's existing -absent-bearer 404/admitted-wrong-method 405 remain. No POST, OpenAPI operation or -SDK census change is admitted; P03-3/G1/G6 owns new-user transport decisions. +absent-bearer 404/admitted-wrong-method 405 remain. That P03-1 handoff admits no +POST or SDK operation. ADR-0076 separately accepts +the [administrative directory](#administrative-membership-directory) below; +P03-3/G1/G6 retains new-user transport and native BFF extension decisions. ## Tenant Context @@ -296,6 +303,41 @@ no-store on success and early errors. Generated GETs have exact disjoint public and authenticated ownership with an exhaustive union. The public transport stays credential-free; P02b-7 owns authenticated session transport. +### Administrative membership directory + +ADR-0076 extends the +AccountSite-only authenticated API with `GET /api/v1/identity/memberships`. +It is a declared tenant-wide administrative read requiring the existing Tenant +`identity.membership.admin` capability, including an eligible NULL-org Tenant +Admin Membership in a branch session. An Organization grant never authorizes it. +HostAndClaim and confirmed ClaimAndMembership authority admit it, including a +platform host with a confirmed claim; HostOnly and host/claim disagreement do not. +PublicSurface and AccountSite keep their separate real-HostScope ceilings. + +- Return only MembershipId, UserId, OrganizationId, status, contextual DisplayName + and PreferredLocale for undeleted Active/Suspended Memberships. Revoked/retired + rows, provider subject, email, grant graph and global profile are excluded. + Module-local IDs cross contracts as Guid; SharedKernel identity/scope types stay. +- Use fixed MembershipId ordering and an opaque versioned cursor bound to actor, + tenant/org and declared operation. Limit defaults to 20 and clamps at 100. + Bound the cursor to 1,024 encoded/768 decoded bytes and strictly validate + canonical version/fields. Every page rechecks live policy and actual scope; + the anchor supplies no authentication or snapshot authority. No search, + arbitrary filter/sort or mutation is admitted. +- Responses are no-store. Success discloses the DTO only after the MUST + `identity.membership.list` ReadSensitive row commits on the ordinary ReadWrite + owner. This GET is not added to the physical READ ONLY catalogue. Audit failure + returns `503 audit_unavailable` without a result payload. +- Served OpenAPI and the generated SDK carry the exact directory contract. + The native authenticated transport allowlist still contains only AccountSite; + its extension, editing routes and UI remain P03-3. + +[Security's live policy boundary](11-security.md#live-authorization-contract) +owns early-gate precedence, runtime default deny, independent application checks +and 403/404/503 outcomes. [Database read authority](05-database.md#operation-bound-read-authority) +owns the invocation witness shared by EF and SQL. Runtime and source-bound +controls separately verify routing, schema, grants and pre-disclosure audit. + ## Pagination Cursor pagination by default: diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index bfb68aadc..4753cce74 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -44,6 +44,10 @@ Identity storage and global audit authority additionally derive from Accepted [ADR-0073](../decisions/0073-scoped-membership-and-identity-data-foundation.md) and [ADR-0074](../decisions/0074-membership-admission-under-ordered-locks.md). +Live authorization and evidence source contracts additionally derive from Accepted +[ADR-0076](../decisions/0076-live-scoped-authorization.md) and +[ADR-0077](../decisions/0077-trusted-authorization-evidence.md). + PostgreSQL schema, EF Core, and migration conventions. ## Database @@ -377,13 +381,13 @@ Rules: `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 + `app.scope` has an implemented operation-bound carrier under Accepted ADR-0076's + [operation-bound read authority](#operation-bound-read-authority); it derives + from a live eligible Tenant capability plus a declared tenant-wide operation + and binds EF/SQL to the actual owner generation. The + [Phase 03 Status block](../roadmap/phase-03-identity-admin.md) owns current + qualification and review state. The placement rule + applies to that carrier unchanged. `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(…, '')`, @@ -601,6 +605,9 @@ 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)). + This is the implemented narrow form; the Accepted + [operation witness](#operation-bound-read-authority) adds only the matching + parameterized tenant-wide read state, with no write-scope widening. - **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` @@ -2056,8 +2063,165 @@ requires a fresh scope. No fifth role or independent public-read connection is a 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, - not to be the isolation boundary. + leaking. The interceptor makes that missing announcement explicit. For an + operation-bound tenant-wide read it also checks the invocation's live authority: + SQL scope can remain announced until cleanup after the invocation ends, so retained + EF commands must refuse through the same owner/witness check. RLS continues to + enforce the announced tenant and organization scope. + +### Authorization and evidence source ledger + +ADR-0076/0077 extend the +Connection Management and job-source inventories below with these confined +sources. This section is the sole detailed standards owner for their physical +capacity, configuration and lifetime; acceptance supplies no runtime proof. +The four login roles, grants and canonical RLS templates remain unchanged. +P03-2 Steps 2/4 implement both confined sources and the coupled configuration +selector. API and Seeder composition qualify their actual deployment sources; +the [phase Status block](../roadmap/phase-03-identity-admin.md) +owns current review and qualification state. + +| Source / configuration | Retained-owner and connector bound per API process | Permitted work | +|---|---|---| +| Business — `ConnectionStrings:Default` | Existing application/job floor below, plus separately budgeted HTTP/delivery headroom | Ordinary business owners and existing sanctioned helpers | +| Authorization — `ConnectionStrings:IdentityAuthorization` | Four owners, at most four physical connectors; no waiting admission queue | Exact Identity coverage/grant statement and separate Tenancy lifecycle statement on one isolated READ COMMITTED, physically READ ONLY child unit | +| Evidence — `ConnectionStrings:AuthorizationEvidence` | Two owners, at most two physical connectors; at most one connector per operation including sequential verification; no unbounded admission queue | Central fixed-occurrence event INSERT and exact duplicate verification under captured tenant/org scope | + +- Claim a slot before connection creation/open. Open, statements, cancellation + and actual cleanup occupy it. Authorization has one five-second total admission + deadline; evidence has one shared five-second I/O budget across open, + INSERT/COMMIT and sequential verification, never a fresh verification budget. + Elapsed time does not release a slot: unconfirmed/failed cleanup retains it, + and source shutdown joins actual owners before disposal. +- The authorization checkout awaits the shared reachable-bypass role guard with + that same deadline before BEGIN. Its physical driver initializer cannot carry + the operation token, so the confined source performs this guard explicitly on + every checkout; the ordinary business builder keeps its existing initializer. + No candidate owner is published before guard, READ ONLY and announcement finish. +- Authorization excess admission immediately returns `503 dependency_unavailable`. + Evidence capacity failure is audit-unavailable under the + [pre-success/refusal rules](18-audit-coverage.md#trusted-authorization-evidence). + These limits add at most six retained connectors, not six per job or borrower; + tests may reduce caps, deployment settings cannot widen them. They are bounded + local concurrency choices, not throughput or fairness guarantees. +- Both auxiliary sources use the same guarded `learnstack_app` role, actual + database, installed identity authority and tenant/org RLS as business work. + Neither has a raw general-DI datasource alias, generic SQL/callback API or + caller-selected endpoint. Neither acquires another source while held, dispatches + a business handler or takes business advisory/row locks. Authorization performs + only nonlocking MVCC reads and closes before audit/business continuation. + Evidence performs only captured-scope INSERT/verification and completion. +- Resolver, HTTP, application, nested and post-lock authorization calls all use + this source. The child binds the verified actor and exact candidate tenant/org, + clears unrelated authority, announces only after physical READ ONLY setup and + uses that same owner/generation for `IIdentityScopeLifecycleReader`. It never + replaces a parent unit, installs TenantContext or reads profiles/global Users. + A writable parent or clean ChangeTracker cannot substitute: its own uncommitted + grant/lifecycle writes are not committed authority. +- Job callers add named tracked authorization and evidence lanes to + `JobApplicationSourceOwner` and its actual-cleanup join. Existing main/auxiliary + business accounting remains; these globally bounded lanes neither claim a + second main unit nor suppress the ambient job marker. All opens, borrowers and + retained cleanup enter the source-lifetime census before activation. + +The two new configuration keys are coupled: partial input refuses. Development +alone may derive both from a direct-PostgreSQL Default string, preserving three +physically separate bounded client sources. Outside Development both guarded +strings are explicit; no silent same-endpoint fallback is admitted. All sources +qualify the same application role and installed identity authority. API/Seeder +startup also binds each used auxiliary lane to a live business-database witness: +one existing Default connector holds a READ COMMITTED, physically READ ONLY +transaction with a fresh random 64-bit two-int advisory marker. Auxiliary +qualification checks that marker's actual SQL backend PID, key and current database +in `pg_catalog.pg_locks`, before becoming qualified. Database name/authority +matching alone cannot distinguish a restored copy on another cluster. +The two-int key space (`objsubid = 2`) is separate from bigint business fences; +no business row mutation, User/tenant invariant fence, role, grant, table or +tenant announcement is added. +The SQL backend PID is used because PgBouncer's protocol client cancellation PID +is not PostgreSQL backend identity. The startup witness has one five-second total +deadline across its open/setup and the sequential lane qualifications. Actual +rollback/disposal releases its marker and connector; elapsed time does not. +Concurrent disposal joins that same completion. This is transient startup use of +the existing business source, not another runtime pool or wider four/two cap. +Production uses the witness-bearing overload; an unbound qualification cannot be +upgraded/rebound to a different witness. Direct PostgreSQL and pinned PgBouncer +aliases have positive controls; same-name/same-authority foreign clusters, +wrong roles and expired/closed/foreign witnesses have negative controls. +This verifies common database execution, not the separately required downstream +backend reserve, replica/failover routing or production topology sizing. +Credentials remain environment-only, absent from restore/build inputs; modules never choose +endpoints or branch on deployment mode. +Direct PostgreSQL is a supported-topology precondition for Development derivation, +not a fact an arbitrary endpoint string can prove. PgBouncer needs an explicit +pair and qualified backend reservation even in Development. A port heuristic or +matching alias is not topology evidence. + +Through transaction-pooling PgBouncer, business, authorization and evidence use +independent backend lanes. The auxiliary database aliases are +`learnstack_identity_authorization` and `learnstack_authorization_evidence`, both +mapped to the same actual business database and app role. Alias/backend limits, +cross-alias user/database limits, PostgreSQL total capacity and deployed instance +count must reserve auxiliary headroom outside business capacity. Application +names, extra client pools and matching authority strings prove no backend reserve. +No new login role or BYPASSRLS privilege follows. + +P03-2 activation requires small real-PostgreSQL pools and a pinned transaction- +pooling PgBouncer fixture: hold all business connectors/server transactions while +admitted nested/post-lock checks and ReadOnly standalone evidence complete before +parents release anything. Verify distinct backend PIDs; checked-out client +connections alone may own no PgBouncer server. A deliberately shared saturated +backend topology must fail that progress control. Separately prove auxiliary +saturation/immediate refusal, recovery only after actual cleanup, no observation +of uncommitted parent grants, cancellation/failed cleanup and tracked job shutdown. +Phase 11 owns actual production topology/instance-capacity qualification before +production activation; it cannot replace these P03-2 correctness controls. +General outage, DDL blocking and excess offered load can still refuse or time out. +[Infrastructure topology](12-infrastructure.md#authorization-and-evidence-topology) +owns operational setup and the production activation handoff; this ledger owns +the resource contract and required progress proof. + +### Operation-bound read authority + +**Operation-bound read authority.** ADR-0076 supplies the +`app.scope` carrier. Only a declared tenant-wide read with an eligible +Tenant capability mints an immutable witness binding actor, tenant/org, exact +request policy and invocation. Branch grants cannot mint it. Step 6 binds the +actual owner generation and announced values before data SQL; parameterized EF +filters and RLS consume the same narrow or tenant-wide read state. + +The witness is neither an ITenantContext setter nor a handler-writable Boolean. +It cannot widen tenant boundaries, organization writes or the independent public/ +AccountSite HostScope ceiling. Completion, failure, cancellation and pool reuse +clear it. Mixed read-scope nesting, including narrow work inside a widened owner, +refuses and poisons the unit; a join never retargets scope. Existing transaction- +mode compatibility remains mandatory. The canonical SQL templates above are +unchanged; no second RLS template or IgnoreQueryFilters exception is admitted. + +`OperationReadWitness` is immutable; its exact `OperationInvocation` owner retains +one-time consumption state. The ordinary `NpgsqlUnitOfWork` validates every bound +frame and actual generation before exposing its live read view, unit access, EF +announcement or COMMIT. Open ancestors remain live during compatible nesting; +closed nodes cannot revive in inherited execution contexts. The existing setter +always sends `app.scope` as `tenant` or empty, including narrow setup on pooled +connections. The common module-context registration attaches only that unit's +read-only view; the filter reads a context-instance parameter and retains its +tenant predicate. Unattached contexts remain narrow. + +A scoped Identity context can first serve an existing-binding global owner and +then a separate ordinary tenant owner on the same physical connection. The +contextual adapter re-enrolls only that already-announced live tenant transaction, +matching its dynamic tenant/organization filters and refusing pending tracked +mutations, foreign connections or a global owner. It neither carries global +authority forward nor changes the operation read witness, GUCs or setter census. + +The directory's ordinary ReadWrite owner commits its MUST read evidence before +disclosure. Denial occurrences instead use the confined central store seams and +fixed `(id,timestamp)`/expected-`audit_log_pkey` verification governed by +[Standard 18](18-audit-coverage.md#trusted-authorization-evidence). Their clean +READ ONLY verification uses captured RLS scope and full null-safe draft equality; +it grants no new SELECT, global function or sentinel authority. Ordinary resource +intent reconciliation retains its different-timestamp contract. ## Background jobs @@ -4185,7 +4349,9 @@ The API application source has one `ApplicationDataSourceLifetime` owner and a stable `Lazy` registration, without a raw-source DI alias. Container disposal requests release; actual release waits for the same job and messaging completion witnesses. Each job invocation's `JobApplicationSourceOwner` -permits one main unit and one independently tracked auxiliary operation. Cache +permits one main unit and one independently tracked business auxiliary operation. +The [authorization/evidence extension](#authorization-and-evidence-source-ledger) +adds separate globally bounded tracked lanes; it does not widen that business pair. Cache waiter expiry cannot replace the actual loader, connection or failed cleanup. The four closed auxiliary helpers are audit-config, standalone/best-effort audit, tenant flags and killswitch reads; nested ordinary work shares the same main unit. @@ -4223,8 +4389,10 @@ supported boundary has source-specific qualification in the The combined real-owner proof at the default52 configuration complements the complete ledger and existing topology/retention controls; it does not claim52 simultaneously busy connections or a tight empirical minimum. -Later SQL lanes require a new census and requalification; Phase 11 owns measured -production capacity and PgBouncer. +Later SQL lanes require a new census and requalification. The accepted +[authorization/evidence ledger](#authorization-and-evidence-source-ledger) owns +P03-2's additional lanes and mandatory local PgBouncer progress proof; Phase 11 +owns measured production capacity and actual deployment qualification. diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 03f852b00..eb0cf56b2 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -174,9 +174,13 @@ empty project populations remain covered by discovery. Final results reconcile those records with TRX, including all summary counters. A display-name-only progress record cannot distinguish colliding theory rows. -For the five enumerated live Keycloak test classes, failed execution measurements -retain only the fixture's existing closed startup stage, reason and cleanup -result. Unknown, ambiguous or oversized messages supply no diagnostic fields; +For the six enumerated live Keycloak test classes, failed execution measurements +retain the fixture's closed startup stage, reason and cleanup result, plus the +startup cancellation state when supplied. Stages identify the last reached SDK +milestone, not the exact failing operation. The cancellation state is captured +when failure is caught, before cleanup; it does not identify the provider cause. +Older diagnostic sentences leave that state unknown. +Unknown, ambiguous or oversized messages supply no diagnostic fields; raw messages, exception types and provider output remain private. These fields neither establish quiescence nor replace Docker absence, complete results or a successful execution receipt. Completed-case streams and retained TRX contain diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index dc2dc0a45..809fc961e 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -29,6 +29,9 @@ Identity lock/admission observation derives from Accepted [ADR-0072](../decisions/0072-application-owned-identity-bootstrap.md), [ADR-0073](../decisions/0073-scoped-membership-and-identity-data-foundation.md) and [ADR-0074](../decisions/0074-membership-admission-under-ordered-locks.md). +Accepted [ADR-0076](../decisions/0076-live-scoped-authorization.md) / +[ADR-0077](../decisions/0077-trusted-authorization-evidence.md) add live +authorization/evidence obligations implemented by the live boundary below. Three signals — logs, traces, metrics — bound by a single correlation id. Everything we ship is observable from day one. @@ -292,6 +295,35 @@ owns their current qualification status. Step 3 implements recorder/claimless/quota diagnostics; Steps 4–5 qualify the complete active path. Acceptance alone claims none of these mechanisms delivered. +### Trusted authorization diagnostics + +The canonical +[Standard 18 evidence contract](18-audit-coverage.md#trusted-authorization-evidence) +owns denial/assertion populations, immutable provenance, exact reconciliation +and pre-success durability. Diagnostic records do not substitute for those rows. + +- Authorization-source unavailability/capacity refusal is fixed + `503 dependency_unavailable`, never fabricated noncoverage or invalid-token 401. + Evidence-source saturation is audit-unavailable: preserve an actual decided + refusal, but prevent otherwise-successful outer commit/disclosure with 503. +- MUST store failures retain existing Critical/counter/audit-health reporting. + Independent composer refusal has one fixed Critical signal. Attach no raw + provider/database exception, claims, body, query or asserted UUID. +- A matching fixed denial row establishes that occurrence's durability only; + neither a duplicate counter, 23505 nor event evidence establishes business COMMIT. + Ordinary resource reconciliation retains its separate historical semantics. +- Confirmed claim-only header mismatches use their distinct MUST event and fixed + provenance without host allowance/suppression signals. Refused row 14 remains + diagnostic-only; row 7/10 and host-backed budget signals retain their contracts. +- The trusted provenance snapshot reuses admitted transport address and bounded + route/user agent under Standard 18. User agent supplies no authority and never + becomes a log label. Actor, Membership, Role, tenant and correlation identities + remain excluded from metric labels; no free-form provenance dictionary exists. +- [Standard 05](05-database.md#authorization-and-evidence-source-ledger) owns the + four/two retained-owner sources, deadlines, cleanup and topology proofs. + Elapsed time is not resource release. Local progress/fault controls are P03-2 + correctness gates; Phase 11 owns production capacity, alerts and runbooks. + ### Sampling - Production sampling: 100% of `Information`+ logs by default. @@ -635,14 +667,16 @@ reported as `other`; adapters never derive a label from a full cache key, tenant organization id, host, session id, or entity id. `reason` is one of `explicit`, `expired`, or `capacity`; `outcome` is one of `success`, `faulted`, or `cancelled`. -The two audit counters are labelled by `operation` — the catalogue's dotted slug, a -bounded set nobody outside the deployment chooses — and never by tenant. They answer -different questions and both are required by -[ADR-0033 Amendment 3](../decisions/0033-audit-durability-model.md): a **write failure** -is an operation that succeeded with no record of it, which also takes the `audit` health -check unhealthy and logs at `Critical`; a **duplicate** is the opposite signal, positive -evidence that a business `COMMIT` landed whose outcome the process could not observe, so a -rate that moves is about the database connection rather than about any one request. +The two audit counters are labelled by `operation` — the fixed catalogue slug, +never tenant or caller input. Existing standalone failures log at Critical and +mark audit health unhealthy. They may record an already-refused operation whose +403/404 survives the failure; they do not mean successful unaudited business work. +Ordinary resource duplicates retain +[ADR-0033 Amendment 3](../decisions/0033-audit-durability-model.md)'s separate +reconciliation semantics. Under Accepted ADR-0077, denial duplicates count as +durable evidence only after exact full-draft verification; that evidence never +proves the enclosing business COMMIT outcome. Neither counter replaces the +[pre-success barrier](18-audit-coverage.md#trusted-authorization-evidence). The two entitlement counters belong to the Hub-backed provider's read path ([Hybrid License Model § Failure policy by key class](../architecture/26-hybrid-license-model.md)) diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 690993e08..f64b3dc85 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -50,7 +50,9 @@ Identity admission/owner contracts additionally derive from Accepted [ADR-0073](../decisions/0073-scoped-membership-and-identity-data-foundation.md) and [ADR-0074](../decisions/0074-membership-admission-under-ordered-locks.md). The [phase Status block](../roadmap/phase-03-identity-admin.md) owns current -implementation and qualification state; live membership execution remains P03-2. +implementation and qualification state. Live membership and trusted evidence +derive from Accepted [ADR-0076](../decisions/0076-live-scoped-authorization.md) and +[ADR-0077](../decisions/0077-trusted-authorization-evidence.md). Bounded authenticated assertion recording derives from [ADR-0065](../decisions/0065-bounded-tenant-assertion-diagnostics.md); its implementation and local proofs are delivered in the @@ -366,14 +368,16 @@ tenant-facing endpoints. Per [ADR-0019](../decisions/0019-learnstack-hub.md) and ## Authorization -Every ordinary tenant write use case checks, in order: +Every protected tenant act checks, in order: 1. **Authentication** (valid token). 2. **Tenant membership** (user has a Membership in the resolved tenant). 3. **Role / permission** (user's roles include the required permission). 4. **Resource scope** (e.g. instructor can only edit their own courses). -A failure at any step returns a Problem Details response with the right code (`unauthorized`, `tenant_mismatch`, `forbidden`, `resource_scope_violation`). +The [live policy boundary](#live-authorization-contract) owns precise masked +404/capability 403/dependency 503 precedence; the generic list above does not +override earlier credential, host or binding refusals. The exact internal global-identity creation class instead uses the [adopted subject-confined owner](#identity-admission-contract), with no tenant selection or membership authority. It is not a routed HTTP write in P03-1. @@ -472,8 +476,8 @@ transaction by the step-6 `TransactionBehavior` adapter through `TransactionExecutionCoordinator`, from the context admitted at step 4. Default ReadWrite transactions announce them as their first SQL statement. -For a physical PublicSurface ReadOnly owner or the exact authenticated -`GetAccountSiteQuery` ReadOnly owner only, +For the implemented physical PublicSurface ReadOnly owner or exact authenticated +`GetAccountSiteQuery` ReadOnly owner, [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md) and [ADR-0040 Amendment 8](../decisions/0040-ambient-unit-of-work.md#amendment-8--read-only-public-frames-2026-10-03) with each ADR's 2026-10-19 dated scope-extension disclosure under @@ -490,7 +494,9 @@ The authenticated-query extension is implemented by Step 4 owns HTTP activation. Selection is by exact request type, never a query-name suffix, public marker or general authenticated read category. It reuses the existing physical owner and tenant announcement; -the closed setter set and transaction-ownership contracts do not change. +that extension does not change the setter set or transaction ownership. +ADR-0076 separately accepts the ninth candidate owner below, with the same +READ COMMITTED → awaited physical READ ONLY → exact announcement ordering. Every institution public read dispatches a marked request through ISender, audit Off and GET/HEAD only. Factory HostScope is an independent host ceiling intersected @@ -516,21 +522,15 @@ 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 `app.scope` authority member; ADR-0052's immutable -HostScope is an independent narrowing ceiling, never a scope-widening carrier -([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. +**`app.scope` has an implemented operation-bound carrier.** Accepted ADR-0076 replaces the +former role-missing deferral with an operation-bound witness; P03-1 already stores +Memberships, Roles and scoped definitions. The +[database read-authority contract](05-database.md#operation-bound-read-authority) +owns SQL/EF derivation, owner generation, nesting and cleanup. Step 6 announces +that admitted read authority inside the actual transaction; no middleware or +ITenantContext member may manufacture it. The +[Phase 03 Status block](../roadmap/phase-03-identity-admin.md) owns current +qualification and review state. HostScope remains an independent narrowing ceiling. P02b-1 Step 1 adds a read-only announcement snapshot through `IUnitOfWork.GetTenantContextAnnouncement(DbTransaction?)`, under @@ -545,9 +545,10 @@ context writer, factory privilege or tenant retargeting authority. ### The out-of-band setters `TransactionBehavior` delegates the general announcement path to -`TransactionExecutionCoordinator`. Eight logical setter paths currently exist. -The adopted [candidate-reader extension](#owner-and-setter-census) registers a -ninth for P03-2 implementation; no ninth setter is shipped. The current set is closed +`TransactionExecutionCoordinator`. The baseline eight logical setter paths are +enumerated below. The Accepted [candidate-reader extension](#owner-and-setter-census) +adds the ninth; the API registers its confined live membership reader. ADR-0077's confined +evidence methods extend central audit ownership, not the logical setter count. The current set is closed ([ADR-0040 § Who sets `app.tenant_id`, completely](../decisions/0040-ambient-unit-of-work.md), with its Amendments 3 and 7, is the authority; it is reproduced here because this section is the placement authority). Two set it on the **ambient** transaction; six own a **short @@ -565,15 +566,12 @@ yet: | The `AuditConfig` override loader | its own short read-only one | An out-of-band cached projection, never a request-path query | | The tenant-flag loader (`FeatureFlags`) | its own short read-only one | A cached projection of `tenant_feature_flags`, read on a cache miss wherever `IFeatureFlags` is asked — inside a request's transaction or outside any ([ADR-0045 § 2](../decisions/0045-entitlement-and-feature-flag-socket.md), [ADR-0040 Amendment 7](../decisions/0040-ambient-unit-of-work.md)) | -> **`IOrganizationScopeValidator` is registered and has no reachable caller yet.** Its -> only non-vacuous caller is the reconciliation matrix's row 7. P02b-5 activates -> validated claims, but `DenyAllTenantMembershipReader` refuses row 7 before -> organization-scope validation. Phase 03 owns the live membership path that can -> reach it. 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. +> **`IOrganizationScopeValidator` performs row-7 organization narrowing after +> affirmative live Membership admission.** `TenantAssertionMiddleware` separately +> refuses differences between asserted and resolved values. Neither path supplies +> authority or widens scope. The +> [live authorization contract](#live-authorization-contract) owns admission and +> its boundaries. > **P02d-2 closes G15 without a new setter.** Seed ownership verification now sends > module-owned contextual `ISender` queries in fresh trusted scopes; its old private @@ -1054,13 +1052,12 @@ Security-relevant durability rules: ## Identity admission contract **Status: Global/contextual storage, common audit, seed and existing-binding API -admission are implemented; live membership execution remains P03-2.** Derived from +admission and live membership execution are implemented.** Derived from [ADR-0072](../decisions/0072-application-owned-identity-bootstrap.md), [ADR-0073](../decisions/0073-scoped-membership-and-identity-data-foundation.md) and [ADR-0074](../decisions/0074-membership-admission-under-ordered-locks.md). -The bounded global owner below is implemented; this does not activate HTTP -creation or live membership. The current authentication surface, eight tenant -setters and deny-all membership reader remain until their owning steps. The +The bounded global owner does not activate new-user HTTP creation. The API +uses the live membership adapter and the nine-setter census below. The [adopted database contract](05-database.md#identity-storage-contract) owns the global class, authority anchor, subject-key encoding, exact policies, owner functions and lock/snapshot rules; they are not duplicated here. @@ -1195,18 +1192,23 @@ execute with migration-owner rights and need their own exact privilege/body/call controls before admission. No general SQL executor, global User directory or direct self audit read follows from them. -`PreContextIdentityAuthorizationReader` is the planned ninth logical tenant -setter, owned by P03-2 execution under G2. It uses `learnstack_app`, NOBYPASSRLS, -and a short physically READ ONLY transaction. It binds the verified application -actor and exact candidate tenant/organization to a minimal coverage/grant read, -announces only that candidate scope, and closes before the request owner opens. -It installs no `ITenantContext` and returns no profile or member directory. -Candidate scope remains a question; validated identity and the confined reader -contract supply input authority. P03-1 specifies the port/storage contract only. -Accepted ADR-0040's disclosure and this census admit only that ninth logical path; -P03-2 must implement/register/qualify its actual adapter before activation. The -eight current tenant setters remain the implemented set. The global subject -owners do not add tenant setters or ITenantContext writers. +`PreContextIdentityAuthorizationReader` is the Accepted ninth logical tenant +setter under ADR-0076. Its confined reader/source and normalized HTTP candidate +minter supply the API membership adapter. A verified normalized actor +or separately enumerated trusted execution descriptor supplies the exact candidate +actor/tenant/org; UserId alone supplies no authority. Its isolated child uses the +[confined source ledger](05-database.md#authorization-and-evidence-source-ledger), +physical READ ONLY and narrow UoW announcement seam, with no ambient TenantContext +or new global User/profile/directory projection. It closes before ordinary +business continuation, including when a nested/post-lock parent already exists. +Tenancy's lifecycle reader uses this same physical child owner/generation. + +Nine is the Accepted logical setter set. The pre-existing eight-path census +above remains the baseline; its durable idempotency path is still unimplemented. +The ninth's reader/owner has source, database and exact trusted-source controls. +Global subject owners and the +confined central denial-event methods add no tenant-context writer or logical +setter. Standard 05 owns the source/configuration/retained-cleanup ledger. @@ -1249,6 +1251,79 @@ measurement controls qualify their recorded contextual scope. Existing-binding load observations retain their distinct control and scope; live-admission qualification remains P03-2. None establishes production capacity. +## Live authorization contract + +**Live HTTP and application policy share the canonical admission boundary.** +ADR-0076/0077 replace only the +live-policy, ninth-setter and evidence deferrals identified here. The existing +credential/quota/binding, context writers, public ceilings and BFF contracts +remain; the [phase Status block](../roadmap/phase-03-identity-admin.md) owns +implementation and qualification state. + +The frozen `OperationPolicyCatalog`, scoped coordinator and Identity evaluator +are registered in the API and seed composition roots. Exact seed, validated +delivery and retained job descriptors supply finite authority independently of +SystemActor values. HTTP controller/infrastructure metadata and named requirements +are enforced before dispatch. The canonical `AuthorizationBehavior` occupies +pipeline slot 5; the administrative directory is the sole added HTTP route. + +- Freeze exact HTTP endpoint and application-request policies at composition: + public reads, special AccountSite, scoped protected capabilities and finite + trusted seed/system operations. Infrastructure endpoints are explicitly + classified too. Missing, unknown or conflicting declarations deny at runtime, + in addition to the structural census. Named ASP.NET requirements must agree + with application metadata in SharedKernel/contracts; modules gain no API or + ASP.NET dependency. When no endpoint matches, routing retains 404/405. +- Preserve earlier visitor/host/credential/subject/binding/assertion precedence. + On admitted protected surfaces absent credentials or noncoverage is masked 404; + valid coverage without capability is 403 before handler/resource lookup. + Only admitted capability permits lookup: missing/foreign-scope rows are 404, + a visible row failing ownership policy is 403. Wrong methods keep routing + semantics after earlier refusals. HostOnly never becomes protected authority. +- Every protected act queries committed Membership/Role/grant authority again. + Resolver evidence never authorizes a later HTTP or application act; direct + MediatR repeats classification/capability at step 5. Only active/nondeleted + Memberships, compatible assignments, nonretired Roles and active registered + definitions contribute. NULL-org Tenant grants compose only with the requested + organization's grants. Coverage and capability remain distinct outcomes. +- The separate lifecycle statement admits Trial/Active tenant and, when named, + Active/nondeleted belonging organization. Identity and Tenancy statements each + see committed state; they are not one atomic cross-module snapshot. A revocation + committed before the act's grant query affects admission; already-admitted work + may finish. Earlier subject binding remains the global User eligibility check. + P03-3 owns lifecycle-transition races and P03-10 closure fences before writers. +- Authorization storage unavailability is `503 dependency_unavailable`, never + false noncoverage or invalid-token 401. Binding lookup retains its separate + uniform 401 contract. Cancellation propagates through owned cleanup. No cache, + retry or configurable authority override is admitted. +- AccountSite gains live Membership/lifecycle admission on matching resolution + rows 6/9, retaining audit Off, exact physical READ ONLY and real matching + HostScope; ClaimAndMembership remains ineligible. Row 7 independently retains + its organization validator. Public reads retain publication/HostScope ceilings; + credential-free public reads perform no identity/grant lookup. +- Seed, event and job owners keep exact validated source/request contracts. + SystemActor UUID, provider role or unknown/direct-untrusted source supplies no + bypass. Platform admission remains P03-9. Unrouted production requests also + require exact classification; no pass-through authorization behavior remains. + +[Standard 19](19-permissions.md#live-permission-enforcement-contract) owns the +finite public definitions and grant/delegation rules; +[Standard 04](04-api-design.md#administrative-membership-directory) owns the sole +new HTTP consumer. The [database ledger](05-database.md#authorization-and-evidence-source-ledger) +owns physical owners, progress, deadlines and production topology qualification. + +[Standard 18](18-audit-coverage.md#trusted-authorization-evidence) owns the typed +per-occurrence MUST denial population, separate plural resource intents, fixed +microsecond occurrence identity and clean scoped full-draft duplicate verification. +Its pre-success barrier binds unresolved denials to an enclosing ReadWrite commit +or acknowledged standalone evidence before ReadOnly/ownerless disclosure. An +actual refused caller keeps 403/404 on audit failure; an outer success that absorbs +that denial fails before commit/disclosure if required evidence is unavailable. +Event evidence never proves an uncertain business COMMIT. Successful +ClaimAndMembership resolution followed by a conflicting assertion has its own +Membership-confirmed event, outside the host allowance. Existing row-7/10/refused-14, +anonymous/tenantless and earlier refusal evidence boundaries remain unchanged. + ## Incident Response - A security incident playbook lives in `docs/runbooks/security-incident.md` (Phase 11 deliverable). diff --git a/docs/standards/12-infrastructure.md b/docs/standards/12-infrastructure.md index c2dc2687e..5a2ca24b5 100644 --- a/docs/standards/12-infrastructure.md +++ b/docs/standards/12-infrastructure.md @@ -9,7 +9,9 @@ [ADR-0020 Triple Deployment + Hybrid License](../decisions/0020-triple-deployment-hybrid-license.md), [ADR-0029 Object Storage — SeaweedFS](../decisions/0029-object-storage-seaweedfs.md), [ADR-0030 Redis-Compatible Store — Valkey](../decisions/0030-redis-compatible-store-valkey.md), -[ADR-0031 PostgreSQL — Start on 18.x](../decisions/0031-postgresql-major-version.md). +[ADR-0031 PostgreSQL — Start on 18.x](../decisions/0031-postgresql-major-version.md), +[ADR-0076 Live Scoped Authorization](../decisions/0076-live-scoped-authorization.md), +[ADR-0077 Trusted Authorization Evidence](../decisions/0077-trusted-authorization-evidence.md). How LearnStack is built, packaged, deployed, configured, and observed at the infrastructure level. The application-code rules for the foundation building blocks @@ -221,6 +223,38 @@ Rules: - Replication: read replicas optional from Phase 11. - PgBouncer in transaction pooling mode in production. +### Authorization and evidence topology + +Accepted ADR-0076/0077 require three physically separate bounded application +sources. The [database source ledger](05-database.md#authorization-and-evidence-source-ledger) +owns their borrower census, fixed four authorization/two evidence retained-owner +caps, role/authority guards and actual-cleanup lifetime. These are local capacity +bounds, not measured production sizing. Acceptance does not supply runtime +wiring or deployment proof. + +- Business uses `ConnectionStrings:Default`; the coupled auxiliary pair is + `ConnectionStrings:IdentityAuthorization` and + `ConnectionStrings:AuthorizationEvidence`. Partial explicit configuration + refuses. Only `Development` may derive both from a direct-PostgreSQL Default + endpoint, retaining separate bounded sources. Outside `Development`, both + explicit guarded strings are mandatory. Modules never select endpoints. +- Credentials stay in the environment/private configuration source and do not + enter restore/build inputs. All sources use `learnstack_app`, the same actual + database and matching installed identity authority; no new login or bypass + privilege is introduced. +- PgBouncer transaction pooling uses distinct database aliases + `learnstack_identity_authorization` and `learnstack_authorization_evidence` + targeting that same database/app role, with independent backend lanes outside + business capacity. Reserve downstream headroom across alias pools, cross-alias + user/database limits, PostgreSQL capacity and the admitted application-instance + count. Application names and additional client pools prove no backend reserve. +- P03-2 owns small real-PostgreSQL and pinned transaction-pooling PgBouncer + progress proofs before live activation, including held business server + transactions, admitted nested/post-lock authorization and standalone evidence, + plus the shared-downstream dirty topology. Phase 11 owns qualification of the + actual production topology, total instance capacity and operational controls + before production activation; it cannot replace those P03-2 correctness proofs. + ## Object Storage Operations - SeaweedFS local; S3-compatible cloud storage in production. diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index c9adc78b1..cf7618d22 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -24,6 +24,10 @@ Job additions derive from Global identity creation and contextual foundation authority derive from Accepted [ADR-0072](../decisions/0072-application-owned-identity-bootstrap.md) and [ADR-0073](../decisions/0073-scoped-membership-and-identity-data-foundation.md). +Live authorization evidence derives from jointly Accepted +[ADR-0076](../decisions/0076-live-scoped-authorization.md) and +[ADR-0077](../decisions/0077-trusted-authorization-evidence.md). The +[phase Status block](../roadmap/phase-03-identity-admin.md) owns qualification. This standard defines **which operations must be audited**, **what an audit entry contains**, **how long entries are retained**, and how each module signs up for its @@ -187,22 +191,18 @@ will raise them — which this standard requires it to do. - **`(planned)` is re-checked, not an escape.** A row still marked `(planned)` whose command has since shipped **fails**. The marker is a claim the rule tests on every run, which is what stops the scoping from becoming a hole. -- **`(off-path)` is for operations that are not MediatR requests at all.** Ten rows - carry it: Tenancy's `platform.admin_scope.enter`, `platform.outbox.dead_letter`, - `platform.delivery.dead_letter`, `platform.job.dead_letter`, - `tenancy.killswitch.toggle`, - `tenancy.entitlement.refresh` and the two tenant-assertion keys of § Baseline Coverage, - and Audit's `audit.redaction.apply` and `audit.purge.apply`. They sit outside the - request-type join in **both** directions and register by slug rather than by type — the - six whose writer ships through `DeclareOffPath` today, the other four when their - writer lands. P02b-2 Step 2 registers the shipped producer terminal writer; - P02b-3 Step 4 adds subscriber terminal registration; P02b-4 Step 5 adds the - job terminal writer under Accepted ADR-0047. +- **`(off-path)` is for operations that are not MediatR requests at all.** + These sit outside the request-type join in both directions and register by + slug through `DeclareOffPath` when their writer ships. The module matrices + own the population, including platform entry/terminal operations, assertion + events, global identity creation and privileged Audit maintenance. P03-2 Step 4 + registers authorization-denial and claim-context assertion events beside their + central writers; API/application composition supplies their trusted sources. - **One operation, one row.** A slug is classified in exactly one row across every matrix. A copy is a second answer that passes every comparison the moment it agrees, so the matrix → catalogue direction refuses it, and the catalogue → matrix direction compares every row that carries a slug rather than the first. -- **A row can carry both markers**, and four do. `tenancy.killswitch.toggle` is +- **A row can carry both markers**. `tenancy.killswitch.toggle` is `(off-path)` because every write runs inside `EnterPlatformAdminScope(reason)` rather than through the pipeline, and `(planned)` because Packet 9 ships the table, the policies and the read path but no writer — @@ -230,7 +230,7 @@ not prove implementation or production fit. | Domain | MUST-audit operation | |--------|----------------------| -| Identity | Membership created / removed (per `(user_id, tenant_id, organization_id)`); role assigned / revoked; permission set changed; invitation created / accepted / revoked; platform-admin tenant access; Hub operator access to a tenant resource. | +| Identity | Membership created / removed (per `(user_id, tenant_id, organization_id)`); role assigned / revoked; permission set changed; protected-act authorization denied under trusted resolved tenant (`identity.authorization.deny`, ADR-0077); invitation created / accepted / revoked; platform-admin tenant access; Hub operator access to a tenant resource. | | Tenancy | Tenant created / suspended / deleted; organization created / archived; tenant setting changed; custom domain added / verified / removed; feature flag toggled; entitlement projection refresh (`tenancy.entitlement.refresh`); killswitch toggled (`tenancy.killswitch.toggle`). | | Customization | `TenantContentType` / `TenantPageBlock` / `TenantLessonItemType` / `TenantLevelTaxonomy` / `TenantScoringRule` / `TenantCompletionRule` / `TenantCustomFieldDef` / `TenantTemplateLibrary` created / updated / deleted (schema changes are MUST; both `before` and `after` snapshots required). | | Content / Pages | Page published / unpublished; content type schema changed; redirect created / changed. | @@ -244,14 +244,17 @@ not prove implementation or production fit. | Hub contract | Inbound Hub command received (`tenancy.tenant.create_from_hub`, and the entitlement push, which is the Tenancy row's `tenancy.entitlement.refresh` — one operation, one slug); outbound usage report (`platform.usage.report`); license verification result. | | Notifications | Template changed; outbound delivery to a recipient (SHOULD for non-PII channels; MUST for password reset / invitation / billing). | | Integrations | External provider credential created / rotated / revoked; webhook secret rotated. | -| Security | Login failure burst beyond rate limit; MFA challenge failed; admin override of any guard; mTLS / signed-JWT / HMAC verification failure on `/api/internal/*`; admitted cross-tenant assertion mismatch carrying a validated principal (`tenancy.tenant_assertion.reject`, bounded admission under ADR-0065); anonymous assertion-mismatch burst beyond threshold (`tenancy.tenant_assertion.anonymous_burst`). | +| Security | Login failure burst beyond rate limit; MFA challenge failed; admin override of any guard; mTLS / signed-JWT / HMAC verification failure on `/api/internal/*`; admitted cross-tenant assertion mismatch carrying a validated principal (`tenancy.tenant_assertion.reject`, bounded admission under ADR-0065); anonymous assertion-mismatch burst beyond threshold (`tenancy.tenant_assertion.anonymous_burst`); a header conflicting with successfully confirmed ClaimAndMembership context (`tenancy.assertion.reject_claim_context`, ADR-0077; no host allowance). | There are **no vertical modules**. Tenant-specific extensions land as data via the Customization aggregates and inherit the MUST-audit rules above for schema changes. ## Audit Entry Payload Contract -Every audit entry conforms to this shape; deviations require a one-line note in the module spec and a code-review approval. +The example illustrates ordinary audit fields; it is not an HTTP response DTO or +a caller-authored draft. The mapping rules below and the +[trusted provenance contract](#trusted-authorization-evidence) govern persisted +fields and enrichment; their implementation state is explicit. ```json { @@ -260,11 +263,7 @@ Every audit entry conforms to this shape; deviations require a one-line note in "tenantId": "ten_01H...", "organizationId": "org_01H...", "actor": { - "userId": "usr_01J...", - "membershipId": "mbr_01J...", - "roles": ["instructor"], - "platformAdmin": false, - "hubOperator": false + "userId": "usr_01J..." }, "operation": { "key": "enrollment.enrollment.create", @@ -277,8 +276,7 @@ Every audit entry conforms to this shape; deviations require a one-line note in "request": { "correlationId": "01H...", "sourceIp": "203.0.113.4", - "userAgent": "Mozilla/5.0 ...", - "route": "POST /v1/enrollments" + "userAgent": "Mozilla/5.0 ..." }, "outcome": "success", "before": null, @@ -287,7 +285,7 @@ Every audit entry conforms to this shape; deviations require a one-line note in { "path": "/Enrollment/status", "before": null, "after": "active" } ], "reason": null, - "metadata": { "source": "tenant-admin-studio" } + "metadata": null } ``` @@ -308,7 +306,9 @@ Rules: pair is what a reviewer reads as one commit in doubt. `PostgresAuditStore` supplies `occurredAt` from `IClock` — the intent's declaration time in the transaction, a fresh reading for a standalone re-write — which is what keeps that pair legal under the - composite primary key `(id, timestamp)`. + composite primary key `(id, timestamp)`. This describes ordinary resource + reconciliation; [trusted denial occurrences](#trusted-authorization-evidence) + instead retain one fixed key, timestamp and draft. - `reason` is required when the operator is a platform admin acting on a tenant they are not a member of. It is `EnterPlatformAdminScope(reason)`'s short operator-authored slug naming the operation — never caller-supplied text — and it is surfaced in the tenant admin's audit view. A refusal's cause is not a `reason`: it is the refusal's message key, in the `error_key` column. - `correlationId` matches the trace id in logs and the value in the Problem Details response for failures. - `changes` is always a JSON **array** of `{ path, before, after }`, never the polymorphic @@ -334,12 +334,15 @@ Rules: - **Not every field above is a column.** The `audit_log` DDL ([31-audit-subsystem.md § 7](../architecture/31-audit-subsystem.md)) carries `occurredAt` as `timestamp`, `resource` / `resourceId` as `entity_type` / `entity_id`, - `sourceIp` as `ip_address`, and `operation.key` as `operation`. `actor.membershipId`, - `actor.roles`, `actor.platformAdmin`, `actor.hubOperator` and `request.route` have no - column of their own, and the shipped `PostgresAuditStore` writes none of them: nothing - on the request path carries a membership, a role set or a route yet. - [Phase 03](../roadmap/phase-03-identity-admin.md) settles where they go, with the - identity and request-context work that gives them a source. + `sourceIp` as `ip_address`, and `operation.key` as `operation`. Membership/Role + references, privileged flags and route identity have no column of their own. + The composer keeps email null. Its central owner attaches bounded trusted + provenance under `metadata.authorization`, with IP/user agent in their + existing columns. An exact invocation binds its own resource frame; a direct + inner act without a new frame cannot replace its parent's context, while a + fresh post-lock recheck of that same act can update it. + [The canonical evidence rules below](#trusted-authorization-evidence) + define its sources and limits. ## Storage @@ -461,7 +464,7 @@ Rules: ## Retention -| Class | Default retention | Notes | +| Class | Default retention target | Notes | |-------|-------------------|-------| | `security-event` | **7 years** | KVKK / GDPR / sector compliance baseline | | `platform-admin` | **7 years** | Cross-tenant operations | @@ -470,9 +473,9 @@ Rules: | `create` / `update` / `delete` / `action` (other domains) | **2 years** | Tenant-configurable down to 6 months or up to 7 years | | Recording-policy and consent changes | **7 years** | Lives with the recording itself if longer. **Note:** this is the retention of the *audit-log entry* about a recording policy or consent change — not the retention of the recording **file** itself. Recording files follow the per-tenant retention policy declared in [16-media-pipeline.md § Recordings](../architecture/16-media-pipeline.md) (default 30 days; tenant-configurable up to the platform cap). The two retentions are independent: the audit entry persists for compliance reconstruction even after the recording file is purged. | -A tenant cannot reduce retention below the platform-defined floor for `security-event`, `platform-admin`, or financial entries. Retention is enforced by a daily Hangfire job; deletions are batched, logged, and themselves audited (`security-event`). +A tenant cannot reduce retention below the platform-defined floor for `security-event`, `platform-admin`, or financial entries. Phase 11 owns enforcement by a daily Hangfire job; its deletions must be batched, logged, and themselves audited (`security-event`). -**None of this schedule runs in Phase 02a.** Packet 9 ships `audit_log` with no retention +**This schedule is not implemented today.** Packet 9 ships `audit_log` with no retention column, no purge job and no per-tenant override, and `audit_config` carries classification overrides only. The daily `learnstack:audit:retention-purge` job, the per-tenant override and the partition lifecycle are owned by @@ -480,6 +483,9 @@ and the partition lifecycle are owned by ([ADR-0044 § What we explicitly punted on](../decisions/0044-audit-write-path.md), [ADR-0028 Amendment 2026-09-07](../decisions/0028-audit-log-partition-management.md)). The table above is the policy those deliverables implement, not a description of today. +Rows, including retained authorization metadata, can outlive these targets until +that separately admitted machinery exists. Each row follows its OperationType +and applicable domain policy, not a blanket retention for all MUST rows. ## Required Behaviours @@ -545,7 +551,9 @@ table above is the policy those deliverables implement, not a description of tod "no row". - Fan-out to external sinks rides the outbox and is best-effort; the **local audit row** is not. -- Failed `denied` outcomes are audited even though no state changed. +- Failed `denied` outcomes are audited even though no state changed; the + [trusted authorization evidence contract](#trusted-authorization-evidence) + defines the separate P03-2 event population and pre-success barrier. - Background jobs that mutate MUST-audit resources use the runtime-stamped SystemActor under ADR-0047. A nullable causal human is separate attribution, never execution authority. The authoritative job envelope supplies identity; @@ -618,6 +626,157 @@ table above is the policy those deliverables implement, not a description of tod - The outbox dispatcher attaches the actor and the correlation id to every event it dispatches. +## Trusted authorization evidence + +**Central evidence ownership and pre-success barriers are implemented.** Accepted +[ADR-0076](../decisions/0076-live-scoped-authorization.md) and +[ADR-0077](../decisions/0077-trusted-authorization-evidence.md) add central evidence +ownership without changing ordinary resource classifications or the pipeline order. +The [phase Status block](../roadmap/phase-03-identity-admin.md) owns delivery state. + +### Evidence populations + +| Decided act | Required population | +|---|---| +| HTTP protected-policy rejection under a trusted resolved tenant | One MUST/SecurityEvent `identity.authorization.deny`; no handler dispatch or invented resource attempt. | +| Application/resource capability or ownership rejection | One denial event for that invocation, plus each normally declared resource intent at its existing classification/outcome. | +| HTTP allow, then application/resource deny | Only the rejecting act supplies a denial event; the HTTP allow supplies none. | +| Nested refusal absorbed by an outer success | The inner denial remains `denied` and independently required; the outer capture's Committed flag cannot suppress it. | +| Successful ClaimAndMembership resolution, then conflicting tenant/org header | One MUST/SecurityEvent `tenancy.assertion.reject_claim_context` under the confirmed tenant; no host-budget debit. | +| Successful administrative membership directory read | MUST/ReadSensitive `identity.membership.list` on the ordinary ReadWrite owner, committed before any result disclosure. | + +An Off operation has no resource intent but still owes its denial event; SHOULD, +MAY and tenant override-Off never remove this MUST floor. Plural resource intents +remain plural. The denial event is a separate population, not an ordinary intent +or a promotion of successful operations to MUST. Fixed policy/operation IDs come +from the protected policy catalogue; callers cannot choose slugs, attribution, +tenants or arbitrary subject payloads. Module matrices own the sole operation rows. + +Each policy invocation has its own occurrence. Sharing the same decided reference +between HTTP/application adapters cannot create another event; a genuine nested +act or reevaluation is a new occurrence. Correlation, request and route IDs are +not occurrence identities. The common owner captures/cleans this state, including +Off and absorbed nested refusals; a joiner neither commits nor clears it. + +The common owner retains a private audit-only observation while consuming an +admitted invocation's result. Cancellation revokes execution authority but does +not erase that act's already-decided refusal. The observation binds the exact +request and lexical owner and cannot be reused after that owner closes. Current +invocation, candidate and read-witness authority still require their live checks. + +Earlier credential, unknown-host, malformed assertion and validation refusals +keep their existing contracts. Public anonymous/tenantless refusals invent no +tenant or sentinel. Resolver row 7 retains its host recorder, row 10 remains +unaudited, and refused row 14 remains diagnostic-only. AccountSite success stays +Off; live membership denial attributable to its resolved tenant owes the denial +event. This is not a generic audit of all 401/403/404 responses. + +The claim-context assertion event requires actual successful production-reader +ClaimAndMembership resolution, never a candidate, raw claim or asserted header. +Its fixed source is membership-confirmed header assertion. Capture confirmed +scope/admitted actor and only the fixed tenant/org dimension and mismatch reason; +copy no asserted UUID into audit or diagnostics. Host-backed rejection keeps its +existing separate source/budget. This event is neither refused-row-14 evidence +nor a host-tenant rejection. + +### Durability and occurrence reconciliation + +| Actual enclosing owner/outcome | Barrier and failure posture | +|---|---| +| Otherwise-successful ReadWrite owner | Insert every unresolved denial on the business transaction after domain-event draining and before final event/job commit admission. Failure rolls back business state and yields `503 audit_unavailable`. | +| Otherwise-successful physical ReadOnly or ownerless execution | Acknowledge every unresolved event in its own short evidence transaction before completion/disclosure. Failure yields `503 audit_unavailable`; no INSERT or mode upgrade on the read-only transaction. | +| Actual HTTP/application refusal with no business owner | Attempt standalone before returning; audit failure preserves the decided 403/404. | +| Refusal, exception, cancellation or rollback | Final reconciliation attempts unresolved events standalone and preserves the actual refusal/exception. | +| Unknown business COMMIT acknowledgement | Reconcile the same fixed denial occurrence. Exact event evidence proves only the event, never business COMMIT success. | + +Track pending/written, acknowledged, rolled-back and uncertain state per event and +actual owner generation, independently of normal capture state. An in-transaction +event is acknowledged only by confirmed enclosing commit; standalone acknowledgement +survives a later outer failure. The barrier preserves the existing no-await gap +between final event/job commit admission and physical completion. It is never a +finally-only write after an otherwise-successful business commit. + +The central owner mints one opaque reference containing an `AuditEntryId`, trusted +UTC decision timestamp normalized once to PostgreSQL microseconds, and frozen +complete draft: scope, actor, policy/subject/reason and bounded provenance. +All admitted writes/reconciliation of that occurrence reuse the same `(id,timestamp)` +and fields. Serialize reentry with the same reference. New HTTP requests, event/job +executions and real nested evaluations receive new references; no restart ledger +or persistent replay identity is introduced. + +Confined denial methods on existing `IAuditStore`/`PostgresAuditStore` reuse the +single INSERT/binder, catalogue and failure signals. They do not use generic +`WriteStandaloneAsync`'s broad 23505 acceptance. Only a duplicate of +`audit_log_pkey` for the expected `(id,timestamp)` permits verification. Dispose +the failed INSERT transaction first; a clean physical READ ONLY transaction under +captured tenant/org RLS compares every persisted draft field, including the fixed +event discriminator/outcome, using null-safe and typed JSONB equality. A missing, +mismatched, foreign-scope or unreadable row fails. No new SELECT grant, global +function, sentinel or bypass is admitted. Ordinary plural resource reconciliation +keeps its different-timestamp semantics. Later privileged redaction can make an +exact match fail; this is no indefinitely replayable original-row witness. + +An acknowledged ReadWrite commit carries its denial even if the process exits +immediately afterward. ReadOnly/ownerless success waits for standalone acknowledgement. +A crash before any applicable write commits can lose an uncompleted in-memory +refusal. Already-refused audit-outage paths retain their explicit evidence-loss +posture; no durable recovery queue or universal crash-proof collection is claimed. + +The [Standard 05 source ledger](05-database.md#authorization-and-evidence-source-ledger) +owns the independent four-slot authorization/two-slot evidence sources, retained +cleanup, shared five-second evidence I/O deadline, tracked job lanes and downstream +reservation/progress proofs. Evidence saturation is audit-unavailable, never event +suppression or permission to report outer success. No new aggregate suppression +budget applies. At 600 denied acts/minute one subject can generate 864,000 denial +rows/day plus resource attempts; an illustrative 1,000 bytes/row is about 864 MB/day +before indexes/WAL/replication. This is a cost illustration, not production sizing. +Phase 11 owns measured capacity, distributed admission and alert/runbook hardening; +reducing coverage or retention requires reopening the decision. + +### Immutable trusted provenance + +Trusted HTTP, seed, event and job adapters supply a typed immutable snapshot; +handlers cannot write or replace arbitrary audit context. The central composer +merges source provenance with each invocation's exact policy/subject facts before +INSERT. An early frame may declare intents before authorization; central ownership +attaches evidence when that act's decision exists. No enrichment UPDATE or second +Identity audit table is admitted. + +- Effective actor comes from verified binding or the finite trusted source owner; + event/job IDs and causal human remain separate from effective actor. A SystemActor + UUID or provider claim grants no authority or privileged flag. + A finite seed owner's known absence of a principal remains null; provenance + does not invent SystemActor or a human actor for it. +- At most two contributing Membership IDs and eight contributing built-in Role IDs + come only from the live decision, sorted canonically. Excess or ambiguous evidence + fails closed; never silently truncate an authority set. +- A typed versioned `metadata.authorization` holds those references, fixed policy/ + route identifiers and source kind. No grant graph, body, query string, raw path, + provider claim or credential belongs there. An inner operation retains its own + policy/subject; child scopes cannot inherit another actor's evidence. +- Route is an admitted template/descriptor bounded to 128 characters. Direct + non-HTTP execution invents no route/IP/user agent. The source/caller census names + finite population and cleanup sites; unknown/untrusted sources forge nothing. +- Reuse the admitted visitor/transport snapshot's effective address, with physical + peer fallback, without reparsing forwarded headers. The existing `inet`/user-agent + columns carry these redactable values. User agent is untrusted descriptive data: + remove controls, bound to 256 characters, and never use it for authority or labels. +- ActorEmail stays null until P03-6/G10 or a separately accepted earlier source/ + freshness contract. P03-9 owns actual Platform/operator population. +- Membership/Role references remain personal linkage. Each row retains its own + [retention target](#retention): denial/assertion SecurityEvent seven years; + directory ReadSensitive two years. Automatic purge, retention columns and tenant + retention overrides remain unimplemented until Phase 11, so rows can outlive + targets. Metadata stays outside the six-column redaction grant. P03-10's Audit + participant locates it through retained actor/subject attribution and reports + surviving references under [Data Protection](../architecture/23-data-protection.md#erasure-authority-and-first-writer-prerequisites), + without new metadata UPDATE authority or an anonymisation claim. + +Store failure retains existing Critical/counter/audit-health signals. Independent +composer refusal emits one fixed Critical signal, without raw provider/database +exception details. [Observability](10-observability.md) owns diagnostic rules; +the phase delivery record owns actual fault/crash receipts and review state. + ## Global identity creation **Implemented under ADR-0072 in P03-1 Step 3.** The @@ -725,7 +884,10 @@ Platform admins additionally see cross-tenant entries through a separate route g ## Forbidden -- Writing a MUST-class audit entry **after** the controlling transaction commits, so that a commit failure can lose it. The standalone path is not this: it exists precisely for the cases where no business transaction committed, and it opens its own. +- Writing a MUST-class audit entry **after** its controlling transaction commits. + Standalone writes own their transaction; absorbed denial evidence must satisfy + the [pre-success barrier](#trusted-authorization-evidence) before the enclosing + execution commits or discloses success. - Truncating `before` / `after` / `changes` snapshots silently, or writing an empty object in place of one that was too large. Over `JsonValue.MaxRowBytes` the value becomes the elision record of § Storage, in the column's own JSON type. There is **no** diff --git a/docs/standards/19-permissions.md b/docs/standards/19-permissions.md index 4fdd89f72..da4c74a79 100644 --- a/docs/standards/19-permissions.md +++ b/docs/standards/19-permissions.md @@ -1,6 +1,6 @@ # 19 — Permissions Standards -**Status:** Adopted +**Status:** Active **Derives from:** [11-security.md](11-security.md) § Authorization, [ADR-0017 Tenant + Organization Hierarchy](../decisions/0017-tenant-organization-hierarchy.md), [ADR-0064 Tenant API Authentication Boundary](../decisions/0064-tenant-api-authentication-boundary.md) @@ -10,6 +10,9 @@ Identity foundation definitions, manifests and role ownership derive from Accepted [ADR-0072](../decisions/0072-application-owned-identity-bootstrap.md) and [ADR-0073](../decisions/0073-scoped-membership-and-identity-data-foundation.md). +Live enforcement and evidence additionally derive from Accepted +[ADR-0076](../decisions/0076-live-scoped-authorization.md) and +[ADR-0077](../decisions/0077-trusted-authorization-evidence.md). LearnStack uses permission-based RBAC layered on top of tenant membership. This standard pins the **naming convention**, **closed action set**, **registration pattern**, **enforcement points**, and the **per-module matrix template** that every module must produce. @@ -17,7 +20,8 @@ LearnStack uses permission-based RBAC layered on top of tenant membership. This and confined seed commands are implemented in P03-1 Step 4. Accepted ADR-0073 settles scoped definition identity, tenant-wide templates and the finite initial seed manifests in the [Identity foundation contract](#identity-foundation-contract). -P03-2 owns live enforcement/delegation/adoption and +The [live contract](#live-permission-enforcement-contract) supplies canonical +HTTP/application enforcement, delegation and explicit adoption; P03-9/G13 owns operator admission. Adoption of this standard grants no runtime authority. The closed key/action/scope rules remain binding. @@ -74,26 +78,28 @@ This rule prevents permission sprawl and keeps the matrix template stable. Permissions are identified by **`(PermissionScope, PermissionKey)`** at registration time under ADR-0017/0073. Tenant and Organization can register the same key as distinct definitions. Platform definitions are never assignable to -tenant Roles. Unknown/inactive pairs authorize nothing; Step 4 implements the +tenant Roles. Unknown/inactive pairs authorize nothing; P03-1 Step 4 implements the nonempty foundation registry. | Scope | Examples | Granted to | |-------|----------|------------| | **Platform** | `platform.tenant.write`, `platform.feature_flag.admin`, `platform.audit.read` | Only platform admins / Hub operators; never assignable to a tenant role. | | **Tenant** | `education.course.write`, `enrollment.course_access.read`, `tenancy.organization.write`, `customization.content_type.write` | Tenant roles; permission check requires a `Membership` in the target tenant. | -| **Organization** | `education.course.write` (org-scoped role), `enrollment.enrollment.write` (org-scoped role) | Org-scoped roles; permission check requires `Membership.OrganizationId` matches the resource's `OrganizationId` (or the role is tenant-scoped). | +| **Organization** | `education.course.write` (org-scoped role), `enrollment.enrollment.write` (org-scoped role) | Organization-capability templates assigned to an eligible Membership for the resource's exact organization; Tenant grants are separately evaluated. | -Tenant permissions are only meaningful inside a resolved tenant context. Org-scoped -permissions further require the actor's `OrganizationId` match (or the actor have the -tenant-wide variant of the same role). Platform permissions bypass tenant scope but -require platform-admin authentication and write a `platform-admin` audit entry (see +Tenant permissions are only meaningful inside an admitted tenant scope. +Organization grants require an eligible Membership for that exact organization. +An explicit eligible Tenant grant can independently authorize the declared +operation; same-key pairs never imply one another. Platform admission remains +P03-9 and requires its privileged authentication/audit contract (see [18-audit-coverage.md](18-audit-coverage.md)). Registered architecture obligation `Permission_Scope_Matches_Resource_Scope` requires permissions registered as `Organization`-scope are only checked against `[OrganizationScoped]` resources, and -that `Tenant`-scope permissions never gain `OrganizationId` filtering at runtime -without an explicit `organization-scope` role binding. +that capability identity is never inferred from a storage scope or role name. +The Accepted operation witness below governs tenant-wide reads; a Tenant grant +alone never removes organization filters or widens writes. ## Module-Driven Registration @@ -123,17 +129,22 @@ A permission is **only meaningful** if every code path that could expose the res ### HTTP endpoints -Every endpoint has one of: - -- `[Authorize(Policy = "education.course.write")]` — explicit permission requirement. -- `[Authorize(Policy = "platform.*.")]` — explicit platform-scope requirement. -- `[AllowAnonymous]` with a one-line comment explaining why (public site renderer, health checks, webhook receivers). - -Architecture test fails the build for any endpoint missing one of these attributes. +Every endpoint has an exact classification under the Accepted +[live policy boundary](11-security.md#live-authorization-contract): public, +AccountSite, protected scoped capability or finite trusted operation. +Named ASP.NET requirements agree with application metadata; infrastructure +endpoints are explicitly classified. The runtime missing-classification guard +supplements the endpoint census; authentication alone is no fallback permission. +Application step 5 independently repeats authorization on direct/nested dispatch. +The API and canonical application behavior enforce this contract. ### Background jobs and event handlers -Tenant-scoped jobs and integration-event handlers call `IAuthorizationService.AuthorizeAsync(actor, permission)` before mutating MUST-audit resources. System actors (outbox dispatcher, scheduled jobs) use a stable system identity with explicit permissions; ad-hoc bypass is forbidden. +Trusted seed, event and job sources keep their finite validated source/request +contracts. They do not gain a blanket SystemActor exemption, Platform permission +or authority from a causal actor field. Unknown sources/requests deny. +[Standard 05](05-database.md#authorization-and-evidence-source-ledger) owns their +tracked auxiliary authorization/evidence lanes and actual-cleanup lifetime. ### Resource-scope checks @@ -196,9 +207,10 @@ customization data; they configure role grants by composing the closed set above The historical proposed operator key used `operator` as its action, outside the closed set. It is not a registration exception; G5/G13 choose a conforming key. -`Portal Public` is the anonymous capability model, not a stored membership: its -eventual registry integration preserves the `HostOnly`/PublicSurface ceiling under -ADR-0036 and grants no protected authority. +`Portal Public` is the virtual anonymous capability model, not a stored +Membership. Its Accepted exact definitions are in the +[live contract](#live-permission-enforcement-contract). Its API/application +integration grants no protected authority. ## Testing Requirements @@ -256,11 +268,12 @@ HTTP permission is activated by the foundation. ### Adopted scope and grant boundary The active Scope, Module-Driven Registration and Built-in Roles sections above -adopt this implemented foundation contract. Live enforcement remains P03-2. +adopt this foundation contract. The live boundary below enforces each act. P03-1's five pairs and version-1 grants below are the complete foundation set. P03-2 owns the separately admitted finite live-authorization/grant-adoption -contract; P03-3 owns protected operations and UI/scope transitions. Neither a +contract, including its administrative GET; P03-3 owns remaining protected +HTTP operations and UI/scope transitions. Neither a later operation nor an older built-in-role example silently expands v1 grants. ### Capability scope and template ownership @@ -304,8 +317,8 @@ Finite scope metadata derives from immutable target/validated declarations. The seed profile declaration binds its expected User and single Role assignment; the handler verifies both before and after its tenant-lock wait. Closed seed authority is checked independently before dispatch, default-denied -in API composition. P03-2 qualifies live permission admission before protected -activation; registration/attributes/seed success grant no HTTP route. +in API composition. Live protected admission separately rechecks current +capabilities; registration/attributes/seed success grant no HTTP route. ### Version-1 built-in grant manifest @@ -363,8 +376,9 @@ owns ordering and interrupted-run disposition. P03-1 Step 4 supplies nonempty actual-command registry, templates, reference/ scope guards and kernel artifact. P03-2 owns default deny/delegation, per-act -revocation/freshness, grant adoption and denied controls. P03-3 owns HTTP/new-user -BFF activation, protected UI and explicit scope transitions; +revocation/freshness, grant adoption and denied controls. P03-2 also owns the one +administrative directory GET; P03-3 owns new-user +HTTP/BFF activation, protected UI and explicit scope transitions; P03-9/G13 owns production first-admin/recovery before its production provisioner. The development seed capability is not that future authority. @@ -373,3 +387,57 @@ semantic property census and foundation/manifests/scope controls with actual clean/dirty subjects. Empty censuses, stored Roles and diagrams alone are not implementation proof. Current qualification state lives in the [phase Status block](../roadmap/phase-03-identity-admin.md). + +## Live permission enforcement contract + +**Frozen definitions, live evaluation and explicit grant adoption are implemented.** +ADR-0076 replaces the live +admission/delegation deferrals above while retaining P03-1's exact five assignable +pairs, eight templates, two-pair admin kernel and fixed v1 seed manifest. +Registration remains distinct from grants; no v2 manifest or new assignable +capability is selected. Current implementation status remains in the +[phase Status block](../roadmap/phase-03-identity-admin.md). + +| Virtual Tenant definition | Existing PublicSurface requests | +|---|---| +| `tenancy.public_site.read` | `GetPublicSiteQuery` | +| `education.public_course.read` | `GetPublicCoursesQuery`, `GetPublicCourseQuery` | +| `education.public_lesson.read` | `GetPublicLessonQuery` | + +These three Portal Public definitions are unavailable as stored Role grants. +They create no Membership/template and widen neither publication, HostScope, +tenant/org scope nor physical READ ONLY bounds. Credential-free public requests +perform no identity/grant lookup; these definitions do not expand the assignable +foundation manifest. + +Every protected act uses fresh committed Membership/lifecycle/grant admission +under [Security](11-security.md#live-authorization-contract). Eligible NULL-org +Tenant grants compose with the exact requested branch's eligible Organization +grants; coverage alone supplies no capability. Resolver, HTTP and earlier nested +results are not reusable grants. Tenant-wide read widening requires the +[operation witness](05-database.md#operation-bound-read-authority), never an +Organization grant or implicit clearing of authenticated branch write scope. + +`ChangeRoleGrantsCommand` and `AdoptRoleGrantManifestCommand` are separate +production application acts, each with one Role root and expected Role row version. +Both require current Tenant Admin/role-admin authority, orgless write scope, +concurrency/kernel guards and a current delegation ceiling, rechecked after the +invariant-lock wait before mutation. They cannot grant authority the actor lacks. +An eligible Tenant Admin holding Tenant `identity.role.admin` may author an exact +registered Organization pair only while holding the same key's Tenant pair. +That is an explicit authoring relation, not scoped-key equivalence or evaluation +fallback. Other-key, Organization-to-Tenant and unknown-pair implications refuse. + +Grant edits and finite approved release-manifest adoption have separate MUST +`identity.role.change_grants` / `identity.role.adopt_manifest` audit transactions, +including contained grant changes. Adoption accepts only the approved finite +manifest/diff; P03-2 proves retained v1, unknown manifests refuse, and registration +or rerunning seed never upgrades grants. Legitimate differing adopted state makes +fixed-v1 seed refuse instead of reset. Human-facing edit/adoption routes remain +P03-3; arbitrary roles, wildcard grants and Platform admission are not added. + +Actual denial evidence and successful sensitive disclosure obey +[Standard 18](18-audit-coverage.md#trusted-authorization-evidence), independently +of an ordinary operation's Off/SHOULD/MAY classification. Real HTTP, direct, +post-lock revocation, scoped SQL/EF and finite-source dirty controls must qualify +this contract before activation; acceptance alone advances no runtime proof. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 6c6808fb7..9c22db094 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -62,6 +62,9 @@ Identity foundation registration additionally derives from Accepted [ADR-0072](../decisions/0072-application-owned-identity-bootstrap.md), [ADR-0073](../decisions/0073-scoped-membership-and-identity-data-foundation.md) and [ADR-0074](../decisions/0074-membership-admission-under-ordered-locks.md). +Accepted live-authorization extensions additionally derive from +[ADR-0076](../decisions/0076-live-scoped-authorization.md) and +[ADR-0077](../decisions/0077-trusted-authorization-evidence.md). The implemented Identity obligations and extensions name their actual proof owners below; the [phase Status block](../roadmap/phase-03-identity-admin.md) owns current qualification and review state. @@ -157,7 +160,7 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -**303 test methods run in +**320 test methods run 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, Packet 6, Packet 7, @@ -198,8 +201,8 @@ two fifths of its subject is the defect this section is about. It also refuses a test class that exists nowhere, because otherwise a renamed or deleted file drops its entries out of the subject instead of failing. -**244 rules in this catalogue are Implemented, and 140 of them are in that assembly.** -The other 104 are no less binding, and most could not live there. The table says where +**251 rules in this catalogue are Implemented, and 146 of them are in that assembly.** +The other 105 are no less binding, and most could not live there. The table says where and why, and deliberately carries no per-row count: those are the numbers nothing recomputes, and the first version of this table claimed "three rules" for a suite that holds ten. @@ -1816,12 +1819,13 @@ catalogue as the carrier of their status — so all three are Packet 10's. 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. Under `backend/src`, the configuration/construction scan - admits exactly **eleven** files: six design-time factories, one per migration + admits exactly **thirteen** files: six design-time factories, one per migration chain; `ModuleDbContextRegistration`, which passes a *connection*; `PersistenceCompositionExtensions`, with the guarded platform-role builder; `ApplicationDataSource`, shared by the API and one-shot Seeder; ADR-0046's confined `OutboxDispatcherDataSource`; and ADR-0072's deployment-only - `LearnStack.Tools.IdentityAuthorityInstaller/Program.cs`. Another construction + `LearnStack.Tools.IdentityAuthorityInstaller/Program.cs`; and ADR-0076/0077's + private authorization and evidence source builders. Another construction site requires its owning decision and an explicit allow-list change. A context on its own connection never saw the announcement, so every read through it returns zero rows under the corrected policy — silently. @@ -1831,13 +1835,20 @@ catalogue as the carrier of their status — so all three are Packet 10's. scan covers `UseNpgsql`, `AddDbContext`, data-source builders/factories and raw connection constructors. Connection acquisition from an already configured source remains governed by its named owner contract. + The complete compiled EF transaction-enrollment set contains the common + module factory, the existing global binding store and the exact contextual + Identity reenrollment helper. That helper requires a clean tracker, the same + physical connection and a current matching tenant announcement outside global + mode; it sets no scope and creates no connection. Missing/extra enrollment, + executable-alias and removed-guard controls fail. - **Source:** ADR-0040; ADR-0046's confined dispatcher; ADR-0072's deployment authority installer; [05-database.md § Forbidden](05-database.md). -- **Type:** xUnit + DI registration inspection and a source scan. **Kind:** structural. +- **Type:** xUnit + DI registration inspection, compiled caller census and source guards. **Kind:** structural. - **Status:** **Implemented** (Packet 6 step 6; the allow-list widened to five and keyed by directory in Packet 7 step 10, to six in Packet 8 step 3 and to seven in Packet 9 step 3, eight in P02d-1 step 2 and eleven with the ADR-0046/0072 - construction sites, `LearnStack.Tests.Architecture`, `PersistenceConventionTests`). + construction sites, and thirteen with the independent authorization/evidence + sources; `LearnStack.Tests.Architecture`, `PersistenceConventionTests`). - **Phase:** 02a (Packet 6). #### `The_registration_marker_does_not_vouch_across_containers` @@ -2676,10 +2687,182 @@ Implemented requires nonempty production subjects and dirty/clean controls. [ADR-0073](../decisions/0073-scoped-membership-and-identity-data-foundation.md); [Permissions Standards](19-permissions.md#identity-foundation-contract). - **Type:** compiled definitions/consumers with planted controls. **Kind:** structural. -- **Test owner:** `IdentityFoundationConventionTests`. +- **Test owners:** `IdentityFoundationConventionTests`, `OperationPermissionConventionTests`. - **Status:** **Implemented** (`IdentityFoundationConventionTests`) — the actual five-pair registry, command metadata, generated artifacts and planted definition controls are exercised. -- **Phase:** P03-1 Step 4. +- **Implemented P03-2 metadata extension:** ADR-0076 composes + actual module definition sources and three nonassignable virtual-public pairs; + the five-pair assignable manifest remains intact. Complete metadata/consumer + controls exercise actual module sources and the complete production request + universe, with missing/extra compiled request/source controls and conflicting + runtime registration cases. The + [Phase 03 Status block](../roadmap/phase-03-identity-admin.md) + owns source-bound policy/evidence qualification. +- **Phase:** P03-1 Step 4; P03-2 Step 3 extension. + +### Accepted live-authorization obligations + +Accepted ADR-0076/0077 govern the following implemented controls. Each entry +names its actual proof owners; the +[Phase 03 Status block](../roadmap/phase-03-identity-admin.md) +and delivery records own source-bound qualification. Acceptance alone never +promotes an unrun proof. + +#### `Live_Authorization_Uses_Bounded_Independent_Owners` + +- **Asserts:** actual verified candidate checks use the confined app-role READ + ONLY owner, exact actor/tenant/org and generation, four-slot source and tracked + job authorization lane. Every business connector can remain held while admitted + nested/post-lock checks complete. Actual transaction-pooled backend/PID clean + controls and a shared-backend dirty topology prove independent capacity; + parent uncommitted grants never supply authority. Saturation, cancellation, + delayed/failed cleanup, shutdown and pool reuse preserve retained ownership. +- **Startup qualification:** exact API/Seeder callers bind the auxiliary lanes to + the live business witness under [Standard 05](05-database.md#authorization-and-evidence-source-ledger); + closed factory/binder callers and bound-overload removal/extra-call controls + complement actual same-cluster/PgBouncer positives and foreign-cluster/expiry negatives. +- **Source:** [ADR-0076](../decisions/0076-live-scoped-authorization.md); + [source ledger](05-database.md#authorization-and-evidence-source-ledger). +- **Type:** source/caller/role census plus real app-role PostgreSQL/PgBouncer + progress and lifetime controls. **Kind:** behavioural and structural. +- **Test owners:** `IdentityAuthorizationReaderTests`, + `AuthorizationReadSourceTests`, `AuthorizationPoolProgressTests`, + `IdentityAuthorizationConventionTests`, `TrustedOperationSourceConventionTests`, + `OperationPolicyOwnershipTests`, `IdentityAuditConventionTests`. +- **Status:** **Implemented** — `IdentityAuditConventionTests`; source/caller controls + complement the real PostgreSQL/PgBouncer owners above. +- **Coverage:** the confined reader/source, actual PostgreSQL/ + PgBouncer policy progress, typed HTTP candidates and finite source ownership + have actual controls. The live membership adapter, HTTP candidate source and + private deferred evidence factory join the exact production caller/DI census. +- **Phase:** 03 (P03-2). + +#### `Protected_Operations_Require_Explicit_Live_Policies` + +- **Asserts:** complete real HTTP/request/source composition explicitly classifies + public, AccountSite, permission-bound and confined seed/system operations. + The unchanged canonical eight-step pipeline admits application requests through + `AuthorizationBehavior`. The HTTP census contains exactly eight public GET/HEAD + actions, AccountSite GET and `GET /api/v1/identity/memberships`; exact named + policies and the default/fallback refusal guard own the two authenticated reads. + Live reader/evaluator/current-context/evidence aliases and qualification startup + remain explicit, with no alternate pass-through behavior. + Unclassified or underprivileged requests cannot dispatch at HTTP or direct + application entry. Fresh live Membership/capability, denial/resource ordering, + committed revocation, same-key scoped delegation, finite manifest adoption and + retained public/AccountSite ceilings have useful production positive/denied + controls. Ambient/SystemActor or realm-role values alone supply no exemption. +- **Source:** [ADR-0076](../decisions/0076-live-scoped-authorization.md); + [Permissions](19-permissions.md#live-permission-enforcement-contract). +- **Type:** complete compiled metadata/caller census, real direct dispatch and + administrative-directory HTTP/database controls. **Kind:** behavioural and structural. +- **Test owners:** `IdentityAuthorizationConventionTests`, `OperationPermissionConventionTests`, + `OperationPolicyOwnershipTests`, `OperationHttpClassificationTests`, + `OperationAuthorizationCoordinatorTests`, `IdentityOperationAuthorizerTests`, + `RoleGrantCommandTests`, `RoleGrantCommandIntegrationTests`, + `IdentityAuthorizationHttpTests`, `UndeclaredHttpOperationTests`. +- **Status:** **Implemented** — `OperationPolicyOwnershipTests`; actual composition and + caller controls complement the direct/HTTP proofs above. +- **Phase:** 03 (P03-2). + +#### `Authorization_Denials_Have_PreSuccess_Durability_And_Stable_Identity` + +- **Asserts:** one central occurrence with fixed id/microsecond decision time + and complete frozen draft is distinct from plural ordinary resource intents. + ReadWrite outer success commits unresolved events atomically; ReadOnly or + ownerless success requires standalone acknowledgement first. Per-event state, + rollback/unknown COMMIT, expected-key full scoped duplicate matching, real + nested acts versus re-entry, Off/SHOULD/MAY/override-Off and process-crash controls + preserve the precise guarantee without claiming a persistent replay ledger. + The independent two-slot app-role evidence lane retains actual cleanup and + progresses while business owners remain held; audit fault preserves a real + refusal but prevents otherwise-successful outer commit/disclosure. +- **Source:** [ADR-0077](../decisions/0077-trusted-authorization-evidence.md); + [Audit Coverage](18-audit-coverage.md#trusted-authorization-evidence). +- **Type:** common-owner/source census plus real PostgreSQL/HTTP, process crash, + uncertainty, capacity and cleanup controls. **Kind:** behavioural and structural. +- **Test owners:** `AuthorizationEvidenceCoordinatorTests`, `AuthorizationEvidenceStoreTests`, + `AuthorizationEvidenceSourceTests`, `AuthorizationEvidenceBarrierTests`, + `AuthorizationPoolProgressTests`, `IdentityAuditConventionTests`. +- **Status:** **Implemented** — `AuthorizationEvidenceBarrierTests`; real transaction, + crash and reconciliation controls complement the source/lifetime owners above. +- **Phase:** 03 (P03-2). + +#### `Authorization_Evidence_Uses_Trusted_Bounded_Provenance` + +- **Asserts:** frozen actor/Membership/Role/source/route and admitted address/UA + facts come only from enumerated trusted owners, never arbitrary handler + dictionaries, claims or forwarded input. Direct/nested/job scopes preserve + ownership and clearing. Exact successful claim-only header refusals use their + Membership-confirmed tenant and separate per-occurrence event without a host + budget debit. Typed sink canaries, source/caller dirty controls and per-row + retention/redaction declarations preserve personal linkage honestly. +- **Source:** [ADR-0077](../decisions/0077-trusted-authorization-evidence.md); + [Audit Coverage](18-audit-coverage.md#trusted-authorization-evidence). +- **Type:** real source/HTTP/record composition and diagnostic sink controls. + **Kind:** behavioural and structural. +- **Test owners:** `AuthorizationEvidenceCoordinatorTests`, + `IdentityDiagnosticSinkTests`, `AuthorizationEvidenceApiSourceTests`, + `IdentityAuthorizationHttpTests`, `TenantAuthenticationRejectionAuditTests`, + `IdentityAuditConventionTests`. +- **Status:** **Implemented** — `IdentityAuditConventionTests`; closed provenance + controls complement the real source/HTTP/evidence owners above. +- **Phase:** 03 (P03-2). + +#### `Permission_Scope_Matches_Resource_Scope` + +- **Asserts:** exact registered scoped pairs match admitted target facts; + Organization grants apply only through the matching branch Membership. + Tenant grants alone remove no filter or write scope. The declared operation + witness alone permits accepted tenant-wide reads; independent HostScope and + organization write guards survive. Missing/forged/other-branch target metadata + and prohibited scope implications refuse. +- **Source:** [ADR-0076](../decisions/0076-live-scoped-authorization.md); + [Permissions](19-permissions.md#live-permission-enforcement-contract). +- **Type:** actual registry/consumer metadata plus scoped direct/HTTP controls. + **Kind:** structural and behavioural. +- **Test owners:** `IdentityAuthorizationConventionTests`, `OperationPermissionConventionTests`, + `OperationReadScopeTests`, `MembershipDirectoryIntegrationTests`, + `IdentityOperationAuthorizerTests`. +- **Status:** **Implemented** — `OperationPermissionConventionTests`; actual policy + metadata and scoped direct/HTTP controls preserve read/write boundaries. +- **Phase:** 03 (P03-2). + +#### `Permission_Keys_Match_Convention` + +- **Asserts:** every actual module definition has the closed three-segment key, + module/resource/action grammar and explicit scope. Missing/inactive/duplicate + pairs and invalid action/name controls fail; virtual-public pairs are separate + from assignable grants and cannot expand the retained v1 manifest. +- **Source:** [ADR-0076](../decisions/0076-live-scoped-authorization.md); + [Permissions](19-permissions.md). +- **Type:** complete nonempty compiled source/consumer census with dirty controls. + **Kind:** structural. +- **Test owners:** `IdentityAuthorizationConventionTests`, `OperationPermissionConventionTests`, + `OperationPolicyCatalogTests`. +- **Status:** **Implemented** — `OperationPermissionConventionTests`; actual nonempty + registry and invalid-key controls enforce the closed grammar. +- **Phase:** 03 (P03-2). + +#### `Permission_Registry_Has_DeniedTest` + +- **Asserts:** each actual scoped pair has meaningful actual production-consumer + denial proof. Virtual-public and confined seed/system classes have their own + explicit negative controls; an empty scan, matching test name or test-only + operation cannot establish coverage. Missing-pair and removed-test controls + fail the complete cross-reference. +- **Source:** [ADR-0076](../decisions/0076-live-scoped-authorization.md); + [Permissions](19-permissions.md). +- **Type:** registry/production-consumer proof map with removed/missing controls. + **Kind:** structural with behavioural subjects. +- **Test owners:** `OperationPermissionConventionTests`, + `RoleGrantCommandIntegrationTests`, `IdentityAuthorizationHttpTests`, + `SeedOperationSourceTests`, + `DeliveryOperationSourceTests`, `TrustedJobOperationSourceTests`. +- **Status:** **Implemented** — `OperationPermissionConventionTests`; the complete + scoped-pair map binds real delegation positives/denials and distinct virtual/finite + controls. +- **Phase:** 03 (P03-2). @@ -2755,16 +2938,17 @@ which decides identity, multiplicity, capture and classification; carrying the `denied` outcome and zero business rows, and keeps its own `403` — the 503 is for a write that would otherwise have **succeeded** ([ADR-0033 Amendment 1](../decisions/0033-audit-durability-model.md)). -- **The `denied` clause lands with Phase 03, and the rest lands in Packet 9.** Nothing in - the Packet 9 pipeline can produce a `403`: `AuthorizationBehavior` is still the Packet 3 - pass-through, because the permission registry arrives with Identity in - [Phase 03](../roadmap/phase-03-identity-admin.md). A case written now could only reach - the `denied` outcome by returning `Result.Fail(forbidden)` from a **handler** — which - produces the right row and the right standalone path, and proves nothing about the - authorization step the clause is about. Splitting the rule's implementation is the - honest answer: Packet 9 implements the first two clauses against the real pipeline, and - the `denied` clause is written the day step 5 can refuse. Recording it here is what - stops a later reader taking the whole rule as satisfied. +- **Implemented P03-2 extension.** ADR-0077 adds one central denial occurrence + beside the separately classified resource intents. Real policy refusals count + both populations exactly; `AuthorizationEvidenceBarrierTests` covers absorbed + denials, ReadWrite/ReadOnly/ownerless pre-success barriers, fixed-key + reconciliation and fault/crash outcomes under + [Audit Coverage](18-audit-coverage.md#trusted-authorization-evidence). + These controls extend Packet 9's success/rollback baseline. A handler-returned + `Result.Fail(forbidden)` alone proves neither policy admission nor the separate + event's pre-success barrier. The + [Phase 03 Status block](../roadmap/phase-03-identity-admin.md) + owns source-bound qualification. - **One row per intent, not one per request.** `IAuditStateCapture` holds an ordered list of intents, one per audited `(resource, operation)`, and only the **owning** unit-of-work frame (`IUnitOfWorkScope.IsOwner`) flushes it — draining every intent in @@ -4105,9 +4289,6 @@ implements it. | `Cert_PrivateKey_NeverLeavesVault_To_Logs` | The log redaction filter strips a PEM private-key block before a line is emitted, in every deployment mode | behavioural | [Phase 02c](../roadmap/phase-02c-hub-foundation.md) on the Hub side, per ADR-0022, which applies it in every mode; on the LearnStack side [Phase 11](../roadmap/phase-11-production-hardening.md), with the TLS automation — the first LearnStack code that handles certificate material, and a Self-Hosted air-gapped deployment has no Hub | ADR-0022; [Custom Domain TLS](../architecture/27-custom-domain-tls.md) | | `Frontend_Has_Only_The_OperatorPortal_App` | The Hub repository ships exactly one frontend application, the operator portal | structural | [Phase 02c](../roadmap/phase-02c-hub-foundation.md), owned and run by the `learnstack-hub` repository | [ADR-0019](../decisions/0019-learnstack-hub.md) | | `Customization_Reference_Resolution_Is_Batched` | Resolving an entry's references costs a small constant number of queries, not one per reference | behavioural | [Phase 05](../roadmap/phase-05-education-learning-content.md), with the batched reference walk — Phase 02d's lesson body carries its fields inline and resolves no reference | [Tenant Customization Model](../architecture/32-tenant-customization-model.md); [Phase 05](../roadmap/phase-05-education-learning-content.md) | -| `Permission_Scope_Matches_Resource_Scope` | An `Organization`-scope permission is checked only against an `[OrganizationScoped]` resource, and a `Tenant`-scope permission never gains organization filtering | structural | [Phase 03](../roadmap/phase-03-identity-admin.md) | [Permissions](19-permissions.md); `add-permission` | -| `Permission_Keys_Match_Convention` | Every registered key parses as `{module}.{resource}.{action}` with an action from the closed set | structural | [Phase 03](../roadmap/phase-03-identity-admin.md) | [Permissions](19-permissions.md); `add-permission` | -| `Permission_Registry_Has_DeniedTest` | Every registered key has at least one test that is denied it | structural | [Phase 03](../roadmap/phase-03-identity-admin.md) | [Permissions](19-permissions.md); `add-permission` | | `Search_Kinds_AreNot_Domain_Prefixed_In_Code` | No module registers a domain-prefixed search kind; every domain-shaped index arrives through a `TenantContentType` | structural | [Phase 04](../roadmap/phase-04-cms-media-pages.md), with `ITenantSearch` | [Search](../architecture/20-search.md) | | `Block_Schemas_Are_Immutable_After_Publish` | A published page-block schema version is never edited in place; a breaking change ships a new version | behavioural | [Phase 04](../roadmap/phase-04-cms-media-pages.md), with `TenantPageBlock` | [ADR-0013](../decisions/0013-page-block-schema-versioning.md); `add-page-block` | | `Scoring_Rules_Compile_Against_Sandbox` | Every `TenantScoringRule` expression compiles inside the DSL sandbox and nowhere else | behavioural | [Phase 05](../roadmap/phase-05-education-learning-content.md), with the evaluator | [Phase 05](../roadmap/phase-05-education-learning-content.md); `add-tenant-scoring-rule` | @@ -5000,14 +5181,18 @@ structural test proves — and what it does not. #### `Authenticated_Http_Surface_Is_Enumerated_And_ReadOnly` - **Asserts:** the nonempty complete production route/method/transitive-request - census contains only the eight existing public GET/HEAD actions and exact - AccountSite GET, with enumerated infrastructure exclusions. Extra controller, + census contains only the eight existing public GET/HEAD actions, exact + AccountSite GET and `GET /api/v1/identity/memberships`, with enumerated + infrastructure exclusions. Extra controller, minimal-route, mutation and unregistered/nested dispatch controls fail. `GetAccountSiteQuery` independently requires valid HostAndClaim actor and matching real host provenance before any reader, rejecting future ClaimAndMembership too. It reads actual host-scoped public data as `learnstack_app`, is explicitly Off with zero normal query audit rows, and uses the existing exact-type physical ReadOnly owner: a planted SQL write is refused. + Both authenticated actions require their exact named canonical policies. The + administrative directory remains an ordinary ReadWrite operation with a MUST + ReadSensitive Membership intent; its result requires the audit commit. No-store covers early errors; served OpenAPI and disjoint exhaustive public/authenticated SDK GET ownership agree without a public AccountSite wrapper. The real per-action MVC filter descriptors preserve the exact framework, @@ -5027,8 +5212,9 @@ structural test proves — and what it does not. `backend/tests/LearnStack.Tests.Architecture/AuthenticatedHttpSurfaceTests.cs` and `backend/tests/LearnStack.Tests.Integration/Database/AccountSiteHttpTests.cs`; SDK/OpenAPI contract controls remain with their actual contract owners. - The architecture owner now checks the nine real production actions, exact - request construction, Off classification, controller/helper persistence and + The architecture owner checks the ten real production actions, exact + request construction, existing Off declarations and the directory's exact MUST + declaration, controller/helper persistence and handler/helper nested dispatch across every production assembly, including direct interface/concrete handler construction, fields, constructor parameters, locals and `Handle` calls, custom-interface implementations and generic service @@ -5046,6 +5232,15 @@ structural test proves — and what it does not. cross-contract local proof ships in Step 4; Step 5 owns full hosted qualification. - **Status:** **Implemented** (`AuthenticatedHttpSurfaceTests`, `AccountSiteHttpTests`, `AccountSiteQueryTests`, `OpenApiContractTests`). +- **Implemented P03-2 extension:** ADR-0076 adds only the administrative + directory GET and explicit policy metadata/classes. AccountSite/public reads + retain their exact physical READ ONLY contracts. The directory uses an ordinary + ReadWrite owner for MUST sensitive-read evidence; the canonical rule name does + not imply every authenticated GET is physically READ ONLY. Exact route/policy/DI + controls, live adapters and real classified/unclassified/underprivileged cases + complement the canonical application gate. The + [Phase 03 Status block](../roadmap/phase-03-identity-admin.md) + owns source-bound qualification. - **Phase:** P02b-5 Step 2 query/database boundary; Step 4 route/contract activation; Step 5 complete transitive enforcement. @@ -5076,6 +5271,11 @@ structural test proves — and what it does not. role/claim, header-reparse, derived-attribute, formatting, deconstruction and forged/clone probes use the same detectors; weakened predicates, missing real subjects and same-owner additional writes fail independently. + The HTTP classifier's single `IAuthorizeData.Roles` read is confined to an + immediate null/empty check that refuses nonempty metadata. Removed/reversed + rejection controls preserve the raw-role ban. The two HTTP policy adapters may + forward only the actual same-request endpoint; extra identity formatting still + fails inside those approved callers. - **Status:** **Implemented** (`AuthenticationAuthorityTests`). - **Phase:** P02b-5 Step 1 profile constraints; Step 5 complete production census. @@ -5279,23 +5479,34 @@ structural test proves — and what it does not. #### `Tenant_Scope_Widening_Is_Never_Set_From_Request_Input` -- **Asserts:** `app.scope = 'tenant'` is derived from the actor's role plus a declared tenant-wide operation, never from a header, query parameter, cookie or body, and is unreachable under `TenantContextOrigin.HostOnly`. Until Phase 03 derives it from a role, that holds as its strongest form: no production code sets the variable — no `set_config('app.scope'`, no `SET [LOCAL|SESSION] app.scope` — outside an exact-path list, empty until the Phase 03 setter adds its own path, and `ITenantContext` has no app.scope authority member for request input to reach. ADR-0052's immutable HostScope only narrows public reads. -- **Source:** ADR-0036 § The reconciliation matrix. -- **Type:** xUnit + source scan + reflection. **Kind:** structural. -- **Status:** **Implemented** — `TenancyConventionTests.cs`, Packet 10. It asserts its - premise first — a row-security policy does read `app.scope`, so the variable guards - something — and its companion, `The_Scope_Setter_Scan_Can_Actually_Fail`, feeds the - pattern every setter spelling and a policy's read, which it must not flag. - Mutation-checked: a planted `set_config('app.scope', …)` in `FeatureFlags` fails it. -- **Phase:** 02a (Packet 7 registers it; Packet 10 implements it). -- **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. +- **Asserts:** `app.scope = 'tenant'` requires eligible Tenant capability and an + explicitly declared tenant-wide read. Its immutable operation witness binds + the actor, tenant/org, invocation and physical owner generation; request input, + HostOnly and branch grants cannot mint it. SQL/EF use the same read authority; + nested mismatch and pooled cleanup preserve its lifetime. Restrictive + organization writes never widen, and `ITenantContext` has no writable scope + authority. ADR-0052's immutable HostScope only narrows public reads. +- **Implemented P03-2 extension:** ADR-0076's witness-consuming announcement, + mint/consume caller census and real SQL/EF/read-lifetime controls replace the + earlier no-carrier premise. The + [operation-bound read contract](05-database.md#operation-bound-read-authority) + owns the canonical behavior. +- **Source:** ADR-0036 § The reconciliation matrix; + [ADR-0076](../decisions/0076-live-scoped-authorization.md). +- **Type:** compiled/source/reflection controls plus actual app-role SQL/EF tests. + **Kind:** structural and behavioural. +- **Test owners:** `TenancyConventionTests`, `OperationPolicyOwnershipTests`, + `OperationReadScopeTests`, `MembershipDirectoryIntegrationTests`. +- **Status:** **Implemented** — `TenancyConventionTests`; the exact announcement + scan and planted setter controls complement the witness and real scoped-read + owners above. `The_Scope_Setter_Scan_Can_Actually_Fail` retains its dirty controls. +- **Phase:** 02a (Packet 7 registers it; Packet 10 implements the negative fence); + 03 (P03-2 implements the operation-bound carrier). +- **Note:** Packet 7 shipped without an `app.scope` carrier. ADR-0076 now supplies + the operation-bound witness while `ITenantContext` remains without writable + scope authority. The + [Phase 03 Status block](../roadmap/phase-03-identity-admin.md) + owns source-bound qualification. #### `The_Platform_Scope_Writes_No_Tenant_Context_And_Sets_No_Session_Variable` @@ -5325,9 +5536,10 @@ structural test proves — and what it does not. 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()`. API authentication is delivered under ADR-0064; the - Platform-scope permission remains Identity work in + *Subject, vacuous:* there is no Platform permission to hold. The canonical + `AuthorizationBehavior` uses explicit live policies, while the retained + assignable manifest contains only Tenant/Organization pairs. API authentication + is delivered under ADR-0064; the Platform-scope permission remains Identity work 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 gives the scope its first real work and inherits the gate: @@ -5440,13 +5652,15 @@ of structural detection. - **Asserts:** production composition and committed snapshot have the same document after object-key normalization, preserving array order; exactly eight public operations and four public GET wrappers exist, alongside the separate - authenticated AccountSite GET contract (nine operations and five GET contracts). Drift and policy-breaking + authenticated AccountSite and membership-directory GET contracts (ten operations + and six GET contracts). The four public wrappers and AccountSite-only native + authenticated transport remain closed. Drift and policy-breaking fixtures fail the pinned diff/generation checks. -- **Source:** ADR-0024; ADR-0052; accepted P02d-4 G31. +- **Source:** ADR-0024; ADR-0052; ADR-0076; accepted P02d-4 G31. - **Type:** xUnit + served OpenAPI + SDK/CLI fixtures. **Kind:** behavioural + structural. -- **Status:** **Implemented** (`OpenApiContractTests.PublicSurface_Contract_Matches_Served_OpenApi`, P02d-4 Step 4); nine-operation, disjoint public/authenticated GET ownership, missing-operation/parameter/status and array-drift controls; generated SDK coverage and pinned CLI fixtures accompany it. -- **Phase:** 02d (P02d-4 Step 4). +- **Status:** **Implemented** (`OpenApiContractTests.PublicSurface_Contract_Matches_Served_OpenApi`, P02d-4 Step 4; P03-2 Step 5 extends the contract union); ten-operation, disjoint public/authenticated GET ownership, directory projection/403/pagination, missing-operation/parameter/status and array-drift controls; generated SDK coverage and pinned CLI fixtures accompany it. +- **Phase:** 02d (P02d-4 Step 4); 03 (P03-2 Step 5 contract extension). ## P02d-5 public server rendering controls diff --git a/docs/standards/README.md b/docs/standards/README.md index 0f6c8be17..cd9a41f68 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -122,7 +122,7 @@ included. | 16 | [Accessibility](16-accessibility.md) | **Active** | P6 delivers catalog/course/lesson and controlled-state semantics, all 31 active recommended jsx-a11y rules at error severity with per-rule actual-config dirty/clean controls, and real product HTML/Flight proofs. The [packet closeout](../roadmap/phase-02d-delivery-log.md#p02d-6-packet-closeout-2026-10-10) records keyboard/focus/reflow/contrast observations and the maintainer's passing VoiceOver smoke. This is enforcement for delivered public-page scope, not a full WCAG audit. P02d-7 adds hard Lighthouse automated accessibility and a narrow library browser walkthrough; full Playwright Test/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). | | 17 | [Code Review](17-code-review.md) | **Active** | Applied to every pull request merged so far; the zero-tolerance blocker list is in live use. | | 18 | [Audit Coverage](18-audit-coverage.md) | **Active** | Packet 9 lit the write path under [ADR-0033](../decisions/0033-audit-durability-model.md) and [ADR-0044](../decisions/0044-audit-write-path.md): classification at step 3, MUST rows on the business transaction before `COMMIT`, `audit_log` / `audit_config` with their append-only layers, and the catalogue ↔ matrix join enforced in both directions and per request/concrete integration-handler identity by `AuditCoverageTests`; actual API/Seeder catalogue parity includes Off coordinators. Retention and partitioning are [Phase 11](../roadmap/phase-11-production-hardening.md)'s. | -| 19 | [Permissions](19-permissions.md) | **Adopted** | The finite scoped registry, Role templates and seed command declarations are implemented under the [Identity foundation contract](19-permissions.md#identity-foundation-contract). API composition denies seed authority; live permission enforcement remains P03-2, with current qualification owned by [Phase 03](../roadmap/phase-03-identity-admin.md). | +| 19 | [Permissions](19-permissions.md) | **Active** | The finite scoped registry, Role templates and seed command declarations implement the [Identity foundation contract](19-permissions.md#identity-foundation-contract). HTTP and application dispatch enforce live Membership, scoped permissions and finite trusted-source admission under the [live contract](19-permissions.md#live-permission-enforcement-contract). API composition denies seed authority; [Phase 03](../roadmap/phase-03-identity-admin.md) owns source-bound qualification. | | 20 | [Infrastructure Stack](20-infrastructure-stack.md) | **Active** | `ISecretProvider` shipped in Packet 3, the foundation ports and their defaults in Packet 5, and the entitlement socket in Packet 9; `DeploymentMode` branching happens once, at the composition root. P02b-2 delivers producer dispatch; P02b-3 Steps 1–5 add durable acceptance, shared inbox and independently scheduled default consumers. P02b-3 is complete and merged through PR #31; its [merge closeout](../roadmap/phase-02b-delivery-log.md#p02b-3-merge-and-closeout-2026-10-15) owns final verification. Packet 10 made the bans this document states mechanical: no module reaches a cache client, a Hub namespace, `audit_log` or `platform_entitlement_cache`, and every entitlement key a call site names is a registry member. The adapters themselves — Dapr, Kafka, Valkey, Vault, APISIX — arrive on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s triggers, which is the model rather than a gap in it. | | 21 | [Architecture Tests Catalogue](21-architecture-tests-catalogue.md) | **Active** | The catalogue's own § Implemented today carries the counts, and `The_Catalogue_Counts_Its_Own_Rules` recomputes them, so this row does not keep a third copy. Rules run in the architecture assembly and beside it in the unit, integration and frontend suites — each where it can actually fail, against an applied schema, a real host or a real ESLint configuration. `Every_Implemented_Rule_Names_A_Test_That_Exists` holds each Implemented entry to a method of that name. `No_Architecture_Test_Is_Skippable` governs backend suites; P02d-5 delivers G38(d)'s guarded frontend runner and planted skip/todo, empty-report and omitted-workspace controls. | @@ -136,13 +136,15 @@ Step 3 adds Education eligibility, scoped seek pages and bounded content/label projections with HTTP/database proof. Step 4 adds served/snapshot equality, recursive DTO guards, generated SDK/drift and breaking-policy CI controls. -Twenty-one `Active`, one `Adopted`. Packet 10 moved five: the frontend rules, the one- +Twenty-two `Active`, zero `Adopted`. Packet 10 moved five: the frontend rules, the one- app rule, the locale invariants, the out-of-band setters and the port bans are all mechanical now, and a standard whose rules a test enforces is `Active` by this document's own definition. P02d-6 promotes Accessibility with blocking lint/semantic controls and recorded manual evidence. P02d-7 promotes Performance with the hard checks above; this does not make advisory or future production measurements enforced. -Permissions remains Adopted until Phase 03 ships permission keys and enforcement. +P03-2 implements the finite permission registry and live enforcement under the +[accepted contract](19-permissions.md#live-permission-enforcement-contract); +[Phase 03](../roadmap/phase-03-identity-admin.md) owns source-bound qualification. ## Relationship to ADRs diff --git a/frontend/apps/web/README.md b/frontend/apps/web/README.md index 549173775..e110c8300 100644 --- a/frontend/apps/web/README.md +++ b/frontend/apps/web/README.md @@ -63,6 +63,15 @@ document with 503. Enabled mode exposes the closed `/api/auth` actions in native middleware and requires a session for those two shells. The shells show the read-only AccountSite projection, not future authoring or role permissions. +Accepted [ADR-0076](../../../docs/decisions/0076-live-scoped-authorization.md) +adds live AccountSite membership admission and one administrative directory GET +at the API. It does not extend this native caller's AccountSite-only allowlist +or expose the directory in these shells. Native authenticated operations and +member/role UI remain P03-3; backend authorization/evidence source keys belong +to API configuration, never the web runtime or browser. The +[Phase 03 Status block](../../../docs/roadmap/phase-03-identity-admin.md) owns +implementation and qualification; acceptance activates no new web operation. + The browser receives only host-only opaque cookies and a safe session projection. The warning uses the actual effective expiry; an explicit extension refreshes the provider and reports an unchanged deadline honestly. Logout revokes locally first; diff --git a/frontend/apps/web/scripts/admission-browser.mjs b/frontend/apps/web/scripts/admission-browser.mjs index b5b72dbaf..6e06ba2aa 100644 --- a/frontend/apps/web/scripts/admission-browser.mjs +++ b/frontend/apps/web/scripts/admission-browser.mjs @@ -137,9 +137,11 @@ export function readBrowserDevToolsPort(path) { } catch (error) { return { kind: error?.code === 'ENOENT' ? 'missing' : 'unreadable' }; } - const first = value.split('\n', 1)[0]; + const separator = value.indexOf('\n'); + const first = separator < 0 ? value : value.slice(0, separator); if (first.trim() === '') return { kind: 'empty' }; - if (!/^[1-9][0-9]{0,4}$/.test(first) || Number(first) > 65535) return { kind: 'malformed' }; + if (separator < 0 || !/^[1-9][0-9]{0,4}$/.test(first) || Number(first) > 65535) + return { kind: 'malformed' }; return { kind: 'ready', port: Number(first) }; } diff --git a/frontend/apps/web/scripts/public-demo-diagnostics.d.mts b/frontend/apps/web/scripts/public-demo-diagnostics.d.mts index ce40db512..b5d04f8e8 100644 --- a/frontend/apps/web/scripts/public-demo-diagnostics.d.mts +++ b/frontend/apps/web/scripts/public-demo-diagnostics.d.mts @@ -27,6 +27,12 @@ export function publicDemoChildExited(child: { exitCode: number | null; signalCode: string | null; }): boolean; +export function waitForPublicDemoBrowserPort(options: { + readonly assertActive: () => void; + readonly readPort: () => unknown; + readonly signal?: AbortSignal; + readonly timeout?: number; +}): Promise; export function publicDemoFailure( stage: unknown, error: unknown, diff --git a/frontend/apps/web/scripts/public-demo-diagnostics.mjs b/frontend/apps/web/scripts/public-demo-diagnostics.mjs index d6d9199fb..d461aa3c0 100644 --- a/frontend/apps/web/scripts/public-demo-diagnostics.mjs +++ b/frontend/apps/web/scripts/public-demo-diagnostics.mjs @@ -1,3 +1,5 @@ +import { setTimeout as pause } from 'node:timers/promises'; + import { DemoCommandError } from '../../../../scripts/demo-process.mjs'; const stages = new Set([ @@ -60,6 +62,43 @@ export const publicDemoWorkerStage = (stage) => (workerStages.has(stage) ? stage export const publicDemoChildExited = (child) => child.exitCode !== null || child.signalCode !== null; +/** Port publication may be incomplete; every observation keeps the original owner and clock. */ +export async function waitForPublicDemoBrowserPort({ + assertActive, + readPort, + signal, + timeout = 10_000, +}) { + if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 10_000) + throw new PublicDemoFailure('deadline'); + const deadline = Date.now() + timeout; + while (true) { + signal?.throwIfAborted(); + assertActive(); + let observation; + try { + observation = readPort(); + } catch { + throw new PublicDemoFailure('port-invalid'); + } + signal?.throwIfAborted(); + assertActive(); + const remaining = deadline - Date.now(); + if (remaining <= 0) + throw new PublicDemoFailure(observation?.kind === 'malformed' ? 'port-invalid' : 'deadline'); + if ( + observation?.kind === 'ready' && + Number.isInteger(observation.port) && + observation.port > 0 && + observation.port <= 65535 + ) + return observation.port; + if (!['missing', 'empty', 'malformed'].includes(observation?.kind)) + throw new PublicDemoFailure('port-invalid'); + await pause(Math.min(50, remaining), undefined, { signal }); + } +} + /** Only closed phase/context and native process status may enter public artifacts. */ export function publicDemoFailure(stage, error, input = {}, child, signal) { const phase = stages.has(stage) ? stage : 'proof'; diff --git a/frontend/apps/web/scripts/verify-public-demo.mjs b/frontend/apps/web/scripts/verify-public-demo.mjs index 33ddd93ae..cf8d697a8 100644 --- a/frontend/apps/web/scripts/verify-public-demo.mjs +++ b/frontend/apps/web/scripts/verify-public-demo.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; +import { readBrowserDevToolsPort } from './admission-browser.mjs'; import { digest, reportAdvisories, @@ -21,6 +22,7 @@ import { publicDemoOperation, publicDemoWorkerStage, publicDemoWorkerOutput, + waitForPublicDemoBrowserPort, } from './public-demo-diagnostics.mjs'; import { stopTestChild } from './stop-test-child.mjs'; import { demoRequest } from '../../../../scripts/demo-http.mjs'; @@ -142,24 +144,23 @@ export async function verifyPublicDemo(runtime) { ); stage('startup-port'); const portFile = join(profile, 'DevToolsActivePort'); - const startupDeadline = Date.now() + remaining(10000); - while (!existsSync(portFile)) { - remaining(10000); - if (browser.result().failed) - throw new DemoCommandError( - 'Owned managed Chromium', - browser.result().failed, - undefined, - { - reason: browser.result().failureReason, - }, - ); - if (publicDemoChildExited(browser.child)) throw new PublicDemoFailure('chromium-exited'); - if (Date.now() >= startupDeadline) throw new PublicDemoFailure('deadline'); - await pause(50, undefined, { signal: runtime.signal }); - } - const port = Number(readFileSync(portFile, 'utf8').split('\n')[0]); - if (!(port > 0 && port <= 65535)) throw new PublicDemoFailure('port-invalid'); + const port = await waitForPublicDemoBrowserPort({ + signal: runtime.signal, + timeout: remaining(10000), + readPort: () => readBrowserDevToolsPort(portFile), + assertActive: () => { + remaining(10000); + if (browser.result().failed) + throw new DemoCommandError( + 'Owned managed Chromium', + browser.result().failed, + undefined, + { reason: browser.result().failureReason }, + ); + if (publicDemoChildExited(browser.child)) + throw new PublicDemoFailure('chromium-exited'); + }, + }); writeFileSync( inputFile, JSON.stringify({ diff --git a/frontend/apps/web/src/server/ingress.ts b/frontend/apps/web/src/server/ingress.ts index 588eb38e5..5e3d5258b 100644 --- a/frontend/apps/web/src/server/ingress.ts +++ b/frontend/apps/web/src/server/ingress.ts @@ -321,6 +321,18 @@ export function loadLocalEnvironment( ]) { if (environment[key] !== undefined) result[key] = environment[key]; } + if (options.apiAuthentication) { + // A complete explicit process pair overrides the selected root file as one + // value. A partial pair cannot borrow its missing partner from that file. + const auxiliaryKeys = [ + 'ConnectionStrings__IdentityAuthorization', + 'ConnectionStrings__AuthorizationEvidence', + ]; + const explicit = auxiliaryKeys.filter((key) => environment[key] !== undefined); + if (explicit.length !== 0 && explicit.length !== auxiliaryKeys.length) + throw new Error('Incomplete API authorization source configuration'); + for (const key of explicit) result[key] = environment[key]!; + } // Web startup does not consume API authentication configuration. Only the API // launcher resolves the exact issuer/authority pair and its conflict checks. const identityKeys = ['Authentication__Tenant__Issuer', 'Identity__AuthorityId']; diff --git a/frontend/apps/web/src/test/demo-realm-lifecycle.test.ts b/frontend/apps/web/src/test/demo-realm-lifecycle.test.ts index 11e55c3ad..a119e1363 100644 --- a/frontend/apps/web/src/test/demo-realm-lifecycle.test.ts +++ b/frontend/apps/web/src/test/demo-realm-lifecycle.test.ts @@ -252,6 +252,8 @@ DemoProcesses.prototype.run = async function(command, args, options = {}) { const isCompose = command === 'docker' && args[0] === 'compose' && args.includes('--project-name'); const startup = command === 'make' && args.includes('dev'); const migration = command === 'make' && args.includes('migrate'); + const applicationSeed = command === 'dotnet' && args.length === 1 + && args[0].endsWith('/LearnStack.Tools.Seeder.dll'); const allowed = new Set(['PATH', 'HOME', 'DOTNET_ROOT', 'TMPDIR', 'APPDATA', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR', @@ -262,14 +264,18 @@ DemoProcesses.prototype.run = async function(command, args, options = {}) { ...(migration ? ['ConnectionStrings__Migration', 'Identity__AuthorityId', 'Authentication__Tenant__Issuer'] : []), ...(options.label === 'Contextual seed convergence' || options.label === 'Application seed convergence' ? ['ConnectionStrings__Default'] : [])]); // The app seed is recognised by its real, argument-free Release DLL call too. - if (command === 'dotnet' && args.length === 1 && args[0].endsWith('/LearnStack.Tools.Seeder.dll')) allowed.add('ConnectionStrings__Default'); + if (applicationSeed) for (const key of ['ConnectionStrings__Default', 'Identity__AuthorityId', + 'Authentication__Tenant__Issuer', 'Deployment__Mode']) allowed.add(key); + const runtimeIdentity = !applicationSeed || (env.Identity__AuthorityId === ${JSON.stringify(f.values.Identity__AuthorityId)} + && env.Authentication__Tenant__Issuer === ${JSON.stringify(f.values.Authentication__Tenant__Issuer)} + && env.Deployment__Mode === 'Development'); f.commands.push({ command, args, label: options.label, timeout: options.timeout, dockerContext: env.DOCKER_CONTEXT ?? null, dockerHome: env.HOME ?? null, prerequisitesPreserved: (command === 'docker' ? ['DOCKER_CONFIG', 'DOCKER_CONTEXT'] : ['HTTP_PROXY', 'http_proxy', 'NUGET_PACKAGES']) .every(key => process.env[key] === undefined || env[key] === process.env[key]), - confined: isCompose || Object.keys(env).every(key => allowed.has(key)) }); + confined: (isCompose || Object.keys(env).every(key => allowed.has(key))) && runtimeIdentity }); if (migration) f.migrationSelection = env.LEARNSTACK_COMPOSE_PROJECT?.startsWith('learnstack-demo-') && env.LEARNSTACK_COMPOSE_ENV_FILE === join(${JSON.stringify(f.root)}, '.env') && env.LEARNSTACK_COMPOSE_MODE === 'e2e' && env.ConnectionStrings__Migration?.includes('Username=learnstack_migration'); @@ -1178,6 +1184,40 @@ DemoProcesses.prototype.dispose = async function() { }); describe('private configuration refuses launch before commands', () => { + for (const [label, auxiliary] of [ + [ + 'both empty', + 'ConnectionStrings__IdentityAuthorization=\nConnectionStrings__AuthorizationEvidence=\n', + ], + ['authorization empty', 'ConnectionStrings__IdentityAuthorization=\n'], + ['evidence empty', 'ConnectionStrings__AuthorizationEvidence=\n'], + ['authorization only', 'ConnectionStrings__IdentityAuthorization=private-pair-canary\n'], + ['evidence only', 'ConnectionStrings__AuthorizationEvidence=private-pair-canary\n'], + ]) { + it.each(['seed', 'demo-local', 'demo-ci'] as const)( + `refuses ${label} auxiliary sources before %s starts or migrates a stack`, + (entry) => { + const f = fixture(); + f.source += auxiliary; + writePrivateFileSync(join(f.root, '.env.example'), f.source); + if (entry === 'seed') writePrivateFileSync(join(f.root, 'selected.env'), f.source); + const result = execute( + f, + entry === 'seed' ? 'seed' : 'demo', + 'success', + entry === 'demo-local' ? 'local' : 'ci', + ); + expect(result.failure).toBe('Local demo database configuration is invalid'); + for (const event of ['stack-up', 'migrate', 'appseed', 'realm-start', 'daemon']) + expect(result.events).not.toContain(event); + expect( + result.commands.some(({ args }) => args.includes('up') || args.includes('migrate')), + ).toBe(false); + expect(JSON.stringify(result)).not.toContain('private-pair-canary'); + }, + ); + } + it.each(['seed', 'demo'] as const)( 'refuses a shared private file before %s commands without repairing it', (subject) => { diff --git a/frontend/apps/web/src/test/public-api-environment.test.ts b/frontend/apps/web/src/test/public-api-environment.test.ts new file mode 100644 index 000000000..96c811bb0 --- /dev/null +++ b/frontend/apps/web/src/test/public-api-environment.test.ts @@ -0,0 +1,291 @@ +// @vitest-environment node +import { spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + developmentSeederRuntimeEnvironment, + publicSeederEnvironment, +} from '../../../../../scripts/demo-database.mjs'; +import { + publicApiEnvironment, + publicDemoApiEnvironment, + publicWebEnvironment, +} from '../../../../../scripts/public-runtime.mjs'; +import { loadLocalEnvironment } from '../server/ingress'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +function temporary() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'learnstack-api-environment-'))); + roots.push(root); + return root; +} +function connection(database: string, role = 'learnstack_app') { + return Object.entries({ + Host: 'selected.invalid', + Database: database, + Username: role, + Password: randomBytes(24).toString('base64url'), + 'Application Name': `runtime; "${randomBytes(8).toString('hex')}"`, + }) + .map(([key, value]) => `${key}="${value.replaceAll('"', '""')}"`) + .join(';'); +} +function pair() { + return { + ConnectionStrings__IdentityAuthorization: connection('learnstack_identity_authorization'), + ConnectionStrings__AuthorizationEvidence: connection('learnstack_authorization_evidence'), + }; +} +function selected() { + return { + ConnectionStrings__Default: connection('business'), + ConnectionStrings__OutboxDispatcher: connection('business', 'learnstack_outbox_admin'), + }; +} +const configuration = { + apiOrigin: 'http://127.0.0.1:5080', + secret: randomBytes(32).toString('base64url'), +}; +const keys = [ + 'ConnectionStrings__IdentityAuthorization', + 'ConnectionStrings__AuthorizationEvidence', +] as const; + +describe('selected API auxiliary source environment', () => { + it.each([ + ['absent', {}, true], + ['complete', pair(), true], + ['both empty', Object.fromEntries(keys.map((key) => [key, ''])), false], + ['authorization empty', { [keys[0]]: '' }, false], + ['evidence empty', { [keys[1]]: '' }, false], + ['authorization only', { [keys[0]]: pair()[keys[0]] }, false], + ['evidence only', { [keys[1]]: pair()[keys[1]] }, false], + ['both whitespace', Object.fromEntries(keys.map((key) => [key, ' \t'])), false], + ] as const)('uses the same %s pair rule for seed, API and demo', (_name, auxiliary, valid) => { + const values: Record = { + ...selected(), + Identity__AuthorityId: '01930000-0000-7000-8000-000000009001', + Authentication__Tenant__Issuer: 'http://localhost:8080/realms/learnstack', + ...auxiliary, + }; + const ambient = pair(); + for (const make of [ + () => developmentSeederRuntimeEnvironment(values, ambient), + () => publicApiEnvironment(values, configuration, ambient), + () => publicDemoApiEnvironment(values, configuration, temporary(), ambient), + ]) { + if (!valid) { + expect(make).toThrow(/configuration is invalid$/); + continue; + } + const environment = make(); + expect(environment.ConnectionStrings__Default).toBe(values.ConnectionStrings__Default); + for (const key of keys) { + if (key in auxiliary) expect(environment[key]).toBe(values[key]); + else expect(environment).not.toHaveProperty(key); + } + } + }); + + it.each(['public-api', 'demo'] as const)( + 'preserves the selected auxiliary pair byte-exactly for %s', + (launch) => { + const values = { ...selected(), ...pair() }; + const ambient = { + ...pair(), + ConnectionStrings__Migration: connection('owner', 'learnstack_migration'), + }; + const environment = + launch === 'demo' + ? publicDemoApiEnvironment(values, configuration, temporary(), ambient) + : publicApiEnvironment(values, configuration, ambient); + for (const key of keys) expect(environment[key]).toBe(values[key]); + expect(environment).not.toHaveProperty('ConnectionStrings__Migration'); + }, + ); + + it('keeps helper ambient values, native web and build prerequisites separate from runtime credentials', () => { + const values = { ...selected(), ...pair() }; + const ambient = { + ...values, + KEYCLOAK_ADMIN_PASSWORD: randomBytes(24).toString('hex'), + PATH: '/owned/bin', + }; + const api = publicApiEnvironment(selected(), configuration, ambient); + const demo = publicDemoApiEnvironment(selected(), configuration, temporary(), ambient); + const web = publicWebEnvironment(values, temporary(), ambient); + const build = publicSeederEnvironment(undefined, ambient); + for (const environment of [api, demo, web, build]) { + for (const key of [...keys, 'KEYCLOAK_ADMIN_PASSWORD']) + expect(environment).not.toHaveProperty(key); + } + }); + + for (const key of keys) { + it(`rejects a partial selected ${key} without borrowing its ambient partner`, () => { + const values = { ...selected(), [key]: pair()[key] }; + const ambient = pair(); + for (const make of [ + () => publicApiEnvironment(values, configuration, ambient), + () => publicDemoApiEnvironment(values, configuration, temporary(), ambient), + ]) + expect(make).toThrow('Authorization source configuration is invalid'); + }); + } + + it.each(['', ' ', '\t', '\n', '\r', '\0'])( + 'rejects an explicit invalid selected pair %j without printing its values', + (invalid) => { + const values = { + ...selected(), + ...pair(), + ConnectionStrings__AuthorizationEvidence: invalid, + }; + for (const make of [ + () => publicApiEnvironment(values, configuration, {}), + () => publicDemoApiEnvironment(values, configuration, temporary(), {}), + ]) + expect(make).toThrow('Authorization source configuration is invalid'); + }, + ); + + it('uses a complete process pair atomically and otherwise preserves the selected file pair', () => { + const root = temporary(); + const file = pair(); + const explicit = pair(); + writeFileSync( + join(root, '.env'), + Object.entries(file) + .map(([key, value]) => `${key}=${value}`) + .join('\n'), + ); + const options = { apiAuthentication: true }; + expect(loadLocalEnvironment(root, {}, options)).toMatchObject(file); + expect(loadLocalEnvironment(root, explicit, options)).toMatchObject(explicit); + for (const key of keys) { + expect(() => loadLocalEnvironment(root, { [key]: explicit[key] }, options)).toThrow( + 'Incomplete API authorization source configuration', + ); + } + }); +}); + +function launcher() { + const root = temporary(); + for (const path of ['scripts', 'frontend/apps/web/.server/auth', 'backend', 'bin']) + mkdirSync(join(root, path), { recursive: true }); + writeFileSync(join(root, 'package.json'), '{"type":"module"}\n'); + for (const name of ['public-local.mjs', 'public-runtime.mjs', 'demo-database.mjs']) + writeFileSync( + join(root, 'scripts', name), + readFileSync(new URL(`../../../../../scripts/${name}`, import.meta.url)), + ); + for (const name of ['ingress.js', 'auth/http-paths.js']) + writeFileSync( + join(root, 'frontend/apps/web/.server', name), + readFileSync(new URL(`../../.server/${name}`, import.meta.url)), + ); + const marker = join(root, 'api-child'); + writeFileSync( + join(root, 'bin/dotnet'), + `#!${process.execPath}\nimport { writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(marker)}, JSON.stringify({ args: process.argv.slice(2), env: process.env }), { mode: 0o600 });\n`, + { mode: 0o700 }, + ); + const values = { + ...selected(), + ...pair(), + LEARNSTACK_PUBLIC_API_ORIGIN: configuration.apiOrigin, + LEARNSTACK_PUBLIC_HOP_SECRET: configuration.secret, + LEARNSTACK_PUBLIC_TLS_CERT: '/owned/cert', + LEARNSTACK_PUBLIC_TLS_KEY: '/owned/key', + }; + return { root, marker, values }; +} +function launch( + fixture: ReturnType, + values: Record, + environment: Record = {}, +) { + writeFileSync( + join(fixture.root, '.env'), + Object.entries(values) + .map(([key, value]) => `${key}=${value}`) + .join('\n'), + { mode: 0o600 }, + ); + return spawnSync(process.execPath, [join(fixture.root, 'scripts/public-local.mjs'), 'api'], { + cwd: fixture.root, + env: { NODE_ENV: 'test', PATH: join(fixture.root, 'bin'), ...environment }, + encoding: 'utf8', + timeout: 4000, + killSignal: 'SIGKILL', + }); +} + +describe('actual public-api child admission', () => { + it.each(['file', 'process'] as const)( + 'passes the selected auxiliary pair from %s into the actual child environment', + (source) => { + const fixture = launcher(); + const explicit = pair(); + const result = launch(fixture, fixture.values, source === 'process' ? explicit : {}); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + const child = JSON.parse(readFileSync(fixture.marker, 'utf8')) as { + args: string[]; + env: Record; + }; + expect(child.args).toEqual(['run', '--no-launch-profile', '--project', 'src/LearnStack.Api']); + for (const key of keys) + expect(child.env[key]).toBe(source === 'process' ? explicit[key] : fixture.values[key]); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + }, + ); + + it.each([ + 'file-partial', + 'process-partial', + 'file-empty', + 'process-empty', + 'file-control', + ] as const)( + 'refuses %s before the API child starts and keeps the diagnostic fixed', + (invalid) => { + const fixture = launcher(); + const values: Record = { ...fixture.values }; + let environment: Record = {}; + if (invalid === 'file-partial') delete values.ConnectionStrings__AuthorizationEvidence; + if (invalid === 'process-partial') + environment = { + ConnectionStrings__IdentityAuthorization: pair().ConnectionStrings__IdentityAuthorization, + }; + if (invalid === 'file-empty') values.ConnectionStrings__AuthorizationEvidence = ''; + if (invalid === 'process-empty') + environment = { ...pair(), ConnectionStrings__AuthorizationEvidence: ' ' }; + if (invalid === 'file-control') values.ConnectionStrings__AuthorizationEvidence += '\0'; + const result = launch(fixture, values, environment); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(existsSync(fixture.marker)).toBe(false); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe('Local public configuration or launch failed\n'); + }, + ); +}); diff --git a/frontend/apps/web/src/test/public-demo-readiness.test.ts b/frontend/apps/web/src/test/public-demo-readiness.test.ts new file mode 100644 index 000000000..b561606a5 --- /dev/null +++ b/frontend/apps/web/src/test/public-demo-readiness.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment node +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { readBrowserDevToolsPort } from '../../scripts/admission-browser.mjs'; +import { + PublicDemoFailure, + publicDemoFailure, + waitForPublicDemoBrowserPort, +} from '../../scripts/public-demo-diagnostics.mjs'; + +const roots: string[] = []; +const canary = 'private-profile-endpoint-canary'; +const portFile = () => { + const root = mkdtempSync(join(tmpdir(), 'learnstack-public-port-')); + roots.push(root); + return join(root, 'DevToolsActivePort'); +}; +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('owned public-demo browser readiness', () => { + it('waits for actual missing, empty and incomplete numeric publication before admitting the full port', async () => { + const file = portFile(); + const observations: string[] = []; + const values = ['', '3', '12345\n/devtools/browser/' + canary]; + const assertActive = vi.fn(); + const result = await waitForPublicDemoBrowserPort({ + assertActive, + readPort: () => { + const value = readBrowserDevToolsPort(file); + observations.push(value.kind); + const next = values.shift(); + if (next !== undefined) writeFileSync(file, next); + return value; + }, + }); + expect(result).toBe(12345); + expect(observations).toEqual(['missing', 'empty', 'malformed', 'ready']); + expect(assertActive.mock.calls.length).toBeGreaterThanOrEqual(observations.length); + expect(readFileSync(file, 'utf8')).toContain(canary); + }); + + it.each([ + '3', + '12345', + '0x1234\n', + '1e3\n', + '12.5\n', + '12345junk\n', + ' 12345\n', + '01234\n', + '65536\n', + ])('does not publish an incomplete or noncanonical first line: %j', (value) => { + const file = portFile(); + writeFileSync(file, value); + expect(readBrowserDevToolsPort(file)).toEqual({ kind: 'malformed' }); + }); + + it.each([1, 65535])('admits the complete decimal range boundary %i', (port) => { + const file = portFile(); + writeFileSync(file, port + '\n/devtools/browser/' + canary); + expect(readBrowserDevToolsPort(file)).toEqual({ kind: 'ready', port }); + }); + + it.each([ + ['missing', 'deadline'], + ['empty', 'deadline'], + ['malformed', 'port-invalid'], + ['unreadable', 'port-invalid'], + ['unknown', 'port-invalid'], + ])('keeps a persistent %s observation bounded and closed', async (kind, reason) => { + const failure = await waitForPublicDemoBrowserPort({ + assertActive: () => {}, + readPort: () => ({ kind }), + timeout: 10, + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(PublicDemoFailure); + expect(publicDemoFailure('startup-port', failure)).toMatchObject({ reason }); + expect(JSON.stringify(publicDemoFailure('startup-port', failure))).not.toContain(canary); + }); + + it('keeps a thrown filesystem error private', async () => { + const failure = await waitForPublicDemoBrowserPort({ + assertActive: () => {}, + readPort: () => { + throw new Error(canary); + }, + }).catch((error: unknown) => error); + expect(String(failure)).not.toContain(canary); + expect(publicDemoFailure('startup-port', failure)).toMatchObject({ reason: 'port-invalid' }); + }); + + it('rechecks owner and native browser death after a ready observation', async () => { + let exited = false; + const readPort = vi.fn(() => { + exited = true; + return { kind: 'ready', port: 12345 }; + }); + const failure = await waitForPublicDemoBrowserPort({ + assertActive: () => { + if (exited) throw new PublicDemoFailure('chromium-exited'); + }, + readPort, + }).catch((error: unknown) => error); + expect(readPort).toHaveBeenCalledOnce(); + expect( + publicDemoFailure('startup-port', failure, {}, { exitCode: 0, signalCode: null }), + ).toMatchObject({ + reason: 'chromium-exited', + process: 'browser', + exitCode: 0, + }); + }); + + it('rejects a ready port observed after the existing allowance expires', async () => { + let now = 0; + vi.spyOn(Date, 'now').mockImplementation(() => now); + await expect( + waitForPublicDemoBrowserPort({ + assertActive: () => {}, + readPort: () => { + now = 10; + return { kind: 'ready', port: 12345 }; + }, + timeout: 10, + }), + ).rejects.toMatchObject({ reason: 'deadline' }); + }); + + it.each([0, -1, 10_001, 1.5, Infinity])( + 'does not enlarge or invalidate the startup bound: %s', + async (timeout) => { + const readPort = vi.fn(); + await expect( + waitForPublicDemoBrowserPort({ assertActive: () => {}, readPort, timeout }), + ).rejects.toMatchObject({ reason: 'deadline' }); + expect(readPort).not.toHaveBeenCalled(); + }, + ); + + it('cancels an in-flight publication wait without exposing the owned reason', async () => { + const controller = new AbortController(); + const readPort = vi.fn(() => ({ kind: 'empty' })); + const waiting = waitForPublicDemoBrowserPort({ + assertActive: () => {}, + readPort, + signal: controller.signal, + }).catch((error: unknown) => error); + controller.abort(new Error(canary)); + const failure = await waiting; + expect(readPort).toHaveBeenCalledOnce(); + const row = publicDemoFailure('startup-port', failure, {}, undefined, controller.signal); + expect(row).toMatchObject({ reason: 'cancelled' }); + expect(JSON.stringify(row)).not.toContain(canary); + }); +}); diff --git a/frontend/apps/web/src/test/public-local-file.test.ts b/frontend/apps/web/src/test/public-local-file.test.ts index ad556d4b4..c7989b486 100644 --- a/frontend/apps/web/src/test/public-local-file.test.ts +++ b/frontend/apps/web/src/test/public-local-file.test.ts @@ -38,7 +38,7 @@ function fixture() { mkdirSync(join(root, 'scripts')); mkdirSync(join(root, 'frontend/apps/web/.server'), { recursive: true }); writeFileSync(join(root, 'package.json'), '{"type":"module"}\n'); - for (const name of ['public-local.mjs', 'public-runtime.mjs']) + for (const name of ['public-local.mjs', 'public-runtime.mjs', 'demo-database.mjs']) writeFileSync( join(root, 'scripts', name), readFileSync(new URL(`../../../../../scripts/${name}`, import.meta.url)), diff --git a/frontend/packages/sdk/src/authenticated-contract.test.ts b/frontend/packages/sdk/src/authenticated-contract.test.ts index f8a2de599..c86ffc46c 100644 --- a/frontend/packages/sdk/src/authenticated-contract.test.ts +++ b/frontend/packages/sdk/src/authenticated-contract.test.ts @@ -6,7 +6,10 @@ import type { AccountSiteSuccess, AuthenticatedGetOperation, GetContractOwnership, + MembershipDirectoryItem, + MembershipDirectoryPage, } from './authenticated-contract'; +import type { AuthenticatedServerSdk } from './authenticated-server'; import type { components, operations, paths } from './generated/schema'; import { AUTHENTICATED_GET_CONTRACTS as RootContracts } from './index'; import type { @@ -49,8 +52,11 @@ function ownershipErrors( } describe('generated GET contract ownership', () => { - it('keeps AccountSite in the separate authenticated contract and public methods credential-free', () => { - expect(AUTHENTICATED_GET_CONTRACTS).toEqual({ '/api/v1/account/site': 'GetAccountSite' }); + it('keeps AccountSite and the directory contracts authenticated without adding native directory transport', () => { + expect(AUTHENTICATED_GET_CONTRACTS).toEqual({ + '/api/v1/account/site': 'GetAccountSite', + '/api/v1/identity/memberships': 'ListMemberships', + }); expect(PUBLIC_GET_CONTRACTS).toEqual({ '/api/v1/public/site': 'GetPublicSite', '/api/v1/public/courses': 'GetPublicCourses', @@ -60,7 +66,7 @@ describe('generated GET contract ownership', () => { expect(GET_CONTRACT_OWNERSHIP).toBe(true); expect(ownershipErrors(PUBLIC_GET_CONTRACTS, AUTHENTICATED_GET_CONTRACTS)).toEqual([]); expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<'GetAccountSite'>(); + expectTypeOf().toEqualTypeOf<'GetAccountSite' | 'ListMemberships'>(); expect(RootContracts).toBe(AUTHENTICATED_GET_CONTRACTS); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -89,6 +95,32 @@ describe('generated GET contract ownership', () => { Extract >().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<'getAccountSite'>(); + expectTypeOf< + Extract + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + operations['ListMemberships'] + >(); + expectTypeOf().toEqualTypeOf< + { cursor?: string; limit?: number } | undefined + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + 'membershipId' | 'userId' | 'organizationId' | 'status' | 'displayName' | 'preferredLocale' + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<'Active' | 'Suspended'>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf< + operations['ListMemberships']['responses'][403]['content']['application/problem+json'] + >().toEqualTypeOf(); + expectTypeOf< + operations['ListMemberships']['responses'][200]['headers']['Cache-Control'] + >().toEqualTypeOf<'no-store'>(); }); it('freezes contract and wrapper ownership at runtime', () => { diff --git a/frontend/packages/sdk/src/authenticated-contract.ts b/frontend/packages/sdk/src/authenticated-contract.ts index 36a76aa7f..8bc1f99e9 100644 --- a/frontend/packages/sdk/src/authenticated-contract.ts +++ b/frontend/packages/sdk/src/authenticated-contract.ts @@ -1,15 +1,22 @@ import type { components, operations } from './generated/schema.js'; -/** P02b-7 owns session-based transport for this separate authenticated contract. */ +/** Authenticated wire ownership; native session transport separately admits only AccountSite. */ export const AUTHENTICATED_GET_CONTRACTS = Object.freeze({ '/api/v1/account/site': 'GetAccountSite', -} as const satisfies Record<'/api/v1/account/site', 'GetAccountSite'>); + '/api/v1/identity/memberships': 'ListMemberships', +} as const satisfies { + '/api/v1/account/site': 'GetAccountSite'; + '/api/v1/identity/memberships': 'ListMemberships'; +}); export type AuthenticatedGetOperation = (typeof AUTHENTICATED_GET_CONTRACTS)[keyof typeof AUTHENTICATED_GET_CONTRACTS]; export type AccountSite = components['schemas']['AccountSite']; export type AccountSiteSuccess = operations['GetAccountSite']['responses'][200]['content']['application/json']; +export type MembershipDirectoryItem = components['schemas']['MembershipDirectoryItem']; +export type MembershipDirectoryPage = + operations['ListMemberships']['responses'][200]['content']['application/json']; type Same = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; diff --git a/frontend/packages/sdk/src/generated/schema.d.ts b/frontend/packages/sdk/src/generated/schema.d.ts index 97efd3893..16e2355c4 100644 --- a/frontend/packages/sdk/src/generated/schema.d.ts +++ b/frontend/packages/sdk/src/generated/schema.d.ts @@ -21,6 +21,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/identity/memberships": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Tenant administrative membership directory requiring current Tenant identity.membership.admin. Rechecks live membership, lifecycle and capability for every page; accepts HostAndClaim or confirmed ClaimAndMembership, including a platform host. Returns only membership/user/organization identifiers, status, contextual displayName and preferredLocale. Active/Suspended undeleted memberships only, ordered by membership UUID. No search, sorting, editing, provider data, grants or global profile. No-store; the MUST ReadSensitive audit row commits before any result is disclosed. This operation is not admitted by the native BFF caller. */ + get: operations["ListMemberships"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/public/courses": { parameters: { query?: never; @@ -103,6 +120,17 @@ export interface components { site: components["schemas"]["PublicSite"]; tenantId: components["schemas"]["TenantId"]; }; + /** @enum {string} */ + DirectoryMembershipStatus: "Active" | "Suspended"; + MembershipDirectoryItem: { + displayName: null | string; + /** Format: uuid */ + membershipId: string; + organizationId: null | components["schemas"]["OrganizationId"]; + preferredLocale: null | string; + status: components["schemas"]["DirectoryMembershipStatus"]; + userId: components["schemas"]["UserId"]; + }; /** Format: uuid */ OrganizationId: string; PageInfo: { @@ -111,6 +139,10 @@ export interface components { nextCursor: null | string; previousCursor: null | string; }; + PageOfMembershipDirectoryItem: { + items: components["schemas"]["MembershipDirectoryItem"][]; + pageInfo: components["schemas"]["PageInfo"]; + }; ProblemDetails: { code: string; correlationId: string; @@ -325,7 +357,129 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetails"]; }; }; - /** @description dependency_unavailable from invalid stored public configuration or a sticky subject-registry cleanup fault. */ + /** @description dependency_unavailable from live authorization storage, invalid public configuration or sticky subject-registry cleanup fault; refused audit writes preserve the original refusal. */ + 503: { + headers: { + "Cache-Control": "no-store"; + /** @description Suggested dependency retry delay in seconds; not a recovery guarantee. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + ListMemberships: { + parameters: { + query?: { + /** @description Opaque canonical continuation bound to the actor, tenant, organization and operation; obtain it from pageInfo.nextCursor. Every page reauthorizes. */ + cursor?: string; + /** @description Positive page size, default 20; values above 100 are clamped to 100. Repeated, empty or unknown query parameters are refused. */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "Cache-Control": "no-store"; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageOfMembershipDirectoryItem"]; + }; + }; + /** @description validation_failed for noncanonical/replayed/oversized cursors, invalid limit or unknown/repeated/empty query parameters; malformed assertion headers are also refused. */ + 400: { + headers: { + "Cache-Control": "no-store"; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description unauthorized for present invalid or undecidable credentials on known hosts, including existing-binding failure; bare Bearer challenge without provider details. */ + 401: { + headers: { + "Cache-Control": "no-store"; + "WWW-Authenticate": "Bearer"; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description forbidden for confirmed coverage without the current Tenant administrative capability; no directory lookup or disclosure. */ + 403: { + headers: { + "Cache-Control": "no-store"; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description not_found for absent credentials, noncoverage or untrusted scope; tenant_mismatch for host/claim or header disagreement. Assertions do not select authority. */ + 404: { + headers: { + "Cache-Control": "no-store"; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description method_not_allowed from routing after earlier credential, resolution and surface gates; GET only. */ + 405: { + headers: { + Allow: "GET"; + "Cache-Control": "no-store"; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description payload_too_large from the existing decoded body bound; outer Kestrel framing refusals may omit application policy. */ + 413: { + headers: { + "Cache-Control"?: "no-store"; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description rate_limited from visitor/subject admission or subject-registry capacity. */ + 429: { + headers: { + "Cache-Control": "no-store"; + /** @description Suggested retry delay in seconds for the first refusing admission boundary. A capacity hint does not promise an available registry slot; quota hints do not guarantee admission. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description internal_error for unexpected failures, without exception/query details. */ + 500: { + headers: { + "Cache-Control": "no-store"; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description dependency_unavailable for live membership/lifecycle storage, auxiliary admission or sticky subject-registry cleanup fault; audit_unavailable when the mandatory sensitive-read row cannot commit. No membership payload is disclosed. */ 503: { headers: { "Cache-Control": "no-store"; diff --git a/frontend/packages/sdk/src/index.ts b/frontend/packages/sdk/src/index.ts index 55a2d06a2..a4883c9a5 100644 --- a/frontend/packages/sdk/src/index.ts +++ b/frontend/packages/sdk/src/index.ts @@ -5,4 +5,6 @@ export type { AccountSite, AccountSiteSuccess, AuthenticatedGetOperation, + MembershipDirectoryItem, + MembershipDirectoryPage, } from './authenticated-contract'; diff --git a/infra/compose/README.md b/infra/compose/README.md index 0933fe6cd..8b1819ebc 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -65,6 +65,22 @@ docker compose --env-file .env -f infra/compose/dev.yml exec -T postgres sh -c It is idempotent, so re-running it against a cluster that already has the roles is a no-op rather than an error. +The P03-2 source contract uses two backend-only configuration keys, +`ConnectionStrings:IdentityAuthorization` and +`ConnectionStrings:AuthorizationEvidence`, as a guarded pair alongside Default. +[Infrastructure Standards § Authorization and evidence topology](../../docs/standards/12-infrastructure.md#authorization-and-evidence-topology) +owns the Development-only direct-PostgreSQL derivation, fixed 4/2 lane bounds +and explicit non-Development/PgBouncer setup. The owned seed/demo runtime helper +passes the selected pair and installed authority to Seeder children, with the +existing `Deployment__Mode=Development` selection for this direct local stack. +The Seeder qualifies one process-owned evidence source against the business +source's actual database and installed authority, then shares it across all +per-act providers. Restore/build and projection children receive no runtime +authority or database credentials. Neither matching authority nor these client +pools proves reserved backend capacity. The phase Status block owns current +implementation/review state; production aliases, downstream reserves and admitted +instance count require Phase 11 qualification. + Fresh imports do not upgrade an existing realm. P02b-6's bounded warm-realm reconciler is shared by `make seed` and `make demo` after application seed. Both entries validate the selected private source before infrastructure work; older diff --git a/infra/keycloak/README.md b/infra/keycloak/README.md index 6bafb9a43..602dcf02c 100644 --- a/infra/keycloak/README.md +++ b/infra/keycloak/README.md @@ -270,6 +270,25 @@ the same stable ID and exact issuer across reruns; an installed conflicting registration is never overwritten. Direct `make migrate` can still apply schema only when both values are absent; combined identity seeding requires them. +Both content and identity-foundation Seeder children qualify the installed +authority before application dispatch. The seed/demo runtime environment helper +passes the selected Default and coupled authorization/evidence strings plus the +authority pair; it selects the existing `Deployment__Mode=Development` for its +owned direct-PostgreSQL local stack. Restore/build and credential-free manifest +children retain only process prerequisites. No provider-admin, migration, +platform or dispatcher credential is passed to either runtime child. + +The CLI owns one two-slot evidence source for the run, checks its actual database +against the qualified business source and shares it across per-act providers. +It never creates a pool per command or installs an authority anchor. Direct CLI +use requires the authority pair and both auxiliary strings unless explicit +Development permits the supported direct-PostgreSQL derivation; PgBouncer still +requires the explicit pair and separate backend reservations. Follow +[the source topology contract](../../docs/standards/12-infrastructure.md#authorization-and-evidence-topology). +Startup configuration/qualification refusals emit fixed diagnostics without +provider details. Finite seed authority and its denial-evidence barrier grant no +human capability, HTTP route or production topology qualification. + An older private environment must add `KEYCLOAK_WEB_CLIENT_SECRET` explicitly before either combined entry starts stack or database work. Do not wipe the provider database to upgrade. The tool reports fixed refusal reasons, keeps diff --git a/scripts/ci-native-tests.py b/scripts/ci-native-tests.py index 13bfe20e0..38299c4b3 100644 --- a/scripts/ci-native-tests.py +++ b/scripts/ci-native-tests.py @@ -83,6 +83,7 @@ STARTUP_FAILURES = {'resource_controllers_unavailable', 'work_cgroup_not_empty', 'cgroup_kill_unavailable', 'startup_io_refused', 'startup_internal_refusal'} FAILURE_SOURCES = { + 'AuthorizationReadSourceTests.cs': 'authorization_read_source', 'OutboxProcessorTests.cs': 'outbox_processor', 'BffNativeDriver.cs': 'bff_native_driver', 'TenantBffSessionTests.cs': 'bff_session', @@ -148,11 +149,13 @@ KEYCLOAK_FAILURE_CLASSES = { 'TenantRealmApiTests', 'TenantRealmIssuanceTests', 'TenantRealmProfileProtectionTests', 'TenantRealmReconciliationTests', 'TenantRealmSelfServiceTests', + 'TenantRealmStockBaselineTests', } KEYCLOAK_FAILURE_STAGES = { 'certificate', 'network', 'database', 'import_input', 'provider_start', 'mapped_endpoint', 'loopback_bindings', 'discovery', 'database_create_request', 'database_start', 'database_readiness', 'database_started', + 'provider_created', 'provider_starting', 'provider_started', } KEYCLOAK_FAILURE_REASONS = { 'container_exit', 'deadline', 'readiness_timeout', 'http_transport', 'argument', @@ -971,10 +974,13 @@ def closed_failure_details(case, raw): if len(message) > 65536 or message.count(marker) != 1: return None match = re.search(re.escape(marker) + r'([a-z_]{1,32})/([a-z_]{1,32}); ' - r'owned cleanup ([a-z_]{1,32})\.', message) + r'owned cleanup ([a-z_]{1,32})(?:; startup cancellation requested (true|false))?\.', message) if (match and match[1] in KEYCLOAK_FAILURE_STAGES and match[2] in KEYCLOAK_FAILURE_REASONS and match[3] in KEYCLOAK_FAILURE_CLEANUP): - return {'stage': match[1], 'reason': match[2], 'cleanup': match[3]} + details = {'stage': match[1], 'reason': match[2], 'cleanup': match[3]} + if match[4] is not None: + details['startupCancellationRequested'] = match[4] == 'true' + return details return None message = message[:65536] prefix = 'LearnStack.Tests.Integration.Database.' @@ -1594,7 +1600,10 @@ def __init__(self, path, assembly, expected, stream): self.path, self.assembly, self.stream = path, assembly, stream self.expected = {key(case) for case in expected} require(len(self.expected) == len(expected), 'duplicate_discovery_identity') + self.expected_cases = {key(case): {name: case[name] for name in ('assembly', 'id', 'name')} + for case in expected} self.seen, self.outcomes = set(), Counter() + self.start_observation, self.started_without_result = 'unavailable', set() self.offset, self.pending, self.completed = 0, b'', False self.backup = path.with_name(path.stem + '.bak' + path.suffix) require(not os.path.lexists(self.backup), 'execution_diagnostic_changed') @@ -1628,9 +1637,40 @@ def read_current(self): for line in lines: self.line(line) + def observe_starts(self, payload): + # VSTest 17.12 TestRunCache.SendResults clears its start buffer after + # each StatsChange. ActiveTests is therefore a delta, not a snapshot. + # xUnit's collection/class fixture setup and teardown have no case start. + # These observations never contribute to results, completion or ownership. + starts = payload.get('ActiveTests') + if starts is None or self.start_observation == 'invalid': + return + try: + require(isinstance(starts, list) and len(starts) <= len(self.expected), + 'execution_start_observation_invalid') + identities = {key(case_identity(raw, self.assembly)) for raw in starts} + require(identities <= self.expected, 'execution_start_observation_invalid') + except (Refusal, TypeError, ValueError): + # Optional diagnostics can become unknown, but cannot admit foreign + # identities/provider text or alter the authoritative result parser. + self.start_observation = 'invalid' + self.started_without_result.clear() + return + self.start_observation = 'observed' + self.started_without_result.update(identities - self.seen) + + def execution_diagnostics(self): + # Bounded by the selected population; export only discovery-bound values. + # An empty observed list does not establish that fixtures have completed. + return {'status': self.start_observation, + 'startedWithoutResult': ([self.expected_cases[identity] + for identity in sorted(self.started_without_result)] + if self.start_observation == 'observed' else None)} + def results(self, payload): require(isinstance(payload, dict) and isinstance(payload.get('NewTestResults'), list), 'execution_protocol_invalid') + self.observe_starts(payload) for raw in payload['NewTestResults']: require(isinstance(raw, dict), 'execution_case_invalid') case = case_identity(raw.get('TestCase'), self.assembly) @@ -1678,6 +1718,7 @@ def results(self, payload): self.stream.flush() print(value, flush=True) self.seen.add(identity) + self.started_without_result.discard(identity) self.outcomes[outcome] += 1 def line(self, line): @@ -2013,6 +2054,7 @@ def shard_workload(mode, context, private, output, deadline, work): progress.close() item['failureLocations'] = progress.failure_locations item['successObservations'] = progress.success_observations + item['executionDiagnostics'] = progress.execution_diagnostics() item['testProcessSeconds'] = round(time.monotonic() - began, 3) if progress.first_result_at is not None: item['firstResultSeconds'] = round(progress.first_result_at - began, 3) diff --git a/scripts/ci-test-shard-results.py b/scripts/ci-test-shard-results.py index 4caf1735d..baa3c0497 100644 --- a/scripts/ci-test-shard-results.py +++ b/scripts/ci-test-shard-results.py @@ -47,7 +47,12 @@ UPLOAD_STEP = 'Upload immutable attempt evidence' BUILD_STEP = 'Build Release with warnings as errors' UPLOAD_ACTION = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' -FIXTURE_IMAGES = ('quay.io/keycloak/keycloak:26.6.2', 'postgres:18.4-alpine') +_FIXTURE_IMAGE_COMPONENTS = ( + ('keycloak', 'quay.io/keycloak/keycloak:26.6.2'), + ('postgres', 'postgres:18.4-alpine'), + ('pgbouncer', 'edoburu/pgbouncer:v1.26.0-p0'), +) +FIXTURE_IMAGES = tuple(image for _, image in _FIXTURE_IMAGE_COMPONENTS) def module(name, filename): @@ -90,8 +95,9 @@ def prepare_fixture_images(mode): beginning = time.monotonic() deadline = beginning + min(110, available) images = [] - require(len(FIXTURE_IMAGES) == 2, 'fixture_setup_images_invalid') - for component, image in zip(('keycloak', 'postgres'), FIXTURE_IMAGES): + require(FIXTURE_IMAGES == tuple(image for _, image in _FIXTURE_IMAGE_COMPONENTS), + 'fixture_setup_images_invalid') + for component, image in _FIXTURE_IMAGE_COMPONENTS: image_started = time.monotonic() OWNER.command([OWNER.DOCKER, 'pull', image], deadline, 'fixture_image_pull_failed_' + component, cap=55, diagnose=True) diff --git a/scripts/ci-test-shards-plan.json b/scripts/ci-test-shards-plan.json index 88a155356..3c961b0cf 100644 --- a/scripts/ci-test-shards-plan.json +++ b/scripts/ci-test-shards-plan.json @@ -22,27 +22,6 @@ "runId": null, "note": "Historical combined Docker receipt includes failures; ordinary row sums scaled by 2.271245 completed-prefix ratio; advisory only. Source not embedded in artifact." }, - "local-architecture": { - "kind": "local_trx", - "artifactSha256": "ede6e3df77289dac5cdd5db887120c03477bbc68bb542cf03b7b8f36f6f52ac1", - "sourceSha": null, - "runId": null, - "note": "Complete local Architecture row sums; overlapping durations are not wall bounds. Source not embedded in artifact." - }, - "hosted-architecture-predecessor": { - "kind": "hosted_console", - "artifactSha256": "d1ac4a6dda51b2ae593f36635bc588aaa9d10141bf08f8cf7cdca1f3fdcdba6c", - "sourceSha": null, - "runId": "39131069558", - "note": "Completed-only, rounded Architecture console prefix; maps only exact unambiguous TRX test names to actual TestMethod metadata. Advisory, censored remainder unknown." - }, - "hosted-architecture-first": { - "kind": "hosted_console", - "artifactSha256": "808cdbf102a7bceb5eb3b94dc5c78351b532d99e666e116d8fd9a0ce63d41168", - "sourceSha": "6daa9a1c4263bfc427f538013bd6f8673e4de9c7", - "runId": "39140874785", - "note": "Completed-only, rounded Architecture console prefix; maps only exact unambiguous TRX test names to actual TestMethod metadata. Advisory, censored remainder unknown." - }, "hosted-architecture-current": { "kind": "hosted_console", "artifactSha256": "0c464e7c1b8a197b2ada04487d2109d378e80968695fa4a0aca86f1fe18fb859", @@ -99,20 +78,6 @@ "runId": null, "note": "Complete corrected P02b-6 Step 4 local provider receipt, zero failures/skips. Source commit not embedded in TRX. Exact row durations summed per selector and scaled by historical ordinary ratio 2.271245 (ceil ms, minimum 1); advisory, not a wall bound. Fixture startup/discovery costs are not inferred from case durations; actual hosted fit remains required." }, - "p02b6-local-profile": { - "kind": "local_trx", - "artifactSha256": "f470c252cbbfc908e1d4996278cf116ba2b78eb514755d729c57a6ae6f8a2aa7", - "sourceSha": null, - "runId": null, - "note": "Complete corrected P02b-6 Step 4 local provider receipt, zero failures/skips. Source commit not embedded in TRX. Exact row durations summed per selector and scaled by historical ordinary ratio 2.271245 (ceil ms, minimum 1); advisory, not a wall bound. Fixture startup/discovery costs are not inferred from case durations; actual hosted fit remains required." - }, - "adr68-local-preparation": { - "kind": "local_trx", - "artifactSha256": "b701d3166e5266dab904ae7e921a08034db5d2f6683d17c605bc625c8fa5dc83", - "sourceSha": null, - "runId": null, - "note": "Complete local provider receipt, all rows Passed/zero skips. Source commit absent from TRX; observed planner SHA256 8ef1d58c2774b531d88170ff6e04939bdd631a77d749527764744fdc832e94ed; other source hashes retained separately. Actual TestMethod groups sum case ms, scale 2.271245, ceil/min1; advisory, not wall bounds or final-head qualification." - }, "adr68-local-mapper-correction": { "kind": "local_trx", "artifactSha256": "4497a991aab51936afb923cbd60aa6d01d9480a700211580f6a9fe0b183a1db8", @@ -120,20 +85,6 @@ "runId": null, "note": "Complete local provider receipt, all rows Passed/zero skips. Source commit absent from TRX; observed account policy SHA256 3cc2c01bdfbb64e2343bed2c757434ed6c81f25b93001860c1a34238d9b39dd4; other source hashes retained separately. Actual TestMethod groups sum case ms, scale 2.271245, ceil/min1; advisory, not wall bounds or final-head qualification." }, - "adr68-hosted-baseline-ordinary-1": { - "kind": "hosted_trx", - "artifactSha256": "515636cb1318709236b068f05bc984385ae651c79a0fd5236aec36c62b51d5f4", - "sourceSha": "d55ae6fc37b794e482c42ebbdd49d3e59a9c3f0a", - "runId": "39699380813", - "note": "Run attempt 1; owner ordinary-1; PR head f714a5af96363d880e7e3d72a24ee2642a29cf47; artifact ID 12393016518; API archive SHA256 98c6174c249fa80ad3b53356a2efe0c764f500454de74dc429698766137a14a7. Sanitized TRX IDs bind actual discovery FullyQualifiedName metadata; complete passing owner. Exact summed row ms, ceil/min1; advisory, not wall bounds." - }, - "adr68-hosted-baseline-ordinary-4": { - "kind": "hosted_trx", - "artifactSha256": "7fcf4e254a1f35a96b59c187affdf345aa6400e60a13fc1b16712c3778717159", - "sourceSha": "d55ae6fc37b794e482c42ebbdd49d3e59a9c3f0a", - "runId": "39699380813", - "note": "Run attempt 1; owner ordinary-4; PR head f714a5af96363d880e7e3d72a24ee2642a29cf47; artifact ID 12392133270; API archive SHA256 d02f5a7ac79d62ce023ffde59d2989b7059be142925128f71cb6d129ebf714d6. Sanitized TRX IDs bind actual discovery FullyQualifiedName metadata; complete passing owner. Exact summed row ms, ceil/min1; advisory, not wall bounds." - }, "adr68-local-warm-journey": { "kind": "local_trx", "artifactSha256": "3487a4510af4444908eaef908365e8cc4334b0dfc6a531b0af25276f49e657c9", @@ -141,34 +92,6 @@ "runId": null, "note": "Complete final refresh-presence-guard local journey Fact, 1/1 Passed and zero skips. Commit is not embedded in TRX. Separate seven-file source hash manifest SHA256 92cde32454dfb23f0421ce4e2d92ffe3c054281138cfe2d8a2ebe8c734960da3. Actual TestMethod row is 40205.7204 ms; local scale 2.271245, ceil/min1. Advisory, not a hosted wall bound or final-source CI qualification." }, - "local-bff-lifecycle-20261024": { - "kind": "local_trx", - "artifactSha256": "e7e98d28870397f11c8e0962dafc0c7fa2473cd9f25d9113098ba292e1df7d78", - "sourceSha": null, - "runId": null, - "note": "Passing Step 5 local BFF lifecycle/browser/startup method durations. Local uncommitted source is not embedded in TRX. Advisory body sums exclude shared collection startup; hosted fit remains unqualified." - }, - "local-bff-lifecycle-r1-20261024": { - "kind": "local_trx", - "artifactSha256": "a8aed7b2684219ebec622a931c85640c8a59fcfa40407aabe9f0be95a1208fb5", - "sourceSha": null, - "runId": null, - "note": "Passing Step 5 R1 native/browser/early-disposal/nonzero-stop controls. Local source not embedded in TRX; advisory method body costs exclude collection startup and do not establish hosted wall fit." - }, - "local-bff-lifecycle-r2-20261024": { - "kind": "local_trx", - "artifactSha256": "eb29f34d707a87e2ddcdc9c0c269cd011c983cce4a79d8592ffaf9c95aa2ccf0", - "sourceSha": null, - "runId": null, - "note": "Passing Step 5 R2 six-case real BFF/browser and acquisition EOF/shutdown controls. Local source not embedded in TRX. Advisory method body costs exclude shared fixture startup; hosted fit remains unqualified." - }, - "p02b7-local-causal-retry": { - "kind": "local_trx", - "artifactSha256": "8f004ff7e3631179620ed3ca5cc8873402520ec147f537cca224eb2a6b3d242d", - "sourceSha": null, - "runId": null, - "note": "Three passing local causal-restart variants, including the additional genuine-handler post-write conflict/retry journey. Exact case durations summed per actual selector and scaled by the existing historical ordinary ratio 2.271245 (ceil ms); advisory only, not a wall bound. Immutable source is not embedded in the local TRX." - }, "p031-hosted-architecture-0": { "kind": "hosted_trx", "artifactSha256": "6c923d6eb54664b3f0d7d889e6ea9eaa29f1b582f6173fefa9318524d3be3b0f", @@ -400,20 +323,6 @@ "runId": "40318359878", "note": "Attempt 2, ordinary-8, artifact ID 12649532186, head c14ec6a78891591b52c0156b97197f9e29cfee7b, merge 5e02e3cc26da696aaed43d7557127b8ca8164a9c. Complete passing original TRX; ceil summed case ms/minimum 1; advisory, not a wall bound." }, - "pr38-df-1-architecture-1": { - "kind": "hosted_trx", - "artifactSha256": "810581e38845581eb1b96afd5551b3da6a3d136af5443cfa7f6582ee50096e69", - "sourceSha": "d6a794cd41ddefbffac5fb9f9195ded465d0c358", - "runId": "40326601181", - "note": "Attempt 1, architecture-1, artifact ID 12653685003, head dfdf9cab92ab94ed7b882553c8828f1337bac9d4, merge d6a794cd41ddefbffac5fb9f9195ded465d0c358; complete passing original TRX; ceil summed case ms/minimum 1; advisory, not wall time." - }, - "pr38-df-1-architecture-2": { - "kind": "hosted_trx", - "artifactSha256": "97893f76633c41b6046d90bf2ec35c3bbc02d6249b28fd849d916f7e7f5dcdb5", - "sourceSha": "d6a794cd41ddefbffac5fb9f9195ded465d0c358", - "runId": "40326601181", - "note": "Attempt 1, architecture-2, artifact ID 12652606232, head dfdf9cab92ab94ed7b882553c8828f1337bac9d4, merge d6a794cd41ddefbffac5fb9f9195ded465d0c358; complete passing original TRX; ceil summed case ms/minimum 1; advisory, not wall time." - }, "pr38-df-1-architecture-3": { "kind": "hosted_trx", "artifactSha256": "113518ba341dcd597c26a6ed8ba6c8215a58b5107981a8869f582681f15532b6", @@ -421,13 +330,6 @@ "runId": "40326601181", "note": "Attempt 1, architecture-3, artifact ID 12652816720, head dfdf9cab92ab94ed7b882553c8828f1337bac9d4, merge d6a794cd41ddefbffac5fb9f9195ded465d0c358; complete passing original TRX; ceil summed case ms/minimum 1; advisory, not wall time." }, - "pr38-df-1-architecture-4": { - "kind": "hosted_trx", - "artifactSha256": "77251e81bee71b3c63337be660df235e93de35421616b083ebf11fd35ba732b4", - "sourceSha": "d6a794cd41ddefbffac5fb9f9195ded465d0c358", - "runId": "40326601181", - "note": "Attempt 1, architecture-4, artifact ID 12653460765, head dfdf9cab92ab94ed7b882553c8828f1337bac9d4, merge d6a794cd41ddefbffac5fb9f9195ded465d0c358; complete passing original TRX; ceil summed case ms/minimum 1; advisory, not wall time." - }, "pr38-df-1-architecture-5": { "kind": "hosted_trx", "artifactSha256": "d5e63d905231142da8b8e614bfb8ed285508a81235e0924d957f72840c604a1e", @@ -484,13 +386,6 @@ "runId": "40326601181", "note": "Attempt 1, ordinary-8, artifact ID 12652516461, head dfdf9cab92ab94ed7b882553c8828f1337bac9d4, merge d6a794cd41ddefbffac5fb9f9195ded465d0c358; complete passing original TRX; ceil summed case ms/minimum 1; advisory, not wall time." }, - "pr38-df-2-architecture-1": { - "kind": "hosted_trx", - "artifactSha256": "d0f7f75b02851e9ad2375a575053925d9601ccbe11aef33d919d3996989c8457", - "sourceSha": "d6a794cd41ddefbffac5fb9f9195ded465d0c358", - "runId": "40326601181", - "note": "Attempt 2, architecture-1, artifact ID 12653084289, head dfdf9cab92ab94ed7b882553c8828f1337bac9d4, merge d6a794cd41ddefbffac5fb9f9195ded465d0c358; complete passing original TRX; ceil summed case ms/minimum 1; advisory, not wall time." - }, "pr38-df-2-architecture-3": { "kind": "hosted_trx", "artifactSha256": "46d6ade56095884f79b05d6371224332c54be1c3db92825149553cc025c1cc8e", @@ -498,13 +393,6 @@ "runId": "40326601181", "note": "Attempt 2, architecture-3, artifact ID 12655345633, head dfdf9cab92ab94ed7b882553c8828f1337bac9d4, merge d6a794cd41ddefbffac5fb9f9195ded465d0c358; complete passing original TRX; ceil summed case ms/minimum 1; advisory, not wall time." }, - "pr38-df-2-architecture-5": { - "kind": "hosted_trx", - "artifactSha256": "62975572f9d8aaa8ba479063ccc7b4cbea759b547edaf6c963043c9e92fec99b", - "sourceSha": "d6a794cd41ddefbffac5fb9f9195ded465d0c358", - "runId": "40326601181", - "note": "Attempt 2, architecture-5, artifact ID 12653768947, head dfdf9cab92ab94ed7b882553c8828f1337bac9d4, merge d6a794cd41ddefbffac5fb9f9195ded465d0c358; complete passing original TRX; ceil summed case ms/minimum 1; advisory, not wall time." - }, "pr38-df-2-ordinary-0": { "kind": "hosted_console", "artifactSha256": "818a8a5a8a659fb698896be2bdf03bff3413045eb433e2337cde35a266712a11", @@ -603,13 +491,6 @@ "runId": "40326601181", "note": "Alias OutboxDispatchConventionTests->CleanupOwnerMutationTests, method Cleanup_owner_census_rejects_extra_acquisition_disposal_capture_and_observation_paths; unchanged dedented Fact body SHA256 74043e6354b36489a5f5e16686d445660ca4cbf1bf725b7711a03a9603518e23; inherits pr38-df-2-architecture-5. Advisory scalar; new selector not executed hosted." }, - "pr38-class-alias-delivery-lifecycle-4abc441d": { - "kind": "hosted_trx", - "artifactSha256": "62975572f9d8aaa8ba479063ccc7b4cbea759b547edaf6c963043c9e92fec99b", - "sourceSha": "d6a794cd41ddefbffac5fb9f9195ded465d0c358", - "runId": "40326601181", - "note": "Alias OutboxDispatchConventionTests->DeliveryLifecycleMutationTests, method Integration_delivery_lifecycle_census_rejects_typed_helper_async_delegate_and_missing_paths; unchanged dedented Fact body SHA256 aadc5f058bab1e61d4c0b8c32ba7e42fe170d6dd3191ac472d34b5ec9596e772; inherits pr38-df-2-architecture-5. Advisory scalar; new selector not executed hosted." - }, "pr38-class-alias-bridge-credential-8d40b87a": { "kind": "hosted_trx", "artifactSha256": "26837faa8779817aab5f299d42c19b0d8cae668f6bdf2143ff3c4d3b303457dc", @@ -679,6 +560,76 @@ "sourceSha": "d6a794cd41ddefbffac5fb9f9195ded465d0c358", "runId": "40326601181", "note": "Alias OutboxDispatchConventionTests+JobAuthorityMutationTests->JobAuthorityCensusMutationTests, method Background_job_authority_census_detects_extra_activation_and_removed_live_guards; unchanged dedented Fact body SHA256 7665f56665cd4acbc8ae85f135e0e41bd19c7e4499d17cb2a0cb6b8a1c8fd499; inherits pr38-df-1-architecture-1. Advisory scalar; new selector not executed hosted." + }, + "p032-local-architecture": { + "kind": "local_trx", + "artifactSha256": "3a787fa53ad878fd2b340fd0d7a1a6150704d5a4f52d64fd9e89d9706fe4dd78", + "sourceSha": "309e6d253c8e972fe62c9ad14a676b6d59eff0d5", + "runId": null, + "note": "Local architecture TRX at clean predecessor 309e6d25: 589 passed, 0 skips; 0 offline cases excluded. Exact-ID current-selector sums, ceil ms/max historical. Own source/tree/DLL before/after observers, not TRX-embedded SHA. IDs do not prove unchanged implementation. Shared-host concurrent no-build advisory; no mixed-head qualification or hosted-fit claim." + }, + "p032-local-ordinary-broad": { + "kind": "local_trx", + "artifactSha256": "54d943c9aa99d0b22055b0cb22ec7906f3c86f84838aaa2b9cdbbae17441ecbf", + "sourceSha": "309e6d253c8e972fe62c9ad14a676b6d59eff0d5", + "runId": null, + "note": "Local ordinary-broad TRX at clean predecessor 309e6d25: 844 passed, 0 skips; 1 offline cases excluded. Exact-ID current-selector sums, ceil ms/max historical. Own source/tree/DLL before/after observers, not TRX-embedded SHA. IDs do not prove unchanged implementation. Shared-host concurrent no-build advisory; no mixed-head qualification or hosted-fit claim." + }, + "p032-local-ordinary-complement": { + "kind": "local_trx", + "artifactSha256": "eaf728e7c81e35dbfb28624bfdfcb8a5e40c2528e9a0da7c4df3cd02a5c15347", + "sourceSha": "f3b8d764c6c3cb9e8d4666ec7cf7f12a5ce04422", + "runId": null, + "note": "Local ordinary-complement TRX at clean current f3b8d764: 2133 passed, 0 skips; 0 offline cases excluded. Exact-ID current-selector sums, ceil ms/max historical. Own source/tree/DLL before/after observers, not TRX-embedded SHA. IDs do not prove unchanged implementation. Shared-host concurrent no-build advisory; no mixed-head qualification or hosted-fit claim." + }, + "p032-hosted-architecture-40577851910-1-0": { + "kind": "hosted_trx", + "artifactSha256": "a09d12c9edc5b7f920bd53444ea8f9fb2ccd60885fd10c5ab93eea418196a7e3", + "sourceSha": "696671b5a13202df87b2a8903d8da550a3067c96", + "runId": "40577851910", + "note": "Complete passing architecture-0 producer: 56 cases, 52 whole selectors. Summed milliseconds, historical maxima retained; failed overall run is not whole-run qualification." + }, + "p032-hosted-architecture-40577851910-1-1": { + "kind": "hosted_trx", + "artifactSha256": "a7c412f576727b3b5bece115a8245a0c3177f4e4c513a5da0c65a41d4fc7a019", + "sourceSha": "696671b5a13202df87b2a8903d8da550a3067c96", + "runId": "40577851910", + "note": "Complete passing architecture-1 producer: 60 cases, 53 whole selectors. Summed milliseconds, historical maxima retained; failed overall run is not whole-run qualification." + }, + "p032-hosted-architecture-40577851910-1-2": { + "kind": "hosted_trx", + "artifactSha256": "59653e21b8814ad5f2da5f31229fce245987c5dade0b3aa4f8870506ec47a114", + "sourceSha": "696671b5a13202df87b2a8903d8da550a3067c96", + "runId": "40577851910", + "note": "Complete passing architecture-2 producer: 84 cases, 53 whole selectors. Summed milliseconds, historical maxima retained; failed overall run is not whole-run qualification." + }, + "p032-hosted-architecture-40577851910-1-3": { + "kind": "hosted_trx", + "artifactSha256": "9a006e3f58a6d694555456c00ed99cd13e585b327ba3d1461a8f40f028fea82f", + "sourceSha": "696671b5a13202df87b2a8903d8da550a3067c96", + "runId": "40577851910", + "note": "Complete passing architecture-3 producer: 111 cases, 53 whole selectors. Summed milliseconds, historical maxima retained; failed overall run is not whole-run qualification." + }, + "p032-hosted-architecture-40577851910-1-4": { + "kind": "hosted_trx", + "artifactSha256": "f46283d8dce4cf5373a837a55612850259ce4c34bdd137c006fbe2f0acf7eca8", + "sourceSha": "696671b5a13202df87b2a8903d8da550a3067c96", + "runId": "40577851910", + "note": "Complete passing architecture-4 producer: 103 cases, 52 whole selectors. Summed milliseconds, historical maxima retained; failed overall run is not whole-run qualification." + }, + "p032-hosted-architecture-40577851910-1-5": { + "kind": "hosted_trx", + "artifactSha256": "19e0bf8a2c34690502299801d28b3130fbf44cc4c68dfc87bb75f9ae40f93af1", + "sourceSha": "696671b5a13202df87b2a8903d8da550a3067c96", + "runId": "40577851910", + "note": "Complete passing architecture-5 producer: 175 cases, 53 whole selectors. Summed milliseconds, historical maxima retained; failed overall run is not whole-run qualification." + }, + "p032-authority-decomposed-local": { + "kind": "local_trx", + "artifactSha256": "d944c6abf53848f6e9ae7f425e4397a712d885e4d91634cb2efae239976ca8bc", + "sourceSha": "23262e95a02f93c7c3c77b458e87d92b7567f74f", + "runId": null, + "note": "Clean 23262e95 whole-Fact TRX: 7 Passed/0 skips; five decomposed authority Facts weighted, two catalogue controls excluded. Actual source/tree/four DLLs stable before/after. Exact summed ms/ceil; retired selector weight retained. Local advisory, not hosted fit or aggregate qualification." } }, "weights": [ @@ -709,14 +660,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AggregateWriteTests.Write_Port_Census_Distinguishes_Typed_Read_Keys_From_Wrapped_Domain_Writes", - "milliseconds": 9, - "source": "p031-hosted-architecture-0" + "milliseconds": 28, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AnalyzerWiringTests.Production_Analyzer_Reference_Is_Unconditional_And_Cannot_Be_Silenced_By_Build_Settings", - "milliseconds": 18, - "source": "p031-hosted-architecture-0" + "milliseconds": 28, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -733,8 +684,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AnalyzerWiringTests.The_EditorConfig_Control_Rejects_LS0002_Suppression", - "milliseconds": 1, - "source": "p031-hosted-architecture-2" + "milliseconds": 2, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -757,8 +708,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.ApiConventionTests.Deployment_Mode_Is_Required_Configuration", - "milliseconds": 3, - "source": "p031-hosted-architecture-0" + "milliseconds": 4, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -787,14 +738,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AsyncStoreInventoryTests.Inventory_coverage_rejects_added_removed_and_reclassified_storage", - "milliseconds": 1834, - "source": "p031-hosted-architecture-4" + "milliseconds": 1976, + "source": "p032-hosted-architecture-40577851910-1-1" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AsyncStoreInventoryTests.Inventory_coverage_rejects_every_removed_row_and_blank_vendor_classification", - "milliseconds": 4487, - "source": "p031-hosted-architecture-2" + "milliseconds": 6288, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", @@ -805,8 +756,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AsyncStoreInventoryTests.Migration_census_detects_select_into_after_escape_literals", - "milliseconds": 8, - "source": "p031-hosted-architecture-0" + "milliseconds": 26, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", @@ -817,32 +768,32 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AsyncStoreInventoryTests.Migration_census_preserves_literal_boundaries_before_table_declarations", - "milliseconds": 9, - "source": "p031-hosted-architecture-3" + "milliseconds": 17, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AsyncStoreInventoryTests.Migration_census_refuses_unsupported_ddl_for_the_expected_reason", - "milliseconds": 24, - "source": "p031-hosted-architecture-4" + "milliseconds": 48, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AsyncStoreInventoryTests.Migration_census_tracks_ordered_literal_and_typed_table_lifecycle", - "milliseconds": 8, - "source": "p031-hosted-architecture-2" + "milliseconds": 17, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AsyncStoreInventoryTests.Typed_migration_operations_cannot_hide_non_public_storage_or_schema_removal", - "milliseconds": 8, - "source": "p031-hosted-architecture-5" + "milliseconds": 17, + "source": "p032-hosted-architecture-40577851910-1-1" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuditConventionTests.AuditEntry_Inherits_Entity_Not_AuditableEntity", - "milliseconds": 1, - "source": "p031-hosted-architecture-0" + "milliseconds": 2, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", @@ -871,20 +822,20 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuditConventionTests.Modules_Do_Not_Write_AuditLog_Directly", - "milliseconds": 679, - "source": "hosted-architecture-39417319998-2-5" + "milliseconds": 1022, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuditConventionTests.No_Set_Based_Write_Bypasses_The_Audit_Capture", - "milliseconds": 356, - "source": "hosted-architecture-39417319998-2-5" + "milliseconds": 438, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuditConventionTests.OperationType_Enum_Matches_Catalog", - "milliseconds": 7, - "source": "hosted-architecture-39417319998-2-1" + "milliseconds": 8, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -913,8 +864,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuditCoverageTests.A_slug_classified_twice_fails_whichever_row_comes_first", - "milliseconds": 13, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 77, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -931,14 +882,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuditCoverageTests.Every_Module_With_An_Aggregate_Or_A_Request_Has_A_Matrix", - "milliseconds": 18, - "source": "hosted-architecture-39417319998-2-1" + "milliseconds": 22, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuditCoverageTests.Every_Shipped_Request_Is_Registered", - "milliseconds": 28, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 41, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -967,14 +918,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuditCoverageTests.Integration_Only_Module_Requires_An_Audit_Matrix", - "milliseconds": 4, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 23, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuditCoverageTests.The_Forward_Sweep_Reads_Only_The_Registering_Module_s_Matrix", - "milliseconds": 8, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 13, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", @@ -1009,20 +960,20 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuthenticatedHttpSurfaceTests.Authenticated_http_census_detects_extra_routes_mutations_and_missing_subjects", - "milliseconds": 24010, - "source": "hosted-architecture-39417319998-2-1" + "milliseconds": 61999, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuthenticatedHttpSurfaceTests.Authenticated_http_census_follows_production_helpers_and_nested_dispatch", - "milliseconds": 53305, - "source": "pr38-df-1-architecture-4" + "milliseconds": 118912, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuthenticationAuthorityTests.Authority_census_closes_shape_and_approved_owner_exports", - "milliseconds": 53887, - "source": "pr38-df-1-architecture-5" + "milliseconds": 62101, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", @@ -1033,14 +984,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuthenticationAuthorityTests.Authority_census_rejects_actual_identity_forwarding_through_opaque_storage", - "milliseconds": 7210, - "source": "p031-hosted-architecture-4" + "milliseconds": 11157, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.AuthenticationAuthorityTests.Authority_census_rejects_actual_identity_forwarding_to_opaque_helpers", - "milliseconds": 7942, - "source": "p031-hosted-architecture-1" + "milliseconds": 11177, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", @@ -1069,8 +1020,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.CorpusConsistencyTests.Standard_Status_Headers_Match_The_Index", - "milliseconds": 19, - "source": "p031-hosted-architecture-0" + "milliseconds": 40, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -1081,8 +1032,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.CorpusConsistencyTests.The_Corpus_Guards_Can_Actually_Fail", - "milliseconds": 20, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 38, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", @@ -1099,8 +1050,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.CrossCuttingFoundationTests.Handlers_Return_Result", - "milliseconds": 44, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 56, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -1111,8 +1062,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.CrossCuttingFoundationTests.IExceptionHandler_Registered_AtStartup", - "milliseconds": 16, - "source": "p031-hosted-architecture-5" + "milliseconds": 145, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", @@ -1129,8 +1080,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.CrossCuttingFoundationTests.LearnStack_Modules_DoNotReference_Hub", - "milliseconds": 294, - "source": "p031-hosted-architecture-3" + "milliseconds": 743, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", @@ -1147,8 +1098,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.CrossCuttingFoundationTests.Modules_Do_Not_Inject_IEventBus_Directly", - "milliseconds": 714, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 716, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -1165,8 +1116,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.CrossCuttingFoundationTests.Modules_Do_Not_Reference_Sentry_SDK_Directly", - "milliseconds": 202, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 203, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -1207,20 +1158,20 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.CustomizationProjectionTests.Projection_validator_guard_follows_domain_contracts_and_external_production_helpers", - "milliseconds": 125, - "source": "p031-hosted-architecture-3" + "milliseconds": 160, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.CustomizationRegistryTests.Composite_Renderer_Keys_Match_The_Frontend_Registry", - "milliseconds": 6, - "source": "p031-hosted-architecture-2" + "milliseconds": 7, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.CustomizationRegistryTests.Generic_Primitives_Only_In_Renderer", - "milliseconds": 8, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 47, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", @@ -1249,14 +1200,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.DataProtectionConventionTests.Mapped_Properties_Have_Explicit_Personal_Data_Classification", - "milliseconds": 16, - "source": "p031-hosted-architecture-2" + "milliseconds": 234, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.DataProtectionConventionTests.Production_model_accepts_the_same_neutral_property_with_an_explicit_declaration", - "milliseconds": 111, - "source": "p031-hosted-architecture-5" + "milliseconds": 667, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -1273,8 +1224,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.DomainEventHandlerTests.Domain_Event_Handlers_Are_Outbox_Only", - "milliseconds": 62, - "source": "local-architecture" + "milliseconds": 70, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -1297,14 +1248,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.DomainEventHandlerTests.Domain_handler_guard_reaches_planted_roots_and_helpers_in_every_production_assembly", - "milliseconds": 736, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 855, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.DomainEventHandlerTests.Domain_handler_guard_rejects_each_forbidden_dependency", - "milliseconds": 899, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 1574, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -1315,8 +1266,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.DomainGenericityTests.Core_Modules_HaveNo_DomainSpecific_Names", - "milliseconds": 1181, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 1603, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -1339,20 +1290,20 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.DomainGenericityTests.Production_Projects_Do_Not_Reference_The_Seeder", - "milliseconds": 31, - "source": "hosted-architecture-39417319998-2-1" + "milliseconds": 53, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.DomainGenericityTests.Seeder_Reference_Fence_Detects_Compiled_And_Restored_Edges", - "milliseconds": 4, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 12, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.DomainGenericityTests.The_Domain_Term_Scan_Can_Actually_Fail", - "milliseconds": 90, - "source": "p031-hosted-architecture-2" + "milliseconds": 188, + "source": "p032-hosted-architecture-40577851910-1-1" }, { "assembly": "learnstack.tests.architecture", @@ -1363,8 +1314,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.DomainModelTests.Aggregates_Do_Not_Redeclare_Entity_Equality", - "milliseconds": 20, - "source": "hosted-architecture-39417319998-2-1" + "milliseconds": 45, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", @@ -1405,14 +1356,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.GlobalIdentityModelTests.Generated_snapshot_matches_the_current_Identity_model", - "milliseconds": 123, - "source": "p031-hosted-architecture-3" + "milliseconds": 704, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.GlobalIdentityModelTests.Historical_Identity_migrations_retain_their_ordered_SQL_operations", - "milliseconds": 37, - "source": "pr38-df-1-architecture-3" + "milliseconds": 46, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", @@ -1423,8 +1374,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.GlobalIdentityModelTests.User_binding_has_permanent_unique_key_creation_pair_and_sensitive_raw_subject", - "milliseconds": 460, - "source": "p031-hosted-architecture-2" + "milliseconds": 709, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -1432,6 +1383,12 @@ "milliseconds": 13, "source": "p031-hosted-architecture-5" }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.IdentityAuditConventionTests.Authorization_Evidence_Uses_Trusted_Bounded_Provenance", + "milliseconds": 357261, + "source": "p032-hosted-architecture-40577851910-1-5" + }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.IdentityAuditConventionTests.Global_Identity_Creation_Uses_The_Common_Audit_Owner", @@ -1441,8 +1398,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.IdentityAuditConventionTests.Identity_infrastructure_core_edges_are_confined_to_exact_types_and_members", - "milliseconds": 627, - "source": "p031-hosted-architecture-1" + "milliseconds": 1378, + "source": "p032-hosted-architecture-40577851910-1-2" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.IdentityAuditConventionTests.Live_Authorization_Uses_Bounded_Independent_Owners", + "milliseconds": 35760, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -1450,17 +1413,47 @@ "milliseconds": 396, "source": "p031-hosted-architecture-3" }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.IdentityAuthorizationConventionTests.Authorization_borrowers_and_lifecycle_callers_are_exact_and_publish_no_raw_source_or_callback", + "milliseconds": 14130, + "source": "p032-hosted-architecture-40577851910-1-2" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.IdentityAuthorizationConventionTests.Authorization_candidates_have_one_verified_minter_and_closed_lifetime_evidence", + "milliseconds": 18384, + "source": "p032-hosted-architecture-40577851910-1-0" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.IdentityAuthorizationConventionTests.Authorization_checkout_awaits_the_shared_role_guard_with_its_deadline_before_begin", + "milliseconds": 1162, + "source": "p032-hosted-architecture-40577851910-1-1" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.IdentityAuthorizationConventionTests.Authorization_owner_awaits_the_real_mode_statement_before_exact_candidate_announcement", + "milliseconds": 55, + "source": "p032-local-architecture" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.IdentityAuthorizationConventionTests.Authorization_reader_executes_only_bounded_nonlocking_identity_and_same_child_lifecycle_statements", + "milliseconds": 933, + "source": "p032-hosted-architecture-40577851910-1-4" + }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.IdentityFoundationConventionTests.Identity_Membership_Invariants_Use_Fresh_Ordered_Locks", - "milliseconds": 1006, - "source": "p031-hosted-architecture-4" + "milliseconds": 1179, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.IdentityFoundationConventionTests.Identity_Seed_Uses_Finite_Adopted_Manifests", - "milliseconds": 19, - "source": "p031-hosted-architecture-2" + "milliseconds": 113, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", @@ -1501,8 +1494,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.ModuleDependencyTests.CoreApplication_DoesNotDependOn_Any_Infrastructure_Or_Module", - "milliseconds": 3, - "source": "p031-hosted-architecture-4" + "milliseconds": 4, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -1513,20 +1506,20 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.ModuleDependencyTests.Domain_Does_Not_Depend_On_Microsoft_EntityFrameworkCore_Except_Vogen_Emitted_Converters", - "milliseconds": 108, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 212, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.ModuleDependencyTests.Meta_NetArchTest_DetectsAPlantedViolation", - "milliseconds": 421, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 463, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.ModuleDependencyTests.ModuleApplication_References_Only_Its_Own_Layers_And_Other_Contracts", - "milliseconds": 9, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 14, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -1570,6 +1563,36 @@ "milliseconds": 597, "source": "hosted-architecture-39417319998-2-1" }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.OperationPermissionConventionTests.Permission_Definitions_DeclareScope", + "milliseconds": 85, + "source": "p032-hosted-architecture-40577851910-1-2" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.OperationPermissionConventionTests.Permission_Keys_Match_Convention", + "milliseconds": 33, + "source": "p032-hosted-architecture-40577851910-1-4" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.OperationPermissionConventionTests.Permission_Registry_Has_DeniedTest", + "milliseconds": 1497, + "source": "p032-hosted-architecture-40577851910-1-0" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.OperationPermissionConventionTests.Permission_Scope_Matches_Resource_Scope", + "milliseconds": 36, + "source": "p032-hosted-architecture-40577851910-1-5" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.OperationPolicyOwnershipTests.Protected_Operations_Require_Explicit_Live_Policies", + "milliseconds": 53949, + "source": "p032-hosted-architecture-40577851910-1-2" + }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+BridgeCredentialMutationTests.Bridge_credential_census_rejects_changed_signatures_captures_and_missing_subjects", @@ -1591,14 +1614,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+CredentialBindingMutationTests.Credential_census_rejects_compiled_connection_string_indexers_and_composed_keys", - "milliseconds": 42130, - "source": "pr38-class-alias-credential-binding-1805dd42" + "milliseconds": 48105, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+CredentialBindingMutationTests.Credential_census_rejects_crossed_keys_changed_closures_and_missing_subjects", - "milliseconds": 154403, - "source": "pr38-class-alias-credential-binding-f900f146" + "milliseconds": 236652, + "source": "p032-hosted-architecture-40577851910-1-1" }, { "assembly": "learnstack.tests.architecture", @@ -1609,8 +1632,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+DeliveryLifecycleMutationTests.Integration_delivery_lifecycle_census_rejects_typed_helper_async_delegate_and_missing_paths", - "milliseconds": 172694, - "source": "pr38-class-alias-delivery-lifecycle-4abc441d" + "milliseconds": 276909, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", @@ -1624,23 +1647,53 @@ "milliseconds": 132079, "source": "pr38-class-alias-integration-invocation-572fda26" }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+JobAuthorityCensusMutationTests.Background_job_admission_rejects_extra_activators_and_removed_privileged_guards", + "milliseconds": 8967, + "source": "p032-authority-decomposed-local" + }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+JobAuthorityCensusMutationTests.Background_job_authority_census_detects_extra_activation_and_removed_live_guards", "milliseconds": 603500, "source": "pr38-class-alias-job-authority-census-ef981129" }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+JobAuthorityCensusMutationTests.Background_job_authority_provenance_rejects_forgery_and_missing_publication_gates", + "milliseconds": 37824, + "source": "p032-authority-decomposed-local" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+JobAuthorityCensusMutationTests.Background_job_framework_capabilities_reject_extra_dispatch_and_issuance", + "milliseconds": 45128, + "source": "p032-authority-decomposed-local" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+JobAuthorityCensusMutationTests.Background_job_generation_binding_rejects_extra_capabilities_and_missing_sites", + "milliseconds": 80941, + "source": "p032-authority-decomposed-local" + }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+JobAuthorityCensusMutationTests.Background_job_routing_credentials_reject_store_and_holder_escapes", + "milliseconds": 22619, + "source": "p032-authority-decomposed-local" + }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+JobAuthorityMutationTests.Background_job_application_source_ownership_has_exact_nonempty_callers_and_rejects_escapes", - "milliseconds": 101387, - "source": "pr38-df-2-architecture-1" + "milliseconds": 292856, + "source": "p032-hosted-architecture-40577851910-1-1" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+JobAuthorityMutationTests.Credential_census_rejects_generic_direct_delegate_async_and_extra_helper_resolution", - "milliseconds": 469168, - "source": "p031-hosted-architecture-3" + "milliseconds": 474452, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", @@ -1657,8 +1710,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+JobMetricMutationTests.Background_job_metric_census_rejects_bait_callbacks_hidden_registration_and_skipped_binding", - "milliseconds": 34392, - "source": "pr38-class-alias-job-metric-a826fef6" + "milliseconds": 43382, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", @@ -1681,8 +1734,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+JobRecipientMutationTests.Background_job_recipient_census_detects_extra_authority_and_missing_real_fences", - "milliseconds": 206098, - "source": "pr38-class-alias-job-recipient" + "milliseconds": 218224, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -1693,8 +1746,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests+ResolutionMutationTests.Resolution_walk_detects_loading_construction_async_and_polymorphic_helpers", - "milliseconds": 277724, - "source": "pr38-class-alias-resolution" + "milliseconds": 321649, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -1705,14 +1758,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.BackgroundJob_Completion_Is_Owner_Atomic_And_Fenced", - "milliseconds": 599, - "source": "p031-hosted-architecture-2" + "milliseconds": 786, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.BackgroundJob_Definitions_Stay_In_Their_Owning_Module", - "milliseconds": 16261, - "source": "p031-hosted-architecture-0" + "milliseconds": 16927, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", @@ -1723,20 +1776,20 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.BackgroundJob_Fanout_Uses_Recipient_Bound_Tenant_Admission", - "milliseconds": 682, - "source": "p031-hosted-architecture-3" + "milliseconds": 737, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.BackgroundJob_Ordinary_Execution_Has_No_Platform_Authority", - "milliseconds": 927, - "source": "p031-hosted-architecture-0" + "milliseconds": 947, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.BackgroundJob_Storage_And_Credentials_Are_Confined", - "milliseconds": 68217, - "source": "pr38-df-1-architecture-3" + "milliseconds": 82175, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -1747,8 +1800,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.BackgroundJob_Worker_Pools_Reserve_Independent_Capacity", - "milliseconds": 298416, - "source": "p031-hosted-architecture-4" + "milliseconds": 332332, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -1765,14 +1818,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Background_job_recipient_history_controls_reject_detached_cleanup_and_unfenced_status", - "milliseconds": 3776, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 5573, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Background_job_recipient_shared_insert_controls_preserve_successful_production", - "milliseconds": 12443, - "source": "p031-hosted-architecture-2" + "milliseconds": 13162, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -1801,8 +1854,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Delivery_SQL_census_rejects_quoted_CTE_helper_overload_missing_and_payload_paths", - "milliseconds": 5809, - "source": "p031-hosted-architecture-5" + "milliseconds": 8136, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -1813,8 +1866,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Delivery_Terminal_Audit_Identity_Is_Row_Bound", - "milliseconds": 586, - "source": "p031-hosted-architecture-5" + "milliseconds": 597, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -1843,8 +1896,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Delivery_registration_census_rejects_missing_duplicate_replaced_and_detached_subjects", - "milliseconds": 27, - "source": "p031-hosted-architecture-3" + "milliseconds": 365, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -1855,8 +1908,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Integration_Event_Handlers_Use_InboxGuard", - "milliseconds": 1405, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 1893, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -1867,14 +1920,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Integration_handler_census_rejects_missing_assemblies_layers_and_production_subjects", - "milliseconds": 2741, - "source": "p031-hosted-architecture-5" + "milliseconds": 6769, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Integration_handler_discovery_sees_inherited_generic_and_explicit_contracts", - "milliseconds": 30, - "source": "p031-hosted-architecture-0" + "milliseconds": 57, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -1885,20 +1938,20 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Integration_manifest_census_rejects_missing_rebound_duplicate_and_unscoped_bindings", - "milliseconds": 328, - "source": "p031-hosted-architecture-1" + "milliseconds": 678, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Job_terminal_census_detects_missing_subjects_extra_subtypes_factories_and_all_caller_escape_shapes", - "milliseconds": 25195, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 27728, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Job_trace_source_and_actual_Activity_ownership_refuse_chained_Start_and_hidden_source_creation", - "milliseconds": 7525, - "source": "p031-hosted-architecture-1" + "milliseconds": 8712, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", @@ -1909,8 +1962,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Observation_registration_control_detects_missing_duplicate_or_replaced_worker_and_check", - "milliseconds": 94, - "source": "p031-hosted-architecture-2" + "milliseconds": 585, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -1921,14 +1974,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Observation_walk_keeps_clean_memory_helpers_and_checks_real_gauge_callbacks", - "milliseconds": 2056, - "source": "p031-hosted-architecture-3" + "milliseconds": 2663, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Outbox_Health_And_Metrics_Are_Bounded", - "milliseconds": 213, - "source": "p031-hosted-architecture-0" + "milliseconds": 218, + "source": "p032-hosted-architecture-40577851910-1-1" }, { "assembly": "learnstack.tests.architecture", @@ -1945,8 +1998,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Outbox_SQL_census_preserves_reads_and_unrelated_table_writes", - "milliseconds": 598, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 705, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", @@ -1957,26 +2010,26 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Outbox_Type_Resolution_Is_Declared_And_Bounded", - "milliseconds": 4281, - "source": "p031-hosted-architecture-5" + "milliseconds": 6214, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Publication_census_rejects_interface_concrete_delegate_async_and_unowned_attempt_paths", - "milliseconds": 9171, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 10822, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Resolution_walk_preserves_declared_type_metadata_and_clean_helpers", - "milliseconds": 5232, - "source": "hosted-architecture-39417319998-2-1" + "milliseconds": 7539, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Subscriber_terminal_census_rejects_extra_mint_paths_crossed_owners_and_missing_subjects", - "milliseconds": 2114, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 2412, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -1987,8 +2040,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxDispatchConventionTests.Terminal_census_detects_interface_concrete_delegate_async_and_extra_writer_paths", - "milliseconds": 2834, - "source": "p031-hosted-architecture-3" + "milliseconds": 2982, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", @@ -2005,8 +2058,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxProducerConventionTests.Outbox_Enqueue_Uses_The_Announced_Transaction", - "milliseconds": 413, - "source": "p031-hosted-architecture-0" + "milliseconds": 505, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", @@ -2017,8 +2070,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.OutboxProducerConventionTests.Producer_walk_reaches_helpers_in_other_production_assemblies", - "milliseconds": 4000, - "source": "hosted-architecture-current" + "milliseconds": 7230, + "source": "p032-hosted-architecture-40577851910-1-1" }, { "assembly": "learnstack.tests.architecture", @@ -2035,50 +2088,50 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.Migrate_Target_Applies_The_Tenancy_Chain_First", - "milliseconds": 17, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 23, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.Migrate_Target_Covers_Every_Migration_Chain", - "milliseconds": 13, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 23, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.Migrate_Target_Reads_The_Role_Through_A_Quoted_Value", - "milliseconds": 31, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 255, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.Migrate_Target_Redacts_A_Quoted_Value_Whole", - "milliseconds": 107, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 2540, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.Migrate_Target_Refuses_A_Uri_Without_Echoing_Its_Userinfo", - "milliseconds": 31, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 207, + "source": "p032-hosted-architecture-40577851910-1-1" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.Migrate_Target_Refuses_An_Aliased_Runtime_Credential", - "milliseconds": 67, - "source": "p031-hosted-architecture-3" + "milliseconds": 763, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.Migration_Startup_Project_References_EntityFrameworkCore_Design", - "milliseconds": 2, - "source": "p031-hosted-architecture-2" + "milliseconds": 4, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork", - "milliseconds": 587, - "source": "hosted-architecture-predecessor" + "milliseconds": 1894, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -2095,14 +2148,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.The_Docker_Trait_Sweep_Can_Actually_Fail", - "milliseconds": 284, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 1479, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.The_Migration_Order_Guard_Rejects_Each_Dependent_Chain_First", - "milliseconds": 49, - "source": "p031-hosted-architecture-0" + "milliseconds": 121, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -2119,8 +2172,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PersistenceConventionTests.The_parallelism_census_reaches_shared_code_and_bounds_its_singleton_exceptions", - "milliseconds": 141, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 391, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", @@ -2149,14 +2202,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PlatformAdminScopeConventionTests.PlatformAdminScope_Entry_Requires_Platform_Permission", - "milliseconds": 139, - "source": "p031-hosted-architecture-1" + "milliseconds": 285, + "source": "p032-hosted-architecture-40577851910-1-2" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PlatformAdminScopeConventionTests.Platform_DataSource_Resolved_Only_By_PlatformAdminScope", - "milliseconds": 624, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 817, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -2167,8 +2220,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.ProductionAssembliesTests.Project_census_includes_every_project_name_and_excludes_generated_projects", - "milliseconds": 6, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 8, + "source": "p032-hosted-architecture-40577851910-1-1" }, { "assembly": "learnstack.tests.architecture", @@ -2197,8 +2250,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PublicSurfaceTests.PublicSurface_Endpoints_Dispatch_Through_The_Pipeline", - "milliseconds": 157, - "source": "hosted-architecture-current" + "milliseconds": 302, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", @@ -2209,8 +2262,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.PublicSurfaceTests.PublicSurface_Requests_Are_Registered_Off", - "milliseconds": 29, - "source": "p031-hosted-architecture-5" + "milliseconds": 44, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -2227,26 +2280,26 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.RepositoryLayoutTests.Commit_Subject_Grammar_Is_Stated_Once", - "milliseconds": 3536, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 8720, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.RepositoryLayoutTests.Frontend_Has_Only_The_Web_App", - "milliseconds": 3, - "source": "hosted-architecture-39417319998-2-1" + "milliseconds": 4, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.RepositoryLayoutTests.No_Source_Folder_Named_Verticals", - "milliseconds": 8, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 28, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.RequestSurfaceTests.AllowsUnresolvedTenantContext_Only_On_Provisioning_Commands", - "milliseconds": 18, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 25, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", @@ -2257,20 +2310,20 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.RequestSurfaceTests.PublicSurface_Requests_Are_Never_ReadSensitive", - "milliseconds": 30, - "source": "p031-hosted-architecture-5" + "milliseconds": 53, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.RequestSurfaceTests.Requests_Are_Never_Streamed", - "milliseconds": 14, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 25, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.RequestSurfaceTests.The_PublicSurface_Cross_Check_Can_Actually_Fail", - "milliseconds": 5, - "source": "p031-hosted-architecture-5" + "milliseconds": 8, + "source": "p032-hosted-architecture-40577851910-1-3" }, { "assembly": "learnstack.tests.architecture", @@ -2281,14 +2334,14 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.RequestSurfaceTests.The_Sweep_Covers_Every_Production_Assembly", - "milliseconds": 16, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 23, + "source": "p032-local-architecture" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.SeederConventionTests.Seed_Literal_Source_Is_Complete_And_Readable", - "milliseconds": 184, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 260, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -2347,26 +2400,26 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenancyConventionTests.Effective_Host_Computed_In_One_Place", - "milliseconds": 47, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 110, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenancyConventionTests.Host_Resolution_Makes_No_Outbound_Calls", - "milliseconds": 66, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 132, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenancyConventionTests.Organization_Aggregate_Declared_In_Tenancy_Domain", - "milliseconds": 4, - "source": "hosted-architecture-39417319998-2-1" + "milliseconds": 5, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenancyConventionTests.Out_Of_Band_Setters_Open_Read_Only_Transactions", - "milliseconds": 109, - "source": "hosted-architecture-39417319998-2-4" + "milliseconds": 167, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -2383,8 +2436,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenancyConventionTests.Tenant_Scope_Widening_Is_Never_Set_From_Request_Input", - "milliseconds": 169, - "source": "hosted-architecture-39417319998-2-1" + "milliseconds": 629, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -2401,26 +2454,26 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenancyConventionTests.The_Scope_Setter_Scan_Can_Actually_Fail", - "milliseconds": 6, - "source": "p031-hosted-architecture-0" + "milliseconds": 64, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenancyConventionTests.The_Setter_Scan_Can_Actually_Fail", - "milliseconds": 76, - "source": "p031-hosted-architecture-3" + "milliseconds": 78, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenancyConventionTests.The_Slug_Scan_Finds_The_Files_It_Exempts", - "milliseconds": 911, - "source": "p031-hosted-architecture-0" + "milliseconds": 1165, + "source": "p032-hosted-architecture-40577851910-1-0" }, { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenantContextConstructionTests.HostScope_Is_Constructed_Only_By_The_Factory", - "milliseconds": 20, - "source": "hosted-architecture-39417319998-2-0" + "milliseconds": 40, + "source": "p032-hosted-architecture-40577851910-1-5" }, { "assembly": "learnstack.tests.architecture", @@ -2431,8 +2484,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenantContextConstructionTests.Organization_reader_census_rejects_missing_keys_extra_readers_and_unbounded_pages", - "milliseconds": 6359, - "source": "hosted-architecture-39417319998-2-1" + "milliseconds": 9372, + "source": "p032-hosted-architecture-40577851910-1-1" }, { "assembly": "learnstack.tests.architecture", @@ -2455,8 +2508,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenantContextConstructionTests.TenantContext_Is_Instantiated_In_One_File", - "milliseconds": 23, - "source": "hosted-architecture-39417319998-2-2" + "milliseconds": 34, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -2479,8 +2532,8 @@ { "assembly": "learnstack.tests.architecture", "method": "LearnStack.Tests.Architecture.TenantScopingTests.Every_Scoping_Interface_Carries_Its_Marker", - "milliseconds": 2, - "source": "p031-hosted-architecture-4" + "milliseconds": 3, + "source": "p032-hosted-architecture-40577851910-1-4" }, { "assembly": "learnstack.tests.architecture", @@ -2500,6 +2553,12 @@ "milliseconds": 2, "source": "p031-hosted-architecture-5" }, + { + "assembly": "learnstack.tests.architecture", + "method": "LearnStack.Tests.Architecture.TrustedOperationSourceConventionTests.Finite_operation_descriptors_have_exact_minters_private_construction_and_revocable_evidence", + "milliseconds": 10755, + "source": "p032-hosted-architecture-40577851910-1-2" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.ApiHandlerCompositionTests.Every_Shipped_Request_Handler_Resolves_From_The_Api_Container", @@ -2596,6 +2655,12 @@ "milliseconds": 7658, "source": "p031-hosted-ordinary-4" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AccountSiteHttpTests.Real_platform_membership_success_still_masks_AccountSite_before_configuration_access", + "milliseconds": 3194, + "source": "p032-local-ordinary-complement" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.AccountSiteQueryTests.Actual_account_query_cannot_join_a_writable_outer_owner_or_reach_its_reader", @@ -2611,20 +2676,20 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.AccountSiteQueryTests.Actual_account_query_physically_refuses_SQL_and_EF_writes_with_writable_controls", - "milliseconds": 187, - "source": "p031-hosted-ordinary-6" + "milliseconds": 190, + "source": "p032-local-ordinary-broad" }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.AccountSiteQueryTests.Actual_account_query_reads_contrasting_tenants_and_mapped_organization_without_query_audits", - "milliseconds": 246, - "source": "p031-hosted-ordinary-5" + "milliseconds": 251, + "source": "p032-local-ordinary-broad" }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.AccountSiteQueryTests.Actual_account_query_rechecks_live_tenant_mapped_and_claim_selected_organization", - "milliseconds": 1442, - "source": "p031-hosted-ordinary-2" + "milliseconds": 1660, + "source": "p032-local-ordinary-broad" }, { "assembly": "learnstack.tests.integration", @@ -2635,8 +2700,8 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.AccountSiteQueryTests.Real_configuration_reader_independently_refuses_invalid_host_provenance_with_same_frame_control", - "milliseconds": 276, - "source": "p031-hosted-ordinary-0" + "milliseconds": 394, + "source": "p032-local-ordinary-broad" }, { "assembly": "learnstack.tests.integration", @@ -2647,14 +2712,14 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.AccountSiteQueryTests.Real_configuration_reader_refuses_missing_or_foreign_EF_transaction_with_same_role_controls", - "milliseconds": 122, - "source": "p031-hosted-ordinary-10" + "milliseconds": 156, + "source": "p032-local-ordinary-broad" }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.AccountSiteQueryTests.Real_configuration_reader_refuses_unannounced_transaction_then_reads_after_announcement", - "milliseconds": 69, - "source": "pr38-b-ordinary-0" + "milliseconds": 79, + "source": "p032-local-ordinary-broad" }, { "assembly": "learnstack.tests.integration", @@ -3130,6 +3195,222 @@ "milliseconds": 860, "source": "p031-hosted-ordinary-7" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests.A_lost_evidence_COMMIT_acknowledgement_refuses_otherwise_successful_RO_or_ownerless_disclosure", + "milliseconds": 73, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests.A_real_denial_INSERT_failure_rolls_back_the_actual_module_write_and_refuses_outer_success", + "milliseconds": 49, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests.Already_refused_403_or_404_survives_evidence_acknowledgement_failure_without_dispatch", + "milliseconds": 43, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests.An_absorbed_admitted_inner_refusal_survives_its_independent_token_cancellation_before_observation", + "milliseconds": 124, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests.Authorization_Denials_Have_PreSuccess_Durability_And_Stable_Identity", + "milliseconds": 245, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests.Exact_event_reconciliation_does_not_promote_an_unknown_business_COMMIT_to_success", + "milliseconds": 50, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests.Physical_RO_and_ownerless_success_wait_for_standalone_COMMIT_acknowledgement_before_disclosure", + "milliseconds": 77, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests.SIGKILL_before_or_immediately_after_physical_COMMIT_proves_atomic_denial_durability_without_finally", + "milliseconds": 1440, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests.Trusted_composition_failure_prevents_an_absorbing_RW_owner_from_committing_any_business_state", + "milliseconds": 39, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceSourceTests.Actual_connection_cleanup_spans_both_transactions_and_controls_source_and_job_shutdown", + "milliseconds": 29, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceSourceTests.Reported_statement_cleanup_fault_prevents_verification_and_retains_the_slot_after_other_cleanup", + "milliseconds": 12, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceSourceTests.Startup_qualification_refuses_another_authority_without_enabling_evidence_issuance", + "milliseconds": 11, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceSourceTests.Two_retained_evidence_connectors_refuse_excess_without_waiting_and_release_only_after_actual_cleanup", + "milliseconds": 22, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceSourceTests.Verification_disposes_the_aborted_INSERT_transaction_before_fresh_physical_READ_ONLY_on_the_same_connector", + "milliseconds": 35, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceSourceTests.Verification_reuses_the_original_deadline_and_expiry_retains_capacity_until_cleanup", + "milliseconds": 5021, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceStoreTests.A_primary_key_collision_with_any_other_persisted_field_changed_is_not_durable_evidence", + "milliseconds": 531, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceStoreTests.Exact_duplicate_evidence_uses_one_connector_and_fresh_RO_verification_with_JSONB_value_equality", + "milliseconds": 85, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceStoreTests.Lost_commit_acknowledgement_can_reconcile_the_same_event_without_claiming_a_business_outcome", + "milliseconds": 26, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceStoreTests.Missing_row_or_another_unique_constraint_never_turns_23505_into_durability", + "milliseconds": 80, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceStoreTests.Pending_evidence_is_atomic_with_its_actual_RW_owner_and_reconciliation_reuses_the_fixed_microsecond_draft", + "milliseconds": 77, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationEvidenceStoreTests.Pending_evidence_refuses_foreign_or_unannounced_scope_and_physical_read_only_owners", + "milliseconds": 59, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationPoolProgressTests.A_separate_client_pool_on_the_saturated_business_alias_fails_the_same_progress_control", + "milliseconds": 4174, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationPoolProgressTests.Actual_denial_is_durable_before_readonly_or_ownerless_success_while_all_business_backends_are_held", + "milliseconds": 344, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationPoolProgressTests.Actual_directory_policy_admission_completes_while_every_business_server_transaction_is_held", + "milliseconds": 275, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationPoolProgressTests.Admitted_real_authorization_completes_while_every_business_server_transaction_is_held", + "milliseconds": 222, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationPoolProgressTests.Evidence_on_the_saturated_business_server_alias_blocks_success_and_the_same_source_recovers_after_release", + "milliseconds": 4089, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationPoolProgressTests.Live_business_database_marker_qualifies_both_independent_lanes_through_direct_and_transaction_pooler_aliases", + "milliseconds": 87, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationPoolProgressTests.Transaction_pooler_reserves_two_four_two_distinct_backends_for_the_same_database_and_app_role", + "milliseconds": 82, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationReadSourceTests.Actual_connection_disposal_retains_source_and_job_ownership_until_its_positive_completion", + "milliseconds": 38, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationReadSourceTests.Cancellation_bounds_the_actual_role_guard_statement_before_any_candidate_owner_can_be_published", + "milliseconds": 8037, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationReadSourceTests.Cancellation_during_actual_setup_keeps_capacity_and_shutdown_join_until_the_operation_really_finishes", + "milliseconds": 28, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationReadSourceTests.Candidate_owner_is_physically_read_only_exactly_announced_and_cannot_join_or_retarget", + "milliseconds": 24, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationReadSourceTests.Four_retained_physical_owners_refuse_excess_immediately_and_recover_only_after_disposal", + "milliseconds": 61, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationReadSourceTests.One_deadline_covers_statements_and_completion_without_a_fresh_commit_budget", + "milliseconds": 5026, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationReadSourceTests.Qualification_refuses_another_installed_authority_and_never_enables_candidate_issuance", + "milliseconds": 16, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.AuthorizationReadSourceTests.Reported_command_cleanup_failure_retains_capacity_even_after_transaction_and_connection_disposal", + "milliseconds": 14, + "source": "p032-local-ordinary-complement" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.BackgroundJobAdmissionTests.A_held_start_COMMIT_acknowledgement_honors_the_SQL_deadline_with_an_unlimited_default", @@ -5392,6 +5673,12 @@ "milliseconds": 3261, "source": "p031-hosted-ordinary-2" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.CustomizationDefaultDeliveryTests.Both_real_initializers_capture_only_their_validated_delivery_and_revoke_evidence_after_completion", + "milliseconds": 799, + "source": "p032-local-ordinary-broad" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.CustomizationDefaultDeliveryTests.Reaccepting_the_exact_provisioning_fact_does_not_repeat_inbox_effect_or_audit", @@ -5596,6 +5883,36 @@ "milliseconds": 38, "source": "p031-hosted-ordinary-3" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.DatabaseQualificationProofTests.Closed_or_expired_proof_cannot_qualify_either_lane", + "milliseconds": 5032, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.DatabaseQualificationProofTests.One_live_business_proof_qualifies_both_lanes_and_disposal_releases_its_actual_marker", + "milliseconds": 38, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.DatabaseQualificationProofTests.Proof_factory_itself_refuses_raw_migration_and_platform_sources_without_a_builder_guard", + "milliseconds": 47, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.DatabaseQualificationProofTests.Proof_from_another_database_cannot_qualify_either_lane_even_with_the_same_authority", + "milliseconds": 464, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.DatabaseQualificationProofTests.Qualification_cannot_upgrade_an_unbound_result_or_retarget_an_existing_proof", + "milliseconds": 104, + "source": "p032-local-ordinary-complement" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.DatabaseRoleTests.ABypassRoleWithNoGrantIsStillRefused", @@ -5929,8 +6246,8 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.DeliveryProcessorTests.Failed_timeout_transition_still_closes_gate_and_ignored_token_cannot_commit_late", - "milliseconds": 3172, - "source": "p031-hosted-ordinary-3" + "milliseconds": 3230, + "source": "p032-local-ordinary-complement" }, { "assembly": "learnstack.tests.integration", @@ -6961,8 +7278,8 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.GlobalIdentitySchemaTests.Non_success_evidence_needs_no_User_and_cannot_satisfy_success", - "milliseconds": 93, - "source": "p031-hosted-ordinary-3" + "milliseconds": 106, + "source": "p032-local-ordinary-complement" }, { "assembly": "learnstack.tests.integration", @@ -7330,6 +7647,204 @@ "milliseconds": 42, "source": "p031-hosted-ordinary-11" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.Actual_directory_resolves_rows_6_7_9_10_14_with_real_memberships_and_committed_sensitive_evidence", + "milliseconds": 3761, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.An_audit_outage_preserves_actual_HTTP_capability_and_coverage_refusals", + "milliseconds": 3078, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.Anonymous_public_read_reaches_application_SQL_without_identity_authorization_queries", + "milliseconds": 3486, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.Confirmed_claim_header_rejections_write_exact_private_evidence_without_spending_the_host_budget", + "milliseconds": 3300, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.Coverage_capability_and_store_failure_are_distinct_and_never_dispatch_the_directory", + "milliseconds": 3871, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.Directory_exposes_only_contextual_fields_with_live_tenant_wide_pagination_and_scope_bound_cursors", + "milliseconds": 3894, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.Directory_wrong_methods_retain_routing_semantics_without_handler_dispatch", + "milliseconds": 3068, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.HTTP_success_waits_for_physical_sensitive_audit_COMMIT", + "milliseconds": 3367, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.Matching_host_revocation_is_observed_by_directory_and_AccountSite_after_committed_membership_change", + "milliseconds": 3420, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.Resolver_noncoverage_preserves_row_7_host_evidence_row_10_silence_and_row_14_diagnostic_only", + "milliseconds": 3195, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests.Sensitive_HTTP_result_is_withheld_on_MUST_insert_or_deferred_COMMIT_failure", + "milliseconds": 6573, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Actual_committed_revocation_is_seen_while_parent_uncommitted_grant_and_locks_remain_open", + "milliseconds": 96, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Actual_connection_disposal_fault_withholds_prepared_facts_and_retains_capacity", + "milliseconds": 70, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Actual_tenant_lifecycle_is_fresh_and_independent_of_membership", + "milliseconds": 193, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Candidate_lifetime_ending_during_actual_cleanup_withholds_already_prepared_facts", + "milliseconds": 37, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Child_is_physically_read_only_and_its_exact_generation_is_the_lifecycle_generation", + "milliseconds": 26, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Duplicate_eligible_role_template_below_eight_refuses_without_publishing_facts", + "milliseconds": 86, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Entire_finite_eight_role_population_is_returned_without_implicit_grants", + "milliseconds": 218, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Exact_tenant_and_branch_compose_only_their_active_registered_facts_without_personal_projection", + "milliseconds": 359, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Excess_or_ambiguous_committed_memberships_fail_closed_instead_of_truncating_the_graph", + "milliseconds": 76, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Foreign_actor_tenant_or_organization_never_supplies_coverage", + "milliseconds": 74, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Fresh_committed_suspended_or_revoked_membership_has_no_coverage", + "milliseconds": 177, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Internal_deadline_is_unavailable_with_no_authority_payload", + "milliseconds": 5020, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Lifecycle_authorization_port_refuses_foreign_announcement_and_stale_physical_transaction", + "milliseconds": 22, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Lifecycle_storage_or_reported_actual_cleanup_failure_never_publishes_prepared_facts", + "milliseconds": 136, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Membership_without_any_role_or_grant_still_confirms_coverage", + "milliseconds": 63, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Nine_eligible_roles_with_a_duplicate_template_refuse_without_truncating_facts", + "milliseconds": 249, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Request_cancellation_propagates_after_owned_cleanup_and_the_slot_recovers", + "milliseconds": 37, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Requested_organization_must_currently_allow_work", + "milliseconds": 113, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Retired_role_and_removed_assignment_grant_nothing_while_membership_coverage_remains", + "milliseconds": 65, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Separate_lifecycle_statement_observes_a_commit_after_the_grant_statement", + "milliseconds": 31, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Stale_candidate_before_or_during_io_never_returns_facts", + "milliseconds": 23, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests.Unknown_and_virtual_public_stored_grants_never_become_assignable_authority", + "milliseconds": 46, + "source": "p032-local-ordinary-complement" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.IdentityCommandQualificationTests.Actual_seed_commands_measure_local_observation_cost_and_pool_occupancy_with_a_real_membership_wait", @@ -7363,8 +7878,8 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.IdentityDiagnosticSinkTests.Overlapping_compositions_protect_every_Identity_span_without_changing_actual_callers", - "milliseconds": 136, - "source": "p031-hosted-ordinary-0" + "milliseconds": 349, + "source": "p032-local-ordinary-complement" }, { "assembly": "learnstack.tests.integration", @@ -7660,6 +8175,12 @@ "milliseconds": 30360, "source": "p031-hosted-ordinary-4" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.IdentitySeederTests.Legitimate_audited_grant_change_makes_fixed_v1_seed_refuse_without_repair_or_additional_audit", + "milliseconds": 3632, + "source": "p032-local-ordinary-complement" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.InboxGuardTests.A_committed_claim_is_false_on_duplicate_but_distinct_tuples_are_independent", @@ -7738,6 +8259,72 @@ "milliseconds": 31, "source": "p031-hosted-ordinary-4" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryIntegrationTests.Actual_branch_admin_directory_pages_across_its_tenant_with_only_contextual_fields_on_ReadWrite", + "milliseconds": 84, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryIntegrationTests.Actual_coverage_without_Tenant_admin_and_foreign_actor_refuse_before_business_checkout", + "milliseconds": 62, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryIntegrationTests.Adapter_refuses_narrow_owner_even_if_SQL_scope_is_tampered_and_real_EF_filter_stays_narrow", + "milliseconds": 65, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryIntegrationTests.Directory_includes_suspended_members_and_excludes_committed_revoked_rows", + "milliseconds": 95, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryIntegrationTests.Modified_anchor_remains_a_seek_only_and_other_branch_cursor_is_validation_failure", + "milliseconds": 82, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryIntegrationTests.Tenant_reenrollment_refuses_a_foreign_physical_connection_without_retargeting_either_owner", + "milliseconds": 47, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryIntegrationTests.Tenant_reenrollment_refuses_pending_tracked_mutation_without_clearing_or_saving_it", + "milliseconds": 79, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryPipelineTests.Actual_MUST_insert_failure_withholds_the_materialized_directory_result", + "milliseconds": 72, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryPipelineTests.Actual_deferred_COMMIT_failure_answers_audit_unavailable_and_withholds_the_directory_result", + "milliseconds": 67, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryPipelineTests.Actual_directory_result_waits_for_its_MUST_row_to_commit_before_disclosure", + "milliseconds": 140, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.MembershipDirectoryPipelineTests.Ordinary_owner_preserves_its_primary_COMMIT_failure_even_with_a_sensitive_read_intent", + "milliseconds": 70, + "source": "p032-local-ordinary-complement" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.MigrationChainCoverageTests.Every_Migration_Chain_On_Disk_Is_Applied_By_The_Fixtures", @@ -7750,6 +8337,66 @@ "milliseconds": 2792, "source": "p031-hosted-ordinary-0" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.OperationReadScopeTests.A_consumed_witness_cannot_rebind_to_another_unit_or_recycled_transaction_generation", + "milliseconds": 21, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.OperationReadScopeTests.A_context_without_the_common_owner_attachment_remains_narrow_even_when_SQL_is_wide", + "milliseconds": 10, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.OperationReadScopeTests.A_missing_expired_or_mismatched_witness_poison_the_actual_owner", + "milliseconds": 38, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.OperationReadScopeTests.A_tenant_read_hatch_does_not_widen_sibling_organization_updates_or_deletes", + "milliseconds": 12, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.OperationReadScopeTests.Actual_completion_and_rollback_clear_read_authority_on_the_same_unreset_pooled_connector", + "milliseconds": 20, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.OperationReadScopeTests.An_unbound_inner_frame_cannot_borrow_the_wide_outer_frames_read_authority", + "milliseconds": 9, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.OperationReadScopeTests.Both_mixed_read_scope_joins_poison_outer_commit_even_after_inner_failure_is_absorbed", + "milliseconds": 19, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.OperationReadScopeTests.Compatible_wide_join_consumes_its_own_witness_and_restores_the_live_outer_frame", + "milliseconds": 10, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.OperationReadScopeTests.Expired_or_canceled_invocation_invalidates_raw_unit_access_EF_and_commit_before_cleanup", + "milliseconds": 25, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.OperationReadScopeTests.SQL_and_parameterized_EF_filters_share_the_exact_live_owner_read_scope", + "milliseconds": 27, + "source": "p032-local-ordinary-complement" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.OrgScopeMigrationTests.OrgScopeCorrection_PopulatedUpgradeDownAndReapply_PreservesRowsAndRestoresDefinitions", @@ -8575,8 +9222,8 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.PublicEducationHttpTests.Detail_validation_does_not_enter_read_transaction", - "milliseconds": 1086, - "source": "local-ordinary-scaled" + "milliseconds": 5816, + "source": "p032-local-ordinary-complement" }, { "assembly": "learnstack.tests.integration", @@ -8587,8 +9234,8 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.PublicEducationHttpTests.Enabled_untranslated_locale_returns_empty_catalog_and_hidden_details_without_fallback", - "milliseconds": 151, - "source": "p031-hosted-ordinary-8" + "milliseconds": 152, + "source": "p032-local-ordinary-complement" }, { "assembly": "learnstack.tests.integration", @@ -8641,8 +9288,8 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.PublicEducationHttpTests.Query_shape_refusals_happen_before_configuration_or_data_read", - "milliseconds": 766, - "source": "p031-hosted-ordinary-0" + "milliseconds": 860, + "source": "p032-local-ordinary-complement" }, { "assembly": "learnstack.tests.integration", @@ -8815,8 +9462,8 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.PublicSiteHttpTests.Live_mapping_does_not_serve_an_inactive_tenant", - "milliseconds": 487, - "source": "p031-hosted-ordinary-1" + "milliseconds": 562, + "source": "p032-local-ordinary-complement" }, { "assembly": "learnstack.tests.integration", @@ -8866,6 +9513,60 @@ "milliseconds": 309, "source": "p031-hosted-ordinary-5" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.RoleGrantCommandIntegrationTests.Actual_MUST_failure_rolls_back_the_Role_version_and_contained_grant_without_a_result", + "milliseconds": 96, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.RoleGrantCommandIntegrationTests.Actual_committed_admin_suspension_during_the_exclusive_lock_wait_refuses_the_postwait_act", + "milliseconds": 171, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.RoleGrantCommandIntegrationTests.Actual_orgless_admin_authors_Organization_template_under_Tenant_ceiling_with_one_MUST_Role_diff", + "milliseconds": 85, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.RoleGrantCommandIntegrationTests.Every_assignable_pair_requires_a_legal_delegation_target_with_committed_denial_and_no_refused_mutation", + "milliseconds": 895, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.RoleGrantCommandIntegrationTests.Finite_v1_adoption_is_a_separate_audited_act_and_unknown_manifest_never_repairs_the_target", + "milliseconds": 619, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.RoleGrantCommandIntegrationTests.Foreign_target_and_wrong_expected_version_are_refused_without_changes", + "milliseconds": 153, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.RoleGrantCommandIntegrationTests.Organization_actor_and_orgless_reissue_never_promote_Organization_grants_to_Tenant_role_administration", + "milliseconds": 134, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.RoleGrantCommandIntegrationTests.Removed_profile_authority_cannot_self_restore_through_change_or_v1_adoption_and_kernel_cannot_be_removed", + "milliseconds": 316, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.SeedAuthorizationActivationTests.Canonical_finite_seed_refusal_uses_one_shared_qualified_evidence_lane_across_per_act_providers", + "milliseconds": 30, + "source": "p032-local-ordinary-complement" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.SeederTests.A_conflict_that_is_not_a_uniqueness_refusal_still_stops_the_run", @@ -9346,6 +10047,18 @@ "milliseconds": 8088, "source": "p031-hosted-ordinary-1" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.TenantExistingBindingAdmissionTests.Actual_host_refuses_either_auxiliary_lane_on_another_cluster_with_the_same_database_name_and_authority", + "milliseconds": 7330, + "source": "p032-local-ordinary-complement" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.Database.TenantExistingBindingAdmissionTests.Actual_host_startup_refuses_auxiliary_role_anchor_and_actual_database_mismatch_without_repair", + "milliseconds": 4557, + "source": "p032-local-ordinary-complement" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.TenantExistingBindingAdmissionTests.Actual_host_startup_requires_the_installed_authority_exact_configuration_and_application_role", @@ -9601,8 +10314,8 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.TrustedVisitorHttpTests.Bad_authenticated_metadata_spends_fallback_quota_and_never_reaches_lookup", - "milliseconds": 1025, - "source": "p031-hosted-ordinary-5" + "milliseconds": 1720, + "source": "p032-local-ordinary-complement" }, { "assembly": "learnstack.tests.integration", @@ -10270,6 +10983,11 @@ "LearnStack.Tests.Integration.Database.AuditSchemaTests", "LearnStack.Tests.Integration.Database.AuditStoreTests", "LearnStack.Tests.Integration.Database.AuditWorkflowTests", + "LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests", + "LearnStack.Tests.Integration.Database.AuthorizationEvidenceSourceTests", + "LearnStack.Tests.Integration.Database.AuthorizationEvidenceStoreTests", + "LearnStack.Tests.Integration.Database.AuthorizationPoolProgressTests", + "LearnStack.Tests.Integration.Database.AuthorizationReadSourceTests", "LearnStack.Tests.Integration.Database.BackgroundJobAdmissionTests", "LearnStack.Tests.Integration.Database.BackgroundJobApplicationSourceTests", "LearnStack.Tests.Integration.Database.BackgroundJobBridgeFaultTests", @@ -10294,6 +11012,7 @@ "LearnStack.Tests.Integration.Database.CustomizationProjectionTests", "LearnStack.Tests.Integration.Database.CustomizationPublicationConcurrencyTests", "LearnStack.Tests.Integration.Database.CustomizationSchemaTests", + "LearnStack.Tests.Integration.Database.DatabaseQualificationProofTests", "LearnStack.Tests.Integration.Database.DatabaseRoleTests", "LearnStack.Tests.Integration.Database.DeliveryDispatchStoreTests", "LearnStack.Tests.Integration.Database.DeliveryExecutionTests", @@ -10317,6 +11036,8 @@ "LearnStack.Tests.Integration.Database.HangfirePoolOwnerTests", "LearnStack.Tests.Integration.Database.HangfireStorageTests", "LearnStack.Tests.Integration.Database.HostResolutionTests", + "LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests", + "LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests", "LearnStack.Tests.Integration.Database.IdentityCommandQualificationTests", "LearnStack.Tests.Integration.Database.IdentityDiagnosticSinkTests", "LearnStack.Tests.Integration.Database.IdentityMembershipTests", @@ -10326,7 +11047,10 @@ "LearnStack.Tests.Integration.Database.IdentitySeederTests", "LearnStack.Tests.Integration.Database.InboxGuardTests", "LearnStack.Tests.Integration.Database.KillswitchOverlayTests", + "LearnStack.Tests.Integration.Database.MembershipDirectoryIntegrationTests", + "LearnStack.Tests.Integration.Database.MembershipDirectoryPipelineTests", "LearnStack.Tests.Integration.Database.MigrationRollbackTests", + "LearnStack.Tests.Integration.Database.OperationReadScopeTests", "LearnStack.Tests.Integration.Database.OrgScopeMigrationTests", "LearnStack.Tests.Integration.Database.OrgScopeSchemaTests", "LearnStack.Tests.Integration.Database.OrganizationScopeValidatorTests", @@ -10348,6 +11072,8 @@ "LearnStack.Tests.Integration.Database.PublicReadTransactionTests", "LearnStack.Tests.Integration.Database.PublicServerRenderingTests", "LearnStack.Tests.Integration.Database.PublicSiteHttpTests", + "LearnStack.Tests.Integration.Database.RoleGrantCommandIntegrationTests", + "LearnStack.Tests.Integration.Database.SeedAuthorizationActivationTests", "LearnStack.Tests.Integration.Database.SeederTests", "LearnStack.Tests.Integration.Database.StorageRelationCensusTests", "LearnStack.Tests.Integration.Database.TenancySchemaTests", diff --git a/scripts/demo-database.d.mts b/scripts/demo-database.d.mts index f30ac117b..3e6367ef8 100644 --- a/scripts/demo-database.d.mts +++ b/scripts/demo-database.d.mts @@ -1,4 +1,9 @@ export function validateDemoDatabaseConfiguration(values: Record | null | undefined): void; +export function authorizationSourceEnvironment(values: Record | null | undefined): Record; +export function developmentSeederRuntimeEnvironment( + values: Record, + base?: Partial, +): NodeJS.ProcessEnv; export function publicSeederEnvironment( applicationConnectionString?: string, base?: Partial, diff --git a/scripts/demo-database.mjs b/scripts/demo-database.mjs index c5554a9f3..085724fa9 100644 --- a/scripts/demo-database.mjs +++ b/scripts/demo-database.mjs @@ -69,6 +69,7 @@ function localConnectionFields(source) { /** Reject stale local credentials in the launcher without giving them to a child process. */ export function validateDemoDatabaseConfiguration(values) { + authorizationSourceEnvironment(values); if ( values === null || values === undefined || @@ -99,6 +100,16 @@ export function validateDemoDatabaseConfiguration(values) { } } +/** An explicitly selected auxiliary pair is complete and nonempty, or absent. */ +export function authorizationSourceEnvironment(values) { + const keys = ['ConnectionStrings__IdentityAuthorization', 'ConnectionStrings__AuthorizationEvidence']; + const supplied = keys.filter(key => values?.[key] !== undefined); + if (supplied.length !== 0 && (supplied.length !== keys.length + || supplied.some(key => typeof values[key] !== 'string' || !values[key].trim() || /[\r\n\0]/.test(values[key])))) + throw invalidConfiguration(); + return Object.fromEntries(supplied.map(key => [key, values[key]])); +} + /** Every Seeder mode receives only its own credential and process prerequisites. */ export function publicSeederEnvironment(applicationConnectionString, base = process.env) { const environment = {}; @@ -113,3 +124,18 @@ export function publicSeederEnvironment(applicationConnectionString, base = proc environment.ConnectionStrings__Default = applicationConnectionString; return environment; } + +/** The owned local runtime receives its selected app-role lanes and installed authority. */ +export function developmentSeederRuntimeEnvironment(values, base = process.env) { + const environment = publicSeederEnvironment(undefined, base); + for (const key of ['ConnectionStrings__Default', 'Identity__AuthorityId', 'Authentication__Tenant__Issuer']) { + if (typeof values?.[key] !== 'string' || !values[key].trim() || /[\r\n\0]/.test(values[key])) + throw invalidConfiguration(); + environment[key] = values[key]; + } + Object.assign(environment, authorizationSourceEnvironment(values)); + // These callers own the documented direct-PostgreSQL Development stack. + // PgBouncer still requires its explicit pair and independent backend reserves. + environment.Deployment__Mode = 'Development'; + return environment; +} diff --git a/scripts/demo-database.test.mjs b/scripts/demo-database.test.mjs new file mode 100644 index 000000000..303656c86 --- /dev/null +++ b/scripts/demo-database.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { randomUUID } from 'node:crypto'; +import { developmentSeederRuntimeEnvironment, publicSeederEnvironment } from './demo-database.mjs'; + +// Per-run synthetic credentials exercise byte-preserving runtime transport; +// they are never usable against a database or published as a credential fixture. +const credentialCanary = randomUUID(); +const connection = (database, application) => Object.entries({ + Host: 'selected', Database: database, Username: 'learnstack_app', + Password: credentialCanary, 'Application Name': application, +}).map(([key, value]) => `${key}="${value.replaceAll('"', '""')}"`).join(';'); + +const selected = { + ConnectionStrings__Default: connection('business', 'selected runtime'), + Identity__AuthorityId: '01930000-0000-7000-8000-000000009001', + Authentication__Tenant__Issuer: 'http://localhost:8080/realms/learnstack', +}; +const hostileAmbient = { + PATH: '/owned/bin', HTTPS_PROXY: 'http://owned-proxy', SSL_CERT_FILE: '/owned/trust.pem', + ConnectionStrings__Default: 'unselected-private', + ConnectionStrings__Migration: 'migration-private', + ConnectionStrings__PlatformAdmin: 'platform-private', + ConnectionStrings__OutboxDispatcher: 'dispatcher-private', + ConnectionStrings__IdentityAuthorization: 'unselected-authorization-private', + ConnectionStrings__AuthorizationEvidence: 'unselected-evidence-private', + Identity__AuthorityId: 'unselected-identity', Authentication__Tenant__Issuer: 'unselected-issuer', + Deployment__Mode: 'SaaS', KEYCLOAK_ADMIN_PASSWORD: 'provider-private', +}; + +test('restore/build environment carries process prerequisites and no runtime authority or credentials', () => { + assert.deepEqual(publicSeederEnvironment(undefined, hostileAmbient), { + PATH: '/owned/bin', HTTPS_PROXY: 'http://owned-proxy', SSL_CERT_FILE: '/owned/trust.pem', + }); +}); + +test('owned Development runtime carries selected app connection and authority without importing ambient credentials', () => { + assert.deepEqual(developmentSeederRuntimeEnvironment(selected, hostileAmbient), { + PATH: '/owned/bin', HTTPS_PROXY: 'http://owned-proxy', SSL_CERT_FILE: '/owned/trust.pem', + ...selected, Deployment__Mode: 'Development', + }); +}); + +test('runtime preserves both selected auxiliary strings exactly and never exposes them in diagnostics', () => { + const pair = { + ConnectionStrings__IdentityAuthorization: connection('learnstack_identity_authorization', 'authorization; "quoted"'), + ConnectionStrings__AuthorizationEvidence: connection('learnstack_authorization_evidence', 'evidence; "quoted"'), + }; + const environment = developmentSeederRuntimeEnvironment({ ...selected, ...pair }, hostileAmbient); + for (const [key, value] of Object.entries(pair)) assert.equal(environment[key], value); + for (const key of Object.keys(pair)) { + assert.throws(() => developmentSeederRuntimeEnvironment({ ...selected, [key]: pair[key] }, hostileAmbient), + error => !error.message.includes(credentialCanary) && !error.message.includes(pair[key])); + } +}); + +for (const key of Object.keys(selected)) { + test(`runtime refuses absent selected ${key} without an ambient fallback`, () => { + const missing = { ...selected }; + delete missing[key]; + assert.throws(() => developmentSeederRuntimeEnvironment(missing, hostileAmbient)); + }); +} diff --git a/scripts/demo.mjs b/scripts/demo.mjs index 03af7da77..3df25d818 100644 --- a/scripts/demo.mjs +++ b/scripts/demo.mjs @@ -20,7 +20,7 @@ import { demoRequest, vacant } from './demo-http.mjs'; import { defaultServices, waitCompose } from './wait-compose.mjs'; import { publicDemoApiEnvironment, publicWebEnvironment } from './public-runtime.mjs'; import { apiCompletionObserver } from './demo-observation.mjs'; -import { publicSeederEnvironment, validateDemoDatabaseConfiguration } from './demo-database.mjs'; +import { publicSeederEnvironment, developmentSeederRuntimeEnvironment, validateDemoDatabaseConfiguration } from './demo-database.mjs'; import { demoSource } from './demo-source.mjs'; import { RealmError, realmFailureNotice } from './development-realm-errors.mjs'; import { readRealmEnvironmentFile, realmEnvironment, seedAuthorityEnvironment, validateLocalDockerContext } from './development-realm-owner.mjs'; @@ -442,7 +442,7 @@ export async function startDemo(mode) { await waitCompose(selected, processes, controller.signal); await run('dotnet', [seederDll], 'Contextual seed convergence', { cwd: backendRoot, - env: publicSeederEnvironment(rawValues.ConnectionStrings__Default), + env: developmentSeederRuntimeEnvironment(rawValues), timeout: 180000, }); await reconcileDevelopmentRealm({ selected, signal: controller.signal, seedIdentityFoundation: true }); diff --git a/scripts/public-runtime.mjs b/scripts/public-runtime.mjs index 3ae4cd22d..2e3e7efcc 100644 --- a/scripts/public-runtime.mjs +++ b/scripts/public-runtime.mjs @@ -1,5 +1,6 @@ import { realpathSync } from 'node:fs'; import { join } from 'node:path'; +import { authorizationSourceEnvironment } from './demo-database.mjs'; /** Give the API its explicit application/dispatcher credentials and bounded configuration. */ export function publicApiEnvironment(values, configuration, base = process.env) { @@ -13,6 +14,12 @@ export function publicApiEnvironment(values, configuration, base = process.env) if (values[key]) environment[key] = values[key]; environment.ConnectionStrings__Default = values.ConnectionStrings__Default; environment.ConnectionStrings__OutboxDispatcher = values.ConnectionStrings__OutboxDispatcher; + // The selected pair is atomic. The ambient prerequisite allowlist cannot + // supply either partner; role/endpoint qualification remains the API's. + try { Object.assign(environment, authorizationSourceEnvironment(values)); } + catch { + throw new Error('Authorization source configuration is invalid'); + } if (values.ConnectionStrings__PlatformAdmin) environment.ConnectionStrings__PlatformAdmin = values.ConnectionStrings__PlatformAdmin; return { diff --git a/scripts/reconcile-development-realm.mjs b/scripts/reconcile-development-realm.mjs index 89fc260e8..23c9ad9d3 100644 --- a/scripts/reconcile-development-realm.mjs +++ b/scripts/reconcile-development-realm.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { parseEnv } from 'node:util'; import { composeSelection, repositoryRoot } from './compose.mjs'; import { DemoCommandError, DemoProcesses, installDemoCancellation } from './demo-process.mjs'; -import { publicSeederEnvironment } from './demo-database.mjs'; +import { publicSeederEnvironment, developmentSeederRuntimeEnvironment } from './demo-database.mjs'; import { reconcileRealm, realmPassMilliseconds } from './development-realm-core.mjs'; import { acquireRealmLock, isLocalDockerEndpoint, readRealmEnvironmentFile, realmEnvironment, seedAuthorityEnvironment, validateRealmTarget } from './development-realm-owner.mjs'; import { RealmError, realmFailureNotice, refuse } from './development-realm-errors.mjs'; @@ -53,7 +53,7 @@ export async function reconcileDevelopmentRealm({ selected, signal, seedIdentity await processes.runPrivateInput('dotnet', [join(repositoryRoot, 'backend/src/LearnStack.Tools.Seeder/bin/Release/net10.0/LearnStack.Tools.Seeder.dll'), '--identity-foundation-stdin'], JSON.stringify(witness), { cwd: join(repositoryRoot, 'backend'), - env: { ...publicSeederEnvironment(applicationConnection), ...identityEnvironment }, + env: developmentSeederRuntimeEnvironment({ ...parseEnv(source), ...identityEnvironment }), // The enclosing active signal retains the remaining shared pass budget. label: 'Verified identity foundation', timeout: realmPassMilliseconds }); } catch (error) { diff --git a/scripts/seed.mjs b/scripts/seed.mjs index 4b86fec93..9566d3bf1 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { parseEnv } from 'node:util'; import { composeProcessEnvironment, composeSelection, composeSelectionFailure, repositoryRoot } from './compose.mjs'; import { DemoCommandError, DemoProcesses, installDemoCancellation } from './demo-process.mjs'; -import { publicSeederEnvironment } from './demo-database.mjs'; +import { publicSeederEnvironment, developmentSeederRuntimeEnvironment, authorizationSourceEnvironment } from './demo-database.mjs'; import { RealmError, realmFailureNotice, refuse } from './development-realm-errors.mjs'; import { readRealmEnvironmentFile, realmEnvironment, seedAuthorityEnvironment, validateLocalDockerContext } from './development-realm-owner.mjs'; import { reconcileDevelopmentRealm } from './reconcile-development-realm.mjs'; @@ -34,13 +34,13 @@ async function selectedConfiguration(selected) { for (const key of ['ConnectionStrings__Default', 'ConnectionStrings__Migration']) if (!values[key] || (process.env[key] !== undefined && process.env[key] !== values[key])) refuse('preflight', 'configuration'); + authorizationSourceEnvironment(values); return values; } /** One selected stack and cancellation owner for readiness, application seed and identity. */ export async function seedDevelopment({ selected, signal }) { const values = await selectedConfiguration(selected); - const connection = values.ConnectionStrings__Default; const prerequisites = { ...publicSeederEnvironment(), ...composeProcessEnvironment(selected.env) }; const selectedPrerequisites = { ...prerequisites, LEARNSTACK_COMPOSE_PROJECT: selected.project, LEARNSTACK_COMPOSE_ENV_FILE: selected.file, @@ -77,7 +77,7 @@ export async function seedDevelopment({ selected, signal }) { cwd: backendRoot, env: publicSeederEnvironment(), label: 'Release Seeder build', timeout: 180000, }); await run('dotnet', [seederDll], { - cwd: backendRoot, env: publicSeederEnvironment(connection), label: 'Application seed', timeout: 180000, + cwd: backendRoot, env: developmentSeederRuntimeEnvironment(values), label: 'Application seed', timeout: 180000, }); const result = await reconcileDevelopmentRealm({ selected, signal, seedIdentityFoundation: true }); preparationDispatched = result.preparationDispatched === true; diff --git a/scripts/test-ci-native-tests.py b/scripts/test-ci-native-tests.py index 9d73bec88..029185e24 100644 --- a/scripts/test-ci-native-tests.py +++ b/scripts/test-ci-native-tests.py @@ -218,12 +218,14 @@ def selector_fixture(self): fixture = fixtures.ShardControls(); fixture.plan = self.owner.shard_owner().load_plan() return fixture - def run_workload(self, manifest, status, name): + def run_workload(self, manifest, status, name, execution=None): private, output = self.root / ('private-' + name), self.root / ('output-' + name) private.mkdir(); output.mkdir(); (output / 'results').mkdir() plan = self.owner.shard_owner().load_plan() expected = self.owner.shard_owner().assign(manifest, plan, '1')['owners']['ordinary-0'] def completed(argv, deadline, work, consume, progress): + if execution is not None: + return execution(progress, expected) self.trx(private / 'trx', expected, raw_canary=True) progress.line(self.execution_complete(expected, tail=expected).encode()) return status @@ -740,7 +742,7 @@ def test_failed_protocol_retains_only_closed_source_codes_and_bounded_line_numbe and row['outcome'] == 'Failed' for row in rows)) expected = {path.name for path in (ROOT / 'backend/tests/LearnStack.Tests.Integration/Database').glob( 'BackgroundJobRuntimeFacadeTests*.cs')} | { - 'OutboxProcessorTests.cs', 'BackgroundJobPositiveSdkTests.CausalRestart.cs', + 'OutboxProcessorTests.cs', 'AuthorizationReadSourceTests.cs', 'BackgroundJobPositiveSdkTests.CausalRestart.cs', 'BackgroundJobPositiveSdkTests.CausalRestartObservations.cs', 'BackgroundJobPositiveSdkTests.NativeSettlement.cs', 'PublicAdmissionRenderingTests.cs', 'BackgroundJobPositiveSdkTests.NativeWait.cs', 'BackgroundJobPositiveSdkTests.NativeHistory.cs', @@ -859,9 +861,14 @@ def test_keycloak_startup_vocabulary_matches_the_existing_fixture_source(self): self.assertEqual(set(re.findall(r'\bcleanup = "([a-z_]+)";', initialize)), self.owner.KEYCLOAK_FAILURE_CLEANUP) self.assertIn('"Keycloak fixture startup failed at " + stage + "/" + reason', initialize) - self.assertIn('"; owned cleanup " + cleanup + "."', initialize) + self.assertIn('"; owned cleanup " + cleanup', initialize) + capture = 'var startupCancellationRequested = startup.IsCancellationRequested;' + self.assertLess(initialize.index(capture), initialize.index('await DisposeAsync();')) + self.assertIn('"; startup cancellation requested " + (startupCancellationRequested ? "true" : "false") + "."', + initialize) self.assertEqual({'TenantRealmApiTests', 'TenantRealmIssuanceTests', 'TenantRealmProfileProtectionTests', - 'TenantRealmReconciliationTests', 'TenantRealmSelfServiceTests'}, + 'TenantRealmReconciliationTests', 'TenantRealmSelfServiceTests', + 'TenantRealmStockBaselineTests'}, self.owner.KEYCLOAK_FAILURE_CLASSES) for name in self.owner.KEYCLOAK_FAILURE_CLASSES: test = (directory / (name + '.cs')).read_text() @@ -892,6 +899,9 @@ def test_keycloak_startup_details_require_named_cases_and_one_complete_bounded_s sentence.replace('cleanup completed', 'cleanup PRIVATE-CANARY'), sentence.replace('provider_start/', 'provider_start\n/'), sentence.replace('; owned cleanup ', '; owned cleanup\x00 '), + sentence.replace('completed.', 'completed; startup cancellation requested TRUE.'), + sentence.replace('completed.', 'completed; startup cancellation requested PRIVATE-CANARY.'), + sentence.replace('completed.', 'completed; startup cancellation requested true\n.'), sentence[:-1], sentence + '\n' + sentence, sentence + '\n' + sentence.replace('provider_start/', 'unknown/'), 'The following constructor parameters did not have matching fixture data: PRIVATE-CANARY', @@ -905,6 +915,29 @@ def test_keycloak_startup_details_require_named_cases_and_one_complete_bounded_s self.assertIsNone(parse(changed, {'ErrorMessage': sentence})) self.assertIsNone(parse(case, {'ErrorMessage': 'unrelated failure', 'ErrorStackTrace': sentence})) + def test_keycloak_startup_cancellation_state_is_closed_and_never_case_output(self): + cases = [self.case(index + 1, 'LearnStack.Tests.Integration.TenancyIdentity.' + 'TenantRealmStockBaselineTests.Proof(row: ' + str(index) + ')') + for index in range(2)] + stream, console = io.StringIO(), io.StringIO() + progress = self.progress(self.root / 'keycloak-cancel.diag', 'LearnStack.Tests.Integration', cases, stream) + results = [] + for case, cancelled in zip(cases, (True, False)): + item = self.execution_result(case, outcome=2) + item['ErrorMessage'] = ('Keycloak fixture startup failed at provider_starting/readiness_timeout; ' + 'owned cleanup completed; startup cancellation requested ' + + str(cancelled).lower() + '.\nPRIVATE-CANARY') + results.append(item) + with contextlib.redirect_stdout(console): + progress.results({'NewTestResults': results}) + self.assertEqual([{'stage': 'provider_starting', 'reason': 'readiness_timeout', 'cleanup': 'completed', + 'startupCancellationRequested': value} for value in (True, False)], + [row['details'] for row in progress.failure_locations]) + self.assertEqual(['Failed', 'Failed'], [json.loads(line)['outcome'] for line in stream.getvalue().splitlines()]) + for private in ('PRIVATE-CANARY', 'startupCancellationRequested', 'provider_starting'): + self.assertNotIn(private, stream.getvalue()) + self.assertNotIn(private, console.getvalue()) + def test_keycloak_fixture_results_keep_only_closed_measurements_and_truthful_sanitized_failures(self): sentence = ('Keycloak fixture startup failed at provider_start/unsupported_operation; ' 'owned cleanup completed.') @@ -1963,6 +1996,106 @@ def test_outage_failure_details_keep_only_consistent_bounded_timing_and_closed_p self.assertEqual([], nonwire['pools'], 'Internal record members are not part of the actual ToString wire shape') self.assertIsNone(parse(self.case(2), {'ErrorMessage': message})) + def test_start_only_protocol_is_diagnostic_and_does_not_complete_a_case(self): + case = self.case(1) + stream = io.StringIO() + progress = self.progress(self.root / 'start-only.diag', 'LearnStack.Tests.Integration', [case], stream) + raw = self.execution_result(case)['TestCase'] + raw['Properties'] = [{'Key': 'PRIVATE-CANARY', 'Value': 'PRIVATE-CANARY'}] + progress.line(self.execution_message('TestExecution.StatsChange', + {'NewTestResults': [], 'ActiveTests': [raw]}).encode()) + self.assertEqual({'status': 'observed', 'startedWithoutResult': [case]}, progress.execution_diagnostics()) + self.assertEqual('', stream.getvalue()) + self.assertEqual(set(), progress.seen) + self.assertEqual(Counter(), progress.outcomes) + self.assertFalse(progress.completed) + self.assertNotIn('PRIVATE-CANARY', json.dumps(progress.execution_diagnostics())) + self.refuses('execution_incomplete', progress.poll, final=True) + + def test_start_deltas_accumulate_and_actual_results_remove_only_their_case(self): + cases = [self.case(1), self.case(2), self.case(3)] + progress = self.progress(self.root / 'start-deltas.diag', 'LearnStack.Tests.Integration', cases, io.StringIO()) + def starts(rows): + progress.line(self.execution_message('TestExecution.StatsChange', + {'NewTestResults': [], 'ActiveTests': [self.execution_result(case)['TestCase'] for case in rows]}).encode()) + starts(cases[:1]); starts(cases[1:2]); starts([]) + self.assertEqual(cases[:2], progress.execution_diagnostics()['startedWithoutResult']) + with contextlib.redirect_stdout(io.StringIO()): + progress.line(self.execution_event(cases[:1]).encode()) + self.assertEqual(cases[1:2], progress.execution_diagnostics()['startedWithoutResult']) + # Repeated and late starts cannot resurrect an already completed case. + starts(cases[:2]); starts(cases[1:2]) + self.assertEqual(cases[1:2], progress.execution_diagnostics()['startedWithoutResult']) + with contextlib.redirect_stdout(io.StringIO()): + progress.line(self.execution_message('TestExecution.StatsChange', { + 'ActiveTests': [self.execution_result(cases[2])['TestCase']], + 'NewTestResults': [self.execution_result(cases[2])]}).encode()) + progress.line(self.execution_complete(cases, tail=cases[1:2]).encode()) + self.assertEqual({'status': 'observed', 'startedWithoutResult': []}, progress.execution_diagnostics()) + self.assertTrue(progress.completed) + self.assertEqual(progress.expected, progress.seen) + + def test_absent_or_null_starts_remain_unknown_without_erasing_observed_starts(self): + case = self.case(1) + progress = self.progress(self.root / 'start-unavailable.diag', 'LearnStack.Tests.Integration', [case], io.StringIO()) + for payload in ({'NewTestResults': []}, {'NewTestResults': [], 'ActiveTests': None}): + progress.line(self.execution_message('TestExecution.StatsChange', payload).encode()) + self.assertEqual({'status': 'unavailable', 'startedWithoutResult': None}, progress.execution_diagnostics()) + progress.line(self.execution_message('TestExecution.StatsChange', + {'NewTestResults': [], 'ActiveTests': [self.execution_result(case)['TestCase']]}).encode()) + progress.line(self.execution_message('TestExecution.StatsChange', + {'NewTestResults': [], 'ActiveTests': None}).encode()) + self.assertEqual({'status': 'observed', 'startedWithoutResult': [case]}, progress.execution_diagnostics()) + self.assertFalse(progress.completed) + + def test_malformed_or_unselected_starts_are_unknown_and_never_exported_or_authoritative(self): + case = self.case(1) + raw = self.execution_result(case)['TestCase'] + invalid = ('PRIVATE-CANARY', {}, 1, [None], [raw, raw], + [{**raw, 'Id': 'PRIVATE-CANARY'}], [{**raw, 'DisplayName': 'PRIVATE-CANARY'}], + [{**raw, 'Source': None}], [{**raw, 'Source': '/private/Foreign.dll'}], + [self.execution_result(self.case(99))['TestCase']]) + for index, value in enumerate(invalid): + with self.subTest(value=value): + stream = io.StringIO() + progress = self.progress(self.root / f'start-invalid-{index}.diag', + 'LearnStack.Tests.Integration', [case], stream) + progress.line(self.execution_message('TestExecution.StatsChange', + {'NewTestResults': [], 'ActiveTests': [raw]}).encode()) + progress.line(self.execution_message('TestExecution.StatsChange', + {'NewTestResults': [], 'ActiveTests': value}).encode()) + progress.line(self.execution_message('TestExecution.StatsChange', + {'NewTestResults': [], 'ActiveTests': [raw]}).encode()) + self.assertEqual({'status': 'invalid', 'startedWithoutResult': None}, progress.execution_diagnostics()) + self.assertEqual('', stream.getvalue()) + self.assertEqual(set(), progress.seen) + self.assertFalse(progress.completed) + self.refuses('execution_incomplete', progress.poll, final=True) + with contextlib.redirect_stdout(io.StringIO()): + progress.line(self.execution_complete([case], tail=[case]).encode()) + self.assertTrue(progress.completed, 'Optional starts do not replace or alter actual completion validation') + self.assertEqual(progress.expected, progress.seen) + self.assertNotIn('PRIVATE-CANARY', stream.getvalue() + json.dumps(progress.execution_diagnostics())) + + def test_deadline_retains_start_diagnostics_in_measurements_without_completion_evidence(self): + manifest = self.selector_fixture().manifest() + started = [] + def interrupted(progress, expected): + started.append({name: expected[0][name] for name in ('assembly', 'id', 'name')}) + progress.line(self.execution_message('TestExecution.StatsChange', + {'NewTestResults': [], 'ActiveTests': [self.execution_result(started[0])['TestCase']]}).encode()) + raise self.owner.Refusal('deadline_exhausted') + self.refuses('deadline_exhausted', self.run_workload, manifest, 0, 'start-deadline', interrupted) + output = self.root / 'output-start-deadline' + private = self.root / 'private-start-deadline' + measured = self.owner.read_json(output / 'measurements.json') + self.assertEqual({'status': 'observed', 'startedWithoutResult': started}, + measured['projects'][0]['executionDiagnostics']) + self.assertEqual('', (output / 'completed-cases.jsonl').read_text()) + self.assertFalse((private / 'workload.json').exists()) + self.refuses('trx_missing', self.owner.validate_results, output, started) + self.assertEqual([], list(private.glob('*-execution*')), 'The private raw protocol remains unexported') + def test_execution_protocol_requires_unique_expected_ids_and_finite_non_skip_outcomes(self): case = self.case(1) for mutation in ('missing', 'wrong', 'duplicate', 'skip', 'none', 'notfound', 'unknown', 'boolean'): @@ -2590,6 +2723,16 @@ def test_source_census_requires_explicit_classification_and_every_test_project(s solution.write_text('') self.assertEqual([('tests/LearnStack.Tests.Example.csproj', 'LearnStack.Tests.Example')], list(self.owner.test_projects())) + probe = tests / 'LearnStack.Tests.AuthorizationEvidenceProbe.csproj' + probe.write_text('false') + solution.write_text('' + '') + self.assertEqual([('tests/LearnStack.Tests.Example.csproj', 'LearnStack.Tests.Example')], + list(self.owner.test_projects()), 'explicit false sidecars are built inputs, not xUnit test assemblies') + solution.write_text('') + self.refuses('test_project_missing_from_solution', lambda: list(self.owner.test_projects())) + solution.write_text('' + '') project.write_text('') with self.assertRaises(self.owner.Refusal) as caught: list(self.owner.test_projects()) diff --git a/scripts/test-ci-test-shard-results.py b/scripts/test-ci-test-shard-results.py index 632bac979..9b63dcc0b 100644 --- a/scripts/test-ci-test-shard-results.py +++ b/scripts/test-ci-test-shard-results.py @@ -338,16 +338,89 @@ def test_aggregate_refuses_complete_receipts_whose_assignments_only_renumber_bin self.refuses('assignment_not_distinct', RESULTS.validate_outputs, paths, self.context(), self.plan, scope) - def test_prepared_images_match_the_actual_provider_and_database_fixture_sources(self): - provider = (ROOT / 'backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs').read_text() - database = (ROOT / 'backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs').read_text() + def fixture_images(self, provider, database, pooler): provider_images = re.findall(r'\bconst string Image = "([^"\n]+)";', provider) database_images = re.findall(r'new PostgreSqlBuilder\("([^"\n]+)"\)', database) provider_databases = re.findall(r'new PostgreSqlBuilder\("([^"\n]+)"\)', provider) + pooler_images = re.findall(r'\bconst string PgBouncerImage = "([^"\n]+)";', pooler) self.assertEqual(1, len(provider_images)) + self.assertEqual(1, len(re.findall(r'new ContainerBuilder\(Image\)', provider))) self.assertEqual(1, len(database_images)) self.assertEqual(database_images, provider_databases) - self.assertEqual(tuple(provider_images + database_images), RESULTS.FIXTURE_IMAGES) + self.assertEqual(1, len(pooler_images)) + self.assertEqual(1, len(re.findall(r'new ContainerBuilder\(PgBouncerImage\)', pooler))) + return tuple(provider_images + database_images + pooler_images) + + def test_prepared_images_match_actual_provider_database_and_pooler_constructor_inputs(self): + provider = (ROOT / 'backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs').read_text() + database = (ROOT / 'backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs').read_text() + pooler = (ROOT / 'backend/tests/LearnStack.Tests.Integration/Database/AuthorizationPoolProgressFixture.cs').read_text() + self.assertEqual(self.fixture_images(provider, database, pooler), RESULTS.FIXTURE_IMAGES) + with self.assertRaises(AssertionError): + self.fixture_images(provider.replace('new ContainerBuilder(Image)', + 'new ContainerBuilder("unapproved:latest")'), database, pooler) + for changed in ( + pooler.replace('const string PgBouncerImage =', 'const string UnusedImage ='), + pooler.replace('new ContainerBuilder(PgBouncerImage)', 'new ContainerBuilder("unapproved:latest")')): + with self.subTest(source='missing_const_or_actual_argument'), self.assertRaises(AssertionError): + self.fixture_images(provider, database, changed) + changed = pooler.replace('edoburu/pgbouncer:v1.26.0-p0', 'edoburu/pgbouncer:latest') + self.assertNotEqual(self.fixture_images(provider, database, changed), RESULTS.FIXTURE_IMAGES) + + def assert_sidecar_inventory(self, solution, integration, name, directory): + relative = f'tests/LearnStack.Tests.{name}/LearnStack.Tests.{name}.csproj' + self.assertEqual(1, len([item for item in solution.iter('Project') if item.get('Path') == relative])) + references = [item for item in integration.iter('ProjectReference') + if item.get('Include') == f'../LearnStack.Tests.{name}/LearnStack.Tests.{name}.csproj'] + self.assertEqual(1, len(references)) + self.assertEqual('false', references[0].get('ReferenceOutputAssembly')) + targets = [item for item in integration.iter('Target') if item.get('Name') == f'Stage{name}'] + self.assertEqual(1, len(targets)) + self.assertEqual('Build', targets[0].get('AfterTargets')) + items = list(targets[0].iter('_' + name + 'Files')) + self.assertEqual(1, len(items)) + self.assertEqual(f'$(MSBuildThisFileDirectory)../LearnStack.Tests.{name}/bin/$(Configuration)/$(TargetFramework)/**/*', + items[0].get('Include')) + errors = list(targets[0].iter('Error')) + self.assertEqual(1, len(errors)) + self.assertEqual(f"'@(_{name}Files)' == ''", errors[0].get('Condition')) + copies = list(targets[0].iter('Copy')) + self.assertEqual(1, len(copies)) + self.assertEqual(f'@(_{name}Files)', copies[0].get('SourceFiles')) + self.assertEqual(f"@(_{name}Files->'$(OutDir){directory}/%(RecursiveDir)%(Filename)%(Extension)')", + copies[0].get('DestinationFiles')) + + def test_actual_probe_sidecars_are_explicit_nontest_inputs_staged_with_their_linked_source(self): + solution = ET.parse(ROOT / 'backend/LearnStack.slnx').getroot() + integration = ET.parse(ROOT / 'backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj').getroot() + discovered = {name for _, name in RESULTS.OWNER.test_projects()} + for name, directory in (('JobLifecycleProbe', 'job-lifecycle-probe'), + ('AuthorizationEvidenceProbe', 'authorization-evidence-probe')): + with self.subTest(sidecar=name): + project = ET.parse(ROOT / f'backend/tests/LearnStack.Tests.{name}/LearnStack.Tests.{name}.csproj').getroot() + self.assertEqual(['false'], [item.text for item in project.iter('IsTestProject')]) + self.assertEqual(['Exe'], [item.text for item in project.iter('OutputType')]) + self.assertEqual(['false'], [item.text for item in project.iter('UseAppHost')]) + self.assertNotIn('LearnStack.Tests.' + name, discovered) + self.assert_sidecar_inventory(solution, integration, name, directory) + linked = '../AuthorizationEvidenceProof/AuthorizationBarrierHarness.cs' + child = ET.parse(ROOT / 'backend/tests/LearnStack.Tests.AuthorizationEvidenceProbe/LearnStack.Tests.AuthorizationEvidenceProbe.csproj').getroot() + for project in (integration, child): + self.assertEqual(1, len([item for item in project.iter('Compile') if item.get('Include') == linked])) + name = 'AuthorizationEvidenceProbe' + for mutation in ('reference', 'stage', 'after', 'destination'): + dirty = copy.deepcopy(integration) + if mutation == 'reference': + item = next(item for item in dirty.iter('ProjectReference') + if name in item.get('Include', '')) + item.set('ReferenceOutputAssembly', 'true') + else: + target = next(item for item in dirty.iter('Target') if item.get('Name') == 'Stage' + name) + if mutation == 'stage': target.set('Name', 'UnscheduledProbe') + elif mutation == 'after': target.set('AfterTargets', 'Never') + else: next(target.iter('Copy')).set('DestinationFiles', 'wrong-sidecar') + with self.subTest(mutation=mutation), self.assertRaises(AssertionError): + self.assert_sidecar_inventory(solution, dirty, name, 'authorization-evidence-probe') def fixture_environment(self): env = {'GITHUB_ACTIONS': 'true', 'RUNNER_ENVIRONMENT': 'github-hosted', @@ -366,7 +439,7 @@ def fixture_environment(self): return env @contextmanager - def fixture_preparation(self, *, elapsed=700, environment=None, pull=None): + def fixture_preparation(self, *, elapsed=700, environment=None, pull=None, monotonic=None): def command(argv, *args, **kwargs): if argv[0] == RESULTS.OWNER.GIT: return ('a' * 40 + '\n').encode() @@ -374,7 +447,7 @@ def command(argv, *args, **kwargs): output = io.StringIO() with mock.patch.dict(os.environ, environment or self.fixture_environment(), clear=True), \ mock.patch.object(RESULTS.time, 'time', return_value=1000 + elapsed), \ - mock.patch.object(RESULTS.time, 'monotonic', return_value=500), \ + mock.patch.object(RESULTS.time, 'monotonic', side_effect=monotonic or (lambda: 500)), \ mock.patch.object(RESULTS.sys, 'platform', 'linux'), \ mock.patch.object(RESULTS.OWNER, 'command', side_effect=command) as calls, \ mock.patch('sys.stdout', output): @@ -383,15 +456,18 @@ def command(argv, *args, **kwargs): def test_fixture_image_preparation_uses_fixed_images_and_preserves_cleanup_reserve(self): with self.fixture_preparation() as (command, output): self.assertEqual(0, RESULTS.prepare_fixture_images('ordinary-0')) - self.assertEqual(('quay.io/keycloak/keycloak:26.6.2', 'postgres:18.4-alpine'), RESULTS.FIXTURE_IMAGES) + self.assertEqual(('quay.io/keycloak/keycloak:26.6.2', 'postgres:18.4-alpine', + 'edoburu/pgbouncer:v1.26.0-p0'), RESULTS.FIXTURE_IMAGES) pulls = [call for call in command.call_args_list if call.args[0][0] == RESULTS.OWNER.DOCKER] self.assertEqual([mock.call(['/usr/bin/docker', 'pull', image], 610, 'fixture_image_pull_failed_' + component, cap=55, diagnose=True) - for component, image in zip(('keycloak', 'postgres'), RESULTS.FIXTURE_IMAGES)], pulls) + for component, image in zip(('keycloak', 'postgres', 'pgbouncer'), RESULTS.FIXTURE_IMAGES)], pulls) self.assertEqual(RESULTS.OWNER.GIT, command.call_args_list[0].args[0][0]) - self.assertEqual(list(RESULTS.FIXTURE_IMAGES), [row['image'] for row in json.loads(output.getvalue())['images']]) + self.assertEqual({'protocol': 1, 'fixtureImagePreparationSeconds': 0, + 'images': [{'image': image, 'pullSeconds': 0} for image in RESULTS.FIXTURE_IMAGES]}, + json.loads(output.getvalue())) self.assertNotIn('PRIVATE-CANARY', output.getvalue()) - for index, component in enumerate(('keycloak', 'postgres')): + for index, component in enumerate(('keycloak', 'postgres', 'pgbouncer')): reason = 'fixture_image_pull_failed_' + component + '_rate_limit' responses = iter([b'PRIVATE-CANARY'] * index + [RESULTS.OWNER.Refusal(reason)]) def pull(*args, **kwargs): @@ -410,11 +486,14 @@ def pull(*args, **kwargs): with self.fixture_preparation(elapsed=elapsed) as (command, _): self.assertEqual(0, RESULTS.prepare_fixture_images('ordinary-0')) pulls = [call for call in command.call_args_list if call.args[0][0] == RESULTS.OWNER.DOCKER] - self.assertEqual(2, len(pulls)) + self.assertEqual(3, len(pulls)) self.assertTrue(all(call.args[1] == 500 + min(110, RESULTS.OWNER.ORDINARY_JOB_SECONDS - RESULTS.OWNER.RESERVE_SECONDS - elapsed) for call in pulls)) self.assertTrue(all(call.kwargs == {'cap': 55, 'diagnose': True} for call in pulls)) - for images in ((), ('one',), ('one', 'two', 'three')): + for images in ((), RESULTS.FIXTURE_IMAGES[:2], RESULTS.FIXTURE_IMAGES[1:], RESULTS.FIXTURE_IMAGES[::2], + tuple(reversed(RESULTS.FIXTURE_IMAGES)), RESULTS.FIXTURE_IMAGES + ('unapproved:latest',), + ('one', 'two', 'three'), (*RESULTS.FIXTURE_IMAGES[:2], RESULTS.FIXTURE_IMAGES[1]), + (*RESULTS.FIXTURE_IMAGES[:2], 'edoburu/pgbouncer:latest'), list(RESULTS.FIXTURE_IMAGES)): with self.fixture_preparation() as (command, _), mock.patch.object(RESULTS, 'FIXTURE_IMAGES', images): self.refuses('fixture_setup_images_invalid', RESULTS.prepare_fixture_images, 'ordinary-0') self.assertFalse(any(call.args[0][0] == RESULTS.OWNER.DOCKER for call in command.call_args_list)) @@ -422,6 +501,28 @@ def pull(*args, **kwargs): self.refuses('fixture_setup_deadline_exhausted', RESULTS.prepare_fixture_images, 'ordinary-0') self.assertFalse(any(call.args[0][0] == RESULTS.OWNER.DOCKER for call in command.call_args_list)) + def test_three_pull_calls_publish_exact_metadata_under_one_unchanged_total_deadline(self): + state = {'now': 500, 'pulls': []} + durations = (12.125, 0.25, 40) + def pull(argv, deadline, reason, **kwargs): + state['pulls'].append((argv, deadline, reason, kwargs)) + state['now'] += durations[len(state['pulls']) - 1] + return b'PRIVATE-CANARY' + with self.fixture_preparation(pull=pull, monotonic=lambda: state['now']) as (_, output): + self.assertEqual(0, RESULTS.prepare_fixture_images('ordinary-0')) + self.assertEqual({'protocol': 1, 'fixtureImagePreparationSeconds': sum(durations), + 'images': [{'image': image, 'pullSeconds': seconds} + for image, seconds in zip(RESULTS.FIXTURE_IMAGES, durations)]}, json.loads(output.getvalue())) + self.assertEqual([610, 610, 610], [call[1] for call in state['pulls']]) + self.assertTrue(all(call[3] == {'cap': 55, 'diagnose': True} for call in state['pulls'])) + self.assertNotIn('PRIVATE-CANARY', output.getvalue()) + def exhaust(*args, **kwargs): + state['now'] += 40 + state['now'] = 500 + with self.fixture_preparation(pull=exhaust, monotonic=lambda: state['now']) as (_, output): + self.refuses('fixture_setup_deadline_exhausted', RESULTS.prepare_fixture_images, 'ordinary-0') + self.assertEqual('', output.getvalue(), 'an incomplete three-image preparation publishes no success metadata') + def test_fixture_preparation_verifies_closed_mode_source_and_runner_before_any_pull(self): for mode in ('base', 'native-shard', 'ordinary', 'ordinary-00', 'ordinary--1', 'ordinary-12', 'ordinary-0extra', 'architecture-0', 'aggregate'): diff --git a/scripts/test-ci-test-shards.py b/scripts/test-ci-test-shards.py index ea227d47f..a5ec2a417 100644 --- a/scripts/test-ci-test-shards.py +++ b/scripts/test-ci-test-shards.py @@ -103,7 +103,20 @@ def test_shared_fixture_witness_matches_actual_source_classes(self): self.plan['sharedFixtureClasses']) for name in ('LearnStack.Tests.Integration.ApiHandlerCompositionTests', 'LearnStack.Tests.Integration.Database.PublicSiteHttpTests', - 'LearnStack.Tests.Integration.Database.HangfireStorageTests'): + 'LearnStack.Tests.Integration.Database.HangfireStorageTests', + 'LearnStack.Tests.Integration.Database.AuthorizationEvidenceBarrierTests', + 'LearnStack.Tests.Integration.Database.AuthorizationEvidenceSourceTests', + 'LearnStack.Tests.Integration.Database.AuthorizationEvidenceStoreTests', + 'LearnStack.Tests.Integration.Database.AuthorizationPoolProgressTests', + 'LearnStack.Tests.Integration.Database.AuthorizationReadSourceTests', + 'LearnStack.Tests.Integration.Database.DatabaseQualificationProofTests', + 'LearnStack.Tests.Integration.Database.IdentityAuthorizationHttpTests', + 'LearnStack.Tests.Integration.Database.IdentityAuthorizationReaderTests', + 'LearnStack.Tests.Integration.Database.MembershipDirectoryIntegrationTests', + 'LearnStack.Tests.Integration.Database.MembershipDirectoryPipelineTests', + 'LearnStack.Tests.Integration.Database.OperationReadScopeTests', + 'LearnStack.Tests.Integration.Database.RoleGrantCommandIntegrationTests', + 'LearnStack.Tests.Integration.Database.SeedAuthorizationActivationTests'): self.assertIn(name, self.plan['sharedFixtureClasses']) def test_fixture_census_binds_qualified_collections_and_other_explicit_class_fixtures(self): @@ -126,6 +139,8 @@ def test_fixture_census_binds_qualified_collections_and_other_explicit_class_fix [global::Xunit.CollectionAttribute(global::Example.Database.PublicReadTestGroup.Name)] public sealed class OtherCollection { } public sealed class OtherFixture(SchemaFixture fixture) : Xunit.IClassFixture { } +[Collection(Database.SharedSchema.Name)] +public sealed partial class SplitTests(SchemaFixture fixture) { } [Collection(WithoutFixture.Name)] public sealed class NoFixtureCollection { } public sealed class UnrelatedAfterCollection { } @@ -137,7 +152,14 @@ def test_fixture_census_binds_qualified_collections_and_other_explicit_class_fix """; } ''') - self.assertEqual(['Example.OtherCollection', 'Example.OtherFixture', 'Example.Qualified'], + # Current HTTP/pool proofs declare their fixture once and keep other + # partial bodies in separate source files; those bodies neither lose + # the fixture-bearing declaration nor invent another observation. + (source / 'Tests.Continuation.cs').write_text('''namespace Example; +public sealed partial class SplitTests { } +''') + self.assertEqual(['Example.OtherCollection', 'Example.OtherFixture', 'Example.Qualified', + 'Example.SplitTests'], OWNER.fixture_class_census(root)) def test_fixture_census_refuses_unknown_unsupported_and_ambiguous_forms(self):