Skip to content

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.

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 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!
end

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.

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:

  1. xixo sends the browser to masks with its name, its resource URL, its redirect URIs, the scopes openid profile email offline_access, the xixo: scope namespace, and masks:delegate:. The redirect URIs are /auth/callback for signing in and /connect/callback for connecting a resource.
  2. A masks account that is allowed to approve connections approves the request.
  3. 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 tenant claim 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.