aster.Web Data / Docs Aster UI ↗

Aster Web Data / Build an API

Rules

State validation and access checks once, in a YAML file beside the model, and the server applies them to every request.

An approved expense must keep its amount. Written as code, that check has to go into every endpoint that writes expenses, and the one that misses it is the one that lets the amount change. A rule states it once, beside the model:

rules:
  - id: ApprovedAmountLocked
    entity: Expense
    host: {Property: amount}
    kind: ReadOnlyWhen
    condition: "status eq 'approved'"
    message: An approved expense keeps its amount

The server checks the rule on every write to Expense, whichever request makes it. A PATCH that changes the amount of an approved expense answers 400 with the code ReadOnly and the target amount, and a change to its title still saves. The condition is $filter text, the same language clients query with.

The rules file

A service names its rules file with rules: rules.yaml. This one gives Expense, a set with an id, a title, an amount and a status, three rules: who may read it, the locked amount, and a check that no expense is negative:

rules:
  # Who may read expenses: the blue team (a JWT claim), auditors, or a
  # request from the acme tenant (an allow-listed header).
  - id: ExpenseReaders
    entity: Expense
    host: EntitySet
    kind: {RequiresWhen: Read}
    condition: "AsterData.Claim('team') eq 'blue' or AsterData.HasRole('auditor') or AsterData.Header('X-Tenant') eq 'acme'"
  - id: ApprovedAmountLocked
    entity: Expense
    host: {Property: amount}
    kind: ReadOnlyWhen
    condition: "status eq 'approved'"
    message: An approved expense keeps its amount
  - id: NonNegativeAmount
    entity: Expense
    host: Entity
    kind: Constraint
    condition: "amount ge 0"
    message: An expense cannot be negative

Each rule has these keys:

Key Meaning
id The rule's name. A simple identifier, unique for its kind. It appears in $metadata and in the error a refused request returns.
entity The set the rule belongs to.
host What the rule applies to: EntitySet for a gate on the whole set, {Binding: operationId} for a gate on one operation, Entity for a check across several properties, {Property: name} for one property.
kind What the rule does, described below.
condition A boolean condition, written as $filter text.
message The text a failed check returns. Gates do not return a message.

A rule that does not compile, or that names a field the set does not have, stops the server from starting, and the error names the rule.

Validation rules

Constraint, ReadOnlyWhen and RequiredWhen check a write before it is saved. A write that breaks one answers 400 Bad Request with the code ValidationFailed. The details list each failed Constraint or RequiredWhen rule with its id as the detail's code and the message the rule gives. A field locked by ReadOnlyWhen is listed with the code ReadOnly and the property as its target.

The shop service that the examples below use has two such rules on Invoice:

rules:
  - id: PositiveAmount
    entity: Invoice
    host: Entity
    kind: Constraint
    condition: "amount gt 0"
    message: An invoice amount must be more than zero
  - id: PaidInvoiceLocked
    entity: Invoice
    host: {Property: amount}
    kind: ReadOnlyWhen
    condition: "paid eq true"
    message: A paid invoice keeps its amount

An insert with a negative amount breaks PositiveAmount. The detail's code is the rule's id:

POST /shop/Invoice
Content-Type: application/json
{
  "customerId": 1,
  "issued": "2026-10-01",
  "amount": -20.00,
  "paid": false
}
As a URLPOST /shop/Invoice
HTTP 400
content-language: en
content-type: application/json;odata.metadata=minimal
odata-version: 4.01
{
  "error": {
    "code": "ValidationFailed",
    "message": "An invoice amount must be more than zero",
    "details": [
      {
        "code": "PositiveAmount",
        "message": "An invoice amount must be more than zero"
      }
    ]
  }
}

An update of the amount of a paid invoice breaks PaidInvoiceLocked. The detail's code is ReadOnly and its target is the property:

PATCH /shop/Invoice(1)
Content-Type: application/json
If-Match: *
{
  "amount": 1.00
}
As a URLPATCH /shop/Invoice(1)
HTTP 400
content-language: en
content-type: application/json;odata.metadata=minimal
odata-version: 4.01
{
  "error": {
    "code": "ValidationFailed",
    "message": "A paid invoice keeps its amount",
    "details": [
      {
        "code": "ReadOnly",
        "message": "A paid invoice keeps its amount",
        "target": "amount"
      }
    ]
  }
}

The rules run inside the write's transaction, so a failed check saves nothing.

Checking a record without writing it

A bound action checks a record against the set's validators and rules and writes nothing. Use it before a save, for example to show errors while a user edits a form:

POST /odata/Product/AsterData.Validate
Content-Type: application/json

{"Record": {"name": "X", "code": "OK"}}

The answer is 204 No Content when the record passes. A record that fails answers 400 with the same details a save would return.

Conditions

A condition is $filter text. It can use the properties of the record, the properties of a related row through a navigation, and the request facts described below:

Which record an unprefixed name reads depends on the rule's kind and the operation. The ApprovedAmountLocked rule above is checked against the stored expense on an update, so its status is the status the record already has.

Rule kind Insert Update Delete Read
ReadOnlyWhen the submitted record the stored record not checked not checked
RequiredWhen, Constraint the submitted record the stored record with the changes applied not checked not checked
RequiresWhen the submitted record the stored record the stored record the request facts only
ViewableWhen the row returned the row returned not checked the row returned

To read a particular record whatever the operation, name it: AsterData.Stored(status), AsterData.Submitted(status) or AsterData.Merged(status).

Access rules

A gate decides whether a caller may use a set at all. RequiresWhen on the set is checked at every read of it, including a read inside an $expand and the read that answers a write. A read gate reads only the request facts, not the fields of a row. A caller the gate refuses gets 403 Forbidden, with a message that does not say which part of the condition failed.

GET /odata/Expense?$orderby=id
X-Tenant: acme

With the ExpenseReaders rule above, a request that carries X-Tenant: acme reads the expenses. The same request without the header, from a caller with no claim and no role, answers 403.

A refused read does not stop a write. A caller who fails the read gate may still insert into the set, as a write without read. Gates for the other writes are separate RequiresWhen rules, with the kind named: Insert, Update or Delete.

A gate can also apply to one operation. {Binding: operationId} applies to the requests that run that operation, and replaces the set's gate of the same operation for them.

Access rules work alongside the per-set access in Authentication and access. The set's configuration says who may read or write at all, and a gate adds a condition over the caller, the request or, for a write, the record.

Hiding a field

A ViewableWhen rule hides a property for the records that match it. A hidden property reads as null, and the entity lists it in @AsterData.Hidden. A write that names it is refused with ReadOnly.

VisibleWhen is a rule for the user interface. It is published in $metadata for clients to act on, and the server does not enforce it.

Request facts

Rules can read facts about the request itself: the caller, the time, the address, and the headers the operator allows. A header is readable only when the configuration lists it, so a client cannot make a rule read one the operator did not expose:

request_facts:
  headers: [X-Tenant]
  client_address_header: X-Forwarded-For   # set only when a proxy you trust sets it

With that configuration, the rules can read these facts:

Function Value
AsterData.Header('X-Tenant') The header's value. Any other header reads as null.
AsterData.ClientAddress() The client's address, from the connection, or from the configured header behind a proxy.
AsterData.RequestTime() The time the request arrived.
AsterData.UserId() The caller's user ID.
AsterData.HasRole('auditor') Whether the caller has the role.
AsterData.Claim('team') The value of a token claim.
AsterData.Operation() The operation the request is running.
AsterData.WasSent(field) Whether the request's body sent the property.

Set client_address_header only when a proxy you trust sets that header.

Next