Skip to content

docs: AWS deployment architecture proposals (subdomain and single-host tenant-picker variants) - #6443

Open
iliyan-velichkov wants to merge 10 commits into
masterfrom
docs/aws-deployment-proposal
Open

docs: AWS deployment architecture proposals (subdomain and single-host tenant-picker variants)#6443
iliyan-velichkov wants to merge 10 commits into
masterfrom
docs/aws-deployment-proposal

Conversation

@iliyan-velichkov

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

Copy link
Copy Markdown
Contributor

Summary

Documentation only — two sibling architecture proposals for deploying a multitenant Dirigible application on AWS, both right-sized to one production unit (~50–100 tenants) and designed independently of the docs/aws-multitenancy-research branch (each records where it agrees/differs).

1. AWS_DEPLOYMENT_PROPOSAL.md — subdomain per tenant

  • Compute: ECS Fargate, desiredCount=1, stop-then-start deploys. The runtime is single-writer by construction (embedded ActiveMQ vm:// broker holding a JDBC lock on SystemDB, node-local synchronizer effects, LocalRepository) — the availability consequence (~99.5% SLO) is stated honestly instead of worked around.
  • Data: RDS PostgreSQL Multi-AZ with the shipped schema-per-tenant provisioning; documents on S3 (engine-cms-s3); content baked into the image with publishing disabled; optional internal authoring instance as the In-System-Programming security boundary.
  • Identity: ONE Cognito user pool (one identity = same credentials in every tenant) and one confidential app client per tenant — decided on merit in §4.2 against the single-shared-client alternative (100-callback hard ceiling at the design capacity, no tenant signal for the pre-token Lambda, multi-tenant bearer tokens with app-side-only denial). The shipped security-client-registration module is explicitly not a design input.
  • Cross-tenant login: membership and roles as prefixed Cognito groups (t:<subdomain>:<role>); a pre-token-generation Lambda scopes every token to its tenant (cognito:groups = roles-here-only + a dirigible:tenant audience claim). One interactive sign-in, silent SSO into other tenants, different roles in each.
  • Fork changes: seven must-fix items with file:line anchors, plus four follow-ups; onboarding/offboarding runbooks, hardening checklist, operations, cost ballpark (~$700/month), how-it-grows, and six pre-go-live hypotheses flagged rather than asserted.

2. AWS_SINGLE_HOST_TENANT_PICKER_PROPOSAL.md — no subdomains, in-app tenant picker

The variant for changed requirements: one host, one login, and the user changes the current tenant from a picker in the UI, holding different roles in each. Everything below the identity layer is inherited from proposal 1 unchanged.

  • Dropping subdomains removes the Host header as the tenant signal, and the picker means the tenant is chosen after login and changes without re-auth — so the active tenant becomes server-side session state, and the default-tenant fallback is removed for authenticated traffic (no selection → picker page, never silent default).
  • The token carries the full membership graph as prefixed groups, parsed at login into a membership map with zero authorities; POST /services/core/security/tenants/current validates membership, rebuilds the session's authorities to the picked tenant's roles, rotates the session id and reloads the UI. Switching tenants is the same call — no re-authentication.
  • Cognito simplifies radically: ONE app client (shipped static registration nearly as-is), no pre-token Lambda (Lite plan may suffice), no clientId→tenant map; onboarding shrinks to provision-the-schema + create-the-groups.
  • Honest costs stated: one browser session = one active tenant (concurrent multi-tenant tabs — free under subdomains — are impossible here), multi-tenant tokens with no audience binding, tenant-less deep links, and a larger fork surface (S1–S6: session/header resolver, neutral login mapper, switch endpoint, picker UI, header-based M2M tenancy, stale-tab guard). Path-based tenancy (/t/<tenant>/…) is analysed and rejected.
  • §8 is a side-by-side trade-off table between the two models; §9 lists the model-specific go-live hypotheses (e.g. authority rebuild on a live session).
  • §10 answers horizontal scaling head-on: raising desiredCount today just blocks the second task at boot (ActiveMQ JDBC lock — safe, never split traffic); the layer-by-layer bill for true replicas (external broker, per-node synchronizer replay, boot-race fixes, cache invalidation — shared by both models; plus spring-session + Redis as the only picker-specific item, after which the design survives unchanged); and the picker-specific unit-routing catch (the edge can't see a session-resident tenant → routing cookie + CloudFront Function). Proposal 1's §11 cross-references it.

3. AWS_TENANCY_MODELS_COMPARISON.md — the decision aid

Puts the two models side by side: what is identical in both (groups-only membership, everything below the identity layer, the horizontal-scaling blockers), a 19-row comparison table, explicit pros/cons per model, decision guidance ("subdomains treat tenants as separate products sharing a platform; the picker treats tenants as workspaces inside one product"), the hybrid option (a picker-style dropdown on top of subdomains), and the migration story between the models (the shared t:<tenant>:<role> group convention means switching changes plumbing, never data).

4. AWS_COGNITO_TENANT_ROLES_LIMITS.md — the storage-limits analysis

Standalone; changes none of the three documents above (its §9 records what it would imply for them). Prompted by the real scale — a user in 20–30 tenants, each tenant defining ~10 roles — it shows that the prefixed-group model the other documents specify does not survive those numbers: "Groups to which each user can belong: 100 — not adjustable" allows at most ~3 held roles per tenant at 30 tenants, and the stated worst case needs 300. Covers the defined-vs-held distinction (pool quota 10,000 vs per-user 100), every applicable quota with its adjustability, the arithmetic (group grid, ~9 KB claims carried in both tokens, the non-adjustable 25 RPS account-wide write quota, reverse queries, propagation delay), six options with the wall each hits, and the recommendation: Cognito for identity, an external store for the tenant ↔ user ↔ roles graph.

5. AWS_TENANT_PICKER_HORIZONTAL_SCALING.md — the session layer, in depth

Expands §10 of the picker proposal into a dedicated design note with diagrams. The claim in two halves: externalising the HTTP session makes the picker design horizontally scalable (it removes the only picker-specific blocker, and the design survives unchanged — only where the session lives moves), and on its own it does not make the platform scalable, because five further blockers are platform-level and identical for the subdomain model. Worth adopting at one instance regardless: sessions then survive deploys.

Three repository-verified facts shape the recommendation: HttpSessionFacade.setAttribute(String, String) means user code can only put Strings in the session, so the serialization surface reduces to one principal class rather than an audit of every project; the same class dirties the session on every access, producing a store write per request — the codebase-specific reason to prefer Redis over spring-session-jdbc (which is still presented fairly as the no-new-infrastructure option); and spring.session.timeout=8h is already in application.properties, inert today, live the moment the dependency lands. Also covers the tenant-switch propagation window, the indexed repository making "eject a user immediately" implementable, the SESSION-vs-JSESSIONID cookie rename, ElastiCache topology, a three-phase rollout, and the maxmemory-policy setting that fails silently. Two items are flagged for experiment rather than asserted — notably whether JSON session serialization is viable under Spring Boot 4.1.0's Jackson 3 web layer.

Test plan

  • Documentation-only change — no code, no configuration, no CI-relevant files touched
  • Markdown renders correctly on GitHub (tables, diagrams, section anchors) in both documents
  • Cross-references resolve: file:line anchors spot-checked against current sources (DataSourceInitializer.java:155-159, TenantExtractor.java:44, security-cognito classes, TenantContext.java); the two documents' links to each other work

🤖 Generated with Claude Code

iliyan-velichkov and others added 2 commits July 29, 2026 10:58
Documentation only. No code, no configuration, nothing implemented.

Adds AWS_DEPLOYMENT_PROPOSAL.md - a production architecture for deploying a
multitenant Dirigible application on AWS, designed independently of the
docs/aws-multitenancy-research branch (SS10 records where the two agree and
deliberately differ), right-sized to one production unit (~50-100 tenants).

Shape, in short:
- ECS Fargate, desiredCount=1 with stop-then-start deploys - the runtime is
  single-writer by construction (embedded ActiveMQ vm:// broker with a JDBC
  lock on SystemDB, node-local synchronizer effects, LocalRepository).
  Realistic SLO ~99.5%, stated honestly.
- RDS PostgreSQL Multi-AZ: SystemDB + DefaultDB with the provisioner's
  schema-per-tenant model; documents on S3 (engine-cms-s3); content baked
  into the image, publishing disabled; optional internal authoring instance
  as the In-System-Programming security boundary.
- Identity: ONE Cognito user pool (one identity = same credentials in every
  tenant); ONE confidential app client per tenant - decided on merit in
  SS4.2 against the single-shared-client alternative (100-callback ceiling,
  no tenant signal for the pre-token Lambda, multi-tenant bearer tokens),
  NOT inherited from the shipped security-client-registration module, which
  is explicitly not a design input.
- Membership and roles as prefixed Cognito groups (t:<subdomain>:<role>)
  projected per token by a pre-token-generation Lambda: cognito:groups
  carries only that tenant's roles plus a dirigible:tenant audience claim -
  cross-tenant login with different roles per tenant, silent via the
  pool-wide SSO cookie.
- Seven must-fix fork changes with file:line anchors (configurable Hikari
  pool sizing, tenant assertion for session+M2M principals, method security
  under the cognito profile, filter-chain ordering, per-tenant logout,
  tenant cache sizing, host-keyed ClientRegistrationRepository reading
  Secrets Manager), plus onboarding/offboarding runbooks, hardening
  checklist, operations, cost ballpark and a how-it-grows section.
- Six hypotheses flagged for verification before go-live rather than
  asserted (e.g. @RolesAllowed inert under the cognito profile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documentation only. No code, no configuration, nothing implemented.

Adds AWS_SINGLE_HOST_TENANT_PICKER_PROPOSAL.md - the sibling of
AWS_DEPLOYMENT_PROPOSAL.md for a changed set of requirements: no dedicated
subdomain per tenant, one login, and an in-app tenant picker from which the
user changes the current tenant, holding different roles in each. Everything
below the identity layer (single-writer ECS Fargate unit, RDS Multi-AZ
schema-per-tenant, S3 CMS, hardening, operations, cost) is inherited from
the companion unchanged.

Shape, in short:
- Dropping subdomains removes the Host header as the tenant signal, and the
  picker means the tenant is chosen AFTER login and changes WITHOUT re-auth,
  so tokens cannot be tenant-scoped at issuance. The active tenant becomes
  server-side session state; the default-tenant fallback is removed for
  authenticated traffic (no selection -> picker page, never silent default).
- The token carries the full membership graph as prefixed Cognito groups
  (t:<tenant>:<role>), parsed at login into a membership map with ZERO
  authorities granted; POST /services/core/security/tenants/current
  validates membership, stores activeTenant in the session, REBUILDS the
  session authorities to that tenant's roles only, rotates the session id
  and reloads the UI. Switching tenants is the same call - the requirement.
- Cognito simplifies radically: ONE app client (the shipped static
  registration nearly as-is), no pre-token Lambda (Lite plan may suffice),
  no clientId->tenant map, shipped single-client logout correct unchanged.
  Onboarding shrinks to provision-the-schema + create-the-groups.
- Honest costs stated: one browser session = one active tenant (concurrent
  multi-tenant tabs, free under subdomains, are impossible here - mitigated
  with an active-tenant chip and a stale-tab guard); multi-tenant tokens
  with no audience binding; tenant-less deep links; and a LARGER fork
  surface than the subdomain model (S1-S6: session/header resolver, neutral
  login mapper, switch endpoint, picker UI, header-based M2M tenancy, tab
  guard). Path-based tenancy (/t/<tenant>/...) analysed and rejected.
- Model-specific go-live hypotheses (authority rebuild on a live session,
  no residual host/default fallback, group-claim size, Lite-plan fit,
  no stale per-tenant state after the reload-on-switch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@iliyan-velichkov iliyan-velichkov changed the title docs: AWS deployment architecture proposal (one unit, ECS + Cognito) docs: AWS deployment architecture proposals (subdomain and single-host tenant-picker variants) Jul 29, 2026
iliyan-velichkov and others added 8 commits July 29, 2026 12:15
Documentation only. Answers, layer by layer, whether the proposed
architectures work with horizontal scaling, and if not, what enabling it
would take.

Adds SS10 to AWS_SINGLE_HOST_TENANT_PICKER_PROPOSAL.md:
- 10.1: raising desiredCount today just blocks the second task at boot on
  the embedded ActiveMQ JDBC lock - a stuck deployment, never split traffic,
  so the in-heap session is never silently wrong.
- 10.2: the layer-by-layer bill for true replicas. Platform blockers shared
  by both models: external broker (vm:// URL is hardcoded), per-node
  synchronizer reconciliation (shared checksums vs per-JVM side effects -
  the deepest rework), boot-time DDL races (Quartz initialize-schema=always
  et al.), per-JVM cache invalidation. The repository layer is already fine
  for runtime-only replicas (baked-in content expands per task); authoring
  stays at 1 forever. The only picker-specific item is the HTTP session:
  spring-session + ElastiCache Redis, after which the tenant-in-session
  design survives unchanged. Cross-cutting: connection pools multiply per
  (instance, tenant) - the pool-sizing fix becomes non-negotiable - and
  WebSocket paths need stickiness regardless.
- 10.3: the picker-specific scale-out catch - with one host the edge cannot
  see the tenant (session state; ALB rules cannot match cookies), so routing
  tenants to units needs a routing cookie + CloudFront Function
  (recommended), a gateway tier, or a same-unit placement rule; hybrid
  fallback is hostnames for routing only.
- 10.4: recommended posture per phase, including the cheap early win of
  adding spring-session + Redis ahead of need so sessions survive deploys.

Also: a scale-out-routing row in the SS8 comparison table, and a pointer
from AWS_DEPLOYMENT_PROPOSAL.md SS11 (blockers 1-5 apply to both models).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GitHub-anchor TOCs after the header blocks: top-level sections in both
documents, plus the subsection level where navigation pays off (SS4 identity
decisions in the subdomain proposal; SS2/SS3 identity+picker mechanics and
the SS10 horizontal-scaling analysis in the single-host proposal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Makes explicit in both proposals what was previously implied: there is NO
tenant user attribute of any kind (the custom:tenant attribute the shipped
CognitoTenantFilter reads is retired). A user's tenants are DERIVED from
the t:<tenant>:<role> group pattern - membership in a tenant is holding at
least one of its groups. One claim (cognito:groups) feeds the picker list,
the membership check and the per-tenant roles, so there is no second source
of truth to drift.

Spelled-out consequences in both documents:
- administration is a single operation surface: add to a tenant = add a
  group, change roles = change groups, remove = delete the user's
  t:<tenant>:* groups - nothing else to keep in sync;
- a "member with zero roles" state does not exist (removing the last role
  group removes the membership itself); a conventional marker role
  (t:<tenant>:member) is the escape hatch if that state is ever needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…picker)

Documentation only. Adds AWS_TENANCY_MODELS_COMPARISON.md - a decision aid
putting the two proposed tenancy models side by side, not a third design.

Content: each model in one paragraph; what is identical in both (the
groups-only membership model, everything below the identity layer, the
horizontal-scaling blockers, the shared fork fixes) - making explicit that
this is purely an identity/UX-layer decision; a 19-row side-by-side table
(login UX, switching, concurrent tabs, deep links, URL legibility, token
scoping and stolen-token blast radius, denial point, Cognito surface and
plan tier, DNS/TLS, onboarding, fork surface, pre-login branding,
federation, vanity domains, per-tenant edge controls, unit scale-out,
future replicas, M2M, logout, CSRF exposure); explicit pros/cons lists per
model; decision guidance ("subdomains treat tenants as separate products
sharing a platform; the picker treats tenants as workspaces inside one
product"); the hybrid option (picker-style dropdown on top of subdomains -
keeps every subdomain advantage, fails only the literal no-per-tenant-URL
requirement); and the migration story between the models (shared group
convention means switching changes plumbing, never data).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documentation only. Standalone analysis; the three existing proposal
documents are deliberately left unchanged (SS9 records what it would imply
for them, as implications rather than edits).

Prompted by the real scale: a user may belong to 20-30 tenants, and each
tenant may define ~10 roles, with different roles per tenant. Checked
against the AWS quota page, the prefixed-group model in the current
proposals does not survive those numbers.

The finding: "Groups to which each user can belong: 100 - not adjustable."
At 30 tenants a user can average at most 3 held roles per tenant, and 3
(=90) already leaves zero headroom; 4 each is 120 and fails; the stated
worst case, 10 roles across 30 tenants, is 300. What disqualifies it is the
failure mode - late and user-specific: the design passes review at 30x2=60,
then a consultant or support engineer hits their 101st grant in production,
against a quota no support ticket can raise.

Contents:
- the distinction that gets conflated: roles a tenant DEFINES load the
  groups-per-pool quota (10,000 - comfortable, caps ~1,000 tenants at 10
  roles), while roles a user HOLDS load the groups-per-user quota (100 -
  the wall);
- every applicable quota with its adjustability (groups, custom attributes
  50 / 2,048 bytes / 20-char names, scopes, resource servers, app clients,
  callback URLs, pre-token claim changes, token validity, and the rate
  categories);
- arithmetic: a tenants x held-roles grid against the ceiling, claim size
  (~9 KB at 300 groups, carried in BOTH the ID and access tokens),
  administration throughput (25 RPS UserUpdate, non-adjustable and
  account-wide - a 1,000-user migration is ~20 minutes of saturated quota
  contended with password/MFA operations), reverse queries and propagation
  delay;
- six options with the wall each hits: flat role groups (fails), persona
  bundles (fits, forfeits granular per-user combinations), membership-only
  groups plus roles elsewhere, packed custom-attribute bitmask (fits ~250
  tenants but opaque, no reverse query, clobbers under concurrency),
  external store with Cognito as identity only (recommended - no quota
  anywhere, and it removes the "roles land at next token refresh" caveat),
  AWS Verified Permissions (overkill);
- recommendation, implications for the existing proposals, and items to
  verify in a real pool before committing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…roups

Review question: does "Characters per attribute: 2,048 bytes" also bound the
groups claim - i.e. is there a SIZE limit on a user's groups, not just a
count limit? No, and the document did not say so clearly enough.

Groups and attributes are different resource types. Attributes are name/value
pairs stored on the user record, and the 2,048-byte quota bounds one attribute
VALUE. Groups are a separate first-class pool resource (CreateGroup /
AdminAddUserToGroup), and cognito:groups is COMPUTED at token issuance from
the user's memberships - never read from a stored attribute - so that quota
never touches it.

There is no aggregate size limit on a user's groups at all: the group side is
bounded only by counts (100 per user, 10,000 per pool) and by each name's
length. The worst case is therefore 100 x 128 = ~12.8 KB of names, with no
published cap on the claim or on token size.

Changes:
- SS3 limits table: add the missing "Characters in a group name - 1-128"
  bound, attributed to the CreateGroup API reference (it is NOT on the quotas
  page); restate the attribute row as applying to attribute-based storage
  ONLY and never to groups.
- SS3: new "Groups are not attributes" note carrying the data-model
  distinction, the 12.8 KB theoretical ceiling, and the two consequences -
  the 2,048-byte quota governs option 4 (packed custom attribute) alone,
  which is why that option tops out near 250 tenants; and a
  t:<tenant>:<role> name has ample room inside 128 characters, so the wall
  in the group model is purely the count of 100, never the size.
- SS4.3: state explicitly that the claim-size figures are a practical
  downstream concern (cookies, gateway headers), not a quota violation.
- SS6 option 4 and SS10 verification updated to match.

No arithmetic changes: the 100-groups-per-user finding and every figure
derived from it stand exactly as published.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a "What the tokens actually look like" subsection to SS3, placed right
after the groups-are-not-attributes note because it illustrates exactly what
"computed at token issuance" produces.

One worked user - Anna, a member of three tenants with different roles in
each (six memberships) - shown three ways, with claim names and envelopes
matching Cognito's documented default payloads:

- the ID token in the picker model (no Lambda: every membership, flat);
- the ACCESS token for the same session, making visible that cognito:groups
  rides in BOTH tokens - so the membership list is paid for twice per
  sign-in - and that the access token has no email and uses client_id where
  the ID token uses aud;
- the same person under the subdomain model, after the pre-token Lambda has
  keyed on the app client, filtered to that tenant, stripped the prefix and
  added dirigible:tenant.

Four points read off the JSON: cognito:groups is a FLAT array of name
strings with no nesting and no per-tenant object - the tenant dimension
exists only because it is encoded into the name, which is the whole reason
for the t:<tenant>:<role> convention and why the application must parse
prefixes; both tokens carry it; cognito:roles / cognito:preferred_role are
absent because they appear only when groups carry IAM role ARNs (the
identity-pool case), so here a group is a pure label; and six entries are
readable where three hundred are not.

Also retitles SS3 to "Every limit that applies, and what the tokens
contain" and extends the table of contents with the two new subsections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Redis

Documentation only. Expands SS10 of the single-host tenant-picker proposal
into a dedicated design note on the session layer, with diagrams.

The claim, in two halves: externalising the HTTP session makes the PICKER
DESIGN horizontally scalable - it removes the only picker-specific blocker,
and the design survives unchanged (the switch endpoint keeps its validate ->
store -> rebuild-authorities semantics; only where the session lives moves) -
and on its own it does NOT make the platform scalable, because five further
blockers are platform-level and identical for the subdomain model. It is
worth adopting immediately at one instance regardless: sessions then survive
deploys, so users stop being logged out by every release.

Grounded in three facts verified against this repository rather than assumed,
each of which shaped the recommendation:

- The serialization surface is provably small. HttpSessionFacade exposes the
  session to user JS/TS and looks like an open door for arbitrary objects,
  but its setter is setAttribute(String, String) - String keys, String
  values. No user project can put a non-serializable object in the session,
  so this reduces to getting one principal class right (fork change S2)
  rather than auditing every project ever written on the platform.
- The same class dirties the session on EVERY access (getSession(true) plus
  an invocation.count increment), so session-touching user code produces one
  store write per request. That is the codebase-specific reason to prefer
  Redis over spring-session-jdbc: routing that write rate to the RDS instance
  whose documented capacity ceiling is database connections is the wrong
  place for it. JDBC is presented fairly as the no-new-infrastructure
  alternative.
- spring.session.timeout=8h is already set in application.properties (twice,
  a duplicate worth tidying) and is completely inert because spring-session
  is not on the classpath. It becomes live the moment the dependency lands,
  and it is what sizes the Redis working set.

Also covers: the mechanism (SessionRepositoryFilter, the SESSION cookie
replacing JSESSIONID, flush/save modes); the tenant-switch propagation
window that write-at-end-of-request opens, bounded and never an escalation;
the indexed repository making "eject a user immediately" implementable
rather than a manual runbook step; ElastiCache topology; the five remaining
blockers plus WebSocket affinity, multiplied connection pools and unit
routing; a three-phase rollout; and operations.

Two items flagged for experiment rather than asserted: whether JSON session
serialization is viable given Spring Boot 4.1.0 uses Jackson 3 (tools.jackson)
while Spring Security's session mix-ins are Jackson 2 - the same split that
already bit this codebase in controllers - and the exact spring.session.* /
spring.data.redis.* property names for the resolved versions.

One operational warning worth repeating: maxmemory-policy must never be
allkeys-lru, which evicts live sessions and logs users out at random with no
error anywhere; use noeviction so writes fail loudly, and alarm on
Evictions > 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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