permdock 0.4.0 → 0.5.0

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