@classytic/arc 2.28.0 → 2.31.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/LICENSE +1 -1
- package/README.md +7 -0
- package/TRADEMARK.md +22 -0
- package/dist/{BaseController-B65zbz9m.mjs → BaseController-De7GqHck.mjs} +25 -24
- package/dist/{EventTransport-QmY7A258.d.mts → EventTransport-2uVV34WU.d.mts} +1 -0
- package/dist/EventTransport-9mlTgqzE.mjs +104 -0
- package/dist/{HookSystem-C1vB_Gc9.mjs → HookSystem-BcmPyq5I.mjs} +16 -35
- package/dist/{QueryCache-DOMT_iq7.d.mts → QueryCache-DFA-QrU9.d.mts} +2 -1
- package/dist/{ResourceRegistry-y9Hnypus.mjs → ResourceRegistry-qq0mn2s9.mjs} +3 -2
- package/dist/{actionPermissions-CAphcmUG.mjs → actionPermissions-BE5NwaQk.mjs} +1 -0
- package/dist/audit/index.d.mts +3 -2
- package/dist/audit/index.mjs +3 -2
- package/dist/auth/audit.d.mts +1 -0
- package/dist/auth/audit.mjs +1 -0
- package/dist/auth/index.d.mts +5 -4
- package/dist/auth/index.mjs +6 -5
- package/dist/auth/redis-session.d.mts +2 -1
- package/dist/auth/redis-session.mjs +1 -0
- package/dist/authorizationDecision-ChvUQGG1.mjs +266 -0
- package/dist/{buildHandler-Dtwd8fdz.mjs → buildHandler-CDrCJzmB.mjs} +34 -7
- package/dist/cache/index.d.mts +4 -3
- package/dist/cache/index.mjs +4 -3
- package/dist/{caching-_NJeDd1_.mjs → caching-BFHA6Scf.mjs} +2 -1
- package/dist/cleanup/index.d.mts +4 -3
- package/dist/cleanup/index.mjs +9 -13
- package/dist/cli/commands/describe.d.mts +2 -1
- package/dist/cli/commands/describe.mjs +3 -2
- package/dist/cli/commands/docs.d.mts +1 -0
- package/dist/cli/commands/docs.mjs +3 -2
- package/dist/cli/commands/doctor.d.mts +1 -0
- package/dist/cli/commands/doctor.mjs +1 -0
- package/dist/cli/commands/generate.d.mts +2 -1
- package/dist/cli/commands/generate.mjs +2 -1
- package/dist/cli/commands/init.d.mts +2 -1
- package/dist/cli/commands/init.mjs +2 -1
- package/dist/cli/commands/introspect.d.mts +1 -0
- package/dist/cli/commands/introspect.mjs +2 -1
- package/dist/cli/index.d.mts +3 -2
- package/dist/cli/index.mjs +3 -2
- package/dist/{constants-DNre0wRg.mjs → constants-D3Dv4LAV.mjs} +1 -0
- package/dist/context/index.d.mts +1 -0
- package/dist/context/index.mjs +3 -2
- package/dist/core/index.d.mts +3 -2
- package/dist/core/index.mjs +7 -5
- package/dist/{core-BGa8TXJR.mjs → core-BGpIxINN.mjs} +4 -49
- package/dist/{createActionRouter-C-N7z2EL.mjs → createActionRouter-CDXEdasl.mjs} +8 -7
- package/dist/{createAggregationRouter-wzwFa_dW.mjs → createAggregationRouter-KrQDzx-k.mjs} +10 -22
- package/dist/{createApp-Du5G54TM.mjs → createApp-Df89BEni.mjs} +505 -209
- package/dist/defineAggregation-DbPPS8RI.mjs +48 -0
- package/dist/{defineResource-2zvVVpxg.mjs → defineResource-Bv6EtN1x.mjs} +90 -72
- package/dist/discovery/index.d.mts +1 -0
- package/dist/discovery/index.mjs +1 -0
- package/dist/docs/index.d.mts +3 -2
- package/dist/docs/index.mjs +3 -2
- package/dist/{elevation-C9s5hFP_.d.mts → elevation-BXCfVcNG.d.mts} +1 -0
- package/dist/{elevation-CIz1GjGm.mjs → elevation-D2l1mh4g.mjs} +4 -3
- package/dist/encryption/index.d.mts +2 -1
- package/dist/encryption/index.mjs +3 -2
- package/dist/{errorHandler-Bq2OeEQL.mjs → errorHandler-C5NeD6bT.mjs} +3 -2
- package/dist/{errorHandler-C6yv4ZTW.d.mts → errorHandler-CpYUJmPA.d.mts} +1 -0
- package/dist/{errors-DHcHVAgk.d.mts → errors-BGdVwP25.d.mts} +1 -0
- package/dist/{errors-DkO8Nk6-.mjs → errors-Cj42AntO.mjs} +1 -0
- package/dist/{eventPlugin-DrQ_FesK.mjs → eventPlugin-CkffEihm.mjs} +6 -106
- package/dist/{eventPlugin-Bg3cP2_u.d.mts → eventPlugin-GF5nnBIq.d.mts} +2 -1
- package/dist/events/index.d.mts +4 -3
- package/dist/events/index.mjs +17 -340
- package/dist/events/transports/redis-stream-entry.d.mts +2 -1
- package/dist/events/transports/redis-stream-entry.mjs +1 -0
- package/dist/events/transports/redis.d.mts +2 -1
- package/dist/events/transports/redis.mjs +1 -0
- package/dist/{externalPaths-BE6wFpYv.d.mts → externalPaths-CmGOxOtD.d.mts} +1 -0
- package/dist/factory/index.d.mts +9 -5
- package/dist/factory/index.mjs +7 -6
- package/dist/{fastify-augmentation-OzYGIiU1.d.mts → fastify-augmentation-zh0_AYHa.d.mts} +13 -4
- package/dist/{applyPermissionResult-BnyVE9eD.mjs → fields-CB5Cuzk9.mjs} +2 -107
- package/dist/{fields-D3lhgzhD.d.mts → fields-I_EeRWND.d.mts} +1 -0
- package/dist/{generate-BnsL9GBm.d.mts → generate-DCGuEbqI.d.mts} +1 -0
- package/dist/{generate-D0wZupBg.mjs → generate-EcA6ui4i.mjs} +2 -1
- package/dist/{headers-BvlQSDMB.mjs → headers-3zvE0rM3.mjs} +2 -1
- package/dist/{health-CB_m4S32.mjs → health-DjZYjM7H.mjs} +1 -0
- package/dist/hooks/index.d.mts +2 -1
- package/dist/hooks/index.mjs +2 -1
- package/dist/idempotency/index.d.mts +3 -2
- package/dist/idempotency/index.mjs +5 -4
- package/dist/idempotency/redis.d.mts +2 -1
- package/dist/idempotency/redis.mjs +1 -0
- package/dist/{index-CjVg-Epg.d.mts → index-Bc2ox8_U.d.mts} +6 -5
- package/dist/{index-CG4yqMDB.d.mts → index-C-481Zf2.d.mts} +74 -18
- package/dist/{index-BMnbzvtO.d.mts → index-CEJNZFtX.d.mts} +242 -24
- package/dist/index-CFThn1K9.d.mts +224 -0
- package/dist/{index-DPRH0Cme.d.mts → index-CpzxqePp.d.mts} +203 -97
- package/dist/index.d.mts +9 -8
- package/dist/index.mjs +11 -9
- package/dist/{init-tlRSt667.mjs → init-BEiNZBsJ.mjs} +19 -23
- package/dist/{init-BPEu8cs-.d.mts → init-Cscs4lab.d.mts} +1 -0
- package/dist/integrations/event-gateway.d.mts +3 -2
- package/dist/integrations/event-gateway.mjs +2 -1
- package/dist/integrations/index.d.mts +4 -3
- package/dist/integrations/index.mjs +1 -0
- package/dist/integrations/jobs/index.d.mts +2 -1
- package/dist/integrations/jobs/index.mjs +108 -32
- package/dist/integrations/mcp/index.d.mts +9 -9
- package/dist/integrations/mcp/index.mjs +2 -1
- package/dist/integrations/mcp/testing.d.mts +3 -2
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.d.mts +1 -0
- package/dist/integrations/streamline.mjs +3 -2
- package/dist/integrations/webhooks.d.mts +1 -0
- package/dist/integrations/webhooks.mjs +1 -0
- package/dist/integrations/websocket-pushref-redis.d.mts +2 -1
- package/dist/integrations/websocket-pushref-redis.mjs +1 -0
- package/dist/integrations/websocket-redis.d.mts +2 -1
- package/dist/integrations/websocket-redis.mjs +1 -0
- package/dist/integrations/websocket.d.mts +3 -2
- package/dist/integrations/websocket.mjs +1 -0
- package/dist/{interface-BGZCzOGL.d.mts → interface-BOk3YnTz.d.mts} +1 -0
- package/dist/{interface-Uk8ONJv7.d.mts → interface-DEWNOMza.d.mts} +1 -0
- package/dist/{interface-n5hpKg1d.mjs → interface-DYzM0q-u.mjs} +1 -0
- package/dist/{jsonBody-BlzAoGCe.mjs → jsonBody-BYbLDi5D.mjs} +2 -1
- package/dist/{keys-Cd316u_y.mjs → keys-C0_pa1tY.mjs} +1 -0
- package/dist/{loadResources-DpU8pfjV.mjs → loadResources-yawi9tAE.mjs} +2 -1
- package/dist/{loadResourcesFromEntry-kvGT6EcO.mjs → loadResourcesFromEntry-BAjYZonx.mjs} +2 -1
- package/dist/logger/index.d.mts +1 -0
- package/dist/logger/index.mjs +1 -0
- package/dist/{mcpPlugin-Cs6nRGCt.mjs → mcpPlugin-B_es0hiF.mjs} +52 -60
- package/dist/{memory-CmnzXE5Z.mjs → memory-DR6wpa9F.mjs} +2 -1
- package/dist/{metrics-fdxI5mn_.mjs → metrics-CUdw_wLB.mjs} +2 -1
- package/dist/middleware/index.d.mts +2 -1
- package/dist/middleware/index.mjs +3 -2
- package/dist/migrations/index.d.mts +1 -3
- package/dist/migrations/index.mjs +2 -4
- package/dist/module-0pFJA6YW.mjs +78 -0
- package/dist/{multipartBody-D0eBvkeq.mjs → multipartBody-wOgj_fnj.mjs} +1 -0
- package/dist/{openapi-BLV4OMXb.mjs → openapi-B2L4js1v.mjs} +6 -5
- package/dist/permissions/index.d.mts +5 -4
- package/dist/permissions/index.mjs +6 -4
- package/dist/{permissions-BrtiBKGs.mjs → permissions-L--QDAir.mjs} +518 -316
- package/dist/{pipe-BOLmGIxa.mjs → pipe-DRDNsI2T.mjs} +2 -1
- package/dist/pipeline/index.d.mts +2 -1
- package/dist/pipeline/index.mjs +2 -1
- package/dist/{pipeline-BGRnV-To.mjs → pipeline-UPQFatit.mjs} +7 -20
- package/dist/plugins/index.d.mts +8 -7
- package/dist/plugins/index.mjs +16 -15
- package/dist/plugins/response-cache.d.mts +1 -0
- package/dist/plugins/response-cache.mjs +2 -1
- package/dist/plugins/tracing-entry.d.mts +2 -1
- package/dist/plugins/tracing-entry.mjs +2 -1
- package/dist/{pluralize-BhrgwZb9.mjs → pluralize-O0FZgESl.mjs} +1 -0
- package/dist/{preloadResources-CM4uUaFv.d.mts → preloadResources-CyoJMSiO.d.mts} +117 -12
- package/dist/{preloadResources-DSY5HGpS.mjs → preloadResources-Dz6BjzM6.mjs} +1 -0
- package/dist/presets/filesUpload.d.mts +6 -5
- package/dist/presets/filesUpload.mjs +9 -11
- package/dist/presets/index.d.mts +15 -2
- package/dist/presets/index.mjs +2 -1
- package/dist/presets/multiTenant.d.mts +2 -1
- package/dist/presets/multiTenant.mjs +3 -2
- package/dist/presets/search.d.mts +3 -2
- package/dist/presets/search.mjs +2 -1
- package/dist/{presets-DwFDVKP0.mjs → presets-C4-g4_4P.mjs} +27 -10
- package/dist/projection-BHw6xlOQ.mjs +18 -0
- package/dist/{pushref-registry-BbcnpNTm.d.mts → pushref-registry-Dov9l4qE.d.mts} +1 -0
- package/dist/{queryCachePlugin-DSGuSVLW.mjs → queryCachePlugin-BZr8v46E.mjs} +5 -4
- package/dist/{queryCachePlugin-br4otiqF.d.mts → queryCachePlugin-BzqxconG.d.mts} +3 -2
- package/dist/{rateLimit-DrED-C4v.mjs → rateLimit-CMPY5Qms.mjs} +7 -3
- package/dist/{rateLimitKey-BoWRH3e7.mjs → rateLimitKey-Dn5JNmkj.mjs} +1 -0
- package/dist/{realtime-DEuDu9io.mjs → realtime-C5NoxWFY.mjs} +20 -8
- package/dist/{redis-UDtLaNj6.d.mts → redis-CduhKsJi.d.mts} +2 -1
- package/dist/{redis-stream-uIaauNpZ.d.mts → redis-stream-qN6A-RFv.d.mts} +2 -1
- package/dist/{registerRoute-DTp18Ah_.mjs → registerRoute-CBN84fc7.mjs} +5 -2
- package/dist/registry/index.d.mts +3 -2
- package/dist/registry/index.mjs +3 -2
- package/dist/{registry-9km_w1aP.mjs → registry-4213U4zb.mjs} +3 -2
- package/dist/{renewingLease-3cuQH7h8.mjs → renewingLease-Dll8mGgc.mjs} +1 -0
- package/dist/{replyHelpers-67tRawQZ.mjs → replyHelpers-DIiZKGUt.mjs} +2 -1
- package/dist/repositoryFilter-f9eAfq_l.mjs +45 -0
- package/dist/{requestContext-MrlJwECf.mjs → requestContext-2TxXOJ7T.mjs} +1 -0
- package/dist/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-CUsjNX9R.mjs} +1 -0
- package/dist/{schedules-CjOd2MG9.mjs → schedules-DdtlICTf.mjs} +3 -2
- package/dist/{schemaConverter-DJvodiHj.mjs → schemaConverter-D--wdJM-.mjs} +70 -17
- package/dist/{schemaIR-Ca7ySNPx.mjs → schemaIR-Bm2enbnh.mjs} +2 -1
- package/dist/schemas/index.d.mts +1 -0
- package/dist/schemas/index.mjs +1 -0
- package/dist/scim/index.d.mts +1 -0
- package/dist/scim/index.mjs +2 -1
- package/dist/scope/index.d.mts +3 -2
- package/dist/scope/index.mjs +5 -4
- package/dist/{scope-DfaPntpo.mjs → scope-DHx5Fous.mjs} +4 -3
- package/dist/{sessionManager-BHOM3N_0.d.mts → sessionManager-qimlKV0P.d.mts} +1 -0
- package/dist/{simpleEqualityMatcher-Cg05HPsn.mjs → simpleEqualityMatcher-DjQJ7FKA.mjs} +1 -0
- package/dist/{sse-BQ1w0dfl.mjs → sse-CUTo9y0X.mjs} +5 -4
- package/dist/{sseStream-BQ7Qaynp.mjs → sseStream-B_5XcVVd.mjs} +2 -1
- package/dist/{storage-DLUZ04kK.d.mts → storage-Cx9Sz3Ou.d.mts} +1 -0
- package/dist/{store-helpers-DY6xq4sM.mjs → store-helpers-D1MP_gRG.mjs} +1 -0
- package/dist/{streaming-BQcW6_Kq.mjs → streaming-PSZwi8JV.mjs} +1 -0
- package/dist/subscribe-helpers-C0Tb2aUp.mjs +340 -0
- package/dist/sync/changelog.d.mts +1 -0
- package/dist/sync/changelog.mjs +1 -0
- package/dist/testing/cleanupStoreContract.d.mts +2 -1
- package/dist/testing/cleanupStoreContract.mjs +1 -0
- package/dist/testing/index.d.mts +167 -12
- package/dist/testing/index.mjs +522 -205
- package/dist/testing/outboxStoreContract.d.mts +1 -0
- package/dist/testing/outboxStoreContract.mjs +1 -0
- package/dist/testing/storageContract.d.mts +2 -1
- package/dist/testing/storageContract.mjs +1 -0
- package/dist/{tracing-CsGKQJGg.d.mts → tracing-Dro5eH6j.d.mts} +1 -0
- package/dist/{transactionContext-PNdoyLLm.mjs → transactionContext-dAfBVUrk.mjs} +1 -0
- package/dist/{typeGuards-C9KXUly6.mjs → typeGuards-B0ryPo3I.mjs} +1 -0
- package/dist/types/index.d.mts +6 -5
- package/dist/types/index.mjs +1 -0
- package/dist/types/storage.d.mts +2 -1
- package/dist/types/storage.mjs +1 -0
- package/dist/{types-Dmw-6Qeo.d.mts → types-B7XeLEgK.d.mts} +127 -90
- package/dist/{types-Cc91nXuA.d.mts → types-ByHn9rEO.d.mts} +1 -0
- package/dist/{types-C_ZZRcD6.d.mts → types-C4QE8qnm.d.mts} +1 -0
- package/dist/{types-Cu6vVJmh.d.mts → types-CBgvkp8O.d.mts} +82 -12
- package/dist/{types-DjC-KUCz.d.mts → types-ChuShpCP.d.mts} +1 -0
- package/dist/{types-QFGc3Cjv.d.mts → types-CwcUQ610.d.mts} +2 -6
- package/dist/{types-CSyCxl2V.mjs → types-Cys0ko9Z.mjs} +4 -3
- package/dist/usage/index.d.mts +3 -2
- package/dist/usage/index.mjs +0 -0
- package/dist/{userHelpers-Cg_y3fBz.mjs → userHelpers-ChYJ6CdK.mjs} +1 -0
- package/dist/utils/index.d.mts +16 -10
- package/dist/utils/index.mjs +10 -9
- package/dist/{utils-taUVD4p3.mjs → utils-vL8BrDE2.mjs} +3 -2
- package/dist/{validate-Bk44F2yT.mjs → validate-_KbifpHF.mjs} +2 -1
- package/dist/{versioning-gKWyryT3.mjs → versioning-CHDTor3P.mjs} +3 -2
- package/dist/{versioning-DXgY5BUm.d.mts → versioning-D_rhHfZm.d.mts} +5 -2
- package/dist/{websocket-_Pd0CgR0.d.mts → websocket-BOXVggMf.d.mts} +2 -1
- package/package.json +15 -7
- package/skills/arc/SKILL.md +22 -8
- package/skills/arc/references/api-reference.md +4 -0
- package/skills/arc/references/auth.md +3 -0
- package/skills/arc/references/integrations.md +33 -0
- package/skills/arc/references/mcp.md +6 -7
- package/skills/arc/references/production.md +24 -25
- package/skills/arc-code-review/references/anti-patterns.md +3 -3
- package/dist/index-CZY4EGga.d.mts +0 -107
- package/dist/module-_-M7C5ES.mjs +0 -29
|
@@ -1,10 +1,69 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
/*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
|
|
2
|
+
import { t as __exportAll } from "./rolldown-runtime-CUsjNX9R.mjs";
|
|
3
|
+
import { arcLog } from "./logger/index.mjs";
|
|
4
|
+
import { a as conjoinPolicyFilters, i as normalizeToDecision } from "./authorizationDecision-ChvUQGG1.mjs";
|
|
5
|
+
import { n as getUserRoles } from "./userHelpers-ChYJ6CdK.mjs";
|
|
6
|
+
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-Cys0ko9Z.mjs";
|
|
7
|
+
import { t as MemoryCacheStore } from "./memory-DR6wpa9F.mjs";
|
|
5
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
+
//#region src/permissions/context.ts
|
|
10
|
+
/**
|
|
11
|
+
* PermissionContext accessors (arc 2.30 — transport-neutral evaluation).
|
|
12
|
+
*
|
|
13
|
+
* The goal of the standardized authorization core is that a `PermissionCheck`
|
|
14
|
+
* reads FACTS off its {@link PermissionContext}, never off a raw
|
|
15
|
+
* `FastifyRequest`. That makes evaluation:
|
|
16
|
+
* - pure (a combinator can hand a child a NEW context with an updated scope
|
|
17
|
+
* instead of mutating `request.scope` — see `algebra.ts`),
|
|
18
|
+
* - transport-neutral (MCP / jobs / websockets build a context directly, no
|
|
19
|
+
* `fakeRequest`),
|
|
20
|
+
* - unit-testable (no Fastify object required).
|
|
21
|
+
*
|
|
22
|
+
* `scopeOf` is the single seam every primitive uses to read the request scope.
|
|
23
|
+
* It prefers the context's first-class `scope` (set by combinators + adapters)
|
|
24
|
+
* and falls back to the raw request during migration (the `ctx.request` shim
|
|
25
|
+
* removed in the final phase). Once `scope` is populated everywhere, the
|
|
26
|
+
* fallback is dead and drops out.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the request scope for a permission check. Prefers the context's
|
|
30
|
+
* first-class `scope`; falls back to the raw HTTP `request.scope` when present,
|
|
31
|
+
* else `PUBLIC_SCOPE`. Primitives MUST use this instead of touching
|
|
32
|
+
* `ctx.request` so scope threads through combinators and non-HTTP transports
|
|
33
|
+
* (MCP, jobs) — which have no request — resolve identity correctly.
|
|
34
|
+
*/
|
|
35
|
+
function scopeOf(ctx) {
|
|
36
|
+
if (ctx.scope) return ctx.scope;
|
|
37
|
+
return ctx.request ? getRequestScope(ctx.request) : PUBLIC_SCOPE;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
6
40
|
//#region src/permissions/core.ts
|
|
7
41
|
/**
|
|
42
|
+
* Build an `allow` {@link AuthorizationDecision}, optionally attaching a row-level
|
|
43
|
+
* `policy` and/or a `scope` to install. The idiomatic "grant" return for arc
|
|
44
|
+
* 2.30 checks (parallels Cedar's `permit`).
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* return allow(); // simple grant
|
|
48
|
+
* return allow({ policy: { ownerId: userId } }); // grant + row policy
|
|
49
|
+
*/
|
|
50
|
+
function allow(extra) {
|
|
51
|
+
return extra ? {
|
|
52
|
+
effect: "allow",
|
|
53
|
+
...extra
|
|
54
|
+
} : { effect: "allow" };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build a `deny` {@link AuthorizationDecision} with an optional human-readable
|
|
58
|
+
* reason (parallels Cedar's `forbid`). The idiomatic "refuse" return.
|
|
59
|
+
*/
|
|
60
|
+
function deny(reason) {
|
|
61
|
+
return reason ? {
|
|
62
|
+
effect: "deny",
|
|
63
|
+
reason
|
|
64
|
+
} : { effect: "deny" };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
8
67
|
* Normalize a `string | [readonly string[]]` rest-args tuple into a single
|
|
9
68
|
* `readonly string[]`. Lets a permission helper accept BOTH variadic and
|
|
10
69
|
* array call shapes from one overload signature.
|
|
@@ -45,12 +104,10 @@ function allowPublic() {
|
|
|
45
104
|
*/
|
|
46
105
|
function requireAuth() {
|
|
47
106
|
const check = (ctx) => {
|
|
48
|
-
if (!ctx.user) return
|
|
49
|
-
granted: false,
|
|
50
|
-
reason: "Authentication required"
|
|
51
|
-
};
|
|
107
|
+
if (!ctx.user) return deny("Authentication required");
|
|
52
108
|
return true;
|
|
53
109
|
};
|
|
110
|
+
check._requiresAuth = true;
|
|
54
111
|
return check;
|
|
55
112
|
}
|
|
56
113
|
function requireRoles(rolesOrFirst, optionsOrSecond, ...rest) {
|
|
@@ -69,24 +126,48 @@ function requireRoles(rolesOrFirst, optionsOrSecond, ...rest) {
|
|
|
69
126
|
}
|
|
70
127
|
const includeOrgRoles = options?.includeOrgRoles ?? true;
|
|
71
128
|
const check = (ctx) => {
|
|
72
|
-
if (!ctx.user) return
|
|
73
|
-
granted: false,
|
|
74
|
-
reason: "Authentication required"
|
|
75
|
-
};
|
|
129
|
+
if (!ctx.user) return deny("Authentication required");
|
|
76
130
|
const userRoles = getUserRoles(ctx.user);
|
|
77
131
|
if (options?.bypassRoles?.some((r) => userRoles.includes(r))) return true;
|
|
78
132
|
if (roles.some((r) => userRoles.includes(r))) return true;
|
|
79
133
|
if (includeOrgRoles) {
|
|
80
|
-
const scope =
|
|
134
|
+
const scope = scopeOf(ctx);
|
|
81
135
|
if (isElevated(scope)) return true;
|
|
82
136
|
if (isMember(scope) && roles.some((r) => scope.orgRoles.includes(r))) return true;
|
|
83
137
|
}
|
|
84
|
-
return {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
138
|
+
return deny(`Required roles: ${roles.join(", ")}`);
|
|
139
|
+
};
|
|
140
|
+
check._roles = roles;
|
|
141
|
+
return check;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Require a PLATFORM role — global roles only. An organization role can never
|
|
145
|
+
* satisfy this gate.
|
|
146
|
+
*
|
|
147
|
+
* `requireRoles(["ops"])` also accepts an ORG role named `ops` (that is its
|
|
148
|
+
* documented default), and `requireOrgRole("manager")` grants ANY organization's
|
|
149
|
+
* manager. Both return a bare allow with no policy, so a surface that is global
|
|
150
|
+
* by construction — a queue dashboard, a cluster-wide operations endpoint —
|
|
151
|
+
* cannot distinguish them from a real operator gate by looking at the decision.
|
|
152
|
+
* That makes "platform-only" a property the check has to DECLARE, which is what
|
|
153
|
+
* `_platformOnly` records and what such a surface can then require.
|
|
154
|
+
*
|
|
155
|
+
* Elevated scope passes: platform admin is the identity this gate is for.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* permissions: { list: requirePlatformRole("platform-ops") }
|
|
159
|
+
*/
|
|
160
|
+
function requirePlatformRole(...args) {
|
|
161
|
+
const roles = normalizeVariadicOrArray(args);
|
|
162
|
+
const check = (ctx) => {
|
|
163
|
+
if (isElevated(scopeOf(ctx))) return true;
|
|
164
|
+
if (!ctx.user) return deny("Authentication required");
|
|
165
|
+
const userRoles = getUserRoles(ctx.user);
|
|
166
|
+
if (roles.some((r) => userRoles.includes(r))) return true;
|
|
167
|
+
return deny(`Required platform roles: ${roles.join(", ")}`);
|
|
88
168
|
};
|
|
89
169
|
check._roles = roles;
|
|
170
|
+
check._platformOnly = true;
|
|
90
171
|
return check;
|
|
91
172
|
}
|
|
92
173
|
/**
|
|
@@ -97,19 +178,13 @@ function requireRoles(rolesOrFirst, optionsOrSecond, ...rest) {
|
|
|
97
178
|
function roles(...args) {
|
|
98
179
|
const roleList = normalizeVariadicOrArray(args);
|
|
99
180
|
const check = (ctx) => {
|
|
100
|
-
if (!ctx.user) return
|
|
101
|
-
granted: false,
|
|
102
|
-
reason: "Authentication required"
|
|
103
|
-
};
|
|
181
|
+
if (!ctx.user) return deny("Authentication required");
|
|
104
182
|
const userRoles = getUserRoles(ctx.user);
|
|
105
183
|
if (roleList.some((r) => userRoles.includes(r))) return true;
|
|
106
|
-
const scope =
|
|
184
|
+
const scope = scopeOf(ctx);
|
|
107
185
|
if (isElevated(scope)) return true;
|
|
108
186
|
if (isMember(scope) && roleList.some((r) => scope.orgRoles.includes(r))) return true;
|
|
109
|
-
return {
|
|
110
|
-
granted: false,
|
|
111
|
-
reason: `Required roles: ${roleList.join(", ")}`
|
|
112
|
-
};
|
|
187
|
+
return deny(`Required roles: ${roleList.join(", ")}`);
|
|
113
188
|
};
|
|
114
189
|
check._roles = roleList;
|
|
115
190
|
return check;
|
|
@@ -128,31 +203,29 @@ function roles(...args) {
|
|
|
128
203
|
*/
|
|
129
204
|
function requireOwnership(ownerField = "userId", options) {
|
|
130
205
|
return (ctx) => {
|
|
131
|
-
if (!ctx.user) return
|
|
132
|
-
granted: false,
|
|
133
|
-
reason: "Authentication required"
|
|
134
|
-
};
|
|
206
|
+
if (!ctx.user) return deny("Authentication required");
|
|
135
207
|
const userRoles = getUserRoles(ctx.user);
|
|
136
208
|
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
|
-
};
|
|
209
|
+
const userId = getUserId(scopeOf(ctx)) ?? ctx.user.id ?? ctx.user._id;
|
|
210
|
+
if (!userId) return deny("User identity missing (no id or _id)");
|
|
211
|
+
return allow({ policy: { [ownerField]: userId } });
|
|
146
212
|
};
|
|
147
213
|
}
|
|
148
214
|
/**
|
|
149
215
|
* Combine multiple checks — ALL must pass (AND logic).
|
|
150
216
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
217
|
+
* Evaluation is PURE — nothing is written to the request. Each child runs
|
|
218
|
+
* against the accumulated state of previous children, threaded through a fresh
|
|
219
|
+
* child context:
|
|
220
|
+
* - `policy` from earlier children is CONJOINED (logical AND) — a later child
|
|
221
|
+
* can add or narrow restrictions but never silently replaces an earlier
|
|
222
|
+
* child's constraint on the same key (conflicting values are preserved under
|
|
223
|
+
* `$and`; see `conjoinPolicyFilters`)
|
|
224
|
+
* - `scope` installed by an earlier child is visible to the next through
|
|
225
|
+
* `scopeOf(ctx)`, and never downgrades an already-authoritative scope
|
|
154
226
|
*
|
|
155
|
-
* The
|
|
227
|
+
* The returned decision carries the merged `policy` + `scope`; the enforcement
|
|
228
|
+
* point (`applyAuthorizationDecision`) applies them to the request once.
|
|
156
229
|
*
|
|
157
230
|
* @example
|
|
158
231
|
* ```typescript
|
|
@@ -163,50 +236,46 @@ function requireOwnership(ownerField = "userId", options) {
|
|
|
163
236
|
* ```
|
|
164
237
|
*/
|
|
165
238
|
function allOf(...checks) {
|
|
166
|
-
|
|
239
|
+
const check = async (ctx) => {
|
|
167
240
|
let mergedFilters = {};
|
|
168
241
|
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;
|
|
242
|
+
let threaded = ctx.scope;
|
|
243
|
+
let childCtx = ctx;
|
|
244
|
+
for (const inner of checks) {
|
|
245
|
+
const decision = normalizeToDecision(await inner(childCtx));
|
|
246
|
+
if (decision.effect !== "allow") return deny(decision.reason);
|
|
247
|
+
if (decision.policy) mergedFilters = conjoinPolicyFilters(mergedFilters, decision.policy);
|
|
248
|
+
if (decision.scope && (!threaded || threaded.kind === "public")) {
|
|
249
|
+
threaded = decision.scope;
|
|
250
|
+
installedScope = decision.scope;
|
|
251
|
+
} else if (decision.scope && !installedScope) installedScope = decision.scope;
|
|
252
|
+
if (threaded !== childCtx.scope) childCtx = {
|
|
253
|
+
...childCtx,
|
|
254
|
+
scope: threaded
|
|
255
|
+
};
|
|
203
256
|
}
|
|
204
|
-
return {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
};
|
|
257
|
+
return allow({
|
|
258
|
+
...Object.keys(mergedFilters).length > 0 ? { policy: mergedFilters } : {},
|
|
259
|
+
...installedScope ? { scope: installedScope } : {}
|
|
260
|
+
});
|
|
209
261
|
};
|
|
262
|
+
const roleBranches = checks.filter((c) => (c._roles?.length ?? 0) > 0 || (c._orgRoles?.length ?? 0) > 0);
|
|
263
|
+
if (checks.length > 0 && checks.every((c) => c._isPublic)) check._isPublic = true;
|
|
264
|
+
else if (roleBranches.length === 1) {
|
|
265
|
+
const [rb] = roleBranches;
|
|
266
|
+
if (rb?._roles?.length) check._roles = [...rb._roles];
|
|
267
|
+
if (rb?._orgRoles?.length) check._orgRoles = [...rb._orgRoles];
|
|
268
|
+
}
|
|
269
|
+
const scopeContext = {};
|
|
270
|
+
let scopeConflict = false;
|
|
271
|
+
for (const c of checks) for (const [dim, val] of Object.entries(c._scopeContext ?? {})) if (dim in scopeContext && scopeContext[dim] !== val) scopeConflict = true;
|
|
272
|
+
else scopeContext[dim] = val;
|
|
273
|
+
if (Object.keys(scopeContext).length > 0) check._scopeContext = scopeContext;
|
|
274
|
+
const hasMetaSurface = (c) => Boolean(c._isPublic || c._roles?.length || c._orgRoles?.length || c._scopeContext && Object.keys(c._scopeContext).length > 0);
|
|
275
|
+
const opaqueBranch = checks.some((c) => !hasMetaSurface(c) && !c._requiresAuth);
|
|
276
|
+
const childConditional = checks.some((c) => c._conditional);
|
|
277
|
+
if (opaqueBranch || scopeConflict || childConditional) check._conditional = true;
|
|
278
|
+
return check;
|
|
210
279
|
}
|
|
211
280
|
/**
|
|
212
281
|
* Combine multiple checks — ANY must pass (OR logic).
|
|
@@ -219,19 +288,31 @@ function allOf(...checks) {
|
|
|
219
288
|
* ```
|
|
220
289
|
*/
|
|
221
290
|
function anyOf(...checks) {
|
|
222
|
-
|
|
291
|
+
const check = async (ctx) => {
|
|
223
292
|
const reasons = [];
|
|
224
|
-
for (const
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
293
|
+
for (const inner of checks) {
|
|
294
|
+
const decision = normalizeToDecision(await inner(ctx));
|
|
295
|
+
if (decision.effect === "allow") return allow({
|
|
296
|
+
...decision.policy ? { policy: decision.policy } : {},
|
|
297
|
+
...decision.scope ? { scope: decision.scope } : {}
|
|
298
|
+
});
|
|
299
|
+
if (decision.reason) reasons.push(decision.reason);
|
|
229
300
|
}
|
|
230
|
-
return
|
|
231
|
-
granted: false,
|
|
232
|
-
reason: reasons.join("; ")
|
|
233
|
-
};
|
|
301
|
+
return deny(reasons.join("; "));
|
|
234
302
|
};
|
|
303
|
+
if (checks.some((c) => c._isPublic)) check._isPublic = true;
|
|
304
|
+
else if (checks.every((c) => (c._roles?.length ?? 0) > 0 || (c._orgRoles?.length ?? 0) > 0)) {
|
|
305
|
+
const roles = /* @__PURE__ */ new Set();
|
|
306
|
+
const orgRoles = /* @__PURE__ */ new Set();
|
|
307
|
+
for (const c of checks) {
|
|
308
|
+
for (const r of c._roles ?? []) roles.add(r);
|
|
309
|
+
for (const r of c._orgRoles ?? []) orgRoles.add(r);
|
|
310
|
+
}
|
|
311
|
+
if (roles.size > 0) check._roles = [...roles];
|
|
312
|
+
if (orgRoles.size > 0) check._orgRoles = [...orgRoles];
|
|
313
|
+
if (checks.some((c) => c._conditional)) check._conditional = true;
|
|
314
|
+
}
|
|
315
|
+
return check;
|
|
235
316
|
}
|
|
236
317
|
/**
|
|
237
318
|
* Invert a permission check. Grants when the wrapped check denies, denies
|
|
@@ -251,11 +332,7 @@ function anyOf(...checks) {
|
|
|
251
332
|
*/
|
|
252
333
|
function not(check, reason = "Access denied") {
|
|
253
334
|
return async (ctx) => {
|
|
254
|
-
|
|
255
|
-
return (typeof result === "boolean" ? { granted: result } : result).granted ? {
|
|
256
|
-
granted: false,
|
|
257
|
-
reason
|
|
258
|
-
} : true;
|
|
335
|
+
return normalizeToDecision(await check(ctx)).effect === "allow" ? deny(reason) : true;
|
|
259
336
|
};
|
|
260
337
|
}
|
|
261
338
|
/**
|
|
@@ -267,10 +344,7 @@ function not(check, reason = "Access denied") {
|
|
|
267
344
|
* ```
|
|
268
345
|
*/
|
|
269
346
|
function denyAll(reason = "Access denied") {
|
|
270
|
-
return () => (
|
|
271
|
-
granted: false,
|
|
272
|
-
reason
|
|
273
|
-
});
|
|
347
|
+
return () => deny(reason);
|
|
274
348
|
}
|
|
275
349
|
/**
|
|
276
350
|
* Dynamic permission based on a condition function.
|
|
@@ -284,11 +358,7 @@ function denyAll(reason = "Access denied") {
|
|
|
284
358
|
*/
|
|
285
359
|
function when(condition) {
|
|
286
360
|
return async (ctx) => {
|
|
287
|
-
|
|
288
|
-
return {
|
|
289
|
-
granted: result,
|
|
290
|
-
reason: result ? void 0 : "Condition not met"
|
|
291
|
-
};
|
|
361
|
+
return await condition(ctx) ? allow() : deny("Condition not met");
|
|
292
362
|
};
|
|
293
363
|
}
|
|
294
364
|
//#endregion
|
|
@@ -349,16 +419,10 @@ const DEFAULT_TTL_GRACE_MS = 3e4;
|
|
|
349
419
|
*/
|
|
350
420
|
function requireDPoP() {
|
|
351
421
|
const check = (ctx) => {
|
|
352
|
-
const scope =
|
|
422
|
+
const scope = scopeOf(ctx);
|
|
353
423
|
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
|
-
};
|
|
424
|
+
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).");
|
|
425
|
+
if (!getDPoPJkt(scope)) return deny("Sender-constrained credential required (DPoP). Inbound token is bearer; replay-resistance is mandatory on this endpoint.");
|
|
362
426
|
return true;
|
|
363
427
|
};
|
|
364
428
|
check._dpopRequired = true;
|
|
@@ -404,38 +468,20 @@ function requireMandate(capability, opts = {}) {
|
|
|
404
468
|
const ttlGrace = opts.ttlGraceMs ?? DEFAULT_TTL_GRACE_MS;
|
|
405
469
|
const noElevatedBypass = opts.noElevatedBypass === true;
|
|
406
470
|
const check = (ctx) => {
|
|
407
|
-
const scope =
|
|
471
|
+
const scope = scopeOf(ctx);
|
|
408
472
|
if (isElevated(scope) && !noElevatedBypass) return true;
|
|
409
473
|
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
|
-
};
|
|
474
|
+
if (!mandate) return deny(`Capability mandate required (${capability}). Configure your authenticate callback to populate request.scope.mandate after verifying the mandate JWT/VC.`);
|
|
475
|
+
if (mandate.capability !== capability) return deny(`Mandate authorizes "${mandate.capability}", not "${capability}"`);
|
|
476
|
+
if (mandate.expiresAt !== void 0 && Date.now() > mandate.expiresAt + ttlGrace) return deny(`Mandate expired at ${new Date(mandate.expiresAt).toISOString()}`);
|
|
422
477
|
if (opts.audience) {
|
|
423
478
|
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
|
-
};
|
|
479
|
+
if (required && mandate.audience && mandate.audience !== required) return deny(`Mandate is bound to "${mandate.audience}", not "${required}"`);
|
|
480
|
+
if (required && !mandate.audience) return deny(`Mandate must be bound to "${required}" (no audience claim)`);
|
|
432
481
|
}
|
|
433
482
|
if (opts.validateAmount) {
|
|
434
483
|
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
|
-
};
|
|
484
|
+
if (result !== true) return deny(typeof result === "string" ? result : `Action exceeds mandate cap ${mandate.cap}${mandate.currency ? ` ${mandate.currency}` : ""}`);
|
|
439
485
|
}
|
|
440
486
|
return true;
|
|
441
487
|
};
|
|
@@ -482,30 +528,18 @@ function requireAgentScope(opts) {
|
|
|
482
528
|
const dpopCheck = needsDPoP ? requireDPoP() : null;
|
|
483
529
|
const requiredScopes = scopes && scopes.length > 0 ? [...scopes] : null;
|
|
484
530
|
const check = async (ctx) => {
|
|
485
|
-
const scope =
|
|
531
|
+
const scope = scopeOf(ctx);
|
|
486
532
|
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
|
-
};
|
|
533
|
+
if (!isService(scope)) return deny("Service identity required (machine principal). Agent flows must authenticate as a service, not a logged-in user.");
|
|
491
534
|
if (requiredScopes) {
|
|
492
535
|
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
|
-
};
|
|
536
|
+
if (!requiredScopes.some((s) => granted.includes(s))) return deny(`Service identity is missing required OAuth scope(s): ${requiredScopes.join(", ")}`);
|
|
497
537
|
}
|
|
498
538
|
const mandateResult = await mandateCheck(ctx);
|
|
499
|
-
if (mandateResult !== true) return typeof mandateResult === "object" ? mandateResult :
|
|
500
|
-
granted: false,
|
|
501
|
-
reason: "Mandate check failed"
|
|
502
|
-
};
|
|
539
|
+
if (mandateResult !== true) return typeof mandateResult === "object" ? mandateResult : deny("Mandate check failed");
|
|
503
540
|
if (dpopCheck) {
|
|
504
541
|
const dpopResult = await dpopCheck(ctx);
|
|
505
|
-
if (dpopResult !== true) return typeof dpopResult === "object" ? dpopResult :
|
|
506
|
-
granted: false,
|
|
507
|
-
reason: "DPoP binding required"
|
|
508
|
-
};
|
|
542
|
+
if (dpopResult !== true) return typeof dpopResult === "object" ? dpopResult : deny("DPoP binding required");
|
|
509
543
|
}
|
|
510
544
|
return true;
|
|
511
545
|
};
|
|
@@ -521,9 +555,9 @@ function requireAgentScope(opts) {
|
|
|
521
555
|
/**
|
|
522
556
|
* Permission Scope — checks bound to RequestScope.
|
|
523
557
|
*
|
|
524
|
-
* All read
|
|
525
|
-
* JWT custom auth
|
|
526
|
-
* `
|
|
558
|
+
* All read the request scope via `scopeOf(ctx)` - populated by an auth adapter
|
|
559
|
+
* (Better Auth bridge, JWT custom auth) or threaded from an upstream check's
|
|
560
|
+
* `AuthorizationDecision.scope`.
|
|
527
561
|
*/
|
|
528
562
|
/**
|
|
529
563
|
* Require an org-bound caller. Grants for `member`, `service`, and
|
|
@@ -544,15 +578,9 @@ function requireAgentScope(opts) {
|
|
|
544
578
|
*/
|
|
545
579
|
function requireOrgMembership() {
|
|
546
580
|
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
|
-
};
|
|
581
|
+
if (hasOrgAccess(scopeOf(ctx))) return true;
|
|
582
|
+
if (!ctx.user) return deny("Authentication required");
|
|
583
|
+
return deny("Organization membership required");
|
|
556
584
|
};
|
|
557
585
|
check._orgPermission = "membership";
|
|
558
586
|
return check;
|
|
@@ -579,25 +607,13 @@ function requireOrgMembership() {
|
|
|
579
607
|
function requireOrgRole(...args) {
|
|
580
608
|
const roles = normalizeVariadicOrArray(args);
|
|
581
609
|
const check = (ctx) => {
|
|
582
|
-
const scope =
|
|
610
|
+
const scope = scopeOf(ctx);
|
|
583
611
|
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
|
-
};
|
|
612
|
+
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.");
|
|
613
|
+
if (!ctx.user) return deny("Authentication required");
|
|
614
|
+
if (!isMember(scope)) return deny("Organization membership required");
|
|
596
615
|
if (roles.some((r) => scope.orgRoles.includes(r))) return true;
|
|
597
|
-
return {
|
|
598
|
-
granted: false,
|
|
599
|
-
reason: `Required org roles: ${roles.join(", ")}`
|
|
600
|
-
};
|
|
616
|
+
return deny(`Required org roles: ${roles.join(", ")}`);
|
|
601
617
|
};
|
|
602
618
|
check._orgRoles = roles;
|
|
603
619
|
return check;
|
|
@@ -629,18 +645,12 @@ function requireServiceScope(...args) {
|
|
|
629
645
|
const required = normalizeVariadicOrArray(args);
|
|
630
646
|
if (required.length === 0) throw new Error("requireServiceScope() requires at least one scope string (e.g. requireServiceScope('jobs:write'))");
|
|
631
647
|
const check = (ctx) => {
|
|
632
|
-
const scope =
|
|
648
|
+
const scope = scopeOf(ctx);
|
|
633
649
|
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
|
-
};
|
|
650
|
+
if (!isService(scope)) return deny("Service identity required (API key). For human users, use requireOrgRole(...) or compose with anyOf(requireOrgRole(...), requireServiceScope(...)).");
|
|
638
651
|
const granted = getServiceScopes(scope);
|
|
639
652
|
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
|
-
};
|
|
653
|
+
return deny(`Required service scopes: ${required.join(", ")} (granted: ${granted.length > 0 ? granted.join(", ") : "none"})`);
|
|
644
654
|
};
|
|
645
655
|
check._serviceScopes = required;
|
|
646
656
|
return check;
|
|
@@ -678,23 +688,14 @@ function requireScopeContext(keyOrMap, value) {
|
|
|
678
688
|
const requiredKeys = Object.keys(required);
|
|
679
689
|
if (requiredKeys.length === 0) throw new Error("requireScopeContext() requires at least one key (e.g. requireScopeContext('branchId'))");
|
|
680
690
|
const check = (ctx) => {
|
|
681
|
-
const scope =
|
|
691
|
+
const scope = scopeOf(ctx);
|
|
682
692
|
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
|
-
};
|
|
693
|
+
if (!getScopeContextMap(scope)) return deny("Scope context required (member, service, or elevated scope). Populate request.scope.context in your auth function.");
|
|
687
694
|
for (const key of requiredKeys) {
|
|
688
695
|
const expected = required[key];
|
|
689
696
|
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
|
-
};
|
|
697
|
+
if (actual === void 0) return deny(`Required scope context key "${key}" is missing`);
|
|
698
|
+
if (expected !== void 0 && actual !== expected) return deny(`Required scope context "${key}" must equal "${expected}" (got "${actual}")`);
|
|
698
699
|
}
|
|
699
700
|
return true;
|
|
700
701
|
};
|
|
@@ -711,8 +712,8 @@ function requireScopeContext(keyOrMap, value) {
|
|
|
711
712
|
*
|
|
712
713
|
* **Two call shapes:**
|
|
713
714
|
* ```typescript
|
|
714
|
-
* requireOrgInScope('acme-holding')
|
|
715
|
-
* requireOrgInScope((ctx) => ctx.
|
|
715
|
+
* requireOrgInScope('acme-holding') // static
|
|
716
|
+
* requireOrgInScope((ctx) => ctx.params?.orgId) // dynamic — from ctx facts, not the raw request
|
|
716
717
|
* ```
|
|
717
718
|
*
|
|
718
719
|
* `elevated` scope grants unconditionally (cross-org bypass).
|
|
@@ -720,9 +721,9 @@ function requireScopeContext(keyOrMap, value) {
|
|
|
720
721
|
* @example
|
|
721
722
|
* ```typescript
|
|
722
723
|
* permissions: {
|
|
723
|
-
* list: requireOrgInScope((ctx) => ctx.
|
|
724
|
+
* list: requireOrgInScope((ctx) => ctx.params?.orgId),
|
|
724
725
|
* create: allOf(
|
|
725
|
-
* requireOrgInScope((ctx) => ctx.
|
|
726
|
+
* requireOrgInScope((ctx) => (ctx.data as { organizationId?: string })?.organizationId),
|
|
726
727
|
* requireOrgRole('admin'),
|
|
727
728
|
* ),
|
|
728
729
|
* }
|
|
@@ -731,18 +732,12 @@ function requireScopeContext(keyOrMap, value) {
|
|
|
731
732
|
function requireOrgInScope(target) {
|
|
732
733
|
if (target === void 0 || target === null) throw new Error("requireOrgInScope() requires a target org id (string) or an extractor function");
|
|
733
734
|
const check = (ctx) => {
|
|
734
|
-
const scope =
|
|
735
|
+
const scope = scopeOf(ctx);
|
|
735
736
|
if (isElevated(scope)) return true;
|
|
736
737
|
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
|
-
};
|
|
738
|
+
if (!targetOrgId) return deny("requireOrgInScope: target org id could not be resolved from the request");
|
|
741
739
|
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
|
-
};
|
|
740
|
+
return deny(`Target organization "${targetOrgId}" is not in the caller's org hierarchy`);
|
|
746
741
|
};
|
|
747
742
|
check._orgInScopeTarget = target;
|
|
748
743
|
return check;
|
|
@@ -762,20 +757,11 @@ function requireOrgInScope(target) {
|
|
|
762
757
|
*/
|
|
763
758
|
function requireTeamMembership() {
|
|
764
759
|
const check = (ctx) => {
|
|
765
|
-
if (!ctx.user) return
|
|
766
|
-
|
|
767
|
-
reason: "Authentication required"
|
|
768
|
-
};
|
|
769
|
-
const scope = getRequestScope(ctx.request);
|
|
760
|
+
if (!ctx.user) return deny("Authentication required");
|
|
761
|
+
const scope = scopeOf(ctx);
|
|
770
762
|
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
|
-
};
|
|
763
|
+
if (!isMember(scope)) return deny("Organization membership required");
|
|
764
|
+
if (!getTeamId(scope)) return deny("No active team");
|
|
779
765
|
return true;
|
|
780
766
|
};
|
|
781
767
|
check._teamPermission = "membership";
|
|
@@ -832,21 +818,12 @@ function createOrgPermissions(config) {
|
|
|
832
818
|
return {
|
|
833
819
|
can(permissions) {
|
|
834
820
|
return (ctx) => {
|
|
835
|
-
if (!ctx.user) return
|
|
836
|
-
|
|
837
|
-
reason: "Authentication required"
|
|
838
|
-
};
|
|
839
|
-
const scope = getRequestScope(ctx.request);
|
|
821
|
+
if (!ctx.user) return deny("Authentication required");
|
|
822
|
+
const scope = scopeOf(ctx);
|
|
840
823
|
if (isElevated(scope)) return true;
|
|
841
|
-
if (!isMember(scope)) return
|
|
842
|
-
granted: false,
|
|
843
|
-
reason: "Organization membership required"
|
|
844
|
-
};
|
|
824
|
+
if (!isMember(scope)) return deny("Organization membership required");
|
|
845
825
|
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
|
-
};
|
|
826
|
+
return deny(`Missing permissions: ${Object.entries(permissions).map(([r, a]) => `${r}:[${a.join(",")}]`).join(", ")}`);
|
|
850
827
|
};
|
|
851
828
|
},
|
|
852
829
|
requireRole(...roles) {
|
|
@@ -873,7 +850,7 @@ function createOrgPermissions(config) {
|
|
|
873
850
|
* - Cross-node invalidation via the event bus
|
|
874
851
|
*/
|
|
875
852
|
function createDynamicPermissionMatrix(config) {
|
|
876
|
-
const logger = config.logger ??
|
|
853
|
+
const logger = config.logger ?? arcLog("permissions:dynamic");
|
|
877
854
|
const configuredTtlSeconds = config.cache?.ttlSeconds ?? 0;
|
|
878
855
|
const hasExternalStore = !!config.cacheStore;
|
|
879
856
|
const cacheTtlSeconds = configuredTtlSeconds > 0 ? configuredTtlSeconds : hasExternalStore ? 300 : 0;
|
|
@@ -883,20 +860,66 @@ function createDynamicPermissionMatrix(config) {
|
|
|
883
860
|
}) : void 0;
|
|
884
861
|
const cacheStore = config.cacheStore ?? internalStore;
|
|
885
862
|
const trackedKeys = /* @__PURE__ */ new Set();
|
|
863
|
+
const orgKeyIndex = /* @__PURE__ */ new Map();
|
|
864
|
+
const maxTrackedKeys = config.cache?.maxEntries ?? 1e4;
|
|
865
|
+
/**
|
|
866
|
+
* Record a key and ENFORCE the cap in the same step.
|
|
867
|
+
*
|
|
868
|
+
* The cap has to live here rather than at the write site: a node that mostly
|
|
869
|
+
* READS a shared cache registers on every hit but may rarely set, so a
|
|
870
|
+
* cap applied only after a `set` never runs and the bookkeeping grows with
|
|
871
|
+
* every distinct org/user/role combination the node has ever seen — unbounded,
|
|
872
|
+
* and outliving the cache entries themselves, which the external store expires
|
|
873
|
+
* on its own schedule. Eviction is insertion-ordered (`Set` iteration order),
|
|
874
|
+
* so the oldest bookkeeping goes first; dropping a key only costs a missed
|
|
875
|
+
* local invalidation for an entry that will still expire by TTL.
|
|
876
|
+
*/
|
|
877
|
+
const registerKey = (orgId, key) => {
|
|
878
|
+
trackedKeys.add(key);
|
|
879
|
+
const bucket = orgId ?? "no-org";
|
|
880
|
+
let keys = orgKeyIndex.get(bucket);
|
|
881
|
+
if (!keys) {
|
|
882
|
+
keys = /* @__PURE__ */ new Set();
|
|
883
|
+
orgKeyIndex.set(bucket, keys);
|
|
884
|
+
}
|
|
885
|
+
keys.add(key);
|
|
886
|
+
if (trackedKeys.size > maxTrackedKeys) {
|
|
887
|
+
const overflow = trackedKeys.size - maxTrackedKeys;
|
|
888
|
+
const iter = trackedKeys.values();
|
|
889
|
+
for (let i = 0; i < overflow; i++) {
|
|
890
|
+
const oldest = iter.next().value;
|
|
891
|
+
if (oldest) forgetKey(oldest);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
const forgetKey = (key) => {
|
|
896
|
+
trackedKeys.delete(key);
|
|
897
|
+
for (const [bucket, keys] of orgKeyIndex) if (keys.delete(key) && keys.size === 0) orgKeyIndex.delete(bucket);
|
|
898
|
+
};
|
|
886
899
|
const nodeId = randomUUID().slice(0, 8);
|
|
887
900
|
const DEFAULT_EVENT_TYPE = "arc.permissions.invalidated";
|
|
888
901
|
let eventBridge = null;
|
|
889
902
|
async function localInvalidateByOrg(orgId) {
|
|
890
903
|
if (!cacheStore) return;
|
|
891
904
|
const prefix = `${orgId}::`;
|
|
892
|
-
|
|
893
|
-
|
|
905
|
+
if (cacheStore.clear) try {
|
|
906
|
+
await cacheStore.clear(`${prefix}*`);
|
|
907
|
+
const keys = orgKeyIndex.get(orgId);
|
|
908
|
+
if (keys) for (const k of keys) trackedKeys.delete(k);
|
|
909
|
+
orgKeyIndex.delete(orgId);
|
|
910
|
+
return;
|
|
911
|
+
} catch (error) {
|
|
912
|
+
logger.warn(`[DynamicPermissionMatrix] invalidateByOrg clear('${prefix}*') failed — falling back to per-key delete: ${error instanceof Error ? error.message : String(error)}`);
|
|
913
|
+
}
|
|
914
|
+
const toDelete = new Set(orgKeyIndex.get(orgId) ?? []);
|
|
915
|
+
for (const key of trackedKeys) if (key.startsWith(prefix)) toDelete.add(key);
|
|
894
916
|
for (const key of toDelete) try {
|
|
895
917
|
await cacheStore.delete(key);
|
|
896
|
-
|
|
918
|
+
forgetKey(key);
|
|
897
919
|
} catch (error) {
|
|
898
920
|
logger.warn(`[DynamicPermissionMatrix] invalidateByOrg delete failed for '${key}': ${error instanceof Error ? error.message : String(error)}`);
|
|
899
921
|
}
|
|
922
|
+
orgKeyIndex.delete(orgId);
|
|
900
923
|
}
|
|
901
924
|
function isActionAllowed(actions, action) {
|
|
902
925
|
if (!actions || actions.length === 0) return false;
|
|
@@ -916,27 +939,22 @@ function createDynamicPermissionMatrix(config) {
|
|
|
916
939
|
}
|
|
917
940
|
async function resolveMatrix(ctx, orgId, orgRoles) {
|
|
918
941
|
if (!cacheStore) return config.resolveRolePermissions(ctx);
|
|
919
|
-
const
|
|
942
|
+
const customKey = config.cache?.key?.(ctx);
|
|
943
|
+
const cacheKey = customKey ? `${orgId ?? "no-org"}::${(orgRoles ?? []).slice().sort().join(",")}::${customKey}` : buildDefaultCacheKey(ctx, orgId, orgRoles);
|
|
920
944
|
if (!cacheKey) return config.resolveRolePermissions(ctx);
|
|
921
945
|
try {
|
|
922
946
|
const hit = await cacheStore.get(cacheKey);
|
|
923
|
-
if (hit)
|
|
947
|
+
if (hit) {
|
|
948
|
+
registerKey(orgId, cacheKey);
|
|
949
|
+
return hit;
|
|
950
|
+
}
|
|
924
951
|
} catch (error) {
|
|
925
952
|
logger.warn(`[DynamicPermissionMatrix] Cache get failed for '${cacheKey}': ${error instanceof Error ? error.message : String(error)}`);
|
|
926
953
|
}
|
|
927
954
|
const value = await config.resolveRolePermissions(ctx);
|
|
928
955
|
try {
|
|
929
956
|
await cacheStore.set(cacheKey, value, cacheTtlSeconds);
|
|
930
|
-
|
|
931
|
-
const maxTracked = config.cache?.maxEntries ?? 1e4;
|
|
932
|
-
if (trackedKeys.size > maxTracked) {
|
|
933
|
-
const overflow = trackedKeys.size - maxTracked;
|
|
934
|
-
const iter = trackedKeys.values();
|
|
935
|
-
for (let i = 0; i < overflow; i++) {
|
|
936
|
-
const oldest = iter.next().value;
|
|
937
|
-
if (oldest) trackedKeys.delete(oldest);
|
|
938
|
-
}
|
|
939
|
-
}
|
|
957
|
+
registerKey(orgId, cacheKey);
|
|
940
958
|
} catch (error) {
|
|
941
959
|
logger.warn(`[DynamicPermissionMatrix] Cache set failed for '${cacheKey}': ${error instanceof Error ? error.message : String(error)}`);
|
|
942
960
|
}
|
|
@@ -944,34 +962,20 @@ function createDynamicPermissionMatrix(config) {
|
|
|
944
962
|
}
|
|
945
963
|
function can(required) {
|
|
946
964
|
return async (ctx) => {
|
|
947
|
-
if (!ctx.user) return
|
|
948
|
-
|
|
949
|
-
reason: "Authentication required"
|
|
950
|
-
};
|
|
951
|
-
const scope = getRequestScope(ctx.request);
|
|
965
|
+
if (!ctx.user) return deny("Authentication required");
|
|
966
|
+
const scope = scopeOf(ctx);
|
|
952
967
|
if (isElevated(scope)) return true;
|
|
953
|
-
if (!isMember(scope)) return
|
|
954
|
-
granted: false,
|
|
955
|
-
reason: "Organization membership required"
|
|
956
|
-
};
|
|
968
|
+
if (!isMember(scope)) return deny("Organization membership required");
|
|
957
969
|
const orgRoles = scope.orgRoles;
|
|
958
|
-
if (orgRoles.length === 0) return
|
|
959
|
-
granted: false,
|
|
960
|
-
reason: "Not a member of this organization"
|
|
961
|
-
};
|
|
970
|
+
if (orgRoles.length === 0) return deny("Not a member of this organization");
|
|
962
971
|
let matrix;
|
|
963
972
|
try {
|
|
964
973
|
matrix = await resolveMatrix(ctx, scope.organizationId, orgRoles);
|
|
965
974
|
} catch (error) {
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
reason: `Permission matrix resolution failed: ${error instanceof Error ? error.message : String(error)}`
|
|
969
|
-
};
|
|
975
|
+
logger.warn(`[DynamicPermissionMatrix] matrix resolution failed for org '${scope.organizationId ?? "?"}': ${error instanceof Error ? error.message : String(error)}`);
|
|
976
|
+
return deny("Permission policy is temporarily unavailable");
|
|
970
977
|
}
|
|
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
|
-
};
|
|
978
|
+
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
979
|
return true;
|
|
976
980
|
};
|
|
977
981
|
}
|
|
@@ -1005,6 +1009,7 @@ function createDynamicPermissionMatrix(config) {
|
|
|
1005
1009
|
if (cacheStore.clear) try {
|
|
1006
1010
|
await cacheStore.clear();
|
|
1007
1011
|
trackedKeys.clear();
|
|
1012
|
+
orgKeyIndex.clear();
|
|
1008
1013
|
return;
|
|
1009
1014
|
} catch (error) {
|
|
1010
1015
|
logger.warn(`[DynamicPermissionMatrix] cacheStore.clear failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1015,6 +1020,7 @@ function createDynamicPermissionMatrix(config) {
|
|
|
1015
1020
|
logger.warn(`[DynamicPermissionMatrix] Cache delete failed for '${key}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1016
1021
|
}
|
|
1017
1022
|
trackedKeys.clear();
|
|
1023
|
+
orgKeyIndex.clear();
|
|
1018
1024
|
},
|
|
1019
1025
|
async connectEvents(events, options) {
|
|
1020
1026
|
if (eventBridge) await this.disconnectEvents();
|
|
@@ -1052,8 +1058,187 @@ function createDynamicPermissionMatrix(config) {
|
|
|
1052
1058
|
};
|
|
1053
1059
|
}
|
|
1054
1060
|
//#endregion
|
|
1061
|
+
//#region src/permissions/explain.ts
|
|
1062
|
+
/**
|
|
1063
|
+
* Extract a check's static requirement from its `PermissionCheckMeta`. Public
|
|
1064
|
+
* wins; then a scope-context gate (optionally with roles); then the union of
|
|
1065
|
+
* platform + org roles a role gate declares; otherwise "authenticated"
|
|
1066
|
+
* (auth-required / custom / request-time checks). An `allOf` that couldn't be
|
|
1067
|
+
* fully captured statically carries `conditional: true` (see `_conditional`).
|
|
1068
|
+
*/
|
|
1069
|
+
function describePermission(check) {
|
|
1070
|
+
if (check._isPublic) return { kind: "public" };
|
|
1071
|
+
const roles = [...check._roles ?? [], ...check._orgRoles ?? []];
|
|
1072
|
+
const scope = check._scopeContext;
|
|
1073
|
+
const conditional = check._conditional ? true : void 0;
|
|
1074
|
+
if (scope && Object.keys(scope).length > 0) return roles.length > 0 ? {
|
|
1075
|
+
kind: "scoped",
|
|
1076
|
+
scope,
|
|
1077
|
+
roles,
|
|
1078
|
+
conditional
|
|
1079
|
+
} : {
|
|
1080
|
+
kind: "scoped",
|
|
1081
|
+
scope,
|
|
1082
|
+
conditional
|
|
1083
|
+
};
|
|
1084
|
+
if (roles.length > 0) return {
|
|
1085
|
+
kind: "roles",
|
|
1086
|
+
roles,
|
|
1087
|
+
conditional
|
|
1088
|
+
};
|
|
1089
|
+
return { kind: "authenticated" };
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Explain whether a principal (by roles) passes a permission check, from static
|
|
1093
|
+
* metadata alone — for permission-matrix UIs, docs, and audits. Omit `principal`
|
|
1094
|
+
* to get the requirement-only view (roles → `conditional`).
|
|
1095
|
+
*
|
|
1096
|
+
* @example
|
|
1097
|
+
* explainAccess(requireRoles("admin"), { roles: ["editor"] })
|
|
1098
|
+
* // → { decision: "deny", reason: "requires one of: admin", requirement: { kind: "roles", roles: ["admin"] } }
|
|
1099
|
+
*/
|
|
1100
|
+
function explainAccess(check, principal) {
|
|
1101
|
+
const requirement = describePermission(check);
|
|
1102
|
+
if (requirement.kind === "public") return {
|
|
1103
|
+
decision: "allow",
|
|
1104
|
+
reason: "public — no authentication required",
|
|
1105
|
+
requirement
|
|
1106
|
+
};
|
|
1107
|
+
if (requirement.kind === "authenticated") return {
|
|
1108
|
+
decision: "conditional",
|
|
1109
|
+
reason: "requires authentication; decided per request",
|
|
1110
|
+
requirement
|
|
1111
|
+
};
|
|
1112
|
+
const held = /* @__PURE__ */ new Set([...principal?.roles ?? [], ...principal?.orgRoles ?? []]);
|
|
1113
|
+
if (requirement.kind === "scoped") {
|
|
1114
|
+
const scopeReason = describeScope(requirement.scope);
|
|
1115
|
+
const rolesReason = requirement.roles?.length ? `role(s) ${requirement.roles.join(", ")} + ` : "";
|
|
1116
|
+
if (requirement.roles?.length && principal && !requirement.roles.some((r) => held.has(r))) return {
|
|
1117
|
+
decision: "deny",
|
|
1118
|
+
reason: `requires ${rolesReason}${scopeReason}`,
|
|
1119
|
+
requirement
|
|
1120
|
+
};
|
|
1121
|
+
if (!principal?.scope) return {
|
|
1122
|
+
decision: "conditional",
|
|
1123
|
+
reason: `requires ${rolesReason}${scopeReason}`,
|
|
1124
|
+
requirement
|
|
1125
|
+
};
|
|
1126
|
+
if (!Object.entries(requirement.scope).every(([dim, val]) => val === void 0 ? principal.scope?.[dim] !== void 0 : principal.scope?.[dim] === val)) return {
|
|
1127
|
+
decision: "deny",
|
|
1128
|
+
reason: `requires ${rolesReason}${scopeReason}`,
|
|
1129
|
+
requirement
|
|
1130
|
+
};
|
|
1131
|
+
if (requirement.conditional) return {
|
|
1132
|
+
decision: "conditional",
|
|
1133
|
+
reason: `in scope ${scopeReason}; additional runtime conditions apply`,
|
|
1134
|
+
requirement
|
|
1135
|
+
};
|
|
1136
|
+
return {
|
|
1137
|
+
decision: "allow",
|
|
1138
|
+
reason: `granted in scope: ${scopeReason}`,
|
|
1139
|
+
requirement
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
const rolesText = `requires one of: ${requirement.roles.join(", ")}`;
|
|
1143
|
+
if (!principal) return {
|
|
1144
|
+
decision: "conditional",
|
|
1145
|
+
reason: rolesText,
|
|
1146
|
+
requirement
|
|
1147
|
+
};
|
|
1148
|
+
const matched = requirement.roles.filter((r) => held.has(r));
|
|
1149
|
+
if (matched.length === 0) return {
|
|
1150
|
+
decision: "deny",
|
|
1151
|
+
reason: rolesText,
|
|
1152
|
+
requirement
|
|
1153
|
+
};
|
|
1154
|
+
if (requirement.conditional) return {
|
|
1155
|
+
decision: "conditional",
|
|
1156
|
+
reason: `has role(s) ${matched.join(", ")}; additional runtime conditions apply`,
|
|
1157
|
+
requirement
|
|
1158
|
+
};
|
|
1159
|
+
return {
|
|
1160
|
+
decision: "allow",
|
|
1161
|
+
reason: `granted via role(s): ${matched.join(", ")}`,
|
|
1162
|
+
requirement
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
/** Render a scope-context requirement as a readable clause: `branchRole=head_office`. */
|
|
1166
|
+
function describeScope(scope) {
|
|
1167
|
+
return Object.entries(scope).map(([dim, val]) => val === void 0 ? `${dim} (any)` : `${dim}=${val}`).join(", ");
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Introspect a whole permission map (`{ list, get, create, ... }` or any
|
|
1171
|
+
* action→check record) into `action → requirement`. The reusable core of a
|
|
1172
|
+
* `/permissions/matrix` endpoint or an access audit — framework-owned, so a host
|
|
1173
|
+
* never hand-rolls `(check as any)._roles` peeking. Undefined slots (a CRUD op
|
|
1174
|
+
* with no gate = public-by-omission) surface as `public` so the inventory is
|
|
1175
|
+
* honest about what's exposed.
|
|
1176
|
+
*/
|
|
1177
|
+
function describePermissionMap(permissions) {
|
|
1178
|
+
const out = {};
|
|
1179
|
+
for (const [action, check] of Object.entries(permissions)) out[action] = check ? describePermission(check) : { kind: "public" };
|
|
1180
|
+
return out;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* The public attack surface of a permission map — the actions reachable with NO
|
|
1184
|
+
* authentication (explicit `allowPublic()` or public-by-omission). Empty is the
|
|
1185
|
+
* healthy default for a protected resource; a non-empty list is what a
|
|
1186
|
+
* "what's exposed?" audit report should show.
|
|
1187
|
+
*/
|
|
1188
|
+
function collectPublicSurface(permissions) {
|
|
1189
|
+
return Object.entries(describePermissionMap(permissions)).filter(([, req]) => req.kind === "public").map(([action]) => action);
|
|
1190
|
+
}
|
|
1191
|
+
//#endregion
|
|
1055
1192
|
//#region src/permissions/grants.ts
|
|
1056
1193
|
/**
|
|
1194
|
+
* Per-record grants — the `requireGrant` combinator + mode lattice.
|
|
1195
|
+
*
|
|
1196
|
+
* The one ACL idea arc's permission vocabulary lacked (design verdict:
|
|
1197
|
+
* `designs/record-sharing.md`): subject × record × mode grants, Puter-style,
|
|
1198
|
+
* WITHOUT Puter's mistakes. Arc ships only the GATE — grant storage is a
|
|
1199
|
+
* host `defineResource` (tenant-scoped, `audit: true`, any kit via
|
|
1200
|
+
* repo-core's `RepositoryLike`), and the `resolve` callback is structural,
|
|
1201
|
+
* so core stays DB-agnostic and storage-free by construction.
|
|
1202
|
+
*
|
|
1203
|
+
* What this design deliberately avoids (verified against Puter's source):
|
|
1204
|
+
* - string-typed permissions (`fs:<uuid>:read` VARCHARs that silently
|
|
1205
|
+
* never match when malformed) → typed {@link GrantMode} union
|
|
1206
|
+
* - per-item ACL checks on list responses (their readdir returns
|
|
1207
|
+
* unfiltered children; the UI stats each item — N+1) → resolutions
|
|
1208
|
+
* return a row `policy` that composes into THE list query via the
|
|
1209
|
+
* decision's `policy` → `_policyFilters` (one paginated, correct query)
|
|
1210
|
+
* - un-revocable capability tokens → share links should reference a
|
|
1211
|
+
* grant ROW (`subjectType: 'link'`); delete the row, the link dies
|
|
1212
|
+
*
|
|
1213
|
+
* @example
|
|
1214
|
+
* ```ts
|
|
1215
|
+
* // Host owns the grants table — it's just a resource:
|
|
1216
|
+
* // { resource, resourceId, subjectType: 'user'|'team'|'link',
|
|
1217
|
+
* // subjectId, mode, grantedBy, expiresAt? }
|
|
1218
|
+
* import { anyOf, requireGrant, requireOwnership } from '@classytic/arc/permissions';
|
|
1219
|
+
*
|
|
1220
|
+
* defineResource({
|
|
1221
|
+
* name: 'document',
|
|
1222
|
+
* permissions: {
|
|
1223
|
+
* get: anyOf(requireOwnership('ownerId'), requireGrant({ mode: 'read', resolve })),
|
|
1224
|
+
* update: anyOf(requireOwnership('ownerId'), requireGrant({ mode: 'write', resolve })),
|
|
1225
|
+
* list: requireGrant({
|
|
1226
|
+
* mode: 'list',
|
|
1227
|
+
* // List-shaped resolution: return filters — owner OR granted ids.
|
|
1228
|
+
* resolve: async (ctx) => ({
|
|
1229
|
+
* filters: {
|
|
1230
|
+
* $or: [
|
|
1231
|
+
* { ownerId: ctx.user?.id },
|
|
1232
|
+
* { _id: { $in: await grants.grantedIds(ctx) } },
|
|
1233
|
+
* ],
|
|
1234
|
+
* },
|
|
1235
|
+
* }),
|
|
1236
|
+
* }),
|
|
1237
|
+
* },
|
|
1238
|
+
* });
|
|
1239
|
+
* ```
|
|
1240
|
+
*/
|
|
1241
|
+
/**
|
|
1057
1242
|
* The grant-mode lattice, weakest → strongest. Holding a mode satisfies
|
|
1058
1243
|
* every mode at or below it (`write` ⊇ `read` ⊇ `list` ⊇ `see`); `manage`
|
|
1059
1244
|
* (grant/revoke rights) satisfies everything. Same semantics as Puter's
|
|
@@ -1095,36 +1280,21 @@ function requireGrant(options) {
|
|
|
1095
1280
|
try {
|
|
1096
1281
|
resolution = await resolve(ctx);
|
|
1097
1282
|
} catch (err) {
|
|
1098
|
-
ctx.request
|
|
1283
|
+
ctx.request?.log?.warn?.({
|
|
1099
1284
|
err,
|
|
1100
1285
|
resource: ctx.resource,
|
|
1101
1286
|
action: ctx.action
|
|
1102
1287
|
}, "requireGrant: resolve threw — denying (fail-closed)");
|
|
1103
|
-
return
|
|
1104
|
-
granted: false,
|
|
1105
|
-
reason: "Grant lookup failed"
|
|
1106
|
-
};
|
|
1288
|
+
return deny("Grant lookup failed");
|
|
1107
1289
|
}
|
|
1108
1290
|
if (typeof resolution === "boolean") return resolution;
|
|
1109
1291
|
const { mode: held, filters, reason } = resolution;
|
|
1110
1292
|
if (held !== void 0) {
|
|
1111
|
-
if (!modeSatisfies(held, required)) return
|
|
1112
|
-
|
|
1113
|
-
...reason ? { reason } : {}
|
|
1114
|
-
};
|
|
1115
|
-
return {
|
|
1116
|
-
granted: true,
|
|
1117
|
-
...filters ? { filters } : {}
|
|
1118
|
-
};
|
|
1293
|
+
if (!modeSatisfies(held, required)) return deny(reason);
|
|
1294
|
+
return allow(filters ? { policy: filters } : void 0);
|
|
1119
1295
|
}
|
|
1120
|
-
if (filters !== void 0) return {
|
|
1121
|
-
|
|
1122
|
-
filters
|
|
1123
|
-
};
|
|
1124
|
-
return {
|
|
1125
|
-
granted: false,
|
|
1126
|
-
...reason ? { reason } : {}
|
|
1127
|
-
};
|
|
1296
|
+
if (filters !== void 0) return allow({ policy: filters });
|
|
1297
|
+
return deny(reason);
|
|
1128
1298
|
};
|
|
1129
1299
|
}
|
|
1130
1300
|
//#endregion
|
|
@@ -1138,27 +1308,59 @@ function requireGrant(options) {
|
|
|
1138
1308
|
*/
|
|
1139
1309
|
function createRoleHierarchy(map) {
|
|
1140
1310
|
const cache = /* @__PURE__ */ new Map();
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1311
|
+
/**
|
|
1312
|
+
* Resolve a role's transitive expansion.
|
|
1313
|
+
*
|
|
1314
|
+
* `stack` is the CURRENT recursion path (a DFS stack), NOT a shared visited
|
|
1315
|
+
* accumulator: a role is added on entry and REMOVED on exit, so sibling
|
|
1316
|
+
* branches never pollute each other. Without this, a diamond `A→B→D`, `A→C→D`
|
|
1317
|
+
* would let B's traversal leave `D` in a shared set, making C's `D` look like a
|
|
1318
|
+
* back-edge and caching `C` WITHOUT `D` — a nondeterministic under-grant.
|
|
1319
|
+
*
|
|
1320
|
+
* Returns `cyclic` = whether a back-edge into `stack` was cut anywhere in the
|
|
1321
|
+
* subtree. A cyclic subtree's result depends on the entry path, so it is NOT
|
|
1322
|
+
* cached (recomputed each call — correct, if unmemoized). Acyclic subtrees (the
|
|
1323
|
+
* norm) cache safely.
|
|
1324
|
+
*/
|
|
1325
|
+
function resolveRole(role, stack) {
|
|
1326
|
+
if (stack.has(role)) return {
|
|
1327
|
+
roles: [],
|
|
1328
|
+
cyclic: true
|
|
1329
|
+
};
|
|
1144
1330
|
const cached = cache.get(role);
|
|
1145
|
-
if (cached) return
|
|
1331
|
+
if (cached) return {
|
|
1332
|
+
roles: cached,
|
|
1333
|
+
cyclic: false
|
|
1334
|
+
};
|
|
1146
1335
|
const children = map[role];
|
|
1147
1336
|
if (!children || children.length === 0) {
|
|
1148
1337
|
cache.set(role, [role]);
|
|
1149
|
-
return
|
|
1338
|
+
return {
|
|
1339
|
+
roles: [role],
|
|
1340
|
+
cyclic: false
|
|
1341
|
+
};
|
|
1150
1342
|
}
|
|
1343
|
+
stack.add(role);
|
|
1151
1344
|
const result = [role];
|
|
1152
|
-
|
|
1345
|
+
let cyclic = false;
|
|
1346
|
+
for (const child of children) {
|
|
1347
|
+
const childResult = resolveRole(child, stack);
|
|
1348
|
+
result.push(...childResult.roles);
|
|
1349
|
+
if (childResult.cyclic) cyclic = true;
|
|
1350
|
+
}
|
|
1351
|
+
stack.delete(role);
|
|
1153
1352
|
const deduped = [...new Set(result)];
|
|
1154
|
-
cache.set(role, deduped);
|
|
1155
|
-
return
|
|
1353
|
+
if (!cyclic) cache.set(role, deduped);
|
|
1354
|
+
return {
|
|
1355
|
+
roles: deduped,
|
|
1356
|
+
cyclic
|
|
1357
|
+
};
|
|
1156
1358
|
}
|
|
1157
1359
|
return {
|
|
1158
1360
|
expand(roles) {
|
|
1159
1361
|
if (roles.length === 0) return [];
|
|
1160
1362
|
const all = /* @__PURE__ */ new Set();
|
|
1161
|
-
for (const role of roles) for (const expanded of resolveRole(role, /* @__PURE__ */ new Set())) all.add(expanded);
|
|
1363
|
+
for (const role of roles) for (const expanded of resolveRole(role, /* @__PURE__ */ new Set()).roles) all.add(expanded);
|
|
1162
1364
|
return [...all];
|
|
1163
1365
|
},
|
|
1164
1366
|
includes(userRoles, requiredRole) {
|
|
@@ -1343,4 +1545,4 @@ function readOnly(overrides) {
|
|
|
1343
1545
|
}, overrides);
|
|
1344
1546
|
}
|
|
1345
1547
|
//#endregion
|
|
1346
|
-
export {
|
|
1548
|
+
export { allow as A, roles as B, requireScopeContext as C, requireDPoP as D, requireAgentScope as E, not as F, scopeOf as H, requireAuth as I, requireOwnership as L, anyOf as M, deny as N, requireMandate as O, denyAll as P, requirePlatformRole as R, requireOrgRole as S, requireTeamMembership as T, when 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, requireRoles as z };
|