Tenant selection: the identity provider half of token-groups tenant resolution - #6879
Open
iliyan-velichkov wants to merge 6 commits into
Open
Tenant selection: the identity provider half of token-groups tenant resolution#6879iliyan-velichkov wants to merge 6 commits into
iliyan-velichkov wants to merge 6 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdsaid so: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
TOKEN_GROUPSmode (which tenant's roles apply is unknown at login)TenantAwareAuthoritiesMapper, behind both profiles'userAuthoritiesMapper()globalRoles ∪ rolesFor(tenant)TenantSelectionManagerGET/POST /services/security/tenant-selection- list and enter,POSTdoubles as the switchTenantSelectionEndpoint409 TENANT_SELECTION_REQUIRED; none → pass for global-role holders, else 403TenantSelectionFilter?switch=trueto change tenantstatic/tenant-selection.htmlThree decisions worth reviewing
It lives in
security-oauth2, not in a profile module. Cognito and Keycloak need exactly the same thing and theirSecurityConfigurationclasses differ only in the groups claim, so the shared code sits whereOAuth2SessionRevalidationFilteralready is; each profile keeps oneuserAuthoritiesMapper()bean. That module gains a dependency oncore-tenants(a selection has to be validated against the tenant registry); it is acyclic -engine-securitydoes not depend onsecurity-oauth2- and both consumers already pullcore-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 beforeAuthorizationFilter(a user who has not picked has no tenant roles, so authorization would 403 them before they saw the picker). BecauseHttpSecurityURIConfigurator.configureapplies 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 staticPUBLIC_PATTERNSmatrix.The mapper's legacy path is unchanged on purpose. In
SUBDOMAINmode 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
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 inSUBDOMAINmode). 60 tests pass in the module, including the four pre-existing suites.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 ownDIRIGIBLE_CONFIGURATIONSwith a per-tenant marker; plus the offered list, both refusals, the redirect, the 409, and the picker's reachability.TokenGroupsTenantResolutionIT,TenantResolutionConfigValidationIT,SecurityIT,EndpointAuthorizationIT,EnabledMultitenantModeIT,DisabledMultitenantModeIT- 31 tests, unmodified and green.mvn -T 1C formatter:validateclean.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=falsethere 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_GROUPSmode the authorities of a session are recomputed from the groups, so a platform role such asADMINISTRATORhas 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