permdock 0.5.0 → 0.6.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 (281) hide show
  1. package/dist/a2a/index.d.ts +5 -5
  2. package/dist/a2a/index.js +3 -3
  3. package/dist/{adoption-scan-B4R5fnMU.js → adoption-scan-DCYe60Pi.js} +1 -1
  4. package/dist/agent/index.d.ts +2 -2
  5. package/dist/agent/index.js +1 -1
  6. package/dist/ai-sdk/index.d.ts +4 -4
  7. package/dist/ai-sdk/index.js +3 -3
  8. package/dist/{approval-prompt-Djo_PyZc.d.ts → approval-prompt-DvSSPQJX.d.ts} +2 -2
  9. package/dist/approvals/index.d.ts +3 -3
  10. package/dist/approvals/index.js +3 -3
  11. package/dist/{approvals-DJ6Yd5z1.js → approvals-p1PGoVjb.js} +3 -3
  12. package/dist/{arazzo-B6lebmiQ.js → arazzo-41WuzHou.js} +1 -1
  13. package/dist/{arazzo-BWhNF-k0.js → arazzo-CaUVBn7D.js} +2 -2
  14. package/dist/astro/index.d.ts +6 -6
  15. package/dist/astro/index.js +2 -2
  16. package/dist/{audience-Dg8XsRaO.js → audience-DabClFLQ.js} +1 -1
  17. package/dist/authzen/index.d.ts +4 -4
  18. package/dist/authzen/index.js +2 -2
  19. package/dist/{automation-BMZPIVUM.d.ts → automation-BNM3TtDo.d.ts} +3 -3
  20. package/dist/better-auth/index.d.ts +2 -2
  21. package/dist/better-auth/index.js +1 -1
  22. package/dist/better-supabase/index.d.ts +7 -7
  23. package/dist/better-supabase/index.js +6 -6
  24. package/dist/{bind-CZzhx-Vj.js → bind-CAvgUuKs.js} +4 -4
  25. package/dist/{bind--9EFVYWF.d.ts → bind-DOyrv6zi.d.ts} +7 -7
  26. package/dist/{build-instance-BMnAudHn.js → build-instance-Bu_eDqYw.js} +1 -1
  27. package/dist/{capability-BSslw1QV.d.ts → capability-Drg-1YhL.d.ts} +2 -2
  28. package/dist/catalog/index.d.ts +5 -5
  29. package/dist/catalog/index.js +3 -3
  30. package/dist/catalog-BoWglDPI.js +1 -0
  31. package/dist/{catalog-R_A__RsH.js → catalog-DZVbrF-z.js} +1 -1
  32. package/dist/{catalog-BECMPrwc.js → catalog-HD2gf5mY.js} +2 -2
  33. package/dist/{catalog-doc-C9wjsfNY.js → catalog-doc-Cdn7-0pQ.js} +1 -1
  34. package/dist/chat/index.d.ts +1 -1
  35. package/dist/chat/index.js +2 -2
  36. package/dist/chat-sdk/index.d.ts +3 -3
  37. package/dist/claude-agent/index.d.ts +3 -3
  38. package/dist/claude-agent/index.js +2 -2
  39. package/dist/clerk/index.d.ts +3 -3
  40. package/dist/cli/bin.js +1 -1
  41. package/dist/cli/index.d.ts +3 -3
  42. package/dist/cli/index.js +2 -2
  43. package/dist/cloud/index.d.ts +8 -8
  44. package/dist/cloud/index.js +1 -1
  45. package/dist/{cloud-CIoHNevm.js → cloud-ytQ3uviA.js} +2 -2
  46. package/dist/{codemod-Bmj9dWMH.js → codemod-5mpSpnUY.js} +2 -2
  47. package/dist/{codemod-Cxvvc70T.js → codemod-ua2hHy7w.js} +1 -1
  48. package/dist/{collect-DloHbDVa.js → collect-Ci6akVwH.js} +2 -2
  49. package/dist/collect-D7eQDffE.js +1 -0
  50. package/dist/{collect-CrZJJnEF.js → collect-mLTY4nwb.js} +2 -2
  51. package/dist/compile/index.d.ts +1 -1
  52. package/dist/{compile-Ckm2HqBZ.d.ts → compile-1bsAezL0.d.ts} +3 -3
  53. package/dist/{completion-BqhRBJD8.js → completion-BTFrHZhX.js} +1 -1
  54. package/dist/{config-B46rm9sc.js → config-Bj2Ixq3d.js} +1 -1
  55. package/dist/{config-report-DUgj62Ai.js → config-report-DSpsLpCE.js} +2 -2
  56. package/dist/{context-bbFHR-0D.js → context-B_u3_bDJ.js} +2 -2
  57. package/dist/convex/index.d.ts +5 -5
  58. package/dist/convex/index.js +2 -2
  59. package/dist/{create-0VZ7Pdiu.d.ts → create-B30OMUOJ.d.ts} +5 -5
  60. package/dist/{create-BDS7wc3y.d.ts → create-CBMmFie-.d.ts} +9 -9
  61. package/dist/{create-C3b4B5eF.js → create-CGm28gyI.js} +4 -4
  62. package/dist/{create-BWRSXgOF.js → create-CQWIpUG-.js} +1 -1
  63. package/dist/{create-DnrmLnLW.js → create-DCF2SvoT.js} +4 -4
  64. package/dist/{create-BFfm17UA.d.ts → create-D_NmPOdH.d.ts} +7 -7
  65. package/dist/{credential-DM2um3ha.js → credential-BcoiejpY.js} +2 -2
  66. package/dist/{delegation-DaXVdDhM.js → delegation-j6-rc37o.js} +2 -2
  67. package/dist/delivery/index.d.ts +4 -4
  68. package/dist/delivery/index.js +1 -1
  69. package/dist/{denied-text-D8jDHgsl.d.ts → denied-text-CiFQNuMV.d.ts} +2 -2
  70. package/dist/{describe-BZUsixiL.d.ts → describe-B5qgDvEW.d.ts} +2 -2
  71. package/dist/{diff-DCePiN6M.js → diff-3jbx6Rm4.js} +3 -3
  72. package/dist/{diff-CxZo9pTq.js → diff-CmuSD3vc.js} +1 -1
  73. package/dist/{doctor-CxiwSfqH.js → doctor-3ZqNHX_G.js} +2 -2
  74. package/dist/{doctor-DFjUxf2f.js → doctor-BdZw53-k.js} +14 -14
  75. package/dist/drizzle/index.d.ts +3 -3
  76. package/dist/elysia/index.d.ts +5 -5
  77. package/dist/elysia/index.js +3 -3
  78. package/dist/{errors-C2PXIv4c.d.ts → errors-DH5QEGXh.d.ts} +2 -2
  79. package/dist/{evaluate-_R8QkWaf.js → evaluate-CNClBvcj.js} +81 -28
  80. package/dist/{evaluations-16UqqQ6W.d.ts → evaluations-C_1vdSZc.d.ts} +3 -3
  81. package/dist/{evaluations-DZ2CbV5k.js → evaluations-beAooV0n.js} +1 -1
  82. package/dist/eve/index.d.ts +5 -5
  83. package/dist/eve/index.js +3 -3
  84. package/dist/express/index.d.ts +5 -5
  85. package/dist/express/index.js +3 -3
  86. package/dist/fastify/index.d.ts +5 -5
  87. package/dist/fastify/index.js +3 -3
  88. package/dist/{fetch-adapter-g0ypLkID.js → fetch-adapter-DD_gfmrC.js} +2 -2
  89. package/dist/{fetch-adapter-qKJBuRqy.d.ts → fetch-adapter-Dw0MTS3D.d.ts} +4 -4
  90. package/dist/{fields-D9tPVUUS.js → fields-OYG_JR78.js} +3 -3
  91. package/dist/{from-snapshot-D2lZt4Dd.js → from-snapshot-CTCMsFZk.js} +4 -4
  92. package/dist/{grantee-Cn1HyUsI.js → grantee-hxrUsbpM.js} +13 -3
  93. package/dist/{grantee-match-DHArqY82.js → grantee-match-DbENBHLB.js} +1 -1
  94. package/dist/h3/index.d.ts +4 -4
  95. package/dist/h3/index.js +3 -3
  96. package/dist/{helpers-DTcZOzhF.js → helpers-CBYVMjwh.js} +1 -1
  97. package/dist/hono/index.d.ts +5 -5
  98. package/dist/hono/index.js +3 -3
  99. package/dist/{hosted-BZIAYvon.js → hosted-B46-5LYE.js} +3 -3
  100. package/dist/{hosted-DBDX0M-P.d.ts → hosted-BPk51-kX.d.ts} +1 -1
  101. package/dist/{import-DMmOG27O.js → import-wyqXUyjm.js} +3 -3
  102. package/dist/{index-Chrs5fQ-.d.ts → index-B8YjkEZ4.d.ts} +6 -6
  103. package/dist/{index-CxGUswIC.d.ts → index-C-6Letxg.d.ts} +5 -5
  104. package/dist/{index-SGdIQyzc.d.ts → index-Ch8LpNDp.d.ts} +2 -2
  105. package/dist/{index-D8EovfsP.d.ts → index-Cp_ilKi1.d.ts} +2 -2
  106. package/dist/{index-BcHcPyIo.d.ts → index-CueFnxQc.d.ts} +6 -6
  107. package/dist/{index-DOVf7RJv.d.ts → index-DuWxc5dt.d.ts} +21 -12
  108. package/dist/index.d.ts +17 -17
  109. package/dist/index.js +14 -14
  110. package/dist/{instance-options-Omfd793B.d.ts → instance-options-BsVpyGIk.d.ts} +1 -1
  111. package/dist/{instance-snapshot-Cxq4ygFc.js → instance-snapshot-BOEhcqIF.js} +10 -9
  112. package/dist/jwt/index.d.ts +4 -4
  113. package/dist/{kernel-5JatXyND.js → kernel-D0NVb1cK.js} +5 -5
  114. package/dist/kysely/index.d.ts +3 -3
  115. package/dist/{manifest-CtKR3GXL.js → manifest-dvL2XEe1.js} +23 -23
  116. package/dist/{map-error-TCAdw-eW.js → map-error-Dr70oA_3.js} +1 -1
  117. package/dist/mcp/index.d.ts +9 -9
  118. package/dist/mcp/index.js +4 -4
  119. package/dist/{migrate-BShP7Vf4.js → migrate-CIq4-E9F.js} +1 -1
  120. package/dist/nest/index.d.ts +5 -5
  121. package/dist/nest/index.js +3 -3
  122. package/dist/next/index.d.ts +9 -9
  123. package/dist/next/index.js +3 -3
  124. package/dist/next/plugin.d.ts +1 -1
  125. package/dist/next/plugin.js +1 -1
  126. package/dist/node/index.d.ts +5 -5
  127. package/dist/node/index.js +2 -2
  128. package/dist/openai/index.d.ts +5 -5
  129. package/dist/openai/index.js +3 -3
  130. package/dist/openapi/index.d.ts +3 -3
  131. package/dist/{openapi-ahr8rHu3.js → openapi-C4dDTVEd.js} +1 -1
  132. package/dist/{openapi-DTaEmTSW.js → openapi-DD1SV-5-.js} +1 -1
  133. package/dist/{operations-CxybZErY.d.ts → operations-X0VM7F3m.d.ts} +1 -1
  134. package/dist/orpc/index.d.ts +6 -6
  135. package/dist/orpc/index.js +3 -3
  136. package/dist/otel/index.d.ts +3 -3
  137. package/dist/{output-Dyw8fyPF.js → output-1im__-08.js} +1 -1
  138. package/dist/{ownership-BatFgAA4.js → ownership-DqaJNJPn.js} +921 -858
  139. package/dist/{parse-local-manifest-DgqtruPn.d.ts → parse-local-manifest-5ODwqu8b.d.ts} +1 -1
  140. package/dist/pdp/index.d.ts +3 -3
  141. package/dist/pdp/index.js +1 -1
  142. package/dist/{permdock-B9JxkBwC.js → permdock-BrAscV28.js} +5 -5
  143. package/dist/{permdock-D7eeLJER.d.ts → permdock-SV8ZINYT.d.ts} +4 -4
  144. package/dist/{permissions-CZKiUfFM.js → permissions-BPxlwVhN.js} +2 -2
  145. package/dist/{permissions-oEWfl-kl.d.ts → permissions-u_KHg-sN.d.ts} +1 -1
  146. package/dist/{policies-CO1oOymN.js → policies-DP2nqRoF.js} +1 -1
  147. package/dist/{policy-Czo4n3Hs.d.ts → policy-77FSAq_V.d.ts} +2 -2
  148. package/dist/{policy-DLZJ5gAp.js → policy-DbM5YYtV.js} +2 -2
  149. package/dist/{policy-from-catalog-DzhEc3Y9.js → policy-from-catalog-C2gMa2Dj.js} +5 -5
  150. package/dist/{policy-types-DzwivBgR.d.ts → policy-types-n0uRTsW8.d.ts} +28 -3
  151. package/dist/{postgrest-NUqZT-RB.d.ts → postgrest-CEgRbefE.d.ts} +1 -1
  152. package/dist/{postgrest-CW0IqC-U.js → postgrest-DAjUkF9w.js} +10 -1
  153. package/dist/{powersync-bYUDLOAO.js → powersync-BkWWCS1K.js} +6 -6
  154. package/dist/{powersync-BHfuehf3.js → powersync-DISt6SUe.js} +2 -2
  155. package/dist/powersync-MMJ2mI2q.js +1 -0
  156. package/dist/prisma/index.d.ts +2 -2
  157. package/dist/{problem-details-DXlt-vks.d.ts → problem-details-B45V5d2b.d.ts} +1 -1
  158. package/dist/{protected-DfjY5mLA.js → protected-BlF0F6wL.js} +1 -1
  159. package/dist/{protected-DEPHc2xV.d.ts → protected-RFGpakN9.d.ts} +5 -5
  160. package/dist/{provider-CC-u3bYP.js → provider-B1al-AZz.js} +2 -2
  161. package/dist/{provider-DMVt6yYn.d.ts → provider-DILL9oPK.d.ts} +1 -1
  162. package/dist/{provider-store-B5Yrkhzr.js → provider-store-DM6DNcta.js} +2 -2
  163. package/dist/pull-CvpINcMK.js +1 -0
  164. package/dist/{pull-BgIfno6w.js → pull-jEwbf80J.js} +1 -1
  165. package/dist/{push-CmFVBOeI.js → push-BD4f15co.js} +2 -2
  166. package/dist/{pushed-policy-DobP7AHI.js → pushed-policy-CqtxkV4G.js} +1 -1
  167. package/dist/{pushed-policy-BImMTTIM.d.ts → pushed-policy-DCW91KQU.d.ts} +1 -1
  168. package/dist/react/index.d.ts +6 -6
  169. package/dist/react/index.js +2 -2
  170. package/dist/react/provider-client.d.ts +1 -1
  171. package/dist/react/provider-client.js +1 -1
  172. package/dist/react-native/index.d.ts +9 -9
  173. package/dist/react-native/index.js +10 -8
  174. package/dist/{replay-D7iKcLFe.d.ts → replay-Bkm-Ne4l.d.ts} +1 -1
  175. package/dist/{request-context-BTRTG7OT.js → request-context-C81Pip9n.js} +1 -1
  176. package/dist/resend/index.d.ts +4 -4
  177. package/dist/{rls-BA8an5Ap.js → rls-BAqP4q66.js} +1 -1
  178. package/dist/{rls-j6t43t7n.js → rls-DTvYd-lI.js} +12 -12
  179. package/dist/{role-builder-CXbis5-X.js → role-builder-uWQZ4v84.js} +1 -1
  180. package/dist/{rpc-aPyNi6tA.js → rpc-VSl5ylaQ.js} +1 -1
  181. package/dist/{run-DOfMtc9V.js → run-CUKuz0A8.js} +2 -2
  182. package/dist/run-DDwUaJDU.js +1 -0
  183. package/dist/{run-ticket-BjhTOi6u.d.ts → run-ticket-DocwaTKn.d.ts} +2 -2
  184. package/dist/{saas-FO-Q8oj0.js → saas-BCXRuXca.js} +5 -5
  185. package/dist/{scan-BOI5tJ-6.js → scan-qvOe0hS3.js} +1 -1
  186. package/dist/scim/index.d.ts +1 -1
  187. package/dist/server/index.d.ts +10 -10
  188. package/dist/server/index.js +4 -4
  189. package/dist/{server-DqCT4CgZ.js → server-C3WXAHZj.js} +1 -1
  190. package/dist/{server-types-BLd4MnBX.d.ts → server-types-CKQiCnON.d.ts} +9 -9
  191. package/dist/{shared-CD11nBbC.js → shared-BAK8t4Uo.js} +2 -1
  192. package/dist/{sink-Bj_lDUDq.d.ts → sink-4-eQIRWf.d.ts} +4 -4
  193. package/dist/{snapshot-CXUWfsNy.js → snapshot-B7DkYg6g.js} +1 -1
  194. package/dist/{snapshot-cache-DqK9eDmM.d.ts → snapshot-cache-PXI-vGpY.d.ts} +1 -1
  195. package/dist/solid/index.d.ts +5 -5
  196. package/dist/solid/index.js +2 -2
  197. package/dist/solid-start/index.d.ts +6 -6
  198. package/dist/solid-start/index.js +3 -3
  199. package/dist/{sources-BV9j0DeZ.d.ts → sources-Xc4zkszI.d.ts} +2 -2
  200. package/dist/{src-DLfUWFAI.js → src-Cu2m75uo.js} +24 -15
  201. package/dist/ssf/index.d.ts +3 -3
  202. package/dist/ssf/index.js +1 -1
  203. package/dist/{standard-webhooks-6lg8v_is.d.ts → standard-webhooks-rbD7LS6h.d.ts} +2 -2
  204. package/dist/{store-4u_hxjWa.js → store-BDutJkDL.js} +1 -1
  205. package/dist/{store-options-BqEakYRa.js → store-options-u0ycEe8s.js} +4 -4
  206. package/dist/{stores-ClZUgeoP.d.ts → stores-BUGNbJTt.d.ts} +5 -5
  207. package/dist/{stores-BbsD4fAR.js → stores-CGhM8BN_.js} +1 -1
  208. package/dist/{stream-7-xmnf3U.d.ts → stream-CeCMJmBB.d.ts} +2 -2
  209. package/dist/supabase/index.d.ts +5 -5
  210. package/dist/supabase/index.js +2 -2
  211. package/dist/supabase/middleware.d.ts +6 -6
  212. package/dist/supabase/middleware.js +5 -5
  213. package/dist/{supabase-9pqI57pW.js → supabase-By_frk3U.js} +2 -2
  214. package/dist/{supabase-hook-DdDQhMlw.js → supabase-hook-BMoEhZir.js} +14 -9
  215. package/dist/supabase-hook-CCw5m35C.js +1 -0
  216. package/dist/{supabase-Dxlp8A94.js → supabase-x5JtR2tc.js} +3 -3
  217. package/dist/svelte/index.d.ts +5 -5
  218. package/dist/svelte/index.js +2 -2
  219. package/dist/svelte/runtime.d.ts +3 -3
  220. package/dist/svelte/runtime.js +1 -1
  221. package/dist/svelte/source.d.ts +5 -5
  222. package/dist/svelte/source.js +1 -1
  223. package/dist/sveltekit/index.d.ts +6 -6
  224. package/dist/sveltekit/index.js +2 -2
  225. package/dist/tanstack-start/index.d.ts +6 -6
  226. package/dist/tanstack-start/index.js +2 -2
  227. package/dist/terminal/index.d.ts +6 -6
  228. package/dist/terminal/index.js +3 -3
  229. package/dist/testing/index.d.ts +20 -20
  230. package/dist/testing/index.js +18 -18
  231. package/dist/testing/saas/index.d.ts +5 -5
  232. package/dist/testing/saas/index.js +2 -2
  233. package/dist/testing/saas/permissions.d.ts +1 -1
  234. package/dist/testing/saas/permissions.js +1 -1
  235. package/dist/trpc/index.d.ts +6 -6
  236. package/dist/trpc/index.js +3 -3
  237. package/dist/{types-C8-KNsFf.d.ts → types-8RnwUAvE.d.ts} +5 -5
  238. package/dist/{types-BNORmDM8.d.ts → types-B3_ooZgZ.d.ts} +4 -4
  239. package/dist/{types-CCpc2Dcv.d.ts → types-BdtSDbBO.d.ts} +2 -2
  240. package/dist/{types-DX0LaKTb.d.ts → types-BtHtcKMr.d.ts} +3 -3
  241. package/dist/{types-CdlxsYQh.d.ts → types-BzzaHxt3.d.ts} +1 -1
  242. package/dist/{types-DiQjupG2.d.ts → types-C791HFu9.d.ts} +2 -2
  243. package/dist/{types-BjV-fqcA.d.ts → types-C863OQiR.d.ts} +4 -4
  244. package/dist/{types-fbZ7JEXr.d.ts → types-CVZSNocA.d.ts} +4 -4
  245. package/dist/{types-tIgIeIKJ.d.ts → types-CX7AvRqP.d.ts} +4 -4
  246. package/dist/{types-_sHy8BQf.d.ts → types-C_IiEs64.d.ts} +1 -1
  247. package/dist/{types-5Ut_pbKy.d.ts → types-Dfv0aTER.d.ts} +4 -2
  248. package/dist/{types-CZTnPFzM.d.ts → types-Druu9wLM.d.ts} +2 -2
  249. package/dist/{types-ClZaWzfb.d.ts → types-_WhUP7ud.d.ts} +3 -3
  250. package/dist/{types-BqAKcRNW.d.ts → types-lUlG3TVd.d.ts} +2 -2
  251. package/dist/unplugin/index.d.ts +1 -1
  252. package/dist/unplugin/index.js +1 -1
  253. package/dist/{usage-DHtE8j4E.js → usage-CRVl9V3t.js} +1 -1
  254. package/dist/usage-CZpSQP6L.js +1 -0
  255. package/dist/{usage-T5mXq98v.js → usage-SveTRTyr.js} +4 -4
  256. package/dist/{validation-error-Bg5Tpluk.d.ts → validation-error-DHUrAkku.d.ts} +1 -1
  257. package/dist/{verify-DbCL9bnB.js → verify-BaAKx7t6.js} +11 -11
  258. package/dist/{vocabulary-D6lPdb4Z.d.ts → vocabulary-CN8H_9UF.d.ts} +2 -0
  259. package/dist/vue/index.d.ts +5 -5
  260. package/dist/vue/index.js +2 -2
  261. package/dist/{waiter-CKy4MQSY.d.ts → waiter-B986w1oK.d.ts} +2 -2
  262. package/dist/watch--ovlPOc0.js +1 -0
  263. package/dist/{watch-CszoTJMq.js → watch-vea54PRw.js} +1 -1
  264. package/dist/webmcp/index.d.ts +2 -2
  265. package/dist/{where-scope-Dv-WTP-w.js → where-scope-C9vfEphl.js} +2 -2
  266. package/dist/{who-can-D0QoYbEb.js → who-can-DWBAcaCQ.js} +2 -2
  267. package/dist/workflow/index.d.ts +3 -3
  268. package/dist/workflow/index.js +2 -2
  269. package/dist/workflow-sdk/index.d.ts +5 -5
  270. package/dist/{wrap-Bd1Tejur.d.ts → wrap-C5jC6sKI.d.ts} +2 -2
  271. package/package.json +2 -2
  272. package/skills/permdock-data/references/queries-and-rls.md +1 -1
  273. package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +1 -0
  274. package/dist/catalog-DjhMYYCq.js +0 -1
  275. package/dist/collect-FlXJlhus.js +0 -1
  276. package/dist/powersync-CbzkqL_M.js +0 -1
  277. package/dist/pull-ByOks_Tg.js +0 -1
  278. package/dist/run-CNsApDpd.js +0 -1
  279. package/dist/supabase-hook-CXcGpHqq.js +0 -1
  280. package/dist/usage-CHOtwL19.js +0 -1
  281. package/dist/watch-Cb-NcaUw.js +0 -1
@@ -4,14 +4,14 @@ import { l as isSqlFunctionField } from "./refs-K6eWUJ0H.js";
4
4
  import { u as isoAt } from "./time-DkWjdL98.js";
5
5
  import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-CUPSjo9q.js";
6
6
  import { t as isoInstant } from "./instant-BluqDvyS.js";
7
- import { m as findRole, v as listRoles } from "./grantee-Cn1HyUsI.js";
7
+ import { m as findRole, v as listRoles } from "./grantee-hxrUsbpM.js";
8
8
  import { r as relationCondition, t as inheritCondition } from "./grantee-conditions-CcXf8hMM.js";
9
9
  import { l as partitionsOf, p as scopeField } from "./tenancy-DN2QAx9z.js";
10
10
  import { i as configError } from "./errors-CWTY6uih.js";
11
11
  import { i as scopeMembershipTable, o as relatedSql, r as scopeColumn } from "./compile-CMqMx_IN.js";
12
12
  import { i as qualifySqlTable, t as SQL_IDENT, u as sqlTextArray } from "./sql-B5T1IL83.js";
13
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";
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-BAK8t4Uo.js";
15
15
  //#region src/cli/sql/active.ts
16
16
  function checkActiveRow(label, row) {
17
17
  quoteTable(row.table);
@@ -840,909 +840,963 @@ function byKey([a], [b]) {
840
840
  return byCodeUnit(a, b);
841
841
  }
842
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("");
843
+ //#region src/cli/rls/custom-roles.ts
844
+ /** The readers every helpers file ends with. Names are part of the SQL contract. */
845
+ const READERS = {
846
+ keys: "permdock_permission_keys",
847
+ roles: "permdock_role_permissions",
848
+ trusted: "permdock_trusted_role_permissions"
849
+ };
850
+ /**
851
+ * The custom-role objects: the tables (`database` mode), the ceiling view
852
+ * over the `assignable` declared roles, and `permdock_custom_keys`, which
853
+ * resolves one custom role to the grant keys it holds in a scope. A row in
854
+ * the tables can never reach a key outside the view.
855
+ */
856
+ function customRolesSql(ctx) {
960
857
  const custom = ctx.customRoles;
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);
858
+ if (custom === void 0) return "";
998
859
  const rp = qualified(ctx, "role_permissions");
999
- const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
1000
- const custom = ctx.customRoles;
860
+ const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
861
+ const keys = qualified(ctx, CUSTOM_ROLES.keys);
862
+ const chunks = [];
1001
863
  if (ctx.authorize === "database") {
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
- )`;
1022
- }
1023
- const declared = ` select ${signedIn(ctx)} and exists (
1024
- select 1
1025
- from ${roleRows(ctx)}
1026
- join ${rp} rp on rp.role = r.role
1027
- where rp.grant_key = p_grant
1028
- and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, "r.role"))}${active}
1029
- )`;
1030
- if (custom === void 0) return declared;
1031
- const claim = subjectClaimJsonSql(ctx, GLOBAL_GRANTS_CLAIM);
1032
- return `${declared}
1033
- or (${signedIn(ctx)} and exists (
1034
- select 1
1035
- from ${roleRows(ctx)}
1036
- cross join lateral (select ${claim} -> r.role as g) cg
1037
- where jsonb_typeof(cg.g) = 'array'
1038
- and not (r.role = any(${sqlTextArray(custom.declared)}))${active}
1039
- ${customKeysSql(ctx, "global", claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)")).trimStart()}
1040
- ))`;
1041
- }
1042
- /** The claim `jwt` mode reads platform custom roles from: `customRoleClaim(globalRoles)`. */
1043
- const GLOBAL_GRANTS_CLAIM = "role_grants";
1044
- function memberColumn(name) {
1045
- return `m.${quoteIdent(name)}`;
1046
- }
1047
- function disabledColumn(table) {
1048
- return table.disabledAt === void 0 ? void 0 : memberColumn(table.disabledAt);
1049
- }
1050
- function claimKeptLines(ctx, indent, permission) {
1051
- return disabledKeep(ctx).length === 0 ? [] : [`${indent}and ${keptRowSql("m -> 'keep'", permission)}`];
1052
- }
1053
- /** Entries of a compact custom-role claim (`cg.g`) that match `where`. */
1054
- function claimEntries(where, value) {
1055
- return `array(select ${value} from jsonb_array_elements_text(cg.g) e where ${where})`;
1056
- }
1057
- /** `p_grant in (select permdock_custom_keys(allows, denies, includes, scope))`. */
1058
- function customKeysSql(ctx, scope, allows, denies, includes) {
1059
- return ` and p_grant in (select ${qualified(ctx, CUSTOM_ROLES.keys)}(
1060
- ${allows},
1061
- ${denies},
1062
- ${includes},
1063
- ${quoteLiteral(scope)}
1064
- ))`;
1065
- }
1066
- function tableBody(ctx, scope, type) {
1067
- const mapped = scopeTable(ctx, scope);
1068
- if (mapped === void 0) return ` select null::${type} where false -- no ${scope} memberships table configured`;
1069
- const { table, column, tenantColumn } = mapped;
1070
- const set = ctx.grantSet === true;
1071
- const filtersFor = (grant) => {
1072
- const filters = [` where ${memberColumn(table.user)} = ${subjectIdSql(ctx)}`];
1073
- if (table.expiresAt !== void 0) {
1074
- const expires = memberColumn(table.expiresAt);
1075
- filters.push(` and (${expires} is null or ${expires} > now())`);
864
+ const tenantType = tenantTypeOf(ctx);
865
+ for (const [name, column, check] of [[
866
+ CUSTOM_ROLES.permissions,
867
+ "permission",
868
+ `,
869
+ effect text not null default 'allow' check (effect in ('allow', 'deny'))`
870
+ ], [
871
+ CUSTOM_ROLES.includes,
872
+ "include_role",
873
+ ""
874
+ ]]) {
875
+ const table = qualified(ctx, name);
876
+ const leveled = custom.levels === true && name === CUSTOM_ROLES.permissions;
877
+ const unique = `${column}${name === CUSTOM_ROLES.permissions ? ", effect" : ""}${leveled ? ", coalesce(level, '')" : ""}`;
878
+ const index = leveled ? `drop index if exists ${qualified(ctx, quoteIdent(`${name}_key`))};
879
+ alter table ${table} add column if not exists level text check (level ~ '^[a-z][a-z0-9_]*$');
880
+ create unique index if not exists ${quoteIdent(`${name}_level_key`)}` : `create unique index if not exists ${quoteIdent(`${name}_key`)}`;
881
+ chunks.push(`create table if not exists ${table} (
882
+ tenant_id ${tenantType},
883
+ scope text not null default ${quoteLiteral(rootName(ctx))},
884
+ scope_id text,
885
+ role text not null,
886
+ ${column} text not null${check}
887
+ );
888
+ -- global: no tenant or instance; a tenant role: a tenant; a shared role: no tenant or instance
889
+ alter table ${table} drop constraint if exists ${quoteIdent(`${name}_check`)};
890
+ alter table ${table} drop constraint if exists ${quoteIdent(`${name}_shape`)};
891
+ alter table ${table} add constraint ${quoteIdent(`${name}_shape`)} check (
892
+ case when scope = 'global' then tenant_id is null and scope_id is null
893
+ else tenant_id is not null or scope_id is null end
894
+ );
895
+ ${index}
896
+ on ${table} (coalesce(tenant_id::text, ''), scope, coalesce(scope_id, ''), role, ${unique});
897
+ -- a row identity for triggers the application adds, such as an audit trigger that reads the primary key
898
+ alter table ${table} add column if not exists id bigint generated always as identity primary key;
899
+ alter table ${table} enable row level security;
900
+ revoke all on table ${table} from anon, authenticated, public;`);
1076
901
  }
1077
- filters.push(...activeMembershipSql(ctx, disabledColumn(table), grantPermissionSql(grant)).map((part) => ` and ${part}`));
1078
- if (tenantColumn !== void 0 && ctx.tenants !== "all") filters.push(` and (${activeTenant(ctx)} is null or ${memberColumn(tenantColumn)}::text = ${activeTenant(ctx)})`);
1079
- const keyed = keyTenantSql(ctx, tenantColumn === void 0 ? void 0 : memberColumn(tenantColumn));
1080
- if (keyed !== void 0) filters.push(` and ${keyed}`);
1081
- filters.push(...userActive(ctx, " "), ...instancesActive(ctx, scope, (name) => {
1082
- const held = scopeColumn(table, ctx.scopes, name);
1083
- return held === void 0 ? void 0 : memberColumn(held);
1084
- }, " ", grantPermissionSql(grant)));
1085
- return filters;
1086
- };
1087
- const filters = filtersFor(set ? "rp.grant_key" : "p_grant");
1088
- const [owner, ...rest] = filters;
1089
- const role = memberRoleOf(table, "m", " ");
1090
- const kind = kindFilterSql(ctx, role.sql, memberVia(table));
1091
- const lines = [
1092
- ` select${role.lateral ? " distinct" : ""} ${memberColumn(column)}::${type}${set ? ", rp.grant_key" : ""}`,
1093
- ` from ${quoteQualified(table.table)} m${role.join}`,
1094
- ` join ${qualified(ctx, "role_permissions")} rp on rp.role = ${role.sql}`,
1095
- owner ?? "",
1096
- ...set ? [] : [" and rp.grant_key = p_grant"],
1097
- ` and rp.scope = ${quoteLiteral(scope)}`,
1098
- ...kind === void 0 ? [] : [` and ${kind}`],
1099
- ...rest
1100
- ];
1101
- const custom = ctx.customRoles;
1102
- if (custom === void 0) return lines.join("\n");
1103
- const match = [
1104
- `c.tenant_id::text = ${memberColumn(tenantColumn ?? column)}::text`,
1105
- `c.scope = ${quoteLiteral(scope)}`,
1106
- `(c.scope_id is null or c.scope_id = ${memberColumn(column)}::text)`,
1107
- `c.role = ${role.sql}`
1108
- ].join(" and ");
1109
- const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1110
- const entries = [
1111
- rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"),
1112
- rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"),
1113
- rows(CUSTOM_ROLES.includes, "include_role", "")
1114
- ];
1115
- if (set) return [
1116
- ...lines,
1117
- " union",
1118
- ` select ${memberColumn(column)}::${type}, ck.grant_key`,
1119
- ` from ${quoteQualified(table.table)} m${role.join}`,
1120
- customKeysJoin(ctx, scope, ...entries),
1121
- ...filtersFor("ck.grant_key"),
1122
- ` and not (${role.sql} = any(${sqlTextArray(custom.declared)}))`
1123
- ].join("\n");
1124
- return [
1125
- ...lines,
1126
- " union",
1127
- ` select ${memberColumn(column)}::${type}`,
1128
- ` from ${quoteQualified(table.table)} m${role.join}`,
1129
- ...filters,
1130
- ` and not (${role.sql} = any(${sqlTextArray(custom.declared)}))`,
1131
- customKeysSql(ctx, scope, ...entries)
1132
- ].join("\n");
1133
- }
1134
- /** The membership's instance of the first scope, from the canonical claim. */
1135
- function claimTenant(ctx, scope) {
1136
- const root = rootName(ctx);
1137
- return scope === root ? `m ->> 'id'` : `m -> 'within' ->> ${quoteLiteral(root)}`;
1138
- }
1139
- function claimBody(ctx, scope, type) {
1140
- const set = ctx.grantSet === true;
1141
- const narrow = narrowsTo(ctx, scope) ? `
1142
- and (${activeTenant(ctx)} is null or ${claimTenant(ctx, scope)} = ${activeTenant(ctx)})` : "";
1143
- const filtersFor = (grant) => ` and m ->> 'scope' = ${quoteLiteral(scope)}
1144
- and m ->> 'id' is not null${narrow}${keyTenantLine(ctx, scope, claimTenant(ctx, scope))}
1145
- and case jsonb_typeof(m -> 'expires_at')
1146
- when 'number' then (m ->> 'expires_at')::numeric > extract(epoch from now())
1147
- else true
1148
- end${[
1149
- ...claimKeptLines(ctx, " ", grantPermissionSql(grant)),
1150
- ...userActive(ctx, " "),
1151
- ...instancesActive(ctx, scope, (name) => name === scope ? `m ->> 'id'` : `m -> 'within' ->> ${quoteLiteral(name)}`, " ", grantPermissionSql(grant))
1152
- ].map((line) => `\n${line}`).join("")}`;
1153
- const filters = filtersFor(set ? "rp.grant_key" : "p_grant");
1154
- const declared = ` select (m ->> 'id')::${type}${set ? ", rp.grant_key" : ""}
1155
- from ${membershipRows(ctx)}
1156
- join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
1157
- where ${signedIn(ctx)}${set ? "" : "\n and rp.grant_key = p_grant"}
1158
- and rp.scope = ${quoteLiteral(scope)}${andLine(" ", kindFilterSql(ctx, "r.role", "m ->> 'via'"))}
1159
- ${filters}`;
1160
- const custom = ctx.customRoles;
1161
- if (custom === void 0) return declared;
1162
- if (set) return `${declared}
902
+ }
903
+ chunks.push(`-- the ceiling: keys of assignable declared roles, and the denies of those roles on the same permissions
904
+ create or replace view ${ceiling} with (security_invoker = true) as
905
+ select rp.scope, rp.role, rp.permission, rp.grant_key, rp.effect
906
+ from ${rp} rp
907
+ where rp.role = any(${sqlTextArray(custom.assignable)})
908
+ and exists (
909
+ select 1 from ${rp} a
910
+ where a.role = rp.role
911
+ and a.permission = rp.permission
912
+ and a.scope = rp.scope
913
+ and a.effect = 'allow'
914
+ );
915
+ revoke all on table ${ceiling} from anon, authenticated, public;`);
916
+ const renames = Object.entries(custom.renamed ?? {}).toSorted(byKey);
917
+ const shared = sharedRoleSql(ctx);
918
+ if (custom.levels === true) {
919
+ chunks.push(leveledKeysSql(ctx, renames));
920
+ if (shared !== "") chunks.push(shared);
921
+ return chunks.join("\n\n");
922
+ }
923
+ const rename = renamesSql(renames);
924
+ chunks.push(`-- one custom role: included ceiling keys and same-scope denies, plus every ceiling key of an allowed permission, minus denied permissions
925
+ create or replace function ${keys}(p_allow text[], p_deny text[], p_include text[], p_scope text)
926
+ returns setof text
927
+ language sql
928
+ stable
929
+ set search_path = ''
930
+ as $$
931
+ with ${rename.prelude}ceiling as (
932
+ select c.role, ${rename.permission("c")}, c.grant_key, c.effect
933
+ from ${ceiling} c${rename.join("c")}
934
+ where c.scope = p_scope
935
+ ),
936
+ included as (
937
+ select rp.role, ${rename.permission("rp")}, rp.grant_key, rp.scope, rp.effect
938
+ from ${rp} rp${rename.join("rp")}
939
+ where rp.role = any(coalesce(p_include, '{}'::text[]))
940
+ ),
941
+ kept as (
942
+ select i.permission, i.grant_key
943
+ from included i
944
+ where i.effect = 'allow'
945
+ and i.scope = p_scope
946
+ and exists (
947
+ select 1 from ceiling c
948
+ where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
949
+ )
950
+ ),
951
+ wanted as (
952
+ ${rename.allow}
953
+ union
954
+ select i.permission
955
+ from included i
956
+ where i.effect = 'allow'
957
+ and not (i.scope = p_scope and exists (
958
+ select 1 from ceiling c
959
+ where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
960
+ ))
961
+ ),
962
+ allowed as (
963
+ select w.permission
964
+ from wanted w
965
+ where not (${rename.denied("w")})
966
+ and not exists (select 1 from kept k where k.permission = w.permission)
967
+ )
968
+ select k.grant_key from kept k
969
+ where not (${rename.denied("k")})
1163
970
  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}
971
+ select i.grant_key from included i
972
+ where i.effect = 'deny' and i.scope = p_scope
1173
973
  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)"))}`;
974
+ select c.grant_key from ceiling c
975
+ where c.permission in (select a.permission from allowed a)
976
+ $$;
977
+ revoke execute on function ${keys}(text[], text[], text[], text) from public, anon, authenticated;`);
978
+ if (shared !== "") chunks.push(shared);
979
+ return chunks.join("\n\n");
980
+ }
981
+ /** `permdock_is_shared_role(p_scope, p_role)` (`database` mode): whether a shared custom role of that name exists at the scope. */
982
+ function sharedRoleSql(ctx) {
983
+ if (ctx.authorize !== "database") return "";
984
+ const perms = qualified(ctx, CUSTOM_ROLES.permissions);
985
+ const includes = qualified(ctx, CUSTOM_ROLES.includes);
986
+ const shared = qualified(ctx, CUSTOM_ROLES.shared);
987
+ return `-- a shared custom role: rows with no tenant at a scope below global; its name wins over a tenant role's in every tenant
988
+ create or replace function ${shared}(p_scope text, p_role text)
989
+ returns boolean
990
+ language sql
991
+ stable
992
+ security definer
993
+ set search_path = ''
994
+ as $$
995
+ select p_scope <> 'global' and (
996
+ exists (select 1 from ${perms} c where c.tenant_id is null and c.scope = p_scope and c.role = p_role)
997
+ or exists (select 1 from ${includes} c where c.tenant_id is null and c.scope = p_scope and c.role = p_role)
998
+ )
999
+ $$;
1000
+ revoke execute on function ${shared}(text, text) from public, anon, authenticated;`;
1182
1001
  }
1183
1002
  /**
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.
1003
+ * `permdock_custom_keys` when resources declare levels. An allow entry is
1004
+ * `key` or `key@level`. A level reaches only the ceiling's `<grant key>@<level>`
1005
+ * keys, plus the ceiling denies of that permission; a level no ceiling key
1006
+ * carries removes the permission, as `resolveCustomRole` drops it with
1007
+ * `unknown-level`.
1187
1008
  */
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");
1009
+ function leveledKeysSql(ctx, renames) {
1010
+ const rp = qualified(ctx, "role_permissions");
1011
+ const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
1012
+ const keys = qualified(ctx, CUSTOM_ROLES.keys);
1013
+ const renamed = renames.length > 0;
1014
+ const prelude = renamed ? `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
1015
+ ` : "";
1016
+ const current = (raw, alias) => renamed ? `coalesce(r_${alias}.key, ${raw})` : raw;
1017
+ const join = (raw, alias) => renamed ? ` left join renamed r_${alias} on r_${alias}.former = ${raw}` : "";
1018
+ return `-- one custom role: as above, where an allow entry key@level reaches only the ceiling keys of that level
1019
+ create or replace function ${keys}(p_allow text[], p_deny text[], p_include text[], p_scope text)
1020
+ returns setof text
1021
+ language sql
1022
+ stable
1023
+ set search_path = ''
1024
+ as $$
1025
+ with ${prelude}entries as (
1026
+ select ${current("split_part(e.entry, '@', 1)", "e")} as permission, nullif(split_part(e.entry, '@', 2), '') as level
1027
+ from unnest(coalesce(p_allow, '{}'::text[])) as e(entry)${join("split_part(e.entry, '@', 1)", "e")}
1028
+ ),
1029
+ ceiling as (
1030
+ select c.role, ${current("c.permission", "c")} as permission, c.grant_key, c.effect
1031
+ from ${ceiling} c${join("c.permission", "c")}
1032
+ where c.scope = p_scope
1033
+ ),
1034
+ levels as (
1035
+ select distinct c.permission, split_part(c.grant_key, '@', 2) as level
1036
+ from ceiling c
1037
+ where c.effect = 'allow' and strpos(c.grant_key, '@') > 0
1038
+ ),
1039
+ denied as (
1040
+ select ${current("d.permission", "d")} as permission
1041
+ from unnest(coalesce(p_deny, '{}'::text[])) as d(permission)${join("d.permission", "d")}
1042
+ union
1043
+ select e.permission from entries e
1044
+ where e.level is not null
1045
+ and not exists (select 1 from levels l where l.permission = e.permission and l.level = e.level)
1046
+ ),
1047
+ included as (
1048
+ select rp.role, ${current("rp.permission", "rp")} as permission, rp.grant_key, rp.scope, rp.effect
1049
+ from ${rp} rp${join("rp.permission", "rp")}
1050
+ where rp.role = any(coalesce(p_include, '{}'::text[]))
1051
+ ),
1052
+ kept as (
1053
+ select i.permission, i.grant_key
1054
+ from included i
1055
+ where i.effect = 'allow'
1056
+ and i.scope = p_scope
1057
+ and exists (
1058
+ select 1 from ceiling c
1059
+ where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
1060
+ )
1061
+ ),
1062
+ wanted as (
1063
+ select e.permission from entries e where e.level is null
1064
+ union
1065
+ select i.permission
1066
+ from included i
1067
+ where i.effect = 'allow'
1068
+ and not (i.scope = p_scope and exists (
1069
+ select 1 from ceiling c
1070
+ where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
1071
+ ))
1072
+ ),
1073
+ allowed as (
1074
+ select w.permission
1075
+ from wanted w
1076
+ where w.permission not in (select d.permission from denied d)
1077
+ and not exists (select 1 from kept k where k.permission = w.permission)
1078
+ ),
1079
+ allowed_levels as (
1080
+ select e.permission, e.level
1081
+ from entries e
1082
+ where e.level is not null
1083
+ and e.permission not in (select d.permission from denied d)
1084
+ and e.permission not in (select a.permission from allowed a)
1085
+ and not exists (select 1 from kept k where k.permission = e.permission)
1086
+ )
1087
+ select k.grant_key from kept k
1088
+ where k.permission not in (select d.permission from denied d)
1089
+ union
1090
+ select i.grant_key from included i
1091
+ where i.effect = 'deny' and i.scope = p_scope
1092
+ union
1093
+ select c.grant_key from ceiling c
1094
+ where c.permission in (select a.permission from allowed a)
1095
+ union
1096
+ select c.grant_key from ceiling c
1097
+ join allowed_levels a on a.permission = c.permission
1098
+ where c.effect = 'deny' or split_part(c.grant_key, '@', 2) = a.level
1099
+ $$;
1100
+ revoke execute on function ${keys}(text[], text[], text[], text) from public, anon, authenticated;`;
1194
1101
  }
1195
- function sourceUser(index) {
1196
- return `v_user_${String(index)}`;
1102
+ function currentKeysSql(array) {
1103
+ return `select coalesce(r.key, e.permission) as permission
1104
+ from unnest(coalesce(${array}, '{}'::text[])) as e(permission)
1105
+ left join renamed r on r.former = e.permission`;
1197
1106
  }
1198
- function sqlBody(sql) {
1199
- return {
1200
- sql,
1201
- vars: []
1107
+ /**
1108
+ * SQL fragments that read a stored former key as its current key inside
1109
+ * `permdock_custom_keys`. Without renames they are the plain column and
1110
+ * array reads.
1111
+ */
1112
+ function renamesSql(renames) {
1113
+ if (renames.length === 0) return {
1114
+ prelude: "",
1115
+ permission: (alias) => `${alias}.permission`,
1116
+ join: () => "",
1117
+ allow: "select unnest(coalesce(p_allow, '{}'::text[])) as permission",
1118
+ denied: (alias) => `${alias}.permission = any(coalesce(p_deny, '{}'::text[]))`
1202
1119
  };
1203
- }
1204
- function sourcesBodyOf(sql, sources, user) {
1205
1120
  return {
1206
- sql,
1207
- vars: sources.map((source, index) => `${sourceUser(index)} ${source.sql.userType} := ${user};`)
1121
+ prelude: `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
1122
+ denied as (
1123
+ ${currentKeysSql("p_deny")}
1124
+ ),
1125
+ `,
1126
+ permission: (alias) => `coalesce(r_${alias}.key, ${alias}.permission) as permission`,
1127
+ join: (alias) => ` left join renamed r_${alias} on r_${alias}.former = ${alias}.permission`,
1128
+ allow: currentKeysSql("p_allow"),
1129
+ denied: (alias) => `${alias}.permission in (select d.permission from denied d)`
1208
1130
  };
1209
1131
  }
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
1132
+ /**
1133
+ * The catalog reader `permdock_permission_keys()` and the role reader
1134
+ * `permdock_role_permissions(p_role, p_scope, p_tenant, p_scope_id)`, both
1135
+ * for `authenticated`. A declared role answers from `role_permissions`, which
1136
+ * is the catalog. A custom role (`database` mode) answers from the tenant's
1137
+ * own rows through `permdock_custom_keys`, only to a member of the tenant or
1138
+ * a holder of a `meta.manageRoles` permission for a platform role; a shared
1139
+ * role (no tenant below global) to any signed-in caller. The
1140
+ * effect is `deny` for a deny the role carries after the ceiling, as
1141
+ * `resolveCustomRole` returns it, never for a deny that merely removes an allow.
1142
+ */
1143
+ function roleReadersSql(ctx, permissions, trustedReaders) {
1144
+ const rp = qualified(ctx, "role_permissions");
1145
+ const reader = qualified(ctx, READERS.roles);
1146
+ const tenantType = tenantTypeOf(ctx);
1147
+ const custom = ctx.customRoles;
1148
+ const root = ctx.scopes[0]?.name;
1149
+ const database = ctx.authorize === "database";
1150
+ const chunks = [];
1151
+ if (permissions !== void 0) {
1152
+ const catalog = qualified(ctx, READERS.keys);
1153
+ chunks.push(`-- every declared permission key
1154
+ create or replace function ${catalog}()
1155
+ returns setof text
1156
+ language sql
1157
+ stable
1158
+ set search_path = ''
1159
+ as $$
1160
+ select k.permission from unnest(${sqlTextArray(permissions.toSorted())}) k(permission) order by 1
1161
+ $$;
1162
+ revoke execute on function ${catalog}() from public, anon;
1163
+ grant execute on function ${catalog}() to authenticated;
1164
+
1165
+ create or replace function ${catalog}(p_scope text)
1166
+ returns setof text
1167
+ language sql
1168
+ stable
1169
+ security definer
1170
+ set search_path = ''
1171
+ as $$
1172
+ select distinct rp.permission
1173
+ from ${rp} rp
1174
+ where rp.scope = p_scope
1175
+ and rp.effect = 'allow'
1176
+ and rp.permission = any(${sqlTextArray(permissions.toSorted())})
1177
+ order by 1
1178
+ $$;
1179
+ revoke execute on function ${catalog}(text) from public, anon;
1180
+ grant execute on function ${catalog}(text) to authenticated;`);
1181
+ }
1182
+ const declared = ` select distinct rp.permission, rp.effect
1183
+ from ${rp} rp
1184
+ where rp.role = p_role and rp.scope = p_scope
1185
+ order by 1, 2`;
1186
+ let body = `begin
1187
+ perform p_tenant, p_scope_id; -- only a custom role reads them
1188
+ return query
1189
+ ${declared};
1190
+ end;`;
1191
+ let trustedBody = body;
1192
+ if (custom !== void 0 && database && root !== void 0) {
1193
+ const tenant = qualified(ctx, CUSTOM_ROLES.permissions);
1194
+ const includes = qualified(ctx, CUSTOM_ROLES.includes);
1195
+ const keys = qualified(ctx, CUSTOM_ROLES.keys);
1196
+ const shape = qualified(ctx, CUSTOM_ROLES.shape);
1197
+ const ids = (helper) => `(select x::text from ${qualified(ctx, helper)}() x)`;
1198
+ const memberOf = `p_tenant::text in ${ids(memberIdsHelper(root))}
1199
+ or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${ids(memberIdsHelper(scope))}`)}`;
1200
+ const manage = custom.manage ?? [];
1201
+ const platform = manage.length === 0 ? "false" : `exists (
1202
+ select 1 from ${rp} m
1203
+ where m.effect = 'allow'
1204
+ and m.permission = any(${sqlTextArray(manage)})
1205
+ and ${qualified(ctx, HELPERS.has)}(m.grant_key)
1206
+ )`;
1207
+ 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`;
1208
+ const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
1209
+ const checked = (gate) => `begin
1210
+ if p_role = any(${sqlTextArray(custom.declared)}) then
1211
+ return query
1212
+ ${declared};
1213
+ return;
1214
+ end if;
1215
+ perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);${gate}
1216
+ return query
1217
+ select distinct rp.permission, rp.effect
1218
+ from ${keys}(
1219
+ array(select ${allowEntryOf} from ${tenant} c where ${match} and c.effect = 'allow'),
1220
+ array(select c.permission from ${tenant} c where ${match} and c.effect = 'deny'),
1221
+ array(select c.include_role from ${includes} c where ${match}),
1222
+ p_scope
1223
+ ) k(grant_key)
1224
+ join ${rp} rp on rp.grant_key = k.grant_key and rp.scope = p_scope
1225
+ order by 1, 2;
1226
+ end;`;
1227
+ body = checked(`
1228
+ if not (${signedIn(ctx)} and (
1229
+ ${platform}
1230
+ or (p_scope <> 'global' and p_tenant is null and p_scope_id is null)
1231
+ or (p_scope <> 'global' and (${memberOf}))
1232
+ )) then
1233
+ ${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
1234
+ end if;`);
1235
+ trustedBody = checked("");
1236
+ }
1237
+ 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
1238
+ create or replace function ${reader}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
1239
+ returns table (permission text, effect text)
1240
+ language plpgsql
1213
1241
  stable
1214
1242
  security definer
1215
1243
  set search_path = ''
1216
1244
  as $$
1217
- ${body.sql}
1218
- $$;`;
1219
- return `language plpgsql
1245
+ ${body}
1246
+ $$;
1247
+ revoke execute on function ${reader}(text, text, ${tenantType}, text) from public, anon;
1248
+ grant execute on function ${reader}(text, text, ${tenantType}, text) to authenticated;`);
1249
+ const trusted = qualified(ctx, READERS.trusted);
1250
+ const signature = `text, text, ${tenantType}, text`;
1251
+ const grants = trustedReaders === void 0 || trustedReaders.length === 0 ? "" : `\ngrant execute on function ${trusted}(${signature}) to ${trustedReaders.map(quoteIdent).join(", ")};`;
1252
+ chunks.push(`create or replace function ${trusted}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
1253
+ returns table (permission text, effect text)
1254
+ language plpgsql
1220
1255
  stable
1221
1256
  security definer
1222
1257
  set search_path = ''
1223
1258
  as $$
1224
- declare
1225
- ${body.vars.map((line) => ` ${line}`).join("\n")}
1226
- begin
1227
- return query
1228
- ${body.sql};
1229
- end;
1230
- $$;`;
1259
+ ${trustedBody}
1260
+ $$;
1261
+ revoke execute on function ${trusted}(${signature}) from public, anon, authenticated;${grants}`);
1262
+ return chunks.join("\n\n");
1263
+ }
1264
+ /** The stored definition of `p_role` at `p_scope` for `p_tenant`: the shared rows when a shared role has the name. */
1265
+ function storedTenant(ctx) {
1266
+ return `case when ${qualified(ctx, CUSTOM_ROLES.shared)}(p_scope, p_role) then c.tenant_id is null else c.tenant_id is not distinct from p_tenant end`;
1231
1267
  }
1232
1268
  /**
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`.
1269
+ * The tenant half of a custom-role row match for a membership: the
1270
+ * membership's tenant, or no tenant when a shared role of that name exists at
1271
+ * the scope, so a shared role shadows a tenant role of the same name.
1235
1272
  */
1236
- function sourceIdOf(scope) {
1237
- return (name) => name === scope ? "ms.id" : `ms.within ->> ${quoteLiteral(name)}`;
1273
+ function customTenantMatch(ctx, tenant, scope, role) {
1274
+ return `case when ${qualified(ctx, CUSTOM_ROLES.shared)}(${quoteLiteral(scope)}, ${role}) then c.tenant_id is null else c.tenant_id::text = ${tenant} end`;
1238
1275
  }
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("");
1276
+ /**
1277
+ * The scope of a row of the application's roles table: its scope column (or
1278
+ * the first scope) with a tenant; with no tenant, the same scope when the
1279
+ * `shared` column is true, else `'global'`.
1280
+ */
1281
+ function rolesTableScope(table, tenant, root, column) {
1282
+ const own = table.scope === void 0 ? quoteLiteral(root) : `${column(table.scope)}::text`;
1283
+ return `case when ${tenant} is null then ${table.shared === void 0 ? "'global'" : `case when coalesce(${column(table.shared)}, false) then ${own} else 'global' end`} else ${own} end`;
1245
1284
  }
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));
1285
+ /** `expr` read as its current key: a former key maps to the key it was renamed to. */
1286
+ function currentKeyExpr(renames, expr) {
1287
+ if (renames.length === 0) return expr;
1288
+ return `(case ${expr} ${renames.map(([former, current]) => `when ${quoteLiteral(former)} then ${quoteLiteral(current)}`).join(" ")} else ${expr} end)`;
1289
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));
1290
+ /** `case p_scope when '<scope>' then <sql for scope> ... else false end` over the declared scopes. */
1291
+ function perScope(ctx, sql) {
1292
+ const arms = ctx.scopes.map((scope) => `when ${quoteLiteral(scope.name)} then ${sql(scope.name)}`).join(" ");
1293
+ return arms === "" ? "false" : `(case p_scope ${arms} else false end)`;
1294
+ }
1295
+ function raiseSql(indent, errcode, message, hint) {
1296
+ return `${indent}raise exception using
1297
+ ${indent} errcode = '${errcode}',
1298
+ ${indent} message = ${message},
1299
+ ${indent} hint = ${quoteLiteral(hint)};`;
1300
+ }
1301
+ function hasCustomRoleChecksFor(ctx) {
1302
+ return ctx.authorize === "database" && ctx.customRoles !== void 0 && ctx.scopes.length > 0 && ctx.scopes.every((scope) => hasMemberFor(ctx, scope.name));
1293
1303
  }
1294
1304
  //#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}))`;
1305
+ //#region src/cli/sql/global-roles.ts
1306
+ function qualify(name, schema) {
1307
+ return name.includes(".") ? name : `${schema}.${name}`;
1308
+ }
1309
+ function throughTenant(through, roles) {
1310
+ if (through === void 0 || roles?.tenant === void 0) return;
1311
+ return qualify(roles.table, "public") === through.table ? roles.tenant : void 0;
1312
+ }
1313
+ /** Resolves `source` against `schema`, aliasing the table `alias` and the roles table `<alias>k`. */
1314
+ function globalRoleSource(source, schema, alias, roles) {
1315
+ const table = qualify(source.table, schema);
1316
+ const user = source.user ?? "user_id";
1317
+ const role = roleColumn(source.role ?? "role", table, alias, {
1318
+ label: "roles.role",
1319
+ prefix: "PermDock CLI",
1320
+ indent: " "
1321
+ });
1322
+ const tenant = role.lateral ? void 0 : throughTenant(role.through, roles);
1323
+ const shared = tenant === void 0 ? void 0 : roles?.shared;
1324
+ const global = tenant === void 0 ? "" : ` and ${alias}k.${quoteIdent(tenant)} is null${shared === void 0 ? "" : ` and ${alias}k.${quoteIdent(shared)} is not true`}`;
1325
+ return {
1326
+ table,
1327
+ user,
1328
+ from: `${quoteTable(table)} ${alias}${role.join}${global}`,
1329
+ userSql: `${alias}.${quoteIdent(user)}`,
1330
+ roleSql: role.sql,
1331
+ role: roleManifest(role),
1332
+ ...role.through === void 0 ? {} : { through: role.through },
1333
+ ...tenant === void 0 ? {} : { tenant },
1334
+ ...shared === void 0 ? {} : { shared }
1335
+ };
1336
+ }
1337
+ //#endregion
1338
+ //#region src/cli/rls/helper-rows.ts
1339
+ function activeTenant(ctx) {
1340
+ return `nullif(${subjectClaimSql(ctx, ctx.tenantClaim)}, '')`;
1341
+ }
1342
+ function keyTenantLine(ctx, scope, tenant) {
1343
+ const keyed = keyTenantSql(ctx, underRoot(ctx, scope) ? tenant : void 0);
1344
+ return keyed === void 0 ? "" : `\n and ${keyed}`;
1345
+ }
1346
+ /** Whether the helper for `scope` narrows to the tenant claim: unless `rls.tenants` is `'all'`, when the first scope is on its chain. */
1347
+ function narrowsTo(ctx, scope) {
1348
+ return ctx.tenants !== "all" && underRoot(ctx, scope);
1349
+ }
1350
+ /** Whether the active tenant narrows memberships of `scope`: the first scope is on its chain. */
1351
+ function underRoot(ctx, scope) {
1352
+ const root = ctx.scopes[0]?.name;
1353
+ let current = ctx.scopes.find((item) => item.name === scope);
1354
+ const seen = /* @__PURE__ */ new Set();
1355
+ while (current !== void 0 && !seen.has(current.name)) {
1356
+ if (current.name === root) return true;
1357
+ seen.add(current.name);
1358
+ const parent = current.within;
1359
+ current = parent === void 0 ? void 0 : ctx.scopes.find((item) => item.name === parent);
1360
+ }
1361
+ return false;
1362
+ }
1363
+ /** `and exists (...)` lines for an active user; empty without `rls.suspension.users`. */
1364
+ function userActive(ctx, indent, user) {
1365
+ return activeUserSql(ctx, user).map((part) => `${indent}and ${part}`);
1366
+ }
1367
+ /** `and exists (...)` lines for every suspendable instance on the chain of `scope`'s membership. */
1368
+ function instancesActive(ctx, scope, idOf, indent, permission) {
1369
+ return activeInstancesSql(ctx, scope, idOf, permission).map((part) => `${indent}and ${part}`);
1370
+ }
1371
+ function roleRows(ctx) {
1372
+ const claim = ctx.roleClaim ?? "user_role";
1373
+ switch (ctx.dialect) {
1374
+ case "guc": return `unnest(string_to_array(nullif(current_setting(${quoteLiteral(`${ctx.gucPrefix}.${claim}`)}, true), ''), ',')) r(role)`;
1375
+ case "supabase":
1376
+ case "neon": {
1377
+ const raw = subjectClaimJsonSql(ctx, claim);
1378
+ const value = ctx.dialect === "supabase" ? `coalesce(nullif(${raw}, 'null'::jsonb), (select auth.jwt()) -> 'app_metadata' -> ${quoteLiteral(claim)})` : raw;
1379
+ return `jsonb_array_elements_text(
1380
+ case jsonb_typeof(${value})
1381
+ when 'array' then ${value}
1382
+ when 'string' then jsonb_build_array(${value})
1383
+ else '[]'::jsonb
1384
+ end
1385
+ ) r(role)`;
1386
+ }
1387
+ default: return ctx.dialect;
1388
+ }
1389
+ }
1390
+ function membershipRows(ctx) {
1391
+ const raw = subjectClaimJsonSql(ctx, "memberships");
1392
+ const value = ctx.dialect === "supabase" ? `coalesce(${raw}, (select auth.jwt()) -> 'app_metadata' -> 'memberships')` : raw;
1393
+ return `jsonb_array_elements(
1394
+ case jsonb_typeof(${value}) when 'array' then ${value} else '[]'::jsonb end
1395
+ ) m
1396
+ cross join lateral jsonb_array_elements_text(
1397
+ case jsonb_typeof(m -> 'roles') when 'array' then m -> 'roles' else '[]'::jsonb end
1398
+ ) r(role)`;
1399
+ }
1400
+ function andLine(indent, condition) {
1401
+ return condition === void 0 ? "" : `\n${indent}and ${condition}`;
1402
+ }
1403
+ /** `rls.roles`, or the generated `user_roles (user_id, role)`, aliased `ur`. */
1404
+ function globalRoleRows(ctx) {
1405
+ return ctx.roles === void 0 ? {
1406
+ from: `${qualified(ctx, "user_roles")} ur`,
1407
+ userSql: "ur.user_id",
1408
+ roleSql: "ur.role::text"
1409
+ } : globalRoleSource(ctx.roles, "public", "ur", ctx.customRoles?.table);
1311
1410
  }
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;`;
1411
+ function customKeysJoin(ctx, scope, allows, denies, includes) {
1412
+ return ` cross join lateral ${qualified(ctx, CUSTOM_ROLES.keys)}(
1413
+ ${allows},
1414
+ ${denies},
1415
+ ${includes},
1416
+ ${quoteLiteral(scope)}
1417
+ ) ck(grant_key)`;
1336
1418
  }
1337
1419
  //#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 "";
1420
+ //#region src/cli/rls/helper-bodies.ts
1421
+ function hasSetBody(ctx) {
1354
1422
  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 = [];
1423
+ const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
1424
+ const custom = ctx.customRoles;
1358
1425
  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
- }
1426
+ const ur = globalRoleRows(ctx);
1427
+ const declared = ` select rp.grant_key
1428
+ from ${ur.from}
1429
+ join ${rp} rp on rp.role = ${ur.roleSql}
1430
+ where ${ur.userSql} = ${subjectIdSql(ctx)}
1431
+ and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}`;
1432
+ if (custom === void 0) return declared;
1433
+ const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
1434
+ const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1435
+ return `${declared}
1436
+ union
1437
+ select ck.grant_key
1438
+ from ${ur.from}
1439
+ ${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", ""))}
1440
+ where ${ur.userSql} = ${subjectIdSql(ctx)}
1441
+ and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}`;
1392
1442
  }
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");
1443
+ const declared = ` select rp.grant_key
1444
+ from ${roleRows(ctx)}
1445
+ join ${rp} rp on rp.role = r.role
1446
+ where ${signedIn(ctx)}
1447
+ and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, "r.role"))}${active}`;
1448
+ if (custom === void 0) return declared;
1449
+ const claim = subjectClaimJsonSql(ctx, GLOBAL_GRANTS_CLAIM);
1450
+ return `${declared}
1451
+ union
1452
+ select ck.grant_key
1453
+ from ${roleRows(ctx)}
1454
+ cross join lateral (select ${claim} -> r.role as g) cg
1455
+ ${customKeysJoin(ctx, "global", claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
1456
+ where ${signedIn(ctx)}
1457
+ and jsonb_typeof(cg.g) = 'array'
1458
+ and not (r.role = any(${sqlTextArray(custom.declared)}))${active}`;
1459
+ }
1460
+ function hasBody(ctx) {
1461
+ if (ctx.grantSet === true) return hasSetBody(ctx);
1462
+ const rp = qualified(ctx, "role_permissions");
1463
+ const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
1464
+ const custom = ctx.customRoles;
1465
+ if (ctx.authorize === "database") {
1466
+ const ur = globalRoleRows(ctx);
1467
+ const declared = ` select exists (
1468
+ select 1
1469
+ from ${ur.from}
1470
+ join ${rp} rp on rp.role = ${ur.roleSql}
1471
+ where ${ur.userSql} = ${subjectIdSql(ctx)}
1472
+ and rp.grant_key = p_grant
1473
+ and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}
1474
+ )`;
1475
+ if (custom === void 0) return declared;
1476
+ const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
1477
+ const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1478
+ return `${declared}
1479
+ or exists (
1480
+ select 1
1481
+ from ${ur.from}
1482
+ where ${ur.userSql} = ${subjectIdSql(ctx)}
1483
+ and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}
1484
+ ${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()}
1485
+ )`;
1410
1486
  }
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")})
1487
+ const declared = ` select ${signedIn(ctx)} and exists (
1488
+ select 1
1489
+ from ${roleRows(ctx)}
1490
+ join ${rp} rp on rp.role = r.role
1491
+ where rp.grant_key = p_grant
1492
+ and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, "r.role"))}${active}
1493
+ )`;
1494
+ if (custom === void 0) return declared;
1495
+ const claim = subjectClaimJsonSql(ctx, GLOBAL_GRANTS_CLAIM);
1496
+ return `${declared}
1497
+ or (${signedIn(ctx)} and exists (
1498
+ select 1
1499
+ from ${roleRows(ctx)}
1500
+ cross join lateral (select ${claim} -> r.role as g) cg
1501
+ where jsonb_typeof(cg.g) = 'array'
1502
+ and not (r.role = any(${sqlTextArray(custom.declared)}))${active}
1503
+ ${customKeysSql(ctx, "global", claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)")).trimStart()}
1504
+ ))`;
1505
+ }
1506
+ /** The claim `jwt` mode reads platform custom roles from: `customRoleClaim(globalRoles)`. */
1507
+ const GLOBAL_GRANTS_CLAIM = "role_grants";
1508
+ function memberColumn(name) {
1509
+ return `m.${quoteIdent(name)}`;
1510
+ }
1511
+ function disabledColumn(table) {
1512
+ return table.disabledAt === void 0 ? void 0 : memberColumn(table.disabledAt);
1513
+ }
1514
+ function claimKeptLines(ctx, indent, permission) {
1515
+ return disabledKeep(ctx).length === 0 ? [] : [`${indent}and ${keptRowSql("m -> 'keep'", permission)}`];
1516
+ }
1517
+ /** Entries of a compact custom-role claim (`cg.g`) that match `where`. */
1518
+ function claimEntries(where, value) {
1519
+ return `array(select ${value} from jsonb_array_elements_text(cg.g) e where ${where})`;
1520
+ }
1521
+ /** `p_grant in (select permdock_custom_keys(allows, denies, includes, scope))`. */
1522
+ function customKeysSql(ctx, scope, allows, denies, includes) {
1523
+ return ` and p_grant in (select ${qualified(ctx, CUSTOM_ROLES.keys)}(
1524
+ ${allows},
1525
+ ${denies},
1526
+ ${includes},
1527
+ ${quoteLiteral(scope)}
1528
+ ))`;
1529
+ }
1530
+ function tableBody(ctx, scope, type) {
1531
+ const mapped = scopeTable(ctx, scope);
1532
+ if (mapped === void 0) return ` select null::${type} where false -- no ${scope} memberships table configured`;
1533
+ const { table, column, tenantColumn } = mapped;
1534
+ const set = ctx.grantSet === true;
1535
+ const filtersFor = (grant) => {
1536
+ const filters = [` where ${memberColumn(table.user)} = ${subjectIdSql(ctx)}`];
1537
+ if (table.expiresAt !== void 0) {
1538
+ const expires = memberColumn(table.expiresAt);
1539
+ filters.push(` and (${expires} is null or ${expires} > now())`);
1540
+ }
1541
+ filters.push(...activeMembershipSql(ctx, disabledColumn(table), grantPermissionSql(grant)).map((part) => ` and ${part}`));
1542
+ if (tenantColumn !== void 0 && ctx.tenants !== "all") filters.push(` and (${activeTenant(ctx)} is null or ${memberColumn(tenantColumn)}::text = ${activeTenant(ctx)})`);
1543
+ const keyed = keyTenantSql(ctx, tenantColumn === void 0 ? void 0 : memberColumn(tenantColumn));
1544
+ if (keyed !== void 0) filters.push(` and ${keyed}`);
1545
+ filters.push(...userActive(ctx, " "), ...instancesActive(ctx, scope, (name) => {
1546
+ const held = scopeColumn(table, ctx.scopes, name);
1547
+ return held === void 0 ? void 0 : memberColumn(held);
1548
+ }, " ", grantPermissionSql(grant)));
1549
+ return filters;
1550
+ };
1551
+ const filters = filtersFor(set ? "rp.grant_key" : "p_grant");
1552
+ const [owner, ...rest] = filters;
1553
+ const role = memberRoleOf(table, "m", " ");
1554
+ const kind = kindFilterSql(ctx, role.sql, memberVia(table));
1555
+ const lines = [
1556
+ ` select${role.lateral ? " distinct" : ""} ${memberColumn(column)}::${type}${set ? ", rp.grant_key" : ""}`,
1557
+ ` from ${quoteQualified(table.table)} m${role.join}`,
1558
+ ` join ${qualified(ctx, "role_permissions")} rp on rp.role = ${role.sql}`,
1559
+ owner ?? "",
1560
+ ...set ? [] : [" and rp.grant_key = p_grant"],
1561
+ ` and rp.scope = ${quoteLiteral(scope)}`,
1562
+ ...kind === void 0 ? [] : [` and ${kind}`],
1563
+ ...rest
1564
+ ];
1565
+ const custom = ctx.customRoles;
1566
+ if (custom === void 0) return lines.join("\n");
1567
+ const match = [
1568
+ customTenantMatch(ctx, `${memberColumn(tenantColumn ?? column)}::text`, scope, role.sql),
1569
+ `c.scope = ${quoteLiteral(scope)}`,
1570
+ `(c.scope_id is null or c.scope_id = ${memberColumn(column)}::text)`,
1571
+ `c.role = ${role.sql}`
1572
+ ].join(" and ");
1573
+ const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1574
+ const entries = [
1575
+ rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"),
1576
+ rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"),
1577
+ rows(CUSTOM_ROLES.includes, "include_role", "")
1578
+ ];
1579
+ if (set) return [
1580
+ ...lines,
1581
+ " union",
1582
+ ` select ${memberColumn(column)}::${type}, ck.grant_key`,
1583
+ ` from ${quoteQualified(table.table)} m${role.join}`,
1584
+ customKeysJoin(ctx, scope, ...entries),
1585
+ ...filtersFor("ck.grant_key"),
1586
+ ` and not (${role.sql} = any(${sqlTextArray(custom.declared)}))`
1587
+ ].join("\n");
1588
+ return [
1589
+ ...lines,
1590
+ " union",
1591
+ ` select ${memberColumn(column)}::${type}`,
1592
+ ` from ${quoteQualified(table.table)} m${role.join}`,
1593
+ ...filters,
1594
+ ` and not (${role.sql} = any(${sqlTextArray(custom.declared)}))`,
1595
+ customKeysSql(ctx, scope, ...entries)
1596
+ ].join("\n");
1597
+ }
1598
+ /** The membership's instance of the first scope, from the canonical claim. */
1599
+ function claimTenant(ctx, scope) {
1600
+ const root = rootName(ctx);
1601
+ return scope === root ? `m ->> 'id'` : `m -> 'within' ->> ${quoteLiteral(root)}`;
1602
+ }
1603
+ function claimBody(ctx, scope, type) {
1604
+ const set = ctx.grantSet === true;
1605
+ const narrow = narrowsTo(ctx, scope) ? `
1606
+ and (${activeTenant(ctx)} is null or ${claimTenant(ctx, scope)} = ${activeTenant(ctx)})` : "";
1607
+ const filtersFor = (grant) => ` and m ->> 'scope' = ${quoteLiteral(scope)}
1608
+ and m ->> 'id' is not null${narrow}${keyTenantLine(ctx, scope, claimTenant(ctx, scope))}
1609
+ and case jsonb_typeof(m -> 'expires_at')
1610
+ when 'number' then (m ->> 'expires_at')::numeric > extract(epoch from now())
1611
+ else true
1612
+ end${[
1613
+ ...claimKeptLines(ctx, " ", grantPermissionSql(grant)),
1614
+ ...userActive(ctx, " "),
1615
+ ...instancesActive(ctx, scope, (name) => name === scope ? `m ->> 'id'` : `m -> 'within' ->> ${quoteLiteral(name)}`, " ", grantPermissionSql(grant))
1616
+ ].map((line) => `\n${line}`).join("")}`;
1617
+ const filters = filtersFor(set ? "rp.grant_key" : "p_grant");
1618
+ const declared = ` select (m ->> 'id')::${type}${set ? ", rp.grant_key" : ""}
1619
+ from ${membershipRows(ctx)}
1620
+ join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
1621
+ where ${signedIn(ctx)}${set ? "" : "\n and rp.grant_key = p_grant"}
1622
+ and rp.scope = ${quoteLiteral(scope)}${andLine(" ", kindFilterSql(ctx, "r.role", "m ->> 'via'"))}
1623
+ ${filters}`;
1624
+ const custom = ctx.customRoles;
1625
+ if (custom === void 0) return declared;
1626
+ if (set) return `${declared}
1458
1627
  union
1459
- select i.grant_key from included i
1460
- where i.effect = 'deny' and i.scope = p_scope
1628
+ select (m ->> 'id')::${type}, ck.grant_key
1629
+ from ${membershipRows(ctx)}
1630
+ cross join lateral (select m -> 'grants' -> r.role as g) cg
1631
+ ${customKeysJoin(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
1632
+ where ${signedIn(ctx)}
1633
+ and jsonb_typeof(cg.g) = 'array'
1634
+ and not (r.role = any(${sqlTextArray(custom.declared)}))
1635
+ ${filtersFor("ck.grant_key")}`;
1636
+ return `${declared}
1461
1637
  union
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");
1638
+ select (m ->> 'id')::${type}
1639
+ from ${membershipRows(ctx)}
1640
+ cross join lateral (select m -> 'grants' -> r.role as g) cg
1641
+ where ${signedIn(ctx)}
1642
+ and jsonb_typeof(cg.g) = 'array'
1643
+ and not (r.role = any(${sqlTextArray(custom.declared)}))
1644
+ ${filters}
1645
+ ${customKeysSql(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}`;
1467
1646
  }
1468
1647
  /**
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`.
1648
+ * The rows the membership sources select for the subject, one per instance
1649
+ * (`scope`, `id`, `within`, `roles`, `via`), with each source's own expiry
1650
+ * and suspension filters: the statements the token hook runs.
1474
1651
  */
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;`;
1652
+ function sourcesKeep(sources) {
1653
+ return sources.some((source) => source.sql.keeps);
1567
1654
  }
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`;
1655
+ function sourceRows(sources) {
1656
+ const keep = sourcesKeep(sources);
1657
+ return sources.map((source, index) => source.sql.select(sourceUser(index), keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
1572
1658
  }
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[]))`
1585
- };
1659
+ function sourceUser(index) {
1660
+ return `v_user_${String(index)}`;
1661
+ }
1662
+ function sqlBody(sql) {
1586
1663
  return {
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)`
1664
+ sql,
1665
+ vars: []
1666
+ };
1667
+ }
1668
+ function sourcesBodyOf(sql, sources, user) {
1669
+ return {
1670
+ sql,
1671
+ vars: sources.map((source, index) => `${sourceUser(index)} ${source.sql.userType} := ${user};`)
1596
1672
  };
1597
1673
  }
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
1622
- stable
1623
- set search_path = ''
1624
- as $$
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
1633
- stable
1634
- security definer
1635
- set search_path = ''
1636
- as $$
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
1653
- return query
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
1674
+ /** `language sql`, or `plpgsql` returning the query when the body declares variables. */
1675
+ function functionBody(body) {
1676
+ if (body.vars.length === 0) return `language sql
1705
1677
  stable
1706
1678
  security definer
1707
1679
  set search_path = ''
1708
1680
  as $$
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
1681
+ ${body.sql}
1682
+ $$;`;
1683
+ return `language plpgsql
1719
1684
  stable
1720
1685
  security definer
1721
1686
  set search_path = ''
1722
1687
  as $$
1723
- ${trustedBody}
1724
- $$;
1725
- revoke execute on function ${trusted}(${signature}) from public, anon, authenticated;${grants}`);
1726
- return chunks.join("\n\n");
1688
+ declare
1689
+ ${body.vars.map((line) => ` ${line}`).join("\n")}
1690
+ begin
1691
+ return query
1692
+ ${body.sql};
1693
+ end;
1694
+ $$;`;
1727
1695
  }
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)`;
1696
+ /**
1697
+ * `ms.id` for the scope itself and `ms.within ->> name` for an ancestor. The
1698
+ * alias is not `s`, which `activeRowSql` binds inside its `exists`.
1699
+ */
1700
+ function sourceIdOf(scope) {
1701
+ return (name) => name === scope ? "ms.id" : `ms.within ->> ${quoteLiteral(name)}`;
1732
1702
  }
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)`;
1703
+ function sourceFilters(ctx, scope, user = subjectIdSql(ctx), permission, keeps = false) {
1704
+ return [
1705
+ ...keeps ? [` and ${keptRowSql("ms.keep", permission)}`] : [],
1706
+ ...userActive(ctx, " ", user),
1707
+ ...instancesActive(ctx, scope, sourceIdOf(scope), " ", permission)
1708
+ ].map((line) => `\n${line}`).join("");
1737
1709
  }
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)};`;
1710
+ function sourcesBody(ctx, scope, type) {
1711
+ const root = rootName(ctx);
1712
+ const tenant = sourceIdOf(scope)(root);
1713
+ const narrow = narrowsTo(ctx, scope) ? `\n and (${activeTenant(ctx)} is null or ${tenant} = ${activeTenant(ctx)})${keyTenantLine(ctx, scope, tenant)}` : keyTenantLine(ctx, scope, tenant);
1714
+ const sources = scopeSources(ctx, scope);
1715
+ const set = ctx.grantSet === true;
1716
+ const rows = ` from (
1717
+ ${sourceRows(sources)}
1718
+ ) ms
1719
+ cross join lateral jsonb_array_elements_text(
1720
+ case jsonb_typeof(ms.roles) when 'array' then ms.roles else '[]'::jsonb end
1721
+ ) r(role)`;
1722
+ const declared = ` select (ms.id)::${type}${set ? ", rp.grant_key" : ""}
1723
+ ${rows}
1724
+ join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
1725
+ where ${signedIn(ctx)}
1726
+ and ms.scope = ${quoteLiteral(scope)}${set ? "" : "\n and rp.grant_key = p_grant"}
1727
+ 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))}`;
1728
+ const custom = ctx.customRoles;
1729
+ if (custom === void 0) return sourcesBodyOf(declared, sources, subjectIdSql(ctx));
1730
+ const match = [
1731
+ customTenantMatch(ctx, `(${tenant})::text`, scope, "r.role"),
1732
+ `c.scope = ${quoteLiteral(scope)}`,
1733
+ "(c.scope_id is null or c.scope_id = (ms.id)::text)",
1734
+ "c.role = r.role"
1735
+ ].join(" and ");
1736
+ const entries = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1737
+ if (set) return sourcesBodyOf(`${declared}
1738
+ union
1739
+ select (ms.id)::${type}, ck.grant_key
1740
+ ${rows}
1741
+ ${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", ""))}
1742
+ where ${signedIn(ctx)}
1743
+ and ms.scope = ${quoteLiteral(scope)}
1744
+ and not (r.role = any(${sqlTextArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("ck.grant_key"), sourcesKeep(sources))}`, sources, subjectIdSql(ctx));
1745
+ return sourcesBodyOf(`${declared}
1746
+ union
1747
+ select (ms.id)::${type}
1748
+ ${rows}
1749
+ where ${signedIn(ctx)}
1750
+ and ms.scope = ${quoteLiteral(scope)}
1751
+ and not (r.role = any(${sqlTextArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("p_grant"), sourcesKeep(sources))}
1752
+ ${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));
1743
1753
  }
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));
1754
+ function scopedBody(ctx, scope, type) {
1755
+ if (ctx.authorize !== "database") return sqlBody(claimBody(ctx, scope, type));
1756
+ return scopeSources(ctx, scope).length > 0 ? sourcesBody(ctx, scope, type) : sqlBody(tableBody(ctx, scope, type));
1757
+ }
1758
+ //#endregion
1759
+ //#region src/cli/rls/capabilities.ts
1760
+ /** The helper `--capabilities` adds: ids of one resource a link capability claim reaches. Part of the SQL contract. */
1761
+ const CAPABILITIES = { ids: "permdock_capability_ids" };
1762
+ /**
1763
+ * The `anon` branch access for a resource-scoped grant reached through a link
1764
+ * capability: the row's `field` holds an id the capability claim names for
1765
+ * `resource`, `role` and `permission`. Uncorrelated, so it runs once per
1766
+ * statement; the column is compared as text because the claim holds text.
1767
+ */
1768
+ function capabilityAccessSql(ctx, field, resource, role, permission) {
1769
+ const args = [
1770
+ resource,
1771
+ role,
1772
+ permission
1773
+ ].map(quoteLiteral).join(", ");
1774
+ return `${quoteIdent(field)}::text in (select ${qualified(ctx, CAPABILITIES.ids)}(${args}))`;
1775
+ }
1776
+ function capabilitiesSql(ctx) {
1777
+ if (ctx.capabilities !== true) return "";
1778
+ const fn = qualified(ctx, CAPABILITIES.ids);
1779
+ return `-- link capabilities: the capability claim exchangeCapability mints (role anon, short-lived)
1780
+ create or replace function ${fn}(p_resource text, p_role text, p_permission text)
1781
+ returns setof text
1782
+ language sql
1783
+ stable
1784
+ set search_path = ''
1785
+ as $$
1786
+ select c -> 'on' ->> 'id'
1787
+ from (select ${subjectClaimJsonSql(ctx, "capability")} as c) capability
1788
+ where jsonb_typeof(c) = 'object'
1789
+ and c ->> 'v' = '1'
1790
+ and c ->> 'holder' = 'link'
1791
+ and c -> 'on' ->> 'resource' = p_resource
1792
+ and jsonb_typeof(c -> 'roles') = 'array'
1793
+ and c -> 'roles' @> jsonb_build_array(p_role)
1794
+ and (c -> 'permissions' is null or c -> 'permissions' @> jsonb_build_array(p_permission))
1795
+ and jsonb_typeof(c -> 'expires_at') = 'number'
1796
+ and (c ->> 'expires_at')::numeric > extract(epoch from now())
1797
+ $$;
1798
+ revoke execute on function ${fn}(text, text, text) from public;
1799
+ grant execute on function ${fn}(text, text, text) to anon, authenticated;`;
1746
1800
  }
1747
1801
  //#endregion
1748
1802
  //#region src/cli/rls/custom-role-cascade.ts
@@ -1775,7 +1829,7 @@ function customRoleCascadeSql(ctx) {
1775
1829
  const fn = qualified(ctx, CUSTOM_ROLES.cascade);
1776
1830
  const target = quoteTable(qualifySqlTable(table.table));
1777
1831
  const tenant = (row) => table.tenant === void 0 ? `null::${tenantType}` : `${rowColumn(row, table.tenant)}::${tenantType}`;
1778
- const scope = (row) => table.scope === void 0 ? `case when ${tenant(row)} is null then 'global' else ${quoteLiteral(root)} end` : `case when ${tenant(row)} is null then 'global' else ${rowColumn(row, table.scope)}::text end`;
1832
+ const scope = (row) => rolesTableScope(table, tenant(row), root, (name) => rowColumn(row, name));
1779
1833
  const id = (row) => table.id === void 0 ? "null::text" : `${rowColumn(row, table.id)}::text`;
1780
1834
  const skipped = (row) => table.skip === void 0 ? "false" : `coalesce(${rowColumn(row, table.skip)}, false)`;
1781
1835
  const guard = qualified(ctx, CUSTOM_ROLES.guard);
@@ -1909,7 +1963,7 @@ ${raiseSql(" ", "42501", `'permdock: the caller may not manage roles in ' ||
1909
1963
  )` : "";
1910
1964
  const reachCte = leveled && reachRows !== "" ? `,
1911
1965
  reach (grant_key, level) as (values ${reachRows})` : "";
1912
- 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`;
1966
+ const match = `${storedTenant(ctx)} and c.scope = p_scope and c.scope_id is not distinct from p_scope_id`;
1913
1967
  const memberOf = `p_tenant::text in ${members(root)}
1914
1968
  or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${members(scope)}`)}`;
1915
1969
  return {
@@ -1961,7 +2015,7 @@ begin
1961
2015
  perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
1962
2016
  if not (${signedIn(ctx, user)} and (
1963
2017
  ${platform}
1964
- or (p_scope <> 'global' and (${memberOf}))
2018
+ or (p_scope <> 'global' and coalesce(${memberOf}, false))
1965
2019
  )) then
1966
2020
  ${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
1967
2021
  end if;${requires}
@@ -1989,8 +2043,9 @@ revoke execute on function ${guard}(${userArg}${tenantType}, text, text, text) f
1989
2043
  * \`assignablePermissions\`: entries inside the ceiling of the role's scope,
1990
2044
  * includes naming declared roles, and only permissions and levels the caller
1991
2045
  * may hand out, for the new definition and the stored one alike. A platform
1992
- * custom role (\`p_scope = 'global'\`, no tenant) is written by a caller who
1993
- * holds a \`meta.manageRoles\` permission through a global role. The
2046
+ * custom role (\`p_scope = 'global'\`, no tenant) and a shared custom role (no
2047
+ * tenant at a lower scope) are written by a caller who holds a
2048
+ * \`meta.manageRoles\` permission through a global role. The
1994
2049
  * \`permdock_trusted_*\` variants skip the caller checks and keep the
1995
2050
  * definition checks; no client role may execute them.
1996
2051
  */
@@ -2016,9 +2071,15 @@ function customRoleWritesSql(ctx, levelReach) {
2016
2071
  const scopes = sqlTextArray(ctx.scopes.map((scope) => scope.name));
2017
2072
  const roleName = (param) => `coalesce(${param}, '') = '' or ${param} = any(${sqlTextArray(custom.declared)})`;
2018
2073
  const levelOf = leveled ? ", nullif(split_part(e, '@', 2), '')" : "";
2019
- const write = ` delete from ${perms} c where ${match} and c.role = p_role;
2074
+ const levelColumn = leveled ? ", level" : "";
2075
+ const sharedRole = qualified(ctx, CUSTOM_ROLES.shared);
2076
+ const taken = (param) => ` if p_tenant is not null and ${sharedRole}(p_scope, ${param}) then
2077
+ ${raiseSql(" ", "22023", `'permdock: ' || ${param} || ' is the name of a shared custom role'`, "name-taken")}
2078
+ end if;
2079
+ `;
2080
+ const write = `${taken("p_role")} delete from ${perms} c where ${match} and c.role = p_role;
2020
2081
  delete from ${includes} c where ${match} and c.role = p_role;
2021
- insert into ${perms} (tenant_id, scope, scope_id, role, permission, effect${leveled ? ", level" : ""})
2082
+ insert into ${perms} (tenant_id, scope, scope_id, role, permission, effect${levelColumn})
2022
2083
  select distinct p_tenant, p_scope, p_scope_id, p_role, ${current(leveled ? "split_part(e, '@', 1)" : "e")}, 'allow'${levelOf}
2023
2084
  from unnest(coalesce(p_allow, '{}'::text[])) e
2024
2085
  union
@@ -2027,7 +2088,7 @@ function customRoleWritesSql(ctx, levelReach) {
2027
2088
  insert into ${includes} (tenant_id, scope, scope_id, role, include_role)
2028
2089
  select distinct p_tenant, p_scope, p_scope_id, p_role, e
2029
2090
  from unnest(coalesce(p_include, '{}'::text[])) e;`;
2030
- const move = ` if ${roleName("p_to")} then
2091
+ const move = `${taken("p_to")} if ${roleName("p_to")} then
2031
2092
  ${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_to, 'null') || ' is not a custom role name'`, "declared-role")}
2032
2093
  end if;
2033
2094
  if exists (select 1 from ${perms} c where ${match} and c.role = p_to)
@@ -2058,7 +2119,7 @@ grant execute on function ${qualified(ctx, name)}(${args}) to authenticated;` :
2058
2119
  const removeParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
2059
2120
  return `${caller.beyond}
2060
2121
 
2061
- -- where a custom role may live: a scope of a tenant, or the platform (scope global, no tenant or instance), under an undeclared name
2122
+ -- where a custom role may live: a scope of a tenant, a scope of every tenant (shared: no tenant or instance), or the platform (scope global, no tenant or instance), under an undeclared name
2062
2123
  create or replace function ${shape}(p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
2063
2124
  returns void
2064
2125
  language plpgsql
@@ -2070,8 +2131,10 @@ begin
2070
2131
  if p_tenant is not null or p_scope_id is not null then
2071
2132
  ${raiseSql(" ", "22023", `'permdock: a platform custom role has no tenant or instance'`, "unknown-scope")}
2072
2133
  end if;
2073
- elsif p_tenant is null or not (p_scope = any(${scopes})) then
2134
+ elsif not (p_scope = any(${scopes})) then
2074
2135
  ${raiseSql(" ", "22023", `'permdock: custom roles are held at a scope of a tenant or at global, not ' || coalesce(p_scope, 'null')`, "unknown-scope")}
2136
+ elsif p_tenant is null and p_scope_id is not null then
2137
+ ${raiseSql(" ", "22023", `'permdock: a shared custom role has no instance'`, "unknown-scope")}
2075
2138
  end if;
2076
2139
  if ${roleName("p_role")} then
2077
2140
  ${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_role, 'null') || ' is not a custom role name'`, "declared-role")}
@@ -2217,7 +2280,7 @@ function globalRolesSql(ctx) {
2217
2280
  const id = quoteIdent(through.id);
2218
2281
  const key = quoteIdent(through.key);
2219
2282
  const ref = quoteIdent(through.ref);
2220
- const global = source.tenant === void 0 ? "" : ` and k.${quoteIdent(source.tenant)} is null`;
2283
+ const global = source.tenant === void 0 ? "" : ` and k.${quoteIdent(source.tenant)} is null${source.shared === void 0 ? "" : ` and k.${quoteIdent(source.shared)} is not true`}`;
2221
2284
  body = ` select string_agg(w.r, ', ' order by w.r) into v_unknown
2222
2285
  from (${wanted}) w(r)
2223
2286
  where not exists (select 1 from ${keys} k where k.${key}::text = w.r${global});
@@ -2909,7 +2972,7 @@ function canAssignCustomSql(ctx, forUser = false) {
2909
2972
  const guard = qualified(ctx, forUser ? CUSTOM_ROLES.guardFor : CUSTOM_ROLES.guard);
2910
2973
  const userParam = forUser ? "p_user uuid, " : "";
2911
2974
  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)`;
2975
+ const stored = `${storedTenant(ctx)} and c.scope = p_scope and c.role = p_role and (c.scope_id is null or c.scope_id = p_scope_id)`;
2913
2976
  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
2977
  grant execute on function ${fn}(${tenantType}, text, text, text) to authenticated;`;
2915
2978
  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" : ""}
@@ -3631,4 +3694,4 @@ function ownershipSql(ctx) {
3631
3694
  return chunks.length === 0 ? "" : `${chunks.join("\n\n")}\n`;
3632
3695
  }
3633
3696
  //#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 };
3697
+ export { activeUserSql as $, collectGrants as A, hasPermittedUsers as B, byKey as C, apiKeysPlan as D, apiKeyFields as E, CLOSURE as F, memberIdsHelper as G, memberForHelper as H, graphHelper as I, permittedIdsHelper as J, memberRoleOf as K, graphSqlName as L, compileConditionSql as M, contextRefs as N, breakGlassHolder as O, sqlFunctionNames as P, activeRowSql as Q, graphSqlText as R, byCodeUnit as S, apiKeyAllowsCall as T, memberForSources as U, linkHelper as V, memberForTable as W, scopeSources as X, restrictedHelper as Y, scopeTable as Z, capabilityAccessSql as _, guardedTables as a, currentKeyExpr as b, PERMISSION_HELPERS as c, permissionKeysForHelper as d, checkSuspension as et, permittedByPermissionHelper as f, seedSql as g, helpersSql as h, assignmentSql as i, roleNames as j, breakGlassKey as k, checkName as l, accessSql as m, ownershipSql as n, ASSIGNMENTS as o, requiresSql as p, permittedForHelper as q, holderSources as r, OWNERSHIP as s, ownershipRules as t, permissionHelpersSql as u, globalRoleRows as v, API_KEY_ALLOWS as w, rolesTableScope as x, globalRoleSource as y, hasMemberFor as z };