neenv

Tenants: one declaration, one edge that names them, each application deciding entry

The problem

Several groups of people share the same applications: a family, a firm, a school. Each application runs in development, staging and production. Every request has to be served as one tenant's, and the worst failure a shared system has is serving it as another's: one tenant reading another's pages, files or accounts.

Three things go wrong in practice. The fact "which tenant is this name" is copied into every application and the copies drift. An application trusts a tenant header that a caller can type. And a name nobody declared lands on whichever site the proxy happens to list first.

This pattern keeps the tenant in one declaration, lets only the edge proxy tell an application which tenant a request is for, and leaves the decision about who may enter to each application.

Words used here. A tenant is a group of people whose data it is; its key names the people (acme, family), never an application or a module. A name is a host name a person or program calls. The edge is the reverse proxy in front of every application. The door is the one sign-in every application shares (OpenID Connect).

What runs here

nginx at the edge, Keycloak as the door, and applications that each choose how they keep tenants apart.

1. One declaration owns "which tenant is this name"

Every name belongs to exactly one tenant and one application. The tenant is the declaration's fact; which application serves the name is the catalogue's: one list of every machine, service, name and address, which the other layers read instead of keeping copies (The layers). A check holds the two name lists to one set. Nothing else in the estate may say which tenant a name is: not a vhost row, not an application's settings.

A check runs at every push and refuses the declaration when:

Two shapes go beyond a flat list:

One tenant is the platform's own: the system's administration lives there, and the door gives no per-tenant role in it (its pages open to the administrator role alone).

2. The edge sets the tenant header and overwrites any copy

Each name gets its own nginx server block, generated from the declaration. Every block that proxies to an application carries one line:

proxy_set_header X-Tenant "acme";

When nginx sets a header with proxy_set_header, it does not forward the caller's header of the same name, in any case. A caller can no more choose its tenant than its identity. Two details matter:

For a content system run by FastCGI, the same rule is a fastcgi_param HTTP_X_TENANT; see Many tenants' sites from one content system.

3. A name nobody declared is refused, on both ports

nginx answers an unmatched name with the port's default server, which is the first block it read unless one is marked. So each port gets an explicit default that refuses:

server { listen 80 default_server;      server_name _; return 404; }
server { listen 443 ssl default_server; server_name _; ssl_reject_handshake on; }

Over https there is no name to hold a certificate for, so the handshake is refused (TLS unrecognized_name) rather than completed with some other tenant's certificate. Over plain http the answer is a plain 404 that says nothing about what lives here.

4. Applications are reachable only through the edge

A request that skips the proxy skips the header line, and can carry any X-Tenant it likes. So an application that trusts the header binds to loopback (127.0.0.1) on the edge's own machine, and the edge's row for it points there. An application listening on every address is a hole even with a perfect edge.

5. The header says which tenant; each application decides who enters

X-Tenant says which tenant a request is for. It says nothing about who the caller is or what they may do. Reach is a property of the caller, decided inside each application, never written in the declaration or in a header.

6. The isolation model is chosen per application

The standard terms: silo gives each tenant its own copy of the application or store; pool serves every tenant from one shared copy and keeps them apart inside it; bridge mixes the two, for example a pooled application whose tenants' files may sit in separate stores.

Application Model Where the tenant lives What keeps tenants apart
Content system pool one installation; one context (site) per tenant the edge's key picks the context; per-tenant door client; reader groups (cms-tenant)
A business application with its own database pool one database; a tenants table whose key is the declaration's key the application serves only the tenant the edge named, and checks <tenant>:<role>
The file store no tenant in the store; a tenant may be given its own backend (silo) nowhere: the store keeps no tenant at all each application's own list of which files are a tenant's
The door pool one realm per environment per-tenant roles and, where needed, per-tenant clients

The file store holds no tenant. A file may belong to several tenants, and which tenant owns which file is the application's fact. A caller may name a tenant on upload, but only to choose where the file goes: the store looks the tenant's storage_destination up in the declaration (served read-only to anyone, like DNS), and never writes the tenant down. Giving one tenant its own backend (its own bucket and credentials) is a move of existing files, not a flag.

Object contents are encrypted with a key the store never keeps. The object store is S3-compatible (Garage) with customer-supplied keys (SSE-C): the file store sends its key on every request, and the object store uses it and discards it. What that covers, precisely:

The key is one per service, not one per tenant. It separates the contents from the disks and backups that hold them; it does not separate one tenant from another.

7. A tenant arrives

What a new tenant needs, in order, from the declaration outward:

  1. A key and its names in the declaration, for every environment. The push check runs.
  2. DNS records and a certificate for each name. Whether a name answers from the internet is a separate decision, made per name.
  3. The edge's rows, rendered from the declaration; the header follows by itself.
  4. The door: the per-tenant roles appear on the next run of the door's configuration. A tenant in the content system also gets a client per environment, whose secret is written to the secret store before the run that names it.
  5. The application's own part: a context in the content system, a row in a business application's tenants table.

The declaration, with example values

tenants:
  acme:
    what: "a firm's staff"
    names:
      - acme.example.org
      - acme.staging.example.test
      - acme.dev.example.test
    children: [acme-lab]          # reach over acme runs down to acme-lab
  acme-lab:
    what: "one team inside the firm"
    names:
      - lab.acme.example.org
      - lab.acme.staging.example.test
      - lab.acme.dev.example.test
  family:
    what: "a family's private site"
    names:
      - family.example.org
      - family.staging.example.test
      - family.dev.example.test
    storage_destination: family-own # absent means the default backend
groups: {}                        # a group: a set of tenant keys, never names

The edge's block for one of those names, as rendered:

server {
    listen 443 ssl;
    server_name acme.example.org;
    location / {
        proxy_pass http://127.0.0.1:8080;      # the application, bound to loopback
        proxy_set_header X-Tenant "acme";      # replaces any X-Tenant the caller sent
    }
}

The drill

drill-tenants.sh renders a real nginx configuration from a two-tenant declaration, runs it on loopback in front of a small backend that answers with every tenant header it received, in any spelling, and deletes everything when it exits. It needs bash, nginx 1.19.4 or later, python3, curl and openssl, and no root and no credential.

./drill-tenants.sh
ok    1 acme.example.test reaches the backend as acme
ok    2 a caller's own X-Tenant: family, sent as two copies, is overwritten (backend saw acme only)
ok    3 a caller's X_Tenant: family never reaches the backend
ok    4 family.dev.example.test reaches the backend as family
ok    5 an undeclared name is refused at the TLS handshake
ok    6 an undeclared name over http gets 404 and reaches no backend
ok    7 a name declared under two tenants is refused (shared.example.test is declared under acme and family; a name belongs to one tenant)

Each check is seen to fail with its fix removed. An ARM run exits 0 only when exactly the expected checks failed:

ARM=noheader ./drill-tenants.sh       # the edge does not set the header
FAIL  1 acme.example.test: backend saw X-Tenant=(none)
FAIL  2 a caller's own X-Tenant: family, sent as two copies: backend saw X-Tenant=family,family
FAIL  3 a caller's X_Tenant: family: backend saw X-Tenant=(none)
FAIL  4 family.dev.example.test: backend saw X-Tenant=(none)
ok    5 ...  ok    6 ...  ok    7 ...
ARM=noheader: exactly checks 1 2 3 4 failed, as they must

ARM=underscores ./drill-tenants.sh    # underscores_in_headers on
FAIL  3 a caller's X_Tenant: family: backend saw X-Tenant=acme,family
ARM=underscores: exactly checks 3 failed, as they must

ARM=invalidheaders ./drill-tenants.sh # ignore_invalid_headers off
FAIL  3 a caller's X_Tenant: family: backend saw X-Tenant=acme,family
ARM=invalidheaders: exactly checks 3 failed, as they must

ARM=nodefault ./drill-tenants.sh      # no default server refusing other names
FAIL  5 an undeclared name over https: curl exit 0, backend saw X-Tenant=acme
FAIL  6 an undeclared name over http: answer 200, backend requests 5 -> 6
ARM=nodefault: exactly checks 5 6 failed, as they must

ARM=nocheck ./drill-tenants.sh        # the renderer lets one name sit under two tenants
FAIL  7 a name declared under two tenants was rendered
ARM=nocheck: exactly checks 7 failed, as they must

Check 2 sends the forged header twice, as X-Tenant and x-tenant, so family,family under ARM=noheader is both of the caller's copies arriving untouched. ARM=nodefault shows the trap section 3 closes: with no default server, a name nobody declared is served as acme, the first tenant nginx read.

What the drill does not prove: that an application is bound to loopback (section 4), and that it refuses a signed-in person of another tenant (section 5). Both are the application's own checks, against its own door client, in a non-production environment.

What is still open