Rule Cascade
LearnChannels and enforcement

Client and server

Decide where each rule runs with enforcement, and why the server always has the last word.

Every rule has an enforcement. It says on which channel the rule runs: in the browser (client), in your API (server), or in both (both, the default). The compiler builds two manifests from one ruleset. The client manifest holds only the client and both rules, and only the params and messages those rules need.

A client evaluation is advice for the user. A server evaluation is the decision. Your API always evaluates on the server, whatever the browser said, and takes actor from its own authentication.

Syntax

enforcement on a rule
- id: <rule id>
  kind: validation
  enforcement: both     # client | server | both (default)
  # ...
a golden test on the client channel
tests:
  - name: <name>
    entity: <Entity>
    operation: create
    channel: client      # server (default) | client
    given: { data: { } }
    expect: { decision: allow, findings: [] }

Example

The blocked-country rule is server only, so the browser never receives the list of countries. The request below runs on the client channel: the browser allows it.

client-and-server.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.channels, version: 1.0.0, title: Client and server }
scope:
  - { level: organization, id: learn }
entities:
  Order:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
params:
  blockedCountries:
    type: stringList
    default: ["KP", "IR"]
rules:
  - id: order.quantity.max
    kind: validation
    target: { entity: Order, field: /quantity }
    operations: [create]
    enforcement: both
    assert: { op: lte, args: [{ var: data.quantity }, 10] }
    severity: error
    finding: { code: LRN-CHN-001, message: order.tooMany }
  - id: order.country.blocked
    kind: validation
    target: { entity: Order, field: /country }
    operations: [create]
    enforcement: server
    assert: { op: not, args: [{ op: in, args: [{ var: data.country }, { var: params.blockedCountries }] }] }
    severity: error
    finding: { code: LRN-CHN-002, message: order.blocked }
  - id: order.notes.hint
    kind: validation
    target: { entity: Order, field: /notes }
    operations: [create]
    enforcement: client
    assert: { op: exists, args: [{ var: data.notes }] }
    severity: info
    finding: { code: LRN-CHN-003, message: order.addNotes }
messages:
  en:
    order.tooMany: "You can order at most 10 items."
    order.blocked: "We cannot ship to this country."
    order.addNotes: "Add delivery notes if the courier needs them."
tests:
  - name: the browser does not know about blocked countries
    entity: Order
    operation: create
    channel: client
    given:
      data: { quantity: 3, country: "KP" }
    expect:
      decision: allow
      findings:
        - { rule: order.notes.hint, severity: info, blocking: false }
  - name: the server denies the same request
    entity: Order
    operation: create
    channel: server
    given:
      data: { quantity: 3, country: "KP" }
    expect:
      decision: deny
      findings:
        - { rule: order.country.blocked, fields: [/country] }
  - name: both channels check the quantity
    entity: Order
    operation: create
    channel: server
    given:
      data: { quantity: 12, country: "FR" }
    expect:
      decision: deny
      findings:
        - { rule: order.quantity.max }
request.json (client channel)
{
  "entity": "Order",
  "operation": "create",
  "data": {
    "quantity": 3,
    "country": "KP"
  }
}

Result, from the engine

Decisionallow1 finding, client channel

  • LRN-CHN-003infonot blockingAdd delivery notes if the courier needs them./notes
Try it YourselfOpens this ruleset and request in the playground. Nothing to install.

Open it in the playground and switch the channel to server. The same request is denied by order.country.blocked. The hint about notes is client only, so the server never reports it.

Common mistakes

  • Trusting the client. A user can change anything in a browser. Keep every rule that protects data on server or both, and evaluate on the server before you save.
  • Putting secrets in client or both rules. Everything a client rule reads (its params and messages) is sent to the browser. A list of blocked countries belongs in a server rule.
  • Expecting action rules on the client. Commands only come from the server channel.

Exercise

Make the quantity check advice only: it should run in the browser and never on the server. Write two golden tests with 12 items: one on the client channel (denied) and one on the server channel (allowed).

Hint

Set enforcement: client on order.quantity.max. Then test 12 items on both channels.

Show answer
client-and-server.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.channels, version: 1.0.0, title: Client and server }
scope:
  - { level: organization, id: learn }
entities:
  Order:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
params:
  blockedCountries:
    type: stringList
    default: ["KP", "IR"]
rules:
  - id: order.quantity.max
    kind: validation
    target: { entity: Order, field: /quantity }
    operations: [create]
    enforcement: client
    assert: { op: lte, args: [{ var: data.quantity }, 10] }
    severity: error
    finding: { code: LRN-CHN-001, message: order.tooMany }
  - id: order.country.blocked
    kind: validation
    target: { entity: Order, field: /country }
    operations: [create]
    enforcement: server
    assert: { op: not, args: [{ op: in, args: [{ var: data.country }, { var: params.blockedCountries }] }] }
    severity: error
    finding: { code: LRN-CHN-002, message: order.blocked }
  - id: order.notes.hint
    kind: validation
    target: { entity: Order, field: /notes }
    operations: [create]
    enforcement: client
    assert: { op: exists, args: [{ var: data.notes }] }
    severity: info
    finding: { code: LRN-CHN-003, message: order.addNotes }
messages:
  en:
    order.tooMany: "You can order at most 10 items."
    order.blocked: "We cannot ship to this country."
    order.addNotes: "Add delivery notes if the courier needs them."
tests:
  - name: twelve items are only advice in the browser
    entity: Order
    operation: create
    channel: client
    given:
      data: { quantity: 12, country: "FR", notes: "Ring twice" }
    expect:
      decision: deny
      findings:
        - { rule: order.quantity.max }
  - name: the server no longer checks the quantity
    entity: Order
    operation: create
    channel: server
    given:
      data: { quantity: 12, country: "FR", notes: "Ring twice" }
    expect: { decision: allow, findings: [] }
request.json (client channel)
{
  "entity": "Order",
  "operation": "create",
  "data": {
    "quantity": 12,
    "country": "FR",
    "notes": "Ring twice"
  }
}

Result, from the engine

Decisiondeny1 finding, client channel

  • LRN-CHN-001errorblockingYou can order at most 10 items./quantity
Course overview

On this page