From bd8b89d8887c64114f4f249941e0fc8826b15b41 Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Thu, 20 Aug 2026 15:29:57 +0300 Subject: [PATCH 1/8] feat(tenants): map only the global roles at login in token-groups mode 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) --- .../cognito/CognitoSecurityConfiguration.java | 57 ++---- .../KeycloakSecurityConfiguration.java | 55 ++---- components/security/security-oauth2/pom.xml | 7 + .../tenant/TenantAwareAuthoritiesMapper.java | 125 +++++++++++++ .../oauth2/tenant/TenantGroupsClaim.java | 147 +++++++++++++++ .../TenantAwareAuthoritiesMapperTest.java | 168 ++++++++++++++++++ 6 files changed, 480 insertions(+), 79 deletions(-) create mode 100644 components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantAwareAuthoritiesMapper.java create mode 100644 components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantGroupsClaim.java create mode 100644 components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantAwareAuthoritiesMapperTest.java diff --git a/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoSecurityConfiguration.java b/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoSecurityConfiguration.java index cfc65737625..8750b34d381 100644 --- a/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoSecurityConfiguration.java +++ b/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoSecurityConfiguration.java @@ -9,32 +9,22 @@ */ package org.eclipse.dirigible.components.security.cognito; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; import org.eclipse.dirigible.commons.config.DirigibleConfig; import org.eclipse.dirigible.components.base.http.access.HttpSecurityURIConfigurator; -import org.eclipse.dirigible.components.base.http.roles.Roles; -import org.eclipse.dirigible.components.base.util.AuthoritiesUtil; import org.eclipse.dirigible.components.security.oauth.ScopeRoleJwtAuthoritiesConverter; import org.eclipse.dirigible.components.security.oauth2.IdpHintAuthorizationRequestResolver; import org.eclipse.dirigible.components.security.oauth2.OAuth2SessionRevalidationFilter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.eclipse.dirigible.components.security.oauth2.tenant.TenantAwareAuthoritiesMapper; +import org.eclipse.dirigible.components.security.oauth2.tenant.TenantGroupsClaim; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; @@ -49,16 +39,13 @@ @Configuration public class CognitoSecurityConfiguration { - /** The Constant LOGGER. */ - private static final Logger LOGGER = LoggerFactory.getLogger(CognitoSecurityConfiguration.class); - - private final boolean trialModeEnabled; + /** The claim AWS Cognito puts the user groups in. */ + private static final String COGNITO_GROUPS_CLAIM = "cognito:groups"; /** The Cognito JWKS endpoint backing the resource-server (Bearer) JWT decoder. */ private final String jwkSetUri; public CognitoSecurityConfiguration(@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri) { - this.trialModeEnabled = DirigibleConfig.TRIAL_ENABLED.getBooleanValue(); this.jwkSetUri = jwkSetUri; } @@ -72,8 +59,8 @@ public CognitoSecurityConfiguration(@Value("${spring.security.oauth2.resourceser @Bean SecurityFilterChain filterChain(HttpSecurity http, HttpSecurityURIConfigurator httpSecurityURIConfigurator, ScopeRoleJwtAuthoritiesConverter scopeRoleJwtAuthoritiesConverter, CognitoLogoutSuccessHandler cognitoLogoutSuccessHandler, - OAuth2AuthorizedClientService authorizedClientService, ClientRegistrationRepository clientRegistrationRepository) - throws Exception { + OAuth2AuthorizedClientService authorizedClientService, ClientRegistrationRepository clientRegistrationRepository, + GrantedAuthoritiesMapper userAuthoritiesMapper) throws Exception { String loginPage = DirigibleConfig.SECURITY_LOGIN_PAGE.getStringValue(); // both oauth2Client and oauth2Login register an authorization-request redirect filter, and // the client one runs first - the resolver must be set on both for the hints to pass through @@ -82,13 +69,12 @@ SecurityFilterChain filterChain(HttpSecurity http, HttpSecurityURIConfigurator h http.authorizeHttpRequests(authz -> authz.requestMatchers("/oauth2/**", "/login/**") .permitAll()) .csrf(csrf -> csrf.disable()) - .addFilterBefore(new OAuth2SessionRevalidationFilter(authorizedClientService, userAuthoritiesMapper()), - AuthorizationFilter.class) + .addFilterBefore(new OAuth2SessionRevalidationFilter(authorizedClientService, userAuthoritiesMapper), AuthorizationFilter.class) .headers(headers -> headers.frameOptions(frameOpts -> frameOpts.disable())) .oauth2Client(oauth2Client -> oauth2Client.authorizationCodeGrant( grant -> grant.authorizationRequestResolver(authorizationRequestResolver))) .oauth2Login(oauth2 -> { - oauth2.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userAuthoritiesMapper(userAuthoritiesMapper())); + oauth2.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userAuthoritiesMapper(userAuthoritiesMapper)); oauth2.authorizationEndpoint( authorizationEndpoint -> authorizationEndpoint.authorizationRequestResolver(authorizationRequestResolver)); if (StringUtils.hasText(loginPage)) { @@ -142,24 +128,15 @@ private JwtDecoder jwtDecoder() { .build(); } + /** + * Maps the Cognito groups of the logged in user to authorities. What exactly is mapped depends on + * the tenant resolution strategy - see {@link TenantAwareAuthoritiesMapper}. + * + * @param tenantGroupsClaim the configured groups claim + * @return the authorities mapper + */ @Bean - public GrantedAuthoritiesMapper userAuthoritiesMapper() { - return (authorities) -> { - Set grantedAuthorities = new HashSet<>(); - if (trialModeEnabled) { - LOGGER.debug("Trial enabled - returning all available system roles for the current user."); - grantedAuthorities.addAll(AuthoritiesUtil.toAuthorities(Arrays.stream(Roles.values()) - .map(Roles::getRoleName) - .collect(Collectors.toSet()))); - } else { - OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) new ArrayList<>(authorities).get(0); - List cognitoGroups = (ArrayList) oidcUserAuthority.getAttributes() - .get("cognito:groups"); - if (cognitoGroups != null) { - grantedAuthorities.addAll(AuthoritiesUtil.toAuthorities(cognitoGroups)); - } - } - return grantedAuthorities; - }; + public GrantedAuthoritiesMapper userAuthoritiesMapper(TenantGroupsClaim tenantGroupsClaim) { + return new TenantAwareAuthoritiesMapper(tenantGroupsClaim, COGNITO_GROUPS_CLAIM); } } diff --git a/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakSecurityConfiguration.java b/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakSecurityConfiguration.java index 86e5d60892b..cc06f1b1e07 100644 --- a/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakSecurityConfiguration.java +++ b/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakSecurityConfiguration.java @@ -9,22 +9,14 @@ */ package org.eclipse.dirigible.components.security.keycloak; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; import org.eclipse.dirigible.commons.config.DirigibleConfig; import org.eclipse.dirigible.components.base.http.access.HttpSecurityURIConfigurator; -import org.eclipse.dirigible.components.base.http.roles.Roles; -import org.eclipse.dirigible.components.base.util.AuthoritiesUtil; import org.eclipse.dirigible.components.security.oauth.ScopeRoleJwtAuthoritiesConverter; import org.eclipse.dirigible.components.security.oauth2.IdpHintAuthorizationRequestResolver; import org.eclipse.dirigible.components.security.oauth2.OAuth2SessionRevalidationFilter; +import org.eclipse.dirigible.components.security.oauth2.tenant.TenantAwareAuthoritiesMapper; +import org.eclipse.dirigible.components.security.oauth2.tenant.TenantGroupsClaim; import org.eclipse.dirigible.components.tenants.tenant.TenantContextInitFilter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -32,13 +24,11 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; -import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; @@ -54,16 +44,13 @@ @EnableWebSecurity public class KeycloakSecurityConfiguration { - /** The Constant LOGGER. */ - private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakSecurityConfiguration.class); - - private final boolean trialModeEnabled; + /** The claim a Keycloak realm typically puts the user groups in. */ + private static final String KEYCLOAK_GROUPS_CLAIM = "groups"; /** The Keycloak JWKS endpoint backing the resource-server (Bearer) JWT decoder. */ private final String jwkSetUri; public KeycloakSecurityConfiguration(@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri) { - this.trialModeEnabled = DirigibleConfig.TRIAL_ENABLED.getBooleanValue(); this.jwkSetUri = jwkSetUri; } @@ -79,7 +66,7 @@ public KeycloakSecurityConfiguration(@Value("${spring.security.oauth2.resourcese SecurityFilterChain configure(HttpSecurity http, TenantContextInitFilter tenantContextInitFilter, HttpSecurityURIConfigurator httpSecurityURIConfigurator, ScopeRoleJwtAuthoritiesConverter scopeRoleJwtAuthoritiesConverter, KeycloakLogoutSuccessHandler keycloakLogoutSuccessHandler, OAuth2AuthorizedClientService authorizedClientService, - ClientRegistrationRepository clientRegistrationRepository) throws Exception { + ClientRegistrationRepository clientRegistrationRepository, GrantedAuthoritiesMapper userAuthoritiesMapper) throws Exception { String loginPage = DirigibleConfig.SECURITY_LOGIN_PAGE.getStringValue(); // both oauth2Client and oauth2Login register an authorization-request redirect filter, and // the client one runs first - the resolver must be set on both for the hints to pass through @@ -89,14 +76,13 @@ SecurityFilterChain configure(HttpSecurity http, TenantContextInitFilter tenantC .permitAll()) .csrf(csrf -> csrf.disable()) .addFilterBefore(tenantContextInitFilter, OAuth2LoginAuthenticationFilter.class) - .addFilterBefore(new OAuth2SessionRevalidationFilter(authorizedClientService, userAuthoritiesMapper()), - AuthorizationFilter.class) + .addFilterBefore(new OAuth2SessionRevalidationFilter(authorizedClientService, userAuthoritiesMapper), AuthorizationFilter.class) .headers(headers -> headers.frameOptions(frameOpts -> frameOpts.sameOrigin())) .oauth2Client(oauth2Client -> oauth2Client.authorizationCodeGrant( grant -> grant.authorizationRequestResolver(authorizationRequestResolver))) .oauth2Login(Customizer.withDefaults()) .oauth2Login(oauth2 -> { - oauth2.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userAuthoritiesMapper(userAuthoritiesMapper())); + oauth2.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userAuthoritiesMapper(userAuthoritiesMapper)); oauth2.authorizationEndpoint( authorizationEndpoint -> authorizationEndpoint.authorizationRequestResolver(authorizationRequestResolver)); if (StringUtils.hasText(loginPage)) { @@ -150,24 +136,15 @@ private JwtDecoder jwtDecoder() { .build(); } + /** + * Maps the Keycloak groups of the logged in user to authorities. What exactly is mapped depends on + * the tenant resolution strategy - see {@link TenantAwareAuthoritiesMapper}. + * + * @param tenantGroupsClaim the configured groups claim + * @return the authorities mapper + */ @Bean - public GrantedAuthoritiesMapper userAuthoritiesMapper() { - return (authorities) -> { - Set grantedAuthorities = new HashSet<>(); - if (trialModeEnabled) { - LOGGER.debug("Trial enabled - returning all available system roles for the current user."); - grantedAuthorities.addAll(AuthoritiesUtil.toAuthorities(Arrays.stream(Roles.values()) - .map(Roles::getRoleName) - .collect(Collectors.toSet()))); - } else { - OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) new ArrayList<>(authorities).get(0); - List keycloakGroups = (ArrayList) oidcUserAuthority.getAttributes() - .get("groups"); - if (keycloakGroups != null) { - grantedAuthorities.addAll(AuthoritiesUtil.toAuthorities(keycloakGroups)); - } - } - return grantedAuthorities; - }; + public GrantedAuthoritiesMapper userAuthoritiesMapper(TenantGroupsClaim tenantGroupsClaim) { + return new TenantAwareAuthoritiesMapper(tenantGroupsClaim, KEYCLOAK_GROUPS_CLAIM); } } diff --git a/components/security/security-oauth2/pom.xml b/components/security/security-oauth2/pom.xml index ecb80024913..fa64597a4ba 100644 --- a/components/security/security-oauth2/pom.xml +++ b/components/security/security-oauth2/pom.xml @@ -25,6 +25,13 @@ dirigible-components-core-base + + + org.eclipse.dirigible + dirigible-components-core-tenants + + org.springframework.boot spring-boot-starter-oauth2-client diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantAwareAuthoritiesMapper.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantAwareAuthoritiesMapper.java new file mode 100644 index 00000000000..3829c1bbbf8 --- /dev/null +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantAwareAuthoritiesMapper.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.eclipse.dirigible.components.base.http.roles.Roles; +import org.eclipse.dirigible.components.base.tenant.TenantResolutionStrategy; +import org.eclipse.dirigible.components.base.tenant.groups.TenantGroupsParser; +import org.eclipse.dirigible.components.base.util.AuthoritiesUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; + +/** + * Maps the groups of a logged in user to authorities, the way the configured tenant resolution + * strategy requires. + * + *

+ * With {@link TenantResolutionStrategy#SUBDOMAIN} every group becomes an authority, exactly as + * before this class existed - the tenant comes from the host, so the groups carry no tenant. + * + *

+ * With {@link TenantResolutionStrategy#TOKEN_GROUPS} only the global roles are mapped. The + * tenant-bearing groups name a tenant each and which of them applies is not known at login: the + * user picks a tenant afterwards and {@link TenantSelectionManager} grants that tenant's roles + * then. Granting them all here would give a user every tenant's roles at once. + * + *

+ * Shared by the OIDC login profiles. They pass the claim their identity provider uses, which is + * what the subdomain strategy keeps reading; the token groups strategy reads the configured + * {@link TenantGroupsClaim} instead, since with two supported identity providers the claim is a + * deployment decision rather than something to guess. + */ +public class TenantAwareAuthoritiesMapper implements GrantedAuthoritiesMapper { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(TenantAwareAuthoritiesMapper.class); + + /** The configured claim, read in the token groups strategy. */ + private final TenantGroupsClaim groupsClaim; + + /** The claim of the identity provider, read in the subdomain strategy. */ + private final String providerGroupsClaim; + + private final boolean trialModeEnabled; + + /** + * Instantiates a new tenant aware authorities mapper. + * + * @param groupsClaim the configured claim the user groups are read from + * @param providerGroupsClaim the claim the identity provider of the active profile uses, e.g. + * {@code cognito:groups} or {@code groups} + */ + public TenantAwareAuthoritiesMapper(TenantGroupsClaim groupsClaim, String providerGroupsClaim) { + this(groupsClaim, providerGroupsClaim, DirigibleConfig.TRIAL_ENABLED.getBooleanValue()); + } + + /** + * Instantiates a new tenant aware authorities mapper. + * + * @param groupsClaim the configured claim the user groups are read from + * @param providerGroupsClaim the claim the identity provider of the active profile uses + * @param trialModeEnabled whether trial mode grants every system role + */ + TenantAwareAuthoritiesMapper(TenantGroupsClaim groupsClaim, String providerGroupsClaim, boolean trialModeEnabled) { + this.groupsClaim = groupsClaim; + this.providerGroupsClaim = providerGroupsClaim; + this.trialModeEnabled = trialModeEnabled; + } + + /** + * Maps the authorities of the login to the authorities of the session. + * + * @param authorities the authorities the OIDC login produced + * @return the granted authorities + */ + @Override + public Collection mapAuthorities(Collection authorities) { + if (trialModeEnabled) { + LOGGER.debug("Trial enabled - returning all available system roles for the current user."); + return AuthoritiesUtil.toAuthorities(Arrays.stream(Roles.values()) + .map(Roles::getRoleName) + .collect(Collectors.toSet())); + } + if (TenantResolutionStrategy.fromConfiguration() != TenantResolutionStrategy.TOKEN_GROUPS) { + Set providerGroups = TenantGroupsClaim.readGroups(authorities, providerGroupsClaim); + return providerGroups.isEmpty() ? Collections.emptySet() : AuthoritiesUtil.toAuthorities(providerGroups); + } + Set groups = groupsClaim.groupsOf(authorities); + if (groups.isEmpty()) { + LOGGER.debug("No groups found in claim [{}] of the current user.", groupsClaim.getName()); + return Collections.emptySet(); + } + Set globalRoles = TenantGroupsParser.parse(groups, DirigibleConfig.APP_ID.getStringValue()) + .globalRoles(); + LOGGER.debug("Mapped [{}] global roles out of [{}] groups. The roles of a tenant are granted when it is selected.", + globalRoles.size(), groups.size()); + return AuthoritiesUtil.toAuthorities(globalRoles); + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "TenantAwareAuthoritiesMapper [groupsClaim=" + groupsClaim + ", providerGroupsClaim=" + providerGroupsClaim + + ", trialModeEnabled=" + trialModeEnabled + "]"; + } +} diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantGroupsClaim.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantGroupsClaim.java new file mode 100644 index 00000000000..2d525111b52 --- /dev/null +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantGroupsClaim.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; +import org.springframework.stereotype.Component; + +/** + * The token claim the user groups are read from, and the reading itself. + * + *

+ * Identity providers disagree on the claim - AWS Cognito uses {@code cognito:groups}, a Keycloak + * realm typically {@code groups} - so it is configured, through + * {@link DirigibleConfig#TENANT_GROUPS_CLAIM}. This one bean is what everything granting tenant + * roles reads, so the login mapper and the tenant selection can never disagree on where to look. + */ +@Component +public class TenantGroupsClaim { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(TenantGroupsClaim.class); + + private final String name; + + /** + * Instantiates the configured claim. + */ + public TenantGroupsClaim() { + this(DirigibleConfig.TENANT_GROUPS_CLAIM.getStringValue()); + } + + /** + * Instantiates a claim by name. + * + * @param name the claim name + */ + public TenantGroupsClaim(String name) { + this.name = name; + } + + /** + * Gets the name of the claim. + * + * @return the claim name + */ + public String getName() { + return name; + } + + /** + * Reads the groups of an authenticated user. + * + * @param authentication the authentication; may be {@code null} + * @return the group names, never {@code null} + */ + public Set groupsOf(Authentication authentication) { + if (null == authentication) { + return Set.of(); + } + if (authentication.getPrincipal() instanceof OidcUser oidcUser) { + return toGroups(oidcUser.getClaims() + .get(name), + name, authentication.getName()); + } + return groupsOf(authentication.getAuthorities()); + } + + /** + * Reads the groups out of the authorities of a login. + * + * @param authorities the authorities; may be {@code null} + * @return the group names, never {@code null} + */ + public Set groupsOf(Collection authorities) { + return readGroups(authorities, name); + } + + /** + * Reads the groups out of the authorities of a login, from a claim named explicitly. Anything that + * is not an OIDC user authority - a bearer token authority, for instance - carries no groups claim + * and contributes nothing. + * + * @param authorities the authorities; may be {@code null} + * @param claimName the claim to read + * @return the group names, never {@code null} + */ + static Set readGroups(Collection authorities, String claimName) { + Set groups = new LinkedHashSet<>(); + if (null == authorities) { + return groups; + } + for (GrantedAuthority authority : authorities) { + if (authority instanceof OidcUserAuthority oidcUserAuthority) { + groups.addAll(toGroups(oidcUserAuthority.getAttributes() + .get(claimName), + claimName, oidcUserAuthority.getIdToken() + .getSubject())); + } + } + return groups; + } + + private static Set toGroups(Object claimValue, String claimName, String userName) { + if (null == claimValue) { + return Set.of(); + } + if (claimValue instanceof Collection claimValues) { + Set groups = new LinkedHashSet<>(); + claimValues.stream() + .filter(value -> null != value) + .map(Object::toString) + .forEach(groups::add); + return groups; + } + LOGGER.warn("Claim [{}] of user [{}] is not a collection but [{}] and cannot be read as groups.", claimName, userName, + claimValue.getClass() + .getName()); + return Set.of(); + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "TenantGroupsClaim [name=" + name + "]"; + } +} diff --git a/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantAwareAuthoritiesMapperTest.java b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantAwareAuthoritiesMapperTest.java new file mode 100644 index 00000000000..499f1eaf473 --- /dev/null +++ b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantAwareAuthoritiesMapperTest.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.dirigible.commons.config.Configuration; +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.eclipse.dirigible.components.base.http.roles.Roles; +import org.eclipse.dirigible.components.base.util.AuthoritiesUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; + +/** + * The subdomain strategy keeps mapping every group of the identity provider's own claim, byte for + * byte as before this class existed. The token groups strategy maps only the global roles, because + * which tenant's roles apply is not known until the user picks one. + */ +class TenantAwareAuthoritiesMapperTest { + + private static final String COGNITO_CLAIM = "cognito:groups"; + private static final String KEYCLOAK_CLAIM = "groups"; + private static final String APP_ID = "library"; + + @BeforeEach + @AfterEach + void clearConfiguration() { + Configuration.remove(DirigibleConfig.TENANT_RESOLUTION_STRATEGY.getKey()); + Configuration.remove(DirigibleConfig.TENANT_GROUPS_CLAIM.getKey()); + Configuration.remove(DirigibleConfig.APP_ID.getKey()); + } + + @Test + void subdomainStrategyMapsEveryGroupOfTheProviderClaim() { + Collection mapped = + mapper(COGNITO_CLAIM).mapAuthorities(List.of(oidcAuthority(COGNITO_CLAIM, "DEVELOPER", "acme.library.Owner"))); + + assertThat(roleNames(mapped)).containsExactlyInAnyOrder("DEVELOPER", "acme.library.Owner"); + } + + @Test + void subdomainStrategyIsIdenticalToTheLegacyMapping() { + List groups = List.of("DEVELOPER", "OPERATOR", "acme.library.Owner"); + + Collection mapped = + new LinkedHashSet<>(mapper(COGNITO_CLAIM).mapAuthorities(List.of(oidcAuthority(COGNITO_CLAIM, groups)))); + + assertThat(mapped).containsExactlyInAnyOrderElementsOf(AuthoritiesUtil.toAuthorities(groups)); + } + + @Test + void subdomainStrategyReadsTheProviderClaimAndNotTheConfiguredOne() { + DirigibleConfig.TENANT_GROUPS_CLAIM.setStringValue(COGNITO_CLAIM); + + Collection mapped = + mapper(KEYCLOAK_CLAIM).mapAuthorities(List.of(oidcAuthority(KEYCLOAK_CLAIM, "DEVELOPER"))); + + assertThat(roleNames(mapped)).containsExactly("DEVELOPER"); + } + + @Test + void tokenGroupsStrategyMapsGlobalRolesOnly() { + useTokenGroups(); + + Collection mapped = mapper(COGNITO_CLAIM).mapAuthorities( + List.of(oidcAuthority(COGNITO_CLAIM, "DEVELOPER", "acme.library.Owner", "globex.library.User", "acme.bi.Owner"))); + + assertThat(roleNames(mapped)).containsExactly("DEVELOPER"); + } + + @Test + void tokenGroupsStrategyGrantsNothingToAUserWithTenantGroupsOnly() { + useTokenGroups(); + + Collection mapped = + mapper(COGNITO_CLAIM).mapAuthorities(List.of(oidcAuthority(COGNITO_CLAIM, "acme.library.Owner"))); + + assertThat(mapped).isEmpty(); + } + + @Test + void tokenGroupsStrategyReadsTheConfiguredClaim() { + useTokenGroups(); + DirigibleConfig.TENANT_GROUPS_CLAIM.setStringValue(KEYCLOAK_CLAIM); + + // The provider default is the Cognito claim, but a Keycloak realm was configured. + Collection mapped = + mapper(COGNITO_CLAIM).mapAuthorities(List.of(oidcAuthority(KEYCLOAK_CLAIM, "OPERATOR"))); + + assertThat(roleNames(mapped)).containsExactly("OPERATOR"); + } + + @Test + void aMissingClaimGrantsNothing() { + useTokenGroups(); + + assertThat(mapper(COGNITO_CLAIM).mapAuthorities(List.of(oidcAuthority("some-other-claim", "DEVELOPER")))).isEmpty(); + assertThat(mapper(COGNITO_CLAIM).mapAuthorities(List.of())).isEmpty(); + assertThat(mapper(COGNITO_CLAIM).mapAuthorities(null)).isEmpty(); + } + + @Test + void anAuthorityThatIsNotAnOidcUserAuthorityIsIgnoredInsteadOfFailing() { + useTokenGroups(); + + Collection mapped = + mapper(COGNITO_CLAIM).mapAuthorities(List.of(new SimpleGrantedAuthority("SCOPE_read"))); + + assertThat(mapped).isEmpty(); + } + + @Test + void trialModeGrantsEverySystemRole() { + TenantAwareAuthoritiesMapper trialMapper = + new TenantAwareAuthoritiesMapper(new TenantGroupsClaim(COGNITO_CLAIM), COGNITO_CLAIM, true); + + Collection mapped = trialMapper.mapAuthorities(List.of()); + + assertThat(roleNames(mapped)).containsExactlyInAnyOrderElementsOf(Arrays.stream(Roles.values()) + .map(Roles::getRoleName) + .collect(Collectors.toSet())); + } + + private static void useTokenGroups() { + DirigibleConfig.TENANT_RESOLUTION_STRATEGY.setStringValue("TOKEN_GROUPS"); + DirigibleConfig.APP_ID.setStringValue(APP_ID); + } + + private static TenantAwareAuthoritiesMapper mapper(String providerClaim) { + return new TenantAwareAuthoritiesMapper(new TenantGroupsClaim(), providerClaim, false); + } + + private static OidcUserAuthority oidcAuthority(String claimName, String... groups) { + return oidcAuthority(claimName, List.of(groups)); + } + + private static OidcUserAuthority oidcAuthority(String claimName, List groups) { + OidcIdToken idToken = new OidcIdToken("token", Instant.now(), Instant.now() + .plusSeconds(300), + Map.of("sub", "user@example.com", claimName, groups)); + return new OidcUserAuthority(idToken); + } + + private static Set roleNames(Collection authorities) { + return Set.copyOf(AuthoritiesUtil.toRoleNames(authorities)); + } +} From f600cb43b394001186c65d35a0755defbf4e997d Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Thu, 20 Aug 2026 15:34:16 +0300 Subject: [PATCH 2/8] feat(tenants): let a user enter one of their tenants 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) --- .../security/oauth2/tenant/TenantOption.java | 21 ++ .../tenant/TenantSelectionEndpoint.java | 165 ++++++++++++ .../tenant/TenantSelectionException.java | 70 +++++ .../oauth2/tenant/TenantSelectionManager.java | 250 +++++++++++++++++ .../tenant/TenantSelectionEndpointTest.java | 143 ++++++++++ .../tenant/TenantSelectionManagerTest.java | 254 ++++++++++++++++++ 6 files changed, 903 insertions(+) create mode 100644 components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantOption.java create mode 100644 components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionEndpoint.java create mode 100644 components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionException.java create mode 100644 components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManager.java create mode 100644 components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionEndpointTest.java create mode 100644 components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManagerTest.java diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantOption.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantOption.java new file mode 100644 index 00000000000..ef7466eef70 --- /dev/null +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantOption.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +/** + * A tenant the user may enter, as offered to the tenant picker. + * + * @param id the tenant id, as it appears in the user's groups + * @param name the name this instance knows the tenant under, the id when it knows none + * @param provisionedHere whether this instance has finished provisioning the tenant; a tenant that + * is not cannot be entered yet, and the picker says so instead of hiding it + */ +public record TenantOption(String id, String name, boolean provisionedHere) { +} diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionEndpoint.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionEndpoint.java new file mode 100644 index 00000000000..8243fe36542 --- /dev/null +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionEndpoint.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +import java.util.List; +import java.util.Set; + +import org.eclipse.dirigible.components.base.endpoint.BaseEndpoint; +import org.eclipse.dirigible.components.base.tenant.TenantResolutionStrategy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Lets a logged in user see which tenants of this application they may enter, and enter one. + * + *

+ * A {@code POST} is also how a user switches tenant - the selection and the authorities are + * replaced together, with no re-login. + * + *

+ * Deliberately without a role gate: before a tenant is selected a user has only their global roles, + * and a user of a single tenant has none at all - requiring a role here would lock out exactly the + * people who need to pick. The URL is gated as authenticated, which is the requirement that + * matters. A body is required to be JSON, which is what keeps a cross-origin form from posting a + * selection (the security chains disable CSRF tokens). + */ +@RestController +@RequestMapping(BaseEndpoint.PREFIX_ENDPOINT_SECURITY + "tenant-selection") +public class TenantSelectionEndpoint { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(TenantSelectionEndpoint.class); + + private final TenantSelectionManager tenantSelectionManager; + + /** + * Instantiates a new tenant selection endpoint. + * + * @param tenantSelectionManager the tenant selection manager + */ + public TenantSelectionEndpoint(TenantSelectionManager tenantSelectionManager) { + this.tenantSelectionManager = tenantSelectionManager; + } + + /** + * The tenants the user may enter, and which of them is selected. + * + * @param request the request + * @return the selection state + */ + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity state(HttpServletRequest request) { + requireTokenGroupsStrategy(); + List tenants = tenantSelectionManager.availableTenants(SecurityContextHolder.getContext() + .getAuthentication()); + return ResponseEntity.ok(new TenantSelectionState(tenantSelectionManager.selectedTenantId(request), tenants)); + } + + /** + * Enters a tenant, or switches to it. + * + * @param selection the tenant to enter + * @param request the request + * @param response the response + * @return the tenant and the roles the user now has in it + */ + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity select(@RequestBody TenantSelectionRequest selection, HttpServletRequest request, + HttpServletResponse response) { + requireTokenGroupsStrategy(); + if (selection == null || selection.tenantId() == null || selection.tenantId() + .isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "A tenant id is required"); + } + String tenantId = selection.tenantId() + .trim(); + Set roles = tenantSelectionManager.selectTenant(request, response, tenantId); + return ResponseEntity.ok(new TenantSelectionResult(tenantId, roles)); + } + + /** + * Answers a refused selection. + * + * @param exception the refusal + * @return the response + */ + @ExceptionHandler(TenantSelectionException.class) + public ResponseEntity onRefusedSelection(TenantSelectionException exception) { + LOGGER.info("Refused tenant selection [{}]: {}", exception.getTenantId(), exception.getMessage()); + HttpStatus status = switch (exception.getReason()) { + case NOT_A_MEMBER -> HttpStatus.FORBIDDEN; + case NOT_PROVISIONED_HERE -> HttpStatus.CONFLICT; + case NOT_AN_INTERACTIVE_SESSION -> HttpStatus.UNAUTHORIZED; + }; + return ResponseEntity.status(status) + .body(new TenantSelectionRefusal(exception.getReason() + .name(), + exception.getMessage())); + } + + /** + * The endpoint exists only where a tenant is something a user selects. + */ + private void requireTokenGroupsStrategy() { + if (TenantResolutionStrategy.fromConfiguration() != TenantResolutionStrategy.TOKEN_GROUPS) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tenants are not selected in this deployment"); + } + } + + /** + * What the user may enter, and what they entered. + * + * @param selectedTenantId the selected tenant id, {@code null} when none is selected + * @param tenants the tenants the user may enter + */ + public record TenantSelectionState(String selectedTenantId, List tenants) { + } + + /** + * A request to enter a tenant. + * + * @param tenantId the tenant id + */ + public record TenantSelectionRequest(String tenantId) { + } + + /** + * The outcome of entering a tenant. + * + * @param tenantId the tenant entered + * @param roles the roles the user has now + */ + public record TenantSelectionResult(String tenantId, Set roles) { + } + + /** + * A refused selection. + * + * @param reason why it was refused + * @param message the human readable explanation + */ + public record TenantSelectionRefusal(String reason, String message) { + } +} diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionException.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionException.java new file mode 100644 index 00000000000..791691928a4 --- /dev/null +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionException.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +/** + * A tenant a user asked for cannot be entered. + * + *

+ * Carries the reason rather than an HTTP status, because the same refusal is answered differently + * depending on who asked - a REST client gets a status, the selection filter gets to redirect. + */ +public class TenantSelectionException extends RuntimeException { + + /** The serial version UID. */ + private static final long serialVersionUID = 1L; + + /** + * Why a tenant cannot be entered. + */ + public enum Reason { + /** The groups of the user do not grant the tenant in this application. */ + NOT_A_MEMBER, + /** The tenant exists for the user, but this instance has not provisioned it yet. */ + NOT_PROVISIONED_HERE, + /** The request is not an interactive session that could hold a selection. */ + NOT_AN_INTERACTIVE_SESSION + } + + private final Reason reason; + + private final String tenantId; + + /** + * Instantiates a new tenant selection exception. + * + * @param reason the reason + * @param tenantId the tenant that was asked for + * @param message the message + */ + public TenantSelectionException(Reason reason, String tenantId, String message) { + super(message); + this.reason = reason; + this.tenantId = tenantId; + } + + /** + * Gets the reason. + * + * @return the reason + */ + public Reason getReason() { + return reason; + } + + /** + * Gets the tenant that was asked for. + * + * @return the tenant id + */ + public String getTenantId() { + return tenantId; + } +} diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManager.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManager.java new file mode 100644 index 00000000000..3b712dc0791 --- /dev/null +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManager.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.eclipse.dirigible.components.base.tenant.groups.TenantGroupsParser; +import org.eclipse.dirigible.components.base.tenant.groups.UserTenantAssignments; +import org.eclipse.dirigible.components.base.util.AuthoritiesUtil; +import org.eclipse.dirigible.components.tenants.domain.Tenant; +import org.eclipse.dirigible.components.tenants.domain.TenantStatus; +import org.eclipse.dirigible.components.tenants.service.TenantService; +import org.eclipse.dirigible.components.tenants.tenant.TenantSelectionConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Grants a user the tenant they picked. + * + *

+ * The selection is kept in the HTTP session, where the tenant scope of every following request + * reads it, and the authorities of the session become the user's global roles plus the roles their + * groups grant them in that tenant. Selecting again is how a user switches tenant: the + * session attribute and the authorities are replaced together, with no re-login. + * + *

+ * The identity provider stays the authority on membership - a selection is only accepted when the + * user's own groups grant the tenant. + */ +@Component +public class TenantSelectionManager { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(TenantSelectionManager.class); + + private final TenantGroupsClaim groupsClaim; + + private final TenantService tenantService; + + private final SecurityContextRepository securityContextRepository; + + /** + * Instantiates a new tenant selection manager. + * + * @param groupsClaim the claim the user groups are read from + * @param tenantService the tenant registry of this instance + */ + public TenantSelectionManager(TenantGroupsClaim groupsClaim, TenantService tenantService) { + this(groupsClaim, tenantService, new HttpSessionSecurityContextRepository()); + } + + /** + * Instantiates a new tenant selection manager. + * + * @param groupsClaim the claim the user groups are read from + * @param tenantService the tenant registry of this instance + * @param securityContextRepository where the rebuilt authentication is persisted + */ + TenantSelectionManager(TenantGroupsClaim groupsClaim, TenantService tenantService, + SecurityContextRepository securityContextRepository) { + this.groupsClaim = groupsClaim; + this.tenantService = tenantService; + this.securityContextRepository = securityContextRepository; + } + + /** + * What the groups of the authenticated user say about this application. + * + * @param authentication the authentication; may be {@code null} + * @return the assignments, never {@code null} + */ + public UserTenantAssignments assignmentsOf(Authentication authentication) { + Set groups = groupsClaim.groupsOf(authentication); + if (groups.isEmpty()) { + return UserTenantAssignments.empty(); + } + return TenantGroupsParser.parse(groups, DirigibleConfig.APP_ID.getStringValue()); + } + + /** + * The tenants the user may enter, in the order their groups name them. + * + * @param authentication the authentication; may be {@code null} + * @return the tenants, never {@code null} + */ + public List availableTenants(Authentication authentication) { + List options = new ArrayList<>(); + for (String tenantId : assignmentsOf(authentication).tenantIds()) { + Optional tenant = tenantService.findById(tenantId); + String name = tenant.map(Tenant::getName) + .orElse(tenantId); + options.add(new TenantOption(tenantId, name, isProvisioned(tenant))); + } + return options; + } + + /** + * The tenant the session has selected, if any. + * + * @param request the request + * @return the selected tenant id, or {@code null} + */ + public String selectedTenantId(HttpServletRequest request) { + Object selected = request.getSession(false) == null ? null + : request.getSession(false) + .getAttribute(TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE); + return selected == null ? null : selected.toString(); + } + + /** + * Enters a tenant: stores the selection in the session and rebuilds the authorities of the + * authenticated user as their global roles plus their roles in that tenant. + * + *

+ * The tenant scope of the current request was opened before this ran, so the selection takes effect + * from the next request on - which is why the picker navigates away after a successful selection. + * + * @param request the request + * @param response the response + * @param tenantId the tenant to enter + * @return the role names the user now has + * @throws TenantSelectionException if the user's groups do not grant the tenant, if this instance + * has not provisioned it, or if the request carries no interactive session + */ + public Set selectTenant(HttpServletRequest request, HttpServletResponse response, String tenantId) { + Authentication authentication = SecurityContextHolder.getContext() + .getAuthentication(); + if (!(authentication instanceof OAuth2AuthenticationToken oauth2Authentication)) { + throw new TenantSelectionException(TenantSelectionException.Reason.NOT_AN_INTERACTIVE_SESSION, tenantId, + "Only a logged in user can select a tenant"); + } + UserTenantAssignments assignments = assignmentsOf(authentication); + Set tenantRoles = assignments.rolesFor(tenantId); + if (tenantRoles.isEmpty()) { + throw new TenantSelectionException(TenantSelectionException.Reason.NOT_A_MEMBER, tenantId, + "User [" + authentication.getName() + "] is not assigned to tenant [" + tenantId + "] of this application"); + } + if (!isProvisioned(tenantService.findById(tenantId))) { + throw new TenantSelectionException(TenantSelectionException.Reason.NOT_PROVISIONED_HERE, tenantId, + "Tenant [" + tenantId + "] is not provisioned in this application yet"); + } + request.getSession() + .setAttribute(TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE, tenantId); + Set roles = rolesOf(assignments, tenantId); + reauthenticate(oauth2Authentication, roles, request, response); + + LOGGER.info("User [{}] selected tenant [{}] and has roles [{}].", authentication.getName(), tenantId, roles); + return roles; + } + + /** + * Re-applies the authorities the current selection implies, when they drifted from what the user's + * groups now grant. + * + *

+ * Two things make them drift: an access-token refresh rebuilds the authorities from the identity + * provider, which yields the global roles only, and a group revoked at the identity provider is + * reflected in the very next token. A selection that is no longer granted is dropped, so the user + * is asked to pick again instead of keeping roles they lost. + * + * @param request the request + * @param response the response + */ + public void ensureConsistent(HttpServletRequest request, HttpServletResponse response) { + Authentication authentication = SecurityContextHolder.getContext() + .getAuthentication(); + if (!(authentication instanceof OAuth2AuthenticationToken oauth2Authentication)) { + return; + } + String selectedTenantId = selectedTenantId(request); + if (selectedTenantId == null) { + return; + } + UserTenantAssignments assignments = assignmentsOf(authentication); + if (assignments.rolesFor(selectedTenantId) + .isEmpty()) { + LOGGER.info("User [{}] is no longer assigned to the selected tenant [{}]. Dropping the selection.", authentication.getName(), + selectedTenantId); + request.getSession() + .removeAttribute(TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE); + reauthenticate(oauth2Authentication, assignments.globalRoles(), request, response); + return; + } + Set expectedRoles = rolesOf(assignments, selectedTenantId); + Set currentRoles = new LinkedHashSet<>(AuthoritiesUtil.toRoleNames(authentication.getAuthorities())); + if (!currentRoles.equals(expectedRoles)) { + LOGGER.debug("Re-applying the roles of tenant [{}] for user [{}]: [{}] instead of [{}].", selectedTenantId, + authentication.getName(), expectedRoles, currentRoles); + reauthenticate(oauth2Authentication, expectedRoles, request, response); + } + } + + private Set rolesOf(UserTenantAssignments assignments, String tenantId) { + Set roles = new LinkedHashSet<>(assignments.globalRoles()); + roles.addAll(assignments.rolesFor(tenantId)); + return roles; + } + + /** + * Replaces the authentication of the session with one carrying the given roles, the way the session + * revalidation does after a token refresh. The session id is deliberately kept: it is the same user + * in the same identity provider session. + * + * @param authentication the current authentication + * @param roles the role names to grant + * @param request the request + * @param response the response + */ + private void reauthenticate(OAuth2AuthenticationToken authentication, Set roles, HttpServletRequest request, + HttpServletResponse response) { + Collection authorities = new LinkedHashSet<>(AuthoritiesUtil.toAuthorities(roles)); + OAuth2AuthenticationToken reauthenticated = new OAuth2AuthenticationToken(authentication.getPrincipal(), authorities, + authentication.getAuthorizedClientRegistrationId()); + reauthenticated.setDetails(authentication.getDetails()); + + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(reauthenticated); + SecurityContextHolder.setContext(securityContext); + securityContextRepository.saveContext(securityContext, request, response); + } + + private boolean isProvisioned(Optional tenant) { + return tenant.filter(found -> TenantStatus.PROVISIONED == found.getStatus()) + .isPresent(); + } +} diff --git a/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionEndpointTest.java b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionEndpointTest.java new file mode 100644 index 00000000000..03ae975df16 --- /dev/null +++ b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionEndpointTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Set; + +import org.eclipse.dirigible.commons.config.Configuration; +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.eclipse.dirigible.components.security.oauth2.tenant.TenantSelectionEndpoint.TenantSelectionRefusal; +import org.eclipse.dirigible.components.security.oauth2.tenant.TenantSelectionEndpoint.TenantSelectionRequest; +import org.eclipse.dirigible.components.security.oauth2.tenant.TenantSelectionEndpoint.TenantSelectionResult; +import org.eclipse.dirigible.components.security.oauth2.tenant.TenantSelectionEndpoint.TenantSelectionState; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.server.ResponseStatusException; + +/** + * The endpoint is the picker's contract: it lists what the user may enter, enters one, and answers + * a refusal with a status a client can act on. It exists only where tenants are selected at all. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TenantSelectionEndpointTest { + + private static final String ACME = "acme"; + + @Mock + private TenantSelectionManager tenantSelectionManager; + + private TenantSelectionEndpoint endpoint; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + DirigibleConfig.TENANT_RESOLUTION_STRATEGY.setStringValue("TOKEN_GROUPS"); + endpoint = new TenantSelectionEndpoint(tenantSelectionManager); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @AfterEach + void tearDown() { + Configuration.remove(DirigibleConfig.TENANT_RESOLUTION_STRATEGY.getKey()); + } + + @Test + void theStateListsTheTenantsAndTheSelection() { + when(tenantSelectionManager.selectedTenantId(request)).thenReturn(ACME); + when(tenantSelectionManager.availableTenants(any())).thenReturn( + List.of(new TenantOption(ACME, "Acme Ltd", true), new TenantOption("globex", "globex", false))); + + ResponseEntity state = endpoint.state(request); + + assertThat(state.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(state.getBody() + .selectedTenantId()).isEqualTo(ACME); + assertThat(state.getBody() + .tenants()).hasSize(2); + } + + @Test + void selectingReturnsTheTenantAndTheRoles() { + when(tenantSelectionManager.selectTenant(any(), any(), eq(ACME))).thenReturn(Set.of("Owner", "DEVELOPER")); + + ResponseEntity result = endpoint.select(new TenantSelectionRequest(ACME), request, response); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody() + .tenantId()).isEqualTo(ACME); + assertThat(result.getBody() + .roles()).containsExactlyInAnyOrder("Owner", "DEVELOPER"); + } + + @Test + void theTenantIdIsRequiredAndTrimmed() { + when(tenantSelectionManager.selectTenant(any(), any(), eq(ACME))).thenReturn(Set.of("Owner")); + + assertThat(endpoint.select(new TenantSelectionRequest(" acme "), request, response) + .getBody() + .tenantId()).isEqualTo(ACME); + + assertThatThrownBy(() -> endpoint.select(new TenantSelectionRequest(" "), request, response)).isInstanceOf( + ResponseStatusException.class); + assertThatThrownBy(() -> endpoint.select(new TenantSelectionRequest(null), request, response)).isInstanceOf( + ResponseStatusException.class); + assertThatThrownBy(() -> endpoint.select(null, request, response)).isInstanceOf(ResponseStatusException.class); + } + + @Test + void aRefusalCarriesTheStatusOfItsReason() { + assertThat(refusalStatus(TenantSelectionException.Reason.NOT_A_MEMBER)).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(refusalStatus(TenantSelectionException.Reason.NOT_PROVISIONED_HERE)).isEqualTo(HttpStatus.CONFLICT); + assertThat(refusalStatus(TenantSelectionException.Reason.NOT_AN_INTERACTIVE_SESSION)).isEqualTo(HttpStatus.UNAUTHORIZED); + + ResponseEntity refusal = endpoint.onRefusedSelection( + new TenantSelectionException(TenantSelectionException.Reason.NOT_PROVISIONED_HERE, ACME, "not provisioned yet")); + assertThat(refusal.getBody() + .reason()).isEqualTo("NOT_PROVISIONED_HERE"); + assertThat(refusal.getBody() + .message()).isEqualTo("not provisioned yet"); + } + + @Test + void theEndpointIsAbsentWhereTenantsAreNotSelected() { + Configuration.remove(DirigibleConfig.TENANT_RESOLUTION_STRATEGY.getKey()); + + assertThatThrownBy(() -> endpoint.state(request)).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("404"); + assertThatThrownBy(() -> endpoint.select(new TenantSelectionRequest(ACME), request, response)).isInstanceOf( + ResponseStatusException.class); + } + + private HttpStatus refusalStatus(TenantSelectionException.Reason reason) { + return HttpStatus.valueOf(endpoint.onRefusedSelection(new TenantSelectionException(reason, ACME, "refused")) + .getStatusCode() + .value()); + } +} diff --git a/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManagerTest.java b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManagerTest.java new file mode 100644 index 00000000000..155ab233b5c --- /dev/null +++ b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManagerTest.java @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.dirigible.commons.config.Configuration; +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.eclipse.dirigible.components.base.util.AuthoritiesUtil; +import org.eclipse.dirigible.components.tenants.domain.Tenant; +import org.eclipse.dirigible.components.tenants.domain.TenantStatus; +import org.eclipse.dirigible.components.tenants.service.TenantService; +import org.eclipse.dirigible.components.tenants.tenant.TenantSelectionConstants; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.context.SecurityContextRepository; + +/** + * A user enters a tenant their own groups grant them, and gets the roles of that tenant on top of + * their global ones. The identity provider stays the authority on membership: a tenant the groups + * do not name is refused, and one the groups lost is dropped again on the next request. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TenantSelectionManagerTest { + + private static final String GROUPS_CLAIM = "groups"; + private static final String APP_ID = "library"; + private static final String USER = "owner@example.com"; + private static final String ACME = "acme"; + private static final String GLOBEX = "globex"; + + @Mock + private TenantService tenantService; + + @Mock + private SecurityContextRepository securityContextRepository; + + private TenantSelectionManager manager; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + DirigibleConfig.TENANT_GROUPS_CLAIM.setStringValue(GROUPS_CLAIM); + DirigibleConfig.APP_ID.setStringValue(APP_ID); + manager = new TenantSelectionManager(new TenantGroupsClaim(), tenantService, securityContextRepository); + request = new MockHttpServletRequest(); + request.setSession(new MockHttpSession()); + response = new MockHttpServletResponse(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + Configuration.remove(DirigibleConfig.TENANT_GROUPS_CLAIM.getKey()); + Configuration.remove(DirigibleConfig.APP_ID.getKey()); + } + + @Test + void theTenantsOfTheGroupsAreOfferedWithTheirLocalState() { + authenticate("acme.library.Owner", "globex.library.User", "acme.bi.Owner", "DEVELOPER"); + when(tenantService.findById(ACME)).thenReturn(Optional.of(tenant(ACME, "Acme Ltd", TenantStatus.PROVISIONED))); + when(tenantService.findById(GLOBEX)).thenReturn(Optional.empty()); + + List tenants = manager.availableTenants(SecurityContextHolder.getContext() + .getAuthentication()); + + assertThat(tenants).containsExactly(new TenantOption(ACME, "Acme Ltd", true), new TenantOption(GLOBEX, GLOBEX, false)); + } + + @Test + void selectingATenantStoresItAndGrantsItsRolesOnTopOfTheGlobalOnes() { + authenticate("acme.library.Owner", "acme.library.User", "globex.library.User", "DEVELOPER"); + when(tenantService.findById(ACME)).thenReturn(Optional.of(tenant(ACME, "Acme Ltd", TenantStatus.PROVISIONED))); + + Set roles = manager.selectTenant(request, response, ACME); + + assertThat(roles).containsExactlyInAnyOrder("DEVELOPER", "Owner", "User"); + assertThat(request.getSession() + .getAttribute(TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE)).isEqualTo(ACME); + assertThat(currentRoleNames()).containsExactlyInAnyOrder("DEVELOPER", "Owner", "User"); + verify(securityContextRepository).saveContext(any(SecurityContext.class), any(), any()); + } + + @Test + void switchingTenantReplacesTheSelectionAndTheRoles() { + authenticate("acme.library.Owner", "globex.library.User"); + when(tenantService.findById(ACME)).thenReturn(Optional.of(tenant(ACME, "Acme Ltd", TenantStatus.PROVISIONED))); + when(tenantService.findById(GLOBEX)).thenReturn(Optional.of(tenant(GLOBEX, "Globex", TenantStatus.PROVISIONED))); + + manager.selectTenant(request, response, ACME); + Set roles = manager.selectTenant(request, response, GLOBEX); + + assertThat(roles).containsExactly("User"); + assertThat(request.getSession() + .getAttribute(TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE)).isEqualTo(GLOBEX); + assertThat(currentRoleNames()).containsExactly("User"); + } + + @Test + void aTenantTheGroupsDoNotGrantIsRefused() { + authenticate("acme.library.Owner"); + + assertThatThrownBy( + () -> manager.selectTenant(request, response, "someone-elses-tenant")).isInstanceOf(TenantSelectionException.class) + .extracting("reason") + .isEqualTo( + TenantSelectionException.Reason.NOT_A_MEMBER); + assertThat(request.getSession() + .getAttribute(TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE)).isNull(); + } + + @Test + void aTenantOfAnotherApplicationIsNotAMemberEither() { + authenticate("acme.bi.Owner"); + + assertThatThrownBy(() -> manager.selectTenant(request, response, ACME)).isInstanceOf(TenantSelectionException.class); + } + + @Test + void aTenantThisInstanceHasNotProvisionedYetIsRefused() { + authenticate("acme.library.Owner"); + when(tenantService.findById(ACME)).thenReturn(Optional.of(tenant(ACME, "Acme Ltd", TenantStatus.INITIAL))); + + assertThatThrownBy(() -> manager.selectTenant(request, response, ACME)).isInstanceOf(TenantSelectionException.class) + .extracting("reason") + .isEqualTo( + TenantSelectionException.Reason.NOT_PROVISIONED_HERE); + } + + @Test + void onlyALoggedInUserCanSelect() { + SecurityContextHolder.clearContext(); + + assertThatThrownBy(() -> manager.selectTenant(request, response, ACME)).isInstanceOf(TenantSelectionException.class) + .extracting("reason") + .isEqualTo( + TenantSelectionException.Reason.NOT_AN_INTERACTIVE_SESSION); + } + + @Test + void authoritiesLostToATokenRefreshAreReApplied() { + authenticate("acme.library.Owner", "DEVELOPER"); + when(tenantService.findById(ACME)).thenReturn(Optional.of(tenant(ACME, "Acme Ltd", TenantStatus.PROVISIONED))); + manager.selectTenant(request, response, ACME); + // What a refresh leaves behind: the mapper granted the global roles only. + authenticate(Set.of("DEVELOPER"), "acme.library.Owner", "DEVELOPER"); + + manager.ensureConsistent(request, response); + + assertThat(currentRoleNames()).containsExactlyInAnyOrder("DEVELOPER", "Owner"); + } + + @Test + void consistentAuthoritiesAreLeftAlone() { + authenticate(Set.of("DEVELOPER", "Owner"), "acme.library.Owner", "DEVELOPER"); + request.getSession() + .setAttribute(TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE, ACME); + + manager.ensureConsistent(request, response); + + verify(securityContextRepository, never()).saveContext(any(SecurityContext.class), any(), any()); + } + + @Test + void aSelectionTheGroupsNoLongerGrantIsDropped() { + // The Owner group of acme was revoked at the identity provider. + authenticate(Set.of("DEVELOPER", "Owner"), "DEVELOPER"); + request.getSession() + .setAttribute(TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE, ACME); + + manager.ensureConsistent(request, response); + + assertThat(request.getSession() + .getAttribute(TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE)).isNull(); + assertThat(currentRoleNames()).containsExactly("DEVELOPER"); + } + + @Test + void aRequestWithoutASelectionNeedsNoRepair() { + authenticate("acme.library.Owner"); + + manager.ensureConsistent(request, response); + + verify(securityContextRepository, never()).saveContext(any(SecurityContext.class), any(), any()); + } + + private void authenticate(String... groups) { + authenticate(Set.of(), groups); + } + + private void authenticate(Set currentRoles, String... groups) { + OidcIdToken idToken = new OidcIdToken("id-token", Instant.now(), Instant.now() + .plusSeconds(300), + Map.of("sub", USER, GROUPS_CLAIM, List.of(groups))); + OidcUser oidcUser = new DefaultOidcUser(List.of(new SimpleGrantedAuthority("ROLE_USER")), idToken); + Authentication authentication = new OAuth2AuthenticationToken(oidcUser, currentRoles.stream() + .map(role -> new SimpleGrantedAuthority( + "ROLE_" + role)) + .toList(), + "keycloak"); + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + } + + private Set currentRoleNames() { + return Set.copyOf(AuthoritiesUtil.toRoleNames(SecurityContextHolder.getContext() + .getAuthentication() + .getAuthorities())); + } + + private static Tenant tenant(String id, String name, TenantStatus status) { + Tenant tenant = new Tenant("-", name, "The " + name + " tenant", id, status); + tenant.setId(id); + return tenant; + } +} From 3a4db6087e1aeccc092f325d24bb002daad3e03a Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Thu, 20 Aug 2026 15:37:41 +0300 Subject: [PATCH 3/8] feat(tenants): put every interactive request into a tenant 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) --- .../oauth2/tenant/TenantSelectionFilter.java | 224 +++++++++++++++++ .../TenantSelectionSecurityConfigurator.java | 69 ++++++ .../tenant/TenantSelectionFilterTest.java | 230 ++++++++++++++++++ 3 files changed, 523 insertions(+) create mode 100644 components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilter.java create mode 100644 components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionSecurityConfigurator.java create mode 100644 components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilterTest.java diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilter.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilter.java new file mode 100644 index 00000000000..914770612b6 --- /dev/null +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilter.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.base.tenant.TenantResolutionStrategy; +import org.eclipse.dirigible.components.base.tenant.groups.UserTenantAssignments; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Makes sure an interactive request knows which tenant it is in. + * + *

+ * 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 {@code 409} naming the choices, so an + * API client is told what to do rather than silently landing in the wrong tenant. A user of none is + * let through if they have global roles (staff of the instance) and refused otherwise. + * + *

+ * It runs before authorization on purpose: until a tenant is selected the user has no + * tenant roles, so authorization would answer 403 before they ever saw the picker. + * + *

+ * Requests that carry no interactive session - machine-to-machine bearer tokens, anonymous + * requests, basic authentication - pass through untouched; their tenant is the default one. + */ +@Component +public class TenantSelectionFilter extends OncePerRequestFilter { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(TenantSelectionFilter.class); + + /** The page a browser is sent to when a choice has to be made. */ + public static final String TENANT_SELECTION_PAGE = "/tenant-selection.html"; + + /** Answered to a programmatic caller that has to choose. */ + private static final String TENANT_SELECTION_REQUIRED = "TENANT_SELECTION_REQUIRED"; + + /** + * What a request may need before a tenant is known: the picker itself and what it loads, the + * selection endpoint, authentication, error pages and the platform's own status surfaces. + */ + private static final List UNFILTERED_PREFIXES = List.of( // + TENANT_SELECTION_PAGE, // + "/services/security/tenant-selection", // + "/webjars/", // + "/services/web/platform-core/", // + "/services/js/platform-core/", // + "/services/js/platform-branding/", // + "/services/core/theme/", // + "/services/core/healthcheck", // + "/services/core/readiness", // + "/login", // + "/logout", // + "/oauth2/", // + "/error", // + "/actuator/", // + "/index-busy.html"); + + private final TenantSelectionManager tenantSelectionManager; + + private final TenantResolutionStrategy resolutionStrategy; + + private final Gson gson; + + /** + * Instantiates a new tenant selection filter. + * + * @param tenantSelectionManager the tenant selection manager + */ + public TenantSelectionFilter(TenantSelectionManager tenantSelectionManager) { + this.tenantSelectionManager = tenantSelectionManager; + this.resolutionStrategy = TenantResolutionStrategy.fromConfiguration(); + this.gson = new GsonBuilder().serializeNulls() + .create(); + } + + /** + * Do filter internal. + * + * @param request the request + * @param response the response + * @param chain the chain + * @throws ServletException the servlet exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + Authentication authentication = SecurityContextHolder.getContext() + .getAuthentication(); + if (!(authentication instanceof OAuth2AuthenticationToken)) { + chain.doFilter(request, response); + return; + } + if (tenantSelectionManager.selectedTenantId(request) != null) { + tenantSelectionManager.ensureConsistent(request, response); + chain.doFilter(request, response); + return; + } + UserTenantAssignments assignments = tenantSelectionManager.assignmentsOf(authentication); + if (assignments.hasNoTenants()) { + if (assignments.globalRoles() + .isEmpty()) { + LOGGER.warn("User [{}] is not assigned to any tenant of this application.", authentication.getName()); + response.sendError(HttpServletResponse.SC_FORBIDDEN, "User is not assigned to any tenant of this application"); + return; + } + LOGGER.debug("User [{}] is assigned to no tenant but has global roles. Passing through.", authentication.getName()); + chain.doFilter(request, response); + return; + } + if (assignments.tenantIds() + .size() == 1 + && autoSelect(request, response, assignments.tenantIds() + .iterator() + .next(), + authentication)) { + chain.doFilter(request, response); + return; + } + requireSelection(request, response, authentication); + } + + /** + * A user of a single tenant is not asked which one. + * + * @param request the request + * @param response the response + * @param tenantId the only tenant of the user + * @param authentication the authenticated user + * @return true if the tenant was entered + */ + private boolean autoSelect(HttpServletRequest request, HttpServletResponse response, String tenantId, Authentication authentication) { + try { + tenantSelectionManager.selectTenant(request, response, tenantId); + return true; + } catch (TenantSelectionException ex) { + LOGGER.info("The only tenant [{}] of user [{}] cannot be entered: {}", tenantId, authentication.getName(), ex.getMessage()); + return false; + } + } + + /** + * Sends the user to the picker: a browser by redirect, a programmatic caller by a conflict naming + * the choices. + * + * @param request the request + * @param response the response + * @param authentication the authenticated user + * @throws IOException Signals that an I/O exception has occurred. + */ + private void requireSelection(HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException { + if (prefersHtml(request)) { + LOGGER.debug("User [{}] has to select a tenant. Redirecting to the picker.", authentication.getName()); + response.sendRedirect(request.getContextPath() + TENANT_SELECTION_PAGE); + return; + } + LOGGER.debug("User [{}] has to select a tenant. Answering the programmatic caller with a conflict.", authentication.getName()); + Map body = new LinkedHashMap<>(); + body.put("error", TENANT_SELECTION_REQUIRED); + body.put("tenants", tenantSelectionManager.availableTenants(authentication)); + response.setStatus(HttpServletResponse.SC_CONFLICT); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter() + .write(gson.toJson(body)); + } + + private boolean prefersHtml(HttpServletRequest request) { + String accept = request.getHeader(HttpHeaders.ACCEPT); + if (accept == null) { + return false; + } + String lowerCaseAccept = accept.toLowerCase(); + if (lowerCaseAccept.contains(MediaType.APPLICATION_JSON_VALUE)) { + return false; + } + return lowerCaseAccept.contains(MediaType.TEXT_HTML_VALUE); + } + + /** + * Should not filter. + * + * @param request the request + * @return true, if the request must work before a tenant is known + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + if (TenantResolutionStrategy.TOKEN_GROUPS != resolutionStrategy) { + return true; + } + String path = request.getRequestURI(); + return UNFILTERED_PREFIXES.stream() + .anyMatch(path::startsWith); + } +} diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionSecurityConfigurator.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionSecurityConfigurator.java new file mode 100644 index 00000000000..e908d67d669 --- /dev/null +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionSecurityConfigurator.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +import org.eclipse.dirigible.components.base.http.access.CustomSecurityConfigurator; +import org.eclipse.dirigible.components.base.tenant.TenantResolutionStrategy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.stereotype.Component; + +/** + * Puts the tenant selection into whichever security chain the deployment runs. + * + *

+ * The filter has to run before authorization: a user who has not selected a tenant yet has + * no tenant roles, so authorization would answer 403 before they ever saw the picker. Registering + * it here rather than in each profile's chain builder is what makes the placement deterministic - + * the chains apply the custom configurators as their last step, so the filter lands after the + * session revalidation the OIDC profiles install (the refreshed authorities have to exist before + * they are repaired) and the same wiring serves every profile. + * + *

+ * The picker page is claimed as authenticated here too, ahead of the platform's own URL matrix: it + * is for a user who is logged in but has no tenant, so it can be neither public nor role gated. + */ +@Component +public class TenantSelectionSecurityConfigurator implements CustomSecurityConfigurator { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(TenantSelectionSecurityConfigurator.class); + + private final TenantSelectionFilter tenantSelectionFilter; + + /** + * Instantiates a new tenant selection security configurator. + * + * @param tenantSelectionFilter the tenant selection filter + */ + public TenantSelectionSecurityConfigurator(TenantSelectionFilter tenantSelectionFilter) { + this.tenantSelectionFilter = tenantSelectionFilter; + } + + /** + * Configure. + * + * @param http the http + * @throws Exception the exception + */ + @Override + public void configure(HttpSecurity http) throws Exception { + if (TenantResolutionStrategy.TOKEN_GROUPS != TenantResolutionStrategy.fromConfiguration()) { + return; + } + LOGGER.info("Tenants are selected by the user. Registering the tenant selection filter and the picker page [{}].", + TenantSelectionFilter.TENANT_SELECTION_PAGE); + http.authorizeHttpRequests(authz -> authz.requestMatchers(TenantSelectionFilter.TENANT_SELECTION_PAGE) + .authenticated()) + .addFilterBefore(tenantSelectionFilter, AuthorizationFilter.class); + } +} diff --git a/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilterTest.java b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilterTest.java new file mode 100644 index 00000000000..ee9f41c60f5 --- /dev/null +++ b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilterTest.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.security.oauth2.tenant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.dirigible.commons.config.Configuration; +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.eclipse.dirigible.components.base.tenant.groups.UserTenantAssignments; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +/** + * The decision the filter makes on every interactive request: one tenant is entered silently, + * several send the user to the picker (a browser by redirect, a programmatic caller by a conflict), + * none is fine for staff and refused for everyone else, and a selection that already exists is kept + * consistent. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TenantSelectionFilterTest { + + private static final String ACME = "acme"; + private static final String GLOBEX = "globex"; + + @Mock + private TenantSelectionManager tenantSelectionManager; + + private TenantSelectionFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + private MockFilterChain chain; + + @BeforeEach + void setUp() { + DirigibleConfig.TENANT_RESOLUTION_STRATEGY.setStringValue("TOKEN_GROUPS"); + filter = new TenantSelectionFilter(tenantSelectionManager); + request = new MockHttpServletRequest("GET", "/services/web/home/index.html"); + request.setSession(new MockHttpSession()); + response = new MockHttpServletResponse(); + chain = new MockFilterChain(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + Configuration.remove(DirigibleConfig.TENANT_RESOLUTION_STRATEGY.getKey()); + } + + @Test + void theOnlyTenantOfAUserIsEnteredWithoutAsking() throws Exception { + authenticate(); + when(tenantSelectionManager.assignmentsOf(any())).thenReturn(assignments(Map.of(ACME, Set.of("Owner")), Set.of())); + + filter.doFilter(request, response, chain); + + verify(tenantSelectionManager).selectTenant(any(), any(), eq(ACME)); + assertThat(chain.getRequest()).isNotNull(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + void aBrowserWithSeveralTenantsIsSentToThePicker() throws Exception { + authenticate(); + request.addHeader(HttpHeaders.ACCEPT, MediaType.TEXT_HTML_VALUE); + when(tenantSelectionManager.assignmentsOf(any())).thenReturn( + assignments(Map.of(ACME, Set.of("Owner"), GLOBEX, Set.of("User")), Set.of())); + + filter.doFilter(request, response, chain); + + assertThat(response.getRedirectedUrl()).isEqualTo(TenantSelectionFilter.TENANT_SELECTION_PAGE); + assertThat(chain.getRequest()).isNull(); + verify(tenantSelectionManager, never()).selectTenant(any(), any(), any()); + } + + @Test + void aProgrammaticCallerWithSeveralTenantsIsToldToChoose() throws Exception { + authenticate(); + request.addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + when(tenantSelectionManager.assignmentsOf(any())).thenReturn( + assignments(Map.of(ACME, Set.of("Owner"), GLOBEX, Set.of("User")), Set.of())); + when(tenantSelectionManager.availableTenants(any())).thenReturn( + List.of(new TenantOption(ACME, "Acme Ltd", true), new TenantOption(GLOBEX, "Globex", true))); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(409); + assertThat(response.getContentAsString()).contains("TENANT_SELECTION_REQUIRED") + .contains(ACME) + .contains(GLOBEX); + assertThat(chain.getRequest()).isNull(); + } + + @Test + void aRequestWithoutAnAcceptHeaderIsTreatedAsProgrammatic() throws Exception { + authenticate(); + when(tenantSelectionManager.assignmentsOf(any())).thenReturn( + assignments(Map.of(ACME, Set.of("Owner"), GLOBEX, Set.of("User")), Set.of())); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(409); + } + + @Test + void staffWithoutATenantPassesThrough() throws Exception { + authenticate(); + when(tenantSelectionManager.assignmentsOf(any())).thenReturn(assignments(Map.of(), Set.of("DEVELOPER"))); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isNotNull(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + void aUserWithNeitherTenantsNorGlobalRolesIsRefused() throws Exception { + authenticate(); + when(tenantSelectionManager.assignmentsOf(any())).thenReturn(assignments(Map.of(), Set.of())); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getErrorMessage()).contains("not assigned to any tenant"); + assertThat(chain.getRequest()).isNull(); + } + + @Test + void anExistingSelectionIsKeptConsistent() throws Exception { + authenticate(); + when(tenantSelectionManager.selectedTenantId(request)).thenReturn(ACME); + + filter.doFilter(request, response, chain); + + verify(tenantSelectionManager).ensureConsistent(request, response); + assertThat(chain.getRequest()).isNotNull(); + } + + @Test + void theOnlyTenantOfAUserThatCannotBeEnteredSendsThemToThePicker() throws Exception { + authenticate(); + request.addHeader(HttpHeaders.ACCEPT, MediaType.TEXT_HTML_VALUE); + when(tenantSelectionManager.assignmentsOf(any())).thenReturn(assignments(Map.of(ACME, Set.of("Owner")), Set.of())); + doThrow(new TenantSelectionException(TenantSelectionException.Reason.NOT_PROVISIONED_HERE, ACME, + "not provisioned yet")).when(tenantSelectionManager) + .selectTenant(any(), any(), eq(ACME)); + + filter.doFilter(request, response, chain); + + assertThat(response.getRedirectedUrl()).isEqualTo(TenantSelectionFilter.TENANT_SELECTION_PAGE); + } + + @Test + void aRequestThatIsNotAnInteractiveSessionPassesThrough() throws Exception { + // No authentication at all - a machine-to-machine bearer token or an anonymous request. + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isNotNull(); + verify(tenantSelectionManager, never()).assignmentsOf(any()); + } + + @Test + void thePickerAndWhatItLoadsAreNotFiltered() { + assertThat(filter.shouldNotFilter(new MockHttpServletRequest("GET", TenantSelectionFilter.TENANT_SELECTION_PAGE))).isTrue(); + assertThat(filter.shouldNotFilter(new MockHttpServletRequest("POST", "/services/security/tenant-selection"))).isTrue(); + assertThat(filter.shouldNotFilter(new MockHttpServletRequest("GET", "/webjars/codbex__harmonia/dist/harmonia.css"))).isTrue(); + assertThat(filter.shouldNotFilter(new MockHttpServletRequest("GET", "/services/js/platform-branding/branding.js"))).isTrue(); + assertThat(filter.shouldNotFilter(new MockHttpServletRequest("GET", "/logout"))).isTrue(); + assertThat(filter.shouldNotFilter(new MockHttpServletRequest("GET", "/services/web/home/index.html"))).isFalse(); + } + + @Test + void theFilterIsInertWhereTenantsAreNotSelected() { + Configuration.remove(DirigibleConfig.TENANT_RESOLUTION_STRATEGY.getKey()); + TenantSelectionFilter subdomainFilter = new TenantSelectionFilter(tenantSelectionManager); + + assertThat(subdomainFilter.shouldNotFilter(new MockHttpServletRequest("GET", "/services/web/home/index.html"))).isTrue(); + } + + private void authenticate() { + OidcIdToken idToken = new OidcIdToken("id-token", Instant.now(), Instant.now() + .plusSeconds(300), + Map.of("sub", "owner@example.com")); + OidcUser oidcUser = new DefaultOidcUser(List.of(new SimpleGrantedAuthority("ROLE_USER")), idToken); + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(new OAuth2AuthenticationToken(oidcUser, List.of(), "keycloak")); + SecurityContextHolder.setContext(securityContext); + } + + private static UserTenantAssignments assignments(Map> tenantRoles, Set globalRoles) { + return new UserTenantAssignments(tenantRoles, globalRoles); + } +} From 69a87b2f945c301d52e1f26203021d1d99b56108 Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Thu, 20 Aug 2026 15:40:27 +0300 Subject: [PATCH 4/8] feat(tenants): the tenant picker page 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) --- .../security/cognito/CognitoTenantFilter.java | 6 +- .../keycloak/KeycloakTenantFilter.java | 5 +- .../resources/static/tenant-selection.html | 252 ++++++++++++++++++ 3 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 components/security/security-oauth2/src/main/resources/static/tenant-selection.html diff --git a/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoTenantFilter.java b/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoTenantFilter.java index 90344869598..089f54c6367 100644 --- a/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoTenantFilter.java +++ b/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoTenantFilter.java @@ -18,6 +18,7 @@ import java.util.stream.Collectors; import org.eclipse.dirigible.commons.config.DirigibleConfig; import org.eclipse.dirigible.components.base.tenant.Tenant; +import org.eclipse.dirigible.components.base.tenant.TenantResolutionStrategy; import org.eclipse.dirigible.components.tenants.tenant.TenantExtractor; import org.springframework.context.annotation.Profile; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -48,8 +49,11 @@ public class CognitoTenantFilter extends OncePerRequestFilter { public CognitoTenantFilter(TenantExtractor tenantExtractor) { this.tenantExtractor = tenantExtractor; this.multitenantModeEnabled = DirigibleConfig.MULTI_TENANT_MODE_ENABLED.getBooleanValue(); + // The token groups strategy replaces this custom:tenant model. The two are already mutually + // exclusive at startup; this keeps the filter honest should that validation ever loosen. this.multitenantModeCognitoSingleUserPoolEnabled = - DirigibleConfig.MULTI_TENANT_MODE_COGNITO_SINGLE_USER_POOL_ENABLED.getBooleanValue(); + DirigibleConfig.MULTI_TENANT_MODE_COGNITO_SINGLE_USER_POOL_ENABLED.getBooleanValue() + && TenantResolutionStrategy.TOKEN_GROUPS != TenantResolutionStrategy.fromConfiguration(); } /** diff --git a/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakTenantFilter.java b/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakTenantFilter.java index 4699b01e05e..0166fcabe01 100644 --- a/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakTenantFilter.java +++ b/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakTenantFilter.java @@ -18,6 +18,7 @@ import java.util.stream.Collectors; import org.eclipse.dirigible.commons.config.DirigibleConfig; import org.eclipse.dirigible.components.base.tenant.Tenant; +import org.eclipse.dirigible.components.base.tenant.TenantResolutionStrategy; import org.eclipse.dirigible.components.tenants.tenant.TenantExtractor; import org.springframework.context.annotation.Profile; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -48,7 +49,9 @@ public class KeycloakTenantFilter extends OncePerRequestFilter { public KeycloakTenantFilter(TenantExtractor tenantExtractor) { this.tenantExtractor = tenantExtractor; this.multitenantModeEnabled = DirigibleConfig.MULTI_TENANT_MODE_ENABLED.getBooleanValue(); - this.multitenantModeKeycloakSingleRealm = DirigibleConfig.MULTI_TENANT_MODE_KEYCLOAK_SINGLE_REALM_ENABLED.getBooleanValue(); + // The token groups strategy replaces this custom:tenant model, so the two never both apply. + this.multitenantModeKeycloakSingleRealm = DirigibleConfig.MULTI_TENANT_MODE_KEYCLOAK_SINGLE_REALM_ENABLED.getBooleanValue() + && TenantResolutionStrategy.TOKEN_GROUPS != TenantResolutionStrategy.fromConfiguration(); } /** diff --git a/components/security/security-oauth2/src/main/resources/static/tenant-selection.html b/components/security/security-oauth2/src/main/resources/static/tenant-selection.html new file mode 100644 index 00000000000..8c0e90736f4 --- /dev/null +++ b/components/security/security-oauth2/src/main/resources/static/tenant-selection.html @@ -0,0 +1,252 @@ + + + + + + + + + + + Select a tenant + + + + + + + + + + +

+
+ +
+ + +
+
+
+ + + +
+ +
+
+ +

Where are you working today

+

Select a tenant

+

+ Your account belongs to more than one tenant of this application. Everything you see and + change afterwards belongs to the one you pick. +

+ +
+ + Loading your tenants... +
+ + + +
+ +
+ +

+ Your account is not assigned to any tenant of this application yet. Ask an administrator to + invite you. +

+ +
+
+ + + + + + + + + + + + + + + From 2b9831ca875503f4c7e7e600555f11436ca5c9dc Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Thu, 20 Aug 2026 15:50:42 +0300 Subject: [PATCH 5/8] test(tenants): the selection end to end, from the endpoint to the tenant 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) --- .../oauth2/tenant/TenantSelectionManager.java | 5 +- .../tests/api/TenantSelectionIT.java | 310 ++++++++++++++++++ 2 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/TenantSelectionIT.java diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManager.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManager.java index 3b712dc0791..a8b74814ccb 100644 --- a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManager.java +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManager.java @@ -26,6 +26,7 @@ import org.eclipse.dirigible.components.tenants.tenant.TenantSelectionConstants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContext; @@ -64,11 +65,13 @@ public class TenantSelectionManager { private final SecurityContextRepository securityContextRepository; /** - * Instantiates a new tenant selection manager. + * Instantiates a new tenant selection manager. Annotated because the class carries a second + * constructor for the tests, so the injectable one has to be named. * * @param groupsClaim the claim the user groups are read from * @param tenantService the tenant registry of this instance */ + @Autowired public TenantSelectionManager(TenantGroupsClaim groupsClaim, TenantService tenantService) { this(groupsClaim, tenantService, new HttpSessionSecurityContextRepository()); } diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/TenantSelectionIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/TenantSelectionIT.java new file mode 100644 index 00000000000..8c63856ff82 --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/TenantSelectionIT.java @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.integration.tests.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.eclipse.dirigible.components.base.tenant.DefaultTenant; +import org.eclipse.dirigible.components.base.tenant.Tenant; +import org.eclipse.dirigible.components.base.tenant.TenantContext; +import org.eclipse.dirigible.components.configurations.tenant.TenantConfigurationService; +import org.eclipse.dirigible.components.security.oauth2.tenant.TenantSelectionFilter; +import org.eclipse.dirigible.components.tenants.tenant.TenantSelectionConstants; +import org.eclipse.dirigible.tests.base.IntegrationTest; +import org.eclipse.dirigible.tests.framework.tenant.DirigibleTestTenant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +/** + * End-to-end test of the tenant selection: a user's groups decide which tenants they may enter, + * entering one puts it into the session, and the very next request is served in that tenant. + * + *

+ * That last step is what makes this test worth its boot time. Which tenant a request landed in is + * observed through the tenant's own configuration table - {@code GET + * /services/core/configurations/tenant} reads the current tenant's DIRIGIBLE_CONFIGURATIONS - so a + * marker seeded per tenant proves the whole chain: endpoint, session attribute, tenant resolution, + * tenant scope. + * + *

+ * The login itself is not exercised. Booting an OIDC profile means {@code basic.enabled=false}, + * which takes away the authentication every test harness in this repo uses, and a fake identity + * provider would prove little beyond bean wiring - so the authenticated user is fabricated, exactly + * as the unit tests of the security module do, and the real login path is covered by the scenario + * tests outside this repo. + */ +// One Dirigible boot for the whole class: provisioning a tenant is expensive and the tests only +// read. +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Tag("slow") +class TenantSelectionIT extends IntegrationTest { + + private static final String TENANT_SELECTION_ENDPOINT = "/services/security/tenant-selection"; + private static final String TENANT_CONFIGURATIONS_PATH = "/services/core/configurations/tenant"; + + private static final String MARKER_KEY = "TENANT_SELECTION_IT_MARKER"; + private static final String MARKER_OF_DEFAULT_TENANT = "marker-of-the-default-tenant"; + private static final String MARKER_OF_SELECTED_TENANT = "marker-of-the-selected-tenant"; + + private static final String APP_ID = "library"; + private static final String GROUPS_CLAIM = "groups"; + private static final String USER = "owner@example.com"; + + /** + * A group that carries no tenant, so it is a global role. It has to be a group: in this mode the + * authorities of a session are recomputed from the groups, so nothing else survives a request. + */ + private static final String ADMINISTRATOR_GROUP = "ADMINISTRATOR"; + + private static DirigibleTestTenant provisionedTenant; + private static DirigibleTestTenant tenantAwaitingProvisioning; + + @Autowired + private MockMvc mvc; + + @Autowired + private TenantContext tenantContext; + + @Autowired + @DefaultTenant + private Tenant defaultTenant; + + @Autowired + private TenantConfigurationService tenantConfigurationService; + + @Autowired + private Scheduler scheduler; + + @BeforeAll + static void useTokenGroupsResolution() { + DirigibleConfig.MULTI_TENANT_MODE_ENABLED.setBooleanValue(true); + DirigibleConfig.TENANT_RESOLUTION_STRATEGY.setStringValue("TOKEN_GROUPS"); + DirigibleConfig.APP_ID.setStringValue(APP_ID); + DirigibleConfig.TENANT_GROUPS_CLAIM.setStringValue(GROUPS_CLAIM); + } + + /** + * Provisions the tenants and seeds the markers, once for the class. + * + * @throws SchedulerException if the provisioning job cannot be triggered + * @throws SQLException if a marker cannot be written + */ + @BeforeEach + void provisionTenantsAndSeedMarkers() throws SchedulerException, SQLException { + if (provisionedTenant != null) { + return; + } + DirigibleTestTenant created = new DirigibleTestTenant("tenant-selection-it"); + createTenants(created); + scheduler.triggerJob(JobKey.jobKey("TenantsProvisioningJob", "system")); + waitForTenantProvisioning(created); + provisionedTenant = created; + + // Registered after the provisioning pass, so it stays in status INITIAL for this class. + DirigibleTestTenant awaiting = new DirigibleTestTenant("tenant-selection-unprovisioned-it"); + createTenants(awaiting); + tenantAwaitingProvisioning = awaiting; + + seedMarker(defaultTenant.getId(), MARKER_OF_DEFAULT_TENANT); + seedMarker(provisionedTenant.getId(), MARKER_OF_SELECTED_TENANT); + } + + @Test + void theTenantsOfTheGroupsAreOfferedWithTheirLocalState() throws Exception { + mvc.perform( + get(TENANT_SELECTION_ENDPOINT).with(authentication(userOf(ownerOf(provisionedTenant), userOf(tenantAwaitingProvisioning))))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.selectedTenantId").doesNotExist()) + .andExpect(jsonPath("$.tenants.length()").value(2)) + .andExpect(jsonPath("$.tenants[?(@.id=='" + provisionedTenant.getId() + "')].provisionedHere", contains(true))) + .andExpect(jsonPath("$.tenants[?(@.id=='" + tenantAwaitingProvisioning.getId() + "')].provisionedHere", contains(false))); + } + + /** + * The whole point: selecting a tenant makes the next request run in it. + * + *

+ * The user carries a global {@code ADMINISTRATOR} group because in this mode the authorities are + * recomputed from the groups on every request - a role granted any other way would be dropped + * again, which is exactly what the selection is for. + */ + @Test + void aSelectedTenantIsTheTenantOfTheFollowingRequests() throws Exception { + MockHttpSession session = new MockHttpSession(); + Authentication user = userOf(ownerOf(provisionedTenant), ADMINISTRATOR_GROUP); + + mvc.perform(post(TENANT_SELECTION_ENDPOINT).session(session) + .with(authentication(user)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tenantId\":\"" + provisionedTenant.getId() + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tenantId").value(provisionedTenant.getId())) + .andExpect(jsonPath("$.roles", containsInAnyOrder(ADMINISTRATOR_GROUP, "Owner"))); + + assertThat(session.getAttribute(TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE)).isEqualTo( + provisionedTenant.getId()); + + mvc.perform(get(TENANT_CONFIGURATIONS_PATH).session(session) + .with(authentication(user))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.key=='" + MARKER_KEY + "')].value", contains(MARKER_OF_SELECTED_TENANT))); + } + + /** + * Staff of the instance - global roles, no tenant of their own - keep working in the default tenant + * instead of being asked to pick one they do not have. + * + *

+ * Their authorities are the ones the login mapper granted, which is why this is the one case that + * has to state them: without a selection nothing recomputes them during the request. + */ + @Test + void staffWithoutATenantStayInTheDefaultTenant() throws Exception { + mvc.perform(get(TENANT_CONFIGURATIONS_PATH).with(authentication(loggedInStaff(ADMINISTRATOR_GROUP)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.key=='" + MARKER_KEY + "')].value", contains(MARKER_OF_DEFAULT_TENANT))); + } + + @Test + void aTenantTheGroupsDoNotGrantIsRefused() throws Exception { + mvc.perform(post(TENANT_SELECTION_ENDPOINT).with(authentication(userOf(ownerOf(provisionedTenant)))) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tenantId\":\"someone-elses-tenant\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.reason").value("NOT_A_MEMBER")); + } + + @Test + void aTenantThatIsNotProvisionedYetIsRefused() throws Exception { + mvc.perform(post(TENANT_SELECTION_ENDPOINT).with(authentication(userOf(userOf(tenantAwaitingProvisioning)))) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tenantId\":\"" + tenantAwaitingProvisioning.getId() + "\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.reason").value("NOT_PROVISIONED_HERE")); + } + + @Test + void aBrowserWithSeveralTenantsIsSentToThePicker() throws Exception { + mvc.perform(get("/services/web/home/index.html") + .with(authentication( + userOf(ownerOf(provisionedTenant), userOf(tenantAwaitingProvisioning)))) + .header(HttpHeaders.ACCEPT, MediaType.TEXT_HTML_VALUE)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(TenantSelectionFilter.TENANT_SELECTION_PAGE)); + } + + @Test + void aProgrammaticCallerWithSeveralTenantsIsToldToChoose() throws Exception { + mvc.perform(get("/services/web/home/index.html") + .with(authentication( + userOf(ownerOf(provisionedTenant), userOf(tenantAwaitingProvisioning)))) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error").value("TENANT_SELECTION_REQUIRED")) + .andExpect(jsonPath("$.tenants.length()").value(2)); + } + + @Test + void thePickerIsReachableForALoggedInUserAndNotForAnyoneElse() throws Exception { + mvc.perform(get(TenantSelectionFilter.TENANT_SELECTION_PAGE).with(authentication(userOf(ownerOf(provisionedTenant))))) + .andExpect(status().isOk()); + + mvc.perform(get(TenantSelectionFilter.TENANT_SELECTION_PAGE)) + .andExpect(status().is(org.springframework.http.HttpStatus.UNAUTHORIZED.value())); + } + + private void seedMarker(String tenantId, String marker) throws SQLException { + try { + tenantContext.execute(tenantId, () -> { + tenantConfigurationService.set(MARKER_KEY, marker); + return null; + }); + } catch (SQLException | RuntimeException ex) { + throw ex; + } catch (Exception ex) { + throw new IllegalStateException("Failed to seed the marker of tenant [" + tenantId + "]", ex); + } + } + + private static String ownerOf(DirigibleTestTenant tenant) { + return tenant.getId() + "." + APP_ID + ".Owner"; + } + + private static String userOf(DirigibleTestTenant tenant) { + return tenant.getId() + "." + APP_ID + ".User"; + } + + /** + * An authenticated user whose groups are the given ones - what an OIDC login leaves behind. + * + * @param groups the groups of the user + * @return the authentication + */ + private static Authentication userOf(String... groups) { + return authenticationOf(List.of(groups)); + } + + /** + * A user of global groups only, carrying the authorities the login mapper grants for them. + * + * @param globalGroups the groups that carry no tenant + * @return the authentication + */ + private static Authentication loggedInStaff(String... globalGroups) { + List authorities = List.of(globalGroups) + .stream() + .map(group -> new SimpleGrantedAuthority("ROLE_" + group)) + .toList(); + return authenticationOf(List.of(globalGroups), authorities); + } + + private static Authentication authenticationOf(List groups) { + return authenticationOf(groups, List.of()); + } + + private static Authentication authenticationOf(List groups, List authorities) { + OidcIdToken idToken = new OidcIdToken("id-token", Instant.now(), Instant.now() + .plusSeconds(300), + Map.of("sub", USER, GROUPS_CLAIM, groups)); + OidcUser oidcUser = new DefaultOidcUser(List.of(new SimpleGrantedAuthority("ROLE_USER")), idToken); + return new OAuth2AuthenticationToken(oidcUser, authorities, "keycloak"); + } +} From 2e0896808a64f76bbcf1753511757d53eec51d88 Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Thu, 20 Aug 2026 15:51:34 +0300 Subject: [PATCH 6/8] docs: the tenant selection, and what it means for authorities 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) --- CLAUDE.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fcd129db9b8..24314f6cf0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -330,9 +330,22 @@ How the current tenant of a request is determined is configurable through `DIRIG - **`SUBDOMAIN`** (default, unchanged behaviour) — the subdomain of the `host` / `x-forwarded-host` header is matched against `DIRIGIBLE_TENANT_SUBDOMAIN_REGEX` and looked up by subdomain. A host naming no registered tenant is answered with the 404 written by `TenantContextInitFilter`. Every tenant needs its own host. - **`TOKEN_GROUPS`** — the tenant is the one the user selected, read from the HTTP session attribute `TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE` and required to be `PROVISIONED`. The host is never consulted, so one host serves every tenant. No session, no selection, an unknown selection, or a tenant that is not provisioned yet all fall back to the **default tenant** — machine-to-machine calls and anonymous requests carry no session, and a stale selection must not lock a user out. -`TOKEN_GROUPS` exists for deployments where authorization is carried in identity provider groups named **`..`** (e.g. `acme.library.Owner`). `TenantGroupsParser` (`core-base`, `base/tenant/groups/`, free of Spring/servlet/OAuth2 types) turns a user's groups into `UserTenantAssignments`: groups of this deployment's application (`DIRIGIBLE_APP_ID`) become that tenant's roles, groups of other applications are ignored, and non-tenant-bearing groups (plain `DEVELOPER`, `OPERATOR`) stay global roles. The role part may contain dots; tenant and application ids may not. The groups claim is `DIRIGIBLE_TENANT_GROUPS_CLAIM` (`cognito:groups` by default, `groups` on Keycloak realms). **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. +`TOKEN_GROUPS` exists for deployments where authorization is carried in identity provider groups named **`..`** (e.g. `acme.library.Owner`). `TenantGroupsParser` (`core-base`, `base/tenant/groups/`, free of Spring/servlet/OAuth2 types) turns a user's groups into `UserTenantAssignments`: groups of this deployment's application (`DIRIGIBLE_APP_ID`) become that tenant's roles, groups of other applications are ignored, and non-tenant-bearing groups (plain `DEVELOPER`, `OPERATOR`) stay global roles. The role part may contain dots; tenant and application ids may not. The groups claim is `DIRIGIBLE_TENANT_GROUPS_CLAIM` (`cognito:groups` by default, `groups` on Keycloak realms) — **set it explicitly on a Keycloak realm**: it is the one place both the login mapping and the selection read, so a wrong claim silently means "no tenants". -`TenantResolutionConfigValidator` (`core-tenants`) refuses to start on an unusable combination: with `TOKEN_GROUPS` the app id must be set and dot-free, `DIRIGIBLE_MULTI_TENANT_MODE` must be true, the groups claim non-blank, and `DIRIGIBLE_MULTI_TENANT_MODE_COGNITO_SINGLE_USER_POOL` (the legacy `custom:tenant` model, still read by `CognitoTenantFilter` / `KeycloakTenantFilter`, which keep resolving by subdomain) must be off. It validates **in its constructor** on purpose — a half-usable resolution setup must abort the context refresh rather than serve requests that silently land in the wrong tenant. +## Tenant selection (`security-oauth2`, both OIDC profiles) + +What writes the session attribute — the identity-provider half of `TOKEN_GROUPS`. It lives in `security-oauth2` (`.../oauth2/tenant/`) rather than in a profile module, because Cognito and Keycloak need the same thing and their configurations differ only in the groups claim. Everything here is gated on the strategy, not on a Spring profile, so it is inert in `SUBDOMAIN` mode. + +- **`TenantAwareAuthoritiesMapper`** behind each profile's `userAuthoritiesMapper()`: in `SUBDOMAIN` mode every group of the provider's own claim becomes an authority (byte-identical to what the two profiles did inline before); in `TOKEN_GROUPS` mode only `globalRoles()`, since which tenant's roles apply is unknown at login. +- **`TenantSelectionManager`**: `selectTenant` validates the tenant against the user's **own groups** (403 if not a member) and requires it `PROVISIONED` here (409), writes the session attribute and rebuilds the `OAuth2AuthenticationToken` with `globalRoles ∪ rolesFor(tenant)`, saved through the `SecurityContextRepository` — the `OAuth2SessionRevalidationFilter.refreshAuthentication` sequence, without rotating the session id. `ensureConsistent` re-applies them when they drift and **drops a selection whose group was revoked**. +- **`TenantSelectionEndpoint`** at `services/security/tenant-selection`: `GET` lists `{selectedTenantId, tenants:[{id,name,provisionedHere}]}`, `POST {tenantId}` enters one and doubles as the switch. No `@RolesAllowed` on purpose — a user who has not picked yet has only global roles, and a single-tenant user none at all. JSON-only body is the CSRF defence (the chains disable CSRF tokens), as in `NativeLoginEndpoint`. +- **`TenantSelectionFilter`**: one tenant → auto-select; several → `302 /tenant-selection.html` for a browser, `409 {"error":"TENANT_SELECTION_REQUIRED","tenants":[…]}` otherwise; none → pass for global-role holders, else 403. Non-`OAuth2AuthenticationToken` requests (M2M bearer, anonymous, basic) pass through. +- **Registration** is `TenantSelectionSecurityConfigurator`, the repo's **first `CustomSecurityConfigurator`**. `HttpSecurityURIConfigurator.configure` applies those before its own matchers and every chain calls it, so one bean adds the filter `before AuthorizationFilter` (it must precede authorization, or a user without a tenant is 403'd before reaching the picker) and claims `/tenant-selection.html` as `authenticated()` — no per-profile wiring, no edit to the static URL matrix. +- **The picker** is `security-oauth2/src/main/resources/static/tenant-selection.html` — Harmonia + Alpine, the load order of the Home landing page, inline script, `?switch=true` to change tenant. A classpath static page because the registry is itself tenant-scoped. + +⚠ In `TOKEN_GROUPS` mode the authorities of a session are **recomputed from the groups**, so a platform role such as `ADMINISTRATOR` must be granted as a *global group*; anything granted another way is dropped on the next request. Note also that a fresh selection applies from the **next** request: the tenant scope of the current one was opened before the selection was written, which is why the picker navigates away on success. + +`TenantResolutionConfigValidator` (`core-tenants`) refuses to start on an unusable combination: with `TOKEN_GROUPS` the app id must be set and dot-free, `DIRIGIBLE_MULTI_TENANT_MODE` must be true, the groups claim non-blank, and `DIRIGIBLE_MULTI_TENANT_MODE_COGNITO_SINGLE_USER_POOL` (the legacy `custom:tenant` model of `CognitoTenantFilter` / `KeycloakTenantFilter`, which resolve by subdomain and stand down under `TOKEN_GROUPS`) must be off. It validates **in its constructor** on purpose — a half-usable resolution setup must abort the context refresh rather than serve requests that silently land in the wrong tenant. Caches: `TenantExtractor.TENANT_CACHE` (by subdomain) and `TENANT_ID_CACHE` (provisioned tenants by id), both 10 min. Use `TenantExtractor.evictFromCaches(tenantId, subdomain)` after changing a tenant's registration or status; `TenantService.save/delete` already do. From 469ad45d66a1654bd490f652d5796c16e4935928 Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Fri, 21 Aug 2026 11:25:04 +0300 Subject: [PATCH 7/8] fix(tenants): serve the auto-selecting request in the tenant it selected A user of a single tenant has it selected for them by the filter - but the tenant scope of that request was opened before the selection existed, so the request went on being served in the default tenant while already carrying the selected tenant's roles. The roles of one tenant with the data of another is exactly what this feature exists to prevent, so the filter now runs the rest of the chain in the tenant it just entered. Only the request that performs the auto-selection was affected; every later one resolved correctly. Found by walking the flow by hand against a real Keycloak. Co-Authored-By: Claude Opus 5 (1M context) --- .../oauth2/tenant/TenantSelectionFilter.java | 50 ++++++++++++++++--- .../tenant/TenantSelectionFilterTest.java | 33 +++++++++++- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilter.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilter.java index 914770612b6..7e0ee9dcc78 100644 --- a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilter.java +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilter.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; +import org.eclipse.dirigible.components.base.tenant.TenantContext; import org.eclipse.dirigible.components.base.tenant.TenantResolutionStrategy; import org.eclipse.dirigible.components.base.tenant.groups.UserTenantAssignments; import org.slf4j.Logger; @@ -86,6 +87,8 @@ public class TenantSelectionFilter extends OncePerRequestFilter { private final TenantSelectionManager tenantSelectionManager; + private final TenantContext tenantContext; + private final TenantResolutionStrategy resolutionStrategy; private final Gson gson; @@ -94,9 +97,11 @@ public class TenantSelectionFilter extends OncePerRequestFilter { * Instantiates a new tenant selection filter. * * @param tenantSelectionManager the tenant selection manager + * @param tenantContext the tenant scope of the current execution */ - public TenantSelectionFilter(TenantSelectionManager tenantSelectionManager) { + public TenantSelectionFilter(TenantSelectionManager tenantSelectionManager, TenantContext tenantContext) { this.tenantSelectionManager = tenantSelectionManager; + this.tenantContext = tenantContext; this.resolutionStrategy = TenantResolutionStrategy.fromConfiguration(); this.gson = new GsonBuilder().serializeNulls() .create(); @@ -138,13 +143,18 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } if (assignments.tenantIds() - .size() == 1 - && autoSelect(request, response, assignments.tenantIds() - .iterator() - .next(), - authentication)) { - chain.doFilter(request, response); - return; + .size() == 1) { + String onlyTenantId = assignments.tenantIds() + .iterator() + .next(); + if (autoSelect(request, response, onlyTenantId, authentication)) { + // The tenant scope of this request was opened before the selection existed, so it is + // still the default tenant's. Continuing in it would serve the request with the roles + // of the selected tenant and the data of another one - so the rest of the chain runs + // in the tenant just entered. + continueInTenant(onlyTenantId, request, response, chain); + return; + } } requireSelection(request, response, authentication); } @@ -168,6 +178,30 @@ private boolean autoSelect(HttpServletRequest request, HttpServletResponse respo } } + /** + * Runs the rest of the chain in the scope of a tenant. + * + * @param tenantId the tenant to run in + * @param request the request + * @param response the response + * @param chain the chain + * @throws ServletException the servlet exception + * @throws IOException Signals that an I/O exception has occurred. + */ + private void continueInTenant(String tenantId, HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + try { + tenantContext.execute(tenantId, () -> { + chain.doFilter(request, response); + return null; + }); + } catch (ServletException | IOException | RuntimeException ex) { + throw ex; + } catch (Exception ex) { + throw new ServletException(ex.getMessage(), ex); + } + } + /** * Sends the user to the picker: a browser by redirect, a programmatic caller by a conflict naming * the choices. diff --git a/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilterTest.java b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilterTest.java index ee9f41c60f5..3f950321fdc 100644 --- a/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilterTest.java +++ b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilterTest.java @@ -11,6 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; @@ -24,6 +25,8 @@ import org.eclipse.dirigible.commons.config.Configuration; import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.eclipse.dirigible.components.base.callable.CallableResultAndException; +import org.eclipse.dirigible.components.base.tenant.TenantContext; import org.eclipse.dirigible.components.base.tenant.groups.UserTenantAssignments; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -63,6 +66,9 @@ class TenantSelectionFilterTest { @Mock private TenantSelectionManager tenantSelectionManager; + @Mock + private TenantContext tenantContext; + private TenantSelectionFilter filter; private MockHttpServletRequest request; private MockHttpServletResponse response; @@ -71,7 +77,14 @@ class TenantSelectionFilterTest { @BeforeEach void setUp() { DirigibleConfig.TENANT_RESOLUTION_STRATEGY.setStringValue("TOKEN_GROUPS"); - filter = new TenantSelectionFilter(tenantSelectionManager); + filter = new TenantSelectionFilter(tenantSelectionManager, tenantContext); + // The real one opens the tenant scope around the callable; here it just runs it. + try { + when(tenantContext.execute(anyString(), any(CallableResultAndException.class))).thenAnswer( + invocation -> ((CallableResultAndException) invocation.getArgument(1)).call()); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } request = new MockHttpServletRequest("GET", "/services/web/home/index.html"); request.setSession(new MockHttpSession()); response = new MockHttpServletResponse(); @@ -96,6 +109,22 @@ void theOnlyTenantOfAUserIsEnteredWithoutAsking() throws Exception { assertThat(response.getStatus()).isEqualTo(200); } + /** + * The regression this guards: the tenant scope of the current request was opened before the + * selection existed, so without re-entering it the request is served with the roles of the selected + * tenant and the data of the default one. + */ + @Test + void theRequestThatAutoSelectedIsAlreadyServedInThatTenant() throws Exception { + authenticate(); + when(tenantSelectionManager.assignmentsOf(any())).thenReturn(assignments(Map.of(ACME, Set.of("Owner")), Set.of())); + + filter.doFilter(request, response, chain); + + verify(tenantContext).execute(eq(ACME), any(CallableResultAndException.class)); + assertThat(chain.getRequest()).isNotNull(); + } + @Test void aBrowserWithSeveralTenantsIsSentToThePicker() throws Exception { authenticate(); @@ -209,7 +238,7 @@ void thePickerAndWhatItLoadsAreNotFiltered() { @Test void theFilterIsInertWhereTenantsAreNotSelected() { Configuration.remove(DirigibleConfig.TENANT_RESOLUTION_STRATEGY.getKey()); - TenantSelectionFilter subdomainFilter = new TenantSelectionFilter(tenantSelectionManager); + TenantSelectionFilter subdomainFilter = new TenantSelectionFilter(tenantSelectionManager, tenantContext); assertThat(subdomainFilter.shouldNotFilter(new MockHttpServletRequest("GET", "/services/web/home/index.html"))).isTrue(); } From 7009014af762a7daecd8aafe2bef2468bf6edea0 Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Fri, 21 Aug 2026 12:13:20 +0300 Subject: [PATCH 8/8] fix(tenants): render every tile of the tenant picker An icon placeholder is REPLACED by the rendered svg, so an Alpine directive on an is lost - and Harmonia says so by throwing, which aborted the x-for loop: only the first tenant was bound. The rest of the tiles stayed raw markup, showing no name, no id and a "Current" badge whose x-show had never run, on a button whose disabled binding had never been applied. Every icon that carries a directive is now an placeholder, as the error message asks for. Two more things that were wrong in that markup: - The "Current" badge is added and removed with x-if instead of shown and hidden. x-show fought with the styling Harmonia applies to the same element, so the badge appeared on every tile - including when no tenant was selected at all. - A branding logo that does not resolve left the browser's broken-image glyph next to the product name; it is hidden when it fails to load. The tenant id is also dropped from its own line when it is the same string as the name, which is what an unregistered tenant looks like. Verified by rendering the page headlessly against a stubbed endpoint in both states: three unprovisioned tenants (all three named, all three not clickable, no badge) and three provisioned ones with a selection (all clickable, the badge and aria-current on the selected one only), with no Alpine errors in the console. Co-Authored-By: Claude Opus 5 (1M context) --- .../resources/static/tenant-selection.html | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/components/security/security-oauth2/src/main/resources/static/tenant-selection.html b/components/security/security-oauth2/src/main/resources/static/tenant-selection.html index 8c0e90736f4..debbcc61b58 100644 --- a/components/security/security-oauth2/src/main/resources/static/tenant-selection.html +++ b/components/security/security-oauth2/src/main/resources/static/tenant-selection.html @@ -56,7 +56,8 @@

- +
@@ -70,7 +71,7 @@
@@ -85,7 +86,7 @@

- + Loading your tenants...

@@ -100,20 +101,24 @@

- + - - Current + + - + + Being prepared. Try again in a few minutes. - +