EU data residency — routing, enforcement, and the honest gap table
ROADMAP-V70 E37. Status, in one sentence: the request-time routing and enforcement layer now exists in code; the EU stack is still not provisioned, and this document says exactly which data paths would still leave the EU if it were.
This is the second half of the work docs/developers/data-residency.md
described in 2026-07-08. That document remains accurate for the first half
(Organization.region, region-parameterized Terraform, the signup picker) and
should be read first. Where the two disagree about "there is no request-time
routing layer", this document is newer: that layer is what E37 built.
Owner decision recorded 2026-09-05: build the routing and enforcement; do
NOT provision the EU stack. No terraform apply was run, no cloud resource
was created, and infra/terraform/eu/ is definitions only — see its README.
1. What E37 added
| Piece | Where |
|---|---|
| Region config, parsed and validated once at boot | apps/api/src/region/region.config.ts |
Tenant→region resolution, region-home URLs, unhonoured accounting | apps/api/src/region/region.service.ts |
| Request-time refusal (421) | apps/api/src/region/region-enforcement.interceptor.ts |
GET /region/lookup, GET /region/status | apps/api/src/region/region.controller.ts |
| EU stack definition, unapplied | infra/terraform/eu/ |
Config keys
| Key | Meaning | Unset behaviour |
|---|---|---|
SERVED_REGION | Which regional stack this API process is (US_EAST_1 | EU_WEST_1). | Region unknown. In production this logs an ERROR at boot and shows on GET /region/status. It is never silently coerced to a default. |
REGION_BASE_URLS | JSON {"US_EAST_1":"https://app…","EU_WEST_1":"https://eu…"} — the public base URL of each region. | Single-region topology: nothing is ever redirected. This is the state of every deployment of this product today. |
Both are read with a safe empty default, so an existing deployment that sets
neither behaves exactly as it does now. Neither value is ever hardcoded: an
on-prem or partner deployment is never redirected at a cmdash.com hostname
it has not been told about.
The enforcement rule
decideRegionAction() (pure, fully unit-tested) decides, per request:
- No
REGION_BASE_URLS→ serve, and if the tenant's home region is not this stack's, log a WARN once per tenant and count it asunhonouredonGET /region/status. This is the residency gap this product has had since the region column shipped; E37 makes it countable, not worse. - Multi-region configured but
SERVED_REGIONunknown → fail closed: refuse anything that has a region home to go to. Only a deployment that has opted into multi-region can reach this branch, so a misconfigured single-region fleet is never taken down by it. - Tenant home == served region → serve.
- Mismatch, home URL known → refuse:
421 Misdirected Request, withX-CMDash-Region-Home: <base url>and aLocationheader carrying the same request path on the home stack. - Mismatch, no home URL configured → serve and report
unhonoured; there is nowhere to send the request and refusing would be an outage with no remedy.
Why 421 and not a redirect. A 3xx is followed automatically by browsers and most HTTP clients, which would ship the request and its body to the other region — a cross-region data movement performed by the residency mechanism itself. 421 means "this server is not authoritative for this request" and leaves the decision with the client.
Why an interceptor, not a global guard. ApiKeyGuard is a route-level
guard, so it runs after every global guard; a global guard would enforce
residency on JWT sessions and silently skip every public-API (v1/v2) request.
Interceptors run after all guards, so JWT, API-key and SCIM callers are all
covered by one mechanism.
GET /region/lookup?email=
Public, throttled to 20/min/IP, and deliberately not an account-existence
oracle: an unknown address returns the identical shape and the default
region — which is also the answer for the overwhelming majority of real
accounts. What a caller can learn is that some address is EU-homed; that is
the entire function of the route and the same fact an eu. login hostname
reveals to anyone watching. It reveals nothing about whether the address has a
password, is verified, or belongs to any particular organization.
Its org_memberships read runs inside the existing session_management RLS
lookup scope, mirroring AuthService.login exactly. Without that scope, the
tenant-isolation policy returns zero rows under FORCE RLS and every EU tenant
would be quietly told "US" — a wrong answer indistinguishable from a correct
one. apps/api/test/e37-region-lookup.spec.ts asserts the scope is active by
reading the ambient AsyncLocalStorage from inside the query.
2. The gap table — where tenant data leaves the region
Classification:
- In-region by design — the resource is per-stack, provisioned by that
stack's own Terraform in that stack's own
aws_region. - Tenant-directed egress — data leaves because a tenant administrator configured a destination. Their choice, their responsibility; the product's duty is to make it visible, not to prevent it.
- RESIDENCY GAP — data crosses a region boundary for a reason the tenant did not choose and E37 does not fix.
| Path | What crosses | Classification | Notes / what closing it needs |
|---|---|---|---|
Primary Postgres (DATABASE_URL) | All tenant data | In-region by design | Each stack's own RDS/Lightsail DB in its own aws_region. |
| Redis cache/queues | Cached tenant rows, job payloads | In-region by design | Per-stack ElastiCache/redis container. |
Object storage — exports, evidence, logos (MINIO_*) | Evidence files, export bundles, uploads | In-region by design, conditional | MINIO_ENDPOINT/MINIO_BUCKET are per-deployment env, and infra/terraform/lightsail-saas/objectstore.tf follows var.aws_region. This holds only because a stack never serves another region's tenants — which is what the 421 enforcement above now guarantees. Before E37 there was one bucket in us-east-1 holding every tenant's evidence regardless of Organization.region. |
Control plane api.cmdash.com (CONTROL_PLANE_URL) | Account name, billing email, account status, licence and invoice records, partner API keys, vCISO account creation | RESIDENCY GAP | One control plane, in us-east-1, with its own separate Postgres (cmdash_control_plane). An EU tenant's billing contact and commercial records would sit in the US. Closing it needs either a second control plane (and a rule for which one owns an account) or a documented carve-out that the control plane holds billing data only, never assessment/evidence content. Not fixable inside E37's surfaces. |
| Stripe | Billing contact, payment method, invoice line items | Tenant-directed / subprocessor | Stripe is a global processor with its own EU/US infrastructure and DPA. Out of scope of a regional stack; belongs in the subprocessor list, not in a residency fix. |
Email (RESEND_API_KEY / SMTP_HOST; SES in us-east-1 for the hosted deployment) | Recipient address, subject, message body (which can quote tenant content — digests, findings, dunning) | RESIDENCY GAP | Both keys are per-deployment env, so an EU stack can point at an EU transport (SES eu-west-1, or any EU SMTP relay). Nothing enforces that it does, and the hosted fleet's notify.cmdash.com SES identity is us-east-1. Closing it is an operator action on the EU stack plus a check that fails loudly when the transport's region disagrees with SERVED_REGION — deliberately not built here, because a per-provider region check that guesses wrong is worse than an honest note. |
PostHog product analytics (POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_HOST) | Pseudonymous event stream: user id, org id, feature usage. Not assessment content. | RESIDENCY GAP (narrow) | The host is env-configurable and PostHog Cloud offers an EU host, so this is closable by config on the EU stack. Also already opt-out per organization (PATCH /tenant analytics opt-out). |
| SIEM streaming, outbound webhooks, notification channels (Slack/Teams/PagerDuty) | Whatever the tenant subscribed to | Tenant-directed egress | The tenant supplies the destination URL. common/ssrf.ts governs where these may go; residency is the tenant's call. |
| Export destinations (S3/SFTP/Azure, ROADMAP-V70 E34) | Full export bundles | Tenant-directed egress | Destination and credentials are tenant-configured. An EU tenant pointing an export at a US bucket is their decision and is recorded in the audit log. |
| LDAP/SCIM/SSO IdP callbacks | Directory attributes | Tenant-directed | The IdP is the tenant's. |
Cross-region DR backups (enable_cross_region_backup / backup_region) | RDS snapshots | Configurable — set it wrong and it is a GAP | This is DR, not residency (see rds-backup-replication.tf), but an EU stack replicating snapshots to a US region moves EU data to the US. infra/terraform/eu/ sets enable_cross_region_backup = false and says why. |
| Terraform state | Resource names, endpoints, no tenant rows | Not tenant data | The S3 backend stays in us-east-1 by design; state holds infrastructure metadata, not tenant content. Called out because it looks like a gap and is not. |
Two gaps are recorded, not fixed: the control plane and email transport. Neither is fixable inside E37's surfaces, and neither is papered over.
3. What an operator must still do before EU residency is real
- Apply
infra/terraform/eu/(nobody has). See its README. - Set
SERVED_REGIONon both stacks and give both the sameREGION_BASE_URLSmap. A stack that does not know its own region and has a multi-region map configured refuses traffic — that is the fail-closed branch, and it is deliberate. - Point the EU stack's email transport and
POSTHOG_HOSTat EU endpoints, or accept the gaps above in writing. - Decide the control-plane question (second control plane, or a documented billing-only carve-out).
- Do not flip an existing tenant's
Organization.region. There is still no cross-region migration tooling; the column change would 421 them out of the stack that holds their data.