Multi-tenancy
One Kiln deployment can serve many sites. Each site is an organization
(KilnCMS.Accounts.Organization), and an org's content, media, taxonomy,
forms, analytics, newsletter and history are isolated from every other org's.
A single-site install is just a deployment with one org — the default org —
and never has to think about any of this.
This page is the isolation model in one place. The configuration table is in environment-variables.md; who can do what within an org is in granular-rbac.md.
The model: one database, rows tagged by org
Kiln uses Ash attribute multitenancy: every per-site table carries an
org_id column, and every read and write on it is filtered by the request's
org. It is not schema-per-tenant — one set of tables, one set of indexes, one
migration run — because schema-per-tenant would multiply the pgvector and
trigram search indexes by the number of sites.
The data layer fails closed. config :kiln_cms, :strict_tenancy (on by
default, compile-time) makes every org-scoped resource require a tenant
(global?: false), so an action that forgets to pass one is an error rather
than a read across every org. The few reads that are deliberately org-spanning
— resolving a newsletter confirm/unsubscribe token and billing webhooks, which
arrive knowing a token or a payment id rather than a host — are marked
multitenancy :bypass action by action, and then scope themselves to the org
of the row they found.
How a request finds its org
KilnCMSWeb.Tenant.fetch_org/1 resolves the org from the request's host,
in this order:
- a subdomain of
TENANT_BASE_HOST(default:PHX_HOST) —acme.example.com→ the org whose slug isacme; - an exact
custom_domainon an org —www.acme.com; - otherwise the default org.
KilnCMSWeb.Plugs.SetTenant runs this in the endpoint, before the router, and
sets the result as the Ash tenant — so the JSON:API and GraphQL surfaces are
scoped with no per-resolver code. LiveView mounts and all three sockets
(/ws/gql, /ws/bridge, /ws/collab) resolve the same way from the URI they
connected on, never from anything the client sends in its payload.
Lookups are cached (KilnCMS.Cache.Hosts), so resolution is not a query per
request.
The default-org fallback, and TENANT_STRICT_HOST
Step 3 is right for a single-host install — a bare hostname, an IP literal or
localhost should reach the only site there is. On a multi-tenant deployment
it is wrong: any request with an unrecognized Host, including an
attacker-supplied one, would be served the default org's content, branding and
analytics.
Strict host matching turns itself on once a second organization exists
(#1547). With TENANT_STRICT_HOST unset — the default — a single-org install
keeps the fallback, and the create that makes a deployment multi-tenant removes
it: an unmatched host then gets a 404 (or a 503 with retry-after if the
lookup could not run because the database is down) instead of the default org.
No restart, and every node follows — the creating node tells the others over
Phoenix.PubSub, and a node that missed the message recounts within 30
seconds (#1654). The apex (PHX_HOST) is never refused; health probes and the payment
webhook are exempt. The full behaviour, including what static files do, is under
TENANT_STRICT_HOST.
TENANT_STRICT_HOST=true refuses unknown hosts even with one org.
TENANT_STRICT_HOST=false keeps the fallback only while there is one org
(#1662). Until 0.12 it kept it with many too, which served an unrecognized — and
possibly attacker-chosen — Host another tenant's site; Kiln no longer honours
false on a multi-org deployment. Once a second organization exists, unknown
hosts are refused whatever the setting says, and Kiln logs an error saying the
setting is being ignored: at boot, when the second org is created, and on
/editor/system for as long as false stays set. Remove it.
There is deliberately no switch to get the old behaviour back. Anything that
relied on the fallback can be given a real home instead: a host that should
reach a site belongs to an organization — a subdomain of TENANT_BASE_HOST, or
its custom_domain (the default org can have one too) — and anything else, a
second alias or a bare IP, is redirected to one of those at your proxy. The PHX_HOST apex and
KILN_CONSOLE_HOST are never refused, and the health probes and the payment
webhook are exempt. Organizations cannot be deleted, so a single-org install
that creates a second one — even by accident — stays strict; check your hosts
before you create it.
If the organizations cannot be counted — a node that booted while Postgres was
unreachable — an unset setting behaves as multi-org and refuses unknown hosts
until the count succeeds. Serving an unrecognized host another tenant's site
cannot be undone; a retryable refusal on a single-org install for the length of
an outage can. See KilnCMSWeb.Tenant.OrgCount.
What is per-org and what is deployment-wide
Per org (carries org_id): every content type and its versions, published
artifacts and their reference graph, media, categories and tags, dynamic
content types and custom fields, forms and submissions, webhooks and their
deliveries, analytics, funnels and experiments, newsletter subscribers and
sends, automation rules, document history, custom roles, and the org's
settings (branding, public theme, editorial settings). Cache keys for these
include the org id, and search results are filtered to the request's org.
Deployment-wide (no org_id): user accounts and their sign-in methods
(passwords, passkeys, SSO identities, tokens), API keys, outbound mail
settings (sender and DKIM key — one per deployment), billing settings, the
mail suppression list, and federation replay protection.
So one person has one account across sites and belongs to orgs through
KilnCMS.Accounts.OrgMembership, which carries their tier (:admin /
:editor / :viewer) and editorial scope for that org. An account that
holds any membership has no access to an org it isn't a member of — the
org comes from the host, which the client controls, so falling back to the
account's global role there would let a scoped editor escape by switching
hosts. A platform admin (User.role == :admin) is the exception and keeps
admin everywhere. Details: granular-rbac.md, "Per-org
capability tiers".
API keys belong to a user, not an org. A headless request is scoped to the org its host resolves to, and the key's user is authorized there like any other actor.
Creating an org
Creating and updating orgs is a platform-admin action
(KilnCMS.Accounts.create_organization/2 with an admin actor); there is no
screen for it yet. An org has a name, a slug (its subdomain) and an
optional custom_domain. The slug also names its console host when
KILN_CONSOLE_HOST is set (<slug>.<console host>, below). Members are then managed per org at
/editor/team.
The slug is a hostname label, because it becomes one (#1710). It is trimmed
and downcased on save (Acme is stored as acme), and then refused unless it
is 1 to 63 of a-z, 0-9 and -, not starting or ending with -.
www, console, api and mail are reserved, and so is the first label of
KILN_CONSOLE_HOST when that host sits directly under PHX_HOST. A slug saved
by an earlier release had no such rule. mix kiln.org_slugs lists the orgs whose slug
can't be a hostname, and --fix downcases the ones where that alone is
enough. In a release, run bin/kiln_cms eval 'KilnCMS.Release.org_slugs()'.
Kiln also warns at boot while any are left.
Creating the second org turns strict host matching on unless
TENANT_STRICT_HOST is already true — and since 0.12 an explicit false does
not stop it (see above) — so before you create it, make sure every host the
deployment answers on is either PHX_HOST or belongs to an org.
With KILN_CONSOLE_HOST unset, it is also the create that puts one org admin's
code injection on the same origin as every other tenant's editors (#1661) — see
Serving the console from its own host.
Kiln warns at boot, on that create, and on /editor/system.
It also caps form embedding at EMBED_ORIGINS unless EMBED_ORIGINS_LOCKED
is set (#1618): from then on an org admin's per-form or per-site embed
allowlist may only narrow the operator's list, and a saved list naming a site
outside it stops being served to that site. With EMBED_ORIGINS unset, that
means no other site can frame any form — so before you create the second
org, put every site any org embeds forms on into EMBED_ORIGINS, or set
EMBED_ORIGINS_LOCKED=false to keep each org's own list uncapped. Kiln warns at
boot, on that create, and on /editor/system while saved lists are being cut
down. Details: forms.md.
Orgs cannot be deleted: paper-trail version rows outlive the content they
describe, and deleting an org would strand them. A single-tenant install that
wants a hard guarantee can set config :kiln_cms, :multitenancy_enabled, false,
which refuses any org beyond the default one.
Serving the console from its own host
By default each org's editor console answers on that org's own host, so a
page's custom scripts are same-origin with the console. KILN_CONSOLE_HOST
moves every console onto hosts that serve no tenant content:
| Org | Site (code injection runs here) | Console |
|---|---|---|
| default | example.com | console.example.com |
acme | acme.example.com (or its custom_domain) | acme.console.example.com |
beta | beta.example.com | beta.console.example.com |
Each org's console host is derived from its slug (#1688), so there is nothing
to configure per org. An editor who opens /editor on an org's site is
redirected to that org's console host. Every console host is its own origin
with its own session cookie, so no site's script can reach any console, and no
org's console shares an origin with another org's. Signing in on one console
host does not sign you in on another; each is a separate sign-in.
Setting it up:
- Pick a host under
PHX_HOST, e.g.console.example.comforPHX_HOST=example.com. Passkeys' relying-party ID is thePHX_HOSThost, and a browser accepts it only on hosts under it. A console host outsidePHX_HOSTstill works, but without passkey sign-in, and Kiln warns about that at boot. Existing passkeys keep working; nobody re-enrolls. - DNS and TLS for the host and its wildcard:
console.example.comand*.console.example.com. A certificate for*.example.comdoes not coveracme.console.example.com. - Set
KILN_CONSOLE_HOST=console.example.comand restart. Kiln adds the console hosts to the socket origin check itself;CHECK_ORIGINSneeds no change.
On a single-org deployment only the bare console host is ever used, so the wildcard is optional there.
On a multi-org deployment without it, an org admin's code injection can act
with the session of any editor who opens that org's site signed in, a platform
admin included. Kiln warns about exactly that combination (more than one org
and KILN_CONSOLE_HOST unset) at boot, when the second org is created, and on
/editor/system (#1661, threat model residual risk 16). The alternative is to
grant org admin only to people you would trust with the console. Why one
console host per org rather than one shared, org-switching console host is
decision record 0011.
See environment-variables.md and
code-injection.md.