Repository navigation
keycloak: stop hard-coding UserContext.tenant_id from the tenant_id JWT claim, and check register_setup_steps opt-out still holds #376
Description
Why
KeycloakAuthProvider._claims_to_user_context (modules/keycloak/keycloak/provider.py:89-95) sets UserContext.tenant_id=claims.get("tenant_id") directly from the IdP's JWT — this is exactly the legacy single-tenant claim path the tenancy design doc says TenantMiddleware's resolver now supersedes ("Without one, the legacy claim path"/"An authenticated user with no tenant could previously name any tenant with it"), so once the tenants module registers a real resolver this claim is ignored. Without it, the claim is trusted as-is: that is sound only while the realm alone controls tenant_id (a protocol mapper users cannot edit). A realm that maps it from an editable user attribute lets a user pick their tenant, and nothing in this module says so.
Tables
KeycloakUserCache(models.py:14) — adoptMultiTenantMixin: no. It's akeycloak_sub→ framework-UUID identity cache, analogous tousers.User— identity is platform-level, not per-tenant.keycloak_subunique constraint (models.py:18) stays global: a Keycloak subject maps to exactly one framework identity regardless of which tenants that person later belongs to.
Code paths
provider.py:89-95—UserContext(..., tenant_id=claims.get("tenant_id"))is the concrete instance called out in the task: this must be removed (or left for backward compat but never trusted) oncetenants' resolver exists, sinceTenantMiddlewareonly falls back to the claim/header path "without" a registered resolver — the risk window is betweenmulti_tenantbeing turned on and thetenantsmodule actually being installed and its resolver registered, during which this claim is the only source of tenant and is fully attacker/IdP-controlled._resolve_bearer/resolve_user(provider.py:31-37,64-73) — the session path (UserContext.from_session_dict, provider.py:37) round-trips whatevertenant_idwas stamped at login (including from the claim above) through the signed session cookie; even aftertenantsships, confirm nothing re-reads this staleUserContext.tenant_idfield for authorization instead of calling the live resolver each request (same concern raised in theusersmodule issue for the local-auth path)._upsert_user_cache(provider.py:97-134) opens its own session viarequest.app.state.sm.db.session_factory, not throughget_db's request-scoped session — worth confirming (not fully verified here) that this session still goes through the app'ssync_session_class/tenant-aware listener chain (framework/db/simple_module_db/listeners.py:99) rather than bypassing it the waybackground_tasks' separate sync engine does;KeycloakUserCachehas no tenant column so it may not matter today, but any future tenant-scoped write through this same pattern would inherit whatever answer that check gives.register_setup_stepsopt-out: per the framework CLAUDE.md,keycloakdeliberately registers no setup steps because its localuserstable is legitimately empty forever ("a host-level superuser count would lock those installs out permanently"). Confirm this still holds once tenants exist — specifically, that a fresh multi-tenant install using Keycloak as its only auth provider does not get stuck with no tenant and no local admin to create one from/setup, since the design doc's "No-tenant handling" (redirect to/tenants) assumes an authenticated user exists to be redirected.- No public/anonymous routes beyond the existing login/logout/callback paths (
get_public_paths, provider.py:45-57), unaffected by tenancy.
Migration
- No schema change to
KeycloakUserCache. - Operationally: realms/clients issuing a
tenant_idclaim should stop doing so (or it should be documented as ignored) once thetenantsresolver is live, to avoid operators believing that claim still does anything.
Tests
- A JWT bearing an arbitrary/attacker-supplied
tenant_idclaim never becomes the request's active tenant once atenant_resolveris registered — assertTenantMiddlewareprefers the resolver's answer overUserContext.tenant_idunconditionally, not just when the claim is absent. - Session round-trip: a
UserContextcached in the session from before a tenant switch does not resurrect the old tenant on a later request — the active tenant is re-resolved per request, not read from the cookie. _upsert_user_cache's session write path is confirmed to go through (or explicitly documented as not needing) the app's tenant/soft-delete listener chain.- Regression: a Keycloak-only install with
multi_tenant=Trueand zero local users still boots (noSM021-style lockout) and a first Keycloak login can reach/tenantsto create an org.
Out of scope / open questions
- Whether Keycloak should eventually map a role claim into
tenant:<role>for consistency with thetenantsmodule's principal augmentation is a design question fortenants/keycloakintegration, not decided here. - The
_upsert_user_cachesession-factory bypass is flagged for verification, not confirmed broken — worth a quick follow-up check againstsimple_module_db/listeners.py, but not blocking this issue's core fix (removing the trustedtenant_idclaim).
Context: part of the SaaS tenancy work in #370 (fail-closed isolation, tenant_context()/all_tenants(), the tenants module). Design: docs/plans/2026-09-27-saas-tenancy-design.md and docs/framework/multi-tenancy.md on that branch.
Activity
feat(keycloak): opt-in trust_tenant_claim, resolver stays authoritati…