Pular para o conteúdo principal
Versão: main (dev) 🚧

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 int key + filtered unique index (UserId, TenantId) over [IsDeleted] = 0 — aligned with the canon's Entity<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) use IgnoreQueryFilters() + an explicit !IsDeleted predicate.
  • Deliberately not IMultiTenant. TenantUser.TenantId is the join's own explicit datum. Marking it IMultiTenant would let the stamping interceptor overwrite TenantId with 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:

  1. 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).
  2. Otherwise the deterministic default — the smallest tenant id the user is a member of.
  3. 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 Forbidden error type. Switch-denied returns Auth.TenantAccessDenied (an Unauthorized-typed error with a distinctive code) which the switch endpoint maps explicitly to 403. Promoting a Forbidden error type to the DevPack is deferred (would be a Scenario 3 publish).
  • Token refresh resets to the default tenant. The /token/refresh endpoint 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 on Entity<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 explicit TenantId.
  • IsPrimary flag 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.