Endpoint policies — Versola Docs
VersolaVersola/docs
Pre-1.00.7.1versola.kzGitHub

Endpoint policies

The per-endpoint rules edge evaluates with CEL (allow, inject, userinfo, step-up, max age): readable variables, worked examples and the status code for every failure.

Every endpoint you register on a resource can carry a policy that edge evaluates on each request: a boolean allow rule, inject rules that rewrite the forwarded request, and step-up requirements. The rules are written in CEL. This page lists the fields, the data a rule can read, the order of evaluation and what happens when something fails. You need a registered resource first (How to register a resource).

Policy fields

FieldTypeMeaning
allowCEL, booleanMust evaluate to true for the call to go on. Absent or blank: no check
injectlist of rulesEach rule: target (header, query or body), name, expression (CEL). The result replaces the client’s value of that name
fetchUserInfobooleanLoad the user’s claims from auth /userinfo before evaluating rules. Costs one extra call per request
stepUpConditionCEL, booleanWhen true, the token must carry an acr listed in stepUpAcr
stepUpAcrstringSpace-separated acr values. The token’s acr must equal one of them
maxAgeinteger, secondsThe token’s auth_time must be no older than this. Checked on its own, with or without a condition

Permissions are not part of the policy. A role or client permission must also grant the endpoint, and that check runs first (Permission).

What a rule can read

Every expression, allow, inject and stepUpCondition alike, sees the same three variables:

VariableTypeContent
tokenmapThe claims of the validated access token. Whole numbers are CEL int, other numbers double. Always present: sub, client_id, aud (list), tenant_id, roles (list), iat, exp, jti. Present when auth issued them: acr, auth_time, sid, cnf
usermapThe /userinfo claims. Empty unless fetchUserInfo is on
request.path.paramsmap of stringValues bound by the {name} segments of the endpoint path
request.querymap of stringFirst value of each query parameter
request.queryAllmap of list of stringAll values of each query parameter
request.headersmap of stringRequest headers, first value
request.headersAllmap of list of stringRequest headers, all values
request.bodymapThe parsed JSON body. Present only when Content-Type is application/json and the body is not empty

The language is standard CEL with its standard macros: comparison, logical operators, in, arithmetic, ?:, has(x.y), all, exists, exists_one, map, filter, string and list functions. Expressions are compiled once and cached. The console validates syntax and the result type when you save, so a syntax error or a non-boolean allow blocks the save.

A rule that reads something the request does not carry is not an error in CEL, it is missing data. request.body.total on a request without a JSON body, or user.plan on a user without that claim, is missing data. Guard optional values with has():

!has(request.query.draft) || "manager" in token.roles

Missing data in allow means 403. Use has() wherever the value is optional.

Order of evaluation

flowchart TD
    A["Request to /resources/{resourceId}/{path}"] --> B{"Token found?\nBearer, DPoP or EDGE_SESSION"}
    B -- no --> R401A["401"]
    B -- yes --> C{"Signature valid\nand not expired?"}
    C -- "expired, cookie" --> C2["Refresh with stored refresh token\nrotate cookie"]
    C -- "expired, header" --> R401B["401"]
    C -- "invalid" --> R401C["401"]
    C2 -- "refresh fails" --> R401D["401 + Location /login/{presetId}\ncookie cleared"]
    C2 --> D
    C -- yes --> D{"Revoked?\nin-memory list"}
    D -- yes --> R401E["401"]
    D -- no --> E{"Token binding OK?\nDPoP proof, cnf rules"}
    E -- no --> R401F["401 + WWW-Authenticate"]
    E -- yes --> F{"Resource and endpoint\nregistered?"}
    F -- no --> R404["404"]
    F -- yes --> G{"Role or client permission\ngrants this endpoint?"}
    G -- no --> R403A["403"]
    G -- yes --> H{"Audience covers\nthe resource?"}
    H -- no --> R403B["403"]
    H -- yes --> I["Fetch /userinfo\nonly if fetchUserInfo"]
    I --> J{"allow rule true?"}
    J -- "false or data missing" --> R403C["403"]
    J -- "rule broken" --> R500A["500"]
    J -- yes --> K{"Step-up needed?\nstepUpCondition, stepUpAcr, maxAge"}
    K -- yes --> R401G["401 + WWW-Authenticate\ninsufficient_user_authentication"]
    K -- "condition broken" --> R500B["500"]
    K -- no --> L["Evaluate inject rules\nheader, query, body"]
    L -- "data missing" --> R403D["403"]
    L -- "rule broken" --> R500C["500"]
    L --> M["Forward to the resource\nBasic credential or caller's Bearer token"]
  1. Token. Found, signature valid, not expired. An expired cookie token is refreshed, an expired header token is not.
  2. Revocation. Looked up in memory by token ID, refresh-token family, session and subject.
  3. Binding. DPoP proof, cnf rules (Sender-constrained tokens).
  4. Lookup. The resource, then the endpoint by method and path. A literal segment beats a {param} segment, so /users/me wins over /users/{userId}.
  5. Permission. A role of the caller grants the endpoint, or, for a service token (sub equals client_id), the client’s permissions do.
  6. Audience. aud holds resource://{resourceId} (or resource://edge) for an internal resource, or the resource’s absolute URI for a public one.
  7. Userinfo, only when fetchUserInfo is on.
  8. allow.
  9. Step-up. After allow on purpose: a caller with no right to the endpoint gets a plain 403 and never a re-authentication prompt that ends in the same denial.
  10. Inject, then forward.

Rules run on the data of this one request. Nothing is shared between requests, and allow and stepUpCondition cannot read each other’s result.

Examples

Allow by role or claim

GET /invoices with an optional draft filter. Drafts are for managers; everyone else with the permission sees finalised invoices.

FieldValue
Method, pathGET /invoices
allow!has(request.query.draft) || "manager" in token.roles

A claim that only userinfo carries needs fetchUserInfo on:

FieldValue
Method, pathGET /reports
Fetch userinfoon
allowhas(user.department) && user.department == "finance"

Inject the user’s identity

The upstream reads identity from headers and never parses a token.

TargetNameExpression
headerX-User-Idtoken.sub
headerX-Tenant-Idtoken.tenant_id

Whatever the client sent as X-User-Id is removed first, so the header cannot be spoofed. The same works for a query rule (name tenant, expression token.tenant_id) or a body rule (name userId, expression token.sub).

Path-parameter ownership

Users may read only their own orders.

FieldValue
Method, pathGET /users/{userId}/orders
allowrequest.path.params.userId == token.sub

Step-up for a sensitive endpoint

Transfers above a limit need a stronger authentication, and every transfer needs a login no older than ten minutes.

FieldValue
Method, pathPOST /payments/transfer
allowhas(request.body.amount) && request.body.amount > 0
stepUpConditionrequest.body.amount > 100000
stepUpAcrotp
maxAge600

stepUpAcr takes values from your tenant’s ACR vocabulary (Challenges & Security in the console). A request that fails either check gets:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="otp", max_age="600"

The challenge lists every unmet requirement, so one re-authentication fixes both. Your app sends the browser to /login/{presetId}?acr_values=otp&max_age=600 and retries after sign-in (RFC 9470).

To demand an acr on every call, set stepUpCondition to true. With a stepUpAcr and no condition, nothing is required. With a condition that is true and no stepUpAcr, only maxAge applies.

Failure semantics

SituationResponseLogged error code
No credential401token_missing
Bad signature, expired header token401token_invalid, token_expired
Expired cookie token that cannot be refreshed401 with Location: /login/{presetId} and the cookie clearednone
Revoked token401none
Binding failed401, WWW-Authenticate: DPoP error="invalid_dpop_proof" or use_dpop_nonce with DPoP-Nonce, or a bare 401dpop_*, certificate_bound_token_outside_session
Unknown resource, or no endpoint for this method and path404none
No permission for the endpoint403permission_denied
Audience does not cover the resource403audience_denied
auth rejects the token at /userinfo401user_info_unauthorized
/userinfo fails otherwise500none
allow is false403access_rule_denied
allow reads missing data403access_rule_denied, with a description
allow is broken: does not compile, runtime error, not a boolean500access_rule_failed
Step-up unmet401, WWW-Authenticate: Bearer error="insufficient_user_authentication"step_up_required
stepUpCondition reads missing datatreated as truenone
stepUpCondition is broken500step_up_condition_failed
inject reads missing data403inject_rule_denied
inject is broken500inject_rule_failed

Denials carry the bare status with no explanation; the code appears under the error key of the request’s log line and in the metrics. An inject rule that evaluates to null adds nothing, and the client’s value of that name is still removed.

Two limits to plan around. Inject values are strings: a number or boolean result is converted with toString. Body injection needs a JSON object body, and any other body is forwarded untouched without injection.

Set a policy in the console

  1. Open Resources, press + Create Resource, or open a resource and edit it.
  2. Press Add endpoint. Enter the method and the relative path, for example /users/{userId}/orders.
  3. Turn on Fetch userinfo if a rule reads user.
  4. Enter Allow (CEL). Leave it empty for no check.
  5. Under Inject, press Add rule and set Target, Name and Expression (CEL).
  6. Enter Step-up Condition (CEL), Step-up ACR and Max Auth Age (seconds).
  7. Save the resource. edge picks the change up within configuration-cache-refresh-interval.
The endpoint card with Allow (CEL), Step-up Condition (CEL), Step-up ACR, Max Auth Age and an Inject rule
Resource endpoint with Allow, step-up and Inject rule.
The saved policy, read-only: the Required Permission row shows which permissions cover the endpoint
Resource endpoint expanded with its CEL policy fields.

Saving the resource does not grant anyone access. Attach the endpoint to a permission, and the permission to a role, as in How to register a permission.

Next steps