Skip to content

docs: multitenancy behaviour reference, AWS deployment research, target architecture, and setup guide - #6412

Open
iliyan-velichkov wants to merge 3 commits into
masterfrom
docs/aws-multitenancy-research
Open

docs: multitenancy behaviour reference, AWS deployment research, target architecture, and setup guide#6412
iliyan-velichkov wants to merge 3 commits into
masterfrom
docs/aws-multitenancy-research

Conversation

@iliyan-velichkov

@iliyan-velichkov iliyan-velichkov commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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 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; 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.md gains a ## Multitenancy section linking these. The existing Tenant-aware configuration section 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 → 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 doesn't apply — membership lives in the control plane (DynamoDB).
  • JSESSIONID is 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 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).

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

  • The application is effectively single-writer. MessagingConfig builds the embedded ActiveMQ broker on vm://localhost with JDBCPersistenceAdapter(SystemDB) and setPersistent(true), never disabling the database locker — so broker.start() blocks on ACTIVEMQ_LOCK and a second instance's context never finishes refreshing. Separately, SynchronizationJob is 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's replicaCount: 1 + Recreate already encodes the same conclusion.
  • Hikari pool sizing is effectively hardcoded. DataSourceInitializer calls setMaximumPoolSize(20) / setMinimumIdle(10) after the HikariConfig(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 on BasicSecurityConfig, which is @ConditionalOnProperty(basic.enabled=true) — and every OIDC profile sets that false. Keycloak's bare @EnableMethodSecurity defaults jsr250Enabled to false. Read literally, all 52 @RolesAllowed annotations 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.
  • 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.
  • TenantEndpoint / UsersEndpoint don't tenant-scope reads — an ADMINISTRATOR in any tenant can enumerate and modify every tenant's users. RepositoryEndpoint's /{*path} CRUD is the same class of problem.
  • 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 must defensively pin around.

Isolation

  • JavaCompiledOutputDirectory resolves to one global dirigible/java-compiled/bin for all tenants, loaded by a single ClientClassLoader — 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 as PublisherService writing into the shared /registry/public.

Stale / dead

  • DIRIGIBLE_MASTER_REPOSITORY_* is dead code — nothing in components/ ever creates an IMasterRepository. The working content-seeding paths are ClasspathExpander and DIRIGIBLE_REGISTRY_EXTERNAL_FOLDER.
  • build/helm-charts/ is stale (Tomcat paths, /services/v4/healthcheck, DIRIGIBLE_SCHEDULER_MEMORY_STORE which nothing reads). Noted so it isn't used as a deployment reference.

Notes for the reviewer

  • Nothing here asserts an unverified claim. Four findings are labelled hypotheses and confined to the verification sections: the inert @RolesAllowed, whether HTTP Basic re-invokes loadUserByUsername per request, whether a cross-subdomain session replay is exploitable, and whether Role.name global uniqueness is enforced or merely implied by single-result queries. Each has a suggested experiment.
  • The cheapest first step the docs propose costs nothing: a failing integration test asserting that admin in tenant A and admin in 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.
  • Per a 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. Worth knowing before anyone reopens that area.
  • The 2024 blog "Multitenant applications with zero effort" is mostly still accurate; the docs call out the two things that drifted (onboarding moved from raw INSERTs to the services/security/ REST endpoints, and per-tenant configuration didn't exist then).
  • Markdown only, so formatter:validate and the license check are unaffected; existing module CLAUDE.md files carry no license header either.

🤖 Generated with Claude Code

iliyan-velichkov and others added 2 commits July 27, 2026 15:49
…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>
@iliyan-velichkov iliyan-velichkov changed the title docs: multitenancy behaviour reference + AWS deployment architecture research docs: multitenancy behaviour reference, AWS deployment research, and recommended target architecture Jul 28, 2026
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>
@iliyan-velichkov iliyan-velichkov changed the title docs: multitenancy behaviour reference, AWS deployment research, and recommended target architecture docs: multitenancy behaviour reference, AWS deployment research, target architecture, and setup guide Jul 28, 2026
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.

1 participant