permdock 0.3.1 → 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.
Files changed (323) hide show
  1. package/dist/a2a/index.d.ts +7 -5
  2. package/dist/a2a/index.js +14 -21
  3. package/dist/ai-sdk/index.d.ts +18 -6
  4. package/dist/ai-sdk/index.js +8 -5
  5. package/dist/app-data-CV8pqqMi.js +59 -0
  6. package/dist/approvals/index.d.ts +2 -2
  7. package/dist/approvals/index.js +3 -3
  8. package/dist/{approvals-DbYAuNkG.js → approvals-DZL76lKW.js} +35 -19
  9. package/dist/{approvers-DT4AVyIg.js → approvers-CqPOLD-o.js} +1 -1
  10. package/dist/{arazzo-BAgw9ap6.js → arazzo-C4BcskbX.js} +6 -5
  11. package/dist/{arazzo-C9chp3ST.js → arazzo-CD8d0WLb.js} +2 -2
  12. package/dist/authzen/index.d.ts +3 -3
  13. package/dist/authzen/index.js +161 -23
  14. package/dist/better-auth/index.d.ts +2 -2
  15. package/dist/better-auth/index.js +6 -19
  16. package/dist/better-supabase/index.d.ts +112 -87
  17. package/dist/better-supabase/index.js +257 -156
  18. package/dist/{bind-C_ob3LKa.d.ts → bind-BzqG8SMb.d.ts} +16 -5
  19. package/dist/{bind-CuzGytRf.js → bind-kk-uyCwH.js} +14 -6
  20. package/dist/build-instance-DIIV9DbC.js +51 -0
  21. package/dist/{canonical-json-DemiK4Vp.js → canonical-json-CFKyFOke.js} +1 -3
  22. package/dist/{capability-2kYTMWDv.d.ts → capability-CksyCWDH.d.ts} +2 -2
  23. package/dist/{capability-4qPdO9gk.js → capability-txATtKV4.js} +3 -2
  24. package/dist/catalog/index.d.ts +1 -1
  25. package/dist/catalog/index.js +1 -1
  26. package/dist/catalog-6yWAX6Ps.js +2 -0
  27. package/dist/{catalog-CYj-mX2O.js → catalog-WHG4kBq7.js} +2 -2
  28. package/dist/{catalog-doc-DdGm82XT.js → catalog-doc-DWtg0gGR.js} +17 -10
  29. package/dist/{catalog-DWF5cYqB.js → catalog-tZUyOfOv.js} +4 -4
  30. package/dist/claude-agent/index.d.ts +4 -4
  31. package/dist/claude-agent/index.js +4 -5
  32. package/dist/clerk/index.d.ts +3 -3
  33. package/dist/clerk/index.js +8 -33
  34. package/dist/cli/bin.js +1 -1
  35. package/dist/cli/index.d.ts +3 -3
  36. package/dist/cli/index.js +3 -3
  37. package/dist/cloud/index.d.ts +4 -4
  38. package/dist/cloud/index.js +9 -15
  39. package/dist/{cloud-CfwZ39Rl.js → cloud-C7bSDgX4.js} +2 -2
  40. package/dist/{cloud-DWLXO2af.js → cloud-PeBN3IEn.js} +5 -5
  41. package/dist/{collect-DSTE3ml9.js → collect-2SrB7lfk.js} +6 -8
  42. package/dist/collect-Bw2Jdq1q.js +2 -0
  43. package/dist/{collect-CHB22Sx4.js → collect-CQx4hX4N.js} +3 -3
  44. package/dist/{commands-BeaGNj_o.js → commands-Cpip4ULx.js} +13 -13
  45. package/dist/{compact-y6vfu2_f.js → compact-CAf557ZB.js} +11 -1
  46. package/dist/compile/index.d.ts +1 -1
  47. package/dist/compile/index.js +1 -1
  48. package/dist/{compile-Ba4blROR.js → compile-CcEu2ZQ1.js} +6 -5
  49. package/dist/{compile-BkRyBDBw.d.ts → compile-CtTP7eur.d.ts} +2 -2
  50. package/dist/{config-BJiM-xAm.js → config-BIjREpyE.js} +4 -4
  51. package/dist/{config-ir40y8m0.js → config-DLt6BvV7.js} +2 -2
  52. package/dist/{config-report-CdCgbRnV.js → config-report--Xb9APJS.js} +5 -6
  53. package/dist/{connection-CU8MQxRi.js → connection-DUsczpB5.js} +3 -2
  54. package/dist/constants-DcJABbLW.js +20 -0
  55. package/dist/{context-Bcy85rGi.js → context-DieZVJXu.js} +16 -5
  56. package/dist/convex/index.d.ts +4 -4
  57. package/dist/convex/index.js +14 -16
  58. package/dist/create-BBO24vj6.js +324 -0
  59. package/dist/{credential-8P81ff4u.js → credential-kue0qpbm.js} +4 -4
  60. package/dist/deciding-columns-Sq12mEA6.js +123 -0
  61. package/dist/delegation-input-FTxMK3ji.js +19 -0
  62. package/dist/denied-text-DfRk15uw.d.ts +19 -0
  63. package/dist/{describe-DMYiCjJV.d.ts → describe-C3ldA9os.d.ts} +11 -2
  64. package/dist/{describe-B_gVZEVi.js → describe-Cgkp7eP0.js} +5 -4
  65. package/dist/{diff-C9RDjjhs.js → diff-Cnghh_Ip.js} +2 -2
  66. package/dist/{diff-2VaeChzX.js → diff-rpqoe7Ag.js} +9 -9
  67. package/dist/{doctor-Djh6JDEk.js → doctor-CuVJvhV_.js} +2 -2
  68. package/dist/doctor-owlv6ddh.js +2166 -0
  69. package/dist/drizzle/index.d.ts +2 -2
  70. package/dist/drizzle/index.js +6 -14
  71. package/dist/elysia/index.d.ts +4 -4
  72. package/dist/elysia/index.js +7 -7
  73. package/dist/{errors-DPdqI50v.d.ts → errors-OD_4c__H.d.ts} +2 -2
  74. package/dist/{errors-Cz5zn0mW.js → errors-drnfxYtm.js} +3 -3
  75. package/dist/{evaluations-tz6D9A2G.js → evaluations-C9YJsv3H.js} +45 -7
  76. package/dist/eve/index.d.ts +5 -5
  77. package/dist/eve/index.js +5 -5
  78. package/dist/express/index.d.ts +4 -4
  79. package/dist/express/index.js +8 -7
  80. package/dist/fastify/index.d.ts +15 -5
  81. package/dist/fastify/index.js +9 -29
  82. package/dist/fields-fwfXN01n.js +1016 -0
  83. package/dist/{files-DWUN9PzI.js → files-D_JIzUrP.js} +12 -3
  84. package/dist/{fixtures-ht28Cplr.js → fixtures-h2eG2OZV.js} +5 -6
  85. package/dist/{from-snapshot-CTLijvdm.js → from-snapshot-7REQZe0H.js} +32 -22
  86. package/dist/{generate-CH7ThJ14.js → generate-CNU1pIOo.js} +1 -3
  87. package/dist/grantee-CXY-0C87.js +254 -0
  88. package/dist/grantee-conditions-MQs5NIuX.js +270 -0
  89. package/dist/grantee-match-fxYp9Nmt.js +141 -0
  90. package/dist/{helpers-H_KARDVC.js → helpers-C9YmyBIa.js} +7 -6
  91. package/dist/hono/index.d.ts +4 -4
  92. package/dist/hono/index.js +8 -8
  93. package/dist/hosted-CBHgkzDl.js +655 -0
  94. package/dist/{http-SO7qGttf.js → http-BBoKefeS.js} +35 -2
  95. package/dist/{http-BIhmLfCc.js → http-BEbdMZ05.js} +2 -24
  96. package/dist/{rls-import-4lLW_pbb.js → import-BFIlhetD.js} +86 -80
  97. package/dist/{openapi-import-cmwetbzw.js → import-UHk1N7vH.js} +6 -7
  98. package/dist/{index-C8z4RjQV.d.ts → index-BwMe94Xq.d.ts} +1 -1
  99. package/dist/{index-0nNqHspG.d.ts → index-DgM7w96K.d.ts} +2 -2
  100. package/dist/{index-C7Tw7MaW.d.ts → index-Dz7didpC.d.ts} +3 -3
  101. package/dist/{index-BNc7Wer_.d.ts → index-UdVQgq48.d.ts} +105 -38
  102. package/dist/index.d.ts +15 -13
  103. package/dist/index.js +22 -22
  104. package/dist/{instance-options-jARdwfPN.d.ts → instance-options-plkrsAog.d.ts} +1 -1
  105. package/dist/{json-schema-BsAflUGW.js → json-schema-DBkdUuCO.js} +1 -3
  106. package/dist/jwt/index.d.ts +4 -4
  107. package/dist/jwt/index.js +13 -13
  108. package/dist/{kernel-_RRHmnMY.js → kernel-CB3LrIJM.js} +81 -32
  109. package/dist/kysely/index.d.ts +2 -2
  110. package/dist/kysely/index.js +6 -14
  111. package/dist/{live-options-H2eG8dfJ.js → live-options-BaAFCIiu.js} +16 -3
  112. package/dist/load-B_shHrmd.js +2 -0
  113. package/dist/{load-DhWEB9P1.js → load-C3_oTHyO.js} +11 -3
  114. package/dist/{manifest-iaoBXHKc.js → manifest-BngWKryL.js} +5 -18
  115. package/dist/{map-claims-Z4QZOiFF.js → map-claims-AVlBaJoa.js} +7 -16
  116. package/dist/{map-error-pgUWNNtG.js → map-error-BE27PYkE.js} +5 -5
  117. package/dist/{markers-B0hYLvMD.js → markers-CiwAOZtO.js} +6 -2
  118. package/dist/{may-use-DndQ5tQ-.js → may-use-BOn_dR_w.js} +4 -3
  119. package/dist/mcp/index.d.ts +26 -9
  120. package/dist/mcp/index.js +456 -446
  121. package/dist/{rls-migrate-CN4TlJTO.js → migrate-BwlB5-Wk.js} +75 -15
  122. package/dist/nest/index.d.ts +4 -4
  123. package/dist/nest/index.js +11 -12
  124. package/dist/next/index.d.ts +31 -8
  125. package/dist/next/index.js +29 -33
  126. package/dist/next/plugin.d.ts +1 -1
  127. package/dist/next/plugin.js +1 -1
  128. package/dist/node/index.d.ts +4 -4
  129. package/dist/node/index.js +4 -4
  130. package/dist/ocsf-uYFXGVw2.js +115 -0
  131. package/dist/openai/index.d.ts +5 -5
  132. package/dist/openai/index.js +4 -4
  133. package/dist/openapi/index.d.ts +3 -3
  134. package/dist/openapi/index.js +1 -1
  135. package/dist/{openapi-CqqEbHsL.js → openapi-BJdBmYDG.js} +19 -23
  136. package/dist/{openapi-BqRBvAng.js → openapi-DBzHQbov.js} +2 -5
  137. package/dist/{openapi-CQTsgKoB.js → openapi-DcQ1Kg40.js} +3 -3
  138. package/dist/orpc/index.d.ts +5 -5
  139. package/dist/orpc/index.js +9 -11
  140. package/dist/otel/index.d.ts +2 -2
  141. package/dist/otel/index.js +26 -23
  142. package/dist/output-files-CgIsvdSa.js +140 -0
  143. package/dist/{rls-ownership-DcYlZ09B.js → ownership-BgnbOJFT.js} +387 -379
  144. package/dist/{package-root-zGkxO3oN.js → package-root-DtNnpmUd.js} +3 -2
  145. package/dist/{parse-BuyjxhZV.js → parse-CVFQHk6c.js} +3 -3
  146. package/dist/{parse-local-manifest-BrMozJCo.js → parse-local-manifest-C-q02Pal.js} +2 -4
  147. package/dist/{parse-local-manifest-JLnaBH0S.d.ts → parse-local-manifest-DvYUR0QL.d.ts} +1 -1
  148. package/dist/pdp/index.d.ts +2 -2
  149. package/dist/pdp/index.js +8 -9
  150. package/dist/{permdock-CbgvLnuo.js → permdock-L26udHSk.js} +1387 -1220
  151. package/dist/{rls-fields-CZOMlwHl.js → permission-keys-B26jeyUv.js} +1268 -2053
  152. package/dist/permission-registry-BsblpB-z.js +385 -0
  153. package/dist/{permissions-DttnqqxC.d.ts → permission-types-Dg39z9Kv.d.ts} +59 -31
  154. package/dist/{permissions-RJrovw98.js → permissions-CCJ6lmxB.js} +3 -3
  155. package/dist/{permissions-CDgvKG5L.d.ts → permissions-COH-KRgT.d.ts} +2 -2
  156. package/dist/{policy-IB6yxpC3.d.ts → policy-types-sp9P1iIV.d.ts} +661 -582
  157. package/dist/{policy-D2W7uv19.d.ts → policy-ur0BBK_e.d.ts} +2 -2
  158. package/dist/{powersync-DE19b9dT.js → powersync-BFSbSElQ.js} +2 -2
  159. package/dist/powersync-DCDgZgfZ.js +2 -0
  160. package/dist/{powersync-Dv1_aWF7.js → powersync-cZe4kzhL.js} +26 -27
  161. package/dist/prisma/index.d.ts +1 -1
  162. package/dist/prisma/index.js +6 -12
  163. package/dist/{problem-B43ztKIm.js → problem-CnWraVhC.js} +8 -7
  164. package/dist/{problem-details-_AMOYz-w.js → problem-details-DECUnXAn.js} +2 -2
  165. package/dist/{problem-details-DX075l9V.d.ts → problem-details-rIIo8B2C.d.ts} +1 -1
  166. package/dist/{protected-BdnIEFkY.d.ts → protected-B3_P7xuw.d.ts} +9 -6
  167. package/dist/{protected-utU-SLuy.js → protected-BN7BUdY1.js} +20 -8
  168. package/dist/{provider-CSQd7m92.js → provider-Bi-0HNL3.js} +23 -10
  169. package/dist/{provider-DZY281jO.d.ts → provider-kwHC9CZD.d.ts} +1 -1
  170. package/dist/{provider-store-Cfbvo4DE.js → provider-store-Ctt-81Ni.js} +6 -3
  171. package/dist/react/index.d.ts +7 -7
  172. package/dist/react/index.js +5 -5
  173. package/dist/react/provider-client.d.ts +1 -1
  174. package/dist/react/provider-client.js +1 -1
  175. package/dist/react-native/index.d.ts +20 -11
  176. package/dist/react-native/index.js +33 -17
  177. package/dist/{refs-KYb4ntaz.js → refs-f3imMjNW.js} +1 -1
  178. package/dist/relation-walk-BXnlLtg2.js +186 -0
  179. package/dist/relations-CvmSpG67.js +163 -0
  180. package/dist/{replay-CYyiLiwO.d.ts → replay-COpmZUfx.d.ts} +1 -1
  181. package/dist/request-context-BVM7aWNs.js +71 -0
  182. package/dist/request-context-C2_1lchh.d.ts +5 -0
  183. package/dist/resource-relations-D1Un3pcz.js +243 -0
  184. package/dist/retire-BFcxL6un.js +240 -0
  185. package/dist/{rls-P-BeBx0n.js → rls-55DQTuf5.js} +14 -2
  186. package/dist/{rls-DiinlRML.js → rls-BEktUaqg.js} +884 -484
  187. package/dist/{policy-xyWg7QdR.js → role-builder-B4GbKM-i.js} +235 -541
  188. package/dist/{rpc-DL9Sqvb1.js → rpc-nYC3cR_u.js} +15 -5
  189. package/dist/{run-BWc9qqvE.js → run-DMcNW_8T.js} +3 -3
  190. package/dist/{runtime-CB8gQFvS.js → runtime-D_tMXnsq.js} +1 -1
  191. package/dist/{saas-DhpJjnBA.js → saas-DtaA4JuW.js} +9 -8
  192. package/dist/{openapi-schema--ARjyfQr.js → schema-Bol6gqXA.js} +3 -5
  193. package/dist/{schema-D3MnDMxa.js → schema-DjQBcAP9.js} +4 -2
  194. package/dist/scim/index.d.ts +1 -1
  195. package/dist/scim/index.js +1 -1
  196. package/dist/{scim-BFGBBAqR.js → scim-vFR1O5oG.js} +6 -12
  197. package/dist/{scopes-CVdPrWN-.js → scopes-tvpa34ep.js} +10 -2
  198. package/dist/server/index.d.ts +18 -8
  199. package/dist/server/index.js +8 -8
  200. package/dist/{server-DlZ28Oz5.js → server-ZYEHtNHS.js} +4 -4
  201. package/dist/{create-B1d519XK.d.ts → server-types-DdqBwZN_.d.ts} +69 -33
  202. package/dist/shared-1uhI-e-b.js +294 -0
  203. package/dist/{shared-DRu8VFVM.js → shared-JhMeOsDF.js} +1 -1
  204. package/dist/{sink-Bd9PSyfq.js → sink-BTtmfPnE.js} +1 -1
  205. package/dist/{sink-B1fZzIMf.d.ts → sink-CPN4zLfs.d.ts} +1 -1
  206. package/dist/{skills-Biq5uup3.js → skills-BZdRQkxs.js} +4 -3
  207. package/dist/{skills-C5CbhhpG.js → skills-Dle4ZoLF.js} +2 -2
  208. package/dist/{snapshot-Enxd0n-s.js → snapshot-Dc9CHPME.js} +116 -13
  209. package/dist/{snapshot-cache-D6jLelln.js → snapshot-cache-Cod40D-X.js} +1 -1
  210. package/dist/{snapshot-cache-B-LxUahj.d.ts → snapshot-cache-J5T7t_vD.d.ts} +1 -1
  211. package/dist/solid/index.d.ts +22 -22
  212. package/dist/solid/index.js +40 -10
  213. package/dist/{sources-4p0-4xQm.js → sources-B1-b-R0t.js} +25 -26
  214. package/dist/{sources-BdOYLw9j.d.ts → sources-Dljy06zL.d.ts} +6 -2
  215. package/dist/{sql-xdg5f4Fi.js → sql-t8teWOfo.js} +19 -3
  216. package/dist/{src-BDwEZWrZ.js → src-Bb7no-hS.js} +194 -123
  217. package/dist/ssf/index.d.ts +3 -3
  218. package/dist/ssf/index.js +58 -61
  219. package/dist/store-9KO4BlkB.d.ts +147 -0
  220. package/dist/{store-DMAWwAJV.js → store-ILkLUj_o.js} +4 -4
  221. package/dist/{store-options-ByhZq1jV.js → store-options-CbUHDoSe.js} +16 -7
  222. package/dist/{stores-BT2pSCub.js → stores-BHXdSbw9.js} +7 -3
  223. package/dist/stores-Cxz_80g-.d.ts +31 -0
  224. package/dist/{stream-DDpvynSB.js → stream-BMui98KK.js} +2 -1
  225. package/dist/{stream-D1RXubw2.d.ts → stream-Dwsg6WwE.d.ts} +2 -2
  226. package/dist/{subject-settings-BwBmCdTR.js → subject-settings-CkEwrw3W.js} +23 -5
  227. package/dist/supabase/index.d.ts +4 -4
  228. package/dist/supabase/index.js +3 -3
  229. package/dist/supabase/middleware.d.ts +11 -5
  230. package/dist/supabase/middleware.js +10 -7
  231. package/dist/{supabase-C0qESOa-.js → supabase-C1TWlnYt.js} +36 -52
  232. package/dist/{supabase-s4o9s1O1.js → supabase-Dbqr141s.js} +2 -2
  233. package/dist/supabase-hook-BULg7Ut_.js +2 -0
  234. package/dist/{supabase-hook-ntETYCQu.js → supabase-hook-Cva3ikXz.js} +730 -781
  235. package/dist/{supautils-u8jXTGrt.js → supautils-B6HYL9eL.js} +2 -2
  236. package/dist/svelte/Protected.svelte +9 -2
  237. package/dist/svelte/index.d.ts +6 -6
  238. package/dist/svelte/index.js +25 -11
  239. package/dist/svelte/runtime.d.ts +10 -3
  240. package/dist/svelte/runtime.js +4 -3
  241. package/dist/svelte/source.d.ts +6 -6
  242. package/dist/svelte/source.js +4 -4
  243. package/dist/{validity-uOp8UpYn.js → tenancy-DshjqY0n.js} +3 -39
  244. package/dist/terminal/index.d.ts +5 -5
  245. package/dist/terminal/index.js +18 -34
  246. package/dist/testing/index.d.ts +89 -77
  247. package/dist/testing/index.js +1276 -1262
  248. package/dist/testing/saas/index.d.ts +4 -4
  249. package/dist/testing/saas/index.js +2 -2
  250. package/dist/testing/saas/permissions.d.ts +1 -1
  251. package/dist/testing/saas/permissions.js +1 -1
  252. package/dist/thenable-QSmvt-vZ.js +65 -0
  253. package/dist/{token-CIWxJOCh.js → token-Bx9qy30p.js} +1 -1
  254. package/dist/tool-refusal-CQ7OEia9.js +41 -0
  255. package/dist/trpc/index.d.ts +5 -5
  256. package/dist/trpc/index.js +7 -8
  257. package/dist/{types-DbjRN55U.d.ts → types-7rJVLWe5.d.ts} +82 -4
  258. package/dist/{types-aqOHTHcp.d.ts → types-Br9IXUBG.d.ts} +1 -1
  259. package/dist/types-ByUo56un.d.ts +88 -0
  260. package/dist/{types-Cq_H8N_X.d.ts → types-C0dee0Qf.d.ts} +2 -2
  261. package/dist/{types-DxiCGmmd.d.ts → types-CIlTojhq.d.ts} +2 -2
  262. package/dist/{types-QCuYJh4q.d.ts → types-CUcFZfpU.d.ts} +1 -1
  263. package/dist/{types-C97IskBw.d.ts → types-CqrjB9o6.d.ts} +1 -1
  264. package/dist/{types-BnIEz1sU.d.ts → types-DHrmBJLH.d.ts} +24 -6
  265. package/dist/{types-Dnemwd77.d.ts → types-DpWfQdSh.d.ts} +5 -1
  266. package/dist/{types-Bvc4oWT6.d.ts → types-DvupZE0w.d.ts} +1 -1
  267. package/dist/unplugin/index.d.ts +1 -1
  268. package/dist/unplugin/index.js +1 -1
  269. package/dist/{usage-B0YuTQRO.js → usage-4G6t643F.js} +2 -2
  270. package/dist/{usage-ItS7nLcl.js → usage-CG9nOYZw.js} +7 -7
  271. package/dist/usage-iFLJ0-rr.js +2 -0
  272. package/dist/{validation-error-Ba264OaJ.d.ts → validation-error-BSSw0l_8.d.ts} +1 -1
  273. package/dist/{validation-error-CnjZ4t92.js → validation-error-ZfUHbKvf.js} +1 -1
  274. package/dist/validity-dVrUEPJt.js +62 -0
  275. package/dist/{verifier-BxBo6I44.js → verifier-DMr_Zmb_.js} +51 -15
  276. package/dist/{rls-verify-CMv9H3wV.js → verify-BztUB3GX.js} +668 -239
  277. package/dist/{version-BvacVTZR.js → version-BEogC-rB.js} +1 -1
  278. package/dist/{views-Soe7bGSC.js → views-CJsrBS1f.js} +23 -1
  279. package/dist/{vocabulary-DA8a8zif.d.ts → vocabulary-hhEFLFoL.d.ts} +15 -6
  280. package/dist/vue/index.d.ts +24 -19
  281. package/dist/vue/index.js +24 -8
  282. package/dist/watch-C8TpsAl4.js +2 -0
  283. package/dist/{watch-DVBJ9cBq.js → watch-zeucB7eF.js} +6 -6
  284. package/dist/webmcp/index.d.ts +2 -2
  285. package/dist/webmcp/index.js +13 -37
  286. package/dist/wrap-Cr93qqdP.d.ts +14 -0
  287. package/dist/wrap-DWsU_5Bd.js +26 -0
  288. package/package.json +8 -8
  289. package/schemas/catalog-v1.json +6 -0
  290. package/skills/permdock-credentials/SKILL.md +1 -1
  291. package/skills/permdock-credentials/references/tokens-keys-links.md +7 -2
  292. package/skills/permdock-data/references/queries-and-rls.md +4 -0
  293. package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +1 -0
  294. package/skills/permdock-wire/SKILL.md +2 -2
  295. package/skills/permdock-wire/references/adapters.md +9 -1
  296. package/skills/permdock-wire/references/policy-options.md +10 -1
  297. package/dist/catalog-CRW5Mx_B.js +0 -2
  298. package/dist/collect-DSWOk19R.js +0 -2
  299. package/dist/create-CMwIl-w2.js +0 -253
  300. package/dist/delegation-D8pUEuce.js +0 -103
  301. package/dist/doctor-DvH0IQff.js +0 -1194
  302. package/dist/doctor-collect-CjdO0wQX.js +0 -665
  303. package/dist/evaluation-items-CfCCejQd.js +0 -40
  304. package/dist/fields-BMl1RxoX.js +0 -26
  305. package/dist/grantee-Cv5Sogfv.js +0 -602
  306. package/dist/hosted-DJQw46hn.js +0 -266
  307. package/dist/instance-options-Dhhcnm3y.js +0 -22
  308. package/dist/load-DwJUafYN.js +0 -2
  309. package/dist/map-AcxK9o_D.js +0 -147
  310. package/dist/permissions-DVoF6dMt.js +0 -662
  311. package/dist/powersync-CrBX8hY6.js +0 -2
  312. package/dist/protected-xAr24FEC.d.ts +0 -40
  313. package/dist/relations-cjOL01hZ.js +0 -505
  314. package/dist/rls-graph-CHwx0V8q.js +0 -435
  315. package/dist/rls-shared-Cewd_-zI.js +0 -495
  316. package/dist/store-jOBYpiXC.d.ts +0 -36
  317. package/dist/stores-C0IMQrGI.d.ts +0 -52
  318. package/dist/supabase-config-Bj3iDH2I.js +0 -472
  319. package/dist/supabase-hook-gd3s8bxB.js +0 -2
  320. package/dist/thenable-okc2xDsj.js +0 -10
  321. package/dist/types-CKNSyIf_.d.ts +0 -52
  322. package/dist/usage-DorAJiU_.js +0 -2
  323. package/dist/watch-BwxLTYMZ.js +0 -2
@@ -1,150 +1,293 @@
1
- import { n as isReadonlyArray, r as sole } from "./compact-y6vfu2_f.js";
2
- import { T as isForbiddenKey, _ as listPermissions, a as formerKeys } from "./permissions-DVoF6dMt.js";
3
- import { a as hasConditionOp, c as isConditionRef, l as isSqlFunctionField } from "./refs-KYb4ntaz.js";
4
- import { p as policyLevels } from "./policy-xyWg7QdR.js";
5
- import { c as rootScope, d as scopeList, o as resolveScope } from "./scopes-CVdPrWN-.js";
6
- import { E as listRoles, l as inheritCondition, m as relationCondition } from "./grantee-Cv5Sogfv.js";
7
- import { t as canonicalJson } from "./canonical-json-DemiK4Vp.js";
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 jsonSchemaOf } from "./catalog-doc-DdGm82XT.js";
14
- import { i as scopeMembershipTable, o as relatedSql, r as scopeColumn } from "./compile-Ba4blROR.js";
15
- import { t as SQL_IDENT } from "./sql-xdg5f4Fi.js";
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 { A as keptRowSql, B as memberVia, C as grantPermissionSql, D as hasMemberFor, E as graphSqlText, F as memberForHelper, G as quoteIdent, H as permittedForHelper, I as memberForSources, J as restrictedHelper, K as quoteLiteral, M as kindFilterSql, N as linkHelper, O as inheritedRowsHelper, P as liveSessionSql, Q as scopeTypeOf, R as memberIdsHelper, S as globalKindFilterSql, U as permittedIdsHelper, W as qualifiedTable, X as scopeSources, Y as roleKindSql, Z as scopeTable, _ as activeUserSql, a as helperSchema, at as tenantTypeOf, b as columnTypesOf, c as signedIn, d as CLOSURE, et as sqlType, h as activeMembershipSql, i as allowEntry, it as tenantClaimSql, j as keyTenantSql, k as isRecord, l as textArray, m as activeInstancesSql, n as GLOBAL_ROLES, nt as subjectClaimSql, o as qualified$1, ot as userIdHelperSql, q as quoteTable, r as HELPERS, rt as subjectIdSql, s as rootName, t as CUSTOM_ROLES, tt as subjectClaimJsonSql, u as CLAIM, v as arrayColumnsOf, w as graphHelper, x as disabledKeep, z as memberRoleOf } from "./rls-shared-Cewd_-zI.js";
19
- import { createHash } from "node:crypto";
20
- //#region src/cli/rls-api-keys.ts
21
- const API_KEY_ALLOWS = "permdock_api_key_allows";
22
- const DEFAULTS = {
23
- claim: "api_key",
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 apiKeyFields(setting) {
34
- const settings = setting === true ? {} : setting;
20
+ function options(db) {
35
21
  return {
36
- claim: field(settings, "claim"),
37
- scopes: field(settings, "scopes"),
38
- tenant: field(settings, "tenant"),
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
- function apiKeysPlan(setting, declared, renamed) {
44
- if (setting === void 0) return;
45
- const fields = apiKeyFields(setting);
46
- const unknown = fields.serviceRoles.filter((role) => !declared.has(role));
47
- if (unknown.length > 0) throw new Error(`PermDock CLI: rls.apiKeys.serviceRoles names roles the policy does not declare: ${unknown.join(", ")}`);
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
- ...fields,
50
- ...Object.keys(renamed).length === 0 ? {} : { renamed }
55
+ query: (sql, values) => client.query(sql, values),
56
+ end: endOnInterrupt(() => client.end())
51
57
  };
52
58
  }
53
- function qualified(ctx, name) {
54
- return `${quoteIdent(ctx.schema ?? "permdock")}.${name}`;
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
- function keyOf(ctx, plan) {
57
- return `(select ${subjectClaimJsonSql(ctx, plan.claim)} as k) api_key`;
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 permissionOf(plan, expr) {
60
- return plan.renamed === void 0 ? expr : `coalesce(${quoteLiteral(JSON.stringify(plan.renamed))}::jsonb ->> ${expr}, ${expr})`;
114
+ function disabledKeep(ctx) {
115
+ return keptKeys(ctx.suspension?.memberships);
61
116
  }
62
- function apiKeyAllowsCall(ctx, grant) {
63
- return `${qualified(ctx, API_KEY_ALLOWS)}(${grant})`;
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 apiKeyAllowsSql(ctx, plan, anonExecute) {
66
- const fn = qualified(ctx, API_KEY_ALLOWS);
67
- const scopes = `k -> ${quoteLiteral(plan.scopes)}`;
68
- const grants = anonExecute ? `revoke execute on function ${fn}(text) from public;
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
- function serviceRolesJson(plan) {
97
- return `${quoteLiteral(JSON.stringify(plan.serviceRoles))}::jsonb`;
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
- function serviceKeyRows(ctx, plan) {
100
- const roles = `k -> ${quoteLiteral(plan.roles)}`;
101
- return ` from ${keyOf(ctx, plan)}
102
- cross join lateral jsonb_array_elements_text(
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
- function serviceKeyFilters(ctx, plan, root, permission) {
107
- const tenant = `k ->> ${quoteLiteral(plan.tenant)}`;
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
- function serviceKeyIdsSql(ctx, plan, root, type) {
116
- const kind = kindFilterSql(ctx, "r.role", { value: "credential" });
117
- const set = ctx.grantSet === true;
118
- const filters = [
119
- ...serviceKeyFilters(ctx, plan, root, grantPermissionSql(set ? "rp.grant_key" : "p_grant")),
120
- ...set ? [] : ["rp.grant_key = p_grant"],
121
- `rp.scope = ${quoteLiteral(root)}`,
122
- ...kind === void 0 ? [] : [kind]
123
- ];
124
- return ` select (k ->> ${quoteLiteral(plan.tenant)})::${type}${set ? ", rp.grant_key" : ""}
125
- ${serviceKeyRows(ctx, plan)}
126
- join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
127
- where ${filters.join("\n and ")}`;
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
- function serviceKeyMemberSql(ctx, plan, root, type) {
130
- return ` select distinct (k ->> ${quoteLiteral(plan.tenant)})::${type}
131
- ${serviceKeyRows(ctx, plan)}
132
- where ${serviceKeyFilters(ctx, plan, root).join("\n and ")}`;
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 ceilingIdsSql(body, allows) {
135
- return ` select ids.id
136
- from (
137
- ${body}
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
- function ceilingHasSql(body, allows) {
142
- return ` select ${allows} and coalesce((
143
- ${body}
144
- ), false)`;
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/rls-conditions.ts
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 ${quoteIdent(ctx.schema ?? "permdock")}.${memberIdsHelper(scope)}())`;
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 ${quoteIdent(ctx.schema ?? "permdock")}.${memberIdsHelper(scope)}())` : `${quoteIdent(condition.field)} = ${tenantClaimSql(ctx)}`,
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 ? `${`${quoteIdent(ctx.schema ?? "permdock")}.${graphHelper(condition.resource)}`}(${quoteLiteral(condition.relation)})` : `${quoteIdent(ctx.schema ?? "permdock")}.${inheritedRowsHelper(condition.resource)}(${quoteLiteral(condition.permission)})`;
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 ${`${quoteIdent(ctx.schema ?? "permdock")}.${CLOSURE.table}`} where resource = ${quoteLiteral(condition.resource)}${depth} and ancestor = any (array(select ${helper}))`;
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.schema ?? "permdock");
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.schema ?? "permdock"}.${CLOSURE.table}`,
617
+ closure: `${helperSchema(ctx)}.${CLOSURE.table}`,
475
618
  closureDepths: ctx.graph?.closures ?? {},
476
- qualify: qualifiedTable,
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/rls-grants.ts
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,56 +749,149 @@ function roleNames(policy) {
606
749
  return [...names];
607
750
  }
608
751
  //#endregion
609
- //#region src/cli/global-roles.ts
610
- function qualify(name, schema) {
611
- return name.includes(".") ? name : `${schema}.${name}`;
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
- function throughTenant(through, roles) {
614
- if (through === void 0 || roles?.tenant === void 0) return;
615
- return qualify(roles.table, "public") === through.table ? roles.tenant : void 0;
765
+ function apiKeyFields(setting) {
766
+ const settings = setting === true ? {} : setting;
767
+ return {
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 ?? [])]
773
+ };
616
774
  }
617
- /** Resolves `source` against `schema`, aliasing the table `alias` and the roles table `<alias>k`. */
618
- function globalRoleSource(source, schema, alias, roles) {
619
- const table = qualify(source.table, schema);
620
- const user = source.user ?? "user_id";
621
- const role = roleColumn(source.role ?? "role", table, alias, {
622
- label: "roles.role",
623
- prefix: "PermDock CLI",
624
- indent: " "
625
- });
626
- const tenant = role.lateral ? void 0 : throughTenant(role.through, roles);
627
- const global = tenant === void 0 ? "" : ` and ${alias}k.${quoteIdent(tenant)} is null`;
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(", ")}`);
628
780
  return {
629
- table,
630
- user,
631
- from: `${quoteTable(table)} ${alias}${role.join}${global}`,
632
- userSql: `${alias}.${quoteIdent(user)}`,
633
- roleSql: role.sql,
634
- role: roleManifest(role),
635
- ...role.through === void 0 ? {} : { through: role.through },
636
- ...tenant === void 0 ? {} : { tenant }
781
+ ...fields,
782
+ ...Object.keys(renamed).length === 0 ? {} : { renamed }
637
783
  };
638
784
  }
639
- //#endregion
640
- //#region src/cli/rls-custom-roles.ts
641
- /** The readers every helpers file ends with. Names are part of the SQL contract. */
642
- const READERS = {
643
- keys: "permdock_permission_keys",
644
- roles: "permdock_role_permissions",
645
- trusted: "permdock_trusted_role_permissions"
646
- };
647
- /**
648
- * The custom-role objects: the tables (`database` mode), the ceiling view
649
- * over the `assignable` declared roles, and `permdock_custom_keys`, which
650
- * resolves one custom role to the grant keys it holds in a scope. A row in
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
651
887
  * the tables can never reach a key outside the view.
652
888
  */
653
889
  function customRolesSql(ctx) {
654
890
  const custom = ctx.customRoles;
655
891
  if (custom === void 0) return "";
656
- const rp = qualified$1(ctx, "role_permissions");
657
- const ceiling = qualified$1(ctx, CUSTOM_ROLES.ceiling);
658
- const keys = qualified$1(ctx, CUSTOM_ROLES.keys);
892
+ const rp = qualified(ctx, "role_permissions");
893
+ const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
894
+ const keys = qualified(ctx, CUSTOM_ROLES.keys);
659
895
  const chunks = [];
660
896
  if (ctx.authorize === "database") {
661
897
  const tenantType = tenantTypeOf(ctx);
@@ -669,10 +905,10 @@ function customRolesSql(ctx) {
669
905
  "include_role",
670
906
  ""
671
907
  ]]) {
672
- const table = qualified$1(ctx, name);
908
+ const table = qualified(ctx, name);
673
909
  const leveled = custom.levels === true && name === CUSTOM_ROLES.permissions;
674
910
  const unique = `${column}${name === CUSTOM_ROLES.permissions ? ", effect" : ""}${leveled ? ", coalesce(level, '')" : ""}`;
675
- const index = leveled ? `drop index if exists ${qualified$1(ctx, quoteIdent(`${name}_key`))};
911
+ const index = leveled ? `drop index if exists ${qualified(ctx, quoteIdent(`${name}_key`))};
676
912
  alter table ${table} add column if not exists level text check (level ~ '^[a-z][a-z0-9_]*$');
677
913
  create unique index if not exists ${quoteIdent(`${name}_level_key`)}` : `create unique index if not exists ${quoteIdent(`${name}_key`)}`;
678
914
  chunks.push(`create table if not exists ${table} (
@@ -686,6 +922,8 @@ create unique index if not exists ${quoteIdent(`${name}_level_key`)}` : `create
686
922
  );
687
923
  ${index}
688
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;
689
927
  alter table ${table} enable row level security;
690
928
  revoke all on table ${table} from anon, authenticated, public;`);
691
929
  }
@@ -694,7 +932,7 @@ revoke all on table ${table} from anon, authenticated, public;`);
694
932
  create or replace view ${ceiling} with (security_invoker = true) as
695
933
  select rp.scope, rp.role, rp.permission, rp.grant_key, rp.effect
696
934
  from ${rp} rp
697
- where rp.role = any(${textArray(custom.assignable)})
935
+ where rp.role = any(${sqlTextArray(custom.assignable)})
698
936
  and exists (
699
937
  select 1 from ${rp} a
700
938
  where a.role = rp.role
@@ -773,9 +1011,9 @@ revoke execute on function ${keys}(text[], text[], text[], text) from public, an
773
1011
  * `unknown-level`.
774
1012
  */
775
1013
  function leveledKeysSql(ctx, renames) {
776
- const rp = qualified$1(ctx, "role_permissions");
777
- const ceiling = qualified$1(ctx, CUSTOM_ROLES.ceiling);
778
- const keys = qualified$1(ctx, CUSTOM_ROLES.keys);
1014
+ const rp = qualified(ctx, "role_permissions");
1015
+ const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
1016
+ const keys = qualified(ctx, CUSTOM_ROLES.keys);
779
1017
  const renamed = renames.length > 0;
780
1018
  const prelude = renamed ? `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
781
1019
  ` : "";
@@ -906,15 +1144,15 @@ function renamesSql(renames) {
906
1144
  * `resolveCustomRole` returns it, never for a deny that merely removes an allow.
907
1145
  */
908
1146
  function roleReadersSql(ctx, permissions, trustedReaders) {
909
- const rp = qualified$1(ctx, "role_permissions");
910
- const reader = qualified$1(ctx, READERS.roles);
1147
+ const rp = qualified(ctx, "role_permissions");
1148
+ const reader = qualified(ctx, READERS.roles);
911
1149
  const tenantType = tenantTypeOf(ctx);
912
1150
  const custom = ctx.customRoles;
913
1151
  const root = ctx.scopes[0]?.name;
914
1152
  const database = ctx.authorize === "database";
915
1153
  const chunks = [];
916
1154
  if (permissions !== void 0) {
917
- const catalog = qualified$1(ctx, READERS.keys);
1155
+ const catalog = qualified(ctx, READERS.keys);
918
1156
  chunks.push(`-- every declared permission key
919
1157
  create or replace function ${catalog}()
920
1158
  returns setof text
@@ -922,7 +1160,7 @@ language sql
922
1160
  stable
923
1161
  set search_path = ''
924
1162
  as $$
925
- select k.permission from unnest(${textArray(permissions.toSorted())}) k(permission) order by 1
1163
+ select k.permission from unnest(${sqlTextArray(permissions.toSorted())}) k(permission) order by 1
926
1164
  $$;
927
1165
  revoke execute on function ${catalog}() from public, anon;
928
1166
  grant execute on function ${catalog}() to authenticated;
@@ -938,7 +1176,7 @@ as $$
938
1176
  from ${rp} rp
939
1177
  where rp.scope = p_scope
940
1178
  and rp.effect = 'allow'
941
- and rp.permission = any(${textArray(permissions.toSorted())})
1179
+ and rp.permission = any(${sqlTextArray(permissions.toSorted())})
942
1180
  order by 1
943
1181
  $$;
944
1182
  revoke execute on function ${catalog}(text) from public, anon;
@@ -955,24 +1193,24 @@ ${declared};
955
1193
  end;`;
956
1194
  let trustedBody = body;
957
1195
  if (custom !== void 0 && database && root !== void 0) {
958
- const tenant = qualified$1(ctx, CUSTOM_ROLES.permissions);
959
- const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
960
- const keys = qualified$1(ctx, CUSTOM_ROLES.keys);
961
- const shape = qualified$1(ctx, CUSTOM_ROLES.shape);
962
- const ids = (helper) => `(select x::text from ${qualified$1(ctx, helper)}() x)`;
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)`;
963
1201
  const memberOf = `p_tenant::text in ${ids(memberIdsHelper(root))}
964
1202
  or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${ids(memberIdsHelper(scope))}`)}`;
965
1203
  const manage = custom.manage ?? [];
966
1204
  const platform = manage.length === 0 ? "false" : `exists (
967
1205
  select 1 from ${rp} m
968
1206
  where m.effect = 'allow'
969
- and m.permission = any(${textArray(manage)})
970
- and ${qualified$1(ctx, HELPERS.has)}(m.grant_key)
1207
+ and m.permission = any(${sqlTextArray(manage)})
1208
+ and ${qualified(ctx, HELPERS.has)}(m.grant_key)
971
1209
  )`;
972
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`;
973
1211
  const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
974
1212
  const checked = (gate) => `begin
975
- if p_role = any(${textArray(custom.declared)}) then
1213
+ if p_role = any(${sqlTextArray(custom.declared)}) then
976
1214
  return query
977
1215
  ${declared};
978
1216
  return;
@@ -1010,7 +1248,7 @@ ${body}
1010
1248
  $$;
1011
1249
  revoke execute on function ${reader}(text, text, ${tenantType}, text) from public, anon;
1012
1250
  grant execute on function ${reader}(text, text, ${tenantType}, text) to authenticated;`);
1013
- const trusted = qualified$1(ctx, READERS.trusted);
1251
+ const trusted = qualified(ctx, READERS.trusted);
1014
1252
  const signature = `text, text, ${tenantType}, text`;
1015
1253
  const grants = trustedReaders === void 0 || trustedReaders.length === 0 ? "" : `\ngrant execute on function ${trusted}(${signature}) to ${trustedReaders.map(quoteIdent).join(", ")};`;
1016
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)
@@ -1044,594 +1282,39 @@ ${indent} hint = ${quoteLiteral(hint)};`;
1044
1282
  function hasCustomRoleChecksFor(ctx) {
1045
1283
  return ctx.authorize === "database" && ctx.customRoles !== void 0 && ctx.scopes.length > 0 && ctx.scopes.every((scope) => hasMemberFor(ctx, scope.name));
1046
1284
  }
1047
- function callerFunctionsSql(ctx, custom, root, levelReach, forUser) {
1048
- const leveled = custom.levels === true;
1049
- const tenantType = tenantTypeOf(ctx);
1050
- const rp = qualified$1(ctx, "role_permissions");
1051
- const keys = qualified$1(ctx, CUSTOM_ROLES.keys);
1052
- const perms = qualified$1(ctx, CUSTOM_ROLES.permissions);
1053
- const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
1054
- const shape = qualified$1(ctx, CUSTOM_ROLES.shape);
1055
- const beyond = qualified$1(ctx, forUser ? CUSTOM_ROLES.beyondFor : CUSTOM_ROLES.beyond);
1056
- const guard = qualified$1(ctx, forUser ? CUSTOM_ROLES.guardFor : CUSTOM_ROLES.guard);
1057
- const user = forUser ? "p_user" : subjectIdSql(ctx);
1058
- const userParam = forUser ? "p_user uuid, " : "";
1059
- const userArg = forUser ? "uuid, " : "";
1060
- const userCall = forUser ? "p_user, " : "";
1061
- const has = (key) => forUser ? `${qualified$1(ctx, HELPERS.hasFor)}(p_user, ${key})` : `${qualified$1(ctx, HELPERS.has)}(${key})`;
1062
- const ids = (helper, arg) => `(select x::text from ${qualified$1(ctx, helper)}(${arg}) x)`;
1063
- const permitted = (scope, key) => forUser ? ids(permittedForHelper(scope), `p_user, ${key}`) : ids(permittedIdsHelper(scope), key);
1064
- const members = (scope) => forUser ? ids(memberForHelper(scope), "p_user") : ids(memberIdsHelper(scope), "");
1065
- const heldKey = (key) => `(${has(key)}
1066
- or (p_scope <> 'global'
1067
- and (p_tenant::text in ${permitted(root, key)}
1068
- or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${permitted(scope, key)}`)})))`;
1069
- const holdsAny = (permissions, held) => permissions.length === 0 ? "false" : `exists (
1070
- select 1 from ${rp} rp
1071
- where rp.effect = 'allow'
1072
- and rp.permission = any(${textArray(permissions)})
1073
- and ${held("rp.grant_key")}
1074
- )`;
1075
- const manageKeys = custom.manage ?? [];
1076
- const manage = holdsAny(manageKeys, heldKey);
1077
- const platform = holdsAny(manageKeys, has);
1078
- const requires = custom.requires === void 0 ? "" : `
1079
- if not ${holdsAny(custom.requires, heldKey)} then
1080
- ${raiseSql(" ", "42501", `'permdock: the caller may not manage roles in ' || coalesce(p_tenant::text, 'the platform')`, "manage-roles")}
1081
- end if;`;
1082
- const reachRows = levelReach.map(([grantKey, level]) => `(${quoteLiteral(grantKey)}, ${quoteLiteral(level)})`).join(", ");
1083
- const levelCheck = leveled && reachRows !== "" ? `
1084
- or exists (
1085
- select 1
1086
- from reach x
1087
- where x.grant_key = r.grant_key
1088
- and not exists (
1089
- select 1 from held h
1090
- join reach o on o.grant_key = h.grant_key
1091
- where h.permission = r.permission and o.level = x.level
1092
- )
1093
- )` : "";
1094
- const reachCte = leveled && reachRows !== "" ? `,
1095
- reach (grant_key, level) as (values ${reachRows})` : "";
1096
- 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`;
1097
- const memberOf = `p_tenant::text in ${members(root)}
1098
- or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${members(scope)}`)}`;
1285
+ //#endregion
1286
+ //#region src/cli/sql/global-roles.ts
1287
+ function qualify(name, schema) {
1288
+ return name.includes(".") ? name : `${schema}.${name}`;
1289
+ }
1290
+ function throughTenant(through, roles) {
1291
+ if (through === void 0 || roles?.tenant === void 0) return;
1292
+ return qualify(roles.table, "public") === through.table ? roles.tenant : void 0;
1293
+ }
1294
+ /** Resolves `source` against `schema`, aliasing the table `alias` and the roles table `<alias>k`. */
1295
+ function globalRoleSource(source, schema, alias, roles) {
1296
+ const table = qualify(source.table, schema);
1297
+ const user = source.user ?? "user_id";
1298
+ const role = roleColumn(source.role ?? "role", table, alias, {
1299
+ label: "roles.role",
1300
+ prefix: "PermDock CLI",
1301
+ indent: " "
1302
+ });
1303
+ const tenant = role.lateral ? void 0 : throughTenant(role.through, roles);
1304
+ const global = tenant === void 0 ? "" : ` and ${alias}k.${quoteIdent(tenant)} is null`;
1099
1305
  return {
1100
- beyond: `-- custom-role writes: the permission keys of a definition the caller may not hand out in the tenant, as assignablePermissions computes them
1101
- 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[])
1102
- returns setof text
1103
- language plpgsql
1104
- stable
1105
- security definer
1106
- set search_path = ''
1107
- as $$
1108
- begin
1109
- if ${manage} then
1110
- return;
1111
- end if;
1112
- return query
1113
- with resolved as (
1114
- select distinct rp.permission, k.key as grant_key
1115
- from ${keys}(p_allow, p_deny, p_include, p_scope) k(key)
1116
- join ${rp} rp on rp.grant_key = k.key and rp.effect = 'allow'
1117
- ),
1118
- held as (
1119
- select distinct rp.permission, rp.grant_key
1120
- from ${rp} rp
1121
- where rp.effect = 'allow'
1122
- and rp.permission in (select r.permission from resolved r)
1123
- and ${heldKey("rp.grant_key")}
1124
- )${reachCte}
1125
- select distinct r.permission
1126
- from resolved r
1127
- where not exists (select 1 from held h where h.permission = r.permission)${levelCheck};
1128
- end;
1129
- $$;
1130
- revoke execute on function ${beyond}(${userArg}${tenantType}, text, text, text[], text[], text[]) from public, anon, authenticated;`,
1131
- 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
1132
- create or replace function ${guard}(${userParam}p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
1133
- returns void
1134
- language plpgsql
1135
- stable
1136
- security definer
1137
- set search_path = ''
1138
- as $$
1139
- declare
1140
- v_allow text[];
1141
- v_deny text[];
1142
- v_include text[];
1143
- v_beyond text;
1144
- begin
1145
- perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
1146
- if not (${signedIn(ctx, user)} and (
1147
- ${platform}
1148
- or (p_scope <> 'global' and (${memberOf}))
1149
- )) then
1150
- ${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
1151
- end if;${requires}
1152
- select array_agg(${allowEntry(ctx)}) filter (where c.effect = 'allow'),
1153
- array_agg(c.permission) filter (where c.effect = 'deny')
1154
- into v_allow, v_deny
1155
- from ${perms} c
1156
- where ${match} and c.role = p_role;
1157
- select array_agg(c.include_role) into v_include
1158
- from ${includes} c
1159
- where ${match} and c.role = p_role;
1160
- select string_agg(b, ', ' order by b) into v_beyond
1161
- from ${beyond}(${userCall}p_tenant, p_scope, p_scope_id, v_allow, v_deny, v_include) b;
1162
- if v_beyond is not null then
1163
- ${raiseSql(" ", "42501", `'permdock: ' || p_role || ' allows ' || v_beyond || ', which the caller may not hand out'`, "not-assignable-by")}
1164
- end if;
1165
- end;
1166
- $$;
1167
- revoke execute on function ${guard}(${userArg}${tenantType}, text, text, text) from public, anon, authenticated;`
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 }
1168
1314
  };
1169
1315
  }
1170
- /**
1171
- * The functions an application calls to save, rename and delete a custom
1172
- * role in \`database\` mode, with the rules of \`validateCustomRole\` and
1173
- * \`assignablePermissions\`: entries inside the ceiling of the role's scope,
1174
- * includes naming declared roles, and only permissions and levels the caller
1175
- * may hand out, for the new definition and the stored one alike. A platform
1176
- * custom role (\`p_scope = 'global'\`, no tenant) is written by a caller who
1177
- * holds a \`meta.manageRoles\` permission through a global role. The
1178
- * \`permdock_trusted_*\` variants skip the caller checks and keep the
1179
- * definition checks; no client role may execute them.
1180
- */
1181
- function customRoleWritesSql(ctx, levelReach) {
1182
- const custom = ctx.customRoles;
1183
- const root = ctx.scopes[0]?.name;
1184
- if (custom === void 0 || ctx.authorize !== "database" || root === void 0) return "";
1185
- const leveled = custom.levels === true;
1186
- const tenantType = tenantTypeOf(ctx);
1187
- const rp = qualified$1(ctx, "role_permissions");
1188
- const ceiling = qualified$1(ctx, CUSTOM_ROLES.ceiling);
1189
- const perms = qualified$1(ctx, CUSTOM_ROLES.permissions);
1190
- const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
1191
- const beyond = qualified$1(ctx, CUSTOM_ROLES.beyond);
1192
- const guard = qualified$1(ctx, CUSTOM_ROLES.guard);
1193
- const shape = qualified$1(ctx, CUSTOM_ROLES.shape);
1194
- const entries = qualified$1(ctx, CUSTOM_ROLES.entries);
1195
- const renames = Object.entries(custom.renamed ?? {}).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1196
- const current = (expr) => currentKeyExpr(renames, expr);
1197
- const known = textArray([...custom.permissions ?? [], ...renames.map(([former]) => former)]);
1198
- const caller = callerFunctionsSql(ctx, custom, root, levelReach, false);
1199
- 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`;
1200
- const scopes = textArray(ctx.scopes.map((scope) => scope.name));
1201
- const roleName = (param) => `coalesce(${param}, '') = '' or ${param} = any(${textArray(custom.declared)})`;
1202
- const levelOf = leveled ? ", nullif(split_part(e, '@', 2), '')" : "";
1203
- const write = ` delete from ${perms} c where ${match} and c.role = p_role;
1204
- delete from ${includes} c where ${match} and c.role = p_role;
1205
- insert into ${perms} (tenant_id, scope, scope_id, role, permission, effect${leveled ? ", level" : ""})
1206
- select distinct p_tenant, p_scope, p_scope_id, p_role, ${current(leveled ? "split_part(e, '@', 1)" : "e")}, 'allow'${levelOf}
1207
- from unnest(coalesce(p_allow, '{}'::text[])) e
1208
- union
1209
- select distinct p_tenant, p_scope, p_scope_id, p_role, ${current("e")}, 'deny'${leveled ? ", null" : ""}
1210
- from unnest(coalesce(p_deny, '{}'::text[])) e;
1211
- insert into ${includes} (tenant_id, scope, scope_id, role, include_role)
1212
- select distinct p_tenant, p_scope, p_scope_id, p_role, e
1213
- from unnest(coalesce(p_include, '{}'::text[])) e;`;
1214
- const move = ` if ${roleName("p_to")} then
1215
- ${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_to, 'null') || ' is not a custom role name'`, "declared-role")}
1216
- end if;
1217
- if exists (select 1 from ${perms} c where ${match} and c.role = p_to)
1218
- or exists (select 1 from ${includes} c where ${match} and c.role = p_to) then
1219
- ${raiseSql(" ", "22023", `'permdock: ' || p_to || ' already has grants'`, "role-exists")}
1220
- end if;
1221
- update ${perms} c set role = p_to where ${match} and c.role = p_from;
1222
- update ${includes} c set role = p_to where ${match} and c.role = p_from;`;
1223
- const remove = ` delete from ${perms} c where ${match} and c.role = p_role;
1224
- delete from ${includes} c where ${match} and c.role = p_role;`;
1225
- const writeArgs = `${tenantType}, text, text, text, text[], text[], text[]`;
1226
- const renameArgs = `${tenantType}, text, text, text, text`;
1227
- const removeArgs = `${tenantType}, text, text, text`;
1228
- const fn = (comment, name, params, args, body, member) => `-- ${comment}
1229
- create or replace function ${qualified$1(ctx, name)}(${params})
1230
- returns void
1231
- language plpgsql
1232
- volatile
1233
- security definer
1234
- set search_path = ''
1235
- as $$
1236
- ${body}
1237
- $$;
1238
- ${member ? `revoke execute on function ${qualified$1(ctx, name)}(${args}) from public, anon;
1239
- grant execute on function ${qualified$1(ctx, name)}(${args}) to authenticated;` : `revoke execute on function ${qualified$1(ctx, name)}(${args}) from public, anon, authenticated;`}`;
1240
- const writeParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text, p_allow text[], p_deny text[], p_include text[]`;
1241
- const renameParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_from text, p_to text`;
1242
- const removeParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
1243
- return `${caller.beyond}
1244
-
1245
- -- where a custom role may live: a scope of a tenant, or the platform (scope global, no tenant or instance), under an undeclared name
1246
- create or replace function ${shape}(p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
1247
- returns void
1248
- language plpgsql
1249
- stable
1250
- set search_path = ''
1251
- as $$
1252
- begin
1253
- if p_scope = 'global' then
1254
- if p_tenant is not null or p_scope_id is not null then
1255
- ${raiseSql(" ", "22023", `'permdock: a platform custom role has no tenant or instance'`, "unknown-scope")}
1256
- end if;
1257
- elsif p_tenant is null or not (p_scope = any(${scopes})) then
1258
- ${raiseSql(" ", "22023", `'permdock: custom roles are held at a scope of a tenant or at global, not ' || coalesce(p_scope, 'null')`, "unknown-scope")}
1259
- end if;
1260
- if ${roleName("p_role")} then
1261
- ${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_role, 'null') || ' is not a custom role name'`, "declared-role")}
1262
- end if;
1263
- end;
1264
- $$;
1265
- revoke execute on function ${shape}(${tenantType}, text, text, text) from public, anon, authenticated;
1266
-
1267
- ${caller.guard}
1268
- ${hasCustomRoleChecksFor(ctx) ? `
1269
- -- the same checks for a user the caller names: trusted SQL acting later for a stored user; no client role may execute them
1270
- ${callerFunctionsSql(ctx, custom, root, levelReach, true).beyond}
1271
-
1272
- ${callerFunctionsSql(ctx, custom, root, levelReach, true).guard}
1273
- ` : ""}
1274
- -- the entries of a definition: declared keys and levels inside the ceiling of the scope, and declared includes
1275
- create or replace function ${entries}(p_scope text, p_allow text[], p_deny text[], p_include text[])
1276
- returns void
1277
- language plpgsql
1278
- stable
1279
- security definer
1280
- set search_path = ''
1281
- as $$
1282
- declare
1283
- v_entry text;
1284
- v_key text;
1285
- begin
1286
- foreach v_entry in array coalesce(p_allow, '{}'::text[]) loop
1287
- v_key := ${current(leveled ? "split_part(v_entry, '@', 1)" : "v_entry")};
1288
- if not (v_key = any(${known})) then
1289
- ${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared permission'`, "unknown-permission")}
1290
- end if;
1291
- if not exists (
1292
- select 1 from ${ceiling} c
1293
- where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
1294
- ) then
1295
- ${raiseSql(" ", "22023", `'permdock: ' || v_key || ' is outside the ceiling of ' || p_scope`, "outside-ceiling")}
1296
- end if;${leveled ? `
1297
- if strpos(v_entry, '@') > 0 and not exists (
1298
- select 1 from ${ceiling} c
1299
- where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
1300
- and split_part(c.grant_key, '@', 2) = split_part(v_entry, '@', 2)
1301
- ) then
1302
- ${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' names a level the resource does not declare'`, "unknown-level")}
1303
- end if;` : ""}
1304
- end loop;
1305
- foreach v_entry in array coalesce(p_deny, '{}'::text[]) loop
1306
- if not (v_entry = any(${known})) then
1307
- ${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared permission'`, "unknown-permission")}
1308
- end if;
1309
- end loop;
1310
- foreach v_entry in array coalesce(p_include, '{}'::text[]) loop
1311
- if not (v_entry = any(${textArray(custom.declared)})) then
1312
- ${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared role'`, "unknown-role")}
1313
- end if;
1314
- end loop;
1315
- select string_agg(distinct ${current("rp.permission")}, ', ') into v_key
1316
- from ${rp} rp
1317
- where rp.role = any(coalesce(p_include, '{}'::text[]))
1318
- and rp.effect = 'allow'
1319
- and not exists (
1320
- select 1 from ${ceiling} c
1321
- where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = ${current("rp.permission")}
1322
- );
1323
- if v_key is not null then
1324
- ${raiseSql(" ", "22023", `'permdock: an included role allows ' || v_key || ', outside the ceiling of ' || p_scope`, "outside-ceiling")}
1325
- end if;
1326
- end;
1327
- $$;
1328
- revoke execute on function ${entries}(text, text[], text[], text[]) from public, anon, authenticated;
1329
-
1330
- ${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
1331
- v_beyond text;
1332
- begin
1333
- perform ${guard}(p_tenant, p_scope, p_scope_id, p_role);
1334
- perform ${entries}(p_scope, p_allow, p_deny, p_include);
1335
- select string_agg(b, ', ' order by b) into v_beyond
1336
- from ${beyond}(p_tenant, p_scope, p_scope_id, p_allow, p_deny, p_include) b;
1337
- if v_beyond is not null then
1338
- ${raiseSql(" ", "42501", `'permdock: ' || p_role || ' would allow ' || v_beyond || ', which the caller may not hand out'`, "not-assignable-by")}
1339
- end if;
1340
- ${write}
1341
- end;`, true)}
1342
-
1343
- ${fn("rename a custom role: move its grants and includes to the new name", CUSTOM_ROLES.rename, renameParams, renameArgs, `begin
1344
- perform ${guard}(p_tenant, p_scope, p_scope_id, p_from);
1345
- ${move}
1346
- end;`, true)}
1347
-
1348
- ${fn("delete a custom role: remove its grants and includes", CUSTOM_ROLES.remove, removeParams, removeArgs, `begin
1349
- perform ${guard}(p_tenant, p_scope, p_scope_id, p_role);
1350
- ${remove}
1351
- end;`, true)}
1352
-
1353
- ${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
1354
- perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
1355
- perform ${entries}(p_scope, p_allow, p_deny, p_include);
1356
- ${write}
1357
- end;`, false)}
1358
-
1359
- ${fn("rename a custom role for a trusted caller", CUSTOM_ROLES.trustedRename, renameParams, renameArgs, `begin
1360
- perform ${shape}(p_tenant, p_scope, p_scope_id, p_from);
1361
- ${move}
1362
- end;`, false)}
1363
-
1364
- ${fn("delete a custom role for a trusted caller", CUSTOM_ROLES.trustedRemove, removeParams, removeArgs, `begin
1365
- perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
1366
- ${remove}
1367
- end;`, false)}`;
1368
- }
1369
- /** `row.column`, for a trigger's `old` or `new`. */
1370
- function rowColumn(row, name) {
1371
- return `${row}.${quoteIdent(name)}`;
1372
- }
1373
- /** The custom-role rows of the role a trigger's `v_<prefix>_*` variables name. */
1374
- function cascadeMatch(prefix) {
1375
- 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`;
1376
- }
1377
- /**
1378
- * The trigger on the application's roles table (`rls.customRoleWrites.roles`):
1379
- * a rename, a move to another tenant, scope or instance, or a delete of a
1380
- * custom role's row carries its grants and includes along. A signed-in
1381
- * caller at trigger depth 1 passes the write functions' checks: authority
1382
- * over the stored definition where it was, and, after a move, membership
1383
- * and authority where it lands and entries inside that scope's ceiling.
1384
- * Other paths (a migration, a job, a nested trigger) move the rows as the
1385
- * function owner.
1386
- */
1387
- function customRoleCascadeSql(ctx) {
1388
- const custom = ctx.customRoles;
1389
- const table = custom?.table;
1390
- const root = ctx.scopes[0]?.name;
1391
- if (custom === void 0 || table === void 0 || root === void 0) return "";
1392
- const tenantType = tenantTypeOf(ctx);
1393
- const perms = qualified$1(ctx, CUSTOM_ROLES.permissions);
1394
- const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
1395
- const fn = qualified$1(ctx, CUSTOM_ROLES.cascade);
1396
- const target = quoteTable(table.table.includes(".") ? table.table : `public.${table.table}`);
1397
- const tenant = (row) => table.tenant === void 0 ? `null::${tenantType}` : `${rowColumn(row, table.tenant)}::${tenantType}`;
1398
- 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`;
1399
- const id = (row) => table.id === void 0 ? "null::text" : `${rowColumn(row, table.id)}::text`;
1400
- const skipped = (row) => table.skip === void 0 ? "false" : `coalesce(${rowColumn(row, table.skip)}, false)`;
1401
- const guard = qualified$1(ctx, CUSTOM_ROLES.guard);
1402
- const entries = qualified$1(ctx, CUSTOM_ROLES.entries);
1403
- const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
1404
- return `-- the application's roles table: renames, moves and deletes of a custom role carry its grants and includes
1405
- create or replace function ${fn}()
1406
- returns trigger
1407
- language plpgsql
1408
- volatile
1409
- security definer
1410
- set search_path = ''
1411
- as $$
1412
- declare
1413
- v_old_tenant ${tenantType} := ${tenant("old")};
1414
- v_old_scope text := ${scope("old")};
1415
- v_old_id text := ${id("old")};
1416
- v_old_key text := ${rowColumn("old", table.key)}::text;
1417
- v_new_tenant ${tenantType};
1418
- v_new_scope text;
1419
- v_new_id text;
1420
- v_new_key text;
1421
- v_checked boolean := ${signedIn(ctx)} and pg_trigger_depth() = 1;
1422
- v_allow text[];
1423
- v_deny text[];
1424
- v_include text[];
1425
- begin
1426
- if ${skipped("old")} then
1427
- return null;
1428
- end if;
1429
- if tg_op = 'UPDATE' and not ${skipped("new")} then
1430
- v_new_tenant := ${tenant("new")};
1431
- v_new_scope := ${scope("new")};
1432
- v_new_id := ${id("new")};
1433
- v_new_key := ${rowColumn("new", table.key)}::text;
1434
- if v_new_key = v_old_key
1435
- and v_new_tenant is not distinct from v_old_tenant
1436
- and v_new_scope = v_old_scope
1437
- and v_new_id is not distinct from v_old_id then
1438
- return null;
1439
- end if;
1440
- end if;
1441
- if not exists (select 1 from ${perms} c where ${cascadeMatch("old")})
1442
- and not exists (select 1 from ${includes} c where ${cascadeMatch("old")}) then
1443
- return null;
1444
- end if;
1445
- if v_checked then
1446
- perform ${guard}(v_old_tenant, v_old_scope, v_old_id, v_old_key);
1447
- end if;
1448
- if v_new_key is null then
1449
- delete from ${perms} c where ${cascadeMatch("old")};
1450
- delete from ${includes} c where ${cascadeMatch("old")};
1451
- return null;
1452
- end if;
1453
- if exists (select 1 from ${perms} c where ${cascadeMatch("new")})
1454
- or exists (select 1 from ${includes} c where ${cascadeMatch("new")}) then
1455
- ${raiseSql(" ", "22023", `'permdock: ' || v_new_key || ' already has grants'`, "role-exists")}
1456
- end if;
1457
- 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")};
1458
- 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")};
1459
- if v_checked then
1460
- perform ${guard}(v_new_tenant, v_new_scope, v_new_id, v_new_key);
1461
- select array_agg(${allowEntryOf}) filter (where c.effect = 'allow'),
1462
- array_agg(c.permission) filter (where c.effect = 'deny')
1463
- into v_allow, v_deny
1464
- from ${perms} c
1465
- where ${cascadeMatch("new")};
1466
- select array_agg(c.include_role) into v_include
1467
- from ${includes} c
1468
- where ${cascadeMatch("new")};
1469
- perform ${entries}(v_new_scope, v_allow, v_deny, v_include);
1470
- end if;
1471
- return null;
1472
- end;
1473
- $$;
1474
- revoke execute on function ${fn}() from public, anon, authenticated;
1475
- drop trigger if exists "permdock_custom_role_cascade" on ${target};
1476
- create trigger "permdock_custom_role_cascade"
1477
- after update or delete on ${target}
1478
- for each row execute function ${fn}();
1479
- `;
1480
- }
1481
- //#endregion
1482
- //#region src/cli/rls-global-roles.ts
1483
- function qualifiedName(name) {
1484
- return name.includes(".") ? name : `public.${name}`;
1485
- }
1486
- /**
1487
- * `permdock_replace_global_roles(p_user, p_roles)`: sets the global roles of
1488
- * one user in `rls.roles` in one statement pair. It runs as the invoker, so
1489
- * the table's policies and the `rls.assignments` trigger judge every row it
1490
- * deletes or inserts.
1491
- */
1492
- function globalRolesSql(ctx) {
1493
- if (ctx.roles === void 0) return "";
1494
- const source = globalRoleSource(ctx.roles, "public", "t", ctx.customRoles?.table);
1495
- const table = quoteTable(qualifiedName(ctx.roles.table));
1496
- const user = quoteIdent(source.user);
1497
- const fn = qualified$1(ctx, GLOBAL_ROLES.replace);
1498
- const type = ctx.dialect === "supabase" ? "uuid" : "text";
1499
- const wanted = `select distinct r from unnest(coalesce(p_roles, '{}'::text[])) r where r is not null`;
1500
- const through = source.through;
1501
- let body;
1502
- if (through === void 0) {
1503
- const role = typeof ctx.roles.role === "string" ? ctx.roles.role : "role";
1504
- const column = quoteIdent(role);
1505
- body = ` delete from ${table} t
1506
- where t.${user} = p_user
1507
- and t.${column}::text <> all(coalesce(p_roles, '{}'::text[]));
1508
- insert into ${table} (${user}, ${column})
1509
- select p_user, w.r
1510
- from (${wanted}) w(r)
1511
- where not exists (
1512
- select 1 from ${table} t where t.${user} = p_user and t.${column}::text = w.r
1513
- );`;
1514
- } else {
1515
- const keys = quoteTable(through.table);
1516
- const id = quoteIdent(through.id);
1517
- const key = quoteIdent(through.key);
1518
- const ref = quoteIdent(through.ref);
1519
- const global = source.tenant === void 0 ? "" : ` and k.${quoteIdent(source.tenant)} is null`;
1520
- body = ` select string_agg(w.r, ', ' order by w.r) into v_unknown
1521
- from (${wanted}) w(r)
1522
- where not exists (select 1 from ${keys} k where k.${key}::text = w.r${global});
1523
- if v_unknown is not null then
1524
- raise exception using
1525
- errcode = '22023',
1526
- message = 'permdock: ' || v_unknown || ' is not a role',
1527
- hint = 'unknown-role';
1528
- end if;
1529
- delete from ${table} t
1530
- where t.${user} = p_user
1531
- and not exists (
1532
- select 1 from ${keys} k
1533
- where k.${id} = t.${ref}
1534
- and k.${key}::text = any(coalesce(p_roles, '{}'::text[]))${global}
1535
- );
1536
- insert into ${table} (${user}, ${ref})
1537
- select p_user, k.${id}
1538
- from ${keys} k
1539
- where k.${key}::text in (${wanted})${global}
1540
- and not exists (
1541
- select 1 from ${table} t where t.${user} = p_user and t.${ref} = k.${id}
1542
- );`;
1543
- }
1544
- const declare = through === void 0 ? "" : "declare\n v_unknown text;\n";
1545
- const args = `${type}, text[]`;
1546
- 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
1547
- create or replace function ${fn}(p_user ${type}, p_roles text[])
1548
- returns void
1549
- language plpgsql
1550
- volatile
1551
- set search_path = ''
1552
- as $$
1553
- ${declare}begin
1554
- ${body}
1555
- end;
1556
- $$;
1557
- revoke execute on function ${fn}(${args}) from public, anon;
1558
- grant execute on function ${fn}(${args}) to authenticated;`;
1559
- }
1560
1316
  //#endregion
1561
- //#region src/cli/rls-helpers.ts
1562
- /** The helper `--capabilities` adds: ids of one resource a link capability claim reaches. Part of the SQL contract. */
1563
- const CAPABILITIES = { ids: "permdock_capability_ids" };
1564
- /**
1565
- * The policy-side call for a grant key. Every form is uncorrelated, so
1566
- * Postgres runs the helper once per statement (an InitPlan, or a hashed
1567
- * SubPlan for `in`), never once per row.
1568
- */
1569
- function accessSql(ctx, scope, grantKey, column) {
1570
- const key = quoteLiteral(grantKey);
1571
- if (scope === "global") return `(select ${qualified$1(ctx, HELPERS.has)}(${key}))`;
1572
- if (column === void 0) throw new Error(`PermDock CLI: ${scope}-scoped grant needs definePolicy({ scopes.${scope} })`);
1573
- return `${quoteIdent(column)} in (select ${qualified$1(ctx, permittedIdsHelper(scope))}(${key}))`;
1574
- }
1575
- /**
1576
- * The `anon` branch access for a resource-scoped grant reached through a link
1577
- * capability: the row's `field` holds an id the capability claim names for
1578
- * `resource`, `role` and `permission`. Uncorrelated, so it runs once per
1579
- * statement; the column is compared as text because the claim holds text.
1580
- */
1581
- function capabilityAccessSql(ctx, field, resource, role, permission) {
1582
- const args = [
1583
- resource,
1584
- role,
1585
- permission
1586
- ].map(quoteLiteral).join(", ");
1587
- return `${quoteIdent(field)}::text in (select ${qualified$1(ctx, CAPABILITIES.ids)}(${args}))`;
1588
- }
1589
- function capabilitiesSql(ctx) {
1590
- if (ctx.capabilities !== true) return "";
1591
- const fn = qualified$1(ctx, CAPABILITIES.ids);
1592
- return `-- link capabilities: the capability claim exchangeCapability mints (role anon, short-lived)
1593
- create or replace function ${fn}(p_resource text, p_role text, p_permission text)
1594
- returns setof text
1595
- language sql
1596
- stable
1597
- set search_path = ''
1598
- as $$
1599
- select c -> 'on' ->> 'id'
1600
- from (select ${subjectClaimJsonSql(ctx, "capability")} as c) capability
1601
- where jsonb_typeof(c) = 'object'
1602
- and c ->> 'v' = '1'
1603
- and c ->> 'holder' = 'link'
1604
- and c -> 'on' ->> 'resource' = p_resource
1605
- and jsonb_typeof(c -> 'roles') = 'array'
1606
- and c -> 'roles' @> jsonb_build_array(p_role)
1607
- and (c -> 'permissions' is null or c -> 'permissions' @> jsonb_build_array(p_permission))
1608
- and jsonb_typeof(c -> 'expiresAt') = 'number'
1609
- and (c ->> 'expiresAt')::numeric > extract(epoch from now())
1610
- $$;
1611
- revoke execute on function ${fn}(text, text, text) from public;
1612
- grant execute on function ${fn}(text, text, text) to anon, authenticated;`;
1613
- }
1614
- function userIdSql(ctx, anonExecute) {
1615
- const fn = userIdHelperSql(ctx);
1616
- return `-- the caller's user id: the sub of request.jwt.claims, or null when it is empty or absent
1617
- create or replace function ${fn}()
1618
- returns uuid
1619
- language sql
1620
- stable
1621
- set search_path = ''
1622
- as $$
1623
- select nullif(
1624
- nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub',
1625
- ''
1626
- )::uuid
1627
- $$;
1628
- ${anonExecute ? `revoke execute on function ${fn}() from public;
1629
- grant execute on function ${fn}() to anon, authenticated;` : `revoke execute on function ${fn}() from public, anon;
1630
- grant execute on function ${fn}() to authenticated;`}`;
1631
- }
1632
- function membershipTable(name) {
1633
- return quoteTable(name.includes(".") ? name : `public.${name}`);
1634
- }
1317
+ //#region src/cli/rls/helper-rows.ts
1635
1318
  function activeTenant(ctx) {
1636
1319
  return `nullif(${subjectClaimSql(ctx, ctx.tenantClaim)}, '')`;
1637
1320
  }
@@ -1699,21 +1382,23 @@ function andLine(indent, condition) {
1699
1382
  /** `rls.roles`, or the generated `user_roles (user_id, role)`, aliased `ur`. */
1700
1383
  function globalRoleRows(ctx) {
1701
1384
  return ctx.roles === void 0 ? {
1702
- from: `${qualified$1(ctx, "user_roles")} ur`,
1385
+ from: `${qualified(ctx, "user_roles")} ur`,
1703
1386
  userSql: "ur.user_id",
1704
1387
  roleSql: "ur.role::text"
1705
1388
  } : globalRoleSource(ctx.roles, "public", "ur", ctx.customRoles?.table);
1706
1389
  }
1707
1390
  function customKeysJoin(ctx, scope, allows, denies, includes) {
1708
- return ` cross join lateral ${qualified$1(ctx, CUSTOM_ROLES.keys)}(
1391
+ return ` cross join lateral ${qualified(ctx, CUSTOM_ROLES.keys)}(
1709
1392
  ${allows},
1710
1393
  ${denies},
1711
1394
  ${includes},
1712
1395
  ${quoteLiteral(scope)}
1713
1396
  ) ck(grant_key)`;
1714
1397
  }
1398
+ //#endregion
1399
+ //#region src/cli/rls/helper-bodies.ts
1715
1400
  function hasSetBody(ctx) {
1716
- const rp = qualified$1(ctx, "role_permissions");
1401
+ const rp = qualified(ctx, "role_permissions");
1717
1402
  const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
1718
1403
  const custom = ctx.customRoles;
1719
1404
  if (ctx.authorize === "database") {
@@ -1725,14 +1410,14 @@ function hasSetBody(ctx) {
1725
1410
  and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}`;
1726
1411
  if (custom === void 0) return declared;
1727
1412
  const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
1728
- const rows = (source, value, extra) => `array(select c.${value} from ${qualified$1(ctx, source)} c where ${match}${extra})`;
1413
+ const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1729
1414
  return `${declared}
1730
1415
  union
1731
1416
  select ck.grant_key
1732
1417
  from ${ur.from}
1733
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", ""))}
1734
1419
  where ${ur.userSql} = ${subjectIdSql(ctx)}
1735
- and not (${ur.roleSql} = any(${textArray(custom.declared)}))${active}`;
1420
+ and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}`;
1736
1421
  }
1737
1422
  const declared = ` select rp.grant_key
1738
1423
  from ${roleRows(ctx)}
@@ -1749,11 +1434,11 @@ ${customKeysJoin(ctx, "global", rows(CUSTOM_ROLES.permissions, allowEntry(ctx),
1749
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)"))}
1750
1435
  where ${signedIn(ctx)}
1751
1436
  and jsonb_typeof(cg.g) = 'array'
1752
- and not (r.role = any(${textArray(custom.declared)}))${active}`;
1437
+ and not (r.role = any(${sqlTextArray(custom.declared)}))${active}`;
1753
1438
  }
1754
1439
  function hasBody(ctx) {
1755
1440
  if (ctx.grantSet === true) return hasSetBody(ctx);
1756
- const rp = qualified$1(ctx, "role_permissions");
1441
+ const rp = qualified(ctx, "role_permissions");
1757
1442
  const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
1758
1443
  const custom = ctx.customRoles;
1759
1444
  if (ctx.authorize === "database") {
@@ -1768,13 +1453,13 @@ function hasBody(ctx) {
1768
1453
  )`;
1769
1454
  if (custom === void 0) return declared;
1770
1455
  const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
1771
- const rows = (source, value, extra) => `array(select c.${value} from ${qualified$1(ctx, source)} c where ${match}${extra})`;
1456
+ const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1772
1457
  return `${declared}
1773
1458
  or exists (
1774
1459
  select 1
1775
1460
  from ${ur.from}
1776
1461
  where ${ur.userSql} = ${subjectIdSql(ctx)}
1777
- and not (${ur.roleSql} = any(${textArray(custom.declared)}))${active}
1462
+ and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}
1778
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()}
1779
1464
  )`;
1780
1465
  }
@@ -1793,7 +1478,7 @@ function hasBody(ctx) {
1793
1478
  from ${roleRows(ctx)}
1794
1479
  cross join lateral (select ${claim} -> r.role as g) cg
1795
1480
  where jsonb_typeof(cg.g) = 'array'
1796
- and not (r.role = any(${textArray(custom.declared)}))${active}
1481
+ and not (r.role = any(${sqlTextArray(custom.declared)}))${active}
1797
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()}
1798
1483
  ))`;
1799
1484
  }
@@ -1814,7 +1499,7 @@ function claimEntries(where, value) {
1814
1499
  }
1815
1500
  /** `p_grant in (select permdock_custom_keys(allows, denies, includes, scope))`. */
1816
1501
  function customKeysSql(ctx, scope, allows, denies, includes) {
1817
- return ` and p_grant in (select ${qualified$1(ctx, CUSTOM_ROLES.keys)}(
1502
+ return ` and p_grant in (select ${qualified(ctx, CUSTOM_ROLES.keys)}(
1818
1503
  ${allows},
1819
1504
  ${denies},
1820
1505
  ${includes},
@@ -1848,8 +1533,8 @@ function tableBody(ctx, scope, type) {
1848
1533
  const kind = kindFilterSql(ctx, role.sql, memberVia(table));
1849
1534
  const lines = [
1850
1535
  ` select${role.lateral ? " distinct" : ""} ${memberColumn(column)}::${type}${set ? ", rp.grant_key" : ""}`,
1851
- ` from ${membershipTable(table.table)} m${role.join}`,
1852
- ` join ${qualified$1(ctx, "role_permissions")} rp on rp.role = ${role.sql}`,
1536
+ ` from ${quoteQualified(table.table)} m${role.join}`,
1537
+ ` join ${qualified(ctx, "role_permissions")} rp on rp.role = ${role.sql}`,
1853
1538
  owner ?? "",
1854
1539
  ...set ? [] : [" and rp.grant_key = p_grant"],
1855
1540
  ` and rp.scope = ${quoteLiteral(scope)}`,
@@ -1864,7 +1549,7 @@ function tableBody(ctx, scope, type) {
1864
1549
  `(c.scope_id is null or c.scope_id = ${memberColumn(column)}::text)`,
1865
1550
  `c.role = ${role.sql}`
1866
1551
  ].join(" and ");
1867
- const rows = (source, value, extra) => `array(select c.${value} from ${qualified$1(ctx, source)} c where ${match}${extra})`;
1552
+ const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1868
1553
  const entries = [
1869
1554
  rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"),
1870
1555
  rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"),
@@ -1874,18 +1559,18 @@ function tableBody(ctx, scope, type) {
1874
1559
  ...lines,
1875
1560
  " union",
1876
1561
  ` select ${memberColumn(column)}::${type}, ck.grant_key`,
1877
- ` from ${membershipTable(table.table)} m${role.join}`,
1562
+ ` from ${quoteQualified(table.table)} m${role.join}`,
1878
1563
  customKeysJoin(ctx, scope, ...entries),
1879
1564
  ...filtersFor("ck.grant_key"),
1880
- ` and not (${role.sql} = any(${textArray(custom.declared)}))`
1565
+ ` and not (${role.sql} = any(${sqlTextArray(custom.declared)}))`
1881
1566
  ].join("\n");
1882
1567
  return [
1883
1568
  ...lines,
1884
1569
  " union",
1885
1570
  ` select ${memberColumn(column)}::${type}`,
1886
- ` from ${membershipTable(table.table)} m${role.join}`,
1571
+ ` from ${quoteQualified(table.table)} m${role.join}`,
1887
1572
  ...filters,
1888
- ` and not (${role.sql} = any(${textArray(custom.declared)}))`,
1573
+ ` and not (${role.sql} = any(${sqlTextArray(custom.declared)}))`,
1889
1574
  customKeysSql(ctx, scope, ...entries)
1890
1575
  ].join("\n");
1891
1576
  }
@@ -1911,7 +1596,7 @@ function claimBody(ctx, scope, type) {
1911
1596
  const filters = filtersFor(set ? "rp.grant_key" : "p_grant");
1912
1597
  const declared = ` select (m ->> 'id')::${type}${set ? ", rp.grant_key" : ""}
1913
1598
  from ${membershipRows(ctx)}
1914
- join ${qualified$1(ctx, "role_permissions")} rp on rp.role = r.role
1599
+ join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
1915
1600
  where ${signedIn(ctx)}${set ? "" : "\n and rp.grant_key = p_grant"}
1916
1601
  and rp.scope = ${quoteLiteral(scope)}${andLine(" ", kindFilterSql(ctx, "r.role", "m ->> 'via'"))}
1917
1602
  ${filters}`;
@@ -1925,7 +1610,7 @@ ${filters}`;
1925
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)"))}
1926
1611
  where ${signedIn(ctx)}
1927
1612
  and jsonb_typeof(cg.g) = 'array'
1928
- and not (r.role = any(${textArray(custom.declared)}))
1613
+ and not (r.role = any(${sqlTextArray(custom.declared)}))
1929
1614
  ${filtersFor("ck.grant_key")}`;
1930
1615
  return `${declared}
1931
1616
  union
@@ -1934,7 +1619,7 @@ ${filtersFor("ck.grant_key")}`;
1934
1619
  cross join lateral (select m -> 'grants' -> r.role as g) cg
1935
1620
  where ${signedIn(ctx)}
1936
1621
  and jsonb_typeof(cg.g) = 'array'
1937
- and not (r.role = any(${textArray(custom.declared)}))
1622
+ and not (r.role = any(${sqlTextArray(custom.declared)}))
1938
1623
  ${filters}
1939
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)"))}`;
1940
1625
  }
@@ -1946,109 +1631,668 @@ ${customKeysSql(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), c
1946
1631
  function sourcesKeep(sources) {
1947
1632
  return sources.some((source) => source.sql.keeps);
1948
1633
  }
1949
- function sourceRows(sources) {
1950
- const keep = sourcesKeep(sources);
1951
- 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;`;
1952
1779
  }
1953
- function sourceUser(index) {
1954
- return `v_user_${String(index)}`;
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)}`;
1955
1785
  }
1956
- function sqlBody(sql) {
1957
- return {
1958
- sql,
1959
- vars: []
1960
- };
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`;
1961
1789
  }
1962
- function sourcesBodyOf(sql, sources, user) {
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)}`)}`;
1963
1948
  return {
1964
- sql,
1965
- vars: sources.map((source, index) => `${sourceUser(index)} ${source.sql.userType} := ${user};`)
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;`
1966
2017
  };
1967
2018
  }
1968
- /** `language sql`, or `plpgsql` returning the query when the body declares variables. */
1969
- function functionBody(body) {
1970
- if (body.vars.length === 0) return `language sql
1971
- stable
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
1972
2082
  security definer
1973
2083
  set search_path = ''
1974
2084
  as $$
1975
- ${body.sql}
1976
- $$;`;
1977
- return `language plpgsql
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
1978
2127
  stable
1979
2128
  security definer
1980
2129
  set search_path = ''
1981
2130
  as $$
1982
2131
  declare
1983
- ${body.vars.map((line) => ` ${line}`).join("\n")}
2132
+ v_entry text;
2133
+ v_key text;
1984
2134
  begin
1985
- return query
1986
- ${body.sql};
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;
1987
2175
  end;
1988
- $$;`;
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)}`;
1989
2217
  }
2218
+ //#endregion
2219
+ //#region src/cli/rls/global-roles.ts
1990
2220
  /**
1991
- * `ms.id` for the scope itself and `ms.within ->> name` for an ancestor. The
1992
- * alias is not `s`, which `activeRowSql` binds inside its `exists`.
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.
1993
2225
  */
1994
- function sourceIdOf(scope) {
1995
- return (name) => name === scope ? "ms.id" : `ms.within ->> ${quoteLiteral(name)}`;
1996
- }
1997
- function sourceFilters(ctx, scope, user = subjectIdSql(ctx), permission, keeps = false) {
1998
- return [
1999
- ...keeps ? [` and ${keptRowSql("ms.keep", permission)}`] : [],
2000
- ...userActive(ctx, " ", user),
2001
- ...instancesActive(ctx, scope, sourceIdOf(scope), " ", permission)
2002
- ].map((line) => `\n${line}`).join("");
2003
- }
2004
- function sourcesBody(ctx, scope, type) {
2005
- const root = rootName(ctx);
2006
- const tenant = sourceIdOf(scope)(root);
2007
- const narrow = narrowsTo(ctx, scope) ? `\n and (${activeTenant(ctx)} is null or ${tenant} = ${activeTenant(ctx)})${keyTenantLine(ctx, scope, tenant)}` : keyTenantLine(ctx, scope, tenant);
2008
- const sources = scopeSources(ctx, scope);
2009
- const set = ctx.grantSet === true;
2010
- const rows = ` from (
2011
- ${sourceRows(sources)}
2012
- ) ms
2013
- cross join lateral jsonb_array_elements_text(
2014
- case jsonb_typeof(ms.roles) when 'array' then ms.roles else '[]'::jsonb end
2015
- ) r(role)`;
2016
- const declared = ` select (ms.id)::${type}${set ? ", rp.grant_key" : ""}
2017
- ${rows}
2018
- join ${qualified$1(ctx, "role_permissions")} rp on rp.role = r.role
2019
- where ${signedIn(ctx)}
2020
- and ms.scope = ${quoteLiteral(scope)}${set ? "" : "\n and rp.grant_key = p_grant"}
2021
- 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))}`;
2022
- const custom = ctx.customRoles;
2023
- if (custom === void 0) return sourcesBodyOf(declared, sources, subjectIdSql(ctx));
2024
- const match = [
2025
- `c.tenant_id::text = (${tenant})::text`,
2026
- `c.scope = ${quoteLiteral(scope)}`,
2027
- "(c.scope_id is null or c.scope_id = (ms.id)::text)",
2028
- "c.role = r.role"
2029
- ].join(" and ");
2030
- const entries = (source, value, extra) => `array(select c.${value} from ${qualified$1(ctx, source)} c where ${match}${extra})`;
2031
- if (set) return sourcesBodyOf(`${declared}
2032
- union
2033
- select (ms.id)::${type}, ck.grant_key
2034
- ${rows}
2035
- ${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", ""))}
2036
- where ${signedIn(ctx)}
2037
- and ms.scope = ${quoteLiteral(scope)}
2038
- and not (r.role = any(${textArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("ck.grant_key"), sourcesKeep(sources))}`, sources, subjectIdSql(ctx));
2039
- return sourcesBodyOf(`${declared}
2040
- union
2041
- select (ms.id)::${type}
2042
- ${rows}
2043
- where ${signedIn(ctx)}
2044
- and ms.scope = ${quoteLiteral(scope)}
2045
- and not (r.role = any(${textArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("p_grant"), sourcesKeep(sources))}
2046
- ${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));
2047
- }
2048
- function scopedBody(ctx, scope, type) {
2049
- if (ctx.authorize !== "database") return sqlBody(claimBody(ctx, scope, type));
2050
- return scopeSources(ctx, scope).length > 0 ? sourcesBody(ctx, scope, type) : sqlBody(tableBody(ctx, scope, type));
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;`;
2051
2293
  }
2294
+ //#endregion
2295
+ //#region src/cli/rls/helper-members.ts
2052
2296
  /**
2053
2297
  * The body of `member_<scope>_ids()`: instances of `scope` the subject holds
2054
2298
  * a live membership of, with any role. Not narrowed to the active tenant, and
@@ -2096,7 +2340,7 @@ ${sourceRows(sources)}
2096
2340
  const role = memberRoleOf(table, "m", " ");
2097
2341
  const lines = [
2098
2342
  ` select distinct ${memberColumn(column)}::${type}`,
2099
- ` from ${membershipTable(table.table)} m${role.join}`,
2343
+ ` from ${quoteQualified(table.table)} m${role.join}`,
2100
2344
  ` where ${memberColumn(table.user)} = ${user}`,
2101
2345
  ` and ${role.through === void 0 && !role.lateral ? memberColumn(role.column) : role.sql} is not null`
2102
2346
  ];
@@ -2117,7 +2361,7 @@ function membershipClaim(ctx) {
2117
2361
  return ctx.dialect === "supabase" ? `coalesce(${raw}, (select auth.jwt()) -> 'app_metadata' -> 'memberships')` : raw;
2118
2362
  }
2119
2363
  function memberFunction(ctx, scope, type, anonExecute) {
2120
- const fn = qualified$1(ctx, memberIdsHelper(scope));
2364
+ const fn = qualified(ctx, memberIdsHelper(scope));
2121
2365
  const body = memberBody(ctx, scope, type);
2122
2366
  const keys = ctx.apiKeys;
2123
2367
  const keyed = keys === void 0 || scope !== rootName(ctx) ? body : {
@@ -2140,14 +2384,45 @@ ${grants}`;
2140
2384
  * `supabase_auth_admin` execute.
2141
2385
  */
2142
2386
  function memberForFunction(ctx, scope, type) {
2143
- const fn = qualified$1(ctx, memberForHelper(scope));
2387
+ const fn = qualified(ctx, memberForHelper(scope));
2144
2388
  return `create or replace function ${fn}(p_user uuid)
2145
2389
  returns setof ${type}
2146
2390
  ${functionBody(memberRowsBody(ctx, scope, type, "p_user", memberForSources(ctx, scope)))}
2147
2391
  revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
2148
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
+ }
2149
2424
  function helperFunction(ctx, name, returns, body, anonExecute) {
2150
- const fn = qualified$1(ctx, name);
2425
+ const fn = qualified(ctx, name);
2151
2426
  const grants = anonExecute ? `revoke execute on function ${fn}(text) from public;
2152
2427
  grant execute on function ${fn}(text) to anon, authenticated;` : `revoke execute on function ${fn}(text) from public, anon;
2153
2428
  grant execute on function ${fn}(text) to authenticated;`;
@@ -2171,7 +2446,7 @@ function forUserSql(ctx) {
2171
2446
  const forUser = forUserContext(ctx);
2172
2447
  const type = ctx.dialect === "supabase" ? "uuid" : "text";
2173
2448
  const fn = (name, returns, body) => {
2174
- const qualifiedName = qualified$1(ctx, name);
2449
+ const qualifiedName = qualified(ctx, name);
2175
2450
  return `create or replace function ${qualifiedName}(p_user ${type}, p_grant text)
2176
2451
  returns ${returns}
2177
2452
  ${functionBody(body)}
@@ -2192,7 +2467,7 @@ function userIdType(ctx) {
2192
2467
  * declarative schema; `--split ...,seeds` writes it as its own migration.
2193
2468
  */
2194
2469
  function seedSql(ctx, rows) {
2195
- const table = qualified$1(ctx, "role_permissions");
2470
+ const table = qualified(ctx, "role_permissions");
2196
2471
  if (rows.length === 0) return `delete from ${table};`;
2197
2472
  return `insert into ${table} (role, permission, grant_key, scope, effect) values
2198
2473
  ${rows.map((row) => ` (${[
@@ -2211,308 +2486,107 @@ ${rows.map((row) => ` (${[
2211
2486
  row.grantKey,
2212
2487
  row.scope
2213
2488
  ].map(quoteLiteral).join(", ")})`).join(",\n")}
2214
- );`;
2215
- }
2216
- /**
2217
- * `role_permissions`, its seeds, and the three `security definer` helpers
2218
- * with `search_path = ''`. `execute` goes to `authenticated` only.
2219
- */
2220
- function helpersSql(ctx, rows, options) {
2221
- const schema = helperSchema(ctx);
2222
- const s = quoteIdent(schema);
2223
- const rp = qualified$1(ctx, "role_permissions");
2224
- const chunks = [`-- permdock helpers (${ctx.authorize === "database" ? "database: reads the membership and user_roles tables" : "jwt: reads the role and memberships claims"})
2225
- -- policies call them uncorrelated, so Postgres evaluates each once per statement`];
2226
- 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"};`);
2227
- if (ctx.dialect === "supabase") chunks.push(userIdSql(ctx, options.anonExecute === true));
2228
- chunks.push(`create table if not exists ${rp} (
2229
- role text not null,
2230
- permission text not null,
2231
- grant_key text not null,
2232
- scope text not null check (scope ~ '^[a-z][a-z0-9_]*$'),
2233
- effect text not null default 'allow' check (effect in ('allow', 'deny')),
2234
- primary key (role, grant_key, scope)
2235
- );
2236
- alter table ${rp} enable row level security;
2237
- revoke all on table ${rp} from anon, authenticated, public;`);
2238
- if (options.withoutSeeds !== true) chunks.push(seedSql(ctx, rows));
2239
- if (ctx.authorize === "database" && options.userRoles && ctx.roles === void 0) {
2240
- const ur = qualified$1(ctx, "user_roles");
2241
- chunks.push(`create table if not exists ${ur} (
2242
- user_id ${userIdType(ctx)},
2243
- role text not null,
2244
- primary key (user_id, role)
2245
- );
2246
- alter table ${ur} enable row level security;
2247
- revoke all on table ${ur} from anon, authenticated, public;`);
2248
- }
2249
- const custom = customRolesSql(ctx);
2250
- if (custom !== "") chunks.push(custom);
2251
- const anon = options.anonExecute === true;
2252
- const keys = ctx.apiKeys;
2253
- if (keys !== void 0) chunks.push(apiKeyAllowsSql(ctx, keys, anon));
2254
- const bodies = grantBodies(ctx);
2255
- chunks.push(helperFunction(ctx, HELPERS.has, "boolean", bodies.has, anon));
2256
- const capabilities = capabilitiesSql(ctx);
2257
- if (capabilities !== "") chunks.push(capabilities);
2258
- for (const scope of ctx.scopes) {
2259
- const type = scopeTypeOf(ctx, scope.name);
2260
- chunks.push(helperFunction(ctx, permittedIdsHelper(scope.name), `setof ${type}`, bodies.ids(scope.name), anon));
2261
- chunks.push(memberFunction(ctx, scope.name, type, anon));
2262
- if (hasMemberFor(ctx, scope.name)) chunks.push(memberForFunction(ctx, scope.name, type));
2263
- }
2264
- const forUser = forUserSql(ctx);
2265
- if (forUser !== "") chunks.push(forUser);
2266
- const writes = customRoleWritesSql(ctx, options.levelReach ?? []);
2267
- if (writes !== "") {
2268
- chunks.push(writes);
2269
- const cascade = customRoleCascadeSql(ctx);
2270
- if (cascade !== "") chunks.push(cascade);
2271
- }
2272
- const globalRoles = globalRolesSql(ctx);
2273
- if (globalRoles !== "") chunks.push(globalRoles);
2274
- const readers = roleReadersSql(ctx, options.permissions, options.trustedReaders);
2275
- if (readers !== "") chunks.push(readers);
2276
- return `${chunks.join("\n\n")}\n`;
2277
- }
2278
- function grantBodies(ctx) {
2279
- const keys = ctx.apiKeys;
2280
- const set = ctx.grantSet === true;
2281
- const allows = apiKeyAllowsCall(ctx, set ? "ids.grant_key" : "p_grant");
2282
- const keyTenant = keyTenantSql(ctx, void 0) ?? "true";
2283
- return {
2284
- has: sqlBody(keys === void 0 ? hasBody(ctx) : set ? ` select g.grant_key
2285
- from (
2286
- ${hasBody(ctx)}
2287
- ) g(grant_key)
2288
- where ${apiKeyAllowsCall(ctx, "g.grant_key")} and ${keyTenant}` : ceilingHasSql(hasBody(ctx), `${allows} and ${keyTenant}`)),
2289
- ids: (scope) => {
2290
- const type = scopeTypeOf(ctx, scope);
2291
- const body = scopedBody(ctx, scope, type);
2292
- if (keys === void 0) return body;
2293
- const sql = scope === rootName(ctx) ? `${body.sql}\n union\n${serviceKeyIdsSql(ctx, keys, scope, type)}` : body.sql;
2294
- return {
2295
- sql: set ? ` select ids.id, ids.grant_key
2296
- from (
2297
- ${sql}
2298
- ) ids(id, grant_key)
2299
- where ${allows}` : ceilingIdsSql(sql, allows),
2300
- vars: body.vars
2301
- };
2302
- }
2303
- };
2304
- }
2305
- function forUserContext(ctx) {
2306
- const { apiKeys: _apiKeys, ...unkeyed } = ctx;
2307
- return {
2308
- ...unkeyed,
2309
- subjectId: "p_user",
2310
- tenants: "all"
2311
- };
2312
- }
2313
- //#endregion
2314
- //#region src/cli/pg.ts
2315
- const CONNECT_TIMEOUT_MS = 1e4;
2316
- const STATEMENT_TIMEOUT_MS = 6e4;
2317
- function loadPg(command) {
2318
- return requirePeer(() => import("pg"), "pg", command);
2319
- }
2320
- function options(db) {
2321
- return {
2322
- connectionString: db,
2323
- connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
2324
- statement_timeout: STATEMENT_TIMEOUT_MS
2325
- };
2326
- }
2327
- /** Ctrl-C ends the connection, so a query that hangs cannot keep the process alive. */
2328
- function endOnInterrupt(end) {
2329
- const interrupt = () => {
2330
- end().catch(() => void 0);
2331
- process.exitCode = 130;
2332
- };
2333
- process.once("SIGINT", interrupt);
2334
- return async () => {
2335
- process.off("SIGINT", interrupt);
2336
- await end();
2337
- };
2338
- }
2339
- function unreachable(command, cause) {
2340
- return new CliError("unavailable", `PermDock CLI: ${command.replace(/^permdock /u, "")} could not connect`, { cause });
2341
- }
2342
- /** One connection, for commands that run a transaction. */
2343
- async function connectPg(db, command) {
2344
- const client = new (await (loadPg(command))).Client(options(db));
2345
- try {
2346
- await client.connect();
2347
- } catch (cause) {
2348
- throw unreachable(command, cause);
2349
- }
2350
- return {
2351
- query: (sql, values) => client.query(sql, values),
2352
- end: endOnInterrupt(() => client.end())
2353
- };
2354
- }
2355
- /** A small pool, for independent catalog reads that can run in parallel. */
2356
- async function connectPgPool(db, command) {
2357
- const pool = new (await (loadPg(command))).Pool({
2358
- ...options(db),
2359
- max: 4
2360
- });
2361
- try {
2362
- (await pool.connect()).release();
2363
- } catch (cause) {
2364
- await pool.end();
2365
- throw unreachable(command, cause);
2366
- }
2367
- return {
2368
- query: (sql, values) => pool.query(sql, values),
2369
- end: endOnInterrupt(() => pool.end())
2370
- };
2371
- }
2372
- //#endregion
2373
- //#region src/cli/rls-indexes.ts
2374
- /** Postgres truncates identifiers longer than this, in bytes. */
2375
- const MAX_IDENT = 63;
2376
- /**
2377
- * The row fields `condition` compares with the subject, a claim or a
2378
- * membership (`ownerId: principal.id`, `memberOf`), through `and` and `or`:
2379
- * the selective comparisons an index serves. A comparison with a constant
2380
- * (`status: 'sent'`) narrows rows the scope key already found, a negation
2381
- * scans anyway, and a dotted field is a JSON path a plain index does not serve.
2382
- */
2383
- function indexedFields(condition) {
2384
- if (condition === void 0) return [];
2385
- switch (condition.op) {
2386
- case "and":
2387
- case "or": return condition.conditions.flatMap(indexedFields);
2388
- case "memberOf": return plainField(condition.field);
2389
- case "eq":
2390
- case "in": return isConditionRef(condition.value) ? plainField(condition.field) : [];
2391
- case "not":
2392
- case "sqlFunction":
2393
- case "opaque":
2394
- case "related":
2395
- case "ne":
2396
- case "gt":
2397
- case "gte":
2398
- case "lt":
2399
- case "lte":
2400
- case "contains":
2401
- case "notIn":
2402
- case "isNull":
2403
- case "liveSession": return [];
2404
- default: return condition;
2405
- }
2406
- }
2407
- function plainField(field) {
2408
- return field.includes(".") ? [] : [field];
2489
+ );`;
2409
2490
  }
2410
2491
  /**
2411
- * Every index the generated SQL reads through: the row columns policies
2412
- * filter on (scope keys and selective condition fields), and the membership
2413
- * 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.
2414
2494
  */
2415
- function indexTargets(ctx, rowColumns) {
2416
- const targets = /* @__PURE__ */ new Map();
2417
- const add = (table, columns) => {
2418
- const target = {
2419
- table: qualifiedTable(table),
2420
- columns
2421
- };
2422
- targets.set(`${target.table}\u0000${columns[0] ?? ""}`, target);
2423
- };
2424
- for (const { table, column } of rowColumns) add(table, [column]);
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);
2425
2533
  for (const scope of ctx.scopes) {
2426
- const mapped = scopeTable(ctx, scope.name);
2427
- if (mapped !== void 0) add(mapped.table.table, [mapped.table.user, mapped.column]);
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));
2428
2538
  }
2429
- for (const source of [...ctx.sources ?? [], ...ctx.memberSources ?? []]) {
2430
- add(source.sql.table, [source.sql.user]);
2431
- const users = source.sql.userThrough;
2432
- if (users !== void 0) add(users.table, [users.key]);
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);
2433
2546
  }
2434
- return [...targets.values()].toSorted((a, b) => `${a.table}.${a.columns.join(",")}` < `${b.table}.${b.columns.join(",")}` ? -1 : 1);
2435
- }
2436
- /** A stable index name under Postgres' identifier limit. */
2437
- function indexName(target) {
2438
- const name = `permdock_${target.table.slice(target.table.lastIndexOf(".") + 1)}_${target.columns.join("_")}_idx`;
2439
- if (Buffer.byteLength(name) <= MAX_IDENT) return name;
2440
- return `permdock_${createHash("sha256").update(name).digest("hex").slice(0, 8)}_idx`;
2441
- }
2442
- /** `create index if not exists` for every target, one per line. */
2443
- function indexesSql(targets) {
2444
- return targets.map((target) => `create index if not exists ${quoteIdent(indexName(target))} on ${quoteTable(target.table)} (${target.columns.map(quoteIdent).join(", ")});`).join("\n");
2445
- }
2446
- /** Every live column of the tables in `$1` (`schema.table`), as `target` and `name`. */
2447
- const COLUMNS_SQL = `select n.nspname || '.' || c.relname as target, a.attname as name
2448
- from pg_attribute a
2449
- join pg_class c on c.oid = a.attrelid
2450
- join pg_namespace n on n.oid = c.relnamespace
2451
- where a.attnum > 0 and not a.attisdropped
2452
- and n.nspname || '.' || c.relname = any($1::text[])`;
2453
- const INDEX_COLUMNS_SQL = `select n.nspname || '.' || c.relname as target,
2454
- array(
2455
- select a.attname::text
2456
- from unnest(i.indkey) with ordinality k(attnum, ord)
2457
- left join pg_attribute a on a.attrelid = i.indrelid and a.attnum = k.attnum and k.attnum > 0
2458
- where k.ord <= i.indnkeyatts
2459
- order by k.ord
2460
- ) as columns
2461
- from pg_index i
2462
- join pg_class c on c.oid = i.indrelid
2463
- join pg_namespace n on n.oid = c.relnamespace
2464
- where i.indpred is null and n.nspname || '.' || c.relname = any($1::text[])`;
2465
- function strings(value) {
2466
- return Array.isArray(value) ? value.map((item) => typeof item === "string" ? item : null) : [];
2467
- }
2468
- /** Reads the columns and index key columns of `tables` (`schema.table`). */
2469
- async function readTableIndexFacts(db, tables, connect = (url) => connectPgPool(url, "permdock rls generate --db")) {
2470
- const client = await connect(db);
2471
- try {
2472
- const [columns, indexes] = await Promise.all([client.query(COLUMNS_SQL, [[...tables]]), client.query(INDEX_COLUMNS_SQL, [[...tables]])]);
2473
- const byTable = {};
2474
- for (const row of columns.rows) (byTable[String(row["target"])] ??= []).push(String(row["name"]));
2475
- const keys = {};
2476
- for (const row of indexes.rows) {
2477
- const list = strings(row["columns"]);
2478
- const end = list.indexOf(null);
2479
- const lead = end === -1 ? list : list.slice(0, end);
2480
- 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
+ };
2481
2577
  }
2482
- return {
2483
- columns: byTable,
2484
- indexes: keys
2485
- };
2486
- } finally {
2487
- await client.end();
2488
- }
2578
+ };
2489
2579
  }
2490
- /**
2491
- * Drops the targets a database already serves or cannot hold: a target on a
2492
- * table or column the database lacks (with a warning), and one an existing
2493
- * index leads with (its key columns start with the target's).
2494
- */
2495
- function pruneIndexTargets(targets, facts) {
2496
- const warnings = [];
2580
+ function forUserContext(ctx) {
2581
+ const { apiKeys: _apiKeys, ...unkeyed } = ctx;
2497
2582
  return {
2498
- targets: targets.filter((target) => {
2499
- const columns = facts.columns[target.table];
2500
- if (columns === void 0) {
2501
- warnings.push(`no index suggested on ${target.table}: the database has no such table`);
2502
- return false;
2503
- }
2504
- const absent = target.columns.filter((column) => !columns.includes(column));
2505
- if (absent.length > 0) {
2506
- warnings.push(`no index suggested on ${target.table} (${target.columns.join(", ")}): the table has no column ${absent.join(", ")}`);
2507
- return false;
2508
- }
2509
- return !(facts.indexes[target.table] ?? []).some((index) => target.columns.every((column, at) => index[at] === column));
2510
- }),
2511
- warnings
2583
+ ...unkeyed,
2584
+ subjectId: "p_user",
2585
+ tenants: "all"
2512
2586
  };
2513
2587
  }
2514
2588
  //#endregion
2515
- //#region src/cli/rls-permission-keys.ts
2589
+ //#region src/cli/rls/permission-keys.ts
2516
2590
  /** The helpers that take a permission key instead of a grant key. Names are part of the SQL contract. */
2517
2591
  const PERMISSION_HELPERS = {
2518
2592
  grantKeys: "grant_keys",
@@ -2539,9 +2613,9 @@ function permittedByPermissionHelper(scope) {
2539
2613
  */
2540
2614
  function requiresSql(ctx, key, resource) {
2541
2615
  const literal = quoteLiteral(key);
2542
- return `(${[`(select ${qualified$1(ctx, PERMISSION_HELPERS.has)}(${literal}))`, ...(resource === void 0 ? [] : partitionsOf(resource, ctx.scopes)).map((scope) => {
2616
+ return `(${[`(select ${qualified(ctx, PERMISSION_HELPERS.has)}(${literal}))`, ...(resource === void 0 ? [] : partitionsOf(resource, ctx.scopes)).map((scope) => {
2543
2617
  const field = scopeField(resource, scope, ctx.scopes) ?? scope;
2544
- return `${quoteIdent(field)} in (select ${qualified$1(ctx, checkName(permittedByPermissionHelper(scope)))}(${literal}))`;
2618
+ return `${quoteIdent(field)} in (select ${qualified(ctx, checkName(permittedByPermissionHelper(scope)))}(${literal}))`;
2545
2619
  })].join(" or ")})`;
2546
2620
  }
2547
2621
  /** Postgres truncates an identifier past 63 bytes, which would merge two helpers. */
@@ -2550,7 +2624,7 @@ function checkName(name) {
2550
2624
  return name;
2551
2625
  }
2552
2626
  function grantsFunction(ctx, grants, renamed, anonExecute) {
2553
- const fn = qualified$1(ctx, PERMISSION_HELPERS.grantKeys);
2627
+ const fn = qualified(ctx, PERMISSION_HELPERS.grantKeys);
2554
2628
  const map = Object.fromEntries([...grants].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
2555
2629
  const current = Object.keys(renamed).length === 0 ? "p_permission" : `coalesce(${quoteLiteral(JSON.stringify(renamed))}::jsonb ->> p_permission, p_permission)`;
2556
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
@@ -2608,7 +2682,7 @@ ${access}`;
2608
2682
  * caller, as `permittedIds(..., { conditioned: true })` does in process.
2609
2683
  */
2610
2684
  function permissionHelpersSql(ctx, grants, renamed, anonExecute) {
2611
- const keys = qualified$1(ctx, PERMISSION_HELPERS.grantKeys);
2685
+ const keys = qualified(ctx, PERMISSION_HELPERS.grantKeys);
2612
2686
  const allowKeys = (scope) => `${keys}(p_permission, ${quoteLiteral(scope)}) g(grant_key)`;
2613
2687
  const denyKeys = (scope) => `${keys}(p_permission, ${quoteLiteral(scope)}, 'deny') g(grant_key)`;
2614
2688
  const has = (helper, user) => ` select exists (select 1 from ${allowKeys("global")} where ${helper}(${user}g.grant_key))
@@ -2684,13 +2758,13 @@ ${idsBody.sql}
2684
2758
  };
2685
2759
  };
2686
2760
  const chunks = [grantsFunction(ctx, grants, renamed, anonExecute), `-- the helpers by permission key: unconditional allows minus any deny
2687
- ${definer(qualified$1(ctx, PERMISSION_HELPERS.has), "p_permission text", "boolean", has(qualified$1(ctx, HELPERS.has), ""))}
2688
- ${grantsSql(qualified$1(ctx, PERMISSION_HELPERS.has), "text", anonExecute)}`];
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)}`];
2689
2763
  for (const scope of ctx.scopes) {
2690
- const fn = qualified$1(ctx, checkName(permittedByPermissionHelper(scope.name)));
2691
- chunks.push(`${definer(fn, "p_permission text", `setof ${scopeTypeOf(ctx, scope.name)}`, ids(qualified$1(ctx, permittedIdsHelper(scope.name)), scope.name, ""))}
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, ""))}
2692
2766
  ${grantsSql(fn, "text", anonExecute)}`);
2693
- const keysFn = qualified$1(ctx, checkName(permissionKeysHelper(scope.name)));
2767
+ const keysFn = qualified(ctx, checkName(permissionKeysHelper(scope.name)));
2694
2768
  const type = scopeTypeOf(ctx, scope.name);
2695
2769
  const caller = grantBodies({
2696
2770
  ...ctx,
@@ -2700,23 +2774,23 @@ ${grantsSql(fn, "text", anonExecute)}`);
2700
2774
  ${keysFunction(keysFn, `p_id ${type}`, keysBody(scope.name, caller, false), grantsSql(keysFn, type, anonExecute))}
2701
2775
  ${keysFunction(keysFn, `p_id ${type}, p_keys text[]`, keysBody(scope.name, caller, true), grantsSql(keysFn, `${type}, text[]`, anonExecute))}`);
2702
2776
  chunks.push(`-- with p_conditioned true, also the instances a conditioned allow reaches, minus only unconditional denies: the caller applies the row condition
2703
- ${definer(fn, "p_permission text, p_conditioned boolean", `setof ${scopeTypeOf(ctx, scope.name)}`, conditionedIds(qualified$1(ctx, permittedIdsHelper(scope.name)), scope.name, ""))}
2777
+ ${definer(fn, "p_permission text, p_conditioned boolean", `setof ${scopeTypeOf(ctx, scope.name)}`, conditionedIds(qualified(ctx, permittedIdsHelper(scope.name)), scope.name, ""))}
2704
2778
  ${grantsSql(fn, "text, boolean", anonExecute)}`);
2705
2779
  }
2706
2780
  if (ctx.authorize !== "database") return `${chunks.join("\n\n")}\n`;
2707
2781
  const user = ctx.dialect === "supabase" ? "uuid" : "text";
2708
2782
  const revoke = (fn) => `revoke execute on function ${fn}(${user}, text) from public, anon, authenticated;`;
2709
- const hasFor = qualified$1(ctx, PERMISSION_HELPERS.hasFor);
2783
+ const hasFor = qualified(ctx, PERMISSION_HELPERS.hasFor);
2710
2784
  chunks.push(`-- the same for a user the caller names; no client role may execute them
2711
- ${definer(hasFor, `p_user ${user}, p_permission text`, "boolean", has(qualified$1(ctx, HELPERS.hasFor), "p_user, "))}
2785
+ ${definer(hasFor, `p_user ${user}, p_permission text`, "boolean", has(qualified(ctx, HELPERS.hasFor), "p_user, "))}
2712
2786
  ${revoke(hasFor)}`);
2713
2787
  for (const scope of ctx.scopes) {
2714
- const fn = qualified$1(ctx, checkName(`${permittedByPermissionHelper(scope.name)}_for`));
2715
- chunks.push(`${definer(fn, `p_user ${user}, p_permission text`, `setof ${scopeTypeOf(ctx, scope.name)}`, ids(qualified$1(ctx, permittedForHelper(scope.name)), scope.name, "p_user, "))}
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, "))}
2716
2790
  ${revoke(fn)}`);
2717
- chunks.push(`${definer(fn, `p_user ${user}, p_permission text, p_conditioned boolean`, `setof ${scopeTypeOf(ctx, scope.name)}`, conditionedIds(qualified$1(ctx, permittedForHelper(scope.name)), scope.name, "p_user, "))}
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, "))}
2718
2792
  revoke execute on function ${fn}(${user}, text, boolean) from public, anon, authenticated;`);
2719
- const keysFor = qualified$1(ctx, checkName(permissionKeysForHelper(scope.name)));
2793
+ const keysFor = qualified(ctx, checkName(permissionKeysForHelper(scope.name)));
2720
2794
  const type = scopeTypeOf(ctx, scope.name);
2721
2795
  const named = grantBodies({
2722
2796
  ...forUserContext(ctx),
@@ -2728,863 +2802,4 @@ ${keysFunction(keysFor, `p_user ${user}, p_id ${type}, p_keys text[]`, keysBody(
2728
2802
  return `${chunks.join("\n\n")}\n`;
2729
2803
  }
2730
2804
  //#endregion
2731
- //#region src/cli/rls-compile.ts
2732
- /**
2733
- * The levels an allow reaches: every level without a row condition, else the
2734
- * levels whose condition equals its condition.
2735
- */
2736
- function reachedLevels(where, levels) {
2737
- if (where === void 0) return levels.map(([name]) => name);
2738
- const held = canonicalJson(where);
2739
- return levels.filter(([, condition]) => canonicalJson(condition) === held).map(([name]) => name);
2740
- }
2741
- function levelReachOf(policy, keys) {
2742
- const reach = /* @__PURE__ */ new Map();
2743
- for (const [entry, grantKey] of keys) {
2744
- const { item } = entry;
2745
- const { grant } = item;
2746
- if (item.access.kind !== "role" || grant.effect !== "allow" || grant.permission.kind !== "instance" || grant.level !== void 0) continue;
2747
- const levels = policyLevels(policy, grant.permission.resource);
2748
- if (levels.length > 0) {
2749
- const set = reach.get(grantKey) ?? /* @__PURE__ */ new Set();
2750
- for (const name of reachedLevels(grant.where, levels)) set.add(name);
2751
- reach.set(grantKey, set);
2752
- }
2753
- }
2754
- const out = [];
2755
- for (const grantKey of new Set(keys.values())) {
2756
- const at = grantKey.lastIndexOf("@");
2757
- const base = at === -1 ? grantKey : grantKey.slice(0, at);
2758
- const level = at === -1 ? void 0 : grantKey.slice(at + 1);
2759
- for (const name of reach.get(base) ?? []) if (level === void 0 || level === name) out.push([grantKey, name]);
2760
- }
2761
- return out.toSorted(([a, x], [b, y]) => a === b ? x < y ? -1 : 1 : a < b ? -1 : 1);
2762
- }
2763
- /** The SQL command `action` compiles to: `rls.actions` first, then the six default verbs. */
2764
- function commandFor(action, actions) {
2765
- if (actions !== void 0 && Object.hasOwn(actions, action)) {
2766
- const configured = actions[action];
2767
- return configured === "none" ? void 0 : configured;
2768
- }
2769
- switch (action) {
2770
- case "read":
2771
- case "list":
2772
- case "get": return "select";
2773
- case "create": return "insert";
2774
- case "update": return "update";
2775
- case "delete": return "delete";
2776
- default: return;
2777
- }
2778
- }
2779
- function tableFor(resource, tables) {
2780
- return tables?.[resource] ?? resource;
2781
- }
2782
- function ancestorsOf(policy, name) {
2783
- const names = [];
2784
- let current = policy.resources.get(name);
2785
- while (current?.parent !== void 0 && !names.includes(current.parent.resource)) {
2786
- names.push(current.parent.resource);
2787
- current = policy.resources.get(current.parent.resource);
2788
- }
2789
- return names;
2790
- }
2791
- function membershipField(policy, resource, holder) {
2792
- if (resource === void 0) return;
2793
- if (resource.name === holder) return resource.id ?? "id";
2794
- let current = resource;
2795
- const seen = /* @__PURE__ */ new Set();
2796
- while (current?.parent !== void 0 && !seen.has(current.name)) {
2797
- seen.add(current.name);
2798
- if (current.parent.resource === holder) return current.parent.field;
2799
- current = policy.resources.get(current.parent.resource);
2800
- }
2801
- }
2802
- function resourceCondition(item, policy, ctx) {
2803
- if (item.access.kind !== "resource") throw new Error("PermDock CLI: resourceCondition needs a resource role");
2804
- const { role, resource: roleResource } = item.access;
2805
- const target = policy.resources.get(item.grant.permission.resource);
2806
- const holders = [roleResource, ...ancestorsOf(policy, roleResource)];
2807
- const hops = [];
2808
- for (const [index, holder] of holders.entries()) {
2809
- const field = membershipField(policy, target, holder);
2810
- if (field === void 0) continue;
2811
- if (index > 0 && ctx.memberships?.resource?.[holder] === void 0) continue;
2812
- hops.push({
2813
- op: "memberOf",
2814
- scope: "resource",
2815
- field,
2816
- roles: [role],
2817
- resource: holder
2818
- });
2819
- }
2820
- return sole(hops) ?? {
2821
- op: "or",
2822
- conditions: hops
2823
- };
2824
- }
2825
- /**
2826
- * The access a link capability has to a resource-scoped grant, over the same
2827
- * holders as `resourceCondition`. It reads the claim, so ancestor hops need
2828
- * no memberships table. `undefined` when no holder is on the row's chain.
2829
- */
2830
- function capabilityAccess(item, policy, ctx) {
2831
- if (item.access.kind !== "resource") return;
2832
- const { role, resource: roleResource } = item.access;
2833
- const kinds = ctx.ownership?.kinds[role];
2834
- if (kinds !== void 0 && !kinds.includes("link")) return;
2835
- const target = policy.resources.get(item.grant.permission.resource);
2836
- const hops = [];
2837
- for (const holder of [roleResource, ...ancestorsOf(policy, roleResource)]) {
2838
- const field = membershipField(policy, target, holder);
2839
- if (field !== void 0) hops.push(capabilityAccessSql(ctx, field, holder, role, item.grant.permission.key));
2840
- }
2841
- if (hops.length === 0) return;
2842
- return hops.length === 1 ? hops[0] : hops.map(wrapSql).join(" or ");
2843
- }
2844
- function prepare(item, tables, warnings, skipClosures, actions, rowActions = false) {
2845
- const { grant, label } = item;
2846
- if (grant.breakGlass !== void 0) {
2847
- warnings.push(`break-glass grant ${label}/${grant.permission.key} reads through permdock_break_glass_${grant.permission.resource}, not a policy`);
2848
- return;
2849
- }
2850
- if (grant.closure !== void 0 || grant.portable === false) {
2851
- if (skipClosures) {
2852
- warnings.push(`skipped non-portable grant ${label}/${grant.permission.key}`);
2853
- return;
2854
- }
2855
- throw new Error(`PermDock CLI: closure grant ${label}/${grant.permission.key} is not portable; rewrite it or pass --skip-closures`);
2856
- }
2857
- const context = [.../* @__PURE__ */ new Set([...contextRefs(item.where), ...contextRefs(grant.check)])];
2858
- if (context.length > 0) {
2859
- if (skipClosures) {
2860
- warnings.push(`skipped grant ${label}/${grant.permission.key}: it reads ${context.join(", ")}, which is not in the token`);
2861
- return;
2862
- }
2863
- 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)`);
2864
- }
2865
- if (requiresApproval(grant.approval)) {
2866
- warnings.push(`skipped approval grant ${label}/${grant.permission.key}`);
2867
- return;
2868
- }
2869
- const command = commandFor(grant.permission.action, actions);
2870
- const table = tableFor(grant.permission.resource, tables);
2871
- if (command === void 0) {
2872
- if (item.access.kind !== "role" && rowActions) {
2873
- 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`);
2874
- return {
2875
- item,
2876
- command: "none",
2877
- table,
2878
- ...item.where === void 0 ? {} : { using: item.where }
2879
- };
2880
- }
2881
- if (item.access.kind !== "role") {
2882
- warnings.push(`skipped ${label}/${grant.permission.key}: action '${grant.permission.action}' has no SQL command (rls.actions) and only role grants are seeded`);
2883
- return;
2884
- }
2885
- if (grant.validity !== void 0 && grant.effect === "allow") {
2886
- 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`);
2887
- return;
2888
- }
2889
- 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`);
2890
- return {
2891
- item,
2892
- command: "none",
2893
- table,
2894
- ...item.where === void 0 ? {} : { using: item.where },
2895
- ...grant.check === void 0 ? {} : { check: grant.check }
2896
- };
2897
- }
2898
- const using = command === "insert" ? void 0 : item.where;
2899
- const check = command === "insert" ? grant.check ?? item.where : command === "update" ? grant.check ?? item.where : void 0;
2900
- return {
2901
- item,
2902
- command,
2903
- table,
2904
- ...using === void 0 ? {} : { using },
2905
- ...check === void 0 ? {} : { check }
2906
- };
2907
- }
2908
- function signature(entry, byFields) {
2909
- const base = [
2910
- entry.item.grant.effect,
2911
- entry.using ?? null,
2912
- entry.check ?? null
2913
- ];
2914
- return JSON.stringify(byFields ? [...base, entry.item.grant.fields ?? null] : base);
2915
- }
2916
- /**
2917
- * Grant keys per permission: the key is the permission, split into
2918
- * `permission#n` when role grants carry different portable conditions (or
2919
- * effects), one key per condition group; a group whose grants set `group`
2920
- * is `permission#<group>` instead. With field views, grants that differ
2921
- * only in `fields` get their own keys too, so a view can tell them apart.
2922
- */
2923
- function assignKeys(entries, byFields) {
2924
- const groups = /* @__PURE__ */ new Map();
2925
- for (const entry of entries) {
2926
- if (entry.item.access.kind !== "role") continue;
2927
- const key = entry.item.grant.permission.key;
2928
- const byCondition = groups.get(key) ?? /* @__PURE__ */ new Map();
2929
- const sig = signature(entry, byFields);
2930
- byCondition.set(sig, [...byCondition.get(sig) ?? [], entry]);
2931
- groups.set(key, byCondition);
2932
- }
2933
- const keys = /* @__PURE__ */ new Map();
2934
- for (const [permission, byCondition] of groups) {
2935
- const lists = [...byCondition.values()];
2936
- const named = /* @__PURE__ */ new Set();
2937
- for (const [index, list] of lists.entries()) {
2938
- const group = groupOf(permission, list);
2939
- 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`);
2940
- if (group !== void 0) named.add(group);
2941
- const grantKey = group !== void 0 ? `${permission}#${group}` : lists.length === 1 ? permission : `${permission}#${index + 1}`;
2942
- for (const entry of list) keys.set(entry, grantKey);
2943
- }
2944
- }
2945
- return keys;
2946
- }
2947
- function groupOf(permission, list) {
2948
- const names = [...new Set(list.flatMap((entry) => entry.item.grant.group === void 0 ? [] : [entry.item.grant.group]))].toSorted();
2949
- if (names.length > 1) throw new Error(`PermDock CLI: grants of ${permission} with one condition name the groups ${names.join(", ")}; give them one group`);
2950
- return names[0];
2951
- }
2952
- /**
2953
- * One entry per level for every allow of an assignable role on a resource
2954
- * with levels, keyed `<grant key>@<level>`. Only `permdock_custom_keys`
2955
- * hands these keys out, to a custom role whose stored allow names the level.
2956
- */
2957
- function levelEntries(policy, ctx, keys, tables, warnings, skipClosures) {
2958
- const custom = ctx.customRoles;
2959
- if (custom?.levels !== true) return [];
2960
- const assignable = new Set(custom.assignable);
2961
- const out = [];
2962
- for (const [entry, grantKey] of Array.from(keys)) {
2963
- const { item } = entry;
2964
- const { grant } = item;
2965
- if (item.access.kind !== "role" || grant.effect !== "allow" || grant.permission.kind !== "instance" || !assignable.has(item.access.role)) continue;
2966
- 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`);
2967
- for (const [level, condition] of policyLevels(policy, grant.permission.resource)) {
2968
- const where = andWhere(item.where, condition);
2969
- const prepared = prepare({
2970
- ...item,
2971
- grant: leveled(grant, level, condition),
2972
- ...where === void 0 ? {} : { where }
2973
- }, tables, warnings, skipClosures, ctx.actions);
2974
- if (prepared !== void 0) {
2975
- keys.set(prepared, `${grantKey}@${level}`);
2976
- out.push(prepared);
2977
- }
2978
- }
2979
- }
2980
- return out;
2981
- }
2982
- function compileOptional(condition, ctx) {
2983
- return condition === void 0 ? void 0 : compileConditionSql(condition, ctx);
2984
- }
2985
- function noteConditions(entry, warnings) {
2986
- const { label, grant } = entry.item;
2987
- const names = [...sqlFunctionNames(entry.using), ...sqlFunctionNames(entry.check)];
2988
- if (names.length > 0) warnings.push(`sqlFunction ${[...new Set(names)].join(", ")} on ${label}/${grant.permission.key} is portable via twin`);
2989
- if (hasConditionOp(entry.using, "opaque") || hasConditionOp(entry.check, "opaque")) warnings.push(`opaque SQL on ${label}/${grant.permission.key} is untestable app-side`);
2990
- }
2991
- /**
2992
- * Compiles every grant (role and top-level) to branches whose role check is
2993
- * a helper call keyed by grant key. Nothing in a branch is per-row except the
2994
- * portable row condition and resource-membership joins.
2995
- */
2996
- /** A Supabase token from a permanent user: anonymous sign-ins carry `is_anonymous: true`. */
2997
- const PERMANENT_USER = `((select auth.jwt()) ->> 'is_anonymous') is distinct from 'true'`;
2998
- function compileGrants(policy, ctx, tables, warnings, skipClosures, rowActions = false) {
2999
- const items = collectGrants(policy);
3000
- const entries = items.flatMap((item) => {
3001
- const entry = prepare(item, tables, warnings, skipClosures, ctx.actions, rowActions);
3002
- return entry === void 0 ? [] : [entry];
3003
- });
3004
- const keys = assignKeys(entries, ctx.fields === "views");
3005
- entries.push(...levelEntries(policy, ctx, keys, tables, warnings, skipClosures));
3006
- const conditionedKeys = /* @__PURE__ */ new Set();
3007
- 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);
3008
- const rows = /* @__PURE__ */ new Map();
3009
- for (const item of items) {
3010
- const holder = item.grant.breakGlass === void 0 ? void 0 : breakGlassHolder(item);
3011
- if (holder !== void 0) {
3012
- const row = {
3013
- role: holder.role,
3014
- permission: item.grant.permission.key,
3015
- grantKey: breakGlassKey(item.grant.permission.key),
3016
- scope: holder.scope,
3017
- effect: item.grant.effect
3018
- };
3019
- rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
3020
- }
3021
- }
3022
- const branches = [];
3023
- const actionBranches = [];
3024
- const rowColumns = /* @__PURE__ */ new Map();
3025
- const filters = (resource, table, column) => {
3026
- rowColumns.set(`${table}\u0000${column}`, {
3027
- resource,
3028
- table,
3029
- column
3030
- });
3031
- };
3032
- const typed = /* @__PURE__ */ new Map();
3033
- const contextFor = (name) => {
3034
- const known = typed.get(name);
3035
- if (known !== void 0) return known;
3036
- const node = policy.resources.get(name);
3037
- const schema = node === void 0 ? void 0 : jsonSchemaOf(node);
3038
- const next = {
3039
- ...ctx,
3040
- columnTypes: columnTypesOf(schema),
3041
- arrayColumns: arrayColumnsOf(schema)
3042
- };
3043
- typed.set(name, next);
3044
- return next;
3045
- };
3046
- for (const entry of entries) {
3047
- const { item, table } = entry;
3048
- const { grant, access, label } = item;
3049
- if (entry.command === "none") {
3050
- const grantKey = keys.get(entry);
3051
- if (access.kind === "role" && grantKey !== void 0) {
3052
- const row = {
3053
- role: access.role,
3054
- permission: grant.permission.key,
3055
- grantKey,
3056
- scope: access.scope,
3057
- effect: grant.effect
3058
- };
3059
- rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
3060
- }
3061
- if (!rowActions) continue;
3062
- }
3063
- const actionOnly = entry.command === "none";
3064
- const command = actionOnly ? "select" : entry.command;
3065
- const target = actionOnly ? actionBranches : branches;
3066
- const rowCtx = {
3067
- ...contextFor(item.grant.permission.resource),
3068
- permission: grant.permission.key
3069
- };
3070
- noteConditions(entry, warnings);
3071
- for (const field of [...indexedFields(entry.using), ...indexedFields(entry.check)]) filters(grant.permission.resource, table, field);
3072
- const grantKey = keys.get(entry);
3073
- let accessExpr;
3074
- if (access.kind === "role" && grantKey !== void 0) {
3075
- const column = access.scope === "global" ? void 0 : scopeField(policy.resources.get(grant.permission.resource), access.scope, policy.scopes);
3076
- accessExpr = accessSql(ctx, access.scope, grantKey, column);
3077
- if (column !== void 0) filters(grant.permission.resource, table, column);
3078
- const row = {
3079
- role: access.role,
3080
- permission: grant.permission.key,
3081
- grantKey,
3082
- scope: access.scope,
3083
- effect: grant.effect
3084
- };
3085
- rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
3086
- }
3087
- const linkOnly = access.kind === "resource" && ctx.capabilities === true && ctx.memberships?.resource?.[access.resource] === void 0;
3088
- if (access.kind === "actor") accessExpr = `${subjectClaimSql(ctx, "client_id")} is not null or ${subjectClaimJsonSql(ctx, "act")} is not null`;
3089
- if (access.kind === "resource" && !linkOnly) accessExpr = compileConditionSql(resourceCondition(item, policy, ctx), {
3090
- ...ctx,
3091
- permission: grant.permission.key
3092
- });
3093
- if (linkOnly) warnings.push(`no ${access.resource} memberships table: only link capabilities reach ${label}/${grant.permission.key}`);
3094
- const validity = validitySql(grant.validity);
3095
- accessExpr = andSql(accessExpr, validity);
3096
- for (const key of grant.requires ?? []) accessExpr = andSql(accessExpr, requiresSql(ctx, key, policy.resources.get(grant.permission.resource)));
3097
- if (ctx.anonymousSignIns === "deny" && access.kind !== "anyone") accessExpr = andSql(accessExpr, PERMANENT_USER);
3098
- if (ctx.apiKeys !== void 0 && grant.effect === "allow" && access.kind !== "anyone" && access.kind !== "role") {
3099
- const calls = (requiresStandIn(grant) ? grant.requires ?? [] : [grant.permission.key, ...grant.requires ?? []]).map((key) => `(select ${apiKeyAllowsCall(ctx, quoteLiteral(key))})`);
3100
- accessExpr = andSql(accessExpr, calls.length === 1 ? calls[0] ?? "" : `(${calls.join(" and ")})`);
3101
- }
3102
- const using = compileOptional(entry.using, rowCtx);
3103
- const check = compileOptional(entry.check, rowCtx);
3104
- const fields = grant.fields === void 0 ? {} : { fields: grant.fields };
3105
- if (!linkOnly) target.push({
3106
- table,
3107
- command,
3108
- effect: grant.effect,
3109
- roles: access.kind === "anyone" ? ["anon", "authenticated"] : ["authenticated"],
3110
- label,
3111
- resource: grant.permission.resource,
3112
- permissionKey: grant.permission.key,
3113
- ...fields,
3114
- ...grantKey === void 0 ? {} : { grantKey },
3115
- ...accessExpr === void 0 ? {} : { access: accessExpr },
3116
- ...using === void 0 ? {} : { using },
3117
- ...check === void 0 ? {} : { check }
3118
- });
3119
- const linked = ctx.capabilities === true ? capabilityAccess(item, policy, ctx) : void 0;
3120
- if (linked !== void 0) target.push({
3121
- table,
3122
- command,
3123
- effect: grant.effect,
3124
- roles: ["anon"],
3125
- label,
3126
- resource: grant.permission.resource,
3127
- permissionKey: grant.permission.key,
3128
- ...fields,
3129
- access: andSql(linked, validity) ?? linked,
3130
- ...using === void 0 ? {} : { using },
3131
- ...check === void 0 ? {} : { check }
3132
- });
3133
- }
3134
- return {
3135
- branches: ensureSelectCoverage(branches, warnings),
3136
- actionBranches,
3137
- rolePermissions: withAliasRows(policy, [...rows.values()]),
3138
- conditionedKeys,
3139
- rowColumns: [...rowColumns.values()],
3140
- levelReach: ctx.customRoles?.levels === true ? levelReachOf(policy, keys) : []
3141
- };
3142
- }
3143
- /**
3144
- * Each row again under every key its permission was renamed from, so SQL that
3145
- * still calls `permdock_has('<old key>')` keeps the access the current key
3146
- * has. A split grant key (`key#n`) keeps its suffix; break-glass rows are not
3147
- * copied.
3148
- */
3149
- function withAliasRows(policy, rows) {
3150
- const former = new Map(listPermissions(policy.permissions).map((leaf) => [leaf.key, formerKeys(leaf)]));
3151
- const out = [...rows];
3152
- for (const row of rows) {
3153
- const suffix = row.grantKey.slice(row.permission.length);
3154
- if (!row.grantKey.startsWith(row.permission) || suffix !== "" && (!/^#[a-z0-9][a-z0-9_-]*$/u.test(suffix) || suffix === "#break-glass")) continue;
3155
- for (const old of former.get(row.permission) ?? []) out.push({
3156
- ...row,
3157
- permission: old,
3158
- grantKey: `${old}${suffix}`
3159
- });
3160
- }
3161
- return out;
3162
- }
3163
- /**
3164
- * Postgres needs SELECT access to find rows for UPDATE / DELETE and for
3165
- * RETURNING, so a table with only update or delete grants gets matching
3166
- * SELECT branches.
3167
- */
3168
- function ensureSelectCoverage(branches, warnings) {
3169
- const extra = [];
3170
- const readable = new Set(branches.filter((item) => item.command === "select" && item.effect === "allow").map((item) => item.table));
3171
- const covered = /* @__PURE__ */ new Set();
3172
- for (const item of branches) {
3173
- if (item.effect !== "allow" || item.command !== "update" && item.command !== "delete" || readable.has(item.table)) continue;
3174
- const { check: _check, ...rest } = item;
3175
- extra.push({
3176
- ...rest,
3177
- command: "select",
3178
- coverage: true
3179
- });
3180
- if (!covered.has(item.table)) {
3181
- covered.add(item.table);
3182
- warnings.push(`added SELECT coverage for ${item.table} (${item.permissionKey})`);
3183
- }
3184
- }
3185
- return [...branches, ...extra];
3186
- }
3187
- /** True when `sql` is one parenthesised expression, so AND / OR need not wrap it again. */
3188
- function isWrapped(sql) {
3189
- if (!sql.startsWith("(") || !sql.endsWith(")")) return false;
3190
- let depth = 0;
3191
- let quoted = false;
3192
- for (let index = 0; index < sql.length; index += 1) {
3193
- const ch = sql[index];
3194
- if (ch === "'") quoted = !quoted;
3195
- else if (!quoted && ch === "(") depth += 1;
3196
- else if (!quoted && ch === ")") {
3197
- depth -= 1;
3198
- if (depth === 0 && index < sql.length - 1) return false;
3199
- }
3200
- }
3201
- return depth === 0;
3202
- }
3203
- function wrapSql(sql) {
3204
- return isWrapped(sql) ? sql : `(${sql})`;
3205
- }
3206
- /**
3207
- * The grant's `validFrom` / `validUntil` as a clock check: `from` inclusive,
3208
- * `until` exclusive, matching the in-process evaluator.
3209
- */
3210
- function validitySql(validity) {
3211
- if (validity === void 0) return;
3212
- return andSql(validity.from === void 0 ? void 0 : `now() >= to_timestamp(${validity.from})`, validity.until === void 0 ? void 0 : `now() < to_timestamp(${validity.until})`);
3213
- }
3214
- function andSql(...parts) {
3215
- const present = parts.filter((part) => part !== void 0 && part !== "true");
3216
- if (present.length === 0) return;
3217
- return present.length === 1 ? present[0] : present.map(wrapSql).join(" and ");
3218
- }
3219
- /** `USING` and `WITH CHECK` for a branch: its access check ANDed with its row conditions. */
3220
- function branchClauses(branch) {
3221
- const using = branch.command === "insert" ? void 0 : andSql(branch.access, branch.using) ?? "true";
3222
- const check = branch.command === "insert" || branch.command === "update" ? andSql(branch.access, branch.check) ?? "true" : void 0;
3223
- return {
3224
- ...using === void 0 ? {} : { using },
3225
- ...check === void 0 ? {} : { check }
3226
- };
3227
- }
3228
- //#endregion
3229
- //#region src/cli/rls-policies.ts
3230
- /** Each database role gets its own policy, so no role sees two permissive policies for one command. */
3231
- const AUDIENCES = ["authenticated", "anon"];
3232
- const DEFAULT_POLICY_NAME = "{table}_{op}";
3233
- const DEFAULT_PER_ROLE_NAME = "{role}_{permission}";
3234
- const PLACEHOLDER = /\{(table|op|role|permission)\}/gu;
3235
- function sanitize(name) {
3236
- return name.replaceAll(/[^A-Za-z0-9_]/g, "_");
3237
- }
3238
- function render(template, values) {
3239
- return sanitize(template.replaceAll(PLACEHOLDER, (_match, key) => values[key]));
3240
- }
3241
- /** Rejects a template that cannot name every policy of the chosen shape uniquely. */
3242
- function assertPolicyName(template, perRole) {
3243
- 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");
3244
- 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");
3245
- }
3246
- function orSql(parts) {
3247
- const distinct = [...new Set(parts)];
3248
- if (distinct.includes("true")) return "true";
3249
- return sole(distinct) ?? distinct.map(wrapSql).join(" or ");
3250
- }
3251
- function negate(sql) {
3252
- return sql === void 0 ? void 0 : `(${sql}) is not true`;
3253
- }
3254
- function policyOf(name, branch, using, check) {
3255
- const deny = branch.effect === "deny";
3256
- const policy = {
3257
- name,
3258
- table: branch.table,
3259
- command: branch.command,
3260
- effect: branch.effect,
3261
- roles: branch.roles
3262
- };
3263
- const finalUsing = deny ? negate(using) : using;
3264
- const finalCheck = deny ? negate(check) : check;
3265
- if (finalUsing !== void 0) policy.using = finalUsing;
3266
- if (finalCheck !== void 0) policy.check = finalCheck;
3267
- return policy;
3268
- }
3269
- function uniqueName(names, name) {
3270
- const seen = names.get(name) ?? 0;
3271
- names.set(name, seen + 1);
3272
- return seen === 0 ? name : `${name}_${seen + 1}`;
3273
- }
3274
- function perRolePolicies(branches, template) {
3275
- const names = /* @__PURE__ */ new Map();
3276
- return branches.map((branch) => {
3277
- const clauses = branchClauses(branch);
3278
- const deny = branch.effect === "deny";
3279
- const base = render(template, {
3280
- table: branch.table,
3281
- op: branch.command,
3282
- role: branch.label,
3283
- permission: branch.permissionKey
3284
- });
3285
- const name = `${deny ? "deny_" : ""}${base}${branch.coverage === true ? "_select_coverage" : ""}`;
3286
- return policyOf(uniqueName(names, name), branch, clauses.using, clauses.check);
3287
- });
3288
- }
3289
- /**
3290
- * Branches of one grant-key group share their row condition, so the group
3291
- * becomes one OR branch whose access ORs the helper calls of each scope.
3292
- */
3293
- function mergeGroups(branches) {
3294
- const merged = [];
3295
- const byKey = /* @__PURE__ */ new Map();
3296
- for (const branch of branches) {
3297
- const id = branch.grantKey === void 0 ? void 0 : `${branch.table}\u0000${branch.command}\u0000${branch.grantKey}\u0000${branch.coverage === true}`;
3298
- const entry = id === void 0 ? void 0 : byKey.get(id);
3299
- if (entry === void 0) {
3300
- const created = {
3301
- branch,
3302
- accesses: branch.access === void 0 ? [] : [branch.access]
3303
- };
3304
- if (id !== void 0) byKey.set(id, created);
3305
- merged.push(created);
3306
- continue;
3307
- }
3308
- if (branch.access !== void 0 && !entry.accesses.includes(branch.access)) entry.accesses.push(branch.access);
3309
- }
3310
- return merged.map(({ branch, accesses }) => accesses.length <= 1 ? branch : withAccess(branch, orSql(accesses)));
3311
- }
3312
- function withAccess(branch, access) {
3313
- return {
3314
- ...branch,
3315
- access
3316
- };
3317
- }
3318
- function collapsedPolicies(branches, template) {
3319
- const groups = /* @__PURE__ */ new Map();
3320
- for (const branch of mergeGroups(branches)) {
3321
- const id = `${branch.table}\u0000${branch.command}\u0000${branch.effect}`;
3322
- groups.set(id, [...groups.get(id) ?? [], branch]);
3323
- }
3324
- const names = /* @__PURE__ */ new Map();
3325
- return [...groups.values()].flatMap((group) => AUDIENCES.flatMap((audience) => {
3326
- const members = group.filter((branch) => branch.roles.includes(audience));
3327
- const [first] = members;
3328
- if (first === void 0) return [];
3329
- const deny = first.effect === "deny";
3330
- const clauses = members.map(branchClauses);
3331
- const usings = clauses.flatMap((item) => item.using === void 0 ? [] : [item.using]);
3332
- const checks = clauses.flatMap((item) => item.check === void 0 ? [] : [item.check]);
3333
- const base = render(template, {
3334
- table: first.table,
3335
- op: first.command,
3336
- role: first.label,
3337
- permission: first.permissionKey
3338
- });
3339
- return [policyOf(uniqueName(names, `${deny ? "deny_" : ""}${base}${audience === "anon" ? "_anon" : ""}`), {
3340
- ...first,
3341
- roles: [audience]
3342
- }, usings.length === 0 ? void 0 : orSql(usings), checks.length === 0 ? void 0 : orSql(checks))];
3343
- }));
3344
- }
3345
- /**
3346
- * Assembles policies. The default is one PERMISSIVE policy per table,
3347
- * command and database role (Splinter `multiple_permissive_policies` stays
3348
- * quiet), with denies in one RESTRICTIVE policy per table, command and role.
3349
- */
3350
- function assemblePolicies(branches, shape) {
3351
- const template = shape.name ?? (shape.perRole ? DEFAULT_PER_ROLE_NAME : DEFAULT_POLICY_NAME);
3352
- assertPolicyName(template, shape.perRole);
3353
- return shape.perRole ? perRolePolicies(branches, template) : collapsedPolicies(branches, template);
3354
- }
3355
- const DEFAULT_READ_ONLY_ACTORS = ["support", "impersonation"];
3356
- const ACTOR_KIND = /^[a-z][a-z0-9_-]*$/u;
3357
- /** The actor kinds `rls.readOnlyActors` names, or `undefined` when it is off. */
3358
- function readOnlyActorKinds(setting) {
3359
- if (setting === void 0 || setting === false) return;
3360
- const kinds = setting === true ? DEFAULT_READ_ONLY_ACTORS : setting;
3361
- 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'");
3362
- return [...new Set(kinds)];
3363
- }
3364
- /**
3365
- * One restrictive policy per table and write command the allow policies
3366
- * grant to `authenticated`: the write passes unless the token's `act` claim
3367
- * (`act`, as SQL `jsonb`) is a session of one of `kinds` that is not
3368
- * `read_only: false`. Reads are never touched.
3369
- */
3370
- function readOnlyActorPolicies(policies, act, kinds) {
3371
- const list = `array[${kinds.map((kind) => `'${kind}'`).join(", ")}]::text[]`;
3372
- 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)`;
3373
- const seen = /* @__PURE__ */ new Set();
3374
- const out = [];
3375
- for (const policy of policies) {
3376
- const key = `${policy.table}\u0000${policy.command}`;
3377
- if (policy.effect !== "allow" || policy.command === "select" || !policy.roles.includes("authenticated") || seen.has(key)) continue;
3378
- seen.add(key);
3379
- out.push({
3380
- name: sanitize(`${policy.table}_${policy.command}_read_only_actors`),
3381
- table: policy.table,
3382
- command: policy.command,
3383
- effect: "deny",
3384
- roles: ["authenticated"],
3385
- ...policy.command === "insert" ? {} : { using: passes },
3386
- ...policy.command === "delete" ? {} : { check: passes }
3387
- });
3388
- }
3389
- return out;
3390
- }
3391
- //#endregion
3392
- //#region src/cli/rls-fields.ts
3393
- /** Names of the field-view objects. Part of the SQL contract. */
3394
- const FIELD_VIEWS = {
3395
- /** `<table>_visible`: the `security_invoker` view clients read. */
3396
- view: "_visible",
3397
- /** `<table>_visible_fields`: the owner-rights companion `--revoke-columns` adds. */
3398
- companion: "_visible_fields",
3399
- /** The companion's row-key column, joined to the base table's key. */
3400
- key: "permdock_key",
3401
- /** Marks the companion so doctor PD022 knows it reads as its owner on purpose. */
3402
- comment: "permdock:field-companion"
3403
- };
3404
- function covers(branch, column) {
3405
- return branch.fields === void 0 || branch.fields.includes(column);
3406
- }
3407
- /**
3408
- * A read grant that only shapes columns: a field-limited deny (it never
3409
- * denies the row) or an allow with an empty list (it grants nothing). With
3410
- * field views it leaves the row policy and lives in the view's masks.
3411
- */
3412
- function fieldOnly(branch) {
3413
- if (branch.command !== "select" || branch.fields === void 0) return false;
3414
- return branch.effect === "deny" || branch.fields.length === 0;
3415
- }
3416
- /** Branches the row policies keep when field views compile. */
3417
- function rowBranches(branches) {
3418
- return branches.filter((branch) => !fieldOnly(branch));
3419
- }
3420
- /**
3421
- * Whether the reader is signed in, for branches with no helper call: a grant
3422
- * to any signed-in user relies on `to authenticated` in its policy, which a
3423
- * view does not have.
3424
- */
3425
- function audience(ctx, branch) {
3426
- if (branch.access !== void 0 || branch.roles.length !== 1) return;
3427
- if (branch.roles[0] === "authenticated") return signedIn(ctx);
3428
- return branch.roles[0] === "anon" ? `not (${signedIn(ctx)})` : void 0;
3429
- }
3430
- function clause(ctx, branch) {
3431
- return andSql(audience(ctx, branch), branchClauses(branch).using) ?? "true";
3432
- }
3433
- /**
3434
- * `can(permission, row, { field })` in SQL: an allow that covers the column
3435
- * holds for the row and no covering deny does. Every access term is the
3436
- * uncorrelated helper call the row policy uses, so it runs once per statement.
3437
- */
3438
- function maskSql(ctx, allows, denies, column) {
3439
- const allowed = allows.filter((branch) => covers(branch, column)).map((branch) => clause(ctx, branch));
3440
- if (allowed.length === 0) return "false";
3441
- const denied = denies.filter((branch) => covers(branch, column)).map((branch) => clause(ctx, branch));
3442
- const allow = orSql(allowed);
3443
- if (denied.length === 0) return allow;
3444
- const deny = orSql(denied);
3445
- return allow === "true" ? `not ${wrapSql(deny)}` : `${wrapSql(allow)} and not ${wrapSql(deny)}`;
3446
- }
3447
- function schemaColumns(policy, resource) {
3448
- const node = policy.resources.get(resource);
3449
- const schema = node === void 0 ? null : jsonSchemaOf(node);
3450
- const properties = schema !== null && typeof schema === "object" ? schema.properties : void 0;
3451
- return properties !== null && typeof properties === "object" ? Object.keys(properties) : void 0;
3452
- }
3453
- function viewName(table, suffix) {
3454
- return `${table}${suffix}`;
3455
- }
3456
- /**
3457
- * `<helper schema>.<table>_visible_fields`: outside the Data API's schemas,
3458
- * so its owner rights reach clients only through the `_visible` join.
3459
- */
3460
- function companionName(ctx, table) {
3461
- const base = table.startsWith("public.") ? table.slice(7) : table.replaceAll(".", "_");
3462
- return `${helperSchema(ctx)}.${viewName(base, FIELD_VIEWS.companion)}`;
3463
- }
3464
- /**
3465
- * One view per table whose read grants limit fields. A column is restricted
3466
- * when some read allow lists fields without it or some read deny lists it;
3467
- * every other column, and the row key, passes through unchanged.
3468
- */
3469
- function fieldViews(policy, ctx, branches, options, warnings) {
3470
- const byTable = /* @__PURE__ */ new Map();
3471
- for (const branch of branches) {
3472
- if (branch.command !== "select" || branch.coverage === true) continue;
3473
- byTable.set(branch.table, [...byTable.get(branch.table) ?? [], branch]);
3474
- }
3475
- const views = [];
3476
- for (const [table, reads] of byTable) {
3477
- const allows = reads.filter((branch) => branch.effect === "allow" && branch.fields?.length !== 0);
3478
- const denies = reads.filter((branch) => branch.effect === "deny");
3479
- const limited = reads.some((branch) => branch.fields !== void 0);
3480
- const resource = reads.find((branch) => branch.resource !== void 0)?.resource;
3481
- if (!limited || allows.length === 0 || resource === void 0) continue;
3482
- const names = schemaColumns(policy, resource);
3483
- 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}`);
3484
- const key = policy.resources.get(resource)?.id ?? "id";
3485
- const restricted = (column) => column !== key && (allows.some((branch) => branch.fields !== void 0 && !branch.fields.includes(column)) || denies.some((branch) => branch.fields?.includes(column) === true));
3486
- const columns = names.map((name) => {
3487
- quoteIdent(name);
3488
- return restricted(name) ? {
3489
- name,
3490
- mask: maskSql(ctx, allows, denies, name)
3491
- } : { name };
3492
- });
3493
- if (columns.every((column) => column.mask === void 0)) continue;
3494
- const view = viewName(table, FIELD_VIEWS.view);
3495
- 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`);
3496
- const hidden = columns.filter((column) => column.mask !== void 0).map((column) => column.name);
3497
- 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`);
3498
- 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`);
3499
- views.push({
3500
- resource,
3501
- table,
3502
- view,
3503
- key,
3504
- columns,
3505
- roles: reads.some((branch) => branch.roles.includes("anon")) ? ["anon", "authenticated"] : ["authenticated"],
3506
- ...options.revokeColumns ? { companion: companionName(ctx, table) } : {}
3507
- });
3508
- }
3509
- return views;
3510
- }
3511
- /** Columns of `view`'s table that `anon` and `authenticated` keep on the base table with `--revoke-columns`. */
3512
- function readableColumns(view) {
3513
- return view.columns.filter((column) => column.mask === void 0).map((column) => column.name);
3514
- }
3515
- function restrictedColumns(view) {
3516
- return view.columns.filter((column) => column.mask !== void 0).map((column) => column.name);
3517
- }
3518
- function columnList(names) {
3519
- return names.map(quoteIdent).join(", ");
3520
- }
3521
- function selectList(lines) {
3522
- return lines.map((line) => ` ${line}`).join(",\n");
3523
- }
3524
- function grantView(name, roles) {
3525
- return [`revoke all on table ${quoteTable(qualifiedTable(name))} from anon, authenticated, public;`, `grant select on table ${quoteTable(qualifiedTable(name))} to ${roles.join(", ")};`].join("\n");
3526
- }
3527
- function inlineViewSql(view) {
3528
- 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)}`);
3529
- return `create or replace view ${quoteTable(qualifiedTable(view.view))} with (security_invoker = true) as
3530
- select
3531
- ${selectList(targets)}
3532
- from ${quoteTable(qualifiedTable(view.table))};`;
3533
- }
3534
- function companionSql(view, companion) {
3535
- const restricted = view.columns.filter((column) => column.mask !== void 0);
3536
- 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)}`)];
3537
- const any = orSql(restricted.map((column) => column.mask ?? "false"));
3538
- const name = companion.slice(0, companion.indexOf("."));
3539
- const schema = quoteIdent(name);
3540
- return `${name === "public" ? "" : `create schema if not exists ${schema};
3541
- revoke all on schema ${schema} from public;
3542
- grant usage on schema ${schema} to ${view.roles.join(", ")};
3543
- `}create or replace view ${quoteTable(qualifiedTable(companion))} with (security_barrier = true) as
3544
- select
3545
- ${selectList(targets)}
3546
- from ${quoteTable(qualifiedTable(view.table))}
3547
- where ${any};
3548
- comment on view ${quoteTable(qualifiedTable(companion))} is ${quoteLiteral(`${FIELD_VIEWS.comment} ${view.view}`)};`;
3549
- }
3550
- function joinedViewSql(view, companion) {
3551
- const targets = view.columns.map((column) => column.mask === void 0 ? `t.${quoteIdent(column.name)}` : `f.${quoteIdent(column.name)}`);
3552
- return `create or replace view ${quoteTable(qualifiedTable(view.view))} with (security_invoker = true) as
3553
- select
3554
- ${selectList(targets)}
3555
- from ${quoteTable(qualifiedTable(view.table))} t
3556
- left join ${quoteTable(qualifiedTable(companion))} f on f.${quoteIdent(FIELD_VIEWS.key)} = t.${quoteIdent(view.key)};`;
3557
- }
3558
- /** `select` on only the unrestricted columns (and the key) of the base table, for `--revoke-columns`. */
3559
- function columnGrantSql(view, role) {
3560
- return `grant select (${columnList(readableColumns(view))}) on table ${quoteTable(qualifiedTable(view.table))} to ${role};`;
3561
- }
3562
- function columnRevokeSql(view) {
3563
- return `revoke select (${columnList(restrictedColumns(view))}) on table ${quoteTable(qualifiedTable(view.table))} from anon, authenticated;`;
3564
- }
3565
- /**
3566
- * The view SQL: the `security_invoker` view, and with `--revoke-columns` the
3567
- * owner-rights companion it joins for the restricted columns. The companion
3568
- * masks every column with the full field decision and keeps only rows where
3569
- * one is readable, so reading it directly shows nothing `pick` would not.
3570
- */
3571
- function fieldViewsSql(views) {
3572
- return views.map((view) => {
3573
- const head = `-- field view over ${view.table}: ${restrictedColumns(view).join(", ")} read as null unless a read grant covers the column for the row`;
3574
- if (view.companion === void 0) return [
3575
- head,
3576
- inlineViewSql(view),
3577
- grantView(view.view, view.roles)
3578
- ].join("\n");
3579
- return [
3580
- head,
3581
- `-- ${view.companion} reads ${view.table} as its owner; ${view.view} reads the rows as the caller and joins it`,
3582
- companionSql(view, view.companion),
3583
- grantView(view.companion, view.roles),
3584
- joinedViewSql(view, view.companion),
3585
- grantView(view.view, view.roles)
3586
- ].join("\n");
3587
- }).join("\n\n");
3588
- }
3589
- //#endregion
3590
- 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 };