@warlock.js/access 5.19.1 → 5.21.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.
- package/CHANGELOG.md +12 -1
- package/llms-full.txt +405 -405
- package/llms.txt +5 -5
- package/package.json +7 -7
- package/skills/check-permissions/SKILL.md +71 -71
- package/skills/configure-access/SKILL.md +89 -89
- package/skills/define-policies/SKILL.md +68 -68
- package/skills/implement-resolver/SKILL.md +106 -106
- package/skills/index.md +21 -0
- package/skills/manage-roles/SKILL.md +71 -71
package/llms.txt
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
## Skills
|
|
8
8
|
|
|
9
|
-
- [check-permissions](@warlock.js/access/check-permissions/SKILL.md):
|
|
10
|
-
- [configure-access](@warlock.js/access/configure-access/SKILL.md):
|
|
11
|
-
- [define-policies](@warlock.js/access/define-policies/SKILL.md):
|
|
12
|
-
- [implement-resolver](@warlock.js/access/implement-resolver/SKILL.md):
|
|
13
|
-
- [manage-roles](@warlock.js/access/manage-roles/SKILL.md):
|
|
9
|
+
- [check-permissions](@warlock.js/access/check-permissions/SKILL.md): (no description)
|
|
10
|
+
- [configure-access](@warlock.js/access/configure-access/SKILL.md): (no description)
|
|
11
|
+
- [define-policies](@warlock.js/access/define-policies/SKILL.md): (no description)
|
|
12
|
+
- [implement-resolver](@warlock.js/access/implement-resolver/SKILL.md): (no description)
|
|
13
|
+
- [manage-roles](@warlock.js/access/manage-roles/SKILL.md): (no description)
|
|
14
14
|
- [overview](@warlock.js/access/overview/SKILL.md): @warlock.js/access overview for orientation and choosing the focused skill for the task.
|
package/package.json
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
"environment": "server"
|
|
6
6
|
},
|
|
7
7
|
"peerDependencies": {
|
|
8
|
-
"@warlock.js/auth": "5.
|
|
9
|
-
"@warlock.js/cache": "5.
|
|
10
|
-
"@warlock.js/cascade": "5.
|
|
11
|
-
"@warlock.js/core": "5.
|
|
12
|
-
"@warlock.js/logger": "5.
|
|
13
|
-
"@warlock.js/seal": "5.
|
|
8
|
+
"@warlock.js/auth": "5.21.0",
|
|
9
|
+
"@warlock.js/cache": "5.21.0",
|
|
10
|
+
"@warlock.js/cascade": "5.21.0",
|
|
11
|
+
"@warlock.js/core": "5.21.0",
|
|
12
|
+
"@warlock.js/logger": "5.21.0",
|
|
13
|
+
"@warlock.js/seal": "5.21.0"
|
|
14
14
|
},
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"author": "hassanzohdy",
|
|
29
29
|
"license": "MIT",
|
|
30
|
-
"version": "5.
|
|
30
|
+
"version": "5.21.0",
|
|
31
31
|
"main": "./cjs/index.cjs",
|
|
32
32
|
"module": "./esm/index.mjs",
|
|
33
33
|
"types": "./esm/index.d.mts",
|
|
@@ -1,71 +1,71 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: check-permissions
|
|
3
|
-
description:
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Check permissions
|
|
7
|
-
|
|
8
|
-
## In a route — class-level gate
|
|
9
|
-
|
|
10
|
-
Stack it AFTER `authMiddleware` (which sets `request.locals.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.locals.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.
|
|
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.locals.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.locals.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.
|
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: configure-access
|
|
3
|
-
description:
|
|
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
|
-
strictPolicies?: boolean; // default true — see below
|
|
58
|
-
};
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
### `strictPolicies`
|
|
62
|
-
|
|
63
|
-
An instance check (`{ resource }`) for a permission with no registered ABAC policy **denies by default** (`strictPolicies: true`) — a forgotten policy must never fail open. The miss is logged once per permission (`log.warn`) either way.
|
|
64
|
-
|
|
65
|
-
Set `strictPolicies: false` to explicitly restore the pre-5.13 behavior: fall back to the RBAC grant alone (ALLOWS — any holder of the permission can act on ANY resource, a plausible IDOR). See [`define-policies`](@warlock.js/access/define-policies/SKILL.md) for the full decision table.
|
|
66
|
-
|
|
67
|
-
> **Breaking in 5.13:** `strictPolicies` now defaults to `true` and DENIES (it used to default to `false` and ALLOW). Set `strictPolicies: false` to keep the old fail-open fallback.
|
|
68
|
-
|
|
69
|
-
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.
|
|
70
|
-
|
|
71
|
-
## Tenant
|
|
72
|
-
|
|
73
|
-
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:
|
|
74
|
-
|
|
75
|
-
```ts
|
|
76
|
-
public resolveTenant(user: Auth): string | undefined {
|
|
77
|
-
return user.get("organization_id");
|
|
78
|
-
}
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
## Caching
|
|
82
|
-
|
|
83
|
-
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.
|
|
84
|
-
|
|
85
|
-
## See also
|
|
86
|
-
|
|
87
|
-
- [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — write a resolver for your own storage.
|
|
88
|
-
- [`@warlock.js/access/manage-roles/SKILL.md`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke with the ejected table.
|
|
89
|
-
```
|
|
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
|
+
strictPolicies?: boolean; // default true — see below
|
|
58
|
+
};
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### `strictPolicies`
|
|
62
|
+
|
|
63
|
+
An instance check (`{ resource }`) for a permission with no registered ABAC policy **denies by default** (`strictPolicies: true`) — a forgotten policy must never fail open. The miss is logged once per permission (`log.warn`) either way.
|
|
64
|
+
|
|
65
|
+
Set `strictPolicies: false` to explicitly restore the pre-5.13 behavior: fall back to the RBAC grant alone (ALLOWS — any holder of the permission can act on ANY resource, a plausible IDOR). See [`define-policies`](@warlock.js/access/define-policies/SKILL.md) for the full decision table.
|
|
66
|
+
|
|
67
|
+
> **Breaking in 5.13:** `strictPolicies` now defaults to `true` and DENIES (it used to default to `false` and ALLOW). Set `strictPolicies: false` to keep the old fail-open fallback.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
## Tenant
|
|
72
|
+
|
|
73
|
+
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:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
public resolveTenant(user: Auth): string | undefined {
|
|
77
|
+
return user.get("organization_id");
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Caching
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
## See also
|
|
86
|
+
|
|
87
|
+
- [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — write a resolver for your own storage.
|
|
88
|
+
- [`@warlock.js/access/manage-roles/SKILL.md`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke with the ejected table.
|
|
89
|
+
```
|
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: define-policies
|
|
3
|
-
description:
|
|
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 → **denied by default** (`strictPolicies: true`, the 5.13+ default) — a typo'd permission or a forgotten `import "./policies"` must never silently degrade an instance check to a class-level one. Logs a one-time `log.warn` naming the permission either way. Set `strictPolicies: false` in the access config (see [`configure-access`](@warlock.js/access/configure-access/SKILL.md)) to explicitly restore the pre-5.13 RBAC-only fallback (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 })`.
|
|
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 → **denied by default** (`strictPolicies: true`, the 5.13+ default) — a typo'd permission or a forgotten `import "./policies"` must never silently degrade an instance check to a class-level one. Logs a one-time `log.warn` naming the permission either way. Set `strictPolicies: false` in the access config (see [`configure-access`](@warlock.js/access/configure-access/SKILL.md)) to explicitly restore the pre-5.13 RBAC-only fallback (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 })`.
|