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:

ActionEffect
BlockDeny, with an optional custom message
LogRecord at Info, Warn, or Error
WebhookPOST to a callback URL
MetricRecord 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

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