Tenants
A tenant is a separate catalog with its own resources, feeds, analyses, runs, gates, settings, and audit trail. Every request and every job runs inside exactly one tenant.
Resolving the tenant
Section titled “Resolving the tenant”A deployment declares its tenants in one of two ways. Setting both is an error.
| Declared by | A request resolves by | |
|---|---|---|
| Single | XIXO_TENANT=xixo |
Nothing. Every host is that tenant. |
| Multi | XIXO_TENANTS=demo,acme |
The first label of the hostname. demo.xixo.example is demo. |
bin/rails xixo:tenants creates the declared tenants, and the container entrypoint runs it on
boot. Tenancy::Middleware resolves the host and enters the tenant before any controller runs. A
host that names no tenant receives 404 Unknown tenant. The health check at /up is the only path
served outside a tenant.
Isolation
Section titled “Isolation”Isolation has two layers.
| Layer | What it does | When it fails |
|---|---|---|
TenantScoped |
Adds a default scope and a default tenant_id from Current.tenant. |
Only when code bypasses it, as unscoped does. |
| Row-level security | Applies FORCE ROW LEVEL SECURITY and a tenant_isolation policy to every tenant table. |
It fails with an error or returns no rows. |
The policy compares tenant_id with the xixo.tenant_id session setting for both reads and
writes, so a row cannot be read from, or written to, another tenant. Tenant.switch sets the value
for the session, so it covers every query and transaction inside the block, and it restores the
previous tenant when the block ends. Outside a tenant the setting is empty and the policy matches
no rows, so Model.unscoped returns zero rows.
Active Storage’s blobs, attachments, and variant records tables also have a tenant_id and the same
policy, and TenantScoped is added to their models on load. A signed blob id is signed with the
application’s secret and does not identify its tenant. With the policy in place, a signed id from
one tenant finds no row in another. Active Storage’s own routes are not drawn. Bytes are served
through /references/:id/content, which requires the same grant as the rest of the app.
A Postgres role with SUPERUSER or BYPASSRLS ignores every policy. The first Tenant.switch in a
process checks which role the connection uses, and raises an error if that role can bypass the
policies. Set POSTGRES_USER to an ordinary role.
Every job records the subdomain of the tenant it was enqueued in, and the worker switches into that tenant before performing it. This is built into the job base class. A job enqueued outside a tenant raises an error. Maintenance jobs that work across every tenant declare it and switch into each tenant themselves:
class SweepAuditEventsJob < ApplicationJob across_tenants!endSettings
Section titled “Settings”A setting is a named value from a fixed list of choices. Each setting has a level that decides who it belongs to and which scope changes it.
| Level | Belongs to | Read with | Changed with |
|---|---|---|---|
personal |
the person signed in | xixo:settings:read |
xixo:settings:write |
shared |
everyone in the tenant | xixo:settings:read |
xixo:settings:write |
server |
everyone in the tenant | xixo:settings:admin |
xixo:settings:admin |
A setting that was never changed reads as its default.
| Setting | Level | Choices | Default |
|---|---|---|---|
How the catalog opens (catalog_view) |
personal |
list, cards |
list |
Thumbnail width (thumbnail_size) |
shared |
160, 240, 320, 480, or 640 pixels | 320 |
Hi-res size (hires_size) |
shared |
1024, 1500, 2048, or 3072 pixels | 1500 |
The two image sizes decide how analysis renders thumbnails and hi-res images. An item rendered before a change keeps its old images until it is analyzed again.
The settings query lists every setting the token may read, with its value, default, and choices.
setSetting takes a key and a value, and refuses a value outside the choices. The browser app
shows them under Settings, then Account. See the GraphQL reference.
Signing in
Section titled “Signing in”Each xixo tenant is a client of a masks tenant. MASKS_ISSUER_TEMPLATE sets the issuer URL, with
%{subdomain} taken from the request’s host, so demo.xixo.example can sign in against
demo.masks.example.
A new tenant has no client, so the first visit shows Connect a sign-in server. This starts the
handshake at /auth/handshake:
- xixo sends the browser to masks with its name, its resource URL, its redirect URIs, the scopes
openid profile email offline_access, thexixo:scope namespace, andmasks:delegate:. The redirect URIs are/auth/callbackfor signing in and/connect/callbackfor connecting a resource. - A masks account that is allowed to approve connections approves the request.
- masks returns a one-time initial access token, and xixo redeems it at masks’ registration endpoint, server to server.
xixo stores client_id, client_secret, registration_access_token, registration_client_uri,
and connected_at on the tenant, with the secret and the registration token encrypted.
Disconnecting deletes the registration in masks and clears these columns.
The masks engine is mounted at /auth. GET /auth/session returns the signed-in account, or a 401
whose error is handshake_required or login_required, which tells the browser which button to
show. Signing in requests openid profile email offline_access and every xixo: scope except
xixo:settings:admin. See the scopes reference.
/graphql, /mcp, and /uploads all verify a masks access token. The browser’s token comes from
its session, and other clients send Authorization: Bearer. A token is accepted only when:
- it was issued by this tenant’s issuer,
- its audience is this tenant’s resource URL (the origin followed by
/mcp), and - any
tenantclaim it carries names this tenant.
A token whose tenant claim names acme is refused at demo. The browser’s session requests the
same audience, so one token works on all three paths.