diff --git a/CLAUDE.md b/CLAUDE.md index fcd129db9b..24314f6cf0 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. 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 cfc6573762..8750b34d38 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-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 9034486959..089f54c636 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/KeycloakSecurityConfiguration.java b/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakSecurityConfiguration.java index 86e5d60892..cc06f1b1e0 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-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 4699b01e05..0166fcabe0 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/pom.xml b/components/security/security-oauth2/pom.xml index ecb8002491..fa64597a4b 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 0000000000..3829c1bbbf --- /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 0000000000..2d525111b5 --- /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/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 0000000000..ef7466eef7 --- /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 0000000000..8243fe3654 --- /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 0000000000..791691928a --- /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/TenantSelectionFilter.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilter.java new file mode 100644 index 0000000000..7e0ee9dcc7 --- /dev/null +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilter.java @@ -0,0 +1,258 @@ +/* + * 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.TenantContext; +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 TenantContext tenantContext; + + private final TenantResolutionStrategy resolutionStrategy; + + private final Gson gson; + + /** + * 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, TenantContext tenantContext) { + this.tenantSelectionManager = tenantSelectionManager; + this.tenantContext = tenantContext; + 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) { + 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); + } + + /** + * 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; + } + } + + /** + * 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. + * + * @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/TenantSelectionManager.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManager.java new file mode 100644 index 0000000000..a8b74814cc --- /dev/null +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionManager.java @@ -0,0 +1,253 @@ +/* + * 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.beans.factory.annotation.Autowired; +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. 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()); + } + + /** + * 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/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 0000000000..e908d67d66 --- /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/main/resources/static/tenant-selection.html b/components/security/security-oauth2/src/main/resources/static/tenant-selection.html new file mode 100644 index 0000000000..debbcc61b5 --- /dev/null +++ b/components/security/security-oauth2/src/main/resources/static/tenant-selection.html @@ -0,0 +1,257 @@ + + + + + + + + + + + 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. +

+ +
+
+ + + + + + + + + + + + + + + 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 0000000000..499f1eaf47 --- /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)); + } +} 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 0000000000..03ae975df1 --- /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/TenantSelectionFilterTest.java b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilterTest.java new file mode 100644 index 0000000000..3f950321fd --- /dev/null +++ b/components/security/security-oauth2/src/test/java/org/eclipse/dirigible/components/security/oauth2/tenant/TenantSelectionFilterTest.java @@ -0,0 +1,259 @@ +/* + * 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.anyString; +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.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; +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; + + @Mock + private TenantContext tenantContext; + + 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, 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(); + 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); + } + + /** + * 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(); + 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, tenantContext); + + 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); + } +} 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 0000000000..155ab233b5 --- /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; + } +} 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 0000000000..8c63856ff8 --- /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"); + } +}