Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

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.

  • The stack from Run your own descriptor, run from its alvo-help-desk directory with COMPOSE_FILE and that page’s secrets exported in this shell. Step 1 adds ALVO_ACME_KEY_SECRET and ALVO_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: acme and globex (roles agent, authenticated). It also uses admin, 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.

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.

  1. 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-desk
    services:
    alvo:
    environment:
    Alvo__DescriptorPath: /alvo/descriptor.json
    Alvo__Auth__DevKeys__1__KeyId: agent
    Alvo__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-5d6e7f8a9b01
    Alvo__Auth__DevKeys__1__Roles__0: agent
    Alvo__Auth__DevKeys__1__Roles__1: authenticated
    Alvo__Auth__DevKeys__1__Scopes__0: "*:read"
    Alvo__Auth__DevKeys__1__Scopes__1: "*:write"
    Alvo__Auth__DevKeys__2__KeyId: admin
    Alvo__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-9f0b1c2d3e02
    Alvo__Auth__DevKeys__2__Roles__0: admin
    Alvo__Auth__DevKeys__2__Roles__1: authenticated
    Alvo__Auth__DevKeys__2__Scopes__0: "*:read"
    Alvo__Auth__DevKeys__2__Scopes__1: "*:write"
    Alvo__Auth__DevKeys__3__KeyId: acme
    Alvo__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-1b3d5f7a9c01
    Alvo__Auth__DevKeys__3__Tenant: 0b8f3c2a-5d4e-4f6a-8b7c-1d2e3f4a5b6c
    Alvo__Auth__DevKeys__3__Roles__0: agent
    Alvo__Auth__DevKeys__3__Roles__1: authenticated
    Alvo__Auth__DevKeys__3__Scopes__0: "*:read"
    Alvo__Auth__DevKeys__3__Scopes__1: "*:write"
    Alvo__Auth__DevKeys__4__KeyId: globex
    Alvo__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-2c4e6f8a0b02
    Alvo__Auth__DevKeys__4__Tenant: 7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918
    Alvo__Auth__DevKeys__4__Roles__0: agent
    Alvo__Auth__DevKeys__4__Roles__1: authenticated
    Alvo__Auth__DevKeys__4__Scopes__0: "*:read"
    Alvo__Auth__DevKeys__4__Scopes__1: "*:write"
    volumes:
    - ./help-desk.alvo.json:/alvo/descriptor.json:ro
  2. Turn tenancy on for the whole project, and keep categories shared:

    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".

  3. Download the override, generate the two secrets, and start the stack over this descriptor. The down deletes 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.yml
    export ALVO_ACME_KEY_SECRET="$(openssl rand -hex 16)"
    export ALVO_GLOBEX_KEY_SECRET="$(openssl rand -hex 16)"
    docker compose down --volumes
    curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/multi-tenancy/01-tenancy.alvo.json
    docker compose up --wait --wait-timeout 90

    Every compose command reads the override, so keep its secrets exported in this shell.

  1. The administrator adds a category. categories is 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 Created
    Content-Type: application/json; charset=utf-8
    Location: /api/categories/c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a
    {
    "id": "c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a",
    "name": "Hardware"
    }
  2. Acme files a ticket. A POST to a scoped entity must carry the caller’s own tenant_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 Created
    Content-Type: application/json; charset=utf-8
    Location: /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"
    }
  3. Leave tenant_id out, 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 Forbidden
    Content-Type: application/problem+json
    {
    "type": "https://alvo.dev/errors/forbidden",
    "title": "Forbidden",
    "status": 403,
    "detail": "The write was rejected by policy."
    }

    A PUT that creates a row is the other way round: it must not send tenant_id, and the new row lands in the caller’s tenant.

  4. 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 OK
    Content-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 Found
    Content-Type: application/problem+json
    {
    "type": "https://alvo.dev/errors/not-found",
    "title": "Not Found",
    "status": 404,
    "detail": "The requested record was not found."
    }
  5. 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 OK
    Content-Type: application/json; charset=utf-8
    {
    "items": [
    {
    "id": "c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a",
    "name": "Hardware"
    }
    ],
    "next": null,
    "count": null
    }
  1. tenant_id is 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 Forbidden
    Content-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."
    }
  2. 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 Forbidden
    Content-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."
    }
  3. The X-Alvo-Tenant header 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 Unauthorized
    Content-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.

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.

  • 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 a unique field is unique per tenant, so two tenants may hold the same value and a duplicate tells you only about your own rows. The exception: a PUT create-or-replace on a UUID you already hold answers 409 if a row of any tenant has that id, because the primary key is the id alone.
  • A row cannot move. tenant_id is 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.
StatusProblem typeWhenFixReturned by
403forbiddenA 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
404not-foundThe row belongs to another tenant, or does not exist.Use a key of the tenant that owns the row.every host
401unauthenticatedX-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
422validationA ref points at a row in another tenant (code unresolved-reference).Reference a row of your own tenant, or a global entity.every host

Validate and transform writes: refuse a write, or fill in a value, as a row is stored.