permdock 0.3.0 → 0.4.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/dist/a2a/index.d.ts +7 -5
- package/dist/a2a/index.js +14 -21
- package/dist/ai-sdk/index.d.ts +18 -6
- package/dist/ai-sdk/index.js +8 -5
- package/dist/app-data-CV8pqqMi.js +59 -0
- package/dist/approvals/index.d.ts +2 -2
- package/dist/approvals/index.js +3 -3
- package/dist/{approvals-DbYAuNkG.js → approvals-DZL76lKW.js} +35 -19
- package/dist/{approvers-DT4AVyIg.js → approvers-CqPOLD-o.js} +1 -1
- package/dist/{arazzo-BAgw9ap6.js → arazzo-C4BcskbX.js} +6 -5
- package/dist/{arazzo-CKwwqFWz.js → arazzo-CD8d0WLb.js} +2 -2
- package/dist/authzen/index.d.ts +3 -3
- package/dist/authzen/index.js +161 -23
- package/dist/better-auth/index.d.ts +2 -2
- package/dist/better-auth/index.js +6 -19
- package/dist/better-supabase/index.d.ts +112 -87
- package/dist/better-supabase/index.js +257 -156
- package/dist/{bind-C_ob3LKa.d.ts → bind-BzqG8SMb.d.ts} +16 -5
- package/dist/{bind-CuzGytRf.js → bind-kk-uyCwH.js} +14 -6
- package/dist/build-instance-DIIV9DbC.js +51 -0
- package/dist/{canonical-json-DemiK4Vp.js → canonical-json-CFKyFOke.js} +1 -3
- package/dist/{capability-2kYTMWDv.d.ts → capability-CksyCWDH.d.ts} +2 -2
- package/dist/{capability-4qPdO9gk.js → capability-txATtKV4.js} +3 -2
- package/dist/catalog/index.d.ts +1 -1
- package/dist/catalog/index.js +1 -1
- package/dist/catalog-6yWAX6Ps.js +2 -0
- package/dist/{catalog-dFZdWCGN.js → catalog-WHG4kBq7.js} +2 -2
- package/dist/{catalog-doc-DdGm82XT.js → catalog-doc-DWtg0gGR.js} +17 -10
- package/dist/{catalog-DWF5cYqB.js → catalog-tZUyOfOv.js} +4 -4
- package/dist/claude-agent/index.d.ts +4 -4
- package/dist/claude-agent/index.js +4 -5
- package/dist/clerk/index.d.ts +3 -3
- package/dist/clerk/index.js +8 -33
- package/dist/cli/bin.js +1 -1
- package/dist/cli/index.d.ts +3 -3
- package/dist/cli/index.js +3 -3
- package/dist/cloud/index.d.ts +4 -4
- package/dist/cloud/index.js +9 -15
- package/dist/{cloud-CB_ELYuX.js → cloud-C7bSDgX4.js} +2 -2
- package/dist/{cloud-DWLXO2af.js → cloud-PeBN3IEn.js} +5 -5
- package/dist/{collect-DSTE3ml9.js → collect-2SrB7lfk.js} +6 -8
- package/dist/collect-Bw2Jdq1q.js +2 -0
- package/dist/{collect-DClJsheJ.js → collect-CQx4hX4N.js} +3 -3
- package/dist/{commands-DSxCqG9A.js → commands-Cpip4ULx.js} +13 -13
- 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 -1
- package/dist/{compile-Ba4blROR.js → compile-CcEu2ZQ1.js} +6 -5
- package/dist/{compile-BkRyBDBw.d.ts → compile-CtTP7eur.d.ts} +2 -2
- package/dist/{config-BJiM-xAm.js → config-BIjREpyE.js} +4 -4
- package/dist/{config-Dnszbv-6.js → config-DLt6BvV7.js} +2 -2
- package/dist/{config-report-DMvWKUxL.js → config-report--Xb9APJS.js} +5 -6
- package/dist/{connection-CU8MQxRi.js → connection-DUsczpB5.js} +3 -2
- package/dist/constants-DcJABbLW.js +20 -0
- package/dist/{context-Bcy85rGi.js → context-DieZVJXu.js} +16 -5
- package/dist/convex/index.d.ts +4 -4
- package/dist/convex/index.js +14 -16
- package/dist/create-BBO24vj6.js +324 -0
- package/dist/{credential-8P81ff4u.js → credential-kue0qpbm.js} +4 -4
- package/dist/deciding-columns-Sq12mEA6.js +123 -0
- package/dist/delegation-input-FTxMK3ji.js +19 -0
- package/dist/denied-text-DfRk15uw.d.ts +19 -0
- package/dist/{describe-DMYiCjJV.d.ts → describe-C3ldA9os.d.ts} +11 -2
- package/dist/{describe-B_gVZEVi.js → describe-Cgkp7eP0.js} +5 -4
- package/dist/{diff-DBIhz_f2.js → diff-Cnghh_Ip.js} +2 -2
- package/dist/{diff-2VaeChzX.js → diff-rpqoe7Ag.js} +9 -9
- package/dist/{doctor-BwCAMLx_.js → doctor-CuVJvhV_.js} +2 -2
- package/dist/doctor-owlv6ddh.js +2166 -0
- package/dist/drizzle/index.d.ts +2 -2
- package/dist/drizzle/index.js +6 -14
- package/dist/elysia/index.d.ts +4 -4
- package/dist/elysia/index.js +7 -7
- package/dist/{errors-DPdqI50v.d.ts → errors-OD_4c__H.d.ts} +2 -2
- package/dist/{errors-Cz5zn0mW.js → errors-drnfxYtm.js} +3 -3
- package/dist/{evaluations-tz6D9A2G.js → evaluations-C9YJsv3H.js} +45 -7
- package/dist/eve/index.d.ts +5 -5
- package/dist/eve/index.js +5 -5
- package/dist/express/index.d.ts +4 -4
- package/dist/express/index.js +8 -7
- package/dist/fastify/index.d.ts +15 -5
- package/dist/fastify/index.js +9 -29
- package/dist/fields-fwfXN01n.js +1016 -0
- package/dist/{files-DWUN9PzI.js → files-D_JIzUrP.js} +12 -3
- package/dist/{fixtures-ht28Cplr.js → fixtures-h2eG2OZV.js} +5 -6
- package/dist/{from-snapshot-CTLijvdm.js → from-snapshot-7REQZe0H.js} +32 -22
- package/dist/{generate-CH7ThJ14.js → generate-CNU1pIOo.js} +1 -3
- package/dist/grantee-CXY-0C87.js +254 -0
- package/dist/grantee-conditions-MQs5NIuX.js +270 -0
- package/dist/grantee-match-fxYp9Nmt.js +141 -0
- package/dist/{helpers-H_KARDVC.js → helpers-C9YmyBIa.js} +7 -6
- package/dist/hono/index.d.ts +4 -4
- package/dist/hono/index.js +8 -8
- package/dist/hosted-CBHgkzDl.js +655 -0
- package/dist/{http-SO7qGttf.js → http-BBoKefeS.js} +35 -2
- package/dist/{http-BIhmLfCc.js → http-BEbdMZ05.js} +2 -24
- package/dist/{rls-import-BTFVk3jH.js → import-BFIlhetD.js} +86 -80
- package/dist/{openapi-import-cmwetbzw.js → import-UHk1N7vH.js} +6 -7
- package/dist/{index-C8z4RjQV.d.ts → index-BwMe94Xq.d.ts} +1 -1
- package/dist/{index-0nNqHspG.d.ts → index-DgM7w96K.d.ts} +2 -2
- package/dist/{index-C7Tw7MaW.d.ts → index-Dz7didpC.d.ts} +3 -3
- package/dist/{index-BNc7Wer_.d.ts → index-UdVQgq48.d.ts} +105 -38
- package/dist/index.d.ts +15 -13
- package/dist/index.js +22 -22
- package/dist/{instance-options-jARdwfPN.d.ts → instance-options-plkrsAog.d.ts} +1 -1
- package/dist/{json-schema-BsAflUGW.js → json-schema-DBkdUuCO.js} +1 -3
- package/dist/jwt/index.d.ts +4 -4
- package/dist/jwt/index.js +13 -13
- package/dist/{kernel-_RRHmnMY.js → kernel-CB3LrIJM.js} +81 -32
- package/dist/kysely/index.d.ts +2 -2
- package/dist/kysely/index.js +6 -14
- package/dist/{live-options-H2eG8dfJ.js → live-options-BaAFCIiu.js} +16 -3
- package/dist/load-B_shHrmd.js +2 -0
- package/dist/{load-DhWEB9P1.js → load-C3_oTHyO.js} +11 -3
- package/dist/{manifest-iaoBXHKc.js → manifest-BngWKryL.js} +5 -18
- package/dist/{map-claims-Z4QZOiFF.js → map-claims-AVlBaJoa.js} +7 -16
- package/dist/{map-error-pgUWNNtG.js → map-error-BE27PYkE.js} +5 -5
- package/dist/{markers-B0hYLvMD.js → markers-CiwAOZtO.js} +6 -2
- package/dist/{may-use-DndQ5tQ-.js → may-use-BOn_dR_w.js} +4 -3
- package/dist/mcp/index.d.ts +26 -9
- package/dist/mcp/index.js +456 -446
- package/dist/{rls-migrate-CN4TlJTO.js → migrate-BwlB5-Wk.js} +75 -15
- package/dist/nest/index.d.ts +4 -4
- package/dist/nest/index.js +11 -12
- package/dist/next/index.d.ts +31 -8
- package/dist/next/index.js +29 -33
- package/dist/next/plugin.d.ts +1 -1
- package/dist/next/plugin.js +1 -1
- package/dist/node/index.d.ts +4 -4
- package/dist/node/index.js +4 -4
- package/dist/ocsf-uYFXGVw2.js +115 -0
- package/dist/openai/index.d.ts +5 -5
- package/dist/openai/index.js +4 -4
- package/dist/openapi/index.d.ts +3 -3
- package/dist/openapi/index.js +1 -1
- package/dist/{openapi-CqqEbHsL.js → openapi-BJdBmYDG.js} +19 -23
- package/dist/{openapi-BqRBvAng.js → openapi-DBzHQbov.js} +2 -5
- package/dist/{openapi-l0xYHkcv.js → openapi-DcQ1Kg40.js} +3 -3
- package/dist/orpc/index.d.ts +5 -5
- package/dist/orpc/index.js +9 -11
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +26 -23
- package/dist/output-files-CgIsvdSa.js +140 -0
- package/dist/{rls-ownership-RuFqaqGz.js → ownership-BgnbOJFT.js} +387 -379
- package/dist/{package-root-zGkxO3oN.js → package-root-DtNnpmUd.js} +3 -2
- package/dist/{parse-BuyjxhZV.js → parse-CVFQHk6c.js} +3 -3
- package/dist/{parse-local-manifest-BrMozJCo.js → parse-local-manifest-C-q02Pal.js} +2 -4
- package/dist/{parse-local-manifest-JLnaBH0S.d.ts → parse-local-manifest-DvYUR0QL.d.ts} +1 -1
- package/dist/pdp/index.d.ts +2 -2
- package/dist/pdp/index.js +8 -9
- package/dist/{permdock-CbgvLnuo.js → permdock-L26udHSk.js} +1387 -1220
- package/dist/{rls-fields-74oZPXWL.js → permission-keys-B26jeyUv.js} +1289 -2066
- package/dist/permission-registry-BsblpB-z.js +385 -0
- package/dist/{permissions-DttnqqxC.d.ts → permission-types-Dg39z9Kv.d.ts} +59 -31
- package/dist/{permissions-RJrovw98.js → permissions-CCJ6lmxB.js} +3 -3
- package/dist/{permissions-CDgvKG5L.d.ts → permissions-COH-KRgT.d.ts} +2 -2
- package/dist/{policy-IB6yxpC3.d.ts → policy-types-sp9P1iIV.d.ts} +661 -582
- package/dist/{policy-D2W7uv19.d.ts → policy-ur0BBK_e.d.ts} +2 -2
- package/dist/{powersync-DliZK_-y.js → powersync-BFSbSElQ.js} +2 -2
- package/dist/powersync-DCDgZgfZ.js +2 -0
- package/dist/{powersync-BylawD_U.js → powersync-cZe4kzhL.js} +26 -27
- package/dist/prisma/index.d.ts +1 -1
- package/dist/prisma/index.js +6 -12
- package/dist/{problem-B43ztKIm.js → problem-CnWraVhC.js} +8 -7
- package/dist/{problem-details-_AMOYz-w.js → problem-details-DECUnXAn.js} +2 -2
- package/dist/{problem-details-DX075l9V.d.ts → problem-details-rIIo8B2C.d.ts} +1 -1
- package/dist/{protected-BdnIEFkY.d.ts → protected-B3_P7xuw.d.ts} +9 -6
- package/dist/{protected-utU-SLuy.js → protected-BN7BUdY1.js} +20 -8
- package/dist/{provider-CSQd7m92.js → provider-Bi-0HNL3.js} +23 -10
- package/dist/{provider-DZY281jO.d.ts → provider-kwHC9CZD.d.ts} +1 -1
- package/dist/{provider-store-Cfbvo4DE.js → provider-store-Ctt-81Ni.js} +6 -3
- package/dist/react/index.d.ts +7 -7
- package/dist/react/index.js +5 -5
- package/dist/react/provider-client.d.ts +1 -1
- package/dist/react/provider-client.js +1 -1
- package/dist/react-native/index.d.ts +20 -11
- package/dist/react-native/index.js +33 -17
- package/dist/{refs-KYb4ntaz.js → refs-f3imMjNW.js} +1 -1
- package/dist/relation-walk-BXnlLtg2.js +186 -0
- package/dist/relations-CvmSpG67.js +163 -0
- package/dist/{replay-CYyiLiwO.d.ts → replay-COpmZUfx.d.ts} +1 -1
- package/dist/request-context-BVM7aWNs.js +71 -0
- package/dist/request-context-C2_1lchh.d.ts +5 -0
- package/dist/resource-relations-D1Un3pcz.js +243 -0
- package/dist/retire-BFcxL6un.js +240 -0
- package/dist/{rls-B4ur8nKQ.js → rls-55DQTuf5.js} +14 -2
- package/dist/{rls-DQtdgTHB.js → rls-BEktUaqg.js} +884 -484
- package/dist/{policy-xyWg7QdR.js → role-builder-B4GbKM-i.js} +235 -541
- package/dist/{rpc-DL9Sqvb1.js → rpc-nYC3cR_u.js} +15 -5
- package/dist/{run-DKDbM4Du.js → run-DMcNW_8T.js} +3 -3
- package/dist/{runtime-CB8gQFvS.js → runtime-D_tMXnsq.js} +1 -1
- package/dist/{saas-DhpJjnBA.js → saas-DtaA4JuW.js} +9 -8
- package/dist/{openapi-schema--ARjyfQr.js → schema-Bol6gqXA.js} +3 -5
- package/dist/{schema-D3MnDMxa.js → schema-DjQBcAP9.js} +4 -2
- package/dist/scim/index.d.ts +1 -1
- package/dist/scim/index.js +1 -1
- package/dist/{scim-BFGBBAqR.js → scim-vFR1O5oG.js} +6 -12
- package/dist/{scopes-CVdPrWN-.js → scopes-tvpa34ep.js} +10 -2
- package/dist/server/index.d.ts +18 -8
- package/dist/server/index.js +8 -8
- package/dist/{server-DlZ28Oz5.js → server-ZYEHtNHS.js} +4 -4
- package/dist/{create-B1d519XK.d.ts → server-types-DdqBwZN_.d.ts} +69 -33
- package/dist/shared-1uhI-e-b.js +294 -0
- package/dist/{shared-DRu8VFVM.js → shared-JhMeOsDF.js} +1 -1
- package/dist/{sink-Bd9PSyfq.js → sink-BTtmfPnE.js} +1 -1
- package/dist/{sink-B1fZzIMf.d.ts → sink-CPN4zLfs.d.ts} +1 -1
- package/dist/{skills-Biq5uup3.js → skills-BZdRQkxs.js} +4 -3
- package/dist/{skills-Bx-U0irG.js → skills-Dle4ZoLF.js} +2 -2
- package/dist/{snapshot-Enxd0n-s.js → snapshot-Dc9CHPME.js} +116 -13
- package/dist/{snapshot-cache-D6jLelln.js → snapshot-cache-Cod40D-X.js} +1 -1
- package/dist/{snapshot-cache-B-LxUahj.d.ts → snapshot-cache-J5T7t_vD.d.ts} +1 -1
- package/dist/solid/index.d.ts +22 -22
- package/dist/solid/index.js +40 -10
- package/dist/{sources-4p0-4xQm.js → sources-B1-b-R0t.js} +25 -26
- package/dist/{sources-BdOYLw9j.d.ts → sources-Dljy06zL.d.ts} +6 -2
- package/dist/{sql-xdg5f4Fi.js → sql-t8teWOfo.js} +19 -3
- package/dist/{src-BDwEZWrZ.js → src-Bb7no-hS.js} +194 -123
- package/dist/ssf/index.d.ts +3 -3
- package/dist/ssf/index.js +58 -61
- package/dist/store-9KO4BlkB.d.ts +147 -0
- package/dist/{store-DMAWwAJV.js → store-ILkLUj_o.js} +4 -4
- package/dist/{store-options-ByhZq1jV.js → store-options-CbUHDoSe.js} +16 -7
- package/dist/{stores-BT2pSCub.js → stores-BHXdSbw9.js} +7 -3
- package/dist/stores-Cxz_80g-.d.ts +31 -0
- package/dist/{stream-DDpvynSB.js → stream-BMui98KK.js} +2 -1
- package/dist/{stream-D1RXubw2.d.ts → stream-Dwsg6WwE.d.ts} +2 -2
- package/dist/{subject-settings-BwBmCdTR.js → subject-settings-CkEwrw3W.js} +23 -5
- package/dist/supabase/index.d.ts +4 -4
- package/dist/supabase/index.js +3 -3
- package/dist/supabase/middleware.d.ts +11 -5
- package/dist/supabase/middleware.js +10 -7
- package/dist/{supabase-C0qESOa-.js → supabase-C1TWlnYt.js} +36 -52
- package/dist/{supabase-Boqb9a_Y.js → supabase-Dbqr141s.js} +2 -2
- package/dist/supabase-hook-BULg7Ut_.js +2 -0
- package/dist/{supabase-hook-CSLQ4zJ3.js → supabase-hook-Cva3ikXz.js} +731 -782
- package/dist/{supautils-Cn4R4cJ4.js → supautils-B6HYL9eL.js} +2 -2
- package/dist/svelte/Protected.svelte +9 -2
- package/dist/svelte/index.d.ts +6 -6
- package/dist/svelte/index.js +25 -11
- package/dist/svelte/runtime.d.ts +10 -3
- package/dist/svelte/runtime.js +4 -3
- package/dist/svelte/source.d.ts +6 -6
- package/dist/svelte/source.js +4 -4
- package/dist/{validity-uOp8UpYn.js → tenancy-DshjqY0n.js} +3 -39
- package/dist/terminal/index.d.ts +5 -5
- package/dist/terminal/index.js +18 -34
- package/dist/testing/index.d.ts +89 -77
- package/dist/testing/index.js +1276 -1262
- package/dist/testing/saas/index.d.ts +4 -4
- package/dist/testing/saas/index.js +2 -2
- package/dist/testing/saas/permissions.d.ts +1 -1
- package/dist/testing/saas/permissions.js +1 -1
- package/dist/thenable-QSmvt-vZ.js +65 -0
- package/dist/{token-CIWxJOCh.js → token-Bx9qy30p.js} +1 -1
- package/dist/tool-refusal-CQ7OEia9.js +41 -0
- package/dist/trpc/index.d.ts +5 -5
- package/dist/trpc/index.js +7 -8
- package/dist/{types-DbjRN55U.d.ts → types-7rJVLWe5.d.ts} +82 -4
- package/dist/{types-aqOHTHcp.d.ts → types-Br9IXUBG.d.ts} +1 -1
- package/dist/types-ByUo56un.d.ts +88 -0
- package/dist/{types-Cq_H8N_X.d.ts → types-C0dee0Qf.d.ts} +2 -2
- package/dist/{types-DxiCGmmd.d.ts → types-CIlTojhq.d.ts} +2 -2
- package/dist/{types-QCuYJh4q.d.ts → types-CUcFZfpU.d.ts} +1 -1
- package/dist/{types-C97IskBw.d.ts → types-CqrjB9o6.d.ts} +1 -1
- package/dist/{types-BnIEz1sU.d.ts → types-DHrmBJLH.d.ts} +24 -6
- package/dist/{types-Dnemwd77.d.ts → types-DpWfQdSh.d.ts} +5 -1
- package/dist/{types-Bvc4oWT6.d.ts → types-DvupZE0w.d.ts} +1 -1
- package/dist/unplugin/index.d.ts +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/{usage-GTixvDDU.js → usage-4G6t643F.js} +2 -2
- package/dist/{usage-ItS7nLcl.js → usage-CG9nOYZw.js} +7 -7
- package/dist/usage-iFLJ0-rr.js +2 -0
- package/dist/{validation-error-Ba264OaJ.d.ts → validation-error-BSSw0l_8.d.ts} +1 -1
- package/dist/{validation-error-CnjZ4t92.js → validation-error-ZfUHbKvf.js} +1 -1
- package/dist/validity-dVrUEPJt.js +62 -0
- package/dist/{verifier-BxBo6I44.js → verifier-DMr_Zmb_.js} +51 -15
- package/dist/{rls-verify-CVQTHTh5.js → verify-BztUB3GX.js} +668 -239
- package/dist/{version-BvacVTZR.js → version-BEogC-rB.js} +1 -1
- package/dist/{views-Soe7bGSC.js → views-CJsrBS1f.js} +23 -1
- package/dist/{vocabulary-DA8a8zif.d.ts → vocabulary-hhEFLFoL.d.ts} +15 -6
- package/dist/vue/index.d.ts +24 -19
- package/dist/vue/index.js +24 -8
- package/dist/watch-C8TpsAl4.js +2 -0
- package/dist/{watch-DVBJ9cBq.js → watch-zeucB7eF.js} +6 -6
- package/dist/webmcp/index.d.ts +2 -2
- package/dist/webmcp/index.js +13 -37
- package/dist/wrap-Cr93qqdP.d.ts +14 -0
- package/dist/wrap-DWsU_5Bd.js +26 -0
- package/package.json +8 -8
- package/schemas/catalog-v1.json +6 -0
- package/skills/permdock-credentials/SKILL.md +1 -1
- package/skills/permdock-credentials/references/tokens-keys-links.md +7 -2
- package/skills/permdock-data/references/queries-and-rls.md +4 -0
- package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +1 -0
- package/skills/permdock-wire/SKILL.md +2 -2
- package/skills/permdock-wire/references/adapters.md +9 -1
- package/skills/permdock-wire/references/policy-options.md +10 -1
- package/dist/catalog-CRW5Mx_B.js +0 -2
- package/dist/collect-DSWOk19R.js +0 -2
- package/dist/create-CMwIl-w2.js +0 -253
- package/dist/delegation-D8pUEuce.js +0 -103
- package/dist/doctor-collect-CHk6XWOA.js +0 -665
- package/dist/doctor-igIiM_Hl.js +0 -1194
- package/dist/evaluation-items-CfCCejQd.js +0 -40
- package/dist/fields-BMl1RxoX.js +0 -26
- package/dist/grantee-Cv5Sogfv.js +0 -602
- package/dist/hosted-DJQw46hn.js +0 -266
- package/dist/instance-options-Dhhcnm3y.js +0 -22
- package/dist/load-DwJUafYN.js +0 -2
- package/dist/map-AcxK9o_D.js +0 -147
- package/dist/permissions-DVoF6dMt.js +0 -662
- package/dist/powersync-e_AZrKbg.js +0 -2
- package/dist/protected-xAr24FEC.d.ts +0 -40
- package/dist/relations-cjOL01hZ.js +0 -505
- package/dist/rls-graph-CHwx0V8q.js +0 -435
- package/dist/rls-shared-Cewd_-zI.js +0 -495
- package/dist/store-jOBYpiXC.d.ts +0 -36
- package/dist/stores-C0IMQrGI.d.ts +0 -52
- package/dist/supabase-config-BpPXt3x1.js +0 -472
- package/dist/supabase-hook-6sYLXg_u.js +0 -2
- package/dist/thenable-okc2xDsj.js +0 -10
- package/dist/types-CKNSyIf_.d.ts +0 -52
- package/dist/usage-DorAJiU_.js +0 -2
- package/dist/watch-BwxLTYMZ.js +0 -2
|
@@ -1,129 +1,79 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
1
|
+
import { r as isRecord } from "./compact-CAf557ZB.js";
|
|
2
|
+
import { a as hasConditionOp } from "./refs-f3imMjNW.js";
|
|
3
|
+
import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-tvpa34ep.js";
|
|
4
|
+
import { i as describeError } from "./errors-CWfoNg9N.js";
|
|
5
|
+
import { i as readJsonFile, o as sqlFiles, s as writeFileEnsuringDir } from "./files-D_JIzUrP.js";
|
|
6
|
+
import { i as SUPABASE_MANIFEST_SCHEMA } from "./version-BEogC-rB.js";
|
|
7
|
+
import { c as requirePolicy } from "./load-C3_oTHyO.js";
|
|
8
|
+
import { r as scopeColumn } from "./compile-CcEu2ZQ1.js";
|
|
9
|
+
import { t as authorizeSql } from "./supabase-C1TWlnYt.js";
|
|
9
10
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
writeFileSync(path, file.text);
|
|
45
|
-
wrote.push(file.rel);
|
|
11
|
+
import { r as qualifySqlTable } from "./sql-t8teWOfo.js";
|
|
12
|
+
import { c as hookApiNames, d as keptKeys, t as AUTHZ_VERSION_TABLE, u as roleManifest } from "./sources-B1-b-R0t.js";
|
|
13
|
+
import { i as readMemberships } from "./manifest-BngWKryL.js";
|
|
14
|
+
import { n as GRANTS_MARKER, r as HOOK_MARKER, s as hookMarkerFields } from "./markers-CiwAOZtO.js";
|
|
15
|
+
import { n as MIGRATION_DIRS } from "./constants-DcJABbLW.js";
|
|
16
|
+
import { E as scopeTypeOf, F as quoteQualified, I as quoteTable, N as quoteIdent, O as tenantTypeOf, P as quoteLiteral, a as customRolesOn, l as USER_ID_HELPER, r as HELPERS } from "./shared-1uhI-e-b.js";
|
|
17
|
+
import { l as writeOutputFiles, n as driftOf, o as partPath, s as pgDeltaPath } from "./output-files-CgIsvdSa.js";
|
|
18
|
+
import { a as splitUndiffed, r as tableKey, t as decidingColumns } from "./deciding-columns-Sq12mEA6.js";
|
|
19
|
+
import { B as memberIdsHelper, D as roleNames, F as hasMemberFor, H as permittedForHelper, L as memberForHelper, R as memberForSources, S as apiKeyFields, U as permittedIdsHelper, V as memberRoleOf, Y as checkSuspension, Z as pgConnector, _ as globalRoleSource, b as API_KEY_ALLOWS, n as permissionKeysForHelper, q as activeRowSql, z as memberForTable } from "./permission-keys-B26jeyUv.js";
|
|
20
|
+
import { a as ASSIGNMENTS, i as guardedTables, o as OWNERSHIP, t as ownershipRules } from "./ownership-BgnbOJFT.js";
|
|
21
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
22
|
+
import { dirname, join, posix, resolve } from "node:path";
|
|
23
|
+
import { parse } from "smol-toml";
|
|
24
|
+
//#region src/cli/supabase-config.ts
|
|
25
|
+
const SUPABASE_CONFIG = "supabase/config.toml";
|
|
26
|
+
function table(value) {
|
|
27
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function strings(value) {
|
|
31
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
|
|
32
|
+
}
|
|
33
|
+
/** `supabase/config.toml`, parsed; `undefined` when the project has none. */
|
|
34
|
+
function readSupabaseConfig(cwd) {
|
|
35
|
+
const file = join(cwd, SUPABASE_CONFIG);
|
|
36
|
+
if (!existsSync(file)) return;
|
|
37
|
+
let root;
|
|
38
|
+
try {
|
|
39
|
+
root = parse(readFileSync(file, "utf8"));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
error: describeError(error)
|
|
44
|
+
};
|
|
46
45
|
}
|
|
46
|
+
const expiry = table(root["auth"])?.["jwt_expiry"];
|
|
47
|
+
const paths = table(table(root["db"])?.["migrations"])?.["schema_paths"];
|
|
48
|
+
const schemaPaths = strings(paths);
|
|
49
|
+
const apiSchemas = strings(table(root["api"])?.["schemas"]);
|
|
50
|
+
const pgDelta = table(table(root["experimental"])?.["pgdelta"]);
|
|
51
|
+
const schemaPath = pgDelta?.["declarative_schema_path"];
|
|
47
52
|
return {
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
ok: true,
|
|
54
|
+
config: {
|
|
55
|
+
...typeof expiry === "number" ? { jwtExpiry: expiry } : {},
|
|
56
|
+
...schemaPaths === void 0 ? {} : { schemaPaths },
|
|
57
|
+
...apiSchemas === void 0 ? {} : { apiSchemas },
|
|
58
|
+
...pgDelta?.["enabled"] === true ? { pgDelta: { schemaDir: posix.join("supabase", typeof schemaPath === "string" ? schemaPath : "schemas").replace(/\/$/u, "") } } : {}
|
|
59
|
+
}
|
|
50
60
|
};
|
|
51
61
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function partPath(out, part) {
|
|
68
|
-
return out.replaceAll("{part}", part);
|
|
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
|
-
};
|
|
62
|
+
/** The parsed config, or `{}` when it is missing or does not parse (PD045 reports that). */
|
|
63
|
+
function supabaseConfig(cwd) {
|
|
64
|
+
const read = readSupabaseConfig(cwd);
|
|
65
|
+
return read?.ok === true ? read.config : {};
|
|
66
|
+
}
|
|
67
|
+
/** PD045: a `supabase/config.toml` the CLI cannot parse, so every check that reads it falls back to defaults. */
|
|
68
|
+
function pd045(cwd) {
|
|
69
|
+
const read = readSupabaseConfig(cwd);
|
|
70
|
+
if (read === void 0 || read.ok) return [];
|
|
71
|
+
return [{
|
|
72
|
+
code: "PD045",
|
|
73
|
+
severity: "warning",
|
|
74
|
+
message: `${SUPABASE_CONFIG} does not parse: ${read.error.replace(/\n[\s\S]*/u, "")}`,
|
|
75
|
+
fix: `fix the TOML syntax in ${SUPABASE_CONFIG}; until then doctor assumes Supabase's defaults for jwt_expiry and schema_paths`
|
|
76
|
+
}];
|
|
127
77
|
}
|
|
128
78
|
//#endregion
|
|
129
79
|
//#region src/cli/claims-schema.ts
|
|
@@ -249,92 +199,7 @@ const PERMDOCK_CLAIMS_SCHEMA = {
|
|
|
249
199
|
}
|
|
250
200
|
};
|
|
251
201
|
//#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";
|
|
202
|
+
//#region src/cli/supabase-hook/claims.ts
|
|
338
203
|
const CLAIM_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
339
204
|
const PROTOTYPE_KEYS = /* @__PURE__ */ new Set([
|
|
340
205
|
"__proto__",
|
|
@@ -485,103 +350,341 @@ create extension if not exists pg_jsonschema with schema extensions;
|
|
|
485
350
|
grant usage on schema extensions to supabase_auth_admin;
|
|
486
351
|
grant execute on function extensions.jsonb_matches_schema(json, jsonb) to supabase_auth_admin;` : "";
|
|
487
352
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
353
|
+
/**
|
|
354
|
+
* Fails the migration when a client role can write an `attrs` column: a
|
|
355
|
+
* claim the user can set is not a server-owned attribute.
|
|
356
|
+
*/
|
|
357
|
+
function attrsGuardSql(plan) {
|
|
358
|
+
if (plan?.table === void 0 || plan.columns.length === 0) return "";
|
|
359
|
+
const target = quoteLiteral(quoteQualified(plan.table));
|
|
360
|
+
return `-- attrs must be server-owned: refuse columns anon or authenticated can insert or update
|
|
361
|
+
do $$
|
|
362
|
+
begin
|
|
363
|
+
if exists (
|
|
364
|
+
select 1
|
|
365
|
+
from (values ${plan.columns.map((column) => `(${quoteLiteral(column)})`).join(", ")}) c(name)
|
|
366
|
+
cross join (values ('anon'), ('authenticated')) r(role)
|
|
367
|
+
where has_column_privilege(r.role, ${target}, c.name, 'INSERT')
|
|
368
|
+
or has_column_privilege(r.role, ${target}, c.name, 'UPDATE')
|
|
369
|
+
) then
|
|
370
|
+
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}
|
|
371
|
+
using errcode = '42501';
|
|
372
|
+
end if;
|
|
373
|
+
end
|
|
374
|
+
$$;`;
|
|
505
375
|
}
|
|
506
|
-
|
|
507
|
-
function mappedMembership(mapped, scopes, name) {
|
|
508
|
-
const id = scopeColumn(mapped, scopes, name) ?? "";
|
|
509
|
-
const within = {};
|
|
510
|
-
for (const ancestor of scopeChain(scopes, name).slice(1)) {
|
|
511
|
-
const column = scopeColumn(mapped, scopes, ancestor);
|
|
512
|
-
if (column !== void 0) within[ancestor] = column;
|
|
513
|
-
}
|
|
514
|
-
const role = memberRoleOf(mapped);
|
|
515
|
-
const columns = [
|
|
516
|
-
mapped.user,
|
|
517
|
-
id,
|
|
518
|
-
...role.columns,
|
|
519
|
-
...Object.values(within),
|
|
520
|
-
...typeof mapped.via === "string" ? [mapped.via] : [],
|
|
521
|
-
...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
|
|
522
|
-
...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
|
|
523
|
-
];
|
|
376
|
+
function permdockClaim(name, budget = false) {
|
|
524
377
|
return {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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)]
|
|
378
|
+
name,
|
|
379
|
+
source: "permdock",
|
|
380
|
+
budget
|
|
535
381
|
};
|
|
536
382
|
}
|
|
537
|
-
function
|
|
538
|
-
return
|
|
383
|
+
function hookClaims(parts) {
|
|
384
|
+
return [
|
|
385
|
+
permdockClaim("user_role"),
|
|
386
|
+
permdockClaim("roles"),
|
|
387
|
+
permdockClaim("memberships", true),
|
|
388
|
+
permdockClaim("memberships_truncated"),
|
|
389
|
+
permdockClaim(parts.tenantClaim),
|
|
390
|
+
...parts.attrs === void 0 ? [] : [permdockClaim("attrs", true)],
|
|
391
|
+
...parts.version ? [permdockClaim("authz_ver")] : [],
|
|
392
|
+
...parts.extra.map((entry) => ({
|
|
393
|
+
name: entry.claim,
|
|
394
|
+
source: entry.fn,
|
|
395
|
+
budget: false
|
|
396
|
+
}))
|
|
397
|
+
];
|
|
539
398
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/cli/supabase-setup.ts
|
|
401
|
+
const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
|
|
402
|
+
function escape(text) {
|
|
403
|
+
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
544
404
|
}
|
|
545
|
-
/**
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`PermDock CLI: unsafe app_metadata key '${key}'`);
|
|
554
|
-
return { sql: `claims -> 'app_metadata' ->> ${quoteLiteral(key)}` };
|
|
405
|
+
/** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
|
|
406
|
+
function helpersDefined(sql, schema) {
|
|
407
|
+
const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
|
|
408
|
+
const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
|
|
409
|
+
const names = /* @__PURE__ */ new Set();
|
|
410
|
+
for (const match of sql.matchAll(create)) {
|
|
411
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
412
|
+
if (HELPER_NAME.test(name)) names.add(name);
|
|
555
413
|
}
|
|
556
|
-
|
|
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)
|
|
562
|
-
};
|
|
563
|
-
})() : spec;
|
|
564
|
-
const id = "id" in parsed && parsed.id !== void 0 ? parsed.id : "id";
|
|
565
|
-
return {
|
|
566
|
-
sql: `(select a.${quoteIdent(parsed.column)}::text from ${table(parsed.table)} a where a.${quoteIdent(id)} = v_active_user)`,
|
|
567
|
-
userType: `${table(parsed.table)}.${quoteIdent(id)}%type`
|
|
568
|
-
};
|
|
414
|
+
return names;
|
|
569
415
|
}
|
|
570
416
|
/**
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
417
|
+
* SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`;
|
|
418
|
+
* its `helpers` part when it has `{part}`), the SQL files next to the hook,
|
|
419
|
+
* and the migration folders, not the hook itself.
|
|
574
420
|
*/
|
|
575
|
-
function
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
421
|
+
function helperSources(cwd, config, hookOut) {
|
|
422
|
+
const hook = resolve(cwd, hookOut);
|
|
423
|
+
const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
|
|
424
|
+
const rlsOut = resolve(cwd, partPath(config.rls?.out ?? "rls.sql", "helpers"));
|
|
425
|
+
if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
|
|
426
|
+
const beside = dirname(hook);
|
|
427
|
+
if (existsSync(beside)) {
|
|
428
|
+
for (const entry of readdirSync(beside, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".sql")) files.add(join(beside, entry.name));
|
|
429
|
+
}
|
|
430
|
+
files.delete(hook);
|
|
431
|
+
return [...files];
|
|
582
432
|
}
|
|
583
|
-
function
|
|
584
|
-
|
|
433
|
+
function missingHelpersInFiles(cwd, config, manifest) {
|
|
434
|
+
const found = /* @__PURE__ */ new Set();
|
|
435
|
+
for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
|
|
436
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
437
|
+
}
|
|
438
|
+
async function missingHelpersInDb(db, manifest, connect = pgConnector("permdock supabase hook generate --db")) {
|
|
439
|
+
const client = await connect(db);
|
|
440
|
+
try {
|
|
441
|
+
const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
442
|
+
where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
|
|
443
|
+
const found = new Set(result.rows.map((row) => row["proname"]));
|
|
444
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
445
|
+
} finally {
|
|
446
|
+
await client.end();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function missingHelpersMessage(manifest, missing) {
|
|
450
|
+
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`;
|
|
451
|
+
}
|
|
452
|
+
/** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
|
|
453
|
+
function oversizedClaims(samples, manifest) {
|
|
454
|
+
const encoder = new TextEncoder();
|
|
455
|
+
const oversized = [];
|
|
456
|
+
for (const claim of manifest.claims) {
|
|
457
|
+
if (claim.source === "permdock") continue;
|
|
458
|
+
let bytes = 0;
|
|
459
|
+
for (const sample of samples) {
|
|
460
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
461
|
+
const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
|
|
462
|
+
if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
|
|
463
|
+
}
|
|
464
|
+
if (bytes > manifest.budget.bytes) oversized.push({
|
|
465
|
+
name: claim.name,
|
|
466
|
+
bytes
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return oversized;
|
|
470
|
+
}
|
|
471
|
+
/** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
|
|
472
|
+
function droppedMemberships(samples) {
|
|
473
|
+
const out = [];
|
|
474
|
+
for (const [index, sample] of samples.entries()) {
|
|
475
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
476
|
+
const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
|
|
477
|
+
const { dropped } = readMemberships(claim);
|
|
478
|
+
if (dropped.length > 0) out.push({
|
|
479
|
+
sample: index,
|
|
480
|
+
entries: dropped
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
function pd039(input) {
|
|
486
|
+
const findings = [];
|
|
487
|
+
const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
|
|
488
|
+
if (missing.length > 0) findings.push({
|
|
489
|
+
code: "PD039",
|
|
490
|
+
severity: "warning",
|
|
491
|
+
message: missingHelpersMessage(input.manifest, missing).slice(6),
|
|
492
|
+
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"
|
|
493
|
+
});
|
|
494
|
+
const fixture = input.config.doctor?.claims;
|
|
495
|
+
if (fixture !== void 0) {
|
|
496
|
+
const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
|
|
497
|
+
const samples = Array.isArray(parsed) ? parsed : [];
|
|
498
|
+
for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
|
|
499
|
+
code: "PD039",
|
|
500
|
+
severity: "warning",
|
|
501
|
+
message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
|
|
502
|
+
fix: "return less from the claim function, or read the data per request instead of from the token"
|
|
503
|
+
});
|
|
504
|
+
for (const { sample, entries } of droppedMemberships(samples)) findings.push({
|
|
505
|
+
code: "PD039",
|
|
506
|
+
severity: "warning",
|
|
507
|
+
message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
|
|
508
|
+
fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return findings;
|
|
512
|
+
}
|
|
513
|
+
//#endregion
|
|
514
|
+
//#region src/cli/rls/rbac.ts
|
|
515
|
+
/**
|
|
516
|
+
* Where the helpers read roles and memberships: the flag, `rls.authorize`,
|
|
517
|
+
* `rls.rbac.authorize`, then `database` with `rls.membershipSources`, with
|
|
518
|
+
* `--rbac` or with a mapped memberships table, else `jwt`.
|
|
519
|
+
*/
|
|
520
|
+
function resolveAuthorize(config, flags = {}) {
|
|
521
|
+
const rls = config.rls;
|
|
522
|
+
const explicit = flags.authorize ?? rls?.authorize ?? rls?.rbac?.authorize;
|
|
523
|
+
if (explicit !== void 0) return explicit;
|
|
524
|
+
if (rls?.membershipSources !== void 0 || flags.rbac === true) return "database";
|
|
525
|
+
const memberships = flags.memberships ?? rls?.memberships;
|
|
526
|
+
return memberships?.tenant !== void 0 || memberships?.team !== void 0 || Object.keys(memberships?.scopes ?? {}).length > 0 ? "database" : "jwt";
|
|
527
|
+
}
|
|
528
|
+
function parseRbacAuthorize(raw) {
|
|
529
|
+
if (raw === void 0) return;
|
|
530
|
+
if (raw === "database" || raw === "jwt") return raw;
|
|
531
|
+
throw new Error(`PermDock CLI: --authorize must be database or jwt, got '${raw}'`);
|
|
532
|
+
}
|
|
533
|
+
function hookUri(schema) {
|
|
534
|
+
return `pg-functions://postgres/${schema}/custom_access_token_hook`;
|
|
535
|
+
}
|
|
536
|
+
function createEnum(schema, name, values) {
|
|
537
|
+
const list = values.map((value) => quoteLiteral(value)).join(", ");
|
|
538
|
+
return `do $$ begin
|
|
539
|
+
create type ${quoteTable(`${schema}.${name}`)} as enum (${list});
|
|
540
|
+
exception when duplicate_object then null;
|
|
541
|
+
end $$;`;
|
|
542
|
+
}
|
|
543
|
+
/** A read policy and `select` grant for `supabase_auth_admin` on a table the hook reads. */
|
|
544
|
+
function authAdminRead(table, label) {
|
|
545
|
+
const t = quoteQualified(table);
|
|
546
|
+
const policy = quoteIdent(`permdock_auth_admin_read_${label}`);
|
|
547
|
+
return `grant select on table ${t} to supabase_auth_admin;
|
|
548
|
+
drop policy if exists ${policy} on ${t};
|
|
549
|
+
create policy ${policy} on ${t}
|
|
550
|
+
as permissive for select
|
|
551
|
+
to supabase_auth_admin
|
|
552
|
+
using (true);`;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Supabase's Custom Claims and RBAC scaffold on top of the PermDock helpers:
|
|
556
|
+
* enums, `user_roles` and `authorize()` over the shared `role_permissions`.
|
|
557
|
+
* The token hook comes only from `permdock supabase hook generate`, so one
|
|
558
|
+
* function writes the claims. Policies never call `authorize()` per row; they
|
|
559
|
+
* call the helpers. Never grants anything to `service_role`.
|
|
560
|
+
*/
|
|
561
|
+
function rbacScaffold(policy, options) {
|
|
562
|
+
const schema = options.schema;
|
|
563
|
+
const q = (name) => quoteTable(`${schema}.${name}`);
|
|
564
|
+
const s = quoteIdent(schema);
|
|
565
|
+
const permissions = [...new Set(policy.grants.map((grant) => grant.permission.key))];
|
|
566
|
+
const authorizeFn = authorizeSql({
|
|
567
|
+
schema,
|
|
568
|
+
authorize: options.authorize,
|
|
569
|
+
...options.scope === void 0 ? {} : { scope: options.scope },
|
|
570
|
+
...options.memberships === void 0 ? {} : { tenant: options.memberships },
|
|
571
|
+
...options.customRoles === void 0 ? {} : { customRoles: options.customRoles },
|
|
572
|
+
...options.context?.suspension === void 0 ? {} : { suspension: options.context.suspension }
|
|
573
|
+
});
|
|
574
|
+
return {
|
|
575
|
+
head: `-- rbac scaffold (Supabase Custom Claims and RBAC)
|
|
576
|
+
-- authorize: ${options.authorize}${options.authorize === "jwt" ? " (reads the hook claims; stale until the token refreshes)" : " (reads user_roles on every statement)"}
|
|
577
|
+
-- the custom access token hook that writes user_role${options.authorize === "jwt" ? " and memberships" : ""}: permdock supabase hook generate
|
|
578
|
+
${schema === "public" ? "" : `create schema if not exists ${s};\n`}${createEnum(schema, "app_role", roleNames(policy))}
|
|
579
|
+
${createEnum(schema, "app_permission", permissions)}
|
|
580
|
+
|
|
581
|
+
create table if not exists ${q("user_roles")} (
|
|
582
|
+
user_id uuid not null references auth.users on delete cascade,
|
|
583
|
+
role ${options.customRoles === void 0 ? q("app_role") : "text"} not null,
|
|
584
|
+
primary key (user_id, role)
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
alter table ${q("user_roles")} enable row level security;
|
|
588
|
+
revoke all on table ${q("user_roles")} from authenticated, anon, public;
|
|
589
|
+
`,
|
|
590
|
+
tail: authorizeFn,
|
|
591
|
+
warnings: []
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region src/cli/supabase-hook/memberships.ts
|
|
596
|
+
function memberForPlan(input) {
|
|
597
|
+
const memberFor = input.scopes.map((scope) => scope.name).filter((name) => hasMemberFor({
|
|
598
|
+
dialect: "supabase",
|
|
599
|
+
...input
|
|
600
|
+
}, name));
|
|
601
|
+
const entries = /* @__PURE__ */ new Map();
|
|
602
|
+
for (const name of memberFor) {
|
|
603
|
+
const mapped = memberForTable(input, name);
|
|
604
|
+
const found = mapped === void 0 ? memberForSources(input, name).map((source) => source.sql.manifest) : [mappedMembership(mapped, input.scopes, name)];
|
|
605
|
+
for (const entry of found) entries.set(JSON.stringify(entry), entry);
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
memberFor,
|
|
609
|
+
memberships: [...entries.values()]
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/** An `rls.memberships` table in the manifest's `fromTable` / `fromJunction` shape, for scope `name`. */
|
|
613
|
+
function mappedMembership(mapped, scopes, name) {
|
|
614
|
+
const id = scopeColumn(mapped, scopes, name) ?? "";
|
|
615
|
+
const within = {};
|
|
616
|
+
for (const ancestor of scopeChain(scopes, name).slice(1)) {
|
|
617
|
+
const column = scopeColumn(mapped, scopes, ancestor);
|
|
618
|
+
if (column !== void 0) within[ancestor] = column;
|
|
619
|
+
}
|
|
620
|
+
const role = memberRoleOf(mapped);
|
|
621
|
+
const columns = [
|
|
622
|
+
mapped.user,
|
|
623
|
+
id,
|
|
624
|
+
...role.columns,
|
|
625
|
+
...Object.values(within),
|
|
626
|
+
...typeof mapped.via === "string" ? [mapped.via] : [],
|
|
627
|
+
...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
|
|
628
|
+
...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
|
|
629
|
+
];
|
|
630
|
+
return {
|
|
631
|
+
table: qualifySqlTable(mapped.table),
|
|
632
|
+
user: { column: mapped.user },
|
|
633
|
+
scope: { value: name },
|
|
634
|
+
id: { column: id },
|
|
635
|
+
role: roleManifest(role),
|
|
636
|
+
...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
|
|
637
|
+
...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
|
|
638
|
+
...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
|
|
639
|
+
...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
|
|
640
|
+
columns: [...new Set(columns)]
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function positiveInteger(value, label) {
|
|
644
|
+
const number = typeof value === "string" ? Number(value) : value;
|
|
645
|
+
if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) throw new Error(`PermDock CLI: ${label} must be a positive integer`);
|
|
646
|
+
return number;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* SQL for the active first-scope id, as text, for user `uid`; a table source
|
|
650
|
+
* compares its id column with `v_active_user`, declared as `userType`.
|
|
651
|
+
*/
|
|
652
|
+
function activeFromSql(input, root) {
|
|
653
|
+
const spec = input ?? `app_metadata.active_${root}`;
|
|
654
|
+
if (typeof spec === "string" && spec.startsWith("app_metadata.")) {
|
|
655
|
+
const key = spec.slice(13);
|
|
656
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`PermDock CLI: unsafe app_metadata key '${key}'`);
|
|
657
|
+
return { sql: `claims -> 'app_metadata' ->> ${quoteLiteral(key)}` };
|
|
658
|
+
}
|
|
659
|
+
const parsed = typeof spec === "string" ? (() => {
|
|
660
|
+
const dot = spec.lastIndexOf(".");
|
|
661
|
+
if (dot <= 0) throw new Error(`PermDock CLI: --active-from must be app_metadata.<key> or <table>.<column>, got '${spec}'`);
|
|
662
|
+
return {
|
|
663
|
+
table: spec.slice(0, dot),
|
|
664
|
+
column: spec.slice(dot + 1)
|
|
665
|
+
};
|
|
666
|
+
})() : spec;
|
|
667
|
+
const id = "id" in parsed && parsed.id !== void 0 ? parsed.id : "id";
|
|
668
|
+
return {
|
|
669
|
+
sql: `(select a.${quoteIdent(parsed.column)}::text from ${quoteQualified(parsed.table)} a where a.${quoteIdent(id)} = v_active_user)`,
|
|
670
|
+
userType: `${quoteQualified(parsed.table)}.${quoteIdent(id)}%type`
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* One variable per user-keyed table the hook reads, holding `uid` (a uuid, as
|
|
675
|
+
* Supabase Auth issues) as that column's type, so each lookup compares the
|
|
676
|
+
* column uncast and its index applies.
|
|
677
|
+
*/
|
|
678
|
+
function typedUsers(parts) {
|
|
679
|
+
return [
|
|
680
|
+
...parts.sources.map((source, index) => `v_user_${String(index)} ${source.sql.userType} := uid;`),
|
|
681
|
+
...parts.roles === void 0 ? [] : [`v_roles_user ${quoteQualified(parts.roles.table)}.${quoteIdent(parts.roles.user)}%type := uid;`],
|
|
682
|
+
...parts.attrs?.table === void 0 || parts.attrs.columns.length === 0 ? [] : [`v_attrs_user ${quoteQualified(parts.attrs.table)}.${quoteIdent(parts.attrs.id)}%type := uid;`],
|
|
683
|
+
...parts.active.userType === void 0 ? [] : [`v_active_user ${parts.active.userType} := uid;`]
|
|
684
|
+
].map((line) => `\n ${line}`).join("");
|
|
685
|
+
}
|
|
686
|
+
function checkSources(parts) {
|
|
687
|
+
if (parts.sources.length === 0) throw new Error("PermDock CLI: supabase.hook.memberships needs at least one fromTable or fromJunction source");
|
|
585
688
|
for (const source of parts.sources) {
|
|
586
689
|
if (source.sql === void 0 || typeof source.sql.select !== "function") throw new Error("PermDock CLI: supabase.hook.memberships takes fromTable / fromJunction sources from permdock/supabase");
|
|
587
690
|
const scope = source.sql.scope;
|
|
@@ -607,6 +710,214 @@ from (
|
|
|
607
710
|
${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
|
|
608
711
|
) s`).join("\nunion all\n");
|
|
609
712
|
}
|
|
713
|
+
//#endregion
|
|
714
|
+
//#region src/cli/supabase-hook/api.ts
|
|
715
|
+
const SUBJECT_FOR = "subject_for";
|
|
716
|
+
/**
|
|
717
|
+
* `subject_for(p_user uuid) returns jsonb`: what the hook would put in a
|
|
718
|
+
* token for `p_user`, from the same membership, role, suspension and version
|
|
719
|
+
* reads, plus the custom roles the user holds when `database` mode keeps them
|
|
720
|
+
* in tables. A backend that only has PostgREST (`postgrestSources`) builds a
|
|
721
|
+
* subject for an acting user or a job from it. Naming the user is why no
|
|
722
|
+
* client role may execute it.
|
|
723
|
+
*/
|
|
724
|
+
function subjectForSql(parts, config) {
|
|
725
|
+
const schema = quoteIdent(parts.schema);
|
|
726
|
+
const fn = `${schema}.${SUBJECT_FOR}`;
|
|
727
|
+
const rows = parts.roles;
|
|
728
|
+
const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
|
|
729
|
+
into held
|
|
730
|
+
from ${rows.from}
|
|
731
|
+
where ${rows.userSql} = v_roles_user;`;
|
|
732
|
+
const suspended = parts.users === void 0 ? "" : `
|
|
733
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
734
|
+
return jsonb_build_object('id', uid::text, 'active', false);
|
|
735
|
+
end if;`;
|
|
736
|
+
const version = parts.version ? `
|
|
737
|
+
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
738
|
+
ver := coalesce(ver, 0);` : "";
|
|
739
|
+
const custom = customRolesOn(config.rls) && resolveAuthorize(config) === "database" ? customRolesOfSql(parts) : "";
|
|
740
|
+
return `-- the subject the hook would mint for p_user, for a backend acting for a stored user; no client role may execute it
|
|
741
|
+
create or replace function ${fn}(p_user uuid)
|
|
742
|
+
returns jsonb
|
|
743
|
+
language plpgsql
|
|
744
|
+
stable
|
|
745
|
+
security definer
|
|
746
|
+
set search_path = ''
|
|
747
|
+
as $$
|
|
748
|
+
declare
|
|
749
|
+
uid uuid := p_user;
|
|
750
|
+
held jsonb;
|
|
751
|
+
memberships jsonb;
|
|
752
|
+
custom jsonb := '[]'::jsonb;
|
|
753
|
+
ver bigint;${typedUsers(parts)}
|
|
754
|
+
begin
|
|
755
|
+
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
756
|
+
return null;
|
|
757
|
+
end if;${suspended}
|
|
758
|
+
${roles}
|
|
759
|
+
select coalesce(jsonb_agg(x.entry order by x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text), '[]'::jsonb)
|
|
760
|
+
into memberships
|
|
761
|
+
from (
|
|
762
|
+
${entriesSql(parts).replaceAll(/^/gmu, " ")}
|
|
763
|
+
) x;${custom}${version}
|
|
764
|
+
return jsonb_strip_nulls(jsonb_build_object(
|
|
765
|
+
'id', uid::text,
|
|
766
|
+
'active', true,
|
|
767
|
+
'roles', held,
|
|
768
|
+
'memberships', memberships,
|
|
769
|
+
'customRoles', custom,
|
|
770
|
+
'authzVersion', ver
|
|
771
|
+
));
|
|
772
|
+
end;
|
|
773
|
+
$$;
|
|
774
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* The PostgREST wrappers `supabase.hook.api` asks for: one `security definer`
|
|
778
|
+
* function per reader, in the API schema, executable by `service_role` only.
|
|
779
|
+
*/
|
|
780
|
+
function apiWrappersSql(parts) {
|
|
781
|
+
const api = parts.hook.api;
|
|
782
|
+
if (api === void 0) return "";
|
|
783
|
+
const target = api.schema ?? "public";
|
|
784
|
+
const names = hookApiNames(api.prefix);
|
|
785
|
+
const source = quoteIdent(parts.schema);
|
|
786
|
+
const wrapper = (name, inner, params, args, types, returns) => {
|
|
787
|
+
const fn = `${quoteIdent(target)}.${quoteIdent(name)}`;
|
|
788
|
+
if (target === parts.schema && name === inner) throw new Error(`PermDock CLI: supabase.hook.api would replace ${source}.${inner}: set api.schema or api.prefix`);
|
|
789
|
+
return `create or replace function ${fn}(${params})
|
|
790
|
+
returns ${returns}
|
|
791
|
+
language sql
|
|
792
|
+
stable
|
|
793
|
+
security definer
|
|
794
|
+
set search_path = ''
|
|
795
|
+
as $$ select ${source}.${inner}(${args}) $$;
|
|
796
|
+
revoke execute on function ${fn}(${types}) from public, anon, authenticated;
|
|
797
|
+
grant execute on function ${fn}(${types}) to service_role;`;
|
|
798
|
+
};
|
|
799
|
+
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
|
|
800
|
+
${wrapper(names.subjectFor, SUBJECT_FOR, "p_user uuid", "p_user", "uuid", "jsonb")}
|
|
801
|
+
|
|
802
|
+
${wrapper(names.membersOf, MEMBERS_OF, "p_scope text, p_id text", "p_scope, p_id", "text, text", "jsonb")}
|
|
803
|
+
|
|
804
|
+
${wrapper(names.authzVersionFor, AUTHZ_VERSION_FOR, "p_user uuid", "p_user", "uuid", "bigint")}`;
|
|
805
|
+
}
|
|
806
|
+
const AUTHZ_VERSION_FOR = "authz_version_for";
|
|
807
|
+
/**
|
|
808
|
+
* `authz_version_for(p_user uuid) returns bigint`: the authorization version
|
|
809
|
+
* `subject_for` reports, without reading roles or memberships, so a
|
|
810
|
+
* `claimsFirst` reread can check a token's freshness on every request and read
|
|
811
|
+
* the full subject only when it is behind. `null` for an unknown or suspended
|
|
812
|
+
* user, and when the version is off.
|
|
813
|
+
*/
|
|
814
|
+
function authzVersionForSql(parts) {
|
|
815
|
+
const schema = quoteIdent(parts.schema);
|
|
816
|
+
const fn = `${schema}.${AUTHZ_VERSION_FOR}`;
|
|
817
|
+
return `-- the authorization version subject_for reports for p_user, for a cheap freshness check; no client role may execute it
|
|
818
|
+
create or replace function ${fn}(p_user uuid)
|
|
819
|
+
returns bigint
|
|
820
|
+
language plpgsql
|
|
821
|
+
stable
|
|
822
|
+
security definer
|
|
823
|
+
set search_path = ''
|
|
824
|
+
as $$
|
|
825
|
+
declare
|
|
826
|
+
uid uuid := p_user;
|
|
827
|
+
ver bigint;
|
|
828
|
+
begin
|
|
829
|
+
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
830
|
+
return null;
|
|
831
|
+
end if;${parts.users === void 0 ? "" : `
|
|
832
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
833
|
+
return null;
|
|
834
|
+
end if;`}${parts.version ? `
|
|
835
|
+
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
836
|
+
ver := coalesce(ver, 0);` : ""}
|
|
837
|
+
return ver;
|
|
838
|
+
end;
|
|
839
|
+
$$;
|
|
840
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
841
|
+
}
|
|
842
|
+
const MEMBERS_OF = "members_of";
|
|
843
|
+
function membersOfSql(parts) {
|
|
844
|
+
const fn = `${quoteIdent(parts.schema)}.${MEMBERS_OF}`;
|
|
845
|
+
const keep = parts.sources.some((source) => source.sql.keeps);
|
|
846
|
+
const rows = parts.sources.map((source) => source.sql.list(keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
847
|
+
return `-- the live memberships of one scope instance, for a backend that lists members over PostgREST; no client role may execute it
|
|
848
|
+
create or replace function ${fn}(p_scope text, p_id text)
|
|
849
|
+
returns jsonb
|
|
850
|
+
language sql
|
|
851
|
+
stable
|
|
852
|
+
security definer
|
|
853
|
+
set search_path = ''
|
|
854
|
+
as $$
|
|
855
|
+
select coalesce(jsonb_agg(jsonb_build_object(
|
|
856
|
+
'principal', jsonb_build_object('id', s.user_id),
|
|
857
|
+
'membership', jsonb_strip_nulls(jsonb_build_object(
|
|
858
|
+
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
859
|
+
'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
|
|
860
|
+
'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
|
|
861
|
+
'managedBy', s.managed_by, 'entitlements', s.seats${keep ? ", 'keep', s.keep" : ""}
|
|
862
|
+
))
|
|
863
|
+
) order by s.user_id, s.roles::text), '[]'::jsonb)
|
|
864
|
+
from (
|
|
865
|
+
${rows}
|
|
866
|
+
) s
|
|
867
|
+
$$;
|
|
868
|
+
revoke execute on function ${fn}(text, text) from public, anon, authenticated;`;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* The custom roles `p_user` holds: tenant roles named on a membership of the
|
|
872
|
+
* scope (and instance) they live at, and platform roles among the global
|
|
873
|
+
* roles, each with its grants and includes in `CustomRole` form.
|
|
874
|
+
*/
|
|
875
|
+
function customRolesOfSql(parts) {
|
|
876
|
+
const helpers = quoteIdent(parts.helpers.schema);
|
|
877
|
+
const perms = `${helpers}.custom_role_permissions`;
|
|
878
|
+
const includes = `${helpers}.custom_role_includes`;
|
|
879
|
+
const root = quoteLiteral(parts.root);
|
|
880
|
+
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`;
|
|
881
|
+
return `
|
|
882
|
+
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
883
|
+
'name', c.role,
|
|
884
|
+
'tenant', c.tenant_id::text,
|
|
885
|
+
'scope', c.scope,
|
|
886
|
+
'id', c.scope_id,
|
|
887
|
+
'grants', (
|
|
888
|
+
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
889
|
+
'permission', p.permission,
|
|
890
|
+
'effect', p.effect,
|
|
891
|
+
'level', to_jsonb(p) ->> 'level'
|
|
892
|
+
)) order by p.permission, p.effect), '[]'::jsonb)
|
|
893
|
+
from ${perms} p
|
|
894
|
+
where ${match}
|
|
895
|
+
),
|
|
896
|
+
'includes', (
|
|
897
|
+
select jsonb_agg(p.include_role order by p.include_role)
|
|
898
|
+
from ${includes} p
|
|
899
|
+
where ${match}
|
|
900
|
+
)
|
|
901
|
+
)) order by c.tenant_id::text, c.scope, c.scope_id, c.role), '[]'::jsonb)
|
|
902
|
+
into custom
|
|
903
|
+
from (
|
|
904
|
+
select tenant_id, scope, scope_id, role from ${perms}
|
|
905
|
+
union
|
|
906
|
+
select tenant_id, scope, scope_id, role from ${includes}
|
|
907
|
+
) c
|
|
908
|
+
where (c.scope = 'global' and c.tenant_id is null and held ? c.role)
|
|
909
|
+
or exists (
|
|
910
|
+
select 1
|
|
911
|
+
from jsonb_array_elements(memberships) m
|
|
912
|
+
where m -> 'roles' ? c.role
|
|
913
|
+
and m ->> 'scope' = c.scope
|
|
914
|
+
and c.tenant_id::text = case when m ->> 'scope' = ${root} then m ->> 'id' else m -> 'within' ->> ${root} end
|
|
915
|
+
and (c.scope_id is null or c.scope_id = m ->> 'id')
|
|
916
|
+
);`;
|
|
917
|
+
}
|
|
918
|
+
//#endregion
|
|
919
|
+
//#region src/cli/supabase-hook/sql.ts
|
|
920
|
+
const MANAGED_TRIGGER = "permdock_protect_managed";
|
|
610
921
|
function hookSql(parts) {
|
|
611
922
|
const schema = quoteIdent(parts.schema);
|
|
612
923
|
const fn = `${schema}.custom_access_token_hook`;
|
|
@@ -619,7 +930,7 @@ function hookSql(parts) {
|
|
|
619
930
|
const fromTable = plan === void 0 || plan.columns.length === 0 || plan.table === void 0 ? "" : `
|
|
620
931
|
select jsonb_strip_nulls(jsonb_build_object(${plan.columns.map((column) => `${quoteLiteral(column)}, to_jsonb(p.${quoteIdent(column)})`).join(", ")}))
|
|
621
932
|
into attrs
|
|
622
|
-
from ${
|
|
933
|
+
from ${quoteQualified(plan.table)} p
|
|
623
934
|
where p.${quoteIdent(plan.id)} = v_attrs_user;`;
|
|
624
935
|
const fromMeta = plan === void 0 || plan.meta.length === 0 ? "" : `
|
|
625
936
|
attrs := coalesce(attrs, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(${plan.meta.map((key) => `${quoteLiteral(key)}, claims -> 'app_metadata' -> ${quoteLiteral(key)}`).join(", ")}));`;
|
|
@@ -752,6 +1063,92 @@ function readsSql(parts) {
|
|
|
752
1063
|
if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
|
|
753
1064
|
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
1065
|
}
|
|
1066
|
+
function managedSql(parts) {
|
|
1067
|
+
const managed = parts.sources.filter((source) => source.sql.managed !== void 0);
|
|
1068
|
+
if (managed.length === 0) return "";
|
|
1069
|
+
const guard = `${quoteIdent(parts.schema)}.permdock_protect_managed`;
|
|
1070
|
+
return `-- memberships the identity provider owns: clients (anon, authenticated) cannot write them
|
|
1071
|
+
create or replace function ${guard}()
|
|
1072
|
+
returns trigger
|
|
1073
|
+
language plpgsql
|
|
1074
|
+
set search_path = ''
|
|
1075
|
+
as $$
|
|
1076
|
+
declare
|
|
1077
|
+
column_name text := tg_argv[0];
|
|
1078
|
+
begin
|
|
1079
|
+
if current_user in ('anon', 'authenticated') and (
|
|
1080
|
+
column_name = ''
|
|
1081
|
+
or (tg_op <> 'INSERT' and to_jsonb(old) ->> column_name = 'idp')
|
|
1082
|
+
or (tg_op <> 'DELETE' and to_jsonb(new) ->> column_name = 'idp')
|
|
1083
|
+
) then
|
|
1084
|
+
raise exception 'PermDock: % rows managed by the identity provider cannot be changed here', tg_table_name
|
|
1085
|
+
using errcode = '42501';
|
|
1086
|
+
end if;
|
|
1087
|
+
return coalesce(new, old);
|
|
1088
|
+
end;
|
|
1089
|
+
$$;
|
|
1090
|
+
${managed.map((source) => `drop trigger if exists ${quoteIdent(MANAGED_TRIGGER)} on ${quoteQualified(source.sql.table)};
|
|
1091
|
+
create trigger ${quoteIdent(MANAGED_TRIGGER)}
|
|
1092
|
+
before insert or update or delete on ${quoteQualified(source.sql.table)}
|
|
1093
|
+
for each row execute function ${guard}(${quoteLiteral(source.sql.managed ?? "")});`).join("\n")}`;
|
|
1094
|
+
}
|
|
1095
|
+
/** The `config.toml` block that enables the hook and bounds token staleness. */
|
|
1096
|
+
function configToml(schema, jwtExpiry) {
|
|
1097
|
+
return `[auth]
|
|
1098
|
+
jwt_expiry = ${String(jwtExpiry)}
|
|
1099
|
+
|
|
1100
|
+
[auth.hook.custom_access_token]
|
|
1101
|
+
enabled = true
|
|
1102
|
+
uri = "${hookUri(schema)}"`;
|
|
1103
|
+
}
|
|
1104
|
+
const BUDGET_MEASURE = "octet_length(memberships::text) + octet_length(attrs::text)";
|
|
1105
|
+
function hookParts(scopes, config, overrides) {
|
|
1106
|
+
const hook = config.supabase?.hook;
|
|
1107
|
+
if (hook === void 0) throw new Error("PermDock CLI: supabase hook generate needs supabase.hook in permdock.config.ts");
|
|
1108
|
+
const root = rootScope(scopes) ?? "tenant";
|
|
1109
|
+
const suspension = checkSuspension(hook.suspension ?? config.rls?.suspension, scopes);
|
|
1110
|
+
const schema = overrides.schema ?? hook.schema ?? config.rls?.schema ?? "permdock";
|
|
1111
|
+
quoteIdent(schema);
|
|
1112
|
+
const tenantClaim = config.rls?.tenantClaim ?? "tenant_id";
|
|
1113
|
+
const extraPlan = extraClaimsPlan(hook.claims, tenantClaim);
|
|
1114
|
+
checkSources({
|
|
1115
|
+
sources: hook.memberships,
|
|
1116
|
+
scopes
|
|
1117
|
+
});
|
|
1118
|
+
const parts = {
|
|
1119
|
+
schema,
|
|
1120
|
+
scopes,
|
|
1121
|
+
root,
|
|
1122
|
+
sources: hook.memberships,
|
|
1123
|
+
budget: positiveInteger(overrides.budget ?? hook.budget ?? 1024, "budget"),
|
|
1124
|
+
version: hook.version !== false,
|
|
1125
|
+
jwtExpiry: positiveInteger(hook.jwtExpiry ?? 900, "jwtExpiry"),
|
|
1126
|
+
tenantClaim,
|
|
1127
|
+
users: suspension?.users,
|
|
1128
|
+
hook,
|
|
1129
|
+
roles: hook.roles === false ? void 0 : globalRoleSource(hook.roles ?? config.rls?.roles ?? { table: `${schema}.user_roles` }, "public", "r", config.rls?.customRoleWrites?.roles),
|
|
1130
|
+
active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
|
|
1131
|
+
attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
|
|
1132
|
+
extra: extraPlan.claims,
|
|
1133
|
+
before: beforePlan(hook.before),
|
|
1134
|
+
helpers: {
|
|
1135
|
+
schema: config.rls?.schema ?? "permdock",
|
|
1136
|
+
...memberForPlan({
|
|
1137
|
+
scopes,
|
|
1138
|
+
...config.rls?.memberships === void 0 ? {} : { memberships: config.rls.memberships },
|
|
1139
|
+
memberSources: config.rls?.membershipSources ?? hook.memberships
|
|
1140
|
+
})
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
if (parts.attrs !== void 0 && parts.attrs.errors.length > 0) throw new Error(`PermDock CLI: ${parts.attrs.errors.join("; ")}`);
|
|
1144
|
+
if (extraPlan.errors.length > 0) throw new Error(`PermDock CLI: ${extraPlan.errors.join("; ")}`);
|
|
1145
|
+
quoteIdent(parts.tenantClaim);
|
|
1146
|
+
return parts;
|
|
1147
|
+
}
|
|
1148
|
+
//#endregion
|
|
1149
|
+
//#region src/cli/supabase-hook/version.ts
|
|
1150
|
+
const VERSION_TRIGGER = "permdock_authz_version";
|
|
1151
|
+
const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
|
|
755
1152
|
function versionSql(parts) {
|
|
756
1153
|
if (!parts.version) return "";
|
|
757
1154
|
const schema = quoteIdent(parts.schema);
|
|
@@ -782,230 +1179,27 @@ as $$
|
|
|
782
1179
|
$$;
|
|
783
1180
|
revoke execute on function ${bumpFor}(uuid[]) from public, anon, authenticated;
|
|
784
1181
|
|
|
785
|
-
create or replace function ${bump}()
|
|
786
|
-
returns trigger
|
|
787
|
-
language plpgsql
|
|
788
|
-
security definer
|
|
789
|
-
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
|
-
);`;
|
|
1182
|
+
create or replace function ${bump}()
|
|
1183
|
+
returns trigger
|
|
1184
|
+
language plpgsql
|
|
1185
|
+
security definer
|
|
1186
|
+
set search_path = ''
|
|
1187
|
+
as $$
|
|
1188
|
+
declare
|
|
1189
|
+
column_name text := tg_argv[0];
|
|
1190
|
+
begin
|
|
1191
|
+
perform ${bumpFor}(array[
|
|
1192
|
+
case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
|
|
1193
|
+
case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
|
|
1194
|
+
]::uuid[]);
|
|
1195
|
+
return null;
|
|
1196
|
+
end;
|
|
1197
|
+
$$;
|
|
1198
|
+
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
1199
|
+
${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${quoteQualified(name)};
|
|
1200
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1201
|
+
after insert or update or delete on ${quoteQualified(name)}
|
|
1202
|
+
for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${memberUsersVersionSql(parts, bumpFor)}${roleKeysVersionSql(parts, versionTable)}`;
|
|
1009
1203
|
}
|
|
1010
1204
|
function signedUp(user) {
|
|
1011
1205
|
return `exists (select 1 from auth.users a where a.id = ${user})`;
|
|
@@ -1046,7 +1240,7 @@ function memberUsersVersionSql(parts, bumpFor) {
|
|
|
1046
1240
|
const linkedBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_linked_users`;
|
|
1047
1241
|
const groups = [...byTable];
|
|
1048
1242
|
const lookup = (users, indent) => `${indent}perform ${bumpFor}(array(
|
|
1049
|
-
${indent} select u.${quoteIdent(users.key)}::uuid from ${
|
|
1243
|
+
${indent} select u.${quoteIdent(users.key)}::uuid from ${quoteQualified(users.table)} u
|
|
1050
1244
|
${indent} where ((tg_op <> 'INSERT' and u.${quoteIdent(users.id)} = old.${quoteIdent(users.ref)})
|
|
1051
1245
|
${indent} or (tg_op <> 'DELETE' and u.${quoteIdent(users.id)} = new.${quoteIdent(users.ref)}))
|
|
1052
1246
|
${indent} and ${signedUp(`u.${quoteIdent(users.key)}::uuid`)}
|
|
@@ -1059,7 +1253,7 @@ ${group.users.map((users) => lookup(users, " ")).join("\n")}
|
|
|
1059
1253
|
end if;`;
|
|
1060
1254
|
}).join("\n");
|
|
1061
1255
|
const memberTriggers = groups.map(([, group]) => {
|
|
1062
|
-
const target =
|
|
1256
|
+
const target = quoteQualified(group.table);
|
|
1063
1257
|
return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
|
|
1064
1258
|
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1065
1259
|
after insert or update or delete on ${target}
|
|
@@ -1077,7 +1271,7 @@ create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
|
1077
1271
|
userTables.set(key, entry);
|
|
1078
1272
|
}
|
|
1079
1273
|
const userTriggers = [...userTables.values()].map((entry) => {
|
|
1080
|
-
const target =
|
|
1274
|
+
const target = quoteQualified(entry.table);
|
|
1081
1275
|
const name = quoteIdent(`${VERSION_TRIGGER}_${entry.key}`);
|
|
1082
1276
|
return `drop trigger if exists ${name} on ${target};
|
|
1083
1277
|
create trigger ${name}
|
|
@@ -1169,7 +1363,7 @@ ${indent}end if;
|
|
|
1169
1363
|
${indent}insert into ${versionTable} as v (user_id, version)
|
|
1170
1364
|
${indent}select distinct h.user_id, 1
|
|
1171
1365
|
${indent}from (
|
|
1172
|
-
${indent} ${holders.map((holder) => holder.userThrough === void 0 ? `select h.${quoteIdent(holder.user)}::uuid as user_id from ${
|
|
1366
|
+
${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
1367
|
${indent}) h
|
|
1174
1368
|
${indent}where h.user_id is not null
|
|
1175
1369
|
${indent} and exists (select 1 from auth.users au where au.id = h.user_id)
|
|
@@ -1217,143 +1411,15 @@ end;
|
|
|
1217
1411
|
$$;
|
|
1218
1412
|
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
1219
1413
|
${groups.map(([, group]) => {
|
|
1220
|
-
const target =
|
|
1414
|
+
const target = quoteQualified(group.table);
|
|
1221
1415
|
return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
|
|
1222
1416
|
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1223
1417
|
after update or delete on ${target}
|
|
1224
1418
|
for each row execute function ${bump}();`;
|
|
1225
1419
|
}).join("\n")}`;
|
|
1226
1420
|
}
|
|
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"),
|
|
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
1421
|
//#endregion
|
|
1356
|
-
//#region src/cli/supabase-hook
|
|
1422
|
+
//#region src/cli/supabase-hook/manifest.ts
|
|
1357
1423
|
function helperList(parts, types) {
|
|
1358
1424
|
const client = ["authenticated"];
|
|
1359
1425
|
return [helperEntry(HELPERS.has, "p_grant text", "boolean", client), ...parts.scopes.flatMap((scope) => {
|
|
@@ -1380,7 +1446,7 @@ function helperEntry(name, args, returns, execute) {
|
|
|
1380
1446
|
*/
|
|
1381
1447
|
function trustedHelpers(parts, config, types, tenantType, policy) {
|
|
1382
1448
|
const database = resolveAuthorize(config) === "database";
|
|
1383
|
-
const custom = database && config.rls
|
|
1449
|
+
const custom = database && customRolesOn(config.rls);
|
|
1384
1450
|
const everyFor = parts.scopes.every((scope) => parts.helpers.memberFor.includes(scope.name));
|
|
1385
1451
|
const client = ["authenticated"];
|
|
1386
1452
|
const scopeTypes = parts.scopes.map((scope) => [scope.name, types.get(scope.name) ?? "uuid"]);
|
|
@@ -1410,7 +1476,7 @@ function trustedHelpers(parts, config, types, tenantType, policy) {
|
|
|
1410
1476
|
function activeRowManifest(row) {
|
|
1411
1477
|
const keep = "keep" in row ? keptKeys(row) : [];
|
|
1412
1478
|
return {
|
|
1413
|
-
table:
|
|
1479
|
+
table: qualifySqlTable(row.table),
|
|
1414
1480
|
id: row.id,
|
|
1415
1481
|
...row.disabledAt === void 0 ? {} : { disabledAt: row.disabledAt },
|
|
1416
1482
|
...row.status === void 0 ? {} : { status: row.status },
|
|
@@ -1425,9 +1491,9 @@ function rlsSettings(parts, config) {
|
|
|
1425
1491
|
const scopes = Object.entries(suspension?.scopes ?? {});
|
|
1426
1492
|
const assigned = rls?.assignments === void 0 ? void 0 : guardedTables(config, parts.scopes);
|
|
1427
1493
|
return {
|
|
1428
|
-
customRoles: resolveAuthorize(config) === "database" && rls
|
|
1494
|
+
customRoles: resolveAuthorize(config) === "database" && customRolesOn(rls),
|
|
1429
1495
|
...parts.roles === void 0 ? {} : { roles: {
|
|
1430
|
-
table:
|
|
1496
|
+
table: qualifySqlTable(parts.roles.table),
|
|
1431
1497
|
user: { column: parts.roles.user },
|
|
1432
1498
|
role: parts.roles.role
|
|
1433
1499
|
} },
|
|
@@ -1537,121 +1603,7 @@ function defaultOut(config) {
|
|
|
1537
1603
|
return config.supabase?.hook?.out ?? "supabase/permdock-hook.sql";
|
|
1538
1604
|
}
|
|
1539
1605
|
//#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
|
|
1606
|
+
//#region src/cli/supabase-hook/supabase-hook.ts
|
|
1655
1607
|
const SUPABASE_HELP = `permdock supabase hook generate | inspect
|
|
1656
1608
|
|
|
1657
1609
|
hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
|
|
@@ -1745,8 +1697,7 @@ function inspectText(manifest) {
|
|
|
1745
1697
|
function manifestFile(cwd, rel, manifest, check) {
|
|
1746
1698
|
const path = resolve(cwd, rel);
|
|
1747
1699
|
if (!check) {
|
|
1748
|
-
|
|
1749
|
-
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1700
|
+
writeFileEnsuringDir(path, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1750
1701
|
return {
|
|
1751
1702
|
code: 0,
|
|
1752
1703
|
output: `wrote ${rel}`
|
|
@@ -1758,14 +1709,14 @@ function manifestFile(cwd, rel, manifest, check) {
|
|
|
1758
1709
|
};
|
|
1759
1710
|
let onDisk;
|
|
1760
1711
|
try {
|
|
1761
|
-
onDisk =
|
|
1712
|
+
onDisk = readJsonFile(path);
|
|
1762
1713
|
} catch {
|
|
1763
1714
|
return {
|
|
1764
1715
|
code: 1,
|
|
1765
1716
|
output: `supabase manifest drift: ${rel} is not JSON`
|
|
1766
1717
|
};
|
|
1767
1718
|
}
|
|
1768
|
-
const found =
|
|
1719
|
+
const found = isRecord(onDisk) ? Object.fromEntries(Object.entries(onDisk)) : {};
|
|
1769
1720
|
const changed = [.../* @__PURE__ */ new Set([...Object.keys(manifest), ...Object.keys(found)])].filter((key) => JSON.stringify(Reflect.get(manifest, key)) !== JSON.stringify(found[key]));
|
|
1770
1721
|
return changed.length === 0 ? {
|
|
1771
1722
|
code: 0,
|
|
@@ -1778,9 +1729,8 @@ function manifestFile(cwd, rel, manifest, check) {
|
|
|
1778
1729
|
async function loadScopes(cwd, config) {
|
|
1779
1730
|
return scopeList((await loadHookPolicy(cwd, config)).scopes);
|
|
1780
1731
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
return asPolicy(pickNamed(await loadModule(resolve(cwd, config.policy)), ["policy"]));
|
|
1732
|
+
function loadHookPolicy(cwd, config) {
|
|
1733
|
+
return requirePolicy(cwd, config.policy, "supabase hook generate needs policy in permdock.config.ts");
|
|
1784
1734
|
}
|
|
1785
1735
|
async function runSupabase(input) {
|
|
1786
1736
|
const [area, action] = input.rest;
|
|
@@ -1834,9 +1784,8 @@ async function runSupabase(input) {
|
|
|
1834
1784
|
output: `supabase hook drift: ${drift.join("; ")}`
|
|
1835
1785
|
};
|
|
1836
1786
|
}
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
const grantsWritten = writeSqlFiles(input.cwd, grantsFile);
|
|
1787
|
+
writeFileEnsuringDir(outPath, sql);
|
|
1788
|
+
const grantsWritten = writeOutputFiles(input.cwd, grantsFile);
|
|
1840
1789
|
const placed = {
|
|
1841
1790
|
...manifest,
|
|
1842
1791
|
hook: {
|
|
@@ -1857,4 +1806,4 @@ async function runSupabase(input) {
|
|
|
1857
1806
|
};
|
|
1858
1807
|
}
|
|
1859
1808
|
//#endregion
|
|
1860
|
-
export {
|
|
1809
|
+
export { supabaseHookManifest as a, rbacScaffold as c, attrsPlan as d, pd045 as f, runSupabase as i, resolveAuthorize as l, hookOut as n, supabaseHookSql as o, supabaseConfig as p, loadScopes as r, parseRbacAuthorize as s, grantsLabel as t, pd039 as u };
|