Skip to content
Merged
17 changes: 15 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 **`<tenantId>.<appId>.<role>`** (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 **`<tenantId>.<appId>.<role>`** (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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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<GrantedAuthority> 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<String> cognitoGroups = (ArrayList<String>) 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

/**
Expand Down
Loading
Loading