permdock 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -35
- package/dist/{openapi-schema--ARjyfQr.js → 2020-C9VN-x8c.js} +5 -132
- package/dist/a2a/index.d.ts +8 -6
- package/dist/a2a/index.js +21 -31
- package/dist/{adapter-context-mlWsVGGg.js → adapter-context-CRhqyzCk.js} +1 -1
- package/dist/adoption-scan-B4R5fnMU.js +107 -0
- package/dist/agent/index.d.ts +3 -0
- package/dist/agent/index.js +1 -0
- package/dist/ai-sdk/index.d.ts +18 -23
- package/dist/ai-sdk/index.js +38 -21
- package/dist/app-data-MuyzVqX8.js +60 -0
- package/dist/approval-header-CJACtHkv.js +23 -0
- package/dist/approval-prompt-Dg5LeAcT.js +62 -0
- package/dist/approval-prompt-Djo_PyZc.d.ts +57 -0
- package/dist/approvals/index.d.ts +5 -3
- package/dist/approvals/index.js +8 -5
- package/dist/approvals-DJ6Yd5z1.js +995 -0
- package/dist/{approvers-DT4AVyIg.js → approvers-CqPOLD-o.js} +1 -1
- package/dist/arazzo-B6lebmiQ.js +36 -0
- package/dist/{arazzo-BAgw9ap6.js → arazzo-BWhNF-k0.js} +24 -17
- package/dist/args-IIolxxKA.js +197 -0
- package/dist/astro/index.d.ts +43 -0
- package/dist/astro/index.js +27 -0
- package/dist/audience-Dg8XsRaO.js +55 -0
- package/dist/auth-DRLEizbL.js +178 -0
- package/dist/authzen/index.d.ts +26 -6
- package/dist/authzen/index.js +228 -75
- package/dist/automation-BMZPIVUM.d.ts +114 -0
- package/dist/automation-DVjOckjV.js +157 -0
- package/dist/better-auth/index.d.ts +8 -6
- package/dist/better-auth/index.js +11 -23
- package/dist/better-supabase/index.d.ts +438 -86
- package/dist/better-supabase/index.js +1027 -184
- package/dist/bind--9EFVYWF.d.ts +38 -0
- package/dist/{bind-CuzGytRf.js → bind-CZzhx-Vj.js} +22 -10
- package/dist/body-CH5of8Ne.js +10 -0
- package/dist/body-text-QcXok_SI.js +52 -0
- package/dist/build-instance-BMnAudHn.js +52 -0
- package/dist/{canonical-json-DemiK4Vp.js → canonical-json-BC8_nMTw.js} +6 -6
- package/dist/{capability-2kYTMWDv.d.ts → capability-BSslw1QV.d.ts} +44 -25
- package/dist/{capability-4qPdO9gk.js → capability-N1K9IpqS.js} +69 -42
- package/dist/casbin-Cr7RLtA3.js +30 -0
- package/dist/casbin-Dyu2gnUD.js +234 -0
- package/dist/casl-64gpT-8x.js +24 -0
- package/dist/casl-DjEDhTCP.js +299 -0
- package/dist/catalog/index.d.ts +74 -2
- package/dist/catalog/index.js +51 -2
- package/dist/catalog-BECMPrwc.js +100 -0
- package/dist/catalog-DjhMYYCq.js +1 -0
- package/dist/catalog-R_A__RsH.js +52 -0
- package/dist/{catalog-doc-DdGm82XT.js → catalog-doc-C9wjsfNY.js} +54 -25
- package/dist/catalog-fingerprint-CCP0-yQS.js +153 -0
- package/dist/chat/index.d.ts +2 -0
- package/dist/chat/index.js +224 -0
- package/dist/chat-sdk/index.d.ts +87 -0
- package/dist/chat-sdk/index.js +197 -0
- package/dist/claims-map-7oQ3ue4X.js +13 -0
- package/dist/claims-map-Ca-QX8C6.d.ts +32 -0
- package/dist/claude-agent/index.d.ts +7 -14
- package/dist/claude-agent/index.js +26 -30
- package/dist/clerk/index.d.ts +9 -8
- package/dist/clerk/index.js +15 -45
- package/dist/cli/bin.js +36 -17
- package/dist/cli/index.d.ts +20 -6
- package/dist/cli/index.js +164 -4
- package/dist/cloud/index.d.ts +102 -63
- package/dist/cloud/index.js +240 -347
- package/dist/cloud-CIoHNevm.js +87 -0
- package/dist/codemod-Bmj9dWMH.js +37 -0
- package/dist/codemod-Cxvvc70T.js +2084 -0
- package/dist/{collect-DSTE3ml9.js → collect-CrZJJnEF.js} +79 -17
- package/dist/collect-DloHbDVa.js +55 -0
- package/dist/collect-FlXJlhus.js +1 -0
- package/dist/command-L8EstFNm.js +6 -0
- package/dist/{compact-y6vfu2_f.js → compact-CAf557ZB.js} +11 -1
- package/dist/compile/index.d.ts +1 -1
- package/dist/compile/index.js +1 -2
- package/dist/{compile-Ba4blROR.js → compile-CMqMx_IN.js} +13 -9
- package/dist/{compile-BkRyBDBw.d.ts → compile-Ckm2HqBZ.d.ts} +5 -3
- package/dist/completion-BqhRBJD8.js +143 -0
- package/dist/config-B46rm9sc.js +26 -0
- package/dist/{config-BJiM-xAm.js → config-bs_kZeYq.js} +15 -10
- package/dist/{config-report-CdCgbRnV.js → config-report-DUgj62Ai.js} +17 -8
- package/dist/{connection-CU8MQxRi.js → connection-DjhIJSs2.js} +8 -6
- package/dist/constants-DcJABbLW.js +20 -0
- package/dist/{context-Bcy85rGi.js → context-bbFHR-0D.js} +16 -5
- package/dist/convex/index.d.ts +11 -10
- package/dist/convex/index.js +18 -18
- package/dist/create-0VZ7Pdiu.d.ts +107 -0
- package/dist/create-BDS7wc3y.d.ts +138 -0
- package/dist/create-BFfm17UA.d.ts +110 -0
- package/dist/create-BWRSXgOF.js +403 -0
- package/dist/create-C3b4B5eF.js +130 -0
- package/dist/create-DnrmLnLW.js +323 -0
- package/dist/{credential-8P81ff4u.js → credential-DM2um3ha.js} +153 -32
- package/dist/credentials-DPyHmYz5.js +74 -0
- package/dist/deciding-columns-Sq12mEA6.js +123 -0
- package/dist/delegation-DaXVdDhM.js +286 -0
- package/dist/delegation-input-FTxMK3ji.js +19 -0
- package/dist/delivery/index.d.ts +5 -0
- package/dist/delivery/index.js +5 -0
- package/dist/delivery-DCAgNyCn.js +499 -0
- package/dist/delivery-event-xX4obB8F.js +40 -0
- package/dist/denied-text-D8jDHgsl.d.ts +19 -0
- package/dist/{describe-DMYiCjJV.d.ts → describe-BZUsixiL.d.ts} +11 -2
- package/dist/{describe-B_gVZEVi.js → describe-BybMRY7A.js} +10 -5
- package/dist/device-BARQ8eYG.js +193 -0
- package/dist/diff-CxZo9pTq.js +37 -0
- package/dist/{diff-2VaeChzX.js → diff-DCePiN6M.js} +61 -55
- package/dist/doctor-CxiwSfqH.js +44 -0
- package/dist/doctor-DFjUxf2f.js +2361 -0
- package/dist/drizzle/index.d.ts +5 -3
- package/dist/drizzle/index.js +6 -14
- package/dist/elysia/index.d.ts +16 -13
- package/dist/elysia/index.js +14 -13
- package/dist/env-DJZrPjRi.js +8 -0
- package/dist/errors-BDUwg2qC.js +27 -0
- package/dist/{errors-Cz5zn0mW.js → errors-BKy0EfhN.js} +6 -10
- package/dist/errors-BWXBMaKK.d.ts +11 -0
- package/dist/{errors-DPdqI50v.d.ts → errors-C2PXIv4c.d.ts} +2 -2
- package/dist/errors-CWTY6uih.js +143 -0
- package/dist/evaluate-_R8QkWaf.js +1732 -0
- package/dist/evaluations-16UqqQ6W.d.ts +32 -0
- package/dist/{evaluations-tz6D9A2G.js → evaluations-DZ2CbV5k.js} +181 -32
- package/dist/eve/index.d.ts +12 -16
- package/dist/eve/index.js +23 -29
- package/dist/event-time-CsegxXxB.js +23 -0
- package/dist/expiry-D4_YKRKD.js +52 -0
- package/dist/express/index.d.ts +13 -10
- package/dist/express/index.js +14 -12
- package/dist/fastify/index.d.ts +26 -13
- package/dist/fastify/index.js +17 -36
- package/dist/fetch-adapter-g0ypLkID.js +120 -0
- package/dist/fetch-adapter-qKJBuRqy.d.ts +31 -0
- package/dist/fields-D9tPVUUS.js +203 -0
- package/dist/files-C5ZI27eV.js +124 -0
- package/dist/{files-DWUN9PzI.js → files-D_JIzUrP.js} +12 -3
- package/dist/{fixtures-ht28Cplr.js → fixtures-B7XrLeY7.js} +12 -12
- package/dist/flags-C3DktBDY.js +86 -0
- package/dist/{from-snapshot-CTLijvdm.js → from-snapshot-D2lZt4Dd.js} +78 -65
- package/dist/{generate-CH7ThJ14.js → generate-CNU1pIOo.js} +1 -3
- package/dist/grantee-Cn1HyUsI.js +254 -0
- package/dist/grantee-conditions-CcXf8hMM.js +271 -0
- package/dist/grantee-match-DHArqY82.js +142 -0
- package/dist/h3/index.d.ts +37 -0
- package/dist/h3/index.js +35 -0
- package/dist/headers-BIjfMaoC.js +8 -0
- package/dist/headers-CIbDTUTK.d.ts +7 -0
- package/dist/{helpers-H_KARDVC.js → helpers-DTcZOzhF.js} +47 -39
- package/dist/hono/index.d.ts +12 -9
- package/dist/hono/index.js +14 -13
- package/dist/{hosted-DJQw46hn.js → hosted-BZIAYvon.js} +64 -15
- package/dist/hosted-DBDX0M-P.d.ts +78 -0
- package/dist/{http-SO7qGttf.js → http-BBoKefeS.js} +35 -2
- package/dist/{http-DPuDaChb.d.ts → http-B_LphE5Y.d.ts} +1 -2
- package/dist/http-DXJ37RDA.js +46 -0
- package/dist/{http-BIhmLfCc.js → http-ujKm0JXE.js} +3 -26
- package/dist/{openapi-import-cmwetbzw.js → import-CNV7I2Dw.js} +30 -26
- package/dist/{rls-import-4lLW_pbb.js → import-DMmOG27O.js} +271 -128
- package/dist/index-BcHcPyIo.d.ts +110 -0
- package/dist/index-CaxrMD1A.d.ts +1 -0
- package/dist/index-Chrs5fQ-.d.ts +210 -0
- package/dist/index-CxGUswIC.d.ts +431 -0
- package/dist/index-D8EovfsP.d.ts +91 -0
- package/dist/index-DOVf7RJv.d.ts +900 -0
- package/dist/{index-C8z4RjQV.d.ts → index-SGdIQyzc.d.ts} +34 -16
- package/dist/index.d.ts +20 -14
- package/dist/index.js +33 -23
- package/dist/init-DeMYsnYJ.js +96 -0
- package/dist/init-GG6Jh8HD.js +95 -0
- package/dist/{instance-options-jARdwfPN.d.ts → instance-options-Omfd793B.d.ts} +2 -2
- package/dist/instance-snapshot-Cxq4ygFc.js +600 -0
- package/dist/instant-BluqDvyS.js +19 -0
- package/dist/internals-Dk8A8fX3.js +17 -0
- package/dist/{json-schema-BsAflUGW.js → json-schema-B-vAwlde.js} +46 -9
- package/dist/json-schema-CN_iLFH2.d.ts +40 -0
- package/dist/jwt/index.d.ts +18 -11
- package/dist/jwt/index.js +166 -145
- package/dist/{kernel-_RRHmnMY.js → kernel-5JatXyND.js} +120 -43
- package/dist/kysely/index.d.ts +5 -3
- package/dist/kysely/index.js +6 -14
- package/dist/{live-options-H2eG8dfJ.js → live-options-BaAFCIiu.js} +16 -3
- package/dist/load-BxEtwlBS.js +1 -0
- package/dist/{load-DhWEB9P1.js → load-DFS_ry0W.js} +31 -7
- package/dist/{load-jose-BL6LH4bL.js → load-jose-DZ4dnjri.js} +1 -4
- package/dist/login-Dfe5RlYk.js +68 -0
- package/dist/login-imhuwvn3.js +254 -0
- package/dist/manifest-C8IzW3Do.js +42 -0
- package/dist/manifest-CtKR3GXL.js +370 -0
- package/dist/manifest-json-CAGzls-C.js +9 -0
- package/dist/{manifest-uDS9y5de.d.ts → manifest-mx5ghSFM.d.ts} +9 -2
- package/dist/manifest-schema-C0kK_8V0.js +399 -0
- package/dist/{map-claims-Z4QZOiFF.js → map-claims-D3oRmTk0.js} +19 -25
- package/dist/{map-error-pgUWNNtG.js → map-error-TCAdw-eW.js} +5 -5
- package/dist/{markers-B0hYLvMD.js → markers-C6joDI3o.js} +8 -3
- package/dist/mcp/index.d.ts +36 -9
- package/dist/mcp/index.js +476 -448
- package/dist/{rls-migrate-CN4TlJTO.js → migrate-BShP7Vf4.js} +119 -20
- package/dist/nest/index.d.ts +12 -9
- package/dist/nest/index.js +20 -20
- package/dist/next/index.d.ts +40 -20
- package/dist/next/index.js +37 -39
- package/dist/next/plugin.d.ts +1 -1
- package/dist/next/plugin.js +2 -2
- package/dist/node/index.d.ts +10 -10
- package/dist/node/index.js +7 -9
- package/dist/ocsf-BFp-PZZA.js +126 -0
- package/dist/openai/index.d.ts +9 -16
- package/dist/openai/index.js +18 -25
- package/dist/openapi/index.d.ts +3 -3
- package/dist/openapi/index.js +1 -1
- package/dist/{openapi-BqRBvAng.js → openapi-B7HZ0f-r.js} +9 -12
- package/dist/{openapi-CqqEbHsL.js → openapi-DTaEmTSW.js} +23 -26
- package/dist/openapi-ahr8rHu3.js +138 -0
- package/dist/operation-findings-DgATDpvh.js +36 -0
- package/dist/operations-CxybZErY.d.ts +59 -0
- package/dist/orpc/index.d.ts +13 -10
- package/dist/orpc/index.js +23 -20
- package/dist/otel/index.d.ts +3 -2
- package/dist/otel/index.js +53 -27
- package/dist/output-Dyw8fyPF.js +88 -0
- package/dist/output-files-B3IZJksC.js +1 -0
- package/dist/output-files-DhRBBE4n.js +138 -0
- package/dist/{rls-fields-CZOMlwHl.js → ownership-BatFgAA4.js} +2393 -2349
- package/dist/{package-root-zGkxO3oN.js → package-root-Dk_ETdkp.js} +4 -3
- package/dist/{parse-BuyjxhZV.js → parse-Cxq0WxrA.js} +3 -3
- package/dist/{parse-local-manifest-BrMozJCo.js → parse-local-manifest-CVSs6J9I.js} +2 -4
- package/dist/{parse-local-manifest-JLnaBH0S.d.ts → parse-local-manifest-DgqtruPn.d.ts} +1 -1
- package/dist/parse-request-D_tUXG6M.js +26 -0
- package/dist/paths-DXSHAMIa.js +42 -0
- package/dist/pdp/index.d.ts +7 -3
- package/dist/pdp/index.js +47 -29
- package/dist/{peer-BOr_kJE1.js → peer-C8swJ2GH.js} +23 -4
- package/dist/peer-CRvC7Hg1.js +1 -0
- package/dist/permdock-B9JxkBwC.js +868 -0
- package/dist/permdock-D7eeLJER.d.ts +502 -0
- package/dist/permission-registry-Br4jSNVi.js +385 -0
- package/dist/permissions-B-wCP5ZC.js +30 -0
- package/dist/{permissions-RJrovw98.js → permissions-CZKiUfFM.js} +4 -3
- package/dist/{permissions-CDgvKG5L.d.ts → permissions-oEWfl-kl.d.ts} +1 -2
- package/dist/policies-CO1oOymN.js +295 -0
- package/dist/{policy-D2W7uv19.d.ts → policy-Czo4n3Hs.d.ts} +2 -2
- package/dist/policy-DLZJ5gAp.js +399 -0
- package/dist/policy-from-catalog-DzhEc3Y9.js +450 -0
- package/dist/policy-module-CTlwMvLI.js +89 -0
- package/dist/{policy-IB6yxpC3.d.ts → policy-types-DzwivBgR.d.ts} +939 -1057
- package/dist/postgrest-CW0IqC-U.js +566 -0
- package/dist/postgrest-NUqZT-RB.d.ts +100 -0
- package/dist/powersync-BHfuehf3.js +62 -0
- package/dist/powersync-CbzkqL_M.js +1 -0
- package/dist/{powersync-Dv1_aWF7.js → powersync-bYUDLOAO.js} +30 -32
- package/dist/preset/index.d.ts +146 -0
- package/dist/preset/index.js +1 -0
- package/dist/preset-DZdU0tne.js +1768 -0
- package/dist/principal-source-pHneukrx.js +103 -0
- package/dist/prisma/index.d.ts +6 -3
- package/dist/prisma/index.js +6 -12
- package/dist/{problem-B43ztKIm.js → problem-CTviyH5L.js} +18 -27
- package/dist/{problem-details-DX075l9V.d.ts → problem-details-DXlt-vks.d.ts} +1 -1
- package/dist/{problem-details-_AMOYz-w.js → problem-details-cn5X30gZ.js} +45 -42
- package/dist/problem-types-CJ2325SZ.js +5 -0
- package/dist/{prompt-CXYhLfsS.js → prompt-DfZLJLIn.js} +2 -2
- package/dist/protected-DEPHc2xV.d.ts +38 -0
- package/dist/{protected-utU-SLuy.js → protected-DfjY5mLA.js} +37 -8
- package/dist/{provider-CSQd7m92.js → provider-CC-u3bYP.js} +23 -10
- package/dist/{provider-DZY281jO.d.ts → provider-DMVt6yYn.d.ts} +1 -1
- package/dist/{provider-store-Cfbvo4DE.js → provider-store-B5Yrkhzr.js} +6 -3
- package/dist/pull-BgIfno6w.js +256 -0
- package/dist/pull-ByOks_Tg.js +1 -0
- package/dist/push-CmFVBOeI.js +116 -0
- package/dist/pushed-policy-BImMTTIM.d.ts +57 -0
- package/dist/pushed-policy-DobP7AHI.js +238 -0
- package/dist/react/index.d.ts +8 -8
- package/dist/react/index.js +6 -6
- package/dist/react/provider-client.d.ts +1 -1
- package/dist/react/provider-client.js +1 -2
- package/dist/react-native/index.d.ts +24 -63
- package/dist/react-native/index.js +45 -27
- package/dist/{refs-KYb4ntaz.js → refs-K6eWUJ0H.js} +1 -1
- package/dist/relation-walk-3OyDBF0S.js +186 -0
- package/dist/relations-BnCWnJGI.js +163 -0
- package/dist/renames-CGj7YwcQ.js +175 -0
- package/dist/{replay-CYyiLiwO.d.ts → replay-D7iKcLFe.d.ts} +1 -1
- package/dist/{replay-BsOyTrdV.js → replay-Du1tJjG7.js} +2 -1
- package/dist/request-context-BTRTG7OT.js +71 -0
- package/dist/request-context-C2_1lchh.d.ts +5 -0
- package/dist/request-schema-CMreVuFM.js +148 -0
- package/dist/resend/index.d.ts +126 -0
- package/dist/resend/index.js +379 -0
- package/dist/resource-relations-D_jw0BFx.js +243 -0
- package/dist/retire-CALjIQ7e.js +240 -0
- package/dist/{revocations-Cy06JNx2.d.ts → revocations-R07lFBnX.d.ts} +2 -0
- package/dist/rls-BA8an5Ap.js +336 -0
- package/dist/{rls-DiinlRML.js → rls-j6t43t7n.js} +1066 -1068
- package/dist/role-builder-CXbis5-X.js +623 -0
- package/dist/{rpc-DL9Sqvb1.js → rpc-aPyNi6tA.js} +18 -8
- package/dist/run-CNsApDpd.js +1 -0
- package/dist/run-DOfMtc9V.js +723 -0
- package/dist/run-ticket-BjhTOi6u.d.ts +124 -0
- package/dist/run-ticket-sxFI6LAk.js +337 -0
- package/dist/{runtime-CB8gQFvS.js → runtime-D3VTAiCA.js} +1 -1
- package/dist/{saas-DhpJjnBA.js → saas-FO-Q8oj0.js} +18 -15
- package/dist/scan-BOI5tJ-6.js +29 -0
- package/dist/{schema-D3MnDMxa.js → schema-B4v0Qrac.js} +106 -10
- package/dist/schema-yLuBWWsh.js +130 -0
- package/dist/scim/index.d.ts +2 -2
- package/dist/scim/index.js +1 -2
- package/dist/{scim-BFGBBAqR.js → scim-ClHtpyV3.js} +213 -87
- package/dist/{scopes-CVdPrWN-.js → scopes-CUPSjo9q.js} +20 -7
- package/dist/server/index.d.ts +13 -134
- package/dist/server/index.js +13 -10
- package/dist/server-DqCT4CgZ.js +453 -0
- package/dist/{create-B1d519XK.d.ts → server-types-BLd4MnBX.d.ts} +89 -70
- package/dist/{sha256-BO0swRXL.js → sha256-D9ihqmvd.js} +8 -1
- package/dist/shared-CD11nBbC.js +304 -0
- package/dist/{shared-DRu8VFVM.js → shared-DvvFzYXe.js} +8 -9
- package/dist/signals-D3NcTKUS.js +32 -0
- package/dist/sink-BEZSlMS_.js +957 -0
- package/dist/sink-Bj_lDUDq.d.ts +225 -0
- package/dist/{skills-Biq5uup3.js → skills-B6lVdD_W.js} +16 -8
- package/dist/skills-CTYwkl44.js +51 -0
- package/dist/snapshot-CXUWfsNy.js +269 -0
- package/dist/{snapshot-cache-D6jLelln.js → snapshot-cache-DZHQ0KF6.js} +11 -5
- package/dist/{snapshot-cache-B-LxUahj.d.ts → snapshot-cache-DqK9eDmM.d.ts} +7 -5
- package/dist/solid/index.d.ts +24 -25
- package/dist/solid/index.js +41 -11
- package/dist/solid-start/index.d.ts +52 -0
- package/dist/solid-start/index.js +49 -0
- package/dist/{sources-BdOYLw9j.d.ts → sources-BV9j0DeZ.d.ts} +11 -13
- package/dist/{sources-4p0-4xQm.js → sources-vs5dGOsS.js} +147 -142
- package/dist/sql-B5T1IL83.js +55 -0
- package/dist/src-DLfUWFAI.js +1851 -0
- package/dist/ssf/index.d.ts +4 -4
- package/dist/ssf/index.js +440 -106
- package/dist/standard-webhooks-6lg8v_is.d.ts +88 -0
- package/dist/standard-webhooks-DgFFD-Ga.js +228 -0
- package/dist/storage-C2bWdvJH.js +132 -0
- package/dist/{store-DMAWwAJV.js → store-4u_hxjWa.js} +42 -55
- package/dist/{store-options-ByhZq1jV.js → store-options-BqEakYRa.js} +38 -24
- package/dist/{stores-BT2pSCub.js → stores-BbsD4fAR.js} +7 -3
- package/dist/stores-ClZUgeoP.d.ts +30 -0
- package/dist/{stream-D1RXubw2.d.ts → stream-7-xmnf3U.d.ts} +2 -2
- package/dist/{stream-DDpvynSB.js → stream-CvdUS9V1.js} +10 -2
- package/dist/style-DiKWnAvn.js +1 -0
- package/dist/{style-CTm1AXDT.js → style-Uao76wO7.js} +3 -2
- package/dist/subject-BGoM5o8B.js +17 -0
- package/dist/{subject-settings-BwBmCdTR.js → subject-settings-CkEwrw3W.js} +23 -5
- package/dist/supabase/index.d.ts +23 -106
- package/dist/supabase/index.js +5 -4
- package/dist/supabase/middleware.d.ts +13 -6
- package/dist/supabase/middleware.js +16 -11
- package/dist/supabase-9pqI57pW.js +92 -0
- package/dist/{supabase-C0qESOa-.js → supabase-Dxlp8A94.js} +124 -247
- package/dist/supabase-hook-CXcGpHqq.js +1 -0
- package/dist/{supabase-hook-ntETYCQu.js → supabase-hook-DdDQhMlw.js} +1659 -819
- package/dist/{supautils-u8jXTGrt.js → supautils-B6HYL9eL.js} +2 -2
- package/dist/support-session-C6xJQYut.js +158 -0
- package/dist/svelte/Protected.svelte +11 -4
- package/dist/svelte/index.d.ts +7 -7
- package/dist/svelte/index.js +26 -12
- package/dist/svelte/runtime.d.ts +10 -3
- package/dist/svelte/runtime.js +4 -3
- package/dist/svelte/source.d.ts +7 -7
- package/dist/svelte/source.js +5 -5
- package/dist/sveltekit/index.d.ts +51 -0
- package/dist/sveltekit/index.js +27 -0
- package/dist/tanstack-start/index.d.ts +51 -0
- package/dist/tanstack-start/index.js +40 -0
- package/dist/target-Bw37TFNA.js +51 -0
- package/dist/{validity-uOp8UpYn.js → tenancy-DN2QAx9z.js} +6 -42
- package/dist/terminal/index.d.ts +11 -9
- package/dist/terminal/index.js +36 -316
- package/dist/testing/index.d.ts +391 -106
- package/dist/testing/index.js +4574 -1286
- package/dist/testing/saas/index.d.ts +9 -8
- package/dist/testing/saas/index.js +2 -2
- package/dist/testing/saas/permissions.d.ts +1 -1
- package/dist/testing/saas/permissions.js +1 -2
- package/dist/text-BMFRfBqc.js +8 -0
- package/dist/{text-diff-BdkkYSbi.js → text-diff-BQcUapOb.js} +1 -1
- package/dist/thenable-yD7SabqG.js +31 -0
- package/dist/time-DkWjdL98.js +71 -0
- package/dist/{token-CIWxJOCh.js → token-BLZcjQgt.js} +15 -6
- package/dist/tool-refusal-CMLN5xhn.js +41 -0
- package/dist/trpc/index.d.ts +12 -9
- package/dist/trpc/index.js +18 -17
- package/dist/{types-DbjRN55U.d.ts → types-5Ut_pbKy.d.ts} +141 -13
- package/dist/types-BNORmDM8.d.ts +172 -0
- package/dist/types-BjV-fqcA.d.ts +57 -0
- package/dist/{types-QCuYJh4q.d.ts → types-BqAKcRNW.d.ts} +2 -1
- package/dist/types-C8-KNsFf.d.ts +107 -0
- package/dist/{types-aqOHTHcp.d.ts → types-CCpc2Dcv.d.ts} +77 -17
- package/dist/{types-Dnemwd77.d.ts → types-CZTnPFzM.d.ts} +50 -11
- package/dist/{types-C97IskBw.d.ts → types-CdlxsYQh.d.ts} +23 -11
- package/dist/{types-DxiCGmmd.d.ts → types-ClZaWzfb.d.ts} +9 -6
- package/dist/types-DX0LaKTb.d.ts +157 -0
- package/dist/types-DiQjupG2.d.ts +332 -0
- package/dist/types-DqzsnlSA.js +67 -0
- package/dist/types-_sHy8BQf.d.ts +65 -0
- package/dist/types-fbZ7JEXr.d.ts +89 -0
- package/dist/types-tIgIeIKJ.d.ts +66 -0
- package/dist/unplugin/index.d.ts +1 -1
- package/dist/unplugin/index.js +2 -2
- package/dist/usage-CHOtwL19.js +1 -0
- package/dist/usage-DHtE8j4E.js +32 -0
- package/dist/{usage-ItS7nLcl.js → usage-T5mXq98v.js} +56 -13
- package/dist/{validation-error-Ba264OaJ.d.ts → validation-error-Bg5Tpluk.d.ts} +1 -1
- package/dist/{validation-error-CnjZ4t92.js → validation-error-ZfUHbKvf.js} +1 -1
- package/dist/validity-CZ7-y0Cj.js +79 -0
- package/dist/{verifier-BxBo6I44.js → verifier-DHuh-okk.js} +68 -21
- package/dist/verify-DbCL9bnB.js +1770 -0
- package/dist/{version-BvacVTZR.js → version-CEbQ5UeM.js} +4 -3
- package/dist/version-DXkYUISx.js +1 -0
- package/dist/{views-Soe7bGSC.js → views-DTIIO-oQ.js} +41 -2
- package/dist/{permissions-DttnqqxC.d.ts → vocabulary-D6lPdb4Z.d.ts} +120 -29
- package/dist/vue/index.d.ts +25 -21
- package/dist/vue/index.js +25 -9
- package/dist/waiter-BGUtGYoU.js +67 -0
- package/dist/waiter-CKy4MQSY.d.ts +36 -0
- package/dist/watch-Cb-NcaUw.js +1 -0
- package/dist/{watch-DVBJ9cBq.js → watch-CszoTJMq.js} +47 -8
- package/dist/webmcp/index.d.ts +2 -2
- package/dist/webmcp/index.js +13 -37
- package/dist/where-scope-Dv-WTP-w.js +306 -0
- package/dist/who-can-D0QoYbEb.js +364 -0
- package/dist/wire-delegation-Cv3W2eaM.js +36 -0
- package/dist/wire-denial-C7PaVYEM.js +27 -0
- package/dist/workflow/index.d.ts +5 -0
- package/dist/workflow/index.js +223 -0
- package/dist/workflow-sdk/index.d.ts +56 -0
- package/dist/workflow-sdk/index.js +78 -0
- package/dist/wrap-Bd1Tejur.d.ts +15 -0
- package/dist/wrap-gX61EtpB.js +27 -0
- package/package.json +140 -41
- package/schemas/access-event-v1.json +118 -0
- package/schemas/approval-request-v1.json +34 -1
- package/schemas/approval-signature-v1.json +94 -0
- package/schemas/automation-event-v1.json +113 -0
- package/schemas/capability-v1.json +84 -0
- package/schemas/catalog-event-v1.json +87 -0
- package/schemas/catalog-v1.json +129 -12
- package/schemas/cli-commands-v1.json +164 -0
- package/schemas/cli-result-v1.json +96 -0
- package/schemas/cloud-api-v1.json +5407 -0
- package/schemas/connector-v1.json +408 -0
- package/schemas/credential-event-v1.json +81 -0
- package/schemas/credential-v1.json +134 -0
- package/schemas/decision-event-v1.json +288 -0
- package/schemas/delivery-event-v1.json +117 -0
- package/schemas/directory-event-v1.json +85 -0
- package/schemas/evidence-export-v1.json +54 -0
- package/schemas/membership-event-v1.json +93 -0
- package/schemas/policy-v1.json +68 -0
- package/schemas/pushed-policy-v1.json +236 -0
- package/schemas/run-v1.json +162 -0
- package/schemas/snapshot-v1.json +138 -0
- package/schemas/supabase-claims-v1.json +4 -4
- package/schemas/supabase-manifest-v1.json +5 -1
- package/schemas/support-session-v1.json +111 -0
- package/skills/permdock/SKILL.md +16 -12
- package/skills/permdock-agents/SKILL.md +40 -23
- package/skills/permdock-agents/references/operations-automations-chat.md +221 -0
- package/skills/permdock-agents/references/runtimes.md +22 -18
- package/skills/permdock-approvals/SKILL.md +29 -13
- package/skills/permdock-approvals/references/delivery-and-waiters.md +172 -0
- package/skills/permdock-approvals/references/stores-and-resume.md +21 -7
- package/skills/permdock-audit/SKILL.md +3 -3
- package/skills/permdock-credentials/SKILL.md +5 -5
- package/skills/permdock-credentials/references/tokens-keys-links.md +19 -10
- package/skills/permdock-data/SKILL.md +6 -6
- package/skills/permdock-data/references/queries-and-rls.md +13 -8
- package/skills/permdock-data/references/relationships.md +3 -3
- package/skills/permdock-tenancy/SKILL.md +20 -7
- package/skills/permdock-tenancy/references/contacts-and-support-sessions.md +122 -0
- package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +6 -4
- package/skills/permdock-wire/SKILL.md +18 -12
- package/skills/permdock-wire/references/adapters.md +146 -50
- package/skills/permdock-wire/references/policy-options.md +15 -6
- package/skills/permdock-wire/references/presets-and-recipes.md +58 -0
- package/dist/approvals-DbYAuNkG.js +0 -224
- package/dist/arazzo-C9chp3ST.js +0 -54
- package/dist/bind-C_ob3LKa.d.ts +0 -25
- package/dist/catalog-CRW5Mx_B.js +0 -2
- package/dist/catalog-CYj-mX2O.js +0 -48
- package/dist/catalog-DWF5cYqB.js +0 -55
- package/dist/cloud-CfwZ39Rl.js +0 -50
- package/dist/cloud-DWLXO2af.js +0 -118
- package/dist/collect-CHB22Sx4.js +0 -83
- package/dist/collect-DSWOk19R.js +0 -2
- package/dist/commands-BeaGNj_o.js +0 -97
- package/dist/config-ir40y8m0.js +0 -36
- package/dist/create-CMwIl-w2.js +0 -253
- package/dist/delegation-D8pUEuce.js +0 -103
- package/dist/diff-C9RDjjhs.js +0 -48
- package/dist/doctor-Djh6JDEk.js +0 -53
- package/dist/doctor-DvH0IQff.js +0 -1194
- package/dist/doctor-collect-CjdO0wQX.js +0 -665
- package/dist/duration-xK2jGFVc.js +0 -25
- package/dist/errors-CWfoNg9N.js +0 -47
- package/dist/errors-DRnGZHzF.js +0 -30
- package/dist/evaluation-items-CfCCejQd.js +0 -40
- package/dist/expiry-BoBJ8Tvc.js +0 -7
- package/dist/fields-BMl1RxoX.js +0 -26
- package/dist/grantee-Cv5Sogfv.js +0 -602
- package/dist/headers-BZSDieNT.js +0 -6
- package/dist/headers-Cf-zWvsO.d.ts +0 -6
- package/dist/ids-DC4V4CFu.js +0 -18
- package/dist/index-0nNqHspG.d.ts +0 -6
- package/dist/index-BNc7Wer_.d.ts +0 -483
- package/dist/index-C7Tw7MaW.d.ts +0 -201
- package/dist/instance-options-Dhhcnm3y.js +0 -22
- package/dist/load-DwJUafYN.js +0 -2
- package/dist/manifest-iaoBXHKc.js +0 -783
- package/dist/map-AcxK9o_D.js +0 -147
- package/dist/may-use-DndQ5tQ-.js +0 -77
- package/dist/openapi-CQTsgKoB.js +0 -164
- package/dist/permdock-CbgvLnuo.js +0 -3479
- package/dist/permissions-DVoF6dMt.js +0 -662
- package/dist/policy-xyWg7QdR.js +0 -1122
- package/dist/powersync-CrBX8hY6.js +0 -2
- package/dist/powersync-DE19b9dT.js +0 -62
- package/dist/protected-BdnIEFkY.d.ts +0 -27
- package/dist/protected-xAr24FEC.d.ts +0 -40
- package/dist/relations-cjOL01hZ.js +0 -505
- package/dist/rls-P-BeBx0n.js +0 -238
- package/dist/rls-graph-CHwx0V8q.js +0 -435
- package/dist/rls-ownership-DcYlZ09B.js +0 -843
- package/dist/rls-shared-Cewd_-zI.js +0 -495
- package/dist/rls-verify-CMv9H3wV.js +0 -746
- package/dist/run-BWc9qqvE.js +0 -174
- package/dist/server-DlZ28Oz5.js +0 -268
- package/dist/sink-B1fZzIMf.d.ts +0 -104
- package/dist/sink-Bd9PSyfq.js +0 -141
- package/dist/skills-C5CbhhpG.js +0 -56
- package/dist/snapshot-Enxd0n-s.js +0 -975
- package/dist/sql-xdg5f4Fi.js +0 -27
- package/dist/src-BDwEZWrZ.js +0 -568
- package/dist/store-jOBYpiXC.d.ts +0 -36
- package/dist/stores-C0IMQrGI.d.ts +0 -52
- package/dist/subject-C6dLxqHU.js +0 -24
- package/dist/supabase-config-Bj3iDH2I.js +0 -472
- package/dist/supabase-hook-gd3s8bxB.js +0 -2
- package/dist/supabase-s4o9s1O1.js +0 -83
- package/dist/thenable-okc2xDsj.js +0 -10
- package/dist/types-BnIEz1sU.d.ts +0 -48
- package/dist/types-Bvc4oWT6.d.ts +0 -9
- package/dist/types-CKNSyIf_.d.ts +0 -52
- package/dist/types-Cq_H8N_X.d.ts +0 -197
- package/dist/usage-B0YuTQRO.js +0 -42
- package/dist/usage-DorAJiU_.js +0 -2
- package/dist/vocabulary-DA8a8zif.d.ts +0 -56
- package/dist/watch-BwxLTYMZ.js +0 -2
|
@@ -1,129 +1,90 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { a as
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
1
|
+
import { r as isRecord } from "./compact-CAf557ZB.js";
|
|
2
|
+
import { a as hasConditionOp } from "./refs-K6eWUJ0H.js";
|
|
3
|
+
import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-CUPSjo9q.js";
|
|
4
|
+
import { t as APPROVAL_REQUEST_SCHEMA } from "./request-schema-CMreVuFM.js";
|
|
5
|
+
import { a as describeError, i as configError } from "./errors-CWTY6uih.js";
|
|
6
|
+
import { i as readJsonFile, o as sqlFiles, s as writeFileEnsuringDir } from "./files-D_JIzUrP.js";
|
|
7
|
+
import { a as SUPABASE_MANIFEST_SCHEMA } from "./version-CEbQ5UeM.js";
|
|
8
|
+
import { c as requirePolicy } from "./load-DFS_ry0W.js";
|
|
9
|
+
import { r as scopeColumn } from "./compile-CMqMx_IN.js";
|
|
10
|
+
import { t as authorizeSql } from "./supabase-Dxlp8A94.js";
|
|
9
11
|
import "./budget-NbEtbJC_.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
12
|
+
import { i as qualifySqlTable } from "./sql-B5T1IL83.js";
|
|
13
|
+
import { a as hookApiNames, d as keptKeys, t as AUTHZ_VERSION_TABLE, u as roleManifest } from "./sources-vs5dGOsS.js";
|
|
14
|
+
import { o as readMemberships } from "./postgrest-CW0IqC-U.js";
|
|
15
|
+
import { t as beforeFirst } from "./text-BMFRfBqc.js";
|
|
16
|
+
import { n as GRANTS_MARKER, r as HOOK_MARKER, s as hookMarkerFields } from "./markers-C6joDI3o.js";
|
|
17
|
+
import { n as formatManifestJson, t as MANIFEST_FILE } from "./manifest-json-CAGzls-C.js";
|
|
18
|
+
import { n as MIGRATION_DIRS } from "./constants-DcJABbLW.js";
|
|
19
|
+
import { f as writeOutputFiles, l as partPath, r as driftOf, u as pgDeltaPath } from "./output-files-DhRBBE4n.js";
|
|
20
|
+
import { E as scopeTypeOf, F as quoteQualified, I as quoteTable, M as qualified, N as quoteIdent, O as tenantTypeOf, P as quoteLiteral, a as customRolesOn, l as USER_ID_HELPER, r as HELPERS, y as subjectIdSql } from "./shared-CD11nBbC.js";
|
|
21
|
+
import { a as splitUndiffed, r as tableKey, t as decidingColumns } from "./deciding-columns-Sq12mEA6.js";
|
|
22
|
+
import { a as readOnlyActorKinds, f as pgConnector, t as ACTOR_MAY_WRITE } from "./policies-CO1oOymN.js";
|
|
23
|
+
import { $ as checkSuspension, A as roleNames, C as API_KEY_ALLOWS, G as memberRoleOf, H as memberForSources, K as permittedForHelper, R as hasMemberFor, T as apiKeyFields, U as memberForTable, V as memberForHelper, W as memberIdsHelper, X as scopeTable, Z as activeRowSql, a as guardedTables, b as globalRoleSource, c as PERMISSION_HELPERS, d as permissionKeysForHelper, f as permittedByPermissionHelper, l as checkName, o as ASSIGNMENTS, q as permittedIdsHelper, r as holderSources, s as OWNERSHIP, t as ownershipRules, y as globalRoleRows, z as hasPermittedUsers } from "./ownership-BatFgAA4.js";
|
|
24
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
25
|
+
import { dirname, join, posix, resolve } from "node:path";
|
|
26
|
+
import { parse } from "smol-toml";
|
|
27
|
+
//#region src/cli/supabase-config.ts
|
|
28
|
+
const SUPABASE_CONFIG = "supabase/config.toml";
|
|
29
|
+
function table(value) {
|
|
30
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function strings(value) {
|
|
34
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
|
|
35
|
+
}
|
|
36
|
+
/** `supabase/config.toml`, parsed; `undefined` when the project has none. */
|
|
37
|
+
function readSupabaseConfig(cwd) {
|
|
38
|
+
const file = join(cwd, SUPABASE_CONFIG);
|
|
39
|
+
if (!existsSync(file)) return;
|
|
40
|
+
let root;
|
|
41
|
+
try {
|
|
42
|
+
root = parse(readFileSync(file, "utf8"));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
error: describeError(error)
|
|
47
|
+
};
|
|
46
48
|
}
|
|
49
|
+
const expiry = table(root["auth"])?.["jwt_expiry"];
|
|
50
|
+
const paths = table(table(root["db"])?.["migrations"])?.["schema_paths"];
|
|
51
|
+
const schemaPaths = strings(paths);
|
|
52
|
+
const apiSchemas = strings(table(root["api"])?.["schemas"]);
|
|
53
|
+
const pgDelta = table(table(root["experimental"])?.["pgdelta"]);
|
|
54
|
+
const schemaPath = pgDelta?.["declarative_schema_path"];
|
|
55
|
+
const enabled = pgDelta?.["enabled"];
|
|
47
56
|
return {
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
ok: true,
|
|
58
|
+
config: {
|
|
59
|
+
...typeof expiry === "number" ? { jwtExpiry: expiry } : {},
|
|
60
|
+
...schemaPaths === void 0 ? {} : { schemaPaths },
|
|
61
|
+
...apiSchemas === void 0 ? {} : { apiSchemas },
|
|
62
|
+
...pgDelta === void 0 ? {} : { pgDelta: {
|
|
63
|
+
...typeof enabled === "boolean" ? { enabled } : {},
|
|
64
|
+
schemaDir: declarativeSchemaDir(schemaPath)
|
|
65
|
+
} }
|
|
66
|
+
}
|
|
50
67
|
};
|
|
51
68
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
* pg-delta's per-schema, unnumbered layout: pg-delta orders statements by
|
|
72
|
-
* their dependencies, so no file needs a number to apply first.
|
|
73
|
-
*/
|
|
74
|
-
function pgDeltaPath(root, part, schema) {
|
|
75
|
-
switch (part) {
|
|
76
|
-
case "helpers":
|
|
77
|
-
case "indexes":
|
|
78
|
-
case "seeds": return `${root}/${schema}/${part}.sql`;
|
|
79
|
-
case "policies": return `${root}/public/policies/permdock.sql`;
|
|
80
|
-
case "hook": return `${root}/${schema}/functions/custom_access_token_hook.sql`;
|
|
81
|
-
default: return part;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
/** Whether `--seeds-out` names a migrations directory: it ends with \`/\` or is an existing directory. */
|
|
85
|
-
function isSeedsDirectory(cwd, out) {
|
|
86
|
-
if (out === "-") return false;
|
|
87
|
-
if (out.endsWith("/")) return true;
|
|
88
|
-
const path = resolve(cwd, out);
|
|
89
|
-
return existsSync(path) && statSync(path).isDirectory();
|
|
90
|
-
}
|
|
91
|
-
/** `YYYYMMDDHHMMSS` in UTC, the Supabase migration version. */
|
|
92
|
-
function migrationVersion(now) {
|
|
93
|
-
return now.toISOString().replaceAll(/[^0-9]/gu, "").slice(0, 14);
|
|
94
|
-
}
|
|
95
|
-
const VERSION_PREFIX = /^\d{14}(?=_)/u;
|
|
96
|
-
/**
|
|
97
|
-
* The version of a new migration that sorts after every migration in
|
|
98
|
-
* `names`: one second past the newest, so the same directory always gives the
|
|
99
|
-
* same name. Only a directory with no versioned migration falls back to `now`.
|
|
100
|
-
*/
|
|
101
|
-
function nextMigrationVersion(names, now) {
|
|
102
|
-
const times = names.flatMap((name) => {
|
|
103
|
-
const stamp = VERSION_PREFIX.exec(name)?.[0];
|
|
104
|
-
return stamp === void 0 ? [] : [Date.UTC(Number(stamp.slice(0, 4)), Number(stamp.slice(4, 6)) - 1, Number(stamp.slice(6, 8)), Number(stamp.slice(8, 10)), Number(stamp.slice(10, 12)), Number(stamp.slice(12, 14)))];
|
|
105
|
-
});
|
|
106
|
-
return times.length === 0 ? migrationVersion(now) : migrationVersion(new Date(Math.max(...times) + 1e3));
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* The seeds part in a migrations directory: the newest migration there that
|
|
110
|
-
* starts with \`marker\` when its text is \`text\` already, otherwise a new
|
|
111
|
-
* \`<version>_permdock_seeds.sql\` one second after the newest migration
|
|
112
|
-
* (\`nextMigrationVersion\`), so a regeneration of the same directory names it
|
|
113
|
-
* the same. \`current\` is false when a new one is due.
|
|
114
|
-
*/
|
|
115
|
-
function seedsMigration(cwd, dir, marker, text, now) {
|
|
116
|
-
const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
|
117
|
-
const path = resolve(cwd, base);
|
|
118
|
-
const latest = existsSync(path) ? readdirSync(path).filter((name) => name.endsWith(".sql")).toSorted().toReversed().find((name) => readFileSync(resolve(path, name), "utf8").startsWith(marker)) : void 0;
|
|
119
|
-
if (latest !== void 0 && readFileSync(resolve(path, latest), "utf8") === text) return {
|
|
120
|
-
rel: `${base}/${latest}`,
|
|
121
|
-
current: true
|
|
122
|
-
};
|
|
123
|
-
return {
|
|
124
|
-
rel: `${base}/${nextMigrationVersion(existsSync(path) ? readdirSync(path) : [], now)}_permdock_seeds.sql`,
|
|
125
|
-
current: false
|
|
126
|
-
};
|
|
69
|
+
/** `supabase/` joined with `declarative_schema_path`, which defaults to `schemas`. */
|
|
70
|
+
function declarativeSchemaDir(schemaPath) {
|
|
71
|
+
return posix.join("supabase", typeof schemaPath === "string" ? schemaPath : "schemas").replace(/\/$/u, "");
|
|
72
|
+
}
|
|
73
|
+
/** The parsed config, or `{}` when it is missing or does not parse (PD045 reports that). */
|
|
74
|
+
function supabaseConfig(cwd) {
|
|
75
|
+
const read = readSupabaseConfig(cwd);
|
|
76
|
+
return read?.ok === true ? read.config : {};
|
|
77
|
+
}
|
|
78
|
+
/** PD045: a `supabase/config.toml` the CLI cannot parse, so every check that reads it falls back to defaults. */
|
|
79
|
+
function pd045(cwd) {
|
|
80
|
+
const read = readSupabaseConfig(cwd);
|
|
81
|
+
if (read === void 0 || read.ok) return [];
|
|
82
|
+
return [{
|
|
83
|
+
code: "PD045",
|
|
84
|
+
severity: "warning",
|
|
85
|
+
message: `${SUPABASE_CONFIG} does not parse: ${read.error.replace(/\n[\s\S]*/u, "")}`,
|
|
86
|
+
fix: `fix the TOML syntax in ${SUPABASE_CONFIG}; until then PermDock assumes Supabase's defaults for jwt_expiry, schema_paths and [experimental.pgdelta], and generates for pg-delta`
|
|
87
|
+
}];
|
|
127
88
|
}
|
|
128
89
|
//#endregion
|
|
129
90
|
//#region src/cli/claims-schema.ts
|
|
@@ -206,11 +167,11 @@ const PERMDOCK_CLAIMS_SCHEMA = {
|
|
|
206
167
|
items: { type: "string" }
|
|
207
168
|
},
|
|
208
169
|
via: { type: "string" },
|
|
209
|
-
|
|
210
|
-
description: "
|
|
170
|
+
expires_at: {
|
|
171
|
+
description: "NumericDate (RFC 7519): seconds since the epoch.",
|
|
211
172
|
type: "number"
|
|
212
173
|
},
|
|
213
|
-
|
|
174
|
+
granted_by: { type: "string" },
|
|
214
175
|
reason: { type: "string" },
|
|
215
176
|
member: {
|
|
216
177
|
description: "The subgroup a membership source's group fills.",
|
|
@@ -221,7 +182,7 @@ const PERMDOCK_CLAIMS_SCHEMA = {
|
|
|
221
182
|
minLength: 1
|
|
222
183
|
} }
|
|
223
184
|
},
|
|
224
|
-
|
|
185
|
+
managed_by: { const: "idp" },
|
|
225
186
|
entitlements: {
|
|
226
187
|
type: "array",
|
|
227
188
|
items: { type: "string" }
|
|
@@ -249,92 +210,7 @@ const PERMDOCK_CLAIMS_SCHEMA = {
|
|
|
249
210
|
}
|
|
250
211
|
};
|
|
251
212
|
//#endregion
|
|
252
|
-
//#region src/cli/
|
|
253
|
-
/**
|
|
254
|
-
* Where the helpers read roles and memberships: the flag, `rls.authorize`,
|
|
255
|
-
* `rls.rbac.authorize`, then `database` with `rls.membershipSources`, with
|
|
256
|
-
* `--rbac` or with a mapped memberships table, else `jwt`.
|
|
257
|
-
*/
|
|
258
|
-
function resolveAuthorize(config, flags = {}) {
|
|
259
|
-
const rls = config.rls;
|
|
260
|
-
const explicit = flags.authorize ?? rls?.authorize ?? rls?.rbac?.authorize;
|
|
261
|
-
if (explicit !== void 0) return explicit;
|
|
262
|
-
if (rls?.membershipSources !== void 0 || flags.rbac === true) return "database";
|
|
263
|
-
const memberships = flags.memberships ?? rls?.memberships;
|
|
264
|
-
return memberships?.tenant !== void 0 || memberships?.team !== void 0 || Object.keys(memberships?.scopes ?? {}).length > 0 ? "database" : "jwt";
|
|
265
|
-
}
|
|
266
|
-
function parseRbacAuthorize(raw) {
|
|
267
|
-
if (raw === void 0) return;
|
|
268
|
-
if (raw === "database" || raw === "jwt") return raw;
|
|
269
|
-
throw new Error(`PermDock CLI: --authorize must be database or jwt, got '${raw}'`);
|
|
270
|
-
}
|
|
271
|
-
function hookUri(schema) {
|
|
272
|
-
return `pg-functions://postgres/${schema}/custom_access_token_hook`;
|
|
273
|
-
}
|
|
274
|
-
function createEnum(schema, name, values) {
|
|
275
|
-
const list = values.map((value) => quoteLiteral(value)).join(", ");
|
|
276
|
-
return `do $$ begin
|
|
277
|
-
create type ${quoteTable(`${schema}.${name}`)} as enum (${list});
|
|
278
|
-
exception when duplicate_object then null;
|
|
279
|
-
end $$;`;
|
|
280
|
-
}
|
|
281
|
-
function hookTable(name) {
|
|
282
|
-
return quoteTable(name.includes(".") ? name : `public.${name}`);
|
|
283
|
-
}
|
|
284
|
-
/** A read policy and `select` grant for `supabase_auth_admin` on a table the hook reads. */
|
|
285
|
-
function authAdminRead(table, label) {
|
|
286
|
-
const t = hookTable(table);
|
|
287
|
-
const policy = quoteIdent(`permdock_auth_admin_read_${label}`);
|
|
288
|
-
return `grant select on table ${t} to supabase_auth_admin;
|
|
289
|
-
drop policy if exists ${policy} on ${t};
|
|
290
|
-
create policy ${policy} on ${t}
|
|
291
|
-
as permissive for select
|
|
292
|
-
to supabase_auth_admin
|
|
293
|
-
using (true);`;
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* Supabase's Custom Claims and RBAC scaffold on top of the PermDock helpers:
|
|
297
|
-
* enums, `user_roles` and `authorize()` over the shared `role_permissions`.
|
|
298
|
-
* The token hook comes only from `permdock supabase hook generate`, so one
|
|
299
|
-
* function writes the claims. Policies never call `authorize()` per row; they
|
|
300
|
-
* call the helpers. Never grants anything to `service_role`.
|
|
301
|
-
*/
|
|
302
|
-
function rbacScaffold(policy, options) {
|
|
303
|
-
const schema = options.schema;
|
|
304
|
-
const q = (name) => quoteTable(`${schema}.${name}`);
|
|
305
|
-
const s = quoteIdent(schema);
|
|
306
|
-
const permissions = [...new Set(policy.grants.map((grant) => grant.permission.key))];
|
|
307
|
-
const authorizeFn = authorizeSql({
|
|
308
|
-
schema,
|
|
309
|
-
authorize: options.authorize,
|
|
310
|
-
...options.scope === void 0 ? {} : { scope: options.scope },
|
|
311
|
-
...options.memberships === void 0 ? {} : { tenant: options.memberships },
|
|
312
|
-
...options.customRoles === void 0 ? {} : { customRoles: options.customRoles },
|
|
313
|
-
...options.context?.suspension === void 0 ? {} : { suspension: options.context.suspension }
|
|
314
|
-
});
|
|
315
|
-
return {
|
|
316
|
-
head: `-- rbac scaffold (Supabase Custom Claims and RBAC)
|
|
317
|
-
-- authorize: ${options.authorize}${options.authorize === "jwt" ? " (reads the hook claims; stale until the token refreshes)" : " (reads user_roles on every statement)"}
|
|
318
|
-
-- the custom access token hook that writes user_role${options.authorize === "jwt" ? " and memberships" : ""}: permdock supabase hook generate
|
|
319
|
-
${schema === "public" ? "" : `create schema if not exists ${s};\n`}${createEnum(schema, "app_role", roleNames(policy))}
|
|
320
|
-
${createEnum(schema, "app_permission", permissions)}
|
|
321
|
-
|
|
322
|
-
create table if not exists ${q("user_roles")} (
|
|
323
|
-
user_id uuid not null references auth.users on delete cascade,
|
|
324
|
-
role ${options.customRoles === void 0 ? q("app_role") : "text"} not null,
|
|
325
|
-
primary key (user_id, role)
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
alter table ${q("user_roles")} enable row level security;
|
|
329
|
-
revoke all on table ${q("user_roles")} from authenticated, anon, public;
|
|
330
|
-
`,
|
|
331
|
-
tail: authorizeFn,
|
|
332
|
-
warnings: []
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
//#endregion
|
|
336
|
-
//#region src/cli/supabase-hook-sql.ts
|
|
337
|
-
const MANAGED_TRIGGER = "permdock_protect_managed";
|
|
213
|
+
//#region src/cli/supabase-hook/claims.ts
|
|
338
214
|
const CLAIM_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
339
215
|
const PROTOTYPE_KEYS = /* @__PURE__ */ new Set([
|
|
340
216
|
"__proto__",
|
|
@@ -444,7 +320,7 @@ function extraClaimsPlan(claims, tenantClaim) {
|
|
|
444
320
|
function beforePlan(before) {
|
|
445
321
|
const list = before === void 0 ? [] : typeof before === "string" ? [before] : before;
|
|
446
322
|
const bad = list.filter((fn) => typeof fn !== "string" || !CLAIM_FUNCTION.test(fn));
|
|
447
|
-
if (bad.length > 0) throw
|
|
323
|
+
if (bad.length > 0) throw configError(`PermDock CLI: supabase.hook.before must name schema-qualified functions such as auth_checks.before_token (got ${bad.map(String).join(", ")})`);
|
|
448
324
|
return [...new Set(list)];
|
|
449
325
|
}
|
|
450
326
|
function beforeSql(before) {
|
|
@@ -485,128 +361,1432 @@ create extension if not exists pg_jsonschema with schema extensions;
|
|
|
485
361
|
grant usage on schema extensions to supabase_auth_admin;
|
|
486
362
|
grant execute on function extensions.jsonb_matches_schema(json, jsonb) to supabase_auth_admin;` : "";
|
|
487
363
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
364
|
+
/**
|
|
365
|
+
* Fails the migration when a client role can write an `attrs` column: a
|
|
366
|
+
* claim the user can set is not a server-owned attribute.
|
|
367
|
+
*/
|
|
368
|
+
function attrsGuardSql(plan) {
|
|
369
|
+
if (plan?.table === void 0 || plan.columns.length === 0) return "";
|
|
370
|
+
const target = quoteLiteral(quoteQualified(plan.table));
|
|
371
|
+
return `-- attrs must be server-owned: refuse columns anon or authenticated can insert or update
|
|
372
|
+
do $$
|
|
373
|
+
begin
|
|
374
|
+
if exists (
|
|
375
|
+
select 1
|
|
376
|
+
from (values ${plan.columns.map((column) => `(${quoteLiteral(column)})`).join(", ")}) c(name)
|
|
377
|
+
cross join (values ('anon'), ('authenticated')) r(role)
|
|
378
|
+
where has_column_privilege(r.role, ${target}, c.name, 'INSERT')
|
|
379
|
+
or has_column_privilege(r.role, ${target}, c.name, 'UPDATE')
|
|
380
|
+
) then
|
|
381
|
+
raise exception 'PermDock: an attrs column of % is writable by anon or authenticated; revoke insert and update on it before it becomes a claim', ${target}
|
|
382
|
+
using errcode = '42501';
|
|
383
|
+
end if;
|
|
384
|
+
end
|
|
385
|
+
$$;`;
|
|
386
|
+
}
|
|
387
|
+
function permdockClaim(name, budget = false) {
|
|
501
388
|
return {
|
|
502
|
-
|
|
503
|
-
|
|
389
|
+
name,
|
|
390
|
+
source: "permdock",
|
|
391
|
+
budget
|
|
504
392
|
};
|
|
505
393
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
...typeof mapped.via === "string" ? [mapped.via] : [],
|
|
521
|
-
...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
|
|
522
|
-
...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
|
|
394
|
+
function hookClaims(parts) {
|
|
395
|
+
return [
|
|
396
|
+
permdockClaim("user_role"),
|
|
397
|
+
permdockClaim("roles"),
|
|
398
|
+
permdockClaim("memberships", true),
|
|
399
|
+
permdockClaim("memberships_truncated"),
|
|
400
|
+
permdockClaim(parts.tenantClaim),
|
|
401
|
+
...parts.attrs === void 0 ? [] : [permdockClaim("attrs", true)],
|
|
402
|
+
...parts.version ? [permdockClaim("authz_ver")] : [],
|
|
403
|
+
...parts.extra.map((entry) => ({
|
|
404
|
+
name: entry.claim,
|
|
405
|
+
source: entry.fn,
|
|
406
|
+
budget: false
|
|
407
|
+
}))
|
|
523
408
|
];
|
|
524
|
-
return {
|
|
525
|
-
table: mapped.table.includes(".") ? mapped.table : `public.${mapped.table}`,
|
|
526
|
-
user: { column: mapped.user },
|
|
527
|
-
scope: { value: name },
|
|
528
|
-
id: { column: id },
|
|
529
|
-
role: roleManifest(role),
|
|
530
|
-
...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
|
|
531
|
-
...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
|
|
532
|
-
...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
|
|
533
|
-
...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
|
|
534
|
-
columns: [...new Set(columns)]
|
|
535
|
-
};
|
|
536
409
|
}
|
|
537
|
-
|
|
538
|
-
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/cli/supabase-schemas.ts
|
|
412
|
+
const MODES = [
|
|
413
|
+
"pg-delta",
|
|
414
|
+
"migra",
|
|
415
|
+
"migrations"
|
|
416
|
+
];
|
|
417
|
+
function isSupabaseSchemas(value) {
|
|
418
|
+
return MODES.some((mode) => mode === value);
|
|
539
419
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
return number;
|
|
420
|
+
/** The `--schemas` refusal, or `undefined` when the value is one of the modes. */
|
|
421
|
+
function schemasRefusal(command, value) {
|
|
422
|
+
return value === void 0 || isSupabaseSchemas(value) ? void 0 : `${command} --schemas must be ${MODES.join(", ")} (got ${value})`;
|
|
544
423
|
}
|
|
545
424
|
/**
|
|
546
|
-
*
|
|
547
|
-
*
|
|
425
|
+
* `--schemas`, else `supabase.schemas`, else `migra` when `config.toml`
|
|
426
|
+
* sets `[experimental.pgdelta] enabled = false`, else `pg-delta`: the default
|
|
427
|
+
* of new Supabase projects and of the next Supabase CLI major.
|
|
548
428
|
*/
|
|
549
|
-
function
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const parsed = typeof spec === "string" ? (() => {
|
|
557
|
-
const dot = spec.lastIndexOf(".");
|
|
558
|
-
if (dot <= 0) throw new Error(`PermDock CLI: --active-from must be app_metadata.<key> or <table>.<column>, got '${spec}'`);
|
|
559
|
-
return {
|
|
560
|
-
table: spec.slice(0, dot),
|
|
561
|
-
column: spec.slice(dot + 1)
|
|
429
|
+
function resolveSchemas(cwd, config, flag) {
|
|
430
|
+
const toml = supabaseConfig(cwd).pgDelta;
|
|
431
|
+
const mode = flag ?? config.supabase?.schemas ?? (toml?.enabled === false ? "migra" : "pg-delta");
|
|
432
|
+
switch (mode) {
|
|
433
|
+
case "pg-delta": return {
|
|
434
|
+
mode,
|
|
435
|
+
schemaDir: toml?.schemaDir ?? declarativeSchemaDir()
|
|
562
436
|
};
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
userType: `${table(parsed.table)}.${quoteIdent(id)}%type`
|
|
568
|
-
};
|
|
437
|
+
case "migra":
|
|
438
|
+
case "migrations": return { mode };
|
|
439
|
+
default: return mode;
|
|
440
|
+
}
|
|
569
441
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
function typedUsers(parts) {
|
|
576
|
-
return [
|
|
577
|
-
...parts.sources.map((source, index) => `v_user_${String(index)} ${source.sql.userType} := uid;`),
|
|
578
|
-
...parts.roles === void 0 ? [] : [`v_roles_user ${table(parts.roles.table)}.${quoteIdent(parts.roles.user)}%type := uid;`],
|
|
579
|
-
...parts.attrs?.table === void 0 || parts.attrs.columns.length === 0 ? [] : [`v_attrs_user ${table(parts.attrs.table)}.${quoteIdent(parts.attrs.id)}%type := uid;`],
|
|
580
|
-
...parts.active.userType === void 0 ? [] : [`v_active_user ${parts.active.userType} := uid;`]
|
|
581
|
-
].map((line) => `\n ${line}`).join("");
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/cli/supabase-setup.ts
|
|
444
|
+
const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
|
|
445
|
+
function escape(text) {
|
|
446
|
+
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
582
447
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const holds = new Set(source.sql.holds ?? [scope]);
|
|
592
|
-
const missing = chain.filter((ancestor) => !holds.has(ancestor));
|
|
593
|
-
if (missing.length > 0) throw new Error(`PermDock CLI: the ${source.sql.table} source needs within columns for ${missing.join(", ")}: a ${scope} membership without every ancestor grants nothing`);
|
|
448
|
+
/** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
|
|
449
|
+
function helpersDefined(sql, schema) {
|
|
450
|
+
const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
|
|
451
|
+
const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
|
|
452
|
+
const names = /* @__PURE__ */ new Set();
|
|
453
|
+
for (const match of sql.matchAll(create)) {
|
|
454
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
455
|
+
if (HELPER_NAME.test(name)) names.add(name);
|
|
594
456
|
}
|
|
457
|
+
return names;
|
|
595
458
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
)
|
|
459
|
+
/**
|
|
460
|
+
* SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`;
|
|
461
|
+
* its `helpers` part when it has `{part}`), the SQL files next to the hook,
|
|
462
|
+
* and the migration folders, not the hook itself.
|
|
463
|
+
*/
|
|
464
|
+
function helperSources(cwd, config, hookOut) {
|
|
465
|
+
const hook = resolve(cwd, hookOut);
|
|
466
|
+
const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
|
|
467
|
+
const rlsOut = resolve(cwd, partPath(config.rls?.out ?? "rls.sql", "helpers"));
|
|
468
|
+
if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
|
|
469
|
+
const beside = dirname(hook);
|
|
470
|
+
if (existsSync(beside)) {
|
|
471
|
+
for (const entry of readdirSync(beside, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".sql")) files.add(join(beside, entry.name));
|
|
472
|
+
}
|
|
473
|
+
files.delete(hook);
|
|
474
|
+
return [...files];
|
|
475
|
+
}
|
|
476
|
+
function missingHelpersInFiles(cwd, config, manifest) {
|
|
477
|
+
const found = /* @__PURE__ */ new Set();
|
|
478
|
+
for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
|
|
479
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
480
|
+
}
|
|
481
|
+
async function missingHelpersInDb(db, manifest, connect = pgConnector("permdock supabase hook generate --db-url-stdin")) {
|
|
482
|
+
const client = await connect(db);
|
|
483
|
+
try {
|
|
484
|
+
const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
485
|
+
where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
|
|
486
|
+
const found = new Set(result.rows.map((row) => row["proname"]));
|
|
487
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
488
|
+
} finally {
|
|
489
|
+
await client.end();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function missingHelpersMessage(manifest, missing) {
|
|
493
|
+
return `PD039 schema ${manifest.helpers.schema} has no ${missing.join(", ")}: the hook's claims are read by these helpers; run permdock rls generate and apply its migration`;
|
|
494
|
+
}
|
|
495
|
+
/** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
|
|
496
|
+
function oversizedClaims(samples, manifest) {
|
|
497
|
+
const encoder = new TextEncoder();
|
|
498
|
+
const oversized = [];
|
|
499
|
+
for (const claim of manifest.claims) {
|
|
500
|
+
if (claim.source === "permdock") continue;
|
|
501
|
+
let bytes = 0;
|
|
502
|
+
for (const sample of samples) {
|
|
503
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
504
|
+
const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
|
|
505
|
+
if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
|
|
506
|
+
}
|
|
507
|
+
if (bytes > manifest.budget.bytes) oversized.push({
|
|
508
|
+
name: claim.name,
|
|
509
|
+
bytes
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
return oversized;
|
|
513
|
+
}
|
|
514
|
+
/** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
|
|
515
|
+
function droppedMemberships(samples) {
|
|
516
|
+
const out = [];
|
|
517
|
+
for (const [index, sample] of samples.entries()) {
|
|
518
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
519
|
+
const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
|
|
520
|
+
const { dropped } = readMemberships(claim);
|
|
521
|
+
if (dropped.length > 0) out.push({
|
|
522
|
+
sample: index,
|
|
523
|
+
entries: dropped
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
function pd039(input) {
|
|
529
|
+
const findings = [];
|
|
530
|
+
const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
|
|
531
|
+
if (missing.length > 0) findings.push({
|
|
532
|
+
code: "PD039",
|
|
533
|
+
severity: "warning",
|
|
534
|
+
message: missingHelpersMessage(input.manifest, missing).slice(6),
|
|
535
|
+
fix: "run permdock rls generate, or set rls.out to the file that holds the helpers (with {part} for --split output) or write them next to the hook"
|
|
536
|
+
});
|
|
537
|
+
const fixture = input.config.doctor?.claims;
|
|
538
|
+
if (fixture !== void 0) {
|
|
539
|
+
const parsed = readClaimsFixture(resolve(input.cwd, fixture));
|
|
540
|
+
if (!parsed.ok) {
|
|
541
|
+
findings.push({
|
|
542
|
+
code: "PD039",
|
|
543
|
+
severity: "warning",
|
|
544
|
+
message: `doctor.claims ${fixture} is not a JSON file doctor can read (${parsed.message}), so the claim budget went unchecked`,
|
|
545
|
+
fix: "point doctor.claims at a JSON array of sample claims"
|
|
546
|
+
});
|
|
547
|
+
return findings;
|
|
548
|
+
}
|
|
549
|
+
const samples = Array.isArray(parsed.value) ? parsed.value : [];
|
|
550
|
+
for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
|
|
551
|
+
code: "PD039",
|
|
552
|
+
severity: "warning",
|
|
553
|
+
message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
|
|
554
|
+
fix: "return less from the claim function, or read the data per request instead of from the token"
|
|
555
|
+
});
|
|
556
|
+
for (const { sample, entries } of droppedMemberships(samples)) findings.push({
|
|
557
|
+
code: "PD039",
|
|
558
|
+
severity: "warning",
|
|
559
|
+
message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
|
|
560
|
+
fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return findings;
|
|
564
|
+
}
|
|
565
|
+
function readClaimsFixture(path) {
|
|
566
|
+
try {
|
|
567
|
+
return {
|
|
568
|
+
ok: true,
|
|
569
|
+
value: JSON.parse(readFileSync(path, "utf8"))
|
|
570
|
+
};
|
|
571
|
+
} catch (error) {
|
|
572
|
+
return {
|
|
573
|
+
ok: false,
|
|
574
|
+
message: error instanceof Error ? error.message : String(error)
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
//#endregion
|
|
579
|
+
//#region src/cli/rls/rbac.ts
|
|
580
|
+
/**
|
|
581
|
+
* Where the helpers read roles and memberships: the flag, `rls.authorize`,
|
|
582
|
+
* `rls.rbac.authorize`, then `database` with `rls.membershipSources`, with
|
|
583
|
+
* `--rbac` or with a mapped memberships table, else `jwt`.
|
|
584
|
+
*/
|
|
585
|
+
function resolveAuthorize(config, flags = {}) {
|
|
586
|
+
const rls = config.rls;
|
|
587
|
+
const explicit = flags.authorize ?? rls?.authorize ?? rls?.rbac?.authorize;
|
|
588
|
+
if (explicit !== void 0) return explicit;
|
|
589
|
+
if (rls?.membershipSources !== void 0 || flags.rbac === true) return "database";
|
|
590
|
+
const memberships = flags.memberships ?? rls?.memberships;
|
|
591
|
+
return memberships?.tenant !== void 0 || memberships?.team !== void 0 || Object.keys(memberships?.scopes ?? {}).length > 0 ? "database" : "jwt";
|
|
592
|
+
}
|
|
593
|
+
function parseRbacAuthorize(raw) {
|
|
594
|
+
if (raw === void 0) return;
|
|
595
|
+
if (raw === "database" || raw === "jwt") return raw;
|
|
596
|
+
throw configError(`PermDock CLI: --authorize must be database or jwt, got '${raw}'`);
|
|
597
|
+
}
|
|
598
|
+
function hookUri(schema) {
|
|
599
|
+
return `pg-functions://postgres/${schema}/custom_access_token_hook`;
|
|
600
|
+
}
|
|
601
|
+
function createEnum(schema, name, values) {
|
|
602
|
+
const list = values.map((value) => quoteLiteral(value)).join(", ");
|
|
603
|
+
return `do $$ begin
|
|
604
|
+
create type ${quoteTable(`${schema}.${name}`)} as enum (${list});
|
|
605
|
+
exception when duplicate_object then null;
|
|
606
|
+
end $$;`;
|
|
607
|
+
}
|
|
608
|
+
/** A read policy and `select` grant for `supabase_auth_admin` on a table the hook reads. */
|
|
609
|
+
function authAdminRead(table, label) {
|
|
610
|
+
const t = quoteQualified(table);
|
|
611
|
+
const policy = quoteIdent(`permdock_auth_admin_read_${label}`);
|
|
612
|
+
return `grant select on table ${t} to supabase_auth_admin;
|
|
613
|
+
drop policy if exists ${policy} on ${t};
|
|
614
|
+
create policy ${policy} on ${t}
|
|
615
|
+
as permissive for select
|
|
616
|
+
to supabase_auth_admin
|
|
617
|
+
using (true);`;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Supabase's Custom Claims and RBAC scaffold on top of the PermDock helpers:
|
|
621
|
+
* enums, `user_roles` and `authorize()` over the shared `role_permissions`.
|
|
622
|
+
* The token hook comes only from `permdock supabase hook generate`, so one
|
|
623
|
+
* function writes the claims. Policies never call `authorize()` per row; they
|
|
624
|
+
* call the helpers. Never grants anything to `service_role`.
|
|
625
|
+
*/
|
|
626
|
+
function rbacScaffold(policy, options) {
|
|
627
|
+
const schema = options.schema;
|
|
628
|
+
const q = (name) => quoteTable(`${schema}.${name}`);
|
|
629
|
+
const s = quoteIdent(schema);
|
|
630
|
+
const permissions = [...new Set(policy.grants.map((grant) => grant.permission.key))];
|
|
631
|
+
const authorizeFn = authorizeSql({
|
|
632
|
+
schema,
|
|
633
|
+
authorize: options.authorize,
|
|
634
|
+
...options.scope === void 0 ? {} : { scope: options.scope },
|
|
635
|
+
...options.memberships === void 0 ? {} : { tenant: options.memberships },
|
|
636
|
+
...options.customRoles === void 0 ? {} : { customRoles: options.customRoles },
|
|
637
|
+
...options.context?.suspension === void 0 ? {} : { suspension: options.context.suspension }
|
|
638
|
+
});
|
|
639
|
+
return {
|
|
640
|
+
head: `-- rbac scaffold (Supabase Custom Claims and RBAC)
|
|
641
|
+
-- authorize: ${options.authorize}${options.authorize === "jwt" ? " (reads the hook claims; stale until the token refreshes)" : " (reads user_roles on every statement)"}
|
|
642
|
+
-- the custom access token hook that writes user_role${options.authorize === "jwt" ? " and memberships" : ""}: permdock supabase hook generate
|
|
643
|
+
${schema === "public" ? "" : `create schema if not exists ${s};\n`}${createEnum(schema, "app_role", roleNames(policy))}
|
|
644
|
+
${createEnum(schema, "app_permission", permissions)}
|
|
645
|
+
|
|
646
|
+
create table if not exists ${q("user_roles")} (
|
|
647
|
+
user_id uuid not null references auth.users on delete cascade,
|
|
648
|
+
role ${options.customRoles === void 0 ? q("app_role") : "text"} not null,
|
|
649
|
+
primary key (user_id, role)
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
alter table ${q("user_roles")} enable row level security;
|
|
653
|
+
revoke all on table ${q("user_roles")} from authenticated, anon, public;
|
|
654
|
+
`,
|
|
655
|
+
tail: authorizeFn,
|
|
656
|
+
warnings: []
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/cli/supabase-hook/memberships.ts
|
|
661
|
+
function memberForPlan(input) {
|
|
662
|
+
const memberFor = input.scopes.map((scope) => scope.name).filter((name) => hasMemberFor({
|
|
663
|
+
dialect: "supabase",
|
|
664
|
+
...input
|
|
665
|
+
}, name));
|
|
666
|
+
const holders = memberFor.filter((name) => hasPermittedUsers(input, name));
|
|
667
|
+
const entries = /* @__PURE__ */ new Map();
|
|
668
|
+
for (const name of memberFor) {
|
|
669
|
+
const mapped = memberForTable(input, name);
|
|
670
|
+
const found = mapped === void 0 ? memberForSources(input, name).map((source) => source.sql.manifest) : [mappedMembership(mapped, input.scopes, name)];
|
|
671
|
+
for (const entry of found) entries.set(JSON.stringify(entry), entry);
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
memberFor,
|
|
675
|
+
holders,
|
|
676
|
+
memberships: [...entries.values()]
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
/** An `rls.memberships` table in the manifest's `tableMembershipSource` / `junctionMembershipSource` shape, for scope `name`. */
|
|
680
|
+
function mappedMembership(mapped, scopes, name) {
|
|
681
|
+
const id = scopeColumn(mapped, scopes, name) ?? "";
|
|
682
|
+
const within = {};
|
|
683
|
+
for (const ancestor of scopeChain(scopes, name).slice(1)) {
|
|
684
|
+
const column = scopeColumn(mapped, scopes, ancestor);
|
|
685
|
+
if (column !== void 0) within[ancestor] = column;
|
|
686
|
+
}
|
|
687
|
+
const role = memberRoleOf(mapped);
|
|
688
|
+
const columns = [
|
|
689
|
+
mapped.user,
|
|
690
|
+
id,
|
|
691
|
+
...role.columns,
|
|
692
|
+
...Object.values(within),
|
|
693
|
+
...typeof mapped.via === "string" ? [mapped.via] : [],
|
|
694
|
+
...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
|
|
695
|
+
...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
|
|
696
|
+
];
|
|
697
|
+
return {
|
|
698
|
+
table: qualifySqlTable(mapped.table),
|
|
699
|
+
user: { column: mapped.user },
|
|
700
|
+
scope: { value: name },
|
|
701
|
+
id: { column: id },
|
|
702
|
+
role: roleManifest(role),
|
|
703
|
+
...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
|
|
704
|
+
...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
|
|
705
|
+
...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
|
|
706
|
+
...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
|
|
707
|
+
columns: [...new Set(columns)]
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
function positiveInteger(value, label) {
|
|
711
|
+
const number = typeof value === "string" ? Number(value) : value;
|
|
712
|
+
if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) throw configError(`PermDock CLI: ${label} must be a positive integer`);
|
|
713
|
+
return number;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* SQL for the active first-scope id, as text, for user `uid`; a table source
|
|
717
|
+
* compares its id column with `v_active_user`, declared as `userType`.
|
|
718
|
+
*/
|
|
719
|
+
function activeFromSql(input, root) {
|
|
720
|
+
const spec = input ?? `app_metadata.active_${root}`;
|
|
721
|
+
if (typeof spec === "string" && spec.startsWith("app_metadata.")) {
|
|
722
|
+
const key = spec.slice(13);
|
|
723
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw configError(`PermDock CLI: unsafe app_metadata key '${key}'`);
|
|
724
|
+
return { sql: `claims -> 'app_metadata' ->> ${quoteLiteral(key)}` };
|
|
725
|
+
}
|
|
726
|
+
const parsed = typeof spec === "string" ? (() => {
|
|
727
|
+
const dot = spec.lastIndexOf(".");
|
|
728
|
+
if (dot <= 0) throw configError(`PermDock CLI: --active-from must be app_metadata.<key> or <table>.<column>, got '${spec}'`);
|
|
729
|
+
return {
|
|
730
|
+
table: spec.slice(0, dot),
|
|
731
|
+
column: spec.slice(dot + 1)
|
|
732
|
+
};
|
|
733
|
+
})() : spec;
|
|
734
|
+
const id = "id" in parsed && parsed.id !== void 0 ? parsed.id : "id";
|
|
735
|
+
return {
|
|
736
|
+
sql: `(select a.${quoteIdent(parsed.column)}::text from ${quoteQualified(parsed.table)} a where a.${quoteIdent(id)} = v_active_user)`,
|
|
737
|
+
userType: `${quoteQualified(parsed.table)}.${quoteIdent(id)}%type`
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* One variable per user-keyed table the hook reads, holding `uid` (a uuid, as
|
|
742
|
+
* Supabase Auth issues) as that column's type, so each lookup compares the
|
|
743
|
+
* column uncast and its index applies.
|
|
744
|
+
*/
|
|
745
|
+
function typedUsers(parts) {
|
|
746
|
+
return [
|
|
747
|
+
...parts.sources.map((source, index) => `v_user_${String(index)} ${source.sql.userType} := uid;`),
|
|
748
|
+
...parts.roles === void 0 ? [] : [`v_roles_user ${quoteQualified(parts.roles.table)}.${quoteIdent(parts.roles.user)}%type := uid;`],
|
|
749
|
+
...parts.attrs?.table === void 0 || parts.attrs.columns.length === 0 ? [] : [`v_attrs_user ${quoteQualified(parts.attrs.table)}.${quoteIdent(parts.attrs.id)}%type := uid;`],
|
|
750
|
+
...parts.active.userType === void 0 ? [] : [`v_active_user ${parts.active.userType} := uid;`]
|
|
751
|
+
].map((line) => `\n ${line}`).join("");
|
|
752
|
+
}
|
|
753
|
+
function checkSources(parts) {
|
|
754
|
+
if (parts.sources.length === 0) throw configError("PermDock CLI: supabase.hook.memberships needs at least one tableMembershipSource or junctionMembershipSource source");
|
|
755
|
+
for (const source of parts.sources) {
|
|
756
|
+
if (source.sql === void 0 || typeof source.sql.select !== "function") throw configError("PermDock CLI: supabase.hook.memberships takes tableMembershipSource / junctionMembershipSource sources from permdock/supabase");
|
|
757
|
+
const scope = source.sql.scope;
|
|
758
|
+
if (scope === void 0) continue;
|
|
759
|
+
if (resolveScope(parts.scopes, scope) !== scope) throw configError(`PermDock CLI: the ${source.sql.table} source names scope '${scope}', which the policy does not declare by that name`);
|
|
760
|
+
const chain = scopeChain(parts.scopes, scope);
|
|
761
|
+
const holds = new Set(source.sql.holds ?? [scope]);
|
|
762
|
+
const missing = chain.filter((ancestor) => !holds.has(ancestor));
|
|
763
|
+
if (missing.length > 0) throw configError(`PermDock CLI: the ${source.sql.table} source needs within columns for ${missing.join(", ")}: a ${scope} membership without every ancestor grants nothing`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function entriesSql(parts) {
|
|
767
|
+
const tenant = `case when s.scope = ${quoteLiteral(parts.root)} then s.id else s.within ->> ${quoteLiteral(parts.root)} end`;
|
|
768
|
+
return parts.sources.map((source, index) => `select ${String(index)} as ord,
|
|
769
|
+
(${tenant}) as tenant,
|
|
770
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
771
|
+
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
772
|
+
'expires_at', s.expires_at, 'granted_by', s.granted_by, 'reason', s.reason,
|
|
773
|
+
'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
|
|
774
|
+
'managed_by', s.managed_by, 'entitlements', s.seats${source.sql.keeps ? ", 'keep', s.keep" : ""}
|
|
775
|
+
)) as entry
|
|
776
|
+
from (
|
|
777
|
+
${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
|
|
778
|
+
) s`).join("\nunion all\n");
|
|
779
|
+
}
|
|
780
|
+
//#endregion
|
|
781
|
+
//#region src/cli/supabase-hook/api.ts
|
|
782
|
+
const SUBJECT_FOR = "subject_for";
|
|
783
|
+
/**
|
|
784
|
+
* `subject_for(p_user uuid) returns jsonb`: what the hook would put in a
|
|
785
|
+
* token for `p_user`, from the same membership, role, suspension and version
|
|
786
|
+
* reads, plus the custom roles the user holds when `database` mode keeps them
|
|
787
|
+
* in tables. A backend that only has PostgREST (`postgrestSources`) builds a
|
|
788
|
+
* subject for an acting user or a job from it. Naming the user is why no
|
|
789
|
+
* client role may execute it.
|
|
790
|
+
*/
|
|
791
|
+
function subjectForSql(parts, config) {
|
|
792
|
+
const schema = quoteIdent(parts.schema);
|
|
793
|
+
const fn = `${schema}.${SUBJECT_FOR}`;
|
|
794
|
+
const rows = parts.roles;
|
|
795
|
+
const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
|
|
796
|
+
into held
|
|
797
|
+
from ${rows.from}
|
|
798
|
+
where ${rows.userSql} = v_roles_user;`;
|
|
799
|
+
const suspended = parts.users === void 0 ? "" : `
|
|
800
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
801
|
+
return jsonb_build_object('id', uid::text, 'active', false);
|
|
802
|
+
end if;`;
|
|
803
|
+
const version = parts.version ? `
|
|
804
|
+
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
805
|
+
ver := coalesce(ver, 0);` : "";
|
|
806
|
+
const custom = customRolesOn(config.rls) && resolveAuthorize(config) === "database" ? customRolesOfSql(parts) : "";
|
|
807
|
+
return `-- the subject the hook would mint for p_user, for a backend acting for a stored user; no client role may execute it
|
|
808
|
+
create or replace function ${fn}(p_user uuid)
|
|
809
|
+
returns jsonb
|
|
810
|
+
language plpgsql
|
|
811
|
+
stable
|
|
812
|
+
security definer
|
|
813
|
+
set search_path = ''
|
|
814
|
+
as $$
|
|
815
|
+
declare
|
|
816
|
+
uid uuid := p_user;
|
|
817
|
+
held jsonb;
|
|
818
|
+
memberships jsonb;
|
|
819
|
+
custom jsonb := '[]'::jsonb;
|
|
820
|
+
ver bigint;${typedUsers(parts)}
|
|
821
|
+
begin
|
|
822
|
+
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
823
|
+
return null;
|
|
824
|
+
end if;${suspended}
|
|
825
|
+
${roles}
|
|
826
|
+
select coalesce(jsonb_agg(x.entry order by x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text), '[]'::jsonb)
|
|
827
|
+
into memberships
|
|
828
|
+
from (
|
|
829
|
+
${entriesSql(parts).replaceAll(/^/gmu, " ")}
|
|
830
|
+
) x;${custom}${version}
|
|
831
|
+
return jsonb_strip_nulls(jsonb_build_object(
|
|
832
|
+
'id', uid::text,
|
|
833
|
+
'active', true,
|
|
834
|
+
'roles', held,
|
|
835
|
+
'memberships', memberships,
|
|
836
|
+
'customRoles', custom,
|
|
837
|
+
'authzVersion', ver
|
|
838
|
+
));
|
|
839
|
+
end;
|
|
840
|
+
$$;
|
|
841
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* The PostgREST wrappers `supabase.hook.api` asks for: one `security definer`
|
|
845
|
+
* function per reader, in the API schema, executable by `service_role` only.
|
|
846
|
+
*/
|
|
847
|
+
function apiWrappersSql(parts) {
|
|
848
|
+
const api = parts.hook.api;
|
|
849
|
+
if (api === void 0) return "";
|
|
850
|
+
const target = api.schema ?? "public";
|
|
851
|
+
const names = hookApiNames(api.prefix);
|
|
852
|
+
const source = quoteIdent(parts.schema);
|
|
853
|
+
const wrapper = (name, inner, params, args, types, returns) => {
|
|
854
|
+
const fn = `${quoteIdent(target)}.${quoteIdent(name)}`;
|
|
855
|
+
if (target === parts.schema && name === inner) throw configError(`PermDock CLI: supabase.hook.api would replace ${source}.${inner}: set api.schema or api.prefix`);
|
|
856
|
+
return `create or replace function ${fn}(${params})
|
|
857
|
+
returns ${returns}
|
|
858
|
+
language sql
|
|
859
|
+
stable
|
|
860
|
+
security definer
|
|
861
|
+
set search_path = ''
|
|
862
|
+
as $$ select ${source}.${inner}(${args}) $$;
|
|
863
|
+
revoke execute on function ${fn}(${types}) from public, anon, authenticated;
|
|
864
|
+
grant execute on function ${fn}(${types}) to service_role;`;
|
|
865
|
+
};
|
|
866
|
+
return `-- supabase.hook.api: wrappers in ${quoteIdent(target)} for a backend that reaches the hook schema through the Data API; only service_role may execute them
|
|
867
|
+
${wrapper(names.subjectFor, SUBJECT_FOR, "p_user uuid", "p_user", "uuid", "jsonb")}
|
|
868
|
+
|
|
869
|
+
${wrapper(names.membersOf, MEMBERS_OF, "p_scope text, p_id text", "p_scope, p_id", "text, text", "jsonb")}
|
|
870
|
+
|
|
871
|
+
${wrapper(names.authzVersionFor, AUTHZ_VERSION_FOR, "p_user uuid", "p_user", "uuid", "bigint")}`;
|
|
872
|
+
}
|
|
873
|
+
const AUTHZ_VERSION_FOR = "authz_version_for";
|
|
874
|
+
/**
|
|
875
|
+
* `authz_version_for(p_user uuid) returns bigint`: the authorization version
|
|
876
|
+
* `subject_for` reports, without reading roles or memberships, so a
|
|
877
|
+
* `claimsFirst` reread can check a token's freshness on every request and read
|
|
878
|
+
* the full subject only when it is behind. `null` for an unknown or suspended
|
|
879
|
+
* user, and when the version is off.
|
|
880
|
+
*/
|
|
881
|
+
function authzVersionForSql(parts) {
|
|
882
|
+
const schema = quoteIdent(parts.schema);
|
|
883
|
+
const fn = `${schema}.${AUTHZ_VERSION_FOR}`;
|
|
884
|
+
return `-- the authorization version subject_for reports for p_user, for a cheap freshness check; no client role may execute it
|
|
885
|
+
create or replace function ${fn}(p_user uuid)
|
|
886
|
+
returns bigint
|
|
887
|
+
language plpgsql
|
|
888
|
+
stable
|
|
889
|
+
security definer
|
|
890
|
+
set search_path = ''
|
|
891
|
+
as $$
|
|
892
|
+
declare
|
|
893
|
+
uid uuid := p_user;
|
|
894
|
+
ver bigint;
|
|
895
|
+
begin
|
|
896
|
+
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
897
|
+
return null;
|
|
898
|
+
end if;${parts.users === void 0 ? "" : `
|
|
899
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
900
|
+
return null;
|
|
901
|
+
end if;`}${parts.version ? `
|
|
902
|
+
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
903
|
+
ver := coalesce(ver, 0);` : ""}
|
|
904
|
+
return ver;
|
|
905
|
+
end;
|
|
906
|
+
$$;
|
|
907
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
908
|
+
}
|
|
909
|
+
const MEMBERS_OF = "members_of";
|
|
910
|
+
function membersOfSql(parts) {
|
|
911
|
+
const fn = `${quoteIdent(parts.schema)}.${MEMBERS_OF}`;
|
|
912
|
+
const keep = parts.sources.some((source) => source.sql.keeps);
|
|
913
|
+
const rows = parts.sources.map((source) => source.sql.list(keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
914
|
+
return `-- the live memberships of one scope instance, for a backend that lists members over PostgREST; no client role may execute it
|
|
915
|
+
create or replace function ${fn}(p_scope text, p_id text)
|
|
916
|
+
returns jsonb
|
|
917
|
+
language sql
|
|
918
|
+
stable
|
|
919
|
+
security definer
|
|
920
|
+
set search_path = ''
|
|
921
|
+
as $$
|
|
922
|
+
select coalesce(jsonb_agg(jsonb_build_object(
|
|
923
|
+
'principal', jsonb_build_object('id', s.user_id),
|
|
924
|
+
'membership', jsonb_strip_nulls(jsonb_build_object(
|
|
925
|
+
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
926
|
+
'expires_at', s.expires_at, 'granted_by', s.granted_by, 'reason', s.reason,
|
|
927
|
+
'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
|
|
928
|
+
'managed_by', s.managed_by, 'entitlements', s.seats${keep ? ", 'keep', s.keep" : ""}
|
|
929
|
+
))
|
|
930
|
+
) order by s.user_id, s.roles::text), '[]'::jsonb)
|
|
931
|
+
from (
|
|
932
|
+
${rows}
|
|
933
|
+
) s
|
|
934
|
+
$$;
|
|
935
|
+
revoke execute on function ${fn}(text, text) from public, anon, authenticated;`;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* The custom roles `p_user` holds: tenant roles named on a membership of the
|
|
939
|
+
* scope (and instance) they live at, and platform roles among the global
|
|
940
|
+
* roles, each with its grants and includes in `CustomRole` form.
|
|
941
|
+
*/
|
|
942
|
+
function customRolesOfSql(parts) {
|
|
943
|
+
const helpers = quoteIdent(parts.helpers.schema);
|
|
944
|
+
const perms = `${helpers}.custom_role_permissions`;
|
|
945
|
+
const includes = `${helpers}.custom_role_includes`;
|
|
946
|
+
const root = quoteLiteral(parts.root);
|
|
947
|
+
const match = `p.tenant_id is not distinct from c.tenant_id and p.scope = c.scope and p.scope_id is not distinct from c.scope_id and p.role = c.role`;
|
|
948
|
+
return `
|
|
949
|
+
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
950
|
+
'name', c.role,
|
|
951
|
+
'tenant', c.tenant_id::text,
|
|
952
|
+
'scope', c.scope,
|
|
953
|
+
'id', c.scope_id,
|
|
954
|
+
'grants', (
|
|
955
|
+
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
956
|
+
'permission', p.permission,
|
|
957
|
+
'effect', p.effect,
|
|
958
|
+
'level', to_jsonb(p) ->> 'level'
|
|
959
|
+
)) order by p.permission, p.effect), '[]'::jsonb)
|
|
960
|
+
from ${perms} p
|
|
961
|
+
where ${match}
|
|
962
|
+
),
|
|
963
|
+
'includes', (
|
|
964
|
+
select jsonb_agg(p.include_role order by p.include_role)
|
|
965
|
+
from ${includes} p
|
|
966
|
+
where ${match}
|
|
967
|
+
)
|
|
968
|
+
)) order by c.tenant_id::text, c.scope, c.scope_id, c.role), '[]'::jsonb)
|
|
969
|
+
into custom
|
|
970
|
+
from (
|
|
971
|
+
select tenant_id, scope, scope_id, role from ${perms}
|
|
972
|
+
union
|
|
973
|
+
select tenant_id, scope, scope_id, role from ${includes}
|
|
974
|
+
) c
|
|
975
|
+
where (c.scope = 'global' and c.tenant_id is null and held ? c.role)
|
|
976
|
+
or exists (
|
|
977
|
+
select 1
|
|
978
|
+
from jsonb_array_elements(memberships) m
|
|
979
|
+
where m -> 'roles' ? c.role
|
|
980
|
+
and m ->> 'scope' = c.scope
|
|
981
|
+
and c.tenant_id::text = case when m ->> 'scope' = ${root} then m ->> 'id' else m -> 'within' ->> ${root} end
|
|
982
|
+
and (c.scope_id is null or c.scope_id = m ->> 'id')
|
|
983
|
+
);`;
|
|
984
|
+
}
|
|
985
|
+
//#endregion
|
|
986
|
+
//#region src/cli/rls/approvals.ts
|
|
987
|
+
/** `rls.approvals`' approver check; better-supabase's `isApprover` template calls it. */
|
|
988
|
+
const IS_APPROVER = "permdock_is_approver";
|
|
989
|
+
/** The objects `rls.approvals` adds. Names are part of the SQL contract `supabaseApprovalStore` calls. */
|
|
990
|
+
const APPROVAL_STORE = {
|
|
991
|
+
table: "approval_requests",
|
|
992
|
+
open: "permdock_approval_open",
|
|
993
|
+
get: "permdock_approval_get",
|
|
994
|
+
resolve: "permdock_approval_resolve",
|
|
995
|
+
consume: "permdock_approval_consume",
|
|
996
|
+
list: "permdock_approval_list",
|
|
997
|
+
expire: "permdock_approval_expire",
|
|
998
|
+
cancel: "permdock_approval_cancel",
|
|
999
|
+
isApprover: IS_APPROVER
|
|
1000
|
+
};
|
|
1001
|
+
/** `body` as the row's filter columns hold it. */
|
|
1002
|
+
const COLUMNS = `p_request ->> 'token',
|
|
1003
|
+
p_request,
|
|
1004
|
+
p_request ->> 'status',
|
|
1005
|
+
p_request #>> '{subject,principal,tenant}',
|
|
1006
|
+
p_request #>> '{subject,principal,id}',
|
|
1007
|
+
p_request #>> '{subject,actor,id}',
|
|
1008
|
+
p_request #>> '{subject,session}',
|
|
1009
|
+
coalesce(jsonb_array_length(p_request -> 'approvals'), 0),
|
|
1010
|
+
p_request ->> 'createdAt',
|
|
1011
|
+
(p_request ->> 'expiresAt')::timestamptz,
|
|
1012
|
+
(p_request ->> 'consumedAt')::timestamptz`;
|
|
1013
|
+
/** The `ApprovalListFilter` in `p_filter`, over the row aliased `a`. */
|
|
1014
|
+
const FILTER = `(p_filter ->> 'status' is null or a.status = p_filter ->> 'status')
|
|
1015
|
+
and (p_filter ->> 'tenant' is null or a.tenant = p_filter ->> 'tenant')
|
|
1016
|
+
and (p_filter ->> 'principalId' is null or a.principal_id = p_filter ->> 'principalId')
|
|
1017
|
+
and (p_filter ->> 'actorId' is null or a.actor_id = p_filter ->> 'actorId')
|
|
1018
|
+
and (p_filter ->> 'session' is null or a.session = p_filter ->> 'session')`;
|
|
1019
|
+
function serverOnly(fn, args) {
|
|
1020
|
+
return `revoke execute on function ${fn}(${args}) from public, anon, authenticated;`;
|
|
1021
|
+
}
|
|
1022
|
+
const BODY_CHECK = `${APPROVAL_STORE.table}_body_schema`;
|
|
1023
|
+
/**
|
|
1024
|
+
* `rls.jsonSchema`: a pg_jsonschema check that `body` matches
|
|
1025
|
+
* `approval-request-v1.json`, added `not valid` and then validated. `auto`
|
|
1026
|
+
* adds it only where the extension is available.
|
|
1027
|
+
*/
|
|
1028
|
+
function bodySchemaSql(table, mode, body = "body", check = BODY_CHECK) {
|
|
1029
|
+
const statements = [
|
|
1030
|
+
"create schema if not exists extensions;",
|
|
1031
|
+
"create extension if not exists pg_jsonschema with schema extensions;",
|
|
1032
|
+
`alter table ${table} drop constraint if exists "${check}";`,
|
|
1033
|
+
`alter table ${table} add constraint "${check}" check (extensions.jsonb_matches_schema(${quoteLiteral(JSON.stringify(APPROVAL_REQUEST_SCHEMA))}::json, ${body})) not valid;`,
|
|
1034
|
+
`alter table ${table} validate constraint "${check}";`
|
|
1035
|
+
];
|
|
1036
|
+
const comment = "-- approval store: body matches schemas/approval-request-v1.json through pg_jsonschema";
|
|
1037
|
+
if (mode === true) return `\n${comment}\n${statements.join("\n")}\n`;
|
|
1038
|
+
return `\n${comment}, where the extension is available
|
|
1039
|
+
do $permdock$
|
|
1040
|
+
begin
|
|
1041
|
+
if exists (select 1 from pg_available_extensions where name = 'pg_jsonschema') then
|
|
1042
|
+
${statements.map((statement) => ` ${statement}`).join("\n")}
|
|
1043
|
+
end if;
|
|
1044
|
+
end
|
|
1045
|
+
$permdock$;\n`;
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* The approval store (`rls.approvals`): one table of `ApprovalRequest`
|
|
1049
|
+
* bodies with the columns a list filters on, and one `security definer`
|
|
1050
|
+
* function per `ApprovalStore` method, each one atomic statement. No client
|
|
1051
|
+
* role may read the table or execute the functions: approvals are opened and
|
|
1052
|
+
* resolved by the server, which grants its own role.
|
|
1053
|
+
*/
|
|
1054
|
+
function approvalStoreSql(ctx, jsonSchema = false) {
|
|
1055
|
+
const table = qualified(ctx, APPROVAL_STORE.table);
|
|
1056
|
+
const fn = (name) => qualified(ctx, name);
|
|
1057
|
+
const index = (name) => `"${APPROVAL_STORE.table}_${name}"`;
|
|
1058
|
+
return `-- approval store: ApprovalRequest bodies, opened, resolved and consumed through the functions below; supabaseApprovalStore calls them
|
|
1059
|
+
create table if not exists ${table} (
|
|
1060
|
+
token text primary key,
|
|
1061
|
+
body jsonb not null,
|
|
1062
|
+
status text not null check (status in ('pending', 'approved', 'rejected', 'expired')),
|
|
1063
|
+
tenant text,
|
|
1064
|
+
principal_id text,
|
|
1065
|
+
actor_id text,
|
|
1066
|
+
session text,
|
|
1067
|
+
approvals integer not null default 0,
|
|
1068
|
+
created_at text not null,
|
|
1069
|
+
expires_at timestamptz not null,
|
|
1070
|
+
consumed_at timestamptz
|
|
1071
|
+
);
|
|
1072
|
+
create index if not exists ${index("page")} on ${table} (created_at collate "C", token collate "C");
|
|
1073
|
+
create index if not exists ${index("pending")} on ${table} (status, expires_at);
|
|
1074
|
+
alter table ${table} enable row level security;
|
|
1075
|
+
revoke all on table ${table} from anon, authenticated, public;
|
|
1076
|
+
${jsonSchema === false ? "" : bodySchemaSql(table, jsonSchema)}
|
|
1077
|
+
-- open a request; a repeated ask keeps the open record and replaces only an expired one
|
|
1078
|
+
create or replace function ${fn(APPROVAL_STORE.open)}(p_request jsonb)
|
|
1079
|
+
returns void
|
|
1080
|
+
language sql
|
|
1081
|
+
volatile
|
|
1082
|
+
security definer
|
|
1083
|
+
set search_path = ''
|
|
1084
|
+
as $$
|
|
1085
|
+
insert into ${table} as a (token, body, status, tenant, principal_id, actor_id, session, approvals, created_at, expires_at, consumed_at)
|
|
1086
|
+
values (
|
|
1087
|
+
${COLUMNS}
|
|
1088
|
+
)
|
|
1089
|
+
on conflict (token) do update
|
|
1090
|
+
set body = excluded.body, status = excluded.status, tenant = excluded.tenant,
|
|
1091
|
+
principal_id = excluded.principal_id, actor_id = excluded.actor_id, session = excluded.session,
|
|
1092
|
+
approvals = excluded.approvals, created_at = excluded.created_at,
|
|
1093
|
+
expires_at = excluded.expires_at, consumed_at = excluded.consumed_at
|
|
1094
|
+
where a.status = 'expired' or a.expires_at <= now()
|
|
1095
|
+
$$;
|
|
1096
|
+
${serverOnly(fn(APPROVAL_STORE.open), "jsonb")}
|
|
1097
|
+
|
|
1098
|
+
create or replace function ${fn(APPROVAL_STORE.get)}(p_token text)
|
|
1099
|
+
returns jsonb
|
|
1100
|
+
language sql
|
|
1101
|
+
stable
|
|
1102
|
+
security definer
|
|
1103
|
+
set search_path = ''
|
|
1104
|
+
as $$
|
|
1105
|
+
select a.body from ${table} a where a.token = p_token
|
|
1106
|
+
$$;
|
|
1107
|
+
${serverOnly(fn(APPROVAL_STORE.get), "text")}
|
|
1108
|
+
|
|
1109
|
+
-- write a verdict over the record it was computed from: still pending, unexpired, with p_seen approvals
|
|
1110
|
+
create or replace function ${fn(APPROVAL_STORE.resolve)}(p_token text, p_next jsonb, p_seen integer)
|
|
1111
|
+
returns jsonb
|
|
1112
|
+
language sql
|
|
1113
|
+
volatile
|
|
1114
|
+
security definer
|
|
1115
|
+
set search_path = ''
|
|
1116
|
+
as $$
|
|
1117
|
+
update ${table} a
|
|
1118
|
+
set body = p_next,
|
|
1119
|
+
status = p_next ->> 'status',
|
|
1120
|
+
approvals = coalesce(jsonb_array_length(p_next -> 'approvals'), 0)
|
|
1121
|
+
where a.token = p_token
|
|
1122
|
+
and a.status = 'pending'
|
|
1123
|
+
and a.expires_at > now()
|
|
1124
|
+
and a.approvals = p_seen
|
|
1125
|
+
returning a.body
|
|
1126
|
+
$$;
|
|
1127
|
+
${serverOnly(fn(APPROVAL_STORE.resolve), "text, jsonb, integer")}
|
|
1128
|
+
|
|
1129
|
+
-- consume an approved, unexpired, unconsumed request once
|
|
1130
|
+
create or replace function ${fn(APPROVAL_STORE.consume)}(p_token text, p_now text)
|
|
1131
|
+
returns jsonb
|
|
1132
|
+
language sql
|
|
1133
|
+
volatile
|
|
1134
|
+
security definer
|
|
1135
|
+
set search_path = ''
|
|
1136
|
+
as $$
|
|
1137
|
+
update ${table} a
|
|
1138
|
+
set consumed_at = p_now::timestamptz,
|
|
1139
|
+
body = a.body || jsonb_build_object('consumedAt', p_now)
|
|
1140
|
+
where a.token = p_token
|
|
1141
|
+
and a.status = 'approved'
|
|
1142
|
+
and a.consumed_at is null
|
|
1143
|
+
and a.expires_at > p_now::timestamptz
|
|
1144
|
+
returning a.body
|
|
1145
|
+
$$;
|
|
1146
|
+
${serverOnly(fn(APPROVAL_STORE.consume), "text, text")}
|
|
1147
|
+
|
|
1148
|
+
-- one page in (createdAt, token) order after the cursor position
|
|
1149
|
+
create or replace function ${fn(APPROVAL_STORE.list)}(p_filter jsonb, p_after_created text, p_after_token text, p_limit integer)
|
|
1150
|
+
returns setof jsonb
|
|
1151
|
+
language sql
|
|
1152
|
+
stable
|
|
1153
|
+
security definer
|
|
1154
|
+
set search_path = ''
|
|
1155
|
+
as $$
|
|
1156
|
+
select a.body
|
|
1157
|
+
from ${table} a
|
|
1158
|
+
where ${FILTER}
|
|
1159
|
+
and (p_after_created is null
|
|
1160
|
+
or (a.created_at collate "C", a.token collate "C") > (p_after_created collate "C", p_after_token collate "C"))
|
|
1161
|
+
order by a.created_at collate "C", a.token collate "C"
|
|
1162
|
+
limit p_limit
|
|
1163
|
+
$$;
|
|
1164
|
+
${serverOnly(fn(APPROVAL_STORE.list), "jsonb, text, text, integer")}
|
|
1165
|
+
|
|
1166
|
+
create or replace function ${fn(APPROVAL_STORE.expire)}(p_now text)
|
|
1167
|
+
returns integer
|
|
1168
|
+
language sql
|
|
1169
|
+
volatile
|
|
1170
|
+
security definer
|
|
1171
|
+
set search_path = ''
|
|
1172
|
+
as $$
|
|
1173
|
+
with expired as (
|
|
1174
|
+
update ${table} a
|
|
1175
|
+
set status = 'expired', body = a.body || '{"status":"expired"}'::jsonb
|
|
1176
|
+
where a.status = 'pending' and a.expires_at <= p_now::timestamptz
|
|
1177
|
+
returning 1
|
|
1178
|
+
)
|
|
1179
|
+
select count(*)::integer from expired
|
|
1180
|
+
$$;
|
|
1181
|
+
${serverOnly(fn(APPROVAL_STORE.expire), "text")}
|
|
1182
|
+
|
|
1183
|
+
-- reject every pending request the filter matches, as system:<by>
|
|
1184
|
+
create or replace function ${fn(APPROVAL_STORE.cancel)}(p_filter jsonb, p_by text, p_note text, p_now text)
|
|
1185
|
+
returns integer
|
|
1186
|
+
language sql
|
|
1187
|
+
volatile
|
|
1188
|
+
security definer
|
|
1189
|
+
set search_path = ''
|
|
1190
|
+
as $$
|
|
1191
|
+
with cancelled as (
|
|
1192
|
+
update ${table} a
|
|
1193
|
+
set status = 'rejected',
|
|
1194
|
+
body = (a.body - 'note') || jsonb_strip_nulls(jsonb_build_object(
|
|
1195
|
+
'status', 'rejected', 'resolvedAt', p_now, 'resolvedBy', 'system:' || p_by, 'note', p_note
|
|
1196
|
+
))
|
|
1197
|
+
where a.status = 'pending'
|
|
1198
|
+
and ${FILTER.replaceAll("\n ", "\n ")}
|
|
1199
|
+
returning 1
|
|
1200
|
+
)
|
|
1201
|
+
select count(*)::integer from cancelled
|
|
1202
|
+
$$;
|
|
1203
|
+
${serverOnly(fn(APPROVAL_STORE.cancel), "jsonb, text, text, text")}
|
|
1204
|
+
`;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* `permdock_is_approver(p_approvers jsonb, p_tenant text default null)`: true
|
|
1208
|
+
* when the signed-in user matches the serialised approvers of a request
|
|
1209
|
+
* (`approvers.by`), for policies on the app's own approval views. A list
|
|
1210
|
+
* needs every item and `any-of` one of them; `user` compares the user id and
|
|
1211
|
+
* `holder(permission)` reads `permitted_<root>_ids_by_permission` in
|
|
1212
|
+
* `p_tenant`, or `permdock_has_permission` without one. Role, relation and
|
|
1213
|
+
* other grantee approvers need facts the database does not hold for every
|
|
1214
|
+
* membership source, so they match nobody.
|
|
1215
|
+
*/
|
|
1216
|
+
function isApproverSql(ctx) {
|
|
1217
|
+
const fn = qualified(ctx, APPROVAL_STORE.isApprover);
|
|
1218
|
+
const root = ctx.scopes[0]?.name;
|
|
1219
|
+
const inTenant = root === void 0 ? "false" : `exists (
|
|
1220
|
+
select 1
|
|
1221
|
+
from ${qualified(ctx, checkName(permittedByPermissionHelper(root)))}(p_approvers ->> 'permission') i(id)
|
|
1222
|
+
where i.id::text = p_tenant
|
|
1223
|
+
)`;
|
|
1224
|
+
return `
|
|
1225
|
+
-- approvers: whether the signed-in user matches a request's approvers.by; policies on approval views call it
|
|
1226
|
+
create or replace function ${fn}(p_approvers jsonb, p_tenant text default null)
|
|
1227
|
+
returns boolean
|
|
1228
|
+
language plpgsql
|
|
1229
|
+
stable
|
|
1230
|
+
security definer
|
|
1231
|
+
set search_path = ''
|
|
1232
|
+
as $$
|
|
1233
|
+
declare
|
|
1234
|
+
v_item jsonb;
|
|
1235
|
+
v_user text := ${subjectIdSql(ctx)}::text;
|
|
1236
|
+
begin
|
|
1237
|
+
if coalesce(v_user, '') = '' or p_approvers is null then
|
|
1238
|
+
return false;
|
|
1239
|
+
end if;
|
|
1240
|
+
if jsonb_typeof(p_approvers) = 'array' then
|
|
1241
|
+
if jsonb_array_length(p_approvers) = 0 then
|
|
1242
|
+
return false;
|
|
1243
|
+
end if;
|
|
1244
|
+
for v_item in select value from jsonb_array_elements(p_approvers) loop
|
|
1245
|
+
if not ${fn}(v_item, p_tenant) then
|
|
1246
|
+
return false;
|
|
1247
|
+
end if;
|
|
1248
|
+
end loop;
|
|
1249
|
+
return true;
|
|
1250
|
+
end if;
|
|
1251
|
+
if jsonb_typeof(p_approvers) <> 'object' then
|
|
1252
|
+
return false;
|
|
1253
|
+
end if;
|
|
1254
|
+
case p_approvers ->> 'kind'
|
|
1255
|
+
when 'user' then
|
|
1256
|
+
return coalesce(p_approvers ->> 'id' = v_user, false);
|
|
1257
|
+
when 'permission' then
|
|
1258
|
+
if p_tenant is null then
|
|
1259
|
+
return coalesce(${qualified(ctx, PERMISSION_HELPERS.has)}(p_approvers ->> 'permission'), false);
|
|
1260
|
+
end if;
|
|
1261
|
+
return ${inTenant};
|
|
1262
|
+
when 'any-of' then
|
|
1263
|
+
if jsonb_typeof(p_approvers -> 'of') <> 'array' then
|
|
1264
|
+
return false;
|
|
1265
|
+
end if;
|
|
1266
|
+
for v_item in select value from jsonb_array_elements(p_approvers -> 'of') loop
|
|
1267
|
+
if ${fn}(v_item, p_tenant) then
|
|
1268
|
+
return true;
|
|
1269
|
+
end if;
|
|
1270
|
+
end loop;
|
|
1271
|
+
return false;
|
|
1272
|
+
else
|
|
1273
|
+
return false;
|
|
1274
|
+
end case;
|
|
1275
|
+
end;
|
|
1276
|
+
$$;
|
|
1277
|
+
revoke execute on function ${fn}(jsonb, text) from public, anon;
|
|
1278
|
+
grant execute on function ${fn}(jsonb, text) to authenticated;
|
|
1279
|
+
`;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Everything `rls.approvals` writes: the generated or adopted store, the
|
|
1283
|
+
* approver check and, with `delegations: true`, the covers.
|
|
1284
|
+
*/
|
|
1285
|
+
function approvalsSql(ctx, approvals, jsonSchema) {
|
|
1286
|
+
if (approvals === void 0 || approvals === false) return;
|
|
1287
|
+
if (approvals === true) return approvalStoreSql(ctx, jsonSchema) + isApproverSql(ctx);
|
|
1288
|
+
const { table, delegations } = approvals;
|
|
1289
|
+
if (delegations !== void 0 && typeof delegations !== "boolean") throw new TypeError("PermDock CLI: rls.approvals.delegations must be a boolean");
|
|
1290
|
+
let store;
|
|
1291
|
+
if (table === void 0) {
|
|
1292
|
+
const adoptOnly = [
|
|
1293
|
+
"token",
|
|
1294
|
+
"body",
|
|
1295
|
+
"open",
|
|
1296
|
+
"schema",
|
|
1297
|
+
"mirror"
|
|
1298
|
+
].find((key) => approvals[key] !== void 0);
|
|
1299
|
+
if (adoptOnly !== void 0) throw configError(`PermDock CLI: rls.approvals.${adoptOnly} adopts a table; set rls.approvals.table too`);
|
|
1300
|
+
store = approvalStoreSql(ctx, jsonSchema);
|
|
1301
|
+
} else store = adoptedApprovalStoreSql(ctx, {
|
|
1302
|
+
...approvals,
|
|
1303
|
+
table
|
|
1304
|
+
}, jsonSchema);
|
|
1305
|
+
return store + isApproverSql(ctx) + (delegations === true ? approvalDelegationsSql(ctx) : "");
|
|
1306
|
+
}
|
|
1307
|
+
/** The objects `rls.approvals.delegations` adds; `tableApprovalDelegationSource` reads the table. */
|
|
1308
|
+
const DELEGATIONS = {
|
|
1309
|
+
table: "approval_delegations",
|
|
1310
|
+
covers: "permdock_cover_approves"
|
|
1311
|
+
};
|
|
1312
|
+
/**
|
|
1313
|
+
* `rls.approvals.delegations`: one table of out-of-office covers that a
|
|
1314
|
+
* user writes for themselves, and a four-argument `permdock_is_approver`
|
|
1315
|
+
* that also answers for a delegate inside an active cover. One hop: a cover
|
|
1316
|
+
* is checked against the delegator's own approver rights, never the
|
|
1317
|
+
* delegator's covers. A delegator who is the requester never counts. A
|
|
1318
|
+
* `holder()` approver through a cover needs `rls.mode: "database"`, where
|
|
1319
|
+
* the `_for` helpers exist; in `jwt` mode only `user()` approvers pass.
|
|
1320
|
+
*/
|
|
1321
|
+
function approvalDelegationsSql(ctx) {
|
|
1322
|
+
const table = qualified(ctx, DELEGATIONS.table);
|
|
1323
|
+
const fn = qualified(ctx, APPROVAL_STORE.isApprover);
|
|
1324
|
+
const covers = qualified(ctx, DELEGATIONS.covers);
|
|
1325
|
+
const user = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
1326
|
+
const me = `${subjectIdSql(ctx)}::text`;
|
|
1327
|
+
const defaultMe = subjectIdSql(ctx).replace(/^\(select (.+)\)$/su, "$1");
|
|
1328
|
+
const root = ctx.scopes[0]?.name;
|
|
1329
|
+
const member = (tenant) => root === void 0 ? "false" : `${tenant} in (select m.id::text from ${qualified(ctx, memberIdsHelper(root))}() m(id))`;
|
|
1330
|
+
const database = ctx.authorize === "database";
|
|
1331
|
+
const holderGlobal = database ? `coalesce(${qualified(ctx, PERMISSION_HELPERS.hasFor)}(p_delegator, p_approvers ->> 'permission'), false)` : "false";
|
|
1332
|
+
const holderTenant = database && root !== void 0 ? `exists (
|
|
1333
|
+
select 1
|
|
1334
|
+
from ${qualified(ctx, checkName(`${permittedByPermissionHelper(root)}_for`))}(p_delegator, p_approvers ->> 'permission') i(id)
|
|
1335
|
+
where i.id::text = p_tenant
|
|
1336
|
+
)` : "false";
|
|
1337
|
+
const policy = (name) => quoteIdent(`${DELEGATIONS.table}_${name}`);
|
|
1338
|
+
const own = `delegator::text = ${me} and (tenant is null or ${member("tenant")})`;
|
|
1339
|
+
return `
|
|
1340
|
+
-- approval delegations: out-of-office covers a user writes for themselves; tableApprovalDelegationSource and permdock_is_approver read them
|
|
1341
|
+
create table if not exists ${table} (
|
|
1342
|
+
id text primary key default gen_random_uuid()::text,
|
|
1343
|
+
delegator ${user} not null default (${defaultMe})::${user},
|
|
1344
|
+
delegate ${user} not null,
|
|
1345
|
+
tenant text,
|
|
1346
|
+
permissions text[],
|
|
1347
|
+
resources text[],
|
|
1348
|
+
"from" timestamptz not null default now(),
|
|
1349
|
+
"until" timestamptz not null,
|
|
1350
|
+
created_at timestamptz not null default now(),
|
|
1351
|
+
constraint ${quoteIdent(`${DELEGATIONS.table}_window`)} check ("from" < "until"),
|
|
1352
|
+
constraint ${quoteIdent(`${DELEGATIONS.table}_distinct`)} check (delegator <> delegate)
|
|
1353
|
+
);
|
|
1354
|
+
create index if not exists ${quoteIdent(`${DELEGATIONS.table}_delegate`)} on ${table} (delegate, "until");
|
|
1355
|
+
alter table ${table} enable row level security;
|
|
1356
|
+
revoke all on table ${table} from anon, public;
|
|
1357
|
+
grant select, insert, update, delete on table ${table} to authenticated;
|
|
1358
|
+
drop policy if exists ${policy("read")} on ${table};
|
|
1359
|
+
create policy ${policy("read")} on ${table}
|
|
1360
|
+
for select to authenticated
|
|
1361
|
+
using (delegator::text = ${me} or delegate::text = ${me});
|
|
1362
|
+
drop policy if exists ${policy("insert")} on ${table};
|
|
1363
|
+
create policy ${policy("insert")} on ${table}
|
|
1364
|
+
for insert to authenticated
|
|
1365
|
+
with check (${own});
|
|
1366
|
+
drop policy if exists ${policy("update")} on ${table};
|
|
1367
|
+
create policy ${policy("update")} on ${table}
|
|
1368
|
+
for update to authenticated
|
|
1369
|
+
using (delegator::text = ${me})
|
|
1370
|
+
with check (${own});
|
|
1371
|
+
drop policy if exists ${policy("delete")} on ${table};
|
|
1372
|
+
create policy ${policy("delete")} on ${table}
|
|
1373
|
+
for delete to authenticated
|
|
1374
|
+
using (delegator::text = ${me});
|
|
1375
|
+
|
|
1376
|
+
-- approval delegations: whether a delegator's own rights match approvers.by; only permdock_is_approver calls it
|
|
1377
|
+
create or replace function ${covers}(p_delegator ${user}, p_approvers jsonb, p_tenant text)
|
|
1378
|
+
returns boolean
|
|
1379
|
+
language plpgsql
|
|
1380
|
+
stable
|
|
1381
|
+
security definer
|
|
1382
|
+
set search_path = ''
|
|
1383
|
+
as $$
|
|
1384
|
+
declare
|
|
1385
|
+
v_item jsonb;
|
|
1386
|
+
begin
|
|
1387
|
+
if p_delegator is null or p_approvers is null then
|
|
1388
|
+
return false;
|
|
1389
|
+
end if;
|
|
1390
|
+
if jsonb_typeof(p_approvers) = 'array' then
|
|
1391
|
+
if jsonb_array_length(p_approvers) = 0 then
|
|
1392
|
+
return false;
|
|
1393
|
+
end if;
|
|
1394
|
+
for v_item in select value from jsonb_array_elements(p_approvers) loop
|
|
1395
|
+
if not ${covers}(p_delegator, v_item, p_tenant) then
|
|
1396
|
+
return false;
|
|
1397
|
+
end if;
|
|
1398
|
+
end loop;
|
|
1399
|
+
return true;
|
|
1400
|
+
end if;
|
|
1401
|
+
if jsonb_typeof(p_approvers) <> 'object' then
|
|
1402
|
+
return false;
|
|
1403
|
+
end if;
|
|
1404
|
+
case p_approvers ->> 'kind'
|
|
1405
|
+
when 'user' then
|
|
1406
|
+
return coalesce(p_approvers ->> 'id' = p_delegator::text, false);
|
|
1407
|
+
when 'permission' then
|
|
1408
|
+
if p_tenant is null then
|
|
1409
|
+
return ${holderGlobal};
|
|
1410
|
+
end if;
|
|
1411
|
+
return ${holderTenant};
|
|
1412
|
+
when 'any-of' then
|
|
1413
|
+
if jsonb_typeof(p_approvers -> 'of') <> 'array' then
|
|
1414
|
+
return false;
|
|
1415
|
+
end if;
|
|
1416
|
+
for v_item in select value from jsonb_array_elements(p_approvers -> 'of') loop
|
|
1417
|
+
if ${covers}(p_delegator, v_item, p_tenant) then
|
|
1418
|
+
return true;
|
|
1419
|
+
end if;
|
|
1420
|
+
end loop;
|
|
1421
|
+
return false;
|
|
1422
|
+
else
|
|
1423
|
+
return false;
|
|
1424
|
+
end case;
|
|
1425
|
+
end;
|
|
1426
|
+
$$;
|
|
1427
|
+
revoke execute on function ${covers}(${user}, jsonb, text) from public, anon, authenticated;
|
|
1428
|
+
|
|
1429
|
+
-- approvers with covers: the signed-in user, or a delegate inside an active cover of a delegator who is not the requester
|
|
1430
|
+
create or replace function ${fn}(p_approvers jsonb, p_tenant text, p_requester text, p_resource text)
|
|
1431
|
+
returns boolean
|
|
1432
|
+
language plpgsql
|
|
1433
|
+
stable
|
|
1434
|
+
security definer
|
|
1435
|
+
set search_path = ''
|
|
1436
|
+
as $$
|
|
1437
|
+
declare
|
|
1438
|
+
v_delegator ${user};
|
|
1439
|
+
v_user text := ${me};
|
|
1440
|
+
begin
|
|
1441
|
+
if ${fn}(p_approvers, p_tenant) then
|
|
1442
|
+
return true;
|
|
1443
|
+
end if;
|
|
1444
|
+
if coalesce(v_user, '') = '' or coalesce(p_requester, '') = '' then
|
|
1445
|
+
return false;
|
|
1446
|
+
end if;
|
|
1447
|
+
if p_tenant is not null and not (${member("p_tenant")}) then
|
|
1448
|
+
return false;
|
|
1449
|
+
end if;
|
|
1450
|
+
for v_delegator in
|
|
1451
|
+
select d.delegator
|
|
1452
|
+
from ${table} d
|
|
1453
|
+
where d.delegate::text = v_user
|
|
1454
|
+
and d.delegator::text <> v_user
|
|
1455
|
+
and d.delegator::text <> p_requester
|
|
1456
|
+
and d."from" <= now()
|
|
1457
|
+
and now() < d."until"
|
|
1458
|
+
and (d.tenant is null or d.tenant = p_tenant)
|
|
1459
|
+
and d.permissions is null
|
|
1460
|
+
and (d.resources is null or p_resource = any (d.resources))
|
|
1461
|
+
loop
|
|
1462
|
+
if ${covers}(v_delegator, p_approvers, p_tenant) then
|
|
1463
|
+
return true;
|
|
1464
|
+
end if;
|
|
1465
|
+
end loop;
|
|
1466
|
+
return false;
|
|
1467
|
+
end;
|
|
1468
|
+
$$;
|
|
1469
|
+
revoke execute on function ${fn}(jsonb, text, text, text) from public, anon;
|
|
1470
|
+
grant execute on function ${fn}(jsonb, text, text, text) to authenticated;
|
|
1471
|
+
`;
|
|
1472
|
+
}
|
|
1473
|
+
/** The request fields an adopted table may mirror into its own columns, as SQL over a body. */
|
|
1474
|
+
const MIRRORS = {
|
|
1475
|
+
status: (body) => `${body} ->> 'status'`,
|
|
1476
|
+
permission: (body) => `${body} ->> 'permission'`,
|
|
1477
|
+
tenant: (body) => `${body} #>> '{subject,principal,tenant}'`,
|
|
1478
|
+
principalId: (body) => `${body} #>> '{subject,principal,id}'`,
|
|
1479
|
+
actorId: (body) => `${body} #>> '{subject,actor,id}'`,
|
|
1480
|
+
session: (body) => `${body} #>> '{subject,session}'`,
|
|
1481
|
+
approvals: (body) => `coalesce(jsonb_array_length(${body} -> 'approvals'), 0)`,
|
|
1482
|
+
createdAt: (body) => `${body} ->> 'createdAt'`,
|
|
1483
|
+
expiresAt: (body) => `${body} ->> 'expiresAt'`,
|
|
1484
|
+
resolvedAt: (body) => `${body} ->> 'resolvedAt'`,
|
|
1485
|
+
resolvedBy: (body) => `${body} ->> 'resolvedBy'`,
|
|
1486
|
+
consumedAt: (body) => `${body} ->> 'consumedAt'`
|
|
1487
|
+
};
|
|
1488
|
+
function mirrorsOf(adopt) {
|
|
1489
|
+
const out = [];
|
|
1490
|
+
for (const [field, column] of Object.entries(adopt.mirror ?? {})) {
|
|
1491
|
+
if (!Object.hasOwn(MIRRORS, field)) throw configError(`PermDock CLI: rls.approvals.mirror.${field} is not a request field; use ${Object.keys(MIRRORS).join(", ")}`);
|
|
1492
|
+
if (typeof column !== "string") throw new TypeError(`PermDock CLI: rls.approvals.mirror.${field} must be a column name`);
|
|
1493
|
+
out.push([column, field]);
|
|
1494
|
+
}
|
|
1495
|
+
return out;
|
|
1496
|
+
}
|
|
1497
|
+
/** One row of `table` from `values` (column, SQL), each converted to the column's own type. */
|
|
1498
|
+
function populate(table, values) {
|
|
1499
|
+
return `pg_catalog.jsonb_populate_record(null::${table}, pg_catalog.jsonb_build_object(${values.map(([column, sql]) => `${quoteLiteral(column)}, ${sql}`).join(", ")}))`;
|
|
609
1500
|
}
|
|
1501
|
+
/** The mirrored columns' values for the request `body`. */
|
|
1502
|
+
function mirrorRow(table, mirror, body) {
|
|
1503
|
+
return populate(table, mirror.map(([column, field]) => [column, MIRRORS[field](body)]));
|
|
1504
|
+
}
|
|
1505
|
+
/** `, (<mirrored columns>) = (select …)` for an update that writes `body`; empty without mirrors. */
|
|
1506
|
+
function mirrorSet(table, mirror, body) {
|
|
1507
|
+
if (mirror.length === 0) return "";
|
|
1508
|
+
const columns = mirror.map(([column]) => quoteIdent(column));
|
|
1509
|
+
const picked = columns.map((column) => `r.${column}`).join(", ");
|
|
1510
|
+
return `,\n ${columns.length === 1 ? columns[0] ?? "" : `(${columns.join(", ")})`} = (select ${picked} from ${mirrorRow(table, mirror, body)} r)`;
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* The approval store over a table the app already has (`rls.approvals:
|
|
1514
|
+
* { table, token?, body?, mirror? }`): the same `permdock_approval_*`
|
|
1515
|
+
* functions, reading every field from the request body in `body`, keyed by
|
|
1516
|
+
* the text column `token`, and copying the fields `mirror` names into the
|
|
1517
|
+
* app's own columns, converted to their types, on every write. The table's
|
|
1518
|
+
* grants and policies stay the app's.
|
|
1519
|
+
*/
|
|
1520
|
+
function adoptedApprovalStoreSql(ctx, adopt, jsonSchema = false) {
|
|
1521
|
+
const table = quoteQualified(adopt.table);
|
|
1522
|
+
const base = adopt.table.split(".").at(-1) ?? adopt.table;
|
|
1523
|
+
const token = quoteIdent(adopt.token ?? "token");
|
|
1524
|
+
const body = quoteIdent(adopt.body ?? "body");
|
|
1525
|
+
const mirror = mirrorsOf(adopt);
|
|
1526
|
+
for (const [column] of mirror) if (column === (adopt.token ?? "token") || column === (adopt.body ?? "body")) throw configError(`PermDock CLI: rls.approvals.mirror names the ${column} column, which already holds the token or the body`);
|
|
1527
|
+
const open = adopt.open ?? "insert";
|
|
1528
|
+
if (open !== "insert" && open !== "attach") throw configError(`PermDock CLI: rls.approvals.open must be 'insert' or 'attach', got '${String(open)}'`);
|
|
1529
|
+
const schema = adopt.schema;
|
|
1530
|
+
if (schema !== void 0 && (typeof schema !== "string" || schema === "")) throw configError("PermDock CLI: rls.approvals.schema must be a schema name");
|
|
1531
|
+
const fn = (name) => schema === void 0 ? qualified(ctx, name) : `${quoteIdent(schema)}.${name}`;
|
|
1532
|
+
const index = (name) => quoteIdent(`${base}_permdock_${name}`);
|
|
1533
|
+
const status = `a.${body} ->> 'status'`;
|
|
1534
|
+
const expires = `(a.${body} ->> 'expiresAt')::timestamptz`;
|
|
1535
|
+
const created = `(a.${body} ->> 'createdAt')`;
|
|
1536
|
+
const filter = `(p_filter ->> 'status' is null or ${status} = p_filter ->> 'status')
|
|
1537
|
+
and (p_filter ->> 'tenant' is null or a.${body} #>> '{subject,principal,tenant}' = p_filter ->> 'tenant')
|
|
1538
|
+
and (p_filter ->> 'principalId' is null or a.${body} #>> '{subject,principal,id}' = p_filter ->> 'principalId')
|
|
1539
|
+
and (p_filter ->> 'actorId' is null or a.${body} #>> '{subject,actor,id}' = p_filter ->> 'actorId')
|
|
1540
|
+
and (p_filter ->> 'session' is null or a.${body} #>> '{subject,session}' = p_filter ->> 'session')`;
|
|
1541
|
+
const columns = [
|
|
1542
|
+
token,
|
|
1543
|
+
body,
|
|
1544
|
+
...mirror.map(([column]) => quoteIdent(column))
|
|
1545
|
+
];
|
|
1546
|
+
const inserted = populate(table, [
|
|
1547
|
+
[adopt.token ?? "token", "p_request ->> 'token'"],
|
|
1548
|
+
[adopt.body ?? "body", "p_request"],
|
|
1549
|
+
...mirror.map(([column, field]) => [column, MIRRORS[field]("p_request")])
|
|
1550
|
+
]);
|
|
1551
|
+
const insertSql = `-- open a request; a repeated ask keeps the open record and replaces only an expired one
|
|
1552
|
+
create or replace function ${fn(APPROVAL_STORE.open)}(p_request jsonb)
|
|
1553
|
+
returns void
|
|
1554
|
+
language sql
|
|
1555
|
+
volatile
|
|
1556
|
+
security definer
|
|
1557
|
+
set search_path = ''
|
|
1558
|
+
as $$
|
|
1559
|
+
insert into ${table} as a (${columns.join(", ")})
|
|
1560
|
+
select ${columns.map((column) => `r.${column}`).join(", ")}
|
|
1561
|
+
from ${inserted} r
|
|
1562
|
+
on conflict (${token}) do update
|
|
1563
|
+
set ${columns.filter((column) => column !== token).map((column) => `${column} = excluded.${column}`).join(", ")}
|
|
1564
|
+
where ${status} = 'expired' or ${expires} <= now()
|
|
1565
|
+
$$;`;
|
|
1566
|
+
const attachSql = `create or replace function ${fn(APPROVAL_STORE.open)}(p_request jsonb)
|
|
1567
|
+
returns void
|
|
1568
|
+
language plpgsql
|
|
1569
|
+
volatile
|
|
1570
|
+
security definer
|
|
1571
|
+
set search_path = ''
|
|
1572
|
+
as $$
|
|
1573
|
+
begin
|
|
1574
|
+
update ${table} a
|
|
1575
|
+
set ${body} = p_request${mirrorSet(table, mirror, "p_request")}
|
|
1576
|
+
where a.${token} = p_request ->> 'token'
|
|
1577
|
+
and (a.${body} is null or ${status} = 'expired' or ${expires} <= now());
|
|
1578
|
+
if not found and not exists (select 1 from ${table} a where a.${token} = p_request ->> 'token') then
|
|
1579
|
+
raise exception using
|
|
1580
|
+
errcode = 'P0002',
|
|
1581
|
+
message = 'permdock: no row of ${adopt.table.replaceAll("'", "''")} holds approval token ' || (p_request ->> 'token'),
|
|
1582
|
+
hint = 'insert the application row with this token before opening the request';
|
|
1583
|
+
end if;
|
|
1584
|
+
end;
|
|
1585
|
+
$$;`;
|
|
1586
|
+
return `-- approval store adopted onto ${adopt.table}: ApprovalRequest bodies in ${adopt.body ?? "body"}, keyed by ${adopt.token ?? "token"}; supabaseApprovalStore calls the functions below
|
|
1587
|
+
alter table ${table} add column if not exists ${token} text;
|
|
1588
|
+
alter table ${table} add column if not exists ${body} jsonb;
|
|
1589
|
+
create unique index if not exists ${index("token")} on ${table} (${token});
|
|
1590
|
+
create index if not exists ${index("page")} on ${table} ((${body} ->> 'createdAt') collate "C", ${token} collate "C");
|
|
1591
|
+
create index if not exists ${index("status")} on ${table} ((${body} ->> 'status'));
|
|
1592
|
+
${jsonSchema === false ? "" : bodySchemaSql(table, jsonSchema, body, `${base}_permdock_body_schema`)}
|
|
1593
|
+
${open === "attach" ? attachSql : insertSql}
|
|
1594
|
+
${serverOnly(fn(APPROVAL_STORE.open), "jsonb")}
|
|
1595
|
+
|
|
1596
|
+
create or replace function ${fn(APPROVAL_STORE.get)}(p_token text)
|
|
1597
|
+
returns jsonb
|
|
1598
|
+
language sql
|
|
1599
|
+
stable
|
|
1600
|
+
security definer
|
|
1601
|
+
set search_path = ''
|
|
1602
|
+
as $$
|
|
1603
|
+
select a.${body} from ${table} a where a.${token} = p_token
|
|
1604
|
+
$$;
|
|
1605
|
+
${serverOnly(fn(APPROVAL_STORE.get), "text")}
|
|
1606
|
+
|
|
1607
|
+
-- write a verdict over the record it was computed from: still pending, unexpired, with p_seen approvals
|
|
1608
|
+
create or replace function ${fn(APPROVAL_STORE.resolve)}(p_token text, p_next jsonb, p_seen integer)
|
|
1609
|
+
returns jsonb
|
|
1610
|
+
language sql
|
|
1611
|
+
volatile
|
|
1612
|
+
security definer
|
|
1613
|
+
set search_path = ''
|
|
1614
|
+
as $$
|
|
1615
|
+
update ${table} a
|
|
1616
|
+
set ${body} = p_next${mirrorSet(table, mirror, "p_next")}
|
|
1617
|
+
where a.${token} = p_token
|
|
1618
|
+
and ${status} = 'pending'
|
|
1619
|
+
and ${expires} > now()
|
|
1620
|
+
and coalesce(jsonb_array_length(a.${body} -> 'approvals'), 0) = p_seen
|
|
1621
|
+
returning a.${body}
|
|
1622
|
+
$$;
|
|
1623
|
+
${serverOnly(fn(APPROVAL_STORE.resolve), "text, jsonb, integer")}
|
|
1624
|
+
|
|
1625
|
+
-- consume an approved, unexpired, unconsumed request once
|
|
1626
|
+
create or replace function ${fn(APPROVAL_STORE.consume)}(p_token text, p_now text)
|
|
1627
|
+
returns jsonb
|
|
1628
|
+
language sql
|
|
1629
|
+
volatile
|
|
1630
|
+
security definer
|
|
1631
|
+
set search_path = ''
|
|
1632
|
+
as $$
|
|
1633
|
+
update ${table} a
|
|
1634
|
+
set ${body} = a.${body} || jsonb_build_object('consumedAt', p_now)${mirrorSet(table, mirror, `(a.${body} || jsonb_build_object('consumedAt', p_now))`)}
|
|
1635
|
+
where a.${token} = p_token
|
|
1636
|
+
and ${status} = 'approved'
|
|
1637
|
+
and a.${body} ->> 'consumedAt' is null
|
|
1638
|
+
and ${expires} > p_now::timestamptz
|
|
1639
|
+
returning a.${body}
|
|
1640
|
+
$$;
|
|
1641
|
+
${serverOnly(fn(APPROVAL_STORE.consume), "text, text")}
|
|
1642
|
+
|
|
1643
|
+
-- one page in (createdAt, token) order after the cursor position
|
|
1644
|
+
create or replace function ${fn(APPROVAL_STORE.list)}(p_filter jsonb, p_after_created text, p_after_token text, p_limit integer)
|
|
1645
|
+
returns setof jsonb
|
|
1646
|
+
language sql
|
|
1647
|
+
stable
|
|
1648
|
+
security definer
|
|
1649
|
+
set search_path = ''
|
|
1650
|
+
as $$
|
|
1651
|
+
select a.${body}
|
|
1652
|
+
from ${table} a
|
|
1653
|
+
where a.${body} is not null
|
|
1654
|
+
and ${filter}
|
|
1655
|
+
and (p_after_created is null
|
|
1656
|
+
or (${created} collate "C", a.${token} collate "C") > (p_after_created collate "C", p_after_token collate "C"))
|
|
1657
|
+
order by ${created} collate "C", a.${token} collate "C"
|
|
1658
|
+
limit p_limit
|
|
1659
|
+
$$;
|
|
1660
|
+
${serverOnly(fn(APPROVAL_STORE.list), "jsonb, text, text, integer")}
|
|
1661
|
+
|
|
1662
|
+
create or replace function ${fn(APPROVAL_STORE.expire)}(p_now text)
|
|
1663
|
+
returns integer
|
|
1664
|
+
language sql
|
|
1665
|
+
volatile
|
|
1666
|
+
security definer
|
|
1667
|
+
set search_path = ''
|
|
1668
|
+
as $$
|
|
1669
|
+
with expired as (
|
|
1670
|
+
update ${table} a
|
|
1671
|
+
set ${body} = a.${body} || '{"status":"expired"}'::jsonb${mirrorSet(table, mirror, `(a.${body} || '{"status":"expired"}'::jsonb)`)}
|
|
1672
|
+
where ${status} = 'pending' and ${expires} <= p_now::timestamptz
|
|
1673
|
+
returning 1
|
|
1674
|
+
)
|
|
1675
|
+
select count(*)::integer from expired
|
|
1676
|
+
$$;
|
|
1677
|
+
${serverOnly(fn(APPROVAL_STORE.expire), "text")}
|
|
1678
|
+
|
|
1679
|
+
-- reject every pending request the filter matches, as system:<by>
|
|
1680
|
+
create or replace function ${fn(APPROVAL_STORE.cancel)}(p_filter jsonb, p_by text, p_note text, p_now text)
|
|
1681
|
+
returns integer
|
|
1682
|
+
language sql
|
|
1683
|
+
volatile
|
|
1684
|
+
security definer
|
|
1685
|
+
set search_path = ''
|
|
1686
|
+
as $$
|
|
1687
|
+
with cancelled as (
|
|
1688
|
+
update ${table} a
|
|
1689
|
+
set ${body} = (a.${body} - 'note') || jsonb_strip_nulls(jsonb_build_object(
|
|
1690
|
+
'status', 'rejected', 'resolvedAt', p_now, 'resolvedBy', 'system:' || p_by, 'note', p_note
|
|
1691
|
+
))${mirrorSet(table, mirror, `((a.${body} - 'note') || jsonb_strip_nulls(jsonb_build_object('status', 'rejected', 'resolvedAt', p_now, 'resolvedBy', 'system:' || p_by, 'note', p_note)))`)}
|
|
1692
|
+
where ${status} = 'pending'
|
|
1693
|
+
and ${filter.replaceAll("\n ", "\n ")}
|
|
1694
|
+
returning 1
|
|
1695
|
+
)
|
|
1696
|
+
select count(*)::integer from cancelled
|
|
1697
|
+
$$;
|
|
1698
|
+
${serverOnly(fn(APPROVAL_STORE.cancel), "jsonb, text, text, text")}
|
|
1699
|
+
`;
|
|
1700
|
+
}
|
|
1701
|
+
//#endregion
|
|
1702
|
+
//#region src/cli/rls/permission-holders.ts
|
|
1703
|
+
/** `permitted_<scope>_users(p_permission, p_id)`. Names are part of the SQL contract. */
|
|
1704
|
+
function permittedUsersHelper(scope) {
|
|
1705
|
+
return checkName(`permitted_${scope}_users`);
|
|
1706
|
+
}
|
|
1707
|
+
/** The users with a membership row on the instance `p_id`, or `undefined` when the scope's memberships are not all in tables. */
|
|
1708
|
+
function candidatesSql(ctx, scope, user) {
|
|
1709
|
+
const mapped = scopeTable(ctx, scope);
|
|
1710
|
+
if (mapped !== void 0) return {
|
|
1711
|
+
sql: ` select m.${quoteIdent(mapped.table.user)}::${user}
|
|
1712
|
+
from ${quoteQualified(mapped.table.table)} m
|
|
1713
|
+
where m.${quoteIdent(mapped.column)} = p_id`,
|
|
1714
|
+
keys: []
|
|
1715
|
+
};
|
|
1716
|
+
const sources = holderSources(ctx, scope);
|
|
1717
|
+
if (sources === void 0 || sources === "not-tables") return;
|
|
1718
|
+
return {
|
|
1719
|
+
sql: sources.map((source, index) => ` select h.user_id::${user}
|
|
1720
|
+
from (
|
|
1721
|
+
${source.holders.rows(scope, { id: `v_key_${String(index)}` }).replaceAll(/^/gmu, " ")}
|
|
1722
|
+
) h`).join("\n union\n"),
|
|
1723
|
+
keys: sources.map((source, index) => `v_key_${String(index)} ${source.holders.idType}`)
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
/** `language sql`, or PL/pgSQL when a source's id column needs `p_id` in a `%type` variable. */
|
|
1727
|
+
function functionBody(keys, query) {
|
|
1728
|
+
if (keys.length === 0) return `language sql
|
|
1729
|
+
stable
|
|
1730
|
+
security definer
|
|
1731
|
+
set search_path = ''
|
|
1732
|
+
as $$
|
|
1733
|
+
${query}
|
|
1734
|
+
$$;`;
|
|
1735
|
+
return `language plpgsql
|
|
1736
|
+
stable
|
|
1737
|
+
security definer
|
|
1738
|
+
set search_path = ''
|
|
1739
|
+
as $$
|
|
1740
|
+
declare
|
|
1741
|
+
${keys.map((key) => ` ${key} := p_id;`).join("\n")}
|
|
1742
|
+
begin
|
|
1743
|
+
return query
|
|
1744
|
+
${query.replaceAll(/^/gmu, " ")};
|
|
1745
|
+
end
|
|
1746
|
+
$$;`;
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* One `permitted_<scope>_users(p_permission, p_id)` per scope in `database`
|
|
1750
|
+
* mode: the users who hold a permission key on one instance, read from the
|
|
1751
|
+
* scope's membership rows and the global-roles table and kept when
|
|
1752
|
+
* `permitted_<scope>_ids_by_permission_for` lists the instance or
|
|
1753
|
+
* `permdock_has_permission_for` answers, so denies, custom roles, expiry and
|
|
1754
|
+
* suspension apply as for the user. It names other users, so no client role
|
|
1755
|
+
* may execute it, and a backend role needs its own grant.
|
|
1756
|
+
*/
|
|
1757
|
+
function permissionHoldersSql(ctx) {
|
|
1758
|
+
if (ctx.authorize !== "database") return "";
|
|
1759
|
+
const user = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
1760
|
+
const global = globalRoleRows(ctx);
|
|
1761
|
+
const hasFor = qualified(ctx, PERMISSION_HELPERS.hasFor);
|
|
1762
|
+
return `${ctx.scopes.map(({ name }) => {
|
|
1763
|
+
const candidates = hasPermittedUsers(ctx, name) ? candidatesSql(ctx, name, user) : void 0;
|
|
1764
|
+
if (candidates === void 0) return `-- ${name}: no memberships table or table source, so permitted_${name}_users is not written`;
|
|
1765
|
+
const fn = qualified(ctx, permittedUsersHelper(name));
|
|
1766
|
+
const ids = qualified(ctx, checkName(`${permittedByPermissionHelper(name)}_for`));
|
|
1767
|
+
const type = scopeTypeOf(ctx, name);
|
|
1768
|
+
return `-- the users who hold a permission on one ${name}: its members and the global-role holders for whom the _for helpers answer; no client role may execute it
|
|
1769
|
+
create or replace function ${fn}(p_permission text, p_id ${type})
|
|
1770
|
+
returns setof ${user}
|
|
1771
|
+
${functionBody(candidates.keys, ` select c.user_id
|
|
1772
|
+
from (
|
|
1773
|
+
${candidates.sql}
|
|
1774
|
+
union
|
|
1775
|
+
select ${global.userSql}::${user}
|
|
1776
|
+
from ${global.from}
|
|
1777
|
+
) c(user_id)
|
|
1778
|
+
where c.user_id is not null
|
|
1779
|
+
and (
|
|
1780
|
+
p_id in (select ${ids}(c.user_id, p_permission))
|
|
1781
|
+
or ${hasFor}(c.user_id, p_permission)
|
|
1782
|
+
)
|
|
1783
|
+
order by 1`)}
|
|
1784
|
+
revoke execute on function ${fn}(text, ${type}) from public, anon, authenticated;`;
|
|
1785
|
+
}).join("\n\n")}\n`;
|
|
1786
|
+
}
|
|
1787
|
+
//#endregion
|
|
1788
|
+
//#region src/cli/supabase-hook/sql.ts
|
|
1789
|
+
const MANAGED_TRIGGER = "permdock_protect_managed";
|
|
610
1790
|
function hookSql(parts) {
|
|
611
1791
|
const schema = quoteIdent(parts.schema);
|
|
612
1792
|
const fn = `${schema}.custom_access_token_hook`;
|
|
@@ -616,14 +1796,14 @@ function hookSql(parts) {
|
|
|
616
1796
|
from ${rows.from}
|
|
617
1797
|
where ${rows.userSql} = v_roles_user;`;
|
|
618
1798
|
const plan = parts.attrs;
|
|
619
|
-
const
|
|
1799
|
+
const tableMembershipSource = plan === void 0 || plan.columns.length === 0 || plan.table === void 0 ? "" : `
|
|
620
1800
|
select jsonb_strip_nulls(jsonb_build_object(${plan.columns.map((column) => `${quoteLiteral(column)}, to_jsonb(p.${quoteIdent(column)})`).join(", ")}))
|
|
621
1801
|
into attrs
|
|
622
|
-
from ${
|
|
1802
|
+
from ${quoteQualified(plan.table)} p
|
|
623
1803
|
where p.${quoteIdent(plan.id)} = v_attrs_user;`;
|
|
624
1804
|
const fromMeta = plan === void 0 || plan.meta.length === 0 ? "" : `
|
|
625
1805
|
attrs := coalesce(attrs, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(${plan.meta.map((key) => `${quoteLiteral(key)}, claims -> 'app_metadata' -> ${quoteLiteral(key)}`).join(", ")}));`;
|
|
626
|
-
const attrs = plan === void 0 ? "" : `${
|
|
1806
|
+
const attrs = plan === void 0 ? "" : `${tableMembershipSource}${fromMeta}
|
|
627
1807
|
if attrs is not null and attrs <> '{}'::jsonb then
|
|
628
1808
|
used := octet_length(attrs::text);
|
|
629
1809
|
if used > budget then
|
|
@@ -721,7 +1901,7 @@ function extraGrantsSql(extra) {
|
|
|
721
1901
|
}
|
|
722
1902
|
function beforeGrantsSql(before, extra) {
|
|
723
1903
|
const granted = new Set(extra.map((entry) => entry.fn.split(".")[0]));
|
|
724
|
-
return [...[...new Set(before.map((fn) => fn
|
|
1904
|
+
return [...[...new Set(before.map((fn) => beforeFirst(fn, ".")))].filter((name) => !granted.has(name)).map((name) => `\ngrant usage on schema ${quoteIdent(name)} to supabase_auth_admin;`), ...before.map((fn) => `\ngrant execute on function ${quoteTable(fn)}(jsonb) to supabase_auth_admin;`)].join("");
|
|
725
1905
|
}
|
|
726
1906
|
/**
|
|
727
1907
|
* Execute on each `member_<scope>_ids_for(uuid)` for the `hook.claims`
|
|
@@ -752,6 +1932,92 @@ function readsSql(parts) {
|
|
|
752
1932
|
if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
|
|
753
1933
|
return [...[...new Set([...reads.keys()].map((name) => name.includes(".") ? name.split(".")[0] : "public").filter((schema) => schema !== void 0 && schema !== parts.schema && schema !== "auth"))].map((schema) => `grant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`), ...[...reads].map(([name, label]) => authAdminRead(name, label))].join("\n");
|
|
754
1934
|
}
|
|
1935
|
+
function managedSql(parts) {
|
|
1936
|
+
const managed = parts.sources.filter((source) => source.sql.managed !== void 0);
|
|
1937
|
+
if (managed.length === 0) return "";
|
|
1938
|
+
const guard = `${quoteIdent(parts.schema)}.permdock_protect_managed`;
|
|
1939
|
+
return `-- memberships the identity provider owns: clients (anon, authenticated) cannot write them
|
|
1940
|
+
create or replace function ${guard}()
|
|
1941
|
+
returns trigger
|
|
1942
|
+
language plpgsql
|
|
1943
|
+
set search_path = ''
|
|
1944
|
+
as $$
|
|
1945
|
+
declare
|
|
1946
|
+
column_name text := tg_argv[0];
|
|
1947
|
+
begin
|
|
1948
|
+
if current_user in ('anon', 'authenticated') and (
|
|
1949
|
+
column_name = ''
|
|
1950
|
+
or (tg_op <> 'INSERT' and to_jsonb(old) ->> column_name = 'idp')
|
|
1951
|
+
or (tg_op <> 'DELETE' and to_jsonb(new) ->> column_name = 'idp')
|
|
1952
|
+
) then
|
|
1953
|
+
raise exception 'PermDock: % rows managed by the identity provider cannot be changed here', tg_table_name
|
|
1954
|
+
using errcode = '42501';
|
|
1955
|
+
end if;
|
|
1956
|
+
return coalesce(new, old);
|
|
1957
|
+
end;
|
|
1958
|
+
$$;
|
|
1959
|
+
${managed.map((source) => `drop trigger if exists ${quoteIdent(MANAGED_TRIGGER)} on ${quoteQualified(source.sql.table)};
|
|
1960
|
+
create trigger ${quoteIdent(MANAGED_TRIGGER)}
|
|
1961
|
+
before insert or update or delete on ${quoteQualified(source.sql.table)}
|
|
1962
|
+
for each row execute function ${guard}(${quoteLiteral(source.sql.managed ?? "")});`).join("\n")}`;
|
|
1963
|
+
}
|
|
1964
|
+
/** The `config.toml` block that enables the hook and bounds token staleness. */
|
|
1965
|
+
function configToml(schema, jwtExpiry) {
|
|
1966
|
+
return `[auth]
|
|
1967
|
+
jwt_expiry = ${String(jwtExpiry)}
|
|
1968
|
+
|
|
1969
|
+
[auth.hook.custom_access_token]
|
|
1970
|
+
enabled = true
|
|
1971
|
+
uri = "${hookUri(schema)}"`;
|
|
1972
|
+
}
|
|
1973
|
+
const BUDGET_MEASURE = "octet_length(memberships::text) + octet_length(attrs::text)";
|
|
1974
|
+
function hookParts(scopes, config, overrides) {
|
|
1975
|
+
const hook = config.supabase?.hook;
|
|
1976
|
+
if (hook === void 0) throw configError("PermDock CLI: supabase hook generate needs supabase.hook in permdock.config.ts");
|
|
1977
|
+
const root = rootScope(scopes) ?? "tenant";
|
|
1978
|
+
const suspension = checkSuspension(hook.suspension ?? config.rls?.suspension, scopes);
|
|
1979
|
+
const schema = overrides.schema ?? hook.schema ?? config.rls?.schema ?? "permdock";
|
|
1980
|
+
quoteIdent(schema);
|
|
1981
|
+
const tenantClaim = config.rls?.tenantClaim ?? "tenant_id";
|
|
1982
|
+
const extraPlan = extraClaimsPlan(hook.claims, tenantClaim);
|
|
1983
|
+
checkSources({
|
|
1984
|
+
sources: hook.memberships,
|
|
1985
|
+
scopes
|
|
1986
|
+
});
|
|
1987
|
+
const parts = {
|
|
1988
|
+
schema,
|
|
1989
|
+
scopes,
|
|
1990
|
+
root,
|
|
1991
|
+
sources: hook.memberships,
|
|
1992
|
+
budget: positiveInteger(overrides.budget ?? hook.budget ?? 1024, "budget"),
|
|
1993
|
+
version: hook.version !== false,
|
|
1994
|
+
jwtExpiry: positiveInteger(hook.jwtExpiry ?? 900, "jwtExpiry"),
|
|
1995
|
+
tenantClaim,
|
|
1996
|
+
users: suspension?.users,
|
|
1997
|
+
hook,
|
|
1998
|
+
roles: hook.roles === false ? void 0 : globalRoleSource(hook.roles ?? config.rls?.roles ?? { table: `${schema}.user_roles` }, "public", "r", config.rls?.customRoleWrites?.roles),
|
|
1999
|
+
active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
|
|
2000
|
+
attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
|
|
2001
|
+
extra: extraPlan.claims,
|
|
2002
|
+
before: beforePlan(hook.before),
|
|
2003
|
+
helpers: {
|
|
2004
|
+
schema: config.rls?.schema ?? "permdock",
|
|
2005
|
+
...memberForPlan({
|
|
2006
|
+
scopes,
|
|
2007
|
+
...config.rls?.memberships === void 0 ? {} : { memberships: config.rls.memberships },
|
|
2008
|
+
memberSources: config.rls?.membershipSources ?? hook.memberships
|
|
2009
|
+
})
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
if (parts.attrs !== void 0 && parts.attrs.errors.length > 0) throw configError(`PermDock CLI: ${parts.attrs.errors.join("; ")}`);
|
|
2013
|
+
if (extraPlan.errors.length > 0) throw configError(`PermDock CLI: ${extraPlan.errors.join("; ")}`);
|
|
2014
|
+
quoteIdent(parts.tenantClaim);
|
|
2015
|
+
return parts;
|
|
2016
|
+
}
|
|
2017
|
+
//#endregion
|
|
2018
|
+
//#region src/cli/supabase-hook/version.ts
|
|
2019
|
+
const VERSION_TRIGGER = "permdock_authz_version";
|
|
2020
|
+
const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
|
|
755
2021
|
function versionSql(parts) {
|
|
756
2022
|
if (!parts.version) return "";
|
|
757
2023
|
const schema = quoteIdent(parts.schema);
|
|
@@ -787,225 +2053,22 @@ returns trigger
|
|
|
787
2053
|
language plpgsql
|
|
788
2054
|
security definer
|
|
789
2055
|
set search_path = ''
|
|
790
|
-
as $$
|
|
791
|
-
declare
|
|
792
|
-
column_name text := tg_argv[0];
|
|
793
|
-
begin
|
|
794
|
-
perform ${bumpFor}(array[
|
|
795
|
-
case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
|
|
796
|
-
case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
|
|
797
|
-
]::uuid[]);
|
|
798
|
-
return null;
|
|
799
|
-
end;
|
|
800
|
-
$$;
|
|
801
|
-
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
802
|
-
${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${
|
|
803
|
-
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
804
|
-
after insert or update or delete on ${
|
|
805
|
-
for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${memberUsersVersionSql(parts, bumpFor)}${roleKeysVersionSql(parts, versionTable)}`;
|
|
806
|
-
}
|
|
807
|
-
const SUBJECT_FOR = "subject_for";
|
|
808
|
-
/**
|
|
809
|
-
* `subject_for(p_user uuid) returns jsonb`: what the hook would put in a
|
|
810
|
-
* token for `p_user`, from the same membership, role, suspension and version
|
|
811
|
-
* reads, plus the custom roles the user holds when `database` mode keeps them
|
|
812
|
-
* in tables. A backend that only has PostgREST (`postgrestSources`) builds a
|
|
813
|
-
* subject for an acting user or a job from it. Naming the user is why no
|
|
814
|
-
* client role may execute it.
|
|
815
|
-
*/
|
|
816
|
-
function subjectForSql(parts, config) {
|
|
817
|
-
const schema = quoteIdent(parts.schema);
|
|
818
|
-
const fn = `${schema}.${SUBJECT_FOR}`;
|
|
819
|
-
const rows = parts.roles;
|
|
820
|
-
const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
|
|
821
|
-
into held
|
|
822
|
-
from ${rows.from}
|
|
823
|
-
where ${rows.userSql} = v_roles_user;`;
|
|
824
|
-
const suspended = parts.users === void 0 ? "" : `
|
|
825
|
-
if not ${activeRowSql(parts.users, "uid")} then
|
|
826
|
-
return jsonb_build_object('id', uid::text, 'active', false);
|
|
827
|
-
end if;`;
|
|
828
|
-
const version = parts.version ? `
|
|
829
|
-
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
830
|
-
ver := coalesce(ver, 0);` : "";
|
|
831
|
-
const custom = config.rls?.customRoles === true && resolveAuthorize(config) === "database" ? customRolesOfSql(parts) : "";
|
|
832
|
-
return `-- the subject the hook would mint for p_user, for a backend acting for a stored user; no client role may execute it
|
|
833
|
-
create or replace function ${fn}(p_user uuid)
|
|
834
|
-
returns jsonb
|
|
835
|
-
language plpgsql
|
|
836
|
-
stable
|
|
837
|
-
security definer
|
|
838
|
-
set search_path = ''
|
|
839
|
-
as $$
|
|
840
|
-
declare
|
|
841
|
-
uid uuid := p_user;
|
|
842
|
-
held jsonb;
|
|
843
|
-
memberships jsonb;
|
|
844
|
-
custom jsonb := '[]'::jsonb;
|
|
845
|
-
ver bigint;${typedUsers(parts)}
|
|
846
|
-
begin
|
|
847
|
-
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
848
|
-
return null;
|
|
849
|
-
end if;${suspended}
|
|
850
|
-
${roles}
|
|
851
|
-
select coalesce(jsonb_agg(x.entry order by x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text), '[]'::jsonb)
|
|
852
|
-
into memberships
|
|
853
|
-
from (
|
|
854
|
-
${entriesSql(parts).replaceAll(/^/gmu, " ")}
|
|
855
|
-
) x;${custom}${version}
|
|
856
|
-
return jsonb_strip_nulls(jsonb_build_object(
|
|
857
|
-
'id', uid::text,
|
|
858
|
-
'active', true,
|
|
859
|
-
'roles', held,
|
|
860
|
-
'memberships', memberships,
|
|
861
|
-
'customRoles', custom,
|
|
862
|
-
'authzVersion', ver
|
|
863
|
-
));
|
|
864
|
-
end;
|
|
865
|
-
$$;
|
|
866
|
-
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
867
|
-
}
|
|
868
|
-
/**
|
|
869
|
-
* The PostgREST wrappers `supabase.hook.api` asks for: one `security definer`
|
|
870
|
-
* function per reader, in the API schema, executable by `service_role` only.
|
|
871
|
-
*/
|
|
872
|
-
function apiWrappersSql(parts) {
|
|
873
|
-
const api = parts.hook.api;
|
|
874
|
-
if (api === void 0) return "";
|
|
875
|
-
const target = api.schema ?? "public";
|
|
876
|
-
const names = hookApiNames(api.prefix);
|
|
877
|
-
const source = quoteIdent(parts.schema);
|
|
878
|
-
const wrapper = (name, inner, params, args, types, returns) => {
|
|
879
|
-
const fn = `${quoteIdent(target)}.${quoteIdent(name)}`;
|
|
880
|
-
if (target === parts.schema && name === inner) throw new Error(`PermDock CLI: supabase.hook.api would replace ${source}.${inner}: set api.schema or api.prefix`);
|
|
881
|
-
return `create or replace function ${fn}(${params})
|
|
882
|
-
returns ${returns}
|
|
883
|
-
language sql
|
|
884
|
-
stable
|
|
885
|
-
security definer
|
|
886
|
-
set search_path = ''
|
|
887
|
-
as $$ select ${source}.${inner}(${args}) $$;
|
|
888
|
-
revoke execute on function ${fn}(${types}) from public, anon, authenticated;
|
|
889
|
-
grant execute on function ${fn}(${types}) to service_role;`;
|
|
890
|
-
};
|
|
891
|
-
return `-- supabase.hook.api: wrappers in ${quoteIdent(target)} for a backend that reaches the hook schema through the Data API; only service_role may execute them
|
|
892
|
-
${wrapper(names.subjectFor, SUBJECT_FOR, "p_user uuid", "p_user", "uuid", "jsonb")}
|
|
893
|
-
|
|
894
|
-
${wrapper(names.membersOf, MEMBERS_OF, "p_scope text, p_id text", "p_scope, p_id", "text, text", "jsonb")}
|
|
895
|
-
|
|
896
|
-
${wrapper(names.authzVersionFor, AUTHZ_VERSION_FOR, "p_user uuid", "p_user", "uuid", "bigint")}`;
|
|
897
|
-
}
|
|
898
|
-
const AUTHZ_VERSION_FOR = "authz_version_for";
|
|
899
|
-
/**
|
|
900
|
-
* `authz_version_for(p_user uuid) returns bigint`: the authorization version
|
|
901
|
-
* `subject_for` reports, without reading roles or memberships, so a
|
|
902
|
-
* `claimsFirst` reread can check a token's freshness on every request and read
|
|
903
|
-
* the full subject only when it is behind. `null` for an unknown or suspended
|
|
904
|
-
* user, and when the version is off.
|
|
905
|
-
*/
|
|
906
|
-
function authzVersionForSql(parts) {
|
|
907
|
-
const schema = quoteIdent(parts.schema);
|
|
908
|
-
const fn = `${schema}.${AUTHZ_VERSION_FOR}`;
|
|
909
|
-
return `-- the authorization version subject_for reports for p_user, for a cheap freshness check; no client role may execute it
|
|
910
|
-
create or replace function ${fn}(p_user uuid)
|
|
911
|
-
returns bigint
|
|
912
|
-
language plpgsql
|
|
913
|
-
stable
|
|
914
|
-
security definer
|
|
915
|
-
set search_path = ''
|
|
916
|
-
as $$
|
|
917
|
-
declare
|
|
918
|
-
uid uuid := p_user;
|
|
919
|
-
ver bigint;
|
|
920
|
-
begin
|
|
921
|
-
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
922
|
-
return null;
|
|
923
|
-
end if;${parts.users === void 0 ? "" : `
|
|
924
|
-
if not ${activeRowSql(parts.users, "uid")} then
|
|
925
|
-
return null;
|
|
926
|
-
end if;`}${parts.version ? `
|
|
927
|
-
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
928
|
-
ver := coalesce(ver, 0);` : ""}
|
|
929
|
-
return ver;
|
|
930
|
-
end;
|
|
931
|
-
$$;
|
|
932
|
-
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
933
|
-
}
|
|
934
|
-
const MEMBERS_OF = "members_of";
|
|
935
|
-
function membersOfSql(parts) {
|
|
936
|
-
const fn = `${quoteIdent(parts.schema)}.${MEMBERS_OF}`;
|
|
937
|
-
const keep = parts.sources.some((source) => source.sql.keeps);
|
|
938
|
-
const rows = parts.sources.map((source) => source.sql.list(keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
939
|
-
return `-- the live memberships of one scope instance, for a backend that lists members over PostgREST; no client role may execute it
|
|
940
|
-
create or replace function ${fn}(p_scope text, p_id text)
|
|
941
|
-
returns jsonb
|
|
942
|
-
language sql
|
|
943
|
-
stable
|
|
944
|
-
security definer
|
|
945
|
-
set search_path = ''
|
|
946
|
-
as $$
|
|
947
|
-
select coalesce(jsonb_agg(jsonb_build_object(
|
|
948
|
-
'principal', jsonb_build_object('id', s.user_id),
|
|
949
|
-
'membership', jsonb_strip_nulls(jsonb_build_object(
|
|
950
|
-
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
951
|
-
'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
|
|
952
|
-
'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
|
|
953
|
-
'managedBy', s.managed_by, 'entitlements', s.seats${keep ? ", 'keep', s.keep" : ""}
|
|
954
|
-
))
|
|
955
|
-
) order by s.user_id, s.roles::text), '[]'::jsonb)
|
|
956
|
-
from (
|
|
957
|
-
${rows}
|
|
958
|
-
) s
|
|
959
|
-
$$;
|
|
960
|
-
revoke execute on function ${fn}(text, text) from public, anon, authenticated;`;
|
|
961
|
-
}
|
|
962
|
-
/**
|
|
963
|
-
* The custom roles `p_user` holds: tenant roles named on a membership of the
|
|
964
|
-
* scope (and instance) they live at, and platform roles among the global
|
|
965
|
-
* roles, each with its grants and includes in `CustomRole` form.
|
|
966
|
-
*/
|
|
967
|
-
function customRolesOfSql(parts) {
|
|
968
|
-
const helpers = quoteIdent(parts.helpers.schema);
|
|
969
|
-
const perms = `${helpers}.custom_role_permissions`;
|
|
970
|
-
const includes = `${helpers}.custom_role_includes`;
|
|
971
|
-
const root = quoteLiteral(parts.root);
|
|
972
|
-
const match = `p.tenant_id is not distinct from c.tenant_id and p.scope = c.scope and p.scope_id is not distinct from c.scope_id and p.role = c.role`;
|
|
973
|
-
return `
|
|
974
|
-
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
975
|
-
'name', c.role,
|
|
976
|
-
'tenant', c.tenant_id::text,
|
|
977
|
-
'scope', c.scope,
|
|
978
|
-
'id', c.scope_id,
|
|
979
|
-
'grants', (
|
|
980
|
-
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
981
|
-
'permission', p.permission,
|
|
982
|
-
'effect', p.effect,
|
|
983
|
-
'level', to_jsonb(p) ->> 'level'
|
|
984
|
-
)) order by p.permission, p.effect), '[]'::jsonb)
|
|
985
|
-
from ${perms} p
|
|
986
|
-
where ${match}
|
|
987
|
-
),
|
|
988
|
-
'includes', (
|
|
989
|
-
select jsonb_agg(p.include_role order by p.include_role)
|
|
990
|
-
from ${includes} p
|
|
991
|
-
where ${match}
|
|
992
|
-
)
|
|
993
|
-
)) order by c.tenant_id::text, c.scope, c.scope_id, c.role), '[]'::jsonb)
|
|
994
|
-
into custom
|
|
995
|
-
from (
|
|
996
|
-
select tenant_id, scope, scope_id, role from ${perms}
|
|
997
|
-
union
|
|
998
|
-
select tenant_id, scope, scope_id, role from ${includes}
|
|
999
|
-
) c
|
|
1000
|
-
where (c.scope = 'global' and c.tenant_id is null and held ? c.role)
|
|
1001
|
-
or exists (
|
|
1002
|
-
select 1
|
|
1003
|
-
from jsonb_array_elements(memberships) m
|
|
1004
|
-
where m -> 'roles' ? c.role
|
|
1005
|
-
and m ->> 'scope' = c.scope
|
|
1006
|
-
and c.tenant_id::text = case when m ->> 'scope' = ${root} then m ->> 'id' else m -> 'within' ->> ${root} end
|
|
1007
|
-
and (c.scope_id is null or c.scope_id = m ->> 'id')
|
|
1008
|
-
);`;
|
|
2056
|
+
as $$
|
|
2057
|
+
declare
|
|
2058
|
+
column_name text := tg_argv[0];
|
|
2059
|
+
begin
|
|
2060
|
+
perform ${bumpFor}(array[
|
|
2061
|
+
case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
|
|
2062
|
+
case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
|
|
2063
|
+
]::uuid[]);
|
|
2064
|
+
return null;
|
|
2065
|
+
end;
|
|
2066
|
+
$$;
|
|
2067
|
+
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
2068
|
+
${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${quoteQualified(name)};
|
|
2069
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
2070
|
+
after insert or update or delete on ${quoteQualified(name)}
|
|
2071
|
+
for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${memberUsersVersionSql(parts, bumpFor)}${roleKeysVersionSql(parts, versionTable)}`;
|
|
1009
2072
|
}
|
|
1010
2073
|
function signedUp(user) {
|
|
1011
2074
|
return `exists (select 1 from auth.users a where a.id = ${user})`;
|
|
@@ -1046,7 +2109,7 @@ function memberUsersVersionSql(parts, bumpFor) {
|
|
|
1046
2109
|
const linkedBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_linked_users`;
|
|
1047
2110
|
const groups = [...byTable];
|
|
1048
2111
|
const lookup = (users, indent) => `${indent}perform ${bumpFor}(array(
|
|
1049
|
-
${indent} select u.${quoteIdent(users.key)}::uuid from ${
|
|
2112
|
+
${indent} select u.${quoteIdent(users.key)}::uuid from ${quoteQualified(users.table)} u
|
|
1050
2113
|
${indent} where ((tg_op <> 'INSERT' and u.${quoteIdent(users.id)} = old.${quoteIdent(users.ref)})
|
|
1051
2114
|
${indent} or (tg_op <> 'DELETE' and u.${quoteIdent(users.id)} = new.${quoteIdent(users.ref)}))
|
|
1052
2115
|
${indent} and ${signedUp(`u.${quoteIdent(users.key)}::uuid`)}
|
|
@@ -1059,7 +2122,7 @@ ${group.users.map((users) => lookup(users, " ")).join("\n")}
|
|
|
1059
2122
|
end if;`;
|
|
1060
2123
|
}).join("\n");
|
|
1061
2124
|
const memberTriggers = groups.map(([, group]) => {
|
|
1062
|
-
const target =
|
|
2125
|
+
const target = quoteQualified(group.table);
|
|
1063
2126
|
return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
|
|
1064
2127
|
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1065
2128
|
after insert or update or delete on ${target}
|
|
@@ -1077,7 +2140,7 @@ create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
|
1077
2140
|
userTables.set(key, entry);
|
|
1078
2141
|
}
|
|
1079
2142
|
const userTriggers = [...userTables.values()].map((entry) => {
|
|
1080
|
-
const target =
|
|
2143
|
+
const target = quoteQualified(entry.table);
|
|
1081
2144
|
const name = quoteIdent(`${VERSION_TRIGGER}_${entry.key}`);
|
|
1082
2145
|
return `drop trigger if exists ${name} on ${target};
|
|
1083
2146
|
create trigger ${name}
|
|
@@ -1169,7 +2232,7 @@ ${indent}end if;
|
|
|
1169
2232
|
${indent}insert into ${versionTable} as v (user_id, version)
|
|
1170
2233
|
${indent}select distinct h.user_id, 1
|
|
1171
2234
|
${indent}from (
|
|
1172
|
-
${indent} ${holders.map((holder) => holder.userThrough === void 0 ? `select h.${quoteIdent(holder.user)}::uuid as user_id from ${
|
|
2235
|
+
${indent} ${holders.map((holder) => holder.userThrough === void 0 ? `select h.${quoteIdent(holder.user)}::uuid as user_id from ${quoteQualified(holder.table)} h where h.${quoteIdent(holder.through.ref)} = old.${quoteIdent(holder.through.id)}` : `select u.${quoteIdent(holder.userThrough.key)}::uuid as user_id from ${quoteQualified(holder.table)} h join ${quoteQualified(holder.userThrough.table)} u on u.${quoteIdent(holder.userThrough.id)} = h.${quoteIdent(holder.user)} where h.${quoteIdent(holder.through.ref)} = old.${quoteIdent(holder.through.id)}`).join(`\n${indent} union\n${indent} `)}
|
|
1173
2236
|
${indent}) h
|
|
1174
2237
|
${indent}where h.user_id is not null
|
|
1175
2238
|
${indent} and exists (select 1 from auth.users au where au.id = h.user_id)
|
|
@@ -1217,143 +2280,15 @@ end;
|
|
|
1217
2280
|
$$;
|
|
1218
2281
|
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
1219
2282
|
${groups.map(([, group]) => {
|
|
1220
|
-
const target =
|
|
2283
|
+
const target = quoteQualified(group.table);
|
|
1221
2284
|
return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
|
|
1222
2285
|
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1223
2286
|
after update or delete on ${target}
|
|
1224
2287
|
for each row execute function ${bump}();`;
|
|
1225
2288
|
}).join("\n")}`;
|
|
1226
2289
|
}
|
|
1227
|
-
/**
|
|
1228
|
-
* Fails the migration when a client role can write an `attrs` column: a
|
|
1229
|
-
* claim the user can set is not a server-owned attribute.
|
|
1230
|
-
*/
|
|
1231
|
-
function attrsGuardSql(plan) {
|
|
1232
|
-
if (plan?.table === void 0 || plan.columns.length === 0) return "";
|
|
1233
|
-
const target = quoteLiteral(table(plan.table));
|
|
1234
|
-
return `-- attrs must be server-owned: refuse columns anon or authenticated can insert or update
|
|
1235
|
-
do $$
|
|
1236
|
-
begin
|
|
1237
|
-
if exists (
|
|
1238
|
-
select 1
|
|
1239
|
-
from (values ${plan.columns.map((column) => `(${quoteLiteral(column)})`).join(", ")}) c(name)
|
|
1240
|
-
cross join (values ('anon'), ('authenticated')) r(role)
|
|
1241
|
-
where has_column_privilege(r.role, ${target}, c.name, 'INSERT')
|
|
1242
|
-
or has_column_privilege(r.role, ${target}, c.name, 'UPDATE')
|
|
1243
|
-
) then
|
|
1244
|
-
raise exception 'PermDock: an attrs column of % is writable by anon or authenticated; revoke insert and update on it before it becomes a claim', ${target}
|
|
1245
|
-
using errcode = '42501';
|
|
1246
|
-
end if;
|
|
1247
|
-
end
|
|
1248
|
-
$$;`;
|
|
1249
|
-
}
|
|
1250
|
-
function managedSql(parts) {
|
|
1251
|
-
const managed = parts.sources.filter((source) => source.sql.managed !== void 0);
|
|
1252
|
-
if (managed.length === 0) return "";
|
|
1253
|
-
const guard = `${quoteIdent(parts.schema)}.permdock_protect_managed`;
|
|
1254
|
-
return `-- memberships the identity provider owns: clients (anon, authenticated) cannot write them
|
|
1255
|
-
create or replace function ${guard}()
|
|
1256
|
-
returns trigger
|
|
1257
|
-
language plpgsql
|
|
1258
|
-
set search_path = ''
|
|
1259
|
-
as $$
|
|
1260
|
-
declare
|
|
1261
|
-
column_name text := tg_argv[0];
|
|
1262
|
-
begin
|
|
1263
|
-
if current_user in ('anon', 'authenticated') and (
|
|
1264
|
-
column_name = ''
|
|
1265
|
-
or (tg_op <> 'INSERT' and to_jsonb(old) ->> column_name = 'idp')
|
|
1266
|
-
or (tg_op <> 'DELETE' and to_jsonb(new) ->> column_name = 'idp')
|
|
1267
|
-
) then
|
|
1268
|
-
raise exception 'PermDock: % rows managed by the identity provider cannot be changed here', tg_table_name
|
|
1269
|
-
using errcode = '42501';
|
|
1270
|
-
end if;
|
|
1271
|
-
return coalesce(new, old);
|
|
1272
|
-
end;
|
|
1273
|
-
$$;
|
|
1274
|
-
${managed.map((source) => `drop trigger if exists ${quoteIdent(MANAGED_TRIGGER)} on ${table(source.sql.table)};
|
|
1275
|
-
create trigger ${quoteIdent(MANAGED_TRIGGER)}
|
|
1276
|
-
before insert or update or delete on ${table(source.sql.table)}
|
|
1277
|
-
for each row execute function ${guard}(${quoteLiteral(source.sql.managed ?? "")});`).join("\n")}`;
|
|
1278
|
-
}
|
|
1279
|
-
/** The `config.toml` block that enables the hook and bounds token staleness. */
|
|
1280
|
-
function configToml(schema, jwtExpiry) {
|
|
1281
|
-
return `[auth]
|
|
1282
|
-
jwt_expiry = ${String(jwtExpiry)}
|
|
1283
|
-
|
|
1284
|
-
[auth.hook.custom_access_token]
|
|
1285
|
-
enabled = true
|
|
1286
|
-
uri = "${hookUri(schema)}"`;
|
|
1287
|
-
}
|
|
1288
|
-
const BUDGET_MEASURE = "octet_length(memberships::text) + octet_length(attrs::text)";
|
|
1289
|
-
function hookParts(scopes, config, overrides) {
|
|
1290
|
-
const hook = config.supabase?.hook;
|
|
1291
|
-
if (hook === void 0) throw new Error("PermDock CLI: supabase hook generate needs supabase.hook in permdock.config.ts");
|
|
1292
|
-
const root = rootScope(scopes) ?? "tenant";
|
|
1293
|
-
const suspension = checkSuspension(hook.suspension ?? config.rls?.suspension, scopes);
|
|
1294
|
-
const schema = overrides.schema ?? hook.schema ?? config.rls?.schema ?? "permdock";
|
|
1295
|
-
quoteIdent(schema);
|
|
1296
|
-
const tenantClaim = config.rls?.tenantClaim ?? "tenant_id";
|
|
1297
|
-
const extraPlan = extraClaimsPlan(hook.claims, tenantClaim);
|
|
1298
|
-
checkSources({
|
|
1299
|
-
sources: hook.memberships,
|
|
1300
|
-
scopes
|
|
1301
|
-
});
|
|
1302
|
-
const parts = {
|
|
1303
|
-
schema,
|
|
1304
|
-
scopes,
|
|
1305
|
-
root,
|
|
1306
|
-
sources: hook.memberships,
|
|
1307
|
-
budget: positiveInteger(overrides.budget ?? hook.budget ?? 1024, "budget"),
|
|
1308
|
-
version: hook.version !== false,
|
|
1309
|
-
jwtExpiry: positiveInteger(hook.jwtExpiry ?? 900, "jwtExpiry"),
|
|
1310
|
-
tenantClaim,
|
|
1311
|
-
users: suspension?.users,
|
|
1312
|
-
hook,
|
|
1313
|
-
roles: hook.roles === false ? void 0 : globalRoleSource(hook.roles ?? config.rls?.roles ?? { table: `${schema}.user_roles` }, "public", "r", config.rls?.customRoleWrites?.roles),
|
|
1314
|
-
active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
|
|
1315
|
-
attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
|
|
1316
|
-
extra: extraPlan.claims,
|
|
1317
|
-
before: beforePlan(hook.before),
|
|
1318
|
-
helpers: {
|
|
1319
|
-
schema: config.rls?.schema ?? "permdock",
|
|
1320
|
-
...memberForPlan({
|
|
1321
|
-
scopes,
|
|
1322
|
-
...config.rls?.memberships === void 0 ? {} : { memberships: config.rls.memberships },
|
|
1323
|
-
memberSources: config.rls?.membershipSources ?? hook.memberships
|
|
1324
|
-
})
|
|
1325
|
-
}
|
|
1326
|
-
};
|
|
1327
|
-
if (parts.attrs !== void 0 && parts.attrs.errors.length > 0) throw new Error(`PermDock CLI: ${parts.attrs.errors.join("; ")}`);
|
|
1328
|
-
if (extraPlan.errors.length > 0) throw new Error(`PermDock CLI: ${extraPlan.errors.join("; ")}`);
|
|
1329
|
-
quoteIdent(parts.tenantClaim);
|
|
1330
|
-
return parts;
|
|
1331
|
-
}
|
|
1332
|
-
function permdockClaim(name, budget = false) {
|
|
1333
|
-
return {
|
|
1334
|
-
name,
|
|
1335
|
-
source: "permdock",
|
|
1336
|
-
budget
|
|
1337
|
-
};
|
|
1338
|
-
}
|
|
1339
|
-
function hookClaims(parts) {
|
|
1340
|
-
return [
|
|
1341
|
-
permdockClaim("user_role"),
|
|
1342
|
-
permdockClaim("roles"),
|
|
1343
|
-
permdockClaim("memberships", true),
|
|
1344
|
-
permdockClaim("memberships_truncated"),
|
|
1345
|
-
permdockClaim(parts.tenantClaim),
|
|
1346
|
-
...parts.attrs === void 0 ? [] : [permdockClaim("attrs", true)],
|
|
1347
|
-
...parts.version ? [permdockClaim("authz_ver")] : [],
|
|
1348
|
-
...parts.extra.map((entry) => ({
|
|
1349
|
-
name: entry.claim,
|
|
1350
|
-
source: entry.fn,
|
|
1351
|
-
budget: false
|
|
1352
|
-
}))
|
|
1353
|
-
];
|
|
1354
|
-
}
|
|
1355
2290
|
//#endregion
|
|
1356
|
-
//#region src/cli/supabase-hook
|
|
2291
|
+
//#region src/cli/supabase-hook/manifest.ts
|
|
1357
2292
|
function helperList(parts, types) {
|
|
1358
2293
|
const client = ["authenticated"];
|
|
1359
2294
|
return [helperEntry(HELPERS.has, "p_grant text", "boolean", client), ...parts.scopes.flatMap((scope) => {
|
|
@@ -1380,18 +2315,21 @@ function helperEntry(name, args, returns, execute) {
|
|
|
1380
2315
|
*/
|
|
1381
2316
|
function trustedHelpers(parts, config, types, tenantType, policy) {
|
|
1382
2317
|
const database = resolveAuthorize(config) === "database";
|
|
1383
|
-
const custom = database && config.rls
|
|
2318
|
+
const custom = database && customRolesOn(config.rls);
|
|
1384
2319
|
const everyFor = parts.scopes.every((scope) => parts.helpers.memberFor.includes(scope.name));
|
|
1385
2320
|
const client = ["authenticated"];
|
|
1386
2321
|
const scopeTypes = parts.scopes.map((scope) => [scope.name, types.get(scope.name) ?? "uuid"]);
|
|
1387
2322
|
const forUser = database ? [
|
|
1388
2323
|
helperEntry(HELPERS.hasFor, "p_user uuid, p_grant text", "boolean", []),
|
|
1389
2324
|
...scopeTypes.map(([scope, type]) => helperEntry(permittedForHelper(scope), "p_user uuid, p_grant text", `setof ${type}`, [])),
|
|
1390
|
-
...scopeTypes.map(([scope, type]) => helperEntry(permissionKeysForHelper(scope), `p_user uuid, p_id ${type}`, "setof text", []))
|
|
2325
|
+
...scopeTypes.map(([scope, type]) => helperEntry(permissionKeysForHelper(scope), `p_user uuid, p_id ${type}`, "setof text", [])),
|
|
2326
|
+
...scopeTypes.filter(([scope]) => parts.helpers.holders.includes(scope)).map(([scope, type]) => helperEntry(permittedUsersHelper(scope), `p_permission text, p_id ${type}`, "setof uuid", []))
|
|
1391
2327
|
] : [];
|
|
1392
2328
|
const keyed = [
|
|
1393
2329
|
helperEntry(USER_ID_HELPER, "", "uuid", client),
|
|
1394
2330
|
...config.rls?.apiKeys === void 0 ? [] : [helperEntry(API_KEY_ALLOWS, "p_grant text", "boolean", client)],
|
|
2331
|
+
...readOnlyActorKinds(config.rls?.readOnlyActors) === void 0 ? [] : [helperEntry(ACTOR_MAY_WRITE, "", "boolean", client)],
|
|
2332
|
+
...!config.rls?.approvals ? [] : [helperEntry(IS_APPROVER, typeof config.rls.approvals === "object" && config.rls.approvals.delegations === true ? "p_approvers jsonb, p_tenant text, p_requester text, p_resource text" : "p_approvers jsonb, p_tenant text", "boolean", client)],
|
|
1395
2333
|
...forUser
|
|
1396
2334
|
];
|
|
1397
2335
|
if (!(policy !== void 0 && (ownershipRules(policy, parts.scopes)?.assigns.length ?? 0) > 0)) return keyed;
|
|
@@ -1410,7 +2348,7 @@ function trustedHelpers(parts, config, types, tenantType, policy) {
|
|
|
1410
2348
|
function activeRowManifest(row) {
|
|
1411
2349
|
const keep = "keep" in row ? keptKeys(row) : [];
|
|
1412
2350
|
return {
|
|
1413
|
-
table:
|
|
2351
|
+
table: qualifySqlTable(row.table),
|
|
1414
2352
|
id: row.id,
|
|
1415
2353
|
...row.disabledAt === void 0 ? {} : { disabledAt: row.disabledAt },
|
|
1416
2354
|
...row.status === void 0 ? {} : { status: row.status },
|
|
@@ -1425,9 +2363,9 @@ function rlsSettings(parts, config) {
|
|
|
1425
2363
|
const scopes = Object.entries(suspension?.scopes ?? {});
|
|
1426
2364
|
const assigned = rls?.assignments === void 0 ? void 0 : guardedTables(config, parts.scopes);
|
|
1427
2365
|
return {
|
|
1428
|
-
customRoles: resolveAuthorize(config) === "database" && rls
|
|
2366
|
+
customRoles: resolveAuthorize(config) === "database" && customRolesOn(rls),
|
|
1429
2367
|
...parts.roles === void 0 ? {} : { roles: {
|
|
1430
|
-
table:
|
|
2368
|
+
table: qualifySqlTable(parts.roles.table),
|
|
1431
2369
|
user: { column: parts.roles.user },
|
|
1432
2370
|
role: parts.roles.role
|
|
1433
2371
|
} },
|
|
@@ -1537,142 +2475,28 @@ function defaultOut(config) {
|
|
|
1537
2475
|
return config.supabase?.hook?.out ?? "supabase/permdock-hook.sql";
|
|
1538
2476
|
}
|
|
1539
2477
|
//#endregion
|
|
1540
|
-
//#region src/cli/supabase-
|
|
1541
|
-
const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
|
|
1542
|
-
function escape(text) {
|
|
1543
|
-
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
1544
|
-
}
|
|
1545
|
-
/** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
|
|
1546
|
-
function helpersDefined(sql, schema) {
|
|
1547
|
-
const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
|
|
1548
|
-
const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
|
|
1549
|
-
const names = /* @__PURE__ */ new Set();
|
|
1550
|
-
for (const match of sql.matchAll(create)) {
|
|
1551
|
-
const name = (match[1] ?? "").toLowerCase();
|
|
1552
|
-
if (HELPER_NAME.test(name)) names.add(name);
|
|
1553
|
-
}
|
|
1554
|
-
return names;
|
|
1555
|
-
}
|
|
1556
|
-
/**
|
|
1557
|
-
* SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`;
|
|
1558
|
-
* its `helpers` part when it has `{part}`), the SQL files next to the hook,
|
|
1559
|
-
* and the migration folders, not the hook itself.
|
|
1560
|
-
*/
|
|
1561
|
-
function helperSources(cwd, config, hookOut) {
|
|
1562
|
-
const hook = resolve(cwd, hookOut);
|
|
1563
|
-
const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
|
|
1564
|
-
const rlsOut = resolve(cwd, partPath(config.rls?.out ?? "rls.sql", "helpers"));
|
|
1565
|
-
if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
|
|
1566
|
-
const beside = dirname(hook);
|
|
1567
|
-
if (existsSync(beside)) {
|
|
1568
|
-
for (const entry of readdirSync(beside, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".sql")) files.add(join(beside, entry.name));
|
|
1569
|
-
}
|
|
1570
|
-
files.delete(hook);
|
|
1571
|
-
return [...files];
|
|
1572
|
-
}
|
|
1573
|
-
function missingHelpersInFiles(cwd, config, manifest) {
|
|
1574
|
-
const found = /* @__PURE__ */ new Set();
|
|
1575
|
-
for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
|
|
1576
|
-
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
1577
|
-
}
|
|
1578
|
-
async function missingHelpersInDb(db, manifest, connect = (url) => connectPg(url, "permdock supabase hook generate --db")) {
|
|
1579
|
-
const client = await connect(db);
|
|
1580
|
-
try {
|
|
1581
|
-
const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
1582
|
-
where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
|
|
1583
|
-
const found = new Set(result.rows.map((row) => row["proname"]));
|
|
1584
|
-
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
1585
|
-
} finally {
|
|
1586
|
-
await client.end();
|
|
1587
|
-
}
|
|
1588
|
-
}
|
|
1589
|
-
function missingHelpersMessage(manifest, missing) {
|
|
1590
|
-
return `PD039 schema ${manifest.helpers.schema} has no ${missing.join(", ")}: the hook's claims are read by these helpers; run permdock rls generate and apply its migration`;
|
|
1591
|
-
}
|
|
1592
|
-
/** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
|
|
1593
|
-
function oversizedClaims(samples, manifest) {
|
|
1594
|
-
const encoder = new TextEncoder();
|
|
1595
|
-
const oversized = [];
|
|
1596
|
-
for (const claim of manifest.claims) {
|
|
1597
|
-
if (claim.source === "permdock") continue;
|
|
1598
|
-
let bytes = 0;
|
|
1599
|
-
for (const sample of samples) {
|
|
1600
|
-
if (sample === null || typeof sample !== "object") continue;
|
|
1601
|
-
const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
|
|
1602
|
-
if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
|
|
1603
|
-
}
|
|
1604
|
-
if (bytes > manifest.budget.bytes) oversized.push({
|
|
1605
|
-
name: claim.name,
|
|
1606
|
-
bytes
|
|
1607
|
-
});
|
|
1608
|
-
}
|
|
1609
|
-
return oversized;
|
|
1610
|
-
}
|
|
1611
|
-
/** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
|
|
1612
|
-
function droppedMemberships(samples) {
|
|
1613
|
-
const out = [];
|
|
1614
|
-
for (const [index, sample] of samples.entries()) {
|
|
1615
|
-
if (sample === null || typeof sample !== "object") continue;
|
|
1616
|
-
const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
|
|
1617
|
-
const { dropped } = readMemberships(claim);
|
|
1618
|
-
if (dropped.length > 0) out.push({
|
|
1619
|
-
sample: index,
|
|
1620
|
-
entries: dropped
|
|
1621
|
-
});
|
|
1622
|
-
}
|
|
1623
|
-
return out;
|
|
1624
|
-
}
|
|
1625
|
-
function pd039(input) {
|
|
1626
|
-
const findings = [];
|
|
1627
|
-
const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
|
|
1628
|
-
if (missing.length > 0) findings.push({
|
|
1629
|
-
code: "PD039",
|
|
1630
|
-
severity: "warning",
|
|
1631
|
-
message: missingHelpersMessage(input.manifest, missing).slice(6),
|
|
1632
|
-
fix: "run permdock rls generate, or set rls.out to the file that holds the helpers (with {part} for --split output) or write them next to the hook"
|
|
1633
|
-
});
|
|
1634
|
-
const fixture = input.config.doctor?.claims;
|
|
1635
|
-
if (fixture !== void 0) {
|
|
1636
|
-
const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
|
|
1637
|
-
const samples = Array.isArray(parsed) ? parsed : [];
|
|
1638
|
-
for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
|
|
1639
|
-
code: "PD039",
|
|
1640
|
-
severity: "warning",
|
|
1641
|
-
message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
|
|
1642
|
-
fix: "return less from the claim function, or read the data per request instead of from the token"
|
|
1643
|
-
});
|
|
1644
|
-
for (const { sample, entries } of droppedMemberships(samples)) findings.push({
|
|
1645
|
-
code: "PD039",
|
|
1646
|
-
severity: "warning",
|
|
1647
|
-
message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
|
|
1648
|
-
fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
|
|
1649
|
-
});
|
|
1650
|
-
}
|
|
1651
|
-
return findings;
|
|
1652
|
-
}
|
|
1653
|
-
//#endregion
|
|
1654
|
-
//#region src/cli/supabase-hook.ts
|
|
2478
|
+
//#region src/cli/supabase-hook/supabase-hook.ts
|
|
1655
2479
|
const SUPABASE_HELP = `permdock supabase hook generate | inspect
|
|
1656
2480
|
|
|
1657
|
-
hook generate [--out
|
|
2481
|
+
hook generate [--out <file>] [--check] [--db-url-stdin] [--schemas pg-delta|migra|migrations]
|
|
1658
2482
|
[--active-from app_metadata.active_<scope>|<table>.<column>]
|
|
1659
2483
|
[--budget 1024] [--schema public] [--grants-out <file>|-]
|
|
1660
2484
|
inspect [--json] [--out [permdock.manifest.json]] [--check]
|
|
1661
2485
|
|
|
1662
|
-
Reads supabase.hook from permdock.config.ts: the
|
|
2486
|
+
Reads supabase.hook from permdock.config.ts: the tableMembershipSource / junctionMembershipSource sources the app
|
|
1663
2487
|
passes as memberships. hook generate emits custom_access_token_hook(jsonb), the grants it
|
|
1664
|
-
needs
|
|
1665
|
-
and
|
|
2488
|
+
needs, the permdock_authz_version table and its triggers. Under pg-delta (the default) --out defaults to
|
|
2489
|
+
supabase/schemas/<schema>/functions/custom_access_token_hook.sql and the grants stay in it; under
|
|
2490
|
+
--schemas migra, --grants-out takes the privileges supabase db diff drops. Never grants anything to
|
|
1666
2491
|
service_role unless supabase.hook.api is set, which grants its wrappers to it. inspect prints the manifest: helper schema, names and signatures, tenant claim,
|
|
1667
2492
|
budget, the claims the hook writes, the membership sources and the columns that decide them.
|
|
1668
2493
|
--out writes it as JSON, to permdock.manifest.json without a path; --check exits 1 when that
|
|
1669
2494
|
file differs.
|
|
1670
2495
|
`;
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
return config.supabase?.hook?.out !== void 0 || pgDelta === void 0 ? defaultOut(config) : pgDeltaPath(pgDelta.schemaDir, "hook", schema ?? config.supabase?.hook?.schema ?? config.rls?.schema ?? "permdock");
|
|
2496
|
+
/** `supabase.hook.out`, else pg-delta's per-schema path under pg-delta, else `supabase/permdock-hook.sql`. */
|
|
2497
|
+
function hookOut(cwd, config, schema, flag) {
|
|
2498
|
+
const schemas = resolveSchemas(cwd, config, flag);
|
|
2499
|
+
return config.supabase?.hook?.out !== void 0 || schemas.mode !== "pg-delta" ? defaultOut(config) : pgDeltaPath(schemas.schemaDir, "hook", schema ?? config.supabase?.hook?.schema ?? config.rls?.schema ?? "permdock");
|
|
1676
2500
|
}
|
|
1677
2501
|
/**
|
|
1678
2502
|
* The manifest `supabase inspect` prints. With `policy`, `rls.helpers` also
|
|
@@ -1680,7 +2504,14 @@ function hookOut(cwd, config, schema) {
|
|
|
1680
2504
|
* `assigns`.
|
|
1681
2505
|
*/
|
|
1682
2506
|
function supabaseHookManifest(scopes, config, overrides = {}, policy) {
|
|
1683
|
-
|
|
2507
|
+
const manifest = manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config, policy);
|
|
2508
|
+
return overrides.schemas === void 0 ? manifest : {
|
|
2509
|
+
...manifest,
|
|
2510
|
+
hook: {
|
|
2511
|
+
...manifest.hook,
|
|
2512
|
+
schemas: overrides.schemas
|
|
2513
|
+
}
|
|
2514
|
+
};
|
|
1684
2515
|
}
|
|
1685
2516
|
/**
|
|
1686
2517
|
* The hook migration. With `grantsOut`, the statements `supabase db diff`
|
|
@@ -1745,8 +2576,7 @@ function inspectText(manifest) {
|
|
|
1745
2576
|
function manifestFile(cwd, rel, manifest, check) {
|
|
1746
2577
|
const path = resolve(cwd, rel);
|
|
1747
2578
|
if (!check) {
|
|
1748
|
-
|
|
1749
|
-
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
2579
|
+
writeFileEnsuringDir(path, formatManifestJson(manifest));
|
|
1750
2580
|
return {
|
|
1751
2581
|
code: 0,
|
|
1752
2582
|
output: `wrote ${rel}`
|
|
@@ -1758,14 +2588,14 @@ function manifestFile(cwd, rel, manifest, check) {
|
|
|
1758
2588
|
};
|
|
1759
2589
|
let onDisk;
|
|
1760
2590
|
try {
|
|
1761
|
-
onDisk =
|
|
2591
|
+
onDisk = readJsonFile(path);
|
|
1762
2592
|
} catch {
|
|
1763
2593
|
return {
|
|
1764
2594
|
code: 1,
|
|
1765
2595
|
output: `supabase manifest drift: ${rel} is not JSON`
|
|
1766
2596
|
};
|
|
1767
2597
|
}
|
|
1768
|
-
const found =
|
|
2598
|
+
const found = isRecord(onDisk) ? Object.fromEntries(Object.entries(onDisk)) : {};
|
|
1769
2599
|
const changed = [.../* @__PURE__ */ new Set([...Object.keys(manifest), ...Object.keys(found)])].filter((key) => JSON.stringify(Reflect.get(manifest, key)) !== JSON.stringify(found[key]));
|
|
1770
2600
|
return changed.length === 0 ? {
|
|
1771
2601
|
code: 0,
|
|
@@ -1778,12 +2608,18 @@ function manifestFile(cwd, rel, manifest, check) {
|
|
|
1778
2608
|
async function loadScopes(cwd, config) {
|
|
1779
2609
|
return scopeList((await loadHookPolicy(cwd, config)).scopes);
|
|
1780
2610
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
return asPolicy(pickNamed(await loadModule(resolve(cwd, config.policy)), ["policy"]));
|
|
2611
|
+
function loadHookPolicy(cwd, config) {
|
|
2612
|
+
return requirePolicy(cwd, config.policy, "supabase hook generate needs policy in permdock.config.ts");
|
|
1784
2613
|
}
|
|
1785
2614
|
async function runSupabase(input) {
|
|
1786
2615
|
const [area, action] = input.rest;
|
|
2616
|
+
const badSchemas = schemasRefusal("supabase", input.schemas);
|
|
2617
|
+
if (badSchemas !== void 0) return {
|
|
2618
|
+
code: 2,
|
|
2619
|
+
output: badSchemas
|
|
2620
|
+
};
|
|
2621
|
+
const flag = isSupabaseSchemas(input.schemas) ? input.schemas : void 0;
|
|
2622
|
+
const schemas = resolveSchemas(input.cwd, input.config, flag);
|
|
1787
2623
|
const overrides = Object.fromEntries(Object.entries({
|
|
1788
2624
|
activeFrom: input.activeFrom,
|
|
1789
2625
|
budget: input.budget,
|
|
@@ -1793,7 +2629,8 @@ async function runSupabase(input) {
|
|
|
1793
2629
|
const policy = await loadHookPolicy(input.cwd, input.config);
|
|
1794
2630
|
const manifest = supabaseHookManifest(scopeList(policy.scopes), input.config, {
|
|
1795
2631
|
...overrides,
|
|
1796
|
-
out: hookOut(input.cwd, input.config, input.schema)
|
|
2632
|
+
out: hookOut(input.cwd, input.config, input.schema, flag),
|
|
2633
|
+
schemas: schemas.mode
|
|
1797
2634
|
}, policy);
|
|
1798
2635
|
if (input.out !== void 0 || input.check) return manifestFile(input.cwd, typeof input.out === "string" ? input.out : MANIFEST_FILE, manifest, input.check);
|
|
1799
2636
|
return {
|
|
@@ -1805,8 +2642,12 @@ async function runSupabase(input) {
|
|
|
1805
2642
|
code: 2,
|
|
1806
2643
|
output: SUPABASE_HELP
|
|
1807
2644
|
};
|
|
2645
|
+
if (input.grantsOut !== void 0 && schemas.mode !== "migra") return {
|
|
2646
|
+
code: 2,
|
|
2647
|
+
output: `supabase hook generate --grants-out is for --schemas migra: ${schemas.mode === "pg-delta" ? "pg-delta keeps the grants in the hook file" : "a hand-written migration keeps its grants"}, so drop --grants-out`
|
|
2648
|
+
};
|
|
1808
2649
|
const { sql, grants, manifest } = supabaseHookSql(await loadScopes(input.cwd, input.config), input.config, overrides, grantsLabel(input.grantsOut));
|
|
1809
|
-
const outRel = typeof input.out === "string" ? input.out : hookOut(input.cwd, input.config, input.schema);
|
|
2650
|
+
const outRel = typeof input.out === "string" ? input.out : hookOut(input.cwd, input.config, input.schema, flag);
|
|
1810
2651
|
const outPath = resolve(input.cwd, outRel);
|
|
1811
2652
|
const hook = input.config.supabase?.hook;
|
|
1812
2653
|
const grantsFile = input.grantsOut === void 0 ? [] : [{
|
|
@@ -1834,9 +2675,8 @@ async function runSupabase(input) {
|
|
|
1834
2675
|
output: `supabase hook drift: ${drift.join("; ")}`
|
|
1835
2676
|
};
|
|
1836
2677
|
}
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
const grantsWritten = writeSqlFiles(input.cwd, grantsFile);
|
|
2678
|
+
writeFileEnsuringDir(outPath, sql);
|
|
2679
|
+
const grantsWritten = writeOutputFiles(input.cwd, grantsFile);
|
|
1840
2680
|
const placed = {
|
|
1841
2681
|
...manifest,
|
|
1842
2682
|
hook: {
|
|
@@ -1857,4 +2697,4 @@ async function runSupabase(input) {
|
|
|
1857
2697
|
};
|
|
1858
2698
|
}
|
|
1859
2699
|
//#endregion
|
|
1860
|
-
export {
|
|
2700
|
+
export { pd045 as _, supabaseHookManifest as a, approvalsSql as c, resolveAuthorize as d, pd039 as f, attrsPlan as g, schemasRefusal as h, runSupabase as i, parseRbacAuthorize as l, resolveSchemas as m, hookOut as n, supabaseHookSql as o, isSupabaseSchemas as p, loadScopes as r, permissionHoldersSql as s, grantsLabel as t, rbacScaffold as u, readSupabaseConfig as v, supabaseConfig as y };
|