permdock 0.3.1 → 0.5.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 +37 -35
- package/dist/{openapi-schema--ARjyfQr.js → 2020-C9VN-x8c.js} +5 -132
- package/dist/a2a/index.d.ts +8 -6
- package/dist/a2a/index.js +21 -31
- package/dist/{adapter-context-mlWsVGGg.js → adapter-context-CRhqyzCk.js} +1 -1
- package/dist/adoption-scan-B4R5fnMU.js +107 -0
- package/dist/agent/index.d.ts +3 -0
- package/dist/agent/index.js +1 -0
- package/dist/ai-sdk/index.d.ts +18 -23
- package/dist/ai-sdk/index.js +38 -21
- package/dist/app-data-MuyzVqX8.js +60 -0
- package/dist/approval-header-CJACtHkv.js +23 -0
- package/dist/approval-prompt-Dg5LeAcT.js +62 -0
- package/dist/approval-prompt-Djo_PyZc.d.ts +57 -0
- package/dist/approvals/index.d.ts +5 -3
- package/dist/approvals/index.js +8 -5
- package/dist/approvals-DJ6Yd5z1.js +995 -0
- package/dist/{approvers-DT4AVyIg.js → approvers-CqPOLD-o.js} +1 -1
- package/dist/arazzo-B6lebmiQ.js +36 -0
- package/dist/{arazzo-BAgw9ap6.js → arazzo-BWhNF-k0.js} +24 -17
- package/dist/args-IIolxxKA.js +197 -0
- package/dist/astro/index.d.ts +43 -0
- package/dist/astro/index.js +27 -0
- package/dist/audience-Dg8XsRaO.js +55 -0
- package/dist/auth-DRLEizbL.js +178 -0
- package/dist/authzen/index.d.ts +26 -6
- package/dist/authzen/index.js +228 -75
- package/dist/automation-BMZPIVUM.d.ts +114 -0
- package/dist/automation-DVjOckjV.js +157 -0
- package/dist/better-auth/index.d.ts +8 -6
- package/dist/better-auth/index.js +11 -23
- package/dist/better-supabase/index.d.ts +438 -86
- package/dist/better-supabase/index.js +1027 -184
- package/dist/bind--9EFVYWF.d.ts +38 -0
- package/dist/{bind-CuzGytRf.js → bind-CZzhx-Vj.js} +22 -10
- package/dist/body-CH5of8Ne.js +10 -0
- package/dist/body-text-QcXok_SI.js +52 -0
- package/dist/build-instance-BMnAudHn.js +52 -0
- package/dist/{canonical-json-DemiK4Vp.js → canonical-json-BC8_nMTw.js} +6 -6
- package/dist/{capability-2kYTMWDv.d.ts → capability-BSslw1QV.d.ts} +44 -25
- package/dist/{capability-4qPdO9gk.js → capability-N1K9IpqS.js} +69 -42
- package/dist/casbin-Cr7RLtA3.js +30 -0
- package/dist/casbin-Dyu2gnUD.js +234 -0
- package/dist/casl-64gpT-8x.js +24 -0
- package/dist/casl-DjEDhTCP.js +299 -0
- package/dist/catalog/index.d.ts +74 -2
- package/dist/catalog/index.js +51 -2
- package/dist/catalog-BECMPrwc.js +100 -0
- package/dist/catalog-DjhMYYCq.js +1 -0
- package/dist/catalog-R_A__RsH.js +52 -0
- package/dist/{catalog-doc-DdGm82XT.js → catalog-doc-C9wjsfNY.js} +54 -25
- package/dist/catalog-fingerprint-CCP0-yQS.js +153 -0
- package/dist/chat/index.d.ts +2 -0
- package/dist/chat/index.js +224 -0
- package/dist/chat-sdk/index.d.ts +87 -0
- package/dist/chat-sdk/index.js +197 -0
- package/dist/claims-map-7oQ3ue4X.js +13 -0
- package/dist/claims-map-Ca-QX8C6.d.ts +32 -0
- package/dist/claude-agent/index.d.ts +7 -14
- package/dist/claude-agent/index.js +26 -30
- package/dist/clerk/index.d.ts +9 -8
- package/dist/clerk/index.js +15 -45
- package/dist/cli/bin.js +36 -17
- package/dist/cli/index.d.ts +20 -6
- package/dist/cli/index.js +164 -4
- package/dist/cloud/index.d.ts +102 -63
- package/dist/cloud/index.js +240 -347
- package/dist/cloud-CIoHNevm.js +87 -0
- package/dist/codemod-Bmj9dWMH.js +37 -0
- package/dist/codemod-Cxvvc70T.js +2084 -0
- package/dist/{collect-DSTE3ml9.js → collect-CrZJJnEF.js} +79 -17
- package/dist/collect-DloHbDVa.js +55 -0
- package/dist/collect-FlXJlhus.js +1 -0
- package/dist/command-L8EstFNm.js +6 -0
- package/dist/{compact-y6vfu2_f.js → compact-CAf557ZB.js} +11 -1
- package/dist/compile/index.d.ts +1 -1
- package/dist/compile/index.js +1 -2
- package/dist/{compile-Ba4blROR.js → compile-CMqMx_IN.js} +13 -9
- package/dist/{compile-BkRyBDBw.d.ts → compile-Ckm2HqBZ.d.ts} +5 -3
- package/dist/completion-BqhRBJD8.js +143 -0
- package/dist/config-B46rm9sc.js +26 -0
- package/dist/{config-BJiM-xAm.js → config-bs_kZeYq.js} +15 -10
- package/dist/{config-report-CdCgbRnV.js → config-report-DUgj62Ai.js} +17 -8
- package/dist/{connection-CU8MQxRi.js → connection-DjhIJSs2.js} +8 -6
- package/dist/constants-DcJABbLW.js +20 -0
- package/dist/{context-Bcy85rGi.js → context-bbFHR-0D.js} +16 -5
- package/dist/convex/index.d.ts +11 -10
- package/dist/convex/index.js +18 -18
- package/dist/create-0VZ7Pdiu.d.ts +107 -0
- package/dist/create-BDS7wc3y.d.ts +138 -0
- package/dist/create-BFfm17UA.d.ts +110 -0
- package/dist/create-BWRSXgOF.js +403 -0
- package/dist/create-C3b4B5eF.js +130 -0
- package/dist/create-DnrmLnLW.js +323 -0
- package/dist/{credential-8P81ff4u.js → credential-DM2um3ha.js} +153 -32
- package/dist/credentials-DPyHmYz5.js +74 -0
- package/dist/deciding-columns-Sq12mEA6.js +123 -0
- package/dist/delegation-DaXVdDhM.js +286 -0
- package/dist/delegation-input-FTxMK3ji.js +19 -0
- package/dist/delivery/index.d.ts +5 -0
- package/dist/delivery/index.js +5 -0
- package/dist/delivery-DCAgNyCn.js +499 -0
- package/dist/delivery-event-xX4obB8F.js +40 -0
- package/dist/denied-text-D8jDHgsl.d.ts +19 -0
- package/dist/{describe-DMYiCjJV.d.ts → describe-BZUsixiL.d.ts} +11 -2
- package/dist/{describe-B_gVZEVi.js → describe-BybMRY7A.js} +10 -5
- package/dist/device-BARQ8eYG.js +193 -0
- package/dist/diff-CxZo9pTq.js +37 -0
- package/dist/{diff-2VaeChzX.js → diff-DCePiN6M.js} +61 -55
- package/dist/doctor-CxiwSfqH.js +44 -0
- package/dist/doctor-DFjUxf2f.js +2361 -0
- package/dist/drizzle/index.d.ts +5 -3
- package/dist/drizzle/index.js +6 -14
- package/dist/elysia/index.d.ts +16 -13
- package/dist/elysia/index.js +14 -13
- package/dist/env-DJZrPjRi.js +8 -0
- package/dist/errors-BDUwg2qC.js +27 -0
- package/dist/{errors-Cz5zn0mW.js → errors-BKy0EfhN.js} +6 -10
- package/dist/errors-BWXBMaKK.d.ts +11 -0
- package/dist/{errors-DPdqI50v.d.ts → errors-C2PXIv4c.d.ts} +2 -2
- package/dist/errors-CWTY6uih.js +143 -0
- package/dist/evaluate-_R8QkWaf.js +1732 -0
- package/dist/evaluations-16UqqQ6W.d.ts +32 -0
- package/dist/{evaluations-tz6D9A2G.js → evaluations-DZ2CbV5k.js} +181 -32
- package/dist/eve/index.d.ts +12 -16
- package/dist/eve/index.js +23 -29
- package/dist/event-time-CsegxXxB.js +23 -0
- package/dist/expiry-D4_YKRKD.js +52 -0
- package/dist/express/index.d.ts +13 -10
- package/dist/express/index.js +14 -12
- package/dist/fastify/index.d.ts +26 -13
- package/dist/fastify/index.js +17 -36
- package/dist/fetch-adapter-g0ypLkID.js +120 -0
- package/dist/fetch-adapter-qKJBuRqy.d.ts +31 -0
- package/dist/fields-D9tPVUUS.js +203 -0
- package/dist/files-C5ZI27eV.js +124 -0
- package/dist/{files-DWUN9PzI.js → files-D_JIzUrP.js} +12 -3
- package/dist/{fixtures-ht28Cplr.js → fixtures-B7XrLeY7.js} +12 -12
- package/dist/flags-C3DktBDY.js +86 -0
- package/dist/{from-snapshot-CTLijvdm.js → from-snapshot-D2lZt4Dd.js} +78 -65
- package/dist/{generate-CH7ThJ14.js → generate-CNU1pIOo.js} +1 -3
- package/dist/grantee-Cn1HyUsI.js +254 -0
- package/dist/grantee-conditions-CcXf8hMM.js +271 -0
- package/dist/grantee-match-DHArqY82.js +142 -0
- package/dist/h3/index.d.ts +37 -0
- package/dist/h3/index.js +35 -0
- package/dist/headers-BIjfMaoC.js +8 -0
- package/dist/headers-CIbDTUTK.d.ts +7 -0
- package/dist/{helpers-H_KARDVC.js → helpers-DTcZOzhF.js} +47 -39
- package/dist/hono/index.d.ts +12 -9
- package/dist/hono/index.js +14 -13
- package/dist/{hosted-DJQw46hn.js → hosted-BZIAYvon.js} +64 -15
- package/dist/hosted-DBDX0M-P.d.ts +78 -0
- package/dist/{http-SO7qGttf.js → http-BBoKefeS.js} +35 -2
- package/dist/{http-DPuDaChb.d.ts → http-B_LphE5Y.d.ts} +1 -2
- package/dist/http-DXJ37RDA.js +46 -0
- package/dist/{http-BIhmLfCc.js → http-ujKm0JXE.js} +3 -26
- package/dist/{openapi-import-cmwetbzw.js → import-CNV7I2Dw.js} +30 -26
- package/dist/{rls-import-4lLW_pbb.js → import-DMmOG27O.js} +271 -128
- package/dist/index-BcHcPyIo.d.ts +110 -0
- package/dist/index-CaxrMD1A.d.ts +1 -0
- package/dist/index-Chrs5fQ-.d.ts +210 -0
- package/dist/index-CxGUswIC.d.ts +431 -0
- package/dist/index-D8EovfsP.d.ts +91 -0
- package/dist/index-DOVf7RJv.d.ts +900 -0
- package/dist/{index-C8z4RjQV.d.ts → index-SGdIQyzc.d.ts} +34 -16
- package/dist/index.d.ts +20 -14
- package/dist/index.js +33 -23
- package/dist/init-DeMYsnYJ.js +96 -0
- package/dist/init-GG6Jh8HD.js +95 -0
- package/dist/{instance-options-jARdwfPN.d.ts → instance-options-Omfd793B.d.ts} +2 -2
- package/dist/instance-snapshot-Cxq4ygFc.js +600 -0
- package/dist/instant-BluqDvyS.js +19 -0
- package/dist/internals-Dk8A8fX3.js +17 -0
- package/dist/{json-schema-BsAflUGW.js → json-schema-B-vAwlde.js} +46 -9
- package/dist/json-schema-CN_iLFH2.d.ts +40 -0
- package/dist/jwt/index.d.ts +18 -11
- package/dist/jwt/index.js +166 -145
- package/dist/{kernel-_RRHmnMY.js → kernel-5JatXyND.js} +120 -43
- package/dist/kysely/index.d.ts +5 -3
- package/dist/kysely/index.js +6 -14
- package/dist/{live-options-H2eG8dfJ.js → live-options-BaAFCIiu.js} +16 -3
- package/dist/load-BxEtwlBS.js +1 -0
- package/dist/{load-DhWEB9P1.js → load-DFS_ry0W.js} +31 -7
- package/dist/{load-jose-BL6LH4bL.js → load-jose-DZ4dnjri.js} +1 -4
- package/dist/login-Dfe5RlYk.js +68 -0
- package/dist/login-imhuwvn3.js +254 -0
- package/dist/manifest-C8IzW3Do.js +42 -0
- package/dist/manifest-CtKR3GXL.js +370 -0
- package/dist/manifest-json-CAGzls-C.js +9 -0
- package/dist/{manifest-uDS9y5de.d.ts → manifest-mx5ghSFM.d.ts} +9 -2
- package/dist/manifest-schema-C0kK_8V0.js +399 -0
- package/dist/{map-claims-Z4QZOiFF.js → map-claims-D3oRmTk0.js} +19 -25
- package/dist/{map-error-pgUWNNtG.js → map-error-TCAdw-eW.js} +5 -5
- package/dist/{markers-B0hYLvMD.js → markers-C6joDI3o.js} +8 -3
- package/dist/mcp/index.d.ts +36 -9
- package/dist/mcp/index.js +476 -448
- package/dist/{rls-migrate-CN4TlJTO.js → migrate-BShP7Vf4.js} +119 -20
- package/dist/nest/index.d.ts +12 -9
- package/dist/nest/index.js +20 -20
- package/dist/next/index.d.ts +40 -20
- package/dist/next/index.js +37 -39
- package/dist/next/plugin.d.ts +1 -1
- package/dist/next/plugin.js +2 -2
- package/dist/node/index.d.ts +10 -10
- package/dist/node/index.js +7 -9
- package/dist/ocsf-BFp-PZZA.js +126 -0
- package/dist/openai/index.d.ts +9 -16
- package/dist/openai/index.js +18 -25
- package/dist/openapi/index.d.ts +3 -3
- package/dist/openapi/index.js +1 -1
- package/dist/{openapi-BqRBvAng.js → openapi-B7HZ0f-r.js} +9 -12
- package/dist/{openapi-CqqEbHsL.js → openapi-DTaEmTSW.js} +23 -26
- package/dist/openapi-ahr8rHu3.js +138 -0
- package/dist/operation-findings-DgATDpvh.js +36 -0
- package/dist/operations-CxybZErY.d.ts +59 -0
- package/dist/orpc/index.d.ts +13 -10
- package/dist/orpc/index.js +23 -20
- package/dist/otel/index.d.ts +3 -2
- package/dist/otel/index.js +53 -27
- package/dist/output-Dyw8fyPF.js +88 -0
- package/dist/output-files-B3IZJksC.js +1 -0
- package/dist/output-files-DhRBBE4n.js +138 -0
- package/dist/{rls-fields-CZOMlwHl.js → ownership-BatFgAA4.js} +2393 -2349
- package/dist/{package-root-zGkxO3oN.js → package-root-Dk_ETdkp.js} +4 -3
- package/dist/{parse-BuyjxhZV.js → parse-Cxq0WxrA.js} +3 -3
- package/dist/{parse-local-manifest-BrMozJCo.js → parse-local-manifest-CVSs6J9I.js} +2 -4
- package/dist/{parse-local-manifest-JLnaBH0S.d.ts → parse-local-manifest-DgqtruPn.d.ts} +1 -1
- package/dist/parse-request-D_tUXG6M.js +26 -0
- package/dist/paths-DXSHAMIa.js +42 -0
- package/dist/pdp/index.d.ts +7 -3
- package/dist/pdp/index.js +47 -29
- package/dist/{peer-BOr_kJE1.js → peer-C8swJ2GH.js} +23 -4
- package/dist/peer-CRvC7Hg1.js +1 -0
- package/dist/permdock-B9JxkBwC.js +868 -0
- package/dist/permdock-D7eeLJER.d.ts +502 -0
- package/dist/permission-registry-Br4jSNVi.js +385 -0
- package/dist/permissions-B-wCP5ZC.js +30 -0
- package/dist/{permissions-RJrovw98.js → permissions-CZKiUfFM.js} +4 -3
- package/dist/{permissions-CDgvKG5L.d.ts → permissions-oEWfl-kl.d.ts} +1 -2
- package/dist/policies-CO1oOymN.js +295 -0
- package/dist/{policy-D2W7uv19.d.ts → policy-Czo4n3Hs.d.ts} +2 -2
- package/dist/policy-DLZJ5gAp.js +399 -0
- package/dist/policy-from-catalog-DzhEc3Y9.js +450 -0
- package/dist/policy-module-CTlwMvLI.js +89 -0
- package/dist/{policy-IB6yxpC3.d.ts → policy-types-DzwivBgR.d.ts} +939 -1057
- package/dist/postgrest-CW0IqC-U.js +566 -0
- package/dist/postgrest-NUqZT-RB.d.ts +100 -0
- package/dist/powersync-BHfuehf3.js +62 -0
- package/dist/powersync-CbzkqL_M.js +1 -0
- package/dist/{powersync-Dv1_aWF7.js → powersync-bYUDLOAO.js} +30 -32
- package/dist/preset/index.d.ts +146 -0
- package/dist/preset/index.js +1 -0
- package/dist/preset-DZdU0tne.js +1768 -0
- package/dist/principal-source-pHneukrx.js +103 -0
- package/dist/prisma/index.d.ts +6 -3
- package/dist/prisma/index.js +6 -12
- package/dist/{problem-B43ztKIm.js → problem-CTviyH5L.js} +18 -27
- package/dist/{problem-details-DX075l9V.d.ts → problem-details-DXlt-vks.d.ts} +1 -1
- package/dist/{problem-details-_AMOYz-w.js → problem-details-cn5X30gZ.js} +45 -42
- package/dist/problem-types-CJ2325SZ.js +5 -0
- package/dist/{prompt-CXYhLfsS.js → prompt-DfZLJLIn.js} +2 -2
- package/dist/protected-DEPHc2xV.d.ts +38 -0
- package/dist/{protected-utU-SLuy.js → protected-DfjY5mLA.js} +37 -8
- package/dist/{provider-CSQd7m92.js → provider-CC-u3bYP.js} +23 -10
- package/dist/{provider-DZY281jO.d.ts → provider-DMVt6yYn.d.ts} +1 -1
- package/dist/{provider-store-Cfbvo4DE.js → provider-store-B5Yrkhzr.js} +6 -3
- package/dist/pull-BgIfno6w.js +256 -0
- package/dist/pull-ByOks_Tg.js +1 -0
- package/dist/push-CmFVBOeI.js +116 -0
- package/dist/pushed-policy-BImMTTIM.d.ts +57 -0
- package/dist/pushed-policy-DobP7AHI.js +238 -0
- package/dist/react/index.d.ts +8 -8
- package/dist/react/index.js +6 -6
- package/dist/react/provider-client.d.ts +1 -1
- package/dist/react/provider-client.js +1 -2
- package/dist/react-native/index.d.ts +24 -63
- package/dist/react-native/index.js +45 -27
- package/dist/{refs-KYb4ntaz.js → refs-K6eWUJ0H.js} +1 -1
- package/dist/relation-walk-3OyDBF0S.js +186 -0
- package/dist/relations-BnCWnJGI.js +163 -0
- package/dist/renames-CGj7YwcQ.js +175 -0
- package/dist/{replay-CYyiLiwO.d.ts → replay-D7iKcLFe.d.ts} +1 -1
- package/dist/{replay-BsOyTrdV.js → replay-Du1tJjG7.js} +2 -1
- package/dist/request-context-BTRTG7OT.js +71 -0
- package/dist/request-context-C2_1lchh.d.ts +5 -0
- package/dist/request-schema-CMreVuFM.js +148 -0
- package/dist/resend/index.d.ts +126 -0
- package/dist/resend/index.js +379 -0
- package/dist/resource-relations-D_jw0BFx.js +243 -0
- package/dist/retire-CALjIQ7e.js +240 -0
- package/dist/{revocations-Cy06JNx2.d.ts → revocations-R07lFBnX.d.ts} +2 -0
- package/dist/rls-BA8an5Ap.js +336 -0
- package/dist/{rls-DiinlRML.js → rls-j6t43t7n.js} +1066 -1068
- package/dist/role-builder-CXbis5-X.js +623 -0
- package/dist/{rpc-DL9Sqvb1.js → rpc-aPyNi6tA.js} +18 -8
- package/dist/run-CNsApDpd.js +1 -0
- package/dist/run-DOfMtc9V.js +723 -0
- package/dist/run-ticket-BjhTOi6u.d.ts +124 -0
- package/dist/run-ticket-sxFI6LAk.js +337 -0
- package/dist/{runtime-CB8gQFvS.js → runtime-D3VTAiCA.js} +1 -1
- package/dist/{saas-DhpJjnBA.js → saas-FO-Q8oj0.js} +18 -15
- package/dist/scan-BOI5tJ-6.js +29 -0
- package/dist/{schema-D3MnDMxa.js → schema-B4v0Qrac.js} +106 -10
- package/dist/schema-yLuBWWsh.js +130 -0
- package/dist/scim/index.d.ts +2 -2
- package/dist/scim/index.js +1 -2
- package/dist/{scim-BFGBBAqR.js → scim-ClHtpyV3.js} +213 -87
- package/dist/{scopes-CVdPrWN-.js → scopes-CUPSjo9q.js} +20 -7
- package/dist/server/index.d.ts +13 -134
- package/dist/server/index.js +13 -10
- package/dist/server-DqCT4CgZ.js +453 -0
- package/dist/{create-B1d519XK.d.ts → server-types-BLd4MnBX.d.ts} +89 -70
- package/dist/{sha256-BO0swRXL.js → sha256-D9ihqmvd.js} +8 -1
- package/dist/shared-CD11nBbC.js +304 -0
- package/dist/{shared-DRu8VFVM.js → shared-DvvFzYXe.js} +8 -9
- package/dist/signals-D3NcTKUS.js +32 -0
- package/dist/sink-BEZSlMS_.js +957 -0
- package/dist/sink-Bj_lDUDq.d.ts +225 -0
- package/dist/{skills-Biq5uup3.js → skills-B6lVdD_W.js} +16 -8
- package/dist/skills-CTYwkl44.js +51 -0
- package/dist/snapshot-CXUWfsNy.js +269 -0
- package/dist/{snapshot-cache-D6jLelln.js → snapshot-cache-DZHQ0KF6.js} +11 -5
- package/dist/{snapshot-cache-B-LxUahj.d.ts → snapshot-cache-DqK9eDmM.d.ts} +7 -5
- package/dist/solid/index.d.ts +24 -25
- package/dist/solid/index.js +41 -11
- package/dist/solid-start/index.d.ts +52 -0
- package/dist/solid-start/index.js +49 -0
- package/dist/{sources-BdOYLw9j.d.ts → sources-BV9j0DeZ.d.ts} +11 -13
- package/dist/{sources-4p0-4xQm.js → sources-vs5dGOsS.js} +147 -142
- package/dist/sql-B5T1IL83.js +55 -0
- package/dist/src-DLfUWFAI.js +1851 -0
- package/dist/ssf/index.d.ts +4 -4
- package/dist/ssf/index.js +440 -106
- package/dist/standard-webhooks-6lg8v_is.d.ts +88 -0
- package/dist/standard-webhooks-DgFFD-Ga.js +228 -0
- package/dist/storage-C2bWdvJH.js +132 -0
- package/dist/{store-DMAWwAJV.js → store-4u_hxjWa.js} +42 -55
- package/dist/{store-options-ByhZq1jV.js → store-options-BqEakYRa.js} +38 -24
- package/dist/{stores-BT2pSCub.js → stores-BbsD4fAR.js} +7 -3
- package/dist/stores-ClZUgeoP.d.ts +30 -0
- package/dist/{stream-D1RXubw2.d.ts → stream-7-xmnf3U.d.ts} +2 -2
- package/dist/{stream-DDpvynSB.js → stream-CvdUS9V1.js} +10 -2
- package/dist/style-DiKWnAvn.js +1 -0
- package/dist/{style-CTm1AXDT.js → style-Uao76wO7.js} +3 -2
- package/dist/subject-BGoM5o8B.js +17 -0
- package/dist/{subject-settings-BwBmCdTR.js → subject-settings-CkEwrw3W.js} +23 -5
- package/dist/supabase/index.d.ts +23 -106
- package/dist/supabase/index.js +5 -4
- package/dist/supabase/middleware.d.ts +13 -6
- package/dist/supabase/middleware.js +16 -11
- package/dist/supabase-9pqI57pW.js +92 -0
- package/dist/{supabase-C0qESOa-.js → supabase-Dxlp8A94.js} +124 -247
- package/dist/supabase-hook-CXcGpHqq.js +1 -0
- package/dist/{supabase-hook-ntETYCQu.js → supabase-hook-DdDQhMlw.js} +1659 -819
- package/dist/{supautils-u8jXTGrt.js → supautils-B6HYL9eL.js} +2 -2
- package/dist/support-session-C6xJQYut.js +158 -0
- package/dist/svelte/Protected.svelte +11 -4
- package/dist/svelte/index.d.ts +7 -7
- package/dist/svelte/index.js +26 -12
- package/dist/svelte/runtime.d.ts +10 -3
- package/dist/svelte/runtime.js +4 -3
- package/dist/svelte/source.d.ts +7 -7
- package/dist/svelte/source.js +5 -5
- package/dist/sveltekit/index.d.ts +51 -0
- package/dist/sveltekit/index.js +27 -0
- package/dist/tanstack-start/index.d.ts +51 -0
- package/dist/tanstack-start/index.js +40 -0
- package/dist/target-Bw37TFNA.js +51 -0
- package/dist/{validity-uOp8UpYn.js → tenancy-DN2QAx9z.js} +6 -42
- package/dist/terminal/index.d.ts +11 -9
- package/dist/terminal/index.js +36 -316
- package/dist/testing/index.d.ts +391 -106
- package/dist/testing/index.js +4574 -1286
- package/dist/testing/saas/index.d.ts +9 -8
- package/dist/testing/saas/index.js +2 -2
- package/dist/testing/saas/permissions.d.ts +1 -1
- package/dist/testing/saas/permissions.js +1 -2
- package/dist/text-BMFRfBqc.js +8 -0
- package/dist/{text-diff-BdkkYSbi.js → text-diff-BQcUapOb.js} +1 -1
- package/dist/thenable-yD7SabqG.js +31 -0
- package/dist/time-DkWjdL98.js +71 -0
- package/dist/{token-CIWxJOCh.js → token-BLZcjQgt.js} +15 -6
- package/dist/tool-refusal-CMLN5xhn.js +41 -0
- package/dist/trpc/index.d.ts +12 -9
- package/dist/trpc/index.js +18 -17
- package/dist/{types-DbjRN55U.d.ts → types-5Ut_pbKy.d.ts} +141 -13
- package/dist/types-BNORmDM8.d.ts +172 -0
- package/dist/types-BjV-fqcA.d.ts +57 -0
- package/dist/{types-QCuYJh4q.d.ts → types-BqAKcRNW.d.ts} +2 -1
- package/dist/types-C8-KNsFf.d.ts +107 -0
- package/dist/{types-aqOHTHcp.d.ts → types-CCpc2Dcv.d.ts} +77 -17
- package/dist/{types-Dnemwd77.d.ts → types-CZTnPFzM.d.ts} +50 -11
- package/dist/{types-C97IskBw.d.ts → types-CdlxsYQh.d.ts} +23 -11
- package/dist/{types-DxiCGmmd.d.ts → types-ClZaWzfb.d.ts} +9 -6
- package/dist/types-DX0LaKTb.d.ts +157 -0
- package/dist/types-DiQjupG2.d.ts +332 -0
- package/dist/types-DqzsnlSA.js +67 -0
- package/dist/types-_sHy8BQf.d.ts +65 -0
- package/dist/types-fbZ7JEXr.d.ts +89 -0
- package/dist/types-tIgIeIKJ.d.ts +66 -0
- package/dist/unplugin/index.d.ts +1 -1
- package/dist/unplugin/index.js +2 -2
- package/dist/usage-CHOtwL19.js +1 -0
- package/dist/usage-DHtE8j4E.js +32 -0
- package/dist/{usage-ItS7nLcl.js → usage-T5mXq98v.js} +56 -13
- package/dist/{validation-error-Ba264OaJ.d.ts → validation-error-Bg5Tpluk.d.ts} +1 -1
- package/dist/{validation-error-CnjZ4t92.js → validation-error-ZfUHbKvf.js} +1 -1
- package/dist/validity-CZ7-y0Cj.js +79 -0
- package/dist/{verifier-BxBo6I44.js → verifier-DHuh-okk.js} +68 -21
- package/dist/verify-DbCL9bnB.js +1770 -0
- package/dist/{version-BvacVTZR.js → version-CEbQ5UeM.js} +4 -3
- package/dist/version-DXkYUISx.js +1 -0
- package/dist/{views-Soe7bGSC.js → views-DTIIO-oQ.js} +41 -2
- package/dist/{permissions-DttnqqxC.d.ts → vocabulary-D6lPdb4Z.d.ts} +120 -29
- package/dist/vue/index.d.ts +25 -21
- package/dist/vue/index.js +25 -9
- package/dist/waiter-BGUtGYoU.js +67 -0
- package/dist/waiter-CKy4MQSY.d.ts +36 -0
- package/dist/watch-Cb-NcaUw.js +1 -0
- package/dist/{watch-DVBJ9cBq.js → watch-CszoTJMq.js} +47 -8
- package/dist/webmcp/index.d.ts +2 -2
- package/dist/webmcp/index.js +13 -37
- package/dist/where-scope-Dv-WTP-w.js +306 -0
- package/dist/who-can-D0QoYbEb.js +364 -0
- package/dist/wire-delegation-Cv3W2eaM.js +36 -0
- package/dist/wire-denial-C7PaVYEM.js +27 -0
- package/dist/workflow/index.d.ts +5 -0
- package/dist/workflow/index.js +223 -0
- package/dist/workflow-sdk/index.d.ts +56 -0
- package/dist/workflow-sdk/index.js +78 -0
- package/dist/wrap-Bd1Tejur.d.ts +15 -0
- package/dist/wrap-gX61EtpB.js +27 -0
- package/package.json +140 -41
- package/schemas/access-event-v1.json +118 -0
- package/schemas/approval-request-v1.json +34 -1
- package/schemas/approval-signature-v1.json +94 -0
- package/schemas/automation-event-v1.json +113 -0
- package/schemas/capability-v1.json +84 -0
- package/schemas/catalog-event-v1.json +87 -0
- package/schemas/catalog-v1.json +129 -12
- package/schemas/cli-commands-v1.json +164 -0
- package/schemas/cli-result-v1.json +96 -0
- package/schemas/cloud-api-v1.json +5407 -0
- package/schemas/connector-v1.json +408 -0
- package/schemas/credential-event-v1.json +81 -0
- package/schemas/credential-v1.json +134 -0
- package/schemas/decision-event-v1.json +288 -0
- package/schemas/delivery-event-v1.json +117 -0
- package/schemas/directory-event-v1.json +85 -0
- package/schemas/evidence-export-v1.json +54 -0
- package/schemas/membership-event-v1.json +93 -0
- package/schemas/policy-v1.json +68 -0
- package/schemas/pushed-policy-v1.json +236 -0
- package/schemas/run-v1.json +162 -0
- package/schemas/snapshot-v1.json +138 -0
- package/schemas/supabase-claims-v1.json +4 -4
- package/schemas/supabase-manifest-v1.json +5 -1
- package/schemas/support-session-v1.json +111 -0
- package/skills/permdock/SKILL.md +16 -12
- package/skills/permdock-agents/SKILL.md +40 -23
- package/skills/permdock-agents/references/operations-automations-chat.md +221 -0
- package/skills/permdock-agents/references/runtimes.md +22 -18
- package/skills/permdock-approvals/SKILL.md +29 -13
- package/skills/permdock-approvals/references/delivery-and-waiters.md +172 -0
- package/skills/permdock-approvals/references/stores-and-resume.md +21 -7
- package/skills/permdock-audit/SKILL.md +3 -3
- package/skills/permdock-credentials/SKILL.md +5 -5
- package/skills/permdock-credentials/references/tokens-keys-links.md +19 -10
- package/skills/permdock-data/SKILL.md +6 -6
- package/skills/permdock-data/references/queries-and-rls.md +13 -8
- package/skills/permdock-data/references/relationships.md +3 -3
- package/skills/permdock-tenancy/SKILL.md +20 -7
- package/skills/permdock-tenancy/references/contacts-and-support-sessions.md +122 -0
- package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +6 -4
- package/skills/permdock-wire/SKILL.md +18 -12
- package/skills/permdock-wire/references/adapters.md +146 -50
- package/skills/permdock-wire/references/policy-options.md +15 -6
- package/skills/permdock-wire/references/presets-and-recipes.md +58 -0
- package/dist/approvals-DbYAuNkG.js +0 -224
- package/dist/arazzo-C9chp3ST.js +0 -54
- package/dist/bind-C_ob3LKa.d.ts +0 -25
- package/dist/catalog-CRW5Mx_B.js +0 -2
- package/dist/catalog-CYj-mX2O.js +0 -48
- package/dist/catalog-DWF5cYqB.js +0 -55
- package/dist/cloud-CfwZ39Rl.js +0 -50
- package/dist/cloud-DWLXO2af.js +0 -118
- package/dist/collect-CHB22Sx4.js +0 -83
- package/dist/collect-DSWOk19R.js +0 -2
- package/dist/commands-BeaGNj_o.js +0 -97
- package/dist/config-ir40y8m0.js +0 -36
- package/dist/create-CMwIl-w2.js +0 -253
- package/dist/delegation-D8pUEuce.js +0 -103
- package/dist/diff-C9RDjjhs.js +0 -48
- package/dist/doctor-Djh6JDEk.js +0 -53
- package/dist/doctor-DvH0IQff.js +0 -1194
- package/dist/doctor-collect-CjdO0wQX.js +0 -665
- package/dist/duration-xK2jGFVc.js +0 -25
- package/dist/errors-CWfoNg9N.js +0 -47
- package/dist/errors-DRnGZHzF.js +0 -30
- package/dist/evaluation-items-CfCCejQd.js +0 -40
- package/dist/expiry-BoBJ8Tvc.js +0 -7
- package/dist/fields-BMl1RxoX.js +0 -26
- package/dist/grantee-Cv5Sogfv.js +0 -602
- package/dist/headers-BZSDieNT.js +0 -6
- package/dist/headers-Cf-zWvsO.d.ts +0 -6
- package/dist/ids-DC4V4CFu.js +0 -18
- package/dist/index-0nNqHspG.d.ts +0 -6
- package/dist/index-BNc7Wer_.d.ts +0 -483
- package/dist/index-C7Tw7MaW.d.ts +0 -201
- package/dist/instance-options-Dhhcnm3y.js +0 -22
- package/dist/load-DwJUafYN.js +0 -2
- package/dist/manifest-iaoBXHKc.js +0 -783
- package/dist/map-AcxK9o_D.js +0 -147
- package/dist/may-use-DndQ5tQ-.js +0 -77
- package/dist/openapi-CQTsgKoB.js +0 -164
- package/dist/permdock-CbgvLnuo.js +0 -3479
- package/dist/permissions-DVoF6dMt.js +0 -662
- package/dist/policy-xyWg7QdR.js +0 -1122
- package/dist/powersync-CrBX8hY6.js +0 -2
- package/dist/powersync-DE19b9dT.js +0 -62
- package/dist/protected-BdnIEFkY.d.ts +0 -27
- package/dist/protected-xAr24FEC.d.ts +0 -40
- package/dist/relations-cjOL01hZ.js +0 -505
- package/dist/rls-P-BeBx0n.js +0 -238
- package/dist/rls-graph-CHwx0V8q.js +0 -435
- package/dist/rls-ownership-DcYlZ09B.js +0 -843
- package/dist/rls-shared-Cewd_-zI.js +0 -495
- package/dist/rls-verify-CMv9H3wV.js +0 -746
- package/dist/run-BWc9qqvE.js +0 -174
- package/dist/server-DlZ28Oz5.js +0 -268
- package/dist/sink-B1fZzIMf.d.ts +0 -104
- package/dist/sink-Bd9PSyfq.js +0 -141
- package/dist/skills-C5CbhhpG.js +0 -56
- package/dist/snapshot-Enxd0n-s.js +0 -975
- package/dist/sql-xdg5f4Fi.js +0 -27
- package/dist/src-BDwEZWrZ.js +0 -568
- package/dist/store-jOBYpiXC.d.ts +0 -36
- package/dist/stores-C0IMQrGI.d.ts +0 -52
- package/dist/subject-C6dLxqHU.js +0 -24
- package/dist/supabase-config-Bj3iDH2I.js +0 -472
- package/dist/supabase-hook-gd3s8bxB.js +0 -2
- package/dist/supabase-s4o9s1O1.js +0 -83
- package/dist/thenable-okc2xDsj.js +0 -10
- package/dist/types-BnIEz1sU.d.ts +0 -48
- package/dist/types-Bvc4oWT6.d.ts +0 -9
- package/dist/types-CKNSyIf_.d.ts +0 -52
- package/dist/types-Cq_H8N_X.d.ts +0 -197
- package/dist/usage-B0YuTQRO.js +0 -42
- package/dist/usage-DorAJiU_.js +0 -2
- package/dist/vocabulary-DA8a8zif.d.ts +0 -56
- package/dist/watch-BwxLTYMZ.js +0 -2
|
@@ -0,0 +1,1770 @@
|
|
|
1
|
+
import { a as sole, r as isRecord } from "./compact-CAf557ZB.js";
|
|
2
|
+
import { a as hasConditionOp, c as isConditionRef } from "./refs-K6eWUJ0H.js";
|
|
3
|
+
import { E as memoryCustomRoleSource, g as memoryRelationSource } from "./src-DLfUWFAI.js";
|
|
4
|
+
import { c as listPermissions, h as formerKeys, n as findPermission } from "./permission-registry-Br4jSNVi.js";
|
|
5
|
+
import { a as expandRelation, c as isEdgeRelation, d as isSelfParented, l as isFieldRelation, o as groupEntries, p as restrictedFor, u as isPrincipalRelation } from "./resource-relations-D_jw0BFx.js";
|
|
6
|
+
import { f as nowMillis, u as isoAt } from "./time-DkWjdL98.js";
|
|
7
|
+
import { t as canonicalJson } from "./canonical-json-BC8_nMTw.js";
|
|
8
|
+
import { d as scopeList } from "./scopes-CUPSjo9q.js";
|
|
9
|
+
import { o as requiresStandIn } from "./delegation-DaXVdDhM.js";
|
|
10
|
+
import { o as flattenGrantee } from "./grantee-Cn1HyUsI.js";
|
|
11
|
+
import { r as relationCondition, t as inheritCondition } from "./grantee-conditions-CcXf8hMM.js";
|
|
12
|
+
import { p as scopeField } from "./tenancy-DN2QAx9z.js";
|
|
13
|
+
import { s as policyLevels } from "./policy-DLZJ5gAp.js";
|
|
14
|
+
import { t as requiresApproval } from "./approval-required-TzNB9oQA.js";
|
|
15
|
+
import { _ as membershipsClaim, c as andWhere, g as leveled, p as customRoleScope } from "./evaluate-_R8QkWaf.js";
|
|
16
|
+
import { t as getPermDock } from "./permdock-B9JxkBwC.js";
|
|
17
|
+
import { a as describeError, i as configError, u as usageResult } from "./errors-CWTY6uih.js";
|
|
18
|
+
import { t as policyRowConditionKeys } from "./row-conditions-h2HythXQ.js";
|
|
19
|
+
import { s as jsonSchemaOf } from "./catalog-doc-C9wjsfNY.js";
|
|
20
|
+
import { o as loadPolicyAt } from "./load-DFS_ry0W.js";
|
|
21
|
+
import { c as relationArmSql, u as restrictedRowsSql } from "./compile-CMqMx_IN.js";
|
|
22
|
+
import "./budget-NbEtbJC_.js";
|
|
23
|
+
import { i as qualifySqlTable, n as defaultSqlCommand } from "./sql-B5T1IL83.js";
|
|
24
|
+
import "./sources-vs5dGOsS.js";
|
|
25
|
+
import { t as beforeFirst } from "./text-BMFRfBqc.js";
|
|
26
|
+
import { n as fixtureSubject, r as loadFixtures, t as fixtureRow } from "./fixtures-B7XrLeY7.js";
|
|
27
|
+
import { F as quoteQualified, I as quoteTable, M as qualified, N as quoteIdent, P as quoteLiteral, T as columnTypesOf, _ as subjectClaimJsonSql, a as customRolesOn, j as helperSchema, r as HELPERS, v as subjectClaimSql, w as arrayColumnsOf } from "./shared-CD11nBbC.js";
|
|
28
|
+
import { d as connectPgPool, f as pgConnector, l as validitySql, s as andSql, u as wrapSql } from "./policies-CO1oOymN.js";
|
|
29
|
+
import { B as linkHelper, D as breakGlassHolder, F as graphHelper, I as graphSqlName, J as restrictedHelper, L as graphSqlText, M as contextRefs, N as sqlFunctionNames, O as breakGlassKey, P as CLOSURE, Q as activeUserSql, X as scopeTable, j as compileConditionSql, k as collectGrants, m as accessSql, p as requiresSql, v as capabilityAccessSql, w as apiKeyAllowsCall } from "./ownership-BatFgAA4.js";
|
|
30
|
+
import { s as viewName, t as FIELD_VIEWS } from "./fields-D9tPVUUS.js";
|
|
31
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
32
|
+
//#region src/cli/rls/indexes.ts
|
|
33
|
+
/** Postgres truncates identifiers longer than this, in bytes. */
|
|
34
|
+
const MAX_IDENT = 63;
|
|
35
|
+
/**
|
|
36
|
+
* The row fields `condition` compares with the subject, a claim or a
|
|
37
|
+
* membership (`ownerId: principal.id`, `memberOf`), through `and` and `or`:
|
|
38
|
+
* the selective comparisons an index serves. A comparison with a constant
|
|
39
|
+
* (`status: 'sent'`) narrows rows the scope key already found, a negation
|
|
40
|
+
* scans anyway, and a dotted field is a JSON path a plain index does not serve.
|
|
41
|
+
*/
|
|
42
|
+
function indexedFields(condition) {
|
|
43
|
+
if (condition === void 0) return [];
|
|
44
|
+
switch (condition.op) {
|
|
45
|
+
case "and":
|
|
46
|
+
case "or": return condition.conditions.flatMap(indexedFields);
|
|
47
|
+
case "memberOf": return plainField(condition.field);
|
|
48
|
+
case "eq":
|
|
49
|
+
case "in": return isConditionRef(condition.value) ? plainField(condition.field) : [];
|
|
50
|
+
case "not":
|
|
51
|
+
case "sqlFunction":
|
|
52
|
+
case "opaque":
|
|
53
|
+
case "related":
|
|
54
|
+
case "ne":
|
|
55
|
+
case "gt":
|
|
56
|
+
case "gte":
|
|
57
|
+
case "lt":
|
|
58
|
+
case "lte":
|
|
59
|
+
case "contains":
|
|
60
|
+
case "notIn":
|
|
61
|
+
case "isNull":
|
|
62
|
+
case "liveSession": return [];
|
|
63
|
+
default: return condition;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function plainField(field) {
|
|
67
|
+
return field.includes(".") ? [] : [field];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Every index the generated SQL reads through: the row columns policies
|
|
71
|
+
* filter on (scope keys and selective condition fields), and the membership
|
|
72
|
+
* tables' user column, followed by the scope column the helpers select.
|
|
73
|
+
*/
|
|
74
|
+
function indexTargets(ctx, rowColumns) {
|
|
75
|
+
const targets = /* @__PURE__ */ new Map();
|
|
76
|
+
const add = (table, columns) => {
|
|
77
|
+
const target = {
|
|
78
|
+
table: qualifySqlTable(table),
|
|
79
|
+
columns
|
|
80
|
+
};
|
|
81
|
+
targets.set(`${target.table}\u0000${columns[0] ?? ""}`, target);
|
|
82
|
+
};
|
|
83
|
+
for (const { table, column } of rowColumns) add(table, [column]);
|
|
84
|
+
for (const scope of ctx.scopes) {
|
|
85
|
+
const mapped = scopeTable(ctx, scope.name);
|
|
86
|
+
if (mapped !== void 0) add(mapped.table.table, [mapped.table.user, mapped.column]);
|
|
87
|
+
}
|
|
88
|
+
for (const source of [...ctx.sources ?? [], ...ctx.memberSources ?? []]) {
|
|
89
|
+
add(source.sql.table, [source.sql.user]);
|
|
90
|
+
const users = source.sql.userThrough;
|
|
91
|
+
if (users !== void 0) add(users.table, [users.key]);
|
|
92
|
+
}
|
|
93
|
+
return [...targets.values()].toSorted((a, b) => `${a.table}.${a.columns.join(",")}` < `${b.table}.${b.columns.join(",")}` ? -1 : 1);
|
|
94
|
+
}
|
|
95
|
+
/** A stable index name under Postgres' identifier limit. */
|
|
96
|
+
function indexName(target) {
|
|
97
|
+
const name = `permdock_${target.table.slice(target.table.lastIndexOf(".") + 1)}_${target.columns.join("_")}_idx`;
|
|
98
|
+
if (Buffer.byteLength(name) <= MAX_IDENT) return name;
|
|
99
|
+
return `permdock_${createHash("sha256").update(name).digest("hex").slice(0, 8)}_idx`;
|
|
100
|
+
}
|
|
101
|
+
/** `create index if not exists` for every target, one per line. */
|
|
102
|
+
function indexesSql(targets) {
|
|
103
|
+
return targets.map((target) => `create index if not exists ${quoteIdent(indexName(target))} on ${quoteTable(target.table)} (${target.columns.map(quoteIdent).join(", ")});`).join("\n");
|
|
104
|
+
}
|
|
105
|
+
/** Every live column of the tables in `$1` (`schema.table`), as `target` and `name`. */
|
|
106
|
+
const COLUMNS_SQL = `select n.nspname || '.' || c.relname as target, a.attname as name
|
|
107
|
+
from pg_attribute a
|
|
108
|
+
join pg_class c on c.oid = a.attrelid
|
|
109
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
110
|
+
where a.attnum > 0 and not a.attisdropped
|
|
111
|
+
and n.nspname || '.' || c.relname = any($1::text[])`;
|
|
112
|
+
const INDEX_COLUMNS_SQL = `select n.nspname || '.' || c.relname as target,
|
|
113
|
+
array(
|
|
114
|
+
select a.attname::text
|
|
115
|
+
from unnest(i.indkey) with ordinality k(attnum, ord)
|
|
116
|
+
left join pg_attribute a on a.attrelid = i.indrelid and a.attnum = k.attnum and k.attnum > 0
|
|
117
|
+
where k.ord <= i.indnkeyatts
|
|
118
|
+
order by k.ord
|
|
119
|
+
) as columns
|
|
120
|
+
from pg_index i
|
|
121
|
+
join pg_class c on c.oid = i.indrelid
|
|
122
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
123
|
+
where i.indpred is null and n.nspname || '.' || c.relname = any($1::text[])`;
|
|
124
|
+
function strings(value) {
|
|
125
|
+
return Array.isArray(value) ? value.map((item) => typeof item === "string" ? item : null) : [];
|
|
126
|
+
}
|
|
127
|
+
/** Reads the columns and index key columns of `tables` (`schema.table`). */
|
|
128
|
+
async function readTableIndexFacts(db, tables, connect = (url) => connectPgPool(url, "permdock rls generate --db-url-stdin")) {
|
|
129
|
+
const client = await connect(db);
|
|
130
|
+
try {
|
|
131
|
+
const [columns, indexes] = await Promise.all([client.query(COLUMNS_SQL, [[...tables]]), client.query(INDEX_COLUMNS_SQL, [[...tables]])]);
|
|
132
|
+
const byTable = {};
|
|
133
|
+
for (const row of columns.rows) (byTable[String(row["target"])] ??= []).push(String(row["name"]));
|
|
134
|
+
const keys = {};
|
|
135
|
+
for (const row of indexes.rows) {
|
|
136
|
+
const list = strings(row["columns"]);
|
|
137
|
+
const end = list.indexOf(null);
|
|
138
|
+
const lead = end === -1 ? list : list.slice(0, end);
|
|
139
|
+
if (lead.length > 0) (keys[String(row["target"])] ??= []).push(lead);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
columns: byTable,
|
|
143
|
+
indexes: keys
|
|
144
|
+
};
|
|
145
|
+
} finally {
|
|
146
|
+
await client.end();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Drops the targets a database already serves or cannot hold: a target on a
|
|
151
|
+
* table or column the database lacks (with a warning), and one an existing
|
|
152
|
+
* index leads with (its key columns start with the target's).
|
|
153
|
+
*/
|
|
154
|
+
function pruneIndexTargets(targets, facts) {
|
|
155
|
+
const warnings = [];
|
|
156
|
+
return {
|
|
157
|
+
targets: targets.filter((target) => {
|
|
158
|
+
const columns = facts.columns[target.table];
|
|
159
|
+
if (columns === void 0) {
|
|
160
|
+
warnings.push(`no index suggested on ${target.table}: the database has no such table`);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
const absent = target.columns.filter((column) => !columns.includes(column));
|
|
164
|
+
if (absent.length > 0) {
|
|
165
|
+
warnings.push(`no index suggested on ${target.table} (${target.columns.join(", ")}): the table has no column ${absent.join(", ")}`);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return !(facts.indexes[target.table] ?? []).some((index) => target.columns.every((column, at) => index[at] === column));
|
|
169
|
+
}),
|
|
170
|
+
warnings
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/cli/sql/compile.ts
|
|
175
|
+
/**
|
|
176
|
+
* The levels an allow reaches: every level without a row condition, else the
|
|
177
|
+
* levels whose condition equals its condition.
|
|
178
|
+
*/
|
|
179
|
+
function reachedLevels(where, levels) {
|
|
180
|
+
if (where === void 0) return levels.map(([name]) => name);
|
|
181
|
+
const held = canonicalJson(where);
|
|
182
|
+
return levels.filter(([, condition]) => canonicalJson(condition) === held).map(([name]) => name);
|
|
183
|
+
}
|
|
184
|
+
function levelReachOf(policy, keys) {
|
|
185
|
+
const reach = /* @__PURE__ */ new Map();
|
|
186
|
+
for (const [entry, grantKey] of keys) {
|
|
187
|
+
const { item } = entry;
|
|
188
|
+
const { grant } = item;
|
|
189
|
+
if (item.access.kind !== "role" || grant.effect !== "allow" || grant.permission.kind !== "instance" || grant.level !== void 0) continue;
|
|
190
|
+
const levels = policyLevels(policy, grant.permission.resource);
|
|
191
|
+
if (levels.length > 0) {
|
|
192
|
+
const set = reach.get(grantKey) ?? /* @__PURE__ */ new Set();
|
|
193
|
+
for (const name of reachedLevels(grant.where, levels)) set.add(name);
|
|
194
|
+
reach.set(grantKey, set);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const grantKey of new Set(keys.values())) {
|
|
199
|
+
const at = grantKey.lastIndexOf("@");
|
|
200
|
+
const base = at === -1 ? grantKey : grantKey.slice(0, at);
|
|
201
|
+
const level = at === -1 ? void 0 : grantKey.slice(at + 1);
|
|
202
|
+
for (const name of reach.get(base) ?? []) if (level === void 0 || level === name) out.push([grantKey, name]);
|
|
203
|
+
}
|
|
204
|
+
return out.toSorted(([a, x], [b, y]) => a === b ? x < y ? -1 : 1 : a < b ? -1 : 1);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The SQL command `action` compiles to: `rls.actions` first, then the six default verbs.
|
|
208
|
+
* Any other verb is `undefined`, as `'none'` is: no policy, and its grants are still seeded.
|
|
209
|
+
*/
|
|
210
|
+
function commandFor(action, actions) {
|
|
211
|
+
if (actions !== void 0 && Object.hasOwn(actions, action)) {
|
|
212
|
+
const configured = actions[action];
|
|
213
|
+
return configured === "none" ? void 0 : configured;
|
|
214
|
+
}
|
|
215
|
+
return defaultSqlCommand(action);
|
|
216
|
+
}
|
|
217
|
+
function tableFor$1(resource, tables) {
|
|
218
|
+
return tables?.[resource] ?? resource;
|
|
219
|
+
}
|
|
220
|
+
function ancestorsOf(policy, name) {
|
|
221
|
+
const names = [];
|
|
222
|
+
let current = policy.resources.get(name);
|
|
223
|
+
while (current?.parent !== void 0 && !names.includes(current.parent.resource)) {
|
|
224
|
+
names.push(current.parent.resource);
|
|
225
|
+
current = policy.resources.get(current.parent.resource);
|
|
226
|
+
}
|
|
227
|
+
return names;
|
|
228
|
+
}
|
|
229
|
+
function membershipField(policy, resource, holder) {
|
|
230
|
+
if (resource === void 0) return;
|
|
231
|
+
if (resource.name === holder) return resource.id ?? "id";
|
|
232
|
+
let current = resource;
|
|
233
|
+
const seen = /* @__PURE__ */ new Set();
|
|
234
|
+
while (current?.parent !== void 0 && !seen.has(current.name)) {
|
|
235
|
+
seen.add(current.name);
|
|
236
|
+
if (current.parent.resource === holder) return current.parent.field;
|
|
237
|
+
current = policy.resources.get(current.parent.resource);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function resourceCondition(item, policy, ctx) {
|
|
241
|
+
if (item.access.kind !== "resource") throw configError("PermDock CLI: resourceCondition needs a resource role");
|
|
242
|
+
const { role, resource: roleResource } = item.access;
|
|
243
|
+
const target = policy.resources.get(item.grant.permission.resource);
|
|
244
|
+
const holders = [roleResource, ...ancestorsOf(policy, roleResource)];
|
|
245
|
+
const hops = [];
|
|
246
|
+
for (const [index, holder] of holders.entries()) {
|
|
247
|
+
const field = membershipField(policy, target, holder);
|
|
248
|
+
if (field === void 0) continue;
|
|
249
|
+
if (index > 0 && ctx.memberships?.resource?.[holder] === void 0) continue;
|
|
250
|
+
hops.push({
|
|
251
|
+
op: "memberOf",
|
|
252
|
+
scope: "resource",
|
|
253
|
+
field,
|
|
254
|
+
roles: [role],
|
|
255
|
+
resource: holder
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return sole(hops) ?? {
|
|
259
|
+
op: "or",
|
|
260
|
+
conditions: hops
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The access a link capability has to a resource-scoped grant, over the same
|
|
265
|
+
* holders as `resourceCondition`. It reads the claim, so ancestor hops need
|
|
266
|
+
* no memberships table. `undefined` when no holder is on the row's chain.
|
|
267
|
+
*/
|
|
268
|
+
function capabilityAccess(item, policy, ctx) {
|
|
269
|
+
if (item.access.kind !== "resource") return;
|
|
270
|
+
const { role, resource: roleResource } = item.access;
|
|
271
|
+
const kinds = ctx.ownership?.kinds[role];
|
|
272
|
+
if (kinds !== void 0 && !kinds.includes("link")) return;
|
|
273
|
+
const target = policy.resources.get(item.grant.permission.resource);
|
|
274
|
+
const hops = [];
|
|
275
|
+
for (const holder of [roleResource, ...ancestorsOf(policy, roleResource)]) {
|
|
276
|
+
const field = membershipField(policy, target, holder);
|
|
277
|
+
if (field !== void 0) hops.push(capabilityAccessSql(ctx, field, holder, role, item.grant.permission.key));
|
|
278
|
+
}
|
|
279
|
+
if (hops.length === 0) return;
|
|
280
|
+
return hops.length === 1 ? hops[0] : hops.map(wrapSql).join(" or ");
|
|
281
|
+
}
|
|
282
|
+
function prepare(item, tables, warnings, skipClosures, actions, rowActions = false) {
|
|
283
|
+
const { grant, label } = item;
|
|
284
|
+
if (grant.breakGlass !== void 0) {
|
|
285
|
+
warnings.push(`break-glass grant ${label}/${grant.permission.key} reads through permdock_break_glass_${grant.permission.resource}, not a policy`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (grant.closure !== void 0 || grant.portable === false) {
|
|
289
|
+
if (skipClosures) {
|
|
290
|
+
warnings.push(`skipped non-portable grant ${label}/${grant.permission.key}`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
throw configError(`PermDock CLI: closure grant ${label}/${grant.permission.key} is not portable; rewrite it or pass --skip-closures`);
|
|
294
|
+
}
|
|
295
|
+
const context = [.../* @__PURE__ */ new Set([...contextRefs(item.where), ...contextRefs(grant.check)])];
|
|
296
|
+
if (context.length > 0) {
|
|
297
|
+
if (skipClosures) {
|
|
298
|
+
warnings.push(`skipped grant ${label}/${grant.permission.key}: it reads ${context.join(", ")}, which is not in the token`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
throw configError(`PermDock CLI: grant ${label}/${grant.permission.key} reads ${context.join(", ")}; request context is not in the token, so RLS cannot compile it. Move the value to a server-set claim (principal.claims.*) or pass --skip-closures (permdock doctor PD027)`);
|
|
302
|
+
}
|
|
303
|
+
if (requiresApproval(grant.approval)) {
|
|
304
|
+
warnings.push(`skipped approval grant ${label}/${grant.permission.key}`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const command = commandFor(grant.permission.action, actions);
|
|
308
|
+
const table = tableFor$1(grant.permission.resource, tables);
|
|
309
|
+
if (command === void 0) {
|
|
310
|
+
if (item.access.kind !== "role" && rowActions) return {
|
|
311
|
+
item,
|
|
312
|
+
command: "none",
|
|
313
|
+
table,
|
|
314
|
+
...item.where === void 0 ? {} : { using: item.where }
|
|
315
|
+
};
|
|
316
|
+
if (item.access.kind !== "role") {
|
|
317
|
+
warnings.push(`skipped ${label}/${grant.permission.key}: action '${grant.permission.action}' has no SQL command (rls.actions) and only role grants are seeded`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (grant.validity !== void 0 && grant.effect === "allow") {
|
|
321
|
+
warnings.push(`skipped ${label}/${grant.permission.key}: a role_permissions row cannot carry validFrom / validUntil, so a time-bounded grant on an action with no SQL command is not seeded`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
item,
|
|
326
|
+
command: "none",
|
|
327
|
+
table,
|
|
328
|
+
...item.where === void 0 ? {} : { using: item.where },
|
|
329
|
+
...grant.check === void 0 ? {} : { check: grant.check }
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const using = command === "insert" ? void 0 : item.where;
|
|
333
|
+
const check = command === "insert" ? grant.check ?? item.where : command === "update" ? grant.check ?? item.where : void 0;
|
|
334
|
+
return {
|
|
335
|
+
item,
|
|
336
|
+
command,
|
|
337
|
+
table,
|
|
338
|
+
...using === void 0 ? {} : { using },
|
|
339
|
+
...check === void 0 ? {} : { check }
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function signature(entry, byFields) {
|
|
343
|
+
const base = [
|
|
344
|
+
entry.item.grant.effect,
|
|
345
|
+
entry.using ?? null,
|
|
346
|
+
entry.check ?? null
|
|
347
|
+
];
|
|
348
|
+
return JSON.stringify(byFields ? [...base, entry.item.grant.fields ?? null] : base);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Grant keys per permission: the key is the permission, split into
|
|
352
|
+
* `permission#n` when role grants carry different portable conditions (or
|
|
353
|
+
* effects), one key per condition group; a group whose grants set `group`
|
|
354
|
+
* is `permission#<group>` instead. With field views, grants that differ
|
|
355
|
+
* only in `fields` get their own keys too, so a view can tell them apart.
|
|
356
|
+
*/
|
|
357
|
+
function assignKeys(entries, byFields) {
|
|
358
|
+
const groups = /* @__PURE__ */ new Map();
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
if (entry.item.access.kind !== "role") continue;
|
|
361
|
+
const key = entry.item.grant.permission.key;
|
|
362
|
+
const byCondition = groups.get(key) ?? /* @__PURE__ */ new Map();
|
|
363
|
+
const sig = signature(entry, byFields);
|
|
364
|
+
byCondition.set(sig, [...byCondition.get(sig) ?? [], entry]);
|
|
365
|
+
groups.set(key, byCondition);
|
|
366
|
+
}
|
|
367
|
+
const keys = /* @__PURE__ */ new Map();
|
|
368
|
+
for (const [permission, byCondition] of groups) {
|
|
369
|
+
const lists = [...byCondition.values()];
|
|
370
|
+
const named = /* @__PURE__ */ new Set();
|
|
371
|
+
for (const [index, list] of lists.entries()) {
|
|
372
|
+
const group = groupOf(permission, list);
|
|
373
|
+
if (group !== void 0 && named.has(group)) throw configError(`PermDock CLI: grants of ${permission} with different conditions share group '${group}'; a group names one condition`);
|
|
374
|
+
if (group !== void 0) named.add(group);
|
|
375
|
+
const grantKey = group !== void 0 ? `${permission}#${group}` : lists.length === 1 ? permission : `${permission}#${index + 1}`;
|
|
376
|
+
for (const entry of list) keys.set(entry, grantKey);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return keys;
|
|
380
|
+
}
|
|
381
|
+
function groupOf(permission, list) {
|
|
382
|
+
const names = [...new Set(list.flatMap((entry) => entry.item.grant.group === void 0 ? [] : [entry.item.grant.group]))].toSorted();
|
|
383
|
+
if (names.length > 1) throw configError(`PermDock CLI: grants of ${permission} with one condition name the groups ${names.join(", ")}; give them one group`);
|
|
384
|
+
return names[0];
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* One entry per level for every allow of an assignable role on a resource
|
|
388
|
+
* with levels, keyed `<grant key>@<level>`. Only `permdock_custom_keys`
|
|
389
|
+
* hands these keys out, to a custom role whose stored allow names the level.
|
|
390
|
+
*/
|
|
391
|
+
function levelEntries(policy, ctx, keys, tables, warnings, skipClosures) {
|
|
392
|
+
const custom = ctx.customRoles;
|
|
393
|
+
if (custom?.levels !== true) return [];
|
|
394
|
+
const assignable = new Set(custom.assignable);
|
|
395
|
+
const out = [];
|
|
396
|
+
for (const [entry, grantKey] of Array.from(keys)) {
|
|
397
|
+
const { item } = entry;
|
|
398
|
+
const { grant } = item;
|
|
399
|
+
if (item.access.kind !== "role" || grant.effect !== "allow" || grant.permission.kind !== "instance" || !assignable.has(item.access.role)) continue;
|
|
400
|
+
if (grant.permission.key.includes("@")) throw configError(`PermDock CLI: permission key '${grant.permission.key}' holds '@', which separates a level in custom-role grant keys`);
|
|
401
|
+
for (const [level, condition] of policyLevels(policy, grant.permission.resource)) {
|
|
402
|
+
const where = andWhere(item.where, condition);
|
|
403
|
+
const prepared = prepare({
|
|
404
|
+
...item,
|
|
405
|
+
grant: leveled(grant, level, condition),
|
|
406
|
+
...where === void 0 ? {} : { where }
|
|
407
|
+
}, tables, warnings, skipClosures, ctx.actions);
|
|
408
|
+
if (prepared !== void 0) {
|
|
409
|
+
keys.set(prepared, `${grantKey}@${level}`);
|
|
410
|
+
out.push(prepared);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
function compileOptional(condition, ctx) {
|
|
417
|
+
return condition === void 0 ? void 0 : compileConditionSql(condition, ctx);
|
|
418
|
+
}
|
|
419
|
+
function noteConditions(entry, warnings) {
|
|
420
|
+
const { label, grant } = entry.item;
|
|
421
|
+
const names = [...sqlFunctionNames(entry.using), ...sqlFunctionNames(entry.check)];
|
|
422
|
+
if (names.length > 0) warnings.push(`sqlFunction ${[...new Set(names)].join(", ")} on ${label}/${grant.permission.key} is portable via twin`);
|
|
423
|
+
if (hasConditionOp(entry.using, "opaque") || hasConditionOp(entry.check, "opaque")) warnings.push(`opaque SQL on ${label}/${grant.permission.key} is untestable app-side`);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Compiles every grant (role and top-level) to branches whose role check is
|
|
427
|
+
* a helper call keyed by grant key. Nothing in a branch is per-row except the
|
|
428
|
+
* portable row condition and resource-membership joins.
|
|
429
|
+
*/
|
|
430
|
+
/** A Supabase token from a permanent user: anonymous sign-ins carry `is_anonymous: true`. */
|
|
431
|
+
const PERMANENT_USER = `((select auth.jwt()) ->> 'is_anonymous') is distinct from 'true'`;
|
|
432
|
+
function compileGrants(policy, ctx, tables, warnings, skipClosures, rowActions = false) {
|
|
433
|
+
const items = collectGrants(policy);
|
|
434
|
+
const entries = items.flatMap((item) => {
|
|
435
|
+
const entry = prepare(item, tables, warnings, skipClosures, ctx.actions, rowActions);
|
|
436
|
+
return entry === void 0 ? [] : [entry];
|
|
437
|
+
});
|
|
438
|
+
const keys = assignKeys(entries, ctx.fields === "views");
|
|
439
|
+
entries.push(...levelEntries(policy, ctx, keys, tables, warnings, skipClosures));
|
|
440
|
+
const conditionedKeys = /* @__PURE__ */ new Set();
|
|
441
|
+
for (const [entry, grantKey] of keys) if (entry.using !== void 0 || entry.check !== void 0 || entry.item.grant.validity !== void 0 || entry.item.grant.requires !== void 0) conditionedKeys.add(grantKey);
|
|
442
|
+
const rows = /* @__PURE__ */ new Map();
|
|
443
|
+
for (const item of items) {
|
|
444
|
+
const holder = item.grant.breakGlass === void 0 ? void 0 : breakGlassHolder(item);
|
|
445
|
+
if (holder !== void 0) {
|
|
446
|
+
const row = {
|
|
447
|
+
role: holder.role,
|
|
448
|
+
permission: item.grant.permission.key,
|
|
449
|
+
grantKey: breakGlassKey(item.grant.permission.key),
|
|
450
|
+
scope: holder.scope,
|
|
451
|
+
effect: item.grant.effect
|
|
452
|
+
};
|
|
453
|
+
rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const branches = [];
|
|
457
|
+
const actionBranches = [];
|
|
458
|
+
const rowColumns = /* @__PURE__ */ new Map();
|
|
459
|
+
const filters = (resource, table, column) => {
|
|
460
|
+
rowColumns.set(`${table}\u0000${column}`, {
|
|
461
|
+
resource,
|
|
462
|
+
table,
|
|
463
|
+
column
|
|
464
|
+
});
|
|
465
|
+
};
|
|
466
|
+
const typed = /* @__PURE__ */ new Map();
|
|
467
|
+
const contextFor = (name) => {
|
|
468
|
+
const known = typed.get(name);
|
|
469
|
+
if (known !== void 0) return known;
|
|
470
|
+
const node = policy.resources.get(name);
|
|
471
|
+
const schema = node === void 0 ? void 0 : jsonSchemaOf(node);
|
|
472
|
+
const next = {
|
|
473
|
+
...ctx,
|
|
474
|
+
columnTypes: columnTypesOf(schema),
|
|
475
|
+
arrayColumns: arrayColumnsOf(schema)
|
|
476
|
+
};
|
|
477
|
+
typed.set(name, next);
|
|
478
|
+
return next;
|
|
479
|
+
};
|
|
480
|
+
for (const entry of entries) {
|
|
481
|
+
const { item, table } = entry;
|
|
482
|
+
const { grant, access, label } = item;
|
|
483
|
+
if (entry.command === "none") {
|
|
484
|
+
const grantKey = keys.get(entry);
|
|
485
|
+
if (access.kind === "role" && grantKey !== void 0) {
|
|
486
|
+
const row = {
|
|
487
|
+
role: access.role,
|
|
488
|
+
permission: grant.permission.key,
|
|
489
|
+
grantKey,
|
|
490
|
+
scope: access.scope,
|
|
491
|
+
effect: grant.effect
|
|
492
|
+
};
|
|
493
|
+
rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
|
|
494
|
+
}
|
|
495
|
+
if (!rowActions) continue;
|
|
496
|
+
}
|
|
497
|
+
const actionOnly = entry.command === "none";
|
|
498
|
+
const command = actionOnly ? "select" : entry.command;
|
|
499
|
+
const target = actionOnly ? actionBranches : branches;
|
|
500
|
+
const rowCtx = {
|
|
501
|
+
...contextFor(item.grant.permission.resource),
|
|
502
|
+
permission: grant.permission.key
|
|
503
|
+
};
|
|
504
|
+
noteConditions(entry, warnings);
|
|
505
|
+
for (const field of [...indexedFields(entry.using), ...indexedFields(entry.check)]) filters(grant.permission.resource, table, field);
|
|
506
|
+
const grantKey = keys.get(entry);
|
|
507
|
+
let accessExpr;
|
|
508
|
+
if (access.kind === "role" && grantKey !== void 0) {
|
|
509
|
+
const column = access.scope === "global" ? void 0 : scopeField(policy.resources.get(grant.permission.resource), access.scope, policy.scopes);
|
|
510
|
+
accessExpr = accessSql(ctx, access.scope, grantKey, column);
|
|
511
|
+
if (column !== void 0) filters(grant.permission.resource, table, column);
|
|
512
|
+
const row = {
|
|
513
|
+
role: access.role,
|
|
514
|
+
permission: grant.permission.key,
|
|
515
|
+
grantKey,
|
|
516
|
+
scope: access.scope,
|
|
517
|
+
effect: grant.effect
|
|
518
|
+
};
|
|
519
|
+
rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
|
|
520
|
+
}
|
|
521
|
+
const linkOnly = access.kind === "resource" && ctx.capabilities === true && ctx.memberships?.resource?.[access.resource] === void 0;
|
|
522
|
+
if (access.kind === "actor") accessExpr = `${subjectClaimSql(ctx, "client_id")} is not null or ${subjectClaimJsonSql(ctx, "act")} is not null`;
|
|
523
|
+
if (access.kind === "resource" && !linkOnly) accessExpr = compileConditionSql(resourceCondition(item, policy, ctx), {
|
|
524
|
+
...ctx,
|
|
525
|
+
permission: grant.permission.key
|
|
526
|
+
});
|
|
527
|
+
if (linkOnly) warnings.push(`no ${access.resource} memberships table: only link capabilities reach ${label}/${grant.permission.key}`);
|
|
528
|
+
const validity = validitySql(grant.validity);
|
|
529
|
+
accessExpr = andSql(accessExpr, validity);
|
|
530
|
+
for (const key of grant.requires ?? []) accessExpr = andSql(accessExpr, requiresSql(ctx, key, policy.resources.get(grant.permission.resource)));
|
|
531
|
+
if (ctx.anonymousSignIns === "deny" && access.kind !== "anyone") accessExpr = andSql(accessExpr, PERMANENT_USER);
|
|
532
|
+
if (ctx.apiKeys !== void 0 && grant.effect === "allow" && access.kind !== "anyone" && access.kind !== "role") {
|
|
533
|
+
const calls = (requiresStandIn(grant) ? grant.requires ?? [] : [grant.permission.key, ...grant.requires ?? []]).map((key) => `(select ${apiKeyAllowsCall(ctx, quoteLiteral(key))})`);
|
|
534
|
+
accessExpr = andSql(accessExpr, calls.length === 1 ? calls[0] ?? "" : `(${calls.join(" and ")})`);
|
|
535
|
+
}
|
|
536
|
+
const using = compileOptional(entry.using, rowCtx);
|
|
537
|
+
const check = compileOptional(entry.check, rowCtx);
|
|
538
|
+
const fields = grant.fields === void 0 ? {} : { fields: grant.fields };
|
|
539
|
+
if (!linkOnly) target.push({
|
|
540
|
+
table,
|
|
541
|
+
command,
|
|
542
|
+
effect: grant.effect,
|
|
543
|
+
roles: access.kind === "anyone" ? ["anon", "authenticated"] : ["authenticated"],
|
|
544
|
+
label,
|
|
545
|
+
resource: grant.permission.resource,
|
|
546
|
+
permissionKey: grant.permission.key,
|
|
547
|
+
...fields,
|
|
548
|
+
...grantKey === void 0 ? {} : { grantKey },
|
|
549
|
+
...accessExpr === void 0 ? {} : { access: accessExpr },
|
|
550
|
+
...using === void 0 ? {} : { using },
|
|
551
|
+
...check === void 0 ? {} : { check }
|
|
552
|
+
});
|
|
553
|
+
const linked = ctx.capabilities === true ? capabilityAccess(item, policy, ctx) : void 0;
|
|
554
|
+
if (linked !== void 0) target.push({
|
|
555
|
+
table,
|
|
556
|
+
command,
|
|
557
|
+
effect: grant.effect,
|
|
558
|
+
roles: ["anon"],
|
|
559
|
+
label,
|
|
560
|
+
resource: grant.permission.resource,
|
|
561
|
+
permissionKey: grant.permission.key,
|
|
562
|
+
...fields,
|
|
563
|
+
access: andSql(linked, validity) ?? linked,
|
|
564
|
+
...using === void 0 ? {} : { using },
|
|
565
|
+
...check === void 0 ? {} : { check }
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
branches: ensureSelectCoverage(branches, warnings),
|
|
570
|
+
actionBranches,
|
|
571
|
+
rolePermissions: withAliasRows(policy, [...rows.values()]),
|
|
572
|
+
conditionedKeys,
|
|
573
|
+
rowColumns: [...rowColumns.values()],
|
|
574
|
+
levelReach: ctx.customRoles?.levels === true ? levelReachOf(policy, keys) : []
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Each row again under every key its permission was renamed from, so SQL that
|
|
579
|
+
* still calls `permdock_has('<old key>')` keeps the access the current key
|
|
580
|
+
* has. A split grant key (`key#n`) keeps its suffix; break-glass rows are not
|
|
581
|
+
* copied.
|
|
582
|
+
*/
|
|
583
|
+
function withAliasRows(policy, rows) {
|
|
584
|
+
const former = new Map(listPermissions(policy.permissions).map((leaf) => [leaf.key, formerKeys(leaf)]));
|
|
585
|
+
const out = [...rows];
|
|
586
|
+
for (const row of rows) {
|
|
587
|
+
const suffix = row.grantKey.slice(row.permission.length);
|
|
588
|
+
if (!row.grantKey.startsWith(row.permission) || suffix !== "" && (!/^#[a-z0-9][a-z0-9_-]*$/u.test(suffix) || suffix === "#break-glass")) continue;
|
|
589
|
+
for (const old of former.get(row.permission) ?? []) out.push({
|
|
590
|
+
...row,
|
|
591
|
+
permission: old,
|
|
592
|
+
grantKey: `${old}${suffix}`
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Postgres needs SELECT access to find rows for UPDATE / DELETE and for
|
|
599
|
+
* RETURNING, so a table with only update or delete grants gets matching
|
|
600
|
+
* SELECT branches.
|
|
601
|
+
*/
|
|
602
|
+
function ensureSelectCoverage(branches, warnings) {
|
|
603
|
+
const extra = [];
|
|
604
|
+
const readable = new Set(branches.filter((item) => item.command === "select" && item.effect === "allow").map((item) => item.table));
|
|
605
|
+
const covered = /* @__PURE__ */ new Set();
|
|
606
|
+
for (const item of branches) {
|
|
607
|
+
if (item.effect !== "allow" || item.command !== "update" && item.command !== "delete" || readable.has(item.table)) continue;
|
|
608
|
+
const { check: _check, ...rest } = item;
|
|
609
|
+
extra.push({
|
|
610
|
+
...rest,
|
|
611
|
+
command: "select",
|
|
612
|
+
coverage: true
|
|
613
|
+
});
|
|
614
|
+
if (!covered.has(item.table)) {
|
|
615
|
+
covered.add(item.table);
|
|
616
|
+
warnings.push(`added SELECT coverage for ${item.table} (${item.permissionKey})`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return [...branches, ...extra];
|
|
620
|
+
}
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/cli/sql/helper-calls.ts
|
|
623
|
+
/**
|
|
624
|
+
* Tables whose policies other packages write by calling the SQL helpers
|
|
625
|
+
* directly (storage buckets and realtime topics).
|
|
626
|
+
*/
|
|
627
|
+
const HELPER_TABLES = ["storage.objects", "realtime.messages"];
|
|
628
|
+
const HELPER_CALL = new RegExp(String.raw`\b(${HELPERS.has}(?:_permission)?|permitted_[a-z][a-z0-9_]*_ids(?:_by_permission)?)\s*\(\s*'((?:[^']|'')+)'(\s*,\s*(false\b)?)?`, "giu");
|
|
629
|
+
const ANY_HELPER = new RegExp(String.raw`\b(?:${HELPERS.has}(?:_permission)?(?:_for)?|permitted_[a-z][a-z0-9_]*_ids(?:_by_permission)?(?:_for)?|member_[a-z][a-z0-9_]*_ids(?:_for)?)\s*\(`, "iu");
|
|
630
|
+
/** Whether a policy expression calls any generated helper, the key-less `member_<scope>_ids` included. */
|
|
631
|
+
function callsHelper(sql) {
|
|
632
|
+
return ANY_HELPER.test(sql);
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* The permission keys a policy expression passes to `permdock_has`,
|
|
636
|
+
* `permitted_<scope>_ids` or their permission-key forms, `#n` stripped. With
|
|
637
|
+
* `blind`, only the calls that ignore row conditions: the grant-key forms,
|
|
638
|
+
* and a permission-key form asked for conditioned allows. A plain
|
|
639
|
+
* `permdock_has_permission(key)` or `permitted_<scope>_ids_by_permission(key)`
|
|
640
|
+
* counts only allows without a row condition, so it never grants more than
|
|
641
|
+
* the application does.
|
|
642
|
+
*/
|
|
643
|
+
function helperCallKeys(sql, blind = false) {
|
|
644
|
+
const keys = /* @__PURE__ */ new Set();
|
|
645
|
+
for (const [, name = "", literal = "", extra, falsy] of sql.matchAll(HELPER_CALL)) {
|
|
646
|
+
const byPermission = /_permission$/iu.test(name);
|
|
647
|
+
if (blind && byPermission && (extra === void 0 || falsy !== void 0)) continue;
|
|
648
|
+
const key = beforeFirst(literal.replaceAll("''", "'"), "#");
|
|
649
|
+
if (key !== "") keys.add(key);
|
|
650
|
+
}
|
|
651
|
+
return [...keys];
|
|
652
|
+
}
|
|
653
|
+
const CREATE_POLICY = /\bcreate\s+policy\s+("(?:[^"]|"")+"|\w+)\s+on\s+((?:"[^"]+"|\w+)\.(?:"[^"]+"|\w+))([\s\S]*?);/giu;
|
|
654
|
+
function tableName(name) {
|
|
655
|
+
return name.replaceAll("\"", "").toLowerCase();
|
|
656
|
+
}
|
|
657
|
+
function helperTable(name) {
|
|
658
|
+
const table = tableName(name);
|
|
659
|
+
return HELPER_TABLES.find((candidate) => candidate === table);
|
|
660
|
+
}
|
|
661
|
+
/** `create policy` statements on {@link HELPER_TABLES} in migration SQL, with the keys their condition-blind helper calls pass. */
|
|
662
|
+
function helperTablePolicies(sql) {
|
|
663
|
+
const text = sql.replaceAll(/--[^\n]*/gu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
|
|
664
|
+
const found = [];
|
|
665
|
+
for (const [, name = "", target = "", body = ""] of text.matchAll(CREATE_POLICY)) {
|
|
666
|
+
const table = helperTable(target);
|
|
667
|
+
const keys = helperCallKeys(body, true);
|
|
668
|
+
if (table !== void 0 && keys.length > 0) found.push({
|
|
669
|
+
table,
|
|
670
|
+
name: name.replaceAll("\"", ""),
|
|
671
|
+
keys
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
return found;
|
|
675
|
+
}
|
|
676
|
+
/** `pg_policies` rows on {@link HELPER_TABLES}, read by `rls verify --db-url-stdin`. */
|
|
677
|
+
const HELPER_TABLE_POLICIES_SQL = `select schemaname || '.' || tablename as target, policyname, coalesce(qual, '') || ' ' || coalesce(with_check, '') as body
|
|
678
|
+
from pg_policies
|
|
679
|
+
where (schemaname, tablename) in (('storage', 'objects'), ('realtime', 'messages'))`;
|
|
680
|
+
/** The same rows as {@link helperTablePolicies}, from `pg_policies`. */
|
|
681
|
+
function helperTablePoliciesFromRows(rows) {
|
|
682
|
+
const found = [];
|
|
683
|
+
for (const row of rows) {
|
|
684
|
+
const table = helperTable(String(row["target"] ?? ""));
|
|
685
|
+
const keys = helperCallKeys(String(row["body"] ?? ""), true);
|
|
686
|
+
if (table !== void 0 && keys.length > 0) found.push({
|
|
687
|
+
table,
|
|
688
|
+
name: String(row["policyname"] ?? ""),
|
|
689
|
+
keys
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
return found;
|
|
693
|
+
}
|
|
694
|
+
/** The message PD037 and `rls verify --db-url-stdin` report for one policy. */
|
|
695
|
+
function rowConditionMessage(policy, keys) {
|
|
696
|
+
return `${policy.table} policy '${policy.name}' calls the SQL helpers with ${keys.join(", ")}, whose grants carry row conditions the helpers do not check: the policy grants more than the application does`;
|
|
697
|
+
}
|
|
698
|
+
const ROW_CONDITION_FIX = "call permitted_<scope>_ids_by_permission('<key>') or permdock_has_permission('<key>'), which count only the allows without a row condition (a relationship grant never reaches a topic or a folder), or use a permission whose catalog entry has rowConditions: false";
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region src/cli/rls/verify-pgtap.ts
|
|
701
|
+
/** A custom role's scope and pinned instance, as the `custom_role_*` tables store them. */
|
|
702
|
+
function customRoleAt(role, scopes) {
|
|
703
|
+
return {
|
|
704
|
+
scope: customRoleScope(role, scopes),
|
|
705
|
+
id: role.scope === void 0 ? role.team : role.id
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function columnsOf(row) {
|
|
709
|
+
return isRecord(row) ? Object.entries(row).filter(([, value]) => value !== void 0 && typeof value !== "object") : [];
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* The statement a fixture runs: `create` inserts the whole row, `update` writes
|
|
713
|
+
* `newRow` when given. Each reads back only the key, so `--revoke-columns`
|
|
714
|
+
* (which keeps only unrestricted columns readable) does not reject it.
|
|
715
|
+
*/
|
|
716
|
+
function statementFor(fixture, action, table) {
|
|
717
|
+
const quoted = quoteTable(table);
|
|
718
|
+
const id = quoteIdent("id");
|
|
719
|
+
const key = rowId(fixture.row);
|
|
720
|
+
const command = defaultSqlCommand(action) ?? "select";
|
|
721
|
+
switch (command) {
|
|
722
|
+
case "select": return {
|
|
723
|
+
sql: `select ${id} from ${quoted} where ${id} = $1`,
|
|
724
|
+
values: [key]
|
|
725
|
+
};
|
|
726
|
+
case "update": {
|
|
727
|
+
const next = columnsOf(fixture.newRow).filter(([name]) => name !== "id");
|
|
728
|
+
return {
|
|
729
|
+
sql: `update ${quoted} set ${next.length === 0 ? `${id} = ${id}` : next.map(([name], index) => `${quoteIdent(name)} = $${index + 2}`).join(", ")} where ${id} = $1 returning ${id}`,
|
|
730
|
+
values: [key, ...next.map(([, value]) => value)]
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
case "insert": {
|
|
734
|
+
const cols = columnsOf(fixture.row);
|
|
735
|
+
if (cols.length === 0) return {
|
|
736
|
+
sql: `insert into ${quoted} (${id}) values ($1) returning ${id}`,
|
|
737
|
+
values: [key]
|
|
738
|
+
};
|
|
739
|
+
return {
|
|
740
|
+
sql: `insert into ${quoted} (${cols.map(([name]) => quoteIdent(name)).join(", ")}) values (${cols.map((_, index) => `$${index + 1}`).join(", ")}) returning ${id}`,
|
|
741
|
+
values: cols.map(([, value]) => value)
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
case "delete": return {
|
|
745
|
+
sql: `delete from ${quoted} where ${id} = $1 returning ${id}`,
|
|
746
|
+
values: [key]
|
|
747
|
+
};
|
|
748
|
+
default: return command;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function rowId(row) {
|
|
752
|
+
if (isRecord(row) && "id" in row) return row["id"];
|
|
753
|
+
}
|
|
754
|
+
/** A fixture value as a SQL literal; untyped, so it coerces to the column like a bound parameter. */
|
|
755
|
+
function sqlValue(value) {
|
|
756
|
+
if (value === null || value === void 0) return "null";
|
|
757
|
+
return quoteLiteral(typeof value === "string" ? value : JSON.stringify(value));
|
|
758
|
+
}
|
|
759
|
+
function inlineStatement(statement) {
|
|
760
|
+
return statement.sql.replaceAll(/\$(\d+)/gu, (_, index) => sqlValue(statement.values[Number(index) - 1]));
|
|
761
|
+
}
|
|
762
|
+
const PGTAP_DECISION = "pg_temp.permdock_decision";
|
|
763
|
+
const PGTAP_DECISION_SQL = `create function ${PGTAP_DECISION}(statement text) returns text
|
|
764
|
+
language plpgsql
|
|
765
|
+
as $$
|
|
766
|
+
declare
|
|
767
|
+
affected bigint;
|
|
768
|
+
begin
|
|
769
|
+
execute statement;
|
|
770
|
+
get diagnostics affected = row_count;
|
|
771
|
+
return case when affected > 0 then 'granted' else 'denied' end;
|
|
772
|
+
exception
|
|
773
|
+
when others then
|
|
774
|
+
return 'denied';
|
|
775
|
+
end;
|
|
776
|
+
$$;`;
|
|
777
|
+
function subjectOf(config, customRoles, scopes) {
|
|
778
|
+
return {
|
|
779
|
+
dialect: config.rls?.dialect ?? "supabase",
|
|
780
|
+
gucPrefix: config.rls?.gucPrefix ?? "app",
|
|
781
|
+
tenantClaim: config.rls?.tenantClaim ?? "tenant_id",
|
|
782
|
+
roleClaim: config.rls?.roleClaim ?? "user_role",
|
|
783
|
+
customRoles,
|
|
784
|
+
scopes
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/** The settings that bind a fixture's subject: the GUCs, or the Supabase JWT claims. */
|
|
788
|
+
function subjectSettings(fixture, subject) {
|
|
789
|
+
const roles = fixture.subject.roles ?? [];
|
|
790
|
+
const memberships = membershipsClaim(fixture.subject.memberships ?? [], subject.customRoles, subject.scopes);
|
|
791
|
+
if (subject.dialect === "guc") {
|
|
792
|
+
const settings = [
|
|
793
|
+
[`${subject.gucPrefix}.user_id`, fixture.subject.id],
|
|
794
|
+
[`${subject.gucPrefix}.${subject.roleClaim}`, roles.join(",")],
|
|
795
|
+
[`${subject.gucPrefix}.memberships`, JSON.stringify(memberships)]
|
|
796
|
+
];
|
|
797
|
+
if (fixture.subject.tenant !== void 0) settings.push([`${subject.gucPrefix}.${subject.tenantClaim}`, fixture.subject.tenant]);
|
|
798
|
+
return settings;
|
|
799
|
+
}
|
|
800
|
+
const claims = {
|
|
801
|
+
...fixture.subject.claims,
|
|
802
|
+
sub: fixture.subject.id,
|
|
803
|
+
role: "authenticated",
|
|
804
|
+
[subject.roleClaim]: roles.length === 1 ? roles[0] : roles,
|
|
805
|
+
[subject.tenantClaim]: fixture.subject.tenant,
|
|
806
|
+
memberships
|
|
807
|
+
};
|
|
808
|
+
return [["request.jwt.claims", JSON.stringify(claims)]];
|
|
809
|
+
}
|
|
810
|
+
/** The `custom_role_*` rows for the fixtures' custom roles; existing rows are kept. */
|
|
811
|
+
function customRoleRows(schema, customRoles, scopes) {
|
|
812
|
+
const table = (name) => `${quoteIdent(schema)}.${quoteIdent(name)}`;
|
|
813
|
+
const rows = [];
|
|
814
|
+
for (const role of customRoles) {
|
|
815
|
+
const at = customRoleAt(role, scopes);
|
|
816
|
+
if (at.scope === void 0) continue;
|
|
817
|
+
for (const grant of role.grants ?? []) rows.push({
|
|
818
|
+
sql: grant.level === void 0 ? `insert into ${table("custom_role_permissions")} (tenant_id, scope, scope_id, role, permission, effect) values ($1, $2, $3, $4, $5, $6) on conflict do nothing` : `insert into ${table("custom_role_permissions")} (tenant_id, scope, scope_id, role, permission, effect, level) values ($1, $2, $3, $4, $5, $6, $7) on conflict do nothing`,
|
|
819
|
+
values: [
|
|
820
|
+
role.tenant ?? null,
|
|
821
|
+
at.scope,
|
|
822
|
+
at.id ?? null,
|
|
823
|
+
role.name,
|
|
824
|
+
grant.permission,
|
|
825
|
+
grant.effect ?? "allow",
|
|
826
|
+
...grant.level === void 0 ? [] : [grant.level]
|
|
827
|
+
]
|
|
828
|
+
});
|
|
829
|
+
for (const name of role.includes ?? []) rows.push({
|
|
830
|
+
sql: `insert into ${table("custom_role_includes")} (tenant_id, scope, scope_id, role, include_role) values ($1, $2, $3, $4, $5) on conflict do nothing`,
|
|
831
|
+
values: [
|
|
832
|
+
role.tenant ?? null,
|
|
833
|
+
at.scope,
|
|
834
|
+
at.id ?? null,
|
|
835
|
+
role.name,
|
|
836
|
+
name
|
|
837
|
+
]
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
return rows;
|
|
841
|
+
}
|
|
842
|
+
function seedsCustomRoleTables(config) {
|
|
843
|
+
const rls = config.rls;
|
|
844
|
+
return customRolesOn(rls) && (rls?.authorize ?? rls?.rbac?.authorize ?? "jwt") === "database";
|
|
845
|
+
}
|
|
846
|
+
function customRoleSchema(config) {
|
|
847
|
+
return config.rls?.schema ?? config.rls?.rbac?.schema ?? "permdock";
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* A pgTAP script that runs each fixture's statement under the fixture's
|
|
851
|
+
* subject and asserts the outcome `can()` gave in-process.
|
|
852
|
+
*/
|
|
853
|
+
function emitPgtap(input) {
|
|
854
|
+
const scopes = scopeList(input.policy.scopes);
|
|
855
|
+
const subject = subjectOf(input.config, input.customRoles, scopes);
|
|
856
|
+
const lines = [
|
|
857
|
+
"begin;",
|
|
858
|
+
`select plan(${String(input.fixtures.length)});`,
|
|
859
|
+
PGTAP_DECISION_SQL
|
|
860
|
+
];
|
|
861
|
+
if (seedsCustomRoleTables(input.config)) for (const row of customRoleRows(customRoleSchema(input.config), input.customRoles, scopes)) lines.push(`${inlineStatement(row)};`);
|
|
862
|
+
for (const [index, fixture] of input.fixtures.entries()) {
|
|
863
|
+
const permission = findPermission(input.policy.permissions, fixture.action);
|
|
864
|
+
const status = input.inProcess[index];
|
|
865
|
+
const label = `fixture ${String(index)} ${fixture.action}`;
|
|
866
|
+
if (permission === void 0 || status === void 0) continue;
|
|
867
|
+
if (status.kind === "opaque") {
|
|
868
|
+
lines.push(`select skip(${quoteLiteral(`${label}: opaque grant untestable app-side`)}, 1);`);
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const outcome = status.granted ? "granted" : "denied";
|
|
872
|
+
const table = input.config.rls?.tables?.[permission.resource] ?? permission.resource;
|
|
873
|
+
const statement = inlineStatement(statementFor(fixture, permission.action, table));
|
|
874
|
+
lines.push(`-- ${label}`, "savepoint permdock_fixture;", "set local role \"authenticated\";", ...subjectSettings(fixture, subject).map(([name, value]) => `select set_config(${quoteLiteral(name)}, ${quoteLiteral(value)}, true);`), `select is(${PGTAP_DECISION}(${quoteLiteral(statement)}), ${quoteLiteral(outcome)}, ${quoteLiteral(`${label} ${outcome}`)});`, "rollback to savepoint permdock_fixture;");
|
|
875
|
+
}
|
|
876
|
+
lines.push("select * from finish();", "rollback;");
|
|
877
|
+
return `${lines.join("\n")}\n`;
|
|
878
|
+
}
|
|
879
|
+
//#endregion
|
|
880
|
+
//#region src/cli/rls/graph.ts
|
|
881
|
+
function tableFor(resource, tables) {
|
|
882
|
+
return tables?.[resource] ?? resource;
|
|
883
|
+
}
|
|
884
|
+
/** Every `related` condition the policy's grants compile to, with the resource graph they need, groups included. */
|
|
885
|
+
function graphPlan(policy) {
|
|
886
|
+
const scopes = scopeList(policy.scopes);
|
|
887
|
+
const plan = /* @__PURE__ */ new Map();
|
|
888
|
+
const entryFor = (node) => {
|
|
889
|
+
const entry = plan.get(node.name) ?? {
|
|
890
|
+
node,
|
|
891
|
+
relations: /* @__PURE__ */ new Set(),
|
|
892
|
+
links: /* @__PURE__ */ new Set()
|
|
893
|
+
};
|
|
894
|
+
plan.set(node.name, entry);
|
|
895
|
+
return entry;
|
|
896
|
+
};
|
|
897
|
+
const addRelation = (node, relation) => {
|
|
898
|
+
const entry = entryFor(node);
|
|
899
|
+
if (entry.relations.has(relation)) return;
|
|
900
|
+
entry.relations.add(relation);
|
|
901
|
+
for (const name of expandRelation(node, relation)) {
|
|
902
|
+
const spec = node.relations[name];
|
|
903
|
+
if (!isEdgeRelation(spec) || spec.groups === void 0) continue;
|
|
904
|
+
for (const { resource, relation: groupRelation } of groupEntries(spec)) {
|
|
905
|
+
const target = policy.resources.get(resource);
|
|
906
|
+
if (target !== void 0 && target.name !== node.name) addRelation(target, groupRelation);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
const addLinks = (hops) => {
|
|
911
|
+
for (let index = 1; index < hops.length; index += 1) {
|
|
912
|
+
const from = policy.resources.get(hops[index - 1]?.resource ?? "");
|
|
913
|
+
const hop = hops[index];
|
|
914
|
+
if (from !== void 0 && hop !== void 0) entryFor(from).links.add(hop.link);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
const addAbove = (above) => {
|
|
918
|
+
const node = above === void 0 ? void 0 : policy.resources.get(above.resource);
|
|
919
|
+
if (above === void 0 || node === void 0) return;
|
|
920
|
+
const entry = entryFor(node);
|
|
921
|
+
entry.closure = Math.max(entry.closure ?? 0, above.depth);
|
|
922
|
+
entry.restrictedAncestors = above;
|
|
923
|
+
};
|
|
924
|
+
for (const grant of policy.grants) {
|
|
925
|
+
const row = policy.resources.get(grant.permission.resource);
|
|
926
|
+
for (const item of flattenGrantee(grant.to)) {
|
|
927
|
+
if (item.kind === "inherit") {
|
|
928
|
+
const inherited = inheritCondition(item, row, policy.resources);
|
|
929
|
+
addLinks(inherited?.hops ?? []);
|
|
930
|
+
addAbove(inherited?.restrictedAncestors);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (item.kind !== "relation") continue;
|
|
934
|
+
const condition = relationCondition(item, row, scopes, { resources: policy.resources });
|
|
935
|
+
if (condition?.op !== "related") continue;
|
|
936
|
+
const node = policy.resources.get(condition.resource);
|
|
937
|
+
if (node === void 0) continue;
|
|
938
|
+
addRelation(node, condition.relation);
|
|
939
|
+
const entry = entryFor(node);
|
|
940
|
+
if (condition.depth > 0 && isSelfParented(node)) entry.closure = Math.max(entry.closure ?? 0, condition.depth);
|
|
941
|
+
addLinks(condition.hops ?? []);
|
|
942
|
+
addAbove(condition.restrictedAncestors);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return plan;
|
|
946
|
+
}
|
|
947
|
+
/** The closure depth per walked resource, as `RlsSqlContext.graph` carries it. */
|
|
948
|
+
function closureDepths(plan) {
|
|
949
|
+
const out = {};
|
|
950
|
+
for (const [name, entry] of plan) if (entry.closure !== void 0) out[name] = entry.closure;
|
|
951
|
+
return out;
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* The `permitted_<resource>_ids(relation)` arm for one concrete relation:
|
|
955
|
+
* ids of `resource` the subject holds it on, directly or through a group.
|
|
956
|
+
* `asked` are the relation names that include it, so a call for any of them
|
|
957
|
+
* (or for all, with null) reads this arm.
|
|
958
|
+
*/
|
|
959
|
+
function relationArm(ctx, plan, node, relation, asked, tables) {
|
|
960
|
+
const spec = node.relations[relation];
|
|
961
|
+
if (!isEdgeRelation(spec) && !isPrincipalRelation(spec) && (!isFieldRelation(spec) || spec.memberOf !== void 0)) throw configError(`PermDock CLI: relation '${relation}' on ${node.name} is not a graph relation RLS can compile`);
|
|
962
|
+
const guard = asked.length === 1 ? `(p_relation is null or p_relation = ${quoteLiteral(asked[0] ?? relation)})` : `(p_relation is null or p_relation in (${asked.map(quoteLiteral).join(", ")}))`;
|
|
963
|
+
return ` select a.id from (${graphSqlText(relationArmSql(node.name, relation, {
|
|
964
|
+
resources: new Map([...plan].map(([name, item]) => [name, item.node])),
|
|
965
|
+
...tables === void 0 ? {} : { tables },
|
|
966
|
+
qualify: qualifySqlTable,
|
|
967
|
+
holders: (resource, groupRelation) => [{ text: `select ${qualified(ctx, graphHelper(resource))}(${quoteLiteral(groupRelation)})` }]
|
|
968
|
+
}), ctx)}) a\n where ${guard}${activeUserSql(ctx).map((part) => ` and ${part}`).join("")}`;
|
|
969
|
+
}
|
|
970
|
+
function permittedSql(ctx, plan, entry, tables) {
|
|
971
|
+
const fn = qualified(ctx, graphHelper(entry.node.name));
|
|
972
|
+
const asked = [...entry.relations].toSorted();
|
|
973
|
+
const arms = [...new Set(asked.flatMap((name) => expandRelation(entry.node, name)))].toSorted().map((relation) => relationArm(ctx, plan, entry.node, relation, asked.filter((name) => expandRelation(entry.node, name).includes(relation)), tables));
|
|
974
|
+
if (arms.length === 0) arms.push(" select null::text where false");
|
|
975
|
+
return `-- ${entry.node.name}: ids the subject holds a relation on (all relations when p_relation is null)
|
|
976
|
+
create or replace function ${fn}(p_relation text)
|
|
977
|
+
returns setof text
|
|
978
|
+
language sql
|
|
979
|
+
stable
|
|
980
|
+
security definer
|
|
981
|
+
set search_path = ''
|
|
982
|
+
as $$
|
|
983
|
+
${arms.join("\n union\n")}
|
|
984
|
+
$$;
|
|
985
|
+
revoke execute on function ${fn}(text) from public, anon;
|
|
986
|
+
grant execute on function ${fn}(text) to authenticated;`;
|
|
987
|
+
}
|
|
988
|
+
/** `permdock_link_<resource>_<link>(ids)`: ids of `resource` whose link points into `ids`, read past the table's own policies. */
|
|
989
|
+
function linkSql(ctx, node, link, tables) {
|
|
990
|
+
const spec = Object.hasOwn(node.links, link) ? node.links[link] : void 0;
|
|
991
|
+
if (spec === void 0) throw configError(`PermDock CLI: ${node.name} has no link '${link}'`);
|
|
992
|
+
const fn = qualified(ctx, linkHelper(node.name, link));
|
|
993
|
+
return `-- ${node.name}.${link}: rows whose ${spec.field} points at one of the given ${spec.resource} ids
|
|
994
|
+
create or replace function ${fn}(p_ids text[])
|
|
995
|
+
returns setof text
|
|
996
|
+
language sql
|
|
997
|
+
stable
|
|
998
|
+
security definer
|
|
999
|
+
set search_path = ''
|
|
1000
|
+
as $$
|
|
1001
|
+
select t.${quoteIdent(node.id)}::text from ${quoteQualified(tableFor(node.name, tables))} t
|
|
1002
|
+
where t.${quoteIdent(spec.field)}::text = any(p_ids)
|
|
1003
|
+
$$;
|
|
1004
|
+
revoke execute on function ${fn}(text[]) from public, anon;
|
|
1005
|
+
grant execute on function ${fn}(text[]) to authenticated;`;
|
|
1006
|
+
}
|
|
1007
|
+
function closureTableSql(ctx) {
|
|
1008
|
+
const table = qualified(ctx, CLOSURE.table);
|
|
1009
|
+
return `-- the object graph: one row per (ancestor, descendant) within each walked resource's depth,
|
|
1010
|
+
-- never crossing a restricted row; kept current by statement triggers on each self-parented table
|
|
1011
|
+
create table if not exists ${table} (
|
|
1012
|
+
resource text not null,
|
|
1013
|
+
ancestor text not null,
|
|
1014
|
+
descendant text not null,
|
|
1015
|
+
depth integer not null check (depth >= 0),
|
|
1016
|
+
primary key (resource, descendant, ancestor)
|
|
1017
|
+
);
|
|
1018
|
+
create index if not exists ${quoteIdent(`${CLOSURE.table}_ancestor`)} on ${table} (resource, ancestor);
|
|
1019
|
+
alter table ${table} enable row level security;
|
|
1020
|
+
revoke all on table ${table} from anon, authenticated, public;
|
|
1021
|
+
grant select on table ${table} to authenticated;`;
|
|
1022
|
+
}
|
|
1023
|
+
function closureObjectsSql(ctx, entry, depth, tables) {
|
|
1024
|
+
const { node } = entry;
|
|
1025
|
+
const parent = node.parent;
|
|
1026
|
+
if (parent === void 0) return "";
|
|
1027
|
+
const name = node.name;
|
|
1028
|
+
const sqlName = graphSqlName(name) ?? name;
|
|
1029
|
+
const closure = qualified(ctx, CLOSURE.table);
|
|
1030
|
+
const table = quoteQualified(tableFor(name, tables));
|
|
1031
|
+
const id = quoteIdent(node.id);
|
|
1032
|
+
const up = quoteIdent(parent.field);
|
|
1033
|
+
const restricted = restrictedFor(node, "parent");
|
|
1034
|
+
const stop = restricted === void 0 ? "false" : `coalesce(t.${quoteIdent(restricted)}, false)`;
|
|
1035
|
+
const stopParent = restricted === void 0 ? "false" : `coalesce(p.${quoteIdent(restricted)}, false)`;
|
|
1036
|
+
const refresh = qualified(ctx, `${CLOSURE.table}_${sqlName}`);
|
|
1037
|
+
const changed = qualified(ctx, `${CLOSURE.table}_${sqlName}_changed`);
|
|
1038
|
+
const literal = quoteLiteral(name);
|
|
1039
|
+
const restrictedChanged = restricted === void 0 ? "" : ` or n.${quoteIdent(restricted)} is distinct from o.${quoteIdent(restricted)}`;
|
|
1040
|
+
const trigger = (suffix) => quoteIdent(`${CLOSURE.table}_${sqlName}_${suffix}`);
|
|
1041
|
+
return `-- ${name}: ${restricted === void 0 ? `rows reach their ancestors up to depth ${String(depth)}` : `rows reach their ancestors up to depth ${String(depth)}, stopping at a restricted row`}
|
|
1042
|
+
drop policy if exists ${quoteIdent(`${CLOSURE.table}_${sqlName}`)} on ${closure};
|
|
1043
|
+
create policy ${quoteIdent(`${CLOSURE.table}_${sqlName}`)} on ${closure}
|
|
1044
|
+
for select to authenticated
|
|
1045
|
+
using (resource = ${literal} and ancestor in (select ${qualified(ctx, graphHelper(name))}(null)));
|
|
1046
|
+
create or replace function ${refresh}(p_ids text[])
|
|
1047
|
+
returns void
|
|
1048
|
+
language plpgsql
|
|
1049
|
+
security definer
|
|
1050
|
+
set search_path = ''
|
|
1051
|
+
as $$
|
|
1052
|
+
declare
|
|
1053
|
+
v_cycle text;
|
|
1054
|
+
v_affected text[];
|
|
1055
|
+
begin
|
|
1056
|
+
with recursive walk(start, next, path) as (
|
|
1057
|
+
select t.${id}, t.${up}, array[t.${id}::text]
|
|
1058
|
+
from ${table} t
|
|
1059
|
+
where t.${id}::text = any(p_ids)
|
|
1060
|
+
union all
|
|
1061
|
+
select walk.start, t.${up}, walk.path || t.${id}::text
|
|
1062
|
+
from walk join ${table} t on t.${id} = walk.next
|
|
1063
|
+
where not (walk.next::text = any(walk.path))
|
|
1064
|
+
)
|
|
1065
|
+
select walk.start::text into v_cycle
|
|
1066
|
+
from walk
|
|
1067
|
+
where walk.next::text = any(walk.path)
|
|
1068
|
+
limit 1;
|
|
1069
|
+
if v_cycle is not null then
|
|
1070
|
+
raise exception 'permdock: % % is its own ancestor', ${literal}, v_cycle
|
|
1071
|
+
using errcode = 'check_violation';
|
|
1072
|
+
end if;
|
|
1073
|
+
with recursive down(id, level) as (
|
|
1074
|
+
select t.${id}, 0
|
|
1075
|
+
from ${table} t
|
|
1076
|
+
where t.${id}::text = any(p_ids) or t.${up}::text = any(p_ids)
|
|
1077
|
+
union
|
|
1078
|
+
select t.${id}, down.level + 1
|
|
1079
|
+
from down join ${table} t on t.${up} = down.id
|
|
1080
|
+
where down.level < ${String(depth)}
|
|
1081
|
+
)
|
|
1082
|
+
select coalesce(array_agg(distinct down.id::text), '{}') into v_affected from down;
|
|
1083
|
+
delete from ${closure} c
|
|
1084
|
+
where c.resource = ${literal}
|
|
1085
|
+
and (c.descendant = any(p_ids) or c.descendant = any(v_affected));
|
|
1086
|
+
insert into ${closure} (resource, ancestor, descendant, depth)
|
|
1087
|
+
with recursive walk(descendant, ancestor, parent, depth, stop) as (
|
|
1088
|
+
select t.${id}::text, t.${id}, t.${up}, 0, ${stop}
|
|
1089
|
+
from ${table} t
|
|
1090
|
+
where t.${id}::text = any(v_affected)
|
|
1091
|
+
union all
|
|
1092
|
+
select walk.descendant, p.${id}, p.${up}, walk.depth + 1, ${stopParent}
|
|
1093
|
+
from walk join ${table} p on p.${id} = walk.parent
|
|
1094
|
+
where not walk.stop and walk.depth < ${String(depth)}
|
|
1095
|
+
)
|
|
1096
|
+
select ${literal}, walk.ancestor::text, walk.descendant, walk.depth from walk
|
|
1097
|
+
on conflict do nothing;
|
|
1098
|
+
end
|
|
1099
|
+
$$;
|
|
1100
|
+
revoke execute on function ${refresh}(text[]) from public, anon, authenticated;
|
|
1101
|
+
create or replace function ${changed}()
|
|
1102
|
+
returns trigger
|
|
1103
|
+
language plpgsql
|
|
1104
|
+
security definer
|
|
1105
|
+
set search_path = ''
|
|
1106
|
+
as $$
|
|
1107
|
+
begin
|
|
1108
|
+
if tg_op = 'TRUNCATE' then
|
|
1109
|
+
delete from ${closure} where resource = ${literal};
|
|
1110
|
+
elsif tg_op = 'INSERT' then
|
|
1111
|
+
perform ${refresh}(array(select n.${id}::text from new_rows n));
|
|
1112
|
+
elsif tg_op = 'DELETE' then
|
|
1113
|
+
perform ${refresh}(array(select o.${id}::text from old_rows o));
|
|
1114
|
+
else
|
|
1115
|
+
perform ${refresh}(array(
|
|
1116
|
+
select n.${id}::text
|
|
1117
|
+
from new_rows n left join old_rows o on o.${id} = n.${id}
|
|
1118
|
+
where o.${id} is null or n.${up} is distinct from o.${up}${restrictedChanged}
|
|
1119
|
+
union
|
|
1120
|
+
select o.${id}::text
|
|
1121
|
+
from old_rows o left join new_rows n on n.${id} = o.${id}
|
|
1122
|
+
where n.${id} is null
|
|
1123
|
+
));
|
|
1124
|
+
end if;
|
|
1125
|
+
return null;
|
|
1126
|
+
end
|
|
1127
|
+
$$;
|
|
1128
|
+
revoke execute on function ${changed}() from public, anon, authenticated;
|
|
1129
|
+
drop trigger if exists ${trigger("insert")} on ${table};
|
|
1130
|
+
create trigger ${trigger("insert")} after insert on ${table}
|
|
1131
|
+
referencing new table as new_rows
|
|
1132
|
+
for each statement execute function ${changed}();
|
|
1133
|
+
drop trigger if exists ${trigger("update")} on ${table};
|
|
1134
|
+
create trigger ${trigger("update")} after update on ${table}
|
|
1135
|
+
referencing old table as old_rows new table as new_rows
|
|
1136
|
+
for each statement execute function ${changed}();
|
|
1137
|
+
drop trigger if exists ${trigger("delete")} on ${table};
|
|
1138
|
+
create trigger ${trigger("delete")} after delete on ${table}
|
|
1139
|
+
referencing old table as old_rows
|
|
1140
|
+
for each statement execute function ${changed}();
|
|
1141
|
+
drop trigger if exists ${trigger("truncate")} on ${table};
|
|
1142
|
+
create trigger ${trigger("truncate")} after truncate on ${table}
|
|
1143
|
+
for each statement execute function ${changed}();
|
|
1144
|
+
select ${refresh}(array(select t.${id}::text from ${table} t));`;
|
|
1145
|
+
}
|
|
1146
|
+
function restrictedRowsHelperSql(ctx, plan, entry, tables) {
|
|
1147
|
+
const above = entry.restrictedAncestors;
|
|
1148
|
+
if (above === void 0) return "";
|
|
1149
|
+
const fn = qualified(ctx, restrictedHelper(entry.node.name));
|
|
1150
|
+
const body = graphSqlText(restrictedRowsSql(above, {
|
|
1151
|
+
resources: new Map([...plan].map(([name, item]) => [name, item.node])),
|
|
1152
|
+
...tables === void 0 ? {} : { tables },
|
|
1153
|
+
qualify: qualifySqlTable,
|
|
1154
|
+
closure: `${helperSchema(ctx)}.${CLOSURE.table}`,
|
|
1155
|
+
closureDepths: closureDepths(plan)
|
|
1156
|
+
}), ctx);
|
|
1157
|
+
return `-- ${entry.node.name}: rows a stopped link does not reach below: restricted, under a restricted row within ${String(above.depth - 1)} levels, or deeper
|
|
1158
|
+
create or replace function ${fn}()
|
|
1159
|
+
returns setof text
|
|
1160
|
+
language sql
|
|
1161
|
+
stable
|
|
1162
|
+
security definer
|
|
1163
|
+
set search_path = ''
|
|
1164
|
+
as $$
|
|
1165
|
+
${body}
|
|
1166
|
+
$$;
|
|
1167
|
+
revoke execute on function ${fn}() from public, anon;
|
|
1168
|
+
grant execute on function ${fn}() to authenticated;`;
|
|
1169
|
+
}
|
|
1170
|
+
/** Group resources a resource's asked relations name, other than itself. */
|
|
1171
|
+
function groupTargets(entry) {
|
|
1172
|
+
const out = /* @__PURE__ */ new Set();
|
|
1173
|
+
for (const asked of entry.relations) for (const name of expandRelation(entry.node, asked)) {
|
|
1174
|
+
const spec = entry.node.relations[name];
|
|
1175
|
+
const resources = isEdgeRelation(spec) ? groupEntries(spec).map((group) => group.resource) : [];
|
|
1176
|
+
for (const resource of resources) if (resource !== entry.node.name) out.add(resource);
|
|
1177
|
+
}
|
|
1178
|
+
return [...out].toSorted();
|
|
1179
|
+
}
|
|
1180
|
+
/** Plan entries by name, each after the group resources its helper calls (SQL function bodies are checked on create). */
|
|
1181
|
+
function dependencyOrder(plan) {
|
|
1182
|
+
const out = [];
|
|
1183
|
+
const done = /* @__PURE__ */ new Set();
|
|
1184
|
+
const visit = (name) => {
|
|
1185
|
+
const entry = plan.get(name);
|
|
1186
|
+
if (entry === void 0 || done.has(name)) return;
|
|
1187
|
+
done.add(name);
|
|
1188
|
+
for (const target of groupTargets(entry)) visit(target);
|
|
1189
|
+
out.push(entry);
|
|
1190
|
+
};
|
|
1191
|
+
for (const name of [...plan.keys()].toSorted()) visit(name);
|
|
1192
|
+
return out;
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* The graph preamble: `permitted_<resource>_ids` for every resource a graph
|
|
1196
|
+
* grant reads, and the closure table with its triggers for every
|
|
1197
|
+
* self-parented resource a grant walks. Empty when no grant is a graph grant.
|
|
1198
|
+
*/
|
|
1199
|
+
function graphSql(ctx, plan, tables) {
|
|
1200
|
+
if (plan.size === 0) return "";
|
|
1201
|
+
const sqlNames = /* @__PURE__ */ new Map();
|
|
1202
|
+
for (const name of plan.keys()) {
|
|
1203
|
+
const helper = graphHelper(name);
|
|
1204
|
+
const sql = graphSqlName(name) ?? name;
|
|
1205
|
+
if (ctx.scopes.some((scope) => scope.name === sql)) throw configError(`PermDock CLI: resource '${name}' has graph relations and shares its SQL name with the ${sql} scope, so ${helper} would replace the scope helper; rename the resource (permdock doctor PD032)`);
|
|
1206
|
+
const other = sqlNames.get(sql);
|
|
1207
|
+
if (other !== void 0) throw configError(`PermDock CLI: resources '${other}' and '${name}' both name the SQL helper ${helper}; rename one (permdock doctor PD032)`);
|
|
1208
|
+
sqlNames.set(sql, name);
|
|
1209
|
+
}
|
|
1210
|
+
const entries = dependencyOrder(plan);
|
|
1211
|
+
const chunks = entries.flatMap((entry) => [...entry.relations.size === 0 && entry.closure === void 0 ? [] : [permittedSql(ctx, plan, entry, tables)], ...[...entry.links].toSorted().map((link) => linkSql(ctx, entry.node, link, tables))]);
|
|
1212
|
+
const walked = entries.filter((entry) => entry.closure !== void 0);
|
|
1213
|
+
if (walked.length > 0) {
|
|
1214
|
+
chunks.push(closureTableSql(ctx));
|
|
1215
|
+
for (const entry of walked) chunks.push(closureObjectsSql(ctx, entry, entry.closure ?? 0, tables));
|
|
1216
|
+
for (const entry of walked) {
|
|
1217
|
+
const helper = restrictedRowsHelperSql(ctx, plan, entry, tables);
|
|
1218
|
+
if (helper !== "") chunks.push(helper);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return `${chunks.join("\n\n")}\n`;
|
|
1222
|
+
}
|
|
1223
|
+
//#endregion
|
|
1224
|
+
//#region src/cli/rls/verify-tree.ts
|
|
1225
|
+
const SYSTEM_ENTROPY = Object.freeze({
|
|
1226
|
+
now: () => nowMillis(),
|
|
1227
|
+
randomUUID: () => randomUUID()
|
|
1228
|
+
});
|
|
1229
|
+
const SUBJECTS = 3;
|
|
1230
|
+
function idFactory(node, entropy) {
|
|
1231
|
+
const type = columnTypesOf(jsonSchemaOf(node))[node.id];
|
|
1232
|
+
let next = 9e5;
|
|
1233
|
+
return type === "numeric" ? () => {
|
|
1234
|
+
next += 1;
|
|
1235
|
+
return next;
|
|
1236
|
+
} : () => entropy.randomUUID();
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Two spines one level deeper than the walk depth, so the depth cap is
|
|
1240
|
+
* crossed; side branches under the first levels alternate restricted, and
|
|
1241
|
+
* the second spine is restricted two levels down.
|
|
1242
|
+
*/
|
|
1243
|
+
function generateTree(node, depth, entropy) {
|
|
1244
|
+
const id = idFactory(node, entropy);
|
|
1245
|
+
const nodes = [];
|
|
1246
|
+
for (const spine of [0, 1]) {
|
|
1247
|
+
let parent = null;
|
|
1248
|
+
for (let level = 0; level <= depth + 1; level += 1) {
|
|
1249
|
+
const current = id();
|
|
1250
|
+
nodes.push({
|
|
1251
|
+
id: current,
|
|
1252
|
+
parent,
|
|
1253
|
+
restricted: node.restricted !== void 0 && spine === 1 && level === 2
|
|
1254
|
+
});
|
|
1255
|
+
if (spine === 0 && level >= 1 && level <= 3) {
|
|
1256
|
+
const side = id();
|
|
1257
|
+
nodes.push({
|
|
1258
|
+
id: side,
|
|
1259
|
+
parent: current,
|
|
1260
|
+
restricted: node.restricted !== void 0 && level % 2 === 1
|
|
1261
|
+
});
|
|
1262
|
+
nodes.push({
|
|
1263
|
+
id: id(),
|
|
1264
|
+
parent: side,
|
|
1265
|
+
restricted: false
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
parent = current;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return nodes;
|
|
1272
|
+
}
|
|
1273
|
+
function insertSql(table, rows) {
|
|
1274
|
+
const columns = Object.keys(rows[0] ?? {});
|
|
1275
|
+
const values = [];
|
|
1276
|
+
const tuples = rows.map((row) => `(${columns.map((column) => {
|
|
1277
|
+
values.push(row[column]);
|
|
1278
|
+
return `$${String(values.length)}`;
|
|
1279
|
+
}).join(", ")})`);
|
|
1280
|
+
return {
|
|
1281
|
+
sql: `insert into ${quoteQualified(table)} (${columns.map(quoteIdent).join(", ")}) values ${tuples.join(", ")} returning *`,
|
|
1282
|
+
values
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
const REQUIRED_COLUMNS_SQL = `select a.attname as name,
|
|
1286
|
+
format_type(a.atttypid, a.atttypmod) as type,
|
|
1287
|
+
k.confrelid::regclass::text as "refTable",
|
|
1288
|
+
r.attname as "refColumn",
|
|
1289
|
+
(select e.enumlabel from pg_enum e where e.enumtypid = a.atttypid order by e.enumsortorder limit 1) as "enumLabel",
|
|
1290
|
+
array(
|
|
1291
|
+
select pg_get_constraintdef(c.oid) from pg_constraint c
|
|
1292
|
+
where c.conrelid = a.attrelid and c.contype = 'c' and c.conkey = array[a.attnum]
|
|
1293
|
+
order by c.conname
|
|
1294
|
+
) as checks
|
|
1295
|
+
from pg_attribute a
|
|
1296
|
+
left join pg_constraint k
|
|
1297
|
+
on k.conrelid = a.attrelid and k.contype = 'f' and k.conkey = array[a.attnum]
|
|
1298
|
+
left join pg_attribute r
|
|
1299
|
+
on r.attrelid = k.confrelid and r.attnum = k.confkey[1]
|
|
1300
|
+
where a.attrelid = $1::regclass and a.attnum > 0 and not a.attisdropped
|
|
1301
|
+
and a.attnotnull and not a.atthasdef
|
|
1302
|
+
and a.attidentity = '' and a.attgenerated = ''`;
|
|
1303
|
+
const UNIQUE_COLUMNS_SQL = `select distinct a.attname as name
|
|
1304
|
+
from pg_index i
|
|
1305
|
+
join pg_attribute a on a.attrelid = i.indrelid
|
|
1306
|
+
where i.indrelid = $1::regclass and i.indisunique
|
|
1307
|
+
and a.attnum > 0 and not a.attisdropped
|
|
1308
|
+
and (
|
|
1309
|
+
a.attnum = any(i.indkey)
|
|
1310
|
+
or exists (
|
|
1311
|
+
select 1 from pg_depend d
|
|
1312
|
+
where d.classid = 'pg_class'::regclass and d.objid = i.indexrelid
|
|
1313
|
+
and d.refclassid = 'pg_class'::regclass and d.refobjid = i.indrelid
|
|
1314
|
+
and d.refobjsubid = a.attnum
|
|
1315
|
+
)
|
|
1316
|
+
)`;
|
|
1317
|
+
function checkedValue(checks) {
|
|
1318
|
+
for (const check of checks) {
|
|
1319
|
+
const literal = /'((?:[^']|'')*)'/u.exec(check);
|
|
1320
|
+
if (literal?.[1] !== void 0 && /[=]|\bin\b/iu.test(check)) return literal[1].replaceAll("''", "'");
|
|
1321
|
+
const bound = /(>=?)\s*\(?(-?\d+(?:\.\d+)?)/u.exec(check);
|
|
1322
|
+
if (bound?.[1] !== void 0 && bound[2] !== void 0) {
|
|
1323
|
+
const value = Number(bound[2]);
|
|
1324
|
+
return bound[1] === ">" ? Math.floor(value) + 1 : value;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
function placeholder(column, entropy) {
|
|
1329
|
+
if (column.enumLabel !== null) return column.enumLabel;
|
|
1330
|
+
const checked = checkedValue(column.checks);
|
|
1331
|
+
if (checked !== void 0) return checked;
|
|
1332
|
+
const type = column.type;
|
|
1333
|
+
if (type === "uuid") return entropy.randomUUID();
|
|
1334
|
+
if (type === "boolean") return false;
|
|
1335
|
+
if (/^(smallint|integer|bigint|numeric|real|double precision)/u.test(type)) return 0;
|
|
1336
|
+
if (type.startsWith("timestamp") || type === "date") return isoAt(entropy.now());
|
|
1337
|
+
if (type === "json" || type === "jsonb") return {};
|
|
1338
|
+
return "permdock-tree";
|
|
1339
|
+
}
|
|
1340
|
+
function distinctPlaceholder(column, index, entropy) {
|
|
1341
|
+
const value = placeholder(column, entropy);
|
|
1342
|
+
if (column.enumLabel !== null) return value;
|
|
1343
|
+
if (typeof value === "number") return value + index;
|
|
1344
|
+
if (typeof value !== "string" || checkedValue(column.checks) !== void 0) return value;
|
|
1345
|
+
if (column.type === "uuid") return value;
|
|
1346
|
+
if (column.type.startsWith("timestamp") || column.type === "date") return isoAt(Date.parse(value) + index * 864e5);
|
|
1347
|
+
return `${value}-${String(index + 1)}`;
|
|
1348
|
+
}
|
|
1349
|
+
function asRequiredColumn(row) {
|
|
1350
|
+
const { name, type, refTable, refColumn, enumLabel, checks } = row;
|
|
1351
|
+
if (typeof name !== "string" || typeof type !== "string") return;
|
|
1352
|
+
return {
|
|
1353
|
+
name,
|
|
1354
|
+
type,
|
|
1355
|
+
refTable: typeof refTable === "string" ? refTable : null,
|
|
1356
|
+
refColumn: typeof refColumn === "string" ? refColumn : null,
|
|
1357
|
+
enumLabel: typeof enumLabel === "string" ? enumLabel : null,
|
|
1358
|
+
checks: Array.isArray(checks) ? checks.filter((check) => typeof check === "string") : []
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Values for the required columns a generated row leaves out, such as a
|
|
1363
|
+
* tenant column or a name: an existing value of the referenced table for a
|
|
1364
|
+
* foreign key, a placeholder of the column's type otherwise.
|
|
1365
|
+
*/
|
|
1366
|
+
async function requiredValues(query, table, set, entropy) {
|
|
1367
|
+
const columns = (await query(REQUIRED_COLUMNS_SQL, [table])).rows.map(asRequiredColumn).filter((column) => column !== void 0 && !set.has(column.name));
|
|
1368
|
+
const unique = new Set(columns.length === 0 ? [] : (await query(UNIQUE_COLUMNS_SQL, [table])).rows.map((row) => String(row["name"])));
|
|
1369
|
+
const values = {};
|
|
1370
|
+
const distinct = [];
|
|
1371
|
+
for (const column of columns) if (column.refTable !== null && column.refColumn !== null) {
|
|
1372
|
+
const existing = await query(`select ${quoteIdent(column.refColumn)} as value from ${column.refTable} limit 1`);
|
|
1373
|
+
values[column.name] = existing.rows[0]?.["value"] ?? null;
|
|
1374
|
+
} else if (unique.has(column.name)) distinct.push(column);
|
|
1375
|
+
else values[column.name] = placeholder(column, entropy);
|
|
1376
|
+
return (index) => {
|
|
1377
|
+
const row = { ...values };
|
|
1378
|
+
for (const column of distinct) row[column.name] = distinctPlaceholder(column, index, entropy);
|
|
1379
|
+
return row;
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
/** Inserts `rows` after filling the required columns they leave out. */
|
|
1383
|
+
async function seedRows(query, table, rows, given, entropy) {
|
|
1384
|
+
const fill = await requiredValues(query, quoteQualified(table), /* @__PURE__ */ new Set([...Object.keys(rows[0] ?? {}), ...Object.keys(given)]), entropy);
|
|
1385
|
+
const inserted = insertSql(table, rows.map((row, index) => ({
|
|
1386
|
+
...fill(index),
|
|
1387
|
+
...given,
|
|
1388
|
+
...row
|
|
1389
|
+
})));
|
|
1390
|
+
return (await query(inserted.sql, inserted.values)).rows;
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Parity between `decide` and RLS on a generated object graph: for every
|
|
1394
|
+
* self-parented resource a graph grant walks, a tree with restricted
|
|
1395
|
+
* branches and a chain past the walk depth, child rows under it, and edges
|
|
1396
|
+
* for a few generated principals. Everything runs in one transaction that
|
|
1397
|
+
* is rolled back.
|
|
1398
|
+
*/
|
|
1399
|
+
async function verifyTree(input) {
|
|
1400
|
+
const { policy, query } = input;
|
|
1401
|
+
const entropy = input.entropy ?? SYSTEM_ENTROPY;
|
|
1402
|
+
const tables = input.config.rls?.tables;
|
|
1403
|
+
const treeValues = input.config.rls?.treeValues ?? {};
|
|
1404
|
+
const seed = (table, list) => seedRows(query, table, list, treeValues[table] ?? {}, entropy);
|
|
1405
|
+
const walked = [...graphPlan(policy).values()].filter((entry) => entry.closure !== void 0);
|
|
1406
|
+
if (walked.length === 0) return {
|
|
1407
|
+
checked: 0,
|
|
1408
|
+
granted: 0,
|
|
1409
|
+
mismatches: [],
|
|
1410
|
+
notes: ["no graph grant walks a self-parented resource"]
|
|
1411
|
+
};
|
|
1412
|
+
const subjects = Array.from({ length: SUBJECTS }, () => entropy.randomUUID());
|
|
1413
|
+
const rows = {};
|
|
1414
|
+
const edges = {};
|
|
1415
|
+
const now = entropy.now();
|
|
1416
|
+
for (const entry of walked) {
|
|
1417
|
+
const node = entry.node;
|
|
1418
|
+
const parent = node.parent;
|
|
1419
|
+
if (parent === void 0) continue;
|
|
1420
|
+
const tree = generateTree(node, entry.closure ?? 0, entropy);
|
|
1421
|
+
const seeded = tree.map((item, index) => {
|
|
1422
|
+
const row = {
|
|
1423
|
+
[node.id]: item.id,
|
|
1424
|
+
[parent.field]: item.parent
|
|
1425
|
+
};
|
|
1426
|
+
if (node.restricted !== void 0) row[node.restricted] = item.restricted;
|
|
1427
|
+
for (const name of entry.relations) {
|
|
1428
|
+
const spec = node.relations[name];
|
|
1429
|
+
const column = isFieldRelation(spec) ? spec.memberOf === void 0 ? spec.field : void 0 : isPrincipalRelation(spec) ? spec.principal : void 0;
|
|
1430
|
+
if (column !== void 0 && column !== parent.field) row[column] = index % 4 === 0 ? subjects[index % SUBJECTS] : entropy.randomUUID();
|
|
1431
|
+
}
|
|
1432
|
+
return row;
|
|
1433
|
+
});
|
|
1434
|
+
if ([...entry.relations].some((name) => {
|
|
1435
|
+
const spec = node.relations[name];
|
|
1436
|
+
return isPrincipalRelation(spec) && spec.principal === parent.field || isFieldRelation(spec) && spec.field === parent.field;
|
|
1437
|
+
})) subjects.push(...tree.filter((_, index) => index % 3 === 0).map((item) => String(item.id)));
|
|
1438
|
+
rows[node.name] = [...await seed(tables?.[node.name] ?? node.name, seeded)];
|
|
1439
|
+
for (const name of entry.relations) {
|
|
1440
|
+
const spec = node.relations[name];
|
|
1441
|
+
if (!isEdgeRelation(spec)) continue;
|
|
1442
|
+
const object = spec.object ?? `${node.name}_id`;
|
|
1443
|
+
const subject = spec.subject ?? "user_id";
|
|
1444
|
+
const list = [];
|
|
1445
|
+
const edgeRows = [];
|
|
1446
|
+
for (const [index, item] of tree.entries()) {
|
|
1447
|
+
const holder = subjects[index % SUBJECTS];
|
|
1448
|
+
if (holder === void 0 || index % 5 !== 1) continue;
|
|
1449
|
+
const expiresAt = spec.expiresAt !== void 0 && index % 10 === 6 ? now - 864e5 : void 0;
|
|
1450
|
+
edgeRows.push({
|
|
1451
|
+
[object]: item.id,
|
|
1452
|
+
[subject]: holder,
|
|
1453
|
+
...spec.expiresAt === void 0 ? {} : { [spec.expiresAt]: expiresAt === void 0 ? null : isoAt(expiresAt) }
|
|
1454
|
+
});
|
|
1455
|
+
list.push({
|
|
1456
|
+
id: String(item.id),
|
|
1457
|
+
principal: holder,
|
|
1458
|
+
...expiresAt === void 0 ? {} : { expiresAt: expiresAt / 1e3 }
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
if (edgeRows.length > 0) await seed(spec.edge, edgeRows);
|
|
1462
|
+
edges[node.name] = {
|
|
1463
|
+
...edges[node.name],
|
|
1464
|
+
[name]: list
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
for (const child of policy.resources.values()) {
|
|
1468
|
+
if (child.name === node.name || child.parent?.resource !== node.name) continue;
|
|
1469
|
+
const make = idFactory(child, entropy);
|
|
1470
|
+
const childRows = tree.map((item, index) => {
|
|
1471
|
+
const row = {
|
|
1472
|
+
[child.id]: make(),
|
|
1473
|
+
[child.parent?.field ?? ""]: item.id
|
|
1474
|
+
};
|
|
1475
|
+
if (child.restricted !== void 0) row[child.restricted] = index % 7 === 3;
|
|
1476
|
+
return row;
|
|
1477
|
+
});
|
|
1478
|
+
rows[child.name] = [...await seed(tables?.[child.name] ?? child.name, childRows)];
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
const relations = memoryRelationSource(policy.permissions, {
|
|
1482
|
+
rows,
|
|
1483
|
+
edges
|
|
1484
|
+
});
|
|
1485
|
+
const mismatches = [];
|
|
1486
|
+
let checked = 0;
|
|
1487
|
+
let granted = 0;
|
|
1488
|
+
const reads = listPermissions(policy.permissions).filter((leaf) => leaf.kind === "instance" && commandFor(leaf.action, input.config.rls?.actions) === "select" && rows[leaf.resource] !== void 0);
|
|
1489
|
+
for (const subject of subjects) {
|
|
1490
|
+
const permdock = await getPermDock(policy, {
|
|
1491
|
+
principal: {
|
|
1492
|
+
id: subject,
|
|
1493
|
+
roles: []
|
|
1494
|
+
},
|
|
1495
|
+
context: {}
|
|
1496
|
+
}, { relations });
|
|
1497
|
+
await input.bind(subject);
|
|
1498
|
+
for (const permission of reads) {
|
|
1499
|
+
const node = policy.resources.get(permission.resource);
|
|
1500
|
+
const list = rows[permission.resource] ?? [];
|
|
1501
|
+
if (node === void 0 || list.length === 0) continue;
|
|
1502
|
+
const ids = list.map((row) => String(row[node.id]));
|
|
1503
|
+
const visible = await query(`select ${quoteIdent(node.id)}::text as id from ${quoteQualified(tables?.[node.name] ?? node.name)} where ${quoteIdent(node.id)} = any($1)`, [ids]);
|
|
1504
|
+
const seen = new Set(visible.rows.map((row) => String(row["id"])));
|
|
1505
|
+
for (const row of list) {
|
|
1506
|
+
checked += 1;
|
|
1507
|
+
const id = String(row[node.id]);
|
|
1508
|
+
const allowed = permdock.can(permission, row);
|
|
1509
|
+
if (allowed !== seen.has(id)) mismatches.push(`${permission.key} ${id}: in-process ${allowed ? "granted" : "denied"}, database ${seen.has(id) ? "allowed" : "filtered"}`);
|
|
1510
|
+
else if (allowed) granted += 1;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
return {
|
|
1515
|
+
checked,
|
|
1516
|
+
granted,
|
|
1517
|
+
mismatches,
|
|
1518
|
+
notes: []
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
//#endregion
|
|
1522
|
+
//#region src/cli/rls/verify-database.ts
|
|
1523
|
+
/** Columns of `row` that hold a value; a masked column reads as null. */
|
|
1524
|
+
function valuedColumns(row) {
|
|
1525
|
+
return isRecord(row) ? Object.keys(row).filter((name) => row[name] !== null && row[name] !== void 0).toSorted() : [];
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* What a field view returns for a row: nothing for a denied row, otherwise
|
|
1529
|
+
* `pick`'s columns with a value plus the key, which always passes through.
|
|
1530
|
+
*/
|
|
1531
|
+
function expectedFields(granted, picked, key, row) {
|
|
1532
|
+
if (!granted) return [];
|
|
1533
|
+
const kept = new Set(valuedColumns(picked));
|
|
1534
|
+
if (isRecord(row) && row[key] !== null && row[key] !== void 0) kept.add(key);
|
|
1535
|
+
return [...kept].toSorted();
|
|
1536
|
+
}
|
|
1537
|
+
function fieldsMismatch(fixture, want, seen) {
|
|
1538
|
+
const kept = want.join(", ");
|
|
1539
|
+
if (typeof seen === "string") return [`${fixture.action}: field view read failed (${seen}), pick keeps [${kept}]`];
|
|
1540
|
+
return seen.join(", ") === kept ? [] : [`${fixture.action} ${String(rowId(fixture.row))}: field view returns [${seen.join(", ")}], pick keeps [${kept}]`];
|
|
1541
|
+
}
|
|
1542
|
+
/** The field view's columns for the fixture's row: the view, or the table itself when no grant limits its fields. */
|
|
1543
|
+
async function viewFields(query, table, key) {
|
|
1544
|
+
const view = quoteTable(viewName(table, FIELD_VIEWS.view));
|
|
1545
|
+
const id = quoteIdent("id");
|
|
1546
|
+
await query("savepoint permdock_fields");
|
|
1547
|
+
const result = await query(`select * from ${view} where ${id} = $1`, [key]);
|
|
1548
|
+
if (result.code === void 0) return valuedColumns(result.rows[0]);
|
|
1549
|
+
await query("rollback to savepoint permdock_fields");
|
|
1550
|
+
if (result.code !== "42P01") return result.code;
|
|
1551
|
+
const base = await query(`select * from ${quoteTable(table)} where ${id} = $1`, [key]);
|
|
1552
|
+
return base.code ?? valuedColumns(base.rows[0]);
|
|
1553
|
+
}
|
|
1554
|
+
/** Writes the custom roles into the `database`-mode tables; the fixture transaction rolls them back. */
|
|
1555
|
+
async function seedCustomRoles(query, schema, customRoles, scopes) {
|
|
1556
|
+
for (const row of customRoleRows(schema, customRoles, scopes)) {
|
|
1557
|
+
const result = await query(row.sql, row.values);
|
|
1558
|
+
if (result.code !== void 0) throw new Error(`PermDock CLI: rls verify --db-url-stdin could not seed custom roles (${result.code}); connect as a role that owns the custom_role_* tables`);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
async function bindSubject(query, fixture, subject) {
|
|
1562
|
+
await query("set local role \"authenticated\"");
|
|
1563
|
+
for (const [name, value] of subjectSettings(fixture, subject)) await query("select set_config($1, $2, true)", [name, value]);
|
|
1564
|
+
}
|
|
1565
|
+
async function verifyAgainstDatabase(input) {
|
|
1566
|
+
const client = await input.connect(input.db);
|
|
1567
|
+
const seedsTables = seedsCustomRoleTables(input.config);
|
|
1568
|
+
const schema = customRoleSchema(input.config);
|
|
1569
|
+
const scopes = scopeList(input.policy.scopes);
|
|
1570
|
+
const subject = subjectOf(input.config, input.customRoles, scopes);
|
|
1571
|
+
const query = async (sql, values) => {
|
|
1572
|
+
try {
|
|
1573
|
+
const result = await client.query(sql, values === void 0 ? [] : [...values]);
|
|
1574
|
+
return {
|
|
1575
|
+
rows: result.rows,
|
|
1576
|
+
rowCount: result.rowCount ?? result.rows.length
|
|
1577
|
+
};
|
|
1578
|
+
} catch (cause) {
|
|
1579
|
+
const code = cause !== null && typeof cause === "object" && "code" in cause && typeof cause.code === "string" ? cause.code : void 0;
|
|
1580
|
+
return {
|
|
1581
|
+
rows: [],
|
|
1582
|
+
rowCount: 0,
|
|
1583
|
+
...code === void 0 ? {} : { code }
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
const mismatches = [];
|
|
1588
|
+
const notes = [];
|
|
1589
|
+
try {
|
|
1590
|
+
const conditioned = policyRowConditionKeys(input.policy);
|
|
1591
|
+
for (const found of helperTablePoliciesFromRows((await query(HELPER_TABLE_POLICIES_SQL)).rows)) {
|
|
1592
|
+
const keys = found.keys.filter((key) => conditioned.has(key));
|
|
1593
|
+
if (keys.length > 0) mismatches.push(`PD037 ${rowConditionMessage(found, keys)}`);
|
|
1594
|
+
}
|
|
1595
|
+
for (const [index, fixture] of input.fixtures.entries()) {
|
|
1596
|
+
const permission = findPermission(input.policy.permissions, fixture.action);
|
|
1597
|
+
const status = input.inProcess[index];
|
|
1598
|
+
if (permission === void 0 || status === void 0) continue;
|
|
1599
|
+
if (status.kind === "opaque") {
|
|
1600
|
+
notes.push(`${fixture.action}: opaque grant untestable app-side`);
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
const table = input.config.rls?.tables?.[permission.resource] ?? permission.resource;
|
|
1604
|
+
await query("begin");
|
|
1605
|
+
try {
|
|
1606
|
+
if (seedsTables) await seedCustomRoles(query, schema, input.customRoles, scopes);
|
|
1607
|
+
await bindSubject(query, fixture, subject);
|
|
1608
|
+
const statement = statementFor(fixture, permission.action, table);
|
|
1609
|
+
const result = await query(statement.sql, statement.values);
|
|
1610
|
+
const count = result.rowCount ?? result.rows.length;
|
|
1611
|
+
const database = result.code === "42501" ? "rejected" : count > 0 ? "allowed" : "filtered";
|
|
1612
|
+
if (!(status.granted ? database === "allowed" : database === "filtered" || database === "rejected")) mismatches.push(`${fixture.action}: in-process ${status.granted ? "granted" : "denied"}, database ${database}`);
|
|
1613
|
+
else if (status.kind === "sqlFunction") notes.push(`${fixture.action}: verified through twin`);
|
|
1614
|
+
mismatches.push(...status.fields === void 0 ? [] : fieldsMismatch(fixture, status.fields, await viewFields(query, table, rowId(fixture.row))));
|
|
1615
|
+
} finally {
|
|
1616
|
+
await query("rollback");
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
} finally {
|
|
1620
|
+
await client.end();
|
|
1621
|
+
}
|
|
1622
|
+
return {
|
|
1623
|
+
mismatches,
|
|
1624
|
+
notes
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
/** `--tree`: seeds a generated object graph in a rolled-back transaction and compares `can()` with RLS. */
|
|
1628
|
+
async function verifyTreeAgainstDatabase(db, policy, config, connect) {
|
|
1629
|
+
const client = await connect(db);
|
|
1630
|
+
const scopes = scopeList(policy.scopes);
|
|
1631
|
+
const query = async (sql, values) => {
|
|
1632
|
+
return { rows: (await client.query(sql, values === void 0 ? [] : [...values])).rows };
|
|
1633
|
+
};
|
|
1634
|
+
const loose = async (sql, values) => {
|
|
1635
|
+
await query(sql, values);
|
|
1636
|
+
return { rows: [] };
|
|
1637
|
+
};
|
|
1638
|
+
try {
|
|
1639
|
+
await query("begin");
|
|
1640
|
+
const result = await verifyTree({
|
|
1641
|
+
policy,
|
|
1642
|
+
config,
|
|
1643
|
+
query,
|
|
1644
|
+
bind: (subject) => bindSubject(loose, {
|
|
1645
|
+
subject: { id: subject },
|
|
1646
|
+
row: {},
|
|
1647
|
+
action: "read"
|
|
1648
|
+
}, subjectOf(config, [], scopes))
|
|
1649
|
+
});
|
|
1650
|
+
if (result.mismatches.length > 0) return {
|
|
1651
|
+
code: 1,
|
|
1652
|
+
output: [...result.mismatches, ...result.notes].join("\n")
|
|
1653
|
+
};
|
|
1654
|
+
const verified = `verified ${String(result.checked)} tree check(s) against the database (${String(result.granted)} granted)`;
|
|
1655
|
+
return {
|
|
1656
|
+
code: 0,
|
|
1657
|
+
output: result.notes.length === 0 ? verified : `${verified}\n${result.notes.join("\n")}`
|
|
1658
|
+
};
|
|
1659
|
+
} catch (cause) {
|
|
1660
|
+
return {
|
|
1661
|
+
code: 2,
|
|
1662
|
+
output: `PermDock CLI: rls verify --tree could not seed the tree (${describeError(cause)}); connect as a role that owns the tables and is a member of authenticated`
|
|
1663
|
+
};
|
|
1664
|
+
} finally {
|
|
1665
|
+
await client.query("rollback", []).catch(() => void 0);
|
|
1666
|
+
await client.end();
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
//#endregion
|
|
1670
|
+
//#region src/cli/rls/verify.ts
|
|
1671
|
+
function canFixture(permdock, permission, row) {
|
|
1672
|
+
if (permission.kind === "collection") return permdock.can(permission, row);
|
|
1673
|
+
return permdock.can(permission, row);
|
|
1674
|
+
}
|
|
1675
|
+
function grantKind(policy, key) {
|
|
1676
|
+
const grants = policy.grants.filter((grant) => grant.permission.key === key);
|
|
1677
|
+
if (grants.some((grant) => hasConditionOp(grant.where, "opaque") || hasConditionOp(grant.check, "opaque"))) return "opaque";
|
|
1678
|
+
if (grants.some((grant) => hasConditionOp(grant.where, "sqlFunction") || hasConditionOp(grant.check, "sqlFunction"))) return "sqlFunction";
|
|
1679
|
+
return "portable";
|
|
1680
|
+
}
|
|
1681
|
+
async function runRlsVerify(input) {
|
|
1682
|
+
const connect = input.connect ?? pgConnector("permdock rls verify --db-url-stdin", input.cwd);
|
|
1683
|
+
const policyPath = input.from ?? input.config.policy;
|
|
1684
|
+
if (policyPath === void 0) return {
|
|
1685
|
+
code: 2,
|
|
1686
|
+
output: "PermDock CLI: rls verify needs policy in the config or --policy"
|
|
1687
|
+
};
|
|
1688
|
+
const policy = await loadPolicyAt(input.cwd, policyPath);
|
|
1689
|
+
if (input.tree === true) {
|
|
1690
|
+
if (input.db === void 0) return {
|
|
1691
|
+
code: 2,
|
|
1692
|
+
output: "PermDock CLI: rls verify --tree needs a database: --db-url-stdin, DATABASE_URL or SUPABASE_DB_URL"
|
|
1693
|
+
};
|
|
1694
|
+
try {
|
|
1695
|
+
return await verifyTreeAgainstDatabase(input.db, policy, input.config, connect);
|
|
1696
|
+
} catch (cause) {
|
|
1697
|
+
return usageResult(cause);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
const fixturesPath = input.fixtures ?? input.config.rls?.fixtures ?? "rls.fixtures.json";
|
|
1701
|
+
const { fixtures, customRoles } = await loadFixtures(input.cwd, fixturesPath);
|
|
1702
|
+
const mismatches = [];
|
|
1703
|
+
const notes = [];
|
|
1704
|
+
const inProcess = [];
|
|
1705
|
+
const fieldsMode = input.config.rls?.fields === "views";
|
|
1706
|
+
for (const fixture of fixtures) {
|
|
1707
|
+
const permission = findPermission(policy.permissions, fixture.action);
|
|
1708
|
+
if (permission === void 0) {
|
|
1709
|
+
mismatches.push(`${fixture.action}: unknown permission`);
|
|
1710
|
+
inProcess.push({
|
|
1711
|
+
action: fixture.action,
|
|
1712
|
+
granted: false,
|
|
1713
|
+
kind: "portable"
|
|
1714
|
+
});
|
|
1715
|
+
continue;
|
|
1716
|
+
}
|
|
1717
|
+
const kind = grantKind(policy, fixture.action);
|
|
1718
|
+
const permdock = await getPermDock(policy, fixtureSubject(fixture.subject), { customRoles: memoryCustomRoleSource(customRoles) });
|
|
1719
|
+
const granted = canFixture(permdock, permission, fixtureRow(fixture, permission.kind));
|
|
1720
|
+
const outcome = granted ? "granted" : "denied";
|
|
1721
|
+
if (fixture.expected !== void 0 && fixture.expected !== outcome) mismatches.push(`${fixture.action}: in-process ${outcome}, expected ${fixture.expected}`);
|
|
1722
|
+
if (kind === "opaque") notes.push(`${fixture.action}: opaque grant untestable app-side`);
|
|
1723
|
+
const fields = fieldsMode && permission.kind === "instance" && commandFor(permission.action, input.config.rls?.actions) === "select" ? expectedFields(granted, permdock.pick(permission, fixture.row), policy.resources.get(permission.resource)?.id ?? "id", fixture.row) : void 0;
|
|
1724
|
+
inProcess.push({
|
|
1725
|
+
action: fixture.action,
|
|
1726
|
+
granted,
|
|
1727
|
+
kind,
|
|
1728
|
+
...fields === void 0 ? {} : { fields }
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
if (input.format === "pgtap") return mismatches.length > 0 ? {
|
|
1732
|
+
code: 1,
|
|
1733
|
+
output: mismatches.join("\n")
|
|
1734
|
+
} : {
|
|
1735
|
+
code: 0,
|
|
1736
|
+
output: emitPgtap({
|
|
1737
|
+
fixtures,
|
|
1738
|
+
inProcess,
|
|
1739
|
+
policy,
|
|
1740
|
+
config: input.config,
|
|
1741
|
+
customRoles
|
|
1742
|
+
})
|
|
1743
|
+
};
|
|
1744
|
+
if (input.db !== void 0) try {
|
|
1745
|
+
const database = await verifyAgainstDatabase({
|
|
1746
|
+
db: input.db,
|
|
1747
|
+
policy,
|
|
1748
|
+
fixtures,
|
|
1749
|
+
customRoles,
|
|
1750
|
+
config: input.config,
|
|
1751
|
+
inProcess,
|
|
1752
|
+
connect
|
|
1753
|
+
});
|
|
1754
|
+
mismatches.push(...database.mismatches);
|
|
1755
|
+
notes.push(...database.notes);
|
|
1756
|
+
} catch (cause) {
|
|
1757
|
+
return usageResult(cause);
|
|
1758
|
+
}
|
|
1759
|
+
if (mismatches.length > 0) return {
|
|
1760
|
+
code: 1,
|
|
1761
|
+
output: [...mismatches, ...notes].join("\n")
|
|
1762
|
+
};
|
|
1763
|
+
const verified = `verified ${String(fixtures.length)} fixture(s) ${input.db === void 0 ? "in-process" : "in-process and against the database"}`;
|
|
1764
|
+
return {
|
|
1765
|
+
code: 0,
|
|
1766
|
+
output: notes.length === 0 ? verified : `${verified}\n${notes.join("\n")}`
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
//#endregion
|
|
1770
|
+
export { indexesSql as _, graphSql as a, callsHelper as c, rowConditionMessage as d, commandFor as f, indexTargets as g, COLUMNS_SQL as h, graphPlan as i, helperCallKeys as l, tableFor$1 as m, runRlsVerify as n, HELPER_TABLES as o, compileGrants as p, closureDepths as r, ROW_CONDITION_FIX as s, canFixture as t, helperTablePolicies as u, pruneIndexTargets as v, readTableIndexFacts as y };
|