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,150 +1,293 @@
|
|
|
1
|
-
import { n as isReadonlyArray, r as
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { d as partitionsOf, h as scopeField } from "./validity-uOp8UpYn.js";
|
|
9
|
-
import { t as requiresApproval } from "./approval-required-TzNB9oQA.js";
|
|
10
|
-
import { _ as leveled, p as andWhere } from "./permdock-CbgvLnuo.js";
|
|
11
|
-
import { i as requiresStandIn } from "./delegation-D8pUEuce.js";
|
|
1
|
+
import { a as sole, n as isReadonlyArray, r as isRecord } from "./compact-CAf557ZB.js";
|
|
2
|
+
import { o as isForbiddenKey } from "./thenable-QSmvt-vZ.js";
|
|
3
|
+
import { l as isSqlFunctionField } from "./refs-f3imMjNW.js";
|
|
4
|
+
import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-tvpa34ep.js";
|
|
5
|
+
import { v as listRoles } from "./grantee-CXY-0C87.js";
|
|
6
|
+
import { r as relationCondition, t as inheritCondition } from "./grantee-conditions-MQs5NIuX.js";
|
|
7
|
+
import { l as partitionsOf, p as scopeField } from "./tenancy-DshjqY0n.js";
|
|
12
8
|
import { t as CliError } from "./errors-CWfoNg9N.js";
|
|
13
|
-
import { o as
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { l as roleColumn, u as roleManifest } from "./sources-4p0-4xQm.js";
|
|
9
|
+
import { i as scopeMembershipTable, o as relatedSql, r as scopeColumn } from "./compile-CcEu2ZQ1.js";
|
|
10
|
+
import { l as sqlTextArray, r as qualifySqlTable, t as SQL_IDENT } from "./sql-t8teWOfo.js";
|
|
11
|
+
import { d as keptKeys, l as roleColumn, u as roleManifest } from "./sources-B1-b-R0t.js";
|
|
17
12
|
import { n as requirePeer } from "./peer-BOr_kJE1.js";
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
scopes: "scopes",
|
|
25
|
-
tenant: "tenant",
|
|
26
|
-
roles: "roles"
|
|
27
|
-
};
|
|
28
|
-
function field(settings, name) {
|
|
29
|
-
const value = settings[name] ?? DEFAULTS[name];
|
|
30
|
-
if (typeof value !== "string" || !SQL_IDENT.test(value)) throw new Error(`PermDock CLI: rls.apiKeys.${name} must be a claim field name matching ${SQL_IDENT.source}`);
|
|
31
|
-
return value;
|
|
13
|
+
import { D as sqlType, E as scopeTypeOf, F as quoteQualified, I as quoteTable, M as qualified, N as quoteIdent, O as tenantTypeOf, P as quoteLiteral, S as CLAIM, _ as subjectClaimJsonSql, b as tenantClaimSql, d as keyTenantSql, f as kindFilterSql, h as roleKindSql, i as allowEntry, j as helperSchema, k as CLI, m as memberVia, n as GLOBAL_ROLES, o as rootName, p as liveSessionSql, r as HELPERS, s as signedIn, t as CUSTOM_ROLES, u as globalKindFilterSql, v as subjectClaimSql, x as userIdHelperSql, y as subjectIdSql } from "./shared-1uhI-e-b.js";
|
|
14
|
+
//#region src/cli/pg.ts
|
|
15
|
+
const CONNECT_TIMEOUT_MS = 1e4;
|
|
16
|
+
const STATEMENT_TIMEOUT_MS = 6e4;
|
|
17
|
+
function loadPg(command) {
|
|
18
|
+
return requirePeer(() => import("pg"), "pg", command);
|
|
32
19
|
}
|
|
33
|
-
function
|
|
34
|
-
const settings = setting === true ? {} : setting;
|
|
20
|
+
function options(db) {
|
|
35
21
|
return {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
roles: field(settings, "roles"),
|
|
40
|
-
serviceRoles: [...new Set(settings.serviceRoles ?? [])]
|
|
22
|
+
connectionString: db,
|
|
23
|
+
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
|
|
24
|
+
statement_timeout: STATEMENT_TIMEOUT_MS
|
|
41
25
|
};
|
|
42
26
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
27
|
+
/** Ctrl-C ends the connection, so a query that hangs cannot keep the process alive. */
|
|
28
|
+
function endOnInterrupt(end) {
|
|
29
|
+
const interrupt = () => {
|
|
30
|
+
end().catch(() => void 0);
|
|
31
|
+
process.exitCode = 130;
|
|
32
|
+
};
|
|
33
|
+
process.once("SIGINT", interrupt);
|
|
34
|
+
return async () => {
|
|
35
|
+
process.off("SIGINT", interrupt);
|
|
36
|
+
await end();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function unreachable(command, cause) {
|
|
40
|
+
return new CliError("unavailable", `PermDock CLI: ${command.replace(/^permdock /u, "")} could not connect`, { cause });
|
|
41
|
+
}
|
|
42
|
+
/** The default `connect` of a command: one connection, failing with a message that names `command`. */
|
|
43
|
+
function pgConnector(command) {
|
|
44
|
+
return (db) => connectPg(db, command);
|
|
45
|
+
}
|
|
46
|
+
/** One connection, for commands that run a transaction. */
|
|
47
|
+
async function connectPg(db, command) {
|
|
48
|
+
const client = new (await (loadPg(command))).Client(options(db));
|
|
49
|
+
try {
|
|
50
|
+
await client.connect();
|
|
51
|
+
} catch (cause) {
|
|
52
|
+
throw unreachable(command, cause);
|
|
53
|
+
}
|
|
48
54
|
return {
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
query: (sql, values) => client.query(sql, values),
|
|
56
|
+
end: endOnInterrupt(() => client.end())
|
|
51
57
|
};
|
|
52
58
|
}
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
/** A small pool, for independent catalog reads that can run in parallel. */
|
|
60
|
+
async function connectPgPool(db, command) {
|
|
61
|
+
const pool = new (await (loadPg(command))).Pool({
|
|
62
|
+
...options(db),
|
|
63
|
+
max: 4
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
(await pool.connect()).release();
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
await pool.end();
|
|
69
|
+
throw unreachable(command, cause);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
query: (sql, values) => pool.query(sql, values),
|
|
73
|
+
end: endOnInterrupt(() => pool.end())
|
|
74
|
+
};
|
|
55
75
|
}
|
|
56
|
-
|
|
57
|
-
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/cli/sql/active.ts
|
|
78
|
+
function checkActiveRow(label, row) {
|
|
79
|
+
quoteTable(row.table);
|
|
80
|
+
quoteIdent(row.id);
|
|
81
|
+
if (row.disabledAt !== void 0) quoteIdent(row.disabledAt);
|
|
82
|
+
if (row.status !== void 0) {
|
|
83
|
+
quoteIdent(row.status);
|
|
84
|
+
if (row.active === void 0 || row.active.length === 0) throw new Error(`PermDock CLI: ${label}.status needs the active values in ${label}.active`);
|
|
85
|
+
}
|
|
86
|
+
if (row.disabledAt === void 0 && row.status === void 0) throw new Error(`PermDock CLI: ${label} needs disabledAt or status to tell an active row`);
|
|
87
|
+
}
|
|
88
|
+
/** Validates `rls.suspension` and keys its scopes by declared name. */
|
|
89
|
+
function checkSuspension(suspension, scopes) {
|
|
90
|
+
if (suspension === void 0) return;
|
|
91
|
+
if (suspension.users !== void 0) checkActiveRow("rls.suspension.users", suspension.users);
|
|
92
|
+
const byName = {};
|
|
93
|
+
for (const [key, row] of Object.entries(suspension.scopes ?? {})) {
|
|
94
|
+
const name = resolveScope(scopes, key);
|
|
95
|
+
if (name === void 0) throw new Error(`PermDock CLI: rls.suspension.scopes.${key} names a scope the policy does not declare`);
|
|
96
|
+
checkActiveRow(`rls.suspension.scopes.${key}`, row);
|
|
97
|
+
if (row.keep !== void 0 && !Array.isArray(row.keep)) throw new Error(`PermDock CLI: rls.suspension.scopes.${key}.keep is a list of permissions or permission keys`);
|
|
98
|
+
const keep = keptKeys(row);
|
|
99
|
+
const { keep: _given, ...active } = row;
|
|
100
|
+
byName[name] = keep.length === 0 ? active : {
|
|
101
|
+
...active,
|
|
102
|
+
keep
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const memberships = suspension.memberships;
|
|
106
|
+
if (memberships?.keep !== void 0 && !Array.isArray(memberships.keep)) throw new Error("PermDock CLI: rls.suspension.memberships.keep is a list of permissions or permission keys");
|
|
107
|
+
const keep = keptKeys(memberships);
|
|
108
|
+
return {
|
|
109
|
+
...suspension.users === void 0 ? {} : { users: suspension.users },
|
|
110
|
+
...Object.keys(byName).length === 0 ? {} : { scopes: byName },
|
|
111
|
+
...keep.length === 0 ? {} : { memberships: { keep } }
|
|
112
|
+
};
|
|
58
113
|
}
|
|
59
|
-
function
|
|
60
|
-
return
|
|
114
|
+
function disabledKeep(ctx) {
|
|
115
|
+
return keptKeys(ctx.suspension?.memberships);
|
|
61
116
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
117
|
+
function activeMembershipSql(ctx, disabledAt, permission = ctx.permission === void 0 ? void 0 : { key: ctx.permission }) {
|
|
118
|
+
if (disabledAt === void 0) return [];
|
|
119
|
+
const keep = disabledKeep(ctx);
|
|
120
|
+
if (permission !== void 0 && "key" in permission && keep.includes(permission.key)) return [];
|
|
121
|
+
return permission !== void 0 && "sql" in permission && keep.length > 0 ? [`(${disabledAt} is null or ${permission.sql} = any(array[${keep.map(quoteLiteral).join(", ")}]::text[]))`] : [`${disabledAt} is null`];
|
|
64
122
|
}
|
|
65
|
-
function
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
grant execute on function ${fn}(text) to anon, authenticated;` : `revoke execute on function ${fn}(text) from public, anon;
|
|
70
|
-
grant execute on function ${fn}(text) to authenticated;`;
|
|
71
|
-
return `-- API keys: the ${plan.claim} claim's ${plan.scopes} list is a ceiling on every allow; denies always apply, and a request without the claim is not limited
|
|
72
|
-
create or replace function ${fn}(p_grant text)
|
|
73
|
-
returns boolean
|
|
74
|
-
language sql
|
|
75
|
-
stable
|
|
76
|
-
security definer
|
|
77
|
-
set search_path = ''
|
|
78
|
-
as $$
|
|
79
|
-
select case
|
|
80
|
-
when k is null or k = 'null'::jsonb then true
|
|
81
|
-
when exists (
|
|
82
|
-
select 1 from ${qualified(ctx, "role_permissions")} rp
|
|
83
|
-
where rp.grant_key = p_grant and rp.effect = 'deny'
|
|
84
|
-
) then true
|
|
85
|
-
when jsonb_typeof(k) is distinct from 'object' or jsonb_typeof(${scopes}) is distinct from 'array' then false
|
|
86
|
-
else exists (
|
|
87
|
-
select 1
|
|
88
|
-
from jsonb_array_elements_text(${scopes}) s(permission)
|
|
89
|
-
where ${permissionOf(plan, "s.permission")} = split_part(split_part(p_grant, '#', 1), '@', 1)
|
|
90
|
-
)
|
|
91
|
-
end
|
|
92
|
-
from ${keyOf(ctx, plan)}
|
|
93
|
-
$$;
|
|
94
|
-
${grants}`;
|
|
123
|
+
function keptRowSql(keep, permission) {
|
|
124
|
+
const kind = `coalesce(jsonb_typeof(${keep}), 'null')`;
|
|
125
|
+
if (permission === void 0) return `${kind} = 'null'`;
|
|
126
|
+
return `case ${kind} when 'null' then true when 'array' then ${keep} @> jsonb_build_array(${"key" in permission ? quoteLiteral(permission.key) : permission.sql}) else false end`;
|
|
95
127
|
}
|
|
96
|
-
|
|
97
|
-
|
|
128
|
+
/**
|
|
129
|
+
* `exists` over an active row whose id column equals `id`: a missing row is
|
|
130
|
+
* suspended, so the check fails closed.
|
|
131
|
+
*/
|
|
132
|
+
function activeRowSql(row, id) {
|
|
133
|
+
const table = quoteTable(qualifySqlTable(row.table));
|
|
134
|
+
const parts = [`s.${quoteIdent(row.id)} = ${id}`];
|
|
135
|
+
if (row.disabledAt !== void 0) parts.push(`s.${quoteIdent(row.disabledAt)} is null`);
|
|
136
|
+
if (row.status !== void 0) {
|
|
137
|
+
const values = (row.active ?? []).map(quoteLiteral).join(", ");
|
|
138
|
+
parts.push(`s.${quoteIdent(row.status)}::text = any(array[${values}]::text[])`);
|
|
139
|
+
}
|
|
140
|
+
return `exists (select 1 from ${table} s where ${parts.join(" and ")})`;
|
|
98
141
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
case jsonb_typeof(${roles}) when 'array' then ${roles} else ${serviceRolesJson(plan)} end
|
|
104
|
-
) r(role)`;
|
|
142
|
+
/** The active-user check for `user`; empty without `rls.suspension.users`. */
|
|
143
|
+
function activeUserSql(ctx, user = subjectIdSql(ctx)) {
|
|
144
|
+
const row = ctx.suspension?.users;
|
|
145
|
+
return row === void 0 ? [] : [activeRowSql(row, user)];
|
|
105
146
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return
|
|
109
|
-
`jsonb_typeof(k) = 'object'`,
|
|
110
|
-
`coalesce(${subjectIdSql(ctx)}::text, '') = ''`,
|
|
111
|
-
`coalesce(${tenant}, '') <> ''`,
|
|
112
|
-
...activeInstancesSql(ctx, root, (name) => name === root ? tenant : void 0, permission)
|
|
113
|
-
];
|
|
147
|
+
/** The permission of a grant key held in `grant`: `key#group` and `key@level` name `key`. */
|
|
148
|
+
function grantPermissionSql(grant) {
|
|
149
|
+
return { sql: `split_part(split_part(${grant}, '#', 1), '@', 1)` };
|
|
114
150
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
];
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
151
|
+
/**
|
|
152
|
+
* The active-instance check for every suspendable scope on `scope`'s chain:
|
|
153
|
+
* its own and each ancestor's. `idOf` gives the SQL for the id the membership
|
|
154
|
+
* holds for a scope on that chain. A scope's `keep` lets `permission` through
|
|
155
|
+
* a suspended instance: a kept key known now drops its check, and one read
|
|
156
|
+
* at run time is compared with the kept keys.
|
|
157
|
+
*/
|
|
158
|
+
function activeInstancesSql(ctx, scope, idOf, permission = ctx.permission === void 0 ? void 0 : { key: ctx.permission }) {
|
|
159
|
+
const parts = [];
|
|
160
|
+
for (const name of scopeChain(ctx.scopes, scope)) {
|
|
161
|
+
const row = ctx.suspension?.scopes?.[name];
|
|
162
|
+
if (row === void 0) continue;
|
|
163
|
+
const id = idOf(name);
|
|
164
|
+
if (id === void 0) throw new Error(`PermDock CLI: rls.suspension.scopes.${name} needs the ${name} id on ${scope} memberships: add columns.${name} to the ${scope} memberships table`);
|
|
165
|
+
const keep = keptKeys(row);
|
|
166
|
+
if (permission !== void 0 && "key" in permission && keep.includes(permission.key)) continue;
|
|
167
|
+
const active = activeRowSql(row, `(${id})::${scopeTypeOf(ctx, name)}`);
|
|
168
|
+
parts.push(permission !== void 0 && "sql" in permission && keep.length > 0 ? `(${active} or ${permission.sql} = any(array[${keep.map(quoteLiteral).join(", ")}]::text[]))` : active);
|
|
169
|
+
}
|
|
170
|
+
return parts;
|
|
128
171
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/cli/sql/names.ts
|
|
174
|
+
/** The closure table graph grants read. Part of the SQL contract. */
|
|
175
|
+
const CLOSURE = { table: "permdock_closure" };
|
|
176
|
+
/**
|
|
177
|
+
* The snake_case SQL name of a resource or link (`chatThread` becomes
|
|
178
|
+
* `chat_thread`), or `undefined` when it is not made of ASCII letters,
|
|
179
|
+
* digits and underscores starting with a letter.
|
|
180
|
+
*/
|
|
181
|
+
function graphSqlName(name) {
|
|
182
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/u.test(name)) return;
|
|
183
|
+
return name.replaceAll(/([a-z0-9])([A-Z])/gu, "$1_$2").replaceAll(/([A-Z]+)([A-Z][a-z])/gu, "$1_$2").toLowerCase();
|
|
133
184
|
}
|
|
134
|
-
function
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
) ids(id)
|
|
139
|
-
where ${allows}`;
|
|
185
|
+
function sqlNameOf(name, label) {
|
|
186
|
+
const sql = graphSqlName(name);
|
|
187
|
+
if (sql === void 0) throw new Error(`PermDock CLI: ${label} is not a SQL name (letters, digits and underscores, starting with a letter), so rls generate cannot name its helper`);
|
|
188
|
+
return sql;
|
|
140
189
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
${
|
|
144
|
-
|
|
190
|
+
/** The helper returning the ids of `resource` the subject holds a relation on: `permitted_<snake_case resource>_ids`. */
|
|
191
|
+
function graphHelper(resource) {
|
|
192
|
+
return permittedIdsHelper(sqlNameOf(resource, `resource '${resource}'`));
|
|
193
|
+
}
|
|
194
|
+
function inheritedRowsHelper(resource) {
|
|
195
|
+
return `permitted_${sqlNameOf(resource, `resource '${resource}'`)}_rows`;
|
|
196
|
+
}
|
|
197
|
+
/** The helper returning the ids of `resource` whose `link` points into the ids it is given. */
|
|
198
|
+
function linkHelper(resource, link) {
|
|
199
|
+
return `permdock_link_${sqlNameOf(resource, `resource '${resource}'`)}_${sqlNameOf(link, `link '${link}' on ${resource}`)}`;
|
|
200
|
+
}
|
|
201
|
+
function restrictedHelper(resource) {
|
|
202
|
+
return `permdock_restricted_${sqlNameOf(resource, `resource '${resource}'`)}`;
|
|
203
|
+
}
|
|
204
|
+
/** Graph SQL parts as RLS text: values inline as literals, the subject from the dialect's claim. */
|
|
205
|
+
function graphSqlText(parts, ctx) {
|
|
206
|
+
return parts.map((part) => {
|
|
207
|
+
if ("text" in part) return part.text;
|
|
208
|
+
if ("column" in part) return quoteIdent(part.column);
|
|
209
|
+
if ("subject" in part) return subjectIdSql(ctx);
|
|
210
|
+
return typeof part.value === "string" ? quoteLiteral(part.value) : String(part.value);
|
|
211
|
+
}).join("");
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The role column of an `rls.memberships` table aliased `alias`; a `through`
|
|
215
|
+
* column joins its roles table as `<alias>k`, after a newline and `indent`
|
|
216
|
+
* (inline without one).
|
|
217
|
+
*/
|
|
218
|
+
function memberRoleOf(table, alias = "m", indent) {
|
|
219
|
+
return roleColumn(table.role, qualifySqlTable(table.table), alias, {
|
|
220
|
+
label: `rls.memberships ${table.table} role`,
|
|
221
|
+
prefix: CLI,
|
|
222
|
+
...indent === void 0 ? {} : { indent }
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** The schema-qualified name of a table under `search_path = ''`. */
|
|
226
|
+
/** The helper returning the ids of scope `name` a grant key reaches. */
|
|
227
|
+
function permittedIdsHelper(name) {
|
|
228
|
+
if (!/^[a-z][a-z0-9_]*$/u.test(name)) throw new Error(`PermDock CLI: unsafe scope name '${name}'`);
|
|
229
|
+
return `permitted_${name}_ids`;
|
|
230
|
+
}
|
|
231
|
+
/** `permitted_<scope>_ids_for(p_user, p_grant)`: the same ids for a user the caller names. */
|
|
232
|
+
function permittedForHelper(name) {
|
|
233
|
+
return `${permittedIdsHelper(name)}_for`;
|
|
234
|
+
}
|
|
235
|
+
/** The helper returning the ids of scope `name` the subject holds any live membership of. */
|
|
236
|
+
function memberIdsHelper(name) {
|
|
237
|
+
if (!/^[a-z][a-z0-9_]*$/u.test(name)) throw new Error(`PermDock CLI: unsafe scope name '${name}'`);
|
|
238
|
+
return `member_${name}_ids`;
|
|
239
|
+
}
|
|
240
|
+
/** `member_<scope>_ids_for(p_user uuid)`: the same rows for a user the caller names; the token hook's helper. */
|
|
241
|
+
function memberForHelper(name) {
|
|
242
|
+
return `${memberIdsHelper(name)}_for`;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Whether `member_<scope>_ids_for` is generated for `name`: Supabase only
|
|
246
|
+
* (its one caller is `supabase_auth_admin`), and only where `memberships` maps
|
|
247
|
+
* a table for the scope or a membership source can hold it.
|
|
248
|
+
*/
|
|
249
|
+
function hasMemberFor(input, name) {
|
|
250
|
+
if (input.dialect !== "supabase") return false;
|
|
251
|
+
return memberForTable(input, name) !== void 0 || memberForSources(input, name).length > 0;
|
|
252
|
+
}
|
|
253
|
+
/** The table `member_<scope>_ids_for` reads for scope `name`, when `memberships` maps one with its id column. */
|
|
254
|
+
function memberForTable(input, name) {
|
|
255
|
+
const table = scopeMembershipTable(input.memberships, input.scopes, name);
|
|
256
|
+
return table !== void 0 && scopeColumn(table, input.scopes, name) !== void 0 ? table : void 0;
|
|
257
|
+
}
|
|
258
|
+
/** The sources `member_<scope>_ids_for` reads: none when a table is mapped, as in `database` mode. */
|
|
259
|
+
function memberForSources(input, name) {
|
|
260
|
+
if (memberForTable(input, name) !== void 0) return [];
|
|
261
|
+
return (input.memberSources ?? []).filter((source) => source.sql.scope === void 0 || source.sql.scope === name);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The membership sources that can hold scope `name` when `memberships` maps
|
|
265
|
+
* no table for it: every `fromTable` source and the `fromJunction` sources of
|
|
266
|
+
* that scope. Empty outside `database` mode.
|
|
267
|
+
*/
|
|
268
|
+
function scopeSources(ctx, name) {
|
|
269
|
+
if (ctx.authorize !== "database" || scopeTable(ctx, name) !== void 0) return [];
|
|
270
|
+
return (ctx.sources ?? []).filter((source) => source.sql.scope === void 0 || source.sql.scope === name);
|
|
271
|
+
}
|
|
272
|
+
/** The membership table mapped for scope `name`, with the column of its id and of the first scope's id. */
|
|
273
|
+
function scopeTable(ctx, name) {
|
|
274
|
+
const table = scopeMembershipTable(ctx.memberships, ctx.scopes, name);
|
|
275
|
+
if (table === void 0) return;
|
|
276
|
+
const column = scopeColumn(table, ctx.scopes, name);
|
|
277
|
+
if (column === void 0) return;
|
|
278
|
+
const root = rootScope(ctx.scopes);
|
|
279
|
+
const tenantColumn = root !== void 0 && scopeChain(ctx.scopes, name).includes(root) ? scopeColumn(table, ctx.scopes, root) : void 0;
|
|
280
|
+
return tenantColumn === void 0 ? {
|
|
281
|
+
table,
|
|
282
|
+
column
|
|
283
|
+
} : {
|
|
284
|
+
table,
|
|
285
|
+
column,
|
|
286
|
+
tenantColumn
|
|
287
|
+
};
|
|
145
288
|
}
|
|
146
289
|
//#endregion
|
|
147
|
-
//#region src/cli/
|
|
290
|
+
//#region src/cli/sql/conditions.ts
|
|
148
291
|
const MAX_CLAIM_DEPTH = 8;
|
|
149
292
|
/**
|
|
150
293
|
* The claim path of a `principal.claim.*` / `principal.claims.*` ref, one
|
|
@@ -308,10 +451,10 @@ function compileMemberOf(condition, ctx) {
|
|
|
308
451
|
return hopTable?.id === void 0 ? [] : [existsSql(hopTable, hopTable.id, parent.field, condition.roles, ctx)];
|
|
309
452
|
})].join(" or ")})`;
|
|
310
453
|
}
|
|
311
|
-
if (scope !== void 0 && condition.roles.length === 0 && scopeSources(ctx, scope).length > 0) return `${quoteIdent(condition.field)} in (select ${
|
|
454
|
+
if (scope !== void 0 && condition.roles.length === 0 && scopeSources(ctx, scope).length > 0) return `${quoteIdent(condition.field)} in (select ${qualified(ctx, memberIdsHelper(scope))}())`;
|
|
312
455
|
if (scope !== void 0 && scope === rootScope(ctx.scopes)) {
|
|
313
456
|
const parts = [
|
|
314
|
-
ctx.tenants === "all" ? `${quoteIdent(condition.field)} in (select ${
|
|
457
|
+
ctx.tenants === "all" ? `${quoteIdent(condition.field)} in (select ${qualified(ctx, memberIdsHelper(scope))}())` : `${quoteIdent(condition.field)} = ${tenantClaimSql(ctx)}`,
|
|
315
458
|
...ctx.tenants === "all" ? [] : [keyTenantSql(ctx, quoteIdent(condition.field))].filter((part) => part !== void 0),
|
|
316
459
|
...activeUserSql(ctx),
|
|
317
460
|
...activeInstancesSql(ctx, scope, () => quoteIdent(condition.field))
|
|
@@ -448,12 +591,12 @@ function compileRelatedSql(condition, ctx) {
|
|
|
448
591
|
const type = ctx.columnTypes?.[condition.field];
|
|
449
592
|
const cast = type === void 0 || type === "text" ? "" : `::${type}`;
|
|
450
593
|
const column = type === void 0 ? `${quoteIdent(condition.field)}::text` : quoteIdent(condition.field);
|
|
451
|
-
const helper = condition.permission === void 0 ? `${
|
|
594
|
+
const helper = condition.permission === void 0 ? `${qualified(ctx, graphHelper(condition.resource))}(${quoteLiteral(condition.relation)})` : `${qualified(ctx, inheritedRowsHelper(condition.resource))}(${quoteLiteral(condition.permission)})`;
|
|
452
595
|
const cap = ctx.graph?.closures[condition.resource];
|
|
453
596
|
let inner;
|
|
454
597
|
if (condition.depth > 0 && cap !== void 0) {
|
|
455
598
|
const depth = condition.depth < cap ? ` and depth <= ${String(condition.depth)}` : "";
|
|
456
|
-
inner = `select descendant${cast} from ${
|
|
599
|
+
inner = `select descendant${cast} from ${qualified(ctx, CLOSURE.table)} where resource = ${quoteLiteral(condition.resource)}${depth} and ancestor = any (array(select ${helper}))`;
|
|
457
600
|
} else inner = cast === "" ? `select ${helper}` : `select p.id${cast} from ${helper} as p(id)`;
|
|
458
601
|
const reach = `${column} in (${inner})`;
|
|
459
602
|
if (condition.parent === true && condition.restricted !== void 0) return `(${reach} and ${quoteIdent(condition.restricted)} is not true)`;
|
|
@@ -467,13 +610,13 @@ function compileRelatedSql(condition, ctx) {
|
|
|
467
610
|
function compileHoppedSql(condition, ctx) {
|
|
468
611
|
const resources = ctx.graph?.resources;
|
|
469
612
|
if (resources === void 0) throw new Error("PermDock CLI: a related condition with link hops needs the policy resources in the RLS context");
|
|
470
|
-
const schema = quoteIdent(ctx
|
|
613
|
+
const schema = quoteIdent(helperSchema(ctx));
|
|
471
614
|
return graphSqlText(relatedSql(condition, {
|
|
472
615
|
resources,
|
|
473
616
|
...ctx.graph?.tables === void 0 ? {} : { tables: ctx.graph.tables },
|
|
474
|
-
closure: `${ctx
|
|
617
|
+
closure: `${helperSchema(ctx)}.${CLOSURE.table}`,
|
|
475
618
|
closureDepths: ctx.graph?.closures ?? {},
|
|
476
|
-
qualify:
|
|
619
|
+
qualify: qualifySqlTable,
|
|
477
620
|
holders: (resource, relation) => [{ text: `select ${schema}.${graphHelper(resource)}(${quoteLiteral(relation)})` }],
|
|
478
621
|
permitted: (resource, permission) => [{ text: `select ${schema}.${inheritedRowsHelper(resource)}(${quoteLiteral(permission)})` }],
|
|
479
622
|
restrictedRows: (resource) => [{ text: `select ${schema}.${restrictedHelper(resource)}()` }],
|
|
@@ -485,7 +628,7 @@ function compileHoppedSql(condition, ctx) {
|
|
|
485
628
|
}), ctx);
|
|
486
629
|
}
|
|
487
630
|
//#endregion
|
|
488
|
-
//#region src/cli/
|
|
631
|
+
//#region src/cli/sql/grants.ts
|
|
489
632
|
/**
|
|
490
633
|
* The `role_permissions` grant key of a break-glass grant. It differs from
|
|
491
634
|
* the permission key, so `authorize()` and the table policies never count it;
|
|
@@ -606,66 +749,166 @@ function roleNames(policy) {
|
|
|
606
749
|
return [...names];
|
|
607
750
|
}
|
|
608
751
|
//#endregion
|
|
609
|
-
//#region src/cli/
|
|
610
|
-
|
|
611
|
-
|
|
752
|
+
//#region src/cli/rls/api-keys.ts
|
|
753
|
+
const API_KEY_ALLOWS = "permdock_api_key_allows";
|
|
754
|
+
const DEFAULTS = {
|
|
755
|
+
claim: "api_key",
|
|
756
|
+
scopes: "scopes",
|
|
757
|
+
tenant: "tenant",
|
|
758
|
+
roles: "roles"
|
|
759
|
+
};
|
|
760
|
+
function field(settings, name) {
|
|
761
|
+
const value = settings[name] ?? DEFAULTS[name];
|
|
762
|
+
if (typeof value !== "string" || !SQL_IDENT.test(value)) throw new Error(`PermDock CLI: rls.apiKeys.${name} must be a claim field name matching ${SQL_IDENT.source}`);
|
|
763
|
+
return value;
|
|
612
764
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const table = qualify(source.table, schema);
|
|
616
|
-
const user = source.user ?? "user_id";
|
|
617
|
-
const role = roleColumn(source.role ?? "role", table, alias, {
|
|
618
|
-
label: "roles.role",
|
|
619
|
-
prefix: "PermDock CLI",
|
|
620
|
-
indent: " "
|
|
621
|
-
});
|
|
765
|
+
function apiKeyFields(setting) {
|
|
766
|
+
const settings = setting === true ? {} : setting;
|
|
622
767
|
return {
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
role: roleManifest(role),
|
|
629
|
-
...role.through === void 0 ? {} : { through: role.through }
|
|
768
|
+
claim: field(settings, "claim"),
|
|
769
|
+
scopes: field(settings, "scopes"),
|
|
770
|
+
tenant: field(settings, "tenant"),
|
|
771
|
+
roles: field(settings, "roles"),
|
|
772
|
+
serviceRoles: [...new Set(settings.serviceRoles ?? [])]
|
|
630
773
|
};
|
|
631
774
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
775
|
+
function apiKeysPlan(setting, declared, renamed) {
|
|
776
|
+
if (setting === void 0) return;
|
|
777
|
+
const fields = apiKeyFields(setting);
|
|
778
|
+
const unknown = fields.serviceRoles.filter((role) => !declared.has(role));
|
|
779
|
+
if (unknown.length > 0) throw new Error(`PermDock CLI: rls.apiKeys.serviceRoles names roles the policy does not declare: ${unknown.join(", ")}`);
|
|
780
|
+
return {
|
|
781
|
+
...fields,
|
|
782
|
+
...Object.keys(renamed).length === 0 ? {} : { renamed }
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function keyOf(ctx, plan) {
|
|
786
|
+
return `(select ${subjectClaimJsonSql(ctx, plan.claim)} as k) api_key`;
|
|
787
|
+
}
|
|
788
|
+
function permissionOf(plan, expr) {
|
|
789
|
+
return plan.renamed === void 0 ? expr : `coalesce(${quoteLiteral(JSON.stringify(plan.renamed))}::jsonb ->> ${expr}, ${expr})`;
|
|
790
|
+
}
|
|
791
|
+
function apiKeyAllowsCall(ctx, grant) {
|
|
792
|
+
return `${qualified(ctx, API_KEY_ALLOWS)}(${grant})`;
|
|
793
|
+
}
|
|
794
|
+
function apiKeyAllowsSql(ctx, plan, anonExecute) {
|
|
795
|
+
const fn = qualified(ctx, API_KEY_ALLOWS);
|
|
796
|
+
const scopes = `k -> ${quoteLiteral(plan.scopes)}`;
|
|
797
|
+
const grants = anonExecute ? `revoke execute on function ${fn}(text) from public;
|
|
798
|
+
grant execute on function ${fn}(text) to anon, authenticated;` : `revoke execute on function ${fn}(text) from public, anon;
|
|
799
|
+
grant execute on function ${fn}(text) to authenticated;`;
|
|
800
|
+
return `-- API keys: the ${plan.claim} claim's ${plan.scopes} list is a ceiling on every allow; denies always apply, and a request without the claim is not limited
|
|
801
|
+
create or replace function ${fn}(p_grant text)
|
|
802
|
+
returns boolean
|
|
803
|
+
language sql
|
|
804
|
+
stable
|
|
805
|
+
security definer
|
|
806
|
+
set search_path = ''
|
|
807
|
+
as $$
|
|
808
|
+
select case
|
|
809
|
+
when k is null or k = 'null'::jsonb then true
|
|
810
|
+
when exists (
|
|
811
|
+
select 1 from ${qualified(ctx, "role_permissions")} rp
|
|
812
|
+
where rp.grant_key = p_grant and rp.effect = 'deny'
|
|
813
|
+
) then true
|
|
814
|
+
when jsonb_typeof(k) is distinct from 'object' or jsonb_typeof(${scopes}) is distinct from 'array' then false
|
|
815
|
+
else exists (
|
|
816
|
+
select 1
|
|
817
|
+
from jsonb_array_elements_text(${scopes}) s(permission)
|
|
818
|
+
where ${permissionOf(plan, "s.permission")} = split_part(split_part(p_grant, '#', 1), '@', 1)
|
|
819
|
+
)
|
|
820
|
+
end
|
|
821
|
+
from ${keyOf(ctx, plan)}
|
|
822
|
+
$$;
|
|
823
|
+
${grants}`;
|
|
824
|
+
}
|
|
825
|
+
function serviceRolesJson(plan) {
|
|
826
|
+
return `${quoteLiteral(JSON.stringify(plan.serviceRoles))}::jsonb`;
|
|
827
|
+
}
|
|
828
|
+
function serviceKeyRows(ctx, plan) {
|
|
829
|
+
const roles = `k -> ${quoteLiteral(plan.roles)}`;
|
|
830
|
+
return ` from ${keyOf(ctx, plan)}
|
|
831
|
+
cross join lateral jsonb_array_elements_text(
|
|
832
|
+
case jsonb_typeof(${roles}) when 'array' then ${roles} else ${serviceRolesJson(plan)} end
|
|
833
|
+
) r(role)`;
|
|
834
|
+
}
|
|
835
|
+
function serviceKeyFilters(ctx, plan, root, permission) {
|
|
836
|
+
const tenant = `k ->> ${quoteLiteral(plan.tenant)}`;
|
|
837
|
+
return [
|
|
838
|
+
`jsonb_typeof(k) = 'object'`,
|
|
839
|
+
`coalesce(${subjectIdSql(ctx)}::text, '') = ''`,
|
|
840
|
+
`coalesce(${tenant}, '') <> ''`,
|
|
841
|
+
...activeInstancesSql(ctx, root, (name) => name === root ? tenant : void 0, permission)
|
|
842
|
+
];
|
|
843
|
+
}
|
|
844
|
+
function serviceKeyIdsSql(ctx, plan, root, type) {
|
|
845
|
+
const kind = kindFilterSql(ctx, "r.role", { value: "credential" });
|
|
846
|
+
const set = ctx.grantSet === true;
|
|
847
|
+
const filters = [
|
|
848
|
+
...serviceKeyFilters(ctx, plan, root, grantPermissionSql(set ? "rp.grant_key" : "p_grant")),
|
|
849
|
+
...set ? [] : ["rp.grant_key = p_grant"],
|
|
850
|
+
`rp.scope = ${quoteLiteral(root)}`,
|
|
851
|
+
...kind === void 0 ? [] : [kind]
|
|
852
|
+
];
|
|
853
|
+
return ` select (k ->> ${quoteLiteral(plan.tenant)})::${type}${set ? ", rp.grant_key" : ""}
|
|
854
|
+
${serviceKeyRows(ctx, plan)}
|
|
855
|
+
join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
|
|
856
|
+
where ${filters.join("\n and ")}`;
|
|
857
|
+
}
|
|
858
|
+
function serviceKeyMemberSql(ctx, plan, root, type) {
|
|
859
|
+
return ` select distinct (k ->> ${quoteLiteral(plan.tenant)})::${type}
|
|
860
|
+
${serviceKeyRows(ctx, plan)}
|
|
861
|
+
where ${serviceKeyFilters(ctx, plan, root).join("\n and ")}`;
|
|
862
|
+
}
|
|
863
|
+
function ceilingIdsSql(body, allows) {
|
|
864
|
+
return ` select ids.id
|
|
865
|
+
from (
|
|
866
|
+
${body}
|
|
867
|
+
) ids(id)
|
|
868
|
+
where ${allows}`;
|
|
869
|
+
}
|
|
870
|
+
function ceilingHasSql(body, allows) {
|
|
871
|
+
return ` select ${allows} and coalesce((
|
|
872
|
+
${body}
|
|
873
|
+
), false)`;
|
|
874
|
+
}
|
|
875
|
+
//#endregion
|
|
876
|
+
//#region src/cli/rls/custom-roles.ts
|
|
877
|
+
/** The readers every helpers file ends with. Names are part of the SQL contract. */
|
|
878
|
+
const READERS = {
|
|
879
|
+
keys: "permdock_permission_keys",
|
|
880
|
+
roles: "permdock_role_permissions",
|
|
881
|
+
trusted: "permdock_trusted_role_permissions"
|
|
882
|
+
};
|
|
883
|
+
/**
|
|
884
|
+
* The custom-role objects: the tables (`database` mode), the ceiling view
|
|
885
|
+
* over the `assignable` declared roles, and `permdock_custom_keys`, which
|
|
886
|
+
* resolves one custom role to the grant keys it holds in a scope. A row in
|
|
887
|
+
* the tables can never reach a key outside the view.
|
|
888
|
+
*/
|
|
889
|
+
function customRolesSql(ctx) {
|
|
890
|
+
const custom = ctx.customRoles;
|
|
891
|
+
if (custom === void 0) return "";
|
|
892
|
+
const rp = qualified(ctx, "role_permissions");
|
|
893
|
+
const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
|
|
894
|
+
const keys = qualified(ctx, CUSTOM_ROLES.keys);
|
|
895
|
+
const chunks = [];
|
|
896
|
+
if (ctx.authorize === "database") {
|
|
897
|
+
const tenantType = tenantTypeOf(ctx);
|
|
898
|
+
for (const [name, column, check] of [[
|
|
899
|
+
CUSTOM_ROLES.permissions,
|
|
900
|
+
"permission",
|
|
901
|
+
`,
|
|
902
|
+
effect text not null default 'allow' check (effect in ('allow', 'deny'))`
|
|
903
|
+
], [
|
|
904
|
+
CUSTOM_ROLES.includes,
|
|
905
|
+
"include_role",
|
|
906
|
+
""
|
|
907
|
+
]]) {
|
|
908
|
+
const table = qualified(ctx, name);
|
|
666
909
|
const leveled = custom.levels === true && name === CUSTOM_ROLES.permissions;
|
|
667
910
|
const unique = `${column}${name === CUSTOM_ROLES.permissions ? ", effect" : ""}${leveled ? ", coalesce(level, '')" : ""}`;
|
|
668
|
-
const index = leveled ? `drop index if exists ${qualified
|
|
911
|
+
const index = leveled ? `drop index if exists ${qualified(ctx, quoteIdent(`${name}_key`))};
|
|
669
912
|
alter table ${table} add column if not exists level text check (level ~ '^[a-z][a-z0-9_]*$');
|
|
670
913
|
create unique index if not exists ${quoteIdent(`${name}_level_key`)}` : `create unique index if not exists ${quoteIdent(`${name}_key`)}`;
|
|
671
914
|
chunks.push(`create table if not exists ${table} (
|
|
@@ -679,6 +922,8 @@ create unique index if not exists ${quoteIdent(`${name}_level_key`)}` : `create
|
|
|
679
922
|
);
|
|
680
923
|
${index}
|
|
681
924
|
on ${table} (coalesce(tenant_id::text, ''), scope, coalesce(scope_id, ''), role, ${unique});
|
|
925
|
+
-- a row identity for triggers the application adds, such as an audit trigger that reads the primary key
|
|
926
|
+
alter table ${table} add column if not exists id bigint generated always as identity primary key;
|
|
682
927
|
alter table ${table} enable row level security;
|
|
683
928
|
revoke all on table ${table} from anon, authenticated, public;`);
|
|
684
929
|
}
|
|
@@ -687,7 +932,7 @@ revoke all on table ${table} from anon, authenticated, public;`);
|
|
|
687
932
|
create or replace view ${ceiling} with (security_invoker = true) as
|
|
688
933
|
select rp.scope, rp.role, rp.permission, rp.grant_key, rp.effect
|
|
689
934
|
from ${rp} rp
|
|
690
|
-
where rp.role = any(${
|
|
935
|
+
where rp.role = any(${sqlTextArray(custom.assignable)})
|
|
691
936
|
and exists (
|
|
692
937
|
select 1 from ${rp} a
|
|
693
938
|
where a.role = rp.role
|
|
@@ -766,9 +1011,9 @@ revoke execute on function ${keys}(text[], text[], text[], text) from public, an
|
|
|
766
1011
|
* `unknown-level`.
|
|
767
1012
|
*/
|
|
768
1013
|
function leveledKeysSql(ctx, renames) {
|
|
769
|
-
const rp = qualified
|
|
770
|
-
const ceiling = qualified
|
|
771
|
-
const keys = qualified
|
|
1014
|
+
const rp = qualified(ctx, "role_permissions");
|
|
1015
|
+
const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
|
|
1016
|
+
const keys = qualified(ctx, CUSTOM_ROLES.keys);
|
|
772
1017
|
const renamed = renames.length > 0;
|
|
773
1018
|
const prelude = renamed ? `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
|
|
774
1019
|
` : "";
|
|
@@ -899,15 +1144,15 @@ function renamesSql(renames) {
|
|
|
899
1144
|
* `resolveCustomRole` returns it, never for a deny that merely removes an allow.
|
|
900
1145
|
*/
|
|
901
1146
|
function roleReadersSql(ctx, permissions, trustedReaders) {
|
|
902
|
-
const rp = qualified
|
|
903
|
-
const reader = qualified
|
|
1147
|
+
const rp = qualified(ctx, "role_permissions");
|
|
1148
|
+
const reader = qualified(ctx, READERS.roles);
|
|
904
1149
|
const tenantType = tenantTypeOf(ctx);
|
|
905
1150
|
const custom = ctx.customRoles;
|
|
906
1151
|
const root = ctx.scopes[0]?.name;
|
|
907
1152
|
const database = ctx.authorize === "database";
|
|
908
1153
|
const chunks = [];
|
|
909
1154
|
if (permissions !== void 0) {
|
|
910
|
-
const catalog = qualified
|
|
1155
|
+
const catalog = qualified(ctx, READERS.keys);
|
|
911
1156
|
chunks.push(`-- every declared permission key
|
|
912
1157
|
create or replace function ${catalog}()
|
|
913
1158
|
returns setof text
|
|
@@ -915,7 +1160,7 @@ language sql
|
|
|
915
1160
|
stable
|
|
916
1161
|
set search_path = ''
|
|
917
1162
|
as $$
|
|
918
|
-
select k.permission from unnest(${
|
|
1163
|
+
select k.permission from unnest(${sqlTextArray(permissions.toSorted())}) k(permission) order by 1
|
|
919
1164
|
$$;
|
|
920
1165
|
revoke execute on function ${catalog}() from public, anon;
|
|
921
1166
|
grant execute on function ${catalog}() to authenticated;
|
|
@@ -931,7 +1176,7 @@ as $$
|
|
|
931
1176
|
from ${rp} rp
|
|
932
1177
|
where rp.scope = p_scope
|
|
933
1178
|
and rp.effect = 'allow'
|
|
934
|
-
and rp.permission = any(${
|
|
1179
|
+
and rp.permission = any(${sqlTextArray(permissions.toSorted())})
|
|
935
1180
|
order by 1
|
|
936
1181
|
$$;
|
|
937
1182
|
revoke execute on function ${catalog}(text) from public, anon;
|
|
@@ -948,24 +1193,24 @@ ${declared};
|
|
|
948
1193
|
end;`;
|
|
949
1194
|
let trustedBody = body;
|
|
950
1195
|
if (custom !== void 0 && database && root !== void 0) {
|
|
951
|
-
const tenant = qualified
|
|
952
|
-
const includes = qualified
|
|
953
|
-
const keys = qualified
|
|
954
|
-
const shape = qualified
|
|
955
|
-
const ids = (helper) => `(select x::text from ${qualified
|
|
1196
|
+
const tenant = qualified(ctx, CUSTOM_ROLES.permissions);
|
|
1197
|
+
const includes = qualified(ctx, CUSTOM_ROLES.includes);
|
|
1198
|
+
const keys = qualified(ctx, CUSTOM_ROLES.keys);
|
|
1199
|
+
const shape = qualified(ctx, CUSTOM_ROLES.shape);
|
|
1200
|
+
const ids = (helper) => `(select x::text from ${qualified(ctx, helper)}() x)`;
|
|
956
1201
|
const memberOf = `p_tenant::text in ${ids(memberIdsHelper(root))}
|
|
957
1202
|
or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${ids(memberIdsHelper(scope))}`)}`;
|
|
958
1203
|
const manage = custom.manage ?? [];
|
|
959
1204
|
const platform = manage.length === 0 ? "false" : `exists (
|
|
960
1205
|
select 1 from ${rp} m
|
|
961
1206
|
where m.effect = 'allow'
|
|
962
|
-
and m.permission = any(${
|
|
963
|
-
and ${qualified
|
|
1207
|
+
and m.permission = any(${sqlTextArray(manage)})
|
|
1208
|
+
and ${qualified(ctx, HELPERS.has)}(m.grant_key)
|
|
964
1209
|
)`;
|
|
965
1210
|
const match = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.scope_id is not distinct from p_scope_id and c.role = p_role`;
|
|
966
1211
|
const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
|
|
967
1212
|
const checked = (gate) => `begin
|
|
968
|
-
if p_role = any(${
|
|
1213
|
+
if p_role = any(${sqlTextArray(custom.declared)}) then
|
|
969
1214
|
return query
|
|
970
1215
|
${declared};
|
|
971
1216
|
return;
|
|
@@ -1003,7 +1248,7 @@ ${body}
|
|
|
1003
1248
|
$$;
|
|
1004
1249
|
revoke execute on function ${reader}(text, text, ${tenantType}, text) from public, anon;
|
|
1005
1250
|
grant execute on function ${reader}(text, text, ${tenantType}, text) to authenticated;`);
|
|
1006
|
-
const trusted = qualified
|
|
1251
|
+
const trusted = qualified(ctx, READERS.trusted);
|
|
1007
1252
|
const signature = `text, text, ${tenantType}, text`;
|
|
1008
1253
|
const grants = trustedReaders === void 0 || trustedReaders.length === 0 ? "" : `\ngrant execute on function ${trusted}(${signature}) to ${trustedReaders.map(quoteIdent).join(", ")};`;
|
|
1009
1254
|
chunks.push(`create or replace function ${trusted}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
|
|
@@ -1037,593 +1282,39 @@ ${indent} hint = ${quoteLiteral(hint)};`;
|
|
|
1037
1282
|
function hasCustomRoleChecksFor(ctx) {
|
|
1038
1283
|
return ctx.authorize === "database" && ctx.customRoles !== void 0 && ctx.scopes.length > 0 && ctx.scopes.every((scope) => hasMemberFor(ctx, scope.name));
|
|
1039
1284
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
const keys = qualified$1(ctx, CUSTOM_ROLES.keys);
|
|
1045
|
-
const perms = qualified$1(ctx, CUSTOM_ROLES.permissions);
|
|
1046
|
-
const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
|
|
1047
|
-
const shape = qualified$1(ctx, CUSTOM_ROLES.shape);
|
|
1048
|
-
const beyond = qualified$1(ctx, forUser ? CUSTOM_ROLES.beyondFor : CUSTOM_ROLES.beyond);
|
|
1049
|
-
const guard = qualified$1(ctx, forUser ? CUSTOM_ROLES.guardFor : CUSTOM_ROLES.guard);
|
|
1050
|
-
const user = forUser ? "p_user" : subjectIdSql(ctx);
|
|
1051
|
-
const userParam = forUser ? "p_user uuid, " : "";
|
|
1052
|
-
const userArg = forUser ? "uuid, " : "";
|
|
1053
|
-
const userCall = forUser ? "p_user, " : "";
|
|
1054
|
-
const has = (key) => forUser ? `${qualified$1(ctx, HELPERS.hasFor)}(p_user, ${key})` : `${qualified$1(ctx, HELPERS.has)}(${key})`;
|
|
1055
|
-
const ids = (helper, arg) => `(select x::text from ${qualified$1(ctx, helper)}(${arg}) x)`;
|
|
1056
|
-
const permitted = (scope, key) => forUser ? ids(permittedForHelper(scope), `p_user, ${key}`) : ids(permittedIdsHelper(scope), key);
|
|
1057
|
-
const members = (scope) => forUser ? ids(memberForHelper(scope), "p_user") : ids(memberIdsHelper(scope), "");
|
|
1058
|
-
const heldKey = (key) => `(${has(key)}
|
|
1059
|
-
or (p_scope <> 'global'
|
|
1060
|
-
and (p_tenant::text in ${permitted(root, key)}
|
|
1061
|
-
or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${permitted(scope, key)}`)})))`;
|
|
1062
|
-
const holdsAny = (permissions, held) => permissions.length === 0 ? "false" : `exists (
|
|
1063
|
-
select 1 from ${rp} rp
|
|
1064
|
-
where rp.effect = 'allow'
|
|
1065
|
-
and rp.permission = any(${textArray(permissions)})
|
|
1066
|
-
and ${held("rp.grant_key")}
|
|
1067
|
-
)`;
|
|
1068
|
-
const manageKeys = custom.manage ?? [];
|
|
1069
|
-
const manage = holdsAny(manageKeys, heldKey);
|
|
1070
|
-
const platform = holdsAny(manageKeys, has);
|
|
1071
|
-
const requires = custom.requires === void 0 ? "" : `
|
|
1072
|
-
if not ${holdsAny(custom.requires, heldKey)} then
|
|
1073
|
-
${raiseSql(" ", "42501", `'permdock: the caller may not manage roles in ' || coalesce(p_tenant::text, 'the platform')`, "manage-roles")}
|
|
1074
|
-
end if;`;
|
|
1075
|
-
const reachRows = levelReach.map(([grantKey, level]) => `(${quoteLiteral(grantKey)}, ${quoteLiteral(level)})`).join(", ");
|
|
1076
|
-
const levelCheck = leveled && reachRows !== "" ? `
|
|
1077
|
-
or exists (
|
|
1078
|
-
select 1
|
|
1079
|
-
from reach x
|
|
1080
|
-
where x.grant_key = r.grant_key
|
|
1081
|
-
and not exists (
|
|
1082
|
-
select 1 from held h
|
|
1083
|
-
join reach o on o.grant_key = h.grant_key
|
|
1084
|
-
where h.permission = r.permission and o.level = x.level
|
|
1085
|
-
)
|
|
1086
|
-
)` : "";
|
|
1087
|
-
const reachCte = leveled && reachRows !== "" ? `,
|
|
1088
|
-
reach (grant_key, level) as (values ${reachRows})` : "";
|
|
1089
|
-
const match = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.scope_id is not distinct from p_scope_id`;
|
|
1090
|
-
const memberOf = `p_tenant::text in ${members(root)}
|
|
1091
|
-
or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${members(scope)}`)}`;
|
|
1092
|
-
return {
|
|
1093
|
-
beyond: `-- custom-role writes: the permission keys of a definition the caller may not hand out in the tenant, as assignablePermissions computes them
|
|
1094
|
-
create or replace function ${beyond}(${userParam}p_tenant ${tenantType}, p_scope text, p_scope_id text, p_allow text[], p_deny text[], p_include text[])
|
|
1095
|
-
returns setof text
|
|
1096
|
-
language plpgsql
|
|
1097
|
-
stable
|
|
1098
|
-
security definer
|
|
1099
|
-
set search_path = ''
|
|
1100
|
-
as $$
|
|
1101
|
-
begin
|
|
1102
|
-
if ${manage} then
|
|
1103
|
-
return;
|
|
1104
|
-
end if;
|
|
1105
|
-
return query
|
|
1106
|
-
with resolved as (
|
|
1107
|
-
select distinct rp.permission, k.key as grant_key
|
|
1108
|
-
from ${keys}(p_allow, p_deny, p_include, p_scope) k(key)
|
|
1109
|
-
join ${rp} rp on rp.grant_key = k.key and rp.effect = 'allow'
|
|
1110
|
-
),
|
|
1111
|
-
held as (
|
|
1112
|
-
select distinct rp.permission, rp.grant_key
|
|
1113
|
-
from ${rp} rp
|
|
1114
|
-
where rp.effect = 'allow'
|
|
1115
|
-
and rp.permission in (select r.permission from resolved r)
|
|
1116
|
-
and ${heldKey("rp.grant_key")}
|
|
1117
|
-
)${reachCte}
|
|
1118
|
-
select distinct r.permission
|
|
1119
|
-
from resolved r
|
|
1120
|
-
where not exists (select 1 from held h where h.permission = r.permission)${levelCheck};
|
|
1121
|
-
end;
|
|
1122
|
-
$$;
|
|
1123
|
-
revoke execute on function ${beyond}(${userArg}${tenantType}, text, text, text[], text[], text[]) from public, anon, authenticated;`,
|
|
1124
|
-
guard: `-- a custom role the caller may change: membership of the tenant or the instance (a platform role, or a caller holding a manageRoles permission through a global role, needs none), and authority over the stored definition
|
|
1125
|
-
create or replace function ${guard}(${userParam}p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
|
|
1126
|
-
returns void
|
|
1127
|
-
language plpgsql
|
|
1128
|
-
stable
|
|
1129
|
-
security definer
|
|
1130
|
-
set search_path = ''
|
|
1131
|
-
as $$
|
|
1132
|
-
declare
|
|
1133
|
-
v_allow text[];
|
|
1134
|
-
v_deny text[];
|
|
1135
|
-
v_include text[];
|
|
1136
|
-
v_beyond text;
|
|
1137
|
-
begin
|
|
1138
|
-
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1139
|
-
if not (${signedIn(ctx, user)} and (
|
|
1140
|
-
${platform}
|
|
1141
|
-
or (p_scope <> 'global' and (${memberOf}))
|
|
1142
|
-
)) then
|
|
1143
|
-
${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
|
|
1144
|
-
end if;${requires}
|
|
1145
|
-
select array_agg(${allowEntry(ctx)}) filter (where c.effect = 'allow'),
|
|
1146
|
-
array_agg(c.permission) filter (where c.effect = 'deny')
|
|
1147
|
-
into v_allow, v_deny
|
|
1148
|
-
from ${perms} c
|
|
1149
|
-
where ${match} and c.role = p_role;
|
|
1150
|
-
select array_agg(c.include_role) into v_include
|
|
1151
|
-
from ${includes} c
|
|
1152
|
-
where ${match} and c.role = p_role;
|
|
1153
|
-
select string_agg(b, ', ' order by b) into v_beyond
|
|
1154
|
-
from ${beyond}(${userCall}p_tenant, p_scope, p_scope_id, v_allow, v_deny, v_include) b;
|
|
1155
|
-
if v_beyond is not null then
|
|
1156
|
-
${raiseSql(" ", "42501", `'permdock: ' || p_role || ' allows ' || v_beyond || ', which the caller may not hand out'`, "not-assignable-by")}
|
|
1157
|
-
end if;
|
|
1158
|
-
end;
|
|
1159
|
-
$$;
|
|
1160
|
-
revoke execute on function ${guard}(${userArg}${tenantType}, text, text, text) from public, anon, authenticated;`
|
|
1161
|
-
};
|
|
1285
|
+
//#endregion
|
|
1286
|
+
//#region src/cli/sql/global-roles.ts
|
|
1287
|
+
function qualify(name, schema) {
|
|
1288
|
+
return name.includes(".") ? name : `${schema}.${name}`;
|
|
1162
1289
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
* \`assignablePermissions\`: entries inside the ceiling of the role's scope,
|
|
1167
|
-
* includes naming declared roles, and only permissions and levels the caller
|
|
1168
|
-
* may hand out, for the new definition and the stored one alike. A platform
|
|
1169
|
-
* custom role (\`p_scope = 'global'\`, no tenant) is written by a caller who
|
|
1170
|
-
* holds a \`meta.manageRoles\` permission through a global role. The
|
|
1171
|
-
* \`permdock_trusted_*\` variants skip the caller checks and keep the
|
|
1172
|
-
* definition checks; no client role may execute them.
|
|
1173
|
-
*/
|
|
1174
|
-
function customRoleWritesSql(ctx, levelReach) {
|
|
1175
|
-
const custom = ctx.customRoles;
|
|
1176
|
-
const root = ctx.scopes[0]?.name;
|
|
1177
|
-
if (custom === void 0 || ctx.authorize !== "database" || root === void 0) return "";
|
|
1178
|
-
const leveled = custom.levels === true;
|
|
1179
|
-
const tenantType = tenantTypeOf(ctx);
|
|
1180
|
-
const rp = qualified$1(ctx, "role_permissions");
|
|
1181
|
-
const ceiling = qualified$1(ctx, CUSTOM_ROLES.ceiling);
|
|
1182
|
-
const perms = qualified$1(ctx, CUSTOM_ROLES.permissions);
|
|
1183
|
-
const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
|
|
1184
|
-
const beyond = qualified$1(ctx, CUSTOM_ROLES.beyond);
|
|
1185
|
-
const guard = qualified$1(ctx, CUSTOM_ROLES.guard);
|
|
1186
|
-
const shape = qualified$1(ctx, CUSTOM_ROLES.shape);
|
|
1187
|
-
const entries = qualified$1(ctx, CUSTOM_ROLES.entries);
|
|
1188
|
-
const renames = Object.entries(custom.renamed ?? {}).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1189
|
-
const current = (expr) => currentKeyExpr(renames, expr);
|
|
1190
|
-
const known = textArray([...custom.permissions ?? [], ...renames.map(([former]) => former)]);
|
|
1191
|
-
const caller = callerFunctionsSql(ctx, custom, root, levelReach, false);
|
|
1192
|
-
const match = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.scope_id is not distinct from p_scope_id`;
|
|
1193
|
-
const scopes = textArray(ctx.scopes.map((scope) => scope.name));
|
|
1194
|
-
const roleName = (param) => `coalesce(${param}, '') = '' or ${param} = any(${textArray(custom.declared)})`;
|
|
1195
|
-
const levelOf = leveled ? ", nullif(split_part(e, '@', 2), '')" : "";
|
|
1196
|
-
const write = ` delete from ${perms} c where ${match} and c.role = p_role;
|
|
1197
|
-
delete from ${includes} c where ${match} and c.role = p_role;
|
|
1198
|
-
insert into ${perms} (tenant_id, scope, scope_id, role, permission, effect${leveled ? ", level" : ""})
|
|
1199
|
-
select distinct p_tenant, p_scope, p_scope_id, p_role, ${current(leveled ? "split_part(e, '@', 1)" : "e")}, 'allow'${levelOf}
|
|
1200
|
-
from unnest(coalesce(p_allow, '{}'::text[])) e
|
|
1201
|
-
union
|
|
1202
|
-
select distinct p_tenant, p_scope, p_scope_id, p_role, ${current("e")}, 'deny'${leveled ? ", null" : ""}
|
|
1203
|
-
from unnest(coalesce(p_deny, '{}'::text[])) e;
|
|
1204
|
-
insert into ${includes} (tenant_id, scope, scope_id, role, include_role)
|
|
1205
|
-
select distinct p_tenant, p_scope, p_scope_id, p_role, e
|
|
1206
|
-
from unnest(coalesce(p_include, '{}'::text[])) e;`;
|
|
1207
|
-
const move = ` if ${roleName("p_to")} then
|
|
1208
|
-
${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_to, 'null') || ' is not a custom role name'`, "declared-role")}
|
|
1209
|
-
end if;
|
|
1210
|
-
if exists (select 1 from ${perms} c where ${match} and c.role = p_to)
|
|
1211
|
-
or exists (select 1 from ${includes} c where ${match} and c.role = p_to) then
|
|
1212
|
-
${raiseSql(" ", "22023", `'permdock: ' || p_to || ' already has grants'`, "role-exists")}
|
|
1213
|
-
end if;
|
|
1214
|
-
update ${perms} c set role = p_to where ${match} and c.role = p_from;
|
|
1215
|
-
update ${includes} c set role = p_to where ${match} and c.role = p_from;`;
|
|
1216
|
-
const remove = ` delete from ${perms} c where ${match} and c.role = p_role;
|
|
1217
|
-
delete from ${includes} c where ${match} and c.role = p_role;`;
|
|
1218
|
-
const writeArgs = `${tenantType}, text, text, text, text[], text[], text[]`;
|
|
1219
|
-
const renameArgs = `${tenantType}, text, text, text, text`;
|
|
1220
|
-
const removeArgs = `${tenantType}, text, text, text`;
|
|
1221
|
-
const fn = (comment, name, params, args, body, member) => `-- ${comment}
|
|
1222
|
-
create or replace function ${qualified$1(ctx, name)}(${params})
|
|
1223
|
-
returns void
|
|
1224
|
-
language plpgsql
|
|
1225
|
-
volatile
|
|
1226
|
-
security definer
|
|
1227
|
-
set search_path = ''
|
|
1228
|
-
as $$
|
|
1229
|
-
${body}
|
|
1230
|
-
$$;
|
|
1231
|
-
${member ? `revoke execute on function ${qualified$1(ctx, name)}(${args}) from public, anon;
|
|
1232
|
-
grant execute on function ${qualified$1(ctx, name)}(${args}) to authenticated;` : `revoke execute on function ${qualified$1(ctx, name)}(${args}) from public, anon, authenticated;`}`;
|
|
1233
|
-
const writeParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text, p_allow text[], p_deny text[], p_include text[]`;
|
|
1234
|
-
const renameParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_from text, p_to text`;
|
|
1235
|
-
const removeParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
|
|
1236
|
-
return `${caller.beyond}
|
|
1237
|
-
|
|
1238
|
-
-- where a custom role may live: a scope of a tenant, or the platform (scope global, no tenant or instance), under an undeclared name
|
|
1239
|
-
create or replace function ${shape}(p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
|
|
1240
|
-
returns void
|
|
1241
|
-
language plpgsql
|
|
1242
|
-
stable
|
|
1243
|
-
set search_path = ''
|
|
1244
|
-
as $$
|
|
1245
|
-
begin
|
|
1246
|
-
if p_scope = 'global' then
|
|
1247
|
-
if p_tenant is not null or p_scope_id is not null then
|
|
1248
|
-
${raiseSql(" ", "22023", `'permdock: a platform custom role has no tenant or instance'`, "unknown-scope")}
|
|
1249
|
-
end if;
|
|
1250
|
-
elsif p_tenant is null or not (p_scope = any(${scopes})) then
|
|
1251
|
-
${raiseSql(" ", "22023", `'permdock: custom roles are held at a scope of a tenant or at global, not ' || coalesce(p_scope, 'null')`, "unknown-scope")}
|
|
1252
|
-
end if;
|
|
1253
|
-
if ${roleName("p_role")} then
|
|
1254
|
-
${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_role, 'null') || ' is not a custom role name'`, "declared-role")}
|
|
1255
|
-
end if;
|
|
1256
|
-
end;
|
|
1257
|
-
$$;
|
|
1258
|
-
revoke execute on function ${shape}(${tenantType}, text, text, text) from public, anon, authenticated;
|
|
1259
|
-
|
|
1260
|
-
${caller.guard}
|
|
1261
|
-
${hasCustomRoleChecksFor(ctx) ? `
|
|
1262
|
-
-- the same checks for a user the caller names: trusted SQL acting later for a stored user; no client role may execute them
|
|
1263
|
-
${callerFunctionsSql(ctx, custom, root, levelReach, true).beyond}
|
|
1264
|
-
|
|
1265
|
-
${callerFunctionsSql(ctx, custom, root, levelReach, true).guard}
|
|
1266
|
-
` : ""}
|
|
1267
|
-
-- the entries of a definition: declared keys and levels inside the ceiling of the scope, and declared includes
|
|
1268
|
-
create or replace function ${entries}(p_scope text, p_allow text[], p_deny text[], p_include text[])
|
|
1269
|
-
returns void
|
|
1270
|
-
language plpgsql
|
|
1271
|
-
stable
|
|
1272
|
-
security definer
|
|
1273
|
-
set search_path = ''
|
|
1274
|
-
as $$
|
|
1275
|
-
declare
|
|
1276
|
-
v_entry text;
|
|
1277
|
-
v_key text;
|
|
1278
|
-
begin
|
|
1279
|
-
foreach v_entry in array coalesce(p_allow, '{}'::text[]) loop
|
|
1280
|
-
v_key := ${current(leveled ? "split_part(v_entry, '@', 1)" : "v_entry")};
|
|
1281
|
-
if not (v_key = any(${known})) then
|
|
1282
|
-
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared permission'`, "unknown-permission")}
|
|
1283
|
-
end if;
|
|
1284
|
-
if not exists (
|
|
1285
|
-
select 1 from ${ceiling} c
|
|
1286
|
-
where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
|
|
1287
|
-
) then
|
|
1288
|
-
${raiseSql(" ", "22023", `'permdock: ' || v_key || ' is outside the ceiling of ' || p_scope`, "outside-ceiling")}
|
|
1289
|
-
end if;${leveled ? `
|
|
1290
|
-
if strpos(v_entry, '@') > 0 and not exists (
|
|
1291
|
-
select 1 from ${ceiling} c
|
|
1292
|
-
where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
|
|
1293
|
-
and split_part(c.grant_key, '@', 2) = split_part(v_entry, '@', 2)
|
|
1294
|
-
) then
|
|
1295
|
-
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' names a level the resource does not declare'`, "unknown-level")}
|
|
1296
|
-
end if;` : ""}
|
|
1297
|
-
end loop;
|
|
1298
|
-
foreach v_entry in array coalesce(p_deny, '{}'::text[]) loop
|
|
1299
|
-
if not (v_entry = any(${known})) then
|
|
1300
|
-
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared permission'`, "unknown-permission")}
|
|
1301
|
-
end if;
|
|
1302
|
-
end loop;
|
|
1303
|
-
foreach v_entry in array coalesce(p_include, '{}'::text[]) loop
|
|
1304
|
-
if not (v_entry = any(${textArray(custom.declared)})) then
|
|
1305
|
-
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared role'`, "unknown-role")}
|
|
1306
|
-
end if;
|
|
1307
|
-
end loop;
|
|
1308
|
-
select string_agg(distinct ${current("rp.permission")}, ', ') into v_key
|
|
1309
|
-
from ${rp} rp
|
|
1310
|
-
where rp.role = any(coalesce(p_include, '{}'::text[]))
|
|
1311
|
-
and rp.effect = 'allow'
|
|
1312
|
-
and not exists (
|
|
1313
|
-
select 1 from ${ceiling} c
|
|
1314
|
-
where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = ${current("rp.permission")}
|
|
1315
|
-
);
|
|
1316
|
-
if v_key is not null then
|
|
1317
|
-
${raiseSql(" ", "22023", `'permdock: an included role allows ' || v_key || ', outside the ceiling of ' || p_scope`, "outside-ceiling")}
|
|
1318
|
-
end if;
|
|
1319
|
-
end;
|
|
1320
|
-
$$;
|
|
1321
|
-
revoke execute on function ${entries}(text, text[], text[], text[]) from public, anon, authenticated;
|
|
1322
|
-
|
|
1323
|
-
${fn("save a custom role: replace its grants and includes after checking each entry against the ceiling and the caller's own permissions", CUSTOM_ROLES.replace, writeParams, writeArgs, `declare
|
|
1324
|
-
v_beyond text;
|
|
1325
|
-
begin
|
|
1326
|
-
perform ${guard}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1327
|
-
perform ${entries}(p_scope, p_allow, p_deny, p_include);
|
|
1328
|
-
select string_agg(b, ', ' order by b) into v_beyond
|
|
1329
|
-
from ${beyond}(p_tenant, p_scope, p_scope_id, p_allow, p_deny, p_include) b;
|
|
1330
|
-
if v_beyond is not null then
|
|
1331
|
-
${raiseSql(" ", "42501", `'permdock: ' || p_role || ' would allow ' || v_beyond || ', which the caller may not hand out'`, "not-assignable-by")}
|
|
1332
|
-
end if;
|
|
1333
|
-
${write}
|
|
1334
|
-
end;`, true)}
|
|
1335
|
-
|
|
1336
|
-
${fn("rename a custom role: move its grants and includes to the new name", CUSTOM_ROLES.rename, renameParams, renameArgs, `begin
|
|
1337
|
-
perform ${guard}(p_tenant, p_scope, p_scope_id, p_from);
|
|
1338
|
-
${move}
|
|
1339
|
-
end;`, true)}
|
|
1340
|
-
|
|
1341
|
-
${fn("delete a custom role: remove its grants and includes", CUSTOM_ROLES.remove, removeParams, removeArgs, `begin
|
|
1342
|
-
perform ${guard}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1343
|
-
${remove}
|
|
1344
|
-
end;`, true)}
|
|
1345
|
-
|
|
1346
|
-
${fn("save a custom role for a trusted caller (a migration, a job, a backend role granted execute): the definition checks without the caller checks", CUSTOM_ROLES.trustedReplace, writeParams, writeArgs, `begin
|
|
1347
|
-
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1348
|
-
perform ${entries}(p_scope, p_allow, p_deny, p_include);
|
|
1349
|
-
${write}
|
|
1350
|
-
end;`, false)}
|
|
1351
|
-
|
|
1352
|
-
${fn("rename a custom role for a trusted caller", CUSTOM_ROLES.trustedRename, renameParams, renameArgs, `begin
|
|
1353
|
-
perform ${shape}(p_tenant, p_scope, p_scope_id, p_from);
|
|
1354
|
-
${move}
|
|
1355
|
-
end;`, false)}
|
|
1356
|
-
|
|
1357
|
-
${fn("delete a custom role for a trusted caller", CUSTOM_ROLES.trustedRemove, removeParams, removeArgs, `begin
|
|
1358
|
-
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1359
|
-
${remove}
|
|
1360
|
-
end;`, false)}`;
|
|
1361
|
-
}
|
|
1362
|
-
/** `row.column`, for a trigger's `old` or `new`. */
|
|
1363
|
-
function rowColumn(row, name) {
|
|
1364
|
-
return `${row}.${quoteIdent(name)}`;
|
|
1365
|
-
}
|
|
1366
|
-
/** The custom-role rows of the role a trigger's `v_<prefix>_*` variables name. */
|
|
1367
|
-
function cascadeMatch(prefix) {
|
|
1368
|
-
return `c.tenant_id is not distinct from v_${prefix}_tenant and c.scope = v_${prefix}_scope and c.scope_id is not distinct from v_${prefix}_id and c.role = v_${prefix}_key`;
|
|
1369
|
-
}
|
|
1370
|
-
/**
|
|
1371
|
-
* The trigger on the application's roles table (`rls.customRoleWrites.roles`):
|
|
1372
|
-
* a rename, a move to another tenant, scope or instance, or a delete of a
|
|
1373
|
-
* custom role's row carries its grants and includes along. A signed-in
|
|
1374
|
-
* caller at trigger depth 1 passes the write functions' checks: authority
|
|
1375
|
-
* over the stored definition where it was, and, after a move, membership
|
|
1376
|
-
* and authority where it lands and entries inside that scope's ceiling.
|
|
1377
|
-
* Other paths (a migration, a job, a nested trigger) move the rows as the
|
|
1378
|
-
* function owner.
|
|
1379
|
-
*/
|
|
1380
|
-
function customRoleCascadeSql(ctx) {
|
|
1381
|
-
const custom = ctx.customRoles;
|
|
1382
|
-
const table = custom?.table;
|
|
1383
|
-
const root = ctx.scopes[0]?.name;
|
|
1384
|
-
if (custom === void 0 || table === void 0 || root === void 0) return "";
|
|
1385
|
-
const tenantType = tenantTypeOf(ctx);
|
|
1386
|
-
const perms = qualified$1(ctx, CUSTOM_ROLES.permissions);
|
|
1387
|
-
const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
|
|
1388
|
-
const fn = qualified$1(ctx, CUSTOM_ROLES.cascade);
|
|
1389
|
-
const target = quoteTable(table.table.includes(".") ? table.table : `public.${table.table}`);
|
|
1390
|
-
const tenant = (row) => table.tenant === void 0 ? `null::${tenantType}` : `${rowColumn(row, table.tenant)}::${tenantType}`;
|
|
1391
|
-
const scope = (row) => table.scope === void 0 ? `case when ${tenant(row)} is null then 'global' else ${quoteLiteral(root)} end` : `case when ${tenant(row)} is null then 'global' else ${rowColumn(row, table.scope)}::text end`;
|
|
1392
|
-
const id = (row) => table.id === void 0 ? "null::text" : `${rowColumn(row, table.id)}::text`;
|
|
1393
|
-
const skipped = (row) => table.skip === void 0 ? "false" : `coalesce(${rowColumn(row, table.skip)}, false)`;
|
|
1394
|
-
const guard = qualified$1(ctx, CUSTOM_ROLES.guard);
|
|
1395
|
-
const entries = qualified$1(ctx, CUSTOM_ROLES.entries);
|
|
1396
|
-
const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
|
|
1397
|
-
return `-- the application's roles table: renames, moves and deletes of a custom role carry its grants and includes
|
|
1398
|
-
create or replace function ${fn}()
|
|
1399
|
-
returns trigger
|
|
1400
|
-
language plpgsql
|
|
1401
|
-
volatile
|
|
1402
|
-
security definer
|
|
1403
|
-
set search_path = ''
|
|
1404
|
-
as $$
|
|
1405
|
-
declare
|
|
1406
|
-
v_old_tenant ${tenantType} := ${tenant("old")};
|
|
1407
|
-
v_old_scope text := ${scope("old")};
|
|
1408
|
-
v_old_id text := ${id("old")};
|
|
1409
|
-
v_old_key text := ${rowColumn("old", table.key)}::text;
|
|
1410
|
-
v_new_tenant ${tenantType};
|
|
1411
|
-
v_new_scope text;
|
|
1412
|
-
v_new_id text;
|
|
1413
|
-
v_new_key text;
|
|
1414
|
-
v_checked boolean := ${signedIn(ctx)} and pg_trigger_depth() = 1;
|
|
1415
|
-
v_allow text[];
|
|
1416
|
-
v_deny text[];
|
|
1417
|
-
v_include text[];
|
|
1418
|
-
begin
|
|
1419
|
-
if ${skipped("old")} then
|
|
1420
|
-
return null;
|
|
1421
|
-
end if;
|
|
1422
|
-
if tg_op = 'UPDATE' and not ${skipped("new")} then
|
|
1423
|
-
v_new_tenant := ${tenant("new")};
|
|
1424
|
-
v_new_scope := ${scope("new")};
|
|
1425
|
-
v_new_id := ${id("new")};
|
|
1426
|
-
v_new_key := ${rowColumn("new", table.key)}::text;
|
|
1427
|
-
if v_new_key = v_old_key
|
|
1428
|
-
and v_new_tenant is not distinct from v_old_tenant
|
|
1429
|
-
and v_new_scope = v_old_scope
|
|
1430
|
-
and v_new_id is not distinct from v_old_id then
|
|
1431
|
-
return null;
|
|
1432
|
-
end if;
|
|
1433
|
-
end if;
|
|
1434
|
-
if not exists (select 1 from ${perms} c where ${cascadeMatch("old")})
|
|
1435
|
-
and not exists (select 1 from ${includes} c where ${cascadeMatch("old")}) then
|
|
1436
|
-
return null;
|
|
1437
|
-
end if;
|
|
1438
|
-
if v_checked then
|
|
1439
|
-
perform ${guard}(v_old_tenant, v_old_scope, v_old_id, v_old_key);
|
|
1440
|
-
end if;
|
|
1441
|
-
if v_new_key is null then
|
|
1442
|
-
delete from ${perms} c where ${cascadeMatch("old")};
|
|
1443
|
-
delete from ${includes} c where ${cascadeMatch("old")};
|
|
1444
|
-
return null;
|
|
1445
|
-
end if;
|
|
1446
|
-
if exists (select 1 from ${perms} c where ${cascadeMatch("new")})
|
|
1447
|
-
or exists (select 1 from ${includes} c where ${cascadeMatch("new")}) then
|
|
1448
|
-
${raiseSql(" ", "22023", `'permdock: ' || v_new_key || ' already has grants'`, "role-exists")}
|
|
1449
|
-
end if;
|
|
1450
|
-
update ${perms} c set tenant_id = v_new_tenant, scope = v_new_scope, scope_id = v_new_id, role = v_new_key where ${cascadeMatch("old")};
|
|
1451
|
-
update ${includes} c set tenant_id = v_new_tenant, scope = v_new_scope, scope_id = v_new_id, role = v_new_key where ${cascadeMatch("old")};
|
|
1452
|
-
if v_checked then
|
|
1453
|
-
perform ${guard}(v_new_tenant, v_new_scope, v_new_id, v_new_key);
|
|
1454
|
-
select array_agg(${allowEntryOf}) filter (where c.effect = 'allow'),
|
|
1455
|
-
array_agg(c.permission) filter (where c.effect = 'deny')
|
|
1456
|
-
into v_allow, v_deny
|
|
1457
|
-
from ${perms} c
|
|
1458
|
-
where ${cascadeMatch("new")};
|
|
1459
|
-
select array_agg(c.include_role) into v_include
|
|
1460
|
-
from ${includes} c
|
|
1461
|
-
where ${cascadeMatch("new")};
|
|
1462
|
-
perform ${entries}(v_new_scope, v_allow, v_deny, v_include);
|
|
1463
|
-
end if;
|
|
1464
|
-
return null;
|
|
1465
|
-
end;
|
|
1466
|
-
$$;
|
|
1467
|
-
revoke execute on function ${fn}() from public, anon, authenticated;
|
|
1468
|
-
drop trigger if exists "permdock_custom_role_cascade" on ${target};
|
|
1469
|
-
create trigger "permdock_custom_role_cascade"
|
|
1470
|
-
after update or delete on ${target}
|
|
1471
|
-
for each row execute function ${fn}();
|
|
1472
|
-
`;
|
|
1473
|
-
}
|
|
1474
|
-
//#endregion
|
|
1475
|
-
//#region src/cli/rls-global-roles.ts
|
|
1476
|
-
function qualifiedName(name) {
|
|
1477
|
-
return name.includes(".") ? name : `public.${name}`;
|
|
1478
|
-
}
|
|
1479
|
-
/**
|
|
1480
|
-
* `permdock_replace_global_roles(p_user, p_roles)`: sets the global roles of
|
|
1481
|
-
* one user in `rls.roles` in one statement pair. It runs as the invoker, so
|
|
1482
|
-
* the table's policies and the `rls.assignments` trigger judge every row it
|
|
1483
|
-
* deletes or inserts.
|
|
1484
|
-
*/
|
|
1485
|
-
function globalRolesSql(ctx) {
|
|
1486
|
-
if (ctx.roles === void 0) return "";
|
|
1487
|
-
const source = globalRoleSource(ctx.roles, "public", "t");
|
|
1488
|
-
const table = quoteTable(qualifiedName(ctx.roles.table));
|
|
1489
|
-
const user = quoteIdent(source.user);
|
|
1490
|
-
const fn = qualified$1(ctx, GLOBAL_ROLES.replace);
|
|
1491
|
-
const type = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
1492
|
-
const wanted = `select distinct r from unnest(coalesce(p_roles, '{}'::text[])) r where r is not null`;
|
|
1493
|
-
const through = source.through;
|
|
1494
|
-
let body;
|
|
1495
|
-
if (through === void 0) {
|
|
1496
|
-
const role = typeof ctx.roles.role === "string" ? ctx.roles.role : "role";
|
|
1497
|
-
const column = quoteIdent(role);
|
|
1498
|
-
body = ` delete from ${table} t
|
|
1499
|
-
where t.${user} = p_user
|
|
1500
|
-
and t.${column}::text <> all(coalesce(p_roles, '{}'::text[]));
|
|
1501
|
-
insert into ${table} (${user}, ${column})
|
|
1502
|
-
select p_user, w.r
|
|
1503
|
-
from (${wanted}) w(r)
|
|
1504
|
-
where not exists (
|
|
1505
|
-
select 1 from ${table} t where t.${user} = p_user and t.${column}::text = w.r
|
|
1506
|
-
);`;
|
|
1507
|
-
} else {
|
|
1508
|
-
const keys = quoteTable(through.table);
|
|
1509
|
-
const id = quoteIdent(through.id);
|
|
1510
|
-
const key = quoteIdent(through.key);
|
|
1511
|
-
const ref = quoteIdent(through.ref);
|
|
1512
|
-
body = ` select string_agg(w.r, ', ' order by w.r) into v_unknown
|
|
1513
|
-
from (${wanted}) w(r)
|
|
1514
|
-
where not exists (select 1 from ${keys} k where k.${key}::text = w.r);
|
|
1515
|
-
if v_unknown is not null then
|
|
1516
|
-
raise exception using
|
|
1517
|
-
errcode = '22023',
|
|
1518
|
-
message = 'permdock: ' || v_unknown || ' is not a role',
|
|
1519
|
-
hint = 'unknown-role';
|
|
1520
|
-
end if;
|
|
1521
|
-
delete from ${table} t
|
|
1522
|
-
where t.${user} = p_user
|
|
1523
|
-
and not exists (
|
|
1524
|
-
select 1 from ${keys} k
|
|
1525
|
-
where k.${id} = t.${ref}
|
|
1526
|
-
and k.${key}::text = any(coalesce(p_roles, '{}'::text[]))
|
|
1527
|
-
);
|
|
1528
|
-
insert into ${table} (${user}, ${ref})
|
|
1529
|
-
select p_user, k.${id}
|
|
1530
|
-
from ${keys} k
|
|
1531
|
-
where k.${key}::text in (${wanted})
|
|
1532
|
-
and not exists (
|
|
1533
|
-
select 1 from ${table} t where t.${user} = p_user and t.${ref} = k.${id}
|
|
1534
|
-
);`;
|
|
1535
|
-
}
|
|
1536
|
-
const declare = through === void 0 ? "" : "declare\n v_unknown text;\n";
|
|
1537
|
-
const args = `${type}, text[]`;
|
|
1538
|
-
return `-- set the global roles of a user: delete the others and insert the missing ones, as the caller, so the table's policies and the assignment trigger still apply
|
|
1539
|
-
create or replace function ${fn}(p_user ${type}, p_roles text[])
|
|
1540
|
-
returns void
|
|
1541
|
-
language plpgsql
|
|
1542
|
-
volatile
|
|
1543
|
-
set search_path = ''
|
|
1544
|
-
as $$
|
|
1545
|
-
${declare}begin
|
|
1546
|
-
${body}
|
|
1547
|
-
end;
|
|
1548
|
-
$$;
|
|
1549
|
-
revoke execute on function ${fn}(${args}) from public, anon;
|
|
1550
|
-
grant execute on function ${fn}(${args}) to authenticated;`;
|
|
1551
|
-
}
|
|
1552
|
-
//#endregion
|
|
1553
|
-
//#region src/cli/rls-helpers.ts
|
|
1554
|
-
/** The helper `--capabilities` adds: ids of one resource a link capability claim reaches. Part of the SQL contract. */
|
|
1555
|
-
const CAPABILITIES = { ids: "permdock_capability_ids" };
|
|
1556
|
-
/**
|
|
1557
|
-
* The policy-side call for a grant key. Every form is uncorrelated, so
|
|
1558
|
-
* Postgres runs the helper once per statement (an InitPlan, or a hashed
|
|
1559
|
-
* SubPlan for `in`), never once per row.
|
|
1560
|
-
*/
|
|
1561
|
-
function accessSql(ctx, scope, grantKey, column) {
|
|
1562
|
-
const key = quoteLiteral(grantKey);
|
|
1563
|
-
if (scope === "global") return `(select ${qualified$1(ctx, HELPERS.has)}(${key}))`;
|
|
1564
|
-
if (column === void 0) throw new Error(`PermDock CLI: ${scope}-scoped grant needs definePolicy({ scopes.${scope} })`);
|
|
1565
|
-
return `${quoteIdent(column)} in (select ${qualified$1(ctx, permittedIdsHelper(scope))}(${key}))`;
|
|
1566
|
-
}
|
|
1567
|
-
/**
|
|
1568
|
-
* The `anon` branch access for a resource-scoped grant reached through a link
|
|
1569
|
-
* capability: the row's `field` holds an id the capability claim names for
|
|
1570
|
-
* `resource`, `role` and `permission`. Uncorrelated, so it runs once per
|
|
1571
|
-
* statement; the column is compared as text because the claim holds text.
|
|
1572
|
-
*/
|
|
1573
|
-
function capabilityAccessSql(ctx, field, resource, role, permission) {
|
|
1574
|
-
const args = [
|
|
1575
|
-
resource,
|
|
1576
|
-
role,
|
|
1577
|
-
permission
|
|
1578
|
-
].map(quoteLiteral).join(", ");
|
|
1579
|
-
return `${quoteIdent(field)}::text in (select ${qualified$1(ctx, CAPABILITIES.ids)}(${args}))`;
|
|
1580
|
-
}
|
|
1581
|
-
function capabilitiesSql(ctx) {
|
|
1582
|
-
if (ctx.capabilities !== true) return "";
|
|
1583
|
-
const fn = qualified$1(ctx, CAPABILITIES.ids);
|
|
1584
|
-
return `-- link capabilities: the capability claim exchangeCapability mints (role anon, short-lived)
|
|
1585
|
-
create or replace function ${fn}(p_resource text, p_role text, p_permission text)
|
|
1586
|
-
returns setof text
|
|
1587
|
-
language sql
|
|
1588
|
-
stable
|
|
1589
|
-
set search_path = ''
|
|
1590
|
-
as $$
|
|
1591
|
-
select c -> 'on' ->> 'id'
|
|
1592
|
-
from (select ${subjectClaimJsonSql(ctx, "capability")} as c) capability
|
|
1593
|
-
where jsonb_typeof(c) = 'object'
|
|
1594
|
-
and c ->> 'v' = '1'
|
|
1595
|
-
and c ->> 'holder' = 'link'
|
|
1596
|
-
and c -> 'on' ->> 'resource' = p_resource
|
|
1597
|
-
and jsonb_typeof(c -> 'roles') = 'array'
|
|
1598
|
-
and c -> 'roles' @> jsonb_build_array(p_role)
|
|
1599
|
-
and (c -> 'permissions' is null or c -> 'permissions' @> jsonb_build_array(p_permission))
|
|
1600
|
-
and jsonb_typeof(c -> 'expiresAt') = 'number'
|
|
1601
|
-
and (c ->> 'expiresAt')::numeric > extract(epoch from now())
|
|
1602
|
-
$$;
|
|
1603
|
-
revoke execute on function ${fn}(text, text, text) from public;
|
|
1604
|
-
grant execute on function ${fn}(text, text, text) to anon, authenticated;`;
|
|
1605
|
-
}
|
|
1606
|
-
function userIdSql(ctx, anonExecute) {
|
|
1607
|
-
const fn = userIdHelperSql(ctx);
|
|
1608
|
-
return `-- the caller's user id: the sub of request.jwt.claims, or null when it is empty or absent
|
|
1609
|
-
create or replace function ${fn}()
|
|
1610
|
-
returns uuid
|
|
1611
|
-
language sql
|
|
1612
|
-
stable
|
|
1613
|
-
set search_path = ''
|
|
1614
|
-
as $$
|
|
1615
|
-
select nullif(
|
|
1616
|
-
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub',
|
|
1617
|
-
''
|
|
1618
|
-
)::uuid
|
|
1619
|
-
$$;
|
|
1620
|
-
${anonExecute ? `revoke execute on function ${fn}() from public;
|
|
1621
|
-
grant execute on function ${fn}() to anon, authenticated;` : `revoke execute on function ${fn}() from public, anon;
|
|
1622
|
-
grant execute on function ${fn}() to authenticated;`}`;
|
|
1290
|
+
function throughTenant(through, roles) {
|
|
1291
|
+
if (through === void 0 || roles?.tenant === void 0) return;
|
|
1292
|
+
return qualify(roles.table, "public") === through.table ? roles.tenant : void 0;
|
|
1623
1293
|
}
|
|
1624
|
-
|
|
1625
|
-
|
|
1294
|
+
/** Resolves `source` against `schema`, aliasing the table `alias` and the roles table `<alias>k`. */
|
|
1295
|
+
function globalRoleSource(source, schema, alias, roles) {
|
|
1296
|
+
const table = qualify(source.table, schema);
|
|
1297
|
+
const user = source.user ?? "user_id";
|
|
1298
|
+
const role = roleColumn(source.role ?? "role", table, alias, {
|
|
1299
|
+
label: "roles.role",
|
|
1300
|
+
prefix: "PermDock CLI",
|
|
1301
|
+
indent: " "
|
|
1302
|
+
});
|
|
1303
|
+
const tenant = role.lateral ? void 0 : throughTenant(role.through, roles);
|
|
1304
|
+
const global = tenant === void 0 ? "" : ` and ${alias}k.${quoteIdent(tenant)} is null`;
|
|
1305
|
+
return {
|
|
1306
|
+
table,
|
|
1307
|
+
user,
|
|
1308
|
+
from: `${quoteTable(table)} ${alias}${role.join}${global}`,
|
|
1309
|
+
userSql: `${alias}.${quoteIdent(user)}`,
|
|
1310
|
+
roleSql: role.sql,
|
|
1311
|
+
role: roleManifest(role),
|
|
1312
|
+
...role.through === void 0 ? {} : { through: role.through },
|
|
1313
|
+
...tenant === void 0 ? {} : { tenant }
|
|
1314
|
+
};
|
|
1626
1315
|
}
|
|
1316
|
+
//#endregion
|
|
1317
|
+
//#region src/cli/rls/helper-rows.ts
|
|
1627
1318
|
function activeTenant(ctx) {
|
|
1628
1319
|
return `nullif(${subjectClaimSql(ctx, ctx.tenantClaim)}, '')`;
|
|
1629
1320
|
}
|
|
@@ -1691,21 +1382,23 @@ function andLine(indent, condition) {
|
|
|
1691
1382
|
/** `rls.roles`, or the generated `user_roles (user_id, role)`, aliased `ur`. */
|
|
1692
1383
|
function globalRoleRows(ctx) {
|
|
1693
1384
|
return ctx.roles === void 0 ? {
|
|
1694
|
-
from: `${qualified
|
|
1385
|
+
from: `${qualified(ctx, "user_roles")} ur`,
|
|
1695
1386
|
userSql: "ur.user_id",
|
|
1696
1387
|
roleSql: "ur.role::text"
|
|
1697
|
-
} : globalRoleSource(ctx.roles, "public", "ur");
|
|
1388
|
+
} : globalRoleSource(ctx.roles, "public", "ur", ctx.customRoles?.table);
|
|
1698
1389
|
}
|
|
1699
1390
|
function customKeysJoin(ctx, scope, allows, denies, includes) {
|
|
1700
|
-
return ` cross join lateral ${qualified
|
|
1391
|
+
return ` cross join lateral ${qualified(ctx, CUSTOM_ROLES.keys)}(
|
|
1701
1392
|
${allows},
|
|
1702
1393
|
${denies},
|
|
1703
1394
|
${includes},
|
|
1704
1395
|
${quoteLiteral(scope)}
|
|
1705
1396
|
) ck(grant_key)`;
|
|
1706
1397
|
}
|
|
1398
|
+
//#endregion
|
|
1399
|
+
//#region src/cli/rls/helper-bodies.ts
|
|
1707
1400
|
function hasSetBody(ctx) {
|
|
1708
|
-
const rp = qualified
|
|
1401
|
+
const rp = qualified(ctx, "role_permissions");
|
|
1709
1402
|
const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
|
|
1710
1403
|
const custom = ctx.customRoles;
|
|
1711
1404
|
if (ctx.authorize === "database") {
|
|
@@ -1717,14 +1410,14 @@ function hasSetBody(ctx) {
|
|
|
1717
1410
|
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}`;
|
|
1718
1411
|
if (custom === void 0) return declared;
|
|
1719
1412
|
const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
|
|
1720
|
-
const rows = (source, value, extra) => `array(select c.${value} from ${qualified
|
|
1413
|
+
const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
|
|
1721
1414
|
return `${declared}
|
|
1722
1415
|
union
|
|
1723
1416
|
select ck.grant_key
|
|
1724
1417
|
from ${ur.from}
|
|
1725
1418
|
${customKeysJoin(ctx, "global", rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"), rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), rows(CUSTOM_ROLES.includes, "include_role", ""))}
|
|
1726
1419
|
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1727
|
-
and not (${ur.roleSql} = any(${
|
|
1420
|
+
and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}`;
|
|
1728
1421
|
}
|
|
1729
1422
|
const declared = ` select rp.grant_key
|
|
1730
1423
|
from ${roleRows(ctx)}
|
|
@@ -1741,11 +1434,11 @@ ${customKeysJoin(ctx, "global", rows(CUSTOM_ROLES.permissions, allowEntry(ctx),
|
|
|
1741
1434
|
${customKeysJoin(ctx, "global", claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
|
|
1742
1435
|
where ${signedIn(ctx)}
|
|
1743
1436
|
and jsonb_typeof(cg.g) = 'array'
|
|
1744
|
-
and not (r.role = any(${
|
|
1437
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))${active}`;
|
|
1745
1438
|
}
|
|
1746
1439
|
function hasBody(ctx) {
|
|
1747
1440
|
if (ctx.grantSet === true) return hasSetBody(ctx);
|
|
1748
|
-
const rp = qualified
|
|
1441
|
+
const rp = qualified(ctx, "role_permissions");
|
|
1749
1442
|
const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
|
|
1750
1443
|
const custom = ctx.customRoles;
|
|
1751
1444
|
if (ctx.authorize === "database") {
|
|
@@ -1760,13 +1453,13 @@ function hasBody(ctx) {
|
|
|
1760
1453
|
)`;
|
|
1761
1454
|
if (custom === void 0) return declared;
|
|
1762
1455
|
const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
|
|
1763
|
-
const rows = (source, value, extra) => `array(select c.${value} from ${qualified
|
|
1456
|
+
const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
|
|
1764
1457
|
return `${declared}
|
|
1765
1458
|
or exists (
|
|
1766
1459
|
select 1
|
|
1767
1460
|
from ${ur.from}
|
|
1768
1461
|
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1769
|
-
and not (${ur.roleSql} = any(${
|
|
1462
|
+
and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}
|
|
1770
1463
|
${customKeysSql(ctx, "global", rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"), rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), rows(CUSTOM_ROLES.includes, "include_role", "")).trimStart()}
|
|
1771
1464
|
)`;
|
|
1772
1465
|
}
|
|
@@ -1785,7 +1478,7 @@ function hasBody(ctx) {
|
|
|
1785
1478
|
from ${roleRows(ctx)}
|
|
1786
1479
|
cross join lateral (select ${claim} -> r.role as g) cg
|
|
1787
1480
|
where jsonb_typeof(cg.g) = 'array'
|
|
1788
|
-
and not (r.role = any(${
|
|
1481
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))${active}
|
|
1789
1482
|
${customKeysSql(ctx, "global", claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)")).trimStart()}
|
|
1790
1483
|
))`;
|
|
1791
1484
|
}
|
|
@@ -1806,7 +1499,7 @@ function claimEntries(where, value) {
|
|
|
1806
1499
|
}
|
|
1807
1500
|
/** `p_grant in (select permdock_custom_keys(allows, denies, includes, scope))`. */
|
|
1808
1501
|
function customKeysSql(ctx, scope, allows, denies, includes) {
|
|
1809
|
-
return ` and p_grant in (select ${qualified
|
|
1502
|
+
return ` and p_grant in (select ${qualified(ctx, CUSTOM_ROLES.keys)}(
|
|
1810
1503
|
${allows},
|
|
1811
1504
|
${denies},
|
|
1812
1505
|
${includes},
|
|
@@ -1840,8 +1533,8 @@ function tableBody(ctx, scope, type) {
|
|
|
1840
1533
|
const kind = kindFilterSql(ctx, role.sql, memberVia(table));
|
|
1841
1534
|
const lines = [
|
|
1842
1535
|
` select${role.lateral ? " distinct" : ""} ${memberColumn(column)}::${type}${set ? ", rp.grant_key" : ""}`,
|
|
1843
|
-
` from ${
|
|
1844
|
-
` join ${qualified
|
|
1536
|
+
` from ${quoteQualified(table.table)} m${role.join}`,
|
|
1537
|
+
` join ${qualified(ctx, "role_permissions")} rp on rp.role = ${role.sql}`,
|
|
1845
1538
|
owner ?? "",
|
|
1846
1539
|
...set ? [] : [" and rp.grant_key = p_grant"],
|
|
1847
1540
|
` and rp.scope = ${quoteLiteral(scope)}`,
|
|
@@ -1856,7 +1549,7 @@ function tableBody(ctx, scope, type) {
|
|
|
1856
1549
|
`(c.scope_id is null or c.scope_id = ${memberColumn(column)}::text)`,
|
|
1857
1550
|
`c.role = ${role.sql}`
|
|
1858
1551
|
].join(" and ");
|
|
1859
|
-
const rows = (source, value, extra) => `array(select c.${value} from ${qualified
|
|
1552
|
+
const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
|
|
1860
1553
|
const entries = [
|
|
1861
1554
|
rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"),
|
|
1862
1555
|
rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"),
|
|
@@ -1866,18 +1559,18 @@ function tableBody(ctx, scope, type) {
|
|
|
1866
1559
|
...lines,
|
|
1867
1560
|
" union",
|
|
1868
1561
|
` select ${memberColumn(column)}::${type}, ck.grant_key`,
|
|
1869
|
-
` from ${
|
|
1562
|
+
` from ${quoteQualified(table.table)} m${role.join}`,
|
|
1870
1563
|
customKeysJoin(ctx, scope, ...entries),
|
|
1871
1564
|
...filtersFor("ck.grant_key"),
|
|
1872
|
-
` and not (${role.sql} = any(${
|
|
1565
|
+
` and not (${role.sql} = any(${sqlTextArray(custom.declared)}))`
|
|
1873
1566
|
].join("\n");
|
|
1874
1567
|
return [
|
|
1875
1568
|
...lines,
|
|
1876
1569
|
" union",
|
|
1877
1570
|
` select ${memberColumn(column)}::${type}`,
|
|
1878
|
-
` from ${
|
|
1571
|
+
` from ${quoteQualified(table.table)} m${role.join}`,
|
|
1879
1572
|
...filters,
|
|
1880
|
-
` and not (${role.sql} = any(${
|
|
1573
|
+
` and not (${role.sql} = any(${sqlTextArray(custom.declared)}))`,
|
|
1881
1574
|
customKeysSql(ctx, scope, ...entries)
|
|
1882
1575
|
].join("\n");
|
|
1883
1576
|
}
|
|
@@ -1903,7 +1596,7 @@ function claimBody(ctx, scope, type) {
|
|
|
1903
1596
|
const filters = filtersFor(set ? "rp.grant_key" : "p_grant");
|
|
1904
1597
|
const declared = ` select (m ->> 'id')::${type}${set ? ", rp.grant_key" : ""}
|
|
1905
1598
|
from ${membershipRows(ctx)}
|
|
1906
|
-
join ${qualified
|
|
1599
|
+
join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
|
|
1907
1600
|
where ${signedIn(ctx)}${set ? "" : "\n and rp.grant_key = p_grant"}
|
|
1908
1601
|
and rp.scope = ${quoteLiteral(scope)}${andLine(" ", kindFilterSql(ctx, "r.role", "m ->> 'via'"))}
|
|
1909
1602
|
${filters}`;
|
|
@@ -1917,7 +1610,7 @@ ${filters}`;
|
|
|
1917
1610
|
${customKeysJoin(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
|
|
1918
1611
|
where ${signedIn(ctx)}
|
|
1919
1612
|
and jsonb_typeof(cg.g) = 'array'
|
|
1920
|
-
and not (r.role = any(${
|
|
1613
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))
|
|
1921
1614
|
${filtersFor("ck.grant_key")}`;
|
|
1922
1615
|
return `${declared}
|
|
1923
1616
|
union
|
|
@@ -1926,7 +1619,7 @@ ${filtersFor("ck.grant_key")}`;
|
|
|
1926
1619
|
cross join lateral (select m -> 'grants' -> r.role as g) cg
|
|
1927
1620
|
where ${signedIn(ctx)}
|
|
1928
1621
|
and jsonb_typeof(cg.g) = 'array'
|
|
1929
|
-
and not (r.role = any(${
|
|
1622
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))
|
|
1930
1623
|
${filters}
|
|
1931
1624
|
${customKeysSql(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}`;
|
|
1932
1625
|
}
|
|
@@ -1938,109 +1631,668 @@ ${customKeysSql(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), c
|
|
|
1938
1631
|
function sourcesKeep(sources) {
|
|
1939
1632
|
return sources.some((source) => source.sql.keeps);
|
|
1940
1633
|
}
|
|
1941
|
-
function sourceRows(sources) {
|
|
1942
|
-
const keep = sourcesKeep(sources);
|
|
1943
|
-
return sources.map((source, index) => source.sql.select(sourceUser(index), keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
1634
|
+
function sourceRows(sources) {
|
|
1635
|
+
const keep = sourcesKeep(sources);
|
|
1636
|
+
return sources.map((source, index) => source.sql.select(sourceUser(index), keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
1637
|
+
}
|
|
1638
|
+
function sourceUser(index) {
|
|
1639
|
+
return `v_user_${String(index)}`;
|
|
1640
|
+
}
|
|
1641
|
+
function sqlBody(sql) {
|
|
1642
|
+
return {
|
|
1643
|
+
sql,
|
|
1644
|
+
vars: []
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
function sourcesBodyOf(sql, sources, user) {
|
|
1648
|
+
return {
|
|
1649
|
+
sql,
|
|
1650
|
+
vars: sources.map((source, index) => `${sourceUser(index)} ${source.sql.userType} := ${user};`)
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
/** `language sql`, or `plpgsql` returning the query when the body declares variables. */
|
|
1654
|
+
function functionBody(body) {
|
|
1655
|
+
if (body.vars.length === 0) return `language sql
|
|
1656
|
+
stable
|
|
1657
|
+
security definer
|
|
1658
|
+
set search_path = ''
|
|
1659
|
+
as $$
|
|
1660
|
+
${body.sql}
|
|
1661
|
+
$$;`;
|
|
1662
|
+
return `language plpgsql
|
|
1663
|
+
stable
|
|
1664
|
+
security definer
|
|
1665
|
+
set search_path = ''
|
|
1666
|
+
as $$
|
|
1667
|
+
declare
|
|
1668
|
+
${body.vars.map((line) => ` ${line}`).join("\n")}
|
|
1669
|
+
begin
|
|
1670
|
+
return query
|
|
1671
|
+
${body.sql};
|
|
1672
|
+
end;
|
|
1673
|
+
$$;`;
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* `ms.id` for the scope itself and `ms.within ->> name` for an ancestor. The
|
|
1677
|
+
* alias is not `s`, which `activeRowSql` binds inside its `exists`.
|
|
1678
|
+
*/
|
|
1679
|
+
function sourceIdOf(scope) {
|
|
1680
|
+
return (name) => name === scope ? "ms.id" : `ms.within ->> ${quoteLiteral(name)}`;
|
|
1681
|
+
}
|
|
1682
|
+
function sourceFilters(ctx, scope, user = subjectIdSql(ctx), permission, keeps = false) {
|
|
1683
|
+
return [
|
|
1684
|
+
...keeps ? [` and ${keptRowSql("ms.keep", permission)}`] : [],
|
|
1685
|
+
...userActive(ctx, " ", user),
|
|
1686
|
+
...instancesActive(ctx, scope, sourceIdOf(scope), " ", permission)
|
|
1687
|
+
].map((line) => `\n${line}`).join("");
|
|
1688
|
+
}
|
|
1689
|
+
function sourcesBody(ctx, scope, type) {
|
|
1690
|
+
const root = rootName(ctx);
|
|
1691
|
+
const tenant = sourceIdOf(scope)(root);
|
|
1692
|
+
const narrow = narrowsTo(ctx, scope) ? `\n and (${activeTenant(ctx)} is null or ${tenant} = ${activeTenant(ctx)})${keyTenantLine(ctx, scope, tenant)}` : keyTenantLine(ctx, scope, tenant);
|
|
1693
|
+
const sources = scopeSources(ctx, scope);
|
|
1694
|
+
const set = ctx.grantSet === true;
|
|
1695
|
+
const rows = ` from (
|
|
1696
|
+
${sourceRows(sources)}
|
|
1697
|
+
) ms
|
|
1698
|
+
cross join lateral jsonb_array_elements_text(
|
|
1699
|
+
case jsonb_typeof(ms.roles) when 'array' then ms.roles else '[]'::jsonb end
|
|
1700
|
+
) r(role)`;
|
|
1701
|
+
const declared = ` select (ms.id)::${type}${set ? ", rp.grant_key" : ""}
|
|
1702
|
+
${rows}
|
|
1703
|
+
join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
|
|
1704
|
+
where ${signedIn(ctx)}
|
|
1705
|
+
and ms.scope = ${quoteLiteral(scope)}${set ? "" : "\n and rp.grant_key = p_grant"}
|
|
1706
|
+
and rp.scope = ${quoteLiteral(scope)}${andLine(" ", kindFilterSql(ctx, "r.role", "ms.via"))}${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql(set ? "rp.grant_key" : "p_grant"), sourcesKeep(sources))}`;
|
|
1707
|
+
const custom = ctx.customRoles;
|
|
1708
|
+
if (custom === void 0) return sourcesBodyOf(declared, sources, subjectIdSql(ctx));
|
|
1709
|
+
const match = [
|
|
1710
|
+
`c.tenant_id::text = (${tenant})::text`,
|
|
1711
|
+
`c.scope = ${quoteLiteral(scope)}`,
|
|
1712
|
+
"(c.scope_id is null or c.scope_id = (ms.id)::text)",
|
|
1713
|
+
"c.role = r.role"
|
|
1714
|
+
].join(" and ");
|
|
1715
|
+
const entries = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
|
|
1716
|
+
if (set) return sourcesBodyOf(`${declared}
|
|
1717
|
+
union
|
|
1718
|
+
select (ms.id)::${type}, ck.grant_key
|
|
1719
|
+
${rows}
|
|
1720
|
+
${customKeysJoin(ctx, scope, entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'allow'"), entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), entries(CUSTOM_ROLES.includes, "include_role", ""))}
|
|
1721
|
+
where ${signedIn(ctx)}
|
|
1722
|
+
and ms.scope = ${quoteLiteral(scope)}
|
|
1723
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("ck.grant_key"), sourcesKeep(sources))}`, sources, subjectIdSql(ctx));
|
|
1724
|
+
return sourcesBodyOf(`${declared}
|
|
1725
|
+
union
|
|
1726
|
+
select (ms.id)::${type}
|
|
1727
|
+
${rows}
|
|
1728
|
+
where ${signedIn(ctx)}
|
|
1729
|
+
and ms.scope = ${quoteLiteral(scope)}
|
|
1730
|
+
and not (r.role = any(${sqlTextArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("p_grant"), sourcesKeep(sources))}
|
|
1731
|
+
${customKeysSql(ctx, scope, entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'allow'"), entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), entries(CUSTOM_ROLES.includes, "include_role", ""))}`, sources, subjectIdSql(ctx));
|
|
1732
|
+
}
|
|
1733
|
+
function scopedBody(ctx, scope, type) {
|
|
1734
|
+
if (ctx.authorize !== "database") return sqlBody(claimBody(ctx, scope, type));
|
|
1735
|
+
return scopeSources(ctx, scope).length > 0 ? sourcesBody(ctx, scope, type) : sqlBody(tableBody(ctx, scope, type));
|
|
1736
|
+
}
|
|
1737
|
+
//#endregion
|
|
1738
|
+
//#region src/cli/rls/capabilities.ts
|
|
1739
|
+
/** The helper `--capabilities` adds: ids of one resource a link capability claim reaches. Part of the SQL contract. */
|
|
1740
|
+
const CAPABILITIES = { ids: "permdock_capability_ids" };
|
|
1741
|
+
/**
|
|
1742
|
+
* The `anon` branch access for a resource-scoped grant reached through a link
|
|
1743
|
+
* capability: the row's `field` holds an id the capability claim names for
|
|
1744
|
+
* `resource`, `role` and `permission`. Uncorrelated, so it runs once per
|
|
1745
|
+
* statement; the column is compared as text because the claim holds text.
|
|
1746
|
+
*/
|
|
1747
|
+
function capabilityAccessSql(ctx, field, resource, role, permission) {
|
|
1748
|
+
const args = [
|
|
1749
|
+
resource,
|
|
1750
|
+
role,
|
|
1751
|
+
permission
|
|
1752
|
+
].map(quoteLiteral).join(", ");
|
|
1753
|
+
return `${quoteIdent(field)}::text in (select ${qualified(ctx, CAPABILITIES.ids)}(${args}))`;
|
|
1754
|
+
}
|
|
1755
|
+
function capabilitiesSql(ctx) {
|
|
1756
|
+
if (ctx.capabilities !== true) return "";
|
|
1757
|
+
const fn = qualified(ctx, CAPABILITIES.ids);
|
|
1758
|
+
return `-- link capabilities: the capability claim exchangeCapability mints (role anon, short-lived)
|
|
1759
|
+
create or replace function ${fn}(p_resource text, p_role text, p_permission text)
|
|
1760
|
+
returns setof text
|
|
1761
|
+
language sql
|
|
1762
|
+
stable
|
|
1763
|
+
set search_path = ''
|
|
1764
|
+
as $$
|
|
1765
|
+
select c -> 'on' ->> 'id'
|
|
1766
|
+
from (select ${subjectClaimJsonSql(ctx, "capability")} as c) capability
|
|
1767
|
+
where jsonb_typeof(c) = 'object'
|
|
1768
|
+
and c ->> 'v' = '1'
|
|
1769
|
+
and c ->> 'holder' = 'link'
|
|
1770
|
+
and c -> 'on' ->> 'resource' = p_resource
|
|
1771
|
+
and jsonb_typeof(c -> 'roles') = 'array'
|
|
1772
|
+
and c -> 'roles' @> jsonb_build_array(p_role)
|
|
1773
|
+
and (c -> 'permissions' is null or c -> 'permissions' @> jsonb_build_array(p_permission))
|
|
1774
|
+
and jsonb_typeof(c -> 'expiresAt') = 'number'
|
|
1775
|
+
and (c ->> 'expiresAt')::numeric > extract(epoch from now())
|
|
1776
|
+
$$;
|
|
1777
|
+
revoke execute on function ${fn}(text, text, text) from public;
|
|
1778
|
+
grant execute on function ${fn}(text, text, text) to anon, authenticated;`;
|
|
1944
1779
|
}
|
|
1945
|
-
|
|
1946
|
-
|
|
1780
|
+
//#endregion
|
|
1781
|
+
//#region src/cli/rls/custom-role-cascade.ts
|
|
1782
|
+
/** `row.column`, for a trigger's `old` or `new`. */
|
|
1783
|
+
function rowColumn(row, name) {
|
|
1784
|
+
return `${row}.${quoteIdent(name)}`;
|
|
1947
1785
|
}
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
vars: []
|
|
1952
|
-
};
|
|
1786
|
+
/** The custom-role rows of the role a trigger's `v_<prefix>_*` variables name. */
|
|
1787
|
+
function cascadeMatch(prefix) {
|
|
1788
|
+
return `c.tenant_id is not distinct from v_${prefix}_tenant and c.scope = v_${prefix}_scope and c.scope_id is not distinct from v_${prefix}_id and c.role = v_${prefix}_key`;
|
|
1953
1789
|
}
|
|
1954
|
-
|
|
1790
|
+
/**
|
|
1791
|
+
* The trigger on the application's roles table (`rls.customRoleWrites.roles`):
|
|
1792
|
+
* a rename, a move to another tenant, scope or instance, or a delete of a
|
|
1793
|
+
* custom role's row carries its grants and includes along. A signed-in
|
|
1794
|
+
* caller at trigger depth 1 passes the write functions' checks: authority
|
|
1795
|
+
* over the stored definition where it was, and, after a move, membership
|
|
1796
|
+
* and authority where it lands and entries inside that scope's ceiling.
|
|
1797
|
+
* Other paths (a migration, a job, a nested trigger) move the rows as the
|
|
1798
|
+
* function owner.
|
|
1799
|
+
*/
|
|
1800
|
+
function customRoleCascadeSql(ctx) {
|
|
1801
|
+
const custom = ctx.customRoles;
|
|
1802
|
+
const table = custom?.table;
|
|
1803
|
+
const root = ctx.scopes[0]?.name;
|
|
1804
|
+
if (custom === void 0 || table === void 0 || root === void 0) return "";
|
|
1805
|
+
const tenantType = tenantTypeOf(ctx);
|
|
1806
|
+
const perms = qualified(ctx, CUSTOM_ROLES.permissions);
|
|
1807
|
+
const includes = qualified(ctx, CUSTOM_ROLES.includes);
|
|
1808
|
+
const fn = qualified(ctx, CUSTOM_ROLES.cascade);
|
|
1809
|
+
const target = quoteTable(qualifySqlTable(table.table));
|
|
1810
|
+
const tenant = (row) => table.tenant === void 0 ? `null::${tenantType}` : `${rowColumn(row, table.tenant)}::${tenantType}`;
|
|
1811
|
+
const scope = (row) => table.scope === void 0 ? `case when ${tenant(row)} is null then 'global' else ${quoteLiteral(root)} end` : `case when ${tenant(row)} is null then 'global' else ${rowColumn(row, table.scope)}::text end`;
|
|
1812
|
+
const id = (row) => table.id === void 0 ? "null::text" : `${rowColumn(row, table.id)}::text`;
|
|
1813
|
+
const skipped = (row) => table.skip === void 0 ? "false" : `coalesce(${rowColumn(row, table.skip)}, false)`;
|
|
1814
|
+
const guard = qualified(ctx, CUSTOM_ROLES.guard);
|
|
1815
|
+
const entries = qualified(ctx, CUSTOM_ROLES.entries);
|
|
1816
|
+
const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
|
|
1817
|
+
return `-- the application's roles table: renames, moves and deletes of a custom role carry its grants and includes
|
|
1818
|
+
create or replace function ${fn}()
|
|
1819
|
+
returns trigger
|
|
1820
|
+
language plpgsql
|
|
1821
|
+
volatile
|
|
1822
|
+
security definer
|
|
1823
|
+
set search_path = ''
|
|
1824
|
+
as $$
|
|
1825
|
+
declare
|
|
1826
|
+
v_old_tenant ${tenantType} := ${tenant("old")};
|
|
1827
|
+
v_old_scope text := ${scope("old")};
|
|
1828
|
+
v_old_id text := ${id("old")};
|
|
1829
|
+
v_old_key text := ${rowColumn("old", table.key)}::text;
|
|
1830
|
+
v_new_tenant ${tenantType};
|
|
1831
|
+
v_new_scope text;
|
|
1832
|
+
v_new_id text;
|
|
1833
|
+
v_new_key text;
|
|
1834
|
+
v_checked boolean := ${signedIn(ctx)} and pg_trigger_depth() = 1;
|
|
1835
|
+
v_allow text[];
|
|
1836
|
+
v_deny text[];
|
|
1837
|
+
v_include text[];
|
|
1838
|
+
begin
|
|
1839
|
+
if ${skipped("old")} then
|
|
1840
|
+
return null;
|
|
1841
|
+
end if;
|
|
1842
|
+
if tg_op = 'UPDATE' and not ${skipped("new")} then
|
|
1843
|
+
v_new_tenant := ${tenant("new")};
|
|
1844
|
+
v_new_scope := ${scope("new")};
|
|
1845
|
+
v_new_id := ${id("new")};
|
|
1846
|
+
v_new_key := ${rowColumn("new", table.key)}::text;
|
|
1847
|
+
if v_new_key = v_old_key
|
|
1848
|
+
and v_new_tenant is not distinct from v_old_tenant
|
|
1849
|
+
and v_new_scope = v_old_scope
|
|
1850
|
+
and v_new_id is not distinct from v_old_id then
|
|
1851
|
+
return null;
|
|
1852
|
+
end if;
|
|
1853
|
+
end if;
|
|
1854
|
+
if not exists (select 1 from ${perms} c where ${cascadeMatch("old")})
|
|
1855
|
+
and not exists (select 1 from ${includes} c where ${cascadeMatch("old")}) then
|
|
1856
|
+
return null;
|
|
1857
|
+
end if;
|
|
1858
|
+
if v_checked then
|
|
1859
|
+
perform ${guard}(v_old_tenant, v_old_scope, v_old_id, v_old_key);
|
|
1860
|
+
end if;
|
|
1861
|
+
if v_new_key is null then
|
|
1862
|
+
delete from ${perms} c where ${cascadeMatch("old")};
|
|
1863
|
+
delete from ${includes} c where ${cascadeMatch("old")};
|
|
1864
|
+
return null;
|
|
1865
|
+
end if;
|
|
1866
|
+
if exists (select 1 from ${perms} c where ${cascadeMatch("new")})
|
|
1867
|
+
or exists (select 1 from ${includes} c where ${cascadeMatch("new")}) then
|
|
1868
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_new_key || ' already has grants'`, "role-exists")}
|
|
1869
|
+
end if;
|
|
1870
|
+
update ${perms} c set tenant_id = v_new_tenant, scope = v_new_scope, scope_id = v_new_id, role = v_new_key where ${cascadeMatch("old")};
|
|
1871
|
+
update ${includes} c set tenant_id = v_new_tenant, scope = v_new_scope, scope_id = v_new_id, role = v_new_key where ${cascadeMatch("old")};
|
|
1872
|
+
if v_checked then
|
|
1873
|
+
perform ${guard}(v_new_tenant, v_new_scope, v_new_id, v_new_key);
|
|
1874
|
+
select array_agg(${allowEntryOf}) filter (where c.effect = 'allow'),
|
|
1875
|
+
array_agg(c.permission) filter (where c.effect = 'deny')
|
|
1876
|
+
into v_allow, v_deny
|
|
1877
|
+
from ${perms} c
|
|
1878
|
+
where ${cascadeMatch("new")};
|
|
1879
|
+
select array_agg(c.include_role) into v_include
|
|
1880
|
+
from ${includes} c
|
|
1881
|
+
where ${cascadeMatch("new")};
|
|
1882
|
+
perform ${entries}(v_new_scope, v_allow, v_deny, v_include);
|
|
1883
|
+
end if;
|
|
1884
|
+
return null;
|
|
1885
|
+
end;
|
|
1886
|
+
$$;
|
|
1887
|
+
revoke execute on function ${fn}() from public, anon, authenticated;
|
|
1888
|
+
drop trigger if exists "permdock_custom_role_cascade" on ${target};
|
|
1889
|
+
create trigger "permdock_custom_role_cascade"
|
|
1890
|
+
after update or delete on ${target}
|
|
1891
|
+
for each row execute function ${fn}();
|
|
1892
|
+
`;
|
|
1893
|
+
}
|
|
1894
|
+
//#endregion
|
|
1895
|
+
//#region src/cli/rls/custom-role-writes.ts
|
|
1896
|
+
function callerFunctionsSql(ctx, custom, root, levelReach, forUser) {
|
|
1897
|
+
const leveled = custom.levels === true;
|
|
1898
|
+
const tenantType = tenantTypeOf(ctx);
|
|
1899
|
+
const rp = qualified(ctx, "role_permissions");
|
|
1900
|
+
const keys = qualified(ctx, CUSTOM_ROLES.keys);
|
|
1901
|
+
const perms = qualified(ctx, CUSTOM_ROLES.permissions);
|
|
1902
|
+
const includes = qualified(ctx, CUSTOM_ROLES.includes);
|
|
1903
|
+
const shape = qualified(ctx, CUSTOM_ROLES.shape);
|
|
1904
|
+
const beyond = qualified(ctx, forUser ? CUSTOM_ROLES.beyondFor : CUSTOM_ROLES.beyond);
|
|
1905
|
+
const guard = qualified(ctx, forUser ? CUSTOM_ROLES.guardFor : CUSTOM_ROLES.guard);
|
|
1906
|
+
const user = forUser ? "p_user" : subjectIdSql(ctx);
|
|
1907
|
+
const userParam = forUser ? "p_user uuid, " : "";
|
|
1908
|
+
const userArg = forUser ? "uuid, " : "";
|
|
1909
|
+
const userCall = forUser ? "p_user, " : "";
|
|
1910
|
+
const has = (key) => forUser ? `${qualified(ctx, HELPERS.hasFor)}(p_user, ${key})` : `${qualified(ctx, HELPERS.has)}(${key})`;
|
|
1911
|
+
const ids = (helper, arg) => `(select x::text from ${qualified(ctx, helper)}(${arg}) x)`;
|
|
1912
|
+
const permitted = (scope, key) => forUser ? ids(permittedForHelper(scope), `p_user, ${key}`) : ids(permittedIdsHelper(scope), key);
|
|
1913
|
+
const members = (scope) => forUser ? ids(memberForHelper(scope), "p_user") : ids(memberIdsHelper(scope), "");
|
|
1914
|
+
const heldKey = (key) => `(${has(key)}
|
|
1915
|
+
or (p_scope <> 'global'
|
|
1916
|
+
and (p_tenant::text in ${permitted(root, key)}
|
|
1917
|
+
or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${permitted(scope, key)}`)})))`;
|
|
1918
|
+
const holdsAny = (permissions, held) => permissions.length === 0 ? "false" : `exists (
|
|
1919
|
+
select 1 from ${rp} rp
|
|
1920
|
+
where rp.effect = 'allow'
|
|
1921
|
+
and rp.permission = any(${sqlTextArray(permissions)})
|
|
1922
|
+
and ${held("rp.grant_key")}
|
|
1923
|
+
)`;
|
|
1924
|
+
const manageKeys = custom.manage ?? [];
|
|
1925
|
+
const manage = holdsAny(manageKeys, heldKey);
|
|
1926
|
+
const platform = holdsAny(manageKeys, has);
|
|
1927
|
+
const requires = custom.requires === void 0 ? "" : `
|
|
1928
|
+
if not ${holdsAny(custom.requires, heldKey)} then
|
|
1929
|
+
${raiseSql(" ", "42501", `'permdock: the caller may not manage roles in ' || coalesce(p_tenant::text, 'the platform')`, "manage-roles")}
|
|
1930
|
+
end if;`;
|
|
1931
|
+
const reachRows = levelReach.map(([grantKey, level]) => `(${quoteLiteral(grantKey)}, ${quoteLiteral(level)})`).join(", ");
|
|
1932
|
+
const levelCheck = leveled && reachRows !== "" ? `
|
|
1933
|
+
or exists (
|
|
1934
|
+
select 1
|
|
1935
|
+
from reach x
|
|
1936
|
+
where x.grant_key = r.grant_key
|
|
1937
|
+
and not exists (
|
|
1938
|
+
select 1 from held h
|
|
1939
|
+
join reach o on o.grant_key = h.grant_key
|
|
1940
|
+
where h.permission = r.permission and o.level = x.level
|
|
1941
|
+
)
|
|
1942
|
+
)` : "";
|
|
1943
|
+
const reachCte = leveled && reachRows !== "" ? `,
|
|
1944
|
+
reach (grant_key, level) as (values ${reachRows})` : "";
|
|
1945
|
+
const match = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.scope_id is not distinct from p_scope_id`;
|
|
1946
|
+
const memberOf = `p_tenant::text in ${members(root)}
|
|
1947
|
+
or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${members(scope)}`)}`;
|
|
1955
1948
|
return {
|
|
1956
|
-
|
|
1957
|
-
|
|
1949
|
+
beyond: `-- custom-role writes: the permission keys of a definition the caller may not hand out in the tenant, as assignablePermissions computes them
|
|
1950
|
+
create or replace function ${beyond}(${userParam}p_tenant ${tenantType}, p_scope text, p_scope_id text, p_allow text[], p_deny text[], p_include text[])
|
|
1951
|
+
returns setof text
|
|
1952
|
+
language plpgsql
|
|
1953
|
+
stable
|
|
1954
|
+
security definer
|
|
1955
|
+
set search_path = ''
|
|
1956
|
+
as $$
|
|
1957
|
+
begin
|
|
1958
|
+
if ${manage} then
|
|
1959
|
+
return;
|
|
1960
|
+
end if;
|
|
1961
|
+
return query
|
|
1962
|
+
with resolved as (
|
|
1963
|
+
select distinct rp.permission, k.key as grant_key
|
|
1964
|
+
from ${keys}(p_allow, p_deny, p_include, p_scope) k(key)
|
|
1965
|
+
join ${rp} rp on rp.grant_key = k.key and rp.effect = 'allow'
|
|
1966
|
+
),
|
|
1967
|
+
held as (
|
|
1968
|
+
select distinct rp.permission, rp.grant_key
|
|
1969
|
+
from ${rp} rp
|
|
1970
|
+
where rp.effect = 'allow'
|
|
1971
|
+
and rp.permission in (select r.permission from resolved r)
|
|
1972
|
+
and ${heldKey("rp.grant_key")}
|
|
1973
|
+
)${reachCte}
|
|
1974
|
+
select distinct r.permission
|
|
1975
|
+
from resolved r
|
|
1976
|
+
where not exists (select 1 from held h where h.permission = r.permission)${levelCheck};
|
|
1977
|
+
end;
|
|
1978
|
+
$$;
|
|
1979
|
+
revoke execute on function ${beyond}(${userArg}${tenantType}, text, text, text[], text[], text[]) from public, anon, authenticated;`,
|
|
1980
|
+
guard: `-- a custom role the caller may change: membership of the tenant or the instance (a platform role, or a caller holding a manageRoles permission through a global role, needs none), and authority over the stored definition
|
|
1981
|
+
create or replace function ${guard}(${userParam}p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
|
|
1982
|
+
returns void
|
|
1983
|
+
language plpgsql
|
|
1984
|
+
stable
|
|
1985
|
+
security definer
|
|
1986
|
+
set search_path = ''
|
|
1987
|
+
as $$
|
|
1988
|
+
declare
|
|
1989
|
+
v_allow text[];
|
|
1990
|
+
v_deny text[];
|
|
1991
|
+
v_include text[];
|
|
1992
|
+
v_beyond text;
|
|
1993
|
+
begin
|
|
1994
|
+
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1995
|
+
if not (${signedIn(ctx, user)} and (
|
|
1996
|
+
${platform}
|
|
1997
|
+
or (p_scope <> 'global' and (${memberOf}))
|
|
1998
|
+
)) then
|
|
1999
|
+
${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
|
|
2000
|
+
end if;${requires}
|
|
2001
|
+
select array_agg(${allowEntry(ctx)}) filter (where c.effect = 'allow'),
|
|
2002
|
+
array_agg(c.permission) filter (where c.effect = 'deny')
|
|
2003
|
+
into v_allow, v_deny
|
|
2004
|
+
from ${perms} c
|
|
2005
|
+
where ${match} and c.role = p_role;
|
|
2006
|
+
select array_agg(c.include_role) into v_include
|
|
2007
|
+
from ${includes} c
|
|
2008
|
+
where ${match} and c.role = p_role;
|
|
2009
|
+
select string_agg(b, ', ' order by b) into v_beyond
|
|
2010
|
+
from ${beyond}(${userCall}p_tenant, p_scope, p_scope_id, v_allow, v_deny, v_include) b;
|
|
2011
|
+
if v_beyond is not null then
|
|
2012
|
+
${raiseSql(" ", "42501", `'permdock: ' || p_role || ' allows ' || v_beyond || ', which the caller may not hand out'`, "not-assignable-by")}
|
|
2013
|
+
end if;
|
|
2014
|
+
end;
|
|
2015
|
+
$$;
|
|
2016
|
+
revoke execute on function ${guard}(${userArg}${tenantType}, text, text, text) from public, anon, authenticated;`
|
|
1958
2017
|
};
|
|
1959
2018
|
}
|
|
1960
|
-
/**
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
2019
|
+
/**
|
|
2020
|
+
* The functions an application calls to save, rename and delete a custom
|
|
2021
|
+
* role in \`database\` mode, with the rules of \`validateCustomRole\` and
|
|
2022
|
+
* \`assignablePermissions\`: entries inside the ceiling of the role's scope,
|
|
2023
|
+
* includes naming declared roles, and only permissions and levels the caller
|
|
2024
|
+
* may hand out, for the new definition and the stored one alike. A platform
|
|
2025
|
+
* custom role (\`p_scope = 'global'\`, no tenant) is written by a caller who
|
|
2026
|
+
* holds a \`meta.manageRoles\` permission through a global role. The
|
|
2027
|
+
* \`permdock_trusted_*\` variants skip the caller checks and keep the
|
|
2028
|
+
* definition checks; no client role may execute them.
|
|
2029
|
+
*/
|
|
2030
|
+
function customRoleWritesSql(ctx, levelReach) {
|
|
2031
|
+
const custom = ctx.customRoles;
|
|
2032
|
+
const root = ctx.scopes[0]?.name;
|
|
2033
|
+
if (custom === void 0 || ctx.authorize !== "database" || root === void 0) return "";
|
|
2034
|
+
const leveled = custom.levels === true;
|
|
2035
|
+
const tenantType = tenantTypeOf(ctx);
|
|
2036
|
+
const rp = qualified(ctx, "role_permissions");
|
|
2037
|
+
const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
|
|
2038
|
+
const perms = qualified(ctx, CUSTOM_ROLES.permissions);
|
|
2039
|
+
const includes = qualified(ctx, CUSTOM_ROLES.includes);
|
|
2040
|
+
const beyond = qualified(ctx, CUSTOM_ROLES.beyond);
|
|
2041
|
+
const guard = qualified(ctx, CUSTOM_ROLES.guard);
|
|
2042
|
+
const shape = qualified(ctx, CUSTOM_ROLES.shape);
|
|
2043
|
+
const entries = qualified(ctx, CUSTOM_ROLES.entries);
|
|
2044
|
+
const renames = Object.entries(custom.renamed ?? {}).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
2045
|
+
const current = (expr) => currentKeyExpr(renames, expr);
|
|
2046
|
+
const known = sqlTextArray([...custom.permissions ?? [], ...renames.map(([former]) => former)]);
|
|
2047
|
+
const caller = callerFunctionsSql(ctx, custom, root, levelReach, false);
|
|
2048
|
+
const match = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.scope_id is not distinct from p_scope_id`;
|
|
2049
|
+
const scopes = sqlTextArray(ctx.scopes.map((scope) => scope.name));
|
|
2050
|
+
const roleName = (param) => `coalesce(${param}, '') = '' or ${param} = any(${sqlTextArray(custom.declared)})`;
|
|
2051
|
+
const levelOf = leveled ? ", nullif(split_part(e, '@', 2), '')" : "";
|
|
2052
|
+
const write = ` delete from ${perms} c where ${match} and c.role = p_role;
|
|
2053
|
+
delete from ${includes} c where ${match} and c.role = p_role;
|
|
2054
|
+
insert into ${perms} (tenant_id, scope, scope_id, role, permission, effect${leveled ? ", level" : ""})
|
|
2055
|
+
select distinct p_tenant, p_scope, p_scope_id, p_role, ${current(leveled ? "split_part(e, '@', 1)" : "e")}, 'allow'${levelOf}
|
|
2056
|
+
from unnest(coalesce(p_allow, '{}'::text[])) e
|
|
2057
|
+
union
|
|
2058
|
+
select distinct p_tenant, p_scope, p_scope_id, p_role, ${current("e")}, 'deny'${leveled ? ", null" : ""}
|
|
2059
|
+
from unnest(coalesce(p_deny, '{}'::text[])) e;
|
|
2060
|
+
insert into ${includes} (tenant_id, scope, scope_id, role, include_role)
|
|
2061
|
+
select distinct p_tenant, p_scope, p_scope_id, p_role, e
|
|
2062
|
+
from unnest(coalesce(p_include, '{}'::text[])) e;`;
|
|
2063
|
+
const move = ` if ${roleName("p_to")} then
|
|
2064
|
+
${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_to, 'null') || ' is not a custom role name'`, "declared-role")}
|
|
2065
|
+
end if;
|
|
2066
|
+
if exists (select 1 from ${perms} c where ${match} and c.role = p_to)
|
|
2067
|
+
or exists (select 1 from ${includes} c where ${match} and c.role = p_to) then
|
|
2068
|
+
${raiseSql(" ", "22023", `'permdock: ' || p_to || ' already has grants'`, "role-exists")}
|
|
2069
|
+
end if;
|
|
2070
|
+
update ${perms} c set role = p_to where ${match} and c.role = p_from;
|
|
2071
|
+
update ${includes} c set role = p_to where ${match} and c.role = p_from;`;
|
|
2072
|
+
const remove = ` delete from ${perms} c where ${match} and c.role = p_role;
|
|
2073
|
+
delete from ${includes} c where ${match} and c.role = p_role;`;
|
|
2074
|
+
const writeArgs = `${tenantType}, text, text, text, text[], text[], text[]`;
|
|
2075
|
+
const renameArgs = `${tenantType}, text, text, text, text`;
|
|
2076
|
+
const removeArgs = `${tenantType}, text, text, text`;
|
|
2077
|
+
const fn = (comment, name, params, args, body, member) => `-- ${comment}
|
|
2078
|
+
create or replace function ${qualified(ctx, name)}(${params})
|
|
2079
|
+
returns void
|
|
2080
|
+
language plpgsql
|
|
2081
|
+
volatile
|
|
1964
2082
|
security definer
|
|
1965
2083
|
set search_path = ''
|
|
1966
2084
|
as $$
|
|
1967
|
-
${body
|
|
1968
|
-
|
|
1969
|
-
|
|
2085
|
+
${body}
|
|
2086
|
+
$$;
|
|
2087
|
+
${member ? `revoke execute on function ${qualified(ctx, name)}(${args}) from public, anon;
|
|
2088
|
+
grant execute on function ${qualified(ctx, name)}(${args}) to authenticated;` : `revoke execute on function ${qualified(ctx, name)}(${args}) from public, anon, authenticated;`}`;
|
|
2089
|
+
const writeParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text, p_allow text[], p_deny text[], p_include text[]`;
|
|
2090
|
+
const renameParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_from text, p_to text`;
|
|
2091
|
+
const removeParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
|
|
2092
|
+
return `${caller.beyond}
|
|
2093
|
+
|
|
2094
|
+
-- where a custom role may live: a scope of a tenant, or the platform (scope global, no tenant or instance), under an undeclared name
|
|
2095
|
+
create or replace function ${shape}(p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
|
|
2096
|
+
returns void
|
|
2097
|
+
language plpgsql
|
|
2098
|
+
stable
|
|
2099
|
+
set search_path = ''
|
|
2100
|
+
as $$
|
|
2101
|
+
begin
|
|
2102
|
+
if p_scope = 'global' then
|
|
2103
|
+
if p_tenant is not null or p_scope_id is not null then
|
|
2104
|
+
${raiseSql(" ", "22023", `'permdock: a platform custom role has no tenant or instance'`, "unknown-scope")}
|
|
2105
|
+
end if;
|
|
2106
|
+
elsif p_tenant is null or not (p_scope = any(${scopes})) then
|
|
2107
|
+
${raiseSql(" ", "22023", `'permdock: custom roles are held at a scope of a tenant or at global, not ' || coalesce(p_scope, 'null')`, "unknown-scope")}
|
|
2108
|
+
end if;
|
|
2109
|
+
if ${roleName("p_role")} then
|
|
2110
|
+
${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_role, 'null') || ' is not a custom role name'`, "declared-role")}
|
|
2111
|
+
end if;
|
|
2112
|
+
end;
|
|
2113
|
+
$$;
|
|
2114
|
+
revoke execute on function ${shape}(${tenantType}, text, text, text) from public, anon, authenticated;
|
|
2115
|
+
|
|
2116
|
+
${caller.guard}
|
|
2117
|
+
${hasCustomRoleChecksFor(ctx) ? `
|
|
2118
|
+
-- the same checks for a user the caller names: trusted SQL acting later for a stored user; no client role may execute them
|
|
2119
|
+
${callerFunctionsSql(ctx, custom, root, levelReach, true).beyond}
|
|
2120
|
+
|
|
2121
|
+
${callerFunctionsSql(ctx, custom, root, levelReach, true).guard}
|
|
2122
|
+
` : ""}
|
|
2123
|
+
-- the entries of a definition: declared keys and levels inside the ceiling of the scope, and declared includes
|
|
2124
|
+
create or replace function ${entries}(p_scope text, p_allow text[], p_deny text[], p_include text[])
|
|
2125
|
+
returns void
|
|
2126
|
+
language plpgsql
|
|
1970
2127
|
stable
|
|
1971
2128
|
security definer
|
|
1972
2129
|
set search_path = ''
|
|
1973
2130
|
as $$
|
|
1974
2131
|
declare
|
|
1975
|
-
|
|
2132
|
+
v_entry text;
|
|
2133
|
+
v_key text;
|
|
1976
2134
|
begin
|
|
1977
|
-
|
|
1978
|
-
${
|
|
2135
|
+
foreach v_entry in array coalesce(p_allow, '{}'::text[]) loop
|
|
2136
|
+
v_key := ${current(leveled ? "split_part(v_entry, '@', 1)" : "v_entry")};
|
|
2137
|
+
if not (v_key = any(${known})) then
|
|
2138
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared permission'`, "unknown-permission")}
|
|
2139
|
+
end if;
|
|
2140
|
+
if not exists (
|
|
2141
|
+
select 1 from ${ceiling} c
|
|
2142
|
+
where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
|
|
2143
|
+
) then
|
|
2144
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_key || ' is outside the ceiling of ' || p_scope`, "outside-ceiling")}
|
|
2145
|
+
end if;${leveled ? `
|
|
2146
|
+
if strpos(v_entry, '@') > 0 and not exists (
|
|
2147
|
+
select 1 from ${ceiling} c
|
|
2148
|
+
where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
|
|
2149
|
+
and split_part(c.grant_key, '@', 2) = split_part(v_entry, '@', 2)
|
|
2150
|
+
) then
|
|
2151
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' names a level the resource does not declare'`, "unknown-level")}
|
|
2152
|
+
end if;` : ""}
|
|
2153
|
+
end loop;
|
|
2154
|
+
foreach v_entry in array coalesce(p_deny, '{}'::text[]) loop
|
|
2155
|
+
if not (v_entry = any(${known})) then
|
|
2156
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared permission'`, "unknown-permission")}
|
|
2157
|
+
end if;
|
|
2158
|
+
end loop;
|
|
2159
|
+
foreach v_entry in array coalesce(p_include, '{}'::text[]) loop
|
|
2160
|
+
if not (v_entry = any(${sqlTextArray(custom.declared)})) then
|
|
2161
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared role'`, "unknown-role")}
|
|
2162
|
+
end if;
|
|
2163
|
+
end loop;
|
|
2164
|
+
select string_agg(distinct ${current("rp.permission")}, ', ') into v_key
|
|
2165
|
+
from ${rp} rp
|
|
2166
|
+
where rp.role = any(coalesce(p_include, '{}'::text[]))
|
|
2167
|
+
and rp.effect = 'allow'
|
|
2168
|
+
and not exists (
|
|
2169
|
+
select 1 from ${ceiling} c
|
|
2170
|
+
where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = ${current("rp.permission")}
|
|
2171
|
+
);
|
|
2172
|
+
if v_key is not null then
|
|
2173
|
+
${raiseSql(" ", "22023", `'permdock: an included role allows ' || v_key || ', outside the ceiling of ' || p_scope`, "outside-ceiling")}
|
|
2174
|
+
end if;
|
|
1979
2175
|
end;
|
|
1980
|
-
|
|
2176
|
+
$$;
|
|
2177
|
+
revoke execute on function ${entries}(text, text[], text[], text[]) from public, anon, authenticated;
|
|
2178
|
+
|
|
2179
|
+
${fn("save a custom role: replace its grants and includes after checking each entry against the ceiling and the caller's own permissions", CUSTOM_ROLES.replace, writeParams, writeArgs, `declare
|
|
2180
|
+
v_beyond text;
|
|
2181
|
+
begin
|
|
2182
|
+
perform ${guard}(p_tenant, p_scope, p_scope_id, p_role);
|
|
2183
|
+
perform ${entries}(p_scope, p_allow, p_deny, p_include);
|
|
2184
|
+
select string_agg(b, ', ' order by b) into v_beyond
|
|
2185
|
+
from ${beyond}(p_tenant, p_scope, p_scope_id, p_allow, p_deny, p_include) b;
|
|
2186
|
+
if v_beyond is not null then
|
|
2187
|
+
${raiseSql(" ", "42501", `'permdock: ' || p_role || ' would allow ' || v_beyond || ', which the caller may not hand out'`, "not-assignable-by")}
|
|
2188
|
+
end if;
|
|
2189
|
+
${write}
|
|
2190
|
+
end;`, true)}
|
|
2191
|
+
|
|
2192
|
+
${fn("rename a custom role: move its grants and includes to the new name", CUSTOM_ROLES.rename, renameParams, renameArgs, `begin
|
|
2193
|
+
perform ${guard}(p_tenant, p_scope, p_scope_id, p_from);
|
|
2194
|
+
${move}
|
|
2195
|
+
end;`, true)}
|
|
2196
|
+
|
|
2197
|
+
${fn("delete a custom role: remove its grants and includes", CUSTOM_ROLES.remove, removeParams, removeArgs, `begin
|
|
2198
|
+
perform ${guard}(p_tenant, p_scope, p_scope_id, p_role);
|
|
2199
|
+
${remove}
|
|
2200
|
+
end;`, true)}
|
|
2201
|
+
|
|
2202
|
+
${fn("save a custom role for a trusted caller (a migration, a job, a backend role granted execute): the definition checks without the caller checks", CUSTOM_ROLES.trustedReplace, writeParams, writeArgs, `begin
|
|
2203
|
+
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
|
|
2204
|
+
perform ${entries}(p_scope, p_allow, p_deny, p_include);
|
|
2205
|
+
${write}
|
|
2206
|
+
end;`, false)}
|
|
2207
|
+
|
|
2208
|
+
${fn("rename a custom role for a trusted caller", CUSTOM_ROLES.trustedRename, renameParams, renameArgs, `begin
|
|
2209
|
+
perform ${shape}(p_tenant, p_scope, p_scope_id, p_from);
|
|
2210
|
+
${move}
|
|
2211
|
+
end;`, false)}
|
|
2212
|
+
|
|
2213
|
+
${fn("delete a custom role for a trusted caller", CUSTOM_ROLES.trustedRemove, removeParams, removeArgs, `begin
|
|
2214
|
+
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
|
|
2215
|
+
${remove}
|
|
2216
|
+
end;`, false)}`;
|
|
1981
2217
|
}
|
|
2218
|
+
//#endregion
|
|
2219
|
+
//#region src/cli/rls/global-roles.ts
|
|
1982
2220
|
/**
|
|
1983
|
-
* `
|
|
1984
|
-
*
|
|
2221
|
+
* `permdock_replace_global_roles(p_user, p_roles)`: sets the global roles of
|
|
2222
|
+
* one user in `rls.roles` in one statement pair. It runs as the invoker, so
|
|
2223
|
+
* the table's policies and the `rls.assignments` trigger judge every row it
|
|
2224
|
+
* deletes or inserts.
|
|
1985
2225
|
*/
|
|
1986
|
-
function
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
)
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
)
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
${
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2226
|
+
function globalRolesSql(ctx) {
|
|
2227
|
+
if (ctx.roles === void 0) return "";
|
|
2228
|
+
const source = globalRoleSource(ctx.roles, "public", "t", ctx.customRoles?.table);
|
|
2229
|
+
const table = quoteQualified(ctx.roles.table);
|
|
2230
|
+
const user = quoteIdent(source.user);
|
|
2231
|
+
const fn = qualified(ctx, GLOBAL_ROLES.replace);
|
|
2232
|
+
const type = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
2233
|
+
const wanted = `select distinct r from unnest(coalesce(p_roles, '{}'::text[])) r where r is not null`;
|
|
2234
|
+
const through = source.through;
|
|
2235
|
+
let body;
|
|
2236
|
+
if (through === void 0) {
|
|
2237
|
+
const role = typeof ctx.roles.role === "string" ? ctx.roles.role : "role";
|
|
2238
|
+
const column = quoteIdent(role);
|
|
2239
|
+
body = ` delete from ${table} t
|
|
2240
|
+
where t.${user} = p_user
|
|
2241
|
+
and t.${column}::text <> all(coalesce(p_roles, '{}'::text[]));
|
|
2242
|
+
insert into ${table} (${user}, ${column})
|
|
2243
|
+
select p_user, w.r
|
|
2244
|
+
from (${wanted}) w(r)
|
|
2245
|
+
where not exists (
|
|
2246
|
+
select 1 from ${table} t where t.${user} = p_user and t.${column}::text = w.r
|
|
2247
|
+
);`;
|
|
2248
|
+
} else {
|
|
2249
|
+
const keys = quoteTable(through.table);
|
|
2250
|
+
const id = quoteIdent(through.id);
|
|
2251
|
+
const key = quoteIdent(through.key);
|
|
2252
|
+
const ref = quoteIdent(through.ref);
|
|
2253
|
+
const global = source.tenant === void 0 ? "" : ` and k.${quoteIdent(source.tenant)} is null`;
|
|
2254
|
+
body = ` select string_agg(w.r, ', ' order by w.r) into v_unknown
|
|
2255
|
+
from (${wanted}) w(r)
|
|
2256
|
+
where not exists (select 1 from ${keys} k where k.${key}::text = w.r${global});
|
|
2257
|
+
if v_unknown is not null then
|
|
2258
|
+
raise exception using
|
|
2259
|
+
errcode = '22023',
|
|
2260
|
+
message = 'permdock: ' || v_unknown || ' is not a role',
|
|
2261
|
+
hint = 'unknown-role';
|
|
2262
|
+
end if;
|
|
2263
|
+
delete from ${table} t
|
|
2264
|
+
where t.${user} = p_user
|
|
2265
|
+
and not exists (
|
|
2266
|
+
select 1 from ${keys} k
|
|
2267
|
+
where k.${id} = t.${ref}
|
|
2268
|
+
and k.${key}::text = any(coalesce(p_roles, '{}'::text[]))${global}
|
|
2269
|
+
);
|
|
2270
|
+
insert into ${table} (${user}, ${ref})
|
|
2271
|
+
select p_user, k.${id}
|
|
2272
|
+
from ${keys} k
|
|
2273
|
+
where k.${key}::text in (${wanted})${global}
|
|
2274
|
+
and not exists (
|
|
2275
|
+
select 1 from ${table} t where t.${user} = p_user and t.${ref} = k.${id}
|
|
2276
|
+
);`;
|
|
2277
|
+
}
|
|
2278
|
+
const declare = through === void 0 ? "" : "declare\n v_unknown text;\n";
|
|
2279
|
+
const args = `${type}, text[]`;
|
|
2280
|
+
return `-- set the global roles of a user: delete the others and insert the missing ones, as the caller, so the table's policies and the assignment trigger still apply
|
|
2281
|
+
create or replace function ${fn}(p_user ${type}, p_roles text[])
|
|
2282
|
+
returns void
|
|
2283
|
+
language plpgsql
|
|
2284
|
+
volatile
|
|
2285
|
+
set search_path = ''
|
|
2286
|
+
as $$
|
|
2287
|
+
${declare}begin
|
|
2288
|
+
${body}
|
|
2289
|
+
end;
|
|
2290
|
+
$$;
|
|
2291
|
+
revoke execute on function ${fn}(${args}) from public, anon;
|
|
2292
|
+
grant execute on function ${fn}(${args}) to authenticated;`;
|
|
2043
2293
|
}
|
|
2294
|
+
//#endregion
|
|
2295
|
+
//#region src/cli/rls/helper-members.ts
|
|
2044
2296
|
/**
|
|
2045
2297
|
* The body of `member_<scope>_ids()`: instances of `scope` the subject holds
|
|
2046
2298
|
* a live membership of, with any role. Not narrowed to the active tenant, and
|
|
@@ -2088,7 +2340,7 @@ ${sourceRows(sources)}
|
|
|
2088
2340
|
const role = memberRoleOf(table, "m", " ");
|
|
2089
2341
|
const lines = [
|
|
2090
2342
|
` select distinct ${memberColumn(column)}::${type}`,
|
|
2091
|
-
` from ${
|
|
2343
|
+
` from ${quoteQualified(table.table)} m${role.join}`,
|
|
2092
2344
|
` where ${memberColumn(table.user)} = ${user}`,
|
|
2093
2345
|
` and ${role.through === void 0 && !role.lateral ? memberColumn(role.column) : role.sql} is not null`
|
|
2094
2346
|
];
|
|
@@ -2109,7 +2361,7 @@ function membershipClaim(ctx) {
|
|
|
2109
2361
|
return ctx.dialect === "supabase" ? `coalesce(${raw}, (select auth.jwt()) -> 'app_metadata' -> 'memberships')` : raw;
|
|
2110
2362
|
}
|
|
2111
2363
|
function memberFunction(ctx, scope, type, anonExecute) {
|
|
2112
|
-
const fn = qualified
|
|
2364
|
+
const fn = qualified(ctx, memberIdsHelper(scope));
|
|
2113
2365
|
const body = memberBody(ctx, scope, type);
|
|
2114
2366
|
const keys = ctx.apiKeys;
|
|
2115
2367
|
const keyed = keys === void 0 || scope !== rootName(ctx) ? body : {
|
|
@@ -2132,14 +2384,45 @@ ${grants}`;
|
|
|
2132
2384
|
* `supabase_auth_admin` execute.
|
|
2133
2385
|
*/
|
|
2134
2386
|
function memberForFunction(ctx, scope, type) {
|
|
2135
|
-
const fn = qualified
|
|
2387
|
+
const fn = qualified(ctx, memberForHelper(scope));
|
|
2136
2388
|
return `create or replace function ${fn}(p_user uuid)
|
|
2137
2389
|
returns setof ${type}
|
|
2138
2390
|
${functionBody(memberRowsBody(ctx, scope, type, "p_user", memberForSources(ctx, scope)))}
|
|
2139
2391
|
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
2140
2392
|
}
|
|
2393
|
+
//#endregion
|
|
2394
|
+
//#region src/cli/rls/helpers.ts
|
|
2395
|
+
/**
|
|
2396
|
+
* The policy-side call for a grant key. Every form is uncorrelated, so
|
|
2397
|
+
* Postgres runs the helper once per statement (an InitPlan, or a hashed
|
|
2398
|
+
* SubPlan for `in`), never once per row.
|
|
2399
|
+
*/
|
|
2400
|
+
function accessSql(ctx, scope, grantKey, column) {
|
|
2401
|
+
const key = quoteLiteral(grantKey);
|
|
2402
|
+
if (scope === "global") return `(select ${qualified(ctx, HELPERS.has)}(${key}))`;
|
|
2403
|
+
if (column === void 0) throw new Error(`PermDock CLI: ${scope}-scoped grant needs definePolicy({ scopes.${scope} })`);
|
|
2404
|
+
return `${quoteIdent(column)} in (select ${qualified(ctx, permittedIdsHelper(scope))}(${key}))`;
|
|
2405
|
+
}
|
|
2406
|
+
function userIdSql(ctx, anonExecute) {
|
|
2407
|
+
const fn = userIdHelperSql(ctx);
|
|
2408
|
+
return `-- the caller's user id: the sub of request.jwt.claims, or null when it is empty or absent
|
|
2409
|
+
create or replace function ${fn}()
|
|
2410
|
+
returns uuid
|
|
2411
|
+
language sql
|
|
2412
|
+
stable
|
|
2413
|
+
set search_path = ''
|
|
2414
|
+
as $$
|
|
2415
|
+
select nullif(
|
|
2416
|
+
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub',
|
|
2417
|
+
''
|
|
2418
|
+
)::uuid
|
|
2419
|
+
$$;
|
|
2420
|
+
${anonExecute ? `revoke execute on function ${fn}() from public;
|
|
2421
|
+
grant execute on function ${fn}() to anon, authenticated;` : `revoke execute on function ${fn}() from public, anon;
|
|
2422
|
+
grant execute on function ${fn}() to authenticated;`}`;
|
|
2423
|
+
}
|
|
2141
2424
|
function helperFunction(ctx, name, returns, body, anonExecute) {
|
|
2142
|
-
const fn = qualified
|
|
2425
|
+
const fn = qualified(ctx, name);
|
|
2143
2426
|
const grants = anonExecute ? `revoke execute on function ${fn}(text) from public;
|
|
2144
2427
|
grant execute on function ${fn}(text) to anon, authenticated;` : `revoke execute on function ${fn}(text) from public, anon;
|
|
2145
2428
|
grant execute on function ${fn}(text) to authenticated;`;
|
|
@@ -2163,7 +2446,7 @@ function forUserSql(ctx) {
|
|
|
2163
2446
|
const forUser = forUserContext(ctx);
|
|
2164
2447
|
const type = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
2165
2448
|
const fn = (name, returns, body) => {
|
|
2166
|
-
const qualifiedName = qualified
|
|
2449
|
+
const qualifiedName = qualified(ctx, name);
|
|
2167
2450
|
return `create or replace function ${qualifiedName}(p_user ${type}, p_grant text)
|
|
2168
2451
|
returns ${returns}
|
|
2169
2452
|
${functionBody(body)}
|
|
@@ -2184,7 +2467,7 @@ function userIdType(ctx) {
|
|
|
2184
2467
|
* declarative schema; `--split ...,seeds` writes it as its own migration.
|
|
2185
2468
|
*/
|
|
2186
2469
|
function seedSql(ctx, rows) {
|
|
2187
|
-
const table = qualified
|
|
2470
|
+
const table = qualified(ctx, "role_permissions");
|
|
2188
2471
|
if (rows.length === 0) return `delete from ${table};`;
|
|
2189
2472
|
return `insert into ${table} (role, permission, grant_key, scope, effect) values
|
|
2190
2473
|
${rows.map((row) => ` (${[
|
|
@@ -2203,308 +2486,107 @@ ${rows.map((row) => ` (${[
|
|
|
2203
2486
|
row.grantKey,
|
|
2204
2487
|
row.scope
|
|
2205
2488
|
].map(quoteLiteral).join(", ")})`).join(",\n")}
|
|
2206
|
-
);`;
|
|
2207
|
-
}
|
|
2208
|
-
/**
|
|
2209
|
-
* `role_permissions`, its seeds, and the three `security definer` helpers
|
|
2210
|
-
* with `search_path = ''`. `execute` goes to `authenticated` only.
|
|
2211
|
-
*/
|
|
2212
|
-
function helpersSql(ctx, rows, options) {
|
|
2213
|
-
const schema = helperSchema(ctx);
|
|
2214
|
-
const s = quoteIdent(schema);
|
|
2215
|
-
const rp = qualified$1(ctx, "role_permissions");
|
|
2216
|
-
const chunks = [`-- permdock helpers (${ctx.authorize === "database" ? "database: reads the membership and user_roles tables" : "jwt: reads the role and memberships claims"})
|
|
2217
|
-
-- policies call them uncorrelated, so Postgres evaluates each once per statement`];
|
|
2218
|
-
if (schema !== "public") chunks.push(`create schema if not exists ${s};\nrevoke all on schema ${s} from public;\ngrant usage on schema ${s} to ${options.anonExecute === true ? "anon, authenticated" : "authenticated"};`);
|
|
2219
|
-
if (ctx.dialect === "supabase") chunks.push(userIdSql(ctx, options.anonExecute === true));
|
|
2220
|
-
chunks.push(`create table if not exists ${rp} (
|
|
2221
|
-
role text not null,
|
|
2222
|
-
permission text not null,
|
|
2223
|
-
grant_key text not null,
|
|
2224
|
-
scope text not null check (scope ~ '^[a-z][a-z0-9_]*$'),
|
|
2225
|
-
effect text not null default 'allow' check (effect in ('allow', 'deny')),
|
|
2226
|
-
primary key (role, grant_key, scope)
|
|
2227
|
-
);
|
|
2228
|
-
alter table ${rp} enable row level security;
|
|
2229
|
-
revoke all on table ${rp} from anon, authenticated, public;`);
|
|
2230
|
-
if (options.withoutSeeds !== true) chunks.push(seedSql(ctx, rows));
|
|
2231
|
-
if (ctx.authorize === "database" && options.userRoles && ctx.roles === void 0) {
|
|
2232
|
-
const ur = qualified$1(ctx, "user_roles");
|
|
2233
|
-
chunks.push(`create table if not exists ${ur} (
|
|
2234
|
-
user_id ${userIdType(ctx)},
|
|
2235
|
-
role text not null,
|
|
2236
|
-
primary key (user_id, role)
|
|
2237
|
-
);
|
|
2238
|
-
alter table ${ur} enable row level security;
|
|
2239
|
-
revoke all on table ${ur} from anon, authenticated, public;`);
|
|
2240
|
-
}
|
|
2241
|
-
const custom = customRolesSql(ctx);
|
|
2242
|
-
if (custom !== "") chunks.push(custom);
|
|
2243
|
-
const anon = options.anonExecute === true;
|
|
2244
|
-
const keys = ctx.apiKeys;
|
|
2245
|
-
if (keys !== void 0) chunks.push(apiKeyAllowsSql(ctx, keys, anon));
|
|
2246
|
-
const bodies = grantBodies(ctx);
|
|
2247
|
-
chunks.push(helperFunction(ctx, HELPERS.has, "boolean", bodies.has, anon));
|
|
2248
|
-
const capabilities = capabilitiesSql(ctx);
|
|
2249
|
-
if (capabilities !== "") chunks.push(capabilities);
|
|
2250
|
-
for (const scope of ctx.scopes) {
|
|
2251
|
-
const type = scopeTypeOf(ctx, scope.name);
|
|
2252
|
-
chunks.push(helperFunction(ctx, permittedIdsHelper(scope.name), `setof ${type}`, bodies.ids(scope.name), anon));
|
|
2253
|
-
chunks.push(memberFunction(ctx, scope.name, type, anon));
|
|
2254
|
-
if (hasMemberFor(ctx, scope.name)) chunks.push(memberForFunction(ctx, scope.name, type));
|
|
2255
|
-
}
|
|
2256
|
-
const forUser = forUserSql(ctx);
|
|
2257
|
-
if (forUser !== "") chunks.push(forUser);
|
|
2258
|
-
const writes = customRoleWritesSql(ctx, options.levelReach ?? []);
|
|
2259
|
-
if (writes !== "") {
|
|
2260
|
-
chunks.push(writes);
|
|
2261
|
-
const cascade = customRoleCascadeSql(ctx);
|
|
2262
|
-
if (cascade !== "") chunks.push(cascade);
|
|
2263
|
-
}
|
|
2264
|
-
const globalRoles = globalRolesSql(ctx);
|
|
2265
|
-
if (globalRoles !== "") chunks.push(globalRoles);
|
|
2266
|
-
const readers = roleReadersSql(ctx, options.permissions, options.trustedReaders);
|
|
2267
|
-
if (readers !== "") chunks.push(readers);
|
|
2268
|
-
return `${chunks.join("\n\n")}\n`;
|
|
2269
|
-
}
|
|
2270
|
-
function grantBodies(ctx) {
|
|
2271
|
-
const keys = ctx.apiKeys;
|
|
2272
|
-
const set = ctx.grantSet === true;
|
|
2273
|
-
const allows = apiKeyAllowsCall(ctx, set ? "ids.grant_key" : "p_grant");
|
|
2274
|
-
const keyTenant = keyTenantSql(ctx, void 0) ?? "true";
|
|
2275
|
-
return {
|
|
2276
|
-
has: sqlBody(keys === void 0 ? hasBody(ctx) : set ? ` select g.grant_key
|
|
2277
|
-
from (
|
|
2278
|
-
${hasBody(ctx)}
|
|
2279
|
-
) g(grant_key)
|
|
2280
|
-
where ${apiKeyAllowsCall(ctx, "g.grant_key")} and ${keyTenant}` : ceilingHasSql(hasBody(ctx), `${allows} and ${keyTenant}`)),
|
|
2281
|
-
ids: (scope) => {
|
|
2282
|
-
const type = scopeTypeOf(ctx, scope);
|
|
2283
|
-
const body = scopedBody(ctx, scope, type);
|
|
2284
|
-
if (keys === void 0) return body;
|
|
2285
|
-
const sql = scope === rootName(ctx) ? `${body.sql}\n union\n${serviceKeyIdsSql(ctx, keys, scope, type)}` : body.sql;
|
|
2286
|
-
return {
|
|
2287
|
-
sql: set ? ` select ids.id, ids.grant_key
|
|
2288
|
-
from (
|
|
2289
|
-
${sql}
|
|
2290
|
-
) ids(id, grant_key)
|
|
2291
|
-
where ${allows}` : ceilingIdsSql(sql, allows),
|
|
2292
|
-
vars: body.vars
|
|
2293
|
-
};
|
|
2294
|
-
}
|
|
2295
|
-
};
|
|
2296
|
-
}
|
|
2297
|
-
function forUserContext(ctx) {
|
|
2298
|
-
const { apiKeys: _apiKeys, ...unkeyed } = ctx;
|
|
2299
|
-
return {
|
|
2300
|
-
...unkeyed,
|
|
2301
|
-
subjectId: "p_user",
|
|
2302
|
-
tenants: "all"
|
|
2303
|
-
};
|
|
2304
|
-
}
|
|
2305
|
-
//#endregion
|
|
2306
|
-
//#region src/cli/pg.ts
|
|
2307
|
-
const CONNECT_TIMEOUT_MS = 1e4;
|
|
2308
|
-
const STATEMENT_TIMEOUT_MS = 6e4;
|
|
2309
|
-
function loadPg(command) {
|
|
2310
|
-
return requirePeer(() => import("pg"), "pg", command);
|
|
2311
|
-
}
|
|
2312
|
-
function options(db) {
|
|
2313
|
-
return {
|
|
2314
|
-
connectionString: db,
|
|
2315
|
-
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
|
|
2316
|
-
statement_timeout: STATEMENT_TIMEOUT_MS
|
|
2317
|
-
};
|
|
2318
|
-
}
|
|
2319
|
-
/** Ctrl-C ends the connection, so a query that hangs cannot keep the process alive. */
|
|
2320
|
-
function endOnInterrupt(end) {
|
|
2321
|
-
const interrupt = () => {
|
|
2322
|
-
end().catch(() => void 0);
|
|
2323
|
-
process.exitCode = 130;
|
|
2324
|
-
};
|
|
2325
|
-
process.once("SIGINT", interrupt);
|
|
2326
|
-
return async () => {
|
|
2327
|
-
process.off("SIGINT", interrupt);
|
|
2328
|
-
await end();
|
|
2329
|
-
};
|
|
2330
|
-
}
|
|
2331
|
-
function unreachable(command, cause) {
|
|
2332
|
-
return new CliError("unavailable", `PermDock CLI: ${command.replace(/^permdock /u, "")} could not connect`, { cause });
|
|
2333
|
-
}
|
|
2334
|
-
/** One connection, for commands that run a transaction. */
|
|
2335
|
-
async function connectPg(db, command) {
|
|
2336
|
-
const client = new (await (loadPg(command))).Client(options(db));
|
|
2337
|
-
try {
|
|
2338
|
-
await client.connect();
|
|
2339
|
-
} catch (cause) {
|
|
2340
|
-
throw unreachable(command, cause);
|
|
2341
|
-
}
|
|
2342
|
-
return {
|
|
2343
|
-
query: (sql, values) => client.query(sql, values),
|
|
2344
|
-
end: endOnInterrupt(() => client.end())
|
|
2345
|
-
};
|
|
2346
|
-
}
|
|
2347
|
-
/** A small pool, for independent catalog reads that can run in parallel. */
|
|
2348
|
-
async function connectPgPool(db, command) {
|
|
2349
|
-
const pool = new (await (loadPg(command))).Pool({
|
|
2350
|
-
...options(db),
|
|
2351
|
-
max: 4
|
|
2352
|
-
});
|
|
2353
|
-
try {
|
|
2354
|
-
(await pool.connect()).release();
|
|
2355
|
-
} catch (cause) {
|
|
2356
|
-
await pool.end();
|
|
2357
|
-
throw unreachable(command, cause);
|
|
2358
|
-
}
|
|
2359
|
-
return {
|
|
2360
|
-
query: (sql, values) => pool.query(sql, values),
|
|
2361
|
-
end: endOnInterrupt(() => pool.end())
|
|
2362
|
-
};
|
|
2363
|
-
}
|
|
2364
|
-
//#endregion
|
|
2365
|
-
//#region src/cli/rls-indexes.ts
|
|
2366
|
-
/** Postgres truncates identifiers longer than this, in bytes. */
|
|
2367
|
-
const MAX_IDENT = 63;
|
|
2368
|
-
/**
|
|
2369
|
-
* The row fields `condition` compares with the subject, a claim or a
|
|
2370
|
-
* membership (`ownerId: principal.id`, `memberOf`), through `and` and `or`:
|
|
2371
|
-
* the selective comparisons an index serves. A comparison with a constant
|
|
2372
|
-
* (`status: 'sent'`) narrows rows the scope key already found, a negation
|
|
2373
|
-
* scans anyway, and a dotted field is a JSON path a plain index does not serve.
|
|
2374
|
-
*/
|
|
2375
|
-
function indexedFields(condition) {
|
|
2376
|
-
if (condition === void 0) return [];
|
|
2377
|
-
switch (condition.op) {
|
|
2378
|
-
case "and":
|
|
2379
|
-
case "or": return condition.conditions.flatMap(indexedFields);
|
|
2380
|
-
case "memberOf": return plainField(condition.field);
|
|
2381
|
-
case "eq":
|
|
2382
|
-
case "in": return isConditionRef(condition.value) ? plainField(condition.field) : [];
|
|
2383
|
-
case "not":
|
|
2384
|
-
case "sqlFunction":
|
|
2385
|
-
case "opaque":
|
|
2386
|
-
case "related":
|
|
2387
|
-
case "ne":
|
|
2388
|
-
case "gt":
|
|
2389
|
-
case "gte":
|
|
2390
|
-
case "lt":
|
|
2391
|
-
case "lte":
|
|
2392
|
-
case "contains":
|
|
2393
|
-
case "notIn":
|
|
2394
|
-
case "isNull":
|
|
2395
|
-
case "liveSession": return [];
|
|
2396
|
-
default: return condition;
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
2399
|
-
function plainField(field) {
|
|
2400
|
-
return field.includes(".") ? [] : [field];
|
|
2489
|
+
);`;
|
|
2401
2490
|
}
|
|
2402
2491
|
/**
|
|
2403
|
-
*
|
|
2404
|
-
*
|
|
2405
|
-
* tables' user column, followed by the scope column the helpers select.
|
|
2492
|
+
* `role_permissions`, its seeds, and the three `security definer` helpers
|
|
2493
|
+
* with `search_path = ''`. `execute` goes to `authenticated` only.
|
|
2406
2494
|
*/
|
|
2407
|
-
function
|
|
2408
|
-
const
|
|
2409
|
-
const
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
}
|
|
2416
|
-
|
|
2495
|
+
function helpersSql(ctx, rows, options) {
|
|
2496
|
+
const schema = helperSchema(ctx);
|
|
2497
|
+
const s = quoteIdent(schema);
|
|
2498
|
+
const rp = qualified(ctx, "role_permissions");
|
|
2499
|
+
const chunks = [`-- permdock helpers (${ctx.authorize === "database" ? "database: reads the membership and user_roles tables" : "jwt: reads the role and memberships claims"})
|
|
2500
|
+
-- policies call them uncorrelated, so Postgres evaluates each once per statement`];
|
|
2501
|
+
if (schema !== "public") chunks.push(`create schema if not exists ${s};\nrevoke all on schema ${s} from public;\ngrant usage on schema ${s} to ${options.anonExecute === true ? "anon, authenticated" : "authenticated"};`);
|
|
2502
|
+
if (ctx.dialect === "supabase") chunks.push(userIdSql(ctx, options.anonExecute === true));
|
|
2503
|
+
chunks.push(`create table if not exists ${rp} (
|
|
2504
|
+
role text not null,
|
|
2505
|
+
permission text not null,
|
|
2506
|
+
grant_key text not null,
|
|
2507
|
+
scope text not null check (scope ~ '^[a-z][a-z0-9_]*$'),
|
|
2508
|
+
effect text not null default 'allow' check (effect in ('allow', 'deny')),
|
|
2509
|
+
primary key (role, grant_key, scope)
|
|
2510
|
+
);
|
|
2511
|
+
alter table ${rp} enable row level security;
|
|
2512
|
+
revoke all on table ${rp} from anon, authenticated, public;`);
|
|
2513
|
+
if (options.withoutSeeds !== true) chunks.push(seedSql(ctx, rows));
|
|
2514
|
+
if (ctx.authorize === "database" && options.userRoles && ctx.roles === void 0) {
|
|
2515
|
+
const ur = qualified(ctx, "user_roles");
|
|
2516
|
+
chunks.push(`create table if not exists ${ur} (
|
|
2517
|
+
user_id ${userIdType(ctx)},
|
|
2518
|
+
role text not null,
|
|
2519
|
+
primary key (user_id, role)
|
|
2520
|
+
);
|
|
2521
|
+
alter table ${ur} enable row level security;
|
|
2522
|
+
revoke all on table ${ur} from anon, authenticated, public;`);
|
|
2523
|
+
}
|
|
2524
|
+
const custom = customRolesSql(ctx);
|
|
2525
|
+
if (custom !== "") chunks.push(custom);
|
|
2526
|
+
const anon = options.anonExecute === true;
|
|
2527
|
+
const keys = ctx.apiKeys;
|
|
2528
|
+
if (keys !== void 0) chunks.push(apiKeyAllowsSql(ctx, keys, anon));
|
|
2529
|
+
const bodies = grantBodies(ctx);
|
|
2530
|
+
chunks.push(helperFunction(ctx, HELPERS.has, "boolean", bodies.has, anon));
|
|
2531
|
+
const capabilities = capabilitiesSql(ctx);
|
|
2532
|
+
if (capabilities !== "") chunks.push(capabilities);
|
|
2417
2533
|
for (const scope of ctx.scopes) {
|
|
2418
|
-
const
|
|
2419
|
-
|
|
2534
|
+
const type = scopeTypeOf(ctx, scope.name);
|
|
2535
|
+
chunks.push(helperFunction(ctx, permittedIdsHelper(scope.name), `setof ${type}`, bodies.ids(scope.name), anon));
|
|
2536
|
+
chunks.push(memberFunction(ctx, scope.name, type, anon));
|
|
2537
|
+
if (hasMemberFor(ctx, scope.name)) chunks.push(memberForFunction(ctx, scope.name, type));
|
|
2420
2538
|
}
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2539
|
+
const forUser = forUserSql(ctx);
|
|
2540
|
+
if (forUser !== "") chunks.push(forUser);
|
|
2541
|
+
const writes = customRoleWritesSql(ctx, options.levelReach ?? []);
|
|
2542
|
+
if (writes !== "") {
|
|
2543
|
+
chunks.push(writes);
|
|
2544
|
+
const cascade = customRoleCascadeSql(ctx);
|
|
2545
|
+
if (cascade !== "") chunks.push(cascade);
|
|
2425
2546
|
}
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
const
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
)
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
where i.indpred is null and n.nspname || '.' || c.relname = any($1::text[])`;
|
|
2457
|
-
function strings(value) {
|
|
2458
|
-
return Array.isArray(value) ? value.map((item) => typeof item === "string" ? item : null) : [];
|
|
2459
|
-
}
|
|
2460
|
-
/** Reads the columns and index key columns of `tables` (`schema.table`). */
|
|
2461
|
-
async function readTableIndexFacts(db, tables, connect = (url) => connectPgPool(url, "permdock rls generate --db")) {
|
|
2462
|
-
const client = await connect(db);
|
|
2463
|
-
try {
|
|
2464
|
-
const [columns, indexes] = await Promise.all([client.query(COLUMNS_SQL, [[...tables]]), client.query(INDEX_COLUMNS_SQL, [[...tables]])]);
|
|
2465
|
-
const byTable = {};
|
|
2466
|
-
for (const row of columns.rows) (byTable[String(row["target"])] ??= []).push(String(row["name"]));
|
|
2467
|
-
const keys = {};
|
|
2468
|
-
for (const row of indexes.rows) {
|
|
2469
|
-
const list = strings(row["columns"]);
|
|
2470
|
-
const end = list.indexOf(null);
|
|
2471
|
-
const lead = end === -1 ? list : list.slice(0, end);
|
|
2472
|
-
if (lead.length > 0) (keys[String(row["target"])] ??= []).push(lead);
|
|
2547
|
+
const globalRoles = globalRolesSql(ctx);
|
|
2548
|
+
if (globalRoles !== "") chunks.push(globalRoles);
|
|
2549
|
+
const readers = roleReadersSql(ctx, options.permissions, options.trustedReaders);
|
|
2550
|
+
if (readers !== "") chunks.push(readers);
|
|
2551
|
+
return `${chunks.join("\n\n")}\n`;
|
|
2552
|
+
}
|
|
2553
|
+
function grantBodies(ctx) {
|
|
2554
|
+
const keys = ctx.apiKeys;
|
|
2555
|
+
const set = ctx.grantSet === true;
|
|
2556
|
+
const allows = apiKeyAllowsCall(ctx, set ? "ids.grant_key" : "p_grant");
|
|
2557
|
+
const keyTenant = keyTenantSql(ctx, void 0) ?? "true";
|
|
2558
|
+
return {
|
|
2559
|
+
has: sqlBody(keys === void 0 ? hasBody(ctx) : set ? ` select g.grant_key
|
|
2560
|
+
from (
|
|
2561
|
+
${hasBody(ctx)}
|
|
2562
|
+
) g(grant_key)
|
|
2563
|
+
where ${apiKeyAllowsCall(ctx, "g.grant_key")} and ${keyTenant}` : ceilingHasSql(hasBody(ctx), `${allows} and ${keyTenant}`)),
|
|
2564
|
+
ids: (scope) => {
|
|
2565
|
+
const type = scopeTypeOf(ctx, scope);
|
|
2566
|
+
const body = scopedBody(ctx, scope, type);
|
|
2567
|
+
if (keys === void 0) return body;
|
|
2568
|
+
const sql = scope === rootName(ctx) ? `${body.sql}\n union\n${serviceKeyIdsSql(ctx, keys, scope, type)}` : body.sql;
|
|
2569
|
+
return {
|
|
2570
|
+
sql: set ? ` select ids.id, ids.grant_key
|
|
2571
|
+
from (
|
|
2572
|
+
${sql}
|
|
2573
|
+
) ids(id, grant_key)
|
|
2574
|
+
where ${allows}` : ceilingIdsSql(sql, allows),
|
|
2575
|
+
vars: body.vars
|
|
2576
|
+
};
|
|
2473
2577
|
}
|
|
2474
|
-
|
|
2475
|
-
columns: byTable,
|
|
2476
|
-
indexes: keys
|
|
2477
|
-
};
|
|
2478
|
-
} finally {
|
|
2479
|
-
await client.end();
|
|
2480
|
-
}
|
|
2578
|
+
};
|
|
2481
2579
|
}
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
* table or column the database lacks (with a warning), and one an existing
|
|
2485
|
-
* index leads with (its key columns start with the target's).
|
|
2486
|
-
*/
|
|
2487
|
-
function pruneIndexTargets(targets, facts) {
|
|
2488
|
-
const warnings = [];
|
|
2580
|
+
function forUserContext(ctx) {
|
|
2581
|
+
const { apiKeys: _apiKeys, ...unkeyed } = ctx;
|
|
2489
2582
|
return {
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
warnings.push(`no index suggested on ${target.table}: the database has no such table`);
|
|
2494
|
-
return false;
|
|
2495
|
-
}
|
|
2496
|
-
const absent = target.columns.filter((column) => !columns.includes(column));
|
|
2497
|
-
if (absent.length > 0) {
|
|
2498
|
-
warnings.push(`no index suggested on ${target.table} (${target.columns.join(", ")}): the table has no column ${absent.join(", ")}`);
|
|
2499
|
-
return false;
|
|
2500
|
-
}
|
|
2501
|
-
return !(facts.indexes[target.table] ?? []).some((index) => target.columns.every((column, at) => index[at] === column));
|
|
2502
|
-
}),
|
|
2503
|
-
warnings
|
|
2583
|
+
...unkeyed,
|
|
2584
|
+
subjectId: "p_user",
|
|
2585
|
+
tenants: "all"
|
|
2504
2586
|
};
|
|
2505
2587
|
}
|
|
2506
2588
|
//#endregion
|
|
2507
|
-
//#region src/cli/rls
|
|
2589
|
+
//#region src/cli/rls/permission-keys.ts
|
|
2508
2590
|
/** The helpers that take a permission key instead of a grant key. Names are part of the SQL contract. */
|
|
2509
2591
|
const PERMISSION_HELPERS = {
|
|
2510
2592
|
grantKeys: "grant_keys",
|
|
@@ -2531,9 +2613,9 @@ function permittedByPermissionHelper(scope) {
|
|
|
2531
2613
|
*/
|
|
2532
2614
|
function requiresSql(ctx, key, resource) {
|
|
2533
2615
|
const literal = quoteLiteral(key);
|
|
2534
|
-
return `(${[`(select ${qualified
|
|
2616
|
+
return `(${[`(select ${qualified(ctx, PERMISSION_HELPERS.has)}(${literal}))`, ...(resource === void 0 ? [] : partitionsOf(resource, ctx.scopes)).map((scope) => {
|
|
2535
2617
|
const field = scopeField(resource, scope, ctx.scopes) ?? scope;
|
|
2536
|
-
return `${quoteIdent(field)} in (select ${qualified
|
|
2618
|
+
return `${quoteIdent(field)} in (select ${qualified(ctx, checkName(permittedByPermissionHelper(scope)))}(${literal}))`;
|
|
2537
2619
|
})].join(" or ")})`;
|
|
2538
2620
|
}
|
|
2539
2621
|
/** Postgres truncates an identifier past 63 bytes, which would merge two helpers. */
|
|
@@ -2542,7 +2624,7 @@ function checkName(name) {
|
|
|
2542
2624
|
return name;
|
|
2543
2625
|
}
|
|
2544
2626
|
function grantsFunction(ctx, grants, renamed, anonExecute) {
|
|
2545
|
-
const fn = qualified
|
|
2627
|
+
const fn = qualified(ctx, PERMISSION_HELPERS.grantKeys);
|
|
2546
2628
|
const map = Object.fromEntries([...grants].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
2547
2629
|
const current = Object.keys(renamed).length === 0 ? "p_permission" : `coalesce(${quoteLiteral(JSON.stringify(renamed))}::jsonb ->> p_permission, p_permission)`;
|
|
2548
2630
|
return `-- the grant keys a permission key reaches on one scope: its unconditional allows, with p_effect 'deny' every deny, and with 'conditioned-allow' or 'conditioned-deny' the allows or denies that carry a row condition
|
|
@@ -2600,7 +2682,7 @@ ${access}`;
|
|
|
2600
2682
|
* caller, as `permittedIds(..., { conditioned: true })` does in process.
|
|
2601
2683
|
*/
|
|
2602
2684
|
function permissionHelpersSql(ctx, grants, renamed, anonExecute) {
|
|
2603
|
-
const keys = qualified
|
|
2685
|
+
const keys = qualified(ctx, PERMISSION_HELPERS.grantKeys);
|
|
2604
2686
|
const allowKeys = (scope) => `${keys}(p_permission, ${quoteLiteral(scope)}) g(grant_key)`;
|
|
2605
2687
|
const denyKeys = (scope) => `${keys}(p_permission, ${quoteLiteral(scope)}, 'deny') g(grant_key)`;
|
|
2606
2688
|
const has = (helper, user) => ` select exists (select 1 from ${allowKeys("global")} where ${helper}(${user}g.grant_key))
|
|
@@ -2676,13 +2758,13 @@ ${idsBody.sql}
|
|
|
2676
2758
|
};
|
|
2677
2759
|
};
|
|
2678
2760
|
const chunks = [grantsFunction(ctx, grants, renamed, anonExecute), `-- the helpers by permission key: unconditional allows minus any deny
|
|
2679
|
-
${definer(qualified
|
|
2680
|
-
${grantsSql(qualified
|
|
2761
|
+
${definer(qualified(ctx, PERMISSION_HELPERS.has), "p_permission text", "boolean", has(qualified(ctx, HELPERS.has), ""))}
|
|
2762
|
+
${grantsSql(qualified(ctx, PERMISSION_HELPERS.has), "text", anonExecute)}`];
|
|
2681
2763
|
for (const scope of ctx.scopes) {
|
|
2682
|
-
const fn = qualified
|
|
2683
|
-
chunks.push(`${definer(fn, "p_permission text", `setof ${scopeTypeOf(ctx, scope.name)}`, ids(qualified
|
|
2764
|
+
const fn = qualified(ctx, checkName(permittedByPermissionHelper(scope.name)));
|
|
2765
|
+
chunks.push(`${definer(fn, "p_permission text", `setof ${scopeTypeOf(ctx, scope.name)}`, ids(qualified(ctx, permittedIdsHelper(scope.name)), scope.name, ""))}
|
|
2684
2766
|
${grantsSql(fn, "text", anonExecute)}`);
|
|
2685
|
-
const keysFn = qualified
|
|
2767
|
+
const keysFn = qualified(ctx, checkName(permissionKeysHelper(scope.name)));
|
|
2686
2768
|
const type = scopeTypeOf(ctx, scope.name);
|
|
2687
2769
|
const caller = grantBodies({
|
|
2688
2770
|
...ctx,
|
|
@@ -2692,23 +2774,23 @@ ${grantsSql(fn, "text", anonExecute)}`);
|
|
|
2692
2774
|
${keysFunction(keysFn, `p_id ${type}`, keysBody(scope.name, caller, false), grantsSql(keysFn, type, anonExecute))}
|
|
2693
2775
|
${keysFunction(keysFn, `p_id ${type}, p_keys text[]`, keysBody(scope.name, caller, true), grantsSql(keysFn, `${type}, text[]`, anonExecute))}`);
|
|
2694
2776
|
chunks.push(`-- with p_conditioned true, also the instances a conditioned allow reaches, minus only unconditional denies: the caller applies the row condition
|
|
2695
|
-
${definer(fn, "p_permission text, p_conditioned boolean", `setof ${scopeTypeOf(ctx, scope.name)}`, conditionedIds(qualified
|
|
2777
|
+
${definer(fn, "p_permission text, p_conditioned boolean", `setof ${scopeTypeOf(ctx, scope.name)}`, conditionedIds(qualified(ctx, permittedIdsHelper(scope.name)), scope.name, ""))}
|
|
2696
2778
|
${grantsSql(fn, "text, boolean", anonExecute)}`);
|
|
2697
2779
|
}
|
|
2698
2780
|
if (ctx.authorize !== "database") return `${chunks.join("\n\n")}\n`;
|
|
2699
2781
|
const user = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
2700
2782
|
const revoke = (fn) => `revoke execute on function ${fn}(${user}, text) from public, anon, authenticated;`;
|
|
2701
|
-
const hasFor = qualified
|
|
2783
|
+
const hasFor = qualified(ctx, PERMISSION_HELPERS.hasFor);
|
|
2702
2784
|
chunks.push(`-- the same for a user the caller names; no client role may execute them
|
|
2703
|
-
${definer(hasFor, `p_user ${user}, p_permission text`, "boolean", has(qualified
|
|
2785
|
+
${definer(hasFor, `p_user ${user}, p_permission text`, "boolean", has(qualified(ctx, HELPERS.hasFor), "p_user, "))}
|
|
2704
2786
|
${revoke(hasFor)}`);
|
|
2705
2787
|
for (const scope of ctx.scopes) {
|
|
2706
|
-
const fn = qualified
|
|
2707
|
-
chunks.push(`${definer(fn, `p_user ${user}, p_permission text`, `setof ${scopeTypeOf(ctx, scope.name)}`, ids(qualified
|
|
2788
|
+
const fn = qualified(ctx, checkName(`${permittedByPermissionHelper(scope.name)}_for`));
|
|
2789
|
+
chunks.push(`${definer(fn, `p_user ${user}, p_permission text`, `setof ${scopeTypeOf(ctx, scope.name)}`, ids(qualified(ctx, permittedForHelper(scope.name)), scope.name, "p_user, "))}
|
|
2708
2790
|
${revoke(fn)}`);
|
|
2709
|
-
chunks.push(`${definer(fn, `p_user ${user}, p_permission text, p_conditioned boolean`, `setof ${scopeTypeOf(ctx, scope.name)}`, conditionedIds(qualified
|
|
2791
|
+
chunks.push(`${definer(fn, `p_user ${user}, p_permission text, p_conditioned boolean`, `setof ${scopeTypeOf(ctx, scope.name)}`, conditionedIds(qualified(ctx, permittedForHelper(scope.name)), scope.name, "p_user, "))}
|
|
2710
2792
|
revoke execute on function ${fn}(${user}, text, boolean) from public, anon, authenticated;`);
|
|
2711
|
-
const keysFor = qualified
|
|
2793
|
+
const keysFor = qualified(ctx, checkName(permissionKeysForHelper(scope.name)));
|
|
2712
2794
|
const type = scopeTypeOf(ctx, scope.name);
|
|
2713
2795
|
const named = grantBodies({
|
|
2714
2796
|
...forUserContext(ctx),
|
|
@@ -2720,863 +2802,4 @@ ${keysFunction(keysFor, `p_user ${user}, p_id ${type}, p_keys text[]`, keysBody(
|
|
|
2720
2802
|
return `${chunks.join("\n\n")}\n`;
|
|
2721
2803
|
}
|
|
2722
2804
|
//#endregion
|
|
2723
|
-
|
|
2724
|
-
/**
|
|
2725
|
-
* The levels an allow reaches: every level without a row condition, else the
|
|
2726
|
-
* levels whose condition equals its condition.
|
|
2727
|
-
*/
|
|
2728
|
-
function reachedLevels(where, levels) {
|
|
2729
|
-
if (where === void 0) return levels.map(([name]) => name);
|
|
2730
|
-
const held = canonicalJson(where);
|
|
2731
|
-
return levels.filter(([, condition]) => canonicalJson(condition) === held).map(([name]) => name);
|
|
2732
|
-
}
|
|
2733
|
-
function levelReachOf(policy, keys) {
|
|
2734
|
-
const reach = /* @__PURE__ */ new Map();
|
|
2735
|
-
for (const [entry, grantKey] of keys) {
|
|
2736
|
-
const { item } = entry;
|
|
2737
|
-
const { grant } = item;
|
|
2738
|
-
if (item.access.kind !== "role" || grant.effect !== "allow" || grant.permission.kind !== "instance" || grant.level !== void 0) continue;
|
|
2739
|
-
const levels = policyLevels(policy, grant.permission.resource);
|
|
2740
|
-
if (levels.length > 0) {
|
|
2741
|
-
const set = reach.get(grantKey) ?? /* @__PURE__ */ new Set();
|
|
2742
|
-
for (const name of reachedLevels(grant.where, levels)) set.add(name);
|
|
2743
|
-
reach.set(grantKey, set);
|
|
2744
|
-
}
|
|
2745
|
-
}
|
|
2746
|
-
const out = [];
|
|
2747
|
-
for (const grantKey of new Set(keys.values())) {
|
|
2748
|
-
const at = grantKey.lastIndexOf("@");
|
|
2749
|
-
const base = at === -1 ? grantKey : grantKey.slice(0, at);
|
|
2750
|
-
const level = at === -1 ? void 0 : grantKey.slice(at + 1);
|
|
2751
|
-
for (const name of reach.get(base) ?? []) if (level === void 0 || level === name) out.push([grantKey, name]);
|
|
2752
|
-
}
|
|
2753
|
-
return out.toSorted(([a, x], [b, y]) => a === b ? x < y ? -1 : 1 : a < b ? -1 : 1);
|
|
2754
|
-
}
|
|
2755
|
-
/** The SQL command `action` compiles to: `rls.actions` first, then the six default verbs. */
|
|
2756
|
-
function commandFor(action, actions) {
|
|
2757
|
-
if (actions !== void 0 && Object.hasOwn(actions, action)) {
|
|
2758
|
-
const configured = actions[action];
|
|
2759
|
-
return configured === "none" ? void 0 : configured;
|
|
2760
|
-
}
|
|
2761
|
-
switch (action) {
|
|
2762
|
-
case "read":
|
|
2763
|
-
case "list":
|
|
2764
|
-
case "get": return "select";
|
|
2765
|
-
case "create": return "insert";
|
|
2766
|
-
case "update": return "update";
|
|
2767
|
-
case "delete": return "delete";
|
|
2768
|
-
default: return;
|
|
2769
|
-
}
|
|
2770
|
-
}
|
|
2771
|
-
function tableFor(resource, tables) {
|
|
2772
|
-
return tables?.[resource] ?? resource;
|
|
2773
|
-
}
|
|
2774
|
-
function ancestorsOf(policy, name) {
|
|
2775
|
-
const names = [];
|
|
2776
|
-
let current = policy.resources.get(name);
|
|
2777
|
-
while (current?.parent !== void 0 && !names.includes(current.parent.resource)) {
|
|
2778
|
-
names.push(current.parent.resource);
|
|
2779
|
-
current = policy.resources.get(current.parent.resource);
|
|
2780
|
-
}
|
|
2781
|
-
return names;
|
|
2782
|
-
}
|
|
2783
|
-
function membershipField(policy, resource, holder) {
|
|
2784
|
-
if (resource === void 0) return;
|
|
2785
|
-
if (resource.name === holder) return resource.id ?? "id";
|
|
2786
|
-
let current = resource;
|
|
2787
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2788
|
-
while (current?.parent !== void 0 && !seen.has(current.name)) {
|
|
2789
|
-
seen.add(current.name);
|
|
2790
|
-
if (current.parent.resource === holder) return current.parent.field;
|
|
2791
|
-
current = policy.resources.get(current.parent.resource);
|
|
2792
|
-
}
|
|
2793
|
-
}
|
|
2794
|
-
function resourceCondition(item, policy, ctx) {
|
|
2795
|
-
if (item.access.kind !== "resource") throw new Error("PermDock CLI: resourceCondition needs a resource role");
|
|
2796
|
-
const { role, resource: roleResource } = item.access;
|
|
2797
|
-
const target = policy.resources.get(item.grant.permission.resource);
|
|
2798
|
-
const holders = [roleResource, ...ancestorsOf(policy, roleResource)];
|
|
2799
|
-
const hops = [];
|
|
2800
|
-
for (const [index, holder] of holders.entries()) {
|
|
2801
|
-
const field = membershipField(policy, target, holder);
|
|
2802
|
-
if (field === void 0) continue;
|
|
2803
|
-
if (index > 0 && ctx.memberships?.resource?.[holder] === void 0) continue;
|
|
2804
|
-
hops.push({
|
|
2805
|
-
op: "memberOf",
|
|
2806
|
-
scope: "resource",
|
|
2807
|
-
field,
|
|
2808
|
-
roles: [role],
|
|
2809
|
-
resource: holder
|
|
2810
|
-
});
|
|
2811
|
-
}
|
|
2812
|
-
return sole(hops) ?? {
|
|
2813
|
-
op: "or",
|
|
2814
|
-
conditions: hops
|
|
2815
|
-
};
|
|
2816
|
-
}
|
|
2817
|
-
/**
|
|
2818
|
-
* The access a link capability has to a resource-scoped grant, over the same
|
|
2819
|
-
* holders as `resourceCondition`. It reads the claim, so ancestor hops need
|
|
2820
|
-
* no memberships table. `undefined` when no holder is on the row's chain.
|
|
2821
|
-
*/
|
|
2822
|
-
function capabilityAccess(item, policy, ctx) {
|
|
2823
|
-
if (item.access.kind !== "resource") return;
|
|
2824
|
-
const { role, resource: roleResource } = item.access;
|
|
2825
|
-
const kinds = ctx.ownership?.kinds[role];
|
|
2826
|
-
if (kinds !== void 0 && !kinds.includes("link")) return;
|
|
2827
|
-
const target = policy.resources.get(item.grant.permission.resource);
|
|
2828
|
-
const hops = [];
|
|
2829
|
-
for (const holder of [roleResource, ...ancestorsOf(policy, roleResource)]) {
|
|
2830
|
-
const field = membershipField(policy, target, holder);
|
|
2831
|
-
if (field !== void 0) hops.push(capabilityAccessSql(ctx, field, holder, role, item.grant.permission.key));
|
|
2832
|
-
}
|
|
2833
|
-
if (hops.length === 0) return;
|
|
2834
|
-
return hops.length === 1 ? hops[0] : hops.map(wrapSql).join(" or ");
|
|
2835
|
-
}
|
|
2836
|
-
function prepare(item, tables, warnings, skipClosures, actions, rowActions = false) {
|
|
2837
|
-
const { grant, label } = item;
|
|
2838
|
-
if (grant.breakGlass !== void 0) {
|
|
2839
|
-
warnings.push(`break-glass grant ${label}/${grant.permission.key} reads through permdock_break_glass_${grant.permission.resource}, not a policy`);
|
|
2840
|
-
return;
|
|
2841
|
-
}
|
|
2842
|
-
if (grant.closure !== void 0 || grant.portable === false) {
|
|
2843
|
-
if (skipClosures) {
|
|
2844
|
-
warnings.push(`skipped non-portable grant ${label}/${grant.permission.key}`);
|
|
2845
|
-
return;
|
|
2846
|
-
}
|
|
2847
|
-
throw new Error(`PermDock CLI: closure grant ${label}/${grant.permission.key} is not portable; rewrite it or pass --skip-closures`);
|
|
2848
|
-
}
|
|
2849
|
-
const context = [.../* @__PURE__ */ new Set([...contextRefs(item.where), ...contextRefs(grant.check)])];
|
|
2850
|
-
if (context.length > 0) {
|
|
2851
|
-
if (skipClosures) {
|
|
2852
|
-
warnings.push(`skipped grant ${label}/${grant.permission.key}: it reads ${context.join(", ")}, which is not in the token`);
|
|
2853
|
-
return;
|
|
2854
|
-
}
|
|
2855
|
-
throw new Error(`PermDock CLI: grant ${label}/${grant.permission.key} reads ${context.join(", ")}; request context is not in the token, so RLS cannot compile it. Move the value to a server-set claim (principal.claims.*) or pass --skip-closures (permdock doctor PD027)`);
|
|
2856
|
-
}
|
|
2857
|
-
if (requiresApproval(grant.approval)) {
|
|
2858
|
-
warnings.push(`skipped approval grant ${label}/${grant.permission.key}`);
|
|
2859
|
-
return;
|
|
2860
|
-
}
|
|
2861
|
-
const command = commandFor(grant.permission.action, actions);
|
|
2862
|
-
const table = tableFor(grant.permission.resource, tables);
|
|
2863
|
-
if (command === void 0) {
|
|
2864
|
-
if (item.access.kind !== "role" && rowActions) {
|
|
2865
|
-
warnings.push(`no policy for ${label}/${grant.permission.key}: action '${grant.permission.action}' has no SQL command (rls.actions); only permitted_<resource>_rows answers it`);
|
|
2866
|
-
return {
|
|
2867
|
-
item,
|
|
2868
|
-
command: "none",
|
|
2869
|
-
table,
|
|
2870
|
-
...item.where === void 0 ? {} : { using: item.where }
|
|
2871
|
-
};
|
|
2872
|
-
}
|
|
2873
|
-
if (item.access.kind !== "role") {
|
|
2874
|
-
warnings.push(`skipped ${label}/${grant.permission.key}: action '${grant.permission.action}' has no SQL command (rls.actions) and only role grants are seeded`);
|
|
2875
|
-
return;
|
|
2876
|
-
}
|
|
2877
|
-
if (grant.validity !== void 0 && grant.effect === "allow") {
|
|
2878
|
-
warnings.push(`skipped ${label}/${grant.permission.key}: a role_permissions row cannot carry validFrom / validUntil, so a time-bounded grant on an action with no SQL command is not seeded`);
|
|
2879
|
-
return;
|
|
2880
|
-
}
|
|
2881
|
-
warnings.push(`no policy for ${label}/${grant.permission.key}: action '${grant.permission.action}' has no SQL command (rls.actions); seeded for permdock_has and permitted_<scope>_ids`);
|
|
2882
|
-
return {
|
|
2883
|
-
item,
|
|
2884
|
-
command: "none",
|
|
2885
|
-
table,
|
|
2886
|
-
...item.where === void 0 ? {} : { using: item.where },
|
|
2887
|
-
...grant.check === void 0 ? {} : { check: grant.check }
|
|
2888
|
-
};
|
|
2889
|
-
}
|
|
2890
|
-
const using = command === "insert" ? void 0 : item.where;
|
|
2891
|
-
const check = command === "insert" ? grant.check ?? item.where : command === "update" ? grant.check ?? item.where : void 0;
|
|
2892
|
-
return {
|
|
2893
|
-
item,
|
|
2894
|
-
command,
|
|
2895
|
-
table,
|
|
2896
|
-
...using === void 0 ? {} : { using },
|
|
2897
|
-
...check === void 0 ? {} : { check }
|
|
2898
|
-
};
|
|
2899
|
-
}
|
|
2900
|
-
function signature(entry, byFields) {
|
|
2901
|
-
const base = [
|
|
2902
|
-
entry.item.grant.effect,
|
|
2903
|
-
entry.using ?? null,
|
|
2904
|
-
entry.check ?? null
|
|
2905
|
-
];
|
|
2906
|
-
return JSON.stringify(byFields ? [...base, entry.item.grant.fields ?? null] : base);
|
|
2907
|
-
}
|
|
2908
|
-
/**
|
|
2909
|
-
* Grant keys per permission: the key is the permission, split into
|
|
2910
|
-
* `permission#n` when role grants carry different portable conditions (or
|
|
2911
|
-
* effects), one key per condition group; a group whose grants set `group`
|
|
2912
|
-
* is `permission#<group>` instead. With field views, grants that differ
|
|
2913
|
-
* only in `fields` get their own keys too, so a view can tell them apart.
|
|
2914
|
-
*/
|
|
2915
|
-
function assignKeys(entries, byFields) {
|
|
2916
|
-
const groups = /* @__PURE__ */ new Map();
|
|
2917
|
-
for (const entry of entries) {
|
|
2918
|
-
if (entry.item.access.kind !== "role") continue;
|
|
2919
|
-
const key = entry.item.grant.permission.key;
|
|
2920
|
-
const byCondition = groups.get(key) ?? /* @__PURE__ */ new Map();
|
|
2921
|
-
const sig = signature(entry, byFields);
|
|
2922
|
-
byCondition.set(sig, [...byCondition.get(sig) ?? [], entry]);
|
|
2923
|
-
groups.set(key, byCondition);
|
|
2924
|
-
}
|
|
2925
|
-
const keys = /* @__PURE__ */ new Map();
|
|
2926
|
-
for (const [permission, byCondition] of groups) {
|
|
2927
|
-
const lists = [...byCondition.values()];
|
|
2928
|
-
const named = /* @__PURE__ */ new Set();
|
|
2929
|
-
for (const [index, list] of lists.entries()) {
|
|
2930
|
-
const group = groupOf(permission, list);
|
|
2931
|
-
if (group !== void 0 && named.has(group)) throw new Error(`PermDock CLI: grants of ${permission} with different conditions share group '${group}'; a group names one condition`);
|
|
2932
|
-
if (group !== void 0) named.add(group);
|
|
2933
|
-
const grantKey = group !== void 0 ? `${permission}#${group}` : lists.length === 1 ? permission : `${permission}#${index + 1}`;
|
|
2934
|
-
for (const entry of list) keys.set(entry, grantKey);
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
2937
|
-
return keys;
|
|
2938
|
-
}
|
|
2939
|
-
function groupOf(permission, list) {
|
|
2940
|
-
const names = [...new Set(list.flatMap((entry) => entry.item.grant.group === void 0 ? [] : [entry.item.grant.group]))].toSorted();
|
|
2941
|
-
if (names.length > 1) throw new Error(`PermDock CLI: grants of ${permission} with one condition name the groups ${names.join(", ")}; give them one group`);
|
|
2942
|
-
return names[0];
|
|
2943
|
-
}
|
|
2944
|
-
/**
|
|
2945
|
-
* One entry per level for every allow of an assignable role on a resource
|
|
2946
|
-
* with levels, keyed `<grant key>@<level>`. Only `permdock_custom_keys`
|
|
2947
|
-
* hands these keys out, to a custom role whose stored allow names the level.
|
|
2948
|
-
*/
|
|
2949
|
-
function levelEntries(policy, ctx, keys, tables, warnings, skipClosures) {
|
|
2950
|
-
const custom = ctx.customRoles;
|
|
2951
|
-
if (custom?.levels !== true) return [];
|
|
2952
|
-
const assignable = new Set(custom.assignable);
|
|
2953
|
-
const out = [];
|
|
2954
|
-
for (const [entry, grantKey] of Array.from(keys)) {
|
|
2955
|
-
const { item } = entry;
|
|
2956
|
-
const { grant } = item;
|
|
2957
|
-
if (item.access.kind !== "role" || grant.effect !== "allow" || grant.permission.kind !== "instance" || !assignable.has(item.access.role)) continue;
|
|
2958
|
-
if (grant.permission.key.includes("@")) throw new Error(`PermDock CLI: permission key '${grant.permission.key}' holds '@', which separates a level in custom-role grant keys`);
|
|
2959
|
-
for (const [level, condition] of policyLevels(policy, grant.permission.resource)) {
|
|
2960
|
-
const where = andWhere(item.where, condition);
|
|
2961
|
-
const prepared = prepare({
|
|
2962
|
-
...item,
|
|
2963
|
-
grant: leveled(grant, level, condition),
|
|
2964
|
-
...where === void 0 ? {} : { where }
|
|
2965
|
-
}, tables, warnings, skipClosures, ctx.actions);
|
|
2966
|
-
if (prepared !== void 0) {
|
|
2967
|
-
keys.set(prepared, `${grantKey}@${level}`);
|
|
2968
|
-
out.push(prepared);
|
|
2969
|
-
}
|
|
2970
|
-
}
|
|
2971
|
-
}
|
|
2972
|
-
return out;
|
|
2973
|
-
}
|
|
2974
|
-
function compileOptional(condition, ctx) {
|
|
2975
|
-
return condition === void 0 ? void 0 : compileConditionSql(condition, ctx);
|
|
2976
|
-
}
|
|
2977
|
-
function noteConditions(entry, warnings) {
|
|
2978
|
-
const { label, grant } = entry.item;
|
|
2979
|
-
const names = [...sqlFunctionNames(entry.using), ...sqlFunctionNames(entry.check)];
|
|
2980
|
-
if (names.length > 0) warnings.push(`sqlFunction ${[...new Set(names)].join(", ")} on ${label}/${grant.permission.key} is portable via twin`);
|
|
2981
|
-
if (hasConditionOp(entry.using, "opaque") || hasConditionOp(entry.check, "opaque")) warnings.push(`opaque SQL on ${label}/${grant.permission.key} is untestable app-side`);
|
|
2982
|
-
}
|
|
2983
|
-
/**
|
|
2984
|
-
* Compiles every grant (role and top-level) to branches whose role check is
|
|
2985
|
-
* a helper call keyed by grant key. Nothing in a branch is per-row except the
|
|
2986
|
-
* portable row condition and resource-membership joins.
|
|
2987
|
-
*/
|
|
2988
|
-
/** A Supabase token from a permanent user: anonymous sign-ins carry `is_anonymous: true`. */
|
|
2989
|
-
const PERMANENT_USER = `((select auth.jwt()) ->> 'is_anonymous') is distinct from 'true'`;
|
|
2990
|
-
function compileGrants(policy, ctx, tables, warnings, skipClosures, rowActions = false) {
|
|
2991
|
-
const items = collectGrants(policy);
|
|
2992
|
-
const entries = items.flatMap((item) => {
|
|
2993
|
-
const entry = prepare(item, tables, warnings, skipClosures, ctx.actions, rowActions);
|
|
2994
|
-
return entry === void 0 ? [] : [entry];
|
|
2995
|
-
});
|
|
2996
|
-
const keys = assignKeys(entries, ctx.fields === "views");
|
|
2997
|
-
entries.push(...levelEntries(policy, ctx, keys, tables, warnings, skipClosures));
|
|
2998
|
-
const conditionedKeys = /* @__PURE__ */ new Set();
|
|
2999
|
-
for (const [entry, grantKey] of keys) if (entry.using !== void 0 || entry.check !== void 0 || entry.item.grant.validity !== void 0 || entry.item.grant.requires !== void 0) conditionedKeys.add(grantKey);
|
|
3000
|
-
const rows = /* @__PURE__ */ new Map();
|
|
3001
|
-
for (const item of items) {
|
|
3002
|
-
const holder = item.grant.breakGlass === void 0 ? void 0 : breakGlassHolder(item);
|
|
3003
|
-
if (holder !== void 0) {
|
|
3004
|
-
const row = {
|
|
3005
|
-
role: holder.role,
|
|
3006
|
-
permission: item.grant.permission.key,
|
|
3007
|
-
grantKey: breakGlassKey(item.grant.permission.key),
|
|
3008
|
-
scope: holder.scope,
|
|
3009
|
-
effect: item.grant.effect
|
|
3010
|
-
};
|
|
3011
|
-
rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
|
|
3012
|
-
}
|
|
3013
|
-
}
|
|
3014
|
-
const branches = [];
|
|
3015
|
-
const actionBranches = [];
|
|
3016
|
-
const rowColumns = /* @__PURE__ */ new Map();
|
|
3017
|
-
const filters = (resource, table, column) => {
|
|
3018
|
-
rowColumns.set(`${table}\u0000${column}`, {
|
|
3019
|
-
resource,
|
|
3020
|
-
table,
|
|
3021
|
-
column
|
|
3022
|
-
});
|
|
3023
|
-
};
|
|
3024
|
-
const typed = /* @__PURE__ */ new Map();
|
|
3025
|
-
const contextFor = (name) => {
|
|
3026
|
-
const known = typed.get(name);
|
|
3027
|
-
if (known !== void 0) return known;
|
|
3028
|
-
const node = policy.resources.get(name);
|
|
3029
|
-
const schema = node === void 0 ? void 0 : jsonSchemaOf(node);
|
|
3030
|
-
const next = {
|
|
3031
|
-
...ctx,
|
|
3032
|
-
columnTypes: columnTypesOf(schema),
|
|
3033
|
-
arrayColumns: arrayColumnsOf(schema)
|
|
3034
|
-
};
|
|
3035
|
-
typed.set(name, next);
|
|
3036
|
-
return next;
|
|
3037
|
-
};
|
|
3038
|
-
for (const entry of entries) {
|
|
3039
|
-
const { item, table } = entry;
|
|
3040
|
-
const { grant, access, label } = item;
|
|
3041
|
-
if (entry.command === "none") {
|
|
3042
|
-
const grantKey = keys.get(entry);
|
|
3043
|
-
if (access.kind === "role" && grantKey !== void 0) {
|
|
3044
|
-
const row = {
|
|
3045
|
-
role: access.role,
|
|
3046
|
-
permission: grant.permission.key,
|
|
3047
|
-
grantKey,
|
|
3048
|
-
scope: access.scope,
|
|
3049
|
-
effect: grant.effect
|
|
3050
|
-
};
|
|
3051
|
-
rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
|
|
3052
|
-
}
|
|
3053
|
-
if (!rowActions) continue;
|
|
3054
|
-
}
|
|
3055
|
-
const actionOnly = entry.command === "none";
|
|
3056
|
-
const command = actionOnly ? "select" : entry.command;
|
|
3057
|
-
const target = actionOnly ? actionBranches : branches;
|
|
3058
|
-
const rowCtx = {
|
|
3059
|
-
...contextFor(item.grant.permission.resource),
|
|
3060
|
-
permission: grant.permission.key
|
|
3061
|
-
};
|
|
3062
|
-
noteConditions(entry, warnings);
|
|
3063
|
-
for (const field of [...indexedFields(entry.using), ...indexedFields(entry.check)]) filters(grant.permission.resource, table, field);
|
|
3064
|
-
const grantKey = keys.get(entry);
|
|
3065
|
-
let accessExpr;
|
|
3066
|
-
if (access.kind === "role" && grantKey !== void 0) {
|
|
3067
|
-
const column = access.scope === "global" ? void 0 : scopeField(policy.resources.get(grant.permission.resource), access.scope, policy.scopes);
|
|
3068
|
-
accessExpr = accessSql(ctx, access.scope, grantKey, column);
|
|
3069
|
-
if (column !== void 0) filters(grant.permission.resource, table, column);
|
|
3070
|
-
const row = {
|
|
3071
|
-
role: access.role,
|
|
3072
|
-
permission: grant.permission.key,
|
|
3073
|
-
grantKey,
|
|
3074
|
-
scope: access.scope,
|
|
3075
|
-
effect: grant.effect
|
|
3076
|
-
};
|
|
3077
|
-
rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
|
|
3078
|
-
}
|
|
3079
|
-
const linkOnly = access.kind === "resource" && ctx.capabilities === true && ctx.memberships?.resource?.[access.resource] === void 0;
|
|
3080
|
-
if (access.kind === "actor") accessExpr = `${subjectClaimSql(ctx, "client_id")} is not null or ${subjectClaimJsonSql(ctx, "act")} is not null`;
|
|
3081
|
-
if (access.kind === "resource" && !linkOnly) accessExpr = compileConditionSql(resourceCondition(item, policy, ctx), {
|
|
3082
|
-
...ctx,
|
|
3083
|
-
permission: grant.permission.key
|
|
3084
|
-
});
|
|
3085
|
-
if (linkOnly) warnings.push(`no ${access.resource} memberships table: only link capabilities reach ${label}/${grant.permission.key}`);
|
|
3086
|
-
const validity = validitySql(grant.validity);
|
|
3087
|
-
accessExpr = andSql(accessExpr, validity);
|
|
3088
|
-
for (const key of grant.requires ?? []) accessExpr = andSql(accessExpr, requiresSql(ctx, key, policy.resources.get(grant.permission.resource)));
|
|
3089
|
-
if (ctx.anonymousSignIns === "deny" && access.kind !== "anyone") accessExpr = andSql(accessExpr, PERMANENT_USER);
|
|
3090
|
-
if (ctx.apiKeys !== void 0 && grant.effect === "allow" && access.kind !== "anyone" && access.kind !== "role") {
|
|
3091
|
-
const calls = (requiresStandIn(grant) ? grant.requires ?? [] : [grant.permission.key, ...grant.requires ?? []]).map((key) => `(select ${apiKeyAllowsCall(ctx, quoteLiteral(key))})`);
|
|
3092
|
-
accessExpr = andSql(accessExpr, calls.length === 1 ? calls[0] ?? "" : `(${calls.join(" and ")})`);
|
|
3093
|
-
}
|
|
3094
|
-
const using = compileOptional(entry.using, rowCtx);
|
|
3095
|
-
const check = compileOptional(entry.check, rowCtx);
|
|
3096
|
-
const fields = grant.fields === void 0 ? {} : { fields: grant.fields };
|
|
3097
|
-
if (!linkOnly) target.push({
|
|
3098
|
-
table,
|
|
3099
|
-
command,
|
|
3100
|
-
effect: grant.effect,
|
|
3101
|
-
roles: access.kind === "anyone" ? ["anon", "authenticated"] : ["authenticated"],
|
|
3102
|
-
label,
|
|
3103
|
-
resource: grant.permission.resource,
|
|
3104
|
-
permissionKey: grant.permission.key,
|
|
3105
|
-
...fields,
|
|
3106
|
-
...grantKey === void 0 ? {} : { grantKey },
|
|
3107
|
-
...accessExpr === void 0 ? {} : { access: accessExpr },
|
|
3108
|
-
...using === void 0 ? {} : { using },
|
|
3109
|
-
...check === void 0 ? {} : { check }
|
|
3110
|
-
});
|
|
3111
|
-
const linked = ctx.capabilities === true ? capabilityAccess(item, policy, ctx) : void 0;
|
|
3112
|
-
if (linked !== void 0) target.push({
|
|
3113
|
-
table,
|
|
3114
|
-
command,
|
|
3115
|
-
effect: grant.effect,
|
|
3116
|
-
roles: ["anon"],
|
|
3117
|
-
label,
|
|
3118
|
-
resource: grant.permission.resource,
|
|
3119
|
-
permissionKey: grant.permission.key,
|
|
3120
|
-
...fields,
|
|
3121
|
-
access: andSql(linked, validity) ?? linked,
|
|
3122
|
-
...using === void 0 ? {} : { using },
|
|
3123
|
-
...check === void 0 ? {} : { check }
|
|
3124
|
-
});
|
|
3125
|
-
}
|
|
3126
|
-
return {
|
|
3127
|
-
branches: ensureSelectCoverage(branches, warnings),
|
|
3128
|
-
actionBranches,
|
|
3129
|
-
rolePermissions: withAliasRows(policy, [...rows.values()]),
|
|
3130
|
-
conditionedKeys,
|
|
3131
|
-
rowColumns: [...rowColumns.values()],
|
|
3132
|
-
levelReach: ctx.customRoles?.levels === true ? levelReachOf(policy, keys) : []
|
|
3133
|
-
};
|
|
3134
|
-
}
|
|
3135
|
-
/**
|
|
3136
|
-
* Each row again under every key its permission was renamed from, so SQL that
|
|
3137
|
-
* still calls `permdock_has('<old key>')` keeps the access the current key
|
|
3138
|
-
* has. A split grant key (`key#n`) keeps its suffix; break-glass rows are not
|
|
3139
|
-
* copied.
|
|
3140
|
-
*/
|
|
3141
|
-
function withAliasRows(policy, rows) {
|
|
3142
|
-
const former = new Map(listPermissions(policy.permissions).map((leaf) => [leaf.key, formerKeys(leaf)]));
|
|
3143
|
-
const out = [...rows];
|
|
3144
|
-
for (const row of rows) {
|
|
3145
|
-
const suffix = row.grantKey.slice(row.permission.length);
|
|
3146
|
-
if (!row.grantKey.startsWith(row.permission) || suffix !== "" && (!/^#[a-z0-9][a-z0-9_-]*$/u.test(suffix) || suffix === "#break-glass")) continue;
|
|
3147
|
-
for (const old of former.get(row.permission) ?? []) out.push({
|
|
3148
|
-
...row,
|
|
3149
|
-
permission: old,
|
|
3150
|
-
grantKey: `${old}${suffix}`
|
|
3151
|
-
});
|
|
3152
|
-
}
|
|
3153
|
-
return out;
|
|
3154
|
-
}
|
|
3155
|
-
/**
|
|
3156
|
-
* Postgres needs SELECT access to find rows for UPDATE / DELETE and for
|
|
3157
|
-
* RETURNING, so a table with only update or delete grants gets matching
|
|
3158
|
-
* SELECT branches.
|
|
3159
|
-
*/
|
|
3160
|
-
function ensureSelectCoverage(branches, warnings) {
|
|
3161
|
-
const extra = [];
|
|
3162
|
-
const readable = new Set(branches.filter((item) => item.command === "select" && item.effect === "allow").map((item) => item.table));
|
|
3163
|
-
const covered = /* @__PURE__ */ new Set();
|
|
3164
|
-
for (const item of branches) {
|
|
3165
|
-
if (item.effect !== "allow" || item.command !== "update" && item.command !== "delete" || readable.has(item.table)) continue;
|
|
3166
|
-
const { check: _check, ...rest } = item;
|
|
3167
|
-
extra.push({
|
|
3168
|
-
...rest,
|
|
3169
|
-
command: "select",
|
|
3170
|
-
coverage: true
|
|
3171
|
-
});
|
|
3172
|
-
if (!covered.has(item.table)) {
|
|
3173
|
-
covered.add(item.table);
|
|
3174
|
-
warnings.push(`added SELECT coverage for ${item.table} (${item.permissionKey})`);
|
|
3175
|
-
}
|
|
3176
|
-
}
|
|
3177
|
-
return [...branches, ...extra];
|
|
3178
|
-
}
|
|
3179
|
-
/** True when `sql` is one parenthesised expression, so AND / OR need not wrap it again. */
|
|
3180
|
-
function isWrapped(sql) {
|
|
3181
|
-
if (!sql.startsWith("(") || !sql.endsWith(")")) return false;
|
|
3182
|
-
let depth = 0;
|
|
3183
|
-
let quoted = false;
|
|
3184
|
-
for (let index = 0; index < sql.length; index += 1) {
|
|
3185
|
-
const ch = sql[index];
|
|
3186
|
-
if (ch === "'") quoted = !quoted;
|
|
3187
|
-
else if (!quoted && ch === "(") depth += 1;
|
|
3188
|
-
else if (!quoted && ch === ")") {
|
|
3189
|
-
depth -= 1;
|
|
3190
|
-
if (depth === 0 && index < sql.length - 1) return false;
|
|
3191
|
-
}
|
|
3192
|
-
}
|
|
3193
|
-
return depth === 0;
|
|
3194
|
-
}
|
|
3195
|
-
function wrapSql(sql) {
|
|
3196
|
-
return isWrapped(sql) ? sql : `(${sql})`;
|
|
3197
|
-
}
|
|
3198
|
-
/**
|
|
3199
|
-
* The grant's `validFrom` / `validUntil` as a clock check: `from` inclusive,
|
|
3200
|
-
* `until` exclusive, matching the in-process evaluator.
|
|
3201
|
-
*/
|
|
3202
|
-
function validitySql(validity) {
|
|
3203
|
-
if (validity === void 0) return;
|
|
3204
|
-
return andSql(validity.from === void 0 ? void 0 : `now() >= to_timestamp(${validity.from})`, validity.until === void 0 ? void 0 : `now() < to_timestamp(${validity.until})`);
|
|
3205
|
-
}
|
|
3206
|
-
function andSql(...parts) {
|
|
3207
|
-
const present = parts.filter((part) => part !== void 0 && part !== "true");
|
|
3208
|
-
if (present.length === 0) return;
|
|
3209
|
-
return present.length === 1 ? present[0] : present.map(wrapSql).join(" and ");
|
|
3210
|
-
}
|
|
3211
|
-
/** `USING` and `WITH CHECK` for a branch: its access check ANDed with its row conditions. */
|
|
3212
|
-
function branchClauses(branch) {
|
|
3213
|
-
const using = branch.command === "insert" ? void 0 : andSql(branch.access, branch.using) ?? "true";
|
|
3214
|
-
const check = branch.command === "insert" || branch.command === "update" ? andSql(branch.access, branch.check) ?? "true" : void 0;
|
|
3215
|
-
return {
|
|
3216
|
-
...using === void 0 ? {} : { using },
|
|
3217
|
-
...check === void 0 ? {} : { check }
|
|
3218
|
-
};
|
|
3219
|
-
}
|
|
3220
|
-
//#endregion
|
|
3221
|
-
//#region src/cli/rls-policies.ts
|
|
3222
|
-
/** Each database role gets its own policy, so no role sees two permissive policies for one command. */
|
|
3223
|
-
const AUDIENCES = ["authenticated", "anon"];
|
|
3224
|
-
const DEFAULT_POLICY_NAME = "{table}_{op}";
|
|
3225
|
-
const DEFAULT_PER_ROLE_NAME = "{role}_{permission}";
|
|
3226
|
-
const PLACEHOLDER = /\{(table|op|role|permission)\}/gu;
|
|
3227
|
-
function sanitize(name) {
|
|
3228
|
-
return name.replaceAll(/[^A-Za-z0-9_]/g, "_");
|
|
3229
|
-
}
|
|
3230
|
-
function render(template, values) {
|
|
3231
|
-
return sanitize(template.replaceAll(PLACEHOLDER, (_match, key) => values[key]));
|
|
3232
|
-
}
|
|
3233
|
-
/** Rejects a template that cannot name every policy of the chosen shape uniquely. */
|
|
3234
|
-
function assertPolicyName(template, perRole) {
|
|
3235
|
-
if (!perRole && /\{(role|permission)\}/u.test(template)) throw new Error("PermDock CLI: --policy-name {role} and {permission} need --policy-per-role; a collapsed policy covers several roles");
|
|
3236
|
-
if (!perRole && !(template.includes("{table}") && template.includes("{op}"))) throw new Error("PermDock CLI: --policy-name needs {table} and {op} so each table and command gets its own policy");
|
|
3237
|
-
}
|
|
3238
|
-
function orSql(parts) {
|
|
3239
|
-
const distinct = [...new Set(parts)];
|
|
3240
|
-
if (distinct.includes("true")) return "true";
|
|
3241
|
-
return sole(distinct) ?? distinct.map(wrapSql).join(" or ");
|
|
3242
|
-
}
|
|
3243
|
-
function negate(sql) {
|
|
3244
|
-
return sql === void 0 ? void 0 : `(${sql}) is not true`;
|
|
3245
|
-
}
|
|
3246
|
-
function policyOf(name, branch, using, check) {
|
|
3247
|
-
const deny = branch.effect === "deny";
|
|
3248
|
-
const policy = {
|
|
3249
|
-
name,
|
|
3250
|
-
table: branch.table,
|
|
3251
|
-
command: branch.command,
|
|
3252
|
-
effect: branch.effect,
|
|
3253
|
-
roles: branch.roles
|
|
3254
|
-
};
|
|
3255
|
-
const finalUsing = deny ? negate(using) : using;
|
|
3256
|
-
const finalCheck = deny ? negate(check) : check;
|
|
3257
|
-
if (finalUsing !== void 0) policy.using = finalUsing;
|
|
3258
|
-
if (finalCheck !== void 0) policy.check = finalCheck;
|
|
3259
|
-
return policy;
|
|
3260
|
-
}
|
|
3261
|
-
function uniqueName(names, name) {
|
|
3262
|
-
const seen = names.get(name) ?? 0;
|
|
3263
|
-
names.set(name, seen + 1);
|
|
3264
|
-
return seen === 0 ? name : `${name}_${seen + 1}`;
|
|
3265
|
-
}
|
|
3266
|
-
function perRolePolicies(branches, template) {
|
|
3267
|
-
const names = /* @__PURE__ */ new Map();
|
|
3268
|
-
return branches.map((branch) => {
|
|
3269
|
-
const clauses = branchClauses(branch);
|
|
3270
|
-
const deny = branch.effect === "deny";
|
|
3271
|
-
const base = render(template, {
|
|
3272
|
-
table: branch.table,
|
|
3273
|
-
op: branch.command,
|
|
3274
|
-
role: branch.label,
|
|
3275
|
-
permission: branch.permissionKey
|
|
3276
|
-
});
|
|
3277
|
-
const name = `${deny ? "deny_" : ""}${base}${branch.coverage === true ? "_select_coverage" : ""}`;
|
|
3278
|
-
return policyOf(uniqueName(names, name), branch, clauses.using, clauses.check);
|
|
3279
|
-
});
|
|
3280
|
-
}
|
|
3281
|
-
/**
|
|
3282
|
-
* Branches of one grant-key group share their row condition, so the group
|
|
3283
|
-
* becomes one OR branch whose access ORs the helper calls of each scope.
|
|
3284
|
-
*/
|
|
3285
|
-
function mergeGroups(branches) {
|
|
3286
|
-
const merged = [];
|
|
3287
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
3288
|
-
for (const branch of branches) {
|
|
3289
|
-
const id = branch.grantKey === void 0 ? void 0 : `${branch.table}\u0000${branch.command}\u0000${branch.grantKey}\u0000${branch.coverage === true}`;
|
|
3290
|
-
const entry = id === void 0 ? void 0 : byKey.get(id);
|
|
3291
|
-
if (entry === void 0) {
|
|
3292
|
-
const created = {
|
|
3293
|
-
branch,
|
|
3294
|
-
accesses: branch.access === void 0 ? [] : [branch.access]
|
|
3295
|
-
};
|
|
3296
|
-
if (id !== void 0) byKey.set(id, created);
|
|
3297
|
-
merged.push(created);
|
|
3298
|
-
continue;
|
|
3299
|
-
}
|
|
3300
|
-
if (branch.access !== void 0 && !entry.accesses.includes(branch.access)) entry.accesses.push(branch.access);
|
|
3301
|
-
}
|
|
3302
|
-
return merged.map(({ branch, accesses }) => accesses.length <= 1 ? branch : withAccess(branch, orSql(accesses)));
|
|
3303
|
-
}
|
|
3304
|
-
function withAccess(branch, access) {
|
|
3305
|
-
return {
|
|
3306
|
-
...branch,
|
|
3307
|
-
access
|
|
3308
|
-
};
|
|
3309
|
-
}
|
|
3310
|
-
function collapsedPolicies(branches, template) {
|
|
3311
|
-
const groups = /* @__PURE__ */ new Map();
|
|
3312
|
-
for (const branch of mergeGroups(branches)) {
|
|
3313
|
-
const id = `${branch.table}\u0000${branch.command}\u0000${branch.effect}`;
|
|
3314
|
-
groups.set(id, [...groups.get(id) ?? [], branch]);
|
|
3315
|
-
}
|
|
3316
|
-
const names = /* @__PURE__ */ new Map();
|
|
3317
|
-
return [...groups.values()].flatMap((group) => AUDIENCES.flatMap((audience) => {
|
|
3318
|
-
const members = group.filter((branch) => branch.roles.includes(audience));
|
|
3319
|
-
const [first] = members;
|
|
3320
|
-
if (first === void 0) return [];
|
|
3321
|
-
const deny = first.effect === "deny";
|
|
3322
|
-
const clauses = members.map(branchClauses);
|
|
3323
|
-
const usings = clauses.flatMap((item) => item.using === void 0 ? [] : [item.using]);
|
|
3324
|
-
const checks = clauses.flatMap((item) => item.check === void 0 ? [] : [item.check]);
|
|
3325
|
-
const base = render(template, {
|
|
3326
|
-
table: first.table,
|
|
3327
|
-
op: first.command,
|
|
3328
|
-
role: first.label,
|
|
3329
|
-
permission: first.permissionKey
|
|
3330
|
-
});
|
|
3331
|
-
return [policyOf(uniqueName(names, `${deny ? "deny_" : ""}${base}${audience === "anon" ? "_anon" : ""}`), {
|
|
3332
|
-
...first,
|
|
3333
|
-
roles: [audience]
|
|
3334
|
-
}, usings.length === 0 ? void 0 : orSql(usings), checks.length === 0 ? void 0 : orSql(checks))];
|
|
3335
|
-
}));
|
|
3336
|
-
}
|
|
3337
|
-
/**
|
|
3338
|
-
* Assembles policies. The default is one PERMISSIVE policy per table,
|
|
3339
|
-
* command and database role (Splinter `multiple_permissive_policies` stays
|
|
3340
|
-
* quiet), with denies in one RESTRICTIVE policy per table, command and role.
|
|
3341
|
-
*/
|
|
3342
|
-
function assemblePolicies(branches, shape) {
|
|
3343
|
-
const template = shape.name ?? (shape.perRole ? DEFAULT_PER_ROLE_NAME : DEFAULT_POLICY_NAME);
|
|
3344
|
-
assertPolicyName(template, shape.perRole);
|
|
3345
|
-
return shape.perRole ? perRolePolicies(branches, template) : collapsedPolicies(branches, template);
|
|
3346
|
-
}
|
|
3347
|
-
const DEFAULT_READ_ONLY_ACTORS = ["support", "impersonation"];
|
|
3348
|
-
const ACTOR_KIND = /^[a-z][a-z0-9_-]*$/u;
|
|
3349
|
-
/** The actor kinds `rls.readOnlyActors` names, or `undefined` when it is off. */
|
|
3350
|
-
function readOnlyActorKinds(setting) {
|
|
3351
|
-
if (setting === void 0 || setting === false) return;
|
|
3352
|
-
const kinds = setting === true ? DEFAULT_READ_ONLY_ACTORS : setting;
|
|
3353
|
-
if (kinds.length === 0 || kinds.some((kind) => !ACTOR_KIND.test(kind))) throw new Error("PermDock CLI: rls.readOnlyActors must be true or a non-empty list of actor kinds such as 'support'");
|
|
3354
|
-
return [...new Set(kinds)];
|
|
3355
|
-
}
|
|
3356
|
-
/**
|
|
3357
|
-
* One restrictive policy per table and write command the allow policies
|
|
3358
|
-
* grant to `authenticated`: the write passes unless the token's `act` claim
|
|
3359
|
-
* (`act`, as SQL `jsonb`) is a session of one of `kinds` that is not
|
|
3360
|
-
* `read_only: false`. Reads are never touched.
|
|
3361
|
-
*/
|
|
3362
|
-
function readOnlyActorPolicies(policies, act, kinds) {
|
|
3363
|
-
const list = `array[${kinds.map((kind) => `'${kind}'`).join(", ")}]::text[]`;
|
|
3364
|
-
const passes = `not coalesce(${kinds.includes("support") ? `(${act} ->> 'kind') = any(${list}) or ((${act} ->> 'kind') is null and ${act} ? 'session_id')` : `(${act} ->> 'kind') = any(${list})`}, false) or coalesce((${act} ->> 'read_only') = 'false', false)`;
|
|
3365
|
-
const seen = /* @__PURE__ */ new Set();
|
|
3366
|
-
const out = [];
|
|
3367
|
-
for (const policy of policies) {
|
|
3368
|
-
const key = `${policy.table}\u0000${policy.command}`;
|
|
3369
|
-
if (policy.effect !== "allow" || policy.command === "select" || !policy.roles.includes("authenticated") || seen.has(key)) continue;
|
|
3370
|
-
seen.add(key);
|
|
3371
|
-
out.push({
|
|
3372
|
-
name: sanitize(`${policy.table}_${policy.command}_read_only_actors`),
|
|
3373
|
-
table: policy.table,
|
|
3374
|
-
command: policy.command,
|
|
3375
|
-
effect: "deny",
|
|
3376
|
-
roles: ["authenticated"],
|
|
3377
|
-
...policy.command === "insert" ? {} : { using: passes },
|
|
3378
|
-
...policy.command === "delete" ? {} : { check: passes }
|
|
3379
|
-
});
|
|
3380
|
-
}
|
|
3381
|
-
return out;
|
|
3382
|
-
}
|
|
3383
|
-
//#endregion
|
|
3384
|
-
//#region src/cli/rls-fields.ts
|
|
3385
|
-
/** Names of the field-view objects. Part of the SQL contract. */
|
|
3386
|
-
const FIELD_VIEWS = {
|
|
3387
|
-
/** `<table>_visible`: the `security_invoker` view clients read. */
|
|
3388
|
-
view: "_visible",
|
|
3389
|
-
/** `<table>_visible_fields`: the owner-rights companion `--revoke-columns` adds. */
|
|
3390
|
-
companion: "_visible_fields",
|
|
3391
|
-
/** The companion's row-key column, joined to the base table's key. */
|
|
3392
|
-
key: "permdock_key",
|
|
3393
|
-
/** Marks the companion so doctor PD022 knows it reads as its owner on purpose. */
|
|
3394
|
-
comment: "permdock:field-companion"
|
|
3395
|
-
};
|
|
3396
|
-
function covers(branch, column) {
|
|
3397
|
-
return branch.fields === void 0 || branch.fields.includes(column);
|
|
3398
|
-
}
|
|
3399
|
-
/**
|
|
3400
|
-
* A read grant that only shapes columns: a field-limited deny (it never
|
|
3401
|
-
* denies the row) or an allow with an empty list (it grants nothing). With
|
|
3402
|
-
* field views it leaves the row policy and lives in the view's masks.
|
|
3403
|
-
*/
|
|
3404
|
-
function fieldOnly(branch) {
|
|
3405
|
-
if (branch.command !== "select" || branch.fields === void 0) return false;
|
|
3406
|
-
return branch.effect === "deny" || branch.fields.length === 0;
|
|
3407
|
-
}
|
|
3408
|
-
/** Branches the row policies keep when field views compile. */
|
|
3409
|
-
function rowBranches(branches) {
|
|
3410
|
-
return branches.filter((branch) => !fieldOnly(branch));
|
|
3411
|
-
}
|
|
3412
|
-
/**
|
|
3413
|
-
* Whether the reader is signed in, for branches with no helper call: a grant
|
|
3414
|
-
* to any signed-in user relies on `to authenticated` in its policy, which a
|
|
3415
|
-
* view does not have.
|
|
3416
|
-
*/
|
|
3417
|
-
function audience(ctx, branch) {
|
|
3418
|
-
if (branch.access !== void 0 || branch.roles.length !== 1) return;
|
|
3419
|
-
if (branch.roles[0] === "authenticated") return signedIn(ctx);
|
|
3420
|
-
return branch.roles[0] === "anon" ? `not (${signedIn(ctx)})` : void 0;
|
|
3421
|
-
}
|
|
3422
|
-
function clause(ctx, branch) {
|
|
3423
|
-
return andSql(audience(ctx, branch), branchClauses(branch).using) ?? "true";
|
|
3424
|
-
}
|
|
3425
|
-
/**
|
|
3426
|
-
* `can(permission, row, { field })` in SQL: an allow that covers the column
|
|
3427
|
-
* holds for the row and no covering deny does. Every access term is the
|
|
3428
|
-
* uncorrelated helper call the row policy uses, so it runs once per statement.
|
|
3429
|
-
*/
|
|
3430
|
-
function maskSql(ctx, allows, denies, column) {
|
|
3431
|
-
const allowed = allows.filter((branch) => covers(branch, column)).map((branch) => clause(ctx, branch));
|
|
3432
|
-
if (allowed.length === 0) return "false";
|
|
3433
|
-
const denied = denies.filter((branch) => covers(branch, column)).map((branch) => clause(ctx, branch));
|
|
3434
|
-
const allow = orSql(allowed);
|
|
3435
|
-
if (denied.length === 0) return allow;
|
|
3436
|
-
const deny = orSql(denied);
|
|
3437
|
-
return allow === "true" ? `not ${wrapSql(deny)}` : `${wrapSql(allow)} and not ${wrapSql(deny)}`;
|
|
3438
|
-
}
|
|
3439
|
-
function schemaColumns(policy, resource) {
|
|
3440
|
-
const node = policy.resources.get(resource);
|
|
3441
|
-
const schema = node === void 0 ? null : jsonSchemaOf(node);
|
|
3442
|
-
const properties = schema !== null && typeof schema === "object" ? schema.properties : void 0;
|
|
3443
|
-
return properties !== null && typeof properties === "object" ? Object.keys(properties) : void 0;
|
|
3444
|
-
}
|
|
3445
|
-
function viewName(table, suffix) {
|
|
3446
|
-
return `${table}${suffix}`;
|
|
3447
|
-
}
|
|
3448
|
-
/**
|
|
3449
|
-
* `<helper schema>.<table>_visible_fields`: outside the Data API's schemas,
|
|
3450
|
-
* so its owner rights reach clients only through the `_visible` join.
|
|
3451
|
-
*/
|
|
3452
|
-
function companionName(ctx, table) {
|
|
3453
|
-
const base = table.startsWith("public.") ? table.slice(7) : table.replaceAll(".", "_");
|
|
3454
|
-
return `${helperSchema(ctx)}.${viewName(base, FIELD_VIEWS.companion)}`;
|
|
3455
|
-
}
|
|
3456
|
-
/**
|
|
3457
|
-
* One view per table whose read grants limit fields. A column is restricted
|
|
3458
|
-
* when some read allow lists fields without it or some read deny lists it;
|
|
3459
|
-
* every other column, and the row key, passes through unchanged.
|
|
3460
|
-
*/
|
|
3461
|
-
function fieldViews(policy, ctx, branches, options, warnings) {
|
|
3462
|
-
const byTable = /* @__PURE__ */ new Map();
|
|
3463
|
-
for (const branch of branches) {
|
|
3464
|
-
if (branch.command !== "select" || branch.coverage === true) continue;
|
|
3465
|
-
byTable.set(branch.table, [...byTable.get(branch.table) ?? [], branch]);
|
|
3466
|
-
}
|
|
3467
|
-
const views = [];
|
|
3468
|
-
for (const [table, reads] of byTable) {
|
|
3469
|
-
const allows = reads.filter((branch) => branch.effect === "allow" && branch.fields?.length !== 0);
|
|
3470
|
-
const denies = reads.filter((branch) => branch.effect === "deny");
|
|
3471
|
-
const limited = reads.some((branch) => branch.fields !== void 0);
|
|
3472
|
-
const resource = reads.find((branch) => branch.resource !== void 0)?.resource;
|
|
3473
|
-
if (!limited || allows.length === 0 || resource === void 0) continue;
|
|
3474
|
-
const names = schemaColumns(policy, resource);
|
|
3475
|
-
if (names === void 0) throw new Error(`PermDock CLI: --fields views needs the ${resource} schema's JSON Schema (Standard JSON Schema) to list the columns of ${table}`);
|
|
3476
|
-
const key = policy.resources.get(resource)?.id ?? "id";
|
|
3477
|
-
const restricted = (column) => column !== key && (allows.some((branch) => branch.fields !== void 0 && !branch.fields.includes(column)) || denies.some((branch) => branch.fields?.includes(column) === true));
|
|
3478
|
-
const columns = names.map((name) => {
|
|
3479
|
-
quoteIdent(name);
|
|
3480
|
-
return restricted(name) ? {
|
|
3481
|
-
name,
|
|
3482
|
-
mask: maskSql(ctx, allows, denies, name)
|
|
3483
|
-
} : { name };
|
|
3484
|
-
});
|
|
3485
|
-
if (columns.every((column) => column.mask === void 0)) continue;
|
|
3486
|
-
const view = viewName(table, FIELD_VIEWS.view);
|
|
3487
|
-
if (allows.some((branch) => branch.fields !== void 0 && !branch.fields.includes(key))) warnings.push(`field view ${view}: the key ${key} passes through, although a read grant on ${resource} omits it`);
|
|
3488
|
-
const hidden = columns.filter((column) => column.mask !== void 0).map((column) => column.name);
|
|
3489
|
-
if (options.helpersOnly === true) warnings.push(`field view ${view}: ${table} still returns ${hidden.join(", ")} to direct reads; revoke select on those columns from client roles in your own grants, or read them through a function`);
|
|
3490
|
-
else if (!options.revokeColumns) warnings.push(`field view ${view}: ${table} still returns ${hidden.join(", ")} to direct reads; add --revoke-columns so clients read through the view`);
|
|
3491
|
-
views.push({
|
|
3492
|
-
resource,
|
|
3493
|
-
table,
|
|
3494
|
-
view,
|
|
3495
|
-
key,
|
|
3496
|
-
columns,
|
|
3497
|
-
roles: reads.some((branch) => branch.roles.includes("anon")) ? ["anon", "authenticated"] : ["authenticated"],
|
|
3498
|
-
...options.revokeColumns ? { companion: companionName(ctx, table) } : {}
|
|
3499
|
-
});
|
|
3500
|
-
}
|
|
3501
|
-
return views;
|
|
3502
|
-
}
|
|
3503
|
-
/** Columns of `view`'s table that `anon` and `authenticated` keep on the base table with `--revoke-columns`. */
|
|
3504
|
-
function readableColumns(view) {
|
|
3505
|
-
return view.columns.filter((column) => column.mask === void 0).map((column) => column.name);
|
|
3506
|
-
}
|
|
3507
|
-
function restrictedColumns(view) {
|
|
3508
|
-
return view.columns.filter((column) => column.mask !== void 0).map((column) => column.name);
|
|
3509
|
-
}
|
|
3510
|
-
function columnList(names) {
|
|
3511
|
-
return names.map(quoteIdent).join(", ");
|
|
3512
|
-
}
|
|
3513
|
-
function selectList(lines) {
|
|
3514
|
-
return lines.map((line) => ` ${line}`).join(",\n");
|
|
3515
|
-
}
|
|
3516
|
-
function grantView(name, roles) {
|
|
3517
|
-
return [`revoke all on table ${quoteTable(qualifiedTable(name))} from anon, authenticated, public;`, `grant select on table ${quoteTable(qualifiedTable(name))} to ${roles.join(", ")};`].join("\n");
|
|
3518
|
-
}
|
|
3519
|
-
function inlineViewSql(view) {
|
|
3520
|
-
const targets = view.columns.map((column) => column.mask === void 0 ? quoteIdent(column.name) : `case when ${column.mask} then ${quoteIdent(column.name)} end as ${quoteIdent(column.name)}`);
|
|
3521
|
-
return `create or replace view ${quoteTable(qualifiedTable(view.view))} with (security_invoker = true) as
|
|
3522
|
-
select
|
|
3523
|
-
${selectList(targets)}
|
|
3524
|
-
from ${quoteTable(qualifiedTable(view.table))};`;
|
|
3525
|
-
}
|
|
3526
|
-
function companionSql(view, companion) {
|
|
3527
|
-
const restricted = view.columns.filter((column) => column.mask !== void 0);
|
|
3528
|
-
const targets = [`${quoteIdent(view.key)} as ${quoteIdent(FIELD_VIEWS.key)}`, ...restricted.map((column) => `case when ${column.mask} then ${quoteIdent(column.name)} end as ${quoteIdent(column.name)}`)];
|
|
3529
|
-
const any = orSql(restricted.map((column) => column.mask ?? "false"));
|
|
3530
|
-
const name = companion.slice(0, companion.indexOf("."));
|
|
3531
|
-
const schema = quoteIdent(name);
|
|
3532
|
-
return `${name === "public" ? "" : `create schema if not exists ${schema};
|
|
3533
|
-
revoke all on schema ${schema} from public;
|
|
3534
|
-
grant usage on schema ${schema} to ${view.roles.join(", ")};
|
|
3535
|
-
`}create or replace view ${quoteTable(qualifiedTable(companion))} with (security_barrier = true) as
|
|
3536
|
-
select
|
|
3537
|
-
${selectList(targets)}
|
|
3538
|
-
from ${quoteTable(qualifiedTable(view.table))}
|
|
3539
|
-
where ${any};
|
|
3540
|
-
comment on view ${quoteTable(qualifiedTable(companion))} is ${quoteLiteral(`${FIELD_VIEWS.comment} ${view.view}`)};`;
|
|
3541
|
-
}
|
|
3542
|
-
function joinedViewSql(view, companion) {
|
|
3543
|
-
const targets = view.columns.map((column) => column.mask === void 0 ? `t.${quoteIdent(column.name)}` : `f.${quoteIdent(column.name)}`);
|
|
3544
|
-
return `create or replace view ${quoteTable(qualifiedTable(view.view))} with (security_invoker = true) as
|
|
3545
|
-
select
|
|
3546
|
-
${selectList(targets)}
|
|
3547
|
-
from ${quoteTable(qualifiedTable(view.table))} t
|
|
3548
|
-
left join ${quoteTable(qualifiedTable(companion))} f on f.${quoteIdent(FIELD_VIEWS.key)} = t.${quoteIdent(view.key)};`;
|
|
3549
|
-
}
|
|
3550
|
-
/** `select` on only the unrestricted columns (and the key) of the base table, for `--revoke-columns`. */
|
|
3551
|
-
function columnGrantSql(view, role) {
|
|
3552
|
-
return `grant select (${columnList(readableColumns(view))}) on table ${quoteTable(qualifiedTable(view.table))} to ${role};`;
|
|
3553
|
-
}
|
|
3554
|
-
function columnRevokeSql(view) {
|
|
3555
|
-
return `revoke select (${columnList(restrictedColumns(view))}) on table ${quoteTable(qualifiedTable(view.table))} from anon, authenticated;`;
|
|
3556
|
-
}
|
|
3557
|
-
/**
|
|
3558
|
-
* The view SQL: the `security_invoker` view, and with `--revoke-columns` the
|
|
3559
|
-
* owner-rights companion it joins for the restricted columns. The companion
|
|
3560
|
-
* masks every column with the full field decision and keeps only rows where
|
|
3561
|
-
* one is readable, so reading it directly shows nothing `pick` would not.
|
|
3562
|
-
*/
|
|
3563
|
-
function fieldViewsSql(views) {
|
|
3564
|
-
return views.map((view) => {
|
|
3565
|
-
const head = `-- field view over ${view.table}: ${restrictedColumns(view).join(", ")} read as null unless a read grant covers the column for the row`;
|
|
3566
|
-
if (view.companion === void 0) return [
|
|
3567
|
-
head,
|
|
3568
|
-
inlineViewSql(view),
|
|
3569
|
-
grantView(view.view, view.roles)
|
|
3570
|
-
].join("\n");
|
|
3571
|
-
return [
|
|
3572
|
-
head,
|
|
3573
|
-
`-- ${view.companion} reads ${view.table} as its owner; ${view.view} reads the rows as the caller and joins it`,
|
|
3574
|
-
companionSql(view, view.companion),
|
|
3575
|
-
grantView(view.companion, view.roles),
|
|
3576
|
-
joinedViewSql(view, view.companion),
|
|
3577
|
-
grantView(view.view, view.roles)
|
|
3578
|
-
].join("\n");
|
|
3579
|
-
}).join("\n\n");
|
|
3580
|
-
}
|
|
3581
|
-
//#endregion
|
|
3582
|
-
export { helpersSql as A, breakGlassHolder as B, readTableIndexFacts as C, claimKeptLines as D, accessSql as E, seedSql as F, API_KEY_ALLOWS as G, collectGrants as H, sourceFilters as I, apiKeyFields as K, sourcesKeep as L, membershipRows as M, membershipTable as N, disabledColumn as O, roleRows as P, hasCustomRoleChecksFor as R, pruneIndexTargets as S, connectPgPool as T, roleNames as U, breakGlassKey as V, contextRefs as W, permissionKeysForHelper as _, fieldViewsSql as a, indexTargets as b, assemblePolicies as c, readOnlyActorPolicies as d, branchClauses as f, permissionHelpersSql as g, tableFor as h, fieldViews as i, memberColumn as j, globalRoleRows as k, orSql as l, compileGrants as m, columnGrantSql as n, rowBranches as o, commandFor as p, apiKeysPlan as q, columnRevokeSql as r, viewName as s, FIELD_VIEWS as t, readOnlyActorKinds as u, permittedByPermissionHelper as v, connectPg as w, indexesSql as x, COLUMNS_SQL as y, globalRoleSource as z };
|
|
2805
|
+
export { sqlFunctionNames as A, memberIdsHelper as B, apiKeysPlan as C, roleNames as D, collectGrants as E, hasMemberFor as F, scopeSources as G, permittedForHelper as H, linkHelper as I, activeUserSql as J, scopeTable as K, memberForHelper as L, graphHelper as M, graphSqlName as N, compileConditionSql as O, graphSqlText as P, memberForSources as R, apiKeyFields as S, breakGlassKey as T, permittedIdsHelper as U, memberRoleOf as V, restrictedHelper as W, connectPgPool as X, checkSuspension as Y, pgConnector as Z, globalRoleSource as _, accessSql as a, API_KEY_ALLOWS as b, capabilityAccessSql as c, memberColumn as d, sourceFilters as f, roleRows as g, membershipRows as h, requiresSql as i, CLOSURE as j, contextRefs as k, claimKeptLines as l, globalRoleRows as m, permissionKeysForHelper as n, helpersSql as o, sourcesKeep as p, activeRowSql as q, permittedByPermissionHelper as r, seedSql as s, permissionHelpersSql as t, disabledColumn as u, currentKeyExpr as v, breakGlassHolder as w, apiKeyAllowsCall as x, hasCustomRoleChecksFor as y, memberForTable as z };
|