Skip to main content
CMDashCybersecurity Maturity Dashboard
Security / Data residency

EU data residency

What routes where today, and the gaps this document names on purpose rather than papering over. See /security for the rest of CMDash’s posture.

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

PieceWhere
Region config, parsed and validated once at bootapps/api/src/region/region.config.ts
Tenant→region resolution, region-home URLs, unhonoured accountingapps/api/src/region/region.service.ts
Request-time refusal (421)apps/api/src/region/region-enforcement.interceptor.ts
GET /region/lookup, GET /region/statusapps/api/src/region/region.controller.ts
EU stack definition, unappliedinfra/terraform/eu/

Config keys

KeyMeaningUnset behaviour
SERVED_REGIONWhich 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_URLSJSON {"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:

  1. 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 as unhonoured on GET /region/status. This is the residency gap this product has had since the region column shipped; E37 makes it countable, not worse.
  2. Multi-region configured but SERVED_REGION unknown → 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.
  3. Tenant home == served region → serve.
  4. Mismatch, home URL known → refuse: 421 Misdirected Request, with X-CMDash-Region-Home: <base url> and a Location header carrying the same request path on the home stack.
  5. 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.
PathWhat crossesClassificationNotes / what closing it needs
Primary Postgres (DATABASE_URL)All tenant dataIn-region by designEach stack's own RDS/Lightsail DB in its own aws_region.
Redis cache/queuesCached tenant rows, job payloadsIn-region by designPer-stack ElastiCache/redis container.
Object storage — exports, evidence, logos (MINIO_*)Evidence files, export bundles, uploadsIn-region by design, conditionalMINIO_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 creationRESIDENCY GAPOne 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.
StripeBilling contact, payment method, invoice line itemsTenant-directed / subprocessorStripe 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 GAPBoth 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 toTenant-directed egressThe 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 bundlesTenant-directed egressDestination 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 callbacksDirectory attributesTenant-directedThe IdP is the tenant's.
Cross-region DR backups (enable_cross_region_backup / backup_region)RDS snapshotsConfigurable — set it wrong and it is a GAPThis 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 stateResource names, endpoints, no tenant rowsNot tenant dataThe 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

  1. Apply infra/terraform/eu/ (nobody has). See its README.
  2. Set SERVED_REGION on both stacks and give both the same REGION_BASE_URLS map. 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.
  3. Point the EU stack's email transport and POSTHOG_HOST at EU endpoints, or accept the gaps above in writing.
  4. Decide the control-plane question (second control plane, or a documented billing-only carve-out).
  5. 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.

Ready to see your posture?

Start a 30-day free trial of any paid plan — a card is required, and it converts unless you cancel — or sign up for Free with no card at all.

EU data residency — CMDash