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

Access rules

Decide per operation who may list, read, create, update and delete the rows of an entity, let callers reach only their own rows, and test a rule before a request does.

  • 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.
  • Two keys: agent (roles agent, authenticated) and admin (roles admin, authenticated). Authentication and API keys explains where roles come from.

The responses below were captured from a real host when the site was built, so your ids will differ.

An entity’s rules hold one CEL condition per operation: list, get, create, update and delete. An operation without a rule is denied to everyone, administrators included. A rule reads the caller as @user.id and @user.roles, the caller’s tenant as @tenant.id, and the row’s own fields by name.

  1. Start the stack over this page’s first descriptor. The first command deletes the stack’s database:

    Terminal
    docker compose down --volumes
    curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/access-rules/01-roles.alvo.json
    docker compose up --wait --wait-timeout 90
  2. Everyone signed in reads queues, and only an administrator writes them. Tickets have no delete rule:

    help-desk.alvo.json
    {
    "$schema": "https://alvo.dev/schema/v1/project.json",
    "apiVersion": "alvo.dev/v1",
    "name": "help-desk",
    "auth": {
    "roles": ["admin", "agent"]
    },
    "entities": {
    "queues": {
    "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" }
    },
    "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"
    }
    }
    }
    }

    A rule is the bare CEL expression as one JSON string. Single quotes go only around a text value inside it, such as a role name.

  3. An agent tries to create a queue. create checks the row being written, and this one fails the rule:

    Request
    curl -sS -X POST http://localhost:8080/api/queues \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"name":"Hardware"}'

    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."
    }
  4. An administrator creates it, and the agent can list it:

    Request
    curl -sS -X POST http://localhost:8080/api/queues \
    -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/queues/9b099d24-8852-4890-b840-45231e486f85
    {
    "id": "9b099d24-8852-4890-b840-45231e486f85",
    "name": "Hardware"
    }
    Request
    curl -sS -X GET http://localhost:8080/api/queues \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET"

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "items": [
    {
    "id": "9b099d24-8852-4890-b840-45231e486f85",
    "name": "Hardware"
    }
    ],
    "next": null,
    "count": null
    }
  5. The agent tries to delete it. delete filters the rows a caller can reach, so the queue is simply not there for them, a 404, not a 403:

    Request
    curl -sS -X DELETE http://localhost:8080/api/queues/9b099d24-8852-4890-b840-45231e486f85 \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_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."
    }
  6. Tickets have no delete rule at all. An agent files a ticket, and even an administrator is refused its delete, with a 403 that says why:

    Request
    curl -sS -X POST http://localhost:8080/api/tickets \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"title":"Printer on fire"}'

    201 Created

    Response
    HTTP/1.1 201 Created
    Content-Type: application/json; charset=utf-8
    Location: /api/tickets/834a1c29-a8a6-400d-978b-afeb13304616
    {
    "id": "834a1c29-a8a6-400d-978b-afeb13304616",
    "status": "open",
    "title": "Printer on fire"
    }
    Request
    curl -sS -X DELETE http://localhost:8080/api/tickets/834a1c29-a8a6-400d-978b-afeb13304616 \
    -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": "No policy allows 'delete' on this entity."
    }

The five rules follow PostgreSQL row-level security. list, get and delete are row filters (USING): a caller they exclude sees fewer rows or none. create checks the row being written (WITH CHECK). update does both with the same condition: the row before and the row after must pass.

The ownership pattern compares a field that holds a user’s id with @user.id. With "audit": true the framework writes created_by on every create, and a caller cannot set it, so it is a field nobody can forge.

  1. Turn on audit and make three of the rules ownership rules. Administrators keep reaching every row, and the access block lets them use the Management API in section 5:

    help-desk.alvo.json
    {
    "$schema": "https://alvo.dev/schema/v1/project.json",
    "apiVersion": "alvo.dev/v1",
    "name": "help-desk",
    "auth": {
    "roles": ["admin", "agent"]
    },
    "access": {
    "developer": "'admin' in @user.roles"
    },
    "entities": {
    "queues": {
    "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": {
    "audit": true,
    "fields": {
    "title": { "type": "string", "required": true, "maxLength": 120 },
    "status": { "type": "enum", "values": ["open", "closed"], "default": "open" }
    },
    "rules": {
    "list": "created_by == @user.id || 'admin' in @user.roles",
    "get": "created_by == @user.id || 'admin' in @user.roles",
    "create": "'agent' in @user.roles || 'admin' in @user.roles",
    "update": "created_by == @user.id || 'admin' in @user.roles"
    }
    }
    }
    }
  2. Apply it by recreating the alvo container (Apply and evolve your descriptor covers the other ways):

    Terminal
    curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/access-rules/02-ownership.alvo.json
    docker compose up -d --wait --force-recreate alvo
  3. The administrator files one ticket and the agent files another:

    Request
    curl -sS -X POST http://localhost:8080/api/tickets \
    -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"title":"Renew the TLS certificate"}'

    201 Created

    Response
    HTTP/1.1 201 Created
    Content-Type: application/json; charset=utf-8
    ETag: "639273155107691040"
    Location: /api/tickets/4f208aca-bf3a-4078-86ba-06c7ffce237c
    {
    "id": "4f208aca-bf3a-4078-86ba-06c7ffce237c",
    "title": "Renew the TLS certificate",
    "created_by": "8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02"
    }
    Request
    curl -sS -X POST http://localhost:8080/api/tickets \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"title":"Printer on fire"}'

    201 Created

    Response
    HTTP/1.1 201 Created
    Content-Type: application/json; charset=utf-8
    ETag: "639273155108192080"
    Location: /api/tickets/8c348efb-8b02-4bdc-823f-304d01ee23bc
    {
    "id": "8c348efb-8b02-4bdc-823f-304d01ee23bc",
    "title": "Printer on fire",
    "created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01"
    }
  4. The agent’s list holds only the agent’s ticket. The administrator’s holds both:

    Request
    curl -sS -X GET 'http://localhost:8080/api/tickets?select=id,title,created_by' \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET"

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "items": [
    {
    "id": "8c348efb-8b02-4bdc-823f-304d01ee23bc",
    "title": "Printer on fire",
    "created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01"
    }
    ],
    "next": null,
    "count": null
    }
    Request
    curl -sS -X GET 'http://localhost:8080/api/tickets?select=id,title,created_by' \
    -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET"

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "items": [
    {
    "id": "4f208aca-bf3a-4078-86ba-06c7ffce237c",
    "title": "Renew the TLS certificate",
    "created_by": "8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02"
    },
    {
    "id": "8c348efb-8b02-4bdc-823f-304d01ee23bc",
    "title": "Printer on fire",
    "created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01"
    }
    ],
    "next": null,
    "count": null
    }
  5. The agent asks for the administrator’s ticket by id. A row the rule excludes is answered exactly like a row that does not exist:

    Request
    curl -sS -X GET http://localhost:8080/api/tickets/4f208aca-bf3a-4078-86ba-06c7ffce237c \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_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."
    }

To let a caller own rows someone else created, use a field that refers to users, such as "assignee_id": { "type": "ref", "entity": "users" }, and write the rule as assignee_id == @user.id. A create rule of the same shape stops a caller from creating a row in somebody else’s name.

Role names are checked when the descriptor is applied. Each role a rule tests must be declared in auth.roles or be built in (anon, authenticated, admin). A typo such as 'amdin' in @user.roles would otherwise match nobody, so the apply refuses it and suggests the role you meant.

A rule that excludes you is a filter, not a refusal. Expecting a 403 where Alvo answers 200 or 404 is the most common surprise in the API, so here is the whole list:

What happenedAnswer
The list rule excludes some or all rows200, with fewer rows or an empty page
The get, update or delete rule excludes the row404 not-found, the same as a row that does not exist
The operation has no rule403 forbidden: “No policy allows ’…’ on this entity.”
The row a create writes fails the create rule403 forbidden: “The write was rejected by policy.”
The rule reads @user.id and the caller sent no key403 forbidden
The entity is tenant-scoped and the caller has no tenant, or the rule reads @tenant.id and the caller has none403 forbidden; see Multi-tenancy
The key’s scopes do not cover the operation403 out-of-scope; see Authentication and API keys

So if you wrote a rule and a caller gets an empty page, it is your rule. If a caller gets a 403 on a read, it is one of the rows above and not the condition you wrote. A request without a key, against the ownership rules:

Request
curl -sS -X GET http://localhost:8080/api/tickets

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 identity, and the policy for this operation reads one."
}

Handle errors shows how a client should branch on these.

A rule is compiled once, when the descriptor is applied, into a SQL predicate with bound parameters, and every list carries it in its WHERE clause. That is why a rule holds for a page of ten rows and for a filter that matches millions. On PostgreSQL, owner_id == @user.id becomes:

cel-to-sql-postgresql.verified.txt
Rule: owner_id == @user.id,
Sql: COALESCE("owner_id" = @alvo_u0, FALSE),
Parameters: [
alvo_u0:Guid
]

The COALESCE(…, FALSE) (COALESCE(…, 0) on SQLite) makes a comparison with null false rather than unknown, on every engine. Mind the negation: !(owner_id == @user.id) is true for a row whose owner_id is null, so a rule written as “everyone but the owner” also admits rows that have no owner.

The Management API answers what the policy engine would decide for any caller, without a request as that caller. It needs the viewer access level, which the access block gives administrators.

  1. Ask what the agent may get. The answer is allowed, with the row filter that will apply:

    Request
    curl -sS -X POST http://localhost:8080/management/projects/help-desk/policy/simulate \
    -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"entity":"tickets","operation":"get","caller":{"user":"3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01","roles":["agent","authenticated"]}}'

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "allowed": true,
    "denyReason": null,
    "using": "created_by == @user.id || 'admin' in @user.roles",
    "withCheck": null,
    "tenantScope": null,
    "hiddenFields": [],
    "readOnlyFields": []
    }
  2. Ask about delete, which has no rule:

    Request
    curl -sS -X POST http://localhost:8080/management/projects/help-desk/policy/simulate \
    -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"entity":"tickets","operation":"delete","caller":{"user":"3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01","roles":["agent","authenticated"]}}'

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "allowed": false,
    "denyReason": "No policy allows 'delete' on this entity.",
    "using": null,
    "withCheck": null,
    "tenantScope": null,
    "hiddenFields": [],
    "readOnlyFields": []
    }

allowed says the engine resolved a policy, not that the caller will see rows: a caller no rule admits still gets true, with a using that none of their rows satisfies. The simulation takes no row id; to check one row, read it through the Data API with that caller’s key. The dashboard’s rules screen runs the same simulation.

PatternRuleWhere it fits
Role gate'admin' in @user.rolesAny operation.
Anyone signed in'authenticated' in @user.rolesReference data every caller reads.
Creator onlycreated_by == @user.idlist, get, update, delete on an entity with audit.
Assigned userassignee_id == @user.idA ref to users; also as the create rule.
Public to everyone, including no key'anon' in @user.roles || 'authenticated' in @user.rolesData meant for the open internet; list and get only.
Public or ownis_public || owner_id == @user.idA boolean field read as a condition. Because it reads @user.id, a caller without a key is refused with a 403 even for public rows.
Combinedcreated_by == @user.id || 'admin' in @user.rolesKeep earlier grants by adding a clause with ||.

Each rule’s slot is in the reference: list, get, create, update and delete. A rule compares with ==, !=, <, <=, >, >=, tests roles with in, tests presence with has(field), and combines with &&, || and !. <, <=, >, >= take numbers and dates, not text; compare with null through has(field), not == null. A rule has no arithmetic, no old. or new. and no changed(): those belong to before-hooks. The same expression language decides per caller whether a field is hidden or readOnly; see Entities and fields.

StatusProblem typeWhenFixReturned by
403forbiddenThe operation has no rule; the row a create writes fails its rule; the caller lacks the identity or tenant the rule reads.Add the rule, or send a key that carries what the rule reads.every host
403out-of-scopeThe key’s scopes do not cover this entity and operation.Grant the key the scope.every host
404not-foundThe get, update or delete rule excludes the row, or the row does not exist.Check the rule with policy/simulate, then the row with the caller’s own key.every host
401unauthenticatedThe key cannot be used, for example because it holds a role the descriptor does not declare.See Authentication and API keys.every host

A rule Alvo cannot compile is refused when the descriptor is applied, and the container does not come back: an undeclared role, a field the entity does not have, a function call, or a rule wrapped in an extra pair of quotes ("'author_id == @user.id'" is one string literal, and the refusal says Remove the outer quotes). The reason is at the end of docker compose logs alvo; in this build that refusal ends the process with exit code 139 instead of 78 (#340).

Multi-tenancy: keep each customer’s rows apart in one database.