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
- id: <rule id>
kind: validation
enforcement: both # client | server | both (default)
# ...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.
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 }{
"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
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
serverorboth, and evaluate on the server before you save. - Putting secrets in
clientorbothrules. Everything a client rule reads (its params and messages) is sent to the browser. A list of blocked countries belongs in aserverrule. - Expecting
actionrules 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
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: [] }{
"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