permdock 0.4.0 → 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/{schema-Bol6gqXA.js → 2020-C9VN-x8c.js} +5 -130
- package/dist/a2a/index.d.ts +6 -6
- package/dist/a2a/index.js +14 -17
- 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 -35
- package/dist/ai-sdk/index.js +37 -23
- package/dist/{app-data-CV8pqqMi.js → app-data-MuyzVqX8.js} +2 -1
- 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/arazzo-B6lebmiQ.js +36 -0
- package/dist/{arazzo-C4BcskbX.js → arazzo-BWhNF-k0.js} +20 -14
- 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 +94 -79
- 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 +9 -8
- package/dist/better-supabase/index.d.ts +357 -30
- package/dist/better-supabase/index.js +936 -194
- package/dist/{bind-BzqG8SMb.d.ts → bind--9EFVYWF.d.ts} +12 -10
- package/dist/{bind-kk-uyCwH.js → bind-CZzhx-Vj.js} +14 -10
- package/dist/body-CH5of8Ne.js +10 -0
- package/dist/body-text-QcXok_SI.js +52 -0
- package/dist/{build-instance-DIIV9DbC.js → build-instance-BMnAudHn.js} +4 -3
- package/dist/{canonical-json-CFKyFOke.js → canonical-json-BC8_nMTw.js} +5 -3
- package/dist/{capability-CksyCWDH.d.ts → capability-BSslw1QV.d.ts} +44 -25
- package/dist/{capability-txATtKV4.js → capability-N1K9IpqS.js} +68 -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-tZUyOfOv.js → catalog-BECMPrwc.js} +51 -6
- package/dist/catalog-DjhMYYCq.js +1 -0
- package/dist/catalog-R_A__RsH.js +52 -0
- package/dist/{catalog-doc-DWtg0gGR.js → catalog-doc-C9wjsfNY.js} +45 -23
- 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 +24 -27
- package/dist/clerk/index.d.ts +9 -8
- package/dist/clerk/index.js +9 -14
- package/dist/cli/bin.js +36 -17
- package/dist/cli/index.d.ts +19 -5
- package/dist/cli/index.js +164 -4
- package/dist/cloud/index.d.ts +102 -63
- package/dist/cloud/index.js +239 -340
- package/dist/cloud-CIoHNevm.js +87 -0
- package/dist/codemod-Bmj9dWMH.js +37 -0
- package/dist/codemod-Cxvvc70T.js +2084 -0
- package/dist/{collect-2SrB7lfk.js → collect-CrZJJnEF.js} +75 -11
- package/dist/collect-DloHbDVa.js +55 -0
- package/dist/collect-FlXJlhus.js +1 -0
- package/dist/command-L8EstFNm.js +6 -0
- package/dist/compile/index.d.ts +1 -1
- package/dist/compile/index.js +1 -2
- package/dist/{compile-CcEu2ZQ1.js → compile-CMqMx_IN.js} +11 -8
- package/dist/{compile-CtTP7eur.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-BIjREpyE.js → config-bs_kZeYq.js} +12 -7
- package/dist/{config-report--Xb9APJS.js → config-report-DUgj62Ai.js} +15 -5
- package/dist/{connection-DUsczpB5.js → connection-DjhIJSs2.js} +6 -5
- package/dist/{context-DieZVJXu.js → context-bbFHR-0D.js} +2 -2
- package/dist/convex/index.d.ts +11 -10
- package/dist/convex/index.js +6 -4
- 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-BBO24vj6.js → create-DnrmLnLW.js} +31 -32
- package/dist/{credential-kue0qpbm.js → credential-DM2um3ha.js} +152 -31
- package/dist/credentials-DPyHmYz5.js +74 -0
- package/dist/delegation-DaXVdDhM.js +286 -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-DfRk15uw.d.ts → denied-text-D8jDHgsl.d.ts} +5 -5
- package/dist/{describe-C3ldA9os.d.ts → describe-BZUsixiL.d.ts} +2 -2
- package/dist/{describe-Cgkp7eP0.js → describe-BybMRY7A.js} +6 -2
- package/dist/device-BARQ8eYG.js +193 -0
- package/dist/diff-CxZo9pTq.js +37 -0
- package/dist/{diff-rpqoe7Ag.js → diff-DCePiN6M.js} +60 -54
- package/dist/doctor-CxiwSfqH.js +44 -0
- package/dist/{doctor-owlv6ddh.js → doctor-DFjUxf2f.js} +245 -50
- package/dist/drizzle/index.d.ts +5 -3
- package/dist/drizzle/index.js +2 -2
- package/dist/elysia/index.d.ts +16 -13
- package/dist/elysia/index.js +13 -12
- package/dist/env-DJZrPjRi.js +8 -0
- package/dist/errors-BDUwg2qC.js +27 -0
- package/dist/{errors-drnfxYtm.js → errors-BKy0EfhN.js} +5 -9
- package/dist/errors-BWXBMaKK.d.ts +11 -0
- package/dist/{errors-OD_4c__H.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-C9YJsv3H.js → evaluations-DZ2CbV5k.js} +145 -34
- package/dist/eve/index.d.ts +12 -16
- package/dist/eve/index.js +22 -28
- 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 +12 -11
- package/dist/fastify/index.d.ts +25 -22
- package/dist/fastify/index.js +13 -12
- 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/{fixtures-h2eG2OZV.js → fixtures-B7XrLeY7.js} +8 -7
- package/dist/flags-C3DktBDY.js +86 -0
- package/dist/{from-snapshot-7REQZe0H.js → from-snapshot-D2lZt4Dd.js} +55 -52
- package/dist/{grantee-CXY-0C87.js → grantee-Cn1HyUsI.js} +3 -3
- package/dist/{grantee-conditions-MQs5NIuX.js → grantee-conditions-CcXf8hMM.js} +4 -3
- package/dist/{grantee-match-fxYp9Nmt.js → grantee-match-DHArqY82.js} +5 -4
- 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-C9YmyBIa.js → helpers-DTcZOzhF.js} +45 -38
- package/dist/hono/index.d.ts +12 -9
- package/dist/hono/index.js +13 -12
- package/dist/hosted-BZIAYvon.js +315 -0
- package/dist/hosted-DBDX0M-P.d.ts +78 -0
- package/dist/{http-DPuDaChb.d.ts → http-B_LphE5Y.d.ts} +1 -2
- package/dist/http-DXJ37RDA.js +46 -0
- package/dist/{http-BEbdMZ05.js → http-ujKm0JXE.js} +1 -2
- package/dist/{import-UHk1N7vH.js → import-CNV7I2Dw.js} +25 -20
- package/dist/{import-BFIlhetD.js → import-DMmOG27O.js} +190 -53
- 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-UdVQgq48.d.ts → index-DOVf7RJv.d.ts} +431 -81
- package/dist/{index-BwMe94Xq.d.ts → index-SGdIQyzc.d.ts} +34 -16
- package/dist/index.d.ts +19 -15
- package/dist/index.js +31 -21
- package/dist/init-DeMYsnYJ.js +96 -0
- package/dist/init-GG6Jh8HD.js +95 -0
- package/dist/{instance-options-plkrsAog.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-DBkdUuCO.js → json-schema-B-vAwlde.js} +45 -6
- package/dist/json-schema-CN_iLFH2.d.ts +40 -0
- package/dist/jwt/index.d.ts +18 -11
- package/dist/jwt/index.js +156 -135
- package/dist/{kernel-CB3LrIJM.js → kernel-5JatXyND.js} +52 -24
- package/dist/kysely/index.d.ts +5 -3
- package/dist/kysely/index.js +2 -2
- package/dist/load-BxEtwlBS.js +1 -0
- package/dist/{load-C3_oTHyO.js → load-DFS_ry0W.js} +22 -6
- 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-AVlBaJoa.js → map-claims-D3oRmTk0.js} +13 -10
- package/dist/{map-error-BE27PYkE.js → map-error-TCAdw-eW.js} +3 -3
- package/dist/{markers-CiwAOZtO.js → markers-C6joDI3o.js} +2 -1
- package/dist/mcp/index.d.ts +22 -12
- package/dist/mcp/index.js +57 -39
- package/dist/{migrate-BwlB5-Wk.js → migrate-BShP7Vf4.js} +53 -14
- package/dist/nest/index.d.ts +12 -9
- package/dist/nest/index.js +15 -14
- package/dist/next/index.d.ts +18 -21
- package/dist/next/index.js +13 -11
- 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-uYFXGVw2.js → ocsf-BFp-PZZA.js} +22 -11
- package/dist/openai/index.d.ts +9 -16
- package/dist/openai/index.js +17 -24
- package/dist/openapi/index.d.ts +3 -3
- package/dist/openapi/index.js +1 -1
- package/dist/{openapi-DBzHQbov.js → openapi-B7HZ0f-r.js} +8 -8
- package/dist/{openapi-BJdBmYDG.js → openapi-DTaEmTSW.js} +10 -9
- 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 +20 -15
- package/dist/otel/index.d.ts +3 -2
- package/dist/otel/index.js +30 -7
- package/dist/output-Dyw8fyPF.js +88 -0
- package/dist/output-files-B3IZJksC.js +1 -0
- package/dist/{output-files-CgIsvdSa.js → output-files-DhRBBE4n.js} +24 -26
- package/dist/{permission-keys-B26jeyUv.js → ownership-BatFgAA4.js} +1698 -869
- package/dist/{package-root-DtNnpmUd.js → package-root-Dk_ETdkp.js} +1 -1
- package/dist/{parse-CVFQHk6c.js → parse-Cxq0WxrA.js} +2 -2
- package/dist/{parse-local-manifest-C-q02Pal.js → parse-local-manifest-CVSs6J9I.js} +1 -1
- package/dist/{parse-local-manifest-DvYUR0QL.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 +45 -26
- 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-BsblpB-z.js → permission-registry-Br4jSNVi.js} +3 -3
- package/dist/permissions-B-wCP5ZC.js +30 -0
- package/dist/{permissions-CCJ6lmxB.js → permissions-CZKiUfFM.js} +4 -3
- package/dist/{permissions-COH-KRgT.d.ts → permissions-oEWfl-kl.d.ts} +1 -2
- package/dist/policies-CO1oOymN.js +295 -0
- package/dist/{policy-ur0BBK_e.d.ts → policy-Czo4n3Hs.d.ts} +2 -2
- package/dist/{hosted-CBHgkzDl.js → policy-DLZJ5gAp.js} +17 -273
- package/dist/policy-from-catalog-DzhEc3Y9.js +450 -0
- package/dist/policy-module-CTlwMvLI.js +89 -0
- package/dist/{policy-types-sp9P1iIV.d.ts → policy-types-DzwivBgR.d.ts} +434 -631
- 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-cZe4kzhL.js → powersync-bYUDLOAO.js} +23 -24
- 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 +2 -2
- package/dist/{problem-CnWraVhC.js → problem-CTviyH5L.js} +13 -23
- package/dist/{problem-details-rIIo8B2C.d.ts → problem-details-DXlt-vks.d.ts} +1 -1
- package/dist/{problem-details-DECUnXAn.js → problem-details-cn5X30gZ.js} +43 -40
- package/dist/problem-types-CJ2325SZ.js +5 -0
- package/dist/{prompt-CXYhLfsS.js → prompt-DfZLJLIn.js} +2 -2
- package/dist/{protected-B3_P7xuw.d.ts → protected-DEPHc2xV.d.ts} +16 -8
- package/dist/{protected-BN7BUdY1.js → protected-DfjY5mLA.js} +21 -4
- package/dist/{provider-Bi-0HNL3.js → provider-CC-u3bYP.js} +2 -2
- package/dist/{provider-kwHC9CZD.d.ts → provider-DMVt6yYn.d.ts} +1 -1
- package/dist/{provider-store-Ctt-81Ni.js → provider-store-B5Yrkhzr.js} +2 -2
- 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 -72
- package/dist/react-native/index.js +24 -22
- package/dist/{refs-f3imMjNW.js → refs-K6eWUJ0H.js} +1 -1
- package/dist/{relation-walk-BXnlLtg2.js → relation-walk-3OyDBF0S.js} +2 -2
- package/dist/{relations-CvmSpG67.js → relations-BnCWnJGI.js} +3 -3
- package/dist/renames-CGj7YwcQ.js +175 -0
- package/dist/{replay-COpmZUfx.d.ts → replay-D7iKcLFe.d.ts} +1 -1
- package/dist/{replay-BsOyTrdV.js → replay-Du1tJjG7.js} +2 -1
- package/dist/{request-context-BVM7aWNs.js → request-context-BTRTG7OT.js} +3 -3
- 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-D1Un3pcz.js → resource-relations-D_jw0BFx.js} +1 -1
- package/dist/{retire-BFcxL6un.js → retire-CALjIQ7e.js} +2 -2
- package/dist/{revocations-Cy06JNx2.d.ts → revocations-R07lFBnX.d.ts} +2 -0
- package/dist/rls-BA8an5Ap.js +336 -0
- package/dist/{rls-BEktUaqg.js → rls-j6t43t7n.js} +287 -689
- package/dist/{role-builder-B4GbKM-i.js → role-builder-CXbis5-X.js} +14 -207
- package/dist/{rpc-nYC3cR_u.js → rpc-aPyNi6tA.js} +7 -7
- 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-D_tMXnsq.js → runtime-D3VTAiCA.js} +1 -1
- package/dist/{saas-DtaA4JuW.js → saas-FO-Q8oj0.js} +16 -14
- package/dist/scan-BOI5tJ-6.js +29 -0
- package/dist/{schema-DjQBcAP9.js → schema-B4v0Qrac.js} +102 -8
- 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-vFR1O5oG.js → scim-ClHtpyV3.js} +208 -76
- package/dist/{scopes-tvpa34ep.js → scopes-CUPSjo9q.js} +12 -7
- package/dist/server/index.d.ts +12 -143
- package/dist/server/index.js +12 -9
- package/dist/server-DqCT4CgZ.js +453 -0
- package/dist/{server-types-DdqBwZN_.d.ts → server-types-BLd4MnBX.d.ts} +51 -68
- package/dist/{sha256-BO0swRXL.js → sha256-D9ihqmvd.js} +8 -1
- package/dist/{shared-1uhI-e-b.js → shared-CD11nBbC.js} +18 -8
- package/dist/{shared-JhMeOsDF.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-BZdRQkxs.js → skills-B6lVdD_W.js} +13 -6
- package/dist/skills-CTYwkl44.js +51 -0
- package/dist/snapshot-CXUWfsNy.js +269 -0
- package/dist/{snapshot-cache-Cod40D-X.js → snapshot-cache-DZHQ0KF6.js} +11 -5
- package/dist/{snapshot-cache-J5T7t_vD.d.ts → snapshot-cache-DqK9eDmM.d.ts} +7 -5
- package/dist/solid/index.d.ts +11 -12
- package/dist/solid/index.js +7 -7
- package/dist/solid-start/index.d.ts +52 -0
- package/dist/solid-start/index.js +49 -0
- package/dist/{sources-Dljy06zL.d.ts → sources-BV9j0DeZ.d.ts} +7 -13
- package/dist/{sources-B1-b-R0t.js → sources-vs5dGOsS.js} +125 -119
- package/dist/{sql-t8teWOfo.js → sql-B5T1IL83.js} +13 -1
- package/dist/src-DLfUWFAI.js +1851 -0
- package/dist/ssf/index.d.ts +4 -4
- package/dist/ssf/index.js +384 -47
- 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-ILkLUj_o.js → store-4u_hxjWa.js} +40 -53
- package/dist/{store-options-CbUHDoSe.js → store-options-BqEakYRa.js} +26 -21
- package/dist/{stores-BHXdSbw9.js → stores-BbsD4fAR.js} +2 -2
- package/dist/{stores-Cxz_80g-.d.ts → stores-ClZUgeoP.d.ts} +7 -8
- package/dist/{stream-Dwsg6WwE.d.ts → stream-7-xmnf3U.d.ts} +2 -2
- package/dist/{stream-BMui98KK.js → stream-CvdUS9V1.js} +9 -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/supabase/index.d.ts +23 -106
- package/dist/supabase/index.js +5 -4
- package/dist/supabase/middleware.d.ts +7 -6
- package/dist/supabase/middleware.js +13 -11
- package/dist/supabase-9pqI57pW.js +92 -0
- package/dist/{supabase-C1TWlnYt.js → supabase-Dxlp8A94.js} +98 -205
- package/dist/supabase-hook-CXcGpHqq.js +1 -0
- package/dist/{supabase-hook-Cva3ikXz.js → supabase-hook-DdDQhMlw.js} +951 -60
- package/dist/support-session-C6xJQYut.js +158 -0
- package/dist/svelte/Protected.svelte +4 -4
- package/dist/svelte/index.d.ts +7 -7
- package/dist/svelte/index.js +10 -10
- package/dist/svelte/runtime.d.ts +3 -3
- package/dist/svelte/runtime.js +3 -3
- package/dist/svelte/source.d.ts +7 -7
- package/dist/svelte/source.js +4 -4
- 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/{tenancy-DshjqY0n.js → tenancy-DN2QAx9z.js} +5 -5
- package/dist/terminal/index.d.ts +11 -9
- package/dist/terminal/index.js +23 -287
- package/dist/testing/index.d.ts +311 -38
- package/dist/testing/index.js +3475 -201
- 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-Bx9qy30p.js → token-BLZcjQgt.js} +15 -6
- package/dist/{tool-refusal-CQ7OEia9.js → tool-refusal-CMLN5xhn.js} +2 -2
- package/dist/trpc/index.d.ts +12 -9
- package/dist/trpc/index.js +16 -14
- package/dist/{types-7rJVLWe5.d.ts → types-5Ut_pbKy.d.ts} +61 -11
- package/dist/{store-9KO4BlkB.d.ts → types-BNORmDM8.d.ts} +100 -75
- package/dist/types-BjV-fqcA.d.ts +57 -0
- package/dist/{types-CUcFZfpU.d.ts → types-BqAKcRNW.d.ts} +2 -1
- package/dist/types-C8-KNsFf.d.ts +107 -0
- package/dist/{types-Br9IXUBG.d.ts → types-CCpc2Dcv.d.ts} +77 -17
- package/dist/{types-DpWfQdSh.d.ts → types-CZTnPFzM.d.ts} +46 -11
- package/dist/{types-CqrjB9o6.d.ts → types-CdlxsYQh.d.ts} +23 -11
- package/dist/{types-CIlTojhq.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-ByUo56un.d.ts → types-fbZ7JEXr.d.ts} +23 -22
- package/dist/{types-DHrmBJLH.d.ts → types-tIgIeIKJ.d.ts} +7 -7
- 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-CG9nOYZw.js → usage-T5mXq98v.js} +54 -11
- package/dist/{validation-error-BSSw0l_8.d.ts → validation-error-Bg5Tpluk.d.ts} +1 -1
- package/dist/{validity-dVrUEPJt.js → validity-CZ7-y0Cj.js} +32 -15
- package/dist/{verifier-DMr_Zmb_.js → verifier-DHuh-okk.js} +18 -7
- package/dist/{verify-BztUB3GX.js → verify-DbCL9bnB.js} +645 -50
- package/dist/{version-BEogC-rB.js → version-CEbQ5UeM.js} +4 -3
- package/dist/version-DXkYUISx.js +1 -0
- package/dist/{views-CJsrBS1f.js → views-DTIIO-oQ.js} +22 -5
- package/dist/{permission-types-Dg39z9Kv.d.ts → vocabulary-D6lPdb4Z.d.ts} +65 -2
- package/dist/vue/index.d.ts +9 -10
- package/dist/vue/index.js +8 -8
- 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-zeucB7eF.js → watch-CszoTJMq.js} +44 -5
- package/dist/webmcp/index.d.ts +2 -2
- package/dist/webmcp/index.js +3 -3
- 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-Cr93qqdP.d.ts → wrap-Bd1Tejur.d.ts} +2 -1
- package/dist/{wrap-DWsU_5Bd.js → wrap-gX61EtpB.js} +7 -6
- package/package.json +136 -37
- 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 +123 -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 +14 -10
- package/skills/permdock-data/SKILL.md +6 -6
- package/skills/permdock-data/references/queries-and-rls.md +9 -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 +5 -4
- package/skills/permdock-wire/SKILL.md +17 -11
- package/skills/permdock-wire/references/adapters.md +139 -51
- package/skills/permdock-wire/references/policy-options.md +5 -5
- package/skills/permdock-wire/references/presets-and-recipes.md +58 -0
- package/dist/approvals-DZL76lKW.js +0 -240
- package/dist/arazzo-CD8d0WLb.js +0 -54
- package/dist/catalog-6yWAX6Ps.js +0 -2
- package/dist/catalog-WHG4kBq7.js +0 -48
- package/dist/cloud-C7bSDgX4.js +0 -50
- package/dist/cloud-PeBN3IEn.js +0 -118
- package/dist/collect-Bw2Jdq1q.js +0 -2
- package/dist/collect-CQx4hX4N.js +0 -83
- package/dist/commands-Cpip4ULx.js +0 -97
- package/dist/config-DLt6BvV7.js +0 -36
- package/dist/diff-Cnghh_Ip.js +0 -48
- package/dist/doctor-CuVJvhV_.js +0 -53
- package/dist/duration-xK2jGFVc.js +0 -25
- package/dist/errors-CWfoNg9N.js +0 -47
- package/dist/errors-DRnGZHzF.js +0 -30
- package/dist/expiry-BoBJ8Tvc.js +0 -7
- package/dist/fields-fwfXN01n.js +0 -1016
- 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-DgM7w96K.d.ts +0 -6
- package/dist/index-Dz7didpC.d.ts +0 -201
- package/dist/load-B_shHrmd.js +0 -2
- package/dist/manifest-BngWKryL.js +0 -770
- package/dist/may-use-BOn_dR_w.js +0 -78
- package/dist/openapi-DcQ1Kg40.js +0 -164
- package/dist/ownership-BgnbOJFT.js +0 -851
- package/dist/permdock-L26udHSk.js +0 -3646
- package/dist/powersync-BFSbSElQ.js +0 -62
- package/dist/powersync-DCDgZgfZ.js +0 -2
- package/dist/rls-55DQTuf5.js +0 -250
- package/dist/run-DMcNW_8T.js +0 -174
- package/dist/server-ZYEHtNHS.js +0 -268
- package/dist/sink-BTtmfPnE.js +0 -141
- package/dist/sink-CPN4zLfs.d.ts +0 -104
- package/dist/skills-Dle4ZoLF.js +0 -56
- package/dist/snapshot-Dc9CHPME.js +0 -1078
- package/dist/src-Bb7no-hS.js +0 -639
- package/dist/subject-C6dLxqHU.js +0 -24
- package/dist/supabase-Dbqr141s.js +0 -83
- package/dist/supabase-hook-BULg7Ut_.js +0 -2
- package/dist/thenable-QSmvt-vZ.js +0 -65
- package/dist/types-C0dee0Qf.d.ts +0 -197
- package/dist/types-DvupZE0w.d.ts +0 -9
- package/dist/usage-4G6t643F.js +0 -42
- package/dist/usage-iFLJ0-rr.js +0 -2
- package/dist/vocabulary-hhEFLFoL.d.ts +0 -65
- package/dist/watch-C8TpsAl4.js +0 -2
|
@@ -1,79 +1,17 @@
|
|
|
1
1
|
import { a as sole, n as isReadonlyArray, r as isRecord } from "./compact-CAf557ZB.js";
|
|
2
|
-
import {
|
|
3
|
-
import { l as isSqlFunctionField } from "./refs-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { t as
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
const CONNECT_TIMEOUT_MS = 1e4;
|
|
16
|
-
const STATEMENT_TIMEOUT_MS = 6e4;
|
|
17
|
-
function loadPg(command) {
|
|
18
|
-
return requirePeer(() => import("pg"), "pg", command);
|
|
19
|
-
}
|
|
20
|
-
function options(db) {
|
|
21
|
-
return {
|
|
22
|
-
connectionString: db,
|
|
23
|
-
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
|
|
24
|
-
statement_timeout: STATEMENT_TIMEOUT_MS
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
/** Ctrl-C ends the connection, so a query that hangs cannot keep the process alive. */
|
|
28
|
-
function endOnInterrupt(end) {
|
|
29
|
-
const interrupt = () => {
|
|
30
|
-
end().catch(() => void 0);
|
|
31
|
-
process.exitCode = 130;
|
|
32
|
-
};
|
|
33
|
-
process.once("SIGINT", interrupt);
|
|
34
|
-
return async () => {
|
|
35
|
-
process.off("SIGINT", interrupt);
|
|
36
|
-
await end();
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
function unreachable(command, cause) {
|
|
40
|
-
return new CliError("unavailable", `PermDock CLI: ${command.replace(/^permdock /u, "")} could not connect`, { cause });
|
|
41
|
-
}
|
|
42
|
-
/** The default `connect` of a command: one connection, failing with a message that names `command`. */
|
|
43
|
-
function pgConnector(command) {
|
|
44
|
-
return (db) => connectPg(db, command);
|
|
45
|
-
}
|
|
46
|
-
/** One connection, for commands that run a transaction. */
|
|
47
|
-
async function connectPg(db, command) {
|
|
48
|
-
const client = new (await (loadPg(command))).Client(options(db));
|
|
49
|
-
try {
|
|
50
|
-
await client.connect();
|
|
51
|
-
} catch (cause) {
|
|
52
|
-
throw unreachable(command, cause);
|
|
53
|
-
}
|
|
54
|
-
return {
|
|
55
|
-
query: (sql, values) => client.query(sql, values),
|
|
56
|
-
end: endOnInterrupt(() => client.end())
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
/** A small pool, for independent catalog reads that can run in parallel. */
|
|
60
|
-
async function connectPgPool(db, command) {
|
|
61
|
-
const pool = new (await (loadPg(command))).Pool({
|
|
62
|
-
...options(db),
|
|
63
|
-
max: 4
|
|
64
|
-
});
|
|
65
|
-
try {
|
|
66
|
-
(await pool.connect()).release();
|
|
67
|
-
} catch (cause) {
|
|
68
|
-
await pool.end();
|
|
69
|
-
throw unreachable(command, cause);
|
|
70
|
-
}
|
|
71
|
-
return {
|
|
72
|
-
query: (sql, values) => pool.query(sql, values),
|
|
73
|
-
end: endOnInterrupt(() => pool.end())
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
//#endregion
|
|
2
|
+
import { n as isForbiddenKey } from "./paths-DXSHAMIa.js";
|
|
3
|
+
import { l as isSqlFunctionField } from "./refs-K6eWUJ0H.js";
|
|
4
|
+
import { u as isoAt } from "./time-DkWjdL98.js";
|
|
5
|
+
import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-CUPSjo9q.js";
|
|
6
|
+
import { t as isoInstant } from "./instant-BluqDvyS.js";
|
|
7
|
+
import { m as findRole, v as listRoles } from "./grantee-Cn1HyUsI.js";
|
|
8
|
+
import { r as relationCondition, t as inheritCondition } from "./grantee-conditions-CcXf8hMM.js";
|
|
9
|
+
import { l as partitionsOf, p as scopeField } from "./tenancy-DN2QAx9z.js";
|
|
10
|
+
import { i as configError } from "./errors-CWTY6uih.js";
|
|
11
|
+
import { i as scopeMembershipTable, o as relatedSql, r as scopeColumn } from "./compile-CMqMx_IN.js";
|
|
12
|
+
import { i as qualifySqlTable, t as SQL_IDENT, u as sqlTextArray } from "./sql-B5T1IL83.js";
|
|
13
|
+
import { d as keptKeys, l as roleColumn, u as roleManifest } from "./sources-vs5dGOsS.js";
|
|
14
|
+
import { A as declaredArray, D as sqlType, E as scopeTypeOf, F as quoteQualified, I as quoteTable, L as textArrayLiteral, M as qualified, N as quoteIdent, O as tenantTypeOf, P as quoteLiteral, S as CLAIM, _ as subjectClaimJsonSql, b as tenantClaimSql, d as keyTenantSql, f as kindFilterSql, h as roleKindSql, i as allowEntry, j as helperSchema, k as CLI, m as memberVia, n as GLOBAL_ROLES, o as rootName, p as liveSessionSql, r as HELPERS, s as signedIn, t as CUSTOM_ROLES, u as globalKindFilterSql, v as subjectClaimSql, x as userIdHelperSql, y as subjectIdSql } from "./shared-CD11nBbC.js";
|
|
77
15
|
//#region src/cli/sql/active.ts
|
|
78
16
|
function checkActiveRow(label, row) {
|
|
79
17
|
quoteTable(row.table);
|
|
@@ -81,9 +19,9 @@ function checkActiveRow(label, row) {
|
|
|
81
19
|
if (row.disabledAt !== void 0) quoteIdent(row.disabledAt);
|
|
82
20
|
if (row.status !== void 0) {
|
|
83
21
|
quoteIdent(row.status);
|
|
84
|
-
if (row.active === void 0 || row.active.length === 0) throw
|
|
22
|
+
if (row.active === void 0 || row.active.length === 0) throw configError(`PermDock CLI: ${label}.status needs the active values in ${label}.active`);
|
|
85
23
|
}
|
|
86
|
-
if (row.disabledAt === void 0 && row.status === void 0) throw
|
|
24
|
+
if (row.disabledAt === void 0 && row.status === void 0) throw configError(`PermDock CLI: ${label} needs disabledAt or status to tell an active row`);
|
|
87
25
|
}
|
|
88
26
|
/** Validates `rls.suspension` and keys its scopes by declared name. */
|
|
89
27
|
function checkSuspension(suspension, scopes) {
|
|
@@ -92,9 +30,9 @@ function checkSuspension(suspension, scopes) {
|
|
|
92
30
|
const byName = {};
|
|
93
31
|
for (const [key, row] of Object.entries(suspension.scopes ?? {})) {
|
|
94
32
|
const name = resolveScope(scopes, key);
|
|
95
|
-
if (name === void 0) throw
|
|
33
|
+
if (name === void 0) throw configError(`PermDock CLI: rls.suspension.scopes.${key} names a scope the policy does not declare`);
|
|
96
34
|
checkActiveRow(`rls.suspension.scopes.${key}`, row);
|
|
97
|
-
if (row.keep !== void 0 && !Array.isArray(row.keep)) throw
|
|
35
|
+
if (row.keep !== void 0 && !Array.isArray(row.keep)) throw configError(`PermDock CLI: rls.suspension.scopes.${key}.keep is a list of permissions or permission keys`);
|
|
98
36
|
const keep = keptKeys(row);
|
|
99
37
|
const { keep: _given, ...active } = row;
|
|
100
38
|
byName[name] = keep.length === 0 ? active : {
|
|
@@ -103,7 +41,7 @@ function checkSuspension(suspension, scopes) {
|
|
|
103
41
|
};
|
|
104
42
|
}
|
|
105
43
|
const memberships = suspension.memberships;
|
|
106
|
-
if (memberships?.keep !== void 0 && !Array.isArray(memberships.keep)) throw
|
|
44
|
+
if (memberships?.keep !== void 0 && !Array.isArray(memberships.keep)) throw configError("PermDock CLI: rls.suspension.memberships.keep is a list of permissions or permission keys");
|
|
107
45
|
const keep = keptKeys(memberships);
|
|
108
46
|
return {
|
|
109
47
|
...suspension.users === void 0 ? {} : { users: suspension.users },
|
|
@@ -126,18 +64,20 @@ function keptRowSql(keep, permission) {
|
|
|
126
64
|
return `case ${kind} when 'null' then true when 'array' then ${keep} @> jsonb_build_array(${"key" in permission ? quoteLiteral(permission.key) : permission.sql}) else false end`;
|
|
127
65
|
}
|
|
128
66
|
/**
|
|
129
|
-
* `
|
|
130
|
-
*
|
|
67
|
+
* Whether `id` is among the active rows' ids: a missing row is suspended, so
|
|
68
|
+
* the check fails closed, and so does a null `id`. `id` stays outside the
|
|
69
|
+
* subquery so a column of the same name on `row.table` cannot capture it.
|
|
131
70
|
*/
|
|
132
71
|
function activeRowSql(row, id) {
|
|
133
72
|
const table = quoteTable(qualifySqlTable(row.table));
|
|
134
|
-
const parts = [
|
|
73
|
+
const parts = [];
|
|
135
74
|
if (row.disabledAt !== void 0) parts.push(`s.${quoteIdent(row.disabledAt)} is null`);
|
|
136
75
|
if (row.status !== void 0) {
|
|
137
76
|
const values = (row.active ?? []).map(quoteLiteral).join(", ");
|
|
138
77
|
parts.push(`s.${quoteIdent(row.status)}::text = any(array[${values}]::text[])`);
|
|
139
78
|
}
|
|
140
|
-
|
|
79
|
+
const where = parts.length === 0 ? "" : ` where ${parts.join(" and ")}`;
|
|
80
|
+
return `coalesce(${id} in (select s.${quoteIdent(row.id)} from ${table} s${where}), false)`;
|
|
141
81
|
}
|
|
142
82
|
/** The active-user check for `user`; empty without `rls.suspension.users`. */
|
|
143
83
|
function activeUserSql(ctx, user = subjectIdSql(ctx)) {
|
|
@@ -161,7 +101,7 @@ function activeInstancesSql(ctx, scope, idOf, permission = ctx.permission === vo
|
|
|
161
101
|
const row = ctx.suspension?.scopes?.[name];
|
|
162
102
|
if (row === void 0) continue;
|
|
163
103
|
const id = idOf(name);
|
|
164
|
-
if (id === void 0) throw
|
|
104
|
+
if (id === void 0) throw configError(`PermDock CLI: rls.suspension.scopes.${name} needs the ${name} id on ${scope} memberships: add columns.${name} to the ${scope} memberships table`);
|
|
165
105
|
const keep = keptKeys(row);
|
|
166
106
|
if (permission !== void 0 && "key" in permission && keep.includes(permission.key)) continue;
|
|
167
107
|
const active = activeRowSql(row, `(${id})::${scopeTypeOf(ctx, name)}`);
|
|
@@ -184,7 +124,7 @@ function graphSqlName(name) {
|
|
|
184
124
|
}
|
|
185
125
|
function sqlNameOf(name, label) {
|
|
186
126
|
const sql = graphSqlName(name);
|
|
187
|
-
if (sql === void 0) throw
|
|
127
|
+
if (sql === void 0) throw configError(`PermDock CLI: ${label} is not a SQL name (letters, digits and underscores, starting with a letter), so rls generate cannot name its helper`);
|
|
188
128
|
return sql;
|
|
189
129
|
}
|
|
190
130
|
/** The helper returning the ids of `resource` the subject holds a relation on: `permitted_<snake_case resource>_ids`. */
|
|
@@ -225,7 +165,7 @@ function memberRoleOf(table, alias = "m", indent) {
|
|
|
225
165
|
/** The schema-qualified name of a table under `search_path = ''`. */
|
|
226
166
|
/** The helper returning the ids of scope `name` a grant key reaches. */
|
|
227
167
|
function permittedIdsHelper(name) {
|
|
228
|
-
if (!/^[a-z][a-z0-9_]*$/u.test(name)) throw
|
|
168
|
+
if (!/^[a-z][a-z0-9_]*$/u.test(name)) throw configError(`PermDock CLI: unsafe scope name '${name}'`);
|
|
229
169
|
return `permitted_${name}_ids`;
|
|
230
170
|
}
|
|
231
171
|
/** `permitted_<scope>_ids_for(p_user, p_grant)`: the same ids for a user the caller names. */
|
|
@@ -234,7 +174,7 @@ function permittedForHelper(name) {
|
|
|
234
174
|
}
|
|
235
175
|
/** The helper returning the ids of scope `name` the subject holds any live membership of. */
|
|
236
176
|
function memberIdsHelper(name) {
|
|
237
|
-
if (!/^[a-z][a-z0-9_]*$/u.test(name)) throw
|
|
177
|
+
if (!/^[a-z][a-z0-9_]*$/u.test(name)) throw configError(`PermDock CLI: unsafe scope name '${name}'`);
|
|
238
178
|
return `member_${name}_ids`;
|
|
239
179
|
}
|
|
240
180
|
/** `member_<scope>_ids_for(p_user uuid)`: the same rows for a user the caller names; the token hook's helper. */
|
|
@@ -250,6 +190,15 @@ function hasMemberFor(input, name) {
|
|
|
250
190
|
if (input.dialect !== "supabase") return false;
|
|
251
191
|
return memberForTable(input, name) !== void 0 || memberForSources(input, name).length > 0;
|
|
252
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Whether `permitted_<scope>_users` can list the members of one instance of
|
|
195
|
+
* `name`: a mapped membership table, or sources that all read a table.
|
|
196
|
+
*/
|
|
197
|
+
function hasPermittedUsers(input, name) {
|
|
198
|
+
if (memberForTable(input, name) !== void 0) return true;
|
|
199
|
+
const sources = memberForSources(input, name);
|
|
200
|
+
return sources.length > 0 && sources.every((source) => source.sql.holders !== void 0);
|
|
201
|
+
}
|
|
253
202
|
/** The table `member_<scope>_ids_for` reads for scope `name`, when `memberships` maps one with its id column. */
|
|
254
203
|
function memberForTable(input, name) {
|
|
255
204
|
const table = scopeMembershipTable(input.memberships, input.scopes, name);
|
|
@@ -262,7 +211,7 @@ function memberForSources(input, name) {
|
|
|
262
211
|
}
|
|
263
212
|
/**
|
|
264
213
|
* The membership sources that can hold scope `name` when `memberships` maps
|
|
265
|
-
* no table for it: every `
|
|
214
|
+
* no table for it: every `tableMembershipSource` source and the `junctionMembershipSource` sources of
|
|
266
215
|
* that scope. Empty outside `database` mode.
|
|
267
216
|
*/
|
|
268
217
|
function scopeSources(ctx, name) {
|
|
@@ -297,8 +246,8 @@ function claimPath(ref) {
|
|
|
297
246
|
const prefix = ["principal.claim.", "principal.claims."].find((item) => ref.startsWith(item));
|
|
298
247
|
if (prefix === void 0) return;
|
|
299
248
|
const segments = ref.slice(prefix.length).split(".");
|
|
300
|
-
if (segments.length > MAX_CLAIM_DEPTH) throw
|
|
301
|
-
for (const segment of segments) if (!CLAIM.test(segment) || isForbiddenKey(segment)) throw
|
|
249
|
+
if (segments.length > MAX_CLAIM_DEPTH) throw configError(`PermDock CLI: claim path '${ref}' is deeper than ${MAX_CLAIM_DEPTH}`);
|
|
250
|
+
for (const segment of segments) if (!CLAIM.test(segment) || isForbiddenKey(segment)) throw configError(`PermDock CLI: unsafe claim name '${segment}'`);
|
|
302
251
|
return segments;
|
|
303
252
|
}
|
|
304
253
|
function jsonPath(base, keys, last) {
|
|
@@ -312,7 +261,7 @@ function jsonPath(base, keys, last) {
|
|
|
312
261
|
*/
|
|
313
262
|
function claimRoot(ctx, path) {
|
|
314
263
|
const [head, ...rest] = path;
|
|
315
|
-
if (head === void 0) throw
|
|
264
|
+
if (head === void 0) throw configError("PermDock CLI: empty claim path");
|
|
316
265
|
switch (ctx.dialect) {
|
|
317
266
|
case "supabase": return {
|
|
318
267
|
root: "(select auth.jwt())",
|
|
@@ -372,13 +321,16 @@ function claimArraySql(ctx, path, type) {
|
|
|
372
321
|
}
|
|
373
322
|
function sqlValue(value, ctx, field) {
|
|
374
323
|
if (value !== null && typeof value === "object" && "ref" in value) return compileRef(value.ref, ctx, field);
|
|
375
|
-
if (value !== null && typeof value === "object" && "date" in value)
|
|
324
|
+
if (value !== null && typeof value === "object" && "date" in value) {
|
|
325
|
+
const instant = isoInstant(value.date);
|
|
326
|
+
return instant === void 0 ? "null" : quoteLiteral(isoAt(instant));
|
|
327
|
+
}
|
|
376
328
|
if (isReadonlyArray(value)) return `array[${value.map((item) => sqlValue(item, ctx)).join(", ")}]`;
|
|
377
329
|
if (value === null) return "null";
|
|
378
330
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
379
331
|
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "null";
|
|
380
332
|
if (typeof value === "string") return quoteLiteral(value);
|
|
381
|
-
throw
|
|
333
|
+
throw configError("PermDock CLI: non-portable condition value");
|
|
382
334
|
}
|
|
383
335
|
function compileRef(ref, ctx, field) {
|
|
384
336
|
if (ref === "principal.id") return subjectIdSql(ctx);
|
|
@@ -386,8 +338,8 @@ function compileRef(ref, ctx, field) {
|
|
|
386
338
|
const path = claimPath(ref);
|
|
387
339
|
if (ref === "principal.tenant" || path?.length === 1 && path[0] === ctx.tenantClaim) return tenantClaimSql(ctx);
|
|
388
340
|
if (path !== void 0) return typedClaimSql(ctx, path, field === void 0 ? void 0 : ctx.columnTypes?.[field]);
|
|
389
|
-
if (ref === "context" || ref.startsWith("context.")) throw
|
|
390
|
-
throw
|
|
341
|
+
if (ref === "context" || ref.startsWith("context.")) throw configError(`PermDock CLI: '${ref}' is request context, which is not in the token; RLS cannot read it (permdock doctor PD027)`);
|
|
342
|
+
throw configError(`PermDock CLI: non-portable subject ref '${ref}'`);
|
|
391
343
|
}
|
|
392
344
|
/** `value` with `\`, `%` and `_` escaped for `like ... escape '\'`, as `escapeLike` does in process. */
|
|
393
345
|
function likeLiteralSql(value) {
|
|
@@ -406,13 +358,18 @@ function compareSql(op, field, value) {
|
|
|
406
358
|
default: return op;
|
|
407
359
|
}
|
|
408
360
|
}
|
|
409
|
-
|
|
410
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Whether the row's `rowField` is among the ids in `table.rowColumn` the
|
|
363
|
+
* subject holds a matching membership on. The row field stays outside the
|
|
364
|
+
* subquery: inside it, an unqualified name resolves to the membership table
|
|
365
|
+
* first, so `m.org_id = "org_id"` would compare the membership with itself.
|
|
366
|
+
*/
|
|
367
|
+
function membershipSql(table, rowColumn, rowField, roles, ctx, tenantColumn, scope) {
|
|
368
|
+
const parts = [`m.${quoteIdent(table.user)} = ${subjectIdSql(ctx)}`];
|
|
411
369
|
const role = memberRoleOf(table);
|
|
412
370
|
if (roles.length > 0) {
|
|
413
|
-
const roleList = roles.map((name) => name.replaceAll("'", "''")).join(",");
|
|
414
371
|
const held = role.through === void 0 && !role.lateral ? `m.${quoteIdent(role.column)}` : role.lookup;
|
|
415
|
-
parts.push(`${held} = any(
|
|
372
|
+
parts.push(`${held} = any(${textArrayLiteral(roles)})`);
|
|
416
373
|
const via = memberVia(table);
|
|
417
374
|
const single = sole(roles);
|
|
418
375
|
const kind = single === void 0 ? kindFilterSql(ctx, role.lookup, via) : roleKindSql(ctx, single, via);
|
|
@@ -432,23 +389,23 @@ function existsSql(table, rowColumn, rowField, roles, ctx, tenantColumn, scope)
|
|
|
432
389
|
}));
|
|
433
390
|
}
|
|
434
391
|
const expand = roles.length > 0 && role.lateral ? role.join : "";
|
|
435
|
-
return
|
|
392
|
+
return `${quoteIdent(rowField)} in (select m.${quoteIdent(rowColumn)} from ${quoteTable(table.table)} m${expand} where ${parts.join(" and ")})`;
|
|
436
393
|
}
|
|
437
394
|
function compileMemberOf(condition, ctx) {
|
|
438
395
|
const scope = condition.scope === "resource" ? void 0 : resolveScope(ctx.scopes, condition.scope);
|
|
439
|
-
if (condition.scope !== "resource" && scope === void 0) throw
|
|
396
|
+
if (condition.scope !== "resource" && scope === void 0) throw configError(`PermDock CLI: memberOf ${condition.scope} names a scope the policy does not declare`);
|
|
440
397
|
const mapping = scope === void 0 ? condition.resource === void 0 ? void 0 : ctx.memberships?.resource?.[condition.resource] : scopeMembershipTable(ctx.memberships, ctx.scopes, scope);
|
|
441
398
|
if (mapping !== void 0) {
|
|
442
399
|
const named = scope === void 0 ? void 0 : scopeTable(ctx, scope);
|
|
443
400
|
const rowColumn = scope === void 0 ? mapping.id : named?.column;
|
|
444
|
-
if (rowColumn === void 0) throw
|
|
401
|
+
if (rowColumn === void 0) throw configError(`PermDock CLI: memberships mapping for ${condition.scope} is missing the row column`);
|
|
445
402
|
const tenantColumn = scope === void 0 || scope === rootScope(ctx.scopes) ? void 0 : named?.tenantColumn;
|
|
446
|
-
const primary =
|
|
403
|
+
const primary = membershipSql(mapping, rowColumn, condition.field, condition.roles, ctx, tenantColumn, scope);
|
|
447
404
|
if (condition.scope !== "resource" || condition.parents === void 0) return primary;
|
|
448
405
|
return `(${[primary, ...condition.parents.flatMap((parent) => {
|
|
449
|
-
if (typeof parent === "string") return [
|
|
406
|
+
if (typeof parent === "string") return [membershipSql(mapping, rowColumn, parent, condition.roles, ctx, tenantColumn)];
|
|
450
407
|
const hopTable = ctx.memberships?.resource?.[parent.resource];
|
|
451
|
-
return hopTable?.id === void 0 ? [] : [
|
|
408
|
+
return hopTable?.id === void 0 ? [] : [membershipSql(hopTable, hopTable.id, parent.field, condition.roles, ctx)];
|
|
452
409
|
})].join(" or ")})`;
|
|
453
410
|
}
|
|
454
411
|
if (scope !== void 0 && condition.roles.length === 0 && scopeSources(ctx, scope).length > 0) return `${quoteIdent(condition.field)} in (select ${qualified(ctx, memberIdsHelper(scope))}())`;
|
|
@@ -461,7 +418,7 @@ function compileMemberOf(condition, ctx) {
|
|
|
461
418
|
];
|
|
462
419
|
return parts.length === 1 ? parts.join("") : `(${parts.join(" and ")})`;
|
|
463
420
|
}
|
|
464
|
-
throw
|
|
421
|
+
throw configError(`PermDock CLI: memberOf ${condition.scope} needs a memberships table mapping`);
|
|
465
422
|
}
|
|
466
423
|
function compileConditionSql(condition, ctx) {
|
|
467
424
|
switch (condition.op) {
|
|
@@ -489,13 +446,14 @@ function compileConditionSql(condition, ctx) {
|
|
|
489
446
|
const path = claimPath(condition.value.ref);
|
|
490
447
|
if (path === void 0) {
|
|
491
448
|
compileRef(condition.value.ref, ctx);
|
|
492
|
-
throw
|
|
449
|
+
throw configError(`PermDock CLI: non-portable ${condition.op} against '${condition.value.ref}'`);
|
|
493
450
|
}
|
|
494
451
|
const column = quoteIdent(condition.field);
|
|
495
452
|
const list = claimArraySql(ctx, path, ctx.columnTypes?.[condition.field]);
|
|
496
453
|
return condition.op === "in" ? `${column} = any (${list})` : `(${column} is not null and not (${column} = any (${list})))`;
|
|
497
454
|
}
|
|
498
455
|
const values = condition.value.map((item) => sqlValue(item, ctx));
|
|
456
|
+
if (values.length === 0) return condition.op === "in" ? "false" : `${quoteIdent(condition.field)} is not null`;
|
|
499
457
|
return `${quoteIdent(condition.field)} ${keyword} (${values.join(", ")})`;
|
|
500
458
|
}
|
|
501
459
|
case "isNull": return `${quoteIdent(condition.field)} is ${condition.value ? "" : "not "}null`;
|
|
@@ -609,7 +567,7 @@ function compileRelatedSql(condition, ctx) {
|
|
|
609
567
|
*/
|
|
610
568
|
function compileHoppedSql(condition, ctx) {
|
|
611
569
|
const resources = ctx.graph?.resources;
|
|
612
|
-
if (resources === void 0) throw
|
|
570
|
+
if (resources === void 0) throw configError("PermDock CLI: a related condition with link hops needs the policy resources in the RLS context");
|
|
613
571
|
const schema = quoteIdent(helperSchema(ctx));
|
|
614
572
|
return graphSqlText(relatedSql(condition, {
|
|
615
573
|
resources,
|
|
@@ -661,17 +619,17 @@ function relationWhere(policy, grant, grantee) {
|
|
|
661
619
|
resources: policy.resources,
|
|
662
620
|
now: { ref: "now" }
|
|
663
621
|
});
|
|
664
|
-
if (where === void 0) throw
|
|
622
|
+
if (where === void 0) throw configError(`PermDock CLI: grant ${grant.permission.key} names relation '${grantee.relation}', which ${grant.permission.resource} does not declare`);
|
|
665
623
|
return where;
|
|
666
624
|
}
|
|
667
625
|
function inheritWhere(policy, grant, grantee) {
|
|
668
626
|
const where = inheritCondition(grantee, policy.resources.get(grant.permission.resource), policy.resources);
|
|
669
|
-
if (where === void 0) throw
|
|
627
|
+
if (where === void 0) throw configError(`PermDock CLI: grant ${grant.permission.key} inherits ${grantee.permission}, which ${grant.permission.resource} cannot reach`);
|
|
670
628
|
return where;
|
|
671
629
|
}
|
|
672
630
|
/** Only a deny for `oauth-client` actors compiles: Postgres sees `client_id` and `act`, not other actor kinds. */
|
|
673
631
|
function actorAccess(grant, grantee, others) {
|
|
674
|
-
if (grant.effect !== "deny" || grantee.actor !== "oauth-client" || others > 0) throw
|
|
632
|
+
if (grant.effect !== "deny" || grantee.actor !== "oauth-client" || others > 0) throw configError(`PermDock CLI: grant ${grant.permission.key} is limited to an actor grantee; RLS compiles only deny(…, { to: actor('oauth-client') }) on its own, so enforce it in the application`);
|
|
675
633
|
return {
|
|
676
634
|
kind: "actor",
|
|
677
635
|
actor: "oauth-client"
|
|
@@ -679,7 +637,7 @@ function actorAccess(grant, grantee, others) {
|
|
|
679
637
|
}
|
|
680
638
|
function accessOf(grant, roles, anyone) {
|
|
681
639
|
const [held, ...more] = roles;
|
|
682
|
-
if (more.length > 0) throw
|
|
640
|
+
if (more.length > 0) throw configError(`PermDock CLI: grant ${grant.permission.key} requires several roles at once (${roles.map((item) => item.role).join(", ")}); RLS compiles one role per grant`);
|
|
683
641
|
if (held === void 0) return anyone ? { kind: "anyone" } : { kind: "authenticated" };
|
|
684
642
|
if (typeof held.scope === "object") return {
|
|
685
643
|
kind: "resource",
|
|
@@ -724,7 +682,7 @@ function collectGrants(policy) {
|
|
|
724
682
|
delegated = actorAccess(grant, item, items.length - 1);
|
|
725
683
|
break;
|
|
726
684
|
case "plan":
|
|
727
|
-
case "assurance": throw
|
|
685
|
+
case "assurance": throw configError(`PermDock CLI: grant ${grant.permission.key} is limited to a ${item.kind} grantee, which RLS cannot compile; enforce it in the application`);
|
|
728
686
|
default: return item;
|
|
729
687
|
}
|
|
730
688
|
const access = delegated ?? accessOf(grant, roles, anyone);
|
|
@@ -759,7 +717,7 @@ const DEFAULTS = {
|
|
|
759
717
|
};
|
|
760
718
|
function field(settings, name) {
|
|
761
719
|
const value = settings[name] ?? DEFAULTS[name];
|
|
762
|
-
if (typeof value !== "string" || !SQL_IDENT.test(value)) throw
|
|
720
|
+
if (typeof value !== "string" || !SQL_IDENT.test(value)) throw configError(`PermDock CLI: rls.apiKeys.${name} must be a claim field name matching ${SQL_IDENT.source}`);
|
|
763
721
|
return value;
|
|
764
722
|
}
|
|
765
723
|
function apiKeyFields(setting) {
|
|
@@ -776,7 +734,7 @@ function apiKeysPlan(setting, declared, renamed) {
|
|
|
776
734
|
if (setting === void 0) return;
|
|
777
735
|
const fields = apiKeyFields(setting);
|
|
778
736
|
const unknown = fields.serviceRoles.filter((role) => !declared.has(role));
|
|
779
|
-
if (unknown.length > 0) throw
|
|
737
|
+
if (unknown.length > 0) throw configError(`PermDock CLI: rls.apiKeys.serviceRoles names roles the policy does not declare: ${unknown.join(", ")}`);
|
|
780
738
|
return {
|
|
781
739
|
...fields,
|
|
782
740
|
...Object.keys(renamed).length === 0 ? {} : { renamed }
|
|
@@ -873,595 +831,194 @@ ${body}
|
|
|
873
831
|
), false)`;
|
|
874
832
|
}
|
|
875
833
|
//#endregion
|
|
876
|
-
//#region src/cli/
|
|
877
|
-
/**
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
function
|
|
834
|
+
//#region src/cli/shared/compare.ts
|
|
835
|
+
/** UTF-16 code-unit order, never `localeCompare`: generated SQL must match byte for byte on every machine. */
|
|
836
|
+
function byCodeUnit(a, b) {
|
|
837
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
838
|
+
}
|
|
839
|
+
function byKey([a], [b]) {
|
|
840
|
+
return byCodeUnit(a, b);
|
|
841
|
+
}
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/cli/sql/global-roles.ts
|
|
844
|
+
function qualify(name, schema) {
|
|
845
|
+
return name.includes(".") ? name : `${schema}.${name}`;
|
|
846
|
+
}
|
|
847
|
+
function throughTenant(through, roles) {
|
|
848
|
+
if (through === void 0 || roles?.tenant === void 0) return;
|
|
849
|
+
return qualify(roles.table, "public") === through.table ? roles.tenant : void 0;
|
|
850
|
+
}
|
|
851
|
+
/** Resolves `source` against `schema`, aliasing the table `alias` and the roles table `<alias>k`. */
|
|
852
|
+
function globalRoleSource(source, schema, alias, roles) {
|
|
853
|
+
const table = qualify(source.table, schema);
|
|
854
|
+
const user = source.user ?? "user_id";
|
|
855
|
+
const role = roleColumn(source.role ?? "role", table, alias, {
|
|
856
|
+
label: "roles.role",
|
|
857
|
+
prefix: "PermDock CLI",
|
|
858
|
+
indent: " "
|
|
859
|
+
});
|
|
860
|
+
const tenant = role.lateral ? void 0 : throughTenant(role.through, roles);
|
|
861
|
+
const global = tenant === void 0 ? "" : ` and ${alias}k.${quoteIdent(tenant)} is null`;
|
|
862
|
+
return {
|
|
863
|
+
table,
|
|
864
|
+
user,
|
|
865
|
+
from: `${quoteTable(table)} ${alias}${role.join}${global}`,
|
|
866
|
+
userSql: `${alias}.${quoteIdent(user)}`,
|
|
867
|
+
roleSql: role.sql,
|
|
868
|
+
role: roleManifest(role),
|
|
869
|
+
...role.through === void 0 ? {} : { through: role.through },
|
|
870
|
+
...tenant === void 0 ? {} : { tenant }
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
//#endregion
|
|
874
|
+
//#region src/cli/rls/helper-rows.ts
|
|
875
|
+
function activeTenant(ctx) {
|
|
876
|
+
return `nullif(${subjectClaimSql(ctx, ctx.tenantClaim)}, '')`;
|
|
877
|
+
}
|
|
878
|
+
function keyTenantLine(ctx, scope, tenant) {
|
|
879
|
+
const keyed = keyTenantSql(ctx, underRoot(ctx, scope) ? tenant : void 0);
|
|
880
|
+
return keyed === void 0 ? "" : `\n and ${keyed}`;
|
|
881
|
+
}
|
|
882
|
+
/** Whether the helper for `scope` narrows to the tenant claim: unless `rls.tenants` is `'all'`, when the first scope is on its chain. */
|
|
883
|
+
function narrowsTo(ctx, scope) {
|
|
884
|
+
return ctx.tenants !== "all" && underRoot(ctx, scope);
|
|
885
|
+
}
|
|
886
|
+
/** Whether the active tenant narrows memberships of `scope`: the first scope is on its chain. */
|
|
887
|
+
function underRoot(ctx, scope) {
|
|
888
|
+
const root = ctx.scopes[0]?.name;
|
|
889
|
+
let current = ctx.scopes.find((item) => item.name === scope);
|
|
890
|
+
const seen = /* @__PURE__ */ new Set();
|
|
891
|
+
while (current !== void 0 && !seen.has(current.name)) {
|
|
892
|
+
if (current.name === root) return true;
|
|
893
|
+
seen.add(current.name);
|
|
894
|
+
const parent = current.within;
|
|
895
|
+
current = parent === void 0 ? void 0 : ctx.scopes.find((item) => item.name === parent);
|
|
896
|
+
}
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
/** `and exists (...)` lines for an active user; empty without `rls.suspension.users`. */
|
|
900
|
+
function userActive(ctx, indent, user) {
|
|
901
|
+
return activeUserSql(ctx, user).map((part) => `${indent}and ${part}`);
|
|
902
|
+
}
|
|
903
|
+
/** `and exists (...)` lines for every suspendable instance on the chain of `scope`'s membership. */
|
|
904
|
+
function instancesActive(ctx, scope, idOf, indent, permission) {
|
|
905
|
+
return activeInstancesSql(ctx, scope, idOf, permission).map((part) => `${indent}and ${part}`);
|
|
906
|
+
}
|
|
907
|
+
function roleRows(ctx) {
|
|
908
|
+
const claim = ctx.roleClaim ?? "user_role";
|
|
909
|
+
switch (ctx.dialect) {
|
|
910
|
+
case "guc": return `unnest(string_to_array(nullif(current_setting(${quoteLiteral(`${ctx.gucPrefix}.${claim}`)}, true), ''), ',')) r(role)`;
|
|
911
|
+
case "supabase":
|
|
912
|
+
case "neon": {
|
|
913
|
+
const raw = subjectClaimJsonSql(ctx, claim);
|
|
914
|
+
const value = ctx.dialect === "supabase" ? `coalesce(nullif(${raw}, 'null'::jsonb), (select auth.jwt()) -> 'app_metadata' -> ${quoteLiteral(claim)})` : raw;
|
|
915
|
+
return `jsonb_array_elements_text(
|
|
916
|
+
case jsonb_typeof(${value})
|
|
917
|
+
when 'array' then ${value}
|
|
918
|
+
when 'string' then jsonb_build_array(${value})
|
|
919
|
+
else '[]'::jsonb
|
|
920
|
+
end
|
|
921
|
+
) r(role)`;
|
|
922
|
+
}
|
|
923
|
+
default: return ctx.dialect;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
function membershipRows(ctx) {
|
|
927
|
+
const raw = subjectClaimJsonSql(ctx, "memberships");
|
|
928
|
+
const value = ctx.dialect === "supabase" ? `coalesce(${raw}, (select auth.jwt()) -> 'app_metadata' -> 'memberships')` : raw;
|
|
929
|
+
return `jsonb_array_elements(
|
|
930
|
+
case jsonb_typeof(${value}) when 'array' then ${value} else '[]'::jsonb end
|
|
931
|
+
) m
|
|
932
|
+
cross join lateral jsonb_array_elements_text(
|
|
933
|
+
case jsonb_typeof(m -> 'roles') when 'array' then m -> 'roles' else '[]'::jsonb end
|
|
934
|
+
) r(role)`;
|
|
935
|
+
}
|
|
936
|
+
function andLine(indent, condition) {
|
|
937
|
+
return condition === void 0 ? "" : `\n${indent}and ${condition}`;
|
|
938
|
+
}
|
|
939
|
+
/** `rls.roles`, or the generated `user_roles (user_id, role)`, aliased `ur`. */
|
|
940
|
+
function globalRoleRows(ctx) {
|
|
941
|
+
return ctx.roles === void 0 ? {
|
|
942
|
+
from: `${qualified(ctx, "user_roles")} ur`,
|
|
943
|
+
userSql: "ur.user_id",
|
|
944
|
+
roleSql: "ur.role::text"
|
|
945
|
+
} : globalRoleSource(ctx.roles, "public", "ur", ctx.customRoles?.table);
|
|
946
|
+
}
|
|
947
|
+
function customKeysJoin(ctx, scope, allows, denies, includes) {
|
|
948
|
+
return ` cross join lateral ${qualified(ctx, CUSTOM_ROLES.keys)}(
|
|
949
|
+
${allows},
|
|
950
|
+
${denies},
|
|
951
|
+
${includes},
|
|
952
|
+
${quoteLiteral(scope)}
|
|
953
|
+
) ck(grant_key)`;
|
|
954
|
+
}
|
|
955
|
+
//#endregion
|
|
956
|
+
//#region src/cli/rls/helper-bodies.ts
|
|
957
|
+
function hasSetBody(ctx) {
|
|
958
|
+
const rp = qualified(ctx, "role_permissions");
|
|
959
|
+
const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
|
|
890
960
|
const custom = ctx.customRoles;
|
|
891
|
-
if (
|
|
961
|
+
if (ctx.authorize === "database") {
|
|
962
|
+
const ur = globalRoleRows(ctx);
|
|
963
|
+
const declared = ` select rp.grant_key
|
|
964
|
+
from ${ur.from}
|
|
965
|
+
join ${rp} rp on rp.role = ${ur.roleSql}
|
|
966
|
+
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
967
|
+
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}`;
|
|
968
|
+
if (custom === void 0) return declared;
|
|
969
|
+
const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
|
|
970
|
+
const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
|
|
971
|
+
return `${declared}
|
|
972
|
+
union
|
|
973
|
+
select ck.grant_key
|
|
974
|
+
from ${ur.from}
|
|
975
|
+
${customKeysJoin(ctx, "global", rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"), rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), rows(CUSTOM_ROLES.includes, "include_role", ""))}
|
|
976
|
+
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
977
|
+
and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}`;
|
|
978
|
+
}
|
|
979
|
+
const declared = ` select rp.grant_key
|
|
980
|
+
from ${roleRows(ctx)}
|
|
981
|
+
join ${rp} rp on rp.role = r.role
|
|
982
|
+
where ${signedIn(ctx)}
|
|
983
|
+
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, "r.role"))}${active}`;
|
|
984
|
+
if (custom === void 0) return declared;
|
|
985
|
+
const claim = subjectClaimJsonSql(ctx, GLOBAL_GRANTS_CLAIM);
|
|
986
|
+
return `${declared}
|
|
987
|
+
union
|
|
988
|
+
select ck.grant_key
|
|
989
|
+
from ${roleRows(ctx)}
|
|
990
|
+
cross join lateral (select ${claim} -> r.role as g) cg
|
|
991
|
+
${customKeysJoin(ctx, "global", claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
|
|
992
|
+
where ${signedIn(ctx)}
|
|
993
|
+
and jsonb_typeof(cg.g) = 'array'
|
|
994
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))${active}`;
|
|
995
|
+
}
|
|
996
|
+
function hasBody(ctx) {
|
|
997
|
+
if (ctx.grantSet === true) return hasSetBody(ctx);
|
|
892
998
|
const rp = qualified(ctx, "role_permissions");
|
|
893
|
-
const
|
|
894
|
-
const
|
|
895
|
-
const chunks = [];
|
|
999
|
+
const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
|
|
1000
|
+
const custom = ctx.customRoles;
|
|
896
1001
|
if (ctx.authorize === "database") {
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
scope_id text,
|
|
918
|
-
role text not null,
|
|
919
|
-
${column} text not null${check},
|
|
920
|
-
check ((scope = 'global') = (tenant_id is null)),
|
|
921
|
-
check (scope <> 'global' or scope_id is null)
|
|
922
|
-
);
|
|
923
|
-
${index}
|
|
924
|
-
on ${table} (coalesce(tenant_id::text, ''), scope, coalesce(scope_id, ''), role, ${unique});
|
|
925
|
-
-- a row identity for triggers the application adds, such as an audit trigger that reads the primary key
|
|
926
|
-
alter table ${table} add column if not exists id bigint generated always as identity primary key;
|
|
927
|
-
alter table ${table} enable row level security;
|
|
928
|
-
revoke all on table ${table} from anon, authenticated, public;`);
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
chunks.push(`-- the ceiling: keys of assignable declared roles, and the denies of those roles on the same permissions
|
|
932
|
-
create or replace view ${ceiling} with (security_invoker = true) as
|
|
933
|
-
select rp.scope, rp.role, rp.permission, rp.grant_key, rp.effect
|
|
934
|
-
from ${rp} rp
|
|
935
|
-
where rp.role = any(${sqlTextArray(custom.assignable)})
|
|
936
|
-
and exists (
|
|
937
|
-
select 1 from ${rp} a
|
|
938
|
-
where a.role = rp.role
|
|
939
|
-
and a.permission = rp.permission
|
|
940
|
-
and a.scope = rp.scope
|
|
941
|
-
and a.effect = 'allow'
|
|
942
|
-
);
|
|
943
|
-
revoke all on table ${ceiling} from anon, authenticated, public;`);
|
|
944
|
-
const renames = Object.entries(custom.renamed ?? {}).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
945
|
-
if (custom.levels === true) {
|
|
946
|
-
chunks.push(leveledKeysSql(ctx, renames));
|
|
947
|
-
return chunks.join("\n\n");
|
|
948
|
-
}
|
|
949
|
-
const rename = renamesSql(renames);
|
|
950
|
-
chunks.push(`-- one custom role: included ceiling keys and same-scope denies, plus every ceiling key of an allowed permission, minus denied permissions
|
|
951
|
-
create or replace function ${keys}(p_allow text[], p_deny text[], p_include text[], p_scope text)
|
|
952
|
-
returns setof text
|
|
953
|
-
language sql
|
|
954
|
-
stable
|
|
955
|
-
set search_path = ''
|
|
956
|
-
as $$
|
|
957
|
-
with ${rename.prelude}ceiling as (
|
|
958
|
-
select c.role, ${rename.permission("c")}, c.grant_key, c.effect
|
|
959
|
-
from ${ceiling} c${rename.join("c")}
|
|
960
|
-
where c.scope = p_scope
|
|
961
|
-
),
|
|
962
|
-
included as (
|
|
963
|
-
select rp.role, ${rename.permission("rp")}, rp.grant_key, rp.scope, rp.effect
|
|
964
|
-
from ${rp} rp${rename.join("rp")}
|
|
965
|
-
where rp.role = any(coalesce(p_include, '{}'::text[]))
|
|
966
|
-
),
|
|
967
|
-
kept as (
|
|
968
|
-
select i.permission, i.grant_key
|
|
969
|
-
from included i
|
|
970
|
-
where i.effect = 'allow'
|
|
971
|
-
and i.scope = p_scope
|
|
972
|
-
and exists (
|
|
973
|
-
select 1 from ceiling c
|
|
974
|
-
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
975
|
-
)
|
|
976
|
-
),
|
|
977
|
-
wanted as (
|
|
978
|
-
${rename.allow}
|
|
979
|
-
union
|
|
980
|
-
select i.permission
|
|
981
|
-
from included i
|
|
982
|
-
where i.effect = 'allow'
|
|
983
|
-
and not (i.scope = p_scope and exists (
|
|
984
|
-
select 1 from ceiling c
|
|
985
|
-
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
986
|
-
))
|
|
987
|
-
),
|
|
988
|
-
allowed as (
|
|
989
|
-
select w.permission
|
|
990
|
-
from wanted w
|
|
991
|
-
where not (${rename.denied("w")})
|
|
992
|
-
and not exists (select 1 from kept k where k.permission = w.permission)
|
|
993
|
-
)
|
|
994
|
-
select k.grant_key from kept k
|
|
995
|
-
where not (${rename.denied("k")})
|
|
996
|
-
union
|
|
997
|
-
select i.grant_key from included i
|
|
998
|
-
where i.effect = 'deny' and i.scope = p_scope
|
|
999
|
-
union
|
|
1000
|
-
select c.grant_key from ceiling c
|
|
1001
|
-
where c.permission in (select a.permission from allowed a)
|
|
1002
|
-
$$;
|
|
1003
|
-
revoke execute on function ${keys}(text[], text[], text[], text) from public, anon, authenticated;`);
|
|
1004
|
-
return chunks.join("\n\n");
|
|
1005
|
-
}
|
|
1006
|
-
/**
|
|
1007
|
-
* `permdock_custom_keys` when resources declare levels. An allow entry is
|
|
1008
|
-
* `key` or `key@level`. A level reaches only the ceiling's `<grant key>@<level>`
|
|
1009
|
-
* keys, plus the ceiling denies of that permission; a level no ceiling key
|
|
1010
|
-
* carries removes the permission, as `resolveCustomRole` drops it with
|
|
1011
|
-
* `unknown-level`.
|
|
1012
|
-
*/
|
|
1013
|
-
function leveledKeysSql(ctx, renames) {
|
|
1014
|
-
const rp = qualified(ctx, "role_permissions");
|
|
1015
|
-
const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
|
|
1016
|
-
const keys = qualified(ctx, CUSTOM_ROLES.keys);
|
|
1017
|
-
const renamed = renames.length > 0;
|
|
1018
|
-
const prelude = renamed ? `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
|
|
1019
|
-
` : "";
|
|
1020
|
-
const current = (raw, alias) => renamed ? `coalesce(r_${alias}.key, ${raw})` : raw;
|
|
1021
|
-
const join = (raw, alias) => renamed ? ` left join renamed r_${alias} on r_${alias}.former = ${raw}` : "";
|
|
1022
|
-
return `-- one custom role: as above, where an allow entry key@level reaches only the ceiling keys of that level
|
|
1023
|
-
create or replace function ${keys}(p_allow text[], p_deny text[], p_include text[], p_scope text)
|
|
1024
|
-
returns setof text
|
|
1025
|
-
language sql
|
|
1026
|
-
stable
|
|
1027
|
-
set search_path = ''
|
|
1028
|
-
as $$
|
|
1029
|
-
with ${prelude}entries as (
|
|
1030
|
-
select ${current("split_part(e.entry, '@', 1)", "e")} as permission, nullif(split_part(e.entry, '@', 2), '') as level
|
|
1031
|
-
from unnest(coalesce(p_allow, '{}'::text[])) as e(entry)${join("split_part(e.entry, '@', 1)", "e")}
|
|
1032
|
-
),
|
|
1033
|
-
ceiling as (
|
|
1034
|
-
select c.role, ${current("c.permission", "c")} as permission, c.grant_key, c.effect
|
|
1035
|
-
from ${ceiling} c${join("c.permission", "c")}
|
|
1036
|
-
where c.scope = p_scope
|
|
1037
|
-
),
|
|
1038
|
-
levels as (
|
|
1039
|
-
select distinct c.permission, split_part(c.grant_key, '@', 2) as level
|
|
1040
|
-
from ceiling c
|
|
1041
|
-
where c.effect = 'allow' and strpos(c.grant_key, '@') > 0
|
|
1042
|
-
),
|
|
1043
|
-
denied as (
|
|
1044
|
-
select ${current("d.permission", "d")} as permission
|
|
1045
|
-
from unnest(coalesce(p_deny, '{}'::text[])) as d(permission)${join("d.permission", "d")}
|
|
1046
|
-
union
|
|
1047
|
-
select e.permission from entries e
|
|
1048
|
-
where e.level is not null
|
|
1049
|
-
and not exists (select 1 from levels l where l.permission = e.permission and l.level = e.level)
|
|
1050
|
-
),
|
|
1051
|
-
included as (
|
|
1052
|
-
select rp.role, ${current("rp.permission", "rp")} as permission, rp.grant_key, rp.scope, rp.effect
|
|
1053
|
-
from ${rp} rp${join("rp.permission", "rp")}
|
|
1054
|
-
where rp.role = any(coalesce(p_include, '{}'::text[]))
|
|
1055
|
-
),
|
|
1056
|
-
kept as (
|
|
1057
|
-
select i.permission, i.grant_key
|
|
1058
|
-
from included i
|
|
1059
|
-
where i.effect = 'allow'
|
|
1060
|
-
and i.scope = p_scope
|
|
1061
|
-
and exists (
|
|
1062
|
-
select 1 from ceiling c
|
|
1063
|
-
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
1064
|
-
)
|
|
1065
|
-
),
|
|
1066
|
-
wanted as (
|
|
1067
|
-
select e.permission from entries e where e.level is null
|
|
1068
|
-
union
|
|
1069
|
-
select i.permission
|
|
1070
|
-
from included i
|
|
1071
|
-
where i.effect = 'allow'
|
|
1072
|
-
and not (i.scope = p_scope and exists (
|
|
1073
|
-
select 1 from ceiling c
|
|
1074
|
-
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
1075
|
-
))
|
|
1076
|
-
),
|
|
1077
|
-
allowed as (
|
|
1078
|
-
select w.permission
|
|
1079
|
-
from wanted w
|
|
1080
|
-
where w.permission not in (select d.permission from denied d)
|
|
1081
|
-
and not exists (select 1 from kept k where k.permission = w.permission)
|
|
1082
|
-
),
|
|
1083
|
-
allowed_levels as (
|
|
1084
|
-
select e.permission, e.level
|
|
1085
|
-
from entries e
|
|
1086
|
-
where e.level is not null
|
|
1087
|
-
and e.permission not in (select d.permission from denied d)
|
|
1088
|
-
and e.permission not in (select a.permission from allowed a)
|
|
1089
|
-
and not exists (select 1 from kept k where k.permission = e.permission)
|
|
1090
|
-
)
|
|
1091
|
-
select k.grant_key from kept k
|
|
1092
|
-
where k.permission not in (select d.permission from denied d)
|
|
1093
|
-
union
|
|
1094
|
-
select i.grant_key from included i
|
|
1095
|
-
where i.effect = 'deny' and i.scope = p_scope
|
|
1096
|
-
union
|
|
1097
|
-
select c.grant_key from ceiling c
|
|
1098
|
-
where c.permission in (select a.permission from allowed a)
|
|
1099
|
-
union
|
|
1100
|
-
select c.grant_key from ceiling c
|
|
1101
|
-
join allowed_levels a on a.permission = c.permission
|
|
1102
|
-
where c.effect = 'deny' or split_part(c.grant_key, '@', 2) = a.level
|
|
1103
|
-
$$;
|
|
1104
|
-
revoke execute on function ${keys}(text[], text[], text[], text) from public, anon, authenticated;`;
|
|
1105
|
-
}
|
|
1106
|
-
function currentKeysSql(array) {
|
|
1107
|
-
return `select coalesce(r.key, e.permission) as permission
|
|
1108
|
-
from unnest(coalesce(${array}, '{}'::text[])) as e(permission)
|
|
1109
|
-
left join renamed r on r.former = e.permission`;
|
|
1110
|
-
}
|
|
1111
|
-
/**
|
|
1112
|
-
* SQL fragments that read a stored former key as its current key inside
|
|
1113
|
-
* `permdock_custom_keys`. Without renames they are the plain column and
|
|
1114
|
-
* array reads.
|
|
1115
|
-
*/
|
|
1116
|
-
function renamesSql(renames) {
|
|
1117
|
-
if (renames.length === 0) return {
|
|
1118
|
-
prelude: "",
|
|
1119
|
-
permission: (alias) => `${alias}.permission`,
|
|
1120
|
-
join: () => "",
|
|
1121
|
-
allow: "select unnest(coalesce(p_allow, '{}'::text[])) as permission",
|
|
1122
|
-
denied: (alias) => `${alias}.permission = any(coalesce(p_deny, '{}'::text[]))`
|
|
1123
|
-
};
|
|
1124
|
-
return {
|
|
1125
|
-
prelude: `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
|
|
1126
|
-
denied as (
|
|
1127
|
-
${currentKeysSql("p_deny")}
|
|
1128
|
-
),
|
|
1129
|
-
`,
|
|
1130
|
-
permission: (alias) => `coalesce(r_${alias}.key, ${alias}.permission) as permission`,
|
|
1131
|
-
join: (alias) => ` left join renamed r_${alias} on r_${alias}.former = ${alias}.permission`,
|
|
1132
|
-
allow: currentKeysSql("p_allow"),
|
|
1133
|
-
denied: (alias) => `${alias}.permission in (select d.permission from denied d)`
|
|
1134
|
-
};
|
|
1135
|
-
}
|
|
1136
|
-
/**
|
|
1137
|
-
* The catalog reader `permdock_permission_keys()` and the role reader
|
|
1138
|
-
* `permdock_role_permissions(p_role, p_scope, p_tenant, p_scope_id)`, both
|
|
1139
|
-
* for `authenticated`. A declared role answers from `role_permissions`, which
|
|
1140
|
-
* is the catalog. A custom role (`database` mode) answers from the tenant's
|
|
1141
|
-
* own rows through `permdock_custom_keys`, only to a member of the tenant or
|
|
1142
|
-
* a holder of a `meta.manageRoles` permission for a platform role. The
|
|
1143
|
-
* effect is `deny` for a deny the role carries after the ceiling, as
|
|
1144
|
-
* `resolveCustomRole` returns it, never for a deny that merely removes an allow.
|
|
1145
|
-
*/
|
|
1146
|
-
function roleReadersSql(ctx, permissions, trustedReaders) {
|
|
1147
|
-
const rp = qualified(ctx, "role_permissions");
|
|
1148
|
-
const reader = qualified(ctx, READERS.roles);
|
|
1149
|
-
const tenantType = tenantTypeOf(ctx);
|
|
1150
|
-
const custom = ctx.customRoles;
|
|
1151
|
-
const root = ctx.scopes[0]?.name;
|
|
1152
|
-
const database = ctx.authorize === "database";
|
|
1153
|
-
const chunks = [];
|
|
1154
|
-
if (permissions !== void 0) {
|
|
1155
|
-
const catalog = qualified(ctx, READERS.keys);
|
|
1156
|
-
chunks.push(`-- every declared permission key
|
|
1157
|
-
create or replace function ${catalog}()
|
|
1158
|
-
returns setof text
|
|
1159
|
-
language sql
|
|
1160
|
-
stable
|
|
1161
|
-
set search_path = ''
|
|
1162
|
-
as $$
|
|
1163
|
-
select k.permission from unnest(${sqlTextArray(permissions.toSorted())}) k(permission) order by 1
|
|
1164
|
-
$$;
|
|
1165
|
-
revoke execute on function ${catalog}() from public, anon;
|
|
1166
|
-
grant execute on function ${catalog}() to authenticated;
|
|
1167
|
-
|
|
1168
|
-
create or replace function ${catalog}(p_scope text)
|
|
1169
|
-
returns setof text
|
|
1170
|
-
language sql
|
|
1171
|
-
stable
|
|
1172
|
-
security definer
|
|
1173
|
-
set search_path = ''
|
|
1174
|
-
as $$
|
|
1175
|
-
select distinct rp.permission
|
|
1176
|
-
from ${rp} rp
|
|
1177
|
-
where rp.scope = p_scope
|
|
1178
|
-
and rp.effect = 'allow'
|
|
1179
|
-
and rp.permission = any(${sqlTextArray(permissions.toSorted())})
|
|
1180
|
-
order by 1
|
|
1181
|
-
$$;
|
|
1182
|
-
revoke execute on function ${catalog}(text) from public, anon;
|
|
1183
|
-
grant execute on function ${catalog}(text) to authenticated;`);
|
|
1184
|
-
}
|
|
1185
|
-
const declared = ` select distinct rp.permission, rp.effect
|
|
1186
|
-
from ${rp} rp
|
|
1187
|
-
where rp.role = p_role and rp.scope = p_scope
|
|
1188
|
-
order by 1, 2`;
|
|
1189
|
-
let body = `begin
|
|
1190
|
-
perform p_tenant, p_scope_id; -- only a custom role reads them
|
|
1191
|
-
return query
|
|
1192
|
-
${declared};
|
|
1193
|
-
end;`;
|
|
1194
|
-
let trustedBody = body;
|
|
1195
|
-
if (custom !== void 0 && database && root !== void 0) {
|
|
1196
|
-
const tenant = qualified(ctx, CUSTOM_ROLES.permissions);
|
|
1197
|
-
const includes = qualified(ctx, CUSTOM_ROLES.includes);
|
|
1198
|
-
const keys = qualified(ctx, CUSTOM_ROLES.keys);
|
|
1199
|
-
const shape = qualified(ctx, CUSTOM_ROLES.shape);
|
|
1200
|
-
const ids = (helper) => `(select x::text from ${qualified(ctx, helper)}() x)`;
|
|
1201
|
-
const memberOf = `p_tenant::text in ${ids(memberIdsHelper(root))}
|
|
1202
|
-
or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${ids(memberIdsHelper(scope))}`)}`;
|
|
1203
|
-
const manage = custom.manage ?? [];
|
|
1204
|
-
const platform = manage.length === 0 ? "false" : `exists (
|
|
1205
|
-
select 1 from ${rp} m
|
|
1206
|
-
where m.effect = 'allow'
|
|
1207
|
-
and m.permission = any(${sqlTextArray(manage)})
|
|
1208
|
-
and ${qualified(ctx, HELPERS.has)}(m.grant_key)
|
|
1209
|
-
)`;
|
|
1210
|
-
const match = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.scope_id is not distinct from p_scope_id and c.role = p_role`;
|
|
1211
|
-
const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
|
|
1212
|
-
const checked = (gate) => `begin
|
|
1213
|
-
if p_role = any(${sqlTextArray(custom.declared)}) then
|
|
1214
|
-
return query
|
|
1215
|
-
${declared};
|
|
1216
|
-
return;
|
|
1217
|
-
end if;
|
|
1218
|
-
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);${gate}
|
|
1219
|
-
return query
|
|
1220
|
-
select distinct rp.permission, rp.effect
|
|
1221
|
-
from ${keys}(
|
|
1222
|
-
array(select ${allowEntryOf} from ${tenant} c where ${match} and c.effect = 'allow'),
|
|
1223
|
-
array(select c.permission from ${tenant} c where ${match} and c.effect = 'deny'),
|
|
1224
|
-
array(select c.include_role from ${includes} c where ${match}),
|
|
1225
|
-
p_scope
|
|
1226
|
-
) k(grant_key)
|
|
1227
|
-
join ${rp} rp on rp.grant_key = k.grant_key and rp.scope = p_scope
|
|
1228
|
-
order by 1, 2;
|
|
1229
|
-
end;`;
|
|
1230
|
-
body = checked(`
|
|
1231
|
-
if not (${signedIn(ctx)} and (
|
|
1232
|
-
${platform}
|
|
1233
|
-
or (p_scope <> 'global' and (${memberOf}))
|
|
1234
|
-
)) then
|
|
1235
|
-
${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
|
|
1236
|
-
end if;`);
|
|
1237
|
-
trustedBody = checked("");
|
|
1238
|
-
}
|
|
1239
|
-
chunks.push(`-- the permission keys a role holds on a scope, with effect allow or deny; a custom role is read for its tenant and, below the first scope, its instance
|
|
1240
|
-
create or replace function ${reader}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
|
|
1241
|
-
returns table (permission text, effect text)
|
|
1242
|
-
language plpgsql
|
|
1243
|
-
stable
|
|
1244
|
-
security definer
|
|
1245
|
-
set search_path = ''
|
|
1246
|
-
as $$
|
|
1247
|
-
${body}
|
|
1248
|
-
$$;
|
|
1249
|
-
revoke execute on function ${reader}(text, text, ${tenantType}, text) from public, anon;
|
|
1250
|
-
grant execute on function ${reader}(text, text, ${tenantType}, text) to authenticated;`);
|
|
1251
|
-
const trusted = qualified(ctx, READERS.trusted);
|
|
1252
|
-
const signature = `text, text, ${tenantType}, text`;
|
|
1253
|
-
const grants = trustedReaders === void 0 || trustedReaders.length === 0 ? "" : `\ngrant execute on function ${trusted}(${signature}) to ${trustedReaders.map(quoteIdent).join(", ")};`;
|
|
1254
|
-
chunks.push(`create or replace function ${trusted}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
|
|
1255
|
-
returns table (permission text, effect text)
|
|
1256
|
-
language plpgsql
|
|
1257
|
-
stable
|
|
1258
|
-
security definer
|
|
1259
|
-
set search_path = ''
|
|
1260
|
-
as $$
|
|
1261
|
-
${trustedBody}
|
|
1262
|
-
$$;
|
|
1263
|
-
revoke execute on function ${trusted}(${signature}) from public, anon, authenticated;${grants}`);
|
|
1264
|
-
return chunks.join("\n\n");
|
|
1265
|
-
}
|
|
1266
|
-
/** `expr` read as its current key: a former key maps to the key it was renamed to. */
|
|
1267
|
-
function currentKeyExpr(renames, expr) {
|
|
1268
|
-
if (renames.length === 0) return expr;
|
|
1269
|
-
return `(case ${expr} ${renames.map(([former, current]) => `when ${quoteLiteral(former)} then ${quoteLiteral(current)}`).join(" ")} else ${expr} end)`;
|
|
1270
|
-
}
|
|
1271
|
-
/** `case p_scope when '<scope>' then <sql for scope> ... else false end` over the declared scopes. */
|
|
1272
|
-
function perScope(ctx, sql) {
|
|
1273
|
-
const arms = ctx.scopes.map((scope) => `when ${quoteLiteral(scope.name)} then ${sql(scope.name)}`).join(" ");
|
|
1274
|
-
return arms === "" ? "false" : `(case p_scope ${arms} else false end)`;
|
|
1275
|
-
}
|
|
1276
|
-
function raiseSql(indent, errcode, message, hint) {
|
|
1277
|
-
return `${indent}raise exception using
|
|
1278
|
-
${indent} errcode = '${errcode}',
|
|
1279
|
-
${indent} message = ${message},
|
|
1280
|
-
${indent} hint = ${quoteLiteral(hint)};`;
|
|
1281
|
-
}
|
|
1282
|
-
function hasCustomRoleChecksFor(ctx) {
|
|
1283
|
-
return ctx.authorize === "database" && ctx.customRoles !== void 0 && ctx.scopes.length > 0 && ctx.scopes.every((scope) => hasMemberFor(ctx, scope.name));
|
|
1284
|
-
}
|
|
1285
|
-
//#endregion
|
|
1286
|
-
//#region src/cli/sql/global-roles.ts
|
|
1287
|
-
function qualify(name, schema) {
|
|
1288
|
-
return name.includes(".") ? name : `${schema}.${name}`;
|
|
1289
|
-
}
|
|
1290
|
-
function throughTenant(through, roles) {
|
|
1291
|
-
if (through === void 0 || roles?.tenant === void 0) return;
|
|
1292
|
-
return qualify(roles.table, "public") === through.table ? roles.tenant : void 0;
|
|
1293
|
-
}
|
|
1294
|
-
/** Resolves `source` against `schema`, aliasing the table `alias` and the roles table `<alias>k`. */
|
|
1295
|
-
function globalRoleSource(source, schema, alias, roles) {
|
|
1296
|
-
const table = qualify(source.table, schema);
|
|
1297
|
-
const user = source.user ?? "user_id";
|
|
1298
|
-
const role = roleColumn(source.role ?? "role", table, alias, {
|
|
1299
|
-
label: "roles.role",
|
|
1300
|
-
prefix: "PermDock CLI",
|
|
1301
|
-
indent: " "
|
|
1302
|
-
});
|
|
1303
|
-
const tenant = role.lateral ? void 0 : throughTenant(role.through, roles);
|
|
1304
|
-
const global = tenant === void 0 ? "" : ` and ${alias}k.${quoteIdent(tenant)} is null`;
|
|
1305
|
-
return {
|
|
1306
|
-
table,
|
|
1307
|
-
user,
|
|
1308
|
-
from: `${quoteTable(table)} ${alias}${role.join}${global}`,
|
|
1309
|
-
userSql: `${alias}.${quoteIdent(user)}`,
|
|
1310
|
-
roleSql: role.sql,
|
|
1311
|
-
role: roleManifest(role),
|
|
1312
|
-
...role.through === void 0 ? {} : { through: role.through },
|
|
1313
|
-
...tenant === void 0 ? {} : { tenant }
|
|
1314
|
-
};
|
|
1315
|
-
}
|
|
1316
|
-
//#endregion
|
|
1317
|
-
//#region src/cli/rls/helper-rows.ts
|
|
1318
|
-
function activeTenant(ctx) {
|
|
1319
|
-
return `nullif(${subjectClaimSql(ctx, ctx.tenantClaim)}, '')`;
|
|
1320
|
-
}
|
|
1321
|
-
function keyTenantLine(ctx, scope, tenant) {
|
|
1322
|
-
const keyed = keyTenantSql(ctx, underRoot(ctx, scope) ? tenant : void 0);
|
|
1323
|
-
return keyed === void 0 ? "" : `\n and ${keyed}`;
|
|
1324
|
-
}
|
|
1325
|
-
/** Whether the helper for `scope` narrows to the tenant claim: unless `rls.tenants` is `'all'`, when the first scope is on its chain. */
|
|
1326
|
-
function narrowsTo(ctx, scope) {
|
|
1327
|
-
return ctx.tenants !== "all" && underRoot(ctx, scope);
|
|
1328
|
-
}
|
|
1329
|
-
/** Whether the active tenant narrows memberships of `scope`: the first scope is on its chain. */
|
|
1330
|
-
function underRoot(ctx, scope) {
|
|
1331
|
-
const root = ctx.scopes[0]?.name;
|
|
1332
|
-
let current = ctx.scopes.find((item) => item.name === scope);
|
|
1333
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1334
|
-
while (current !== void 0 && !seen.has(current.name)) {
|
|
1335
|
-
if (current.name === root) return true;
|
|
1336
|
-
seen.add(current.name);
|
|
1337
|
-
const parent = current.within;
|
|
1338
|
-
current = parent === void 0 ? void 0 : ctx.scopes.find((item) => item.name === parent);
|
|
1339
|
-
}
|
|
1340
|
-
return false;
|
|
1341
|
-
}
|
|
1342
|
-
/** `and exists (...)` lines for an active user; empty without `rls.suspension.users`. */
|
|
1343
|
-
function userActive(ctx, indent, user) {
|
|
1344
|
-
return activeUserSql(ctx, user).map((part) => `${indent}and ${part}`);
|
|
1345
|
-
}
|
|
1346
|
-
/** `and exists (...)` lines for every suspendable instance on the chain of `scope`'s membership. */
|
|
1347
|
-
function instancesActive(ctx, scope, idOf, indent, permission) {
|
|
1348
|
-
return activeInstancesSql(ctx, scope, idOf, permission).map((part) => `${indent}and ${part}`);
|
|
1349
|
-
}
|
|
1350
|
-
function roleRows(ctx) {
|
|
1351
|
-
const claim = ctx.roleClaim ?? "user_role";
|
|
1352
|
-
switch (ctx.dialect) {
|
|
1353
|
-
case "guc": return `unnest(string_to_array(nullif(current_setting(${quoteLiteral(`${ctx.gucPrefix}.${claim}`)}, true), ''), ',')) r(role)`;
|
|
1354
|
-
case "supabase":
|
|
1355
|
-
case "neon": {
|
|
1356
|
-
const raw = subjectClaimJsonSql(ctx, claim);
|
|
1357
|
-
const value = ctx.dialect === "supabase" ? `coalesce(nullif(${raw}, 'null'::jsonb), (select auth.jwt()) -> 'app_metadata' -> ${quoteLiteral(claim)})` : raw;
|
|
1358
|
-
return `jsonb_array_elements_text(
|
|
1359
|
-
case jsonb_typeof(${value})
|
|
1360
|
-
when 'array' then ${value}
|
|
1361
|
-
when 'string' then jsonb_build_array(${value})
|
|
1362
|
-
else '[]'::jsonb
|
|
1363
|
-
end
|
|
1364
|
-
) r(role)`;
|
|
1365
|
-
}
|
|
1366
|
-
default: return ctx.dialect;
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
function membershipRows(ctx) {
|
|
1370
|
-
const raw = subjectClaimJsonSql(ctx, "memberships");
|
|
1371
|
-
const value = ctx.dialect === "supabase" ? `coalesce(${raw}, (select auth.jwt()) -> 'app_metadata' -> 'memberships')` : raw;
|
|
1372
|
-
return `jsonb_array_elements(
|
|
1373
|
-
case jsonb_typeof(${value}) when 'array' then ${value} else '[]'::jsonb end
|
|
1374
|
-
) m
|
|
1375
|
-
cross join lateral jsonb_array_elements_text(
|
|
1376
|
-
case jsonb_typeof(m -> 'roles') when 'array' then m -> 'roles' else '[]'::jsonb end
|
|
1377
|
-
) r(role)`;
|
|
1378
|
-
}
|
|
1379
|
-
function andLine(indent, condition) {
|
|
1380
|
-
return condition === void 0 ? "" : `\n${indent}and ${condition}`;
|
|
1381
|
-
}
|
|
1382
|
-
/** `rls.roles`, or the generated `user_roles (user_id, role)`, aliased `ur`. */
|
|
1383
|
-
function globalRoleRows(ctx) {
|
|
1384
|
-
return ctx.roles === void 0 ? {
|
|
1385
|
-
from: `${qualified(ctx, "user_roles")} ur`,
|
|
1386
|
-
userSql: "ur.user_id",
|
|
1387
|
-
roleSql: "ur.role::text"
|
|
1388
|
-
} : globalRoleSource(ctx.roles, "public", "ur", ctx.customRoles?.table);
|
|
1389
|
-
}
|
|
1390
|
-
function customKeysJoin(ctx, scope, allows, denies, includes) {
|
|
1391
|
-
return ` cross join lateral ${qualified(ctx, CUSTOM_ROLES.keys)}(
|
|
1392
|
-
${allows},
|
|
1393
|
-
${denies},
|
|
1394
|
-
${includes},
|
|
1395
|
-
${quoteLiteral(scope)}
|
|
1396
|
-
) ck(grant_key)`;
|
|
1397
|
-
}
|
|
1398
|
-
//#endregion
|
|
1399
|
-
//#region src/cli/rls/helper-bodies.ts
|
|
1400
|
-
function hasSetBody(ctx) {
|
|
1401
|
-
const rp = qualified(ctx, "role_permissions");
|
|
1402
|
-
const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
|
|
1403
|
-
const custom = ctx.customRoles;
|
|
1404
|
-
if (ctx.authorize === "database") {
|
|
1405
|
-
const ur = globalRoleRows(ctx);
|
|
1406
|
-
const declared = ` select rp.grant_key
|
|
1407
|
-
from ${ur.from}
|
|
1408
|
-
join ${rp} rp on rp.role = ${ur.roleSql}
|
|
1409
|
-
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1410
|
-
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}`;
|
|
1411
|
-
if (custom === void 0) return declared;
|
|
1412
|
-
const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
|
|
1413
|
-
const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
|
|
1414
|
-
return `${declared}
|
|
1415
|
-
union
|
|
1416
|
-
select ck.grant_key
|
|
1417
|
-
from ${ur.from}
|
|
1418
|
-
${customKeysJoin(ctx, "global", rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"), rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), rows(CUSTOM_ROLES.includes, "include_role", ""))}
|
|
1419
|
-
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1420
|
-
and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}`;
|
|
1421
|
-
}
|
|
1422
|
-
const declared = ` select rp.grant_key
|
|
1423
|
-
from ${roleRows(ctx)}
|
|
1424
|
-
join ${rp} rp on rp.role = r.role
|
|
1425
|
-
where ${signedIn(ctx)}
|
|
1426
|
-
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, "r.role"))}${active}`;
|
|
1427
|
-
if (custom === void 0) return declared;
|
|
1428
|
-
const claim = subjectClaimJsonSql(ctx, GLOBAL_GRANTS_CLAIM);
|
|
1429
|
-
return `${declared}
|
|
1430
|
-
union
|
|
1431
|
-
select ck.grant_key
|
|
1432
|
-
from ${roleRows(ctx)}
|
|
1433
|
-
cross join lateral (select ${claim} -> r.role as g) cg
|
|
1434
|
-
${customKeysJoin(ctx, "global", claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
|
|
1435
|
-
where ${signedIn(ctx)}
|
|
1436
|
-
and jsonb_typeof(cg.g) = 'array'
|
|
1437
|
-
and not (r.role = any(${sqlTextArray(custom.declared)}))${active}`;
|
|
1438
|
-
}
|
|
1439
|
-
function hasBody(ctx) {
|
|
1440
|
-
if (ctx.grantSet === true) return hasSetBody(ctx);
|
|
1441
|
-
const rp = qualified(ctx, "role_permissions");
|
|
1442
|
-
const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
|
|
1443
|
-
const custom = ctx.customRoles;
|
|
1444
|
-
if (ctx.authorize === "database") {
|
|
1445
|
-
const ur = globalRoleRows(ctx);
|
|
1446
|
-
const declared = ` select exists (
|
|
1447
|
-
select 1
|
|
1448
|
-
from ${ur.from}
|
|
1449
|
-
join ${rp} rp on rp.role = ${ur.roleSql}
|
|
1450
|
-
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1451
|
-
and rp.grant_key = p_grant
|
|
1452
|
-
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}
|
|
1453
|
-
)`;
|
|
1454
|
-
if (custom === void 0) return declared;
|
|
1455
|
-
const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
|
|
1456
|
-
const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
|
|
1457
|
-
return `${declared}
|
|
1458
|
-
or exists (
|
|
1459
|
-
select 1
|
|
1460
|
-
from ${ur.from}
|
|
1461
|
-
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1462
|
-
and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}
|
|
1463
|
-
${customKeysSql(ctx, "global", rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"), rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), rows(CUSTOM_ROLES.includes, "include_role", "")).trimStart()}
|
|
1464
|
-
)`;
|
|
1002
|
+
const ur = globalRoleRows(ctx);
|
|
1003
|
+
const declared = ` select exists (
|
|
1004
|
+
select 1
|
|
1005
|
+
from ${ur.from}
|
|
1006
|
+
join ${rp} rp on rp.role = ${ur.roleSql}
|
|
1007
|
+
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1008
|
+
and rp.grant_key = p_grant
|
|
1009
|
+
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}
|
|
1010
|
+
)`;
|
|
1011
|
+
if (custom === void 0) return declared;
|
|
1012
|
+
const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
|
|
1013
|
+
const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
|
|
1014
|
+
return `${declared}
|
|
1015
|
+
or exists (
|
|
1016
|
+
select 1
|
|
1017
|
+
from ${ur.from}
|
|
1018
|
+
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1019
|
+
and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}
|
|
1020
|
+
${customKeysSql(ctx, "global", rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"), rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), rows(CUSTOM_ROLES.includes, "include_role", "")).trimStart()}
|
|
1021
|
+
)`;
|
|
1465
1022
|
}
|
|
1466
1023
|
const declared = ` select ${signedIn(ctx)} and exists (
|
|
1467
1024
|
select 1
|
|
@@ -1585,8 +1142,8 @@ function claimBody(ctx, scope, type) {
|
|
|
1585
1142
|
and (${activeTenant(ctx)} is null or ${claimTenant(ctx, scope)} = ${activeTenant(ctx)})` : "";
|
|
1586
1143
|
const filtersFor = (grant) => ` and m ->> 'scope' = ${quoteLiteral(scope)}
|
|
1587
1144
|
and m ->> 'id' is not null${narrow}${keyTenantLine(ctx, scope, claimTenant(ctx, scope))}
|
|
1588
|
-
and case jsonb_typeof(m -> '
|
|
1589
|
-
when 'number' then (m ->> '
|
|
1145
|
+
and case jsonb_typeof(m -> 'expires_at')
|
|
1146
|
+
when 'number' then (m ->> 'expires_at')::numeric > extract(epoch from now())
|
|
1590
1147
|
else true
|
|
1591
1148
|
end${[
|
|
1592
1149
|
...claimKeptLines(ctx, " ", grantPermissionSql(grant)),
|
|
@@ -1601,181 +1158,591 @@ function claimBody(ctx, scope, type) {
|
|
|
1601
1158
|
and rp.scope = ${quoteLiteral(scope)}${andLine(" ", kindFilterSql(ctx, "r.role", "m ->> 'via'"))}
|
|
1602
1159
|
${filters}`;
|
|
1603
1160
|
const custom = ctx.customRoles;
|
|
1604
|
-
if (custom === void 0) return declared;
|
|
1605
|
-
if (set) return `${declared}
|
|
1161
|
+
if (custom === void 0) return declared;
|
|
1162
|
+
if (set) return `${declared}
|
|
1163
|
+
union
|
|
1164
|
+
select (m ->> 'id')::${type}, ck.grant_key
|
|
1165
|
+
from ${membershipRows(ctx)}
|
|
1166
|
+
cross join lateral (select m -> 'grants' -> r.role as g) cg
|
|
1167
|
+
${customKeysJoin(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
|
|
1168
|
+
where ${signedIn(ctx)}
|
|
1169
|
+
and jsonb_typeof(cg.g) = 'array'
|
|
1170
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))
|
|
1171
|
+
${filtersFor("ck.grant_key")}`;
|
|
1172
|
+
return `${declared}
|
|
1173
|
+
union
|
|
1174
|
+
select (m ->> 'id')::${type}
|
|
1175
|
+
from ${membershipRows(ctx)}
|
|
1176
|
+
cross join lateral (select m -> 'grants' -> r.role as g) cg
|
|
1177
|
+
where ${signedIn(ctx)}
|
|
1178
|
+
and jsonb_typeof(cg.g) = 'array'
|
|
1179
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))
|
|
1180
|
+
${filters}
|
|
1181
|
+
${customKeysSql(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}`;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* The rows the membership sources select for the subject, one per instance
|
|
1185
|
+
* (`scope`, `id`, `within`, `roles`, `via`), with each source's own expiry
|
|
1186
|
+
* and suspension filters: the statements the token hook runs.
|
|
1187
|
+
*/
|
|
1188
|
+
function sourcesKeep(sources) {
|
|
1189
|
+
return sources.some((source) => source.sql.keeps);
|
|
1190
|
+
}
|
|
1191
|
+
function sourceRows(sources) {
|
|
1192
|
+
const keep = sourcesKeep(sources);
|
|
1193
|
+
return sources.map((source, index) => source.sql.select(sourceUser(index), keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
1194
|
+
}
|
|
1195
|
+
function sourceUser(index) {
|
|
1196
|
+
return `v_user_${String(index)}`;
|
|
1197
|
+
}
|
|
1198
|
+
function sqlBody(sql) {
|
|
1199
|
+
return {
|
|
1200
|
+
sql,
|
|
1201
|
+
vars: []
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
function sourcesBodyOf(sql, sources, user) {
|
|
1205
|
+
return {
|
|
1206
|
+
sql,
|
|
1207
|
+
vars: sources.map((source, index) => `${sourceUser(index)} ${source.sql.userType} := ${user};`)
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
/** `language sql`, or `plpgsql` returning the query when the body declares variables. */
|
|
1211
|
+
function functionBody(body) {
|
|
1212
|
+
if (body.vars.length === 0) return `language sql
|
|
1213
|
+
stable
|
|
1214
|
+
security definer
|
|
1215
|
+
set search_path = ''
|
|
1216
|
+
as $$
|
|
1217
|
+
${body.sql}
|
|
1218
|
+
$$;`;
|
|
1219
|
+
return `language plpgsql
|
|
1220
|
+
stable
|
|
1221
|
+
security definer
|
|
1222
|
+
set search_path = ''
|
|
1223
|
+
as $$
|
|
1224
|
+
declare
|
|
1225
|
+
${body.vars.map((line) => ` ${line}`).join("\n")}
|
|
1226
|
+
begin
|
|
1227
|
+
return query
|
|
1228
|
+
${body.sql};
|
|
1229
|
+
end;
|
|
1230
|
+
$$;`;
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* `ms.id` for the scope itself and `ms.within ->> name` for an ancestor. The
|
|
1234
|
+
* alias is not `s`, which `activeRowSql` binds inside its `exists`.
|
|
1235
|
+
*/
|
|
1236
|
+
function sourceIdOf(scope) {
|
|
1237
|
+
return (name) => name === scope ? "ms.id" : `ms.within ->> ${quoteLiteral(name)}`;
|
|
1238
|
+
}
|
|
1239
|
+
function sourceFilters(ctx, scope, user = subjectIdSql(ctx), permission, keeps = false) {
|
|
1240
|
+
return [
|
|
1241
|
+
...keeps ? [` and ${keptRowSql("ms.keep", permission)}`] : [],
|
|
1242
|
+
...userActive(ctx, " ", user),
|
|
1243
|
+
...instancesActive(ctx, scope, sourceIdOf(scope), " ", permission)
|
|
1244
|
+
].map((line) => `\n${line}`).join("");
|
|
1245
|
+
}
|
|
1246
|
+
function sourcesBody(ctx, scope, type) {
|
|
1247
|
+
const root = rootName(ctx);
|
|
1248
|
+
const tenant = sourceIdOf(scope)(root);
|
|
1249
|
+
const narrow = narrowsTo(ctx, scope) ? `\n and (${activeTenant(ctx)} is null or ${tenant} = ${activeTenant(ctx)})${keyTenantLine(ctx, scope, tenant)}` : keyTenantLine(ctx, scope, tenant);
|
|
1250
|
+
const sources = scopeSources(ctx, scope);
|
|
1251
|
+
const set = ctx.grantSet === true;
|
|
1252
|
+
const rows = ` from (
|
|
1253
|
+
${sourceRows(sources)}
|
|
1254
|
+
) ms
|
|
1255
|
+
cross join lateral jsonb_array_elements_text(
|
|
1256
|
+
case jsonb_typeof(ms.roles) when 'array' then ms.roles else '[]'::jsonb end
|
|
1257
|
+
) r(role)`;
|
|
1258
|
+
const declared = ` select (ms.id)::${type}${set ? ", rp.grant_key" : ""}
|
|
1259
|
+
${rows}
|
|
1260
|
+
join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
|
|
1261
|
+
where ${signedIn(ctx)}
|
|
1262
|
+
and ms.scope = ${quoteLiteral(scope)}${set ? "" : "\n and rp.grant_key = p_grant"}
|
|
1263
|
+
and rp.scope = ${quoteLiteral(scope)}${andLine(" ", kindFilterSql(ctx, "r.role", "ms.via"))}${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql(set ? "rp.grant_key" : "p_grant"), sourcesKeep(sources))}`;
|
|
1264
|
+
const custom = ctx.customRoles;
|
|
1265
|
+
if (custom === void 0) return sourcesBodyOf(declared, sources, subjectIdSql(ctx));
|
|
1266
|
+
const match = [
|
|
1267
|
+
`c.tenant_id::text = (${tenant})::text`,
|
|
1268
|
+
`c.scope = ${quoteLiteral(scope)}`,
|
|
1269
|
+
"(c.scope_id is null or c.scope_id = (ms.id)::text)",
|
|
1270
|
+
"c.role = r.role"
|
|
1271
|
+
].join(" and ");
|
|
1272
|
+
const entries = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
|
|
1273
|
+
if (set) return sourcesBodyOf(`${declared}
|
|
1274
|
+
union
|
|
1275
|
+
select (ms.id)::${type}, ck.grant_key
|
|
1276
|
+
${rows}
|
|
1277
|
+
${customKeysJoin(ctx, scope, entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'allow'"), entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), entries(CUSTOM_ROLES.includes, "include_role", ""))}
|
|
1278
|
+
where ${signedIn(ctx)}
|
|
1279
|
+
and ms.scope = ${quoteLiteral(scope)}
|
|
1280
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("ck.grant_key"), sourcesKeep(sources))}`, sources, subjectIdSql(ctx));
|
|
1281
|
+
return sourcesBodyOf(`${declared}
|
|
1282
|
+
union
|
|
1283
|
+
select (ms.id)::${type}
|
|
1284
|
+
${rows}
|
|
1285
|
+
where ${signedIn(ctx)}
|
|
1286
|
+
and ms.scope = ${quoteLiteral(scope)}
|
|
1287
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("p_grant"), sourcesKeep(sources))}
|
|
1288
|
+
${customKeysSql(ctx, scope, entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'allow'"), entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), entries(CUSTOM_ROLES.includes, "include_role", ""))}`, sources, subjectIdSql(ctx));
|
|
1289
|
+
}
|
|
1290
|
+
function scopedBody(ctx, scope, type) {
|
|
1291
|
+
if (ctx.authorize !== "database") return sqlBody(claimBody(ctx, scope, type));
|
|
1292
|
+
return scopeSources(ctx, scope).length > 0 ? sourcesBody(ctx, scope, type) : sqlBody(tableBody(ctx, scope, type));
|
|
1293
|
+
}
|
|
1294
|
+
//#endregion
|
|
1295
|
+
//#region src/cli/rls/capabilities.ts
|
|
1296
|
+
/** The helper `--capabilities` adds: ids of one resource a link capability claim reaches. Part of the SQL contract. */
|
|
1297
|
+
const CAPABILITIES = { ids: "permdock_capability_ids" };
|
|
1298
|
+
/**
|
|
1299
|
+
* The `anon` branch access for a resource-scoped grant reached through a link
|
|
1300
|
+
* capability: the row's `field` holds an id the capability claim names for
|
|
1301
|
+
* `resource`, `role` and `permission`. Uncorrelated, so it runs once per
|
|
1302
|
+
* statement; the column is compared as text because the claim holds text.
|
|
1303
|
+
*/
|
|
1304
|
+
function capabilityAccessSql(ctx, field, resource, role, permission) {
|
|
1305
|
+
const args = [
|
|
1306
|
+
resource,
|
|
1307
|
+
role,
|
|
1308
|
+
permission
|
|
1309
|
+
].map(quoteLiteral).join(", ");
|
|
1310
|
+
return `${quoteIdent(field)}::text in (select ${qualified(ctx, CAPABILITIES.ids)}(${args}))`;
|
|
1311
|
+
}
|
|
1312
|
+
function capabilitiesSql(ctx) {
|
|
1313
|
+
if (ctx.capabilities !== true) return "";
|
|
1314
|
+
const fn = qualified(ctx, CAPABILITIES.ids);
|
|
1315
|
+
return `-- link capabilities: the capability claim exchangeCapability mints (role anon, short-lived)
|
|
1316
|
+
create or replace function ${fn}(p_resource text, p_role text, p_permission text)
|
|
1317
|
+
returns setof text
|
|
1318
|
+
language sql
|
|
1319
|
+
stable
|
|
1320
|
+
set search_path = ''
|
|
1321
|
+
as $$
|
|
1322
|
+
select c -> 'on' ->> 'id'
|
|
1323
|
+
from (select ${subjectClaimJsonSql(ctx, "capability")} as c) capability
|
|
1324
|
+
where jsonb_typeof(c) = 'object'
|
|
1325
|
+
and c ->> 'v' = '1'
|
|
1326
|
+
and c ->> 'holder' = 'link'
|
|
1327
|
+
and c -> 'on' ->> 'resource' = p_resource
|
|
1328
|
+
and jsonb_typeof(c -> 'roles') = 'array'
|
|
1329
|
+
and c -> 'roles' @> jsonb_build_array(p_role)
|
|
1330
|
+
and (c -> 'permissions' is null or c -> 'permissions' @> jsonb_build_array(p_permission))
|
|
1331
|
+
and jsonb_typeof(c -> 'expires_at') = 'number'
|
|
1332
|
+
and (c ->> 'expires_at')::numeric > extract(epoch from now())
|
|
1333
|
+
$$;
|
|
1334
|
+
revoke execute on function ${fn}(text, text, text) from public;
|
|
1335
|
+
grant execute on function ${fn}(text, text, text) to anon, authenticated;`;
|
|
1336
|
+
}
|
|
1337
|
+
//#endregion
|
|
1338
|
+
//#region src/cli/rls/custom-roles.ts
|
|
1339
|
+
/** The readers every helpers file ends with. Names are part of the SQL contract. */
|
|
1340
|
+
const READERS = {
|
|
1341
|
+
keys: "permdock_permission_keys",
|
|
1342
|
+
roles: "permdock_role_permissions",
|
|
1343
|
+
trusted: "permdock_trusted_role_permissions"
|
|
1344
|
+
};
|
|
1345
|
+
/**
|
|
1346
|
+
* The custom-role objects: the tables (`database` mode), the ceiling view
|
|
1347
|
+
* over the `assignable` declared roles, and `permdock_custom_keys`, which
|
|
1348
|
+
* resolves one custom role to the grant keys it holds in a scope. A row in
|
|
1349
|
+
* the tables can never reach a key outside the view.
|
|
1350
|
+
*/
|
|
1351
|
+
function customRolesSql(ctx) {
|
|
1352
|
+
const custom = ctx.customRoles;
|
|
1353
|
+
if (custom === void 0) return "";
|
|
1354
|
+
const rp = qualified(ctx, "role_permissions");
|
|
1355
|
+
const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
|
|
1356
|
+
const keys = qualified(ctx, CUSTOM_ROLES.keys);
|
|
1357
|
+
const chunks = [];
|
|
1358
|
+
if (ctx.authorize === "database") {
|
|
1359
|
+
const tenantType = tenantTypeOf(ctx);
|
|
1360
|
+
for (const [name, column, check] of [[
|
|
1361
|
+
CUSTOM_ROLES.permissions,
|
|
1362
|
+
"permission",
|
|
1363
|
+
`,
|
|
1364
|
+
effect text not null default 'allow' check (effect in ('allow', 'deny'))`
|
|
1365
|
+
], [
|
|
1366
|
+
CUSTOM_ROLES.includes,
|
|
1367
|
+
"include_role",
|
|
1368
|
+
""
|
|
1369
|
+
]]) {
|
|
1370
|
+
const table = qualified(ctx, name);
|
|
1371
|
+
const leveled = custom.levels === true && name === CUSTOM_ROLES.permissions;
|
|
1372
|
+
const unique = `${column}${name === CUSTOM_ROLES.permissions ? ", effect" : ""}${leveled ? ", coalesce(level, '')" : ""}`;
|
|
1373
|
+
const index = leveled ? `drop index if exists ${qualified(ctx, quoteIdent(`${name}_key`))};
|
|
1374
|
+
alter table ${table} add column if not exists level text check (level ~ '^[a-z][a-z0-9_]*$');
|
|
1375
|
+
create unique index if not exists ${quoteIdent(`${name}_level_key`)}` : `create unique index if not exists ${quoteIdent(`${name}_key`)}`;
|
|
1376
|
+
chunks.push(`create table if not exists ${table} (
|
|
1377
|
+
tenant_id ${tenantType},
|
|
1378
|
+
scope text not null default ${quoteLiteral(rootName(ctx))},
|
|
1379
|
+
scope_id text,
|
|
1380
|
+
role text not null,
|
|
1381
|
+
${column} text not null${check},
|
|
1382
|
+
check ((scope = 'global') = (tenant_id is null)),
|
|
1383
|
+
check (scope <> 'global' or scope_id is null)
|
|
1384
|
+
);
|
|
1385
|
+
${index}
|
|
1386
|
+
on ${table} (coalesce(tenant_id::text, ''), scope, coalesce(scope_id, ''), role, ${unique});
|
|
1387
|
+
-- a row identity for triggers the application adds, such as an audit trigger that reads the primary key
|
|
1388
|
+
alter table ${table} add column if not exists id bigint generated always as identity primary key;
|
|
1389
|
+
alter table ${table} enable row level security;
|
|
1390
|
+
revoke all on table ${table} from anon, authenticated, public;`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
chunks.push(`-- the ceiling: keys of assignable declared roles, and the denies of those roles on the same permissions
|
|
1394
|
+
create or replace view ${ceiling} with (security_invoker = true) as
|
|
1395
|
+
select rp.scope, rp.role, rp.permission, rp.grant_key, rp.effect
|
|
1396
|
+
from ${rp} rp
|
|
1397
|
+
where rp.role = any(${sqlTextArray(custom.assignable)})
|
|
1398
|
+
and exists (
|
|
1399
|
+
select 1 from ${rp} a
|
|
1400
|
+
where a.role = rp.role
|
|
1401
|
+
and a.permission = rp.permission
|
|
1402
|
+
and a.scope = rp.scope
|
|
1403
|
+
and a.effect = 'allow'
|
|
1404
|
+
);
|
|
1405
|
+
revoke all on table ${ceiling} from anon, authenticated, public;`);
|
|
1406
|
+
const renames = Object.entries(custom.renamed ?? {}).toSorted(byKey);
|
|
1407
|
+
if (custom.levels === true) {
|
|
1408
|
+
chunks.push(leveledKeysSql(ctx, renames));
|
|
1409
|
+
return chunks.join("\n\n");
|
|
1410
|
+
}
|
|
1411
|
+
const rename = renamesSql(renames);
|
|
1412
|
+
chunks.push(`-- one custom role: included ceiling keys and same-scope denies, plus every ceiling key of an allowed permission, minus denied permissions
|
|
1413
|
+
create or replace function ${keys}(p_allow text[], p_deny text[], p_include text[], p_scope text)
|
|
1414
|
+
returns setof text
|
|
1415
|
+
language sql
|
|
1416
|
+
stable
|
|
1417
|
+
set search_path = ''
|
|
1418
|
+
as $$
|
|
1419
|
+
with ${rename.prelude}ceiling as (
|
|
1420
|
+
select c.role, ${rename.permission("c")}, c.grant_key, c.effect
|
|
1421
|
+
from ${ceiling} c${rename.join("c")}
|
|
1422
|
+
where c.scope = p_scope
|
|
1423
|
+
),
|
|
1424
|
+
included as (
|
|
1425
|
+
select rp.role, ${rename.permission("rp")}, rp.grant_key, rp.scope, rp.effect
|
|
1426
|
+
from ${rp} rp${rename.join("rp")}
|
|
1427
|
+
where rp.role = any(coalesce(p_include, '{}'::text[]))
|
|
1428
|
+
),
|
|
1429
|
+
kept as (
|
|
1430
|
+
select i.permission, i.grant_key
|
|
1431
|
+
from included i
|
|
1432
|
+
where i.effect = 'allow'
|
|
1433
|
+
and i.scope = p_scope
|
|
1434
|
+
and exists (
|
|
1435
|
+
select 1 from ceiling c
|
|
1436
|
+
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
1437
|
+
)
|
|
1438
|
+
),
|
|
1439
|
+
wanted as (
|
|
1440
|
+
${rename.allow}
|
|
1441
|
+
union
|
|
1442
|
+
select i.permission
|
|
1443
|
+
from included i
|
|
1444
|
+
where i.effect = 'allow'
|
|
1445
|
+
and not (i.scope = p_scope and exists (
|
|
1446
|
+
select 1 from ceiling c
|
|
1447
|
+
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
1448
|
+
))
|
|
1449
|
+
),
|
|
1450
|
+
allowed as (
|
|
1451
|
+
select w.permission
|
|
1452
|
+
from wanted w
|
|
1453
|
+
where not (${rename.denied("w")})
|
|
1454
|
+
and not exists (select 1 from kept k where k.permission = w.permission)
|
|
1455
|
+
)
|
|
1456
|
+
select k.grant_key from kept k
|
|
1457
|
+
where not (${rename.denied("k")})
|
|
1606
1458
|
union
|
|
1607
|
-
select
|
|
1608
|
-
|
|
1609
|
-
cross join lateral (select m -> 'grants' -> r.role as g) cg
|
|
1610
|
-
${customKeysJoin(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
|
|
1611
|
-
where ${signedIn(ctx)}
|
|
1612
|
-
and jsonb_typeof(cg.g) = 'array'
|
|
1613
|
-
and not (r.role = any(${sqlTextArray(custom.declared)}))
|
|
1614
|
-
${filtersFor("ck.grant_key")}`;
|
|
1615
|
-
return `${declared}
|
|
1459
|
+
select i.grant_key from included i
|
|
1460
|
+
where i.effect = 'deny' and i.scope = p_scope
|
|
1616
1461
|
union
|
|
1617
|
-
select
|
|
1618
|
-
from
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
and not (r.role = any(${sqlTextArray(custom.declared)}))
|
|
1623
|
-
${filters}
|
|
1624
|
-
${customKeysSql(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}`;
|
|
1462
|
+
select c.grant_key from ceiling c
|
|
1463
|
+
where c.permission in (select a.permission from allowed a)
|
|
1464
|
+
$$;
|
|
1465
|
+
revoke execute on function ${keys}(text[], text[], text[], text) from public, anon, authenticated;`);
|
|
1466
|
+
return chunks.join("\n\n");
|
|
1625
1467
|
}
|
|
1626
1468
|
/**
|
|
1627
|
-
*
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1469
|
+
* `permdock_custom_keys` when resources declare levels. An allow entry is
|
|
1470
|
+
* `key` or `key@level`. A level reaches only the ceiling's `<grant key>@<level>`
|
|
1471
|
+
* keys, plus the ceiling denies of that permission; a level no ceiling key
|
|
1472
|
+
* carries removes the permission, as `resolveCustomRole` drops it with
|
|
1473
|
+
* `unknown-level`.
|
|
1630
1474
|
*/
|
|
1631
|
-
function
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
const
|
|
1636
|
-
|
|
1475
|
+
function leveledKeysSql(ctx, renames) {
|
|
1476
|
+
const rp = qualified(ctx, "role_permissions");
|
|
1477
|
+
const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
|
|
1478
|
+
const keys = qualified(ctx, CUSTOM_ROLES.keys);
|
|
1479
|
+
const renamed = renames.length > 0;
|
|
1480
|
+
const prelude = renamed ? `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
|
|
1481
|
+
` : "";
|
|
1482
|
+
const current = (raw, alias) => renamed ? `coalesce(r_${alias}.key, ${raw})` : raw;
|
|
1483
|
+
const join = (raw, alias) => renamed ? ` left join renamed r_${alias} on r_${alias}.former = ${raw}` : "";
|
|
1484
|
+
return `-- one custom role: as above, where an allow entry key@level reaches only the ceiling keys of that level
|
|
1485
|
+
create or replace function ${keys}(p_allow text[], p_deny text[], p_include text[], p_scope text)
|
|
1486
|
+
returns setof text
|
|
1487
|
+
language sql
|
|
1488
|
+
stable
|
|
1489
|
+
set search_path = ''
|
|
1490
|
+
as $$
|
|
1491
|
+
with ${prelude}entries as (
|
|
1492
|
+
select ${current("split_part(e.entry, '@', 1)", "e")} as permission, nullif(split_part(e.entry, '@', 2), '') as level
|
|
1493
|
+
from unnest(coalesce(p_allow, '{}'::text[])) as e(entry)${join("split_part(e.entry, '@', 1)", "e")}
|
|
1494
|
+
),
|
|
1495
|
+
ceiling as (
|
|
1496
|
+
select c.role, ${current("c.permission", "c")} as permission, c.grant_key, c.effect
|
|
1497
|
+
from ${ceiling} c${join("c.permission", "c")}
|
|
1498
|
+
where c.scope = p_scope
|
|
1499
|
+
),
|
|
1500
|
+
levels as (
|
|
1501
|
+
select distinct c.permission, split_part(c.grant_key, '@', 2) as level
|
|
1502
|
+
from ceiling c
|
|
1503
|
+
where c.effect = 'allow' and strpos(c.grant_key, '@') > 0
|
|
1504
|
+
),
|
|
1505
|
+
denied as (
|
|
1506
|
+
select ${current("d.permission", "d")} as permission
|
|
1507
|
+
from unnest(coalesce(p_deny, '{}'::text[])) as d(permission)${join("d.permission", "d")}
|
|
1508
|
+
union
|
|
1509
|
+
select e.permission from entries e
|
|
1510
|
+
where e.level is not null
|
|
1511
|
+
and not exists (select 1 from levels l where l.permission = e.permission and l.level = e.level)
|
|
1512
|
+
),
|
|
1513
|
+
included as (
|
|
1514
|
+
select rp.role, ${current("rp.permission", "rp")} as permission, rp.grant_key, rp.scope, rp.effect
|
|
1515
|
+
from ${rp} rp${join("rp.permission", "rp")}
|
|
1516
|
+
where rp.role = any(coalesce(p_include, '{}'::text[]))
|
|
1517
|
+
),
|
|
1518
|
+
kept as (
|
|
1519
|
+
select i.permission, i.grant_key
|
|
1520
|
+
from included i
|
|
1521
|
+
where i.effect = 'allow'
|
|
1522
|
+
and i.scope = p_scope
|
|
1523
|
+
and exists (
|
|
1524
|
+
select 1 from ceiling c
|
|
1525
|
+
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
1526
|
+
)
|
|
1527
|
+
),
|
|
1528
|
+
wanted as (
|
|
1529
|
+
select e.permission from entries e where e.level is null
|
|
1530
|
+
union
|
|
1531
|
+
select i.permission
|
|
1532
|
+
from included i
|
|
1533
|
+
where i.effect = 'allow'
|
|
1534
|
+
and not (i.scope = p_scope and exists (
|
|
1535
|
+
select 1 from ceiling c
|
|
1536
|
+
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
1537
|
+
))
|
|
1538
|
+
),
|
|
1539
|
+
allowed as (
|
|
1540
|
+
select w.permission
|
|
1541
|
+
from wanted w
|
|
1542
|
+
where w.permission not in (select d.permission from denied d)
|
|
1543
|
+
and not exists (select 1 from kept k where k.permission = w.permission)
|
|
1544
|
+
),
|
|
1545
|
+
allowed_levels as (
|
|
1546
|
+
select e.permission, e.level
|
|
1547
|
+
from entries e
|
|
1548
|
+
where e.level is not null
|
|
1549
|
+
and e.permission not in (select d.permission from denied d)
|
|
1550
|
+
and e.permission not in (select a.permission from allowed a)
|
|
1551
|
+
and not exists (select 1 from kept k where k.permission = e.permission)
|
|
1552
|
+
)
|
|
1553
|
+
select k.grant_key from kept k
|
|
1554
|
+
where k.permission not in (select d.permission from denied d)
|
|
1555
|
+
union
|
|
1556
|
+
select i.grant_key from included i
|
|
1557
|
+
where i.effect = 'deny' and i.scope = p_scope
|
|
1558
|
+
union
|
|
1559
|
+
select c.grant_key from ceiling c
|
|
1560
|
+
where c.permission in (select a.permission from allowed a)
|
|
1561
|
+
union
|
|
1562
|
+
select c.grant_key from ceiling c
|
|
1563
|
+
join allowed_levels a on a.permission = c.permission
|
|
1564
|
+
where c.effect = 'deny' or split_part(c.grant_key, '@', 2) = a.level
|
|
1565
|
+
$$;
|
|
1566
|
+
revoke execute on function ${keys}(text[], text[], text[], text) from public, anon, authenticated;`;
|
|
1637
1567
|
}
|
|
1638
|
-
function
|
|
1639
|
-
return `
|
|
1568
|
+
function currentKeysSql(array) {
|
|
1569
|
+
return `select coalesce(r.key, e.permission) as permission
|
|
1570
|
+
from unnest(coalesce(${array}, '{}'::text[])) as e(permission)
|
|
1571
|
+
left join renamed r on r.former = e.permission`;
|
|
1640
1572
|
}
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1573
|
+
/**
|
|
1574
|
+
* SQL fragments that read a stored former key as its current key inside
|
|
1575
|
+
* `permdock_custom_keys`. Without renames they are the plain column and
|
|
1576
|
+
* array reads.
|
|
1577
|
+
*/
|
|
1578
|
+
function renamesSql(renames) {
|
|
1579
|
+
if (renames.length === 0) return {
|
|
1580
|
+
prelude: "",
|
|
1581
|
+
permission: (alias) => `${alias}.permission`,
|
|
1582
|
+
join: () => "",
|
|
1583
|
+
allow: "select unnest(coalesce(p_allow, '{}'::text[])) as permission",
|
|
1584
|
+
denied: (alias) => `${alias}.permission = any(coalesce(p_deny, '{}'::text[]))`
|
|
1645
1585
|
};
|
|
1646
|
-
}
|
|
1647
|
-
function sourcesBodyOf(sql, sources, user) {
|
|
1648
1586
|
return {
|
|
1649
|
-
|
|
1650
|
-
|
|
1587
|
+
prelude: `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
|
|
1588
|
+
denied as (
|
|
1589
|
+
${currentKeysSql("p_deny")}
|
|
1590
|
+
),
|
|
1591
|
+
`,
|
|
1592
|
+
permission: (alias) => `coalesce(r_${alias}.key, ${alias}.permission) as permission`,
|
|
1593
|
+
join: (alias) => ` left join renamed r_${alias} on r_${alias}.former = ${alias}.permission`,
|
|
1594
|
+
allow: currentKeysSql("p_allow"),
|
|
1595
|
+
denied: (alias) => `${alias}.permission in (select d.permission from denied d)`
|
|
1651
1596
|
};
|
|
1652
1597
|
}
|
|
1653
|
-
/**
|
|
1654
|
-
|
|
1655
|
-
|
|
1598
|
+
/**
|
|
1599
|
+
* The catalog reader `permdock_permission_keys()` and the role reader
|
|
1600
|
+
* `permdock_role_permissions(p_role, p_scope, p_tenant, p_scope_id)`, both
|
|
1601
|
+
* for `authenticated`. A declared role answers from `role_permissions`, which
|
|
1602
|
+
* is the catalog. A custom role (`database` mode) answers from the tenant's
|
|
1603
|
+
* own rows through `permdock_custom_keys`, only to a member of the tenant or
|
|
1604
|
+
* a holder of a `meta.manageRoles` permission for a platform role. The
|
|
1605
|
+
* effect is `deny` for a deny the role carries after the ceiling, as
|
|
1606
|
+
* `resolveCustomRole` returns it, never for a deny that merely removes an allow.
|
|
1607
|
+
*/
|
|
1608
|
+
function roleReadersSql(ctx, permissions, trustedReaders) {
|
|
1609
|
+
const rp = qualified(ctx, "role_permissions");
|
|
1610
|
+
const reader = qualified(ctx, READERS.roles);
|
|
1611
|
+
const tenantType = tenantTypeOf(ctx);
|
|
1612
|
+
const custom = ctx.customRoles;
|
|
1613
|
+
const root = ctx.scopes[0]?.name;
|
|
1614
|
+
const database = ctx.authorize === "database";
|
|
1615
|
+
const chunks = [];
|
|
1616
|
+
if (permissions !== void 0) {
|
|
1617
|
+
const catalog = qualified(ctx, READERS.keys);
|
|
1618
|
+
chunks.push(`-- every declared permission key
|
|
1619
|
+
create or replace function ${catalog}()
|
|
1620
|
+
returns setof text
|
|
1621
|
+
language sql
|
|
1656
1622
|
stable
|
|
1657
|
-
security definer
|
|
1658
1623
|
set search_path = ''
|
|
1659
1624
|
as $$
|
|
1660
|
-
${
|
|
1661
|
-
|
|
1662
|
-
|
|
1625
|
+
select k.permission from unnest(${sqlTextArray(permissions.toSorted())}) k(permission) order by 1
|
|
1626
|
+
$$;
|
|
1627
|
+
revoke execute on function ${catalog}() from public, anon;
|
|
1628
|
+
grant execute on function ${catalog}() to authenticated;
|
|
1629
|
+
|
|
1630
|
+
create or replace function ${catalog}(p_scope text)
|
|
1631
|
+
returns setof text
|
|
1632
|
+
language sql
|
|
1663
1633
|
stable
|
|
1664
1634
|
security definer
|
|
1665
1635
|
set search_path = ''
|
|
1666
1636
|
as $$
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1637
|
+
select distinct rp.permission
|
|
1638
|
+
from ${rp} rp
|
|
1639
|
+
where rp.scope = p_scope
|
|
1640
|
+
and rp.effect = 'allow'
|
|
1641
|
+
and rp.permission = any(${sqlTextArray(permissions.toSorted())})
|
|
1642
|
+
order by 1
|
|
1643
|
+
$$;
|
|
1644
|
+
revoke execute on function ${catalog}(text) from public, anon;
|
|
1645
|
+
grant execute on function ${catalog}(text) to authenticated;`);
|
|
1646
|
+
}
|
|
1647
|
+
const declared = ` select distinct rp.permission, rp.effect
|
|
1648
|
+
from ${rp} rp
|
|
1649
|
+
where rp.role = p_role and rp.scope = p_scope
|
|
1650
|
+
order by 1, 2`;
|
|
1651
|
+
let body = `begin
|
|
1652
|
+
perform p_tenant, p_scope_id; -- only a custom role reads them
|
|
1670
1653
|
return query
|
|
1671
|
-
${
|
|
1672
|
-
end
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
)
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
${
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
${
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
and ms.scope = ${quoteLiteral(scope)}
|
|
1723
|
-
and not (r.role = any(${sqlTextArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("ck.grant_key"), sourcesKeep(sources))}`, sources, subjectIdSql(ctx));
|
|
1724
|
-
return sourcesBodyOf(`${declared}
|
|
1725
|
-
union
|
|
1726
|
-
select (ms.id)::${type}
|
|
1727
|
-
${rows}
|
|
1728
|
-
where ${signedIn(ctx)}
|
|
1729
|
-
and ms.scope = ${quoteLiteral(scope)}
|
|
1730
|
-
and not (r.role = any(${sqlTextArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("p_grant"), sourcesKeep(sources))}
|
|
1731
|
-
${customKeysSql(ctx, scope, entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'allow'"), entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), entries(CUSTOM_ROLES.includes, "include_role", ""))}`, sources, subjectIdSql(ctx));
|
|
1732
|
-
}
|
|
1733
|
-
function scopedBody(ctx, scope, type) {
|
|
1734
|
-
if (ctx.authorize !== "database") return sqlBody(claimBody(ctx, scope, type));
|
|
1735
|
-
return scopeSources(ctx, scope).length > 0 ? sourcesBody(ctx, scope, type) : sqlBody(tableBody(ctx, scope, type));
|
|
1736
|
-
}
|
|
1737
|
-
//#endregion
|
|
1738
|
-
//#region src/cli/rls/capabilities.ts
|
|
1739
|
-
/** The helper `--capabilities` adds: ids of one resource a link capability claim reaches. Part of the SQL contract. */
|
|
1740
|
-
const CAPABILITIES = { ids: "permdock_capability_ids" };
|
|
1741
|
-
/**
|
|
1742
|
-
* The `anon` branch access for a resource-scoped grant reached through a link
|
|
1743
|
-
* capability: the row's `field` holds an id the capability claim names for
|
|
1744
|
-
* `resource`, `role` and `permission`. Uncorrelated, so it runs once per
|
|
1745
|
-
* statement; the column is compared as text because the claim holds text.
|
|
1746
|
-
*/
|
|
1747
|
-
function capabilityAccessSql(ctx, field, resource, role, permission) {
|
|
1748
|
-
const args = [
|
|
1749
|
-
resource,
|
|
1750
|
-
role,
|
|
1751
|
-
permission
|
|
1752
|
-
].map(quoteLiteral).join(", ");
|
|
1753
|
-
return `${quoteIdent(field)}::text in (select ${qualified(ctx, CAPABILITIES.ids)}(${args}))`;
|
|
1754
|
-
}
|
|
1755
|
-
function capabilitiesSql(ctx) {
|
|
1756
|
-
if (ctx.capabilities !== true) return "";
|
|
1757
|
-
const fn = qualified(ctx, CAPABILITIES.ids);
|
|
1758
|
-
return `-- link capabilities: the capability claim exchangeCapability mints (role anon, short-lived)
|
|
1759
|
-
create or replace function ${fn}(p_resource text, p_role text, p_permission text)
|
|
1760
|
-
returns setof text
|
|
1761
|
-
language sql
|
|
1654
|
+
${declared};
|
|
1655
|
+
end;`;
|
|
1656
|
+
let trustedBody = body;
|
|
1657
|
+
if (custom !== void 0 && database && root !== void 0) {
|
|
1658
|
+
const tenant = qualified(ctx, CUSTOM_ROLES.permissions);
|
|
1659
|
+
const includes = qualified(ctx, CUSTOM_ROLES.includes);
|
|
1660
|
+
const keys = qualified(ctx, CUSTOM_ROLES.keys);
|
|
1661
|
+
const shape = qualified(ctx, CUSTOM_ROLES.shape);
|
|
1662
|
+
const ids = (helper) => `(select x::text from ${qualified(ctx, helper)}() x)`;
|
|
1663
|
+
const memberOf = `p_tenant::text in ${ids(memberIdsHelper(root))}
|
|
1664
|
+
or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${ids(memberIdsHelper(scope))}`)}`;
|
|
1665
|
+
const manage = custom.manage ?? [];
|
|
1666
|
+
const platform = manage.length === 0 ? "false" : `exists (
|
|
1667
|
+
select 1 from ${rp} m
|
|
1668
|
+
where m.effect = 'allow'
|
|
1669
|
+
and m.permission = any(${sqlTextArray(manage)})
|
|
1670
|
+
and ${qualified(ctx, HELPERS.has)}(m.grant_key)
|
|
1671
|
+
)`;
|
|
1672
|
+
const match = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.scope_id is not distinct from p_scope_id and c.role = p_role`;
|
|
1673
|
+
const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
|
|
1674
|
+
const checked = (gate) => `begin
|
|
1675
|
+
if p_role = any(${sqlTextArray(custom.declared)}) then
|
|
1676
|
+
return query
|
|
1677
|
+
${declared};
|
|
1678
|
+
return;
|
|
1679
|
+
end if;
|
|
1680
|
+
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);${gate}
|
|
1681
|
+
return query
|
|
1682
|
+
select distinct rp.permission, rp.effect
|
|
1683
|
+
from ${keys}(
|
|
1684
|
+
array(select ${allowEntryOf} from ${tenant} c where ${match} and c.effect = 'allow'),
|
|
1685
|
+
array(select c.permission from ${tenant} c where ${match} and c.effect = 'deny'),
|
|
1686
|
+
array(select c.include_role from ${includes} c where ${match}),
|
|
1687
|
+
p_scope
|
|
1688
|
+
) k(grant_key)
|
|
1689
|
+
join ${rp} rp on rp.grant_key = k.grant_key and rp.scope = p_scope
|
|
1690
|
+
order by 1, 2;
|
|
1691
|
+
end;`;
|
|
1692
|
+
body = checked(`
|
|
1693
|
+
if not (${signedIn(ctx)} and (
|
|
1694
|
+
${platform}
|
|
1695
|
+
or (p_scope <> 'global' and (${memberOf}))
|
|
1696
|
+
)) then
|
|
1697
|
+
${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
|
|
1698
|
+
end if;`);
|
|
1699
|
+
trustedBody = checked("");
|
|
1700
|
+
}
|
|
1701
|
+
chunks.push(`-- the permission keys a role holds on a scope, with effect allow or deny; a custom role is read for its tenant and, below the first scope, its instance
|
|
1702
|
+
create or replace function ${reader}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
|
|
1703
|
+
returns table (permission text, effect text)
|
|
1704
|
+
language plpgsql
|
|
1762
1705
|
stable
|
|
1706
|
+
security definer
|
|
1763
1707
|
set search_path = ''
|
|
1764
1708
|
as $$
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1709
|
+
${body}
|
|
1710
|
+
$$;
|
|
1711
|
+
revoke execute on function ${reader}(text, text, ${tenantType}, text) from public, anon;
|
|
1712
|
+
grant execute on function ${reader}(text, text, ${tenantType}, text) to authenticated;`);
|
|
1713
|
+
const trusted = qualified(ctx, READERS.trusted);
|
|
1714
|
+
const signature = `text, text, ${tenantType}, text`;
|
|
1715
|
+
const grants = trustedReaders === void 0 || trustedReaders.length === 0 ? "" : `\ngrant execute on function ${trusted}(${signature}) to ${trustedReaders.map(quoteIdent).join(", ")};`;
|
|
1716
|
+
chunks.push(`create or replace function ${trusted}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
|
|
1717
|
+
returns table (permission text, effect text)
|
|
1718
|
+
language plpgsql
|
|
1719
|
+
stable
|
|
1720
|
+
security definer
|
|
1721
|
+
set search_path = ''
|
|
1722
|
+
as $$
|
|
1723
|
+
${trustedBody}
|
|
1776
1724
|
$$;
|
|
1777
|
-
revoke execute on function ${
|
|
1778
|
-
|
|
1725
|
+
revoke execute on function ${trusted}(${signature}) from public, anon, authenticated;${grants}`);
|
|
1726
|
+
return chunks.join("\n\n");
|
|
1727
|
+
}
|
|
1728
|
+
/** `expr` read as its current key: a former key maps to the key it was renamed to. */
|
|
1729
|
+
function currentKeyExpr(renames, expr) {
|
|
1730
|
+
if (renames.length === 0) return expr;
|
|
1731
|
+
return `(case ${expr} ${renames.map(([former, current]) => `when ${quoteLiteral(former)} then ${quoteLiteral(current)}`).join(" ")} else ${expr} end)`;
|
|
1732
|
+
}
|
|
1733
|
+
/** `case p_scope when '<scope>' then <sql for scope> ... else false end` over the declared scopes. */
|
|
1734
|
+
function perScope(ctx, sql) {
|
|
1735
|
+
const arms = ctx.scopes.map((scope) => `when ${quoteLiteral(scope.name)} then ${sql(scope.name)}`).join(" ");
|
|
1736
|
+
return arms === "" ? "false" : `(case p_scope ${arms} else false end)`;
|
|
1737
|
+
}
|
|
1738
|
+
function raiseSql(indent, errcode, message, hint) {
|
|
1739
|
+
return `${indent}raise exception using
|
|
1740
|
+
${indent} errcode = '${errcode}',
|
|
1741
|
+
${indent} message = ${message},
|
|
1742
|
+
${indent} hint = ${quoteLiteral(hint)};`;
|
|
1743
|
+
}
|
|
1744
|
+
function hasCustomRoleChecksFor(ctx) {
|
|
1745
|
+
return ctx.authorize === "database" && ctx.customRoles !== void 0 && ctx.scopes.length > 0 && ctx.scopes.every((scope) => hasMemberFor(ctx, scope.name));
|
|
1779
1746
|
}
|
|
1780
1747
|
//#endregion
|
|
1781
1748
|
//#region src/cli/rls/custom-role-cascade.ts
|
|
@@ -2041,7 +2008,7 @@ function customRoleWritesSql(ctx, levelReach) {
|
|
|
2041
2008
|
const guard = qualified(ctx, CUSTOM_ROLES.guard);
|
|
2042
2009
|
const shape = qualified(ctx, CUSTOM_ROLES.shape);
|
|
2043
2010
|
const entries = qualified(ctx, CUSTOM_ROLES.entries);
|
|
2044
|
-
const renames = Object.entries(custom.renamed ?? {}).toSorted(
|
|
2011
|
+
const renames = Object.entries(custom.renamed ?? {}).toSorted(byKey);
|
|
2045
2012
|
const current = (expr) => currentKeyExpr(renames, expr);
|
|
2046
2013
|
const known = sqlTextArray([...custom.permissions ?? [], ...renames.map(([former]) => former)]);
|
|
2047
2014
|
const caller = callerFunctionsSql(ctx, custom, root, levelReach, false);
|
|
@@ -2298,7 +2265,7 @@ grant execute on function ${fn}(${args}) to authenticated;`;
|
|
|
2298
2265
|
* a live membership of, with any role. Not narrowed to the active tenant, and
|
|
2299
2266
|
* no cascade: a membership of a child scope is not one of its parent.
|
|
2300
2267
|
*/
|
|
2301
|
-
function memberBody(ctx, scope, type) {
|
|
2268
|
+
function memberBody(ctx, scope, type, roles) {
|
|
2302
2269
|
if (ctx.authorize !== "database") return sqlBody(` select distinct (m ->> 'id')::${type}
|
|
2303
2270
|
from jsonb_array_elements(
|
|
2304
2271
|
case jsonb_typeof(${membershipClaim(ctx)}) when 'array' then ${membershipClaim(ctx)} else '[]'::jsonb end
|
|
@@ -2307,22 +2274,22 @@ function memberBody(ctx, scope, type) {
|
|
|
2307
2274
|
and m ->> 'scope' = ${quoteLiteral(scope)}
|
|
2308
2275
|
and m ->> 'id' is not null${keyTenantLine(ctx, scope, claimTenant(ctx, scope))}
|
|
2309
2276
|
and jsonb_typeof(m -> 'roles') = 'array'
|
|
2310
|
-
and jsonb_array_length(m -> 'roles') > 0
|
|
2311
|
-
and case jsonb_typeof(m -> '
|
|
2312
|
-
when 'number' then (m ->> '
|
|
2277
|
+
and jsonb_array_length(m -> 'roles') > 0${roles === void 0 ? "" : `\n and (m -> 'roles') ?| ${roles}`}
|
|
2278
|
+
and case jsonb_typeof(m -> 'expires_at')
|
|
2279
|
+
when 'number' then (m ->> 'expires_at')::numeric > extract(epoch from now())
|
|
2313
2280
|
else true
|
|
2314
2281
|
end${[
|
|
2315
2282
|
...claimKeptLines(ctx, " "),
|
|
2316
2283
|
...userActive(ctx, " "),
|
|
2317
2284
|
...instancesActive(ctx, scope, (name) => name === scope ? `m ->> 'id'` : `m -> 'within' ->> ${quoteLiteral(name)}`, " ")
|
|
2318
2285
|
].map((line) => `\n${line}`).join("")}`);
|
|
2319
|
-
return memberRowsBody(ctx, scope, type, subjectIdSql(ctx), scopeSources(ctx, scope), true);
|
|
2286
|
+
return memberRowsBody(ctx, scope, type, subjectIdSql(ctx), scopeSources(ctx, scope), true, roles);
|
|
2320
2287
|
}
|
|
2321
2288
|
/**
|
|
2322
2289
|
* The membership rows of `user` for `scope`, from the sources or else the
|
|
2323
2290
|
* mapped table, with the same expiry and suspension filters either way.
|
|
2324
2291
|
*/
|
|
2325
|
-
function memberRowsBody(ctx, scope, type, user, sources, keyed = false) {
|
|
2292
|
+
function memberRowsBody(ctx, scope, type, user, sources, keyed = false, roles) {
|
|
2326
2293
|
if (sources.length > 0) {
|
|
2327
2294
|
const narrow = keyed ? keyTenantLine(ctx, scope, sourceIdOf(scope)(rootName(ctx))) : "";
|
|
2328
2295
|
return sourcesBodyOf(` select distinct (ms.id)::${type}
|
|
@@ -2332,7 +2299,7 @@ ${sourceRows(sources)}
|
|
|
2332
2299
|
where ${signedIn(ctx, user)}
|
|
2333
2300
|
and ms.scope = ${quoteLiteral(scope)}
|
|
2334
2301
|
and jsonb_typeof(ms.roles) = 'array'
|
|
2335
|
-
and jsonb_array_length(ms.roles) > 0${narrow}${sourceFilters(ctx, scope, user, void 0, sourcesKeep(sources))}`, sources, user);
|
|
2302
|
+
and jsonb_array_length(ms.roles) > 0${roles === void 0 ? "" : `\n and ms.roles ?| ${roles}`}${narrow}${sourceFilters(ctx, scope, user, void 0, sourcesKeep(sources))}`, sources, user);
|
|
2336
2303
|
}
|
|
2337
2304
|
const mapped = scopeTable(ctx, scope);
|
|
2338
2305
|
if (mapped === void 0) return sqlBody(` select null::${type} where false -- no ${scope} memberships table configured`);
|
|
@@ -2344,6 +2311,7 @@ ${sourceRows(sources)}
|
|
|
2344
2311
|
` where ${memberColumn(table.user)} = ${user}`,
|
|
2345
2312
|
` and ${role.through === void 0 && !role.lateral ? memberColumn(role.column) : role.sql} is not null`
|
|
2346
2313
|
];
|
|
2314
|
+
if (roles !== void 0) lines.push(` and (${role.through === void 0 && !role.lateral ? memberColumn(role.column) : role.sql})::text = any(${roles})`);
|
|
2347
2315
|
const narrow = keyed ? keyTenantSql(ctx, tenantColumn === void 0 ? void 0 : memberColumn(tenantColumn)) : void 0;
|
|
2348
2316
|
if (narrow !== void 0) lines.push(` and ${narrow}`);
|
|
2349
2317
|
if (table.expiresAt !== void 0) {
|
|
@@ -2377,6 +2345,22 @@ ${functionBody(keyed)}
|
|
|
2377
2345
|
${grants}`;
|
|
2378
2346
|
}
|
|
2379
2347
|
/**
|
|
2348
|
+
* `member_<scope>_ids(p_roles text[])`: `member_<scope>_ids()` narrowed to
|
|
2349
|
+
* memberships holding one of `p_roles` by name, for `rls migrate`'s `role`
|
|
2350
|
+
* helpers. Generated only for a scope such a helper names.
|
|
2351
|
+
*/
|
|
2352
|
+
function memberRolesFunction(ctx, scope, type, anonExecute) {
|
|
2353
|
+
const fn = qualified(ctx, memberIdsHelper(scope));
|
|
2354
|
+
const body = memberBody(ctx, scope, type, "p_roles");
|
|
2355
|
+
const grants = anonExecute ? `revoke execute on function ${fn}(text[]) from public;
|
|
2356
|
+
grant execute on function ${fn}(text[]) to anon, authenticated;` : `revoke execute on function ${fn}(text[]) from public, anon;
|
|
2357
|
+
grant execute on function ${fn}(text[]) to authenticated;`;
|
|
2358
|
+
return `create or replace function ${fn}(p_roles text[])
|
|
2359
|
+
returns setof ${type}
|
|
2360
|
+
${functionBody(body)}
|
|
2361
|
+
${grants}`;
|
|
2362
|
+
}
|
|
2363
|
+
/**
|
|
2380
2364
|
* `member_<scope>_ids_for(p_user uuid)`: `member_<scope>_ids()` for a user the
|
|
2381
2365
|
* caller names, read from the membership sources (or the mapped table) in
|
|
2382
2366
|
* either mode, so a token hook can call it before any claim exists. Naming
|
|
@@ -2400,7 +2384,7 @@ revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
|
2400
2384
|
function accessSql(ctx, scope, grantKey, column) {
|
|
2401
2385
|
const key = quoteLiteral(grantKey);
|
|
2402
2386
|
if (scope === "global") return `(select ${qualified(ctx, HELPERS.has)}(${key}))`;
|
|
2403
|
-
if (column === void 0) throw
|
|
2387
|
+
if (column === void 0) throw configError(`PermDock CLI: ${scope}-scoped grant needs definePolicy({ scopes.${scope} })`);
|
|
2404
2388
|
return `${quoteIdent(column)} in (select ${qualified(ctx, permittedIdsHelper(scope))}(${key}))`;
|
|
2405
2389
|
}
|
|
2406
2390
|
function userIdSql(ctx, anonExecute) {
|
|
@@ -2534,6 +2518,7 @@ revoke all on table ${ur} from anon, authenticated, public;`);
|
|
|
2534
2518
|
const type = scopeTypeOf(ctx, scope.name);
|
|
2535
2519
|
chunks.push(helperFunction(ctx, permittedIdsHelper(scope.name), `setof ${type}`, bodies.ids(scope.name), anon));
|
|
2536
2520
|
chunks.push(memberFunction(ctx, scope.name, type, anon));
|
|
2521
|
+
if (ctx.roleMemberScopes?.includes(scope.name) === true) chunks.push(memberRolesFunction(ctx, scope.name, type, anon));
|
|
2537
2522
|
if (hasMemberFor(ctx, scope.name)) chunks.push(memberForFunction(ctx, scope.name, type));
|
|
2538
2523
|
}
|
|
2539
2524
|
const forUser = forUserSql(ctx);
|
|
@@ -2620,12 +2605,12 @@ function requiresSql(ctx, key, resource) {
|
|
|
2620
2605
|
}
|
|
2621
2606
|
/** Postgres truncates an identifier past 63 bytes, which would merge two helpers. */
|
|
2622
2607
|
function checkName(name) {
|
|
2623
|
-
if (new TextEncoder().encode(name).length > 63) throw
|
|
2608
|
+
if (new TextEncoder().encode(name).length > 63) throw configError(`PermDock CLI: the helper name ${name} is longer than 63 bytes; use a shorter scope name`);
|
|
2624
2609
|
return name;
|
|
2625
2610
|
}
|
|
2626
2611
|
function grantsFunction(ctx, grants, renamed, anonExecute) {
|
|
2627
2612
|
const fn = qualified(ctx, PERMISSION_HELPERS.grantKeys);
|
|
2628
|
-
const map = Object.fromEntries([...grants].toSorted(
|
|
2613
|
+
const map = Object.fromEntries([...grants].toSorted(byKey));
|
|
2629
2614
|
const current = Object.keys(renamed).length === 0 ? "p_permission" : `coalesce(${quoteLiteral(JSON.stringify(renamed))}::jsonb ->> p_permission, p_permission)`;
|
|
2630
2615
|
return `-- the grant keys a permission key reaches on one scope: its unconditional allows, with p_effect 'deny' every deny, and with 'conditioned-allow' or 'conditioned-deny' the allows or denies that carry a row condition
|
|
2631
2616
|
create or replace function ${fn}(p_permission text, p_scope text, p_effect text default 'allow')
|
|
@@ -2712,7 +2697,7 @@ function permissionHelpersSql(ctx, grants, renamed, anonExecute) {
|
|
|
2712
2697
|
) g
|
|
2713
2698
|
cross join lateral ${helper}(${user}g.grant_key) d(id)`;
|
|
2714
2699
|
const keysBody = (scope, bodies, filtered) => {
|
|
2715
|
-
const rows = ["global", scope].flatMap((at) => Object.entries(grants.get(at) ?? {}).toSorted(
|
|
2700
|
+
const rows = ["global", scope].flatMap((at) => Object.entries(grants.get(at) ?? {}).toSorted(byKey).flatMap(([key, effects]) => ["allow", "deny"].flatMap((effect) => effects[effect].map((grant) => `(${[
|
|
2716
2701
|
key,
|
|
2717
2702
|
at,
|
|
2718
2703
|
effect,
|
|
@@ -2802,4 +2787,848 @@ ${keysFunction(keysFor, `p_user ${user}, p_id ${type}, p_keys text[]`, keysBody(
|
|
|
2802
2787
|
return `${chunks.join("\n\n")}\n`;
|
|
2803
2788
|
}
|
|
2804
2789
|
//#endregion
|
|
2805
|
-
|
|
2790
|
+
//#region src/cli/rls/ownership-names.ts
|
|
2791
|
+
/** Objects the ownership rules add next to the helpers. Names are part of the SQL contract. */
|
|
2792
|
+
const OWNERSHIP = {
|
|
2793
|
+
canAssign: "permdock_can_assign",
|
|
2794
|
+
canAssignFor: "permdock_can_assign_for",
|
|
2795
|
+
canAssignAny: "permdock_can_assign_any",
|
|
2796
|
+
canAssignAnyFor: "permdock_can_assign_any_for",
|
|
2797
|
+
holders: "permdock_holders",
|
|
2798
|
+
transferOnly: "permdock_transfer_only"
|
|
2799
|
+
};
|
|
2800
|
+
/** Objects `rls.assignments` adds. Names are part of the SQL contract. */
|
|
2801
|
+
const ASSIGNMENTS = {
|
|
2802
|
+
custom: "permdock_can_assign_custom_role",
|
|
2803
|
+
customFor: "permdock_can_assign_custom_role_for",
|
|
2804
|
+
trigger: "permdock_assignment"
|
|
2805
|
+
};
|
|
2806
|
+
//#endregion
|
|
2807
|
+
//#region src/cli/rls/assignments.ts
|
|
2808
|
+
/** The client roles whose writes the assignment triggers check; any other role is a trusted path. */
|
|
2809
|
+
const CLIENT_ROLES = [
|
|
2810
|
+
"anon",
|
|
2811
|
+
"anonymous",
|
|
2812
|
+
"authenticated"
|
|
2813
|
+
];
|
|
2814
|
+
/** The mapped membership tables, `rls.assignments.tables` and the global-roles table `rls.roles`. */
|
|
2815
|
+
function assignedTables(ctx) {
|
|
2816
|
+
const out = [];
|
|
2817
|
+
for (const { name } of ctx.scopes) {
|
|
2818
|
+
const mapped = scopeTable(ctx, name);
|
|
2819
|
+
if (mapped === void 0) continue;
|
|
2820
|
+
out.push({
|
|
2821
|
+
table: mapped.table.table,
|
|
2822
|
+
scope: name,
|
|
2823
|
+
id: mapped.column,
|
|
2824
|
+
tenant: mapped.tenantColumn ?? mapped.column,
|
|
2825
|
+
role: mapped.table.role,
|
|
2826
|
+
user: mapped.table.user
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
2829
|
+
if (ctx.roles !== void 0) out.push({
|
|
2830
|
+
table: ctx.roles.table,
|
|
2831
|
+
scope: "global",
|
|
2832
|
+
role: ctx.roles.role ?? "role",
|
|
2833
|
+
user: ctx.roles.user ?? "user_id"
|
|
2834
|
+
});
|
|
2835
|
+
for (const extra of ctx.assignments?.tables ?? []) {
|
|
2836
|
+
const scope = resolveScope(ctx.scopes, extra.scope);
|
|
2837
|
+
if (scope === void 0) throw configError(`PermDock CLI: rls.assignments.tables names scope '${extra.scope}', which the policy does not declare`);
|
|
2838
|
+
if (scope !== ctx.scopes[0]?.name && extra.tenant === void 0) throw configError(`PermDock CLI: rls.assignments.tables entry ${extra.table} assigns at ${scope}, below the first scope: name its tenant column`);
|
|
2839
|
+
out.push({
|
|
2840
|
+
table: extra.table,
|
|
2841
|
+
scope,
|
|
2842
|
+
id: extra.id,
|
|
2843
|
+
tenant: extra.tenant ?? extra.id,
|
|
2844
|
+
role: extra.role,
|
|
2845
|
+
...extra.user === void 0 ? {} : { user: extra.user }
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
return out;
|
|
2849
|
+
}
|
|
2850
|
+
/** The tables `rls.assignments.ownRole` refuses a caller's own rows on, by table key. */
|
|
2851
|
+
function ownRoleTables(ctx, tables) {
|
|
2852
|
+
const own = ctx.assignments?.ownRole;
|
|
2853
|
+
if (own === void 0) return /* @__PURE__ */ new Set();
|
|
2854
|
+
if (own === "refuse") return new Set(tables.filter((entry) => entry.user !== void 0).map((entry) => qualifySqlTable(entry.table)));
|
|
2855
|
+
const out = /* @__PURE__ */ new Set();
|
|
2856
|
+
for (const [name, value] of Object.entries(own)) {
|
|
2857
|
+
const entry = tables.find((table) => qualifySqlTable(table.table) === qualifySqlTable(name));
|
|
2858
|
+
if (entry === void 0) throw configError(`PermDock CLI: rls.assignments.ownRole names ${name}, which no assignment trigger guards`);
|
|
2859
|
+
if (value !== "refuse") throw configError(`PermDock CLI: rls.assignments.ownRole.${name} must be 'refuse'`);
|
|
2860
|
+
if (entry.user === void 0) throw configError(`PermDock CLI: rls.assignments.ownRole names ${name}, which has no user column: set its user in rls.assignments.tables`);
|
|
2861
|
+
out.add(qualifySqlTable(entry.table));
|
|
2862
|
+
}
|
|
2863
|
+
return out;
|
|
2864
|
+
}
|
|
2865
|
+
/**
|
|
2866
|
+
* The tables `rls.assignments` guards, schema-qualified: each scope's mapped
|
|
2867
|
+
* membership table, the global-roles table (`rls.roles`, else
|
|
2868
|
+
* `supabase.hook.roles`) and `rls.assignments.tables`. Empty without
|
|
2869
|
+
* `rls.assignments`.
|
|
2870
|
+
*/
|
|
2871
|
+
function guardedTables(config, scopes) {
|
|
2872
|
+
const rls = config.rls;
|
|
2873
|
+
if (rls?.assignments === void 0) return [];
|
|
2874
|
+
const hook = config.supabase?.hook?.roles;
|
|
2875
|
+
const roles = rls.roles ?? (hook === false ? void 0 : hook);
|
|
2876
|
+
const tables = [
|
|
2877
|
+
...scopes.flatMap((scope) => {
|
|
2878
|
+
const mapped = memberForTable({
|
|
2879
|
+
scopes,
|
|
2880
|
+
...rls.memberships === void 0 ? {} : { memberships: rls.memberships }
|
|
2881
|
+
}, scope.name);
|
|
2882
|
+
return mapped === void 0 ? [] : [mapped.table];
|
|
2883
|
+
}),
|
|
2884
|
+
...roles === void 0 ? [] : [roles.table],
|
|
2885
|
+
...(rls.assignments === true ? [] : rls.assignments.tables ?? []).map((entry) => entry.table)
|
|
2886
|
+
];
|
|
2887
|
+
return [...new Set(tables.map(qualifySqlTable))];
|
|
2888
|
+
}
|
|
2889
|
+
function isRoleList(role) {
|
|
2890
|
+
return Array.isArray(role);
|
|
2891
|
+
}
|
|
2892
|
+
/** The role keys a row variable holds, one scalar per role source. */
|
|
2893
|
+
function rowRoles(entry, row) {
|
|
2894
|
+
return (isRoleList(entry.role) ? entry.role : [entry.role]).map((part) => roleColumn(part, qualifySqlTable(entry.table), row, {
|
|
2895
|
+
label: `rls.assignments ${entry.table} role`,
|
|
2896
|
+
prefix: "PermDock CLI"
|
|
2897
|
+
}).lookup);
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* `permdock_can_assign_custom_role`: whether the signed-in caller may assign a
|
|
2901
|
+
* custom role of the tenant at the instance, by the checks the custom-role
|
|
2902
|
+
* write functions run on its stored definition.
|
|
2903
|
+
*/
|
|
2904
|
+
function canAssignCustomSql(ctx, forUser = false) {
|
|
2905
|
+
const tenantType = tenantTypeOf(ctx);
|
|
2906
|
+
const fn = qualified(ctx, forUser ? ASSIGNMENTS.customFor : ASSIGNMENTS.custom);
|
|
2907
|
+
const perms = qualified(ctx, CUSTOM_ROLES.permissions);
|
|
2908
|
+
const includes = qualified(ctx, CUSTOM_ROLES.includes);
|
|
2909
|
+
const guard = qualified(ctx, forUser ? CUSTOM_ROLES.guardFor : CUSTOM_ROLES.guard);
|
|
2910
|
+
const userParam = forUser ? "p_user uuid, " : "";
|
|
2911
|
+
const userArg = forUser ? "uuid, " : "";
|
|
2912
|
+
const stored = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.role = p_role and (c.scope_id is null or c.scope_id = p_scope_id)`;
|
|
2913
|
+
const grants = forUser ? `revoke execute on function ${fn}(${userArg}${tenantType}, text, text, text) from public, anon, authenticated;` : `revoke execute on function ${fn}(${tenantType}, text, text, text) from public, anon;
|
|
2914
|
+
grant execute on function ${fn}(${tenantType}, text, text, text) to authenticated;`;
|
|
2915
|
+
return `-- whether ${forUser ? "user p_user" : "the caller"} may assign custom role p_role at instance p_scope_id (a platform role: null tenant, scope global): the custom-role write checks on its stored definition${forUser ? "; no client role may execute it" : ""}
|
|
2916
|
+
create or replace function ${fn}(${userParam}p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
|
|
2917
|
+
returns boolean
|
|
2918
|
+
language plpgsql
|
|
2919
|
+
stable
|
|
2920
|
+
security definer
|
|
2921
|
+
set search_path = ''
|
|
2922
|
+
as $$
|
|
2923
|
+
declare
|
|
2924
|
+
v_scope_id text;
|
|
2925
|
+
v_found boolean := false;
|
|
2926
|
+
begin
|
|
2927
|
+
select true, c.scope_id into v_found, v_scope_id
|
|
2928
|
+
from (
|
|
2929
|
+
select c.scope_id from ${perms} c where ${stored}
|
|
2930
|
+
union all
|
|
2931
|
+
select c.scope_id from ${includes} c where ${stored}
|
|
2932
|
+
) c
|
|
2933
|
+
order by c.scope_id nulls first
|
|
2934
|
+
limit 1;
|
|
2935
|
+
if not coalesce(v_found, false) then
|
|
2936
|
+
return false;
|
|
2937
|
+
end if;
|
|
2938
|
+
perform ${guard}(${forUser ? "p_user, " : ""}p_tenant, p_scope, v_scope_id, p_role);
|
|
2939
|
+
return true;
|
|
2940
|
+
exception
|
|
2941
|
+
when insufficient_privilege or invalid_parameter_value then
|
|
2942
|
+
return false;
|
|
2943
|
+
end;
|
|
2944
|
+
$$;
|
|
2945
|
+
${grants}`;
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* The assignment triggers (\`rls.assignments\`): before a client role inserts,
|
|
2949
|
+
* updates or deletes a row that assigns a role, every role the old and new
|
|
2950
|
+
* row hold must be one the caller may assign at that instance, by
|
|
2951
|
+
* \`permdock_can_assign\` for a declared role and
|
|
2952
|
+
* \`permdock_can_assign_custom_role\` for a custom one. The functions run as
|
|
2953
|
+
* the invoker, so \`current_user\` tells a client write from a trusted one.
|
|
2954
|
+
*/
|
|
2955
|
+
function assignmentSql(ctx) {
|
|
2956
|
+
const own = ctx.ownership;
|
|
2957
|
+
if (ctx.assignments === void 0 || own === void 0 || own.assigns.length === 0) return "";
|
|
2958
|
+
const canAssign = qualified(ctx, OWNERSHIP.canAssign);
|
|
2959
|
+
const custom = ctx.customRoles !== void 0 && ctx.authorize === "database";
|
|
2960
|
+
const declared = ctx.customRoles?.declared;
|
|
2961
|
+
const tenantType = tenantTypeOf(ctx);
|
|
2962
|
+
const chunks = [];
|
|
2963
|
+
const clients = `current_user::text = any(array[${CLIENT_ROLES.map(quoteLiteral).join(", ")}])`;
|
|
2964
|
+
const tables = assignedTables(ctx);
|
|
2965
|
+
const ownRole = ownRoleTables(ctx, tables);
|
|
2966
|
+
for (const entry of tables) {
|
|
2967
|
+
const table = quoteQualified(entry.table);
|
|
2968
|
+
const suffix = entry.table.replaceAll(/[^a-z0-9_]/gu, "_");
|
|
2969
|
+
const fn = qualified(ctx, quoteIdent(`${ASSIGNMENTS.trigger}_${suffix}`));
|
|
2970
|
+
const ownUser = entry.user !== void 0 && ownRole.has(qualifySqlTable(entry.table)) ? entry.user : void 0;
|
|
2971
|
+
const ownCheck = (row, reason) => ownUser === void 0 ? "" : ` if ${row}.${quoteIdent(ownUser)}::text = (${subjectIdSql(ctx)})::text then
|
|
2972
|
+
raise exception using
|
|
2973
|
+
errcode = '42501',
|
|
2974
|
+
message = 'permdock: the caller may not change their own roles',
|
|
2975
|
+
hint = '${reason}';
|
|
2976
|
+
end if;
|
|
2977
|
+
`;
|
|
2978
|
+
const check = (row) => {
|
|
2979
|
+
const id = entry.id === void 0 ? "null::text" : `${row}.${quoteIdent(entry.id)}::text`;
|
|
2980
|
+
const customFn = qualified(ctx, ASSIGNMENTS.custom);
|
|
2981
|
+
let allowed = `${canAssign}(v_role, ${id})`;
|
|
2982
|
+
if (custom && declared !== void 0) {
|
|
2983
|
+
const platform = `${customFn}(null, 'global', null, v_role)`;
|
|
2984
|
+
allowed = entry.id === void 0 || entry.tenant === void 0 ? `case when v_role = any(${declaredArray(declared)}) then ${canAssign}(v_role, ${id}) else ${platform} end` : `case when v_role = any(${declaredArray(declared)}) then ${canAssign}(v_role, ${id}) when ${id} is null then ${platform} else ${customFn}(${row}.${quoteIdent(entry.tenant)}::${tenantType}, ${quoteLiteral(entry.scope)}, ${id}, v_role) end`;
|
|
2985
|
+
}
|
|
2986
|
+
return ` foreach v_role in array array[${rowRoles(entry, row).join(", ")}]::text[] loop
|
|
2987
|
+
if v_role is not null and not coalesce(${allowed}, false) then
|
|
2988
|
+
raise exception using
|
|
2989
|
+
errcode = '42501',
|
|
2990
|
+
message = 'permdock: the caller may not assign ' || v_role || ' in ' || coalesce(${id}, 'null'),
|
|
2991
|
+
hint = 'not-assignable-by';
|
|
2992
|
+
end if;
|
|
2993
|
+
end loop;`;
|
|
2994
|
+
};
|
|
2995
|
+
const what = entry.id === void 0 ? "global roles" : `${entry.scope} roles`;
|
|
2996
|
+
chunks.push(`-- ${entry.table}: a client role may write only the ${what} it may assign${ownUser === void 0 ? "" : ", and none on its own rows"}
|
|
2997
|
+
create or replace function ${fn}()
|
|
2998
|
+
returns trigger
|
|
2999
|
+
language plpgsql
|
|
3000
|
+
volatile
|
|
3001
|
+
set search_path = ''
|
|
3002
|
+
as $$
|
|
3003
|
+
declare
|
|
3004
|
+
v_role text;
|
|
3005
|
+
begin
|
|
3006
|
+
if not (${clients}) then
|
|
3007
|
+
return coalesce(new, old);
|
|
3008
|
+
end if;
|
|
3009
|
+
if tg_op in ('UPDATE', 'DELETE') then
|
|
3010
|
+
${ownCheck("old", "self-demotion")}${check("old")}
|
|
3011
|
+
end if;
|
|
3012
|
+
if tg_op in ('INSERT', 'UPDATE') then
|
|
3013
|
+
${ownCheck("new", "not-assignable-by")}${check("new")}
|
|
3014
|
+
end if;
|
|
3015
|
+
return coalesce(new, old);
|
|
3016
|
+
end;
|
|
3017
|
+
$$;
|
|
3018
|
+
revoke execute on function ${fn}() from public, anon, authenticated;
|
|
3019
|
+
drop trigger if exists "permdock_assignment" on ${table};
|
|
3020
|
+
create trigger "permdock_assignment"
|
|
3021
|
+
before insert or update or delete on ${table}
|
|
3022
|
+
for each row execute function ${fn}();`);
|
|
3023
|
+
}
|
|
3024
|
+
return `${chunks.join("\n\n")}\n`;
|
|
3025
|
+
}
|
|
3026
|
+
//#endregion
|
|
3027
|
+
//#region src/cli/rls/ownership-triggers.ts
|
|
3028
|
+
function objectName(prefix, scope) {
|
|
3029
|
+
return quoteIdent(`${prefix}_${scope}`);
|
|
3030
|
+
}
|
|
3031
|
+
/** Live holders of `roleExpr` in instance `idExpr`, with the same kind and expiry rules as the helpers. */
|
|
3032
|
+
function holdersSql(ctx, table, column, idExpr, role, into) {
|
|
3033
|
+
const roleExpr = typeof role === "string" ? quoteLiteral(role) : role.expr;
|
|
3034
|
+
const held = memberRoleOf(table, "m", " ");
|
|
3035
|
+
const filters = [`m.${quoteIdent(column)} = ${idExpr}`, `${held.sql} = ${roleExpr}`];
|
|
3036
|
+
if (table.expiresAt !== void 0) {
|
|
3037
|
+
const expires = memberColumn(table.expiresAt);
|
|
3038
|
+
filters.push(`(${expires} is null or ${expires} > now())`);
|
|
3039
|
+
}
|
|
3040
|
+
const disabled = disabledColumn(table);
|
|
3041
|
+
if (disabled !== void 0) filters.push(`${disabled} is null`);
|
|
3042
|
+
const via = memberVia(table);
|
|
3043
|
+
const kind = typeof role === "string" ? roleKindSql(ctx, role, via) : kindFilterSql(ctx, roleExpr, via);
|
|
3044
|
+
if (kind !== void 0) filters.push(kind);
|
|
3045
|
+
return `select count(distinct m.${quoteIdent(table.user)}) into ${into}
|
|
3046
|
+
from ${quoteQualified(table.table)} m${held.join}
|
|
3047
|
+
where ${filters.join("\n and ")}`;
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* `min` and `max` as a deferred constraint trigger: checked at commit, so a
|
|
3051
|
+
* transfer (demote one holder, promote another) passes in one transaction. An
|
|
3052
|
+
* instance with no memberships left is being torn down and skips `min`.
|
|
3053
|
+
*/
|
|
3054
|
+
function holdersTriggerSql(ctx, scope, table, column, counted) {
|
|
3055
|
+
const fn = qualified(ctx, `${OWNERSHIP.holders}_${scope}`);
|
|
3056
|
+
const col = quoteIdent(column);
|
|
3057
|
+
const checks = counted.map((rule) => {
|
|
3058
|
+
const lines = [` select count(*) into v_total from ${quoteQualified(table.table)} m where m.${col} = v_key;`, ` ${holdersSql(ctx, table, column, "v_key", rule.role, "v_count")};`];
|
|
3059
|
+
if (rule.min > 0) lines.push(` if v_total > 0 and v_count < ${String(rule.min)} then
|
|
3060
|
+
raise exception using
|
|
3061
|
+
errcode = '23514',
|
|
3062
|
+
message = ${quoteLiteral(`permdock: ${scope} `)} || v_id || ${quoteLiteral(` keeps at least ${String(rule.min)} ${rule.role}`)},
|
|
3063
|
+
hint = 'last-holder';
|
|
3064
|
+
end if;`);
|
|
3065
|
+
if (rule.max !== void 0) lines.push(` if v_count > ${String(rule.max)} then
|
|
3066
|
+
raise exception using
|
|
3067
|
+
errcode = '23514',
|
|
3068
|
+
message = ${quoteLiteral(`permdock: ${scope} `)} || v_id || ${quoteLiteral(` has at most ${String(rule.max)} ${rule.role}`)},
|
|
3069
|
+
hint = 'max-holders';
|
|
3070
|
+
end if;`);
|
|
3071
|
+
return lines.join("\n");
|
|
3072
|
+
});
|
|
3073
|
+
return `-- ${scope}: holder counts (min / max), checked at commit
|
|
3074
|
+
create or replace function ${fn}()
|
|
3075
|
+
returns trigger
|
|
3076
|
+
language plpgsql
|
|
3077
|
+
security definer
|
|
3078
|
+
set search_path = ''
|
|
3079
|
+
as $$
|
|
3080
|
+
declare
|
|
3081
|
+
v_ids text[] := '{}';
|
|
3082
|
+
v_id text;
|
|
3083
|
+
v_key ${quoteQualified(table.table)}.${col}%type;
|
|
3084
|
+
v_total bigint;
|
|
3085
|
+
v_count bigint;
|
|
3086
|
+
begin
|
|
3087
|
+
if tg_op in ('UPDATE', 'DELETE') then
|
|
3088
|
+
v_ids := v_ids || old.${col}::text;
|
|
3089
|
+
end if;
|
|
3090
|
+
if tg_op in ('INSERT', 'UPDATE') then
|
|
3091
|
+
v_ids := v_ids || new.${col}::text;
|
|
3092
|
+
end if;
|
|
3093
|
+
foreach v_id in array v_ids loop
|
|
3094
|
+
continue when v_id is null;
|
|
3095
|
+
v_key := v_id;
|
|
3096
|
+
${checks.join("\n")}
|
|
3097
|
+
end loop;
|
|
3098
|
+
return null;
|
|
3099
|
+
end;
|
|
3100
|
+
$$;
|
|
3101
|
+
drop trigger if exists ${objectName(OWNERSHIP.holders, scope)} on ${quoteQualified(table.table)};
|
|
3102
|
+
create constraint trigger ${objectName(OWNERSHIP.holders, scope)}
|
|
3103
|
+
after insert or update or delete on ${quoteQualified(table.table)}
|
|
3104
|
+
deferrable initially deferred
|
|
3105
|
+
for each row execute function ${fn}();`;
|
|
3106
|
+
}
|
|
3107
|
+
/**
|
|
3108
|
+
* `transferOnly` as statement triggers over transition tables: a statement
|
|
3109
|
+
* that changes a role's holder count in an instance that had holders before
|
|
3110
|
+
* and still has some after is refused. Creating the first holder and removing
|
|
3111
|
+
* the last (which `min` guards) are not transfers.
|
|
3112
|
+
*/
|
|
3113
|
+
function transferTriggerSql(ctx, scope, table, column, roles) {
|
|
3114
|
+
const fn = qualified(ctx, `${OWNERSHIP.transferOnly}_${scope}`);
|
|
3115
|
+
const col = quoteIdent(column);
|
|
3116
|
+
const list = `array[${roles.map(quoteLiteral).join(", ")}]::text[]`;
|
|
3117
|
+
const rows = (alias, source, delta) => {
|
|
3118
|
+
const role = memberRoleOf(table, alias);
|
|
3119
|
+
return `select ${alias}.${col} as id, ${role.sql} as role, ${delta} as delta from ${source} ${alias}${role.join} where ${role.sql} = any(${list})`;
|
|
3120
|
+
};
|
|
3121
|
+
const loop = (source) => ` for v_change in
|
|
3122
|
+
select c.id, c.role, sum(c.delta) as delta
|
|
3123
|
+
from (${source}) c
|
|
3124
|
+
group by c.id, c.role
|
|
3125
|
+
loop
|
|
3126
|
+
continue when v_change.delta = 0 or v_change.id is null;
|
|
3127
|
+
${holdersSql(ctx, table, column, "v_change.id", { expr: "v_change.role" }, "v_after")};
|
|
3128
|
+
if v_after > 0 and v_after - v_change.delta > 0 then
|
|
3129
|
+
raise exception using
|
|
3130
|
+
errcode = '23514',
|
|
3131
|
+
message = 'permdock: ' || v_change.role || ${quoteLiteral(` in ${scope} `)} || v_change.id || ' moves only by transfer',
|
|
3132
|
+
hint = 'transfer-only';
|
|
3133
|
+
end if;
|
|
3134
|
+
end loop;`;
|
|
3135
|
+
const inserted = rows("n", "permdock_new", "1");
|
|
3136
|
+
const deleted = rows("o", "permdock_old", "-1");
|
|
3137
|
+
const target = quoteQualified(table.table);
|
|
3138
|
+
const trigger = (op) => {
|
|
3139
|
+
const name = quoteIdent(`${OWNERSHIP.transferOnly}_${scope}_${op}`);
|
|
3140
|
+
return `drop trigger if exists ${name} on ${target};
|
|
3141
|
+
create trigger ${name}
|
|
3142
|
+
after ${op} on ${target}
|
|
3143
|
+
referencing ${op === "insert" ? "new table as permdock_new" : op === "delete" ? "old table as permdock_old" : "old table as permdock_old new table as permdock_new"}
|
|
3144
|
+
for each statement execute function ${fn}();`;
|
|
3145
|
+
};
|
|
3146
|
+
return `-- ${scope}: transfer-only roles (${roles.join(", ")}) keep their holder count per statement
|
|
3147
|
+
create or replace function ${fn}()
|
|
3148
|
+
returns trigger
|
|
3149
|
+
language plpgsql
|
|
3150
|
+
security definer
|
|
3151
|
+
set search_path = ''
|
|
3152
|
+
as $$
|
|
3153
|
+
declare
|
|
3154
|
+
v_change record;
|
|
3155
|
+
v_after bigint;
|
|
3156
|
+
begin
|
|
3157
|
+
if tg_op = 'INSERT' then
|
|
3158
|
+
${loop(inserted)}
|
|
3159
|
+
elsif tg_op = 'DELETE' then
|
|
3160
|
+
${loop(deleted)}
|
|
3161
|
+
else
|
|
3162
|
+
${loop(`${inserted} union all ${deleted}`)}
|
|
3163
|
+
end if;
|
|
3164
|
+
return null;
|
|
3165
|
+
end;
|
|
3166
|
+
$$;
|
|
3167
|
+
${trigger("insert")}
|
|
3168
|
+
${trigger("update")}
|
|
3169
|
+
${trigger("delete")}`;
|
|
3170
|
+
}
|
|
3171
|
+
//#endregion
|
|
3172
|
+
//#region src/cli/rls/ownership-sources.ts
|
|
3173
|
+
/**
|
|
3174
|
+
* The membership sources holding scope `name`: `undefined` when there are
|
|
3175
|
+
* none, `'not-tables'` when one of them carries no table to put a trigger on.
|
|
3176
|
+
*/
|
|
3177
|
+
function holderSources(ctx, name) {
|
|
3178
|
+
const sources = memberForSources(ctx, name);
|
|
3179
|
+
if (sources.length === 0) return;
|
|
3180
|
+
const held = [];
|
|
3181
|
+
for (const source of sources) {
|
|
3182
|
+
if (source.sql.holders === void 0) return "not-tables";
|
|
3183
|
+
held.push({
|
|
3184
|
+
table: source.sql.table,
|
|
3185
|
+
holders: source.sql.holders
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
return held;
|
|
3189
|
+
}
|
|
3190
|
+
function indent(sql, by) {
|
|
3191
|
+
return sql.replaceAll(/^/gmu, by);
|
|
3192
|
+
}
|
|
3193
|
+
/** Every source's rows of one instance, each compared with its own typed `v_key_<n>`. */
|
|
3194
|
+
function sourceRowsOf(scope, sources) {
|
|
3195
|
+
return sources.map((source, index) => indent(source.holders.rows(scope, { id: `v_key_${String(index)}` }), " ")).join("\n union all\n");
|
|
3196
|
+
}
|
|
3197
|
+
function sourceKeys(sources) {
|
|
3198
|
+
return {
|
|
3199
|
+
declare: sources.map((source, index) => ` v_key_${String(index)} ${source.holders.idType};`).join("\n"),
|
|
3200
|
+
assign: (value, by) => sources.map((_, index) => `${by}v_key_${String(index)} := ${value};`).join("\n")
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
3203
|
+
function regclassOf(table) {
|
|
3204
|
+
return `${quoteLiteral(quoteQualified(table))}::regclass`;
|
|
3205
|
+
}
|
|
3206
|
+
function sourceTables(sources) {
|
|
3207
|
+
return [...new Set(sources.map((source) => quoteQualified(source.table)))];
|
|
3208
|
+
}
|
|
3209
|
+
/** `min` and `max` over membership sources: the same commit-time check as on a mapped table, counted over every source of the scope. */
|
|
3210
|
+
function sourceHoldersTriggerSql(ctx, scope, sources, counted) {
|
|
3211
|
+
const fn = qualified(ctx, `${OWNERSHIP.holders}_${scope}`);
|
|
3212
|
+
const keys = sourceKeys(sources);
|
|
3213
|
+
const rows = sourceRowsOf(scope, sources);
|
|
3214
|
+
const collect = sources.map((source) => ` if tg_relid = ${regclassOf(source.table)} then
|
|
3215
|
+
if tg_op in ('UPDATE', 'DELETE') then
|
|
3216
|
+
v_ids := v_ids || ${source.holders.id(scope, "old")};
|
|
3217
|
+
end if;
|
|
3218
|
+
if tg_op in ('INSERT', 'UPDATE') then
|
|
3219
|
+
v_ids := v_ids || ${source.holders.id(scope, "new")};
|
|
3220
|
+
end if;
|
|
3221
|
+
end if;`);
|
|
3222
|
+
const checks = counted.map((rule) => {
|
|
3223
|
+
const filters = ["h.live", `h.role = ${quoteLiteral(rule.role)}`];
|
|
3224
|
+
const kind = roleKindSql(ctx, rule.role, "h.via");
|
|
3225
|
+
if (kind !== void 0) filters.push(kind);
|
|
3226
|
+
const lines = [` select count(distinct h.user_id) into v_count
|
|
3227
|
+
from (
|
|
3228
|
+
${rows}
|
|
3229
|
+
) h
|
|
3230
|
+
where ${filters.join("\n and ")};`];
|
|
3231
|
+
if (rule.min > 0) lines.push(` if v_total > 0 and v_count < ${String(rule.min)} then
|
|
3232
|
+
raise exception using
|
|
3233
|
+
errcode = '23514',
|
|
3234
|
+
message = ${quoteLiteral(`permdock: ${scope} `)} || v_id || ${quoteLiteral(` keeps at least ${String(rule.min)} ${rule.role}`)},
|
|
3235
|
+
hint = 'last-holder';
|
|
3236
|
+
end if;`);
|
|
3237
|
+
if (rule.max !== void 0) lines.push(` if v_count > ${String(rule.max)} then
|
|
3238
|
+
raise exception using
|
|
3239
|
+
errcode = '23514',
|
|
3240
|
+
message = ${quoteLiteral(`permdock: ${scope} `)} || v_id || ${quoteLiteral(` has at most ${String(rule.max)} ${rule.role}`)},
|
|
3241
|
+
hint = 'max-holders';
|
|
3242
|
+
end if;`);
|
|
3243
|
+
return lines.join("\n");
|
|
3244
|
+
});
|
|
3245
|
+
const triggers = sourceTables(sources).map((table) => `drop trigger if exists ${objectName(OWNERSHIP.holders, scope)} on ${table};
|
|
3246
|
+
create constraint trigger ${objectName(OWNERSHIP.holders, scope)}
|
|
3247
|
+
after insert or update or delete on ${table}
|
|
3248
|
+
deferrable initially deferred
|
|
3249
|
+
for each row execute function ${fn}();`);
|
|
3250
|
+
return `-- ${scope}: holder counts (min / max) over the membership sources, checked at commit
|
|
3251
|
+
create or replace function ${fn}()
|
|
3252
|
+
returns trigger
|
|
3253
|
+
language plpgsql
|
|
3254
|
+
security definer
|
|
3255
|
+
set search_path = ''
|
|
3256
|
+
as $$
|
|
3257
|
+
declare
|
|
3258
|
+
v_ids text[] := '{}';
|
|
3259
|
+
v_id text;
|
|
3260
|
+
${keys.declare}
|
|
3261
|
+
v_total bigint;
|
|
3262
|
+
v_count bigint;
|
|
3263
|
+
begin
|
|
3264
|
+
${collect.join("\n")}
|
|
3265
|
+
foreach v_id in array v_ids loop
|
|
3266
|
+
continue when v_id is null;
|
|
3267
|
+
${keys.assign("v_id", " ")}
|
|
3268
|
+
select count(*) into v_total
|
|
3269
|
+
from (
|
|
3270
|
+
${rows}
|
|
3271
|
+
) h;
|
|
3272
|
+
${checks.join("\n")}
|
|
3273
|
+
end loop;
|
|
3274
|
+
return null;
|
|
3275
|
+
end;
|
|
3276
|
+
$$;
|
|
3277
|
+
${triggers.join("\n")}`;
|
|
3278
|
+
}
|
|
3279
|
+
function collectChangesSql(changed) {
|
|
3280
|
+
return ` v_changes := v_changes || coalesce((select jsonb_agg(to_jsonb(c)) from (${changed}) c), '[]'::jsonb);`;
|
|
3281
|
+
}
|
|
3282
|
+
/** `transferOnly` over membership sources: each source table's statement triggers diff its transition tables, and the count after reads every source. */
|
|
3283
|
+
function sourceTransferTriggerSql(ctx, scope, sources, roles) {
|
|
3284
|
+
const fn = qualified(ctx, `${OWNERSHIP.transferOnly}_${scope}`);
|
|
3285
|
+
const keys = sourceKeys(sources);
|
|
3286
|
+
const list = `array[${roles.map(quoteLiteral).join(", ")}]::text[]`;
|
|
3287
|
+
const kind = kindFilterSql(ctx, "h.role", "h.via");
|
|
3288
|
+
const changes = (source, from, delta) => `select h.id, h.role, ${delta} as delta from (${source.holders.rows(scope, { from }).replaceAll("\n", " ")}) h where h.role = any(${list})`;
|
|
3289
|
+
const branches = sources.map((source) => ` if tg_relid = ${regclassOf(source.table)} then
|
|
3290
|
+
if tg_op in ('INSERT', 'UPDATE') then
|
|
3291
|
+
${collectChangesSql(changes(source, "permdock_new", "1"))}
|
|
3292
|
+
end if;
|
|
3293
|
+
if tg_op in ('UPDATE', 'DELETE') then
|
|
3294
|
+
${collectChangesSql(changes(source, "permdock_old", "-1"))}
|
|
3295
|
+
end if;
|
|
3296
|
+
end if;`);
|
|
3297
|
+
const triggers = sourceTables(sources).flatMap((table) => [
|
|
3298
|
+
"insert",
|
|
3299
|
+
"update",
|
|
3300
|
+
"delete"
|
|
3301
|
+
].map((op) => {
|
|
3302
|
+
const name = quoteIdent(`${OWNERSHIP.transferOnly}_${scope}_${op}`);
|
|
3303
|
+
return `drop trigger if exists ${name} on ${table};
|
|
3304
|
+
create trigger ${name}
|
|
3305
|
+
after ${op} on ${table}
|
|
3306
|
+
referencing ${op === "insert" ? "new table as permdock_new" : op === "delete" ? "old table as permdock_old" : "old table as permdock_old new table as permdock_new"}
|
|
3307
|
+
for each statement execute function ${fn}();`;
|
|
3308
|
+
}));
|
|
3309
|
+
return `-- ${scope}: transfer-only roles (${roles.join(", ")}) keep their holder count per statement, over the membership sources
|
|
3310
|
+
create or replace function ${fn}()
|
|
3311
|
+
returns trigger
|
|
3312
|
+
language plpgsql
|
|
3313
|
+
security definer
|
|
3314
|
+
set search_path = ''
|
|
3315
|
+
as $$
|
|
3316
|
+
declare
|
|
3317
|
+
v_changes jsonb := '[]'::jsonb;
|
|
3318
|
+
v_change record;
|
|
3319
|
+
v_after bigint;
|
|
3320
|
+
${keys.declare}
|
|
3321
|
+
begin
|
|
3322
|
+
${branches.join("\n")}
|
|
3323
|
+
for v_change in
|
|
3324
|
+
select x.id, x.role, sum(x.delta) as delta
|
|
3325
|
+
from jsonb_to_recordset(v_changes) x(id text, role text, delta bigint)
|
|
3326
|
+
group by x.id, x.role
|
|
3327
|
+
loop
|
|
3328
|
+
continue when v_change.delta = 0 or v_change.id is null;
|
|
3329
|
+
${keys.assign("v_change.id", " ")}
|
|
3330
|
+
select count(distinct h.user_id) into v_after
|
|
3331
|
+
from (
|
|
3332
|
+
${sourceRowsOf(scope, sources)}
|
|
3333
|
+
) h
|
|
3334
|
+
where h.live
|
|
3335
|
+
and h.role = v_change.role${kind === void 0 ? "" : `\n and ${kind}`};
|
|
3336
|
+
if v_after > 0 and v_after - v_change.delta > 0 then
|
|
3337
|
+
raise exception using
|
|
3338
|
+
errcode = '23514',
|
|
3339
|
+
message = 'permdock: ' || v_change.role || ${quoteLiteral(` in ${scope} `)} || v_change.id || ' moves only by transfer',
|
|
3340
|
+
hint = 'transfer-only';
|
|
3341
|
+
end if;
|
|
3342
|
+
end loop;
|
|
3343
|
+
return null;
|
|
3344
|
+
end;
|
|
3345
|
+
$$;
|
|
3346
|
+
${triggers.join("\n")}`;
|
|
3347
|
+
}
|
|
3348
|
+
function pairsSql(pairs) {
|
|
3349
|
+
return `values ${pairs.map((pair) => `(${quoteLiteral(pair.assigner)}, ${quoteLiteral(pair.role)})`).join(", ")}`;
|
|
3350
|
+
}
|
|
3351
|
+
/**
|
|
3352
|
+
* Membership rows of the membership sources for scope `name`, each source's
|
|
3353
|
+
* `select` comparing its user column with a variable `user` names.
|
|
3354
|
+
*/
|
|
3355
|
+
function sourceRowsSql(sources, user) {
|
|
3356
|
+
const keep = sources.some((source) => source.sql.keeps);
|
|
3357
|
+
return sources.map((source) => source.sql.select(user(source), keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
3358
|
+
}
|
|
3359
|
+
//#endregion
|
|
3360
|
+
//#region src/cli/rls/can-assign.ts
|
|
3361
|
+
/** Whether the signed-in user (or `p_user` in the `_for` form) holds, in instance `p_scope_id`, a role whose `assigns` lists `p_role`. */
|
|
3362
|
+
function canAssignSql(base, own, forUser = false) {
|
|
3363
|
+
const ctx = forUser ? {
|
|
3364
|
+
...base,
|
|
3365
|
+
subjectId: "p_user",
|
|
3366
|
+
tenants: "all"
|
|
3367
|
+
} : base;
|
|
3368
|
+
const fn = qualified(ctx, forUser ? OWNERSHIP.canAssignFor : OWNERSHIP.canAssign);
|
|
3369
|
+
const parts = [];
|
|
3370
|
+
const used = [];
|
|
3371
|
+
const userOf = (source) => {
|
|
3372
|
+
const index = used.includes(source) ? used.indexOf(source) : used.push(source) - 1;
|
|
3373
|
+
return `v_user_${String(index)}`;
|
|
3374
|
+
};
|
|
3375
|
+
const global = own.assigns.filter((pair) => pair.scope === "global");
|
|
3376
|
+
for (const [instance, pairs] of [["p_scope_id is not null", global.filter((pair) => pair.at !== "global")], ["p_scope_id is null", global.filter((pair) => pair.at === "global")]]) {
|
|
3377
|
+
if (pairs.length === 0) continue;
|
|
3378
|
+
if (ctx.authorize === "database") {
|
|
3379
|
+
const ur = globalRoleRows(ctx);
|
|
3380
|
+
const kind = globalKindFilterSql(ctx, ur.roleSql);
|
|
3381
|
+
parts.push(`${instance} and exists (
|
|
3382
|
+
select 1 from ${ur.from}
|
|
3383
|
+
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
3384
|
+
and (${ur.roleSql}, p_role) in (${pairsSql(pairs)})${kind === void 0 ? "" : `\n and ${kind}`}
|
|
3385
|
+
)`);
|
|
3386
|
+
} else {
|
|
3387
|
+
const kind = globalKindFilterSql(ctx, "r.role");
|
|
3388
|
+
parts.push(`${instance} and exists (
|
|
3389
|
+
select 1 from ${roleRows(ctx)}
|
|
3390
|
+
where (r.role, p_role) in (${pairsSql(pairs)})${kind === void 0 ? "" : `\n and ${kind}`}
|
|
3391
|
+
)`);
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
for (const { name } of ctx.scopes) {
|
|
3395
|
+
const pairs = own.assigns.filter((pair) => pair.scope === name);
|
|
3396
|
+
if (pairs.length === 0) continue;
|
|
3397
|
+
const sources = scopeSources(ctx, name);
|
|
3398
|
+
if (sources.length > 0) {
|
|
3399
|
+
const kind = kindFilterSql(ctx, "r.role", "ms.via");
|
|
3400
|
+
parts.push(`exists (
|
|
3401
|
+
select 1
|
|
3402
|
+
from (
|
|
3403
|
+
${sourceRowsSql(sources, userOf)}
|
|
3404
|
+
) ms
|
|
3405
|
+
cross join lateral jsonb_array_elements_text(
|
|
3406
|
+
case jsonb_typeof(ms.roles) when 'array' then ms.roles else '[]'::jsonb end
|
|
3407
|
+
) r(role)
|
|
3408
|
+
where ms.scope = ${quoteLiteral(name)}
|
|
3409
|
+
and ms.id = p_scope_id
|
|
3410
|
+
and (r.role, p_role) in (${pairsSql(pairs)})${kind === void 0 ? "" : `\n and ${kind}`}${sourceFilters(ctx, name, void 0, void 0, sourcesKeep(sources)).replaceAll("\n and ", "\n and ")}
|
|
3411
|
+
)`);
|
|
3412
|
+
continue;
|
|
3413
|
+
}
|
|
3414
|
+
if (ctx.authorize === "database") {
|
|
3415
|
+
const mapped = scopeTable(ctx, name);
|
|
3416
|
+
if (mapped === void 0) continue;
|
|
3417
|
+
const { table, column } = mapped;
|
|
3418
|
+
const role = memberRoleOf(table, "m", " ");
|
|
3419
|
+
const filters = [
|
|
3420
|
+
`${memberColumn(table.user)} = ${subjectIdSql(ctx)}`,
|
|
3421
|
+
`${memberColumn(column)}::text = p_scope_id`,
|
|
3422
|
+
`(${role.sql}, p_role) in (${pairsSql(pairs)})`
|
|
3423
|
+
];
|
|
3424
|
+
if (table.expiresAt !== void 0) {
|
|
3425
|
+
const expires = memberColumn(table.expiresAt);
|
|
3426
|
+
filters.push(`(${expires} is null or ${expires} > now())`);
|
|
3427
|
+
}
|
|
3428
|
+
const disabled = disabledColumn(table);
|
|
3429
|
+
if (disabled !== void 0) filters.push(`${disabled} is null`);
|
|
3430
|
+
const kind = kindFilterSql(ctx, role.sql, memberVia(table));
|
|
3431
|
+
if (kind !== void 0) filters.push(kind);
|
|
3432
|
+
parts.push(`exists (
|
|
3433
|
+
select 1 from ${quoteQualified(table.table)} m${role.join}
|
|
3434
|
+
where ${filters.join("\n and ")}
|
|
3435
|
+
)`);
|
|
3436
|
+
} else {
|
|
3437
|
+
const kind = kindFilterSql(ctx, "r.role", "m ->> 'via'");
|
|
3438
|
+
parts.push(`exists (
|
|
3439
|
+
select 1 from ${membershipRows(ctx)}
|
|
3440
|
+
where m ->> 'scope' = ${quoteLiteral(name)}
|
|
3441
|
+
and m ->> 'id' = p_scope_id
|
|
3442
|
+
and (r.role, p_role) in (${pairsSql(pairs)})
|
|
3443
|
+
and case jsonb_typeof(m -> 'expires_at')
|
|
3444
|
+
when 'number' then (m ->> 'expires_at')::numeric > extract(epoch from now())
|
|
3445
|
+
else true
|
|
3446
|
+
end${kind === void 0 ? "" : `\n and ${kind}`}${claimKeptLines(ctx, " ").map((line) => `\n${line}`).join("")}
|
|
3447
|
+
)`);
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
const body = parts.length === 0 ? "false" : parts.join("\n or ");
|
|
3451
|
+
const language = used.length === 0 ? `language sql
|
|
3452
|
+
stable
|
|
3453
|
+
security definer
|
|
3454
|
+
set search_path = ''
|
|
3455
|
+
as $$
|
|
3456
|
+
select ${signedIn(ctx)} and (
|
|
3457
|
+
${body}
|
|
3458
|
+
)
|
|
3459
|
+
$$;` : `language plpgsql
|
|
3460
|
+
stable
|
|
3461
|
+
security definer
|
|
3462
|
+
set search_path = ''
|
|
3463
|
+
as $$
|
|
3464
|
+
declare
|
|
3465
|
+
${used.map((source, index) => ` v_user_${String(index)} ${source.sql.userType} := ${subjectIdSql(ctx)};`).join("\n")}
|
|
3466
|
+
begin
|
|
3467
|
+
return ${signedIn(ctx)} and (
|
|
3468
|
+
${body}
|
|
3469
|
+
);
|
|
3470
|
+
end;
|
|
3471
|
+
$$;`;
|
|
3472
|
+
if (forUser) {
|
|
3473
|
+
const user = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
3474
|
+
return `-- who may assign, for a user the caller names: trusted SQL acting later for a stored user; no client role may execute it
|
|
3475
|
+
create or replace function ${fn}(p_user ${user}, p_role text, p_scope_id text)
|
|
3476
|
+
returns boolean
|
|
3477
|
+
${language}
|
|
3478
|
+
revoke execute on function ${fn}(${user}, text, text) from public, anon, authenticated;`;
|
|
3479
|
+
}
|
|
3480
|
+
return `-- who may assign: a held role whose assigns lists p_role, in instance p_scope_id (its scope or an ancestor's); a null p_scope_id assigns a global role, which only global assigners may
|
|
3481
|
+
create or replace function ${fn}(p_role text, p_scope_id text)
|
|
3482
|
+
returns boolean
|
|
3483
|
+
${language}
|
|
3484
|
+
revoke execute on function ${fn}(text, text) from public, anon;
|
|
3485
|
+
grant execute on function ${fn}(text, text) to authenticated;`;
|
|
3486
|
+
}
|
|
3487
|
+
/** Whether the helpers keep custom roles in tables, so assigning one is checked against its stored definition. */
|
|
3488
|
+
function assignsCustomRoles(ctx) {
|
|
3489
|
+
return ctx.customRoles !== void 0 && ctx.authorize === "database";
|
|
3490
|
+
}
|
|
3491
|
+
/** Whether `permdock_can_assign_any_for` is written: every function it calls has a `_for` form. */
|
|
3492
|
+
function hasCanAssignAnyFor(ctx) {
|
|
3493
|
+
return ctx.authorize === "database" && (!assignsCustomRoles(ctx) || hasCustomRoleChecksFor(ctx));
|
|
3494
|
+
}
|
|
3495
|
+
/**
|
|
3496
|
+
* `permdock_can_assign_any`: one check for a declared or a custom role, so
|
|
3497
|
+
* SQL that assigns roles calls one function: `permdock_can_assign` for a
|
|
3498
|
+
* declared role, `permdock_can_assign_custom_role` for a custom one. `p_scope`
|
|
3499
|
+
* `'global'` (no tenant, no instance) assigns a platform role.
|
|
3500
|
+
*/
|
|
3501
|
+
function canAssignAnySql(ctx, custom, forUser = false) {
|
|
3502
|
+
const tenantType = tenantTypeOf(ctx);
|
|
3503
|
+
const fn = qualified(ctx, forUser ? OWNERSHIP.canAssignAnyFor : OWNERSHIP.canAssignAny);
|
|
3504
|
+
const declaredFn = qualified(ctx, forUser ? OWNERSHIP.canAssignFor : OWNERSHIP.canAssign);
|
|
3505
|
+
const customFn = qualified(ctx, forUser ? ASSIGNMENTS.customFor : ASSIGNMENTS.custom);
|
|
3506
|
+
const userType = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
3507
|
+
const userParam = forUser ? `p_user ${userType}, ` : "";
|
|
3508
|
+
const userArg = forUser ? `${userType}, ` : "";
|
|
3509
|
+
const user = forUser ? "p_user, " : "";
|
|
3510
|
+
const declared = `${declaredFn}(${user}p_role, case when p_scope = 'global' then null else p_scope_id end)`;
|
|
3511
|
+
const body = custom && ctx.customRoles !== void 0 ? `case
|
|
3512
|
+
when p_role = any(${declaredArray(ctx.customRoles.declared)}) then ${declared}
|
|
3513
|
+
when p_scope = 'global' then ${customFn}(${user}null, 'global', null, p_role)
|
|
3514
|
+
else ${customFn}(${user}p_tenant, p_scope, p_scope_id, p_role)
|
|
3515
|
+
end` : declared;
|
|
3516
|
+
const grants = forUser ? `revoke execute on function ${fn}(${userArg}text, ${tenantType}, text, text) from public, anon, authenticated;` : `revoke execute on function ${fn}(text, ${tenantType}, text, text) from public, anon;
|
|
3517
|
+
grant execute on function ${fn}(text, ${tenantType}, text, text) to authenticated;`;
|
|
3518
|
+
return `-- whether ${forUser ? "user p_user" : "the caller"} may assign p_role at instance p_scope_id of p_scope in tenant p_tenant, declared or custom (p_scope 'global' for a platform role)${forUser ? "; no client role may execute it" : ""}
|
|
3519
|
+
create or replace function ${fn}(${userParam}p_role text, p_tenant ${tenantType}, p_scope text, p_scope_id text)
|
|
3520
|
+
returns boolean
|
|
3521
|
+
language sql
|
|
3522
|
+
stable
|
|
3523
|
+
security definer
|
|
3524
|
+
set search_path = ''
|
|
3525
|
+
as $$
|
|
3526
|
+
select coalesce(${body}, false)
|
|
3527
|
+
$$;
|
|
3528
|
+
${grants}`;
|
|
3529
|
+
}
|
|
3530
|
+
//#endregion
|
|
3531
|
+
//#region src/cli/rls/ownership.ts
|
|
3532
|
+
function roleScope(policy, scopes, name) {
|
|
3533
|
+
const on = policy.rolesByName.get(name)?.on ?? findRole(policy.vocabulary.roles, name)?.on;
|
|
3534
|
+
return typeof on === "string" ? resolveScope(scopes, on) : void 0;
|
|
3535
|
+
}
|
|
3536
|
+
function isGlobalRole(policy, name) {
|
|
3537
|
+
const binding = policy.rolesByName.get(name);
|
|
3538
|
+
if (binding !== void 0) return binding.on === void 0;
|
|
3539
|
+
const leaf = findRole(policy.vocabulary.roles, name);
|
|
3540
|
+
return leaf !== void 0 && leaf.on === void 0;
|
|
3541
|
+
}
|
|
3542
|
+
/** The rules `rls generate` enforces; `undefined` when no role declares one. */
|
|
3543
|
+
function ownershipRules(policy, scopes) {
|
|
3544
|
+
const kinds = {};
|
|
3545
|
+
const assigns = [];
|
|
3546
|
+
const counted = [];
|
|
3547
|
+
for (const binding of policy.roles) {
|
|
3548
|
+
const scope = roleScope(policy, scopes, binding.name);
|
|
3549
|
+
if (binding.for !== void 0) kinds[binding.name] = binding.for;
|
|
3550
|
+
const assigner = scope ?? (binding.on === void 0 ? "global" : void 0);
|
|
3551
|
+
for (const target of binding.assigns ?? []) {
|
|
3552
|
+
const at = roleScope(policy, scopes, target);
|
|
3553
|
+
if (assigner !== void 0 && at !== void 0 && (assigner === "global" || scopeChain(scopes, at).includes(assigner))) assigns.push({
|
|
3554
|
+
assigner: binding.name,
|
|
3555
|
+
scope: assigner,
|
|
3556
|
+
role: target,
|
|
3557
|
+
at
|
|
3558
|
+
});
|
|
3559
|
+
else if (assigner === "global" && isGlobalRole(policy, target)) assigns.push({
|
|
3560
|
+
assigner: binding.name,
|
|
3561
|
+
scope: "global",
|
|
3562
|
+
role: target,
|
|
3563
|
+
at: "global"
|
|
3564
|
+
});
|
|
3565
|
+
}
|
|
3566
|
+
const min = binding.min ?? 0;
|
|
3567
|
+
const transferOnly = binding.transferOnly === true;
|
|
3568
|
+
if (scope !== void 0 && (min > 0 || binding.max !== void 0 || transferOnly)) counted.push(binding.max === void 0 ? {
|
|
3569
|
+
role: binding.name,
|
|
3570
|
+
scope,
|
|
3571
|
+
min,
|
|
3572
|
+
transferOnly
|
|
3573
|
+
} : {
|
|
3574
|
+
role: binding.name,
|
|
3575
|
+
scope,
|
|
3576
|
+
min,
|
|
3577
|
+
max: binding.max,
|
|
3578
|
+
transferOnly
|
|
3579
|
+
});
|
|
3580
|
+
}
|
|
3581
|
+
if (Object.keys(kinds).length === 0 && assigns.length === 0 && counted.length === 0) return;
|
|
3582
|
+
return {
|
|
3583
|
+
kinds,
|
|
3584
|
+
assigns,
|
|
3585
|
+
counted
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
/**
|
|
3589
|
+
* The ownership objects: holder-count and transfer-only triggers on each
|
|
3590
|
+
* scope's membership table, and `permdock_can_assign` for the application's
|
|
3591
|
+
* own policies on those tables. Empty when no role declares a rule.
|
|
3592
|
+
*/
|
|
3593
|
+
function ownershipSql(ctx) {
|
|
3594
|
+
const own = ctx.ownership;
|
|
3595
|
+
if (own === void 0) return "";
|
|
3596
|
+
const chunks = [];
|
|
3597
|
+
const skipped = ctx.skipOwnershipTriggers;
|
|
3598
|
+
for (const { name } of ctx.scopes) {
|
|
3599
|
+
const counted = own.counted.filter((rule) => rule.scope === name);
|
|
3600
|
+
if (counted.length === 0) continue;
|
|
3601
|
+
if (skipped === "all" || skipped?.includes(name) === true) {
|
|
3602
|
+
chunks.push(`-- ${name}: rls.ownershipTriggers leaves out the triggers, so min, max and transferOnly are checked only by decideRoleChange`);
|
|
3603
|
+
continue;
|
|
3604
|
+
}
|
|
3605
|
+
const transfer = counted.filter((rule) => rule.transferOnly).map((rule) => rule.role);
|
|
3606
|
+
const mapped = scopeTable(ctx, name);
|
|
3607
|
+
if (mapped === void 0) {
|
|
3608
|
+
const sources = holderSources(ctx, name);
|
|
3609
|
+
if (sources === void 0) chunks.push(`-- ${name}: no memberships table configured, so min, max and transferOnly are checked only by decideRoleChange`);
|
|
3610
|
+
else if (sources === "not-tables") chunks.push(`-- ${name}: a membership source is not a table, so min, max and transferOnly are checked only by decideRoleChange`);
|
|
3611
|
+
else {
|
|
3612
|
+
chunks.push(sourceHoldersTriggerSql(ctx, name, sources, counted));
|
|
3613
|
+
if (transfer.length > 0) chunks.push(sourceTransferTriggerSql(ctx, name, sources, transfer));
|
|
3614
|
+
}
|
|
3615
|
+
continue;
|
|
3616
|
+
}
|
|
3617
|
+
chunks.push(holdersTriggerSql(ctx, name, mapped.table, mapped.column, counted));
|
|
3618
|
+
if (transfer.length > 0) chunks.push(transferTriggerSql(ctx, name, mapped.table, mapped.column, transfer));
|
|
3619
|
+
}
|
|
3620
|
+
if (own.assigns.length > 0) {
|
|
3621
|
+
chunks.push(canAssignSql(ctx, own));
|
|
3622
|
+
if (ctx.authorize === "database") chunks.push(canAssignSql(ctx, own, true));
|
|
3623
|
+
const custom = assignsCustomRoles(ctx);
|
|
3624
|
+
if (custom) {
|
|
3625
|
+
chunks.push(canAssignCustomSql(ctx));
|
|
3626
|
+
if (hasCustomRoleChecksFor(ctx)) chunks.push(canAssignCustomSql(ctx, true));
|
|
3627
|
+
}
|
|
3628
|
+
chunks.push(canAssignAnySql(ctx, custom));
|
|
3629
|
+
if (hasCanAssignAnyFor(ctx)) chunks.push(canAssignAnySql(ctx, custom, true));
|
|
3630
|
+
}
|
|
3631
|
+
return chunks.length === 0 ? "" : `${chunks.join("\n\n")}\n`;
|
|
3632
|
+
}
|
|
3633
|
+
//#endregion
|
|
3634
|
+
export { checkSuspension as $, roleNames as A, linkHelper as B, API_KEY_ALLOWS as C, breakGlassHolder as D, apiKeysPlan as E, graphHelper as F, memberRoleOf as G, memberForSources as H, graphSqlName as I, restrictedHelper as J, permittedForHelper as K, graphSqlText as L, contextRefs as M, sqlFunctionNames as N, breakGlassKey as O, CLOSURE as P, activeUserSql as Q, hasMemberFor as R, byKey as S, apiKeyFields as T, memberForTable as U, memberForHelper as V, memberIdsHelper as W, scopeTable as X, scopeSources as Y, activeRowSql as Z, currentKeyExpr as _, guardedTables as a, globalRoleSource as b, PERMISSION_HELPERS as c, permissionKeysForHelper as d, permittedByPermissionHelper as f, seedSql as g, helpersSql as h, assignmentSql as i, compileConditionSql as j, collectGrants as k, checkName as l, accessSql as m, ownershipSql as n, ASSIGNMENTS as o, requiresSql as p, permittedIdsHelper as q, holderSources as r, OWNERSHIP as s, ownershipRules as t, permissionHelpersSql as u, capabilityAccessSql as v, apiKeyAllowsCall as w, byCodeUnit as x, globalRoleRows as y, hasPermittedUsers as z };
|