ADR-012 — Per-tenant authentication settings (substrate; local provider only for now)
Date: 2026-06-23
Status: Accepted
Extends: ADR-006 (local Identity dual-scheme), ADR-009, ADR-011 (pre-auth domain resolution).
Pairs with: DevPack ADR-008 (settings store + secret protection).
Reference: ITLab.WorkGuardian (TenantSetting/BaseSettings, public auth-config query), adapted to the canon.
Context
The roadmap's Phase 3 lets each tenant choose its authentication provider(s) — local, Microsoft Entra ID, LDAP — with its own config/secrets, discovered by the SPA before login. This ADR records the substrate delivered now; per the ratified scope, only the local provider (DefaultProvider) is wired — Entra/LDAP scheme wiring and the SPA/MSAL work are deferred. The foundation is built so that work slots in without a breaking change.
Builds on DevPack 10.7.1, which ships the universal pieces (DevPack ADR-008): ISecretProtector (Data Protection) and the IPerTenantSettingsStore contract.
Decision
1. TenantSetting is IMultiTenant
Domain/Tenants/TenantSetting.cs (AuditedSoftDeletableEntity<int>, IMultiTenant): Key, Value (string), Type (SettingType enum: String/Boolean/Integer/Protected), IsSecret (= Protected). Unlike Tenant/TenantUser, this is tenant-scoped data, so it is IMultiTenant — the read filter scopes it and the write interceptor guards cross-tenant modify/delete. TenantSettingConfiguration adds a unique filtered index on (TenantId, Key); no manual query filter (the applicator owns it). TenantSettingKey holds the dotted keys (AuthProvider.{DefaultProvider,EntraID,Ldap}).
2. EF IPerTenantSettingsStore impl with store-side encryption + cache
Infrastructure/Identity/PerTenantSettingsStore.cs implements the DevPack contract over TenantSetting. Secret values are encrypted via ISecretProtector in SetAsync(mustProtect) and decrypted in the getters (callers always see plaintext). Reads use IgnoreQueryFilters() + explicit TenantId so the anonymous public-config endpoint (no current tenant) works. The full per-tenant map is cached in IMemoryCache for 5 minutes and evicted on write — the same shape as DbRolePermissionResolver.
3. Slices + endpoints
GetPublicAuthConfig— anonymous (GET /v1/auth/config); resolves the tenant by host (ADR-011) and returns enabled providers + primary, no secrets. Falls back to local-only when domain validation is off or the host does not resolve, so login is never blocked.GetAuthenticationSettings/UpdateAuthenticationSettings— admin (GET/PUT /v1/tenants/{id}/auth-settings), gated byTenants.AuthSettings.Read/Tenants.AuthSettings.Update(seeded forAdmin). Reuses the DevPackTenantAuthenticationSettingsDto. The update validator forbids disabling the local provider while it is the only wired one.
4. Seed + migration
IdentitySeeder seeds the default tenant's provider flags (local enabled; Entra/LDAP disabled) idempotently via the store. The AddTenantSettings migration creates the table + the two new RolePermission rows.
5. No scheme wiring, no SPA (deferred)
Program.cs is unchanged — no Entra/LDAP schemes yet. The "static schemes + per-tenant validation in OnTokenValidated" recipe (DevPack ADR-008) is the path when those providers land; IPerTenantSettingsStore is the seam they'll read config from.
Alternatives considered
TenantSettingnotIMultiTenant(likeTenantUser): rejected — settings are genuinely tenant-scoped data;IMultiTenantgives the read filter + write guard for free. The store reads cross-tenant viaIgnoreQueryFilterswhere needed (public config).- Encryption via a
SaveChangesInterceptor(WG mirror): rejected at the DevPack level (ADR-008) — store-side encryption avoids double-encryption; the template follows. - Add Entra/LDAP config + secrets now: deferred per ratified scope. The substrate (Protected type,
mustProtect, secret round-trip) is in place and tested so it's a non-breaking add later. - Expose
TenantSettingsraw to handlers: avoided — handlers go through the store so secret decryption + caching are centralized (the store is the settings access pattern, not a generic repository).
Consequences
- Non-breaking and opt-in: with no settings seeded and domain validation off,
/v1/auth/configreturns local-only; existing auth is unchanged. - Secret-at-rest works today (tested) even though no provider currently stores a secret — ready for Entra/LDAP.
- Bumps DevPack 10.6.0 → 10.7.1.
- Tests:
AuthSettingsTests(public config; admin update→get round-trip; disabling-only-provider rejected; missing-permission 403; secret encrypted at rest + decrypted on read) +TenantSettingTests. - InMemory caveat: the InMemory provider does not enforce the
(TenantId, Key)unique index; uniqueness is exercised against SQL Server.
References
- DevPack ADR-008, ADR-011.
Domain/Tenants/TenantSetting.cs,Persistence/Configurations/TenantSettingConfiguration.cs,Persistence/Migrations/*_AddTenantSettings.cs.Infrastructure/Identity/PerTenantSettingsStore.cs,Application/Tenants/AuthSettings/*,Api/Endpoints/{AuthEndpoints,TenantsEndpoints}.cs.