Skip to content

Tenant selection: the identity provider half of token-groups tenant resolution - #6879

Open
iliyan-velichkov wants to merge 6 commits into
masterfrom
tenant-selection-token-groups
Open

Tenant selection: the identity provider half of token-groups tenant resolution#6879
iliyan-velichkov wants to merge 6 commits into
masterfrom
tenant-selection-token-groups

Conversation

@iliyan-velichkov

Copy link
Copy Markdown
Contributor

Why

#6875 made the platform resolve the current tenant from a session attribute when DIRIGIBLE_TENANT_RESOLUTION_STRATEGY=TOKEN_GROUPS, and left one half missing. CLAUDE.md said so:

Who writes the session attribute is the identity provider side, which is not in the platform yet - until it is, TOKEN_GROUPS resolves every request to the default tenant.

This is that half. After it, a deployment whose identity provider carries authorization in groups named <tenantId>.<appId>.<role> works end to end: log in, get your tenants, pick one, work in it, switch without logging out.

What it does

Concern Where
Login maps global roles only in TOKEN_GROUPS mode (which tenant's roles apply is unknown at login) TenantAwareAuthoritiesMapper, behind both profiles' userAuthoritiesMapper()
Enter a tenant: validate against the user's own groups, store the selection, rebuild the authorities as globalRoles ∪ rolesFor(tenant) TenantSelectionManager
GET/POST /services/security/tenant-selection - list and enter, POST doubles as the switch TenantSelectionEndpoint
One tenant → auto-select; several → picker (302) or 409 TENANT_SELECTION_REQUIRED; none → pass for global-role holders, else 403 TenantSelectionFilter
The picker page, Harmonia + Alpine, ?switch=true to change tenant static/tenant-selection.html

Three decisions worth reviewing

It lives in security-oauth2, not in a profile module. Cognito and Keycloak need exactly the same thing and their SecurityConfiguration classes differ only in the groups claim, so the shared code sits where OAuth2SessionRevalidationFilter already is; each profile keeps one userAuthoritiesMapper() bean. That module gains a dependency on core-tenants (a selection has to be validated against the tenant registry); it is acyclic - engine-security does not depend on security-oauth2 - and both consumers already pull core-tenants. Everything is gated on the strategy, not on a Spring profile, so a Keycloak deployment gets the feature and the default path stays inert.

Registration goes through CustomSecurityConfigurator - the first implementation of that seam in the repo. The filter must run before AuthorizationFilter (a user who has not picked has no tenant roles, so authorization would 403 them before they saw the picker). Because HttpSecurityURIConfigurator.configure applies custom configurators before its own matchers and every chain calls it, one bean covers every profile, lands after the OIDC session revalidation, and needs no edit to the static PUBLIC_PATTERNS matrix.

The mapper's legacy path is unchanged on purpose. In SUBDOMAIN mode it maps every group of the provider's own claim, byte for byte as the two inline mappers did - pinned by a regression test. It also replaces their unchecked (OidcUserAuthority) authorities.get(0) + (ArrayList<String>) casts with something that tolerates a non-OIDC authority.

Tests

  • Unit, 36 new in security-oauth2: mapper (legacy identical, trial mode, global-roles-only, claim configurability, missing claim, non-OIDC authority), manager (select, switch, 403 non-member, 409 not-provisioned, refresh repair, revoked-group drop), filter (the full decision matrix), endpoint (payloads, statuses, absent in SUBDOMAIN mode). 60 tests pass in the module, including the four pre-existing suites.
  • Integration, TenantSelectionIT (8): boots the platform and closes the loop - a selection through the endpoint makes the next request run in that tenant, observed through the tenant's own DIRIGIBLE_CONFIGURATIONS with a per-tenant marker; plus the offered list, both refusals, the redirect, the 409, and the picker's reachability.
  • Regression: TokenGroupsTenantResolutionIT, TenantResolutionConfigValidationIT, SecurityIT, EndpointAuthorizationIT, EnabledMultitenantModeIT, DisabledMultitenantModeIT - 31 tests, unmodified and green. mvn -T 1C formatter:validate clean.

What the IT does not prove: a real OIDC login round trip. No test in this repo boots the cognito or keycloak profile - basic.enabled=false there removes the authentication every harness uses - and a fake issuer would prove little beyond bean wiring, so the authenticated user is fabricated exactly as the module's unit tests do. The real login path is exercised by a scenario suite outside this repo, against Keycloak.

mvn -pl components/security/security-oauth2,components/security/security-cognito,components/security/security-keycloak -am clean install -P unit-tests
mvn install -P integration-tests -pl tests/tests-integrations -Dit.test="TenantSelectionIT"

Two consequences documented in CLAUDE.md

In TOKEN_GROUPS mode the authorities of a session are recomputed from the groups, so a platform role such as ADMINISTRATOR has to be granted as a global group - anything granted another way is dropped on the next request. And a fresh selection applies from the next request, since the tenant scope of the current one was opened before the selection was written; the picker navigates away on success for exactly that reason.

🤖 Generated with Claude Code

iliyan-velichkov and others added 6 commits August 20, 2026 15:29
With the token groups strategy a group names a tenant, and which tenant applies
is not known at login: the user picks one afterwards. Mapping every group would
give a user the roles of every tenant they belong to at once, so only the
global roles - the groups that carry no tenant, such as DEVELOPER - become
authorities at login. The subdomain strategy keeps mapping every group of the
identity provider's own claim, byte for byte as before.

The mapper is shared by the two OIDC login profiles, whose authorities mappers
were identical apart from the claim name, and it replaces an unchecked cast of
the first authority that assumed it is always an OIDC one. Where the groups are
read from is now a single bean, TenantGroupsClaim, so the login mapping and the
tenant selection that follows can never disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GET /services/security/tenant-selection lists the tenants the user's groups
grant them in this application, with the name and provisioning state this
instance knows; POST enters one, which is also how a user switches - the
session attribute the tenant scope reads and the authorities of the session are
replaced together, with no re-login.

The identity provider stays the authority on membership: a tenant the user's
own groups do not name is refused with 403, and one this instance has not
finished provisioning with 409, since entering it would mean working in a
half-built schema. The endpoint carries no role gate on purpose - before a
tenant is selected a user has only their global roles, and a user of a single
tenant has none at all, so a role gate would lock out exactly the people who
have to pick. It requires a JSON body, which is what keeps a cross-origin form
from posting a selection while the chains have CSRF tokens disabled.

ensureConsistent re-applies the roles of the selected tenant when they drift:
an access-token refresh rebuilds the authorities from the identity provider and
leaves the global roles only. A selection whose group was revoked is dropped
instead of outliving the group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A user of exactly one tenant is put into it without being asked. A user of
several is sent to the picker: a browser by redirect, anything programmatic by
a 409 naming the choices, so an API client is told what to do instead of
silently landing in the wrong tenant. A user of none passes if they have global
roles, which is what staff of the instance look like, and is refused otherwise.
An existing selection is kept consistent, which is what repairs the authorities
an access-token refresh reduced to the global roles.

The filter must run before authorization - a user who has not selected a tenant
has no tenant roles, so authorization would answer 403 before they ever saw the
picker. It is registered through the CustomSecurityConfigurator seam rather
than in each profile's chain builder: the chains apply the custom configurators
as their last step, so the placement is deterministic (after the session
revalidation the OIDC profiles install, whose refreshed authorities have to
exist before they are repaired) and one wiring serves every profile. The picker
page is claimed as authenticated there too - it is for a user who is logged in
but has no tenant, so it can be neither public nor role gated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A user of several tenants lands here, and can come back with ?switch=true to
change tenant without logging out. Pure Harmonia and Alpine like the Home
landing page, and a classpath static page rather than a registry resource on
purpose: the registry is itself tenant scoped, so a page whose whole job is to
pick the tenant cannot live in it. Everything it loads - Harmonia, lucide, the
branding, the user name - is already on a publicly readable path, and the page
itself is claimed as authenticated by the selection configurator.

A tenant this instance has not finished provisioning is shown but not
selectable, with the reason, rather than hidden - a user who was told they have
a tenant should see it and know it is being prepared. Calls carry
X-Requested-With so an expired session is answered with a plain 401 instead of a
challenge the browser would render as its own login dialog, and a refusal is
translated into what the user can do about it.

The two legacy identity provider tenant filters now also stand down under the
token groups strategy, which replaces their custom:tenant model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ant scope

Boots the platform in token-groups mode and closes the loop the resolution test
had to fake: a user selects a tenant through the endpoint and the very next
request is served in that tenant, observed through the tenant's own
DIRIGIBLE_CONFIGURATIONS with a marker seeded per tenant. Also covers what the
groups offer, the two refusals, the redirect for a browser and the conflict for
a programmatic caller, and that the picker page needs a logged in user.

The login itself is not exercised: booting an OIDC profile means
basic.enabled=false, which removes the authentication every harness in this repo
uses, and a fake identity provider would prove little beyond bean wiring - so
the authenticated user is fabricated, as the unit tests of the security module
do.

Writing it surfaced something worth stating: in this mode the authorities of a
session are recomputed from the groups, so a platform role such as ADMINISTRATOR
has to be granted as a global group. Nothing else survives a request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the note saying the identity provider half is missing with what it now
does, and states the two consequences that are easy to get wrong: a platform
role has to be granted as a global group because the authorities are recomputed
from the groups, and a fresh selection applies from the next request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@iliyan-velichkov iliyan-velichkov self-assigned this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant