Tenant resolution from the user's selection, validated against identity provider groups - #6875
Merged
Merged
Conversation
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>
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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Multi-tenant deployments today need one host per tenant: the tenant of a request is the subdomain of the
hostheader. 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.
SUBDOMAINstays 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=trueTenantExtractor.determineTenant(request)dispatches on the strategy. InTOKEN_GROUPSmode the tenant is the one stored in the HTTP session (TenantSelectionConstants.SELECTED_TENANT_ID_SESSION_ATTRIBUTE) and required to bePROVISIONED. The host is never consulted, so this mode also never produces the "no registered tenant for the current host" 404.TenantGroupsParser(core-base, free of Spring/servlet/OAuth2 types) turns a user's groups intoUserTenantAssignments- this application's groups become tenant roles, other applications' groups are ignored, non-tenant-bearing groups such asDEVELOPERstay global roles. The role part may contain dots, tenant and application ids may not.TenantExtractor.evictFromCaches(id, subdomain)forgets both, so a tenant that becomes provisioned is enterable at once rather than after 10 minutes.TenantResolutionConfigValidatorrefuses to start on a combination that cannot work: withTOKEN_GROUPSthe 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-cognitoand come as a separate PR; until thenTOKEN_GROUPSresolves 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
ApplicationReadyEventlistener would fail after traffic is already accepted.TenantResolutionConfigValidationITrefreshes anAnnotationConfigApplicationContextholding just the validator, per invalid combination. It boots no application, so the whole matrix runs in milliseconds.Tests
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 incore-tenants.TokenGroupsTenantResolutionIT(6) boots the platform in the new mode and proves which tenant a request landed in, observed through the resolved tenant's ownDIRIGIBLE_CONFIGURATIONStable (a marker seeded per tenant); it includes the counterpart ofEnabledMultitenantModeIT.testUnregisteredTenantResolution, which expects a 404 for that very host in subdomain mode.TenantResolutionConfigValidationIT(8) covers the startup matrix.EnabledMultitenantModeIT,DisabledMultitenantModeIT,TenantConfigurationITpass unmodified;mvn -T 1C formatter:validateclean.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 existingdirigible-act-as-userconvention inActAsFacaderather than a dotted name.🤖 Generated with Claude Code