docs: multitenancy behaviour reference, AWS deployment research, target architecture, and setup guide - #6412
Open
iliyan-velichkov wants to merge 3 commits into
Open
docs: multitenancy behaviour reference, AWS deployment research, target architecture, and setup guide#6412iliyan-velichkov wants to merge 3 commits into
iliyan-velichkov wants to merge 3 commits into
Conversation
…research Documentation only. No code, no configuration, nothing implemented. Adds two documents and links them from the root CLAUDE.md: - `components/core/core-tenants/CLAUDE.md` — the in-repo reference for how multitenancy actually works. Scoped as a cross-module guide because the behaviour is a contract between core-base (the SPI), core-tenants (resolution, entity, provisioning, users), data-sources (the actual isolation), core-configurations, and the engines that tenant-qualify their runtime names. Covers the resolution algorithm and its caching gotchas, the TenantContext scope API and the thread-hand-off rule, the datasource-name indirection and the one-Hikari-pool-per-tenant model, provisioning, the per-tenant replay in BaseSynchronizer and which eight synchronizers opt in, the Cognito/Keycloak custom:tenant filters and the per-tenant OAuth client-registration design, the config keys, the multi-node status, the ITs that serve as executable spec, and the footguns. - `AWS_MULTITENANCY_RESEARCH.md` — a production AWS architecture built on that model: schema-per-tenant on Aurora PostgreSQL, ECS Fargate, wildcard subdomains, Cognito single user pool, S3-backed CMS, and cell-based scale-out. Research note; treats the platform as a fixed black box and proposes no code changes. Things worth reviewing from the research, all verified against the code: - The application is effectively single-writer. The embedded ActiveMQ broker uses vm://localhost with JDBCPersistenceAdapter(SystemDB) and never disables the database locker, so broker.start() blocks on ACTIVEMQ_LOCK and a second instance never finishes refreshing its context. SynchronizationJob is a clustered-Quartz singleton whose effects are node-local, so non-winning instances never reconcile their runtime. Hence cells, not replicas. - DataSourceInitializer calls setMaximumPoolSize(20)/setMinimumIdle(10) after the HikariConfig(Properties) constructor, so they override any <DS>_HIKARI_* value — pool sizing is effectively hardcoded, and connection count becomes the capacity limit. - TenantEndpoint and UsersEndpoint do not tenant-scope reads, so an ADMINISTRATOR in any tenant can enumerate and modify every tenant's users. - Shipped-default surfaces that need closing before internet exposure: ttyd on 9000 (writable shell, no auth), DIRIGIBLE_GRAALIUM_ENABLE_DEBUG=true, the SFTP server on 8022 with admin/admin, and actuator exposure of "*". The root CLAUDE.md gains a `## Multitenancy` section; the existing `Tenant-aware configuration` section is demoted to a subsection of it, with its content unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…laims Documentation only. No code, no configuration, nothing implemented. The first document in this PR was written under a deliberate constraint: treat the platform as a fixed black box, change no code. This adds the companion that lifts it, and answers a requirement the current model cannot express — a user registered in one tenant being able to log into another tenant where they hold the necessary authorizations. `AWS_MULTITENANCY_TARGET_ARCHITECTURE.md` recommends making Dirigible a claims-driven authorization consumer instead of adding a membership table: identity, tenant membership and per-tenant roles come from the IdP token, not from DIRIGIBLE_USERS / DIRIGIBLE_USER_ROLE_ASSIGNMENTS. Recommended identity design: ONE Cognito user pool, ONE app client per tenant, and a pre-token-generation Lambda (V2_0/V3_0) that maps clientId -> tenant and emits only that tenant's roles via groupsToOverride. Three properties make this a smaller change than expected: - The existing cognito:groups -> authorities mapper becomes correct per tenant with no change to it, because the token is tenant-scoped at issuance. - Roles need not be real Cognito groups, so the non-adjustable 100-groups-per- user quota does not apply; membership lives in the control plane. - JSESSIONID is host-scoped, so each tenant subdomain gets its own session with its own authorities — which neutralises the fact that authorities are snapshotted at login rather than re-resolved per request. Cross-tenant login then works via Cognito's documented default: the managed- login session cookie authenticates a user across all app clients in one pool, so the second tenant's OIDC dance needs no credential prompt but still yields a different role set. Pool-per-tenant is rejected — it is the configuration AWS names for *preventing* this. Also proposed: a TenantIdentityResolver SPI as the single new abstraction, and demoting the local user tables to a dev-only implementation behind it (which closes the cross-tenant admin gap by construction). Security findings surfaced while mapping the authorization path, all of them independent of this design and worth reviewing on their own: - @EnableMethodSecurity(jsr250Enabled=true) sits only on BasicSecurityConfig, which is @ConditionalOnProperty(basic.enabled=true), and every OIDC profile sets that false. Read literally, all 52 @RolesAllowed annotations are inert under the cognito/keycloak/snowflake/github profiles. Labelled a HYPOTHESIS — confirming it is Phase 0 of the proposal, not an assertion. - M2M Bearer requests bypass the tenant membership check entirely: both *TenantFilter clones guard on `instanceof OAuth2AuthenticationToken`, and a JWT request is a JwtAuthenticationToken. - CognitoSecurityConfiguration never adds TenantContextInitFilter to its chain, unlike the basic/keycloak/snowflake configs; it runs only via Boot's servlet auto-registration, so its position relative to authentication is accidental. - DIRIGIBLE_TRIAL_ENABLED grants every platform role to every authenticated principal, tenant-blind, in three places. - security-client-registration: GET returns clientSecret in plaintext to any tenant's ADMINISTRATOR/DEVELOPER/OPERATOR; registrationId is the entity name while the REST path uses id, so the seeded default (named "cognito") is unreachable through CognitoLoginController, which accepts only tenant subdomains; REGISTRATIONS is a private static non-thread-safe HashMap filled only as a side effect of iterator(). - spring-boot-starter-oauth2-authorization-server is pulled in transitively and never configured; its only effect is a stray auto-configured JwtDecoder that all three resource-server configs defensively pin around. Runtime isolation gap that needs a decision regardless of identity: JavaCompiledOutputDirectory resolves to one global dirigible/java-compiled/bin for all tenants, loaded by a single ClientClassLoader — client Java code is not tenant-isolated. Same class of problem as PublisherService writing into the shared /registry/public. Per the runtime-only production decision, the IDE/authoring tier's user-scoped state is recorded as an accepted constraint rather than fixed — including GitFileUtils.USER_WORKSPACE_SEGMENTS_COUNT=3 / _PROJECT_=4, where inserting a tenant path segment causes silent file mangling in ShareCommand rather than an exception. The two existing documents gain a cross-link. AWS_MULTITENANCY_RESEARCH.md stays as the "deploy the current release unchanged" analysis. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documentation only. No code, no configuration, nothing implemented. Adds AWS_MULTITENANCY_SETUP_GUIDE.md — the how-to that implements the model from AWS_MULTITENANCY_TARGET_ARCHITECTURE.md, answering the concrete questions: how many OAuth app clients and with what config, single user pool vs pool per tenant, how a user's tenant memberships and roles live in Cognito and reach Dirigible, and which Cognito settings to use. Answers, in short: - ONE Cognito user pool for all tenants (Essentials plan). Cross-tenant login requires one identity per person; the managed-login session cookie is shared across all app clients in one pool, which is exactly the cross-tenant SSO we want. Pool-per-tenant is what AWS names for preventing it. - ONE confidential app client per tenant (+ one default/platform client, + optional M2M client per tenant). Client name == tenant subdomain, which is what CognitoLoginController validates; per-tenant callback URLs are why one client per tenant beats one shared client (100-callback-URL cap, no wildcards). - Membership and roles live in DynamoDB, NOT in Cognito groups or attributes. A pre-token-generation Lambda (V2_0/V3_0) maps clientId -> tenant, denies token issuance to non-members, and writes that tenant's roles into cognito:groups. Dirigible's existing mapper turns cognito:groups into ROLE_*, so the current code applies per-tenant roles correctly with no change. The guide is split into Mode A (configuration only, works on today's code) and Mode B (the fork hardening from the target-architecture doc), with an honest Mode-A caveat table (M2M bypasses the membership check; @RolesAllowed possibly inert under the cognito profile — labelled a hypothesis; single-client logout; the client-registration static-map quirks). Includes a full worked example (one user, two tenants, different roles, plus the denied case), both token payloads, exact aws-cli commands, the Lambda handler, the full Dirigible env-var table, an onboarding runbook, and a validation checklist. The two companion AWS docs gain a pointer to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Documentation only. No code, no configuration, nothing implemented. Four markdown files plus a pointer from the root
CLAUDE.md.What this adds
1.
components/core/core-tenants/CLAUDE.md— the in-repo reference for how multitenancy actually works, following the existing module-guide convention (core-liquibase,engine-java,engine-intent,engine-native-apps). Scoped as a cross-module guide, because the behaviour is a contract betweencore-base(the SPI),core-tenants(resolution, entity, provisioning, users),data-sources(the actual isolation),core-configurations, and the engines that tenant-qualify their runtime names.Covers: the resolution algorithm and its caching gotchas; the
TenantContextscope API and the thread-hand-off rule; the datasource-name indirection and the one-Hikari-pool-per-tenant model; provisioning; the per-tenant replay inBaseSynchronizerand which eight synchronizers opt in; the Cognito/Keycloakcustom:tenantfilters; the config keys; the multi-node status; the ITs that serve as executable spec; and a footguns section.2.
AWS_MULTITENANCY_RESEARCH.md— a production AWS architecture for the current release, with zero code changes: schema-per-tenant on Aurora PostgreSQL, ECS Fargate, wildcard subdomains, Cognito single user pool, S3-backed CMS, cell-based scale-out, tenant lifecycle, cost model.3.
AWS_MULTITENANCY_TARGET_ARCHITECTURE.md— the architecture we'd actually recommend, written without that constraint. It answers a requirement the current model cannot express: a user registered in one tenant being able to log into another tenant where they hold the necessary authorizations. Makes Dirigible a claims-driven authorization consumer instead of adding a membership table — identity, tenant membership and per-tenant roles come from the IdP token.4.
AWS_MULTITENANCY_SETUP_GUIDE.md— the concrete how-to that implements #3: how many Cognito app clients and their exact config, single user pool vs pool per tenant, the pre-token-generation Lambda and DynamoDB membership store, the full Dirigible env-var table, a worked example with both token payloads, an onboarding runbook, and a validation checklist. Split into Mode A (configuration only — works on today's code) and Mode B (the fork hardening), with an honest Mode-A caveat table.The root
CLAUDE.mdgains a## Multitenancysection linking these. The existingTenant-aware configurationsection is demoted to a subsection of it — content unchanged — since the per-tenant config layer is a sub-topic of the model it extends.The recommendation, in short
Make Dirigible a claims-driven authorization consumer: identity, tenant membership and per-tenant roles come from the IdP token, not from
DIRIGIBLE_USERS/DIRIGIBLE_USER_ROLE_ASSIGNMENTS.Identity design: one Cognito user pool, one app client per tenant, and a pre-token-generation Lambda (V2_0/V3_0) that maps
clientId → tenantand emits only that tenant's roles viagroupsToOverride. Three properties make this a smaller change than expected:cognito:groups→ authorities mapper becomes correct per tenant with no change to it, because the token is tenant-scoped at issuance.JSESSIONIDis host-scoped, so each tenant subdomain gets its own session with its own authorities. That neutralises the fact that authorities are snapshotted at login rather than re-resolved per request.Cross-tenant login then works via Cognito's documented default: the managed-login session cookie authenticates a user across all app clients in one pool, so the second tenant's OIDC dance needs no credential prompt but still yields a different role set. Pool-per-tenant is rejected — it's the configuration AWS names for preventing exactly this.
Also proposed: a
TenantIdentityResolverSPI as the single new abstraction, and demoting the local user tables to a dev-only implementation behind it (which closes the cross-tenant admin gap by construction).Findings worth reviewing on their own merits
All verified against the code, with path references in the docs. These are independent of the proposal.
Deployment / scaling
MessagingConfigbuilds the embedded ActiveMQ broker onvm://localhostwithJDBCPersistenceAdapter(SystemDB)andsetPersistent(true), never disabling the database locker — sobroker.start()blocks onACTIVEMQ_LOCKand a second instance's context never finishes refreshing. Separately,SynchronizationJobis a clustered-Quartz singleton whose effects are node-local (Camel routes, JMS listeners, Quartz registrations, client-Java classloaders live in that JVM), so non-winning instances never reconcile. Hence cells, not replicas. The Helm chart'sreplicaCount: 1+Recreatealready encodes the same conclusion.DataSourceInitializercallssetMaximumPoolSize(20)/setMinimumIdle(10)after theHikariConfig(Properties)constructor, overriding any<DS>_HIKARI_*value. With one pool per (instance, tenant), connection count becomes the capacity limit.Authorization
@EnableMethodSecurity(jsr250Enabled = true)sits only onBasicSecurityConfig, which is@ConditionalOnProperty(basic.enabled=true)— and every OIDC profile sets that false. Keycloak's bare@EnableMethodSecuritydefaultsjsr250Enabledto false. Read literally, all 52@RolesAllowedannotations are inert under the cognito/keycloak/snowflake/github profiles. Labelled a hypothesis in the docs, not an assertion — confirming it is Phase 0 of the proposal.*TenantFilterclones guard oninstanceof OAuth2AuthenticationToken, and a JWT request is aJwtAuthenticationToken.CognitoSecurityConfigurationnever addsTenantContextInitFilterto its chain, unlike the basic/keycloak/snowflake configs. It runs only via Boot's servlet auto-registration, so its position relative to authentication is accidental.DIRIGIBLE_TRIAL_ENABLEDgrants every platform role to every authenticated principal, tenant-blind, in three places.TenantEndpoint/UsersEndpointdon't tenant-scope reads — anADMINISTRATORin any tenant can enumerate and modify every tenant's users.RepositoryEndpoint's/{*path}CRUD is the same class of problem.security-client-registration:GETreturnsclientSecretin plaintext to any tenant's ADMINISTRATOR/DEVELOPER/OPERATOR;registrationIdis the entitynamewhile the REST path usesid, so the seeded default (named"cognito") is unreachable throughCognitoLoginController, which accepts only tenant subdomains;REGISTRATIONSis aprivate staticnon-thread-safeHashMapfilled only as a side effect ofiterator().spring-boot-starter-oauth2-authorization-serveris pulled in transitively and never configured. Its only effect is a stray auto-configuredJwtDecoderthat all three resource-server configs must defensively pin around.Isolation
JavaCompiledOutputDirectoryresolves to one globaldirigible/java-compiled/binfor all tenants, loaded by a singleClientClassLoader— client Java code is not tenant-isolated. This is runtime, not authoring, so it needs a decision regardless of the identity work. Same class of problem asPublisherServicewriting into the shared/registry/public.Stale / dead
DIRIGIBLE_MASTER_REPOSITORY_*is dead code — nothing incomponents/ever creates anIMasterRepository. The working content-seeding paths areClasspathExpanderandDIRIGIBLE_REGISTRY_EXTERNAL_FOLDER.build/helm-charts/is stale (Tomcat paths,/services/v4/healthcheck,DIRIGIBLE_SCHEDULER_MEMORY_STOREwhich nothing reads). Noted so it isn't used as a deployment reference.Notes for the reviewer
@RolesAllowed, whether HTTP Basic re-invokesloadUserByUsernameper request, whether a cross-subdomain session replay is exploitable, and whetherRole.nameglobal uniqueness is enforced or merely implied by single-result queries. Each has a suggested experiment.adminin tenant A andadminin tenant B get distinct workspaces. None of the existing multitenancy ITs exercises a same-username-in-two-tenants scenario, which is why several latent collisions have gone unnoticed.GitFileUtils.USER_WORKSPACE_SEGMENTS_COUNT = 3/_PROJECT_ = 4, where inserting a tenant path segment causes silent file mangling inShareCommandrather than an exception. Worth knowing before anyone reopens that area.INSERTs to theservices/security/REST endpoints, and per-tenant configuration didn't exist then).formatter:validateand the license check are unaffected; existing moduleCLAUDE.mdfiles carry no license header either.🤖 Generated with Claude Code