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.
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. - Two keys:
agent(rolesagent,authenticated) andadmin(rolesadmin,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.
1. Grant each operation by role
Section titled “1. Grant each operation by role”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.
-
Start the stack over this page’s first descriptor. The first command deletes the stack’s database:
Terminal docker compose down --volumescurl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/access-rules/01-roles.alvo.jsondocker compose up --wait --wait-timeout 90 -
Everyone signed in reads queues, and only an administrator writes them. Tickets have no
deleterule: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.
-
An agent tries to create a queue.
createchecks 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 ForbiddenContent-Type: application/problem+json{"type": "https://alvo.dev/errors/forbidden","title": "Forbidden","status": 403,"detail": "The write was rejected by policy."} -
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 CreatedContent-Type: application/json; charset=utf-8Location: /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 OKContent-Type: application/json; charset=utf-8{"items": [{"id": "9b099d24-8852-4890-b840-45231e486f85","name": "Hardware"}],"next": null,"count": null} -
The agent tries to delete it.
deletefilters 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 FoundContent-Type: application/problem+json{"type": "https://alvo.dev/errors/not-found","title": "Not Found","status": 404,"detail": "The requested record was not found."} -
Tickets have no
deleterule 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 CreatedContent-Type: application/json; charset=utf-8Location: /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 ForbiddenContent-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.
2. Let callers reach only their own rows
Section titled “2. Let callers reach only their own rows”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.
-
Turn on
auditand make three of the rules ownership rules. Administrators keep reaching every row, and theaccessblock 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"}}}} -
Apply it by recreating the
alvocontainer (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.jsondocker compose up -d --wait --force-recreate alvo -
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 CreatedContent-Type: application/json; charset=utf-8ETag: "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 CreatedContent-Type: application/json; charset=utf-8ETag: "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"} -
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 OKContent-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 OKContent-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} -
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 FoundContent-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.
3. Know when Alvo answers 403
Section titled “3. Know when Alvo answers 403”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 happened | Answer |
|---|---|
The list rule excludes some or all rows | 200, with fewer rows or an empty page |
The get, update or delete rule excludes the row | 404 not-found, the same as a row that does not exist |
| The operation has no rule | 403 forbidden: “No policy allows ’…’ on this entity.” |
The row a create writes fails the create rule | 403 forbidden: “The write was rejected by policy.” |
The rule reads @user.id and the caller sent no key | 403 forbidden |
The entity is tenant-scoped and the caller has no tenant, or the rule reads @tenant.id and the caller has none | 403 forbidden; see Multi-tenancy |
| The key’s scopes do not cover the operation | 403 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:
curl -sS -X GET http://localhost:8080/api/tickets403 Forbidden
HTTP/1.1 403 ForbiddenContent-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.
4. See how a rule becomes SQL
Section titled “4. See how a rule becomes SQL”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:
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.
5. Test a rule with policy/simulate
Section titled “5. Test a rule with policy/simulate”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.
-
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 OKContent-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": []} -
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 OKContent-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.
Options and variations
Section titled “Options and variations”| Pattern | Rule | Where it fits |
|---|---|---|
| Role gate | 'admin' in @user.roles | Any operation. |
| Anyone signed in | 'authenticated' in @user.roles | Reference data every caller reads. |
| Creator only | created_by == @user.id | list, get, update, delete on an entity with audit. |
| Assigned user | assignee_id == @user.id | A ref to users; also as the create rule. |
| Public to everyone, including no key | 'anon' in @user.roles || 'authenticated' in @user.roles | Data meant for the open internet; list and get only. |
| Public or own | is_public || owner_id == @user.id | A 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. |
| Combined | created_by == @user.id || 'admin' in @user.roles | Keep 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.
What can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 403 | forbidden | The 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 |
| 403 | out-of-scope | The key’s scopes do not cover this entity and operation. | Grant the key the scope. | every host |
| 404 | not-found | The 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 |
| 401 | unauthenticated | The 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).
Reference
Section titled “Reference”- Descriptor keys: rules,
auth.roles,access,audit. - Management API:
SimulatePolicyAsync. - Problem types:
forbidden,out-of-scope,not-found,unauthenticated. - Design notes: the decision procedure
and
USING/WITH CHECKper operation.
Multi-tenancy: keep each customer’s rows apart in one database.