permdock 0.5.0 → 0.7.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 (399) hide show
  1. package/README.md +1 -0
  2. package/dist/{2020-C9VN-x8c.js → 2020-CJbO8Bvz.js} +1 -3
  3. package/dist/a2a/index.d.ts +5 -5
  4. package/dist/a2a/index.js +4 -4
  5. package/dist/{adoption-scan-B4R5fnMU.js → adoption-scan-D-feE7BE.js} +1 -1
  6. package/dist/agent/index.d.ts +3 -3
  7. package/dist/agent/index.js +1 -1
  8. package/dist/ai-sdk/index.d.ts +21 -6
  9. package/dist/ai-sdk/index.js +19 -9
  10. package/dist/{approval-prompt-Djo_PyZc.d.ts → approval-prompt-K0faOLFp.d.ts} +2 -2
  11. package/dist/approvals/index.d.ts +4 -4
  12. package/dist/approvals/index.js +4 -4
  13. package/dist/{approvals-DJ6Yd5z1.js → approvals-CMRfsbBh.js} +74 -48
  14. package/dist/{arazzo-B6lebmiQ.js → arazzo-ClrF3PVb.js} +2 -2
  15. package/dist/{arazzo-BWhNF-k0.js → arazzo-rdeJ8n1J.js} +4 -4
  16. package/dist/{args-IIolxxKA.js → args-CC3OwyHW.js} +112 -2
  17. package/dist/astro/index.d.ts +6 -6
  18. package/dist/astro/index.js +2 -2
  19. package/dist/{audience-Dg8XsRaO.js → audience-BrMs8c4o.js} +1 -1
  20. package/dist/{auth-DRLEizbL.js → auth-CscUX8Sy.js} +3 -4
  21. package/dist/authzen/index.d.ts +162 -22
  22. package/dist/authzen/index.js +315 -146
  23. package/dist/{automation-BMZPIVUM.d.ts → automation-DOtAFWdy.d.ts} +3 -3
  24. package/dist/{automation-DVjOckjV.js → automation-ySsxp_Yp.js} +1 -1
  25. package/dist/base-url-BQbddZO5.js +9 -0
  26. package/dist/better-auth/index.d.ts +2 -2
  27. package/dist/better-auth/index.js +2 -2
  28. package/dist/better-supabase/index.d.ts +50 -8
  29. package/dist/better-supabase/index.js +94 -32
  30. package/dist/{bind-CZzhx-Vj.js → bind-D1nO6fiC.js} +5 -5
  31. package/dist/{bind--9EFVYWF.d.ts → bind-JDuvxjuW.d.ts} +7 -7
  32. package/dist/{build-instance-BMnAudHn.js → build-instance-DxE7czo9.js} +1 -1
  33. package/dist/{canonical-json-BC8_nMTw.js → canonical-json-DwilZ1iB.js} +6 -3
  34. package/dist/{capability-BSslw1QV.d.ts → capability-D4UC9TTW.d.ts} +2 -2
  35. package/dist/{capability-N1K9IpqS.js → capability-L7NnfhiE.js} +1 -1
  36. package/dist/{casbin-Cr7RLtA3.js → casbin-BHijLuHY.js} +2 -2
  37. package/dist/{casbin-Dyu2gnUD.js → casbin-BW9pS47J.js} +1 -1
  38. package/dist/{casl-64gpT-8x.js → casl-CmJlgziS.js} +2 -2
  39. package/dist/{casl-DjEDhTCP.js → casl-UbI8v-Wq.js} +1 -1
  40. package/dist/catalog/index.d.ts +70 -10
  41. package/dist/catalog/index.js +112 -6
  42. package/dist/catalog-BCCjSAdH.js +1 -0
  43. package/dist/{catalog-R_A__RsH.js → catalog-BaMQfQ6Y.js} +3 -3
  44. package/dist/{catalog-BECMPrwc.js → catalog-D69WhFii.js} +43 -13
  45. package/dist/{catalog-doc-C9wjsfNY.js → catalog-doc-CV9s6B1S.js} +28 -5
  46. package/dist/{catalog-fingerprint-CCP0-yQS.js → catalog-fingerprint-C144oW4M.js} +1 -1
  47. package/dist/{create-BWRSXgOF.js → challenge-nGWma08S.js} +122 -25
  48. package/dist/chat/index.d.ts +1 -1
  49. package/dist/chat/index.js +2 -2
  50. package/dist/chat-sdk/index.d.ts +3 -3
  51. package/dist/checks-BC_0mPIq.js +37 -0
  52. package/dist/claude-agent/index.d.ts +3 -3
  53. package/dist/claude-agent/index.js +2 -2
  54. package/dist/clerk/index.d.ts +3 -3
  55. package/dist/clerk/index.js +1 -1
  56. package/dist/cli/bin.js +2 -2
  57. package/dist/cli/index.d.ts +3 -3
  58. package/dist/cli/index.js +3 -3
  59. package/dist/cloud/index.d.ts +127 -11
  60. package/dist/cloud/index.js +251 -3
  61. package/dist/{cloud-CIoHNevm.js → cloud-CO8pYtwr.js} +5 -5
  62. package/dist/{codemod-Cxvvc70T.js → codemod-BP6n8N9L.js} +3 -3
  63. package/dist/{codemod-Bmj9dWMH.js → codemod-i4N2BhDc.js} +3 -3
  64. package/dist/{collect-CrZJJnEF.js → collect-BaWeMjwV.js} +17 -8
  65. package/dist/{collect-DloHbDVa.js → collect-DNm3oKOk.js} +6 -5
  66. package/dist/collect-k_Dulxr5.js +1 -0
  67. package/dist/compile/index.d.ts +1 -1
  68. package/dist/compile/index.js +1 -1
  69. package/dist/{compile-CMqMx_IN.js → compile-BAEO5bOk.js} +2 -2
  70. package/dist/{compile-Ckm2HqBZ.d.ts → compile-COM2VKuN.d.ts} +3 -3
  71. package/dist/{completion-BqhRBJD8.js → completion-BNdVf7X2.js} +3 -3
  72. package/dist/{config-bs_kZeYq.js → config-CoQHDrDi.js} +7 -3
  73. package/dist/{config-B46rm9sc.js → config-DGZzMV1B.js} +2 -2
  74. package/dist/{config-report-DUgj62Ai.js → config-report-Cm7UITNA.js} +3 -3
  75. package/dist/connector-schema-_VCSsdtD.js +433 -0
  76. package/dist/{context-bbFHR-0D.js → context--uM9V3Ui.js} +2 -2
  77. package/dist/convex/index.d.ts +5 -5
  78. package/dist/convex/index.js +2 -2
  79. package/dist/core-B5-w6Kw9.js +335 -0
  80. package/dist/core-CQkE8Esr.d.ts +179 -0
  81. package/dist/{create-0VZ7Pdiu.d.ts → create-B2-CQqCI.d.ts} +5 -5
  82. package/dist/{create-C3b4B5eF.js → create-Cb86Z8Im.js} +9 -8
  83. package/dist/{create-DnrmLnLW.js → create-DJrlhEp9.js} +5 -5
  84. package/dist/{create-BFfm17UA.d.ts → create-QHuuPw3q.d.ts} +7 -7
  85. package/dist/{create-BDS7wc3y.d.ts → create-UTnUVETj.d.ts} +30 -10
  86. package/dist/{credential-DM2um3ha.js → credential-CIM7wHwA.js} +4 -4
  87. package/dist/{credentials-DPyHmYz5.js → credentials-BpuTwd7a.js} +1 -1
  88. package/dist/{deciding-columns-Sq12mEA6.js → deciding-columns-DRo2QM6E.js} +13 -2
  89. package/dist/{delegation-DaXVdDhM.js → delegation-C5rOdTp5.js} +5 -5
  90. package/dist/delivery/index.d.ts +5 -5
  91. package/dist/delivery/index.js +4 -3
  92. package/dist/{delivery-DCAgNyCn.js → delivery-rwOKLWRm.js} +51 -2
  93. package/dist/{denied-text-D8jDHgsl.d.ts → denied-text-D4qkEtFz.d.ts} +2 -2
  94. package/dist/{describe-BybMRY7A.js → describe-DZlnEtLy.js} +1 -0
  95. package/dist/{describe-BZUsixiL.d.ts → describe-O0SwYIK8.d.ts} +2 -2
  96. package/dist/{diff-CxZo9pTq.js → diff-Btr1c1Ow.js} +2 -2
  97. package/dist/{diff-DCePiN6M.js → diff-DzT-xD13.js} +10 -10
  98. package/dist/{doctor-DFjUxf2f.js → doctor-BU1szn63.js} +241 -49
  99. package/dist/{doctor-CxiwSfqH.js → doctor-BoK_y0KO.js} +4 -4
  100. package/dist/drizzle/index.d.ts +3 -3
  101. package/dist/drizzle/index.js +1 -1
  102. package/dist/elysia/index.d.ts +5 -5
  103. package/dist/elysia/index.js +3 -3
  104. package/dist/{errors-C2PXIv4c.d.ts → errors-B_Qg9D3e.d.ts} +2 -2
  105. package/dist/{errors-CWTY6uih.js → errors-DpP_U6Bf.js} +1 -0
  106. package/dist/{evaluate-_R8QkWaf.js → evaluate-B_oqFbWC.js} +130 -34
  107. package/dist/{evaluations-16UqqQ6W.d.ts → evaluations-BCddEOfO.d.ts} +4 -4
  108. package/dist/{evaluations-DZ2CbV5k.js → evaluations-DReoiVBw.js} +4 -4
  109. package/dist/eve/index.d.ts +5 -5
  110. package/dist/eve/index.js +3 -3
  111. package/dist/express/index.d.ts +5 -5
  112. package/dist/express/index.js +3 -3
  113. package/dist/fastify/index.d.ts +5 -5
  114. package/dist/fastify/index.js +3 -3
  115. package/dist/{fetch-adapter-qKJBuRqy.d.ts → fetch-adapter-CuhyYwC1.d.ts} +4 -4
  116. package/dist/{fetch-adapter-g0ypLkID.js → fetch-adapter-De-u6vhJ.js} +3 -3
  117. package/dist/{fields-D9tPVUUS.js → fields-ClcQLrVc.js} +48 -13
  118. package/dist/{files-C5ZI27eV.js → files-acUbNwWt.js} +15 -2
  119. package/dist/{fixtures-B7XrLeY7.js → fixtures-BTMW6zgU.js} +2 -2
  120. package/dist/{flags-C3DktBDY.js → flags-aaeRAdiD.js} +2 -2
  121. package/dist/{from-snapshot-D2lZt4Dd.js → from-snapshot-Bz469dIh.js} +17 -10
  122. package/dist/generate-3FNO5P5Q.js +159 -0
  123. package/dist/{grantee-Cn1HyUsI.js → grantee-BrRANrpT.js} +14 -4
  124. package/dist/{grantee-conditions-CcXf8hMM.js → grantee-conditions-C6P1Tvol.js} +1 -1
  125. package/dist/{grantee-match-DHArqY82.js → grantee-match-0RtHWQDx.js} +3 -3
  126. package/dist/h3/index.d.ts +4 -4
  127. package/dist/h3/index.js +3 -3
  128. package/dist/{helpers-DTcZOzhF.js → helpers-DY3vXuGs.js} +3 -3
  129. package/dist/hono/index.d.ts +5 -5
  130. package/dist/hono/index.js +3 -3
  131. package/dist/{hosted-DBDX0M-P.d.ts → hosted-BmjzxQhp.d.ts} +14 -2
  132. package/dist/{hosted-BZIAYvon.js → hosted-XBgU6RGT.js} +34 -12
  133. package/dist/{http-DXJ37RDA.js → http-Ca3Sr5x1.js} +1 -1
  134. package/dist/{import-DMmOG27O.js → import-Bz0GefWg.js} +5 -5
  135. package/dist/{import-CNV7I2Dw.js → import-DB2EG_iy.js} +3 -3
  136. package/dist/{index-CxGUswIC.d.ts → index-BE8y3moU.d.ts} +50 -8
  137. package/dist/{index-Chrs5fQ-.d.ts → index-BSfRl0hZ.d.ts} +6 -6
  138. package/dist/{index-DOVf7RJv.d.ts → index-BTkwrK7v.d.ts} +155 -17
  139. package/dist/index-CMiPH2Tv.d.ts +206 -0
  140. package/dist/{index-SGdIQyzc.d.ts → index-DH9DUDEM.d.ts} +18 -3
  141. package/dist/{index-BcHcPyIo.d.ts → index-DLwcuCN_.d.ts} +86 -7
  142. package/dist/index.d.ts +18 -17
  143. package/dist/index.js +27 -25
  144. package/dist/{init-GG6Jh8HD.js → init-5TXNji88.js} +5 -5
  145. package/dist/{init-DeMYsnYJ.js → init-B-4t1NzC.js} +4 -4
  146. package/dist/{instance-options-Omfd793B.d.ts → instance-options-CU0YQCLH.d.ts} +1 -1
  147. package/dist/{instance-snapshot-Cxq4ygFc.js → instance-snapshot-CjcwnEiV.js} +20 -14
  148. package/dist/jwt/index.d.ts +83 -5
  149. package/dist/jwt/index.js +143 -5
  150. package/dist/{kernel-5JatXyND.js → kernel-DVExUwkp.js} +138 -12
  151. package/dist/kysely/index.d.ts +3 -3
  152. package/dist/kysely/index.js +1 -1
  153. package/dist/load-DrMHXzgN.js +1 -0
  154. package/dist/{load-DFS_ry0W.js → load-DxroEzH1.js} +2 -2
  155. package/dist/{login-Dfe5RlYk.js → login-ClSJh5t5.js} +4 -4
  156. package/dist/{login-imhuwvn3.js → login-WC9HPJpU.js} +6 -10
  157. package/dist/{manifest-C8IzW3Do.js → manifest-B79YuF2T.js} +6 -2
  158. package/dist/{manifest-mx5ghSFM.d.ts → manifest-BVhExYhw.d.ts} +23 -2
  159. package/dist/{manifest-CtKR3GXL.js → manifest-Dv6q4i5s.js} +54 -35
  160. package/dist/{manifest-schema-C0kK_8V0.js → manifest-schema-Cv492Qev.js} +37 -7
  161. package/dist/{map-claims-D3oRmTk0.js → map-claims-etGIS_Dv.js} +14 -11
  162. package/dist/{map-error-TCAdw-eW.js → map-error-BQGyDm_M.js} +1 -1
  163. package/dist/mcp/index.d.ts +64 -10
  164. package/dist/mcp/index.js +151 -22
  165. package/dist/{migrate-BShP7Vf4.js → migrate-Dodel0IJ.js} +77 -24
  166. package/dist/nest/index.d.ts +5 -5
  167. package/dist/nest/index.js +3 -3
  168. package/dist/next/index.d.ts +9 -9
  169. package/dist/next/index.js +5 -5
  170. package/dist/next/plugin.d.ts +1 -1
  171. package/dist/next/plugin.js +2 -2
  172. package/dist/node/index.d.ts +5 -5
  173. package/dist/node/index.js +2 -2
  174. package/dist/openai/index.d.ts +5 -5
  175. package/dist/openai/index.js +3 -3
  176. package/dist/openapi/index.d.ts +14 -4
  177. package/dist/openapi/index.js +1 -1
  178. package/dist/{openapi-ahr8rHu3.js → openapi-DMocl1bh.js} +4 -4
  179. package/dist/{openapi-B7HZ0f-r.js → openapi-Ddb4628T.js} +2 -7
  180. package/dist/{openapi-DTaEmTSW.js → openapi-ztVaux6j.js} +8 -8
  181. package/dist/{operation-findings-DgATDpvh.js → operation-findings-kRcEp8lE.js} +1 -1
  182. package/dist/{operations-CxybZErY.d.ts → operations-BNLes61x.d.ts} +1 -1
  183. package/dist/orpc/index.d.ts +6 -6
  184. package/dist/orpc/index.js +4 -4
  185. package/dist/otel/index.d.ts +3 -3
  186. package/dist/otel/index.js +1 -1
  187. package/dist/{output-Dyw8fyPF.js → output-DgSDLrta.js} +7 -3
  188. package/dist/{output-files-DhRBBE4n.js → output-files-CCMdcFWb.js} +2 -2
  189. package/dist/output-files-DPAY7-GX.js +1 -0
  190. package/dist/{ownership-BatFgAA4.js → ownership-Z2LpxsGa.js} +1349 -883
  191. package/dist/parse-Ddhz2xf4.js +148 -0
  192. package/dist/{parse-local-manifest-CVSs6J9I.js → parse-local-manifest-T1Q8X3A2.js} +24 -1
  193. package/dist/{parse-local-manifest-DgqtruPn.d.ts → parse-local-manifest-e4mdwAKd.d.ts} +1 -1
  194. package/dist/pdp/index.d.ts +3 -3
  195. package/dist/pdp/index.js +7 -3
  196. package/dist/peer-12Q5I_RH.js +976 -0
  197. package/dist/{peer-CRvC7Hg1.js → peer-DOzzMfZL.js} +1 -1
  198. package/dist/{permdock-B9JxkBwC.js → permdock-BfWEeMqk.js} +177 -49
  199. package/dist/{permdock-D7eeLJER.d.ts → permdock-Dco6dyCK.d.ts} +34 -5
  200. package/dist/{permission-registry-Br4jSNVi.js → permission-registry-DpPtSKNZ.js} +1 -1
  201. package/dist/{vocabulary-D6lPdb4Z.d.ts → permission-types-DhNEmoRG.d.ts} +4 -62
  202. package/dist/{permissions-oEWfl-kl.d.ts → permissions-CoqCOfJ3.d.ts} +2 -1
  203. package/dist/{permissions-B-wCP5ZC.js → permissions-CyH2MV_B.js} +1 -1
  204. package/dist/{permissions-CZKiUfFM.js → permissions-DArULThq.js} +4 -4
  205. package/dist/pkce-5h8NgMQJ.js +21 -0
  206. package/dist/{policies-CO1oOymN.js → policies-B2lZIurj.js} +3 -3
  207. package/dist/{policy-Czo4n3Hs.d.ts → policy-B7dg_yzu.d.ts} +2 -2
  208. package/dist/{policy-DLZJ5gAp.js → policy-DaJy4nK4.js} +17 -8
  209. package/dist/{policy-from-catalog-DzhEc3Y9.js → policy-from-catalog-DFWYkNdu.js} +9 -9
  210. package/dist/{policy-types-DzwivBgR.d.ts → policy-types-B-F-qAZk.d.ts} +109 -5
  211. package/dist/postgres-store-BL9M_CA1.js +1001 -0
  212. package/dist/{postgrest-NUqZT-RB.d.ts → postgrest-CYacM_u7.d.ts} +12 -1
  213. package/dist/{postgrest-CW0IqC-U.js → postgrest-r5faDIzD.js} +47 -5
  214. package/dist/{powersync-BHfuehf3.js → powersync-13odBwiM.js} +3 -3
  215. package/dist/powersync-53ryOZip.js +1059 -0
  216. package/dist/powersync-CExtTLyA.js +1 -0
  217. package/dist/preset/index.js +1 -1
  218. package/dist/{preset-DZdU0tne.js → preset-CHTbd-DQ.js} +2 -2
  219. package/dist/prisma/index.d.ts +2 -2
  220. package/dist/prisma/index.js +1 -1
  221. package/dist/{problem-details-DXlt-vks.d.ts → problem-details-Tcfmj3bV.d.ts} +1 -1
  222. package/dist/prompt-DwtVemER.js +935 -0
  223. package/dist/{protected-DEPHc2xV.d.ts → protected-BwZYL1X_.d.ts} +6 -5
  224. package/dist/{protected-DfjY5mLA.js → protected-DXE3cQYg.js} +2 -2
  225. package/dist/{provider-CC-u3bYP.js → provider-BQsTYYy0.js} +2 -2
  226. package/dist/{provider-DMVt6yYn.d.ts → provider-Csk-THAn.d.ts} +1 -1
  227. package/dist/{provider-store-B5Yrkhzr.js → provider-store-IgqQipCO.js} +2 -2
  228. package/dist/{pull-BgIfno6w.js → pull-CN5OLxrF.js} +7 -7
  229. package/dist/pull-Cty6Ovyu.js +1 -0
  230. package/dist/{push-CmFVBOeI.js → push-tVdG8dUL.js} +54 -14
  231. package/dist/{pushed-policy-BImMTTIM.d.ts → pushed-policy-BbcY9r_4.d.ts} +1 -1
  232. package/dist/{pushed-policy-DobP7AHI.js → pushed-policy-CmYeSQqq.js} +1 -1
  233. package/dist/react/index.d.ts +6 -6
  234. package/dist/react/index.js +3 -3
  235. package/dist/react/provider-client.d.ts +1 -1
  236. package/dist/react/provider-client.js +1 -1
  237. package/dist/react-native/index.d.ts +25 -11
  238. package/dist/react-native/index.js +214 -24
  239. package/dist/{refs-K6eWUJ0H.js → refs-CNJBiTFo.js} +15 -1
  240. package/dist/{replay-D7iKcLFe.d.ts → replay-DCDD_ZfL.d.ts} +1 -1
  241. package/dist/{request-context-BTRTG7OT.js → request-context-BykKLmyE.js} +2 -2
  242. package/dist/resend/index.d.ts +4 -4
  243. package/dist/{retire-CALjIQ7e.js → retire-Wj6dkyDE.js} +2 -2
  244. package/dist/{rls-BA8an5Ap.js → rls-Ck_PocSp.js} +32 -8
  245. package/dist/{rls-j6t43t7n.js → rls-CxBZb4rZ.js} +478 -534
  246. package/dist/{role-builder-CXbis5-X.js → role-builder-C1VJzvcr.js} +3 -3
  247. package/dist/rolldown-runtime-C3I60J0A.js +4 -0
  248. package/dist/{rpc-aPyNi6tA.js → rpc-Bk1bE-ch.js} +1 -1
  249. package/dist/run-Bz7DXGEv.js +1 -0
  250. package/dist/{run-ticket-sxFI6LAk.js → run-ticket-B5d_63Ma.js} +22 -1
  251. package/dist/{run-ticket-BjhTOi6u.d.ts → run-ticket-BpNi7BCi.d.ts} +8 -3
  252. package/dist/{run-DOfMtc9V.js → run-uLD2aWuO.js} +7 -7
  253. package/dist/{runtime-D3VTAiCA.js → runtime-Vif3_-zf.js} +1 -1
  254. package/dist/{saas-FO-Q8oj0.js → saas-Az_Kz-4D.js} +7 -7
  255. package/dist/{scan-BOI5tJ-6.js → scan-DSljDE1Y.js} +1 -1
  256. package/dist/{schema-yLuBWWsh.js → schema-gIcCx7zM.js} +1 -1
  257. package/dist/scim/index.d.ts +3 -2
  258. package/dist/scim/index.js +3 -1
  259. package/dist/scim-C7JYhySg.js +44 -0
  260. package/dist/{scim-ClHtpyV3.js → scim-YNieBO1U.js} +3 -537
  261. package/dist/{scopes-CUPSjo9q.js → scopes-BHsc-wp-.js} +1 -1
  262. package/dist/server/index.d.ts +11 -11
  263. package/dist/server/index.js +7 -7
  264. package/dist/{server-DqCT4CgZ.js → server-Bnga6I50.js} +3 -3
  265. package/dist/{server-types-BLd4MnBX.d.ts → server-types-C4gZUEZy.d.ts} +9 -9
  266. package/dist/{shared-DvvFzYXe.js → shared-C8LjUdVU.js} +1 -1
  267. package/dist/{shared-CD11nBbC.js → shared-fMKOv8hP.js} +46 -39
  268. package/dist/{sink-Bj_lDUDq.d.ts → sink-Cp_Eot9W.d.ts} +4 -4
  269. package/dist/{skills-B6lVdD_W.js → skills-Dcjw9_1C.js} +5 -5
  270. package/dist/{skills-CTYwkl44.js → skills-g_3BS9NC.js} +3 -3
  271. package/dist/{snapshot-CXUWfsNy.js → snapshot-CbGmYiJx.js} +4 -4
  272. package/dist/{snapshot-cache-DZHQ0KF6.js → snapshot-cache-DT2Kx1F8.js} +1 -1
  273. package/dist/{snapshot-cache-DqK9eDmM.d.ts → snapshot-cache-Pahd3IYJ.d.ts} +1 -1
  274. package/dist/solid/index.d.ts +6 -5
  275. package/dist/solid/index.js +4 -4
  276. package/dist/solid-start/index.d.ts +6 -6
  277. package/dist/solid-start/index.js +3 -3
  278. package/dist/{sources-vs5dGOsS.js → sources-B-zYB4GS.js} +111 -25
  279. package/dist/{sources-BV9j0DeZ.d.ts → sources-DEEuGb8S.d.ts} +63 -14
  280. package/dist/sources-qoIJQrJv.js +11 -0
  281. package/dist/{sql-B5T1IL83.js → sql-B96movIM.js} +5 -1
  282. package/dist/sql-CDEPCA0g.d.ts +10 -0
  283. package/dist/{src-DLfUWFAI.js → src-DLFrtrrC.js} +154 -53
  284. package/dist/ssf/index.d.ts +4 -4
  285. package/dist/ssf/index.js +640 -211
  286. package/dist/{standard-webhooks-6lg8v_is.d.ts → standard-webhooks-B_qRg0ws.d.ts} +2 -2
  287. package/dist/{store-4u_hxjWa.js → store-DSP9oGvh.js} +15 -7
  288. package/dist/{store-options-BqEakYRa.js → store-options-B_poyYvg.js} +6 -6
  289. package/dist/{stores-ClZUgeoP.d.ts → stores-BAZwSYZA.d.ts} +6 -5
  290. package/dist/{stores-BbsD4fAR.js → stores-BqEuHC05.js} +2 -2
  291. package/dist/{stream-7-xmnf3U.d.ts → stream-2Vwhl7Er.d.ts} +2 -2
  292. package/dist/{style-Uao76wO7.js → style-Cqo7-73x.js} +1 -0
  293. package/dist/{style-DiKWnAvn.js → style-Ds1gNm77.js} +1 -1
  294. package/dist/supabase/index.d.ts +8 -7
  295. package/dist/supabase/index.js +5 -5
  296. package/dist/supabase/middleware.d.ts +6 -6
  297. package/dist/supabase/middleware.js +6 -6
  298. package/dist/{supabase-Dxlp8A94.js → supabase--0aJdLEd.js} +9 -7
  299. package/dist/{supabase-9pqI57pW.js → supabase-Bqp7iIY2.js} +3 -3
  300. package/dist/supabase-hook-DJxC4PoP.js +1 -0
  301. package/dist/{supabase-hook-DdDQhMlw.js → supabase-hook-Dg0SrQWR.js} +1069 -123
  302. package/dist/supautils-MESd81bF.js +689 -0
  303. package/dist/{support-session-C6xJQYut.js → support-session-Cna49-3f.js} +2 -2
  304. package/dist/svelte/index.d.ts +5 -5
  305. package/dist/svelte/index.js +5 -5
  306. package/dist/svelte/runtime.d.ts +3 -3
  307. package/dist/svelte/runtime.js +3 -3
  308. package/dist/svelte/source.d.ts +5 -5
  309. package/dist/svelte/source.js +2 -2
  310. package/dist/sveltekit/index.d.ts +6 -6
  311. package/dist/sveltekit/index.js +2 -2
  312. package/dist/tanstack-start/index.d.ts +6 -6
  313. package/dist/tanstack-start/index.js +2 -2
  314. package/dist/{tenancy-DN2QAx9z.js → tenancy-CmeBQ6Nv.js} +12 -3
  315. package/dist/terminal/index.d.ts +6 -6
  316. package/dist/terminal/index.js +3 -3
  317. package/dist/testing/core.d.ts +2 -0
  318. package/dist/testing/core.js +1 -0
  319. package/dist/testing/fixtures/index.d.ts +12 -0
  320. package/dist/testing/fixtures/index.js +770 -0
  321. package/dist/testing/index.d.ts +41 -174
  322. package/dist/testing/index.js +241 -731
  323. package/dist/testing/saas/index.d.ts +5 -5
  324. package/dist/testing/saas/index.js +2 -2
  325. package/dist/testing/saas/permissions.d.ts +1 -1
  326. package/dist/testing/saas/permissions.js +1 -1
  327. package/dist/text-diff-DhITNCnD.js +444 -0
  328. package/dist/{token-BLZcjQgt.js → token-2qYDvXz2.js} +1 -1
  329. package/dist/{tool-refusal-CMLN5xhn.js → tool-refusal-reAhIzdl.js} +1 -1
  330. package/dist/trpc/index.d.ts +6 -6
  331. package/dist/trpc/index.js +3 -3
  332. package/dist/{types-5Ut_pbKy.d.ts → types-BWZybqs9.d.ts} +112 -8
  333. package/dist/{types-CZTnPFzM.d.ts → types-BXMYB7BY.d.ts} +2 -2
  334. package/dist/{types-CdlxsYQh.d.ts → types-BfVmNJO5.d.ts} +14 -3
  335. package/dist/{types-BqAKcRNW.d.ts → types-CIeZcG8v.d.ts} +2 -2
  336. package/dist/{types-BNORmDM8.d.ts → types-CKV71kKh.d.ts} +4 -4
  337. package/dist/{types-tIgIeIKJ.d.ts → types-CQVEQawv.d.ts} +4 -4
  338. package/dist/{types-BjV-fqcA.d.ts → types-CeIscIKp.d.ts} +4 -4
  339. package/dist/{types-_sHy8BQf.d.ts → types-CfiKC5aN.d.ts} +1 -1
  340. package/dist/{types-ClZaWzfb.d.ts → types-D3A19w4A.d.ts} +8 -4
  341. package/dist/{types-C8-KNsFf.d.ts → types-DBHftjgS.d.ts} +15 -8
  342. package/dist/{types-CCpc2Dcv.d.ts → types-DC44-hqb.d.ts} +2 -2
  343. package/dist/{types-fbZ7JEXr.d.ts → types-DXmwu78Z.d.ts} +4 -4
  344. package/dist/{types-DiQjupG2.d.ts → types-cTos-H7p.d.ts} +2 -2
  345. package/dist/{types-DX0LaKTb.d.ts → types-g7Oy6Zg4.d.ts} +3 -3
  346. package/dist/unplugin/index.d.ts +1 -1
  347. package/dist/unplugin/index.js +2 -2
  348. package/dist/{usage-T5mXq98v.js → usage-Bybmck7f.js} +7 -7
  349. package/dist/{usage-DHtE8j4E.js → usage-CDaLXCfL.js} +2 -2
  350. package/dist/usage-y-kBNdqs.js +1 -0
  351. package/dist/{validation-error-Bg5Tpluk.d.ts → validation-error-CQpoquwj.d.ts} +1 -1
  352. package/dist/{verify-DbCL9bnB.js → verify-pe8rL_Qw.js} +350 -83
  353. package/dist/{views-DTIIO-oQ.js → views-BB8E9dTP.js} +1 -1
  354. package/dist/vocabulary-Bg3eeT2H.d.ts +67 -0
  355. package/dist/vue/index.d.ts +6 -5
  356. package/dist/vue/index.js +4 -4
  357. package/dist/{waiter-CKy4MQSY.d.ts → waiter-DwEyouan.d.ts} +2 -2
  358. package/dist/watch-Bd9mx3rd.js +1 -0
  359. package/dist/{watch-CszoTJMq.js → watch-C-4VmFlL.js} +3 -3
  360. package/dist/webmcp/index.d.ts +2 -2
  361. package/dist/webmcp/index.js +2 -2
  362. package/dist/{where-scope-Dv-WTP-w.js → where-scope-wq2coopv.js} +41 -21
  363. package/dist/{who-can-D0QoYbEb.js → who-can-BwNsI_Js.js} +6 -6
  364. package/dist/workflow/index.d.ts +3 -3
  365. package/dist/workflow/index.js +5 -5
  366. package/dist/workflow-sdk/index.d.ts +5 -5
  367. package/dist/{wrap-Bd1Tejur.d.ts → wrap-B6R0cMRL.d.ts} +2 -2
  368. package/dist/{wrap-gX61EtpB.js → wrap-CiSc_KCq.js} +8 -2
  369. package/package.json +22 -11
  370. package/schemas/cli-result-v1.json +1 -0
  371. package/schemas/cloud-api-v1.json +152 -7
  372. package/schemas/connector-v1.json +8 -0
  373. package/schemas/supabase-claims-v1.json +5 -0
  374. package/schemas/supabase-manifest-v1.json +276 -75
  375. package/skills/permdock/SKILL.md +1 -0
  376. package/skills/permdock-agents/references/runtimes.md +3 -1
  377. package/skills/permdock-approvals/references/stores-and-resume.md +2 -1
  378. package/skills/permdock-data/references/queries-and-rls.md +5 -2
  379. package/skills/permdock-tenancy/SKILL.md +1 -1
  380. package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +2 -0
  381. package/skills/permdock-wire/references/adapters.md +14 -0
  382. package/skills/permdock-wire/references/policy-options.md +1 -0
  383. package/dist/catalog-DjhMYYCq.js +0 -1
  384. package/dist/collect-FlXJlhus.js +0 -1
  385. package/dist/index-D8EovfsP.d.ts +0 -91
  386. package/dist/load-BxEtwlBS.js +0 -1
  387. package/dist/output-files-B3IZJksC.js +0 -1
  388. package/dist/parse-Cxq0WxrA.js +0 -53
  389. package/dist/peer-C8swJ2GH.js +0 -50
  390. package/dist/powersync-CbzkqL_M.js +0 -1
  391. package/dist/powersync-bYUDLOAO.js +0 -538
  392. package/dist/prompt-DfZLJLIn.js +0 -32
  393. package/dist/pull-ByOks_Tg.js +0 -1
  394. package/dist/run-CNsApDpd.js +0 -1
  395. package/dist/supabase-hook-CXcGpHqq.js +0 -1
  396. package/dist/supautils-B6HYL9eL.js +0 -91
  397. package/dist/text-diff-BQcUapOb.js +0 -15
  398. package/dist/usage-CHOtwL19.js +0 -1
  399. package/dist/watch-Cb-NcaUw.js +0 -1
@@ -1,17 +1,19 @@
1
1
  import { a as sole, n as isReadonlyArray, r as isRecord } from "./compact-CAf557ZB.js";
2
2
  import { n as isForbiddenKey } from "./paths-DXSHAMIa.js";
3
- import { l as isSqlFunctionField } from "./refs-K6eWUJ0H.js";
3
+ import { u as isSqlFunctionField } from "./refs-CNJBiTFo.js";
4
+ import { c as listPermissions, h as formerKeys } from "./permission-registry-DpPtSKNZ.js";
4
5
  import { u as isoAt } from "./time-DkWjdL98.js";
5
- import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-CUPSjo9q.js";
6
+ import { f as scopeList, l as rootScope, s as resolveScope, u as scopeChain } from "./scopes-BHsc-wp-.js";
6
7
  import { t as isoInstant } from "./instant-BluqDvyS.js";
7
- import { m as findRole, v as listRoles } from "./grantee-Cn1HyUsI.js";
8
- import { r as relationCondition, t as inheritCondition } from "./grantee-conditions-CcXf8hMM.js";
9
- import { l as partitionsOf, p as scopeField } from "./tenancy-DN2QAx9z.js";
10
- import { i as configError } from "./errors-CWTY6uih.js";
11
- import { i as scopeMembershipTable, o as relatedSql, r as scopeColumn } from "./compile-CMqMx_IN.js";
12
- import { i as qualifySqlTable, t as SQL_IDENT, u as sqlTextArray } from "./sql-B5T1IL83.js";
13
- import { d as keptKeys, l as roleColumn, u as roleManifest } from "./sources-vs5dGOsS.js";
14
- import { A as declaredArray, D as sqlType, E as scopeTypeOf, F as quoteQualified, I as quoteTable, L as textArrayLiteral, M as qualified, N as quoteIdent, O as tenantTypeOf, P as quoteLiteral, S as CLAIM, _ as subjectClaimJsonSql, b as tenantClaimSql, d as keyTenantSql, f as kindFilterSql, h as roleKindSql, i as allowEntry, j as helperSchema, k as CLI, m as memberVia, n as GLOBAL_ROLES, o as rootName, p as liveSessionSql, r as HELPERS, s as signedIn, t as CUSTOM_ROLES, u as globalKindFilterSql, v as subjectClaimSql, x as userIdHelperSql, y as subjectIdSql } from "./shared-CD11nBbC.js";
8
+ import { m as findRole, v as listRoles } from "./grantee-BrRANrpT.js";
9
+ import { r as relationCondition, t as inheritCondition } from "./grantee-conditions-C6P1Tvol.js";
10
+ import { l as partitionsOf, p as scopeField } from "./tenancy-CmeBQ6Nv.js";
11
+ import { a as delegatedScopeMap } from "./permdock-BfWEeMqk.js";
12
+ import { i as configError } from "./errors-DpP_U6Bf.js";
13
+ import { d as sqlTextArray, i as qualifySqlTable, t as SQL_IDENT } from "./sql-B96movIM.js";
14
+ import { i as scopeMembershipTable, o as relatedSql, r as scopeColumn } from "./compile-BAEO5bOk.js";
15
+ import { d as keptKeys, l as roleColumn, u as roleManifest } from "./sources-B-zYB4GS.js";
16
+ import { A as quoteQualified, D as qualified, E as helperSchema, F as scopeTypeOf, I as sqlType, L as tenantTypeOf, M as textArrayLiteral, O as quoteIdent, S as CLAIM, T as declaredArray, _ as subjectClaimJsonSql, b as tenantClaimSql, d as keyTenantSql, f as kindFilterSql, h as roleKindSql, i as allowEntry, j as quoteTable, k as quoteLiteral, 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, w as CLI, x as userIdHelperSql, y as subjectIdSql } from "./shared-fMKOv8hP.js";
15
17
  //#region src/cli/sql/active.ts
16
18
  function checkActiveRow(label, row) {
17
19
  quoteTable(row.table);
@@ -699,6 +701,10 @@ function collectGrants(policy) {
699
701
  };
700
702
  });
701
703
  }
704
+ /** The declared roles a custom role may draw grants from: the custom-role ceiling. */
705
+ function assignableRoles(policy) {
706
+ return [...roleNames(policy)].toSorted().filter((name) => policy.rolesByName.get(name)?.assignable ?? listRoles(policy.vocabulary.roles).some((leaf) => leaf.key === name && leaf.assignable));
707
+ }
702
708
  /** Declared role names (bindings and `defineRoles` leaves) plus any role a top-level grant names. */
703
709
  function roleNames(policy) {
704
710
  const names = new Set(policy.roles.map((item) => item.name));
@@ -840,909 +846,1105 @@ function byKey([a], [b]) {
840
846
  return byCodeUnit(a, b);
841
847
  }
842
848
  //#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)`;
849
+ //#region src/cli/rls/custom-roles.ts
850
+ /** The readers every helpers file ends with. Names are part of the SQL contract. */
851
+ const READERS = {
852
+ keys: "permdock_permission_keys",
853
+ roles: "permdock_role_permissions",
854
+ trusted: "permdock_trusted_role_permissions"
855
+ };
856
+ /**
857
+ * The custom-role objects: the tables (`database` mode), the ceiling view
858
+ * over the `assignable` declared roles, and `permdock_custom_keys`, which
859
+ * resolves one custom role to the grant keys it holds in a scope. A row in
860
+ * the tables can never reach a key outside the view.
861
+ */
862
+ function customRolesSql(ctx) {
863
+ const custom = ctx.customRoles;
864
+ if (custom === void 0) return "";
865
+ const rp = qualified(ctx, "role_permissions");
866
+ const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
867
+ const keys = qualified(ctx, CUSTOM_ROLES.keys);
868
+ const chunks = [];
869
+ if (ctx.authorize === "database") {
870
+ const tenantType = tenantTypeOf(ctx);
871
+ for (const [name, column, check] of [[
872
+ CUSTOM_ROLES.permissions,
873
+ "permission",
874
+ `,
875
+ effect text not null default 'allow' check (effect in ('allow', 'deny'))`
876
+ ], [
877
+ CUSTOM_ROLES.includes,
878
+ "include_role",
879
+ ""
880
+ ]]) {
881
+ const table = qualified(ctx, name);
882
+ const leveled = custom.levels === true && name === CUSTOM_ROLES.permissions;
883
+ const unique = `${column}${name === CUSTOM_ROLES.permissions ? ", effect" : ""}${leveled ? ", coalesce(level, '')" : ""}`;
884
+ const index = leveled ? `drop index if exists ${qualified(ctx, quoteIdent(`${name}_key`))};
885
+ alter table ${table} add column if not exists level text check (level ~ '^[a-z][a-z0-9_]*$');
886
+ create unique index if not exists ${quoteIdent(`${name}_level_key`)}` : `create unique index if not exists ${quoteIdent(`${name}_key`)}`;
887
+ chunks.push(`create table if not exists ${table} (
888
+ tenant_id ${tenantType},
889
+ scope text not null default ${quoteLiteral(rootName(ctx))},
890
+ scope_id text,
891
+ role text not null,
892
+ ${column} text not null${check}
893
+ );
894
+ -- global: no tenant or instance; a tenant role: a tenant; a shared role: no tenant or instance
895
+ alter table ${table} drop constraint if exists ${quoteIdent(`${name}_check`)};
896
+ alter table ${table} drop constraint if exists ${quoteIdent(`${name}_shape`)};
897
+ alter table ${table} add constraint ${quoteIdent(`${name}_shape`)} check (
898
+ case when scope = 'global' then tenant_id is null and scope_id is null
899
+ else tenant_id is not null or scope_id is null end
900
+ );
901
+ ${index}
902
+ on ${table} (coalesce(tenant_id::text, ''), scope, coalesce(scope_id, ''), role, ${unique});
903
+ -- a row identity for triggers the application adds, such as an audit trigger that reads the primary key
904
+ alter table ${table} add column if not exists id bigint generated always as identity primary key;
905
+ alter table ${table} enable row level security;
906
+ revoke all on table ${table} from anon, authenticated, public;`);
922
907
  }
923
- default: return ctx.dialect;
924
908
  }
909
+ chunks.push(`-- the ceiling: keys of assignable declared roles, and the denies of those roles on the same permissions
910
+ create or replace view ${ceiling} with (security_invoker = true) as
911
+ select rp.scope, rp.role, rp.permission, rp.grant_key, rp.effect
912
+ from ${rp} rp
913
+ where rp.role = any(${sqlTextArray(custom.assignable)})
914
+ and exists (
915
+ select 1 from ${rp} a
916
+ where a.role = rp.role
917
+ and a.permission = rp.permission
918
+ and a.scope = rp.scope
919
+ and a.effect = 'allow'
920
+ );
921
+ revoke all on table ${ceiling} from anon, authenticated, public;`);
922
+ const renames = Object.entries(custom.renamed ?? {}).toSorted(byKey);
923
+ const shared = sharedRoleSql(ctx);
924
+ if (custom.levels === true) {
925
+ chunks.push(leveledKeysSql(ctx, renames));
926
+ if (shared !== "") chunks.push(shared);
927
+ return chunks.join("\n\n");
928
+ }
929
+ const rename = renamesSql(renames);
930
+ chunks.push(`-- one custom role: included ceiling keys and same-scope denies, plus every ceiling key of an allowed permission, minus denied permissions
931
+ create or replace function ${keys}(p_allow text[], p_deny text[], p_include text[], p_scope text)
932
+ returns setof text
933
+ language sql
934
+ stable
935
+ set search_path = ''
936
+ as $$
937
+ with ${rename.prelude}ceiling as (
938
+ select c.role, ${rename.permission("c")}, c.grant_key, c.effect
939
+ from ${ceiling} c${rename.join("c")}
940
+ where c.scope = p_scope
941
+ ),
942
+ included as (
943
+ select rp.role, ${rename.permission("rp")}, rp.grant_key, rp.scope, rp.effect
944
+ from ${rp} rp${rename.join("rp")}
945
+ where rp.role = any(coalesce(p_include, '{}'::text[]))
946
+ ),
947
+ kept as (
948
+ select i.permission, i.grant_key
949
+ from included i
950
+ where i.effect = 'allow'
951
+ and i.scope = p_scope
952
+ and exists (
953
+ select 1 from ceiling c
954
+ where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
955
+ )
956
+ ),
957
+ wanted as (
958
+ ${rename.allow}
959
+ union
960
+ select i.permission
961
+ from included i
962
+ where i.effect = 'allow'
963
+ and not (i.scope = p_scope and exists (
964
+ select 1 from ceiling c
965
+ where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
966
+ ))
967
+ ),
968
+ allowed as (
969
+ select w.permission
970
+ from wanted w
971
+ where not (${rename.denied("w")})
972
+ and not exists (select 1 from kept k where k.permission = w.permission)
973
+ )
974
+ select k.grant_key from kept k
975
+ where not (${rename.denied("k")})
976
+ union
977
+ select i.grant_key from included i
978
+ where i.effect = 'deny' and i.scope = p_scope
979
+ union
980
+ select c.grant_key from ceiling c
981
+ where c.permission in (select a.permission from allowed a)
982
+ $$;
983
+ revoke execute on function ${keys}(text[], text[], text[], text) from public, anon, authenticated;`);
984
+ if (shared !== "") chunks.push(shared);
985
+ return chunks.join("\n\n");
925
986
  }
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)`;
987
+ /** `permdock_is_shared_role(p_scope, p_role)` (`database` mode): whether a shared custom role of that name exists at the scope. */
988
+ function sharedRoleSql(ctx) {
989
+ if (ctx.authorize !== "database") return "";
990
+ const perms = qualified(ctx, CUSTOM_ROLES.permissions);
991
+ const includes = qualified(ctx, CUSTOM_ROLES.includes);
992
+ const shared = qualified(ctx, CUSTOM_ROLES.shared);
993
+ 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
994
+ create or replace function ${shared}(p_scope text, p_role text)
995
+ returns boolean
996
+ language sql
997
+ stable
998
+ security definer
999
+ set search_path = ''
1000
+ as $$
1001
+ select p_scope <> 'global' and (
1002
+ exists (select 1 from ${perms} c where c.tenant_id is null and c.scope = p_scope and c.role = p_role)
1003
+ or exists (select 1 from ${includes} c where c.tenant_id is null and c.scope = p_scope and c.role = p_role)
1004
+ )
1005
+ $$;
1006
+ revoke execute on function ${shared}(text, text) from public, anon, authenticated;`;
954
1007
  }
955
- //#endregion
956
- //#region src/cli/rls/helper-bodies.ts
957
- function hasSetBody(ctx) {
1008
+ /**
1009
+ * `permdock_custom_keys` when resources declare levels. An allow entry is
1010
+ * `key` or `key@level`. A level reaches only the ceiling's `<grant key>@<level>`
1011
+ * keys, plus the ceiling denies of that permission; a level no ceiling key
1012
+ * carries removes the permission, as `resolveCustomRole` drops it with
1013
+ * `unknown-level`.
1014
+ */
1015
+ function leveledKeysSql(ctx, renames) {
958
1016
  const rp = qualified(ctx, "role_permissions");
959
- const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
960
- 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}
1017
+ const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
1018
+ const keys = qualified(ctx, CUSTOM_ROLES.keys);
1019
+ const renamed = renames.length > 0;
1020
+ const prelude = renamed ? `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
1021
+ ` : "";
1022
+ const current = (raw, alias) => renamed ? `coalesce(r_${alias}.key, ${raw})` : raw;
1023
+ const join = (raw, alias) => renamed ? ` left join renamed r_${alias} on r_${alias}.former = ${raw}` : "";
1024
+ return `-- one custom role: as above, where an allow entry key@level reaches only the ceiling keys of that level
1025
+ create or replace function ${keys}(p_allow text[], p_deny text[], p_include text[], p_scope text)
1026
+ returns setof text
1027
+ language sql
1028
+ stable
1029
+ set search_path = ''
1030
+ as $$
1031
+ with ${prelude}entries as (
1032
+ select ${current("split_part(e.entry, '@', 1)", "e")} as permission, nullif(split_part(e.entry, '@', 2), '') as level
1033
+ from unnest(coalesce(p_allow, '{}'::text[])) as e(entry)${join("split_part(e.entry, '@', 1)", "e")}
1034
+ ),
1035
+ ceiling as (
1036
+ select c.role, ${current("c.permission", "c")} as permission, c.grant_key, c.effect
1037
+ from ${ceiling} c${join("c.permission", "c")}
1038
+ where c.scope = p_scope
1039
+ ),
1040
+ levels as (
1041
+ select distinct c.permission, split_part(c.grant_key, '@', 2) as level
1042
+ from ceiling c
1043
+ where c.effect = 'allow' and strpos(c.grant_key, '@') > 0
1044
+ ),
1045
+ denied as (
1046
+ select ${current("d.permission", "d")} as permission
1047
+ from unnest(coalesce(p_deny, '{}'::text[])) as d(permission)${join("d.permission", "d")}
1048
+ union
1049
+ select e.permission from entries e
1050
+ where e.level is not null
1051
+ and not exists (select 1 from levels l where l.permission = e.permission and l.level = e.level)
1052
+ ),
1053
+ included as (
1054
+ select rp.role, ${current("rp.permission", "rp")} as permission, rp.grant_key, rp.scope, rp.effect
1055
+ from ${rp} rp${join("rp.permission", "rp")}
1056
+ where rp.role = any(coalesce(p_include, '{}'::text[]))
1057
+ ),
1058
+ kept as (
1059
+ select i.permission, i.grant_key
1060
+ from included i
1061
+ where i.effect = 'allow'
1062
+ and i.scope = p_scope
1063
+ and exists (
1064
+ select 1 from ceiling c
1065
+ where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
1066
+ )
1067
+ ),
1068
+ wanted as (
1069
+ select e.permission from entries e where e.level is null
1070
+ union
1071
+ select i.permission
1072
+ from included i
1073
+ where i.effect = 'allow'
1074
+ and not (i.scope = p_scope and exists (
1075
+ select 1 from ceiling c
1076
+ where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
1077
+ ))
1078
+ ),
1079
+ allowed as (
1080
+ select w.permission
1081
+ from wanted w
1082
+ where w.permission not in (select d.permission from denied d)
1083
+ and not exists (select 1 from kept k where k.permission = w.permission)
1084
+ ),
1085
+ allowed_levels as (
1086
+ select e.permission, e.level
1087
+ from entries e
1088
+ where e.level is not null
1089
+ and e.permission not in (select d.permission from denied d)
1090
+ and e.permission not in (select a.permission from allowed a)
1091
+ and not exists (select 1 from kept k where k.permission = e.permission)
1092
+ )
1093
+ select k.grant_key from kept k
1094
+ where k.permission not in (select d.permission from denied d)
972
1095
  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}
1096
+ select i.grant_key from included i
1097
+ where i.effect = 'deny' and i.scope = p_scope
987
1098
  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}`;
1099
+ select c.grant_key from ceiling c
1100
+ where c.permission in (select a.permission from allowed a)
1101
+ union
1102
+ select c.grant_key from ceiling c
1103
+ join allowed_levels a on a.permission = c.permission
1104
+ where c.effect = 'deny' or split_part(c.grant_key, '@', 2) = a.level
1105
+ $$;
1106
+ revoke execute on function ${keys}(text[], text[], text[], text) from public, anon, authenticated;`;
995
1107
  }
996
- function hasBody(ctx) {
997
- if (ctx.grantSet === true) return hasSetBody(ctx);
1108
+ function currentKeysSql(array) {
1109
+ return `select coalesce(r.key, e.permission) as permission
1110
+ from unnest(coalesce(${array}, '{}'::text[])) as e(permission)
1111
+ left join renamed r on r.former = e.permission`;
1112
+ }
1113
+ /**
1114
+ * SQL fragments that read a stored former key as its current key inside
1115
+ * `permdock_custom_keys`. Without renames they are the plain column and
1116
+ * array reads.
1117
+ */
1118
+ function renamesSql(renames) {
1119
+ if (renames.length === 0) return {
1120
+ prelude: "",
1121
+ permission: (alias) => `${alias}.permission`,
1122
+ join: () => "",
1123
+ allow: "select unnest(coalesce(p_allow, '{}'::text[])) as permission",
1124
+ denied: (alias) => `${alias}.permission = any(coalesce(p_deny, '{}'::text[]))`
1125
+ };
1126
+ return {
1127
+ prelude: `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
1128
+ denied as (
1129
+ ${currentKeysSql("p_deny")}
1130
+ ),
1131
+ `,
1132
+ permission: (alias) => `coalesce(r_${alias}.key, ${alias}.permission) as permission`,
1133
+ join: (alias) => ` left join renamed r_${alias} on r_${alias}.former = ${alias}.permission`,
1134
+ allow: currentKeysSql("p_allow"),
1135
+ denied: (alias) => `${alias}.permission in (select d.permission from denied d)`
1136
+ };
1137
+ }
1138
+ /**
1139
+ * The catalog reader `permdock_permission_keys()` and the role reader
1140
+ * `permdock_role_permissions(p_role, p_scope, p_tenant, p_scope_id)`, both
1141
+ * for `authenticated`. A declared role answers from `role_permissions`, which
1142
+ * is the catalog. A custom role (`database` mode) answers from the tenant's
1143
+ * own rows through `permdock_custom_keys`, only to a member of the tenant or
1144
+ * a holder of a `meta.manageRoles` permission for a platform role; a shared
1145
+ * role (no tenant below global) to any signed-in caller. The
1146
+ * effect is `deny` for a deny the role carries after the ceiling, as
1147
+ * `resolveCustomRole` returns it, never for a deny that merely removes an allow.
1148
+ */
1149
+ function roleReadersSql(ctx, permissions, trustedReaders) {
998
1150
  const rp = qualified(ctx, "role_permissions");
999
- const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
1151
+ const reader = qualified(ctx, READERS.roles);
1152
+ const tenantType = tenantTypeOf(ctx);
1000
1153
  const custom = ctx.customRoles;
1001
- 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
- )`;
1154
+ const root = ctx.scopes[0]?.name;
1155
+ const database = ctx.authorize === "database";
1156
+ const chunks = [];
1157
+ if (permissions !== void 0) {
1158
+ const catalog = qualified(ctx, READERS.keys);
1159
+ chunks.push(`-- every declared permission key
1160
+ create or replace function ${catalog}()
1161
+ returns setof text
1162
+ language sql
1163
+ stable
1164
+ set search_path = ''
1165
+ as $$
1166
+ select k.permission from unnest(${sqlTextArray(permissions.toSorted())}) k(permission) order by 1
1167
+ $$;
1168
+ revoke execute on function ${catalog}() from public, anon;
1169
+ grant execute on function ${catalog}() to authenticated;
1170
+
1171
+ create or replace function ${catalog}(p_scope text)
1172
+ returns setof text
1173
+ language sql
1174
+ stable
1175
+ security definer
1176
+ set search_path = ''
1177
+ as $$
1178
+ select distinct rp.permission
1179
+ from ${rp} rp
1180
+ where rp.scope = p_scope
1181
+ and rp.effect = 'allow'
1182
+ and rp.permission = any(${sqlTextArray(permissions.toSorted())})
1183
+ order by 1
1184
+ $$;
1185
+ revoke execute on function ${catalog}(text) from public, anon;
1186
+ grant execute on function ${catalog}(text) to authenticated;`);
1022
1187
  }
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())`);
1076
- }
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}
1163
- union
1164
- select (m ->> 'id')::${type}, ck.grant_key
1165
- from ${membershipRows(ctx)}
1166
- cross join lateral (select m -> 'grants' -> r.role as g) cg
1167
- ${customKeysJoin(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
1168
- where ${signedIn(ctx)}
1169
- and jsonb_typeof(cg.g) = 'array'
1170
- and not (r.role = any(${sqlTextArray(custom.declared)}))
1171
- ${filtersFor("ck.grant_key")}`;
1172
- return `${declared}
1173
- union
1174
- select (m ->> 'id')::${type}
1175
- from ${membershipRows(ctx)}
1176
- cross join lateral (select m -> 'grants' -> r.role as g) cg
1177
- where ${signedIn(ctx)}
1178
- and jsonb_typeof(cg.g) = 'array'
1179
- and not (r.role = any(${sqlTextArray(custom.declared)}))
1180
- ${filters}
1181
- ${customKeysSql(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}`;
1182
- }
1183
- /**
1184
- * The rows the membership sources select for the subject, one per instance
1185
- * (`scope`, `id`, `within`, `roles`, `via`), with each source's own expiry
1186
- * and suspension filters: the statements the token hook runs.
1187
- */
1188
- function sourcesKeep(sources) {
1189
- return sources.some((source) => source.sql.keeps);
1190
- }
1191
- function sourceRows(sources) {
1192
- const keep = sourcesKeep(sources);
1193
- return sources.map((source, index) => source.sql.select(sourceUser(index), keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
1194
- }
1195
- function sourceUser(index) {
1196
- return `v_user_${String(index)}`;
1197
- }
1198
- function sqlBody(sql) {
1199
- return {
1200
- sql,
1201
- vars: []
1202
- };
1203
- }
1204
- function sourcesBodyOf(sql, sources, user) {
1205
- return {
1206
- sql,
1207
- vars: sources.map((source, index) => `${sourceUser(index)} ${source.sql.userType} := ${user};`)
1208
- };
1209
- }
1210
- /** `language sql`, or `plpgsql` returning the query when the body declares variables. */
1211
- function functionBody(body) {
1212
- if (body.vars.length === 0) return `language sql
1188
+ const declared = ` select distinct rp.permission, rp.effect
1189
+ from ${rp} rp
1190
+ where rp.role = p_role and rp.scope = p_scope
1191
+ order by 1, 2`;
1192
+ let body = `begin
1193
+ perform p_tenant, p_scope_id; -- only a custom role reads them
1194
+ return query
1195
+ ${declared};
1196
+ end;`;
1197
+ let trustedBody = body;
1198
+ if (custom !== void 0 && database && root !== void 0) {
1199
+ const tenant = qualified(ctx, CUSTOM_ROLES.permissions);
1200
+ const includes = qualified(ctx, CUSTOM_ROLES.includes);
1201
+ const keys = qualified(ctx, CUSTOM_ROLES.keys);
1202
+ const shape = qualified(ctx, CUSTOM_ROLES.shape);
1203
+ const ids = (helper) => `(select x::text from ${qualified(ctx, helper)}() x)`;
1204
+ const memberOf = `p_tenant::text in ${ids(memberIdsHelper(root))}
1205
+ or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${ids(memberIdsHelper(scope))}`)}`;
1206
+ const manage = custom.manage ?? [];
1207
+ const platform = manage.length === 0 ? "false" : `exists (
1208
+ select 1 from ${rp} m
1209
+ where m.effect = 'allow'
1210
+ and m.permission = any(${sqlTextArray(manage)})
1211
+ and ${qualified(ctx, HELPERS.has)}(m.grant_key)
1212
+ )`;
1213
+ 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`;
1214
+ const allowEntryOf = `c.${allowEntry(ctx)}`;
1215
+ const checked = (gate) => `begin
1216
+ if p_role = any(${sqlTextArray(custom.declared)}) then
1217
+ return query
1218
+ ${declared};
1219
+ return;
1220
+ end if;
1221
+ perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);${gate}
1222
+ return query
1223
+ select distinct rp.permission, rp.effect
1224
+ from ${keys}(
1225
+ array(select ${allowEntryOf} from ${tenant} c where ${match} and c.effect = 'allow'),
1226
+ array(select c.permission from ${tenant} c where ${match} and c.effect = 'deny'),
1227
+ array(select c.include_role from ${includes} c where ${match}),
1228
+ p_scope
1229
+ ) k(grant_key)
1230
+ join ${rp} rp on rp.grant_key = k.grant_key and rp.scope = p_scope
1231
+ order by 1, 2;
1232
+ end;`;
1233
+ body = checked(`
1234
+ if not (${signedIn(ctx)} and (
1235
+ ${platform}
1236
+ or (p_scope <> 'global' and p_tenant is null and p_scope_id is null)
1237
+ or (p_scope <> 'global' and (${memberOf}))
1238
+ )) then
1239
+ ${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
1240
+ end if;`);
1241
+ trustedBody = checked("");
1242
+ }
1243
+ 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
1244
+ create or replace function ${reader}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
1245
+ returns table (permission text, effect text)
1246
+ language plpgsql
1213
1247
  stable
1214
1248
  security definer
1215
1249
  set search_path = ''
1216
1250
  as $$
1217
- ${body.sql}
1218
- $$;`;
1219
- return `language plpgsql
1251
+ ${body}
1252
+ $$;
1253
+ revoke execute on function ${reader}(text, text, ${tenantType}, text) from public, anon;
1254
+ grant execute on function ${reader}(text, text, ${tenantType}, text) to authenticated;`);
1255
+ const trusted = qualified(ctx, READERS.trusted);
1256
+ const signature = `text, text, ${tenantType}, text`;
1257
+ const grants = trustedReaders === void 0 || trustedReaders.length === 0 ? "" : `\n${trustedReaderGrants(ctx, `${trusted}(${signature})`, trustedReaders)}`;
1258
+ chunks.push(`create or replace function ${trusted}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
1259
+ returns table (permission text, effect text)
1260
+ language plpgsql
1220
1261
  stable
1221
1262
  security definer
1222
1263
  set search_path = ''
1223
1264
  as $$
1224
- declare
1225
- ${body.vars.map((line) => ` ${line}`).join("\n")}
1226
- begin
1227
- return query
1228
- ${body.sql};
1229
- end;
1230
- $$;`;
1265
+ ${trustedBody}
1266
+ $$;
1267
+ revoke execute on function ${trusted}(${signature}) from public, anon, authenticated;${grants}`);
1268
+ return chunks.join("\n\n");
1269
+ }
1270
+ /** The stored definition of `p_role` at `p_scope` for `p_tenant`: the shared rows when a shared role has the name. */
1271
+ function storedTenant(ctx) {
1272
+ 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
1273
  }
1232
1274
  /**
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`.
1275
+ * The tenant half of a custom-role row match for a membership: the
1276
+ * membership's tenant, or no tenant when a shared role of that name exists at
1277
+ * the scope, so a shared role shadows a tenant role of the same name.
1235
1278
  */
1236
- function sourceIdOf(scope) {
1237
- return (name) => name === scope ? "ms.id" : `ms.within ->> ${quoteLiteral(name)}`;
1279
+ function customTenantMatch(ctx, tenant, scope, role) {
1280
+ 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
1281
  }
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("");
1282
+ /**
1283
+ * The scope of a row of the application's roles table: its scope column (or
1284
+ * the first scope) with a tenant; with no tenant, the same scope when the
1285
+ * `shared` column is true, else `'global'`.
1286
+ */
1287
+ function rolesTableScope(table, tenant, root, column) {
1288
+ const own = table.scope === void 0 ? quoteLiteral(root) : `${column(table.scope)}::text`;
1289
+ 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
1290
  }
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));
1291
+ /** `expr` read as its current key: a former key maps to the key it was renamed to. */
1292
+ function currentKeyExpr(renames, expr) {
1293
+ if (renames.length === 0) return expr;
1294
+ return `(case ${expr} ${renames.map(([former, current]) => `when ${quoteLiteral(former)} then ${quoteLiteral(current)}`).join(" ")} else ${expr} end)`;
1289
1295
  }
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));
1296
+ /** `case p_scope when '<scope>' then <sql for scope> ... else false end` over the declared scopes. */
1297
+ function perScope(ctx, sql) {
1298
+ const arms = ctx.scopes.map((scope) => `when ${quoteLiteral(scope.name)} then ${sql(scope.name)}`).join(" ");
1299
+ return arms === "" ? "false" : `(case p_scope ${arms} else false end)`;
1300
+ }
1301
+ function raiseSql(indent, errcode, message, hint) {
1302
+ return `${indent}raise exception using
1303
+ ${indent} errcode = '${errcode}',
1304
+ ${indent} message = ${message},
1305
+ ${indent} hint = ${quoteLiteral(hint)};`;
1306
+ }
1307
+ function hasCustomRoleChecksFor(ctx) {
1308
+ return ctx.authorize === "database" && ctx.customRoles !== void 0 && ctx.scopes.length > 0 && ctx.scopes.every((scope) => hasMemberFor(ctx, scope.name));
1293
1309
  }
1294
- //#endregion
1295
- //#region src/cli/rls/capabilities.ts
1296
- /** The helper `--capabilities` adds: ids of one resource a link capability claim reaches. Part of the SQL contract. */
1297
- const CAPABILITIES = { ids: "permdock_capability_ids" };
1298
1310
  /**
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.
1311
+ * The grants a `rls.trustedReaders` role needs for one reader: usage on the
1312
+ * helper schema, which no client role reaches through these lines, and
1313
+ * execute on the function.
1303
1314
  */
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}))`;
1315
+ function trustedReaderGrants(ctx, fn, readers) {
1316
+ const to = readers.map(quoteIdent).join(", ");
1317
+ return `grant usage on schema ${quoteIdent(helperSchema(ctx))} to ${to};
1318
+ grant execute on function ${fn} to ${to};`;
1311
1319
  }
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;`;
1320
+ //#endregion
1321
+ //#region src/cli/sql/global-roles.ts
1322
+ function qualify(name, schema) {
1323
+ return name.includes(".") ? name : `${schema}.${name}`;
1324
+ }
1325
+ function throughTenant(through, roles) {
1326
+ if (through === void 0 || roles?.tenant === void 0) return;
1327
+ return qualify(roles.table, "public") === through.table ? roles.tenant : void 0;
1328
+ }
1329
+ /** Resolves `source` against `schema`, aliasing the table `alias` and the roles table `<alias>k`. */
1330
+ function globalRoleSource(source, schema, alias, roles) {
1331
+ const table = qualify(source.table, schema);
1332
+ const user = source.user ?? "user_id";
1333
+ const role = roleColumn(source.role ?? "role", table, alias, {
1334
+ label: "roles.role",
1335
+ prefix: "PermDock CLI",
1336
+ indent: " "
1337
+ });
1338
+ const tenant = role.lateral ? void 0 : throughTenant(role.through, roles);
1339
+ const shared = tenant === void 0 ? void 0 : roles?.shared;
1340
+ const global = tenant === void 0 ? "" : ` and ${alias}k.${quoteIdent(tenant)} is null${shared === void 0 ? "" : ` and ${alias}k.${quoteIdent(shared)} is not true`}`;
1341
+ return {
1342
+ table,
1343
+ user,
1344
+ from: `${quoteTable(table)} ${alias}${role.join}${global}`,
1345
+ userSql: `${alias}.${quoteIdent(user)}`,
1346
+ roleSql: role.sql,
1347
+ nullable: role.nullable,
1348
+ role: roleManifest(role),
1349
+ ...role.through === void 0 ? {} : { through: role.through },
1350
+ ...tenant === void 0 ? {} : { tenant },
1351
+ ...shared === void 0 ? {} : { shared }
1352
+ };
1336
1353
  }
1337
1354
  //#endregion
1338
- //#region src/cli/rls/custom-roles.ts
1339
- /** The readers every helpers file ends with. Names are part of the SQL contract. */
1340
- const READERS = {
1341
- keys: "permdock_permission_keys",
1342
- roles: "permdock_role_permissions",
1343
- trusted: "permdock_trusted_role_permissions"
1344
- };
1345
- /**
1346
- * The custom-role objects: the tables (`database` mode), the ceiling view
1347
- * over the `assignable` declared roles, and `permdock_custom_keys`, which
1348
- * resolves one custom role to the grant keys it holds in a scope. A row in
1349
- * the tables can never reach a key outside the view.
1350
- */
1351
- function customRolesSql(ctx) {
1352
- const custom = ctx.customRoles;
1353
- if (custom === void 0) return "";
1354
- const rp = qualified(ctx, "role_permissions");
1355
- const ceiling = qualified(ctx, CUSTOM_ROLES.ceiling);
1356
- const keys = qualified(ctx, CUSTOM_ROLES.keys);
1357
- const chunks = [];
1358
- if (ctx.authorize === "database") {
1359
- const tenantType = tenantTypeOf(ctx);
1360
- for (const [name, column, check] of [[
1361
- CUSTOM_ROLES.permissions,
1362
- "permission",
1363
- `,
1364
- effect text not null default 'allow' check (effect in ('allow', 'deny'))`
1365
- ], [
1366
- CUSTOM_ROLES.includes,
1367
- "include_role",
1368
- ""
1369
- ]]) {
1370
- const table = qualified(ctx, name);
1371
- const leveled = custom.levels === true && name === CUSTOM_ROLES.permissions;
1372
- const unique = `${column}${name === CUSTOM_ROLES.permissions ? ", effect" : ""}${leveled ? ", coalesce(level, '')" : ""}`;
1373
- const index = leveled ? `drop index if exists ${qualified(ctx, quoteIdent(`${name}_key`))};
1374
- alter table ${table} add column if not exists level text check (level ~ '^[a-z][a-z0-9_]*$');
1375
- create unique index if not exists ${quoteIdent(`${name}_level_key`)}` : `create unique index if not exists ${quoteIdent(`${name}_key`)}`;
1376
- chunks.push(`create table if not exists ${table} (
1377
- tenant_id ${tenantType},
1378
- scope text not null default ${quoteLiteral(rootName(ctx))},
1379
- scope_id text,
1380
- role text not null,
1381
- ${column} text not null${check},
1382
- check ((scope = 'global') = (tenant_id is null)),
1383
- check (scope <> 'global' or scope_id is null)
1384
- );
1385
- ${index}
1386
- on ${table} (coalesce(tenant_id::text, ''), scope, coalesce(scope_id, ''), role, ${unique});
1387
- -- a row identity for triggers the application adds, such as an audit trigger that reads the primary key
1388
- alter table ${table} add column if not exists id bigint generated always as identity primary key;
1389
- alter table ${table} enable row level security;
1390
- revoke all on table ${table} from anon, authenticated, public;`);
1391
- }
1355
+ //#region src/cli/rls/helper-rows.ts
1356
+ function activeTenant(ctx) {
1357
+ return `nullif(${subjectClaimSql(ctx, ctx.tenantClaim)}, '')`;
1358
+ }
1359
+ function keyTenantLine(ctx, scope, tenant) {
1360
+ const keyed = keyTenantSql(ctx, underRoot(ctx, scope) ? tenant : void 0);
1361
+ return keyed === void 0 ? "" : `\n and ${keyed}`;
1362
+ }
1363
+ /** Whether the helper for `scope` narrows to the tenant claim: unless `rls.tenants` is `'all'`, when the first scope is on its chain. */
1364
+ function narrowsTo(ctx, scope) {
1365
+ return ctx.tenants !== "all" && underRoot(ctx, scope);
1366
+ }
1367
+ /** Whether the active tenant narrows memberships of `scope`: the first scope is on its chain. */
1368
+ function underRoot(ctx, scope) {
1369
+ const root = ctx.scopes[0]?.name;
1370
+ let current = ctx.scopes.find((item) => item.name === scope);
1371
+ const seen = /* @__PURE__ */ new Set();
1372
+ while (current !== void 0 && !seen.has(current.name)) {
1373
+ if (current.name === root) return true;
1374
+ seen.add(current.name);
1375
+ const parent = current.within;
1376
+ current = parent === void 0 ? void 0 : ctx.scopes.find((item) => item.name === parent);
1392
1377
  }
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");
1378
+ return false;
1379
+ }
1380
+ /** `and exists (...)` lines for an active user; empty without `rls.suspension.users`. */
1381
+ function userActive(ctx, indent, user) {
1382
+ return activeUserSql(ctx, user).map((part) => `${indent}and ${part}`);
1383
+ }
1384
+ /** `and exists (...)` lines for every suspendable instance on the chain of `scope`'s membership. */
1385
+ function instancesActive(ctx, scope, idOf, indent, permission) {
1386
+ return activeInstancesSql(ctx, scope, idOf, permission).map((part) => `${indent}and ${part}`);
1387
+ }
1388
+ function roleRows(ctx) {
1389
+ const claim = ctx.roleClaim ?? "user_role";
1390
+ switch (ctx.dialect) {
1391
+ case "guc": return `unnest(string_to_array(nullif(current_setting(${quoteLiteral(`${ctx.gucPrefix}.${claim}`)}, true), ''), ',')) r(role)`;
1392
+ case "supabase":
1393
+ case "neon": {
1394
+ const raw = subjectClaimJsonSql(ctx, claim);
1395
+ const value = ctx.dialect === "supabase" ? `coalesce(nullif(${raw}, 'null'::jsonb), (select auth.jwt()) -> 'app_metadata' -> ${quoteLiteral(claim)})` : raw;
1396
+ return `jsonb_array_elements_text(
1397
+ case jsonb_typeof(${value})
1398
+ when 'array' then ${value}
1399
+ when 'string' then jsonb_build_array(${value})
1400
+ else '[]'::jsonb
1401
+ end
1402
+ ) r(role)`;
1403
+ }
1404
+ default: return ctx.dialect;
1410
1405
  }
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")})
1406
+ }
1407
+ function membershipRows(ctx) {
1408
+ const raw = subjectClaimJsonSql(ctx, "memberships");
1409
+ const value = ctx.dialect === "supabase" ? `coalesce(${raw}, (select auth.jwt()) -> 'app_metadata' -> 'memberships')` : raw;
1410
+ return `jsonb_array_elements(
1411
+ case jsonb_typeof(${value}) when 'array' then ${value} else '[]'::jsonb end
1412
+ ) m
1413
+ cross join lateral jsonb_array_elements_text(
1414
+ case jsonb_typeof(m -> 'roles') when 'array' then m -> 'roles' else '[]'::jsonb end
1415
+ ) r(role)`;
1416
+ }
1417
+ function andLine(indent, condition) {
1418
+ return condition === void 0 ? "" : `\n${indent}and ${condition}`;
1419
+ }
1420
+ /** `rls.roles`, or the generated `user_roles (user_id, role)`, aliased `ur`. */
1421
+ function globalRoleRows(ctx) {
1422
+ return ctx.roles === void 0 ? {
1423
+ from: `${qualified(ctx, "user_roles")} ur`,
1424
+ userSql: "ur.user_id",
1425
+ roleSql: "ur.role::text"
1426
+ } : globalRoleSource(ctx.roles, "public", "ur", ctx.customRoles?.table);
1427
+ }
1428
+ function customKeysJoin(ctx, scope, allows, denies, includes) {
1429
+ return ` cross join lateral ${qualified(ctx, CUSTOM_ROLES.keys)}(
1430
+ ${allows},
1431
+ ${denies},
1432
+ ${includes},
1433
+ ${quoteLiteral(scope)}
1434
+ ) ck(grant_key)`;
1435
+ }
1436
+ //#endregion
1437
+ //#region src/cli/rls/helper-bodies.ts
1438
+ function hasSetBody(ctx) {
1439
+ const rp = qualified(ctx, "role_permissions");
1440
+ const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
1441
+ const custom = ctx.customRoles;
1442
+ if (ctx.authorize === "database") {
1443
+ const ur = globalRoleRows(ctx);
1444
+ const declared = ` select rp.grant_key
1445
+ from ${ur.from}
1446
+ join ${rp} rp on rp.role = ${ur.roleSql}
1447
+ where ${ur.userSql} = ${subjectIdSql(ctx)}
1448
+ and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}`;
1449
+ if (custom === void 0) return declared;
1450
+ const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
1451
+ const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1452
+ return `${declared}
1458
1453
  union
1459
- select i.grant_key from included i
1460
- where i.effect = 'deny' and i.scope = p_scope
1454
+ select ck.grant_key
1455
+ from ${ur.from}
1456
+ ${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", ""))}
1457
+ where ${ur.userSql} = ${subjectIdSql(ctx)}
1458
+ and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}`;
1459
+ }
1460
+ const declared = ` select rp.grant_key
1461
+ from ${roleRows(ctx)}
1462
+ join ${rp} rp on rp.role = r.role
1463
+ where ${signedIn(ctx)}
1464
+ and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, "r.role"))}${active}`;
1465
+ if (custom === void 0) return declared;
1466
+ const claim = subjectClaimJsonSql(ctx, GLOBAL_GRANTS_CLAIM);
1467
+ return `${declared}
1461
1468
  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");
1469
+ select ck.grant_key
1470
+ from ${roleRows(ctx)}
1471
+ cross join lateral (select ${claim} -> r.role as g) cg
1472
+ ${customKeysJoin(ctx, "global", claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
1473
+ where ${signedIn(ctx)}
1474
+ and jsonb_typeof(cg.g) = 'array'
1475
+ and not (r.role = any(${sqlTextArray(custom.declared)}))${active}`;
1467
1476
  }
1468
- /**
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`.
1474
- */
1475
- function leveledKeysSql(ctx, renames) {
1477
+ function hasBody(ctx) {
1478
+ if (ctx.grantSet === true) return hasSetBody(ctx);
1476
1479
  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
1480
+ const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
1481
+ const custom = ctx.customRoles;
1482
+ if (ctx.authorize === "database") {
1483
+ const ur = globalRoleRows(ctx);
1484
+ const declared = ` select exists (
1485
+ select 1
1486
+ from ${ur.from}
1487
+ join ${rp} rp on rp.role = ${ur.roleSql}
1488
+ where ${ur.userSql} = ${subjectIdSql(ctx)}
1489
+ and rp.grant_key = p_grant
1490
+ and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}
1491
+ )`;
1492
+ if (custom === void 0) return declared;
1493
+ const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
1494
+ const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1495
+ return `${declared}
1496
+ or exists (
1497
+ select 1
1498
+ from ${ur.from}
1499
+ where ${ur.userSql} = ${subjectIdSql(ctx)}
1500
+ and not (${ur.roleSql} = any(${sqlTextArray(custom.declared)}))${active}
1501
+ ${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()}
1502
+ )`;
1503
+ }
1504
+ const declared = ` select ${signedIn(ctx)} and exists (
1505
+ select 1
1506
+ from ${roleRows(ctx)}
1507
+ join ${rp} rp on rp.role = r.role
1508
+ where rp.grant_key = p_grant
1509
+ and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, "r.role"))}${active}
1510
+ )`;
1511
+ if (custom === void 0) return declared;
1512
+ const claim = subjectClaimJsonSql(ctx, GLOBAL_GRANTS_CLAIM);
1513
+ return `${declared}
1514
+ or (${signedIn(ctx)} and exists (
1515
+ select 1
1516
+ from ${roleRows(ctx)}
1517
+ cross join lateral (select ${claim} -> r.role as g) cg
1518
+ where jsonb_typeof(cg.g) = 'array'
1519
+ and not (r.role = any(${sqlTextArray(custom.declared)}))${active}
1520
+ ${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()}
1521
+ ))`;
1522
+ }
1523
+ /** The claim `jwt` mode reads platform custom roles from: `customRoleClaim(globalRoles)`. */
1524
+ const GLOBAL_GRANTS_CLAIM = "role_grants";
1525
+ function memberColumn(name) {
1526
+ return `m.${quoteIdent(name)}`;
1527
+ }
1528
+ function disabledColumn(table) {
1529
+ return table.disabledAt === void 0 ? void 0 : memberColumn(table.disabledAt);
1530
+ }
1531
+ function claimKeptLines(ctx, indent, permission) {
1532
+ return disabledKeep(ctx).length === 0 ? [] : [`${indent}and ${keptRowSql("m -> 'keep'", permission)}`];
1533
+ }
1534
+ /** Entries of a compact custom-role claim (`cg.g`) that match `where`. */
1535
+ function claimEntries(where, value) {
1536
+ return `array(select ${value} from jsonb_array_elements_text(cg.g) e where ${where})`;
1537
+ }
1538
+ /** `p_grant in (select permdock_custom_keys(allows, denies, includes, scope))`. */
1539
+ function customKeysSql(ctx, scope, allows, denies, includes) {
1540
+ return ` and p_grant in (select ${qualified(ctx, CUSTOM_ROLES.keys)}(
1541
+ ${allows},
1542
+ ${denies},
1543
+ ${includes},
1544
+ ${quoteLiteral(scope)}
1545
+ ))`;
1546
+ }
1547
+ function tableBody(ctx, scope, type) {
1548
+ const mapped = scopeTable(ctx, scope);
1549
+ if (mapped === void 0) return ` select null::${type} where false -- no ${scope} memberships table configured`;
1550
+ const { table, column, tenantColumn } = mapped;
1551
+ const set = ctx.grantSet === true;
1552
+ const filtersFor = (grant) => {
1553
+ const filters = [` where ${memberColumn(table.user)} = ${subjectIdSql(ctx)}`];
1554
+ if (table.expiresAt !== void 0) {
1555
+ const expires = memberColumn(table.expiresAt);
1556
+ filters.push(` and (${expires} is null or ${expires} > now())`);
1557
+ }
1558
+ filters.push(...activeMembershipSql(ctx, disabledColumn(table), grantPermissionSql(grant)).map((part) => ` and ${part}`));
1559
+ if (tenantColumn !== void 0 && ctx.tenants !== "all") filters.push(` and (${activeTenant(ctx)} is null or ${memberColumn(tenantColumn)}::text = ${activeTenant(ctx)})`);
1560
+ const keyed = keyTenantSql(ctx, tenantColumn === void 0 ? void 0 : memberColumn(tenantColumn));
1561
+ if (keyed !== void 0) filters.push(` and ${keyed}`);
1562
+ filters.push(...userActive(ctx, " "), ...instancesActive(ctx, scope, (name) => {
1563
+ const held = scopeColumn(table, ctx.scopes, name);
1564
+ return held === void 0 ? void 0 : memberColumn(held);
1565
+ }, " ", grantPermissionSql(grant)));
1566
+ return filters;
1567
+ };
1568
+ const filters = filtersFor(set ? "rp.grant_key" : "p_grant");
1569
+ const [owner, ...rest] = filters;
1570
+ const role = memberRoleOf(table, "m", " ");
1571
+ const kind = kindFilterSql(ctx, role.sql, memberVia(table));
1572
+ const lines = [
1573
+ ` select${role.lateral ? " distinct" : ""} ${memberColumn(column)}::${type}${set ? ", rp.grant_key" : ""}`,
1574
+ ` from ${quoteQualified(table.table)} m${role.join}`,
1575
+ ` join ${qualified(ctx, "role_permissions")} rp on rp.role = ${role.sql}`,
1576
+ owner ?? "",
1577
+ ...set ? [] : [" and rp.grant_key = p_grant"],
1578
+ ` and rp.scope = ${quoteLiteral(scope)}`,
1579
+ ...kind === void 0 ? [] : [` and ${kind}`],
1580
+ ...rest
1581
+ ];
1582
+ const custom = ctx.customRoles;
1583
+ if (custom === void 0) return lines.join("\n");
1584
+ const match = [
1585
+ customTenantMatch(ctx, `${memberColumn(tenantColumn ?? column)}::text`, scope, role.sql),
1586
+ `c.scope = ${quoteLiteral(scope)}`,
1587
+ `(c.scope_id is null or c.scope_id = ${memberColumn(column)}::text)`,
1588
+ `c.role = ${role.sql}`
1589
+ ].join(" and ");
1590
+ const rows = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1591
+ const entries = [
1592
+ rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"),
1593
+ rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"),
1594
+ rows(CUSTOM_ROLES.includes, "include_role", "")
1595
+ ];
1596
+ if (set) return [
1597
+ ...lines,
1598
+ " union",
1599
+ ` select ${memberColumn(column)}::${type}, ck.grant_key`,
1600
+ ` from ${quoteQualified(table.table)} m${role.join}`,
1601
+ customKeysJoin(ctx, scope, ...entries),
1602
+ ...filtersFor("ck.grant_key"),
1603
+ ` and not (${role.sql} = any(${sqlTextArray(custom.declared)}))`
1604
+ ].join("\n");
1605
+ return [
1606
+ ...lines,
1607
+ " union",
1608
+ ` select ${memberColumn(column)}::${type}`,
1609
+ ` from ${quoteQualified(table.table)} m${role.join}`,
1610
+ ...filters,
1611
+ ` and not (${role.sql} = any(${sqlTextArray(custom.declared)}))`,
1612
+ customKeysSql(ctx, scope, ...entries)
1613
+ ].join("\n");
1614
+ }
1615
+ /** The membership's instance of the first scope, from the canonical claim. */
1616
+ function claimTenant(ctx, scope) {
1617
+ const root = rootName(ctx);
1618
+ return scope === root ? `m ->> 'id'` : `m -> 'within' ->> ${quoteLiteral(root)}`;
1619
+ }
1620
+ function claimBody(ctx, scope, type) {
1621
+ const set = ctx.grantSet === true;
1622
+ const narrow = narrowsTo(ctx, scope) ? `
1623
+ and (${activeTenant(ctx)} is null or ${claimTenant(ctx, scope)} = ${activeTenant(ctx)})` : "";
1624
+ const filtersFor = (grant) => ` and m ->> 'scope' = ${quoteLiteral(scope)}
1625
+ and m ->> 'id' is not null${narrow}${keyTenantLine(ctx, scope, claimTenant(ctx, scope))}
1626
+ and case jsonb_typeof(m -> 'expires_at')
1627
+ when 'number' then (m ->> 'expires_at')::numeric > extract(epoch from now())
1628
+ else true
1629
+ end${[
1630
+ ...claimKeptLines(ctx, " ", grantPermissionSql(grant)),
1631
+ ...userActive(ctx, " "),
1632
+ ...instancesActive(ctx, scope, (name) => name === scope ? `m ->> 'id'` : `m -> 'within' ->> ${quoteLiteral(name)}`, " ", grantPermissionSql(grant))
1633
+ ].map((line) => `\n${line}`).join("")}`;
1634
+ const filters = filtersFor(set ? "rp.grant_key" : "p_grant");
1635
+ const declared = ` select (m ->> 'id')::${type}${set ? ", rp.grant_key" : ""}
1636
+ from ${membershipRows(ctx)}
1637
+ join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
1638
+ where ${signedIn(ctx)}${set ? "" : "\n and rp.grant_key = p_grant"}
1639
+ and rp.scope = ${quoteLiteral(scope)}${andLine(" ", kindFilterSql(ctx, "r.role", "m ->> 'via'"))}
1640
+ ${filters}`;
1641
+ const custom = ctx.customRoles;
1642
+ if (custom === void 0) return declared;
1643
+ if (set) return `${declared}
1558
1644
  union
1559
- select c.grant_key from ceiling c
1560
- where c.permission in (select a.permission from allowed a)
1645
+ select (m ->> 'id')::${type}, ck.grant_key
1646
+ from ${membershipRows(ctx)}
1647
+ cross join lateral (select m -> 'grants' -> r.role as g) cg
1648
+ ${customKeysJoin(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
1649
+ where ${signedIn(ctx)}
1650
+ and jsonb_typeof(cg.g) = 'array'
1651
+ and not (r.role = any(${sqlTextArray(custom.declared)}))
1652
+ ${filtersFor("ck.grant_key")}`;
1653
+ return `${declared}
1561
1654
  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;`;
1567
- }
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
+ select (m ->> 'id')::${type}
1656
+ from ${membershipRows(ctx)}
1657
+ cross join lateral (select m -> 'grants' -> r.role as g) cg
1658
+ where ${signedIn(ctx)}
1659
+ and jsonb_typeof(cg.g) = 'array'
1660
+ and not (r.role = any(${sqlTextArray(custom.declared)}))
1661
+ ${filters}
1662
+ ${customKeysSql(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}`;
1572
1663
  }
1573
1664
  /**
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.
1665
+ * The rows the membership sources select for the subject, one per instance
1666
+ * (`scope`, `id`, `within`, `roles`, `via`), with each source's own expiry
1667
+ * and suspension filters: the statements the token hook runs.
1577
1668
  */
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[]))`
1669
+ function sourcesKeep(sources) {
1670
+ return sources.some((source) => source.sql.keeps);
1671
+ }
1672
+ function sourceRows(sources) {
1673
+ const keep = sourcesKeep(sources);
1674
+ return sources.map((source, index) => source.sql.select(sourceUser(index), keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
1675
+ }
1676
+ function sourceUser(index) {
1677
+ return `v_user_${String(index)}`;
1678
+ }
1679
+ function sqlBody(sql) {
1680
+ return {
1681
+ sql,
1682
+ vars: []
1585
1683
  };
1684
+ }
1685
+ function sourcesBodyOf(sql, sources, user) {
1586
1686
  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)`
1687
+ sql,
1688
+ vars: sources.map((source, index) => `${sourceUser(index)} ${source.sql.userType} := ${user};`)
1596
1689
  };
1597
1690
  }
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
1691
+ /** `language sql`, or `plpgsql` returning the query when the body declares variables. */
1692
+ function functionBody(body) {
1693
+ if (body.vars.length === 0) return `language sql
1622
1694
  stable
1695
+ security definer
1623
1696
  set search_path = ''
1624
1697
  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)
1698
+ ${body.sql}
1699
+ $$;`;
1700
+ return `language plpgsql
1701
+ stable
1702
+ security definer
1703
+ set search_path = ''
1704
+ as $$
1705
+ declare
1706
+ ${body.vars.map((line) => ` ${line}`).join("\n")}
1707
+ begin
1708
+ return query
1709
+ ${body.sql};
1710
+ end;
1711
+ $$;`;
1712
+ }
1713
+ /**
1714
+ * `ms.id` for the scope itself and `ms.within ->> name` for an ancestor. The
1715
+ * alias is not `s`, which `activeRowSql` binds inside its `exists`.
1716
+ */
1717
+ function sourceIdOf(scope) {
1718
+ return (name) => name === scope ? "ms.id" : `ms.within ->> ${quoteLiteral(name)}`;
1719
+ }
1720
+ function sourceFilters(ctx, scope, user = subjectIdSql(ctx), permission, keeps = false) {
1721
+ return [
1722
+ ...keeps ? [` and ${keptRowSql("ms.keep", permission)}`] : [],
1723
+ ...userActive(ctx, " ", user),
1724
+ ...instancesActive(ctx, scope, sourceIdOf(scope), " ", permission)
1725
+ ].map((line) => `\n${line}`).join("");
1726
+ }
1727
+ function sourcesBody(ctx, scope, type) {
1728
+ const root = rootName(ctx);
1729
+ const tenant = sourceIdOf(scope)(root);
1730
+ const narrow = narrowsTo(ctx, scope) ? `\n and (${activeTenant(ctx)} is null or ${tenant} = ${activeTenant(ctx)})${keyTenantLine(ctx, scope, tenant)}` : keyTenantLine(ctx, scope, tenant);
1731
+ const sources = scopeSources(ctx, scope);
1732
+ const set = ctx.grantSet === true;
1733
+ const rows = ` from (
1734
+ ${sourceRows(sources)}
1735
+ ) ms
1736
+ cross join lateral jsonb_array_elements_text(
1737
+ case jsonb_typeof(ms.roles) when 'array' then ms.roles else '[]'::jsonb end
1738
+ ) r(role)`;
1739
+ const declared = ` select (ms.id)::${type}${set ? ", rp.grant_key" : ""}
1740
+ ${rows}
1741
+ join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
1742
+ where ${signedIn(ctx)}
1743
+ and ms.scope = ${quoteLiteral(scope)}${set ? "" : "\n and rp.grant_key = p_grant"}
1744
+ 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))}`;
1745
+ const custom = ctx.customRoles;
1746
+ if (custom === void 0) return sourcesBodyOf(declared, sources, subjectIdSql(ctx));
1747
+ const match = [
1748
+ customTenantMatch(ctx, `(${tenant})::text`, scope, "r.role"),
1749
+ `c.scope = ${quoteLiteral(scope)}`,
1750
+ "(c.scope_id is null or c.scope_id = (ms.id)::text)",
1751
+ "c.role = r.role"
1752
+ ].join(" and ");
1753
+ const entries = (source, value, extra) => `array(select c.${value} from ${qualified(ctx, source)} c where ${match}${extra})`;
1754
+ if (set) return sourcesBodyOf(`${declared}
1755
+ union
1756
+ select (ms.id)::${type}, ck.grant_key
1757
+ ${rows}
1758
+ ${customKeysJoin(ctx, scope, entries(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"), entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), entries(CUSTOM_ROLES.includes, "include_role", ""))}
1759
+ where ${signedIn(ctx)}
1760
+ and ms.scope = ${quoteLiteral(scope)}
1761
+ and not (r.role = any(${sqlTextArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("ck.grant_key"), sourcesKeep(sources))}`, sources, subjectIdSql(ctx));
1762
+ return sourcesBodyOf(`${declared}
1763
+ union
1764
+ select (ms.id)::${type}
1765
+ ${rows}
1766
+ where ${signedIn(ctx)}
1767
+ and ms.scope = ${quoteLiteral(scope)}
1768
+ and not (r.role = any(${sqlTextArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("p_grant"), sourcesKeep(sources))}
1769
+ ${customKeysSql(ctx, scope, entries(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"), entries(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), entries(CUSTOM_ROLES.includes, "include_role", ""))}`, sources, subjectIdSql(ctx));
1770
+ }
1771
+ function scopedBody(ctx, scope, type) {
1772
+ if (ctx.authorize !== "database") return sqlBody(claimBody(ctx, scope, type));
1773
+ return scopeSources(ctx, scope).length > 0 ? sourcesBody(ctx, scope, type) : sqlBody(tableBody(ctx, scope, type));
1774
+ }
1775
+ //#endregion
1776
+ //#region src/cli/rls/capabilities.ts
1777
+ /** The helper `--capabilities` adds: ids of one resource a link capability claim reaches. Part of the SQL contract. */
1778
+ const CAPABILITIES = { ids: "permdock_capability_ids" };
1779
+ /**
1780
+ * The `anon` branch access for a resource-scoped grant reached through a link
1781
+ * capability: the row's `field` holds an id the capability claim names for
1782
+ * `resource`, `role` and `permission`. Uncorrelated, so it runs once per
1783
+ * statement; the column is compared as text because the claim holds text.
1784
+ */
1785
+ function capabilityAccessSql(ctx, field, resource, role, permission) {
1786
+ const args = [
1787
+ resource,
1788
+ role,
1789
+ permission
1790
+ ].map(quoteLiteral).join(", ");
1791
+ return `${quoteIdent(field)}::text in (select ${qualified(ctx, CAPABILITIES.ids)}(${args}))`;
1792
+ }
1793
+ function capabilitiesSql(ctx) {
1794
+ if (ctx.capabilities !== true) return "";
1795
+ const fn = qualified(ctx, CAPABILITIES.ids);
1796
+ return `-- link capabilities: the capability claim exchangeCapability mints (role anon, short-lived)
1797
+ create or replace function ${fn}(p_resource text, p_role text, p_permission text)
1631
1798
  returns setof text
1632
1799
  language sql
1633
1800
  stable
1634
- security definer
1635
1801
  set search_path = ''
1636
1802
  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
1803
+ select c -> 'on' ->> 'id'
1804
+ from (select ${subjectClaimJsonSql(ctx, "capability")} as c) capability
1805
+ where jsonb_typeof(c) = 'object'
1806
+ and c ->> 'v' = '1'
1807
+ and c ->> 'holder' = 'link'
1808
+ and c -> 'on' ->> 'resource' = p_resource
1809
+ and jsonb_typeof(c -> 'roles') = 'array'
1810
+ and c -> 'roles' @> jsonb_build_array(p_role)
1811
+ and (c -> 'permissions' is null or c -> 'permissions' @> jsonb_build_array(p_permission))
1812
+ and jsonb_typeof(c -> 'expires_at') = 'number'
1813
+ and (c ->> 'expires_at')::numeric > extract(epoch from now())
1643
1814
  $$;
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;
1815
+ revoke execute on function ${fn}(text, text, text) from public;
1816
+ grant execute on function ${fn}(text, text, text) to anon, authenticated;`;
1817
+ }
1818
+ //#endregion
1819
+ //#region src/cli/rls/custom-role-by-id.ts
1820
+ const PREFIX = "PermDock CLI";
1821
+ const col = (name) => `r.${quoteIdent(name)}`;
1822
+ /**
1823
+ * Custom-role functions keyed by the application's role row
1824
+ * (`rls.customRoleWrites.roles.primaryKey`): the tenant, scope, instance and
1825
+ * name come from the row, never from the caller. The writers delegate to
1826
+ * `permdock_replace_custom_role_grants` (or its trusted variant), so every
1827
+ * caller and definition check applies, and keep the stored denies and
1828
+ * includes. The reader returns declared allow keys only and is executable by
1829
+ * `rls.trustedReaders` alone.
1830
+ */
1831
+ function customRoleByIdSql(ctx, trustedReaders) {
1832
+ const custom = ctx.customRoles;
1833
+ const roles = custom?.table;
1834
+ const root = ctx.scopes[0]?.name;
1835
+ if (custom === void 0 || roles?.primaryKey === void 0 || ctx.authorize !== "database" || root === void 0) return "";
1836
+ const pk = roles.primaryKey;
1837
+ if (pk === "") throw configError(`${PREFIX}: rls.customRoleWrites.roles.primaryKey names no column`);
1838
+ const leveled = custom.levels === true;
1839
+ const tenantType = tenantTypeOf(ctx);
1840
+ const table = quoteQualified(roles.table);
1841
+ const idType = `${table}.${quoteIdent(pk)}%type`;
1842
+ const perms = qualified(ctx, CUSTOM_ROLES.permissions);
1843
+ const includes = qualified(ctx, CUSTOM_ROLES.includes);
1844
+ const replace = qualified(ctx, CUSTOM_ROLES.replace);
1845
+ const trustedReplace = qualified(ctx, CUSTOM_ROLES.trustedReplace);
1846
+ const byId = qualified(ctx, CUSTOM_ROLES.replaceById);
1847
+ const trustedById = qualified(ctx, CUSTOM_ROLES.trustedReplaceById);
1848
+ const reader = qualified(ctx, CUSTOM_ROLES.trustedReadById);
1849
+ const renames = Object.entries(custom.renamed ?? {}).toSorted(byKey);
1850
+ const current = (expr) => currentKeyExpr(renames, expr);
1851
+ const declaredKeys = sqlTextArray(custom.permissions ?? []);
1852
+ const declaredRoles = sqlTextArray(custom.declared);
1853
+ const tenant = roles.tenant === void 0 ? `null::${tenantType}` : `${col(roles.tenant)}::${tenantType}`;
1854
+ const scope = rolesTableScope(roles, tenant, root, col);
1855
+ const scopeId = roles.id === void 0 || roles.tenant === void 0 ? "null::text" : `case when ${tenant} is null then null else ${col(roles.id)}::text end`;
1856
+ const skipped = roles.skip === void 0 || roles.tenant === void 0 ? "false" : `(${tenant} is not null and coalesce(${col(roles.skip)}, false))`;
1857
+ const match = "c.tenant_id is not distinct from v_tenant and c.scope = v_scope and c.scope_id is not distinct from v_scope_id and c.role = v_key";
1858
+ const audit = custom.audit;
1859
+ const auditCall = audit === void 0 ? "" : `
1860
+ perform ${quoteQualified(audit.function)}(${[
1861
+ "event_type => 'permdock.custom_role.replaced'",
1862
+ "target_type => 'role'",
1863
+ "record_id => p_role_id::text",
1864
+ "tenant => v_tenant",
1865
+ "metadata => jsonb_build_object('operation', 'replace', 'role', v_key, 'permissions', p_permissions)",
1866
+ ...Object.entries(audit.args ?? {}).toSorted(byKey).map(([name, value]) => `${quoteIdent(name)} => ${quoteLiteral(value)}`)
1867
+ ].join(", ")});`;
1868
+ const body = (write) => `declare
1869
+ v_tenant ${tenantType};
1870
+ v_scope text;
1871
+ v_scope_id text;
1872
+ v_key text;
1873
+ v_skipped boolean;
1874
+ v_deny text[];
1875
+ v_include text[];
1876
+ begin
1877
+ select ${tenant}, ${scope}, ${scopeId}, ${col(roles.key)}::text, ${skipped}
1878
+ into v_tenant, v_scope, v_scope_id, v_key, v_skipped
1879
+ from ${table} r
1880
+ where ${col(pk)} = p_role_id;
1881
+ if not found then
1882
+ ${raiseSql(" ", "P0002", "'permdock: no role row has id ' || coalesce(p_role_id::text, 'null')", "unknown-role")}
1679
1883
  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;
1884
+ if v_skipped or v_key = any(${declaredRoles}) then
1885
+ ${raiseSql(" ", "22023", "'permdock: ' || coalesce(v_key, 'null') || ' is a declared role, not a custom role'", "declared-role")}
1886
+ end if;
1887
+ select array_agg(c.permission) into v_deny
1888
+ from ${perms} c
1889
+ where ${match} and c.effect = 'deny';
1890
+ select array_agg(c.include_role) into v_include
1891
+ from ${includes} c
1892
+ where ${match};
1893
+ perform ${write}(v_tenant, v_scope, v_scope_id, v_key, p_permissions, v_deny, v_include);${auditCall}
1691
1894
  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)
1895
+ const writer = (comment, name, write, member) => `-- ${comment}
1896
+ create or replace function ${name}(p_role_id ${idType}, p_permissions text[])
1897
+ returns void
1704
1898
  language plpgsql
1705
- stable
1899
+ volatile
1706
1900
  security definer
1707
1901
  set search_path = ''
1708
1902
  as $$
1709
- ${body}
1903
+ ${body(write)}
1710
1904
  $$;
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)
1905
+ ${member ? `revoke execute on function ${name}(${idType}, text[]) from public, anon;
1906
+ grant execute on function ${name}(${idType}, text[]) to authenticated;` : `revoke execute on function ${name}(${idType}, text[]) from public, anon, authenticated;`}`;
1907
+ const readerEntry = leveled ? `${current("c.permission")} || coalesce('@' || c.level, '')` : current("c.permission");
1908
+ const readerGrant = trustedReaders === void 0 || trustedReaders.length === 0 ? "" : `\n${trustedReaderGrants(ctx, `${reader}(text[], ${tenantType})`, trustedReaders)}`;
1909
+ return `${writer("save a custom role by its row id: the row supplies tenant, scope, instance and name; the caller checks of permdock_replace_custom_role_grants apply, and stored denies and includes stay", byId, replace, true)}
1910
+
1911
+ ${writer("save a custom role by its row id for a trusted caller: the definition checks without the caller checks", trustedById, trustedReplace, false)}
1912
+
1913
+ -- the declared allow keys of custom roles by their row ids as text, for a trusted reader; with p_tenant, an id of another tenant is refused
1914
+ create or replace function ${reader}(p_role_ids text[], p_tenant ${tenantType} default null)
1915
+ returns table (role_id text, permission text)
1718
1916
  language plpgsql
1719
1917
  stable
1720
1918
  security definer
1721
1919
  set search_path = ''
1722
1920
  as $$
1723
- ${trustedBody}
1921
+ begin
1922
+ if p_tenant is not null and exists (
1923
+ select 1 from ${table} r
1924
+ where ${col(pk)}::text = any(p_role_ids) and ${tenant} is distinct from p_tenant
1925
+ ) then
1926
+ ${raiseSql(" ", "42501", "'permdock: a role id belongs to another tenant than ' || p_tenant::text", "not-member")}
1927
+ end if;
1928
+ return query
1929
+ select distinct v.role_id, ${readerEntry}
1930
+ from (
1931
+ select ${col(pk)}::text as role_id, ${tenant} as tenant_id, ${scope} as scope, ${scopeId} as scope_id, ${col(roles.key)}::text as role
1932
+ from ${table} r
1933
+ where ${col(pk)}::text = any(p_role_ids)
1934
+ and not ${skipped}
1935
+ and not (${col(roles.key)}::text = any(${declaredRoles}))
1936
+ ) v
1937
+ join ${perms} c
1938
+ on c.tenant_id is not distinct from v.tenant_id
1939
+ and c.scope = v.scope
1940
+ and c.scope_id is not distinct from v.scope_id
1941
+ and c.role = v.role
1942
+ where c.effect = 'allow'
1943
+ and ${current("c.permission")} = any(${declaredKeys})
1944
+ order by 1, 2;
1945
+ end;
1724
1946
  $$;
1725
- revoke execute on function ${trusted}(${signature}) from public, anon, authenticated;${grants}`);
1726
- return chunks.join("\n\n");
1727
- }
1728
- /** `expr` read as its current key: a former key maps to the key it was renamed to. */
1729
- function currentKeyExpr(renames, expr) {
1730
- if (renames.length === 0) return expr;
1731
- return `(case ${expr} ${renames.map(([former, current]) => `when ${quoteLiteral(former)} then ${quoteLiteral(current)}`).join(" ")} else ${expr} end)`;
1732
- }
1733
- /** `case p_scope when '<scope>' then <sql for scope> ... else false end` over the declared scopes. */
1734
- function perScope(ctx, sql) {
1735
- const arms = ctx.scopes.map((scope) => `when ${quoteLiteral(scope.name)} then ${sql(scope.name)}`).join(" ");
1736
- return arms === "" ? "false" : `(case p_scope ${arms} else false end)`;
1737
- }
1738
- function raiseSql(indent, errcode, message, hint) {
1739
- return `${indent}raise exception using
1740
- ${indent} errcode = '${errcode}',
1741
- ${indent} message = ${message},
1742
- ${indent} hint = ${quoteLiteral(hint)};`;
1743
- }
1744
- function hasCustomRoleChecksFor(ctx) {
1745
- return ctx.authorize === "database" && ctx.customRoles !== void 0 && ctx.scopes.length > 0 && ctx.scopes.every((scope) => hasMemberFor(ctx, scope.name));
1947
+ revoke execute on function ${reader}(text[], ${tenantType}) from public, anon, authenticated;${readerGrant}`;
1746
1948
  }
1747
1949
  //#endregion
1748
1950
  //#region src/cli/rls/custom-role-cascade.ts
@@ -1775,12 +1977,12 @@ function customRoleCascadeSql(ctx) {
1775
1977
  const fn = qualified(ctx, CUSTOM_ROLES.cascade);
1776
1978
  const target = quoteTable(qualifySqlTable(table.table));
1777
1979
  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`;
1779
- const id = (row) => table.id === void 0 ? "null::text" : `${rowColumn(row, table.id)}::text`;
1780
- const skipped = (row) => table.skip === void 0 ? "false" : `coalesce(${rowColumn(row, table.skip)}, false)`;
1980
+ const scope = (row) => rolesTableScope(table, tenant(row), root, (name) => rowColumn(row, name));
1981
+ const id = (row) => table.id === void 0 || table.tenant === void 0 ? "null::text" : `case when ${tenant(row)} is null then null else ${rowColumn(row, table.id)}::text end`;
1982
+ const skipped = (row) => table.skip === void 0 || table.tenant === void 0 ? "false" : `(${tenant(row)} is not null and coalesce(${rowColumn(row, table.skip)}, false))`;
1781
1983
  const guard = qualified(ctx, CUSTOM_ROLES.guard);
1782
1984
  const entries = qualified(ctx, CUSTOM_ROLES.entries);
1783
- const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
1985
+ const allowEntryOf = `c.${allowEntry(ctx)}`;
1784
1986
  return `-- the application's roles table: renames, moves and deletes of a custom role carry its grants and includes
1785
1987
  create or replace function ${fn}()
1786
1988
  returns trigger
@@ -1859,6 +2061,58 @@ create trigger "permdock_custom_role_cascade"
1859
2061
  `;
1860
2062
  }
1861
2063
  //#endregion
2064
+ //#region src/cli/rls/custom-role-guard.ts
2065
+ /** A column of the roles-table row a trigger sees as `new`. */
2066
+ function column(name) {
2067
+ return `new.${quoteIdent(name)}`;
2068
+ }
2069
+ /**
2070
+ * The trigger on the application's roles table (`rls.customRoleWrites.roles`)
2071
+ * that refuses a tenant row named like a declared role (`declared-role`) or
2072
+ * like a shared custom role (`name-taken`). A row the `skip` column marks is
2073
+ * a system role of the tenant and passes. Empty without a tenant column: then
2074
+ * no row is a tenant row.
2075
+ */
2076
+ function customRoleNameGuardSql(ctx) {
2077
+ const custom = ctx.customRoles;
2078
+ const table = custom?.table;
2079
+ const root = ctx.scopes[0]?.name;
2080
+ if (custom === void 0 || table?.tenant === void 0 || root === void 0) return "";
2081
+ const tenant = `${column(table.tenant)}::${tenantTypeOf(ctx)}`;
2082
+ const skipped = table.skip === void 0 ? "false" : `coalesce(${column(table.skip)}, false)`;
2083
+ const fn = qualified(ctx, CUSTOM_ROLES.nameGuard);
2084
+ const target = quoteTable(qualifySqlTable(table.table));
2085
+ const key = `${column(table.key)}::text`;
2086
+ const scope = rolesTableScope(table, tenant, root, column);
2087
+ return `-- the application's roles table: a tenant row cannot take the name of a declared or a shared role
2088
+ create or replace function ${fn}()
2089
+ returns trigger
2090
+ language plpgsql
2091
+ volatile
2092
+ security definer
2093
+ set search_path = ''
2094
+ as $$
2095
+ begin
2096
+ if ${tenant} is null or ${skipped} then
2097
+ return new;
2098
+ end if;
2099
+ if ${key} = any(${sqlTextArray(custom.declared)}) then
2100
+ ${raiseSql(" ", "22023", `'permdock: ' || ${key} || ' is a declared role'`, "declared-role")}
2101
+ end if;
2102
+ if ${qualified(ctx, CUSTOM_ROLES.shared)}(${scope}, ${key}) then
2103
+ ${raiseSql(" ", "22023", `'permdock: ' || ${key} || ' is a shared role'`, "name-taken")}
2104
+ end if;
2105
+ return new;
2106
+ end;
2107
+ $$;
2108
+ revoke execute on function ${fn}() from public, anon, authenticated;
2109
+ drop trigger if exists "permdock_custom_role_name_guard" on ${target};
2110
+ create trigger "permdock_custom_role_name_guard"
2111
+ before insert or update on ${target}
2112
+ for each row execute function ${fn}();
2113
+ `;
2114
+ }
2115
+ //#endregion
1862
2116
  //#region src/cli/rls/custom-role-writes.ts
1863
2117
  function callerFunctionsSql(ctx, custom, root, levelReach, forUser) {
1864
2118
  const leveled = custom.levels === true;
@@ -1909,7 +2163,7 @@ ${raiseSql(" ", "42501", `'permdock: the caller may not manage roles in ' ||
1909
2163
  )` : "";
1910
2164
  const reachCte = leveled && reachRows !== "" ? `,
1911
2165
  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`;
2166
+ const match = `${storedTenant(ctx)} and c.scope = p_scope and c.scope_id is not distinct from p_scope_id`;
1913
2167
  const memberOf = `p_tenant::text in ${members(root)}
1914
2168
  or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${members(scope)}`)}`;
1915
2169
  return {
@@ -1961,7 +2215,7 @@ begin
1961
2215
  perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
1962
2216
  if not (${signedIn(ctx, user)} and (
1963
2217
  ${platform}
1964
- or (p_scope <> 'global' and (${memberOf}))
2218
+ or (p_scope <> 'global' and coalesce(${memberOf}, false))
1965
2219
  )) then
1966
2220
  ${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
1967
2221
  end if;${requires}
@@ -1989,8 +2243,9 @@ revoke execute on function ${guard}(${userArg}${tenantType}, text, text, text) f
1989
2243
  * \`assignablePermissions\`: entries inside the ceiling of the role's scope,
1990
2244
  * includes naming declared roles, and only permissions and levels the caller
1991
2245
  * 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
2246
+ * custom role (\`p_scope = 'global'\`, no tenant) and a shared custom role (no
2247
+ * tenant at a lower scope) are written by a caller who holds a
2248
+ * \`meta.manageRoles\` permission through a global role. The
1994
2249
  * \`permdock_trusted_*\` variants skip the caller checks and keep the
1995
2250
  * definition checks; no client role may execute them.
1996
2251
  */
@@ -2008,6 +2263,7 @@ function customRoleWritesSql(ctx, levelReach) {
2008
2263
  const guard = qualified(ctx, CUSTOM_ROLES.guard);
2009
2264
  const shape = qualified(ctx, CUSTOM_ROLES.shape);
2010
2265
  const entries = qualified(ctx, CUSTOM_ROLES.entries);
2266
+ const problem = qualified(ctx, CUSTOM_ROLES.entryProblem);
2011
2267
  const renames = Object.entries(custom.renamed ?? {}).toSorted(byKey);
2012
2268
  const current = (expr) => currentKeyExpr(renames, expr);
2013
2269
  const known = sqlTextArray([...custom.permissions ?? [], ...renames.map(([former]) => former)]);
@@ -2016,9 +2272,15 @@ function customRoleWritesSql(ctx, levelReach) {
2016
2272
  const scopes = sqlTextArray(ctx.scopes.map((scope) => scope.name));
2017
2273
  const roleName = (param) => `coalesce(${param}, '') = '' or ${param} = any(${sqlTextArray(custom.declared)})`;
2018
2274
  const levelOf = leveled ? ", nullif(split_part(e, '@', 2), '')" : "";
2019
- const write = ` delete from ${perms} c where ${match} and c.role = p_role;
2275
+ const levelColumn = leveled ? ", level" : "";
2276
+ const sharedRole = qualified(ctx, CUSTOM_ROLES.shared);
2277
+ const taken = (param) => ` if p_tenant is not null and ${sharedRole}(p_scope, ${param}) then
2278
+ ${raiseSql(" ", "22023", `'permdock: ' || ${param} || ' is the name of a shared custom role'`, "name-taken")}
2279
+ end if;
2280
+ `;
2281
+ const write = `${taken("p_role")} delete from ${perms} c where ${match} and c.role = p_role;
2020
2282
  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" : ""})
2283
+ insert into ${perms} (tenant_id, scope, scope_id, role, permission, effect${levelColumn})
2022
2284
  select distinct p_tenant, p_scope, p_scope_id, p_role, ${current(leveled ? "split_part(e, '@', 1)" : "e")}, 'allow'${levelOf}
2023
2285
  from unnest(coalesce(p_allow, '{}'::text[])) e
2024
2286
  union
@@ -2027,7 +2289,7 @@ function customRoleWritesSql(ctx, levelReach) {
2027
2289
  insert into ${includes} (tenant_id, scope, scope_id, role, include_role)
2028
2290
  select distinct p_tenant, p_scope, p_scope_id, p_role, e
2029
2291
  from unnest(coalesce(p_include, '{}'::text[])) e;`;
2030
- const move = ` if ${roleName("p_to")} then
2292
+ const move = `${taken("p_to")} if ${roleName("p_to")} then
2031
2293
  ${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_to, 'null') || ' is not a custom role name'`, "declared-role")}
2032
2294
  end if;
2033
2295
  if exists (select 1 from ${perms} c where ${match} and c.role = p_to)
@@ -2058,7 +2320,7 @@ grant execute on function ${qualified(ctx, name)}(${args}) to authenticated;` :
2058
2320
  const removeParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
2059
2321
  return `${caller.beyond}
2060
2322
 
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
2323
+ -- 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
2324
  create or replace function ${shape}(p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
2063
2325
  returns void
2064
2326
  language plpgsql
@@ -2070,8 +2332,10 @@ begin
2070
2332
  if p_tenant is not null or p_scope_id is not null then
2071
2333
  ${raiseSql(" ", "22023", `'permdock: a platform custom role has no tenant or instance'`, "unknown-scope")}
2072
2334
  end if;
2073
- elsif p_tenant is null or not (p_scope = any(${scopes})) then
2335
+ elsif not (p_scope = any(${scopes})) then
2074
2336
  ${raiseSql(" ", "22023", `'permdock: custom roles are held at a scope of a tenant or at global, not ' || coalesce(p_scope, 'null')`, "unknown-scope")}
2337
+ elsif p_tenant is null and p_scope_id is not null then
2338
+ ${raiseSql(" ", "22023", `'permdock: a shared custom role has no instance'`, "unknown-scope")}
2075
2339
  end if;
2076
2340
  if ${roleName("p_role")} then
2077
2341
  ${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_role, 'null') || ' is not a custom role name'`, "declared-role")}
@@ -2087,6 +2351,38 @@ ${callerFunctionsSql(ctx, custom, root, levelReach, true).beyond}
2087
2351
 
2088
2352
  ${callerFunctionsSql(ctx, custom, root, levelReach, true).guard}
2089
2353
  ` : ""}
2354
+ -- null when an allow entry is a declared permission inside the ceiling of the scope${leveled ? ", at a level the resource declares" : ""}; else unknown-permission, outside-ceiling${leveled ? " or unknown-level" : ""}
2355
+ create or replace function ${problem}(p_scope text, p_entry text)
2356
+ returns text
2357
+ language plpgsql
2358
+ stable
2359
+ security definer
2360
+ set search_path = ''
2361
+ as $$
2362
+ declare
2363
+ v_key text := ${current(leveled ? "split_part(p_entry, '@', 1)" : "p_entry")};
2364
+ begin
2365
+ if p_entry is null or not (v_key = any(${known})) then
2366
+ return 'unknown-permission';
2367
+ end if;
2368
+ if not exists (
2369
+ select 1 from ${ceiling} c
2370
+ where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
2371
+ ) then
2372
+ return 'outside-ceiling';
2373
+ end if;${leveled ? `
2374
+ if strpos(p_entry, '@') > 0 and not exists (
2375
+ select 1 from ${ceiling} c
2376
+ where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
2377
+ and split_part(c.grant_key, '@', 2) = split_part(p_entry, '@', 2)
2378
+ ) then
2379
+ return 'unknown-level';
2380
+ end if;` : ""}
2381
+ return null;
2382
+ end;
2383
+ $$;
2384
+ revoke execute on function ${problem}(text, text) from public, anon, authenticated;
2385
+
2090
2386
  -- the entries of a definition: declared keys and levels inside the ceiling of the scope, and declared includes
2091
2387
  create or replace function ${entries}(p_scope text, p_allow text[], p_deny text[], p_include text[])
2092
2388
  returns void
@@ -2098,23 +2394,18 @@ as $$
2098
2394
  declare
2099
2395
  v_entry text;
2100
2396
  v_key text;
2397
+ v_problem text;
2101
2398
  begin
2102
2399
  foreach v_entry in array coalesce(p_allow, '{}'::text[]) loop
2103
2400
  v_key := ${current(leveled ? "split_part(v_entry, '@', 1)" : "v_entry")};
2104
- if not (v_key = any(${known})) then
2401
+ v_problem := ${problem}(p_scope, v_entry);
2402
+ if v_problem = 'unknown-permission' then
2105
2403
  ${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared permission'`, "unknown-permission")}
2106
2404
  end if;
2107
- if not exists (
2108
- select 1 from ${ceiling} c
2109
- where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
2110
- ) then
2405
+ if v_problem = 'outside-ceiling' then
2111
2406
  ${raiseSql(" ", "22023", `'permdock: ' || v_key || ' is outside the ceiling of ' || p_scope`, "outside-ceiling")}
2112
2407
  end if;${leveled ? `
2113
- if strpos(v_entry, '@') > 0 and not exists (
2114
- select 1 from ${ceiling} c
2115
- where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
2116
- and split_part(c.grant_key, '@', 2) = split_part(v_entry, '@', 2)
2117
- ) then
2408
+ if v_problem = 'unknown-level' then
2118
2409
  ${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' names a level the resource does not declare'`, "unknown-level")}
2119
2410
  end if;` : ""}
2120
2411
  end loop;
@@ -2217,7 +2508,7 @@ function globalRolesSql(ctx) {
2217
2508
  const id = quoteIdent(through.id);
2218
2509
  const key = quoteIdent(through.key);
2219
2510
  const ref = quoteIdent(through.ref);
2220
- const global = source.tenant === void 0 ? "" : ` and k.${quoteIdent(source.tenant)} is null`;
2511
+ 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
2512
  body = ` select string_agg(w.r, ', ' order by w.r) into v_unknown
2222
2513
  from (${wanted}) w(r)
2223
2514
  where not exists (select 1 from ${keys} k where k.${key}::text = w.r${global});
@@ -2375,17 +2666,103 @@ ${functionBody(memberRowsBody(ctx, scope, type, "p_user", memberForSources(ctx,
2375
2666
  revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
2376
2667
  }
2377
2668
  //#endregion
2669
+ //#region src/cli/rls/token-scopes.ts
2670
+ const TOKEN_SCOPE_ALLOWS = "permdock_token_scope_allows";
2671
+ /** `rls.tokenScopes` as the SQL generator needs it, or `undefined` when it is off. */
2672
+ function tokenScopesPlan(setting, policy) {
2673
+ if (setting !== true) return;
2674
+ const permissions = listPermissions(policy.permissions).map((leaf) => ({
2675
+ key: leaf.key,
2676
+ scope: leaf.scope,
2677
+ renamedFrom: formerKeys(leaf)
2678
+ }));
2679
+ return { scopes: delegatedScopeMap(permissions, policy.oauthScopes) };
2680
+ }
2681
+ function tokenScopeAllowsCall(ctx, grant) {
2682
+ return `${qualified(ctx, TOKEN_SCOPE_ALLOWS)}(${grant})`;
2683
+ }
2684
+ /** The token's scopes as rows of `s(scope)`: a space-separated string or, outside `guc`, a JSON array. */
2685
+ function tokenScopeRows(ctx) {
2686
+ if (ctx.dialect === "guc") return `regexp_split_to_table(btrim(coalesce(${subjectClaimSql(ctx, "scope")}, '')), '\\s+') s(scope)`;
2687
+ const scope = subjectClaimJsonSql(ctx, "scope");
2688
+ return `unnest(case jsonb_typeof(${scope})
2689
+ when 'string' then regexp_split_to_array(btrim(${scope} #>> '{}'), '\\s+')
2690
+ when 'array' then array(select jsonb_array_elements_text(${scope}))
2691
+ else '{}'::text[]
2692
+ end) s(scope)`;
2693
+ }
2694
+ /**
2695
+ * `permdock_token_scope_allows(p_grant)`: a delegated token (a `client_id`, or
2696
+ * an `act` that is not a support or impersonation session) reaches a grant key
2697
+ * only when one of its scopes maps to the key's permission. better-supabase's
2698
+ * `cap_allows` reads the same tokens the same way.
2699
+ */
2700
+ function tokenScopeAllowsSql(ctx, plan, anonExecute) {
2701
+ const fn = qualified(ctx, TOKEN_SCOPE_ALLOWS);
2702
+ const act = subjectClaimJsonSql(ctx, "act");
2703
+ const grants = anonExecute ? `revoke execute on function ${fn}(text) from public;
2704
+ grant execute on function ${fn}(text) to anon, authenticated;` : `revoke execute on function ${fn}(text) from public, anon;
2705
+ grant execute on function ${fn}(text) to authenticated;`;
2706
+ return `-- Token scopes: a delegated token holds only the permissions its scope claim maps to; denies always apply, and a token without client_id or an agent act is not limited
2707
+ create or replace function ${fn}(p_grant text)
2708
+ returns boolean
2709
+ language sql
2710
+ stable
2711
+ security definer
2712
+ set search_path = ''
2713
+ as $$
2714
+ select case
2715
+ when coalesce(${subjectClaimSql(ctx, "client_id")}, '') = ''
2716
+ and (${act} is null
2717
+ or ${act} = 'null'::jsonb
2718
+ or (jsonb_typeof(${act}) = 'object'
2719
+ and ${act} ->> 'kind' in ('support', 'impersonation'))) then true
2720
+ when exists (
2721
+ select 1 from ${qualified(ctx, "role_permissions")} rp
2722
+ where rp.grant_key = p_grant and rp.effect = 'deny'
2723
+ ) then true
2724
+ else exists (
2725
+ select 1
2726
+ from ${tokenScopeRows(ctx)}
2727
+ where s.scope <> ''
2728
+ and coalesce(${quoteLiteral(JSON.stringify(plan.scopes))}::jsonb -> s.scope, '[]'::jsonb)
2729
+ ? split_part(split_part(p_grant, '#', 1), '@', 1)
2730
+ )
2731
+ end
2732
+ $$;
2733
+ ${grants}`;
2734
+ }
2735
+ //#endregion
2378
2736
  //#region src/cli/rls/helpers.ts
2379
2737
  /**
2380
2738
  * The policy-side call for a grant key. Every form is uncorrelated, so
2381
2739
  * Postgres runs the helper once per statement (an InitPlan, or a hashed
2382
2740
  * SubPlan for `in`), never once per row.
2383
2741
  */
2384
- function accessSql(ctx, scope, grantKey, column) {
2742
+ function accessSql(ctx, scope, grantKey, column, helper) {
2385
2743
  const key = quoteLiteral(grantKey);
2386
2744
  if (scope === "global") return `(select ${qualified(ctx, HELPERS.has)}(${key}))`;
2387
2745
  if (column === void 0) throw configError(`PermDock CLI: ${scope}-scoped grant needs definePolicy({ scopes.${scope} })`);
2388
- return `${quoteIdent(column)} in (select ${qualified(ctx, permittedIdsHelper(scope))}(${key}))`;
2746
+ return `${quoteIdent(column)} in (select ${qualified(ctx, helper ?? permittedIdsHelper(scope))}(${key}))`;
2747
+ }
2748
+ /**
2749
+ * `permitted_<parent>_ids(p_grant)` for each \`rls.tables\` \`via\`: the parent
2750
+ * rows whose scope instance the grant reaches. \`security definer\`, so the
2751
+ * caller needs no read on the parent, and called uncorrelated like
2752
+ * \`permitted_<scope>_ids\`.
2753
+ */
2754
+ function viaHelpersSql(ctx, anonExecute) {
2755
+ const done = /* @__PURE__ */ new Set();
2756
+ const chunks = [];
2757
+ for (const via of Object.values(ctx.vias ?? {})) {
2758
+ if (done.has(via.helper)) continue;
2759
+ done.add(via.helper);
2760
+ chunks.push(`-- rows of ${via.table} whose ${via.column} is a ${via.scope} the grant reaches
2761
+ ${helperFunction(ctx, via.helper, `setof ${via.type}`, sqlBody(` select p.${quoteIdent(via.id)}
2762
+ from ${quoteQualified(via.table)} p
2763
+ where p.${quoteIdent(via.column)} in (select ${qualified(ctx, permittedIdsHelper(via.scope))}(p_grant))`), anonExecute)}`);
2764
+ }
2765
+ return chunks;
2389
2766
  }
2390
2767
  function userIdSql(ctx, anonExecute) {
2391
2768
  const fn = userIdHelperSql(ctx);
@@ -2473,6 +2850,40 @@ ${rows.map((row) => ` (${[
2473
2850
  );`;
2474
2851
  }
2475
2852
  /**
2853
+ * `role_permissions` as a view over the compiled rows (`rls.seeds: 'view'`).
2854
+ * `security_invoker` makes the view read as its caller, so it adds no
2855
+ * privilege; the table's revoke applies to the view the same way.
2856
+ */
2857
+ function seedViewSql(ctx, rows) {
2858
+ const view = qualified(ctx, "role_permissions");
2859
+ const columns = "role, permission, grant_key, scope, effect";
2860
+ return `-- rls.seeds: 'view': role_permissions is a view, so the rows travel with the schema
2861
+ create or replace view ${view} (${columns}) with (security_invoker = true) as
2862
+ ${rows.length === 0 ? `select null::text, null::text, null::text, null::text, null::text where false` : `select * from (values\n${rows.map((row) => ` (${[
2863
+ row.role,
2864
+ row.permission,
2865
+ row.grantKey,
2866
+ row.scope,
2867
+ row.effect
2868
+ ].map(quoteLiteral).join(", ")})`).join(",\n")}\n) as v(${columns})`};
2869
+ revoke all on table ${view} from anon, authenticated, public;`;
2870
+ }
2871
+ /** `<schema>.seed()`: the same upsert and delete as `seedSql`, for a declarative schema that carries no rows. */
2872
+ function seedFunctionSql(ctx, rows) {
2873
+ const fn = qualified(ctx, "seed");
2874
+ return `-- rls.seeds: 'function': the role_permissions rows are data, so a declarative diff leaves them out; run select ${fn}(); after each deploy
2875
+ create or replace function ${fn}()
2876
+ returns void
2877
+ language sql
2878
+ volatile
2879
+ security definer
2880
+ set search_path = ''
2881
+ as $permdock$
2882
+ ${seedSql(ctx, rows)}
2883
+ $permdock$;
2884
+ revoke execute on function ${fn}() from public, anon, authenticated;`;
2885
+ }
2886
+ /**
2476
2887
  * `role_permissions`, its seeds, and the three `security definer` helpers
2477
2888
  * with `search_path = ''`. `execute` goes to `authenticated` only.
2478
2889
  */
@@ -2482,9 +2893,13 @@ function helpersSql(ctx, rows, options) {
2482
2893
  const rp = qualified(ctx, "role_permissions");
2483
2894
  const chunks = [`-- permdock helpers (${ctx.authorize === "database" ? "database: reads the membership and user_roles tables" : "jwt: reads the role and memberships claims"})
2484
2895
  -- policies call them uncorrelated, so Postgres evaluates each once per statement`];
2485
- if (schema !== "public") chunks.push(`create schema if not exists ${s};\nrevoke all on schema ${s} from public;\ngrant usage on schema ${s} to ${options.anonExecute === true ? "anon, authenticated" : "authenticated"};`);
2896
+ if (schema !== "public") {
2897
+ const anonUsage = options.anonExecute === true || ctx.capabilities === true;
2898
+ chunks.push(`create schema if not exists ${s};\nrevoke all on schema ${s} from public;\ngrant usage on schema ${s} to ${anonUsage ? "anon, authenticated" : "authenticated"};`);
2899
+ }
2486
2900
  if (ctx.dialect === "supabase") chunks.push(userIdSql(ctx, options.anonExecute === true));
2487
- chunks.push(`create table if not exists ${rp} (
2901
+ if (ctx.seeds === "view") chunks.push(seedViewSql(ctx, rows));
2902
+ else chunks.push(`create table if not exists ${rp} (
2488
2903
  role text not null,
2489
2904
  permission text not null,
2490
2905
  grant_key text not null,
@@ -2494,7 +2909,8 @@ function helpersSql(ctx, rows, options) {
2494
2909
  );
2495
2910
  alter table ${rp} enable row level security;
2496
2911
  revoke all on table ${rp} from anon, authenticated, public;`);
2497
- if (options.withoutSeeds !== true) chunks.push(seedSql(ctx, rows));
2912
+ if (ctx.seeds === "function") chunks.push(seedFunctionSql(ctx, rows));
2913
+ else if (options.withoutSeeds !== true && ctx.seeds !== "view") chunks.push(seedSql(ctx, rows));
2498
2914
  if (ctx.authorize === "database" && options.userRoles && ctx.roles === void 0) {
2499
2915
  const ur = qualified(ctx, "user_roles");
2500
2916
  chunks.push(`create table if not exists ${ur} (
@@ -2510,6 +2926,7 @@ revoke all on table ${ur} from anon, authenticated, public;`);
2510
2926
  const anon = options.anonExecute === true;
2511
2927
  const keys = ctx.apiKeys;
2512
2928
  if (keys !== void 0) chunks.push(apiKeyAllowsSql(ctx, keys, anon));
2929
+ if (ctx.tokenScopes !== void 0) chunks.push(tokenScopeAllowsSql(ctx, ctx.tokenScopes, anon));
2513
2930
  const bodies = grantBodies(ctx);
2514
2931
  chunks.push(helperFunction(ctx, HELPERS.has, "boolean", bodies.has, anon));
2515
2932
  const capabilities = capabilitiesSql(ctx);
@@ -2521,6 +2938,7 @@ revoke all on table ${ur} from anon, authenticated, public;`);
2521
2938
  if (ctx.roleMemberScopes?.includes(scope.name) === true) chunks.push(memberRolesFunction(ctx, scope.name, type, anon));
2522
2939
  if (hasMemberFor(ctx, scope.name)) chunks.push(memberForFunction(ctx, scope.name, type));
2523
2940
  }
2941
+ chunks.push(...viaHelpersSql(ctx, anon));
2524
2942
  const forUser = forUserSql(ctx);
2525
2943
  if (forUser !== "") chunks.push(forUser);
2526
2944
  const writes = customRoleWritesSql(ctx, options.levelReach ?? []);
@@ -2528,6 +2946,12 @@ revoke all on table ${ur} from anon, authenticated, public;`);
2528
2946
  chunks.push(writes);
2529
2947
  const cascade = customRoleCascadeSql(ctx);
2530
2948
  if (cascade !== "") chunks.push(cascade);
2949
+ const sync = ctx.customRoles?.sync;
2950
+ if (sync !== void 0) chunks.push(sync);
2951
+ const byId = customRoleByIdSql(ctx, options.trustedReaders);
2952
+ if (byId !== "") chunks.push(byId);
2953
+ const nameGuard = customRoleNameGuardSql(ctx);
2954
+ if (nameGuard !== "") chunks.push(nameGuard);
2531
2955
  }
2532
2956
  const globalRoles = globalRolesSql(ctx);
2533
2957
  if (globalRoles !== "") chunks.push(globalRoles);
@@ -2537,20 +2961,26 @@ revoke all on table ${ur} from anon, authenticated, public;`);
2537
2961
  }
2538
2962
  function grantBodies(ctx) {
2539
2963
  const keys = ctx.apiKeys;
2964
+ const scoped = ctx.tokenScopes !== void 0;
2540
2965
  const set = ctx.grantSet === true;
2541
- const allows = apiKeyAllowsCall(ctx, set ? "ids.grant_key" : "p_grant");
2542
- const keyTenant = keyTenantSql(ctx, void 0) ?? "true";
2966
+ const ceiling = (grant) => {
2967
+ const calls = [...keys === void 0 ? [] : [apiKeyAllowsCall(ctx, grant)], ...scoped ? [tokenScopeAllowsCall(ctx, grant)] : []];
2968
+ return calls.length === 0 ? void 0 : calls.join(" and ");
2969
+ };
2970
+ const allows = ceiling(set ? "ids.grant_key" : "p_grant");
2971
+ const keyTenant = keys === void 0 ? "" : ` and ${keyTenantSql(ctx, void 0) ?? "true"}`;
2972
+ const hasAllows = ceiling(set ? "g.grant_key" : "p_grant");
2543
2973
  return {
2544
- has: sqlBody(keys === void 0 ? hasBody(ctx) : set ? ` select g.grant_key
2974
+ has: sqlBody(hasAllows === void 0 ? hasBody(ctx) : set ? ` select g.grant_key
2545
2975
  from (
2546
2976
  ${hasBody(ctx)}
2547
2977
  ) g(grant_key)
2548
- where ${apiKeyAllowsCall(ctx, "g.grant_key")} and ${keyTenant}` : ceilingHasSql(hasBody(ctx), `${allows} and ${keyTenant}`)),
2978
+ where ${hasAllows}${keyTenant}` : ceilingHasSql(hasBody(ctx), `${hasAllows}${keyTenant}`)),
2549
2979
  ids: (scope) => {
2550
2980
  const type = scopeTypeOf(ctx, scope);
2551
2981
  const body = scopedBody(ctx, scope, type);
2552
- if (keys === void 0) return body;
2553
- const sql = scope === rootName(ctx) ? `${body.sql}\n union\n${serviceKeyIdsSql(ctx, keys, scope, type)}` : body.sql;
2982
+ if (allows === void 0) return body;
2983
+ const sql = keys !== void 0 && scope === rootName(ctx) ? `${body.sql}\n union\n${serviceKeyIdsSql(ctx, keys, scope, type)}` : body.sql;
2554
2984
  return {
2555
2985
  sql: set ? ` select ids.id, ids.grant_key
2556
2986
  from (
@@ -2563,7 +2993,7 @@ ${sql}
2563
2993
  };
2564
2994
  }
2565
2995
  function forUserContext(ctx) {
2566
- const { apiKeys: _apiKeys, ...unkeyed } = ctx;
2996
+ const { apiKeys: _apiKeys, tokenScopes: _tokenScopes, ...unkeyed } = ctx;
2567
2997
  return {
2568
2998
  ...unkeyed,
2569
2999
  subjectId: "p_user",
@@ -2909,7 +3339,7 @@ function canAssignCustomSql(ctx, forUser = false) {
2909
3339
  const guard = qualified(ctx, forUser ? CUSTOM_ROLES.guardFor : CUSTOM_ROLES.guard);
2910
3340
  const userParam = forUser ? "p_user uuid, " : "";
2911
3341
  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)`;
3342
+ 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
3343
  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
3344
  grant execute on function ${fn}(${tenantType}, text, text, text) to authenticated;`;
2915
3345
  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" : ""}
@@ -3586,6 +4016,42 @@ function ownershipRules(policy, scopes) {
3586
4016
  };
3587
4017
  }
3588
4018
  /**
4019
+ * The roles whose `for` lists kinds that no membership source of the role's
4020
+ * scope can hold: such a role resolves to no one. A `via` read from a column
4021
+ * can hold any kind, and so does a source with no `via` (it is not
4022
+ * compared) and a scope with no table or source.
4023
+ */
4024
+ function unsatisfiedKinds(ctx, policy) {
4025
+ const out = [];
4026
+ for (const [role, kinds] of Object.entries(ctx.ownership?.kinds ?? {})) {
4027
+ const scope = roleScope(policy, ctx.scopes, role);
4028
+ if (scope === void 0) continue;
4029
+ const held = [];
4030
+ const mapped = scopeTable(ctx, scope)?.table;
4031
+ if (mapped !== void 0) {
4032
+ const via = memberVia(mapped);
4033
+ held.push({
4034
+ label: mapped.table,
4035
+ via: typeof via === "string" ? void 0 : via.value ?? void 0
4036
+ });
4037
+ } else for (const source of ctx.memberSources ?? ctx.sources ?? []) {
4038
+ if (source.sql.scope !== void 0 && source.sql.scope !== scope) continue;
4039
+ const via = source.sql.manifest.via;
4040
+ held.push({
4041
+ label: source.sql.table,
4042
+ via: via === void 0 ? void 0 : "value" in via && typeof via.value === "string" ? via.value : void 0
4043
+ });
4044
+ }
4045
+ if (held.length === 0 || held.some((entry) => entry.via === void 0 || kinds.includes(entry.via ?? ""))) continue;
4046
+ out.push({
4047
+ role,
4048
+ for: kinds,
4049
+ sources: held.map((entry) => `${entry.label} (via ${entry.via ?? ""})`)
4050
+ });
4051
+ }
4052
+ return out;
4053
+ }
4054
+ /**
3589
4055
  * The ownership objects: holder-count and transfer-only triggers on each
3590
4056
  * scope's membership table, and `permdock_can_assign` for the application's
3591
4057
  * own policies on those tables. Empty when no role declares a rule.
@@ -3631,4 +4097,4 @@ function ownershipSql(ctx) {
3631
4097
  return chunks.length === 0 ? "" : `${chunks.join("\n\n")}\n`;
3632
4098
  }
3633
4099
  //#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 };
4100
+ export { permittedForHelper as $, apiKeyFields as A, sqlFunctionNames as B, globalRoleSource as C, byKey as D, byCodeUnit as E, collectGrants as F, hasMemberFor as G, graphHelper as H, roleNames as I, memberForHelper as J, hasPermittedUsers as K, andConditions as L, assignableRoles as M, breakGlassHolder as N, API_KEY_ALLOWS as O, breakGlassKey as P, memberRoleOf as Q, compileConditionSql as R, globalRoleRows as S, rolesTableScope as T, graphSqlName as U, CLOSURE as V, graphSqlText as W, memberForTable as X, memberForSources as Y, memberIdsHelper as Z, seedSql as _, assignmentSql as a, activeUserSql as at, tokenScopesPlan as b, OWNERSHIP as c, permissionHelpersSql as d, permittedIdsHelper as et, permissionKeysForHelper as f, helpersSql as g, accessSql as h, holderSources as i, activeRowSql as it, apiKeysPlan as j, apiKeyAllowsCall as k, PERMISSION_HELPERS as l, requiresSql as m, ownershipSql as n, scopeSources as nt, guardedTables as o, checkSuspension as ot, permittedByPermissionHelper as p, linkHelper as q, unsatisfiedKinds as r, scopeTable as rt, ASSIGNMENTS as s, ownershipRules as t, restrictedHelper as tt, checkName as u, TOKEN_SCOPE_ALLOWS as v, currentKeyExpr as w, capabilityAccessSql as x, tokenScopeAllowsCall as y, contextRefs as z };