@classytic/arc 2.28.0 → 2.30.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/dist/{BaseController-B65zbz9m.mjs → BaseController-B5DwufbB.mjs} +12 -10
- package/dist/EventTransport-C6YA8xY9.mjs +103 -0
- package/dist/{HookSystem-C1vB_Gc9.mjs → HookSystem-WkIougo6.mjs} +15 -35
- package/dist/applyPermissionResult-Cf6zUCvb.mjs +257 -0
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +1 -1
- package/dist/auth/index.d.mts +2 -2
- package/dist/auth/index.mjs +1 -1
- package/dist/{buildHandler-Dtwd8fdz.mjs → buildHandler-BYS3Vkfz.mjs} +32 -6
- package/dist/cleanup/index.d.mts +2 -2
- package/dist/cleanup/index.mjs +1 -1
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +5 -4
- package/dist/{core-BGa8TXJR.mjs → core-poalJLvp.mjs} +2 -48
- package/dist/{createActionRouter-C-N7z2EL.mjs → createActionRouter-BxuywVRB.mjs} +2 -2
- package/dist/{createAggregationRouter-wzwFa_dW.mjs → createAggregationRouter-BiY-lGdY.mjs} +7 -20
- package/dist/{createApp-Du5G54TM.mjs → createApp-CXrKFciH.mjs} +24 -3
- package/dist/defineAggregation-ufWjUwml.mjs +47 -0
- package/dist/{defineResource-2zvVVpxg.mjs → defineResource-CxWqDCAy.mjs} +35 -42
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/{eventPlugin-DrQ_FesK.mjs → eventPlugin-D2NNR4py.mjs} +2 -103
- package/dist/events/index.mjs +4 -339
- package/dist/factory/index.d.mts +4 -4
- package/dist/factory/index.mjs +1 -1
- package/dist/{fastify-augmentation-OzYGIiU1.d.mts → fastify-augmentation-0WhPnUH7.d.mts} +3 -3
- package/dist/{applyPermissionResult-BnyVE9eD.mjs → fields-CQ_7KQWw.mjs} +1 -107
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.mjs +1 -1
- package/dist/{index-CZY4EGga.d.mts → index-CCzVR-N3.d.mts} +1 -1
- package/dist/{index-BMnbzvtO.d.mts → index-Ch16aAah.d.mts} +201 -21
- package/dist/{index-CjVg-Epg.d.mts → index-Cx7bro4R.d.mts} +4 -4
- package/dist/{index-DPRH0Cme.d.mts → index-Q5gzEIWY.d.mts} +97 -66
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +8 -7
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +6 -6
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/{mcpPlugin-Cs6nRGCt.mjs → mcpPlugin-nRzm5g2s.mjs} +36 -43
- package/dist/middleware/index.d.mts +1 -1
- package/dist/migrations/index.d.mts +0 -3
- package/dist/migrations/index.mjs +0 -3
- package/dist/{openapi-BLV4OMXb.mjs → openapi-B8Vkbxbn.mjs} +1 -1
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +4 -3
- package/dist/{permissions-BrtiBKGs.mjs → permissions-Bfu_8y1U.mjs} +318 -289
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/{pipeline-BGRnV-To.mjs → pipeline-BJnpkY9i.mjs} +4 -18
- package/dist/plugins/index.d.mts +4 -4
- package/dist/plugins/index.mjs +2 -2
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/{preloadResources-CM4uUaFv.d.mts → preloadResources-DCfa2egz.d.mts} +3 -3
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.d.mts +14 -2
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-DwFDVKP0.mjs → presets-CJOEXLuk.mjs} +25 -9
- package/dist/projection-UBDgfMwu.mjs +17 -0
- package/dist/{realtime-DEuDu9io.mjs → realtime-B9nmIY5V.mjs} +2 -1
- package/dist/{registerRoute-DTp18Ah_.mjs → registerRoute-JkxIEBJq.mjs} +3 -1
- package/dist/registry/index.d.mts +2 -2
- package/dist/registry/index.mjs +1 -1
- package/dist/{registry-9km_w1aP.mjs → registry-BYXUUg21.mjs} +1 -1
- package/dist/repositoryFilter-Dz8k3S4P.mjs +44 -0
- package/dist/subscribe-helpers-DfzsivM6.mjs +339 -0
- package/dist/testing/index.d.mts +34 -6
- package/dist/testing/index.mjs +314 -211
- package/dist/types/index.d.mts +3 -3
- package/dist/{types-QFGc3Cjv.d.mts → types-Bmmn-fO0.d.mts} +1 -1
- package/dist/{types-Dmw-6Qeo.d.mts → types-ChvvVNP8.d.mts} +93 -88
- package/dist/{types-Cu6vVJmh.d.mts → types-D_n8L6VY.d.mts} +32 -1
- package/dist/usage/index.d.mts +1 -1
- package/dist/usage/index.mjs +0 -0
- package/dist/utils/index.d.mts +2 -5
- package/package.json +539 -539
|
@@ -1,10 +1,68 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
|
|
2
|
+
import { arcLog } from "./logger/index.mjs";
|
|
2
3
|
import { n as getUserRoles } from "./userHelpers-Cg_y3fBz.mjs";
|
|
3
|
-
import { C as isService, S as isOrgInScope, a as getDPoPJkt, b as isElevated, d as getScopeContext, f as getScopeContextMap, g as getUserId, m as getTeamId, o as getMandate, p as getServiceScopes, u as getRequestScope, v as hasOrgAccess, x as isMember } from "./types-CSyCxl2V.mjs";
|
|
4
|
+
import { C as isService, S as isOrgInScope, a as getDPoPJkt, b as isElevated, d as getScopeContext, f as getScopeContextMap, g as getUserId, m as getTeamId, n as PUBLIC_SCOPE, o as getMandate, p as getServiceScopes, u as getRequestScope, v as hasOrgAccess, x as isMember } from "./types-CSyCxl2V.mjs";
|
|
5
|
+
import { a as normalizeToDecision, o as conjoinPolicyFilters } from "./applyPermissionResult-Cf6zUCvb.mjs";
|
|
4
6
|
import { t as MemoryCacheStore } from "./memory-CmnzXE5Z.mjs";
|
|
5
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
+
//#region src/permissions/context.ts
|
|
9
|
+
/**
|
|
10
|
+
* PermissionContext accessors (arc 2.30 — transport-neutral evaluation).
|
|
11
|
+
*
|
|
12
|
+
* The goal of the standardized authorization core is that a `PermissionCheck`
|
|
13
|
+
* reads FACTS off its {@link PermissionContext}, never off a raw
|
|
14
|
+
* `FastifyRequest`. That makes evaluation:
|
|
15
|
+
* - pure (a combinator can hand a child a NEW context with an updated scope
|
|
16
|
+
* instead of mutating `request.scope` — see `algebra.ts`),
|
|
17
|
+
* - transport-neutral (MCP / jobs / websockets build a context directly, no
|
|
18
|
+
* `fakeRequest`),
|
|
19
|
+
* - unit-testable (no Fastify object required).
|
|
20
|
+
*
|
|
21
|
+
* `scopeOf` is the single seam every primitive uses to read the request scope.
|
|
22
|
+
* It prefers the context's first-class `scope` (set by combinators + adapters)
|
|
23
|
+
* and falls back to the raw request during migration (the `ctx.request` shim
|
|
24
|
+
* removed in the final phase). Once `scope` is populated everywhere, the
|
|
25
|
+
* fallback is dead and drops out.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the request scope for a permission check. Prefers the context's
|
|
29
|
+
* first-class `scope`; falls back to the raw HTTP `request.scope` when present,
|
|
30
|
+
* else `PUBLIC_SCOPE`. Primitives MUST use this instead of touching
|
|
31
|
+
* `ctx.request` so scope threads through combinators and non-HTTP transports
|
|
32
|
+
* (MCP, jobs) — which have no request — resolve identity correctly.
|
|
33
|
+
*/
|
|
34
|
+
function scopeOf(ctx) {
|
|
35
|
+
if (ctx.scope) return ctx.scope;
|
|
36
|
+
return ctx.request ? getRequestScope(ctx.request) : PUBLIC_SCOPE;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
6
39
|
//#region src/permissions/core.ts
|
|
7
40
|
/**
|
|
41
|
+
* Build an `allow` {@link AuthorizationDecision}, optionally attaching a row-level
|
|
42
|
+
* `policy` and/or a `scope` to install. The idiomatic "grant" return for arc
|
|
43
|
+
* 2.30 checks (parallels Cedar's `permit`).
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* return allow(); // simple grant
|
|
47
|
+
* return allow({ policy: { ownerId: userId } }); // grant + row policy
|
|
48
|
+
*/
|
|
49
|
+
function allow(extra) {
|
|
50
|
+
return extra ? {
|
|
51
|
+
effect: "allow",
|
|
52
|
+
...extra
|
|
53
|
+
} : { effect: "allow" };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build a `deny` {@link AuthorizationDecision} with an optional human-readable
|
|
57
|
+
* reason (parallels Cedar's `forbid`). The idiomatic "refuse" return.
|
|
58
|
+
*/
|
|
59
|
+
function deny(reason) {
|
|
60
|
+
return reason ? {
|
|
61
|
+
effect: "deny",
|
|
62
|
+
reason
|
|
63
|
+
} : { effect: "deny" };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
8
66
|
* Normalize a `string | [readonly string[]]` rest-args tuple into a single
|
|
9
67
|
* `readonly string[]`. Lets a permission helper accept BOTH variadic and
|
|
10
68
|
* array call shapes from one overload signature.
|
|
@@ -45,10 +103,7 @@ function allowPublic() {
|
|
|
45
103
|
*/
|
|
46
104
|
function requireAuth() {
|
|
47
105
|
const check = (ctx) => {
|
|
48
|
-
if (!ctx.user) return
|
|
49
|
-
granted: false,
|
|
50
|
-
reason: "Authentication required"
|
|
51
|
-
};
|
|
106
|
+
if (!ctx.user) return deny("Authentication required");
|
|
52
107
|
return true;
|
|
53
108
|
};
|
|
54
109
|
return check;
|
|
@@ -69,22 +124,16 @@ function requireRoles(rolesOrFirst, optionsOrSecond, ...rest) {
|
|
|
69
124
|
}
|
|
70
125
|
const includeOrgRoles = options?.includeOrgRoles ?? true;
|
|
71
126
|
const check = (ctx) => {
|
|
72
|
-
if (!ctx.user) return
|
|
73
|
-
granted: false,
|
|
74
|
-
reason: "Authentication required"
|
|
75
|
-
};
|
|
127
|
+
if (!ctx.user) return deny("Authentication required");
|
|
76
128
|
const userRoles = getUserRoles(ctx.user);
|
|
77
129
|
if (options?.bypassRoles?.some((r) => userRoles.includes(r))) return true;
|
|
78
130
|
if (roles.some((r) => userRoles.includes(r))) return true;
|
|
79
131
|
if (includeOrgRoles) {
|
|
80
|
-
const scope =
|
|
132
|
+
const scope = scopeOf(ctx);
|
|
81
133
|
if (isElevated(scope)) return true;
|
|
82
134
|
if (isMember(scope) && roles.some((r) => scope.orgRoles.includes(r))) return true;
|
|
83
135
|
}
|
|
84
|
-
return {
|
|
85
|
-
granted: false,
|
|
86
|
-
reason: `Required roles: ${roles.join(", ")}`
|
|
87
|
-
};
|
|
136
|
+
return deny(`Required roles: ${roles.join(", ")}`);
|
|
88
137
|
};
|
|
89
138
|
check._roles = roles;
|
|
90
139
|
return check;
|
|
@@ -97,19 +146,13 @@ function requireRoles(rolesOrFirst, optionsOrSecond, ...rest) {
|
|
|
97
146
|
function roles(...args) {
|
|
98
147
|
const roleList = normalizeVariadicOrArray(args);
|
|
99
148
|
const check = (ctx) => {
|
|
100
|
-
if (!ctx.user) return
|
|
101
|
-
granted: false,
|
|
102
|
-
reason: "Authentication required"
|
|
103
|
-
};
|
|
149
|
+
if (!ctx.user) return deny("Authentication required");
|
|
104
150
|
const userRoles = getUserRoles(ctx.user);
|
|
105
151
|
if (roleList.some((r) => userRoles.includes(r))) return true;
|
|
106
|
-
const scope =
|
|
152
|
+
const scope = scopeOf(ctx);
|
|
107
153
|
if (isElevated(scope)) return true;
|
|
108
154
|
if (isMember(scope) && roleList.some((r) => scope.orgRoles.includes(r))) return true;
|
|
109
|
-
return {
|
|
110
|
-
granted: false,
|
|
111
|
-
reason: `Required roles: ${roleList.join(", ")}`
|
|
112
|
-
};
|
|
155
|
+
return deny(`Required roles: ${roleList.join(", ")}`);
|
|
113
156
|
};
|
|
114
157
|
check._roles = roleList;
|
|
115
158
|
return check;
|
|
@@ -128,31 +171,29 @@ function roles(...args) {
|
|
|
128
171
|
*/
|
|
129
172
|
function requireOwnership(ownerField = "userId", options) {
|
|
130
173
|
return (ctx) => {
|
|
131
|
-
if (!ctx.user) return
|
|
132
|
-
granted: false,
|
|
133
|
-
reason: "Authentication required"
|
|
134
|
-
};
|
|
174
|
+
if (!ctx.user) return deny("Authentication required");
|
|
135
175
|
const userRoles = getUserRoles(ctx.user);
|
|
136
176
|
if (options?.bypassRoles?.some((r) => userRoles.includes(r))) return true;
|
|
137
|
-
const userId = getUserId(
|
|
138
|
-
if (!userId) return
|
|
139
|
-
|
|
140
|
-
reason: "User identity missing (no id or _id)"
|
|
141
|
-
};
|
|
142
|
-
return {
|
|
143
|
-
granted: true,
|
|
144
|
-
filters: { [ownerField]: userId }
|
|
145
|
-
};
|
|
177
|
+
const userId = getUserId(scopeOf(ctx)) ?? ctx.user.id ?? ctx.user._id;
|
|
178
|
+
if (!userId) return deny("User identity missing (no id or _id)");
|
|
179
|
+
return allow({ policy: { [ownerField]: userId } });
|
|
146
180
|
};
|
|
147
181
|
}
|
|
148
182
|
/**
|
|
149
183
|
* Combine multiple checks — ALL must pass (AND logic).
|
|
150
184
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
185
|
+
* Evaluation is PURE — nothing is written to the request. Each child runs
|
|
186
|
+
* against the accumulated state of previous children, threaded through a fresh
|
|
187
|
+
* child context:
|
|
188
|
+
* - `policy` from earlier children is CONJOINED (logical AND) — a later child
|
|
189
|
+
* can add or narrow restrictions but never silently replaces an earlier
|
|
190
|
+
* child's constraint on the same key (conflicting values are preserved under
|
|
191
|
+
* `$and`; see `conjoinPolicyFilters`)
|
|
192
|
+
* - `scope` installed by an earlier child is visible to the next through
|
|
193
|
+
* `scopeOf(ctx)`, and never downgrades an already-authoritative scope
|
|
154
194
|
*
|
|
155
|
-
* The
|
|
195
|
+
* The returned decision carries the merged `policy` + `scope`; the enforcement
|
|
196
|
+
* point (`applyAuthorizationDecision`) applies them to the request once.
|
|
156
197
|
*
|
|
157
198
|
* @example
|
|
158
199
|
* ```typescript
|
|
@@ -163,50 +204,37 @@ function requireOwnership(ownerField = "userId", options) {
|
|
|
163
204
|
* ```
|
|
164
205
|
*/
|
|
165
206
|
function allOf(...checks) {
|
|
166
|
-
|
|
207
|
+
const check = async (ctx) => {
|
|
167
208
|
let mergedFilters = {};
|
|
168
209
|
let installedScope;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
...mergedFilters,
|
|
184
|
-
...normalized.filters
|
|
185
|
-
};
|
|
186
|
-
sink._policyFilters = {
|
|
187
|
-
...sink._policyFilters ?? {},
|
|
188
|
-
...normalized.filters
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
if (normalized.scope) {
|
|
192
|
-
const current = sink.scope;
|
|
193
|
-
if (!current || current.kind === "public") {
|
|
194
|
-
sink.scope = normalized.scope;
|
|
195
|
-
installedScope = normalized.scope;
|
|
196
|
-
} else if (!installedScope) installedScope = normalized.scope;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
} catch (err) {
|
|
200
|
-
sink._policyFilters = originalFilters;
|
|
201
|
-
sink.scope = originalScope;
|
|
202
|
-
throw err;
|
|
210
|
+
let threaded = ctx.scope;
|
|
211
|
+
let childCtx = ctx;
|
|
212
|
+
for (const inner of checks) {
|
|
213
|
+
const decision = normalizeToDecision(await inner(childCtx));
|
|
214
|
+
if (decision.effect !== "allow") return deny(decision.reason);
|
|
215
|
+
if (decision.policy) mergedFilters = conjoinPolicyFilters(mergedFilters, decision.policy);
|
|
216
|
+
if (decision.scope && (!threaded || threaded.kind === "public")) {
|
|
217
|
+
threaded = decision.scope;
|
|
218
|
+
installedScope = decision.scope;
|
|
219
|
+
} else if (decision.scope && !installedScope) installedScope = decision.scope;
|
|
220
|
+
if (threaded !== childCtx.scope) childCtx = {
|
|
221
|
+
...childCtx,
|
|
222
|
+
scope: threaded
|
|
223
|
+
};
|
|
203
224
|
}
|
|
204
|
-
return {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
};
|
|
225
|
+
return allow({
|
|
226
|
+
...Object.keys(mergedFilters).length > 0 ? { policy: mergedFilters } : {},
|
|
227
|
+
...installedScope ? { scope: installedScope } : {}
|
|
228
|
+
});
|
|
209
229
|
};
|
|
230
|
+
const roleBranches = checks.filter((c) => (c._roles?.length ?? 0) > 0 || (c._orgRoles?.length ?? 0) > 0);
|
|
231
|
+
if (checks.length > 0 && checks.every((c) => c._isPublic)) check._isPublic = true;
|
|
232
|
+
else if (roleBranches.length === 1) {
|
|
233
|
+
const [rb] = roleBranches;
|
|
234
|
+
if (rb?._roles?.length) check._roles = [...rb._roles];
|
|
235
|
+
if (rb?._orgRoles?.length) check._orgRoles = [...rb._orgRoles];
|
|
236
|
+
}
|
|
237
|
+
return check;
|
|
210
238
|
}
|
|
211
239
|
/**
|
|
212
240
|
* Combine multiple checks — ANY must pass (OR logic).
|
|
@@ -219,19 +247,30 @@ function allOf(...checks) {
|
|
|
219
247
|
* ```
|
|
220
248
|
*/
|
|
221
249
|
function anyOf(...checks) {
|
|
222
|
-
|
|
250
|
+
const check = async (ctx) => {
|
|
223
251
|
const reasons = [];
|
|
224
|
-
for (const
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
252
|
+
for (const inner of checks) {
|
|
253
|
+
const decision = normalizeToDecision(await inner(ctx));
|
|
254
|
+
if (decision.effect === "allow") return allow({
|
|
255
|
+
...decision.policy ? { policy: decision.policy } : {},
|
|
256
|
+
...decision.scope ? { scope: decision.scope } : {}
|
|
257
|
+
});
|
|
258
|
+
if (decision.reason) reasons.push(decision.reason);
|
|
229
259
|
}
|
|
230
|
-
return
|
|
231
|
-
granted: false,
|
|
232
|
-
reason: reasons.join("; ")
|
|
233
|
-
};
|
|
260
|
+
return deny(reasons.join("; "));
|
|
234
261
|
};
|
|
262
|
+
if (checks.some((c) => c._isPublic)) check._isPublic = true;
|
|
263
|
+
else if (checks.every((c) => (c._roles?.length ?? 0) > 0 || (c._orgRoles?.length ?? 0) > 0)) {
|
|
264
|
+
const roles = /* @__PURE__ */ new Set();
|
|
265
|
+
const orgRoles = /* @__PURE__ */ new Set();
|
|
266
|
+
for (const c of checks) {
|
|
267
|
+
for (const r of c._roles ?? []) roles.add(r);
|
|
268
|
+
for (const r of c._orgRoles ?? []) orgRoles.add(r);
|
|
269
|
+
}
|
|
270
|
+
if (roles.size > 0) check._roles = [...roles];
|
|
271
|
+
if (orgRoles.size > 0) check._orgRoles = [...orgRoles];
|
|
272
|
+
}
|
|
273
|
+
return check;
|
|
235
274
|
}
|
|
236
275
|
/**
|
|
237
276
|
* Invert a permission check. Grants when the wrapped check denies, denies
|
|
@@ -251,11 +290,7 @@ function anyOf(...checks) {
|
|
|
251
290
|
*/
|
|
252
291
|
function not(check, reason = "Access denied") {
|
|
253
292
|
return async (ctx) => {
|
|
254
|
-
|
|
255
|
-
return (typeof result === "boolean" ? { granted: result } : result).granted ? {
|
|
256
|
-
granted: false,
|
|
257
|
-
reason
|
|
258
|
-
} : true;
|
|
293
|
+
return normalizeToDecision(await check(ctx)).effect === "allow" ? deny(reason) : true;
|
|
259
294
|
};
|
|
260
295
|
}
|
|
261
296
|
/**
|
|
@@ -267,10 +302,7 @@ function not(check, reason = "Access denied") {
|
|
|
267
302
|
* ```
|
|
268
303
|
*/
|
|
269
304
|
function denyAll(reason = "Access denied") {
|
|
270
|
-
return () => (
|
|
271
|
-
granted: false,
|
|
272
|
-
reason
|
|
273
|
-
});
|
|
305
|
+
return () => deny(reason);
|
|
274
306
|
}
|
|
275
307
|
/**
|
|
276
308
|
* Dynamic permission based on a condition function.
|
|
@@ -284,11 +316,7 @@ function denyAll(reason = "Access denied") {
|
|
|
284
316
|
*/
|
|
285
317
|
function when(condition) {
|
|
286
318
|
return async (ctx) => {
|
|
287
|
-
|
|
288
|
-
return {
|
|
289
|
-
granted: result,
|
|
290
|
-
reason: result ? void 0 : "Condition not met"
|
|
291
|
-
};
|
|
319
|
+
return await condition(ctx) ? allow() : deny("Condition not met");
|
|
292
320
|
};
|
|
293
321
|
}
|
|
294
322
|
//#endregion
|
|
@@ -349,16 +377,10 @@ const DEFAULT_TTL_GRACE_MS = 3e4;
|
|
|
349
377
|
*/
|
|
350
378
|
function requireDPoP() {
|
|
351
379
|
const check = (ctx) => {
|
|
352
|
-
const scope =
|
|
380
|
+
const scope = scopeOf(ctx);
|
|
353
381
|
if (isElevated(scope)) return true;
|
|
354
|
-
if (!isService(scope)) return
|
|
355
|
-
|
|
356
|
-
reason: "DPoP-bound service identity required. Configure your authenticate callback to set scope.dpopJkt after verifying the DPoP proof header (RFC 9449)."
|
|
357
|
-
};
|
|
358
|
-
if (!getDPoPJkt(scope)) return {
|
|
359
|
-
granted: false,
|
|
360
|
-
reason: "Sender-constrained credential required (DPoP). Inbound token is bearer; replay-resistance is mandatory on this endpoint."
|
|
361
|
-
};
|
|
382
|
+
if (!isService(scope)) return deny("DPoP-bound service identity required. Configure your authenticate callback to set scope.dpopJkt after verifying the DPoP proof header (RFC 9449).");
|
|
383
|
+
if (!getDPoPJkt(scope)) return deny("Sender-constrained credential required (DPoP). Inbound token is bearer; replay-resistance is mandatory on this endpoint.");
|
|
362
384
|
return true;
|
|
363
385
|
};
|
|
364
386
|
check._dpopRequired = true;
|
|
@@ -404,38 +426,20 @@ function requireMandate(capability, opts = {}) {
|
|
|
404
426
|
const ttlGrace = opts.ttlGraceMs ?? DEFAULT_TTL_GRACE_MS;
|
|
405
427
|
const noElevatedBypass = opts.noElevatedBypass === true;
|
|
406
428
|
const check = (ctx) => {
|
|
407
|
-
const scope =
|
|
429
|
+
const scope = scopeOf(ctx);
|
|
408
430
|
if (isElevated(scope) && !noElevatedBypass) return true;
|
|
409
431
|
const mandate = getMandate(scope);
|
|
410
|
-
if (!mandate) return {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
};
|
|
414
|
-
if (mandate.capability !== capability) return {
|
|
415
|
-
granted: false,
|
|
416
|
-
reason: `Mandate authorizes "${mandate.capability}", not "${capability}"`
|
|
417
|
-
};
|
|
418
|
-
if (mandate.expiresAt !== void 0 && Date.now() > mandate.expiresAt + ttlGrace) return {
|
|
419
|
-
granted: false,
|
|
420
|
-
reason: `Mandate expired at ${new Date(mandate.expiresAt).toISOString()}`
|
|
421
|
-
};
|
|
432
|
+
if (!mandate) return deny(`Capability mandate required (${capability}). Configure your authenticate callback to populate request.scope.mandate after verifying the mandate JWT/VC.`);
|
|
433
|
+
if (mandate.capability !== capability) return deny(`Mandate authorizes "${mandate.capability}", not "${capability}"`);
|
|
434
|
+
if (mandate.expiresAt !== void 0 && Date.now() > mandate.expiresAt + ttlGrace) return deny(`Mandate expired at ${new Date(mandate.expiresAt).toISOString()}`);
|
|
422
435
|
if (opts.audience) {
|
|
423
436
|
const required = typeof opts.audience === "function" ? opts.audience(ctx) : opts.audience;
|
|
424
|
-
if (required && mandate.audience && mandate.audience !== required) return {
|
|
425
|
-
|
|
426
|
-
reason: `Mandate is bound to "${mandate.audience}", not "${required}"`
|
|
427
|
-
};
|
|
428
|
-
if (required && !mandate.audience) return {
|
|
429
|
-
granted: false,
|
|
430
|
-
reason: `Mandate must be bound to "${required}" (no audience claim)`
|
|
431
|
-
};
|
|
437
|
+
if (required && mandate.audience && mandate.audience !== required) return deny(`Mandate is bound to "${mandate.audience}", not "${required}"`);
|
|
438
|
+
if (required && !mandate.audience) return deny(`Mandate must be bound to "${required}" (no audience claim)`);
|
|
432
439
|
}
|
|
433
440
|
if (opts.validateAmount) {
|
|
434
441
|
const result = opts.validateAmount(ctx, mandate);
|
|
435
|
-
if (result !== true) return {
|
|
436
|
-
granted: false,
|
|
437
|
-
reason: typeof result === "string" ? result : `Action exceeds mandate cap ${mandate.cap}${mandate.currency ? ` ${mandate.currency}` : ""}`
|
|
438
|
-
};
|
|
442
|
+
if (result !== true) return deny(typeof result === "string" ? result : `Action exceeds mandate cap ${mandate.cap}${mandate.currency ? ` ${mandate.currency}` : ""}`);
|
|
439
443
|
}
|
|
440
444
|
return true;
|
|
441
445
|
};
|
|
@@ -482,30 +486,18 @@ function requireAgentScope(opts) {
|
|
|
482
486
|
const dpopCheck = needsDPoP ? requireDPoP() : null;
|
|
483
487
|
const requiredScopes = scopes && scopes.length > 0 ? [...scopes] : null;
|
|
484
488
|
const check = async (ctx) => {
|
|
485
|
-
const scope =
|
|
489
|
+
const scope = scopeOf(ctx);
|
|
486
490
|
if (isElevated(scope) && !mandateOpts.noElevatedBypass) return true;
|
|
487
|
-
if (!isService(scope)) return
|
|
488
|
-
granted: false,
|
|
489
|
-
reason: "Service identity required (machine principal). Agent flows must authenticate as a service, not a logged-in user."
|
|
490
|
-
};
|
|
491
|
+
if (!isService(scope)) return deny("Service identity required (machine principal). Agent flows must authenticate as a service, not a logged-in user.");
|
|
491
492
|
if (requiredScopes) {
|
|
492
493
|
const granted = scope.scopes ?? [];
|
|
493
|
-
if (!requiredScopes.some((s) => granted.includes(s))) return {
|
|
494
|
-
granted: false,
|
|
495
|
-
reason: `Service identity is missing required OAuth scope(s): ${requiredScopes.join(", ")}`
|
|
496
|
-
};
|
|
494
|
+
if (!requiredScopes.some((s) => granted.includes(s))) return deny(`Service identity is missing required OAuth scope(s): ${requiredScopes.join(", ")}`);
|
|
497
495
|
}
|
|
498
496
|
const mandateResult = await mandateCheck(ctx);
|
|
499
|
-
if (mandateResult !== true) return typeof mandateResult === "object" ? mandateResult :
|
|
500
|
-
granted: false,
|
|
501
|
-
reason: "Mandate check failed"
|
|
502
|
-
};
|
|
497
|
+
if (mandateResult !== true) return typeof mandateResult === "object" ? mandateResult : deny("Mandate check failed");
|
|
503
498
|
if (dpopCheck) {
|
|
504
499
|
const dpopResult = await dpopCheck(ctx);
|
|
505
|
-
if (dpopResult !== true) return typeof dpopResult === "object" ? dpopResult :
|
|
506
|
-
granted: false,
|
|
507
|
-
reason: "DPoP binding required"
|
|
508
|
-
};
|
|
500
|
+
if (dpopResult !== true) return typeof dpopResult === "object" ? dpopResult : deny("DPoP binding required");
|
|
509
501
|
}
|
|
510
502
|
return true;
|
|
511
503
|
};
|
|
@@ -521,9 +513,9 @@ function requireAgentScope(opts) {
|
|
|
521
513
|
/**
|
|
522
514
|
* Permission Scope — checks bound to RequestScope.
|
|
523
515
|
*
|
|
524
|
-
* All read
|
|
525
|
-
* JWT custom auth
|
|
526
|
-
* `
|
|
516
|
+
* All read the request scope via `scopeOf(ctx)` - populated by an auth adapter
|
|
517
|
+
* (Better Auth bridge, JWT custom auth) or threaded from an upstream check's
|
|
518
|
+
* `AuthorizationDecision.scope`.
|
|
527
519
|
*/
|
|
528
520
|
/**
|
|
529
521
|
* Require an org-bound caller. Grants for `member`, `service`, and
|
|
@@ -544,15 +536,9 @@ function requireAgentScope(opts) {
|
|
|
544
536
|
*/
|
|
545
537
|
function requireOrgMembership() {
|
|
546
538
|
const check = (ctx) => {
|
|
547
|
-
if (hasOrgAccess(
|
|
548
|
-
if (!ctx.user) return
|
|
549
|
-
|
|
550
|
-
reason: "Authentication required"
|
|
551
|
-
};
|
|
552
|
-
return {
|
|
553
|
-
granted: false,
|
|
554
|
-
reason: "Organization membership required"
|
|
555
|
-
};
|
|
539
|
+
if (hasOrgAccess(scopeOf(ctx))) return true;
|
|
540
|
+
if (!ctx.user) return deny("Authentication required");
|
|
541
|
+
return deny("Organization membership required");
|
|
556
542
|
};
|
|
557
543
|
check._orgPermission = "membership";
|
|
558
544
|
return check;
|
|
@@ -579,25 +565,13 @@ function requireOrgMembership() {
|
|
|
579
565
|
function requireOrgRole(...args) {
|
|
580
566
|
const roles = normalizeVariadicOrArray(args);
|
|
581
567
|
const check = (ctx) => {
|
|
582
|
-
const scope =
|
|
568
|
+
const scope = scopeOf(ctx);
|
|
583
569
|
if (isElevated(scope)) return true;
|
|
584
|
-
if (isService(scope)) return
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
};
|
|
588
|
-
if (!ctx.user) return {
|
|
589
|
-
granted: false,
|
|
590
|
-
reason: "Authentication required"
|
|
591
|
-
};
|
|
592
|
-
if (!isMember(scope)) return {
|
|
593
|
-
granted: false,
|
|
594
|
-
reason: "Organization membership required"
|
|
595
|
-
};
|
|
570
|
+
if (isService(scope)) return deny("Service scopes (API keys) cannot satisfy requireOrgRole. Use requireServiceScope(...) for machine identities, or compose with anyOf(requireOrgRole(...), requireServiceScope(...)) to accept both.");
|
|
571
|
+
if (!ctx.user) return deny("Authentication required");
|
|
572
|
+
if (!isMember(scope)) return deny("Organization membership required");
|
|
596
573
|
if (roles.some((r) => scope.orgRoles.includes(r))) return true;
|
|
597
|
-
return {
|
|
598
|
-
granted: false,
|
|
599
|
-
reason: `Required org roles: ${roles.join(", ")}`
|
|
600
|
-
};
|
|
574
|
+
return deny(`Required org roles: ${roles.join(", ")}`);
|
|
601
575
|
};
|
|
602
576
|
check._orgRoles = roles;
|
|
603
577
|
return check;
|
|
@@ -629,18 +603,12 @@ function requireServiceScope(...args) {
|
|
|
629
603
|
const required = normalizeVariadicOrArray(args);
|
|
630
604
|
if (required.length === 0) throw new Error("requireServiceScope() requires at least one scope string (e.g. requireServiceScope('jobs:write'))");
|
|
631
605
|
const check = (ctx) => {
|
|
632
|
-
const scope =
|
|
606
|
+
const scope = scopeOf(ctx);
|
|
633
607
|
if (isElevated(scope)) return true;
|
|
634
|
-
if (!isService(scope)) return
|
|
635
|
-
granted: false,
|
|
636
|
-
reason: "Service identity required (API key). For human users, use requireOrgRole(...) or compose with anyOf(requireOrgRole(...), requireServiceScope(...))."
|
|
637
|
-
};
|
|
608
|
+
if (!isService(scope)) return deny("Service identity required (API key). For human users, use requireOrgRole(...) or compose with anyOf(requireOrgRole(...), requireServiceScope(...)).");
|
|
638
609
|
const granted = getServiceScopes(scope);
|
|
639
610
|
if (required.some((r) => granted.includes(r))) return true;
|
|
640
|
-
return {
|
|
641
|
-
granted: false,
|
|
642
|
-
reason: `Required service scopes: ${required.join(", ")} (granted: ${granted.length > 0 ? granted.join(", ") : "none"})`
|
|
643
|
-
};
|
|
611
|
+
return deny(`Required service scopes: ${required.join(", ")} (granted: ${granted.length > 0 ? granted.join(", ") : "none"})`);
|
|
644
612
|
};
|
|
645
613
|
check._serviceScopes = required;
|
|
646
614
|
return check;
|
|
@@ -678,23 +646,14 @@ function requireScopeContext(keyOrMap, value) {
|
|
|
678
646
|
const requiredKeys = Object.keys(required);
|
|
679
647
|
if (requiredKeys.length === 0) throw new Error("requireScopeContext() requires at least one key (e.g. requireScopeContext('branchId'))");
|
|
680
648
|
const check = (ctx) => {
|
|
681
|
-
const scope =
|
|
649
|
+
const scope = scopeOf(ctx);
|
|
682
650
|
if (isElevated(scope)) return true;
|
|
683
|
-
if (!getScopeContextMap(scope)) return
|
|
684
|
-
granted: false,
|
|
685
|
-
reason: "Scope context required (member, service, or elevated scope). Populate request.scope.context in your auth function."
|
|
686
|
-
};
|
|
651
|
+
if (!getScopeContextMap(scope)) return deny("Scope context required (member, service, or elevated scope). Populate request.scope.context in your auth function.");
|
|
687
652
|
for (const key of requiredKeys) {
|
|
688
653
|
const expected = required[key];
|
|
689
654
|
const actual = getScopeContext(scope, key);
|
|
690
|
-
if (actual === void 0) return {
|
|
691
|
-
|
|
692
|
-
reason: `Required scope context key "${key}" is missing`
|
|
693
|
-
};
|
|
694
|
-
if (expected !== void 0 && actual !== expected) return {
|
|
695
|
-
granted: false,
|
|
696
|
-
reason: `Required scope context "${key}" must equal "${expected}" (got "${actual}")`
|
|
697
|
-
};
|
|
655
|
+
if (actual === void 0) return deny(`Required scope context key "${key}" is missing`);
|
|
656
|
+
if (expected !== void 0 && actual !== expected) return deny(`Required scope context "${key}" must equal "${expected}" (got "${actual}")`);
|
|
698
657
|
}
|
|
699
658
|
return true;
|
|
700
659
|
};
|
|
@@ -711,8 +670,8 @@ function requireScopeContext(keyOrMap, value) {
|
|
|
711
670
|
*
|
|
712
671
|
* **Two call shapes:**
|
|
713
672
|
* ```typescript
|
|
714
|
-
* requireOrgInScope('acme-holding')
|
|
715
|
-
* requireOrgInScope((ctx) => ctx.
|
|
673
|
+
* requireOrgInScope('acme-holding') // static
|
|
674
|
+
* requireOrgInScope((ctx) => ctx.params?.orgId) // dynamic — from ctx facts, not the raw request
|
|
716
675
|
* ```
|
|
717
676
|
*
|
|
718
677
|
* `elevated` scope grants unconditionally (cross-org bypass).
|
|
@@ -720,9 +679,9 @@ function requireScopeContext(keyOrMap, value) {
|
|
|
720
679
|
* @example
|
|
721
680
|
* ```typescript
|
|
722
681
|
* permissions: {
|
|
723
|
-
* list: requireOrgInScope((ctx) => ctx.
|
|
682
|
+
* list: requireOrgInScope((ctx) => ctx.params?.orgId),
|
|
724
683
|
* create: allOf(
|
|
725
|
-
* requireOrgInScope((ctx) => ctx.
|
|
684
|
+
* requireOrgInScope((ctx) => (ctx.data as { organizationId?: string })?.organizationId),
|
|
726
685
|
* requireOrgRole('admin'),
|
|
727
686
|
* ),
|
|
728
687
|
* }
|
|
@@ -731,18 +690,12 @@ function requireScopeContext(keyOrMap, value) {
|
|
|
731
690
|
function requireOrgInScope(target) {
|
|
732
691
|
if (target === void 0 || target === null) throw new Error("requireOrgInScope() requires a target org id (string) or an extractor function");
|
|
733
692
|
const check = (ctx) => {
|
|
734
|
-
const scope =
|
|
693
|
+
const scope = scopeOf(ctx);
|
|
735
694
|
if (isElevated(scope)) return true;
|
|
736
695
|
const targetOrgId = typeof target === "function" ? target(ctx) : target;
|
|
737
|
-
if (!targetOrgId) return
|
|
738
|
-
granted: false,
|
|
739
|
-
reason: "requireOrgInScope: target org id could not be resolved from the request"
|
|
740
|
-
};
|
|
696
|
+
if (!targetOrgId) return deny("requireOrgInScope: target org id could not be resolved from the request");
|
|
741
697
|
if (isOrgInScope(scope, targetOrgId)) return true;
|
|
742
|
-
return {
|
|
743
|
-
granted: false,
|
|
744
|
-
reason: `Target organization "${targetOrgId}" is not in the caller's org hierarchy`
|
|
745
|
-
};
|
|
698
|
+
return deny(`Target organization "${targetOrgId}" is not in the caller's org hierarchy`);
|
|
746
699
|
};
|
|
747
700
|
check._orgInScopeTarget = target;
|
|
748
701
|
return check;
|
|
@@ -762,20 +715,11 @@ function requireOrgInScope(target) {
|
|
|
762
715
|
*/
|
|
763
716
|
function requireTeamMembership() {
|
|
764
717
|
const check = (ctx) => {
|
|
765
|
-
if (!ctx.user) return
|
|
766
|
-
|
|
767
|
-
reason: "Authentication required"
|
|
768
|
-
};
|
|
769
|
-
const scope = getRequestScope(ctx.request);
|
|
718
|
+
if (!ctx.user) return deny("Authentication required");
|
|
719
|
+
const scope = scopeOf(ctx);
|
|
770
720
|
if (isElevated(scope)) return true;
|
|
771
|
-
if (!isMember(scope)) return
|
|
772
|
-
|
|
773
|
-
reason: "Organization membership required"
|
|
774
|
-
};
|
|
775
|
-
if (!getTeamId(scope)) return {
|
|
776
|
-
granted: false,
|
|
777
|
-
reason: "No active team"
|
|
778
|
-
};
|
|
721
|
+
if (!isMember(scope)) return deny("Organization membership required");
|
|
722
|
+
if (!getTeamId(scope)) return deny("No active team");
|
|
779
723
|
return true;
|
|
780
724
|
};
|
|
781
725
|
check._teamPermission = "membership";
|
|
@@ -832,21 +776,12 @@ function createOrgPermissions(config) {
|
|
|
832
776
|
return {
|
|
833
777
|
can(permissions) {
|
|
834
778
|
return (ctx) => {
|
|
835
|
-
if (!ctx.user) return
|
|
836
|
-
|
|
837
|
-
reason: "Authentication required"
|
|
838
|
-
};
|
|
839
|
-
const scope = getRequestScope(ctx.request);
|
|
779
|
+
if (!ctx.user) return deny("Authentication required");
|
|
780
|
+
const scope = scopeOf(ctx);
|
|
840
781
|
if (isElevated(scope)) return true;
|
|
841
|
-
if (!isMember(scope)) return
|
|
842
|
-
granted: false,
|
|
843
|
-
reason: "Organization membership required"
|
|
844
|
-
};
|
|
782
|
+
if (!isMember(scope)) return deny("Organization membership required");
|
|
845
783
|
if (hasPermissions(scope.orgRoles, permissions)) return true;
|
|
846
|
-
return {
|
|
847
|
-
granted: false,
|
|
848
|
-
reason: `Missing permissions: ${Object.entries(permissions).map(([r, a]) => `${r}:[${a.join(",")}]`).join(", ")}`
|
|
849
|
-
};
|
|
784
|
+
return deny(`Missing permissions: ${Object.entries(permissions).map(([r, a]) => `${r}:[${a.join(",")}]`).join(", ")}`);
|
|
850
785
|
};
|
|
851
786
|
},
|
|
852
787
|
requireRole(...roles) {
|
|
@@ -873,7 +808,7 @@ function createOrgPermissions(config) {
|
|
|
873
808
|
* - Cross-node invalidation via the event bus
|
|
874
809
|
*/
|
|
875
810
|
function createDynamicPermissionMatrix(config) {
|
|
876
|
-
const logger = config.logger ??
|
|
811
|
+
const logger = config.logger ?? arcLog("permissions:dynamic");
|
|
877
812
|
const configuredTtlSeconds = config.cache?.ttlSeconds ?? 0;
|
|
878
813
|
const hasExternalStore = !!config.cacheStore;
|
|
879
814
|
const cacheTtlSeconds = configuredTtlSeconds > 0 ? configuredTtlSeconds : hasExternalStore ? 300 : 0;
|
|
@@ -944,34 +879,19 @@ function createDynamicPermissionMatrix(config) {
|
|
|
944
879
|
}
|
|
945
880
|
function can(required) {
|
|
946
881
|
return async (ctx) => {
|
|
947
|
-
if (!ctx.user) return
|
|
948
|
-
|
|
949
|
-
reason: "Authentication required"
|
|
950
|
-
};
|
|
951
|
-
const scope = getRequestScope(ctx.request);
|
|
882
|
+
if (!ctx.user) return deny("Authentication required");
|
|
883
|
+
const scope = scopeOf(ctx);
|
|
952
884
|
if (isElevated(scope)) return true;
|
|
953
|
-
if (!isMember(scope)) return
|
|
954
|
-
granted: false,
|
|
955
|
-
reason: "Organization membership required"
|
|
956
|
-
};
|
|
885
|
+
if (!isMember(scope)) return deny("Organization membership required");
|
|
957
886
|
const orgRoles = scope.orgRoles;
|
|
958
|
-
if (orgRoles.length === 0) return
|
|
959
|
-
granted: false,
|
|
960
|
-
reason: "Not a member of this organization"
|
|
961
|
-
};
|
|
887
|
+
if (orgRoles.length === 0) return deny("Not a member of this organization");
|
|
962
888
|
let matrix;
|
|
963
889
|
try {
|
|
964
890
|
matrix = await resolveMatrix(ctx, scope.organizationId, orgRoles);
|
|
965
891
|
} catch (error) {
|
|
966
|
-
return {
|
|
967
|
-
granted: false,
|
|
968
|
-
reason: `Permission matrix resolution failed: ${error instanceof Error ? error.message : String(error)}`
|
|
969
|
-
};
|
|
892
|
+
return deny(`Permission matrix resolution failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
970
893
|
}
|
|
971
|
-
for (const [resource, actions] of Object.entries(required)) for (const action of actions) if (!orgRoles.some((role) => roleAllows(matrix, role, resource, action))) return {
|
|
972
|
-
granted: false,
|
|
973
|
-
reason: `Missing permission: ${resource}:${action}`
|
|
974
|
-
};
|
|
894
|
+
for (const [resource, actions] of Object.entries(required)) for (const action of actions) if (!orgRoles.some((role) => roleAllows(matrix, role, resource, action))) return deny(`Missing permission: ${resource}:${action}`);
|
|
975
895
|
return true;
|
|
976
896
|
};
|
|
977
897
|
}
|
|
@@ -1052,8 +972,132 @@ function createDynamicPermissionMatrix(config) {
|
|
|
1052
972
|
};
|
|
1053
973
|
}
|
|
1054
974
|
//#endregion
|
|
975
|
+
//#region src/permissions/explain.ts
|
|
976
|
+
/**
|
|
977
|
+
* Extract a check's static requirement from its `PermissionCheckMeta`. Public
|
|
978
|
+
* wins; then the union of platform + org roles a role gate declares; otherwise
|
|
979
|
+
* "authenticated" (auth-required / custom / request-time checks).
|
|
980
|
+
*/
|
|
981
|
+
function describePermission(check) {
|
|
982
|
+
if (check._isPublic) return { kind: "public" };
|
|
983
|
+
const roles = [...check._roles ?? [], ...check._orgRoles ?? []];
|
|
984
|
+
if (roles.length > 0) return {
|
|
985
|
+
kind: "roles",
|
|
986
|
+
roles
|
|
987
|
+
};
|
|
988
|
+
return { kind: "authenticated" };
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Explain whether a principal (by roles) passes a permission check, from static
|
|
992
|
+
* metadata alone — for permission-matrix UIs, docs, and audits. Omit `principal`
|
|
993
|
+
* to get the requirement-only view (roles → `conditional`).
|
|
994
|
+
*
|
|
995
|
+
* @example
|
|
996
|
+
* explainAccess(requireRoles("admin"), { roles: ["editor"] })
|
|
997
|
+
* // → { decision: "deny", reason: "requires one of: admin", requirement: { kind: "roles", roles: ["admin"] } }
|
|
998
|
+
*/
|
|
999
|
+
function explainAccess(check, principal) {
|
|
1000
|
+
const requirement = describePermission(check);
|
|
1001
|
+
if (requirement.kind === "public") return {
|
|
1002
|
+
decision: "allow",
|
|
1003
|
+
reason: "public — no authentication required",
|
|
1004
|
+
requirement
|
|
1005
|
+
};
|
|
1006
|
+
if (requirement.kind === "authenticated") return {
|
|
1007
|
+
decision: "conditional",
|
|
1008
|
+
reason: "requires authentication; decided per request",
|
|
1009
|
+
requirement
|
|
1010
|
+
};
|
|
1011
|
+
if (!principal) return {
|
|
1012
|
+
decision: "conditional",
|
|
1013
|
+
reason: `requires one of: ${requirement.roles.join(", ")}`,
|
|
1014
|
+
requirement
|
|
1015
|
+
};
|
|
1016
|
+
const held = /* @__PURE__ */ new Set([...principal.roles ?? [], ...principal.orgRoles ?? []]);
|
|
1017
|
+
const matched = requirement.roles.filter((r) => held.has(r));
|
|
1018
|
+
return matched.length > 0 ? {
|
|
1019
|
+
decision: "allow",
|
|
1020
|
+
reason: `granted via role(s): ${matched.join(", ")}`,
|
|
1021
|
+
requirement
|
|
1022
|
+
} : {
|
|
1023
|
+
decision: "deny",
|
|
1024
|
+
reason: `requires one of: ${requirement.roles.join(", ")}`,
|
|
1025
|
+
requirement
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Introspect a whole permission map (`{ list, get, create, ... }` or any
|
|
1030
|
+
* action→check record) into `action → requirement`. The reusable core of a
|
|
1031
|
+
* `/permissions/matrix` endpoint or an access audit — framework-owned, so a host
|
|
1032
|
+
* never hand-rolls `(check as any)._roles` peeking. Undefined slots (a CRUD op
|
|
1033
|
+
* with no gate = public-by-omission) surface as `public` so the inventory is
|
|
1034
|
+
* honest about what's exposed.
|
|
1035
|
+
*/
|
|
1036
|
+
function describePermissionMap(permissions) {
|
|
1037
|
+
const out = {};
|
|
1038
|
+
for (const [action, check] of Object.entries(permissions)) out[action] = check ? describePermission(check) : { kind: "public" };
|
|
1039
|
+
return out;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* The public attack surface of a permission map — the actions reachable with NO
|
|
1043
|
+
* authentication (explicit `allowPublic()` or public-by-omission). Empty is the
|
|
1044
|
+
* healthy default for a protected resource; a non-empty list is what a
|
|
1045
|
+
* "what's exposed?" audit report should show.
|
|
1046
|
+
*/
|
|
1047
|
+
function collectPublicSurface(permissions) {
|
|
1048
|
+
return Object.entries(describePermissionMap(permissions)).filter(([, req]) => req.kind === "public").map(([action]) => action);
|
|
1049
|
+
}
|
|
1050
|
+
//#endregion
|
|
1055
1051
|
//#region src/permissions/grants.ts
|
|
1056
1052
|
/**
|
|
1053
|
+
* Per-record grants — the `requireGrant` combinator + mode lattice.
|
|
1054
|
+
*
|
|
1055
|
+
* The one ACL idea arc's permission vocabulary lacked (design verdict:
|
|
1056
|
+
* `designs/record-sharing.md`): subject × record × mode grants, Puter-style,
|
|
1057
|
+
* WITHOUT Puter's mistakes. Arc ships only the GATE — grant storage is a
|
|
1058
|
+
* host `defineResource` (tenant-scoped, `audit: true`, any kit via
|
|
1059
|
+
* repo-core's `RepositoryLike`), and the `resolve` callback is structural,
|
|
1060
|
+
* so core stays DB-agnostic and storage-free by construction.
|
|
1061
|
+
*
|
|
1062
|
+
* What this design deliberately avoids (verified against Puter's source):
|
|
1063
|
+
* - string-typed permissions (`fs:<uuid>:read` VARCHARs that silently
|
|
1064
|
+
* never match when malformed) → typed {@link GrantMode} union
|
|
1065
|
+
* - per-item ACL checks on list responses (their readdir returns
|
|
1066
|
+
* unfiltered children; the UI stats each item — N+1) → resolutions
|
|
1067
|
+
* return a row `policy` that composes into THE list query via the
|
|
1068
|
+
* decision's `policy` → `_policyFilters` (one paginated, correct query)
|
|
1069
|
+
* - un-revocable capability tokens → share links should reference a
|
|
1070
|
+
* grant ROW (`subjectType: 'link'`); delete the row, the link dies
|
|
1071
|
+
*
|
|
1072
|
+
* @example
|
|
1073
|
+
* ```ts
|
|
1074
|
+
* // Host owns the grants table — it's just a resource:
|
|
1075
|
+
* // { resource, resourceId, subjectType: 'user'|'team'|'link',
|
|
1076
|
+
* // subjectId, mode, grantedBy, expiresAt? }
|
|
1077
|
+
* import { anyOf, requireGrant, requireOwnership } from '@classytic/arc/permissions';
|
|
1078
|
+
*
|
|
1079
|
+
* defineResource({
|
|
1080
|
+
* name: 'document',
|
|
1081
|
+
* permissions: {
|
|
1082
|
+
* get: anyOf(requireOwnership('ownerId'), requireGrant({ mode: 'read', resolve })),
|
|
1083
|
+
* update: anyOf(requireOwnership('ownerId'), requireGrant({ mode: 'write', resolve })),
|
|
1084
|
+
* list: requireGrant({
|
|
1085
|
+
* mode: 'list',
|
|
1086
|
+
* // List-shaped resolution: return filters — owner OR granted ids.
|
|
1087
|
+
* resolve: async (ctx) => ({
|
|
1088
|
+
* filters: {
|
|
1089
|
+
* $or: [
|
|
1090
|
+
* { ownerId: ctx.user?.id },
|
|
1091
|
+
* { _id: { $in: await grants.grantedIds(ctx) } },
|
|
1092
|
+
* ],
|
|
1093
|
+
* },
|
|
1094
|
+
* }),
|
|
1095
|
+
* }),
|
|
1096
|
+
* },
|
|
1097
|
+
* });
|
|
1098
|
+
* ```
|
|
1099
|
+
*/
|
|
1100
|
+
/**
|
|
1057
1101
|
* The grant-mode lattice, weakest → strongest. Holding a mode satisfies
|
|
1058
1102
|
* every mode at or below it (`write` ⊇ `read` ⊇ `list` ⊇ `see`); `manage`
|
|
1059
1103
|
* (grant/revoke rights) satisfies everything. Same semantics as Puter's
|
|
@@ -1095,36 +1139,21 @@ function requireGrant(options) {
|
|
|
1095
1139
|
try {
|
|
1096
1140
|
resolution = await resolve(ctx);
|
|
1097
1141
|
} catch (err) {
|
|
1098
|
-
ctx.request
|
|
1142
|
+
ctx.request?.log?.warn?.({
|
|
1099
1143
|
err,
|
|
1100
1144
|
resource: ctx.resource,
|
|
1101
1145
|
action: ctx.action
|
|
1102
1146
|
}, "requireGrant: resolve threw — denying (fail-closed)");
|
|
1103
|
-
return
|
|
1104
|
-
granted: false,
|
|
1105
|
-
reason: "Grant lookup failed"
|
|
1106
|
-
};
|
|
1147
|
+
return deny("Grant lookup failed");
|
|
1107
1148
|
}
|
|
1108
1149
|
if (typeof resolution === "boolean") return resolution;
|
|
1109
1150
|
const { mode: held, filters, reason } = resolution;
|
|
1110
1151
|
if (held !== void 0) {
|
|
1111
|
-
if (!modeSatisfies(held, required)) return
|
|
1112
|
-
|
|
1113
|
-
...reason ? { reason } : {}
|
|
1114
|
-
};
|
|
1115
|
-
return {
|
|
1116
|
-
granted: true,
|
|
1117
|
-
...filters ? { filters } : {}
|
|
1118
|
-
};
|
|
1152
|
+
if (!modeSatisfies(held, required)) return deny(reason);
|
|
1153
|
+
return allow(filters ? { policy: filters } : void 0);
|
|
1119
1154
|
}
|
|
1120
|
-
if (filters !== void 0) return {
|
|
1121
|
-
|
|
1122
|
-
filters
|
|
1123
|
-
};
|
|
1124
|
-
return {
|
|
1125
|
-
granted: false,
|
|
1126
|
-
...reason ? { reason } : {}
|
|
1127
|
-
};
|
|
1155
|
+
if (filters !== void 0) return allow({ policy: filters });
|
|
1156
|
+
return deny(reason);
|
|
1128
1157
|
};
|
|
1129
1158
|
}
|
|
1130
1159
|
//#endregion
|
|
@@ -1343,4 +1372,4 @@ function readOnly(overrides) {
|
|
|
1343
1372
|
}, overrides);
|
|
1344
1373
|
}
|
|
1345
1374
|
//#endregion
|
|
1346
|
-
export {
|
|
1375
|
+
export { allow as A, when as B, requireScopeContext as C, requireDPoP as D, requireAgentScope as E, not as F, requireAuth as I, requireOwnership as L, anyOf as M, deny as N, requireMandate as O, denyAll as P, requireRoles as R, requireOrgRole as S, requireTeamMembership as T, scopeOf as V, explainAccess as _, permissionMatrix as a, requireOrgInScope as b, publicReadAdminWrite as c, GRANT_MODES as d, modeSatisfies as f, describePermissionMap as g, describePermission as h, ownerWithAdminBypass as i, allowPublic as j, allOf as k, readOnly as l, collectPublicSurface as m, authenticated as n, presets_exports as o, requireGrant as p, fullPublic as r, publicRead as s, adminOnly as t, createRoleHierarchy as u, createDynamicPermissionMatrix as v, requireServiceScope as w, requireOrgMembership as x, createOrgPermissions as y, roles as z };
|