# @pleach/rbac-contract (/docs/rbac-contract)



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](/docs/cycle-break-contract-packages) —
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 [#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.
* `CheckPermissionInput` — `actorId`, optional `actorType`, `action`,
  optional `resource`, optional free-form `context`.
* `PermissionDecision` — `allowed`, 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.

```typescript
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 [#install]

```bash
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 [#the-adapter--and-what-it-doesnt-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:

```typescript
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.

```ts
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 [#where-to-go-next]

<Cards>
  <Card title="Cycle-break contract packages" href="/docs/cycle-break-contract-packages" description="The pattern @pleach/rbac-contract and @pleach/compliance-contract both follow, and when to extract a new one." />

  <Card title="Plugin contract" href="/docs/plugin-contract" description="The full HarnessPlugin surface, including contributeApprovalFlow and every other extension seam." />

  <Card title="Packages" href="/docs/packages" description="The full @pleach/* SKU matrix." />
</Cards>
