Authorization
gate.policy and gate.scope — reusable permission checks composed with gate.all and attached via .gate.
Authorization answers “may this principal do this?” after identity is known. Declare reusable
gate.policy / gate.scope handles, compose them with gate.all, and attach the chain on the
trigger.
For developers enforcing RBAC / ABAC on okengine — name the check once, reuse it on every route.
The one rule
Policies receive GatePolicyContext (auth, operator, optional meta) — never invent
ctx.user or ctx.store. World access stays inside do via fx.
Smallest Example
Declare policies and scopes
import { gate } from "okengine";
/** Signed-in member. */
export const member = gate.policy("member", {
check: ({ auth }) => !!auth.verified,
});
/** Holds the notes:write scope (name is the scope string). */
export const notesWrite = gate.scope("notes:write");
/** Admin-only ABAC example. */
export const adminOnly = gate.policy("adminOnly", ({ auth }) => auth.scopes.has("admin"));gate.scope(name) is shorthand for
gate.policy(name, ({ auth }) => auth.scopes.has(name)) with scopes: [name] recorded.
Compose and attach
import { on, flow, http, gate } from "okengine";
import { member, notesWrite } from "@/core/gate";
export const notesMutate = gate.all(member, notesWrite);
export const create = on(
http.post().gate(notesMutate),
// or: .gate(member, notesWrite)
flow({
do: async ({ title }, fx) => fx.json.create({ id: fx.id(), title }),
}),
);See the denial
Missing notes:write on an authenticated caller:
{
"data": null,
"error": {
"code": "Forbidden",
"message": "You are not allowed to perform this action.",
"data": { "gate": "notes:write", "reason": "policy denied" }
}
}Anonymous callers denied earlier in the chain get Unauthorized instead.
Progressive Patterns
From a single scope to Module:Action names, composition, and operator plane:
Prefer gate.scope when the check is exactly “has this scope string”:
export const bookingCreate = gate.scope("booking:create");
export const notesRead = gate.scope("notes:read");Names containing : are Module:Action pairs — extracted into Manifest permissions for Console.
Policy Context
| Field | Type | Meaning |
|---|---|---|
auth.userId | string | null | User-plane principal |
auth.scopes | ReadonlySet<string> | Granted scopes (may include tenant-role union) |
auth.verified | boolean | undefined | Credential verified |
auth.apiKeyId | string | null | undefined | API-key principal when present |
operator.id | string | null | Operator-plane principal |
meta.ip / userId | optional | Subject dims for rate keyBy |
Policies must not touch the store, vault, or network — those belong in do via fx.
Declaration Forms
| Form | Example | Notes |
|---|---|---|
| Predicate | gate.policy("member", ({ auth }) => !!auth.verified) | Shortest |
| Options | gate.policy("member", { check, description? }) | Console / docs label |
| Scope shorthand | gate.scope("notes:write") | Records scopes: ["notes:write"] |
| Public sentinel | gate.public | Always allows; reserved name |
| Chain | gate.all(a, b, c) | Flattened at attach time |
Reserved: gate.policy("public", …) and gate.scope("public") throw — use gate.public.
Module:Action Permissions
Detailed section
Scopes with a colon (notes:write) are Module:Action pairs. Manifest + Console derive the
permission catalog from Flows, effects, and gate scopes — you do not hand-maintain a second list.
| Source | Example pair |
|---|---|
Flow id notes.create | notes:create |
gate.scope("booking:create") | booking:create |
Effect reads: ["sql:notes"] | store.sql:read |
| Operator-plane Flow | also console:… when applicable |
Consequence: prefer gate.scope("notes:write") over a one-off policy with the same string —
the scope is recorded on the declaration for Manifest / Console.
Tenant roles may grant application scopes only (notes:write), not console:*. See
Tenancy.
Attaching on Resources & Live
Chain once on the mount — every verb (and live, when present) inherits the same gates:
import { on, http } from "okengine";
import { member, notesWrite } from "@/core/gate";
import { notesResource } from "./resource";
export const notes = on(http.resource("/notes", notesResource.all()).gate(member, notesWrite));Live firehoses use the same .gate(...) fluent:
on(http.live(orderStatus).gate(member));See HTTP · Resources and HTTP · Live Streams.
Denial Mapping
| Situation | Code | Status | error.data |
|---|---|---|---|
Policy denied, no auth.userId | Unauthorized | 401 | {} |
| Policy denied, principal present | Forbidden | 403 | { gate, reason } |
| Unknown gate name at runtime | deny | — | reason: "unknown gate: …" |
reason is "policy denied" for failed predicates (unless a rate gate burned earlier).
Troubleshooting
A later gate in the chain failed — read error.data.gate. Confirm the session / key actually
carries that scope (fx.auth.scopes), including tenant-role unions when tenancy is on.
Check for !!auth.verified vs auth.userId !== null. Some principals have a userId before
verification completes — pick the predicate that matches your product rule.
Cause: gate.policy: name "public" is reserved — use gate.public for intentionally unauthenticated surfaces (or the gate.scope variant). Rename the policy or use .public().
Pass one or more policy / rate / nested all handles — empty gate.all() is invalid.
Tenant-role scopes union into fx.auth.scopes only when the Flow is tenant-scoped (default when
tenancy is on). A Flow with tenantScoped: false keeps session scopes only.
Learn more
- Authentication — how
fx.authis filled - RLS — row policies from the same Gate identity
- Rate Limits — throttle on the same chain
- Tenancy — tenant-role scope union
- HTTP —
.gate(...)on triggers