pleach
Build

@pleach/rbac-contract

Zero-dependency type contract for role-based access control — the RbacRuntime interface @pleach/core types its rbacRuntime? slot against, plus the plugin-hook adapter that wires it in.

A latch with no gate built to hang it on yet: @pleach/rbac-contract defines the shape of a permission check before any host has wired one in. It's the second package to use the cycle-break contract pattern — same reasoning as @pleach/compliance-contract, applied to authorization instead of PII/PHI compliance. @pleach/core types SessionRuntimeConfig.rbacRuntime?: against this contract so it takes no type-graph edge on whatever concrete authorization service a host plugs in.

What it provides

  • The RbacRuntime interface — two methods, checkPermission and getRolesForActor. Deliberately smaller than ComplianceRuntime's five: this contract was authored fresh, with no shipped implementer yet, so it stays scoped to what a pre-execution authorization gate actually needs rather than speculating ahead of a real consumer.
  • CheckPermissionInputactorId, optional actorType, action, optional resource, optional free-form context.
  • PermissionDecisionallowed, optional reason, optional roles.
  • RBAC_RUNTIME_UNCONFIGURED_SENTINEL — the one runtime export (a literal string), for hosts that want a grep-able marker on a decision made with no RbacRuntime configured.
interface RbacRuntime {
  checkPermission(input: CheckPermissionInput): Promise<PermissionDecision>;
  getRolesForActor(actorId: string): Promise<readonly string[]>;
}

Ships zero runtime and zero peer dependencies. Every export is a TypeScript type or the one sentinel constant.

Install

npm install @pleach/rbac-contract

Most consumers never install this directly — @pleach/core depends on it and re-exports the type for slot-typing purposes. Install it explicitly only when authoring an RbacRuntime implementation without pulling in a specific implementer package.

The adapter — and what it doesn't do yet

@pleach/core/rbac ships createRbacApprovalFlow(rbacRuntime, resolveActor), which adapts an RbacRuntime into ApprovalFlowContribution — the existing pre-execution checkApprovalNeeded plugin hook, the same one @pleach/core already reserves for cost/risk gating. No new hook was added for this.

Read this before wiring it into anything that needs to actually enforce a boundary: checkApprovalNeeded has no shipped consumer in the reference host today. Registering createRbacApprovalFlow's output via contributeApprovalFlow() wires a plugin hook that nothing currently calls — on its own, it does not gate tool execution. It demonstrates the intended integration shape for the day a host opts into the hook-routed gateway, or for calling rbacRuntime.checkPermission() directly from your own enforcement path in the meantime.

There's a second, structural reason not to treat it as a hard denial today: ApprovalRequest (the hook's non-null return type) has no verdict field — it's a pre-execution question a human resolves, not an answer. Every resolution path lets a human edit the arguments and approve anyway, so a denial routed through this adapter is human-overridable by construction. resolveActor classifies every call into one of three outcomes rather than a bare actor | null, so "intentionally ungated" and "couldn't identify the caller" don't collapse into the same auto-approve:

type RbacActorResolution =
  | { kind: "actor"; actor: { actorId: string; actorType?: string } }
  | { kind: "skip" }          // no end-user in the loop — auto-approves
  | { kind: "unresolvable" }; // should be identifiable, resolution failed — DENIES

A policy-check failure denies too — checkPermission throwing is not treated as an implicit grant.

import { createRbacApprovalFlow } from "@pleach/core/rbac"
import { definePleachPlugin } from "@pleach/core/plugins"
import type { RbacRuntime } from "@pleach/rbac-contract"

const rbac: RbacRuntime = {
  async checkPermission({ actorId, action, resource }) {
    const allowed = !(action === "tool:execute" && resource === "delete_database")
    return { allowed, reason: allowed ? undefined : `${actorId} lacks ${action} on ${resource}` }
  },
  async getRolesForActor() {
    return ["member"]
  },
}

const rbacPlugin = definePleachPlugin({
  name: "rbac",
  version: "1.0.0",
  contributeApprovalFlow: () =>
    createRbacApprovalFlow(rbac, async (toolCall) => {
      const actorId = await resolveCurrentRequestActorId() // your own lookup
      return actorId
        ? { kind: "actor", actor: { actorId, actorType: "user" } }
        : { kind: "unresolvable" }
    }),
})

Where to go next

On this page