Page Access Control & RBAC Guard
Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source:
crates/cheng-nodes/src/nodes/builtin/ui/rbac_guard.rs,crates/cheng-nodes/src/nodes/rbac_guard/,crates/cheng-nodes/src/nodes/builtin/ui/common/types.rs,chengflow-ui/src/features/rbac-guard
ui/rbac_guard decides whether a user may reach a route. It outputs a GuardResult, which
connects to a ui/route node's guards port:
[ui/rbac_guard] ──guard──► [ui/route].guards
Because the guard is a node, its rules are part of the workflow — versioned, reviewable, and visible on the canvas rather than buried in configuration.
Three tiers of rules
The rule set is deliberately layered, and picking the right tier matters: the higher tiers are more expressive and also more expensive.
Tier 1 — built-in checks
{ "type": "check_role", "label": "Must be HRBP",
"roles": ["hrbp", "admin"], "mode": "Any" }
{ "type": "check_perm", "label": "Can edit users",
"permission": "user:edit" }
mode is Any (OR, the default) or All (AND). check_perm takes a single permission
identifier such as user:edit or order:delete.
label is a display alias — it appears in the failure message, so a denied user gets "Rule
'Must be HRBP' failed" rather than an index.
Tier 2 — attribute matching
Covers most generic checks:
{ "type": "match_attr", "label": "Engineering only",
"attribute": "user.dept", "operator": "Eq", "value": "engineering" }
{ "type": "match_attr",
"attribute": "user.level", "operator": "Gte", "value": 5 }
{ "type": "match_attr",
"attribute": "request.ip", "operator": "In", "values": ["10.0.0.1", "10.0.0.2"] }
Operators: Eq, Ne, Gt, Lt, Gte, Lte, Contains, In, NotIn.
Use value for single comparisons and values for In / NotIn.
Tier 3 — expressions
For the remainder:
{ "type": "expression", "label": "Senior, business hours",
"expr": "user.level > 5 && time.hour < 20", "timeout_ms": 100 }
The expression engine is sandboxed: only whitelisted variables are exposed, compiled
expressions are cached (up to 1000, one-hour TTL), and every evaluation is timeout-protected —
100 ms by default. Time-based variables such as hour and weekday are available.
The sandbox is why this is safe to expose in a workflow: an expression cannot reach the filesystem, the network, or anything not on the whitelist.
Rule references
{ "type": "rule_ref", "ref_id": "common_login_check",
"override_fail": [{ "action_type": "Block" }] }
A rule_ref expands a named, reusable rule set. Expansion has cycle detection, so a rule
set that references itself fails loudly instead of recursing. override_fail replaces the
template's own failure policy for the expanded rules.
Extract a rule set as soon as the same three rules appear on a second route.
The context
Rules read a context whose core — user, request, route — is read-only:
user.id, user.name, user.roles[], user.permissions[],
user.dept, user.level, user.is_verified, user.attributes{}
request.… route.…
user.attributes is the escape hatch for anything your identity system carries that the fixed
fields do not cover; reach it with match_attr on user.attributes.<key>.
Failure policy
Rules run in order, and the first failure triggers on_fail — a chain of actions, not
a single one:
| Action | Effect |
|---|---|
Block | Deny, with an optional custom message |
Log | Record at Info, Warn, or Error |
Webhook | POST to a callback URL |
Metric | Record a metric |
The default policy is a single Block with no message.
Because it is a chain, the useful pattern is to observe and deny:
{ "on_fail": [
{ "action_type": "Log", "level": "Warn" },
{ "action_type": "Metric", "name": "rbac.denied" },
{ "action_type": "Block", "message": "You need the HRBP role to view this page." }
] }
redirect_on_deny sends the user somewhere useful — a login page or an access-request form —
instead of a dead end.
The result
{ "allow": false,
"redirect": "/login",
"reason": "Rule 'Must be HRBP' (#0) failed: user lacks required role",
"guard_type": "rbac" }
reason names the rule that failed and its index, which is what makes a denial debuggable
without turning on trace logging.
Design guidance
- Guards protect routes, not data. A guard stops a page from rendering; it does not stop the API underneath from answering. Enforce data access in the API layer as well — see Authentication.
- Cheapest rule first. Rules run in order and stop at the first failure, so put
check_rolebefore an expression: a role check is a list comparison, an expression is a compile-and-evaluate. - Label every rule. The label is what the user and the log see.
- Prefer tier 1 and 2. Reach for an expression only when the condition genuinely combines several attributes; a wall of expressions is a rule set nobody can audit.
- Extract shared rule sets with
rule_refrather than copying rules across routes.
The frontend also has an RBAC guard layer (src/features/rbac-guard/) that gates UI by role and
permission, with auth state in the auth context. That layer and this node should agree: the node
is the authority, and the frontend layer is there so users are not shown doors that will slam.
Next steps
- Page Nodes & Layout — the routes these guards protect.
- Publishing a Workflow as an App — the app they belong to.
- Authentication — enforcement below the UI.