# Warlock Access — full skills

> Package: `@warlock.js/access`

> Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/access/skills/`. Re-run `node scripts/generate-llms.mjs` after any change.

## check-permissions  `@warlock.js/access/check-permissions/SKILL.md`

---
name: check-permissions
description: 'Check permissions in `@warlock.js/access` — `can` / `cannot` / `canAll` / `canAny` (boolean), `authorize` / `authorizeAll` / `authorizeAny` (throw 403), and the `gate` / `gateAny` / `gateAll` route middleware. Class-level vs instance-level (pass a `resource` to run its policy). TRIGGER: `can(`, `authorize(`, `canAll`, `canAny`, `gate`, `gateAny`, `gateAll`, "check a permission", "protect route by permission", "403 forbidden", "any vs all permissions". Skip: ownership / tenant conditions — `@warlock.js/access/define-policies/SKILL.md`; role checks — `@warlock.js/access/manage-roles/SKILL.md`.'
---

# Check permissions

## In a route — class-level gate

Stack it AFTER `authMiddleware` (which sets `request.user`):

```ts
import { authMiddleware } from "@warlock.js/auth";
import { gate } from "@warlock.js/access";

router.post("/orders", createOrder, {
  middleware: [authMiddleware([]), gate("orders.create")],
});
```

A missing permission → `403` before your controller runs. Use `gateAny([...])` / `gateAll([...])` for sets.

## In code — boolean

```ts
import { can, cannot, canAll, canAny } from "@warlock.js/access";

if (await can(user, "orders.update")) { /* … */ }

await canAll(user, ["orders.update", "orders.viewCost"]); // needs BOTH
await canAny(user, ["orders.update", "orders.updateStatus"]); // needs EITHER
```

> **Multi-permission checks are named, never defaulted.** Use `canAll` / `canAny` (and `gateAll` / `gateAny`). There is no `can(user, [array])` — a wrong implicit any/all default is a silent privilege-escalation or a lockout.

## In a service — throw

```ts
import { authorize, authorizeAll } from "@warlock.js/access";

await authorize(user, "orders.update"); //              throws ForbiddenError (403, EC100) on deny
await authorizeAll(user, ["orders.update", "orders.viewCost"]);
```

## Instance-level — "this resource"

Pass a `resource` and the permission's policy runs on top of the grant (see [`define-policies`](@warlock.js/access/define-policies/SKILL.md)):

```ts
const order = await Order.find(orderId);

await authorize(user, "orders.update", { resource: order, tenant }); // grant AND policy
```

- `gate` is **class-level only** — no resource, runs before the controller ("can they update orders at all?").
- `authorize(…, { resource })` is **instance-level** — after you load the row ("can they update THIS order?").

## Wildcards

`*` grants everything; `orders.*` covers `orders.update` and any nested `orders.update.status` — but **not** the bare `orders` (the prefix needs a trailing `.`, so granting `orders.*` and checking `orders` is silently denied). Give a role `["*"]` to make it a super-admin.

## Gotchas

- **Fails closed, but config errors are loud.** A resolver or policy that throws → denied and logged (a user with no roles is denied, never allowed by accident). The one exception: `AccessConfigError` (e.g. no resolver configured) is **re-thrown**, not denied — a misconfig must surface, not hide. A cache outage is different again: it degrades to the resolver (the cache fails *open*; the decision still fails closed).
- Stack `gate` **after** `authMiddleware` — it reads `request.user`; without an authenticated user it `403`s.
- The boolean `can*` family never throws; the `authorize*` family throws `ForbiddenError`. Pick per layer (controllers gate with middleware, services assert with `authorize`).

## See also

- [`@warlock.js/access/define-policies/SKILL.md`](@warlock.js/access/define-policies/SKILL.md) — the `{ resource }` conditions.
- [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware`, which runs first.


## configure-access  `@warlock.js/access/configure-access/SKILL.md`

---
name: configure-access
description: 'Configure `@warlock.js/access` — the REQUIRED `resolver` (the quickstart `DefaultAccessResolver` over a fixed code map vs the ejected, DB-backed `DatabaseAccessResolver`), `cache: { ttl }`, and the ambient tenant via the resolver''s optional `resolveTenant()`. TRIGGER: `AccessConfigurations`, `config.access`, `src/config/access.ts`, "set up permissions", "DefaultAccessResolver", "DatabaseAccessResolver", "access cache ttl", "access tenant". Skip: checking permissions — `@warlock.js/access/check-permissions/SKILL.md`; custom storage — `@warlock.js/access/implement-resolver/SKILL.md`.'
---

# Configure access

The resolver is the **one required piece**. It tells the engine how to read a user's roles + permissions. There is no separate `roles` config map — the role→permission catalog lives INSIDE the resolver.

## Quickstart — a fixed, code-defined catalog

`DefaultAccessResolver` reads a user's roles from `user.get("roles")` (or a single `user.get("role")`) and maps them to permissions through a code map. No tables, no migrations.

```ts title="src/config/access.ts"
import { type AccessConfigurations, DefaultAccessResolver } from "@warlock.js/access";

const access: AccessConfigurations = {
  resolver: new DefaultAccessResolver({
    owner: ["*"], //                                 `*` is the super-grant
    editor: ["orders.*", "posts.create"], //         `orders.*` covers orders.update, orders.view, …
    viewer: ["orders.view"],
  }),
};

export default access;
```

`can(user, "orders.update")` now works for any user whose `roles` column includes `editor`.

> `DefaultAccessResolver` is **not tenant-aware** — it returns the same roles in every tenant. For per-tenant roles, use the dynamic resolver below.

## Real apps — dynamic, DB-backed roles

`npx warlock add access` ejects a `DatabaseAccessResolver` (plus the `Role` + `UserRole` tables) into `src/app/access/`. Roles come from the `user_roles` table (multi-role, tenant-aware); permissions are joined through the `roles` catalog table — so admins manage both at runtime, in the DB.

```ts title="src/config/access.ts"
import { type AccessConfigurations } from "@warlock.js/access";
import { DatabaseAccessResolver } from "app/access/services/access-resolver";

const access: AccessConfigurations = {
  resolver: new DatabaseAccessResolver(),
};

export default access;
```

Assign roles via the ejected `UserRole.assign(user, "editor", tenant)` — it flushes that user's cached set for you (see [`manage-roles`](@warlock.js/access/manage-roles/SKILL.md)).

## The full shape

```ts
type AccessConfigurations = {
  resolver: AccessResolver; //          REQUIRED — reads roles + permissions (+ optional tenant)
  cache?: {
    ttl?: string | number; //           resolved-set TTL, default "10m"
  };
};
```

A missing resolver throws `AccessConfigError` **at boot** — the framework's access connector wires `src/config/access.ts` (the same way the notifications connector wires `config/notifications.ts`), so a misconfig fails at startup, never a silent deny.

## Tenant

Checks accept an explicit tenant (`can(user, "x", { tenant })`), but add a `resolveTenant(user)` to your resolver so you never pass it on the happy path. Derive the tenant from the **user**, not from request input — a client-supplied `organizationId` is attacker-controlled and would let a user check (or act) across tenant boundaries:

```ts
public resolveTenant(user: Auth): string | undefined {
  return user.get("organization_id");
}
```

## Caching

The engine caches each user's resolved set (`cache.ttl`, default `10m`). Roles changed out of band? Call `access.flush(user, tenant)`. The cache is **best-effort**: an outage degrades to the resolver, it never denies.

## See also

- [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — write a resolver for your own storage.
- [`@warlock.js/access/manage-roles/SKILL.md`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke with the ejected table.
```


## define-policies  `@warlock.js/access/define-policies/SKILL.md`

---
name: define-policies
description: 'ABAC conditions in `@warlock.js/access` — `definePolicy(permission, (user, resource, ctx) => boolean)` adds an instance-level rule (ownership / tenant / state) on top of the RBAC grant, evaluated when an authorization check carries a `resource`. TRIGGER: `definePolicy`, "ownership check", "only their own", "can edit this specific record", "ABAC", "policy", "resource-level permission", `authorize(user, perm, { resource })`. Skip: plain grant checks — `@warlock.js/access/check-permissions/SKILL.md`.'
---

# Define policies (ABAC)

RBAC says "can update orders". A **policy** adds "...but only THIS order" — the instance-level condition.

## Register a condition

Policies live **per module**, in `src/app/<module>/policies/`, and are loaded by an `import "./policies";` side-effect in that module's `main.ts` — a policy defined in an unimported file silently never registers.

```ts title="src/app/orders/policies/index.ts"
import { definePolicy } from "@warlock.js/access";

definePolicy("orders.update", (user, order, ctx) =>
  order.get("organization_id") === ctx.tenant &&
  (order.get("customer_id") === user.id || ctx.hasRole("manager")),
);
```

```ts title="src/app/orders/main.ts"
import "./policies"; // register this module's policies at boot
```

## It runs ONLY on an instance check

```ts
await authorize(user, "orders.update"); //                     class-level → grant only, policy SKIPPED
await authorize(user, "orders.update", { resource: order }); // instance → grant AND policy
```

So `gate("orders.update")` (a route gate, no resource) checks the grant; the per-order rule runs in your service, after you load the order.

## The decision = grant AND policy

- No grant → denied (the policy never runs).
- Grant + no policy registered → allowed.
- Grant + policy → the policy decides.

Policies **deny**, they don't grant — a policy can't let a user past a permission they don't hold.

## The policy context

`(user, resource, ctx)`, where `ctx` carries:

- `tenant` — the resolved tenant.
- `hasRole(role)` / `hasPermission(perm)` — engine-injected helpers.
- any extra keys you passed on the check:

```ts
await authorize(user, "orders.refund", { resource: order, amount: 5000 });

definePolicy("orders.refund", (user, order, ctx) =>
  ctx.hasRole("manager") || (ctx.amount as number) <= 1000,
);
```

## Gotchas

- Policies are global by permission name — define each once at boot.
- A throwing policy is treated as a **denial** (fail-closed) and logged.
- Need "own resource" at graph scale (deep relationships)? That's ReBAC — out of scope; a policy covers the common ownership case.

## See also

- [`@warlock.js/access/check-permissions/SKILL.md`](@warlock.js/access/check-permissions/SKILL.md) — `authorize(…, { resource })`.


## implement-resolver  `@warlock.js/access/implement-resolver/SKILL.md`

---
name: implement-resolver
description: 'Connect `@warlock.js/access` to your role/permission storage by implementing the `AccessResolver` contract (`resolveRoles` / `resolvePermissions`, optional `resolveTenant`) — for a DB-backed catalog, a user column, a pivot table, a token claim, or an external directory. The engine owns matching / caching / policies; the resolver only fetches. TRIGGER: `AccessResolver`, `DatabaseAccessResolver`, `resolveTenant`, "custom resolver", "where do roles come from", "roles in a token claim", "permissions from an external API", "implement resolver". Skip: the quickstart `DefaultAccessResolver` — `@warlock.js/access/configure-access/SKILL.md`.'
---

# Implement a resolver

The resolver is the ONE thing only you can write — your schema — and it's just a function over your own data. The engine does matching, caching, policies, and fail-closed for you.

```ts
export interface AccessResolver {
  resolveRoles(user: Auth, tenant?: string): Promise<string[]>; //       powers hasRole
  resolvePermissions(user: Auth, tenant?: string): Promise<string[]>; // powers can / authorize
  resolveTenant?(user: Auth): string | undefined; //                     optional ambient tenant
}
```

Register it: `access: { resolver: new MyResolver() }`.

## The default: dynamic, DB-backed (ejected)

`npx warlock add access` ejects this `DatabaseAccessResolver` — roles come from the `user_roles` table, permissions are joined through the `roles` catalog table. This is what most apps run, because admins manage roles + permissions at runtime.

```ts
import type { AccessResolver } from "@warlock.js/access";
import type { Auth } from "@warlock.js/auth";
import { Role } from "app/access/models/role";
import { UserRole } from "app/access/models/user-role";

export class DatabaseAccessResolver implements AccessResolver {
  public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {
    return UserRole.rolesFor(user, tenant);
  }

  public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {
    const names = await this.resolveRoles(user, tenant);

    if (names.length === 0) return [];

    const roles = await Role.query().whereIn("name", names).get();

    return [...new Set(roles.flatMap((role) => role.permissions))];
  }

  // Multi-tenant? Uncomment to scope checks to the user's tenant. Derive it from
  // the user — never trust client-supplied request input for the tenant boundary:
  // public resolveTenant(user: Auth): string | undefined {
  //   return user.get("organization_id");
  // }
}
```

## Other recipes

**A single role column, permissions mapped in code**

```ts
class ColumnResolver implements AccessResolver {
  public constructor(private readonly roles: Record<string, string[]>) {}
  public async resolveRoles(user: Auth): Promise<string[]> {
    return [user.get("role")].filter(Boolean);
  }
  public async resolvePermissions(user: Auth): Promise<string[]> {
    return (await this.resolveRoles(user)).flatMap((role) => this.roles[role] ?? []);
  }
}
```

**Roles from a token claim (external IdP — no DB)**

```ts
class ClaimResolver implements AccessResolver {
  public async resolveRoles(user: Auth): Promise<string[]> {
    return user.get("decodedAccessToken")?.roles ?? [];
  }
  public async resolvePermissions(user: Auth): Promise<string[]> {
    return user.get("decodedAccessToken")?.permissions ?? [];
  }
}
```

**Direct permissions per user (role is just a label)**

```ts
class DirectResolver implements AccessResolver {
  public async resolveRoles(user: Auth): Promise<string[]> {
    return [user.get("role")];
  }
  public async resolvePermissions(user: Auth): Promise<string[]> {
    const rows = await UserPermission.query().where({ user_id: user.id }).get();
    return rows.map((row) => row.get("name") as string);
  }
}
```

## Rules

- **Only fetch — never cache inside the resolver.** The engine caches per `(user, tenant)`; caching twice causes stale grants. Invalidate with `access.flush(user, tenant)` when your data changes.
- Return plain `string[]`. Wildcards (`orders.*`, `*`) in the returned permissions are honored by the engine.
- `resolveRoles` and `resolvePermissions` may read **different** sources — roles and permissions are independent axes (a role can be a pure label with permissions granted directly).
- Throwing from the resolver fails the check **closed** (denied + logged).

## See also

- [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — the quickstart `DefaultAccessResolver`.
```


## manage-roles  `@warlock.js/access/manage-roles/SKILL.md`

---
name: manage-roles
description: 'Assign and read roles in `@warlock.js/access` — the ejected `UserRole.assign` / `UserRole.revoke` (the `user_roles` table) followed by `access.flush`, plus `hasRole` / `hasAnyRole` / `hasAllRoles`. The role→permission catalog is the ejected `Role` table (dynamic). TRIGGER: `UserRole.assign`, `UserRole.revoke`, `access.flush`, `hasRole`, `hasAnyRole`, `hasAllRoles`, `Role` table, "give a user a role", "assign role", "check a user role", "roles per tenant". Skip: permission checks — `@warlock.js/access/check-permissions/SKILL.md`; resolver choice — `@warlock.js/access/configure-access/SKILL.md`.'
---

# Manage roles

Role storage is **ejected into your app** (`npx warlock add access`): a `UserRole` table (who holds which role) and a `Role` catalog table (role → permissions). Both are dynamic — admins manage them at runtime. The package itself only reads them through the `DatabaseAccessResolver`.

## Assign / revoke

Assign and revoke with the ejected `UserRole` model. Both **flush the affected user's cached set for you** — no manual `access.flush` after a role change:

```ts
import { UserRole } from "app/access/models/user-role";

await UserRole.assign(user, "editor", "tenant-1"); // editor in tenant-1 (auto-flushes that user)
await UserRole.assign(user, "viewer", "tenant-2"); // viewer in tenant-2 — same user, different role

await UserRole.revoke(user, "editor", "tenant-1"); // auto-flushes too
```

The `tenant` arg is optional — omit it for single-tenant apps (roles are stored globally). In a **multi-tenant** app, always pass the tenant: an omitted or unresolved tenant scopes to *global* roles only, never the union across tenants.

### When you still need `access.flush`

`assign` / `revoke` cover the common case. The cache keys off `(user, tenant)`, so flush manually only for **out-of-band** changes those two methods can't see:

```ts
import { access } from "@warlock.js/access";

// A role's permission set changed in the `Role` catalog — every holder's cached
// permissions are now stale, so flush each affected user:
await access.flush(user, "tenant-1");

// You changed a user's role membership WITHOUT UserRole (e.g. wrote the user's
// `role` column directly, or a bulk SQL update):
await access.flush(user, "tenant-1");
```

`access.flush(user, tenant)` is per-user — it has no "drop everything" form. For a sweeping catalog change with many holders, flush each affected user, or let the entries expire on their own (`cache.ttl`, default `10m`).

## Read roles

```ts
import { hasAllRoles, hasAnyRole, hasRole } from "@warlock.js/access";

await hasRole(user, "editor"); //              tenant is an optional 3rd arg
await hasAnyRole(user, ["admin", "manager"]);
await hasAllRoles(user, ["staff", "verified"]);
```

> **Prefer permission checks over role checks.** Role checks couple your code to the role taxonomy; `can(user, "orders.update")` survives a role rename. Reach for `hasRole` only for coarse UI gating.

## The catalog (the `Role` table)

A role's permissions live in the ejected `Role` table — `{ name, permissions }`. Add a role, or change what it grants, by writing rows; the `DatabaseAccessResolver` joins `user_roles` through it. No redeploy needed.

## The model (overridable)

The ejected `UserRole` is a thin cascade model — `{ user_id, user_type, role, tenant }`. `user_id` defaults to `uuid` — edit the ejected migration in `src/app/access/models/user-role/migrations/` if your user ids are integers.

## Roles stored elsewhere?

If roles live on the user (a `roles` column) or in a token claim, you don't use `UserRole` — you manage assignment your way and a resolver reads them. See [`implement-resolver`](@warlock.js/access/implement-resolver/SKILL.md).

## See also

- [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — choosing the resolver.
- [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — non-table role storage.
```


## overview  `@warlock.js/access/overview/SKILL.md`

---
name: overview
description: 'Front-door for `@warlock.js/access` — authorization (RBAC + ABAC) for Warlock apps: `can` / `authorize` / `gate` permission checks, `definePolicy` attribute conditions, role management, and a pluggable `AccessResolver` that connects the engine to however you store roles. Depends on `@warlock.js/auth` (reads `request.user`). TRIGGER when: importing from `@warlock.js/access`; "permissions in Warlock", "RBAC", "can this user do X", "protect a route by permission", "role-based access", "ownership / policy check". Skip: authentication / login (that is `@warlock.js/auth`); a known task — load the matching skill (`check-permissions`, `define-policies`, `manage-roles`, `implement-resolver`, `configure-access`).'
---

# `@warlock.js/access` — overview

Authorization for Warlock apps. `@warlock.js/auth` answers _who you are_; `access` answers _what you can do_.

## The mental model in one paragraph

The package owns the **engine** — wildcard matching, caching, ABAC policies, fail-closed decisions. You hand it **one required adapter**, an `AccessResolver`, that reads a user's roles + permissions from however YOUR app stores them (the role→permission catalog lives inside the resolver). Then `can(user, "orders.update")` and `gate("orders.update")` just work. The two concepts inside are **permissions** (RBAC grants) and **policies** (ABAC conditions) — which is why the package is called `access`, not either half.

## When to reach for it

- A Warlock app (already on `@warlock.js/auth`) that needs per-action / per-resource authorization beyond auth's user-type gate.
- **Multi-tenant** role scoping — `editor` in tenant A, `viewer` in tenant B.
- "Only their own / only in their tenant / only while pending" conditions → ABAC policies.

Skip if you only need "is the user an admin" — `authMiddleware("admin")` from `@warlock.js/auth` already covers coarse type gating.

## The two-stage model (important)

- **Class-level** (no resource): _"can this user update orders at all?"_ → `gate("orders.update")` in middleware, `can(user, "orders.update")` in code. Cheap, cached, runs before the controller.
- **Instance-level** (a resource): _"can they update THIS order?"_ → `authorize(user, "orders.update", { resource: order })` in the service, after you load the row. Runs the registered policy on top of the grant.

## Skills index

- [`configure-access`](@warlock.js/access/configure-access/SKILL.md) — the required resolver (`DefaultAccessResolver` vs the ejected `DatabaseAccessResolver`), cache + tenant.
- [`check-permissions`](@warlock.js/access/check-permissions/SKILL.md) — `can` / `cannot` / `canAll` / `canAny` / `authorize*` + the `gate*` middleware.
- [`define-policies`](@warlock.js/access/define-policies/SKILL.md) — `definePolicy` for ownership / tenant / state conditions (ABAC).
- [`manage-roles`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke via the ejected `UserRole` + `access.flush`, `hasRole` / `hasAnyRole` / `hasAllRoles`.
- [`implement-resolver`](@warlock.js/access/implement-resolver/SKILL.md) — connect the engine to your storage (a DB catalog, a column, a pivot, a token claim).

## What it deliberately doesn't do

- **Authentication.** Use `@warlock.js/auth`; `access` reads `request.user`.
- **Ship a permission admin UI.** The engine reads permission strings; whether the catalog is code-defined (`DefaultAccessResolver`) or DB-managed (the ejected `DatabaseAccessResolver`) is your resolver's choice — the package ships no admin screens.
- **ReBAC graphs / row-level query scoping.** Use a policy for "own resource"; graph-scale relationships are out of scope.

## See also

- [`@warlock.js/auth/overview/SKILL.md`](@warlock.js/auth/overview/SKILL.md) — authentication, the layer below.
- [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware`; stack `gate` after it.


