@warlock.js/access 5.19.0 → 5.20.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.
@@ -1,106 +1,106 @@
1
- ---
2
- name: implement-resolver
3
- description: "Implement a resolver in @warlock.js/access; use when you need to implement resolver."
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
- ```
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,21 @@
1
+ ---
2
+ description: "Authorization (RBAC + ABAC) for Warlock apps: what a signed-in user is allowed to do. Exports `can`, `authorize`, `gate`, `gateAny`, `definePolicy`, `hasRole`, `access`, `DefaultAccessResolver`, `AccessResolver`, `AccessErrorCodes`. Use for: \"check a permission\", \"restrict this route to users who can update orders\", \"only the owner can edit\", \"roles and permissions\", \"per-tenant roles\", \"custom role storage\". Not this package: login, tokens, user-type gating → @warlock.js/auth; request validation → @warlock.js/seal."
3
+ ---
4
+ # @warlock.js/access
5
+
6
+ Access is the authorization engine. You supply one adapter, an `AccessResolver`, that reads a user's roles and permissions from your storage; then `can(user, "orders.update")` and `gate("orders.update")` work. Two halves: permissions (RBAC grants) and policies (ABAC conditions). Server-only; reads `request.user`, so it sits beside `@warlock.js/auth`.
7
+
8
+ ## The 80% path
9
+ 1. Orient with `overview.md`.
10
+ 2. Configure the required resolver: `configure-access.md` (`DefaultAccessResolver` for a code-defined catalog, or the ejected DB one); custom storage in `implement-resolver.md`.
11
+ 3. Gate routes and check in code: `check-permissions.md`.
12
+ 4. Add "only their own / only while pending" rules: `define-policies.md`.
13
+ 5. Assign and revoke roles: `manage-roles.md`.
14
+
15
+ ## Conventions and pitfalls
16
+ - Two stages: class-level (`gate`, `can`) asks "can they at all?"; instance-level (`authorize` with a `resource`) runs the policy on the loaded row.
17
+ - Decisions fail closed; a missing resolver is a config error, not a default allow.
18
+ - Stack `gate` after `authMiddleware`; access does not authenticate.
19
+ - After changing a user's roles, flush the cache (`access.flush`) or old grants linger.
20
+ - Only need "is admin"? `authMiddleware("admin")` from auth is enough.
21
+ - Do not value-import from client code; type-only imports are fine.
@@ -1,71 +1,71 @@
1
- ---
2
- name: manage-roles
3
- description: "Manage roles in @warlock.js/access; use when you need to manage roles."
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
- ```
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
+ ```