permdock 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +327 -1
- package/dist/a2a/index.d.ts +169 -0
- package/dist/a2a/index.js +232 -0
- package/dist/adapter-context-mlWsVGGg.js +23 -0
- package/dist/ai-sdk/index.d.ts +95 -0
- package/dist/ai-sdk/index.js +154 -0
- package/dist/approval-required-TzNB9oQA.js +6 -0
- package/dist/approvals/index.d.ts +3 -0
- package/dist/approvals/index.js +5 -0
- package/dist/approvals-DbYAuNkG.js +224 -0
- package/dist/approvers-DT4AVyIg.js +52 -0
- package/dist/arazzo-BAgw9ap6.js +70 -0
- package/dist/arazzo-cmvX2jDe.js +54 -0
- package/dist/authzen/index.d.ts +39 -0
- package/dist/authzen/index.js +302 -0
- package/dist/better-auth/index.d.ts +109 -0
- package/dist/better-auth/index.js +344 -0
- package/dist/better-supabase/index.d.ts +206 -0
- package/dist/better-supabase/index.js +620 -0
- package/dist/bind-CVy3Espv.d.ts +25 -0
- package/dist/bind-VnYn2HKq.js +97 -0
- package/dist/boundary-BM8a_P6E.js +13 -0
- package/dist/boundary-BmHxVH1J.d.ts +15 -0
- package/dist/budget-JqcRqyhK.d.ts +11 -0
- package/dist/budget-NbEtbJC_.js +11 -0
- package/dist/canonical-json-DemiK4Vp.js +26 -0
- package/dist/capability-4qPdO9gk.js +173 -0
- package/dist/capability-Dq43tT1u.d.ts +99 -0
- package/dist/catalog/index.d.ts +12 -0
- package/dist/catalog/index.js +2 -0
- package/dist/catalog-BjZSovXQ.js +2 -0
- package/dist/catalog-doc-Dsc9y7sd.js +309 -0
- package/dist/catalog-h2tlKFkq.js +48 -0
- package/dist/catalog-path-CnI0XTFK.js +12 -0
- package/dist/catalog-z8q6bi_r.js +55 -0
- package/dist/claude-agent/index.d.ts +72 -0
- package/dist/claude-agent/index.js +77 -0
- package/dist/clerk/index.d.ts +65 -0
- package/dist/clerk/index.js +301 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +20 -0
- package/dist/cli/index.d.ts +46 -0
- package/dist/cli/index.js +5 -0
- package/dist/clients-C7zLFuWT.d.ts +12 -0
- package/dist/clients-DjU5ABMy.js +23 -0
- package/dist/cloud/index.d.ts +90 -0
- package/dist/cloud/index.js +404 -0
- package/dist/cloud-BGhHS-lY.js +50 -0
- package/dist/cloud-Bo3e9Ak3.js +118 -0
- package/dist/collect-DDDv_ESs.js +502 -0
- package/dist/collect-DLlujdHC.js +83 -0
- package/dist/collect-rG2IQ3t0.js +2 -0
- package/dist/commands-DWOrSQic.js +97 -0
- package/dist/compact-y6vfu2_f.js +19 -0
- package/dist/compare-pfyVnBjE.js +12 -0
- package/dist/compile/index.d.ts +3 -0
- package/dist/compile/index.js +2 -0
- package/dist/compile-Ba4blROR.js +613 -0
- package/dist/compile-CNnl6uIt.d.ts +94 -0
- package/dist/config-BJiM-xAm.js +157 -0
- package/dist/config-Bn00Z3ks.js +36 -0
- package/dist/config-report-Dw_JcUqT.js +44 -0
- package/dist/connection-CU8MQxRi.js +194 -0
- package/dist/context-Ttp0ekMT.js +82 -0
- package/dist/convex/index.d.ts +45 -0
- package/dist/convex/index.js +65 -0
- package/dist/create-BWyx3jua.d.ts +245 -0
- package/dist/create-CPhDL6qx.js +253 -0
- package/dist/credential-8P81ff4u.js +306 -0
- package/dist/delegation-D8pUEuce.js +103 -0
- package/dist/describe-B_gVZEVi.js +94 -0
- package/dist/describe-PdoqESvg.d.ts +36 -0
- package/dist/diff-B8Tunc3o.js +451 -0
- package/dist/diff-CzqLxZ77.js +48 -0
- package/dist/digest-B3kc_PgQ.d.ts +19 -0
- package/dist/digest-BI-6aa6d.js +27 -0
- package/dist/doctor-WpvG6p4k.js +1194 -0
- package/dist/doctor-collect-ByfYlNv1.js +664 -0
- package/dist/doctor-rOr-4ZfS.js +53 -0
- package/dist/drizzle/index.d.ts +73 -0
- package/dist/drizzle/index.js +160 -0
- package/dist/duration-xK2jGFVc.js +25 -0
- package/dist/elysia/index.d.ts +49 -0
- package/dist/elysia/index.js +59 -0
- package/dist/errors-CWfoNg9N.js +47 -0
- package/dist/errors-Cz5zn0mW.js +123 -0
- package/dist/errors-DAqevpNk.d.ts +125 -0
- package/dist/errors-DRnGZHzF.js +30 -0
- package/dist/evaluation-items-CfCCejQd.js +40 -0
- package/dist/evaluations-tz6D9A2G.js +461 -0
- package/dist/eve/index.d.ts +86 -0
- package/dist/eve/index.js +176 -0
- package/dist/expiry-BoBJ8Tvc.js +7 -0
- package/dist/express/index.d.ts +23 -0
- package/dist/express/index.js +78 -0
- package/dist/fastify/index.d.ts +33 -0
- package/dist/fastify/index.js +92 -0
- package/dist/fields-BMl1RxoX.js +26 -0
- package/dist/files-DWUN9PzI.js +125 -0
- package/dist/fixtures-ht28Cplr.js +67 -0
- package/dist/freeze-CrU8nQeX.js +60 -0
- package/dist/from-snapshot-C3sHpZNS.js +569 -0
- package/dist/generate-CH7ThJ14.js +173 -0
- package/dist/grantee-Cv5Sogfv.js +602 -0
- package/dist/graph-sql-B-dsAT6j.d.ts +29 -0
- package/dist/headers-BZSDieNT.js +6 -0
- package/dist/headers-Cf-zWvsO.d.ts +6 -0
- package/dist/helpers-H_KARDVC.js +421 -0
- package/dist/hono/index.d.ts +48 -0
- package/dist/hono/index.js +99 -0
- package/dist/hosted-DJQw46hn.js +266 -0
- package/dist/http-BIhmLfCc.js +95 -0
- package/dist/http-DPuDaChb.d.ts +13 -0
- package/dist/http-SO7qGttf.js +44 -0
- package/dist/ids-DC4V4CFu.js +18 -0
- package/dist/index-A0YNL8jZ.d.ts +177 -0
- package/dist/index-CtE07CBF.d.ts +6 -0
- package/dist/index-D3vbkoSI.d.ts +483 -0
- package/dist/index-D6_cSa1z.d.ts +201 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +26 -0
- package/dist/instance-options-BK_rNOSC.d.ts +11 -0
- package/dist/instance-options-Dhhcnm3y.js +22 -0
- package/dist/json-schema-BsAflUGW.js +106 -0
- package/dist/jwt/index.d.ts +91 -0
- package/dist/jwt/index.js +590 -0
- package/dist/kernel-GSQnMz3O.js +208 -0
- package/dist/kysely/index.d.ts +78 -0
- package/dist/kysely/index.js +129 -0
- package/dist/live-options-H2eG8dfJ.js +60 -0
- package/dist/load-DhWEB9P1.js +75 -0
- package/dist/load-DwJUafYN.js +2 -0
- package/dist/load-jose-BL6LH4bL.js +11 -0
- package/dist/lru-8q4WZjBV.js +28 -0
- package/dist/manifest-iaoBXHKc.js +783 -0
- package/dist/manifest-uDS9y5de.d.ts +206 -0
- package/dist/map-AcxK9o_D.js +147 -0
- package/dist/map-claims-Z4QZOiFF.js +369 -0
- package/dist/map-error-pgUWNNtG.js +23 -0
- package/dist/markers-B0hYLvMD.js +60 -0
- package/dist/may-use-DndQ5tQ-.js +77 -0
- package/dist/mcp/index.d.ts +159 -0
- package/dist/mcp/index.js +603 -0
- package/dist/nest/index.d.ts +75 -0
- package/dist/nest/index.js +261 -0
- package/dist/next/client.d.ts +23 -0
- package/dist/next/client.js +33 -0
- package/dist/next/index.d.ts +93 -0
- package/dist/next/index.js +180 -0
- package/dist/next/plugin.d.ts +14 -0
- package/dist/next/plugin.js +33 -0
- package/dist/node/index.d.ts +24 -0
- package/dist/node/index.js +34 -0
- package/dist/openai/index.d.ts +62 -0
- package/dist/openai/index.js +92 -0
- package/dist/openapi/index.d.ts +176 -0
- package/dist/openapi/index.js +3 -0
- package/dist/openapi-Bbg4wGVL.js +164 -0
- package/dist/openapi-BqRBvAng.js +538 -0
- package/dist/openapi-CqqEbHsL.js +250 -0
- package/dist/openapi-import-cmwetbzw.js +5959 -0
- package/dist/openapi-schema--ARjyfQr.js +6902 -0
- package/dist/orpc/index.d.ts +51 -0
- package/dist/orpc/index.js +145 -0
- package/dist/otel/index.d.ts +13 -0
- package/dist/otel/index.js +202 -0
- package/dist/package-root-zGkxO3oN.js +30 -0
- package/dist/parse-BuyjxhZV.js +53 -0
- package/dist/parse-local-manifest-BkGQNzAh.d.ts +10 -0
- package/dist/parse-local-manifest-BrMozJCo.js +36 -0
- package/dist/pdp/index.d.ts +22 -0
- package/dist/pdp/index.js +682 -0
- package/dist/peer-BOr_kJE1.js +31 -0
- package/dist/permdock-CzqXjCpU.js +3471 -0
- package/dist/permissions-CDgvKG5L.d.ts +52 -0
- package/dist/permissions-CpZrivYv.js +142 -0
- package/dist/permissions-DVoF6dMt.js +662 -0
- package/dist/permissions-DttnqqxC.d.ts +214 -0
- package/dist/pins-8ILTftC9.js +12 -0
- package/dist/policy-CFZAr_Al.d.ts +2252 -0
- package/dist/policy-D8UFyERw.d.ts +20 -0
- package/dist/policy-xyWg7QdR.js +1122 -0
- package/dist/powersync-A8x_Yyvh.js +2 -0
- package/dist/powersync-C7kLglqX.js +540 -0
- package/dist/powersync-yW1UUFzp.js +62 -0
- package/dist/prisma/index.d.ts +136 -0
- package/dist/prisma/index.js +455 -0
- package/dist/problem-B43ztKIm.js +191 -0
- package/dist/problem-details-CkL50EEf.d.ts +7 -0
- package/dist/problem-details-_AMOYz-w.js +144 -0
- package/dist/prompt-CXYhLfsS.js +32 -0
- package/dist/protected-CNsMzrVd.d.ts +27 -0
- package/dist/protected-Cs6kTaHo.js +100 -0
- package/dist/protected-bZs6rbT1.d.ts +40 -0
- package/dist/provider-Cjr62YMV.d.ts +6 -0
- package/dist/provider-DLzpb2Xz.js +52 -0
- package/dist/provider-store-DNyC1y48.js +43 -0
- package/dist/react/index.d.ts +8 -0
- package/dist/react/index.js +7 -0
- package/dist/react/provider-client.d.ts +2 -0
- package/dist/react/provider-client.js +3 -0
- package/dist/react-native/index.d.ts +198 -0
- package/dist/react-native/index.js +696 -0
- package/dist/refs-KYb4ntaz.js +91 -0
- package/dist/relations-cjOL01hZ.js +505 -0
- package/dist/replay-BsOyTrdV.js +28 -0
- package/dist/replay-sqBGMnNs.d.ts +5 -0
- package/dist/resource-ref-CaS9yVPi.js +20 -0
- package/dist/revocations-Bhr0aMIC.js +27 -0
- package/dist/revocations-Cy06JNx2.d.ts +21 -0
- package/dist/rls-BMNydpHk.js +2521 -0
- package/dist/rls-CEvs64Kj.js +238 -0
- package/dist/rls-fields-BMasREhR.js +3502 -0
- package/dist/rls-graph-pc9fuXYm.js +435 -0
- package/dist/rls-import-D8F7j35Q.js +943 -0
- package/dist/rls-migrate-7wikfrVI.js +367 -0
- package/dist/rls-ownership-B5Lkq62W.js +843 -0
- package/dist/rls-shared-aOQ4hGTO.js +493 -0
- package/dist/rls-verify-D8B7dOTh.js +745 -0
- package/dist/row-check-Dt35czLy.d.ts +40 -0
- package/dist/rpc-DOK54_CS.js +89 -0
- package/dist/run-YXCJDNYk.js +174 -0
- package/dist/runtime-CB8gQFvS.js +11 -0
- package/dist/saas-Bj_FFkjN.js +1203 -0
- package/dist/schema-D3MnDMxa.js +300 -0
- package/dist/scim/index.d.ts +2 -0
- package/dist/scim/index.js +2 -0
- package/dist/scim-BFGBBAqR.js +1445 -0
- package/dist/scopes-CVdPrWN-.js +239 -0
- package/dist/server/index.d.ts +134 -0
- package/dist/server/index.js +11 -0
- package/dist/server-DlZ28Oz5.js +268 -0
- package/dist/sha256-BO0swRXL.js +150 -0
- package/dist/shared-DRu8VFVM.js +123 -0
- package/dist/sink-Bd9PSyfq.js +141 -0
- package/dist/sink-Gs6TTVa-.d.ts +104 -0
- package/dist/skills-Biq5uup3.js +192 -0
- package/dist/skills-JaymNCdK.js +56 -0
- package/dist/snapshot-Enxd0n-s.js +975 -0
- package/dist/snapshot-cache-D6jLelln.js +44 -0
- package/dist/snapshot-cache-DwQH8ufx.d.ts +45 -0
- package/dist/solid/index.d.ts +93 -0
- package/dist/solid/index.js +188 -0
- package/dist/sources-B0UvrJ21.js +534 -0
- package/dist/sources-BcJgw0lt.d.ts +192 -0
- package/dist/sql-xdg5f4Fi.js +27 -0
- package/dist/src-CSbUiozF.js +568 -0
- package/dist/ssf/index.d.ts +4 -0
- package/dist/ssf/index.js +596 -0
- package/dist/store-DMAWwAJV.js +371 -0
- package/dist/store-D_pjn91-.d.ts +36 -0
- package/dist/store-options-yO0V6gRn.js +716 -0
- package/dist/stores-4_sqiOA1.js +87 -0
- package/dist/stores-DVR5NHLE.d.ts +52 -0
- package/dist/stream-DDpvynSB.js +66 -0
- package/dist/stream-DlvG2hs-.d.ts +12 -0
- package/dist/style-CTm1AXDT.js +22 -0
- package/dist/subject-C6dLxqHU.js +24 -0
- package/dist/subject-settings-BwBmCdTR.js +128 -0
- package/dist/supabase/index.d.ts +183 -0
- package/dist/supabase/index.js +5 -0
- package/dist/supabase/middleware.d.ts +82 -0
- package/dist/supabase/middleware.js +102 -0
- package/dist/supabase-CwafwxxF.js +83 -0
- package/dist/supabase-DTBaC20O.js +774 -0
- package/dist/supabase-config-Bs44dare.js +472 -0
- package/dist/supabase-hook-BuxLQLpU.js +1814 -0
- package/dist/supabase-hook-D5qVd_D8.js +2 -0
- package/dist/supautils-DYZnzRWV.js +91 -0
- package/dist/svelte/PermissionBoundary.svelte +37 -0
- package/dist/svelte/Protected.svelte +40 -0
- package/dist/svelte/index.d.ts +13 -0
- package/dist/svelte/index.js +107 -0
- package/dist/svelte/runtime.d.ts +7 -0
- package/dist/svelte/runtime.js +4 -0
- package/dist/svelte/source.d.ts +13 -0
- package/dist/svelte/source.js +11 -0
- package/dist/terminal/index.d.ts +163 -0
- package/dist/terminal/index.js +833 -0
- package/dist/testing/index.d.ts +745 -0
- package/dist/testing/index.js +3672 -0
- package/dist/testing/saas/index.d.ts +194 -0
- package/dist/testing/saas/index.js +3 -0
- package/dist/testing/saas/permissions.d.ts +2 -0
- package/dist/testing/saas/permissions.js +2 -0
- package/dist/text-diff-BdkkYSbi.js +15 -0
- package/dist/thenable-okc2xDsj.js +10 -0
- package/dist/timeout-D51niuQP.js +8 -0
- package/dist/token-CIWxJOCh.js +50 -0
- package/dist/trpc/index.d.ts +61 -0
- package/dist/trpc/index.js +122 -0
- package/dist/types-9AWO9EGt.d.ts +197 -0
- package/dist/types-BPxgszlT.d.ts +550 -0
- package/dist/types-BVyImy5I.d.ts +120 -0
- package/dist/types-Bvc4oWT6.d.ts +9 -0
- package/dist/types-C3s9xVJG.d.ts +48 -0
- package/dist/types-Dnemwd77.d.ts +183 -0
- package/dist/types-kxznc7cn.d.ts +52 -0
- package/dist/types-vKyAMtcX.d.ts +157 -0
- package/dist/types-vNSDF7Dq.d.ts +66 -0
- package/dist/types-yF5LmQVD.d.ts +306 -0
- package/dist/unplugin/index.d.ts +7 -0
- package/dist/unplugin/index.js +31 -0
- package/dist/usage-D07N9QJ3.js +42 -0
- package/dist/usage-D82l1CwK.js +2 -0
- package/dist/usage-DG1oeCca.js +693 -0
- package/dist/validation-error-CnjZ4t92.js +33 -0
- package/dist/validation-error-CtF23SrC.d.ts +26 -0
- package/dist/validity-uOp8UpYn.js +245 -0
- package/dist/verifier-BxBo6I44.js +466 -0
- package/dist/version-BvacVTZR.js +17 -0
- package/dist/views-Soe7bGSC.js +95 -0
- package/dist/vocabulary-DA8a8zif.d.ts +56 -0
- package/dist/vue/index.d.ts +79 -0
- package/dist/vue/index.js +196 -0
- package/dist/watch-Etoa_Mvr.js +2 -0
- package/dist/watch-UihESpsJ.js +128 -0
- package/dist/webmcp/index.d.ts +75 -0
- package/dist/webmcp/index.js +307 -0
- package/package.json +450 -5
- package/rulesets/permdock.yaml +29 -0
- package/schemas/approval-request-v1.json +175 -0
- package/schemas/catalog-v1.json +535 -0
- package/schemas/openapi/oas-3.1.json +1298 -0
- package/schemas/openapi/oas-3.2.json +1516 -0
- package/schemas/openapi/overlay-1.1.json +79 -0
- package/schemas/supabase-claims-v1.json +144 -0
- package/schemas/supabase-manifest-v1.json +361 -0
- package/skills/permdock/SKILL.md +66 -0
- package/skills/permdock-agents/SKILL.md +77 -0
- package/skills/permdock-agents/references/runtimes.md +213 -0
- package/skills/permdock-approvals/SKILL.md +80 -0
- package/skills/permdock-approvals/references/stores-and-resume.md +94 -0
- package/skills/permdock-audit/SKILL.md +67 -0
- package/skills/permdock-credentials/SKILL.md +55 -0
- package/skills/permdock-credentials/references/tokens-keys-links.md +132 -0
- package/skills/permdock-data/SKILL.md +57 -0
- package/skills/permdock-data/references/queries-and-rls.md +62 -0
- package/skills/permdock-data/references/relationships.md +67 -0
- package/skills/permdock-tenancy/SKILL.md +80 -0
- package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +89 -0
- package/skills/permdock-wire/SKILL.md +116 -0
- package/skills/permdock-wire/references/adapters.md +492 -0
- package/skills/permdock-wire/references/policy-options.md +45 -0
|
@@ -0,0 +1,3672 @@
|
|
|
1
|
+
import { _ as listPermissions, b as resource, n as definePermissions } from "../permissions-DVoF6dMt.js";
|
|
2
|
+
import { r as principal } from "../refs-KYb4ntaz.js";
|
|
3
|
+
import { _ as memoryLimitStore, a as definePolicy, m as role, t as allow } from "../policy-xyWg7QdR.js";
|
|
4
|
+
import { _ as memoryRoleSource, u as snapshotFor } from "../src-CSbUiozF.js";
|
|
5
|
+
import { t as memoryRevocationFeed } from "../revocations-Bhr0aMIC.js";
|
|
6
|
+
import { a as normalizeMemberships, d as scopeList } from "../scopes-CVdPrWN-.js";
|
|
7
|
+
import { n as parseSnapshot, p as evaluateCondition } from "../snapshot-Enxd0n-s.js";
|
|
8
|
+
import { i as parsePolicyDocument, r as mergeHostedGrants } from "../hosted-DJQw46hn.js";
|
|
9
|
+
import { E as approvalPoliciesFor, b as validateCustomRole, t as createPermDock, v as membershipsClaim } from "../permdock-CzqXjCpU.js";
|
|
10
|
+
import { t as byCodePoint } from "../compare-pfyVnBjE.js";
|
|
11
|
+
import { n as fromSnapshot } from "../from-snapshot-C3sHpZNS.js";
|
|
12
|
+
import { o as parseCredential } from "../credential-8P81ff4u.js";
|
|
13
|
+
import { o as problemFromDecision } from "../problem-B43ztKIm.js";
|
|
14
|
+
import { l as APPROVAL_HEADER, r as memoryApprovalStore } from "../store-DMAWwAJV.js";
|
|
15
|
+
import { o as resolveApproval } from "../helpers-H_KARDVC.js";
|
|
16
|
+
import { a as parseApiKey } from "../server-DlZ28Oz5.js";
|
|
17
|
+
import { S as sha256Hex, n as directoryMembershipSource, r as scimHandler } from "../scim-BFGBBAqR.js";
|
|
18
|
+
import { t as supabaseMembershipsBudget } from "../budget-NbEtbJC_.js";
|
|
19
|
+
import { a as quoteSqlTable, r as quoteSqlIdent, t as SQL_IDENT } from "../sql-xdg5f4Fi.js";
|
|
20
|
+
import { i as saasPermissions } from "../permissions-CpZrivYv.js";
|
|
21
|
+
import { C as saasOrg, E as saasSeed, S as saasMemberships, a as saasProject, g as verifySaasSession, h as signSaasToken, k as saasPolicy, t as saasDoc, w as saasPrincipal, x as saasCustomRoles } from "../saas-Bj_FFkjN.js";
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
23
|
+
//#region src/testing/describe-policy.ts
|
|
24
|
+
function isOutcomeCell(value) {
|
|
25
|
+
return value === "granted" || value === "denied" || value === "approval-required" || value !== null && typeof value === "object" && ("denials" in value || "outcome" in value || "alternatives" in value || "obligations" in value || "deniedBy" in value);
|
|
26
|
+
}
|
|
27
|
+
/** Asserts `deniedBy` against the trace of a fresh `explain` call for the same arguments. */
|
|
28
|
+
function assertDeniedBy(instance, permission, data, cell) {
|
|
29
|
+
if (typeof cell !== "object" || cell.deniedBy === void 0) return;
|
|
30
|
+
const explained = instance.explain(permission, data);
|
|
31
|
+
expect(explained.outcome).toBe("denied");
|
|
32
|
+
expect(explained.trace.denies[0]?.name).toBe(cell.deniedBy);
|
|
33
|
+
}
|
|
34
|
+
function expectedOutcome(cell) {
|
|
35
|
+
return typeof cell === "string" ? cell : cell.outcome ?? "denied";
|
|
36
|
+
}
|
|
37
|
+
function assertCell(decision, cell) {
|
|
38
|
+
expect(decision.outcome).toBe(expectedOutcome(cell));
|
|
39
|
+
if (typeof cell === "object" && cell.denials !== void 0 && decision.outcome === "denied") for (const denial of cell.denials) expect(decision.denials.some((item) => item.reason === denial.reason && (denial.role === void 0 || item.role === denial.role))).toBe(true);
|
|
40
|
+
if (typeof cell === "object" && cell.alternatives !== void 0 && decision.outcome === "denied") expect(decision.alternatives.map((leaf) => leaf.key)).toEqual(cell.alternatives);
|
|
41
|
+
if (typeof cell === "object" && cell.obligations !== void 0 && decision.outcome === "granted") expect((decision.obligations ?? []).map((item) => item.kind)).toEqual(cell.obligations);
|
|
42
|
+
}
|
|
43
|
+
function assertSnapshotCell(instance, permission, data, cell) {
|
|
44
|
+
const outcome = expectedOutcome(cell);
|
|
45
|
+
if (outcome === "approval-required") return;
|
|
46
|
+
const client = fromSnapshot(JSON.parse(JSON.stringify(instance.snapshot())));
|
|
47
|
+
expect(client.can(permission, data), "snapshot client disagrees with the server").toBe(outcome === "granted");
|
|
48
|
+
}
|
|
49
|
+
function describePolicy(policy, config) {
|
|
50
|
+
describe("policy matrix", () => {
|
|
51
|
+
const permissions = listPermissions(policy.permissions);
|
|
52
|
+
const permdocks = /* @__PURE__ */ new Map();
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
for (const [name, user] of Object.entries(config.subjects)) permdocks.set(name, await createPermDock(policy, user, config.options));
|
|
55
|
+
});
|
|
56
|
+
it("covers every permission", () => {
|
|
57
|
+
if (config.exhaustive === false) return;
|
|
58
|
+
for (const permission of permissions) expect(config.matrix[permission.key], `missing matrix for ${permission.key}`).toBeDefined();
|
|
59
|
+
});
|
|
60
|
+
for (const permission of permissions) {
|
|
61
|
+
const spec = config.matrix[permission.key];
|
|
62
|
+
if (spec === void 0) continue;
|
|
63
|
+
describe(permission.key, () => {
|
|
64
|
+
if (!Object.values(spec).some((value) => !isOutcomeCell(value))) {
|
|
65
|
+
for (const [subjectName, cell] of Object.entries(spec)) it(`${subjectName}`, async () => {
|
|
66
|
+
const instance = permdocks.get(subjectName) ?? await createPermDock(policy, config.subjects[subjectName], config.options);
|
|
67
|
+
const data = permission.kind === "instance" ? Object.values(config.fixtures ?? {})[0] : void 0;
|
|
68
|
+
assertCell(instance.decide(permission, data), cell);
|
|
69
|
+
assertDeniedBy(instance, permission, data, cell);
|
|
70
|
+
if (config.snapshot === true) assertSnapshotCell(instance, permission, data, cell);
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
for (const [fixtureName, row] of Object.entries(spec)) {
|
|
75
|
+
if (isOutcomeCell(row)) continue;
|
|
76
|
+
for (const [subjectName, cell] of Object.entries(row)) it(`${fixtureName} / ${subjectName}`, async () => {
|
|
77
|
+
const instance = permdocks.get(subjectName) ?? await createPermDock(policy, config.subjects[subjectName], config.options);
|
|
78
|
+
const fixture = config.fixtures?.[fixtureName];
|
|
79
|
+
assertCell(instance.decide(permission, fixture), cell);
|
|
80
|
+
assertDeniedBy(instance, permission, fixture, cell);
|
|
81
|
+
if (config.snapshot === true) assertSnapshotCell(instance, permission, fixture, cell);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/testing/rls-parity.ts
|
|
90
|
+
function toSubject(input) {
|
|
91
|
+
return {
|
|
92
|
+
principal: {
|
|
93
|
+
id: input.id,
|
|
94
|
+
roles: input.roles ?? [],
|
|
95
|
+
...input.tenant === void 0 ? {} : { tenant: input.tenant },
|
|
96
|
+
memberships: input.memberships ?? [],
|
|
97
|
+
...input.claims === void 0 ? {} : { claims: input.claims }
|
|
98
|
+
},
|
|
99
|
+
context: {}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function rowId(row) {
|
|
103
|
+
return row["id"];
|
|
104
|
+
}
|
|
105
|
+
function setting(name, value) {
|
|
106
|
+
return {
|
|
107
|
+
sql: "select set_config($1, $2, true)",
|
|
108
|
+
values: [name, value]
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The session state the generated helpers read: JWT claims for `supabase`,
|
|
113
|
+
* GUCs for `guc` (roles as a comma list, memberships as JSON).
|
|
114
|
+
*/
|
|
115
|
+
function subjectSettings(dialect, subject, gucPrefix, tenantClaim, roleClaim, customRoles, scopes) {
|
|
116
|
+
const roles = subject.roles ?? [];
|
|
117
|
+
const memberships = membershipsClaim(subject.memberships ?? [], customRoles, scopes);
|
|
118
|
+
if (dialect === "supabase" || dialect === "neon") {
|
|
119
|
+
const claims = {
|
|
120
|
+
...subject.claims,
|
|
121
|
+
sub: subject.id,
|
|
122
|
+
role: "authenticated",
|
|
123
|
+
[roleClaim]: roles.length === 1 ? roles[0] : roles,
|
|
124
|
+
[tenantClaim]: subject.tenant,
|
|
125
|
+
memberships
|
|
126
|
+
};
|
|
127
|
+
return [setting("request.jwt.claims", JSON.stringify(claims))];
|
|
128
|
+
}
|
|
129
|
+
const settings = [
|
|
130
|
+
...Object.entries(subject.claims ?? {}).map(([name, value]) => {
|
|
131
|
+
if (!SQL_IDENT.test(name)) throw new Error(`PermDock: unsafe claim name '${name}'`);
|
|
132
|
+
return setting(`${gucPrefix}.${name}`, typeof value === "string" ? value : JSON.stringify(value));
|
|
133
|
+
}),
|
|
134
|
+
setting(`${gucPrefix}.user_id`, subject.id),
|
|
135
|
+
setting(`${gucPrefix}.${roleClaim}`, roles.join(",")),
|
|
136
|
+
setting(`${gucPrefix}.memberships`, JSON.stringify(memberships))
|
|
137
|
+
];
|
|
138
|
+
return subject.tenant === void 0 ? settings : [...settings, setting(`${gucPrefix}.${tenantClaim}`, subject.tenant)];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The statement a case runs. With field views it reads back only the key, so
|
|
142
|
+
* a base table whose restricted columns are revoked does not reject it.
|
|
143
|
+
*/
|
|
144
|
+
function statementSql(action, table, keyOnly) {
|
|
145
|
+
const quoted = quoteSqlTable(table);
|
|
146
|
+
const id = quoteSqlIdent("id");
|
|
147
|
+
const back = keyOnly ? id : "*";
|
|
148
|
+
switch (action) {
|
|
149
|
+
case "read":
|
|
150
|
+
case "list":
|
|
151
|
+
case "get": return `select ${back} from ${quoted} where ${id} = $1`;
|
|
152
|
+
case "update": return `update ${quoted} set ${id} = ${id} where ${id} = $1 returning ${back}`;
|
|
153
|
+
case "create": return `insert into ${quoted} (${id}) values ($1) returning ${back}`;
|
|
154
|
+
case "delete": return `delete from ${quoted} where ${id} = $1 returning ${back}`;
|
|
155
|
+
default: return `select ${back} from ${quoted} where ${id} = $1`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function valued(row) {
|
|
159
|
+
if (row === null || typeof row !== "object") return [];
|
|
160
|
+
const record = row;
|
|
161
|
+
return Object.keys(record).filter((name) => record[name] !== null && record[name] !== void 0).toSorted();
|
|
162
|
+
}
|
|
163
|
+
function isRead(action) {
|
|
164
|
+
return action === "read" || action === "get" || action === "list";
|
|
165
|
+
}
|
|
166
|
+
async function viewColumns(query, table, key) {
|
|
167
|
+
const id = quoteSqlIdent("id");
|
|
168
|
+
await query("savepoint permdock_fields");
|
|
169
|
+
const view = await query(`select * from ${quoteSqlTable(`${table}_visible`)} where ${id} = $1`, [key]);
|
|
170
|
+
if (view.code === void 0) return valued(view.rows[0]);
|
|
171
|
+
await query("rollback to savepoint permdock_fields");
|
|
172
|
+
if (view.code !== "42P01") return view.code;
|
|
173
|
+
const base = await query(`select * from ${quoteSqlTable(table)} where ${id} = $1`, [key]);
|
|
174
|
+
return base.code ?? valued(base.rows[0]);
|
|
175
|
+
}
|
|
176
|
+
function dbOutcome(result) {
|
|
177
|
+
if (result.code === "42501") return "rejected";
|
|
178
|
+
return (result.rowCount ?? result.rows.length) > 0 ? "allowed" : "filtered";
|
|
179
|
+
}
|
|
180
|
+
async function rlsParity(policy, options) {
|
|
181
|
+
const dialect = options.dialect ?? "guc";
|
|
182
|
+
const gucPrefix = options.gucPrefix ?? "app";
|
|
183
|
+
const tenantClaim = options.tenantClaim ?? "tenant_id";
|
|
184
|
+
const roleClaim = options.roleClaim ?? "user_role";
|
|
185
|
+
const role = options.role ?? "authenticated";
|
|
186
|
+
const customRoles = options.customRoles ?? [];
|
|
187
|
+
const scopes = scopeList(policy.scopes);
|
|
188
|
+
async function runCase(fixture) {
|
|
189
|
+
const permdock = await createPermDock(policy, toSubject(fixture.subject), { customRoles: memoryRoleSource(customRoles) });
|
|
190
|
+
const granted = fixture.permission.kind === "collection" ? permdock.can(fixture.permission, fixture.row) : permdock.can(fixture.permission, fixture.row);
|
|
191
|
+
let fromClient;
|
|
192
|
+
if (options.snapshot === true) {
|
|
193
|
+
const snapshot = permdock.snapshot();
|
|
194
|
+
if (typeof snapshot !== "object" || snapshot instanceof Promise) throw new TypeError("PermDock: rlsParity needs an unsigned snapshot");
|
|
195
|
+
fromClient = fromSnapshot(parseSnapshot(JSON.stringify(snapshot))).can(fixture.permission, fixture.row);
|
|
196
|
+
}
|
|
197
|
+
await options.query("begin");
|
|
198
|
+
try {
|
|
199
|
+
await options.query(`set local role ${quoteSqlIdent(role === "anon" && dialect === "neon" ? "anonymous" : role)}`);
|
|
200
|
+
for (const item of subjectSettings(dialect, fixture.subject, gucPrefix, tenantClaim, roleClaim, customRoles, scopes)) await options.query(item.sql, item.values);
|
|
201
|
+
const database = dbOutcome(await options.query(statementSql(fixture.permission.action, fixture.table, options.fieldViews === true), [rowId(fixture.row)]));
|
|
202
|
+
const agrees = granted ? database === "allowed" : database === "filtered" || database === "rejected";
|
|
203
|
+
let fields;
|
|
204
|
+
if (options.fieldViews === true && fixture.permission.kind === "instance" && isRead(fixture.permission.action)) {
|
|
205
|
+
const key = policy.resources.get(fixture.permission.resource)?.id ?? "id";
|
|
206
|
+
const kept = new Set(granted ? valued(permdock.pick(fixture.permission, fixture.row)) : []);
|
|
207
|
+
if (granted && fixture.row[key] !== null && fixture.row[key] !== void 0) kept.add(key);
|
|
208
|
+
fields = {
|
|
209
|
+
app: [...kept].toSorted(),
|
|
210
|
+
database: await viewColumns(options.query, fixture.table, rowId(fixture.row))
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const fieldsOk = fields === void 0 || typeof fields.database !== "string" && fields.database.join("\0") === fields.app.join("\0");
|
|
214
|
+
const ok = agrees && fieldsOk && (fromClient === void 0 || fromClient === granted);
|
|
215
|
+
return {
|
|
216
|
+
name: fixture.name,
|
|
217
|
+
granted,
|
|
218
|
+
database,
|
|
219
|
+
...fromClient === void 0 ? {} : { snapshot: fromClient },
|
|
220
|
+
...fields === void 0 ? {} : { fields },
|
|
221
|
+
ok
|
|
222
|
+
};
|
|
223
|
+
} finally {
|
|
224
|
+
await options.query("rollback");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function runAll(remaining, acc) {
|
|
228
|
+
const [head, ...tail] = remaining;
|
|
229
|
+
if (head === void 0) return acc;
|
|
230
|
+
return runAll(tail, [...acc, await runCase(head)]);
|
|
231
|
+
}
|
|
232
|
+
const results = await runAll(options.fixtures, []);
|
|
233
|
+
return {
|
|
234
|
+
ok: results.every((item) => item.ok),
|
|
235
|
+
results
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region src/testing/orm-parity.ts
|
|
240
|
+
function sortedIds(values) {
|
|
241
|
+
return values.map(String).toSorted();
|
|
242
|
+
}
|
|
243
|
+
function subsetOf(small, large) {
|
|
244
|
+
return small.every((value) => large.includes(value));
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* ORM parity: for each scenario, the rows `permdock.filter()` keeps in memory
|
|
248
|
+
* must be exactly the rows the database returns for `toWhere(permdock.where())`.
|
|
249
|
+
* With a `relations` option, the graph is loaded for the rows first, so the
|
|
250
|
+
* in-memory side walks the same graph the database query does.
|
|
251
|
+
* A partial `where()` (a closure grant it cannot compile) may be refused or
|
|
252
|
+
* return fewer rows, never more.
|
|
253
|
+
*/
|
|
254
|
+
async function ormParity(policy, scenarios, options) {
|
|
255
|
+
const idField = options.id ?? "id";
|
|
256
|
+
async function runCase(scenario) {
|
|
257
|
+
const permdock = await createPermDock(policy, scenario.user, scenario.options);
|
|
258
|
+
if (scenario.options?.relations !== void 0) await permdock.loadRelations(scenario.permission, scenario.rows);
|
|
259
|
+
const expected = sortedIds(permdock.filter(scenario.permission, scenario.rows).map((row) => row[idField]));
|
|
260
|
+
const where = permdock.where(scenario.permission);
|
|
261
|
+
try {
|
|
262
|
+
const actual = sortedIds(await options.run({
|
|
263
|
+
scenario,
|
|
264
|
+
where
|
|
265
|
+
}));
|
|
266
|
+
const ok = where.partial ? subsetOf(actual, expected) : actual.length === expected.length && subsetOf(actual, expected);
|
|
267
|
+
return {
|
|
268
|
+
name: scenario.name,
|
|
269
|
+
expected,
|
|
270
|
+
actual,
|
|
271
|
+
partial: where.partial,
|
|
272
|
+
ok
|
|
273
|
+
};
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return {
|
|
276
|
+
name: scenario.name,
|
|
277
|
+
expected,
|
|
278
|
+
actual: [],
|
|
279
|
+
partial: where.partial,
|
|
280
|
+
error: error instanceof Error ? error.message : String(error),
|
|
281
|
+
ok: where.partial
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const results = [];
|
|
286
|
+
for (const scenario of scenarios) results.push(await runCase(scenario));
|
|
287
|
+
return {
|
|
288
|
+
ok: results.every((item) => item.ok),
|
|
289
|
+
results
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/testing/client-parity.ts
|
|
294
|
+
async function outcomes(policy, entry, options) {
|
|
295
|
+
const server = await createPermDock(policy, entry.user, {
|
|
296
|
+
...entry.tenant === void 0 ? {} : { tenant: entry.tenant },
|
|
297
|
+
...options.customRoles === void 0 ? {} : { customRoles: options.customRoles }
|
|
298
|
+
});
|
|
299
|
+
const client = fromSnapshot(parseSnapshot(JSON.stringify(server.snapshot())));
|
|
300
|
+
return {
|
|
301
|
+
server: server.can(entry.permission, entry.row),
|
|
302
|
+
client: client.can(entry.permission, entry.row)
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Asserts that `fromSnapshot(permdock.snapshot())` never grants what the
|
|
307
|
+
* server denies, and matches it exactly unless a case is marked `stricter`.
|
|
308
|
+
*/
|
|
309
|
+
function testClientParity(policy, cases, options = {}) {
|
|
310
|
+
for (const entry of cases) it(`client parity: ${entry.name}`, async () => {
|
|
311
|
+
const result = await outcomes(policy, entry, options);
|
|
312
|
+
if (!result.server) {
|
|
313
|
+
expect(result.client, "client granted what the server denied").toBe(false);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (entry.stricter !== true) expect(result.client, "client denied what the server granted").toBe(true);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/testing/client-store.ts
|
|
321
|
+
const p = saasPermissions;
|
|
322
|
+
function snapshotOf(user, tenant, tenants) {
|
|
323
|
+
return snapshotFor(saasPolicy, saasPrincipal(user, tenant), {
|
|
324
|
+
tenant,
|
|
325
|
+
...tenants === void 0 ? {} : { tenants }
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/** gina leads team-a in both orgs, so `doc.update` needs the endpoint in each. */
|
|
329
|
+
function leadSnapshot() {
|
|
330
|
+
return snapshotFor(saasPolicy, saasPrincipal("gina", "acme"), {
|
|
331
|
+
tenant: "acme",
|
|
332
|
+
tenants: "all",
|
|
333
|
+
memberships: [
|
|
334
|
+
...saasMemberships("gina"),
|
|
335
|
+
{
|
|
336
|
+
tenant: "globex",
|
|
337
|
+
roles: ["viewer"]
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
tenant: "globex",
|
|
341
|
+
team: "team-a",
|
|
342
|
+
roles: ["lead"]
|
|
343
|
+
}
|
|
344
|
+
]
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function json(value, status = 200) {
|
|
348
|
+
return new Response(JSON.stringify(value), {
|
|
349
|
+
status,
|
|
350
|
+
headers: { "content-type": "application/json" }
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
function ignore() {}
|
|
354
|
+
function deferred() {
|
|
355
|
+
let resolve = ignore;
|
|
356
|
+
return {
|
|
357
|
+
promise: new Promise((release) => {
|
|
358
|
+
resolve = release;
|
|
359
|
+
}),
|
|
360
|
+
resolve
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function recorder(respond) {
|
|
364
|
+
const calls = [];
|
|
365
|
+
const impl = async (input, init) => {
|
|
366
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
367
|
+
const body = typeof init?.body === "string" ? JSON.parse(init.body) : void 0;
|
|
368
|
+
const call = {
|
|
369
|
+
url,
|
|
370
|
+
method: init?.method ?? "GET",
|
|
371
|
+
body
|
|
372
|
+
};
|
|
373
|
+
calls.push(call);
|
|
374
|
+
return respond(call);
|
|
375
|
+
};
|
|
376
|
+
return {
|
|
377
|
+
fetch: impl,
|
|
378
|
+
calls
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/** Maps opaque compact tokens to snapshot claims; no key material involved. */
|
|
382
|
+
function stubVerifier(tokens) {
|
|
383
|
+
return { verify(token) {
|
|
384
|
+
const snapshot = tokens.get(token);
|
|
385
|
+
return Promise.resolve(snapshot === void 0 ? {
|
|
386
|
+
ok: false,
|
|
387
|
+
reason: "invalid-token",
|
|
388
|
+
cause: "invalid-signature"
|
|
389
|
+
} : {
|
|
390
|
+
ok: true,
|
|
391
|
+
claims: { snapshot },
|
|
392
|
+
header: {
|
|
393
|
+
alg: "ES256",
|
|
394
|
+
typ: "permdock-snapshot+jwt"
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
} };
|
|
398
|
+
}
|
|
399
|
+
async function settle() {
|
|
400
|
+
for (let tick = 0; tick < 10; tick += 1) await new Promise((resolve) => {
|
|
401
|
+
setTimeout(resolve, 0);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
function denied() {
|
|
405
|
+
return {
|
|
406
|
+
outcome: "denied",
|
|
407
|
+
denials: [{
|
|
408
|
+
role: null,
|
|
409
|
+
reason: "deny"
|
|
410
|
+
}],
|
|
411
|
+
alternatives: []
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function unreachable() {
|
|
415
|
+
throw new Error("no request expected");
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Race and cache scenarios for a client snapshot store: tenant switches,
|
|
419
|
+
* `replace` and `refresh`, logout and a second user during a refresh, stale
|
|
420
|
+
* and signed snapshots, and endpoint answers cached per tenant. Runs against
|
|
421
|
+
* the saas domain; `createStore` wraps the store an adapter ships.
|
|
422
|
+
*/
|
|
423
|
+
function testClientStore(name, createStore) {
|
|
424
|
+
const acmeProject = saasProject("p1");
|
|
425
|
+
const globexProject = saasProject("g1");
|
|
426
|
+
const acmeDoc = saasDoc("d1");
|
|
427
|
+
describe(`client store: ${name}`, () => {
|
|
428
|
+
it("switches to a tenant the snapshot carries without a request", async () => {
|
|
429
|
+
const net = recorder(unreachable);
|
|
430
|
+
const store = createStore({
|
|
431
|
+
snapshot: snapshotOf("erin", "acme", "all"),
|
|
432
|
+
snapshotUrl: "/api/snapshot",
|
|
433
|
+
tenant: "acme",
|
|
434
|
+
fetch: net.fetch
|
|
435
|
+
});
|
|
436
|
+
expect(store.get().can(p.project.update, acmeProject)).toBe(true);
|
|
437
|
+
await store.get().refresh({ tenant: "globex" });
|
|
438
|
+
expect(store.get().can(p.project.update, globexProject)).toBe(true);
|
|
439
|
+
expect(store.get().can(p.project.update, acmeProject)).toBe(false);
|
|
440
|
+
expect(store.get().status()).toBe("ready");
|
|
441
|
+
expect(net.calls).toHaveLength(0);
|
|
442
|
+
});
|
|
443
|
+
it("fetches another tenant with one query string and commits it only on success", async () => {
|
|
444
|
+
let fail = true;
|
|
445
|
+
const net = recorder(() => fail ? json({ error: "down" }, 503) : json(snapshotOf("erin", "globex")));
|
|
446
|
+
const store = createStore({
|
|
447
|
+
snapshot: snapshotOf("erin", "acme"),
|
|
448
|
+
snapshotUrl: "/api/snapshot?v=1",
|
|
449
|
+
tenant: "acme",
|
|
450
|
+
fetch: net.fetch
|
|
451
|
+
});
|
|
452
|
+
await store.get().refresh({ tenant: "globex" });
|
|
453
|
+
expect(net.calls.map((call) => call.url)).toEqual(["/api/snapshot?v=1&tenant=globex"]);
|
|
454
|
+
expect(store.get().subject.principal?.tenant).toBe("acme");
|
|
455
|
+
expect(store.get().can(p.project.update, acmeProject)).toBe(true);
|
|
456
|
+
expect(store.get().status()).toBe("stale");
|
|
457
|
+
fail = false;
|
|
458
|
+
await store.get().refresh({ tenant: "globex" });
|
|
459
|
+
expect(store.get().subject.principal?.tenant).toBe("globex");
|
|
460
|
+
expect(store.get().can(p.project.update, globexProject)).toBe(true);
|
|
461
|
+
expect(store.get().status()).toBe("ready");
|
|
462
|
+
});
|
|
463
|
+
it("reports pending while a refresh is in flight", async () => {
|
|
464
|
+
const gate = deferred();
|
|
465
|
+
const net = recorder(() => gate.promise);
|
|
466
|
+
const store = createStore({
|
|
467
|
+
snapshot: snapshotOf("erin", "acme"),
|
|
468
|
+
snapshotUrl: "/api/snapshot",
|
|
469
|
+
tenant: "acme",
|
|
470
|
+
fetch: net.fetch
|
|
471
|
+
});
|
|
472
|
+
const pending = store.get().refresh();
|
|
473
|
+
expect(store.get().status()).toBe("pending");
|
|
474
|
+
gate.resolve(json(snapshotOf("erin", "acme")));
|
|
475
|
+
await pending;
|
|
476
|
+
expect(store.get().status()).toBe("ready");
|
|
477
|
+
});
|
|
478
|
+
it("drops a refresh that resolves after logout", async () => {
|
|
479
|
+
const gate = deferred();
|
|
480
|
+
const net = recorder(() => gate.promise);
|
|
481
|
+
const store = createStore({
|
|
482
|
+
snapshot: snapshotOf("erin", "acme"),
|
|
483
|
+
snapshotUrl: "/api/snapshot",
|
|
484
|
+
tenant: "acme",
|
|
485
|
+
fetch: net.fetch
|
|
486
|
+
});
|
|
487
|
+
const pending = store.get().refresh();
|
|
488
|
+
store.get().clear();
|
|
489
|
+
gate.resolve(json(snapshotOf("erin", "acme")));
|
|
490
|
+
await pending;
|
|
491
|
+
await settle();
|
|
492
|
+
expect(store.get().subject.principal).toBeNull();
|
|
493
|
+
expect(store.get().can(p.project.read, acmeProject)).toBe(false);
|
|
494
|
+
expect(store.get().status()).toBe("server-only");
|
|
495
|
+
});
|
|
496
|
+
it("drops a refresh for the previous user after a replace", async () => {
|
|
497
|
+
const gate = deferred();
|
|
498
|
+
const net = recorder(() => gate.promise);
|
|
499
|
+
const store = createStore({
|
|
500
|
+
snapshot: snapshotOf("erin", "acme"),
|
|
501
|
+
snapshotUrl: "/api/snapshot",
|
|
502
|
+
tenant: "acme",
|
|
503
|
+
fetch: net.fetch
|
|
504
|
+
});
|
|
505
|
+
const pending = store.get().refresh({ tenant: "globex" });
|
|
506
|
+
store.replace(snapshotOf("bob", "acme"));
|
|
507
|
+
gate.resolve(json(snapshotOf("erin", "globex")));
|
|
508
|
+
await pending;
|
|
509
|
+
await settle();
|
|
510
|
+
expect(store.get().subject.principal?.id).toBe("bob");
|
|
511
|
+
expect(store.get().subject.principal?.tenant).toBe("acme");
|
|
512
|
+
expect(store.get().can(p.project.update, globexProject)).toBe(false);
|
|
513
|
+
});
|
|
514
|
+
it("marks an expired snapshot stale until a fresh one replaces it", () => {
|
|
515
|
+
const net = recorder(unreachable);
|
|
516
|
+
const store = createStore({
|
|
517
|
+
snapshot: {
|
|
518
|
+
...snapshotOf("erin", "acme"),
|
|
519
|
+
expiresAt: 1
|
|
520
|
+
},
|
|
521
|
+
tenant: "acme",
|
|
522
|
+
fetch: net.fetch
|
|
523
|
+
});
|
|
524
|
+
expect(store.get().status()).toBe("stale");
|
|
525
|
+
expect(store.get().status(p.project.read, acmeProject)).toBe("stale");
|
|
526
|
+
store.replace(snapshotOf("erin", "acme"));
|
|
527
|
+
expect(store.get().status()).toBe("ready");
|
|
528
|
+
});
|
|
529
|
+
it("boots a signed snapshot through the verifier", async () => {
|
|
530
|
+
const token = "header.seed.signature";
|
|
531
|
+
const store = createStore({
|
|
532
|
+
snapshot: token,
|
|
533
|
+
tenant: "acme",
|
|
534
|
+
fetch: recorder(unreachable).fetch,
|
|
535
|
+
verifier: stubVerifier(/* @__PURE__ */ new Map([[token, snapshotOf("erin", "acme")]]))
|
|
536
|
+
});
|
|
537
|
+
await settle();
|
|
538
|
+
expect(store.get().subject.principal?.id).toBe("erin");
|
|
539
|
+
expect(store.get().can(p.project.update, acmeProject)).toBe(true);
|
|
540
|
+
expect(store.get().status()).toBe("ready");
|
|
541
|
+
});
|
|
542
|
+
it("rejects a signed snapshot the verifier refuses", async () => {
|
|
543
|
+
const store = createStore({
|
|
544
|
+
snapshot: "header.forged.signature",
|
|
545
|
+
tenant: "acme",
|
|
546
|
+
fetch: recorder(unreachable).fetch,
|
|
547
|
+
verifier: stubVerifier(/* @__PURE__ */ new Map())
|
|
548
|
+
});
|
|
549
|
+
await settle();
|
|
550
|
+
expect(store.get().subject.principal).toBeNull();
|
|
551
|
+
expect(store.get().can(p.project.read, acmeProject)).toBe(false);
|
|
552
|
+
});
|
|
553
|
+
it("verifies a signed refresh response", async () => {
|
|
554
|
+
const seed = "header.seed.signature";
|
|
555
|
+
const next = "header.next.signature";
|
|
556
|
+
const store = createStore({
|
|
557
|
+
snapshot: seed,
|
|
558
|
+
snapshotUrl: "/api/snapshot",
|
|
559
|
+
tenant: "acme",
|
|
560
|
+
fetch: recorder(() => json(next)).fetch,
|
|
561
|
+
verifier: stubVerifier(/* @__PURE__ */ new Map([[seed, snapshotOf("erin", "acme")], [next, snapshotOf("erin", "globex")]]))
|
|
562
|
+
});
|
|
563
|
+
await settle();
|
|
564
|
+
await store.get().refresh({ tenant: "globex" });
|
|
565
|
+
await settle();
|
|
566
|
+
expect(store.get().subject.principal?.tenant).toBe("globex");
|
|
567
|
+
expect(store.get().can(p.project.update, globexProject)).toBe(true);
|
|
568
|
+
});
|
|
569
|
+
it("caches endpoint answers per tenant and settles the store status", async () => {
|
|
570
|
+
const net = recorder((call) => {
|
|
571
|
+
const body = call.body;
|
|
572
|
+
return json({ evaluations: body.evaluations.map(() => ({
|
|
573
|
+
decision: false,
|
|
574
|
+
context: { permdock: denied() }
|
|
575
|
+
})) });
|
|
576
|
+
});
|
|
577
|
+
const store = createStore({
|
|
578
|
+
snapshot: leadSnapshot(),
|
|
579
|
+
endpoint: "/api/permdock",
|
|
580
|
+
tenant: "acme",
|
|
581
|
+
fetch: net.fetch
|
|
582
|
+
});
|
|
583
|
+
expect(store.get().status(p.doc.update, acmeDoc)).toBe("pending");
|
|
584
|
+
await settle();
|
|
585
|
+
expect(store.get().status(p.doc.update, acmeDoc)).toBe("ready");
|
|
586
|
+
expect(store.get().status()).toBe("ready");
|
|
587
|
+
expect(net.calls).toHaveLength(1);
|
|
588
|
+
store.get().status(p.doc.update, acmeDoc);
|
|
589
|
+
await settle();
|
|
590
|
+
expect(net.calls).toHaveLength(1);
|
|
591
|
+
await store.get().refresh({ tenant: "globex" });
|
|
592
|
+
const sameIdInGlobex = {
|
|
593
|
+
...acmeDoc,
|
|
594
|
+
orgId: "globex"
|
|
595
|
+
};
|
|
596
|
+
expect(store.get().status(p.doc.update, sameIdInGlobex)).toBe("pending");
|
|
597
|
+
await settle();
|
|
598
|
+
expect(net.calls).toHaveLength(2);
|
|
599
|
+
});
|
|
600
|
+
it("forgets endpoint answers when a new snapshot arrives", async () => {
|
|
601
|
+
const net = recorder((call) => {
|
|
602
|
+
const body = call.body;
|
|
603
|
+
return json({ evaluations: body.evaluations.map(() => ({
|
|
604
|
+
decision: false,
|
|
605
|
+
context: { permdock: denied() }
|
|
606
|
+
})) });
|
|
607
|
+
});
|
|
608
|
+
const store = createStore({
|
|
609
|
+
snapshot: snapshotOf("gina", "acme"),
|
|
610
|
+
endpoint: "/api/permdock",
|
|
611
|
+
tenant: "acme",
|
|
612
|
+
fetch: net.fetch
|
|
613
|
+
});
|
|
614
|
+
store.get().status(p.doc.update, acmeDoc);
|
|
615
|
+
await settle();
|
|
616
|
+
expect(net.calls).toHaveLength(1);
|
|
617
|
+
store.replace(snapshotOf("gina", "acme"));
|
|
618
|
+
expect(store.get().status(p.doc.update, acmeDoc)).toBe("pending");
|
|
619
|
+
await settle();
|
|
620
|
+
expect(net.calls).toHaveLength(2);
|
|
621
|
+
});
|
|
622
|
+
it("answers a row edited in place once the task that read it ends", async () => {
|
|
623
|
+
const net = recorder(unreachable);
|
|
624
|
+
const store = createStore({
|
|
625
|
+
snapshot: snapshotOf("bob", "acme"),
|
|
626
|
+
tenant: "acme",
|
|
627
|
+
fetch: net.fetch
|
|
628
|
+
});
|
|
629
|
+
const row = {
|
|
630
|
+
...acmeProject,
|
|
631
|
+
ownerId: "bob"
|
|
632
|
+
};
|
|
633
|
+
expect(store.get().status(p.project.update, row)).toBe("ready");
|
|
634
|
+
expect(store.get().can(p.project.update, row)).toBe(true);
|
|
635
|
+
row.ownerId = "alice";
|
|
636
|
+
await settle();
|
|
637
|
+
expect(store.get().can(p.project.update, row)).toBe(false);
|
|
638
|
+
});
|
|
639
|
+
it("answers a check on a row that throws as denied, without throwing", () => {
|
|
640
|
+
const net = recorder(unreachable);
|
|
641
|
+
const store = createStore({
|
|
642
|
+
snapshot: snapshotOf("bob", "acme"),
|
|
643
|
+
tenant: "acme",
|
|
644
|
+
fetch: net.fetch
|
|
645
|
+
});
|
|
646
|
+
const hostile = new Proxy({}, { get() {
|
|
647
|
+
throw new Error("hostile row");
|
|
648
|
+
} });
|
|
649
|
+
expect(store.get().status(p.project.update, hostile)).not.toBe("pending");
|
|
650
|
+
expect(store.get().can(p.project.update, hostile)).toBe(false);
|
|
651
|
+
});
|
|
652
|
+
it("notifies every subscriber of a logout when one of them throws", () => {
|
|
653
|
+
const reported = vi.fn();
|
|
654
|
+
vi.stubGlobal("reportError", reported);
|
|
655
|
+
try {
|
|
656
|
+
const net = recorder(unreachable);
|
|
657
|
+
const store = createStore({
|
|
658
|
+
snapshot: snapshotOf("bob", "acme"),
|
|
659
|
+
tenant: "acme",
|
|
660
|
+
fetch: net.fetch
|
|
661
|
+
});
|
|
662
|
+
let after = 0;
|
|
663
|
+
const offThrowing = store.get().subscribe(() => {
|
|
664
|
+
throw new Error("subscriber failed");
|
|
665
|
+
});
|
|
666
|
+
const offCounting = store.get().subscribe(() => {
|
|
667
|
+
after += 1;
|
|
668
|
+
});
|
|
669
|
+
store.get().clear();
|
|
670
|
+
expect(after).toBe(1);
|
|
671
|
+
expect(reported).toHaveBeenCalled();
|
|
672
|
+
expect(store.get().can(p.project.read, acmeProject)).toBe(false);
|
|
673
|
+
offThrowing();
|
|
674
|
+
offCounting();
|
|
675
|
+
} finally {
|
|
676
|
+
vi.unstubAllGlobals();
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
it("does not notify subscribers synchronously from a status read", () => {
|
|
680
|
+
const net = recorder(() => json({ evaluations: [] }));
|
|
681
|
+
const store = createStore({
|
|
682
|
+
snapshot: snapshotOf("gina", "acme"),
|
|
683
|
+
endpoint: "/api/permdock",
|
|
684
|
+
tenant: "acme",
|
|
685
|
+
fetch: net.fetch
|
|
686
|
+
});
|
|
687
|
+
let notified = 0;
|
|
688
|
+
store.get().subscribe(() => {
|
|
689
|
+
notified += 1;
|
|
690
|
+
});
|
|
691
|
+
store.get().status(p.doc.update, acmeDoc);
|
|
692
|
+
expect(notified).toBe(0);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/testing/snapshot-fixture.ts
|
|
698
|
+
async function snapshotFixture(policy, subject, options = {}) {
|
|
699
|
+
const instance = await createPermDock(policy, subject, options.tenant === void 0 ? {} : { tenant: options.tenant });
|
|
700
|
+
const target = options.simulated === true ? instance.simulate({}) : instance;
|
|
701
|
+
if (Array.isArray(target)) throw new Error("PermDock: snapshotFixture expected a PermDock instance");
|
|
702
|
+
const snapshot = options.include === void 0 && options.tenants === void 0 ? target.snapshot() : target.snapshot({
|
|
703
|
+
...options.include === void 0 ? {} : { include: options.include },
|
|
704
|
+
...options.tenants === void 0 ? {} : { tenants: options.tenants }
|
|
705
|
+
});
|
|
706
|
+
if (typeof snapshot === "string" || snapshot instanceof Promise) throw new Error("PermDock: snapshotFixture expected a JSON snapshot");
|
|
707
|
+
return snapshot;
|
|
708
|
+
}
|
|
709
|
+
//#endregion
|
|
710
|
+
//#region src/testing/http-adapter.ts
|
|
711
|
+
function project$1(id) {
|
|
712
|
+
const row = saasSeed.projects.find((candidate) => candidate.id === id);
|
|
713
|
+
if (row === void 0) throw new Error(`no seed project ${id}`);
|
|
714
|
+
return row;
|
|
715
|
+
}
|
|
716
|
+
function bearerToken(authorization) {
|
|
717
|
+
if (typeof authorization !== "string") return null;
|
|
718
|
+
return /^Bearer (\S+)$/u.exec(authorization)?.[1] ?? null;
|
|
719
|
+
}
|
|
720
|
+
function orgOf(path) {
|
|
721
|
+
const [segment] = path.replace(/^\/+/u, "").split("/");
|
|
722
|
+
return segment === void 0 || segment.length === 0 ? void 0 : saasOrg(segment)?.id;
|
|
723
|
+
}
|
|
724
|
+
async function pause(ms, signal) {
|
|
725
|
+
await new Promise((resolve) => {
|
|
726
|
+
const timer = setTimeout(done, ms);
|
|
727
|
+
function done() {
|
|
728
|
+
clearTimeout(timer);
|
|
729
|
+
signal.removeEventListener("abort", done);
|
|
730
|
+
resolve();
|
|
731
|
+
}
|
|
732
|
+
signal.addEventListener("abort", done, { once: true });
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
async function* ticks(org, signal) {
|
|
736
|
+
const rows = saasSeed.projects.filter((row) => row.orgId === org);
|
|
737
|
+
if (rows.length === 0) return;
|
|
738
|
+
while (!signal.aborted) for (const row of rows) {
|
|
739
|
+
await pause(20, signal);
|
|
740
|
+
if (signal.aborted) return;
|
|
741
|
+
yield row;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function demotionKey(user, org) {
|
|
745
|
+
return JSON.stringify([user, org]);
|
|
746
|
+
}
|
|
747
|
+
function createDomain$1() {
|
|
748
|
+
const demoted = /* @__PURE__ */ new Set();
|
|
749
|
+
const principalOf = (sub, path) => {
|
|
750
|
+
const principal = saasPrincipal(sub, orgOf(path));
|
|
751
|
+
return {
|
|
752
|
+
...principal,
|
|
753
|
+
memberships: (principal.memberships ?? []).filter((membership) => membership.tenant === void 0 || !demoted.has(demotionKey(sub, membership.tenant)))
|
|
754
|
+
};
|
|
755
|
+
};
|
|
756
|
+
return {
|
|
757
|
+
policy: saasPolicy,
|
|
758
|
+
async subject(authorization, path) {
|
|
759
|
+
const verified = await verifySaasSession(bearerToken(authorization));
|
|
760
|
+
return verified === null ? null : principalOf(verified.sub, path);
|
|
761
|
+
},
|
|
762
|
+
async session(authorization, path) {
|
|
763
|
+
const verified = await verifySaasSession(bearerToken(authorization));
|
|
764
|
+
return verified === null ? null : {
|
|
765
|
+
principal: principalOf(verified.sub, path),
|
|
766
|
+
context: {},
|
|
767
|
+
expiresAt: verified.expiresAt
|
|
768
|
+
};
|
|
769
|
+
},
|
|
770
|
+
revocations: memoryRevocationFeed(),
|
|
771
|
+
ticks,
|
|
772
|
+
demote(user, org) {
|
|
773
|
+
const key = demotionKey(user, org);
|
|
774
|
+
demoted.add(key);
|
|
775
|
+
return () => {
|
|
776
|
+
demoted.delete(key);
|
|
777
|
+
};
|
|
778
|
+
},
|
|
779
|
+
org: orgOf,
|
|
780
|
+
customRoles: memoryRoleSource(saasCustomRoles),
|
|
781
|
+
store: memoryApprovalStore(),
|
|
782
|
+
limits: memoryLimitStore(),
|
|
783
|
+
project: (id) => id === void 0 ? null : saasSeed.projects.find((row) => row.id === id) ?? null
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
function restRequest(base, call) {
|
|
787
|
+
const headers = new Headers();
|
|
788
|
+
if (call.authorization !== null) headers.set("authorization", call.authorization);
|
|
789
|
+
if (call.approval !== void 0) headers.set(APPROVAL_HEADER, call.approval);
|
|
790
|
+
const json = (method, path, body) => {
|
|
791
|
+
headers.set("content-type", "application/json");
|
|
792
|
+
return new Request(`${base}${path}`, {
|
|
793
|
+
method,
|
|
794
|
+
headers,
|
|
795
|
+
body: JSON.stringify(body)
|
|
796
|
+
});
|
|
797
|
+
};
|
|
798
|
+
const plain = (method, path) => new Request(`${base}${path}`, {
|
|
799
|
+
method,
|
|
800
|
+
headers
|
|
801
|
+
});
|
|
802
|
+
switch (call.op) {
|
|
803
|
+
case "project.get": return plain("GET", `/${call.org}/projects/${call.id}`);
|
|
804
|
+
case "project.update": return json("PATCH", `/${call.org}/projects/${call.id}`, { name: "Renamed" });
|
|
805
|
+
case "project.create": return json("POST", `/${call.org}/projects`, call.body);
|
|
806
|
+
case "project.delete": return plain("DELETE", `/${call.org}/projects/${call.id}`);
|
|
807
|
+
case "project.upload": {
|
|
808
|
+
const form = new FormData();
|
|
809
|
+
form.set("file", new Blob([call.file.content]), call.file.name);
|
|
810
|
+
return new Request(`${base}/${call.org}/projects/${call.id}/files`, {
|
|
811
|
+
method: "POST",
|
|
812
|
+
headers,
|
|
813
|
+
body: form
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
case "analytics.read": return plain("GET", `/${call.org}/analytics`);
|
|
817
|
+
case "apiKey.create": return plain("POST", `/${call.org}/api-keys`);
|
|
818
|
+
case "apiKey.revokeAll": return plain("POST", `/${call.org}/api-keys/revoke-all`);
|
|
819
|
+
case "admin.members": return plain("GET", `/${call.org}/admin/members`);
|
|
820
|
+
case "evaluations": return json("POST", `/${call.org}/permdock/access/v1/evaluations`, call.body);
|
|
821
|
+
default: return call;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
async function readResult(response) {
|
|
825
|
+
const text = await response.text();
|
|
826
|
+
if (text.length === 0) return {
|
|
827
|
+
status: response.status,
|
|
828
|
+
body: null
|
|
829
|
+
};
|
|
830
|
+
try {
|
|
831
|
+
const body = JSON.parse(text);
|
|
832
|
+
return {
|
|
833
|
+
status: response.status,
|
|
834
|
+
body
|
|
835
|
+
};
|
|
836
|
+
} catch {
|
|
837
|
+
return {
|
|
838
|
+
status: response.status,
|
|
839
|
+
body: text
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
function field(body, key) {
|
|
844
|
+
return body !== null && typeof body === "object" ? body[key] : void 0;
|
|
845
|
+
}
|
|
846
|
+
function reasonsOf(body) {
|
|
847
|
+
const denials = field(body, "denials");
|
|
848
|
+
return Array.isArray(denials) ? denials.map((denial) => field(denial, "reason")) : [];
|
|
849
|
+
}
|
|
850
|
+
async function approverSubject(user, tenant) {
|
|
851
|
+
return (await createPermDock(saasPolicy, saasPrincipal(user, tenant), { tenant })).subject;
|
|
852
|
+
}
|
|
853
|
+
const PARALLEL = [
|
|
854
|
+
{
|
|
855
|
+
user: "alice",
|
|
856
|
+
org: "acme",
|
|
857
|
+
id: "p1",
|
|
858
|
+
status: 200
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
user: "alice",
|
|
862
|
+
org: "globex",
|
|
863
|
+
id: "g1",
|
|
864
|
+
status: 200
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
user: "alice",
|
|
868
|
+
org: "acme",
|
|
869
|
+
id: "g1",
|
|
870
|
+
status: 403
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
user: "bob",
|
|
874
|
+
org: "acme",
|
|
875
|
+
id: "p1",
|
|
876
|
+
status: 200
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
user: "bob",
|
|
880
|
+
org: "globex",
|
|
881
|
+
id: "g1",
|
|
882
|
+
status: 403
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
user: "hank",
|
|
886
|
+
org: "acme",
|
|
887
|
+
id: "p3",
|
|
888
|
+
status: 200
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
user: "mallory",
|
|
892
|
+
org: "acme",
|
|
893
|
+
id: "p1",
|
|
894
|
+
status: 403
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
user: null,
|
|
898
|
+
org: "acme",
|
|
899
|
+
id: "p1",
|
|
900
|
+
status: 401
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
user: "erin",
|
|
904
|
+
org: "globex",
|
|
905
|
+
id: "g1",
|
|
906
|
+
status: 200
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
user: "frank",
|
|
910
|
+
org: "acme",
|
|
911
|
+
id: "p1",
|
|
912
|
+
status: 403
|
|
913
|
+
}
|
|
914
|
+
];
|
|
915
|
+
const STREAM_WAIT_MS = 4e3;
|
|
916
|
+
async function within(promise, what) {
|
|
917
|
+
let timer;
|
|
918
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
919
|
+
timer = setTimeout(() => {
|
|
920
|
+
reject(/* @__PURE__ */ new Error(`timed out waiting for ${what}`));
|
|
921
|
+
}, STREAM_WAIT_MS);
|
|
922
|
+
});
|
|
923
|
+
try {
|
|
924
|
+
return await Promise.race([promise, timeout]);
|
|
925
|
+
} finally {
|
|
926
|
+
clearTimeout(timer);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
function parseFrame(raw) {
|
|
930
|
+
let event = "message";
|
|
931
|
+
const data = [];
|
|
932
|
+
for (const line of raw.split(/\r?\n/u)) if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
933
|
+
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
|
|
934
|
+
return data.length === 0 ? void 0 : {
|
|
935
|
+
event,
|
|
936
|
+
data: data.join("\n")
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
function eventStream(response) {
|
|
940
|
+
const reader = response.body?.pipeThrough(new TextDecoderStream()).getReader();
|
|
941
|
+
let buffer = "";
|
|
942
|
+
const next = async () => {
|
|
943
|
+
if (reader === void 0) return;
|
|
944
|
+
for (;;) {
|
|
945
|
+
const end = /\r?\n\r?\n/u.exec(buffer);
|
|
946
|
+
if (end !== null) {
|
|
947
|
+
const frame = parseFrame(buffer.slice(0, end.index));
|
|
948
|
+
buffer = buffer.slice(end.index + end[0].length);
|
|
949
|
+
if (frame !== void 0) return frame;
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
const chunk = await within(reader.read(), "an SSE frame");
|
|
953
|
+
if (chunk.done) return;
|
|
954
|
+
buffer += chunk.value;
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
return {
|
|
958
|
+
status: response.status,
|
|
959
|
+
next,
|
|
960
|
+
cancel: async () => {
|
|
961
|
+
await reader?.cancel().catch(() => void 0);
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/** Reads frames until the `permdock` frame and the end of the stream. */
|
|
966
|
+
async function untilEnd(stream) {
|
|
967
|
+
for (;;) {
|
|
968
|
+
const frame = await stream.next();
|
|
969
|
+
if (frame === void 0) return {
|
|
970
|
+
problem: void 0,
|
|
971
|
+
ended: true
|
|
972
|
+
};
|
|
973
|
+
if (frame.event === "permdock") return {
|
|
974
|
+
problem: JSON.parse(frame.data),
|
|
975
|
+
ended: await stream.next() === void 0
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Real-life HTTP scenarios over the shared SaaS domain. `mount` builds the
|
|
981
|
+
* application once with the adapter under test; every expectation below is
|
|
982
|
+
* written by hand, never computed with PermDock.
|
|
983
|
+
*/
|
|
984
|
+
function testHttpAdapter(options) {
|
|
985
|
+
const scenario = (name, title, run, timeout) => {
|
|
986
|
+
const reason = options.skip?.[name] ?? (name.startsWith("stream-") && options.streams !== true ? "no events stream" : void 0);
|
|
987
|
+
it.skipIf(reason !== void 0)(reason === void 0 ? title : `${title} (${reason})`, run, timeout);
|
|
988
|
+
};
|
|
989
|
+
describe(`${options.name}: real-life HTTP scenarios`, () => {
|
|
990
|
+
const domain = createDomain$1();
|
|
991
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
992
|
+
let mounted;
|
|
993
|
+
beforeAll(async () => {
|
|
994
|
+
mounted = await options.mount(domain);
|
|
995
|
+
for (const user of [
|
|
996
|
+
"alice",
|
|
997
|
+
"bob",
|
|
998
|
+
"carol",
|
|
999
|
+
"dave",
|
|
1000
|
+
"erin",
|
|
1001
|
+
"frank",
|
|
1002
|
+
"hank",
|
|
1003
|
+
"mallory"
|
|
1004
|
+
]) tokens.set(user, `Bearer ${await signSaasToken(user, { memberships: false })}`);
|
|
1005
|
+
});
|
|
1006
|
+
afterAll(async () => {
|
|
1007
|
+
await mounted.close?.();
|
|
1008
|
+
});
|
|
1009
|
+
const send = async (user, op, approval) => {
|
|
1010
|
+
const call = {
|
|
1011
|
+
...op,
|
|
1012
|
+
authorization: user === null ? null : tokens.get(user) ?? null,
|
|
1013
|
+
...approval === void 0 ? {} : { approval }
|
|
1014
|
+
};
|
|
1015
|
+
if (mounted.call !== void 0) return mounted.call(call);
|
|
1016
|
+
if (mounted.fetch === void 0) throw new Error(`${options.name}: mount returned neither fetch nor call`);
|
|
1017
|
+
return readResult(await mounted.fetch(restRequest("http://app.test", call)));
|
|
1018
|
+
};
|
|
1019
|
+
const openStream = async (authorization, org, id) => {
|
|
1020
|
+
if (mounted.fetch === void 0) throw new Error(`${options.name}: streams need a fetch mount`);
|
|
1021
|
+
const headers = new Headers({ accept: "text/event-stream" });
|
|
1022
|
+
if (authorization !== null) headers.set("authorization", authorization);
|
|
1023
|
+
return eventStream(await mounted.fetch(new Request(`http://app.test/${org}/projects/${id}/events`, { headers })));
|
|
1024
|
+
};
|
|
1025
|
+
scenario("tenant", "resolves the tenant from the path on every protect", async () => {
|
|
1026
|
+
expect((await send("alice", {
|
|
1027
|
+
op: "project.get",
|
|
1028
|
+
org: "acme",
|
|
1029
|
+
id: "p2"
|
|
1030
|
+
})).status).toBe(200);
|
|
1031
|
+
expect((await send("alice", {
|
|
1032
|
+
op: "project.get",
|
|
1033
|
+
org: "globex",
|
|
1034
|
+
id: "g1"
|
|
1035
|
+
})).status).toBe(200);
|
|
1036
|
+
expect((await send("alice", {
|
|
1037
|
+
op: "project.get",
|
|
1038
|
+
org: "acme",
|
|
1039
|
+
id: "g1"
|
|
1040
|
+
})).status).toBe(403);
|
|
1041
|
+
expect((await send("mallory", {
|
|
1042
|
+
op: "project.get",
|
|
1043
|
+
org: "acme",
|
|
1044
|
+
id: "p1"
|
|
1045
|
+
})).status).toBe(403);
|
|
1046
|
+
expect((await send(null, {
|
|
1047
|
+
op: "project.get",
|
|
1048
|
+
org: "acme",
|
|
1049
|
+
id: "p1"
|
|
1050
|
+
})).status).toBe(401);
|
|
1051
|
+
});
|
|
1052
|
+
scenario("roles", "applies the role held in the requested org", async () => {
|
|
1053
|
+
expect((await send("alice", {
|
|
1054
|
+
op: "project.update",
|
|
1055
|
+
org: "acme",
|
|
1056
|
+
id: "p1"
|
|
1057
|
+
})).status).toBe(200);
|
|
1058
|
+
expect((await send("alice", {
|
|
1059
|
+
op: "project.update",
|
|
1060
|
+
org: "globex",
|
|
1061
|
+
id: "g1"
|
|
1062
|
+
})).status).toBe(403);
|
|
1063
|
+
expect((await send("bob", {
|
|
1064
|
+
op: "project.update",
|
|
1065
|
+
org: "acme",
|
|
1066
|
+
id: "p1"
|
|
1067
|
+
})).status).toBe(200);
|
|
1068
|
+
expect((await send("bob", {
|
|
1069
|
+
op: "project.update",
|
|
1070
|
+
org: "acme",
|
|
1071
|
+
id: "p2"
|
|
1072
|
+
})).status).toBe(403);
|
|
1073
|
+
});
|
|
1074
|
+
scenario("custom-roles", "grants through an org custom role", async () => {
|
|
1075
|
+
const body = {
|
|
1076
|
+
id: "c1",
|
|
1077
|
+
orgId: "acme",
|
|
1078
|
+
ownerId: "dave",
|
|
1079
|
+
name: "Contract",
|
|
1080
|
+
archived: false
|
|
1081
|
+
};
|
|
1082
|
+
expect((await send("dave", {
|
|
1083
|
+
op: "project.create",
|
|
1084
|
+
org: "acme",
|
|
1085
|
+
body
|
|
1086
|
+
})).status).toBe(201);
|
|
1087
|
+
expect((await send("hank", {
|
|
1088
|
+
op: "project.create",
|
|
1089
|
+
org: "acme",
|
|
1090
|
+
body
|
|
1091
|
+
})).status).toBe(403);
|
|
1092
|
+
});
|
|
1093
|
+
scenario("plans", "gates a feature on the plan of the requested org", async () => {
|
|
1094
|
+
expect((await send("erin", {
|
|
1095
|
+
op: "analytics.read",
|
|
1096
|
+
org: "globex"
|
|
1097
|
+
})).status).toBe(200);
|
|
1098
|
+
const upgrade = await send("erin", {
|
|
1099
|
+
op: "analytics.read",
|
|
1100
|
+
org: "acme"
|
|
1101
|
+
});
|
|
1102
|
+
expect(upgrade.status).toBe(403);
|
|
1103
|
+
expect(new Set(reasonsOf(upgrade.body))).toEqual(/* @__PURE__ */ new Set(["not-entitled"]));
|
|
1104
|
+
});
|
|
1105
|
+
scenario("expired", "ignores an expired membership", async () => {
|
|
1106
|
+
expect((await send("frank", {
|
|
1107
|
+
op: "project.get",
|
|
1108
|
+
org: "acme",
|
|
1109
|
+
id: "p1"
|
|
1110
|
+
})).status).toBe(403);
|
|
1111
|
+
});
|
|
1112
|
+
scenario("limits", "counts a quota per subject and tenant", async () => {
|
|
1113
|
+
for (let index = 0; index < 5; index += 1) expect((await send("erin", {
|
|
1114
|
+
op: "apiKey.create",
|
|
1115
|
+
org: "acme"
|
|
1116
|
+
})).status).toBe(201);
|
|
1117
|
+
const exhausted = await send("erin", {
|
|
1118
|
+
op: "apiKey.create",
|
|
1119
|
+
org: "acme"
|
|
1120
|
+
});
|
|
1121
|
+
expect(exhausted.status).toBe(429);
|
|
1122
|
+
expect(reasonsOf(exhausted.body)).toContain("limit");
|
|
1123
|
+
expect((await send("erin", {
|
|
1124
|
+
op: "apiKey.create",
|
|
1125
|
+
org: "globex"
|
|
1126
|
+
})).status).toBe(201);
|
|
1127
|
+
});
|
|
1128
|
+
scenario("validation", "validates an untrusted body before the check", async () => {
|
|
1129
|
+
const valid = {
|
|
1130
|
+
id: "n2",
|
|
1131
|
+
orgId: "acme",
|
|
1132
|
+
ownerId: "bob",
|
|
1133
|
+
name: "New",
|
|
1134
|
+
archived: false
|
|
1135
|
+
};
|
|
1136
|
+
expect((await send("bob", {
|
|
1137
|
+
op: "project.create",
|
|
1138
|
+
org: "acme",
|
|
1139
|
+
body: valid
|
|
1140
|
+
})).status).toBe(201);
|
|
1141
|
+
const invalid = await send("bob", {
|
|
1142
|
+
op: "project.create",
|
|
1143
|
+
org: "acme",
|
|
1144
|
+
body: {
|
|
1145
|
+
...valid,
|
|
1146
|
+
name: 42
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
expect(invalid.status).toBe(400);
|
|
1150
|
+
expect((await send("bob", {
|
|
1151
|
+
op: "project.create",
|
|
1152
|
+
org: "acme",
|
|
1153
|
+
body: {
|
|
1154
|
+
...valid,
|
|
1155
|
+
orgId: "globex"
|
|
1156
|
+
}
|
|
1157
|
+
})).status).toBe(403);
|
|
1158
|
+
});
|
|
1159
|
+
scenario("assert", "maps assert inside a handler to the Problem protect sends", async () => {
|
|
1160
|
+
const denied = await send("bob", {
|
|
1161
|
+
op: "project.delete",
|
|
1162
|
+
org: "acme",
|
|
1163
|
+
id: "p2"
|
|
1164
|
+
});
|
|
1165
|
+
expect(denied.status).toBe(403);
|
|
1166
|
+
expect(field(denied.body, "type")).toBe("https://permdock.com/problems/denied");
|
|
1167
|
+
const bob = await createPermDock(saasPolicy, saasPrincipal("bob", "acme"), { tenant: "acme" });
|
|
1168
|
+
const permission = saasPolicy.permissions.project.delete;
|
|
1169
|
+
const fromProtect = await readResult(problemFromDecision(bob.decide(permission, project$1("p2")), permission, bob.subject));
|
|
1170
|
+
expect(denied.status).toBe(fromProtect.status);
|
|
1171
|
+
for (const key of [
|
|
1172
|
+
"type",
|
|
1173
|
+
"title",
|
|
1174
|
+
"permission",
|
|
1175
|
+
"denials"
|
|
1176
|
+
]) expect(field(denied.body, key)).toEqual(field(fromProtect.body, key));
|
|
1177
|
+
expect((await send("bob", {
|
|
1178
|
+
op: "project.delete",
|
|
1179
|
+
org: "acme",
|
|
1180
|
+
id: "p3"
|
|
1181
|
+
})).status).toBe(204);
|
|
1182
|
+
expect((await send("bob", {
|
|
1183
|
+
op: "project.delete",
|
|
1184
|
+
org: "acme",
|
|
1185
|
+
id: "p4"
|
|
1186
|
+
})).status).toBe(403);
|
|
1187
|
+
});
|
|
1188
|
+
scenario("approval", "asks for approval, resumes once and refuses a replay", async () => {
|
|
1189
|
+
const asked = await send("alice", {
|
|
1190
|
+
op: "apiKey.revokeAll",
|
|
1191
|
+
org: "acme"
|
|
1192
|
+
});
|
|
1193
|
+
expect(asked.status).toBe(403);
|
|
1194
|
+
expect(field(asked.body, "type")).toBe("https://permdock.com/problems/approval-required");
|
|
1195
|
+
const token = field(asked.body, "token");
|
|
1196
|
+
expect(typeof token).toBe("string");
|
|
1197
|
+
await resolveApproval(domain.store, String(token), {
|
|
1198
|
+
status: "approved",
|
|
1199
|
+
by: await approverSubject("carol", "acme")
|
|
1200
|
+
});
|
|
1201
|
+
expect((await send("alice", {
|
|
1202
|
+
op: "apiKey.revokeAll",
|
|
1203
|
+
org: "acme"
|
|
1204
|
+
}, String(token))).status).toBe(204);
|
|
1205
|
+
expect((await send("alice", {
|
|
1206
|
+
op: "apiKey.revokeAll",
|
|
1207
|
+
org: "acme"
|
|
1208
|
+
}, String(token))).status).toBe(403);
|
|
1209
|
+
expect((await send("carol", {
|
|
1210
|
+
op: "apiKey.revokeAll",
|
|
1211
|
+
org: "acme"
|
|
1212
|
+
})).status).toBe(204);
|
|
1213
|
+
});
|
|
1214
|
+
scenario("stale-approval", "ignores an approval header meant for another request", async () => {
|
|
1215
|
+
expect((await send("bob", {
|
|
1216
|
+
op: "project.get",
|
|
1217
|
+
org: "acme",
|
|
1218
|
+
id: "p1"
|
|
1219
|
+
}, "pd1.not-a-real-token")).status).toBe(200);
|
|
1220
|
+
});
|
|
1221
|
+
scenario("evaluations", "answers the decision endpoint in the path tenant", async () => {
|
|
1222
|
+
const result = await send("bob", {
|
|
1223
|
+
op: "evaluations",
|
|
1224
|
+
org: "acme",
|
|
1225
|
+
body: { evaluations: [{
|
|
1226
|
+
action: { name: "project.update" },
|
|
1227
|
+
resource: {
|
|
1228
|
+
type: "project",
|
|
1229
|
+
id: "p1",
|
|
1230
|
+
properties: project$1("p1")
|
|
1231
|
+
}
|
|
1232
|
+
}, {
|
|
1233
|
+
action: { name: "project.update" },
|
|
1234
|
+
resource: {
|
|
1235
|
+
type: "project",
|
|
1236
|
+
id: "p2",
|
|
1237
|
+
properties: project$1("p2")
|
|
1238
|
+
}
|
|
1239
|
+
}] }
|
|
1240
|
+
});
|
|
1241
|
+
expect(result.status).toBe(200);
|
|
1242
|
+
const rows = field(result.body, "evaluations");
|
|
1243
|
+
expect(Array.isArray(rows) ? rows.map((row) => field(row, "decision")) : rows).toEqual([true, false]);
|
|
1244
|
+
});
|
|
1245
|
+
scenario("upload", "leaves a multipart body to the framework parser", async () => {
|
|
1246
|
+
const file = {
|
|
1247
|
+
name: "notes.txt",
|
|
1248
|
+
content: "hello"
|
|
1249
|
+
};
|
|
1250
|
+
const uploaded = await send("bob", {
|
|
1251
|
+
op: "project.upload",
|
|
1252
|
+
org: "acme",
|
|
1253
|
+
id: "p1",
|
|
1254
|
+
file
|
|
1255
|
+
});
|
|
1256
|
+
expect(uploaded.status).toBe(201);
|
|
1257
|
+
expect(uploaded.body).toEqual({
|
|
1258
|
+
name: "notes.txt",
|
|
1259
|
+
size: 5
|
|
1260
|
+
});
|
|
1261
|
+
expect((await send("bob", {
|
|
1262
|
+
op: "project.upload",
|
|
1263
|
+
org: "acme",
|
|
1264
|
+
id: "p2",
|
|
1265
|
+
file
|
|
1266
|
+
})).status).toBe(403);
|
|
1267
|
+
});
|
|
1268
|
+
scenario("sub-app", "protects a route inside a sub-app", async () => {
|
|
1269
|
+
expect((await send("alice", {
|
|
1270
|
+
op: "admin.members",
|
|
1271
|
+
org: "acme"
|
|
1272
|
+
})).status).toBe(200);
|
|
1273
|
+
expect((await send("bob", {
|
|
1274
|
+
op: "admin.members",
|
|
1275
|
+
org: "acme"
|
|
1276
|
+
})).status).toBe(403);
|
|
1277
|
+
});
|
|
1278
|
+
scenario("parallel", "keeps 50 parallel requests with mixed subjects apart", async () => {
|
|
1279
|
+
const calls = Array.from({ length: 50 }, (_, index) => {
|
|
1280
|
+
const entry = PARALLEL[index * 7 % PARALLEL.length];
|
|
1281
|
+
if (entry === void 0) throw new Error("parallel table is empty");
|
|
1282
|
+
return entry;
|
|
1283
|
+
});
|
|
1284
|
+
const statuses = await Promise.all(calls.map(async (entry) => (await send(entry.user, {
|
|
1285
|
+
op: "project.get",
|
|
1286
|
+
org: entry.org,
|
|
1287
|
+
id: entry.id
|
|
1288
|
+
})).status));
|
|
1289
|
+
expect(statuses).toEqual(calls.map((entry) => entry.status));
|
|
1290
|
+
});
|
|
1291
|
+
scenario("stream-revoked", "closes an open stream when the session is revoked", async () => {
|
|
1292
|
+
expect((await openStream(null, "acme", "p1")).status).toBe(401);
|
|
1293
|
+
const stream = await openStream(tokens.get("bob") ?? null, "acme", "p1");
|
|
1294
|
+
try {
|
|
1295
|
+
expect(stream.status).toBe(200);
|
|
1296
|
+
expect((await stream.next())?.event).toBe("message");
|
|
1297
|
+
await domain.revocations.revoke({
|
|
1298
|
+
principal: "bob",
|
|
1299
|
+
kind: "session-revoked"
|
|
1300
|
+
});
|
|
1301
|
+
const end = await untilEnd(stream);
|
|
1302
|
+
expect(end.problem).toMatchObject({
|
|
1303
|
+
type: "https://permdock.com/problems/unauthenticated",
|
|
1304
|
+
status: 401,
|
|
1305
|
+
detail: "session-revoked"
|
|
1306
|
+
});
|
|
1307
|
+
expect(end.ended).toBe(true);
|
|
1308
|
+
} finally {
|
|
1309
|
+
await stream.cancel();
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
scenario("stream-demoted", "closes an open stream when a membership change re-denies it", async () => {
|
|
1313
|
+
const stream = await openStream(tokens.get("bob") ?? null, "acme", "p1");
|
|
1314
|
+
const restore = domain.demote("bob", "acme");
|
|
1315
|
+
try {
|
|
1316
|
+
expect(stream.status).toBe(200);
|
|
1317
|
+
expect((await stream.next())?.event).toBe("message");
|
|
1318
|
+
await domain.revocations.revoke({
|
|
1319
|
+
principal: "bob",
|
|
1320
|
+
tenant: "acme",
|
|
1321
|
+
kind: "changed"
|
|
1322
|
+
});
|
|
1323
|
+
const end = await untilEnd(stream);
|
|
1324
|
+
expect(end.problem).toMatchObject({
|
|
1325
|
+
type: "https://permdock.com/problems/denied",
|
|
1326
|
+
status: 403
|
|
1327
|
+
});
|
|
1328
|
+
expect(end.ended).toBe(true);
|
|
1329
|
+
} finally {
|
|
1330
|
+
restore();
|
|
1331
|
+
await stream.cancel();
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
scenario("stream-filter", "drops the items a subscriber cannot read and keeps the stream open", async () => {
|
|
1335
|
+
const stream = await openStream(tokens.get("bob") ?? null, "acme", "p1");
|
|
1336
|
+
try {
|
|
1337
|
+
expect(stream.status).toBe(200);
|
|
1338
|
+
const ids = [];
|
|
1339
|
+
while (ids.length < 7) {
|
|
1340
|
+
const frame = await stream.next();
|
|
1341
|
+
expect(frame?.event).toBe("message");
|
|
1342
|
+
ids.push(String(field(JSON.parse(frame?.data ?? "{}"), "id")));
|
|
1343
|
+
}
|
|
1344
|
+
expect(new Set(ids)).toEqual(/* @__PURE__ */ new Set([
|
|
1345
|
+
"p1",
|
|
1346
|
+
"p3",
|
|
1347
|
+
"p4"
|
|
1348
|
+
]));
|
|
1349
|
+
} finally {
|
|
1350
|
+
await stream.cancel();
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
scenario("stream-expired", "closes an open stream when the token expires", async () => {
|
|
1354
|
+
const stream = await openStream(`Bearer ${await signSaasToken("bob", {
|
|
1355
|
+
memberships: false,
|
|
1356
|
+
ttl: 2
|
|
1357
|
+
})}`, "acme", "p1");
|
|
1358
|
+
try {
|
|
1359
|
+
expect(stream.status).toBe(200);
|
|
1360
|
+
expect((await stream.next())?.event).toBe("message");
|
|
1361
|
+
const end = await untilEnd(stream);
|
|
1362
|
+
expect(end.problem).toMatchObject({
|
|
1363
|
+
type: "https://permdock.com/problems/unauthenticated",
|
|
1364
|
+
detail: "expired"
|
|
1365
|
+
});
|
|
1366
|
+
expect(end.ended).toBe(true);
|
|
1367
|
+
} finally {
|
|
1368
|
+
await stream.cancel();
|
|
1369
|
+
}
|
|
1370
|
+
}, 1e4);
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
//#endregion
|
|
1374
|
+
//#region src/testing/agent-adapter.ts
|
|
1375
|
+
/** The user whose subject resolver throws. */
|
|
1376
|
+
const AGENT_THROWING_USER = "throws";
|
|
1377
|
+
/** The project id whose loader throws. */
|
|
1378
|
+
const AGENT_THROWING_ROW = "throws";
|
|
1379
|
+
const SCENARIOS = [
|
|
1380
|
+
{
|
|
1381
|
+
name: "granted",
|
|
1382
|
+
title: "grants a member reading a project in their org",
|
|
1383
|
+
call: {
|
|
1384
|
+
user: "alice",
|
|
1385
|
+
org: "acme",
|
|
1386
|
+
tool: "project_read",
|
|
1387
|
+
args: { id: "p1" }
|
|
1388
|
+
},
|
|
1389
|
+
expected: "granted"
|
|
1390
|
+
},
|
|
1391
|
+
{
|
|
1392
|
+
name: "denied",
|
|
1393
|
+
title: "denies a user with no membership",
|
|
1394
|
+
call: {
|
|
1395
|
+
user: "mallory",
|
|
1396
|
+
org: "acme",
|
|
1397
|
+
tool: "project_read",
|
|
1398
|
+
args: { id: "p1" }
|
|
1399
|
+
},
|
|
1400
|
+
expected: "denied"
|
|
1401
|
+
},
|
|
1402
|
+
{
|
|
1403
|
+
name: "cross-tenant",
|
|
1404
|
+
title: "denies a row from another org",
|
|
1405
|
+
call: {
|
|
1406
|
+
user: "alice",
|
|
1407
|
+
org: "acme",
|
|
1408
|
+
tool: "project_read",
|
|
1409
|
+
args: { id: "g1" }
|
|
1410
|
+
},
|
|
1411
|
+
expected: "denied"
|
|
1412
|
+
},
|
|
1413
|
+
{
|
|
1414
|
+
name: "anonymous",
|
|
1415
|
+
title: "denies a call with no user",
|
|
1416
|
+
call: {
|
|
1417
|
+
user: null,
|
|
1418
|
+
org: "acme",
|
|
1419
|
+
tool: "project_read",
|
|
1420
|
+
args: { id: "p1" }
|
|
1421
|
+
},
|
|
1422
|
+
expected: "denied"
|
|
1423
|
+
},
|
|
1424
|
+
{
|
|
1425
|
+
name: "subject-throws",
|
|
1426
|
+
title: "denies when the subject resolver throws",
|
|
1427
|
+
call: {
|
|
1428
|
+
user: AGENT_THROWING_USER,
|
|
1429
|
+
org: "acme",
|
|
1430
|
+
tool: "project_read",
|
|
1431
|
+
args: { id: "p1" }
|
|
1432
|
+
},
|
|
1433
|
+
expected: "denied"
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
name: "loader-throws",
|
|
1437
|
+
title: "denies when the row loader throws",
|
|
1438
|
+
call: {
|
|
1439
|
+
user: "alice",
|
|
1440
|
+
org: "acme",
|
|
1441
|
+
tool: "project_read",
|
|
1442
|
+
args: { id: AGENT_THROWING_ROW }
|
|
1443
|
+
},
|
|
1444
|
+
expected: "denied"
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
name: "missing-row",
|
|
1448
|
+
title: "denies when the row does not exist",
|
|
1449
|
+
call: {
|
|
1450
|
+
user: "alice",
|
|
1451
|
+
org: "acme",
|
|
1452
|
+
tool: "project_read",
|
|
1453
|
+
args: { id: "missing" }
|
|
1454
|
+
},
|
|
1455
|
+
expected: "denied"
|
|
1456
|
+
},
|
|
1457
|
+
{
|
|
1458
|
+
name: "approval-required",
|
|
1459
|
+
title: "asks for approval before revoking every API key",
|
|
1460
|
+
call: {
|
|
1461
|
+
user: "alice",
|
|
1462
|
+
org: "acme",
|
|
1463
|
+
tool: "api_keys_revoke_all",
|
|
1464
|
+
args: {}
|
|
1465
|
+
},
|
|
1466
|
+
expected: "approval-required"
|
|
1467
|
+
}
|
|
1468
|
+
];
|
|
1469
|
+
function loadProject(args) {
|
|
1470
|
+
const id = typeof args === "object" && args !== null ? args.id : void 0;
|
|
1471
|
+
if (id === "throws") throw new Error("project store unavailable");
|
|
1472
|
+
return saasSeed.projects.find((row) => row.id === id) ?? null;
|
|
1473
|
+
}
|
|
1474
|
+
function createDomain() {
|
|
1475
|
+
return {
|
|
1476
|
+
policy: saasPolicy,
|
|
1477
|
+
subject: (user, org) => {
|
|
1478
|
+
if (user === "throws") throw new Error("session store unavailable");
|
|
1479
|
+
return user === null ? null : saasPrincipal(user, org);
|
|
1480
|
+
},
|
|
1481
|
+
tools: {
|
|
1482
|
+
project_read: {
|
|
1483
|
+
permission: saasPermissions.project.read,
|
|
1484
|
+
data: loadProject
|
|
1485
|
+
},
|
|
1486
|
+
api_keys_revoke_all: { permission: saasPermissions.apiKey.revokeAll }
|
|
1487
|
+
},
|
|
1488
|
+
customRoles: memoryRoleSource(saasCustomRoles),
|
|
1489
|
+
store: memoryApprovalStore()
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Runs the same tool calls through an agent adapter: a grant, the denials an
|
|
1494
|
+
* attacker or an outage produces, and an approval. Every failure denies.
|
|
1495
|
+
*/
|
|
1496
|
+
function testAgentAdapter(options) {
|
|
1497
|
+
describe(`${options.name}: agent adapter scenarios`, () => {
|
|
1498
|
+
const domain = createDomain();
|
|
1499
|
+
let mounted;
|
|
1500
|
+
beforeAll(async () => {
|
|
1501
|
+
mounted = await options.mount(domain);
|
|
1502
|
+
});
|
|
1503
|
+
afterAll(async () => {
|
|
1504
|
+
await mounted.close?.();
|
|
1505
|
+
});
|
|
1506
|
+
const outcomeOf = (call) => mounted.call(call);
|
|
1507
|
+
for (const scenario of SCENARIOS) {
|
|
1508
|
+
const reason = options.skip?.[scenario.name];
|
|
1509
|
+
it.skipIf(reason !== void 0)(reason === void 0 ? scenario.title : `${scenario.title} (${reason})`, async () => {
|
|
1510
|
+
expect(await outcomeOf(scenario.call)).toBe(scenario.expected);
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
//#endregion
|
|
1516
|
+
//#region src/testing/authzen.ts
|
|
1517
|
+
function sortKey(value) {
|
|
1518
|
+
return JSON.stringify(Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => byCodePoint(a, b))));
|
|
1519
|
+
}
|
|
1520
|
+
/** Runs AuthZEN 1.0 interop vectors against an Authorization API endpoint. */
|
|
1521
|
+
function testAuthZen(handle, options) {
|
|
1522
|
+
const origin = options.origin ?? "https://pdp.example.com";
|
|
1523
|
+
const post = async (path, body) => {
|
|
1524
|
+
const response = await handle(new Request(`${origin}${path}`, {
|
|
1525
|
+
method: "POST",
|
|
1526
|
+
headers: {
|
|
1527
|
+
"content-type": "application/json",
|
|
1528
|
+
...options.headers
|
|
1529
|
+
},
|
|
1530
|
+
body: JSON.stringify(body)
|
|
1531
|
+
}));
|
|
1532
|
+
expect(response.status).toBe(200);
|
|
1533
|
+
return response.json();
|
|
1534
|
+
};
|
|
1535
|
+
if (options.discovery ?? true) it("publishes the AuthZEN metadata document", async () => {
|
|
1536
|
+
const response = await handle(new Request(`${origin}/.well-known/authzen-configuration`, { headers: { ...options.headers } }));
|
|
1537
|
+
expect(response.status).toBe(200);
|
|
1538
|
+
const document = await response.json();
|
|
1539
|
+
expect(document["policy_decision_point"]).toBe(origin);
|
|
1540
|
+
expect(document["access_evaluation_endpoint"]).toBe(`${origin}/access/v1/evaluation`);
|
|
1541
|
+
for (const [name, value] of Object.entries(document)) if (name.endsWith("_endpoint")) expect(String(value).startsWith(origin)).toBe(true);
|
|
1542
|
+
});
|
|
1543
|
+
for (const [index, vector] of (options.vectors.evaluation ?? []).entries()) {
|
|
1544
|
+
const { subject, action, resource: target } = vector.request;
|
|
1545
|
+
it(`evaluation ${index}: ${subject.id ?? "?"} ${action.name} ${target.type}:${target.id ?? ""} is ${String(vector.expected)}`, async () => {
|
|
1546
|
+
const body = await post("/access/v1/evaluation", vector.request);
|
|
1547
|
+
expect(body.decision).toBe(vector.expected);
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
for (const [index, vector] of (options.vectors.evaluations ?? []).entries()) it(`evaluations ${index}: ${vector.request.evaluations.length} items`, async () => {
|
|
1551
|
+
const body = await post("/access/v1/evaluations", vector.request);
|
|
1552
|
+
expect(body.evaluations.map((row) => ({ decision: row.decision }))).toEqual(vector.expected);
|
|
1553
|
+
});
|
|
1554
|
+
const search = options.vectors.search ?? {};
|
|
1555
|
+
for (const kind of [
|
|
1556
|
+
"subject",
|
|
1557
|
+
"resource",
|
|
1558
|
+
"action"
|
|
1559
|
+
]) for (const [index, vector] of (search[kind] ?? []).entries()) it(`search/${kind} ${index}`, async () => {
|
|
1560
|
+
const body = await post(`/access/v1/search/${kind}`, vector.request);
|
|
1561
|
+
expect(body.results.map(sortKey).toSorted()).toEqual(vector.expected.results.map(sortKey).toSorted());
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
/** The AuthZEN interop Todo directory: each user is addressable by email or by identity. */
|
|
1565
|
+
const authzenTodoUsers = Object.freeze([
|
|
1566
|
+
{
|
|
1567
|
+
id: "rick@the-citadel.com",
|
|
1568
|
+
identity: "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs",
|
|
1569
|
+
roles: ["admin", "evil_genius"]
|
|
1570
|
+
},
|
|
1571
|
+
{
|
|
1572
|
+
id: "morty@the-citadel.com",
|
|
1573
|
+
identity: "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs",
|
|
1574
|
+
roles: ["editor"]
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
id: "summer@the-smiths.com",
|
|
1578
|
+
identity: "CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs",
|
|
1579
|
+
roles: ["editor"]
|
|
1580
|
+
},
|
|
1581
|
+
{
|
|
1582
|
+
id: "beth@the-smiths.com",
|
|
1583
|
+
identity: "CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs",
|
|
1584
|
+
roles: ["viewer"]
|
|
1585
|
+
},
|
|
1586
|
+
{
|
|
1587
|
+
id: "jerry@the-smiths.com",
|
|
1588
|
+
identity: "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs",
|
|
1589
|
+
roles: ["viewer"]
|
|
1590
|
+
}
|
|
1591
|
+
]);
|
|
1592
|
+
const authzenTodoPermissions = definePermissions({
|
|
1593
|
+
user: resource({
|
|
1594
|
+
id: "id",
|
|
1595
|
+
actions: ["can_read_user"]
|
|
1596
|
+
}),
|
|
1597
|
+
todo: resource({
|
|
1598
|
+
id: "id",
|
|
1599
|
+
actions: [
|
|
1600
|
+
"can_read_todos",
|
|
1601
|
+
"can_create_todo",
|
|
1602
|
+
"can_update_todo",
|
|
1603
|
+
"can_delete_todo"
|
|
1604
|
+
]
|
|
1605
|
+
})
|
|
1606
|
+
});
|
|
1607
|
+
const todo = authzenTodoPermissions.todo;
|
|
1608
|
+
const everyone = [allow(authzenTodoPermissions.user.can_read_user), allow(todo.can_read_todos)];
|
|
1609
|
+
/** The interop Todo rules: viewers read, editors write their own, admins delete and evil geniuses update anything. */
|
|
1610
|
+
const authzenTodoPolicy = definePolicy(authzenTodoPermissions, {
|
|
1611
|
+
roles: [
|
|
1612
|
+
role("viewer", everyone),
|
|
1613
|
+
role("editor", [
|
|
1614
|
+
...everyone,
|
|
1615
|
+
allow(todo.can_create_todo),
|
|
1616
|
+
allow(todo.can_update_todo, { where: { ownerID: principal.id } }),
|
|
1617
|
+
allow(todo.can_delete_todo, { where: { ownerID: principal.id } })
|
|
1618
|
+
]),
|
|
1619
|
+
role("admin", [
|
|
1620
|
+
...everyone,
|
|
1621
|
+
allow(todo.can_create_todo),
|
|
1622
|
+
allow(todo.can_delete_todo)
|
|
1623
|
+
]),
|
|
1624
|
+
role("evil_genius", [...everyone, allow(todo.can_update_todo)]),
|
|
1625
|
+
role("pep", [])
|
|
1626
|
+
],
|
|
1627
|
+
principal: (user) => {
|
|
1628
|
+
const id = user !== null && typeof user === "object" && "id" in user ? user.id : void 0;
|
|
1629
|
+
if (id === "pep") return {
|
|
1630
|
+
id: "pep",
|
|
1631
|
+
roles: ["pep"]
|
|
1632
|
+
};
|
|
1633
|
+
const found = authzenTodoUsers.find((entry) => entry.id === id || entry.identity === id);
|
|
1634
|
+
return found === void 0 ? null : {
|
|
1635
|
+
id: found.id,
|
|
1636
|
+
roles: found.roles
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
const TODOS = Object.freeze(authzenTodoUsers.map((user, index) => ({
|
|
1641
|
+
id: `todo-${String(index + 1)}`,
|
|
1642
|
+
ownerID: user.id
|
|
1643
|
+
})));
|
|
1644
|
+
/** Rows for `resources.todo.list` and records for `subjects.list` in the Todo domain. */
|
|
1645
|
+
const authzenTodoData = Object.freeze({
|
|
1646
|
+
todos: TODOS,
|
|
1647
|
+
users: Object.freeze(authzenTodoUsers.map((user) => ({ id: user.id })))
|
|
1648
|
+
});
|
|
1649
|
+
function expectedFor(user, action, ownerID) {
|
|
1650
|
+
const own = ownerID === user.id;
|
|
1651
|
+
const has = (name) => user.roles.includes(name);
|
|
1652
|
+
switch (action) {
|
|
1653
|
+
case "can_read_user":
|
|
1654
|
+
case "can_read_todos": return true;
|
|
1655
|
+
case "can_create_todo": return has("admin") || has("editor");
|
|
1656
|
+
case "can_update_todo": return has("evil_genius") || has("editor") && own;
|
|
1657
|
+
case "can_delete_todo": return has("admin") || has("editor") && own;
|
|
1658
|
+
default: return false;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* PermDock's vectors over the interop Todo domain, in the interop layout:
|
|
1663
|
+
* every user against every action on their own and on Rick's todo, one batch
|
|
1664
|
+
* per user, and the three searches.
|
|
1665
|
+
*/
|
|
1666
|
+
function authzenTodoVectors() {
|
|
1667
|
+
const rick = authzenTodoUsers[0];
|
|
1668
|
+
const actions = [
|
|
1669
|
+
"can_read_todos",
|
|
1670
|
+
"can_create_todo",
|
|
1671
|
+
"can_update_todo",
|
|
1672
|
+
"can_delete_todo"
|
|
1673
|
+
];
|
|
1674
|
+
const evaluation = [];
|
|
1675
|
+
const evaluations = [];
|
|
1676
|
+
for (const user of authzenTodoUsers) {
|
|
1677
|
+
const subject = {
|
|
1678
|
+
type: "user",
|
|
1679
|
+
id: user.identity
|
|
1680
|
+
};
|
|
1681
|
+
evaluation.push({
|
|
1682
|
+
request: {
|
|
1683
|
+
subject,
|
|
1684
|
+
action: { name: "can_read_user" },
|
|
1685
|
+
resource: {
|
|
1686
|
+
type: "user",
|
|
1687
|
+
id: rick?.id ?? ""
|
|
1688
|
+
}
|
|
1689
|
+
},
|
|
1690
|
+
expected: true
|
|
1691
|
+
});
|
|
1692
|
+
for (const action of actions) for (const ownerID of /* @__PURE__ */ new Set([user.id, rick?.id ?? ""])) evaluation.push({
|
|
1693
|
+
request: {
|
|
1694
|
+
subject,
|
|
1695
|
+
action: { name: action },
|
|
1696
|
+
resource: {
|
|
1697
|
+
type: "todo",
|
|
1698
|
+
id: `t-${ownerID}`,
|
|
1699
|
+
properties: { ownerID }
|
|
1700
|
+
}
|
|
1701
|
+
},
|
|
1702
|
+
expected: expectedFor(user, action, ownerID)
|
|
1703
|
+
});
|
|
1704
|
+
const owners = authzenTodoUsers.map((entry) => entry.id);
|
|
1705
|
+
evaluations.push({
|
|
1706
|
+
request: {
|
|
1707
|
+
subject,
|
|
1708
|
+
action: { name: "can_update_todo" },
|
|
1709
|
+
evaluations: owners.map((ownerID) => ({ resource: {
|
|
1710
|
+
type: "todo",
|
|
1711
|
+
id: `t-${ownerID}`,
|
|
1712
|
+
properties: { ownerID }
|
|
1713
|
+
} }))
|
|
1714
|
+
},
|
|
1715
|
+
expected: owners.map((ownerID) => ({ decision: expectedFor(user, "can_update_todo", ownerID) }))
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
const morty = authzenTodoUsers[1];
|
|
1719
|
+
const mortyTodo = TODOS.find((row) => row.ownerID === morty?.id);
|
|
1720
|
+
return {
|
|
1721
|
+
evaluation,
|
|
1722
|
+
evaluations,
|
|
1723
|
+
search: {
|
|
1724
|
+
action: [{
|
|
1725
|
+
request: {
|
|
1726
|
+
subject: {
|
|
1727
|
+
type: "user",
|
|
1728
|
+
id: morty?.id
|
|
1729
|
+
},
|
|
1730
|
+
resource: {
|
|
1731
|
+
type: "todo",
|
|
1732
|
+
id: mortyTodo?.id,
|
|
1733
|
+
properties: mortyTodo
|
|
1734
|
+
}
|
|
1735
|
+
},
|
|
1736
|
+
expected: { results: actions.map((name) => ({ name })) }
|
|
1737
|
+
}],
|
|
1738
|
+
resource: [{
|
|
1739
|
+
request: {
|
|
1740
|
+
subject: {
|
|
1741
|
+
type: "user",
|
|
1742
|
+
id: morty?.id
|
|
1743
|
+
},
|
|
1744
|
+
action: { name: "can_delete_todo" },
|
|
1745
|
+
resource: { type: "todo" }
|
|
1746
|
+
},
|
|
1747
|
+
expected: { results: [{
|
|
1748
|
+
type: "todo",
|
|
1749
|
+
id: mortyTodo?.id
|
|
1750
|
+
}] }
|
|
1751
|
+
}],
|
|
1752
|
+
subject: [{
|
|
1753
|
+
request: {
|
|
1754
|
+
subject: { type: "user" },
|
|
1755
|
+
action: { name: "can_delete_todo" },
|
|
1756
|
+
resource: {
|
|
1757
|
+
type: "todo",
|
|
1758
|
+
id: mortyTodo?.id,
|
|
1759
|
+
properties: mortyTodo
|
|
1760
|
+
}
|
|
1761
|
+
},
|
|
1762
|
+
expected: { results: authzenTodoUsers.filter((user) => expectedFor(user, "can_delete_todo", morty?.id ?? "")).map((user) => ({
|
|
1763
|
+
type: "user",
|
|
1764
|
+
id: user.id
|
|
1765
|
+
})) }
|
|
1766
|
+
}]
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
//#endregion
|
|
1771
|
+
//#region src/testing/jwt-fixtures.ts
|
|
1772
|
+
const jwtFixtureJwks = { keys: [{
|
|
1773
|
+
crv: "Ed25519",
|
|
1774
|
+
x: "79ab4WR6Eb9LkefWpmh5ZlvjXg7wqVGNMwIEHQqduIQ",
|
|
1775
|
+
kty: "OKP",
|
|
1776
|
+
kid: "2026-09",
|
|
1777
|
+
alg: "Ed25519",
|
|
1778
|
+
use: "sig"
|
|
1779
|
+
}] };
|
|
1780
|
+
const jwtFixtureIssuer = "https://login.example.com";
|
|
1781
|
+
const jwtFixtureAudience = "https://api.example.com";
|
|
1782
|
+
const jwtFixtureTokens = {
|
|
1783
|
+
valid: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJjbGllbnRfaWQiOiJhcHAiLCJyb2xlcyI6WyJtZW1iZXIiXSwianRpIjoianRpLXZhbGlkIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjIwMDAwMDAwMDB9.ENFMmLSyVde-2s4Gdz4iP7srZvU0Xuy_ZLz-OJGHD_-W1lEayEBeSPTnab0L_TBdj8p5BmkFrT-7VE7o20GbAw",
|
|
1784
|
+
expired: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjEwMDAwMDAwMDAsImV4cCI6MTEwMDAwMDAwMH0.9X3d6kH3_2nmrbqMxuoxzFW-wsWJVCe-lR-aQ-MmsGGXlEUBMgQ-4MtAq1UR9lLE0h2b5JrE_GfjzuC04h5xCg",
|
|
1785
|
+
wrongAud: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9vdGhlci5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwfQ.PgWdzi56J31OwoMqH4ysHzVKSKUDhi-AtHFUp1ewFzi-qGE-tknzrNKnKhDOncVzj0okb_flyy3vQgfzJwG5AQ",
|
|
1786
|
+
wrongIss: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2V2aWwuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwfQ.tIcLHLa9JLtKz1gqjRGdr5DFbUzRDs5KsoPsJCxspvbBcsFfRFG2G0XJnd1h4QHyZ_5B4OhY3W_jIIsnOPD9Bg",
|
|
1787
|
+
unknownKid: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoibm9wZSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MjAwMDAwMDAwMH0.walgG5GvpCVBnkMtkdw-epi5c93JqfvAM_N4EWR9Tqhiw_cnzITnlQyoVfihECmV1fqtFrhwM1RCIRJvd38zAQ",
|
|
1788
|
+
none: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJleHAiOjIwMDAwMDAwMDAsImlhdCI6MTcwMDAwMDAwMH0.",
|
|
1789
|
+
snapshot: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InBlcm1kb2NrLXNuYXBzaG90K2p3dCJ9.eyJzbmFwc2hvdCI6eyJ2IjoyLCJpc3N1ZWRBdCI6MTcwMDAwMDAwMCwic3ViamVjdCI6eyJwcmluY2lwYWwiOnsiaWQiOiJ1XzEiLCJyb2xlcyI6WyJtZW1iZXIiXX0sImNvbnRleHQiOnt9fSwicm9sZXMiOlsibWVtYmVyIl0sImdyYW50cyI6W10sInRlbmFudHMiOltdfSwic3ViIjoidV8xIiwiaXNzIjoiaHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwLCJqdGkiOiJzbmFwX2ZpeHR1cmUifQ.4itaYctluoCwU-syytUxArtS_FRyNAti2SZxuSH-nKIMr7-Sb3h1Q3xugn_vPR1eupV4fVNIe6dH27_EiyDvCA",
|
|
1790
|
+
approval: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InBlcm1kb2NrLWFwcHJvdmFsK2p3dCJ9.eyJhcHByb3ZhbCI6eyJ0b2tlbiI6InBkMS5hYmMiLCJwZXJtaXNzaW9uIjoicG9zdC5kZWxldGUiLCJyZXNvdXJjZSI6eyJ0eXBlIjoicG9zdCIsImlkIjoiNDIifSwic3RhdHVzIjoiYXBwcm92ZWQifSwic3ViIjoidV8xIiwiaXNzIjoiaHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwLCJqdGkiOiJhcHJfZml4dHVyZSJ9.UHsoSzQCo68G7ee-EFqdzMLvmqaKlFIMdQKFuqZEM4yUQnJsJJTXbld_aCXcyfN-IdjF9cj2F5EHzsV4nLKbBg",
|
|
1791
|
+
decisions: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InBlcm1kb2NrLWRlY2lzaW9ucytqd3QifQ.eyJldmVudHMiOltdLCJpc3MiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwLCJqdGkiOiJkZWNfZml4dHVyZSJ9.UZhJd72yFGxHOdjYbzcGYUPgYjvLwOPCtHKxEbLu56SdKSKUDCREXmEBI9WaCPKWjlTBnnIKMYwjG8I8RATJCg"
|
|
1792
|
+
};
|
|
1793
|
+
//#endregion
|
|
1794
|
+
//#region src/testing/conformance.ts
|
|
1795
|
+
function testSubjectResolver(resolver, options) {
|
|
1796
|
+
it("never throws and fails closed to anonymous", async () => {
|
|
1797
|
+
let result;
|
|
1798
|
+
try {
|
|
1799
|
+
result = await resolver(options.invalid);
|
|
1800
|
+
} catch {
|
|
1801
|
+
throw new Error("SubjectResolver must not throw");
|
|
1802
|
+
}
|
|
1803
|
+
expect(result.principal).toBeNull();
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
function testMembershipSource(source, options) {
|
|
1807
|
+
const query = options.tenant === void 0 ? {} : { tenant: options.tenant };
|
|
1808
|
+
it("returns well-formed memberships and fails closed on throw", async () => {
|
|
1809
|
+
for (const principal of options.principals) {
|
|
1810
|
+
let memberships = [];
|
|
1811
|
+
try {
|
|
1812
|
+
memberships = await source.membershipsFor(principal, query);
|
|
1813
|
+
} catch {
|
|
1814
|
+
memberships = [];
|
|
1815
|
+
}
|
|
1816
|
+
for (const membership of memberships) {
|
|
1817
|
+
const shapes = [
|
|
1818
|
+
membership.scope !== void 0,
|
|
1819
|
+
membership.tenant !== void 0 || membership.team !== void 0,
|
|
1820
|
+
membership.on !== void 0
|
|
1821
|
+
].filter(Boolean);
|
|
1822
|
+
expect(shapes.length).toBe(1);
|
|
1823
|
+
expect(Array.isArray(membership.roles)).toBe(true);
|
|
1824
|
+
if (options.policy !== void 0) expect(normalizeMemberships([membership], scopeList(options.policy.scopes))).toHaveLength(1);
|
|
1825
|
+
}
|
|
1826
|
+
const expected = options.expect?.[principal.id];
|
|
1827
|
+
if (expected !== void 0) expect(memberships).toEqual(expected);
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
if (source.list !== void 0) it("lists every member of an instance it returns a membership for", async () => {
|
|
1831
|
+
for (const principal of options.principals) {
|
|
1832
|
+
let memberships = [];
|
|
1833
|
+
try {
|
|
1834
|
+
memberships = await source.membershipsFor(principal, query);
|
|
1835
|
+
} catch {
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
for (const membership of memberships) {
|
|
1839
|
+
if (membership.scope === void 0 || membership.id === void 0) continue;
|
|
1840
|
+
const members = await source.list?.({
|
|
1841
|
+
scope: membership.scope,
|
|
1842
|
+
id: membership.id
|
|
1843
|
+
}) ?? [];
|
|
1844
|
+
expect(members.some((entry) => entry.principal.id === principal.id && entry.membership.scope === membership.scope && entry.membership.id === membership.id)).toBe(true);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
});
|
|
1848
|
+
if (source.version !== void 0) it("reports a finite version or none", async () => {
|
|
1849
|
+
for (const principal of options.principals) {
|
|
1850
|
+
const version = await source.version?.({ id: principal.id });
|
|
1851
|
+
expect(version === void 0 || typeof version === "number" && Number.isFinite(version)).toBe(true);
|
|
1852
|
+
}
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
function holderKey(holder) {
|
|
1856
|
+
return "group" in holder ? `${holder.group.resource}:${holder.group.id}#${holder.group.relation}` : holder.principal.id;
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* A `RelationSource` answers facts only: well-formed chains nearest first and
|
|
1860
|
+
* no longer than `depth`, holders with principal ids or group references and
|
|
1861
|
+
* numeric instants, a `truncated` chain only when more ancestors exist, and
|
|
1862
|
+
* empty answers (never a throw) for an object it does not know. `expect` pins
|
|
1863
|
+
* the chains, holders and links for `objects`, keyed `resource:id`,
|
|
1864
|
+
* `resource:id#relation` and `resource:id>link`; a group holder is written
|
|
1865
|
+
* `resource:id#relation`.
|
|
1866
|
+
*/
|
|
1867
|
+
function testRelationSource(source, options) {
|
|
1868
|
+
it("returns chains nearest first, within depth, and truncated only when more exist", async () => {
|
|
1869
|
+
for (const object of options.objects) {
|
|
1870
|
+
const full = await source.ancestors({
|
|
1871
|
+
resource: object.resource,
|
|
1872
|
+
id: object.id,
|
|
1873
|
+
through: "parent",
|
|
1874
|
+
depth: 32
|
|
1875
|
+
});
|
|
1876
|
+
expect(Array.isArray(full.ancestors)).toBe(true);
|
|
1877
|
+
for (const ancestor of full.ancestors) {
|
|
1878
|
+
expect(typeof ancestor.id).toBe("string");
|
|
1879
|
+
expect(ancestor.id).not.toBe("");
|
|
1880
|
+
}
|
|
1881
|
+
expect(full.ancestors.length).toBeLessThanOrEqual(32);
|
|
1882
|
+
const expected = options.expect?.ancestors?.[`${object.resource}:${object.id}`];
|
|
1883
|
+
if (expected !== void 0) expect(full.ancestors.map((ancestor) => ancestor.id)).toEqual(expected);
|
|
1884
|
+
if (full.ancestors.length > 1) {
|
|
1885
|
+
const short = await source.ancestors({
|
|
1886
|
+
resource: object.resource,
|
|
1887
|
+
id: object.id,
|
|
1888
|
+
through: "parent",
|
|
1889
|
+
depth: 1
|
|
1890
|
+
});
|
|
1891
|
+
expect(short.ancestors.map((ancestor) => ancestor.id)).toEqual([full.ancestors[0]?.id]);
|
|
1892
|
+
expect(short.truncated).toBe(true);
|
|
1893
|
+
}
|
|
1894
|
+
const none = await source.ancestors({
|
|
1895
|
+
resource: object.resource,
|
|
1896
|
+
id: object.id,
|
|
1897
|
+
through: "parent",
|
|
1898
|
+
depth: 0
|
|
1899
|
+
});
|
|
1900
|
+
expect(none.ancestors).toEqual([]);
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
it("returns well-formed holders", async () => {
|
|
1904
|
+
for (const object of options.objects) {
|
|
1905
|
+
if (object.relation === void 0) continue;
|
|
1906
|
+
const holders = await source.related({
|
|
1907
|
+
resource: object.resource,
|
|
1908
|
+
id: object.id,
|
|
1909
|
+
relation: object.relation
|
|
1910
|
+
});
|
|
1911
|
+
expect(Array.isArray(holders)).toBe(true);
|
|
1912
|
+
for (const holder of holders) {
|
|
1913
|
+
if ("group" in holder) for (const part of [
|
|
1914
|
+
holder.group.resource,
|
|
1915
|
+
holder.group.id,
|
|
1916
|
+
holder.group.relation
|
|
1917
|
+
]) {
|
|
1918
|
+
expect(typeof part).toBe("string");
|
|
1919
|
+
expect(part).not.toBe("");
|
|
1920
|
+
}
|
|
1921
|
+
else expect(typeof holder.principal.id).toBe("string");
|
|
1922
|
+
for (const instant of [holder.startsAt, holder.expiresAt]) expect(instant === void 0 || typeof instant === "number" && Number.isFinite(instant)).toBe(true);
|
|
1923
|
+
}
|
|
1924
|
+
const expected = options.expect?.holders?.[`${object.resource}:${object.id}#${object.relation}`];
|
|
1925
|
+
if (expected !== void 0) expect(holders.map(holderKey).toSorted()).toEqual([...expected].toSorted());
|
|
1926
|
+
}
|
|
1927
|
+
});
|
|
1928
|
+
it("follows each link to exactly one instance", async () => {
|
|
1929
|
+
for (const [key, id] of Object.entries(options.expect?.links ?? {})) {
|
|
1930
|
+
const [object, through] = key.split(">");
|
|
1931
|
+
const [resource, objectId] = (object ?? "").split(":");
|
|
1932
|
+
if (resource === void 0 || objectId === void 0 || through === void 0) throw new Error(`expect.links key "${key}" is not resource:id>link.`);
|
|
1933
|
+
const linked = await source.ancestors({
|
|
1934
|
+
resource,
|
|
1935
|
+
id: objectId,
|
|
1936
|
+
through,
|
|
1937
|
+
depth: 1
|
|
1938
|
+
});
|
|
1939
|
+
expect(linked.ancestors.map((ancestor) => ancestor.id)).toEqual([id]);
|
|
1940
|
+
}
|
|
1941
|
+
});
|
|
1942
|
+
it("reads each object's row as an object, or null when there is none", async () => {
|
|
1943
|
+
if (source.row === void 0) return;
|
|
1944
|
+
const read = (query) => source.row?.(query);
|
|
1945
|
+
for (const object of options.objects) {
|
|
1946
|
+
const row = await read({
|
|
1947
|
+
resource: object.resource,
|
|
1948
|
+
id: object.id
|
|
1949
|
+
});
|
|
1950
|
+
expect(row === null || row === void 0 || typeof row === "object" && !Array.isArray(row)).toBe(true);
|
|
1951
|
+
}
|
|
1952
|
+
const missing = await read({
|
|
1953
|
+
resource: options.objects[0]?.resource ?? "",
|
|
1954
|
+
id: "permdock-missing-object"
|
|
1955
|
+
});
|
|
1956
|
+
expect(missing ?? null).toBeNull();
|
|
1957
|
+
});
|
|
1958
|
+
it("answers an unknown object with nothing instead of throwing", async () => {
|
|
1959
|
+
const first = options.objects[0];
|
|
1960
|
+
if (first === void 0) return;
|
|
1961
|
+
const missing = {
|
|
1962
|
+
resource: first.resource,
|
|
1963
|
+
id: "permdock-missing-object"
|
|
1964
|
+
};
|
|
1965
|
+
expect((await source.ancestors({
|
|
1966
|
+
...missing,
|
|
1967
|
+
through: "parent",
|
|
1968
|
+
depth: 16
|
|
1969
|
+
})).ancestors).toEqual([]);
|
|
1970
|
+
if (first.relation !== void 0) expect(await source.related({
|
|
1971
|
+
...missing,
|
|
1972
|
+
relation: first.relation
|
|
1973
|
+
})).toEqual([]);
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
function testEntitlementSource(source, options) {
|
|
1977
|
+
it("returns plan names for a tenant and none without one", async () => {
|
|
1978
|
+
const found = await source.entitlementsFor(options.principal, { tenant: options.tenant });
|
|
1979
|
+
expect(Array.isArray(found)).toBe(true);
|
|
1980
|
+
for (const name of found) expect(typeof name).toBe("string");
|
|
1981
|
+
if (options.expect !== void 0) expect([...found].toSorted()).toEqual([...options.expect].toSorted());
|
|
1982
|
+
expect(await source.entitlementsFor(options.principal, {})).toEqual([]);
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
function testRoleSource(source, options) {
|
|
1986
|
+
it("only resolves declared role names", async () => {
|
|
1987
|
+
const declared = options.declared.map((item) => typeof item === "string" ? item : item.key);
|
|
1988
|
+
const roles = await source.rolesFor(options.tenant);
|
|
1989
|
+
for (const role of roles) {
|
|
1990
|
+
expect(role.tenant).toBe(options.tenant);
|
|
1991
|
+
for (const included of role.includes ?? []) expect(declared).toContain(included);
|
|
1992
|
+
if (options.policy !== void 0) expect(validateCustomRole(options.policy, role).dropped).toEqual([]);
|
|
1993
|
+
}
|
|
1994
|
+
if (source.assignable !== void 0) {
|
|
1995
|
+
const assignable = await source.assignable(options.tenant);
|
|
1996
|
+
for (const name of assignable) expect(declared).toContain(name);
|
|
1997
|
+
}
|
|
1998
|
+
});
|
|
1999
|
+
const every = options.every;
|
|
2000
|
+
if (every !== void 0) it("returns every custom role of the tenant, held or not", async () => {
|
|
2001
|
+
const roles = await source.rolesFor(options.tenant, { held: [] });
|
|
2002
|
+
expect([...new Set(roles.map((role) => role.name))].toSorted()).toEqual([...new Set(every)].toSorted());
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
function testLimitStore(store) {
|
|
2006
|
+
it("counts down synchronously and fails closed when exhausted", async () => {
|
|
2007
|
+
const input = {
|
|
2008
|
+
key: "report.export",
|
|
2009
|
+
subjectId: "u_1",
|
|
2010
|
+
count: 2,
|
|
2011
|
+
per: "hour",
|
|
2012
|
+
now: 17e8
|
|
2013
|
+
};
|
|
2014
|
+
const first = await store.consume(input);
|
|
2015
|
+
expect(first.remaining).toBeGreaterThanOrEqual(0);
|
|
2016
|
+
const peeked = store.remaining(input);
|
|
2017
|
+
if (peeked !== void 0) {
|
|
2018
|
+
expect(typeof peeked.remaining).toBe("number");
|
|
2019
|
+
expect(peeked !== null && typeof peeked === "object" && "then" in peeked && typeof peeked.then === "function").toBe(false);
|
|
2020
|
+
}
|
|
2021
|
+
await store.consume(input);
|
|
2022
|
+
const exhausted = await store.consume(input);
|
|
2023
|
+
expect(exhausted.remaining).toBeLessThan(0);
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
function testDecisionSink(sink) {
|
|
2027
|
+
it("accepts batches and never propagates write errors", async () => {
|
|
2028
|
+
await expect(Promise.resolve(sink.write([{
|
|
2029
|
+
type: "membership",
|
|
2030
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2031
|
+
source: "app",
|
|
2032
|
+
operation: "changed",
|
|
2033
|
+
principal: { id: "u_1" },
|
|
2034
|
+
roles: {
|
|
2035
|
+
added: ["admin"],
|
|
2036
|
+
removed: ["member"]
|
|
2037
|
+
}
|
|
2038
|
+
}]))).resolves.toBeUndefined();
|
|
2039
|
+
await expect(Promise.resolve(sink.write([]))).resolves.toBeUndefined();
|
|
2040
|
+
if (sink.flush !== void 0) {
|
|
2041
|
+
await expect(Promise.resolve(sink.flush())).resolves.toBeUndefined();
|
|
2042
|
+
await expect(Promise.resolve(sink.flush())).resolves.toBeUndefined();
|
|
2043
|
+
}
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
function testSnapshotSource(source) {
|
|
2047
|
+
it("round-trips a snapshot", async () => {
|
|
2048
|
+
const snapshot = await source.get();
|
|
2049
|
+
expect(snapshot === null || snapshot === void 0).toBe(false);
|
|
2050
|
+
if (typeof snapshot === "object" && snapshot !== null && "v" in snapshot) expect(snapshot.v).toBe(1);
|
|
2051
|
+
if (source.subscribe !== void 0) source.subscribe(() => void 0)();
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
function testPolicySource(source, options = {}) {
|
|
2055
|
+
const readCurrent = () => {
|
|
2056
|
+
const document = source.current();
|
|
2057
|
+
expect(document !== null && typeof document === "object" && "then" in document && typeof document.then === "function").toBe(false);
|
|
2058
|
+
return document;
|
|
2059
|
+
};
|
|
2060
|
+
it("returns null or a v1 policy document synchronously", () => {
|
|
2061
|
+
const document = readCurrent();
|
|
2062
|
+
if (document !== null) expect(parsePolicyDocument(document)).toEqual(document);
|
|
2063
|
+
});
|
|
2064
|
+
it("refreshes without rejecting and keeps current() synchronous", async () => {
|
|
2065
|
+
await expect(Promise.resolve(source.refresh())).resolves.toBeUndefined();
|
|
2066
|
+
const document = readCurrent();
|
|
2067
|
+
if (document !== null) expect(document.v).toBe(1);
|
|
2068
|
+
});
|
|
2069
|
+
if (options.policy !== void 0) {
|
|
2070
|
+
const policy = options.policy;
|
|
2071
|
+
it("merges only grants on hostable permissions", () => {
|
|
2072
|
+
const document = readCurrent();
|
|
2073
|
+
const merged = mergeHostedGrants(policy, document);
|
|
2074
|
+
for (const grant of merged.policy.grants) if (grant.hosted !== void 0) expect(policy.hostable).toContain(grant.permission.key);
|
|
2075
|
+
for (const dropped of merged.dropped) expect(dropped.kind).toBe("hosted-grant-dropped");
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
function sampleApproval(token) {
|
|
2080
|
+
return {
|
|
2081
|
+
v: 1,
|
|
2082
|
+
token,
|
|
2083
|
+
permission: "post.delete",
|
|
2084
|
+
scope: "post:delete",
|
|
2085
|
+
resource: {
|
|
2086
|
+
type: "post",
|
|
2087
|
+
id: "42"
|
|
2088
|
+
},
|
|
2089
|
+
subject: {
|
|
2090
|
+
principal: {
|
|
2091
|
+
id: "u_1",
|
|
2092
|
+
roles: ["member"]
|
|
2093
|
+
},
|
|
2094
|
+
actor: {
|
|
2095
|
+
id: "agent-1",
|
|
2096
|
+
kind: "eve"
|
|
2097
|
+
}
|
|
2098
|
+
},
|
|
2099
|
+
detail: "post.delete requires human approval.",
|
|
2100
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2101
|
+
expiresAt: new Date(Date.now() + 36e5).toISOString(),
|
|
2102
|
+
status: "pending"
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
const approver = {
|
|
2106
|
+
principal: {
|
|
2107
|
+
id: "u_9",
|
|
2108
|
+
roles: ["admin"]
|
|
2109
|
+
},
|
|
2110
|
+
context: {}
|
|
2111
|
+
};
|
|
2112
|
+
const secondApprover = {
|
|
2113
|
+
principal: {
|
|
2114
|
+
id: "u_10",
|
|
2115
|
+
roles: ["admin"]
|
|
2116
|
+
},
|
|
2117
|
+
context: {}
|
|
2118
|
+
};
|
|
2119
|
+
const admin = {
|
|
2120
|
+
kind: "role",
|
|
2121
|
+
role: "admin",
|
|
2122
|
+
scope: "global"
|
|
2123
|
+
};
|
|
2124
|
+
function testReplayStore(store) {
|
|
2125
|
+
it("records a jti after remember and reports it as seen", async () => {
|
|
2126
|
+
const jti = "jti-1";
|
|
2127
|
+
expect(await store.seen(jti)).toBe(false);
|
|
2128
|
+
await store.remember(jti);
|
|
2129
|
+
expect(await store.seen(jti)).toBe(true);
|
|
2130
|
+
expect(await store.seen("jti-2")).toBe(false);
|
|
2131
|
+
});
|
|
2132
|
+
it("forgets a jti whose expiresAt has passed", async () => {
|
|
2133
|
+
await store.remember("ttl-jti", Math.floor(Date.now() / 1e3) - 1);
|
|
2134
|
+
expect(await store.seen("ttl-jti")).toBe(false);
|
|
2135
|
+
});
|
|
2136
|
+
it("claims a key once under concurrency and releases it for a retry", async (context) => {
|
|
2137
|
+
if (store.claim === void 0 || store.release === void 0) {
|
|
2138
|
+
context.skip();
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + 60;
|
|
2142
|
+
const results = await Promise.all([store.claim("claim-key", expiresAt), store.claim("claim-key", expiresAt)]);
|
|
2143
|
+
expect(results.toSorted()).toEqual([false, true]);
|
|
2144
|
+
expect(await store.seen("claim-key")).toBe(true);
|
|
2145
|
+
await store.release("claim-key");
|
|
2146
|
+
expect(await store.claim("claim-key", expiresAt)).toBe(true);
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
function testRevocationFeed(feed) {
|
|
2150
|
+
it("delivers each event to every subscriber until it unsubscribes", async () => {
|
|
2151
|
+
const first = [];
|
|
2152
|
+
const second = [];
|
|
2153
|
+
const stopFirst = feed.subscribe((event) => {
|
|
2154
|
+
first.push(event);
|
|
2155
|
+
});
|
|
2156
|
+
const stopSecond = feed.subscribe((event) => {
|
|
2157
|
+
second.push(event);
|
|
2158
|
+
});
|
|
2159
|
+
const revoked = {
|
|
2160
|
+
principal: "u-feed",
|
|
2161
|
+
session: "s-feed",
|
|
2162
|
+
kind: "session-revoked"
|
|
2163
|
+
};
|
|
2164
|
+
await feed.revoke(revoked);
|
|
2165
|
+
await vi.waitFor(() => {
|
|
2166
|
+
expect(first).toEqual([revoked]);
|
|
2167
|
+
expect(second).toEqual([revoked]);
|
|
2168
|
+
});
|
|
2169
|
+
stopFirst();
|
|
2170
|
+
const changed = {
|
|
2171
|
+
principal: "u-feed",
|
|
2172
|
+
tenant: "t-feed",
|
|
2173
|
+
kind: "changed"
|
|
2174
|
+
};
|
|
2175
|
+
await feed.revoke(changed);
|
|
2176
|
+
await vi.waitFor(() => {
|
|
2177
|
+
expect(second).toEqual([revoked, changed]);
|
|
2178
|
+
});
|
|
2179
|
+
expect(first).toEqual([revoked]);
|
|
2180
|
+
stopSecond();
|
|
2181
|
+
});
|
|
2182
|
+
it("keeps delivering when a listener throws", async () => {
|
|
2183
|
+
const seen = [];
|
|
2184
|
+
const stopThrowing = feed.subscribe(() => {
|
|
2185
|
+
throw new Error("listener failed");
|
|
2186
|
+
});
|
|
2187
|
+
const stop = feed.subscribe((event) => {
|
|
2188
|
+
seen.push(event);
|
|
2189
|
+
});
|
|
2190
|
+
const event = {
|
|
2191
|
+
principal: "u-throw",
|
|
2192
|
+
kind: "changed"
|
|
2193
|
+
};
|
|
2194
|
+
await feed.revoke(event);
|
|
2195
|
+
await vi.waitFor(() => {
|
|
2196
|
+
expect(seen).toEqual([event]);
|
|
2197
|
+
});
|
|
2198
|
+
stopThrowing();
|
|
2199
|
+
stop();
|
|
2200
|
+
});
|
|
2201
|
+
it("rejects an event without a principal or with an unknown kind", async () => {
|
|
2202
|
+
const seen = [];
|
|
2203
|
+
const stop = feed.subscribe((event) => {
|
|
2204
|
+
seen.push(event);
|
|
2205
|
+
});
|
|
2206
|
+
for (const event of [{
|
|
2207
|
+
principal: "",
|
|
2208
|
+
kind: "changed"
|
|
2209
|
+
}, {
|
|
2210
|
+
principal: "u-bad",
|
|
2211
|
+
kind: "granted"
|
|
2212
|
+
}]) await expect((async () => {
|
|
2213
|
+
await feed.revoke(event);
|
|
2214
|
+
})()).rejects.toThrow(TypeError);
|
|
2215
|
+
expect(seen).toEqual([]);
|
|
2216
|
+
stop();
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
function testDirectoryStore(store, options) {
|
|
2220
|
+
const [home, other] = options.tenants;
|
|
2221
|
+
it("round-trips users and groups, isolates tenants, and drops inactive memberships", async () => {
|
|
2222
|
+
const created = await store.putUser(home, {
|
|
2223
|
+
id: "",
|
|
2224
|
+
userName: "ada",
|
|
2225
|
+
externalId: "00u1",
|
|
2226
|
+
active: true,
|
|
2227
|
+
meta: {
|
|
2228
|
+
created: "",
|
|
2229
|
+
lastModified: ""
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
expect(created.id).not.toBe("");
|
|
2233
|
+
expect(await store.getUser(home, created.id)).toMatchObject({
|
|
2234
|
+
userName: "ada",
|
|
2235
|
+
externalId: "00u1"
|
|
2236
|
+
});
|
|
2237
|
+
expect(await store.getUser(other, created.id)).toBeNull();
|
|
2238
|
+
await expect(store.putUser(home, {
|
|
2239
|
+
id: "",
|
|
2240
|
+
userName: "ada",
|
|
2241
|
+
active: true,
|
|
2242
|
+
meta: {
|
|
2243
|
+
created: "",
|
|
2244
|
+
lastModified: ""
|
|
2245
|
+
}
|
|
2246
|
+
})).rejects.toThrow(/userName/);
|
|
2247
|
+
const group = await store.putGroup(home, {
|
|
2248
|
+
id: "g_editors",
|
|
2249
|
+
displayName: "Editors",
|
|
2250
|
+
members: [{ value: created.id }],
|
|
2251
|
+
roles: ["editor"],
|
|
2252
|
+
meta: {
|
|
2253
|
+
created: "",
|
|
2254
|
+
lastModified: ""
|
|
2255
|
+
}
|
|
2256
|
+
});
|
|
2257
|
+
expect(await store.groupsFor(home, created.id)).toEqual([expect.objectContaining({
|
|
2258
|
+
id: group.id,
|
|
2259
|
+
displayName: "Editors"
|
|
2260
|
+
})]);
|
|
2261
|
+
expect(await store.groupsFor(other, created.id)).toEqual([]);
|
|
2262
|
+
const patched = await store.patchUser(home, created.id, [{
|
|
2263
|
+
op: "replace",
|
|
2264
|
+
path: "active",
|
|
2265
|
+
value: false
|
|
2266
|
+
}]);
|
|
2267
|
+
expect(patched.active).toBe(false);
|
|
2268
|
+
const source = directoryMembershipSource(store, { assignable: ["editor"] });
|
|
2269
|
+
expect(await source.membershipsFor({ id: "00u1" }, { tenant: home })).toEqual([]);
|
|
2270
|
+
await store.patchUser(home, created.id, [{
|
|
2271
|
+
op: "replace",
|
|
2272
|
+
path: "active",
|
|
2273
|
+
value: true
|
|
2274
|
+
}]);
|
|
2275
|
+
expect(await source.membershipsFor({ id: "00u1" }, { tenant: home })).toEqual([{
|
|
2276
|
+
tenant: home,
|
|
2277
|
+
roles: ["editor"],
|
|
2278
|
+
via: `group:${group.id}`,
|
|
2279
|
+
managedBy: "idp"
|
|
2280
|
+
}]);
|
|
2281
|
+
await store.patchGroup(home, group.id, [{
|
|
2282
|
+
op: "remove",
|
|
2283
|
+
path: "members",
|
|
2284
|
+
value: [{ value: created.id }]
|
|
2285
|
+
}]);
|
|
2286
|
+
expect(await store.groupsFor(home, created.id)).toEqual([]);
|
|
2287
|
+
});
|
|
2288
|
+
it("ends sessions when scimHandler deactivates a user in this store", async () => {
|
|
2289
|
+
const feed = memoryRevocationFeed();
|
|
2290
|
+
const seen = [];
|
|
2291
|
+
feed.subscribe((event) => {
|
|
2292
|
+
seen.push(event);
|
|
2293
|
+
});
|
|
2294
|
+
const kinds = [];
|
|
2295
|
+
const token = "conformance-scim-token";
|
|
2296
|
+
const handle = scimHandler({
|
|
2297
|
+
store,
|
|
2298
|
+
tenant: home,
|
|
2299
|
+
token: {
|
|
2300
|
+
hash: "sha256",
|
|
2301
|
+
lookup: () => sha256Hex(token)
|
|
2302
|
+
},
|
|
2303
|
+
revocations: feed,
|
|
2304
|
+
onChange: (change) => {
|
|
2305
|
+
kinds.push(change.kind);
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
const call = (path, method, body) => handle(new Request(`https://app.example.com/scim/v2${path}`, {
|
|
2309
|
+
method,
|
|
2310
|
+
headers: {
|
|
2311
|
+
authorization: `Bearer ${token}`,
|
|
2312
|
+
"content-type": "application/scim+json"
|
|
2313
|
+
},
|
|
2314
|
+
body: JSON.stringify(body)
|
|
2315
|
+
}));
|
|
2316
|
+
const created = await call("/Users", "POST", {
|
|
2317
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
|
2318
|
+
userName: "grace",
|
|
2319
|
+
active: true
|
|
2320
|
+
});
|
|
2321
|
+
expect(created.status).toBe(201);
|
|
2322
|
+
const id = String((await created.json()).id);
|
|
2323
|
+
seen.length = 0;
|
|
2324
|
+
const patched = await call(`/Users/${id}`, "PATCH", {
|
|
2325
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
2326
|
+
Operations: [{
|
|
2327
|
+
op: "replace",
|
|
2328
|
+
path: "active",
|
|
2329
|
+
value: false
|
|
2330
|
+
}]
|
|
2331
|
+
});
|
|
2332
|
+
expect(patched.status).toBe(200);
|
|
2333
|
+
expect(seen).toContainEqual({
|
|
2334
|
+
principal: "grace",
|
|
2335
|
+
tenant: home,
|
|
2336
|
+
kind: "session-revoked"
|
|
2337
|
+
});
|
|
2338
|
+
expect(seen.every((event) => event.kind === "session-revoked")).toBe(true);
|
|
2339
|
+
expect(kinds).toEqual(["changed", "session-revoked"]);
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
function tenantApproval(token, tenant) {
|
|
2343
|
+
const base = sampleApproval(token);
|
|
2344
|
+
return {
|
|
2345
|
+
...base,
|
|
2346
|
+
subject: {
|
|
2347
|
+
...base.subject,
|
|
2348
|
+
principal: {
|
|
2349
|
+
id: "u_1",
|
|
2350
|
+
roles: ["member"],
|
|
2351
|
+
tenant
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
function tenantApprover(tenant) {
|
|
2357
|
+
return {
|
|
2358
|
+
principal: {
|
|
2359
|
+
id: "u_9",
|
|
2360
|
+
roles: [],
|
|
2361
|
+
memberships: [{
|
|
2362
|
+
tenant,
|
|
2363
|
+
roles: ["admin"]
|
|
2364
|
+
}]
|
|
2365
|
+
},
|
|
2366
|
+
context: {}
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
function testApprovalStore(store, options = {}) {
|
|
2370
|
+
it("keeps the existing record when the same call asks again", async () => {
|
|
2371
|
+
await store.create(sampleApproval("dup-token"));
|
|
2372
|
+
await store.resolve("dup-token", {
|
|
2373
|
+
status: "approved",
|
|
2374
|
+
by: approver
|
|
2375
|
+
});
|
|
2376
|
+
await store.create(sampleApproval("dup-token"));
|
|
2377
|
+
expect((await store.get("dup-token"))?.status).toBe("approved");
|
|
2378
|
+
});
|
|
2379
|
+
it("consumes an approved request exactly once", async () => {
|
|
2380
|
+
await store.create(sampleApproval("once-token"));
|
|
2381
|
+
expect(await store.consume("once-token")).toBeNull();
|
|
2382
|
+
await store.resolve("once-token", {
|
|
2383
|
+
status: "approved",
|
|
2384
|
+
by: approver
|
|
2385
|
+
});
|
|
2386
|
+
const [first, second] = await Promise.all([store.consume("once-token"), store.consume("once-token")]);
|
|
2387
|
+
expect([first, second].filter((item) => item !== null)).toHaveLength(1);
|
|
2388
|
+
expect((first ?? second)?.consumedAt).toEqual(expect.any(String));
|
|
2389
|
+
expect(await store.consume("once-token")).toBeNull();
|
|
2390
|
+
expect((await store.get("once-token"))?.consumedAt).toEqual(expect.any(String));
|
|
2391
|
+
});
|
|
2392
|
+
it("refuses an approver from another tenant", async () => {
|
|
2393
|
+
await store.create(tenantApproval("tenant-token", "o_1"));
|
|
2394
|
+
await expect(Promise.resolve().then(() => store.resolve("tenant-token", {
|
|
2395
|
+
status: "approved",
|
|
2396
|
+
by: tenantApprover("o_2")
|
|
2397
|
+
}))).rejects.toThrow(/tenant|eligible/u);
|
|
2398
|
+
expect((await store.get("tenant-token"))?.status).toBe("pending");
|
|
2399
|
+
});
|
|
2400
|
+
it.skipIf(options.reopen === void 0)("resumes an approval after a restart", async () => {
|
|
2401
|
+
await store.create(sampleApproval("restart-token"));
|
|
2402
|
+
await store.resolve("restart-token", {
|
|
2403
|
+
status: "approved",
|
|
2404
|
+
by: approver
|
|
2405
|
+
});
|
|
2406
|
+
const reopened = await options.reopen();
|
|
2407
|
+
expect((await reopened.get("restart-token"))?.status).toBe("approved");
|
|
2408
|
+
expect(await reopened.consume("restart-token")).not.toBeNull();
|
|
2409
|
+
expect(await store.consume("restart-token")).toBeNull();
|
|
2410
|
+
});
|
|
2411
|
+
it("creates, gets, lists, resolves and expires", async () => {
|
|
2412
|
+
const request = sampleApproval("opaque-token");
|
|
2413
|
+
await store.create(request);
|
|
2414
|
+
const loaded = await store.get("opaque-token");
|
|
2415
|
+
expect(loaded).toEqual(request);
|
|
2416
|
+
expect(JSON.parse(JSON.stringify(loaded))).toEqual(request);
|
|
2417
|
+
const listed = await store.list({ status: "pending" });
|
|
2418
|
+
expect(listed.items.some((item) => item.token === "opaque-token")).toBe(true);
|
|
2419
|
+
const resolved = await store.resolve("opaque-token", {
|
|
2420
|
+
status: "approved",
|
|
2421
|
+
by: approver
|
|
2422
|
+
});
|
|
2423
|
+
expect(resolved.status).toBe("approved");
|
|
2424
|
+
await expect(Promise.resolve().then(() => store.resolve("opaque-token", {
|
|
2425
|
+
status: "rejected",
|
|
2426
|
+
by: approver
|
|
2427
|
+
}))).rejects.toThrow(/not pending/);
|
|
2428
|
+
await store.create(sampleApproval("stale-token"));
|
|
2429
|
+
const expired = await store.expire(new Date(Date.now() + 72e5));
|
|
2430
|
+
expect(expired).toBeGreaterThanOrEqual(1);
|
|
2431
|
+
});
|
|
2432
|
+
it("pages list results with limit and an opaque cursor", async () => {
|
|
2433
|
+
const base = Date.parse("2026-01-01T00:00:00.000Z");
|
|
2434
|
+
await Promise.all([
|
|
2435
|
+
"page-a",
|
|
2436
|
+
"page-b",
|
|
2437
|
+
"page-c"
|
|
2438
|
+
].map(async (token, index) => store.create({
|
|
2439
|
+
...sampleApproval(token),
|
|
2440
|
+
subject: { principal: {
|
|
2441
|
+
id: "u_pager",
|
|
2442
|
+
roles: ["member"]
|
|
2443
|
+
} },
|
|
2444
|
+
createdAt: new Date(base + index * 1e3).toISOString()
|
|
2445
|
+
})));
|
|
2446
|
+
const first = await store.list({
|
|
2447
|
+
principalId: "u_pager",
|
|
2448
|
+
limit: 2
|
|
2449
|
+
});
|
|
2450
|
+
expect(first.items.map((item) => item.token)).toEqual(["page-a", "page-b"]);
|
|
2451
|
+
expect(typeof first.next).toBe("string");
|
|
2452
|
+
const second = await store.list({
|
|
2453
|
+
principalId: "u_pager",
|
|
2454
|
+
limit: 2,
|
|
2455
|
+
cursor: first.next
|
|
2456
|
+
});
|
|
2457
|
+
expect(second.items.map((item) => item.token)).toEqual(["page-c"]);
|
|
2458
|
+
expect(second.next).toBeUndefined();
|
|
2459
|
+
expect((await store.list({
|
|
2460
|
+
principalId: "u_pager",
|
|
2461
|
+
cursor: "not a cursor"
|
|
2462
|
+
})).items).toEqual([]);
|
|
2463
|
+
});
|
|
2464
|
+
it("refuses an approver who does not match request.approvers", async () => {
|
|
2465
|
+
const request = {
|
|
2466
|
+
...sampleApproval("gated-token"),
|
|
2467
|
+
approvers: { by: {
|
|
2468
|
+
kind: "role",
|
|
2469
|
+
role: "admin",
|
|
2470
|
+
scope: "global"
|
|
2471
|
+
} }
|
|
2472
|
+
};
|
|
2473
|
+
await store.create(request);
|
|
2474
|
+
const member = {
|
|
2475
|
+
principal: {
|
|
2476
|
+
id: "u_2",
|
|
2477
|
+
roles: ["member"]
|
|
2478
|
+
},
|
|
2479
|
+
context: {}
|
|
2480
|
+
};
|
|
2481
|
+
await expect(Promise.resolve().then(() => store.resolve("gated-token", {
|
|
2482
|
+
status: "approved",
|
|
2483
|
+
by: member
|
|
2484
|
+
}))).rejects.toThrow(/eligible|not pending|not found/);
|
|
2485
|
+
});
|
|
2486
|
+
it("refuses the principal as approver unless the grant sets distinct: false", async () => {
|
|
2487
|
+
const principal = {
|
|
2488
|
+
principal: {
|
|
2489
|
+
id: "u_1",
|
|
2490
|
+
roles: ["admin"]
|
|
2491
|
+
},
|
|
2492
|
+
context: {}
|
|
2493
|
+
};
|
|
2494
|
+
const shapes = [
|
|
2495
|
+
["self-human", void 0],
|
|
2496
|
+
["self-by", { by: admin }],
|
|
2497
|
+
["self-distinct", {
|
|
2498
|
+
by: admin,
|
|
2499
|
+
distinct: true
|
|
2500
|
+
}]
|
|
2501
|
+
];
|
|
2502
|
+
for (const [token, approvers] of shapes) {
|
|
2503
|
+
await store.create(approvers === void 0 ? sampleApproval(token) : {
|
|
2504
|
+
...sampleApproval(token),
|
|
2505
|
+
approvers
|
|
2506
|
+
});
|
|
2507
|
+
await expect(Promise.resolve().then(() => store.resolve(token, {
|
|
2508
|
+
status: "approved",
|
|
2509
|
+
by: principal
|
|
2510
|
+
}))).rejects.toThrow(/principal/u);
|
|
2511
|
+
expect((await store.get(token))?.status).toBe("pending");
|
|
2512
|
+
}
|
|
2513
|
+
await store.create({
|
|
2514
|
+
...sampleApproval("self-optout"),
|
|
2515
|
+
approvers: {
|
|
2516
|
+
by: admin,
|
|
2517
|
+
distinct: false
|
|
2518
|
+
}
|
|
2519
|
+
});
|
|
2520
|
+
const resolved = await store.resolve("self-optout", {
|
|
2521
|
+
status: "approved",
|
|
2522
|
+
by: principal
|
|
2523
|
+
});
|
|
2524
|
+
expect(resolved.resolvedBy).toBe("u_1");
|
|
2525
|
+
});
|
|
2526
|
+
it("stays pending until the quorum is met and counts an approver once", async () => {
|
|
2527
|
+
await store.create({
|
|
2528
|
+
...sampleApproval("quorum-token"),
|
|
2529
|
+
approvers: {
|
|
2530
|
+
by: admin,
|
|
2531
|
+
quorum: 2
|
|
2532
|
+
}
|
|
2533
|
+
});
|
|
2534
|
+
const first = await store.resolve("quorum-token", {
|
|
2535
|
+
status: "approved",
|
|
2536
|
+
by: approver
|
|
2537
|
+
});
|
|
2538
|
+
expect(first.status).toBe("pending");
|
|
2539
|
+
expect(first.approvals).toEqual([{
|
|
2540
|
+
by: "u_9",
|
|
2541
|
+
at: expect.any(String)
|
|
2542
|
+
}]);
|
|
2543
|
+
expect(await store.consume("quorum-token")).toBeNull();
|
|
2544
|
+
await expect(Promise.resolve().then(() => store.resolve("quorum-token", {
|
|
2545
|
+
status: "approved",
|
|
2546
|
+
by: approver
|
|
2547
|
+
}))).rejects.toThrow(/already approved/u);
|
|
2548
|
+
const second = await store.resolve("quorum-token", {
|
|
2549
|
+
status: "approved",
|
|
2550
|
+
by: secondApprover
|
|
2551
|
+
});
|
|
2552
|
+
expect(second.status).toBe("approved");
|
|
2553
|
+
expect(second.approvals?.map((item) => item.by)).toEqual(["u_9", "u_10"]);
|
|
2554
|
+
expect(second.resolvedBy).toBe("u_10");
|
|
2555
|
+
expect(await store.consume("quorum-token")).not.toBeNull();
|
|
2556
|
+
});
|
|
2557
|
+
it("resolves a vouched verdict at once, records its rule, and still refuses the requester", async () => {
|
|
2558
|
+
await store.create({
|
|
2559
|
+
...sampleApproval("vouched-token"),
|
|
2560
|
+
approvers: {
|
|
2561
|
+
by: admin,
|
|
2562
|
+
quorum: 2
|
|
2563
|
+
}
|
|
2564
|
+
});
|
|
2565
|
+
await expect(Promise.resolve().then(() => store.resolve("vouched-token", {
|
|
2566
|
+
status: "approved",
|
|
2567
|
+
by: {
|
|
2568
|
+
principal: {
|
|
2569
|
+
id: "u_1",
|
|
2570
|
+
roles: []
|
|
2571
|
+
},
|
|
2572
|
+
context: {}
|
|
2573
|
+
},
|
|
2574
|
+
vouched: "app-rule"
|
|
2575
|
+
}))).rejects.toThrow(/principal/u);
|
|
2576
|
+
const resolved = await store.resolve("vouched-token", {
|
|
2577
|
+
status: "approved",
|
|
2578
|
+
by: {
|
|
2579
|
+
principal: {
|
|
2580
|
+
id: "u_outsider",
|
|
2581
|
+
roles: []
|
|
2582
|
+
},
|
|
2583
|
+
context: {}
|
|
2584
|
+
},
|
|
2585
|
+
vouched: "app-rule"
|
|
2586
|
+
});
|
|
2587
|
+
expect(resolved.status).toBe("approved");
|
|
2588
|
+
expect(resolved.vouched).toBe("app-rule");
|
|
2589
|
+
expect(resolved.approvals).toEqual([{
|
|
2590
|
+
by: "u_outsider",
|
|
2591
|
+
at: expect.any(String),
|
|
2592
|
+
vouched: "app-rule"
|
|
2593
|
+
}]);
|
|
2594
|
+
});
|
|
2595
|
+
it("lets one rejection end a quorum request", async () => {
|
|
2596
|
+
await store.create({
|
|
2597
|
+
...sampleApproval("veto-token"),
|
|
2598
|
+
approvers: {
|
|
2599
|
+
by: admin,
|
|
2600
|
+
quorum: 2
|
|
2601
|
+
}
|
|
2602
|
+
});
|
|
2603
|
+
const rejected = await store.resolve("veto-token", {
|
|
2604
|
+
status: "rejected",
|
|
2605
|
+
by: approver
|
|
2606
|
+
});
|
|
2607
|
+
expect(rejected.status).toBe("rejected");
|
|
2608
|
+
expect(rejected.approvals).toBeUndefined();
|
|
2609
|
+
});
|
|
2610
|
+
it("admits the escalation grantee only once the request has waited", async () => {
|
|
2611
|
+
const auditor = {
|
|
2612
|
+
principal: {
|
|
2613
|
+
id: "u_11",
|
|
2614
|
+
roles: ["auditor"]
|
|
2615
|
+
},
|
|
2616
|
+
context: {}
|
|
2617
|
+
};
|
|
2618
|
+
const escalation = {
|
|
2619
|
+
after: "1h",
|
|
2620
|
+
to: {
|
|
2621
|
+
kind: "role",
|
|
2622
|
+
role: "auditor",
|
|
2623
|
+
scope: "global"
|
|
2624
|
+
}
|
|
2625
|
+
};
|
|
2626
|
+
await store.create({
|
|
2627
|
+
...sampleApproval("fresh-escalation"),
|
|
2628
|
+
approvers: {
|
|
2629
|
+
by: admin,
|
|
2630
|
+
escalation
|
|
2631
|
+
}
|
|
2632
|
+
});
|
|
2633
|
+
await expect(Promise.resolve().then(() => store.resolve("fresh-escalation", {
|
|
2634
|
+
status: "approved",
|
|
2635
|
+
by: auditor
|
|
2636
|
+
}))).rejects.toThrow(/eligible/u);
|
|
2637
|
+
await store.create({
|
|
2638
|
+
...sampleApproval("waited-escalation"),
|
|
2639
|
+
createdAt: (/* @__PURE__ */ new Date(Date.now() - 72e5)).toISOString(),
|
|
2640
|
+
approvers: {
|
|
2641
|
+
by: admin,
|
|
2642
|
+
escalation
|
|
2643
|
+
}
|
|
2644
|
+
});
|
|
2645
|
+
const resolved = await store.resolve("waited-escalation", {
|
|
2646
|
+
status: "approved",
|
|
2647
|
+
by: auditor
|
|
2648
|
+
});
|
|
2649
|
+
expect(resolved.status).toBe("approved");
|
|
2650
|
+
expect(resolved.resolvedBy).toBe("u_11");
|
|
2651
|
+
});
|
|
2652
|
+
it("completes sequential stages in order and records each stage", async () => {
|
|
2653
|
+
await store.create({
|
|
2654
|
+
...sampleApproval("stages-token"),
|
|
2655
|
+
approvers: {
|
|
2656
|
+
mode: "sequential",
|
|
2657
|
+
stages: [{ by: {
|
|
2658
|
+
kind: "user",
|
|
2659
|
+
id: "u_10"
|
|
2660
|
+
} }, { by: admin }]
|
|
2661
|
+
}
|
|
2662
|
+
});
|
|
2663
|
+
await expect(Promise.resolve().then(() => store.resolve("stages-token", {
|
|
2664
|
+
status: "approved",
|
|
2665
|
+
by: approver
|
|
2666
|
+
}))).rejects.toThrow(/eligible/u);
|
|
2667
|
+
const first = await store.resolve("stages-token", {
|
|
2668
|
+
status: "approved",
|
|
2669
|
+
by: secondApprover
|
|
2670
|
+
});
|
|
2671
|
+
expect(first.status).toBe("pending");
|
|
2672
|
+
expect(first.approvals).toEqual([{
|
|
2673
|
+
by: "u_10",
|
|
2674
|
+
at: expect.any(String),
|
|
2675
|
+
stage: 0
|
|
2676
|
+
}]);
|
|
2677
|
+
const second = await store.resolve("stages-token", {
|
|
2678
|
+
status: "approved",
|
|
2679
|
+
by: approver
|
|
2680
|
+
});
|
|
2681
|
+
expect(second.status).toBe("approved");
|
|
2682
|
+
expect(second.approvals?.map((item) => item.stage)).toEqual([0, 1]);
|
|
2683
|
+
});
|
|
2684
|
+
it("matches a relation approver only through the verdict's relations", async () => {
|
|
2685
|
+
const manager = {
|
|
2686
|
+
kind: "relation",
|
|
2687
|
+
resource: "post",
|
|
2688
|
+
relation: "manager"
|
|
2689
|
+
};
|
|
2690
|
+
await store.create({
|
|
2691
|
+
...sampleApproval("relation-token"),
|
|
2692
|
+
approvers: { by: manager }
|
|
2693
|
+
});
|
|
2694
|
+
await expect(Promise.resolve().then(() => store.resolve("relation-token", {
|
|
2695
|
+
status: "approved",
|
|
2696
|
+
by: approver
|
|
2697
|
+
}))).rejects.toThrow(/eligible/u);
|
|
2698
|
+
const resolved = await store.resolve("relation-token", {
|
|
2699
|
+
status: "approved",
|
|
2700
|
+
by: approver,
|
|
2701
|
+
relations: [JSON.stringify([
|
|
2702
|
+
"post",
|
|
2703
|
+
"manager",
|
|
2704
|
+
null,
|
|
2705
|
+
null
|
|
2706
|
+
])]
|
|
2707
|
+
});
|
|
2708
|
+
expect(resolved.status).toBe("approved");
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
function decodeHeader(token) {
|
|
2712
|
+
const [encoded] = token.split(".");
|
|
2713
|
+
if (encoded === void 0) return {};
|
|
2714
|
+
const padded = encoded.replaceAll("-", "+").replaceAll("_", "/");
|
|
2715
|
+
const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - padded.length % 4);
|
|
2716
|
+
return JSON.parse(atob(`${padded}${pad}`));
|
|
2717
|
+
}
|
|
2718
|
+
function testTokenVerifier(verifier, options) {
|
|
2719
|
+
const audience = options?.audience ?? "https://api.example.com";
|
|
2720
|
+
const issuer = options?.issuer ?? "https://login.example.com";
|
|
2721
|
+
it("never throws and maps the JWT behaviour table", async () => {
|
|
2722
|
+
const valid = await verifier.verify(jwtFixtureTokens.valid, {
|
|
2723
|
+
audience,
|
|
2724
|
+
issuer
|
|
2725
|
+
});
|
|
2726
|
+
expect(valid.ok).toBe(true);
|
|
2727
|
+
if (valid.ok) {
|
|
2728
|
+
expect(valid.claims.sub).toBe("u_1");
|
|
2729
|
+
expect(valid.header.alg).toBe("Ed25519");
|
|
2730
|
+
}
|
|
2731
|
+
const rows = [
|
|
2732
|
+
{
|
|
2733
|
+
token: jwtFixtureTokens.none,
|
|
2734
|
+
cause: "alg-none"
|
|
2735
|
+
},
|
|
2736
|
+
{
|
|
2737
|
+
token: jwtFixtureTokens.expired,
|
|
2738
|
+
cause: "expired"
|
|
2739
|
+
},
|
|
2740
|
+
{
|
|
2741
|
+
token: jwtFixtureTokens.wrongAud,
|
|
2742
|
+
cause: "wrong-audience"
|
|
2743
|
+
},
|
|
2744
|
+
{
|
|
2745
|
+
token: jwtFixtureTokens.wrongIss,
|
|
2746
|
+
cause: "wrong-issuer"
|
|
2747
|
+
},
|
|
2748
|
+
{
|
|
2749
|
+
token: jwtFixtureTokens.unknownKid,
|
|
2750
|
+
cause: "unknown-kid"
|
|
2751
|
+
},
|
|
2752
|
+
{
|
|
2753
|
+
token: "not-a-jwt",
|
|
2754
|
+
cause: "malformed"
|
|
2755
|
+
},
|
|
2756
|
+
{
|
|
2757
|
+
token: "a.b.c.d.e",
|
|
2758
|
+
cause: "encrypted-token"
|
|
2759
|
+
}
|
|
2760
|
+
];
|
|
2761
|
+
for (const row of rows) {
|
|
2762
|
+
let result;
|
|
2763
|
+
try {
|
|
2764
|
+
result = await verifier.verify(row.token, {
|
|
2765
|
+
audience,
|
|
2766
|
+
issuer
|
|
2767
|
+
});
|
|
2768
|
+
} catch {
|
|
2769
|
+
throw new Error("TokenVerifier must not throw");
|
|
2770
|
+
}
|
|
2771
|
+
expect(result.ok).toBe(false);
|
|
2772
|
+
if (!result.ok) expect(result.cause).toBe(row.cause);
|
|
2773
|
+
}
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
/** Settings for `tenant`, or `undefined`; a thrown source fails the runner. */
|
|
2777
|
+
function testSettingsSource(source, options) {
|
|
2778
|
+
it("answers per tenant with plain settings and nothing for an unknown tenant", async () => {
|
|
2779
|
+
const settings = await source.settingsFor(options.tenant);
|
|
2780
|
+
if (settings !== void 0) {
|
|
2781
|
+
expect(typeof settings).toBe("object");
|
|
2782
|
+
const credentials = settings.credentials;
|
|
2783
|
+
if (credentials !== void 0) {
|
|
2784
|
+
expect(credentials.maxTtl === void 0 || Number.isFinite(credentials.maxTtl) && credentials.maxTtl >= 0).toBe(true);
|
|
2785
|
+
expect(credentials.kinds === void 0 || credentials.kinds.every((kind) => kind === "user" || kind === "service")).toBe(true);
|
|
2786
|
+
}
|
|
2787
|
+
expect(JSON.parse(JSON.stringify(settings))).toEqual(settings);
|
|
2788
|
+
}
|
|
2789
|
+
expect(await source.settingsFor(options.unknown ?? "__permdock_unknown__")).toBeUndefined();
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* Checks an `ApprovalPolicySource`: entries are plain JSON, belong to the
|
|
2794
|
+
* queried tenant or to none, and every entry loads against `policy` (an
|
|
2795
|
+
* entry that does not would deny every call).
|
|
2796
|
+
*/
|
|
2797
|
+
function testApprovalPolicySource(source, options) {
|
|
2798
|
+
it("answers per tenant with plain entries that load against the policy", async () => {
|
|
2799
|
+
const entries = await source.approvalPoliciesFor({ tenants: [options.tenant] });
|
|
2800
|
+
expect(Array.isArray(entries)).toBe(true);
|
|
2801
|
+
for (const entry of entries) {
|
|
2802
|
+
expect(JSON.parse(JSON.stringify(entry))).toEqual(entry);
|
|
2803
|
+
expect([void 0, options.tenant]).toContain(entry.tenant);
|
|
2804
|
+
}
|
|
2805
|
+
const loaded = await approvalPoliciesFor(options.policy, source, [options.tenant], []);
|
|
2806
|
+
expect(loaded).not.toBe("failed");
|
|
2807
|
+
const other = await source.approvalPoliciesFor({ tenants: [options.unknown ?? "__permdock_unknown__"] });
|
|
2808
|
+
expect(other.filter((entry) => entry.tenant !== void 0)).toEqual([]);
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
function flipLast(value) {
|
|
2812
|
+
const last = value.at(-1);
|
|
2813
|
+
return `${value.slice(0, -1)}${last === "A" ? "B" : "A"}`;
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* `key` is a live key the verifier knows, or a function issuing one (called
|
|
2817
|
+
* once). `revoke`, when given, revokes it; the runner then expects the key
|
|
2818
|
+
* to stop verifying.
|
|
2819
|
+
*/
|
|
2820
|
+
function testCredentialVerifier(verifier, options) {
|
|
2821
|
+
let issued;
|
|
2822
|
+
const live = () => {
|
|
2823
|
+
const source = options.key;
|
|
2824
|
+
issued ??= Promise.resolve(typeof source === "string" ? source : source());
|
|
2825
|
+
return issued;
|
|
2826
|
+
};
|
|
2827
|
+
it("verifies the live key to a v1 credential with the key id", async () => {
|
|
2828
|
+
const key = await live();
|
|
2829
|
+
const parts = parseApiKey(key);
|
|
2830
|
+
expect(parts).toBeDefined();
|
|
2831
|
+
const credential = parseCredential(await verifier.verify(key));
|
|
2832
|
+
expect(credential).toBeDefined();
|
|
2833
|
+
expect(credential?.id).toBe(parts?.id);
|
|
2834
|
+
expect(parseCredential(await verifier.verify(key))).toEqual(credential);
|
|
2835
|
+
});
|
|
2836
|
+
it("never throws and answers null for every other key", async () => {
|
|
2837
|
+
const key = await live();
|
|
2838
|
+
const parts = parseApiKey(key);
|
|
2839
|
+
const others = [
|
|
2840
|
+
"",
|
|
2841
|
+
"garbage",
|
|
2842
|
+
"pdk_",
|
|
2843
|
+
`${key}x`,
|
|
2844
|
+
flipLast(key),
|
|
2845
|
+
`pdk_${parts?.id ?? "x"}-other_${parts?.secret ?? ""}`,
|
|
2846
|
+
key.replace(/^pdk_/u, "sk_")
|
|
2847
|
+
];
|
|
2848
|
+
for (const other of others) {
|
|
2849
|
+
let result;
|
|
2850
|
+
try {
|
|
2851
|
+
result = await verifier.verify(other);
|
|
2852
|
+
} catch {
|
|
2853
|
+
throw new Error("CredentialVerifier must not throw");
|
|
2854
|
+
}
|
|
2855
|
+
expect(result).toBeNull();
|
|
2856
|
+
}
|
|
2857
|
+
});
|
|
2858
|
+
if (verifier.touch !== void 0) it("accepts a touch without changing what the key verifies to", async () => {
|
|
2859
|
+
const key = await live();
|
|
2860
|
+
const before = parseCredential(await verifier.verify(key));
|
|
2861
|
+
const id = parseApiKey(key)?.id ?? "";
|
|
2862
|
+
await verifier.touch?.(id, Math.floor(Date.now() / 1e3));
|
|
2863
|
+
await verifier.touch?.("unknown-id", Math.floor(Date.now() / 1e3));
|
|
2864
|
+
expect(parseCredential(await verifier.verify(key))).toEqual(before);
|
|
2865
|
+
});
|
|
2866
|
+
if (options.revoke !== void 0) {
|
|
2867
|
+
const revoke = options.revoke;
|
|
2868
|
+
it("stops verifying a revoked key", async () => {
|
|
2869
|
+
const key = await live();
|
|
2870
|
+
await revoke();
|
|
2871
|
+
expect(await verifier.verify(key)).toBeNull();
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
function testTokenSigner(signer, options) {
|
|
2876
|
+
it("emits compact JWS with only alg, kid and typ", async () => {
|
|
2877
|
+
const token = await signer.sign({
|
|
2878
|
+
snapshot: { v: 1 },
|
|
2879
|
+
sub: "u_1"
|
|
2880
|
+
}, {
|
|
2881
|
+
typ: "permdock-snapshot+jwt",
|
|
2882
|
+
audience: "https://app.example.com"
|
|
2883
|
+
});
|
|
2884
|
+
const header = decodeHeader(token);
|
|
2885
|
+
expect(Object.keys(header).toSorted()).toEqual([
|
|
2886
|
+
"alg",
|
|
2887
|
+
"kid",
|
|
2888
|
+
"typ"
|
|
2889
|
+
]);
|
|
2890
|
+
expect(header["typ"]).toBe("permdock-snapshot+jwt");
|
|
2891
|
+
expect(header["alg"]).not.toBe("none");
|
|
2892
|
+
const verified = await options.verifier.verify(token, {
|
|
2893
|
+
typ: "permdock-snapshot+jwt",
|
|
2894
|
+
audience: "https://app.example.com"
|
|
2895
|
+
});
|
|
2896
|
+
expect(verified.ok).toBe(true);
|
|
2897
|
+
if (signer.jwks !== void 0) {
|
|
2898
|
+
const jwks = await signer.jwks();
|
|
2899
|
+
expect(jwks.keys.length).toBeGreaterThan(0);
|
|
2900
|
+
expect(jwks.keys[0]).not.toHaveProperty("d");
|
|
2901
|
+
}
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
const WHERE_ROWS = [
|
|
2905
|
+
{
|
|
2906
|
+
id: "r1",
|
|
2907
|
+
authorId: "u1",
|
|
2908
|
+
score: 5,
|
|
2909
|
+
title: "Plan 100%",
|
|
2910
|
+
archived: false,
|
|
2911
|
+
note: null
|
|
2912
|
+
},
|
|
2913
|
+
{
|
|
2914
|
+
id: "r2",
|
|
2915
|
+
authorId: "u2",
|
|
2916
|
+
score: 10,
|
|
2917
|
+
title: "plan_b",
|
|
2918
|
+
archived: true,
|
|
2919
|
+
note: "kept"
|
|
2920
|
+
},
|
|
2921
|
+
{
|
|
2922
|
+
id: "r3",
|
|
2923
|
+
authorId: null,
|
|
2924
|
+
score: null,
|
|
2925
|
+
title: null,
|
|
2926
|
+
archived: null,
|
|
2927
|
+
note: null
|
|
2928
|
+
}
|
|
2929
|
+
];
|
|
2930
|
+
const WHERE_CASES = [
|
|
2931
|
+
["eq", {
|
|
2932
|
+
op: "eq",
|
|
2933
|
+
field: "authorId",
|
|
2934
|
+
value: "u1"
|
|
2935
|
+
}],
|
|
2936
|
+
["ne", {
|
|
2937
|
+
op: "ne",
|
|
2938
|
+
field: "authorId",
|
|
2939
|
+
value: "u1"
|
|
2940
|
+
}],
|
|
2941
|
+
["eq on a boolean", {
|
|
2942
|
+
op: "eq",
|
|
2943
|
+
field: "archived",
|
|
2944
|
+
value: false
|
|
2945
|
+
}],
|
|
2946
|
+
["gt", {
|
|
2947
|
+
op: "gt",
|
|
2948
|
+
field: "score",
|
|
2949
|
+
value: 5
|
|
2950
|
+
}],
|
|
2951
|
+
["gte", {
|
|
2952
|
+
op: "gte",
|
|
2953
|
+
field: "score",
|
|
2954
|
+
value: 5
|
|
2955
|
+
}],
|
|
2956
|
+
["lt", {
|
|
2957
|
+
op: "lt",
|
|
2958
|
+
field: "score",
|
|
2959
|
+
value: 10
|
|
2960
|
+
}],
|
|
2961
|
+
["lte", {
|
|
2962
|
+
op: "lte",
|
|
2963
|
+
field: "score",
|
|
2964
|
+
value: 10
|
|
2965
|
+
}],
|
|
2966
|
+
["contains a literal %", {
|
|
2967
|
+
op: "contains",
|
|
2968
|
+
field: "title",
|
|
2969
|
+
value: "100%"
|
|
2970
|
+
}],
|
|
2971
|
+
["contains a literal _", {
|
|
2972
|
+
op: "contains",
|
|
2973
|
+
field: "title",
|
|
2974
|
+
value: "n_b"
|
|
2975
|
+
}],
|
|
2976
|
+
["in", {
|
|
2977
|
+
op: "in",
|
|
2978
|
+
field: "authorId",
|
|
2979
|
+
value: ["u1", "u2"]
|
|
2980
|
+
}],
|
|
2981
|
+
["notIn", {
|
|
2982
|
+
op: "notIn",
|
|
2983
|
+
field: "authorId",
|
|
2984
|
+
value: ["u1"]
|
|
2985
|
+
}],
|
|
2986
|
+
["isNull", {
|
|
2987
|
+
op: "isNull",
|
|
2988
|
+
field: "note",
|
|
2989
|
+
value: true
|
|
2990
|
+
}],
|
|
2991
|
+
["is not null", {
|
|
2992
|
+
op: "isNull",
|
|
2993
|
+
field: "note",
|
|
2994
|
+
value: false
|
|
2995
|
+
}],
|
|
2996
|
+
["and", {
|
|
2997
|
+
op: "and",
|
|
2998
|
+
conditions: [{
|
|
2999
|
+
op: "eq",
|
|
3000
|
+
field: "authorId",
|
|
3001
|
+
value: "u1"
|
|
3002
|
+
}, {
|
|
3003
|
+
op: "lt",
|
|
3004
|
+
field: "score",
|
|
3005
|
+
value: 10
|
|
3006
|
+
}]
|
|
3007
|
+
}],
|
|
3008
|
+
["or", {
|
|
3009
|
+
op: "or",
|
|
3010
|
+
conditions: [{
|
|
3011
|
+
op: "eq",
|
|
3012
|
+
field: "authorId",
|
|
3013
|
+
value: "u1"
|
|
3014
|
+
}, {
|
|
3015
|
+
op: "eq",
|
|
3016
|
+
field: "archived",
|
|
3017
|
+
value: true
|
|
3018
|
+
}]
|
|
3019
|
+
}],
|
|
3020
|
+
["not over a NULL field", {
|
|
3021
|
+
op: "not",
|
|
3022
|
+
condition: {
|
|
3023
|
+
op: "eq",
|
|
3024
|
+
field: "authorId",
|
|
3025
|
+
value: "u1"
|
|
3026
|
+
}
|
|
3027
|
+
}],
|
|
3028
|
+
["not over and", {
|
|
3029
|
+
op: "not",
|
|
3030
|
+
condition: {
|
|
3031
|
+
op: "and",
|
|
3032
|
+
conditions: [{
|
|
3033
|
+
op: "eq",
|
|
3034
|
+
field: "authorId",
|
|
3035
|
+
value: "u2"
|
|
3036
|
+
}, {
|
|
3037
|
+
op: "gt",
|
|
3038
|
+
field: "score",
|
|
3039
|
+
value: 5
|
|
3040
|
+
}]
|
|
3041
|
+
}
|
|
3042
|
+
}],
|
|
3043
|
+
["and of nothing", {
|
|
3044
|
+
op: "and",
|
|
3045
|
+
conditions: []
|
|
3046
|
+
}],
|
|
3047
|
+
["not of the empty allow set", {
|
|
3048
|
+
op: "not",
|
|
3049
|
+
condition: {
|
|
3050
|
+
op: "or",
|
|
3051
|
+
conditions: []
|
|
3052
|
+
}
|
|
3053
|
+
}]
|
|
3054
|
+
];
|
|
3055
|
+
const WHERE_SUBJECT = {
|
|
3056
|
+
principal: null,
|
|
3057
|
+
context: {}
|
|
3058
|
+
};
|
|
3059
|
+
/**
|
|
3060
|
+
* `matches` answers whether the compiled output selects `row`, for example
|
|
3061
|
+
* by running it against a table holding only that row. With it, every case
|
|
3062
|
+
* must select the rows the in-memory evaluator keeps, including rows whose
|
|
3063
|
+
* fields are NULL.
|
|
3064
|
+
*/
|
|
3065
|
+
function testWhereCompiler(compiler, options) {
|
|
3066
|
+
const { matches } = options;
|
|
3067
|
+
it.skipIf(matches === void 0).each(WHERE_CASES)("selects the rows the evaluator keeps: %s", async (_name, condition) => {
|
|
3068
|
+
if (matches === void 0) return;
|
|
3069
|
+
const compiled = compiler(condition, options.target);
|
|
3070
|
+
const selected = [];
|
|
3071
|
+
for (const row of WHERE_ROWS) if (await matches(compiled, row)) selected.push(row["id"]);
|
|
3072
|
+
expect(selected).toEqual(WHERE_ROWS.filter((row) => evaluateCondition(condition, row, WHERE_SUBJECT)).map((row) => row["id"]));
|
|
3073
|
+
});
|
|
3074
|
+
it("fails closed on an empty allow set", () => {
|
|
3075
|
+
const compiled = compiler({
|
|
3076
|
+
op: "or",
|
|
3077
|
+
conditions: []
|
|
3078
|
+
}, options.target);
|
|
3079
|
+
const closed = options.isFailClosed === void 0 ? compiled === false || compiled === void 0 || compiled === null : options.isFailClosed(compiled);
|
|
3080
|
+
expect(closed).toBe(true);
|
|
3081
|
+
});
|
|
3082
|
+
it("compiles a sqlFunction through its twin", () => {
|
|
3083
|
+
const twin = {
|
|
3084
|
+
op: "eq",
|
|
3085
|
+
field: "authorId",
|
|
3086
|
+
value: "u1"
|
|
3087
|
+
};
|
|
3088
|
+
expect(() => compiler({
|
|
3089
|
+
op: "sqlFunction",
|
|
3090
|
+
name: "job_permitted",
|
|
3091
|
+
args: [{ field: "id" }],
|
|
3092
|
+
twin
|
|
3093
|
+
}, options.target)).not.toThrow();
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
//#endregion
|
|
3097
|
+
//#region src/testing/supabase-fixtures.ts
|
|
3098
|
+
const base = {
|
|
3099
|
+
sub: "6f1c2c1e-5d0a-4d9e-9a51-6b1f0e7c2a10",
|
|
3100
|
+
aud: "authenticated",
|
|
3101
|
+
role: "authenticated",
|
|
3102
|
+
iss: "https://project.supabase.co/auth/v1",
|
|
3103
|
+
iat: 179e7,
|
|
3104
|
+
exp: 1790003600,
|
|
3105
|
+
aal: "aal1",
|
|
3106
|
+
session_id: "b7a8f9c0-1111-4222-8333-944455556666",
|
|
3107
|
+
is_anonymous: false
|
|
3108
|
+
};
|
|
3109
|
+
const id = base.sub;
|
|
3110
|
+
const org = "0d8c5a2e-3f4b-4c6d-8e9f-a1b2c3d4e5f6";
|
|
3111
|
+
const project = "3b2a1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d";
|
|
3112
|
+
const customer = "c7d8e9f0-1a2b-4c3d-9e4f-5a6b7c8d9e0f";
|
|
3113
|
+
const supportAdmin = "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d";
|
|
3114
|
+
const supportSessionId = "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b";
|
|
3115
|
+
const ownerMembership = {
|
|
3116
|
+
scope: "organization",
|
|
3117
|
+
id: org,
|
|
3118
|
+
roles: ["owner"],
|
|
3119
|
+
via: "direct",
|
|
3120
|
+
expiresAt: 19e8,
|
|
3121
|
+
grantedBy: "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
|
|
3122
|
+
reason: "founding member",
|
|
3123
|
+
managedBy: "idp",
|
|
3124
|
+
entitlements: ["seat:pro"]
|
|
3125
|
+
};
|
|
3126
|
+
const projectMembership = {
|
|
3127
|
+
scope: "project",
|
|
3128
|
+
id: project,
|
|
3129
|
+
within: { organization: org },
|
|
3130
|
+
roles: ["editor"]
|
|
3131
|
+
};
|
|
3132
|
+
const shareMembership = {
|
|
3133
|
+
on: {
|
|
3134
|
+
resource: "document",
|
|
3135
|
+
id: "doc-1"
|
|
3136
|
+
},
|
|
3137
|
+
roles: ["viewer"],
|
|
3138
|
+
via: "share"
|
|
3139
|
+
};
|
|
3140
|
+
const contactMembership = {
|
|
3141
|
+
scope: "customer",
|
|
3142
|
+
id: customer,
|
|
3143
|
+
within: { organization: org },
|
|
3144
|
+
roles: ["contact"],
|
|
3145
|
+
via: "contact"
|
|
3146
|
+
};
|
|
3147
|
+
const supabaseClaimFixtures = {
|
|
3148
|
+
topLevelRole: {
|
|
3149
|
+
claims: {
|
|
3150
|
+
...base,
|
|
3151
|
+
user_role: "admin",
|
|
3152
|
+
app_metadata: { provider: "email" }
|
|
3153
|
+
},
|
|
3154
|
+
expect: {
|
|
3155
|
+
id,
|
|
3156
|
+
roles: ["admin"],
|
|
3157
|
+
memberships: []
|
|
3158
|
+
}
|
|
3159
|
+
},
|
|
3160
|
+
appMetadataRole: {
|
|
3161
|
+
claims: {
|
|
3162
|
+
...base,
|
|
3163
|
+
app_metadata: {
|
|
3164
|
+
provider: "email",
|
|
3165
|
+
user_role: "moderator"
|
|
3166
|
+
}
|
|
3167
|
+
},
|
|
3168
|
+
expect: {
|
|
3169
|
+
id,
|
|
3170
|
+
roles: ["moderator"],
|
|
3171
|
+
memberships: []
|
|
3172
|
+
}
|
|
3173
|
+
},
|
|
3174
|
+
nullTopLevelFallsBack: {
|
|
3175
|
+
claims: {
|
|
3176
|
+
...base,
|
|
3177
|
+
user_role: null,
|
|
3178
|
+
app_metadata: { user_role: "moderator" }
|
|
3179
|
+
},
|
|
3180
|
+
expect: {
|
|
3181
|
+
id,
|
|
3182
|
+
roles: ["moderator"],
|
|
3183
|
+
memberships: []
|
|
3184
|
+
}
|
|
3185
|
+
},
|
|
3186
|
+
arrayRoles: {
|
|
3187
|
+
claims: {
|
|
3188
|
+
...base,
|
|
3189
|
+
user_role: ["admin", "moderator"]
|
|
3190
|
+
},
|
|
3191
|
+
expect: {
|
|
3192
|
+
id,
|
|
3193
|
+
roles: ["admin", "moderator"],
|
|
3194
|
+
memberships: []
|
|
3195
|
+
}
|
|
3196
|
+
},
|
|
3197
|
+
missingRole: {
|
|
3198
|
+
claims: { ...base },
|
|
3199
|
+
expect: {
|
|
3200
|
+
id,
|
|
3201
|
+
roles: [],
|
|
3202
|
+
memberships: []
|
|
3203
|
+
}
|
|
3204
|
+
},
|
|
3205
|
+
nullRole: {
|
|
3206
|
+
claims: {
|
|
3207
|
+
...base,
|
|
3208
|
+
user_role: null,
|
|
3209
|
+
app_metadata: { user_role: null }
|
|
3210
|
+
},
|
|
3211
|
+
expect: {
|
|
3212
|
+
id,
|
|
3213
|
+
roles: [],
|
|
3214
|
+
memberships: []
|
|
3215
|
+
}
|
|
3216
|
+
},
|
|
3217
|
+
userMetadataIgnored: {
|
|
3218
|
+
claims: {
|
|
3219
|
+
...base,
|
|
3220
|
+
user_metadata: {
|
|
3221
|
+
user_role: "admin",
|
|
3222
|
+
memberships: [{
|
|
3223
|
+
tenant: "acme",
|
|
3224
|
+
roles: ["owner"]
|
|
3225
|
+
}]
|
|
3226
|
+
}
|
|
3227
|
+
},
|
|
3228
|
+
expect: {
|
|
3229
|
+
id,
|
|
3230
|
+
roles: [],
|
|
3231
|
+
memberships: []
|
|
3232
|
+
}
|
|
3233
|
+
},
|
|
3234
|
+
multiOrg: {
|
|
3235
|
+
claims: {
|
|
3236
|
+
...base,
|
|
3237
|
+
tenant_id: "acme",
|
|
3238
|
+
memberships: [{
|
|
3239
|
+
tenant: "acme",
|
|
3240
|
+
roles: ["admin"]
|
|
3241
|
+
}, {
|
|
3242
|
+
tenant: "globex",
|
|
3243
|
+
roles: ["viewer"]
|
|
3244
|
+
}]
|
|
3245
|
+
},
|
|
3246
|
+
expect: {
|
|
3247
|
+
id,
|
|
3248
|
+
roles: [],
|
|
3249
|
+
tenant: "acme",
|
|
3250
|
+
memberships: [{
|
|
3251
|
+
tenant: "acme",
|
|
3252
|
+
roles: ["admin"]
|
|
3253
|
+
}, {
|
|
3254
|
+
tenant: "globex",
|
|
3255
|
+
roles: ["viewer"]
|
|
3256
|
+
}]
|
|
3257
|
+
}
|
|
3258
|
+
},
|
|
3259
|
+
tenantPlans: {
|
|
3260
|
+
claims: {
|
|
3261
|
+
...base,
|
|
3262
|
+
user_role: null,
|
|
3263
|
+
tenant_id: "0d8c5a2e-3f4b-4c6d-8e9f-a1b2c3d4e5f6",
|
|
3264
|
+
memberships: [{
|
|
3265
|
+
scope: "tenant",
|
|
3266
|
+
id: "0d8c5a2e-3f4b-4c6d-8e9f-a1b2c3d4e5f6",
|
|
3267
|
+
roles: ["admin"]
|
|
3268
|
+
}, {
|
|
3269
|
+
scope: "tenant",
|
|
3270
|
+
id: "7e6d5c4b-3a29-4817-9605-f4e3d2c1b0a9",
|
|
3271
|
+
roles: ["viewer"]
|
|
3272
|
+
}],
|
|
3273
|
+
features: {
|
|
3274
|
+
"0d8c5a2e-3f4b-4c6d-8e9f-a1b2c3d4e5f6": ["pro"],
|
|
3275
|
+
"7e6d5c4b-3a29-4817-9605-f4e3d2c1b0a9": ["free"]
|
|
3276
|
+
},
|
|
3277
|
+
authz_ver: 3
|
|
3278
|
+
},
|
|
3279
|
+
options: { plans: "features" },
|
|
3280
|
+
expect: {
|
|
3281
|
+
id,
|
|
3282
|
+
roles: [],
|
|
3283
|
+
tenant: "0d8c5a2e-3f4b-4c6d-8e9f-a1b2c3d4e5f6",
|
|
3284
|
+
memberships: [{
|
|
3285
|
+
scope: "tenant",
|
|
3286
|
+
id: "0d8c5a2e-3f4b-4c6d-8e9f-a1b2c3d4e5f6",
|
|
3287
|
+
roles: ["admin"]
|
|
3288
|
+
}, {
|
|
3289
|
+
scope: "tenant",
|
|
3290
|
+
id: "7e6d5c4b-3a29-4817-9605-f4e3d2c1b0a9",
|
|
3291
|
+
roles: ["viewer"]
|
|
3292
|
+
}],
|
|
3293
|
+
plans: ["pro"]
|
|
3294
|
+
}
|
|
3295
|
+
},
|
|
3296
|
+
full: {
|
|
3297
|
+
claims: {
|
|
3298
|
+
...base,
|
|
3299
|
+
user_role: ["admin"],
|
|
3300
|
+
roles: ["admin"],
|
|
3301
|
+
tenant_id: org,
|
|
3302
|
+
memberships: [
|
|
3303
|
+
{
|
|
3304
|
+
...ownerMembership,
|
|
3305
|
+
grants: { analyst: ["post.read", "-post.delete"] }
|
|
3306
|
+
},
|
|
3307
|
+
projectMembership,
|
|
3308
|
+
shareMembership
|
|
3309
|
+
],
|
|
3310
|
+
memberships_truncated: true,
|
|
3311
|
+
attrs: {
|
|
3312
|
+
department: "finance",
|
|
3313
|
+
clearance: 2
|
|
3314
|
+
},
|
|
3315
|
+
authz_ver: 7,
|
|
3316
|
+
datetime_preferences: {
|
|
3317
|
+
timezone: "Europe/Amsterdam",
|
|
3318
|
+
week_start: "monday",
|
|
3319
|
+
date_format: "dd-MM-yyyy",
|
|
3320
|
+
time_format: "24h"
|
|
3321
|
+
},
|
|
3322
|
+
app_metadata: {
|
|
3323
|
+
provider: "email",
|
|
3324
|
+
providers: ["email"]
|
|
3325
|
+
}
|
|
3326
|
+
},
|
|
3327
|
+
expect: {
|
|
3328
|
+
id,
|
|
3329
|
+
roles: ["admin"],
|
|
3330
|
+
tenant: org,
|
|
3331
|
+
memberships: [
|
|
3332
|
+
ownerMembership,
|
|
3333
|
+
projectMembership,
|
|
3334
|
+
shareMembership
|
|
3335
|
+
]
|
|
3336
|
+
}
|
|
3337
|
+
},
|
|
3338
|
+
portalContact: {
|
|
3339
|
+
claims: {
|
|
3340
|
+
...base,
|
|
3341
|
+
user_role: null,
|
|
3342
|
+
tenant_id: org,
|
|
3343
|
+
memberships: [contactMembership]
|
|
3344
|
+
},
|
|
3345
|
+
expect: {
|
|
3346
|
+
id,
|
|
3347
|
+
roles: [],
|
|
3348
|
+
tenant: org,
|
|
3349
|
+
memberships: [contactMembership]
|
|
3350
|
+
}
|
|
3351
|
+
},
|
|
3352
|
+
oauthClient: {
|
|
3353
|
+
claims: {
|
|
3354
|
+
...base,
|
|
3355
|
+
client_id: "5f0e4d3c-2b1a-4098-8776-655443322110",
|
|
3356
|
+
scope: "openid email posts:read",
|
|
3357
|
+
user_role: "member"
|
|
3358
|
+
},
|
|
3359
|
+
expect: {
|
|
3360
|
+
id,
|
|
3361
|
+
roles: ["member"],
|
|
3362
|
+
memberships: [],
|
|
3363
|
+
actor: {
|
|
3364
|
+
id: "5f0e4d3c-2b1a-4098-8776-655443322110",
|
|
3365
|
+
kind: "oauth-client"
|
|
3366
|
+
},
|
|
3367
|
+
delegation: { scopes: ["posts:read"] }
|
|
3368
|
+
}
|
|
3369
|
+
},
|
|
3370
|
+
actChain: {
|
|
3371
|
+
claims: {
|
|
3372
|
+
...base,
|
|
3373
|
+
scope: "posts:read",
|
|
3374
|
+
act: {
|
|
3375
|
+
sub: "agent-runner",
|
|
3376
|
+
act: { sub: "mcp-client-42" }
|
|
3377
|
+
}
|
|
3378
|
+
},
|
|
3379
|
+
expect: {
|
|
3380
|
+
id,
|
|
3381
|
+
roles: [],
|
|
3382
|
+
memberships: [],
|
|
3383
|
+
actor: {
|
|
3384
|
+
id: "agent-runner",
|
|
3385
|
+
kind: "oauth-client"
|
|
3386
|
+
},
|
|
3387
|
+
delegation: {
|
|
3388
|
+
scopes: ["posts:read"],
|
|
3389
|
+
chain: {
|
|
3390
|
+
sub: "agent-runner",
|
|
3391
|
+
act: { sub: "mcp-client-42" }
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
},
|
|
3396
|
+
supportSession: {
|
|
3397
|
+
claims: {
|
|
3398
|
+
...base,
|
|
3399
|
+
user_role: "member",
|
|
3400
|
+
act: {
|
|
3401
|
+
kind: "support",
|
|
3402
|
+
sub: supportAdmin,
|
|
3403
|
+
reason: "ticket 42",
|
|
3404
|
+
session_id: supportSessionId,
|
|
3405
|
+
read_only: false
|
|
3406
|
+
}
|
|
3407
|
+
},
|
|
3408
|
+
expect: {
|
|
3409
|
+
id,
|
|
3410
|
+
roles: ["member"],
|
|
3411
|
+
memberships: [],
|
|
3412
|
+
actor: {
|
|
3413
|
+
id: supportAdmin,
|
|
3414
|
+
kind: "support",
|
|
3415
|
+
sessionId: supportSessionId,
|
|
3416
|
+
readOnly: false
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
},
|
|
3420
|
+
supportSessionReadOnly: {
|
|
3421
|
+
claims: {
|
|
3422
|
+
...base,
|
|
3423
|
+
user_role: "member",
|
|
3424
|
+
act: {
|
|
3425
|
+
kind: "support",
|
|
3426
|
+
sub: supportAdmin,
|
|
3427
|
+
reason: "ticket 42",
|
|
3428
|
+
session_id: supportSessionId,
|
|
3429
|
+
read_only: true
|
|
3430
|
+
}
|
|
3431
|
+
},
|
|
3432
|
+
expect: {
|
|
3433
|
+
id,
|
|
3434
|
+
roles: ["member"],
|
|
3435
|
+
memberships: [],
|
|
3436
|
+
actor: {
|
|
3437
|
+
id: supportAdmin,
|
|
3438
|
+
kind: "support",
|
|
3439
|
+
sessionId: supportSessionId,
|
|
3440
|
+
readOnly: true
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
},
|
|
3444
|
+
impersonation: {
|
|
3445
|
+
claims: {
|
|
3446
|
+
...base,
|
|
3447
|
+
user_role: "member",
|
|
3448
|
+
act: {
|
|
3449
|
+
kind: "impersonation",
|
|
3450
|
+
sub: supportAdmin,
|
|
3451
|
+
reason: "ticket 42"
|
|
3452
|
+
}
|
|
3453
|
+
},
|
|
3454
|
+
expect: {
|
|
3455
|
+
id,
|
|
3456
|
+
roles: ["member"],
|
|
3457
|
+
memberships: [],
|
|
3458
|
+
actor: {
|
|
3459
|
+
id: supportAdmin,
|
|
3460
|
+
kind: "impersonation"
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
},
|
|
3464
|
+
anonymousSignIn: {
|
|
3465
|
+
claims: {
|
|
3466
|
+
...base,
|
|
3467
|
+
is_anonymous: true
|
|
3468
|
+
},
|
|
3469
|
+
options: { anonymousSignIns: "deny" },
|
|
3470
|
+
expect: {
|
|
3471
|
+
id: null,
|
|
3472
|
+
roles: [],
|
|
3473
|
+
memberships: []
|
|
3474
|
+
}
|
|
3475
|
+
},
|
|
3476
|
+
anon: {
|
|
3477
|
+
claims: {
|
|
3478
|
+
...base,
|
|
3479
|
+
role: "anon",
|
|
3480
|
+
sub: ""
|
|
3481
|
+
},
|
|
3482
|
+
expect: {
|
|
3483
|
+
id: null,
|
|
3484
|
+
roles: [],
|
|
3485
|
+
memberships: []
|
|
3486
|
+
}
|
|
3487
|
+
},
|
|
3488
|
+
serviceRole: {
|
|
3489
|
+
claims: {
|
|
3490
|
+
...base,
|
|
3491
|
+
role: "service_role",
|
|
3492
|
+
user_role: "admin"
|
|
3493
|
+
},
|
|
3494
|
+
expect: {
|
|
3495
|
+
id: null,
|
|
3496
|
+
roles: [],
|
|
3497
|
+
memberships: []
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
};
|
|
3501
|
+
const supabaseClaimVectors = {
|
|
3502
|
+
feature: "auth.session.get_claims",
|
|
3503
|
+
cases: Object.entries(supabaseClaimFixtures).map(([name, fixture]) => ({
|
|
3504
|
+
name,
|
|
3505
|
+
input: {
|
|
3506
|
+
claims: fixture.claims,
|
|
3507
|
+
...fixture.options === void 0 ? {} : { options: fixture.options }
|
|
3508
|
+
},
|
|
3509
|
+
expected: fixture.expect
|
|
3510
|
+
}))
|
|
3511
|
+
};
|
|
3512
|
+
/**
|
|
3513
|
+
* The `permdock supabase inspect --json` manifest for a policy with one
|
|
3514
|
+
* `tenant` scope, the default `supabase.hook` and a `features` claim from
|
|
3515
|
+
* `public.feature_claims`. A package that reads the manifest tests its
|
|
3516
|
+
* parser against this value.
|
|
3517
|
+
*/
|
|
3518
|
+
const supabaseHookManifestFixture = {
|
|
3519
|
+
$schema: "https://permdock.com/schemas/supabase-manifest-v1.json",
|
|
3520
|
+
version: 1,
|
|
3521
|
+
hook: {
|
|
3522
|
+
schema: "permdock",
|
|
3523
|
+
function: "custom_access_token_hook",
|
|
3524
|
+
out: "supabase/permdock-hook.sql"
|
|
3525
|
+
},
|
|
3526
|
+
helpers: {
|
|
3527
|
+
schema: "permdock",
|
|
3528
|
+
functions: [
|
|
3529
|
+
"permdock_has",
|
|
3530
|
+
"permitted_tenant_ids",
|
|
3531
|
+
"member_tenant_ids",
|
|
3532
|
+
"member_tenant_ids_for"
|
|
3533
|
+
]
|
|
3534
|
+
},
|
|
3535
|
+
tenantClaim: "tenant_id",
|
|
3536
|
+
budget: {
|
|
3537
|
+
bytes: 1024,
|
|
3538
|
+
measure: "octet_length(memberships::text) + octet_length(attrs::text)"
|
|
3539
|
+
},
|
|
3540
|
+
claims: [
|
|
3541
|
+
{
|
|
3542
|
+
name: "user_role",
|
|
3543
|
+
source: "permdock",
|
|
3544
|
+
budget: false
|
|
3545
|
+
},
|
|
3546
|
+
{
|
|
3547
|
+
name: "roles",
|
|
3548
|
+
source: "permdock",
|
|
3549
|
+
budget: false
|
|
3550
|
+
},
|
|
3551
|
+
{
|
|
3552
|
+
name: "memberships",
|
|
3553
|
+
source: "permdock",
|
|
3554
|
+
budget: true
|
|
3555
|
+
},
|
|
3556
|
+
{
|
|
3557
|
+
name: "memberships_truncated",
|
|
3558
|
+
source: "permdock",
|
|
3559
|
+
budget: false
|
|
3560
|
+
},
|
|
3561
|
+
{
|
|
3562
|
+
name: "tenant_id",
|
|
3563
|
+
source: "permdock",
|
|
3564
|
+
budget: false
|
|
3565
|
+
},
|
|
3566
|
+
{
|
|
3567
|
+
name: "authz_ver",
|
|
3568
|
+
source: "permdock",
|
|
3569
|
+
budget: false
|
|
3570
|
+
},
|
|
3571
|
+
{
|
|
3572
|
+
name: "features",
|
|
3573
|
+
source: "public.feature_claims",
|
|
3574
|
+
budget: false
|
|
3575
|
+
}
|
|
3576
|
+
],
|
|
3577
|
+
authzVersion: true,
|
|
3578
|
+
authzVersionBump: {
|
|
3579
|
+
schema: "permdock",
|
|
3580
|
+
function: "permdock_bump_authz_version_for",
|
|
3581
|
+
args: "p_users uuid[]"
|
|
3582
|
+
},
|
|
3583
|
+
memberships: [{
|
|
3584
|
+
table: "public.memberships",
|
|
3585
|
+
user: { column: "user_id" },
|
|
3586
|
+
scope: { column: "scope" },
|
|
3587
|
+
id: { column: "scope_id" },
|
|
3588
|
+
role: { column: "role" },
|
|
3589
|
+
columns: [
|
|
3590
|
+
"user_id",
|
|
3591
|
+
"scope",
|
|
3592
|
+
"scope_id",
|
|
3593
|
+
"role"
|
|
3594
|
+
]
|
|
3595
|
+
}],
|
|
3596
|
+
rls: {
|
|
3597
|
+
schema: "permdock",
|
|
3598
|
+
mode: "jwt",
|
|
3599
|
+
tenantClaim: "tenant_id",
|
|
3600
|
+
scopes: [{
|
|
3601
|
+
name: "tenant",
|
|
3602
|
+
type: "uuid"
|
|
3603
|
+
}],
|
|
3604
|
+
helpers: [
|
|
3605
|
+
{
|
|
3606
|
+
name: "permdock_has",
|
|
3607
|
+
args: "p_grant text",
|
|
3608
|
+
returns: "boolean",
|
|
3609
|
+
execute: ["authenticated"]
|
|
3610
|
+
},
|
|
3611
|
+
{
|
|
3612
|
+
name: "permitted_tenant_ids",
|
|
3613
|
+
args: "p_grant text",
|
|
3614
|
+
returns: "setof uuid",
|
|
3615
|
+
execute: ["authenticated"]
|
|
3616
|
+
},
|
|
3617
|
+
{
|
|
3618
|
+
name: "member_tenant_ids",
|
|
3619
|
+
args: "",
|
|
3620
|
+
returns: "setof uuid",
|
|
3621
|
+
execute: ["authenticated"]
|
|
3622
|
+
},
|
|
3623
|
+
{
|
|
3624
|
+
name: "member_tenant_ids_for",
|
|
3625
|
+
args: "p_user uuid",
|
|
3626
|
+
returns: "setof uuid",
|
|
3627
|
+
execute: ["supabase_auth_admin"]
|
|
3628
|
+
},
|
|
3629
|
+
{
|
|
3630
|
+
name: "permdock_user_id",
|
|
3631
|
+
args: "",
|
|
3632
|
+
returns: "uuid",
|
|
3633
|
+
execute: ["authenticated"]
|
|
3634
|
+
}
|
|
3635
|
+
],
|
|
3636
|
+
memberships: [{
|
|
3637
|
+
table: "public.memberships",
|
|
3638
|
+
user: { column: "user_id" },
|
|
3639
|
+
scope: { column: "scope" },
|
|
3640
|
+
id: { column: "scope_id" },
|
|
3641
|
+
role: { column: "role" },
|
|
3642
|
+
columns: [
|
|
3643
|
+
"user_id",
|
|
3644
|
+
"scope",
|
|
3645
|
+
"scope_id",
|
|
3646
|
+
"role"
|
|
3647
|
+
]
|
|
3648
|
+
}],
|
|
3649
|
+
customRoles: false,
|
|
3650
|
+
roles: {
|
|
3651
|
+
table: "permdock.user_roles",
|
|
3652
|
+
user: { column: "user_id" },
|
|
3653
|
+
role: { column: "role" }
|
|
3654
|
+
}
|
|
3655
|
+
},
|
|
3656
|
+
decidingColumns: [
|
|
3657
|
+
"public.memberships.role",
|
|
3658
|
+
"public.memberships.scope",
|
|
3659
|
+
"public.memberships.scope_id",
|
|
3660
|
+
"public.memberships.user_id"
|
|
3661
|
+
],
|
|
3662
|
+
markers: {
|
|
3663
|
+
hook: "v1",
|
|
3664
|
+
grants: "v1"
|
|
3665
|
+
},
|
|
3666
|
+
requires: {
|
|
3667
|
+
matrix: "capability-matrix-v1.12.0",
|
|
3668
|
+
capabilities: ["auth.session.get_claims"]
|
|
3669
|
+
}
|
|
3670
|
+
};
|
|
3671
|
+
//#endregion
|
|
3672
|
+
export { AGENT_THROWING_ROW, AGENT_THROWING_USER, authzenTodoData, authzenTodoPermissions, authzenTodoPolicy, authzenTodoUsers, authzenTodoVectors, describePolicy, expectTypeOf, jwtFixtureAudience, jwtFixtureIssuer, jwtFixtureJwks, jwtFixtureTokens, ormParity, rlsParity, snapshotFixture, supabaseClaimFixtures, supabaseClaimVectors, supabaseHookManifestFixture, supabaseMembershipsBudget, testAgentAdapter, testApprovalPolicySource, testApprovalStore, testAuthZen, testClientParity, testClientStore, testCredentialVerifier, testDecisionSink, testDirectoryStore, testEntitlementSource, testHttpAdapter, testLimitStore, testMembershipSource, testPolicySource, testRelationSource, testReplayStore, testRevocationFeed, testRoleSource, testSettingsSource, testSnapshotSource, testSubjectResolver, testTokenSigner, testTokenVerifier, testWhereCompiler };
|