Multi-tenancy
Keep each tenant's rows apart in one database: turn tenancy on, give each key its tenant, share reference data across tenants, and know what the isolation guarantees.
Before you start
Section titled “Before you start”- The stack from Run your own descriptor, run from its
alvo-help-deskdirectory withCOMPOSE_FILEand that page’s secrets exported in this shell. Step 1 addsALVO_ACME_KEY_SECRETandALVO_GLOBEX_KEY_SECRET; every compose command needs them too while this page’s override is in place. - This page adds two keys that each belong to a tenant:
acmeandglobex(rolesagent,authenticated). It also usesadmin, which belongs to no tenant. - Access rules, for how rules filter rows.
The responses below were captured from a real host when the site was built, so your ids will differ.
1. Turn tenancy on
Section titled “1. Turn tenancy on”A tenant-scoped entity gets a tenant_id column, and every read and write of it is limited to the caller’s tenant.
The tenant comes from the API key, never from the request or the descriptor. In an embedded host that calls
IAlvoData from its own endpoints, it comes from the AlvoContext the host passes.
-
Give each new key a
Tenant. This override replaces the one from Run your own descriptor and keeps its keys:docker-compose.override.yml # Saved as docker-compose.override.yml, it replaces the one from "Run your own descriptor" and keeps everything# that one sets. Keys 3 and 4 each belong to one tenant: every row they write lands in that tenant, and they never# see another tenant's rows.name: alvo-help-deskservices:alvo:environment:Alvo__DescriptorPath: /alvo/descriptor.jsonAlvo__Auth__DevKeys__1__KeyId: agentAlvo__Auth__DevKeys__1__Secret: ${ALVO_AGENT_KEY_SECRET:?set ALVO_AGENT_KEY_SECRET before starting the stack}Alvo__Auth__DevKeys__1__User: 3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01Alvo__Auth__DevKeys__1__Roles__0: agentAlvo__Auth__DevKeys__1__Roles__1: authenticatedAlvo__Auth__DevKeys__1__Scopes__0: "*:read"Alvo__Auth__DevKeys__1__Scopes__1: "*:write"Alvo__Auth__DevKeys__2__KeyId: adminAlvo__Auth__DevKeys__2__Secret: ${ALVO_ADMIN_KEY_SECRET:?set ALVO_ADMIN_KEY_SECRET before starting the stack}Alvo__Auth__DevKeys__2__User: 8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02Alvo__Auth__DevKeys__2__Roles__0: adminAlvo__Auth__DevKeys__2__Roles__1: authenticatedAlvo__Auth__DevKeys__2__Scopes__0: "*:read"Alvo__Auth__DevKeys__2__Scopes__1: "*:write"Alvo__Auth__DevKeys__3__KeyId: acmeAlvo__Auth__DevKeys__3__Secret: ${ALVO_ACME_KEY_SECRET:?set ALVO_ACME_KEY_SECRET before starting the stack}Alvo__Auth__DevKeys__3__User: a1c3e5f7-2b4d-4c6e-8f0a-1b3d5f7a9c01Alvo__Auth__DevKeys__3__Tenant: 0b8f3c2a-5d4e-4f6a-8b7c-1d2e3f4a5b6cAlvo__Auth__DevKeys__3__Roles__0: agentAlvo__Auth__DevKeys__3__Roles__1: authenticatedAlvo__Auth__DevKeys__3__Scopes__0: "*:read"Alvo__Auth__DevKeys__3__Scopes__1: "*:write"Alvo__Auth__DevKeys__4__KeyId: globexAlvo__Auth__DevKeys__4__Secret: ${ALVO_GLOBEX_KEY_SECRET:?set ALVO_GLOBEX_KEY_SECRET before starting the stack}Alvo__Auth__DevKeys__4__User: b2d4f6a8-3c5e-4d7f-9a1b-2c4e6f8a0b02Alvo__Auth__DevKeys__4__Tenant: 7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918Alvo__Auth__DevKeys__4__Roles__0: agentAlvo__Auth__DevKeys__4__Roles__1: authenticatedAlvo__Auth__DevKeys__4__Scopes__0: "*:read"Alvo__Auth__DevKeys__4__Scopes__1: "*:write"volumes:- ./help-desk.alvo.json:/alvo/descriptor.json:ro -
Turn tenancy on for the whole project, and keep
categoriesshared:help-desk.alvo.json {"$schema": "https://alvo.dev/schema/v1/project.json","apiVersion": "alvo.dev/v1","name": "help-desk","auth": {"roles": ["admin", "agent"]},"tenancy": {"enabled": true},"entities": {"categories": {"tenancy": "global","fields": {"name": { "type": "string", "required": true, "unique": true, "maxLength": 60 }},"rules": {"list": "'authenticated' in @user.roles","get": "'authenticated' in @user.roles","create": "'admin' in @user.roles","update": "'admin' in @user.roles","delete": "'admin' in @user.roles"}},"tickets": {"fields": {"title": { "type": "string", "required": true, "maxLength": 120 },"status": { "type": "enum", "values": ["open", "closed"], "default": "open" },"category_id": { "type": "ref", "entity": "categories" }},"rules": {"list": "'authenticated' in @user.roles","get": "'authenticated' in @user.roles","create": "'agent' in @user.roles || 'admin' in @user.roles","update": "'agent' in @user.roles || 'admin' in @user.roles"}}}}With
"tenancy": { "enabled": true }every entity is scoped unless it says"tenancy": "global". Without the project switch, mark single entities with"tenancy": "scoped". -
Download the override, generate the two secrets, and start the stack over this descriptor. The
downdeletes the stack’s database:Terminal curl -fsSL -o docker-compose.override.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/multi-tenancy.compose.override.ymlexport ALVO_ACME_KEY_SECRET="$(openssl rand -hex 16)"export ALVO_GLOBEX_KEY_SECRET="$(openssl rand -hex 16)"docker compose down --volumescurl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/multi-tenancy/01-tenancy.alvo.jsondocker compose up --wait --wait-timeout 90Every compose command reads the override, so keep its secrets exported in this shell.
2. Write and read in a tenant
Section titled “2. Write and read in a tenant”-
The administrator adds a category.
categoriesis global, so a key without a tenant may write it:Request curl -sS -X PUT http://localhost:8080/api/categories/c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a \-H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"name":"Hardware"}'201 Created
Response HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8Location: /api/categories/c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a{"id": "c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a","name": "Hardware"} -
Acme files a ticket. A
POSTto a scoped entity must carry the caller’s owntenant_id: Alvo checks the value against the key’s tenant rather than filling it in.Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: acme.$ALVO_ACME_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"VPN drops every hour","tenant_id":"0b8f3c2a-5d4e-4f6a-8b7c-1d2e3f4a5b6c","category_id":"c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a"}'201 Created
Response HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8Location: /api/tickets/ce0b93df-a80d-4bcd-8bdb-203d3e757c81{"id": "ce0b93df-a80d-4bcd-8bdb-203d3e757c81","category_id": "c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a","status": "open","tenant_id": "0b8f3c2a-5d4e-4f6a-8b7c-1d2e3f4a5b6c","title": "VPN drops every hour"} -
Leave
tenant_idout, or name another tenant, and the write is refused. The refusal names no field:Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: acme.$ALVO_ACME_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"Printer on fire"}'403 Forbidden
Response HTTP/1.1 403 ForbiddenContent-Type: application/problem+json{"type": "https://alvo.dev/errors/forbidden","title": "Forbidden","status": 403,"detail": "The write was rejected by policy."}A
PUTthat creates a row is the other way round: it must not sendtenant_id, and the new row lands in the caller’s tenant. -
Globex lists tickets and sees none of Acme’s, and asking for Acme’s ticket by id is a 404, the same answer as for an id that does not exist:
Request curl -sS -X GET http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: globex.$ALVO_GLOBEX_KEY_SECRET"200 OK
Response HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8{"items": [],"next": null,"count": null}Request curl -sS -X GET http://localhost:8080/api/tickets/ce0b93df-a80d-4bcd-8bdb-203d3e757c81 \-H "X-Alvo-Api-Key: globex.$ALVO_GLOBEX_KEY_SECRET"404 Not Found
Response HTTP/1.1 404 Not FoundContent-Type: application/problem+json{"type": "https://alvo.dev/errors/not-found","title": "Not Found","status": 404,"detail": "The requested record was not found."} -
Both tenants read the shared categories:
Request curl -sS -X GET http://localhost:8080/api/categories \-H "X-Alvo-Api-Key: globex.$ALVO_GLOBEX_KEY_SECRET"200 OK
Response HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8{"items": [{"id": "c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a","name": "Hardware"}],"next": null,"count": null}
3. Rows never change tenant
Section titled “3. Rows never change tenant”-
tenant_idis fixed when the row is created. An update that names it is refused, whatever the value:Request curl -sS -X PATCH http://localhost:8080/api/tickets/ce0b93df-a80d-4bcd-8bdb-203d3e757c81 \-H "X-Alvo-Api-Key: acme.$ALVO_ACME_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"tenant_id":"7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918"}'403 Forbidden
Response HTTP/1.1 403 ForbiddenContent-Type: application/problem+json{"type": "https://alvo.dev/errors/forbidden","title": "Forbidden","status": 403,"detail": "Field 'tenant_id' is fixed at creation and a row can never move to another tenant."} -
A caller without a tenant cannot reach a scoped entity at all, not even an administrator:
Request curl -sS -X GET http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET"403 Forbidden
Response HTTP/1.1 403 ForbiddenContent-Type: application/problem+json{"type": "https://alvo.dev/errors/forbidden","title": "Forbidden","status": 403,"detail": "The caller has no tenant, and this entity is tenant-scoped."} -
The
X-Alvo-Tenantheader can only confirm the key’s own tenant. Naming any other tenant makes the key unusable for that request:Request curl -sS -X GET http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: acme.$ALVO_ACME_KEY_SECRET" \-H "X-Alvo-Tenant: 7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918"401 Unauthorized
Response HTTP/1.1 401 UnauthorizedContent-Type: application/problem+json{"type": "https://alvo.dev/errors/unauthenticated","title": "Unauthorized","status": 401,"detail": "The presented API key could not be used. Check the key, whether it has been revoked or has expired, and whether it was issued for the tenant you requested."}The header is optional: without it the key’s tenant applies. Its name is configurable as
Alvo:Auth:TenantHeaderName.
How it works
Section titled “How it works”A scoped entity gets a tenant condition, tenant_id == @tenant.id, compiled like a rule you wrote and added to every
operation’s own rule, so a list, a read, an update and a delete all carry it in the same WHERE clause. A rule may
read @tenant.id too. A caller whose key has no tenant is
refused before any rule runs, so a missing tenant can never match rows by accident.
docs/architecture/cel.md
has the details.
What isolation guarantees
Section titled “What isolation guarantees”- A tenant cannot see, change or delete another tenant’s rows. Every attempt is the answer for a row that does not exist: an empty page or a 404.
- A tenant cannot learn that another tenant’s row exists, with one exception. A reference to a row in another
tenant is refused like a reference to nothing (
unresolved-reference), and auniquefield is unique per tenant, so two tenants may hold the same value and a duplicate tells you only about your own rows. The exception: aPUTcreate-or-replace on a UUID you already hold answers409if a row of any tenant has that id, because the primary key is the id alone. - A row cannot move.
tenant_idis set once, on create, and checked against the key. - A key acts in one tenant. The tenant header can confirm it, never widen it.
What isolation does not cover:
- An after-hook’s event carries no tenant (#153), and the event queue holds every tenant’s complete rows with no retention (#154). See After-hooks, events and webhooks.
- A custom CEL function that reads stored data does so outside Alvo’s tenant filter: it must take the tenant as a parameter and filter by it. See Custom CEL functions.
What can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 403 | forbidden | A POST left out tenant_id or named another tenant; an update or a PUT sent tenant_id; the key has no tenant and the entity is scoped. | Echo the key’s tenant on a POST, never send tenant_id otherwise, and use a key issued for a tenant. | every host |
| 404 | not-found | The row belongs to another tenant, or does not exist. | Use a key of the tenant that owns the row. | every host |
| 401 | unauthenticated | X-Alvo-Tenant names a tenant other than the key’s own, or is not a valid id. | Drop the header, or send the key’s own tenant. | every host |
| 422 | validation | A ref points at a row in another tenant (code unresolved-reference). | Reference a row of your own tenant, or a global entity. | every host |
Reference
Section titled “Reference”- Descriptor keys:
tenancy,tenancy.enabled, an entity’stenancy. - Configuration:
Alvo:Auth:DevKeys:{n}:TenantandAlvo:Auth:TenantHeaderName. - Problem types:
forbidden,not-found,unauthenticated,validation. - Design notes:
tenant_idon create and replace and unique per tenant.
Validate and transform writes: refuse a write, or fill in a value, as a row is stored.