ADR-010 — Membership 1 user → N tenants via TenantUser join + active tenant selection
Date: 2026-06-22
Status: Accepted
Extends: ADR-009 (multi-tenancy row-level, claim-based) — this ADR only changes the origin of the tenant claim and adds tenant switching; the read filter + stamping machinery is unchanged.
Related: ADR-006 (local Identity dual-scheme — issuer of the claim)
Reference: membership/switch patterns ported from ITLab.WorkGuardian (ABP-style), adapted to the canon.
Context
ADR-009 shipped membership as 1 user → 1 tenant (AppUser.TenantId), with that single tenant emitted as the tenant claim at login. The roadmap's Phase 1 generalizes membership to 1 user → N tenants so that (a) a user can belong to several tenants and pick which one is active, and (b) a future domain/subdomain resolution phase has a real "does this user belong to this tenant?" question to answer (ITenantAccessValidator).
ITenantAccessValidator already exists in the DevPack (since 10.4.0) as an interface with no concrete implementation. So this phase is template-only (no DevPack publish): the template materializes the join, the access validator, the active-tenant selector and the switch surface.
Decision
1. TenantUser join — surrogate key, soft-delete, not IMultiTenant
Membership is a TenantUser entity (Domain/Tenants/TenantUser.cs) extending the canon AuditedSoftDeletableEntity<int>:
- Surrogate
intkey + filtered unique index(UserId, TenantId)over[IsDeleted] = 0— aligned with the canon'sEntity<TKey>, rather than a composite key. Re-assigning a user to a tenant after a soft-deleted membership is therefore allowed. - Soft-delete: removing a user from a tenant soft-deletes the row. Reads that must see through that filter (
ValidateAccessAsync) useIgnoreQueryFilters()+ an explicit!IsDeletedpredicate. - Deliberately not
IMultiTenant.TenantUser.TenantIdis the join's own explicit datum. Marking itIMultiTenantwould let the stamping interceptor overwriteTenantIdwith the active tenant on insert, corrupting the membership. It is therefore stamped by nobody; the applicator gives it only the soft-delete query filter. Covered by a test (TenantUser_IsNotStampedByTheMultiTenantInterceptor).
Membership is managed through the Tenant aggregate (AddTenantUsers/RemoveTenantUsers) or, at bootstrap, the seeder via TenantUser.Assign. TenantUser is not an aggregate root.
2. The tenant claim now carries the active selected tenant
AppUser.TenantId is removed. At login the active tenant is chosen by IActiveTenantSelector:
- Keep the context tenant when the caller is already authenticated for a tenant and still has access (e.g. a cookie refresh re-issuing for the current tenant).
- Otherwise the deterministic default — the smallest tenant id the user is a member of.
- No membership →
Auth.NoTenant(401): a user with no tenant cannot establish a session.
IdentityService.ToAuthenticatedUserAsync (shared by credential login and refresh) calls the selector, so both login and refresh resolve the tenant the same way without per-handler changes. Everything downstream (AuthenticatedUserClaims → bridge → ICurrentUser.TenantId → query filter → interceptor) is unchanged.
IsPrimary-flag membership was considered and rejected for this phase: "smallest id" needs no extra column and the membership domain (which would own a real default) is a later phase.
3. Tenant switching — POST /v1/auth/tenant/switch
A new slice (Application/Identity/SwitchTenant) validates the current user's membership of the target tenant via ITenantAccessValidator, then rebuilds the authenticated user with the new active tenant. The endpoint materializes the new session per scheme (mirroring login): cookie re-sign-in for browsers, a fresh token pair for Bearer callers. GET /v1/auth/tenants (slice Application/Identity/MyTenants) lists the caller's tenants for the SPA selector.
4. Two-step data migration
AddTenantUserMembership creates the TenantUsers table, backfills one active membership per existing user from AppUsers.TenantId, and only then drops that column (a no-op on a fresh database). Down is symmetric.
Consequences
Positive
- Membership scales to N tenants with no change to the isolation machinery — only the claim's origin moved.
- Access checks and switching are first-class and reusable by the upcoming domain-resolution phase.
ITenantAccessValidator(DevPack interface) gets its canonical implementation in the template; no DevPack change required (Scenario 1).
Negative / trade-offs
- DevPack 10.5.0 has no
Forbiddenerror type. Switch-denied returnsAuth.TenantAccessDenied(anUnauthorized-typed error with a distinctive code) which the switch endpoint maps explicitly to 403. Promoting aForbiddenerror type to the DevPack is deferred (would be a Scenario 3 publish). - Token refresh resets to the default tenant. The
/token/refreshendpoint is anonymous (no context tenant) and the refresh token stores only the user id, so a refreshed Bearer token resolves to the smallest-id tenant rather than the previously active one. Switching again restores it. Cookie session refresh keeps the active tenant (the principal is authenticated). Acceptable for this phase; revisit if per-token tenant persistence is needed. - The active-tenant default ("smallest id") is deterministic but arbitrary until the membership domain phase introduces a real primary/default tenant.
Alternatives considered
- Composite key
(UserId, TenantId)(WorkGuardian): rejected — the canon standardizes onEntity<TKey>surrogate keys; a filtered unique index gives the same guarantee while keeping soft-delete re-assignment clean. TenantUser : IMultiTenant: rejected — the stamping interceptor would overwrite the join's explicitTenantId.IsPrimaryflag for the active default: deferred to the membership domain phase; "smallest id" suffices now.- Promoting membership/switch building blocks to the DevPack: deferred — the join modelling and switch wiring are canon-specific (G-01); only
ITenantAccessValidator(already in the DevPack) is universal.