Skip to content

Tenant resolution from the user's selection, validated against identity provider groups - #6875

Merged
iliyan-velichkov merged 6 commits into
masterfrom
tenant-resolution-token-groups
Aug 20, 2026
Merged

Tenant resolution from the user's selection, validated against identity provider groups#6875
iliyan-velichkov merged 6 commits into
masterfrom
tenant-resolution-token-groups

Conversation

@iliyan-velichkov

Copy link
Copy Markdown
Contributor

Why

Multi-tenant deployments today need one host per tenant: the tenant of a request is the subdomain of the host header. That is a poor fit for a fleet of applications behind a single identity provider, where authorization is already carried in groups named <tenantId>.<appId>.<role> (e.g. acme.library.Owner) and a user may belong to several tenants of the same application.

This adds an opt-in second resolution strategy so one host can serve every tenant of an application. SUBDOMAIN stays the default and its behaviour is unchanged, including the two legacy identity provider tenant filters.

What works after this PR

DIRIGIBLE_TENANT_RESOLUTION_STRATEGY=TOKEN_GROUPS
DIRIGIBLE_APP_ID=library
DIRIGIBLE_TENANT_GROUPS_CLAIM=cognito:groups   # 'groups' on a Keycloak realm
DIRIGIBLE_MULTI_TENANT_MODE=true
  • TenantExtractor.determineTenant(request) dispatches on the strategy. In TOKEN_GROUPS mode the tenant is the one stored in the HTTP session (TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE) and required to be PROVISIONED. The host is never consulted, so this mode also never produces the "no registered tenant for the current host" 404.
  • 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 at all, and a stale selection must not lock a user out of the instance.
  • TenantGroupsParser (core-base, free of Spring/servlet/OAuth2 types) turns a user's groups into UserTenantAssignments - this application's groups become tenant roles, other applications' groups are ignored, non-tenant-bearing groups such as DEVELOPER stay global roles. The role part may contain dots, tenant and application ids may not.
  • Selected tenants are cached by id (mirroring the subdomain cache) and TenantExtractor.evictFromCaches(id, subdomain) forgets both, so a tenant that becomes provisioned is enterable at once rather than after 10 minutes.
  • TenantResolutionConfigValidator refuses to start on a combination that cannot work: with TOKEN_GROUPS the app id must be set and dot-free (a dotted one makes the group grammar unparseable), multi-tenant mode must be on, the groups claim non-blank, and the legacy Cognito single-user-pool tenant model - which this strategy replaces - must be off.

What follows

Writing the session attribute is the identity provider side: mapping the user's global roles at login, a tenant selection endpoint, a filter that redirects a user with several tenants to a picker, and the picker page itself. Those live in security-cognito and come as a separate PR; until then TOKEN_GROUPS resolves every request to the default tenant, which is why this PR is useful but not yet complete as a user-facing feature.

Two idioms that are new to the repo

  • Config validation in a constructor. A half-usable tenant resolution setup is worse than a failed startup: users would silently land in the wrong tenant. Validating in the constructor aborts the context refresh before the port opens; an ApplicationReadyEvent listener would fail after traffic is already accepted.
  • An assertion that the context must NOT start. TenantResolutionConfigValidationIT refreshes an AnnotationConfigApplicationContext holding just the validator, per invalid combination. It boots no application, so the whole matrix runs in milliseconds.

Tests

  • Unit: TenantGroupsParserTest (13), TenantResolutionStrategyTest (5), TenantResolutionConfigValidatorTest (10), TenantExtractorTest (9 - strategy dispatch, session cases, non-provisioned tenant, id caching and eviction). The last two are the first tests in core-tenants.
  • Integration: TokenGroupsTenantResolutionIT (6) boots the platform in the new mode and proves which tenant a request landed in, observed through the resolved tenant's own DIRIGIBLE_CONFIGURATIONS table (a marker seeded per tenant); it includes the counterpart of EnabledMultitenantModeIT.testUnregisteredTenantResolution, which expects a 404 for that very host in subdomain mode. TenantResolutionConfigValidationIT (8) covers the startup matrix.
  • Regression: EnabledMultitenantModeIT, DisabledMultitenantModeIT, TenantConfigurationIT pass unmodified; mvn -T 1C formatter:validate clean.
mvn -pl modules/commons/commons-config,components/core/core-base,components/core/core-tenants -am clean install -P unit-tests
mvn install -P integration-tests -pl tests/tests-integrations \
    -Dit.test="TokenGroupsTenantResolutionIT,TenantResolutionConfigValidationIT"

Note on the session attribute name

The attribute is dirigible-selected-tenant-id, following the existing dirigible-act-as-user convention in ActAsFacade rather than a dotted name.

🤖 Generated with Claude Code

iliyan-velichkov and others added 6 commits August 20, 2026 13:02
Adds the three configuration keys the token-groups resolution strategy needs
and the strategy enum both the resolution (core-tenants) and the identity
providers (security-cognito, security-keycloak) have to agree on:

- DIRIGIBLE_TENANT_RESOLUTION_STRATEGY (SUBDOMAIN by default, so nothing
  changes for existing deployments)
- DIRIGIBLE_APP_ID, the application id appearing in the group names
- DIRIGIBLE_TENANT_GROUPS_CLAIM (cognito:groups by default, groups on
  Keycloak realms)

TenantResolutionStrategy.fromConfiguration() normalizes case and whitespace
and rejects an unknown value with InvalidConfigException instead of silently
falling back to a strategy the operator did not ask for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TenantGroupsParser reads groups named <tenantId>.<appId>.<role> for one
application: groups of this application become that tenant's roles, groups of
other applications are ignored (one identity provider serves the whole fleet),
and groups that are not tenant bearing stay global roles, so plain staff
groups such as DEVELOPER keep working.

The role part may contain dots, the tenant and application ids may not.
UserTenantAssignments is an unmodifiable record with the lookups the callers
need. Both types are free of Spring, servlet and OAuth2 types so every
identity provider configuration can reuse them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A half-usable tenant resolution setup is worse than a failed startup: users
would silently land in the wrong tenant, or in no tenant at all. The validator
therefore runs in its constructor, aborting the context refresh before the
instance accepts traffic.

With the token groups strategy it requires an application id that is set and
dot-free (a dotted one makes <tenantId>.<appId>.<role> unparseable, so no group
could ever grant a tenant role), real multi-tenant mode, a groups claim, and
the absence of the legacy Cognito single-user-pool tenant model the strategy
replaces. The default subdomain strategy accepts everything, so nothing
changes for existing deployments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With DIRIGIBLE_TENANT_RESOLUTION_STRATEGY=TOKEN_GROUPS the tenant of a request
is the one the user selected, kept in the HTTP session, instead of the
subdomain of the host - which is what lets a single host serve every tenant of
an application.

Anything else than a session naming a provisioned tenant falls back to the
default tenant: machine-to-machine calls and anonymous requests carry no
session at all, and a selection that no longer resolves must not lock the user
out of the instance. The host is never consulted in this mode, so it also never
produces the "no registered tenant for the current host" response.

Selected tenants are cached by id, mirroring the subdomain-keyed cache, and
both are evicted together by TenantExtractor.evictFromCaches - a tenant that
becomes provisioned has to be enterable at once rather than after the cache
expires. The subdomain strategy stays the default and keeps its behaviour
exactly, including the two legacy identity provider tenant filters, which
continue to resolve by subdomain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TokenGroupsTenantResolutionIT boots the platform in the new mode and proves
where a request lands, observed through the resolved tenant's own
DIRIGIBLE_CONFIGURATIONS table (a marker seeded per tenant): the session
selection decides, the host is not consulted, and a request with no selection,
an unknown one, or one naming a tenant that is not provisioned yet lands in the
default tenant. The host case is the counterpart of
EnabledMultitenantModeIT.testUnregisteredTenantResolution, which expects a 404
for that very host in subdomain mode.

Requests go through MockMvc because the session attribute has to be seeded
directly - in this mode there is deliberately no host to route by.

TenantResolutionConfigValidationIT asserts the startup contract itself: a
Spring context containing the validator must refuse to refresh for every
invalid flag combination, and must refresh for the default configuration and
for a valid one. It boots no application, so the whole matrix runs in
milliseconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@iliyan-velichkov iliyan-velichkov self-assigned this Aug 20, 2026
Comment on lines +49 to +50
LOGGER.info("Tenant resolution strategy is [{}] for application [{}], groups claim [{}].", strategy,
DirigibleConfig.APP_ID.getStringValue(), DirigibleConfig.TENANT_GROUPS_CLAIM.getStringValue());
private InvalidConfigException invalidConfig(DirigibleConfig config, String reason) {
String message = "Invalid configuration [" + config.getKey() + "] while [" + DirigibleConfig.TENANT_RESOLUTION_STRATEGY.getKey()
+ "] is [" + TenantResolutionStrategy.TOKEN_GROUPS + "]: " + reason;
LOGGER.error(message);
@iliyan-velichkov
iliyan-velichkov merged commit 85a8be1 into master Aug 20, 2026
10 checks passed
@iliyan-velichkov
iliyan-velichkov deleted the tenant-resolution-token-groups branch August 20, 2026 11:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants