@warlock.js/access 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +76 -0
  4. package/cjs/index.cjs +367 -0
  5. package/cjs/index.cjs.map +1 -0
  6. package/esm/contracts/access-resolver.d.mts +40 -0
  7. package/esm/contracts/access-resolver.d.mts.map +1 -0
  8. package/esm/contracts/index.d.mts +2 -0
  9. package/esm/contracts/types.d.mts +38 -0
  10. package/esm/contracts/types.d.mts.map +1 -0
  11. package/esm/index.d.mts +11 -0
  12. package/esm/index.mjs +12 -0
  13. package/esm/middleware/gate.middleware.d.mts +20 -0
  14. package/esm/middleware/gate.middleware.d.mts.map +1 -0
  15. package/esm/middleware/gate.middleware.mjs +44 -0
  16. package/esm/middleware/gate.middleware.mjs.map +1 -0
  17. package/esm/middleware/index.d.mts +1 -0
  18. package/esm/middleware/index.mjs +3 -0
  19. package/esm/services/access-config.d.mts +19 -0
  20. package/esm/services/access-config.d.mts.map +1 -0
  21. package/esm/services/access-config.mjs +50 -0
  22. package/esm/services/access-config.mjs.map +1 -0
  23. package/esm/services/access.d.mts +51 -0
  24. package/esm/services/access.d.mts.map +1 -0
  25. package/esm/services/access.mjs +80 -0
  26. package/esm/services/access.mjs.map +1 -0
  27. package/esm/services/default-resolver.d.mts +27 -0
  28. package/esm/services/default-resolver.d.mts.map +1 -0
  29. package/esm/services/default-resolver.mjs +35 -0
  30. package/esm/services/default-resolver.mjs.map +1 -0
  31. package/esm/services/engine.d.mts +8 -0
  32. package/esm/services/engine.d.mts.map +1 -0
  33. package/esm/services/engine.mjs +87 -0
  34. package/esm/services/engine.mjs.map +1 -0
  35. package/esm/services/index.d.mts +5 -0
  36. package/esm/services/index.mjs +7 -0
  37. package/esm/services/matcher.mjs +22 -0
  38. package/esm/services/matcher.mjs.map +1 -0
  39. package/esm/services/policies.d.mts +17 -0
  40. package/esm/services/policies.d.mts.map +1 -0
  41. package/esm/services/policies.mjs +25 -0
  42. package/esm/services/policies.mjs.map +1 -0
  43. package/esm/utils/access-config-error.d.mts +12 -0
  44. package/esm/utils/access-config-error.d.mts.map +1 -0
  45. package/esm/utils/access-config-error.mjs +16 -0
  46. package/esm/utils/access-config-error.mjs.map +1 -0
  47. package/esm/utils/access-error-codes.d.mts +13 -0
  48. package/esm/utils/access-error-codes.d.mts.map +1 -0
  49. package/esm/utils/access-error-codes.mjs +15 -0
  50. package/esm/utils/access-error-codes.mjs.map +1 -0
  51. package/llms-full.txt +470 -0
  52. package/llms.txt +14 -0
  53. package/package.json +43 -0
  54. package/skills/check-permissions/SKILL.md +71 -0
  55. package/skills/configure-access/SKILL.md +80 -0
  56. package/skills/define-policies/SKILL.md +68 -0
  57. package/skills/implement-resolver/SKILL.md +106 -0
  58. package/skills/manage-roles/SKILL.md +71 -0
  59. package/skills/overview/SKILL.md +44 -0
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: check-permissions
3
+ 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`.'
4
+ ---
5
+
6
+ # Check permissions
7
+
8
+ ## In a route — class-level gate
9
+
10
+ Stack it AFTER `authMiddleware` (which sets `request.user`):
11
+
12
+ ```ts
13
+ import { authMiddleware } from "@warlock.js/auth";
14
+ import { gate } from "@warlock.js/access";
15
+
16
+ router.post("/orders", createOrder, {
17
+ middleware: [authMiddleware([]), gate("orders.create")],
18
+ });
19
+ ```
20
+
21
+ A missing permission → `403` before your controller runs. Use `gateAny([...])` / `gateAll([...])` for sets.
22
+
23
+ ## In code — boolean
24
+
25
+ ```ts
26
+ import { can, cannot, canAll, canAny } from "@warlock.js/access";
27
+
28
+ if (await can(user, "orders.update")) { /* … */ }
29
+
30
+ await canAll(user, ["orders.update", "orders.viewCost"]); // needs BOTH
31
+ await canAny(user, ["orders.update", "orders.updateStatus"]); // needs EITHER
32
+ ```
33
+
34
+ > **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.
35
+
36
+ ## In a service — throw
37
+
38
+ ```ts
39
+ import { authorize, authorizeAll } from "@warlock.js/access";
40
+
41
+ await authorize(user, "orders.update"); // throws ForbiddenError (403, EC100) on deny
42
+ await authorizeAll(user, ["orders.update", "orders.viewCost"]);
43
+ ```
44
+
45
+ ## Instance-level — "this resource"
46
+
47
+ Pass a `resource` and the permission's policy runs on top of the grant (see [`define-policies`](@warlock.js/access/define-policies/SKILL.md)):
48
+
49
+ ```ts
50
+ const order = await Order.find(orderId);
51
+
52
+ await authorize(user, "orders.update", { resource: order, tenant }); // grant AND policy
53
+ ```
54
+
55
+ - `gate` is **class-level only** — no resource, runs before the controller ("can they update orders at all?").
56
+ - `authorize(…, { resource })` is **instance-level** — after you load the row ("can they update THIS order?").
57
+
58
+ ## Wildcards
59
+
60
+ `*` 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.
61
+
62
+ ## Gotchas
63
+
64
+ - **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).
65
+ - Stack `gate` **after** `authMiddleware` — it reads `request.user`; without an authenticated user it `403`s.
66
+ - The boolean `can*` family never throws; the `authorize*` family throws `ForbiddenError`. Pick per layer (controllers gate with middleware, services assert with `authorize`).
67
+
68
+ ## See also
69
+
70
+ - [`@warlock.js/access/define-policies/SKILL.md`](@warlock.js/access/define-policies/SKILL.md) — the `{ resource }` conditions.
71
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware`, which runs first.
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: configure-access
3
+ 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`.'
4
+ ---
5
+
6
+ # Configure access
7
+
8
+ 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.
9
+
10
+ ## Quickstart — a fixed, code-defined catalog
11
+
12
+ `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.
13
+
14
+ ```ts title="src/config/access.ts"
15
+ import { type AccessConfigurations, DefaultAccessResolver } from "@warlock.js/access";
16
+
17
+ const access: AccessConfigurations = {
18
+ resolver: new DefaultAccessResolver({
19
+ owner: ["*"], // `*` is the super-grant
20
+ editor: ["orders.*", "posts.create"], // `orders.*` covers orders.update, orders.view, …
21
+ viewer: ["orders.view"],
22
+ }),
23
+ };
24
+
25
+ export default access;
26
+ ```
27
+
28
+ `can(user, "orders.update")` now works for any user whose `roles` column includes `editor`.
29
+
30
+ > `DefaultAccessResolver` is **not tenant-aware** — it returns the same roles in every tenant. For per-tenant roles, use the dynamic resolver below.
31
+
32
+ ## Real apps — dynamic, DB-backed roles
33
+
34
+ `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.
35
+
36
+ ```ts title="src/config/access.ts"
37
+ import { type AccessConfigurations } from "@warlock.js/access";
38
+ import { DatabaseAccessResolver } from "app/access/services/access-resolver";
39
+
40
+ const access: AccessConfigurations = {
41
+ resolver: new DatabaseAccessResolver(),
42
+ };
43
+
44
+ export default access;
45
+ ```
46
+
47
+ 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)).
48
+
49
+ ## The full shape
50
+
51
+ ```ts
52
+ type AccessConfigurations = {
53
+ resolver: AccessResolver; // REQUIRED — reads roles + permissions (+ optional tenant)
54
+ cache?: {
55
+ ttl?: string | number; // resolved-set TTL, default "10m"
56
+ };
57
+ };
58
+ ```
59
+
60
+ 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.
61
+
62
+ ## Tenant
63
+
64
+ 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:
65
+
66
+ ```ts
67
+ public resolveTenant(user: Auth): string | undefined {
68
+ return user.get("organization_id");
69
+ }
70
+ ```
71
+
72
+ ## Caching
73
+
74
+ 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.
75
+
76
+ ## See also
77
+
78
+ - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — write a resolver for your own storage.
79
+ - [`@warlock.js/access/manage-roles/SKILL.md`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke with the ejected table.
80
+ ```
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: define-policies
3
+ 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`.'
4
+ ---
5
+
6
+ # Define policies (ABAC)
7
+
8
+ RBAC says "can update orders". A **policy** adds "...but only THIS order" — the instance-level condition.
9
+
10
+ ## Register a condition
11
+
12
+ 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.
13
+
14
+ ```ts title="src/app/orders/policies/index.ts"
15
+ import { definePolicy } from "@warlock.js/access";
16
+
17
+ definePolicy("orders.update", (user, order, ctx) =>
18
+ order.get("organization_id") === ctx.tenant &&
19
+ (order.get("customer_id") === user.id || ctx.hasRole("manager")),
20
+ );
21
+ ```
22
+
23
+ ```ts title="src/app/orders/main.ts"
24
+ import "./policies"; // register this module's policies at boot
25
+ ```
26
+
27
+ ## It runs ONLY on an instance check
28
+
29
+ ```ts
30
+ await authorize(user, "orders.update"); // class-level → grant only, policy SKIPPED
31
+ await authorize(user, "orders.update", { resource: order }); // instance → grant AND policy
32
+ ```
33
+
34
+ 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.
35
+
36
+ ## The decision = grant AND policy
37
+
38
+ - No grant → denied (the policy never runs).
39
+ - Grant + no policy registered → allowed.
40
+ - Grant + policy → the policy decides.
41
+
42
+ Policies **deny**, they don't grant — a policy can't let a user past a permission they don't hold.
43
+
44
+ ## The policy context
45
+
46
+ `(user, resource, ctx)`, where `ctx` carries:
47
+
48
+ - `tenant` — the resolved tenant.
49
+ - `hasRole(role)` / `hasPermission(perm)` — engine-injected helpers.
50
+ - any extra keys you passed on the check:
51
+
52
+ ```ts
53
+ await authorize(user, "orders.refund", { resource: order, amount: 5000 });
54
+
55
+ definePolicy("orders.refund", (user, order, ctx) =>
56
+ ctx.hasRole("manager") || (ctx.amount as number) <= 1000,
57
+ );
58
+ ```
59
+
60
+ ## Gotchas
61
+
62
+ - Policies are global by permission name — define each once at boot.
63
+ - A throwing policy is treated as a **denial** (fail-closed) and logged.
64
+ - Need "own resource" at graph scale (deep relationships)? That's ReBAC — out of scope; a policy covers the common ownership case.
65
+
66
+ ## See also
67
+
68
+ - [`@warlock.js/access/check-permissions/SKILL.md`](@warlock.js/access/check-permissions/SKILL.md) — `authorize(…, { resource })`.
@@ -0,0 +1,106 @@
1
+ ---
2
+ name: implement-resolver
3
+ 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`.'
4
+ ---
5
+
6
+ # Implement a resolver
7
+
8
+ 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.
9
+
10
+ ```ts
11
+ export interface AccessResolver {
12
+ resolveRoles(user: Auth, tenant?: string): Promise<string[]>; // powers hasRole
13
+ resolvePermissions(user: Auth, tenant?: string): Promise<string[]>; // powers can / authorize
14
+ resolveTenant?(user: Auth): string | undefined; // optional ambient tenant
15
+ }
16
+ ```
17
+
18
+ Register it: `access: { resolver: new MyResolver() }`.
19
+
20
+ ## The default: dynamic, DB-backed (ejected)
21
+
22
+ `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.
23
+
24
+ ```ts
25
+ import type { AccessResolver } from "@warlock.js/access";
26
+ import type { Auth } from "@warlock.js/auth";
27
+ import { Role } from "app/access/models/role";
28
+ import { UserRole } from "app/access/models/user-role";
29
+
30
+ export class DatabaseAccessResolver implements AccessResolver {
31
+ public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {
32
+ return UserRole.rolesFor(user, tenant);
33
+ }
34
+
35
+ public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {
36
+ const names = await this.resolveRoles(user, tenant);
37
+
38
+ if (names.length === 0) return [];
39
+
40
+ const roles = await Role.query().whereIn("name", names).get();
41
+
42
+ return [...new Set(roles.flatMap((role) => role.permissions))];
43
+ }
44
+
45
+ // Multi-tenant? Uncomment to scope checks to the user's tenant. Derive it from
46
+ // the user — never trust client-supplied request input for the tenant boundary:
47
+ // public resolveTenant(user: Auth): string | undefined {
48
+ // return user.get("organization_id");
49
+ // }
50
+ }
51
+ ```
52
+
53
+ ## Other recipes
54
+
55
+ **A single role column, permissions mapped in code**
56
+
57
+ ```ts
58
+ class ColumnResolver implements AccessResolver {
59
+ public constructor(private readonly roles: Record<string, string[]>) {}
60
+ public async resolveRoles(user: Auth): Promise<string[]> {
61
+ return [user.get("role")].filter(Boolean);
62
+ }
63
+ public async resolvePermissions(user: Auth): Promise<string[]> {
64
+ return (await this.resolveRoles(user)).flatMap((role) => this.roles[role] ?? []);
65
+ }
66
+ }
67
+ ```
68
+
69
+ **Roles from a token claim (external IdP — no DB)**
70
+
71
+ ```ts
72
+ class ClaimResolver implements AccessResolver {
73
+ public async resolveRoles(user: Auth): Promise<string[]> {
74
+ return user.get("decodedAccessToken")?.roles ?? [];
75
+ }
76
+ public async resolvePermissions(user: Auth): Promise<string[]> {
77
+ return user.get("decodedAccessToken")?.permissions ?? [];
78
+ }
79
+ }
80
+ ```
81
+
82
+ **Direct permissions per user (role is just a label)**
83
+
84
+ ```ts
85
+ class DirectResolver implements AccessResolver {
86
+ public async resolveRoles(user: Auth): Promise<string[]> {
87
+ return [user.get("role")];
88
+ }
89
+ public async resolvePermissions(user: Auth): Promise<string[]> {
90
+ const rows = await UserPermission.query().where({ user_id: user.id }).get();
91
+ return rows.map((row) => row.get("name") as string);
92
+ }
93
+ }
94
+ ```
95
+
96
+ ## Rules
97
+
98
+ - **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.
99
+ - Return plain `string[]`. Wildcards (`orders.*`, `*`) in the returned permissions are honored by the engine.
100
+ - `resolveRoles` and `resolvePermissions` may read **different** sources — roles and permissions are independent axes (a role can be a pure label with permissions granted directly).
101
+ - Throwing from the resolver fails the check **closed** (denied + logged).
102
+
103
+ ## See also
104
+
105
+ - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — the quickstart `DefaultAccessResolver`.
106
+ ```
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: manage-roles
3
+ 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`.'
4
+ ---
5
+
6
+ # Manage roles
7
+
8
+ 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`.
9
+
10
+ ## Assign / revoke
11
+
12
+ 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:
13
+
14
+ ```ts
15
+ import { UserRole } from "app/access/models/user-role";
16
+
17
+ await UserRole.assign(user, "editor", "tenant-1"); // editor in tenant-1 (auto-flushes that user)
18
+ await UserRole.assign(user, "viewer", "tenant-2"); // viewer in tenant-2 — same user, different role
19
+
20
+ await UserRole.revoke(user, "editor", "tenant-1"); // auto-flushes too
21
+ ```
22
+
23
+ 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.
24
+
25
+ ### When you still need `access.flush`
26
+
27
+ `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:
28
+
29
+ ```ts
30
+ import { access } from "@warlock.js/access";
31
+
32
+ // A role's permission set changed in the `Role` catalog — every holder's cached
33
+ // permissions are now stale, so flush each affected user:
34
+ await access.flush(user, "tenant-1");
35
+
36
+ // You changed a user's role membership WITHOUT UserRole (e.g. wrote the user's
37
+ // `role` column directly, or a bulk SQL update):
38
+ await access.flush(user, "tenant-1");
39
+ ```
40
+
41
+ `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`).
42
+
43
+ ## Read roles
44
+
45
+ ```ts
46
+ import { hasAllRoles, hasAnyRole, hasRole } from "@warlock.js/access";
47
+
48
+ await hasRole(user, "editor"); // tenant is an optional 3rd arg
49
+ await hasAnyRole(user, ["admin", "manager"]);
50
+ await hasAllRoles(user, ["staff", "verified"]);
51
+ ```
52
+
53
+ > **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.
54
+
55
+ ## The catalog (the `Role` table)
56
+
57
+ 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.
58
+
59
+ ## The model (overridable)
60
+
61
+ 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.
62
+
63
+ ## Roles stored elsewhere?
64
+
65
+ 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).
66
+
67
+ ## See also
68
+
69
+ - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — choosing the resolver.
70
+ - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — non-table role storage.
71
+ ```
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: overview
3
+ 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`).'
4
+ ---
5
+
6
+ # `@warlock.js/access` — overview
7
+
8
+ Authorization for Warlock apps. `@warlock.js/auth` answers _who you are_; `access` answers _what you can do_.
9
+
10
+ ## The mental model in one paragraph
11
+
12
+ 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.
13
+
14
+ ## When to reach for it
15
+
16
+ - A Warlock app (already on `@warlock.js/auth`) that needs per-action / per-resource authorization beyond auth's user-type gate.
17
+ - **Multi-tenant** role scoping — `editor` in tenant A, `viewer` in tenant B.
18
+ - "Only their own / only in their tenant / only while pending" conditions → ABAC policies.
19
+
20
+ Skip if you only need "is the user an admin" — `authMiddleware("admin")` from `@warlock.js/auth` already covers coarse type gating.
21
+
22
+ ## The two-stage model (important)
23
+
24
+ - **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.
25
+ - **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.
26
+
27
+ ## Skills index
28
+
29
+ - [`configure-access`](@warlock.js/access/configure-access/SKILL.md) — the required resolver (`DefaultAccessResolver` vs the ejected `DatabaseAccessResolver`), cache + tenant.
30
+ - [`check-permissions`](@warlock.js/access/check-permissions/SKILL.md) — `can` / `cannot` / `canAll` / `canAny` / `authorize*` + the `gate*` middleware.
31
+ - [`define-policies`](@warlock.js/access/define-policies/SKILL.md) — `definePolicy` for ownership / tenant / state conditions (ABAC).
32
+ - [`manage-roles`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke via the ejected `UserRole` + `access.flush`, `hasRole` / `hasAnyRole` / `hasAllRoles`.
33
+ - [`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).
34
+
35
+ ## What it deliberately doesn't do
36
+
37
+ - **Authentication.** Use `@warlock.js/auth`; `access` reads `request.user`.
38
+ - **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.
39
+ - **ReBAC graphs / row-level query scoping.** Use a policy for "own resource"; graph-scale relationships are out of scope.
40
+
41
+ ## See also
42
+
43
+ - [`@warlock.js/auth/overview/SKILL.md`](@warlock.js/auth/overview/SKILL.md) — authentication, the layer below.
44
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware`; stack `gate` after it.