# 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`](/docs/app-page-composition) node's `guards` port:

```text
[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

```json
{ "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:

```json
{ "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:

```json
{ "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

```json
{ "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**:

```text
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:

```json
{ "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

```json
{ "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](/docs/api-authentication).
- **Cheapest rule first.** Rules run in order and stop at the first failure, so put
  `check_role` before 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_ref` rather 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](/docs/app-page-composition) — the routes these guards protect.
- [Publishing a Workflow as an App](/docs/app-publish-workflow) — the app they belong to.
- [Authentication](/docs/api-authentication) — enforcement below the UI.
