permdock 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -47
- package/dist/a2a/index.d.ts +7 -16
- package/dist/a2a/index.js +59 -98
- package/dist/adapter-context-mlWsVGGg.js +23 -0
- package/dist/ai-sdk/index.d.ts +37 -16
- package/dist/ai-sdk/index.js +30 -13
- package/dist/approvals/index.d.ts +3 -3
- package/dist/approvals/index.js +5 -4
- package/dist/{approvals-DaxY-Bdg.js → approvals-DbYAuNkG.js} +17 -11
- package/dist/approvers-DT4AVyIg.js +52 -0
- package/dist/{arazzo-CTeVuXr5.js → arazzo-BAgw9ap6.js} +2 -2
- package/dist/{arazzo-aoTkqYsJ.js → arazzo-CKwwqFWz.js} +2 -2
- package/dist/authzen/index.d.ts +4 -13
- package/dist/authzen/index.js +16 -50
- package/dist/better-auth/index.d.ts +20 -3
- package/dist/better-auth/index.js +27 -7
- package/dist/better-supabase/index.d.ts +223 -0
- package/dist/better-supabase/index.js +648 -0
- package/dist/bind-C_ob3LKa.d.ts +25 -0
- package/dist/bind-CuzGytRf.js +97 -0
- package/dist/boundary-BM8a_P6E.js +13 -0
- package/dist/boundary-BmHxVH1J.d.ts +15 -0
- package/dist/canonical-json-DemiK4Vp.js +26 -0
- package/dist/{capability-BDG_nkxt.d.ts → capability-2kYTMWDv.d.ts} +2 -2
- package/dist/{capability-B68OFnZx.js → capability-4qPdO9gk.js} +7 -5
- package/dist/catalog/index.d.ts +1 -1
- package/dist/catalog/index.js +1 -1
- package/dist/catalog-CRW5Mx_B.js +2 -0
- package/dist/{catalog-H-vFGwWK.js → catalog-DWF5cYqB.js} +4 -4
- package/dist/{catalog-B0E6bggc.js → catalog-dFZdWCGN.js} +2 -2
- package/dist/{catalog-doc-BSo_Q_-6.js → catalog-doc-DdGm82XT.js} +20 -32
- package/dist/claude-agent/index.d.ts +5 -14
- package/dist/claude-agent/index.js +4 -10
- package/dist/clerk/index.d.ts +12 -3
- package/dist/clerk/index.js +26 -5
- package/dist/cli/bin.js +1 -1
- package/dist/cli/index.d.ts +3 -3
- package/dist/cli/index.js +2 -2
- package/dist/clients-C7zLFuWT.d.ts +12 -0
- package/dist/clients-DjU5ABMy.js +23 -0
- package/dist/cloud/index.d.ts +4 -5
- package/dist/cloud/index.js +20 -6
- package/dist/{cloud-BNQTrmrr.js → cloud-CB_ELYuX.js} +2 -2
- package/dist/{cloud-Cuxr6Czw.js → cloud-DWLXO2af.js} +7 -6
- package/dist/collect-DClJsheJ.js +83 -0
- package/dist/{collect-O-tOtbnk.js → collect-DSTE3ml9.js} +194 -42
- package/dist/collect-DSWOk19R.js +2 -0
- package/dist/{commands-BMh_Etm2.js → commands-DSxCqG9A.js} +16 -12
- package/dist/compile/index.d.ts +3 -0
- package/dist/compile/index.js +2 -0
- package/dist/{compile-D22iwvuV.js → compile-Ba4blROR.js} +85 -30
- package/dist/compile-BkRyBDBw.d.ts +94 -0
- package/dist/config-BJiM-xAm.js +157 -0
- package/dist/config-Dnszbv-6.js +36 -0
- package/dist/config-report-DMvWKUxL.js +44 -0
- package/dist/{connection-JvqwHODC.js → connection-CU8MQxRi.js} +1 -1
- package/dist/{context-CKEAabd7.js → context-Bcy85rGi.js} +36 -27
- package/dist/convex/index.d.ts +9 -4
- package/dist/convex/index.js +14 -4
- package/dist/create-B1d519XK.d.ts +245 -0
- package/dist/create-CMwIl-w2.js +253 -0
- package/dist/{credential-B_rkUNlY.js → credential-8P81ff4u.js} +26 -46
- package/dist/delegation-D8pUEuce.js +103 -0
- package/dist/{describe-Bt1YD5uS.js → describe-B_gVZEVi.js} +10 -15
- package/dist/{describe-DernyItf.d.ts → describe-DMYiCjJV.d.ts} +9 -10
- package/dist/{diff-x5Il8LVa.js → diff-2VaeChzX.js} +44 -11
- package/dist/{diff-BUzf5Poi.js → diff-DBIhz_f2.js} +2 -2
- package/dist/{doctor-Bqo6FwUa.js → doctor-BwCAMLx_.js} +2 -2
- package/dist/doctor-collect-CHk6XWOA.js +665 -0
- package/dist/doctor-igIiM_Hl.js +1194 -0
- package/dist/drizzle/index.d.ts +4 -3
- package/dist/drizzle/index.js +3 -3
- package/dist/elysia/index.d.ts +6 -28
- package/dist/elysia/index.js +16 -60
- package/dist/{errors-DSMnAVNa.js → errors-CWfoNg9N.js} +8 -4
- package/dist/{errors-Iy71s7Oj.js → errors-Cz5zn0mW.js} +17 -71
- package/dist/{errors-DbZ1YOnb.d.ts → errors-DPdqI50v.d.ts} +8 -21
- package/dist/{errors-m3u9TmDu.js → errors-DRnGZHzF.js} +4 -1
- package/dist/evaluation-items-CfCCejQd.js +40 -0
- package/dist/{evaluations-DsAU9ppl.js → evaluations-tz6D9A2G.js} +28 -46
- package/dist/eve/index.d.ts +6 -15
- package/dist/eve/index.js +7 -12
- package/dist/expiry-BoBJ8Tvc.js +7 -0
- package/dist/express/index.d.ts +6 -29
- package/dist/express/index.js +28 -58
- package/dist/fastify/index.d.ts +6 -29
- package/dist/fastify/index.js +16 -65
- package/dist/fields-BMl1RxoX.js +26 -0
- package/dist/{files-B_wAGYmt.js → files-DWUN9PzI.js} +5 -1
- package/dist/{fixtures-Cvaw0gDG.js → fixtures-ht28Cplr.js} +5 -2
- package/dist/{freeze-y1_jQL4v.js → freeze-CrU8nQeX.js} +31 -2
- package/dist/{from-snapshot-BMGnhmgv.js → from-snapshot-CTLijvdm.js} +66 -48
- package/dist/{grantee-Bv04_Vzf.js → grantee-Cv5Sogfv.js} +88 -7
- package/dist/graph-sql-B-dsAT6j.d.ts +29 -0
- package/dist/{headers-B5RRv3Xm.js → headers-BZSDieNT.js} +1 -1
- package/dist/{headers-C8SHQccD.d.ts → headers-Cf-zWvsO.d.ts} +1 -1
- package/dist/helpers-H_KARDVC.js +421 -0
- package/dist/hono/index.d.ts +6 -28
- package/dist/hono/index.js +11 -43
- package/dist/{hosted-BwKQ1BTA.js → hosted-DJQw46hn.js} +6 -5
- package/dist/{http-GgVApX1k.js → http-BIhmLfCc.js} +4 -20
- package/dist/http-SO7qGttf.js +44 -0
- package/dist/ids-DC4V4CFu.js +18 -0
- package/dist/index-0nNqHspG.d.ts +6 -0
- package/dist/{index-xIxJLwOQ.d.ts → index-BNc7Wer_.d.ts} +75 -7
- package/dist/{index-CltcQ4Bf.d.ts → index-C7Tw7MaW.d.ts} +68 -5
- package/dist/{index-LF_hS-yn.d.ts → index-C8z4RjQV.d.ts} +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.js +22 -17
- package/dist/instance-options-Dhhcnm3y.js +22 -0
- package/dist/instance-options-jARdwfPN.d.ts +11 -0
- package/dist/{parse-DPfIvFMU.js → json-schema-BsAflUGW.js} +28 -57
- package/dist/jwt/index.d.ts +10 -6
- package/dist/jwt/index.js +32 -13
- package/dist/kernel-_RRHmnMY.js +208 -0
- package/dist/kysely/index.d.ts +4 -3
- package/dist/kysely/index.js +3 -3
- package/dist/live-options-H2eG8dfJ.js +60 -0
- package/dist/{load-BZ24CpZP.js → load-DhWEB9P1.js} +16 -10
- package/dist/load-DwJUafYN.js +2 -0
- package/dist/manifest-iaoBXHKc.js +783 -0
- package/dist/manifest-uDS9y5de.d.ts +206 -0
- package/dist/{map-DowSO96j.js → map-AcxK9o_D.js} +4 -15
- package/dist/{map-claims-B_6I1Rn-.js → map-claims-Z4QZOiFF.js} +13 -4
- package/dist/map-error-pgUWNNtG.js +23 -0
- package/dist/may-use-DndQ5tQ-.js +77 -0
- package/dist/mcp/index.d.ts +54 -22
- package/dist/mcp/index.js +218 -178
- package/dist/nest/index.d.ts +26 -31
- package/dist/nest/index.js +75 -84
- package/dist/next/client.d.ts +7 -8
- package/dist/next/client.js +11 -9
- package/dist/next/index.d.ts +30 -47
- package/dist/next/index.js +45 -60
- package/dist/next/plugin.d.ts +1 -1
- package/dist/next/plugin.js +32 -1
- package/dist/node/index.d.ts +9 -28
- package/dist/node/index.js +11 -43
- package/dist/openai/index.d.ts +6 -15
- package/dist/openai/index.js +7 -20
- package/dist/openapi/index.d.ts +87 -4
- package/dist/openapi/index.js +3 -3
- package/dist/{openapi-DcqM8nU7.js → openapi-BqRBvAng.js} +237 -5
- package/dist/{openapi-DWD0Uc_4.js → openapi-CqqEbHsL.js} +5 -5
- package/dist/{openapi-import-BWRUNNeW.js → openapi-import-cmwetbzw.js} +1 -1
- package/dist/{openapi-CwePaF7C.js → openapi-l0xYHkcv.js} +3 -3
- package/dist/{openapi-schema-D8j_T9tO.js → openapi-schema--ARjyfQr.js} +2 -2
- package/dist/orpc/index.d.ts +15 -28
- package/dist/orpc/index.js +74 -117
- package/dist/otel/index.d.ts +2 -2
- package/dist/parse-BuyjxhZV.js +53 -0
- package/dist/parse-local-manifest-BrMozJCo.js +36 -0
- package/dist/parse-local-manifest-JLnaBH0S.d.ts +10 -0
- package/dist/pdp/index.d.ts +2 -2
- package/dist/pdp/index.js +14 -127
- package/dist/{permdock-yoZ7EjxX.js → permdock-CbgvLnuo.js} +672 -149
- package/dist/{permissions-B8vCg_bI.d.ts → permissions-CDgvKG5L.d.ts} +2 -2
- package/dist/{permissions-DC7GROPA.js → permissions-DVoF6dMt.js} +77 -15
- package/dist/{permissions-B4apnahQ.d.ts → permissions-DttnqqxC.d.ts} +34 -5
- package/dist/{permissions-CofRqesP.js → permissions-RJrovw98.js} +3 -3
- package/dist/{pins-CqE1jZuF.js → pins-8ILTftC9.js} +2 -2
- package/dist/{policy-Cq2fB8Q5.d.ts → policy-D2W7uv19.d.ts} +2 -2
- package/dist/{policy-CInmNGkM.d.ts → policy-IB6yxpC3.d.ts} +351 -42
- package/dist/{policy-ANp36PnI.js → policy-xyWg7QdR.js} +173 -22
- package/dist/powersync-BylawD_U.js +540 -0
- package/dist/powersync-DliZK_-y.js +62 -0
- package/dist/powersync-e_AZrKbg.js +2 -0
- package/dist/prisma/index.d.ts +3 -2
- package/dist/prisma/index.js +45 -20
- package/dist/{problem-Cgp9-KZA.js → problem-B43ztKIm.js} +74 -109
- package/dist/problem-details-DX075l9V.d.ts +7 -0
- package/dist/problem-details-_AMOYz-w.js +144 -0
- package/dist/{protected-BuPAycJT.d.ts → protected-BdnIEFkY.d.ts} +5 -4
- package/dist/protected-utU-SLuy.js +100 -0
- package/dist/protected-xAr24FEC.d.ts +40 -0
- package/dist/provider-CSQd7m92.js +52 -0
- package/dist/{provider-wTAUda2v.d.ts → provider-DZY281jO.d.ts} +1 -1
- package/dist/provider-store-Cfbvo4DE.js +43 -0
- package/dist/react/index.d.ts +7 -5
- package/dist/react/index.js +5 -4
- package/dist/react/provider-client.d.ts +1 -1
- package/dist/react/provider-client.js +1 -1
- package/dist/react-native/index.d.ts +166 -10
- package/dist/react-native/index.js +607 -85
- package/dist/{refs-ChLSxbST.js → refs-KYb4ntaz.js} +4 -3
- package/dist/{relations-r4soIFZB.js → relations-cjOL01hZ.js} +110 -31
- package/dist/replay-BsOyTrdV.js +28 -0
- package/dist/replay-CYyiLiwO.d.ts +5 -0
- package/dist/resource-ref-CaS9yVPi.js +20 -0
- package/dist/revocations-Bhr0aMIC.js +27 -0
- package/dist/{rls-BOFU3chG.js → rls-B4ur8nKQ.js} +8 -3
- package/dist/rls-DQtdgTHB.js +2521 -0
- package/dist/rls-fields-74oZPXWL.js +3582 -0
- package/dist/{rls-graph-DxQ39PJj.js → rls-graph-CHwx0V8q.js} +100 -36
- package/dist/{rls-import-CXV6LBlS.js → rls-import-BTFVk3jH.js} +7 -6
- package/dist/{rls-migrate-DrFPbf8p.js → rls-migrate-CN4TlJTO.js} +6 -5
- package/dist/rls-ownership-RuFqaqGz.js +843 -0
- package/dist/rls-shared-Cewd_-zI.js +495 -0
- package/dist/rls-verify-CVQTHTh5.js +746 -0
- package/dist/{row-check-BajTgQZ-.d.ts → row-check-Dt35czLy.d.ts} +12 -15
- package/dist/row-conditions-h2HythXQ.js +27 -0
- package/dist/rpc-DL9Sqvb1.js +89 -0
- package/dist/{run-CJ6UvS3a.js → run-DKDbM4Du.js} +31 -12
- package/dist/runtime-CB8gQFvS.js +11 -0
- package/dist/{saas-BRrZzJkM.js → saas-DhpJjnBA.js} +17 -7
- package/dist/{schema-P8yoma__.js → schema-D3MnDMxa.js} +8 -4
- package/dist/scim/index.d.ts +1 -1
- package/dist/scim/index.js +1 -1
- package/dist/{scim-9NFa0XVZ.js → scim-BFGBBAqR.js} +1 -1
- package/dist/{scopes-cKVp_WTw.js → scopes-CVdPrWN-.js} +35 -8
- package/dist/server/index.d.ts +22 -11
- package/dist/server/index.js +11 -8
- package/dist/{credentials-BgRX3WEg.js → server-DlZ28Oz5.js} +1 -1
- package/dist/shared-DRu8VFVM.js +123 -0
- package/dist/{sink-Cfr9-7YC.d.ts → sink-B1fZzIMf.d.ts} +1 -1
- package/dist/skills-Biq5uup3.js +192 -0
- package/dist/{skills-C_lpV4Jx.js → skills-Bx-U0irG.js} +10 -4
- package/dist/{snapshot-DE8dE_hz.js → snapshot-Enxd0n-s.js} +138 -214
- package/dist/snapshot-cache-B-LxUahj.d.ts +45 -0
- package/dist/snapshot-cache-D6jLelln.js +44 -0
- package/dist/solid/index.d.ts +39 -9
- package/dist/solid/index.js +76 -85
- package/dist/sources-4p0-4xQm.js +545 -0
- package/dist/sources-BdOYLw9j.d.ts +192 -0
- package/dist/{sql-Bll1d7y0.js → sql-xdg5f4Fi.js} +6 -1
- package/dist/{src-CTNT-PBW.js → src-BDwEZWrZ.js} +149 -11
- package/dist/ssf/index.d.ts +3 -2
- package/dist/ssf/index.js +3 -29
- package/dist/store-DMAWwAJV.js +371 -0
- package/dist/{store-Bq90395i.d.ts → store-jOBYpiXC.d.ts} +13 -4
- package/dist/store-options-ByhZq1jV.js +716 -0
- package/dist/{stores-CkG2HrSg.js → stores-BT2pSCub.js} +10 -63
- package/dist/{stores-CvOnu_2T.d.ts → stores-C0IMQrGI.d.ts} +20 -9
- package/dist/{stream-BhMfZOwr.d.ts → stream-D1RXubw2.d.ts} +2 -2
- package/dist/{stream-Q_xlB6Ko.js → stream-DDpvynSB.js} +16 -2
- package/dist/subject-settings-BwBmCdTR.js +128 -0
- package/dist/supabase/index.d.ts +133 -6
- package/dist/supabase/index.js +4 -3
- package/dist/supabase/middleware.d.ts +32 -27
- package/dist/supabase/middleware.js +56 -22
- package/dist/{supabase-D8UlBA7Z.js → supabase-Boqb9a_Y.js} +3 -3
- package/dist/{supabase-DTxGtYdi.js → supabase-C0qESOa-.js} +347 -367
- package/dist/supabase-config-BpPXt3x1.js +472 -0
- package/dist/supabase-hook-6sYLXg_u.js +2 -0
- package/dist/{supabase-hook-D_OqGb_V.js → supabase-hook-CSLQ4zJ3.js} +1037 -705
- package/dist/supautils-Cn4R4cJ4.js +91 -0
- package/dist/svelte/PermissionBoundary.svelte +37 -0
- package/dist/svelte/Protected.svelte +8 -2
- package/dist/svelte/index.d.ts +9 -6
- package/dist/svelte/index.js +61 -7
- package/dist/svelte/runtime.d.ts +4 -3
- package/dist/svelte/runtime.js +4 -3
- package/dist/svelte/source.d.ts +9 -6
- package/dist/svelte/source.js +7 -4
- package/dist/terminal/index.d.ts +6 -14
- package/dist/terminal/index.js +44 -32
- package/dist/testing/index.d.ts +105 -16
- package/dist/testing/index.js +504 -40
- package/dist/testing/saas/index.d.ts +4 -4
- package/dist/testing/saas/index.js +2 -2
- package/dist/testing/saas/permissions.d.ts +1 -1
- package/dist/testing/saas/permissions.js +1 -1
- package/dist/token-CIWxJOCh.js +50 -0
- package/dist/trpc/index.d.ts +7 -26
- package/dist/trpc/index.js +17 -100
- package/dist/types-BnIEz1sU.d.ts +48 -0
- package/dist/{types-CxquUByI.d.ts → types-Bvc4oWT6.d.ts} +1 -1
- package/dist/{sources-DOpW5Ila.d.ts → types-C97IskBw.d.ts} +86 -127
- package/dist/types-CKNSyIf_.d.ts +52 -0
- package/dist/types-Cq_H8N_X.d.ts +197 -0
- package/dist/{types-D6Z6-rKJ.d.ts → types-DbjRN55U.d.ts} +273 -22
- package/dist/{types-BBK-exvJ.d.ts → types-Dnemwd77.d.ts} +7 -1
- package/dist/{types-BvBk1wgn.d.ts → types-DxiCGmmd.d.ts} +5 -3
- package/dist/{types-B9dmiiab.d.ts → types-QCuYJh4q.d.ts} +1 -1
- package/dist/{types-B8w5PFtS.d.ts → types-aqOHTHcp.d.ts} +17 -1
- package/dist/unplugin/index.d.ts +1 -1
- package/dist/unplugin/index.js +10 -9
- package/dist/usage-DorAJiU_.js +2 -0
- package/dist/{usage-1ozMubLY.js → usage-GTixvDDU.js} +2 -2
- package/dist/{usage-CUnK6Ou9.js → usage-ItS7nLcl.js} +225 -62
- package/dist/validation-error-Ba264OaJ.d.ts +26 -0
- package/dist/validation-error-CnjZ4t92.js +33 -0
- package/dist/{validity-D0uRmJKJ.js → validity-uOp8UpYn.js} +57 -36
- package/dist/{verifier-B90agnhl.js → verifier-BxBo6I44.js} +10 -5
- package/dist/version-BvacVTZR.js +17 -0
- package/dist/views-Soe7bGSC.js +95 -0
- package/dist/{vocabulary-Dk0fs-Jk.d.ts → vocabulary-DA8a8zif.d.ts} +1 -1
- package/dist/vue/index.d.ts +35 -13
- package/dist/vue/index.js +86 -93
- package/dist/watch-BwxLTYMZ.js +2 -0
- package/dist/watch-DVBJ9cBq.js +128 -0
- package/dist/webmcp/index.d.ts +2 -2
- package/dist/webmcp/index.js +7 -13
- package/package.json +62 -36
- package/schemas/approval-request-v1.json +175 -0
- package/schemas/catalog-v1.json +29 -1
- package/schemas/supabase-claims-v1.json +6 -1
- package/schemas/supabase-manifest-v1.json +150 -6
- package/skills/permdock/SKILL.md +7 -7
- package/skills/permdock-agents/SKILL.md +5 -5
- package/skills/permdock-agents/references/runtimes.md +12 -10
- package/skills/permdock-approvals/SKILL.md +4 -4
- package/skills/permdock-approvals/references/stores-and-resume.md +15 -4
- package/skills/permdock-audit/SKILL.md +3 -3
- package/skills/permdock-credentials/SKILL.md +4 -4
- package/skills/permdock-credentials/references/tokens-keys-links.md +7 -2
- package/skills/permdock-data/SKILL.md +4 -4
- package/skills/permdock-data/references/queries-and-rls.md +14 -6
- package/skills/permdock-data/references/relationships.md +5 -3
- package/skills/permdock-tenancy/SKILL.md +9 -6
- package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +14 -8
- package/skills/permdock-wire/SKILL.md +4 -4
- package/skills/permdock-wire/references/adapters.md +42 -30
- package/skills/permdock-wire/references/policy-options.md +5 -3
- package/dist/approvers-aH0PJkmf.js +0 -20
- package/dist/catalog-idsHc0vo.js +0 -2
- package/dist/collect-B7_TIfmg.js +0 -49
- package/dist/collect-fRPAQIWb.js +0 -2
- package/dist/compile-CQbvyIBr.d.ts +0 -30
- package/dist/config-BPYmjTOp.js +0 -26
- package/dist/context-gtAwAILx.js +0 -6
- package/dist/create-BKF3dgFk.d.ts +0 -162
- package/dist/create-CSkhDU9T.js +0 -222
- package/dist/doctor-BGle8Gqi.js +0 -1314
- package/dist/helpers-DnSOrhB9.js +0 -682
- package/dist/index-fG6NSXlc.d.ts +0 -9
- package/dist/kernel-YOGCWNLf.js +0 -211
- package/dist/load-QOOm1z70.js +0 -2
- package/dist/manifest-DDQbC6K0.d.ts +0 -118
- package/dist/map-error-RJ3ZsUyA.js +0 -16
- package/dist/may-use-CnFEjVEp.js +0 -37
- package/dist/pg-4GJWDUxG.js +0 -953
- package/dist/plugin-4gr7IxFJ.js +0 -65
- package/dist/protected-B4K3BKoX.js +0 -138
- package/dist/protected-wY7nXhY9.d.ts +0 -23
- package/dist/provider-_wx46HpB.js +0 -48
- package/dist/rls-CJVja9Ww.js +0 -2119
- package/dist/rls-helpers-BxRuif0l.js +0 -1346
- package/dist/runtime-DP9ttFh1.js +0 -27
- package/dist/skills-Dx3QLnMY.js +0 -110
- package/dist/source-DoBBYJi2.js +0 -6
- package/dist/sources-DVHM3Mwq.js +0 -284
- package/dist/store-C6hFW6ta.js +0 -508
- package/dist/subject-settings-xt7V845g.js +0 -82
- package/dist/supabase-hook-DW-xP4hU.js +0 -2
- package/dist/types-DbWdEfcm.d.ts +0 -87
- package/dist/types-bva3i4Gf.d.ts +0 -98
- package/dist/types-i4BAOvdb.d.ts +0 -97
- package/dist/usage-PyEor6GA.js +0 -2
- package/dist/version-CnSh33Fj.js +0 -16
|
@@ -0,0 +1,3582 @@
|
|
|
1
|
+
import { n as isReadonlyArray, r as sole } from "./compact-y6vfu2_f.js";
|
|
2
|
+
import { T as isForbiddenKey, _ as listPermissions, a as formerKeys } from "./permissions-DVoF6dMt.js";
|
|
3
|
+
import { a as hasConditionOp, c as isConditionRef, l as isSqlFunctionField } from "./refs-KYb4ntaz.js";
|
|
4
|
+
import { p as policyLevels } from "./policy-xyWg7QdR.js";
|
|
5
|
+
import { c as rootScope, d as scopeList, o as resolveScope } from "./scopes-CVdPrWN-.js";
|
|
6
|
+
import { E as listRoles, l as inheritCondition, m as relationCondition } from "./grantee-Cv5Sogfv.js";
|
|
7
|
+
import { t as canonicalJson } from "./canonical-json-DemiK4Vp.js";
|
|
8
|
+
import { d as partitionsOf, h as scopeField } from "./validity-uOp8UpYn.js";
|
|
9
|
+
import { t as requiresApproval } from "./approval-required-TzNB9oQA.js";
|
|
10
|
+
import { _ as leveled, p as andWhere } from "./permdock-CbgvLnuo.js";
|
|
11
|
+
import { i as requiresStandIn } from "./delegation-D8pUEuce.js";
|
|
12
|
+
import { t as CliError } from "./errors-CWfoNg9N.js";
|
|
13
|
+
import { o as jsonSchemaOf } from "./catalog-doc-DdGm82XT.js";
|
|
14
|
+
import { i as scopeMembershipTable, o as relatedSql, r as scopeColumn } from "./compile-Ba4blROR.js";
|
|
15
|
+
import { t as SQL_IDENT } from "./sql-xdg5f4Fi.js";
|
|
16
|
+
import { l as roleColumn, u as roleManifest } from "./sources-4p0-4xQm.js";
|
|
17
|
+
import { n as requirePeer } from "./peer-BOr_kJE1.js";
|
|
18
|
+
import { A as keptRowSql, B as memberVia, C as grantPermissionSql, D as hasMemberFor, E as graphSqlText, F as memberForHelper, G as quoteIdent, H as permittedForHelper, I as memberForSources, J as restrictedHelper, K as quoteLiteral, M as kindFilterSql, N as linkHelper, O as inheritedRowsHelper, P as liveSessionSql, Q as scopeTypeOf, R as memberIdsHelper, S as globalKindFilterSql, U as permittedIdsHelper, W as qualifiedTable, X as scopeSources, Y as roleKindSql, Z as scopeTable, _ as activeUserSql, a as helperSchema, at as tenantTypeOf, b as columnTypesOf, c as signedIn, d as CLOSURE, et as sqlType, h as activeMembershipSql, i as allowEntry, it as tenantClaimSql, j as keyTenantSql, k as isRecord, l as textArray, m as activeInstancesSql, n as GLOBAL_ROLES, nt as subjectClaimSql, o as qualified$1, ot as userIdHelperSql, q as quoteTable, r as HELPERS, rt as subjectIdSql, s as rootName, t as CUSTOM_ROLES, tt as subjectClaimJsonSql, u as CLAIM, v as arrayColumnsOf, w as graphHelper, x as disabledKeep, z as memberRoleOf } from "./rls-shared-Cewd_-zI.js";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
//#region src/cli/rls-api-keys.ts
|
|
21
|
+
const API_KEY_ALLOWS = "permdock_api_key_allows";
|
|
22
|
+
const DEFAULTS = {
|
|
23
|
+
claim: "api_key",
|
|
24
|
+
scopes: "scopes",
|
|
25
|
+
tenant: "tenant",
|
|
26
|
+
roles: "roles"
|
|
27
|
+
};
|
|
28
|
+
function field(settings, name) {
|
|
29
|
+
const value = settings[name] ?? DEFAULTS[name];
|
|
30
|
+
if (typeof value !== "string" || !SQL_IDENT.test(value)) throw new Error(`PermDock CLI: rls.apiKeys.${name} must be a claim field name matching ${SQL_IDENT.source}`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function apiKeyFields(setting) {
|
|
34
|
+
const settings = setting === true ? {} : setting;
|
|
35
|
+
return {
|
|
36
|
+
claim: field(settings, "claim"),
|
|
37
|
+
scopes: field(settings, "scopes"),
|
|
38
|
+
tenant: field(settings, "tenant"),
|
|
39
|
+
roles: field(settings, "roles"),
|
|
40
|
+
serviceRoles: [...new Set(settings.serviceRoles ?? [])]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function apiKeysPlan(setting, declared, renamed) {
|
|
44
|
+
if (setting === void 0) return;
|
|
45
|
+
const fields = apiKeyFields(setting);
|
|
46
|
+
const unknown = fields.serviceRoles.filter((role) => !declared.has(role));
|
|
47
|
+
if (unknown.length > 0) throw new Error(`PermDock CLI: rls.apiKeys.serviceRoles names roles the policy does not declare: ${unknown.join(", ")}`);
|
|
48
|
+
return {
|
|
49
|
+
...fields,
|
|
50
|
+
...Object.keys(renamed).length === 0 ? {} : { renamed }
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function qualified(ctx, name) {
|
|
54
|
+
return `${quoteIdent(ctx.schema ?? "permdock")}.${name}`;
|
|
55
|
+
}
|
|
56
|
+
function keyOf(ctx, plan) {
|
|
57
|
+
return `(select ${subjectClaimJsonSql(ctx, plan.claim)} as k) api_key`;
|
|
58
|
+
}
|
|
59
|
+
function permissionOf(plan, expr) {
|
|
60
|
+
return plan.renamed === void 0 ? expr : `coalesce(${quoteLiteral(JSON.stringify(plan.renamed))}::jsonb ->> ${expr}, ${expr})`;
|
|
61
|
+
}
|
|
62
|
+
function apiKeyAllowsCall(ctx, grant) {
|
|
63
|
+
return `${qualified(ctx, API_KEY_ALLOWS)}(${grant})`;
|
|
64
|
+
}
|
|
65
|
+
function apiKeyAllowsSql(ctx, plan, anonExecute) {
|
|
66
|
+
const fn = qualified(ctx, API_KEY_ALLOWS);
|
|
67
|
+
const scopes = `k -> ${quoteLiteral(plan.scopes)}`;
|
|
68
|
+
const grants = anonExecute ? `revoke execute on function ${fn}(text) from public;
|
|
69
|
+
grant execute on function ${fn}(text) to anon, authenticated;` : `revoke execute on function ${fn}(text) from public, anon;
|
|
70
|
+
grant execute on function ${fn}(text) to authenticated;`;
|
|
71
|
+
return `-- API keys: the ${plan.claim} claim's ${plan.scopes} list is a ceiling on every allow; denies always apply, and a request without the claim is not limited
|
|
72
|
+
create or replace function ${fn}(p_grant text)
|
|
73
|
+
returns boolean
|
|
74
|
+
language sql
|
|
75
|
+
stable
|
|
76
|
+
security definer
|
|
77
|
+
set search_path = ''
|
|
78
|
+
as $$
|
|
79
|
+
select case
|
|
80
|
+
when k is null or k = 'null'::jsonb then true
|
|
81
|
+
when exists (
|
|
82
|
+
select 1 from ${qualified(ctx, "role_permissions")} rp
|
|
83
|
+
where rp.grant_key = p_grant and rp.effect = 'deny'
|
|
84
|
+
) then true
|
|
85
|
+
when jsonb_typeof(k) is distinct from 'object' or jsonb_typeof(${scopes}) is distinct from 'array' then false
|
|
86
|
+
else exists (
|
|
87
|
+
select 1
|
|
88
|
+
from jsonb_array_elements_text(${scopes}) s(permission)
|
|
89
|
+
where ${permissionOf(plan, "s.permission")} = split_part(split_part(p_grant, '#', 1), '@', 1)
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
from ${keyOf(ctx, plan)}
|
|
93
|
+
$$;
|
|
94
|
+
${grants}`;
|
|
95
|
+
}
|
|
96
|
+
function serviceRolesJson(plan) {
|
|
97
|
+
return `${quoteLiteral(JSON.stringify(plan.serviceRoles))}::jsonb`;
|
|
98
|
+
}
|
|
99
|
+
function serviceKeyRows(ctx, plan) {
|
|
100
|
+
const roles = `k -> ${quoteLiteral(plan.roles)}`;
|
|
101
|
+
return ` from ${keyOf(ctx, plan)}
|
|
102
|
+
cross join lateral jsonb_array_elements_text(
|
|
103
|
+
case jsonb_typeof(${roles}) when 'array' then ${roles} else ${serviceRolesJson(plan)} end
|
|
104
|
+
) r(role)`;
|
|
105
|
+
}
|
|
106
|
+
function serviceKeyFilters(ctx, plan, root, permission) {
|
|
107
|
+
const tenant = `k ->> ${quoteLiteral(plan.tenant)}`;
|
|
108
|
+
return [
|
|
109
|
+
`jsonb_typeof(k) = 'object'`,
|
|
110
|
+
`coalesce(${subjectIdSql(ctx)}::text, '') = ''`,
|
|
111
|
+
`coalesce(${tenant}, '') <> ''`,
|
|
112
|
+
...activeInstancesSql(ctx, root, (name) => name === root ? tenant : void 0, permission)
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
function serviceKeyIdsSql(ctx, plan, root, type) {
|
|
116
|
+
const kind = kindFilterSql(ctx, "r.role", { value: "credential" });
|
|
117
|
+
const set = ctx.grantSet === true;
|
|
118
|
+
const filters = [
|
|
119
|
+
...serviceKeyFilters(ctx, plan, root, grantPermissionSql(set ? "rp.grant_key" : "p_grant")),
|
|
120
|
+
...set ? [] : ["rp.grant_key = p_grant"],
|
|
121
|
+
`rp.scope = ${quoteLiteral(root)}`,
|
|
122
|
+
...kind === void 0 ? [] : [kind]
|
|
123
|
+
];
|
|
124
|
+
return ` select (k ->> ${quoteLiteral(plan.tenant)})::${type}${set ? ", rp.grant_key" : ""}
|
|
125
|
+
${serviceKeyRows(ctx, plan)}
|
|
126
|
+
join ${qualified(ctx, "role_permissions")} rp on rp.role = r.role
|
|
127
|
+
where ${filters.join("\n and ")}`;
|
|
128
|
+
}
|
|
129
|
+
function serviceKeyMemberSql(ctx, plan, root, type) {
|
|
130
|
+
return ` select distinct (k ->> ${quoteLiteral(plan.tenant)})::${type}
|
|
131
|
+
${serviceKeyRows(ctx, plan)}
|
|
132
|
+
where ${serviceKeyFilters(ctx, plan, root).join("\n and ")}`;
|
|
133
|
+
}
|
|
134
|
+
function ceilingIdsSql(body, allows) {
|
|
135
|
+
return ` select ids.id
|
|
136
|
+
from (
|
|
137
|
+
${body}
|
|
138
|
+
) ids(id)
|
|
139
|
+
where ${allows}`;
|
|
140
|
+
}
|
|
141
|
+
function ceilingHasSql(body, allows) {
|
|
142
|
+
return ` select ${allows} and coalesce((
|
|
143
|
+
${body}
|
|
144
|
+
), false)`;
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/cli/rls-conditions.ts
|
|
148
|
+
const MAX_CLAIM_DEPTH = 8;
|
|
149
|
+
/**
|
|
150
|
+
* The claim path of a `principal.claim.*` / `principal.claims.*` ref, one
|
|
151
|
+
* segment per JSON key; `undefined` for any other ref.
|
|
152
|
+
*/
|
|
153
|
+
function claimPath(ref) {
|
|
154
|
+
const prefix = ["principal.claim.", "principal.claims."].find((item) => ref.startsWith(item));
|
|
155
|
+
if (prefix === void 0) return;
|
|
156
|
+
const segments = ref.slice(prefix.length).split(".");
|
|
157
|
+
if (segments.length > MAX_CLAIM_DEPTH) throw new Error(`PermDock CLI: claim path '${ref}' is deeper than ${MAX_CLAIM_DEPTH}`);
|
|
158
|
+
for (const segment of segments) if (!CLAIM.test(segment) || isForbiddenKey(segment)) throw new Error(`PermDock CLI: unsafe claim name '${segment}'`);
|
|
159
|
+
return segments;
|
|
160
|
+
}
|
|
161
|
+
function jsonPath(base, keys, last) {
|
|
162
|
+
let sql = base;
|
|
163
|
+
for (const [index, key] of keys.entries()) sql += ` ${index === keys.length - 1 ? last : "->"} ${quoteLiteral(key)}`;
|
|
164
|
+
return sql;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The JSON document a claim path starts from and the keys to walk in it. The
|
|
168
|
+
* `guc` dialect keeps each top-level claim in its own setting as JSON text.
|
|
169
|
+
*/
|
|
170
|
+
function claimRoot(ctx, path) {
|
|
171
|
+
const [head, ...rest] = path;
|
|
172
|
+
if (head === void 0) throw new Error("PermDock CLI: empty claim path");
|
|
173
|
+
switch (ctx.dialect) {
|
|
174
|
+
case "supabase": return {
|
|
175
|
+
root: "(select auth.jwt())",
|
|
176
|
+
keys: path
|
|
177
|
+
};
|
|
178
|
+
case "neon": return {
|
|
179
|
+
root: "(select auth.session())",
|
|
180
|
+
keys: path
|
|
181
|
+
};
|
|
182
|
+
case "guc": return {
|
|
183
|
+
root: subjectClaimJsonSql(ctx, head),
|
|
184
|
+
keys: rest
|
|
185
|
+
};
|
|
186
|
+
default: return ctx.dialect;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** The claim at `path` as `jsonb`. */
|
|
190
|
+
function claimJsonSql(ctx, path) {
|
|
191
|
+
const { root, keys } = claimRoot(ctx, path);
|
|
192
|
+
return keys.length === 0 ? root : `(${jsonPath(root, keys, "->")})`;
|
|
193
|
+
}
|
|
194
|
+
/** The claim at `path` as text: `->>` on the last key, or the plain setting for a one-segment `guc` claim. */
|
|
195
|
+
function claimTextSql(ctx, path) {
|
|
196
|
+
const [head] = path;
|
|
197
|
+
if (path.length === 1 && head !== void 0) return subjectClaimSql(ctx, head);
|
|
198
|
+
const { root, keys } = claimRoot(ctx, path);
|
|
199
|
+
return `(${jsonPath(root, keys, "->>")})`;
|
|
200
|
+
}
|
|
201
|
+
/** The JSON kind a claim must have to compare with a column of `type`. */
|
|
202
|
+
function jsonKindOf(type) {
|
|
203
|
+
if (type === "numeric") return "number";
|
|
204
|
+
return type === "boolean" ? "boolean" : "string";
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A claim compared with a column of `type`. Numbers and booleans must be JSON
|
|
208
|
+
* of that kind (anything else is `null`, so the comparison is false, as in
|
|
209
|
+
* memory); other types cast the text form. A one-segment `guc` claim is text
|
|
210
|
+
* and is cast as is.
|
|
211
|
+
*/
|
|
212
|
+
function typedClaimSql(ctx, path, type) {
|
|
213
|
+
const text = claimTextSql(ctx, path);
|
|
214
|
+
if (type === void 0 || type === "text") return text;
|
|
215
|
+
const cast = `${text}::${sqlType(type)}`;
|
|
216
|
+
const kind = jsonKindOf(type);
|
|
217
|
+
if (kind === "string" || ctx.dialect === "guc" && path.length === 1) return `(${cast})`;
|
|
218
|
+
return `(case when jsonb_typeof(${claimJsonSql(ctx, path)}) = '${kind}' then ${cast} end)`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The elements of an array claim as a Postgres array of the column's type,
|
|
222
|
+
* built once per statement (an uncorrelated `array(select ...)` is an InitPlan).
|
|
223
|
+
* A missing or non-array claim is the empty array; elements of another JSON
|
|
224
|
+
* kind are left out.
|
|
225
|
+
*/
|
|
226
|
+
function claimArraySql(ctx, path, type) {
|
|
227
|
+
const json = claimJsonSql(ctx, path);
|
|
228
|
+
return `array(select ${type === void 0 || type === "text" ? `(e #>> '{}')` : `(e #>> '{}')::${sqlType(type)}`} from jsonb_array_elements(case when jsonb_typeof(${json}) = 'array' then ${json} else '[]'::jsonb end) e where jsonb_typeof(e) = '${jsonKindOf(type)}')`;
|
|
229
|
+
}
|
|
230
|
+
function sqlValue(value, ctx, field) {
|
|
231
|
+
if (value !== null && typeof value === "object" && "ref" in value) return compileRef(value.ref, ctx, field);
|
|
232
|
+
if (value !== null && typeof value === "object" && "date" in value) return quoteLiteral(value.date);
|
|
233
|
+
if (isReadonlyArray(value)) return `array[${value.map((item) => sqlValue(item, ctx)).join(", ")}]`;
|
|
234
|
+
if (value === null) return "null";
|
|
235
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
236
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "null";
|
|
237
|
+
if (typeof value === "string") return quoteLiteral(value);
|
|
238
|
+
throw new Error("PermDock CLI: non-portable condition value");
|
|
239
|
+
}
|
|
240
|
+
function compileRef(ref, ctx, field) {
|
|
241
|
+
if (ref === "principal.id") return subjectIdSql(ctx);
|
|
242
|
+
if (ref === "now") return "now()";
|
|
243
|
+
const path = claimPath(ref);
|
|
244
|
+
if (ref === "principal.tenant" || path?.length === 1 && path[0] === ctx.tenantClaim) return tenantClaimSql(ctx);
|
|
245
|
+
if (path !== void 0) return typedClaimSql(ctx, path, field === void 0 ? void 0 : ctx.columnTypes?.[field]);
|
|
246
|
+
if (ref === "context" || ref.startsWith("context.")) throw new Error(`PermDock CLI: '${ref}' is request context, which is not in the token; RLS cannot read it (permdock doctor PD027)`);
|
|
247
|
+
throw new Error(`PermDock CLI: non-portable subject ref '${ref}'`);
|
|
248
|
+
}
|
|
249
|
+
/** `value` with `\`, `%` and `_` escaped for `like ... escape '\'`, as `escapeLike` does in process. */
|
|
250
|
+
function likeLiteralSql(value) {
|
|
251
|
+
return `replace(replace(replace(${value}, '\\', '\\\\'), '%', '\\%'), '_', '\\_')`;
|
|
252
|
+
}
|
|
253
|
+
function compareSql(op, field, value) {
|
|
254
|
+
const left = quoteIdent(field);
|
|
255
|
+
switch (op) {
|
|
256
|
+
case "eq": return `${left} = ${value}`;
|
|
257
|
+
case "ne": return `${left} <> ${value}`;
|
|
258
|
+
case "gt": return `${left} > ${value}`;
|
|
259
|
+
case "gte": return `${left} >= ${value}`;
|
|
260
|
+
case "lt": return `${left} < ${value}`;
|
|
261
|
+
case "lte": return `${left} <= ${value}`;
|
|
262
|
+
case "contains": return `${left}::text like '%' || ${likeLiteralSql(`${value}::text`)} || '%' escape '\\'`;
|
|
263
|
+
default: return op;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function existsSql(table, rowColumn, rowField, roles, ctx, tenantColumn, scope) {
|
|
267
|
+
const parts = [`m.${quoteIdent(rowColumn)} = ${quoteIdent(rowField)}`, `m.${quoteIdent(table.user)} = ${subjectIdSql(ctx)}`];
|
|
268
|
+
const role = memberRoleOf(table);
|
|
269
|
+
if (roles.length > 0) {
|
|
270
|
+
const roleList = roles.map((name) => name.replaceAll("'", "''")).join(",");
|
|
271
|
+
const held = role.through === void 0 && !role.lateral ? `m.${quoteIdent(role.column)}` : role.lookup;
|
|
272
|
+
parts.push(`${held} = any('{${roleList}}')`);
|
|
273
|
+
const via = memberVia(table);
|
|
274
|
+
const single = sole(roles);
|
|
275
|
+
const kind = single === void 0 ? kindFilterSql(ctx, role.lookup, via) : roleKindSql(ctx, single, via);
|
|
276
|
+
if (kind !== void 0) parts.push(kind);
|
|
277
|
+
}
|
|
278
|
+
if (table.expiresAt !== void 0) parts.push(`(m.${quoteIdent(table.expiresAt)} is null or m.${quoteIdent(table.expiresAt)} > now())`);
|
|
279
|
+
parts.push(...activeMembershipSql(ctx, table.disabledAt === void 0 ? void 0 : `m.${quoteIdent(table.disabledAt)}`));
|
|
280
|
+
if (tenantColumn !== void 0 && ctx.tenants !== "all") parts.push(`m.${quoteIdent(tenantColumn)} = ${tenantClaimSql(ctx)}`);
|
|
281
|
+
parts.push(...activeUserSql(ctx));
|
|
282
|
+
if (scope !== void 0) {
|
|
283
|
+
const tenantOf = scope === rootScope(ctx.scopes) ? rowColumn : tenantColumn;
|
|
284
|
+
const keyed = keyTenantSql(ctx, tenantOf === void 0 ? void 0 : `m.${quoteIdent(tenantOf)}`);
|
|
285
|
+
if (keyed !== void 0) parts.push(keyed);
|
|
286
|
+
parts.push(...activeInstancesSql(ctx, scope, (name) => {
|
|
287
|
+
const held = scopeColumn(table, ctx.scopes, name);
|
|
288
|
+
return held === void 0 ? void 0 : `m.${quoteIdent(held)}`;
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
const expand = roles.length > 0 && role.lateral ? role.join : "";
|
|
292
|
+
return `exists (select 1 from ${quoteTable(table.table)} m${expand} where ${parts.join(" and ")})`;
|
|
293
|
+
}
|
|
294
|
+
function compileMemberOf(condition, ctx) {
|
|
295
|
+
const scope = condition.scope === "resource" ? void 0 : resolveScope(ctx.scopes, condition.scope);
|
|
296
|
+
if (condition.scope !== "resource" && scope === void 0) throw new Error(`PermDock CLI: memberOf ${condition.scope} names a scope the policy does not declare`);
|
|
297
|
+
const mapping = scope === void 0 ? condition.resource === void 0 ? void 0 : ctx.memberships?.resource?.[condition.resource] : scopeMembershipTable(ctx.memberships, ctx.scopes, scope);
|
|
298
|
+
if (mapping !== void 0) {
|
|
299
|
+
const named = scope === void 0 ? void 0 : scopeTable(ctx, scope);
|
|
300
|
+
const rowColumn = scope === void 0 ? mapping.id : named?.column;
|
|
301
|
+
if (rowColumn === void 0) throw new Error(`PermDock CLI: memberships mapping for ${condition.scope} is missing the row column`);
|
|
302
|
+
const tenantColumn = scope === void 0 || scope === rootScope(ctx.scopes) ? void 0 : named?.tenantColumn;
|
|
303
|
+
const primary = existsSql(mapping, rowColumn, condition.field, condition.roles, ctx, tenantColumn, scope);
|
|
304
|
+
if (condition.scope !== "resource" || condition.parents === void 0) return primary;
|
|
305
|
+
return `(${[primary, ...condition.parents.flatMap((parent) => {
|
|
306
|
+
if (typeof parent === "string") return [existsSql(mapping, rowColumn, parent, condition.roles, ctx, tenantColumn)];
|
|
307
|
+
const hopTable = ctx.memberships?.resource?.[parent.resource];
|
|
308
|
+
return hopTable?.id === void 0 ? [] : [existsSql(hopTable, hopTable.id, parent.field, condition.roles, ctx)];
|
|
309
|
+
})].join(" or ")})`;
|
|
310
|
+
}
|
|
311
|
+
if (scope !== void 0 && condition.roles.length === 0 && scopeSources(ctx, scope).length > 0) return `${quoteIdent(condition.field)} in (select ${quoteIdent(ctx.schema ?? "permdock")}.${memberIdsHelper(scope)}())`;
|
|
312
|
+
if (scope !== void 0 && scope === rootScope(ctx.scopes)) {
|
|
313
|
+
const parts = [
|
|
314
|
+
ctx.tenants === "all" ? `${quoteIdent(condition.field)} in (select ${quoteIdent(ctx.schema ?? "permdock")}.${memberIdsHelper(scope)}())` : `${quoteIdent(condition.field)} = ${tenantClaimSql(ctx)}`,
|
|
315
|
+
...ctx.tenants === "all" ? [] : [keyTenantSql(ctx, quoteIdent(condition.field))].filter((part) => part !== void 0),
|
|
316
|
+
...activeUserSql(ctx),
|
|
317
|
+
...activeInstancesSql(ctx, scope, () => quoteIdent(condition.field))
|
|
318
|
+
];
|
|
319
|
+
return parts.length === 1 ? parts.join("") : `(${parts.join(" and ")})`;
|
|
320
|
+
}
|
|
321
|
+
throw new Error(`PermDock CLI: memberOf ${condition.scope} needs a memberships table mapping`);
|
|
322
|
+
}
|
|
323
|
+
function compileConditionSql(condition, ctx) {
|
|
324
|
+
switch (condition.op) {
|
|
325
|
+
case "eq":
|
|
326
|
+
case "ne":
|
|
327
|
+
case "gt":
|
|
328
|
+
case "gte":
|
|
329
|
+
case "lt":
|
|
330
|
+
case "lte": return compareSql(condition.op, condition.field, sqlValue(condition.value, ctx, condition.field));
|
|
331
|
+
case "contains": {
|
|
332
|
+
const element = ctx.arrayColumns?.[condition.field];
|
|
333
|
+
if (element !== void 0) return `${sqlValue(condition.value, {
|
|
334
|
+
...ctx,
|
|
335
|
+
columnTypes: {
|
|
336
|
+
...ctx.columnTypes,
|
|
337
|
+
[condition.field]: element
|
|
338
|
+
}
|
|
339
|
+
}, condition.field)} = any(${quoteIdent(condition.field)})`;
|
|
340
|
+
return compareSql(condition.op, condition.field, sqlValue(condition.value, ctx, condition.field));
|
|
341
|
+
}
|
|
342
|
+
case "in":
|
|
343
|
+
case "notIn": {
|
|
344
|
+
const keyword = condition.op === "in" ? "in" : "not in";
|
|
345
|
+
if (!Array.isArray(condition.value) && typeof condition.value === "object" && "ref" in condition.value) {
|
|
346
|
+
const path = claimPath(condition.value.ref);
|
|
347
|
+
if (path === void 0) {
|
|
348
|
+
compileRef(condition.value.ref, ctx);
|
|
349
|
+
throw new Error(`PermDock CLI: non-portable ${condition.op} against '${condition.value.ref}'`);
|
|
350
|
+
}
|
|
351
|
+
const column = quoteIdent(condition.field);
|
|
352
|
+
const list = claimArraySql(ctx, path, ctx.columnTypes?.[condition.field]);
|
|
353
|
+
return condition.op === "in" ? `${column} = any (${list})` : `(${column} is not null and not (${column} = any (${list})))`;
|
|
354
|
+
}
|
|
355
|
+
const values = condition.value.map((item) => sqlValue(item, ctx));
|
|
356
|
+
return `${quoteIdent(condition.field)} ${keyword} (${values.join(", ")})`;
|
|
357
|
+
}
|
|
358
|
+
case "isNull": return `${quoteIdent(condition.field)} is ${condition.value ? "" : "not "}null`;
|
|
359
|
+
case "and": return `(${condition.conditions.map((item) => compileConditionSql(item, ctx)).join(" and ")})`;
|
|
360
|
+
case "or":
|
|
361
|
+
if (condition.conditions.length === 0) return "false";
|
|
362
|
+
return `(${condition.conditions.map((item) => compileConditionSql(item, ctx)).join(" or ")})`;
|
|
363
|
+
case "not": return `(${compileConditionSql(condition.condition, ctx)}) is not true`;
|
|
364
|
+
case "memberOf": return compileMemberOf(condition, ctx);
|
|
365
|
+
case "related": return compileRelatedSql(condition, ctx);
|
|
366
|
+
case "sqlFunction":
|
|
367
|
+
if (ctx.inlineFunctions === true) return compileConditionSql(condition.twin, ctx);
|
|
368
|
+
return `${quoteTable(condition.name)}(${condition.args.map((arg) => isSqlFunctionField(arg) ? quoteIdent(arg.field) : sqlValue(arg, ctx)).join(", ")})`;
|
|
369
|
+
case "opaque": return condition.sql;
|
|
370
|
+
case "liveSession": return liveSessionSql(ctx);
|
|
371
|
+
default: return condition;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function andConditions(left, right) {
|
|
375
|
+
if (left === void 0) return right;
|
|
376
|
+
if (right === void 0) return left;
|
|
377
|
+
return {
|
|
378
|
+
op: "and",
|
|
379
|
+
conditions: [left, right]
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function valueContextRefs(value) {
|
|
383
|
+
if (Array.isArray(value)) return value.flatMap(valueContextRefs);
|
|
384
|
+
if (isRecord(value) && typeof value["ref"] === "string" && (value["ref"] === "context" || value["ref"].startsWith("context."))) return [value["ref"]];
|
|
385
|
+
return [];
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* The `context.*` refs a condition reads. The request context is not in the
|
|
389
|
+
* token, so RLS cannot evaluate them.
|
|
390
|
+
*/
|
|
391
|
+
function contextRefs(condition) {
|
|
392
|
+
if (condition === void 0) return [];
|
|
393
|
+
switch (condition.op) {
|
|
394
|
+
case "eq":
|
|
395
|
+
case "ne":
|
|
396
|
+
case "gt":
|
|
397
|
+
case "gte":
|
|
398
|
+
case "lt":
|
|
399
|
+
case "lte":
|
|
400
|
+
case "contains":
|
|
401
|
+
case "in":
|
|
402
|
+
case "notIn": return valueContextRefs(condition.value);
|
|
403
|
+
case "and":
|
|
404
|
+
case "or": return condition.conditions.flatMap((child) => contextRefs(child));
|
|
405
|
+
case "not": return contextRefs(condition.condition);
|
|
406
|
+
case "sqlFunction": return [...condition.args.flatMap(valueContextRefs), ...contextRefs(condition.twin)];
|
|
407
|
+
case "isNull":
|
|
408
|
+
case "memberOf":
|
|
409
|
+
case "related":
|
|
410
|
+
case "opaque":
|
|
411
|
+
case "liveSession": return [];
|
|
412
|
+
default: return condition;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function sqlFunctionNames(condition) {
|
|
416
|
+
if (condition === void 0) return [];
|
|
417
|
+
switch (condition.op) {
|
|
418
|
+
case "sqlFunction": return [condition.name, ...sqlFunctionNames(condition.twin)];
|
|
419
|
+
case "and":
|
|
420
|
+
case "or": return condition.conditions.flatMap((child) => sqlFunctionNames(child));
|
|
421
|
+
case "not": return sqlFunctionNames(condition.condition);
|
|
422
|
+
case "eq":
|
|
423
|
+
case "ne":
|
|
424
|
+
case "gt":
|
|
425
|
+
case "gte":
|
|
426
|
+
case "lt":
|
|
427
|
+
case "lte":
|
|
428
|
+
case "contains":
|
|
429
|
+
case "in":
|
|
430
|
+
case "notIn":
|
|
431
|
+
case "isNull":
|
|
432
|
+
case "memberOf":
|
|
433
|
+
case "related":
|
|
434
|
+
case "opaque":
|
|
435
|
+
case "liveSession": return [];
|
|
436
|
+
default: return condition;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* A `related` condition as uncorrelated subqueries, so Postgres runs the
|
|
441
|
+
* helper once per statement: the row's field in the closure's descendants of
|
|
442
|
+
* the ids the subject holds the relation on (or those ids alone at depth 0).
|
|
443
|
+
* The helper sits in `array(...)` so it stays an InitPlan; a plain `in` gets
|
|
444
|
+
* pulled into a join that can rescan it per closure row.
|
|
445
|
+
*/
|
|
446
|
+
function compileRelatedSql(condition, ctx) {
|
|
447
|
+
if (condition.hops !== void 0 && condition.hops.length > 0) return compileHoppedSql(condition, ctx);
|
|
448
|
+
const type = ctx.columnTypes?.[condition.field];
|
|
449
|
+
const cast = type === void 0 || type === "text" ? "" : `::${type}`;
|
|
450
|
+
const column = type === void 0 ? `${quoteIdent(condition.field)}::text` : quoteIdent(condition.field);
|
|
451
|
+
const helper = condition.permission === void 0 ? `${`${quoteIdent(ctx.schema ?? "permdock")}.${graphHelper(condition.resource)}`}(${quoteLiteral(condition.relation)})` : `${quoteIdent(ctx.schema ?? "permdock")}.${inheritedRowsHelper(condition.resource)}(${quoteLiteral(condition.permission)})`;
|
|
452
|
+
const cap = ctx.graph?.closures[condition.resource];
|
|
453
|
+
let inner;
|
|
454
|
+
if (condition.depth > 0 && cap !== void 0) {
|
|
455
|
+
const depth = condition.depth < cap ? ` and depth <= ${String(condition.depth)}` : "";
|
|
456
|
+
inner = `select descendant${cast} from ${`${quoteIdent(ctx.schema ?? "permdock")}.${CLOSURE.table}`} where resource = ${quoteLiteral(condition.resource)}${depth} and ancestor = any (array(select ${helper}))`;
|
|
457
|
+
} else inner = cast === "" ? `select ${helper}` : `select p.id${cast} from ${helper} as p(id)`;
|
|
458
|
+
const reach = `${column} in (${inner})`;
|
|
459
|
+
if (condition.parent === true && condition.restricted !== void 0) return `(${reach} and ${quoteIdent(condition.restricted)} is not true)`;
|
|
460
|
+
return reach;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* A `related` condition that crosses link hops: the reached instances of the
|
|
464
|
+
* last resource, carried back over each link by its security definer helper,
|
|
465
|
+
* so the hop reads a table the subject may not see.
|
|
466
|
+
*/
|
|
467
|
+
function compileHoppedSql(condition, ctx) {
|
|
468
|
+
const resources = ctx.graph?.resources;
|
|
469
|
+
if (resources === void 0) throw new Error("PermDock CLI: a related condition with link hops needs the policy resources in the RLS context");
|
|
470
|
+
const schema = quoteIdent(ctx.schema ?? "permdock");
|
|
471
|
+
return graphSqlText(relatedSql(condition, {
|
|
472
|
+
resources,
|
|
473
|
+
...ctx.graph?.tables === void 0 ? {} : { tables: ctx.graph.tables },
|
|
474
|
+
closure: `${ctx.schema ?? "permdock"}.${CLOSURE.table}`,
|
|
475
|
+
closureDepths: ctx.graph?.closures ?? {},
|
|
476
|
+
qualify: qualifiedTable,
|
|
477
|
+
holders: (resource, relation) => [{ text: `select ${schema}.${graphHelper(resource)}(${quoteLiteral(relation)})` }],
|
|
478
|
+
permitted: (resource, permission) => [{ text: `select ${schema}.${inheritedRowsHelper(resource)}(${quoteLiteral(permission)})` }],
|
|
479
|
+
restrictedRows: (resource) => [{ text: `select ${schema}.${restrictedHelper(resource)}()` }],
|
|
480
|
+
linked: (resource, link, targets) => [
|
|
481
|
+
{ text: `select ${schema}.${linkHelper(resource, link)}(array(` },
|
|
482
|
+
...targets,
|
|
483
|
+
{ text: "))" }
|
|
484
|
+
]
|
|
485
|
+
}), ctx);
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/cli/rls-grants.ts
|
|
489
|
+
/**
|
|
490
|
+
* The `role_permissions` grant key of a break-glass grant. It differs from
|
|
491
|
+
* the permission key, so `authorize()` and the table policies never count it;
|
|
492
|
+
* only the break-glass read asks for it.
|
|
493
|
+
*/
|
|
494
|
+
function breakGlassKey(permission) {
|
|
495
|
+
return `${permission}#break-glass`;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The role and scope holding a break-glass grant. `breakGlass()` sets
|
|
499
|
+
* `to: authenticated`, so inside `role()` the role is on the grant itself.
|
|
500
|
+
*/
|
|
501
|
+
function breakGlassHolder(item) {
|
|
502
|
+
if (item.access.kind === "role") return {
|
|
503
|
+
role: item.access.role,
|
|
504
|
+
scope: item.access.scope
|
|
505
|
+
};
|
|
506
|
+
const { role, scope } = item.grant;
|
|
507
|
+
return role === null || typeof scope !== "string" ? void 0 : {
|
|
508
|
+
role,
|
|
509
|
+
scope
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function granteeItems(to) {
|
|
513
|
+
return Array.isArray(to) ? to : [to];
|
|
514
|
+
}
|
|
515
|
+
function relationWhere(policy, grant, grantee) {
|
|
516
|
+
const node = policy.resources.get(grant.permission.resource);
|
|
517
|
+
const where = relationCondition(grantee, node, scopeList(policy.scopes), {
|
|
518
|
+
resources: policy.resources,
|
|
519
|
+
now: { ref: "now" }
|
|
520
|
+
});
|
|
521
|
+
if (where === void 0) throw new Error(`PermDock CLI: grant ${grant.permission.key} names relation '${grantee.relation}', which ${grant.permission.resource} does not declare`);
|
|
522
|
+
return where;
|
|
523
|
+
}
|
|
524
|
+
function inheritWhere(policy, grant, grantee) {
|
|
525
|
+
const where = inheritCondition(grantee, policy.resources.get(grant.permission.resource), policy.resources);
|
|
526
|
+
if (where === void 0) throw new Error(`PermDock CLI: grant ${grant.permission.key} inherits ${grantee.permission}, which ${grant.permission.resource} cannot reach`);
|
|
527
|
+
return where;
|
|
528
|
+
}
|
|
529
|
+
/** Only a deny for `oauth-client` actors compiles: Postgres sees `client_id` and `act`, not other actor kinds. */
|
|
530
|
+
function actorAccess(grant, grantee, others) {
|
|
531
|
+
if (grant.effect !== "deny" || grantee.actor !== "oauth-client" || others > 0) throw new Error(`PermDock CLI: grant ${grant.permission.key} is limited to an actor grantee; RLS compiles only deny(…, { to: actor('oauth-client') }) on its own, so enforce it in the application`);
|
|
532
|
+
return {
|
|
533
|
+
kind: "actor",
|
|
534
|
+
actor: "oauth-client"
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
function accessOf(grant, roles, anyone) {
|
|
538
|
+
const [held, ...more] = roles;
|
|
539
|
+
if (more.length > 0) throw new Error(`PermDock CLI: grant ${grant.permission.key} requires several roles at once (${roles.map((item) => item.role).join(", ")}); RLS compiles one role per grant`);
|
|
540
|
+
if (held === void 0) return anyone ? { kind: "anyone" } : { kind: "authenticated" };
|
|
541
|
+
if (typeof held.scope === "object") return {
|
|
542
|
+
kind: "resource",
|
|
543
|
+
role: held.role,
|
|
544
|
+
resource: held.scope.resource
|
|
545
|
+
};
|
|
546
|
+
return {
|
|
547
|
+
kind: "role",
|
|
548
|
+
role: held.role,
|
|
549
|
+
scope: held.scope
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Every grant RLS must enforce: role grants and top-level
|
|
554
|
+
* `definePolicy({ grants })` alike (`policy.grants` holds both). A grantee
|
|
555
|
+
* Postgres cannot see (plan, actor, assurance) fails with the permission named.
|
|
556
|
+
*/
|
|
557
|
+
function collectGrants(policy) {
|
|
558
|
+
return policy.grants.map((grant) => {
|
|
559
|
+
const items = granteeItems(grant.to);
|
|
560
|
+
const roles = [];
|
|
561
|
+
let anyone = true;
|
|
562
|
+
let where = grant.where;
|
|
563
|
+
let delegated;
|
|
564
|
+
for (const item of items) switch (item.kind) {
|
|
565
|
+
case "role":
|
|
566
|
+
roles.push(item);
|
|
567
|
+
break;
|
|
568
|
+
case "anyone": break;
|
|
569
|
+
case "authenticated":
|
|
570
|
+
anyone = false;
|
|
571
|
+
break;
|
|
572
|
+
case "relation":
|
|
573
|
+
anyone = false;
|
|
574
|
+
where = andConditions(where, relationWhere(policy, grant, item));
|
|
575
|
+
break;
|
|
576
|
+
case "inherit":
|
|
577
|
+
anyone = false;
|
|
578
|
+
where = andConditions(where, inheritWhere(policy, grant, item));
|
|
579
|
+
break;
|
|
580
|
+
case "actor":
|
|
581
|
+
delegated = actorAccess(grant, item, items.length - 1);
|
|
582
|
+
break;
|
|
583
|
+
case "plan":
|
|
584
|
+
case "assurance": throw new Error(`PermDock CLI: grant ${grant.permission.key} is limited to a ${item.kind} grantee, which RLS cannot compile; enforce it in the application`);
|
|
585
|
+
default: return item;
|
|
586
|
+
}
|
|
587
|
+
const access = delegated ?? accessOf(grant, roles, anyone);
|
|
588
|
+
const label = access.kind === "role" || access.kind === "resource" ? access.role : access.kind;
|
|
589
|
+
return where === void 0 ? {
|
|
590
|
+
grant,
|
|
591
|
+
label,
|
|
592
|
+
access
|
|
593
|
+
} : {
|
|
594
|
+
grant,
|
|
595
|
+
label,
|
|
596
|
+
access,
|
|
597
|
+
where
|
|
598
|
+
};
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/** Declared role names (bindings and `defineRoles` leaves) plus any role a top-level grant names. */
|
|
602
|
+
function roleNames(policy) {
|
|
603
|
+
const names = new Set(policy.roles.map((item) => item.name));
|
|
604
|
+
for (const leaf of listRoles(policy.vocabulary.roles)) names.add(leaf.key);
|
|
605
|
+
for (const item of collectGrants(policy)) if (item.access.kind === "role" || item.access.kind === "resource") names.add(item.access.role);
|
|
606
|
+
return [...names];
|
|
607
|
+
}
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/cli/global-roles.ts
|
|
610
|
+
function qualify(name, schema) {
|
|
611
|
+
return name.includes(".") ? name : `${schema}.${name}`;
|
|
612
|
+
}
|
|
613
|
+
/** Resolves `source` against `schema`, aliasing the table `alias` and the roles table `<alias>k`. */
|
|
614
|
+
function globalRoleSource(source, schema, alias) {
|
|
615
|
+
const table = qualify(source.table, schema);
|
|
616
|
+
const user = source.user ?? "user_id";
|
|
617
|
+
const role = roleColumn(source.role ?? "role", table, alias, {
|
|
618
|
+
label: "roles.role",
|
|
619
|
+
prefix: "PermDock CLI",
|
|
620
|
+
indent: " "
|
|
621
|
+
});
|
|
622
|
+
return {
|
|
623
|
+
table,
|
|
624
|
+
user,
|
|
625
|
+
from: `${quoteTable(table)} ${alias}${role.join}`,
|
|
626
|
+
userSql: `${alias}.${quoteIdent(user)}`,
|
|
627
|
+
roleSql: role.sql,
|
|
628
|
+
role: roleManifest(role),
|
|
629
|
+
...role.through === void 0 ? {} : { through: role.through }
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/cli/rls-custom-roles.ts
|
|
634
|
+
/** The readers every helpers file ends with. Names are part of the SQL contract. */
|
|
635
|
+
const READERS = {
|
|
636
|
+
keys: "permdock_permission_keys",
|
|
637
|
+
roles: "permdock_role_permissions",
|
|
638
|
+
trusted: "permdock_trusted_role_permissions"
|
|
639
|
+
};
|
|
640
|
+
/**
|
|
641
|
+
* The custom-role objects: the tables (`database` mode), the ceiling view
|
|
642
|
+
* over the `assignable` declared roles, and `permdock_custom_keys`, which
|
|
643
|
+
* resolves one custom role to the grant keys it holds in a scope. A row in
|
|
644
|
+
* the tables can never reach a key outside the view.
|
|
645
|
+
*/
|
|
646
|
+
function customRolesSql(ctx) {
|
|
647
|
+
const custom = ctx.customRoles;
|
|
648
|
+
if (custom === void 0) return "";
|
|
649
|
+
const rp = qualified$1(ctx, "role_permissions");
|
|
650
|
+
const ceiling = qualified$1(ctx, CUSTOM_ROLES.ceiling);
|
|
651
|
+
const keys = qualified$1(ctx, CUSTOM_ROLES.keys);
|
|
652
|
+
const chunks = [];
|
|
653
|
+
if (ctx.authorize === "database") {
|
|
654
|
+
const tenantType = tenantTypeOf(ctx);
|
|
655
|
+
for (const [name, column, check] of [[
|
|
656
|
+
CUSTOM_ROLES.permissions,
|
|
657
|
+
"permission",
|
|
658
|
+
`,
|
|
659
|
+
effect text not null default 'allow' check (effect in ('allow', 'deny'))`
|
|
660
|
+
], [
|
|
661
|
+
CUSTOM_ROLES.includes,
|
|
662
|
+
"include_role",
|
|
663
|
+
""
|
|
664
|
+
]]) {
|
|
665
|
+
const table = qualified$1(ctx, name);
|
|
666
|
+
const leveled = custom.levels === true && name === CUSTOM_ROLES.permissions;
|
|
667
|
+
const unique = `${column}${name === CUSTOM_ROLES.permissions ? ", effect" : ""}${leveled ? ", coalesce(level, '')" : ""}`;
|
|
668
|
+
const index = leveled ? `drop index if exists ${qualified$1(ctx, quoteIdent(`${name}_key`))};
|
|
669
|
+
alter table ${table} add column if not exists level text check (level ~ '^[a-z][a-z0-9_]*$');
|
|
670
|
+
create unique index if not exists ${quoteIdent(`${name}_level_key`)}` : `create unique index if not exists ${quoteIdent(`${name}_key`)}`;
|
|
671
|
+
chunks.push(`create table if not exists ${table} (
|
|
672
|
+
tenant_id ${tenantType},
|
|
673
|
+
scope text not null default ${quoteLiteral(rootName(ctx))},
|
|
674
|
+
scope_id text,
|
|
675
|
+
role text not null,
|
|
676
|
+
${column} text not null${check},
|
|
677
|
+
check ((scope = 'global') = (tenant_id is null)),
|
|
678
|
+
check (scope <> 'global' or scope_id is null)
|
|
679
|
+
);
|
|
680
|
+
${index}
|
|
681
|
+
on ${table} (coalesce(tenant_id::text, ''), scope, coalesce(scope_id, ''), role, ${unique});
|
|
682
|
+
alter table ${table} enable row level security;
|
|
683
|
+
revoke all on table ${table} from anon, authenticated, public;`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
chunks.push(`-- the ceiling: keys of assignable declared roles, and the denies of those roles on the same permissions
|
|
687
|
+
create or replace view ${ceiling} with (security_invoker = true) as
|
|
688
|
+
select rp.scope, rp.role, rp.permission, rp.grant_key, rp.effect
|
|
689
|
+
from ${rp} rp
|
|
690
|
+
where rp.role = any(${textArray(custom.assignable)})
|
|
691
|
+
and exists (
|
|
692
|
+
select 1 from ${rp} a
|
|
693
|
+
where a.role = rp.role
|
|
694
|
+
and a.permission = rp.permission
|
|
695
|
+
and a.scope = rp.scope
|
|
696
|
+
and a.effect = 'allow'
|
|
697
|
+
);
|
|
698
|
+
revoke all on table ${ceiling} from anon, authenticated, public;`);
|
|
699
|
+
const renames = Object.entries(custom.renamed ?? {}).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
700
|
+
if (custom.levels === true) {
|
|
701
|
+
chunks.push(leveledKeysSql(ctx, renames));
|
|
702
|
+
return chunks.join("\n\n");
|
|
703
|
+
}
|
|
704
|
+
const rename = renamesSql(renames);
|
|
705
|
+
chunks.push(`-- one custom role: included ceiling keys and same-scope denies, plus every ceiling key of an allowed permission, minus denied permissions
|
|
706
|
+
create or replace function ${keys}(p_allow text[], p_deny text[], p_include text[], p_scope text)
|
|
707
|
+
returns setof text
|
|
708
|
+
language sql
|
|
709
|
+
stable
|
|
710
|
+
set search_path = ''
|
|
711
|
+
as $$
|
|
712
|
+
with ${rename.prelude}ceiling as (
|
|
713
|
+
select c.role, ${rename.permission("c")}, c.grant_key, c.effect
|
|
714
|
+
from ${ceiling} c${rename.join("c")}
|
|
715
|
+
where c.scope = p_scope
|
|
716
|
+
),
|
|
717
|
+
included as (
|
|
718
|
+
select rp.role, ${rename.permission("rp")}, rp.grant_key, rp.scope, rp.effect
|
|
719
|
+
from ${rp} rp${rename.join("rp")}
|
|
720
|
+
where rp.role = any(coalesce(p_include, '{}'::text[]))
|
|
721
|
+
),
|
|
722
|
+
kept as (
|
|
723
|
+
select i.permission, i.grant_key
|
|
724
|
+
from included i
|
|
725
|
+
where i.effect = 'allow'
|
|
726
|
+
and i.scope = p_scope
|
|
727
|
+
and exists (
|
|
728
|
+
select 1 from ceiling c
|
|
729
|
+
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
730
|
+
)
|
|
731
|
+
),
|
|
732
|
+
wanted as (
|
|
733
|
+
${rename.allow}
|
|
734
|
+
union
|
|
735
|
+
select i.permission
|
|
736
|
+
from included i
|
|
737
|
+
where i.effect = 'allow'
|
|
738
|
+
and not (i.scope = p_scope and exists (
|
|
739
|
+
select 1 from ceiling c
|
|
740
|
+
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
741
|
+
))
|
|
742
|
+
),
|
|
743
|
+
allowed as (
|
|
744
|
+
select w.permission
|
|
745
|
+
from wanted w
|
|
746
|
+
where not (${rename.denied("w")})
|
|
747
|
+
and not exists (select 1 from kept k where k.permission = w.permission)
|
|
748
|
+
)
|
|
749
|
+
select k.grant_key from kept k
|
|
750
|
+
where not (${rename.denied("k")})
|
|
751
|
+
union
|
|
752
|
+
select i.grant_key from included i
|
|
753
|
+
where i.effect = 'deny' and i.scope = p_scope
|
|
754
|
+
union
|
|
755
|
+
select c.grant_key from ceiling c
|
|
756
|
+
where c.permission in (select a.permission from allowed a)
|
|
757
|
+
$$;
|
|
758
|
+
revoke execute on function ${keys}(text[], text[], text[], text) from public, anon, authenticated;`);
|
|
759
|
+
return chunks.join("\n\n");
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* `permdock_custom_keys` when resources declare levels. An allow entry is
|
|
763
|
+
* `key` or `key@level`. A level reaches only the ceiling's `<grant key>@<level>`
|
|
764
|
+
* keys, plus the ceiling denies of that permission; a level no ceiling key
|
|
765
|
+
* carries removes the permission, as `resolveCustomRole` drops it with
|
|
766
|
+
* `unknown-level`.
|
|
767
|
+
*/
|
|
768
|
+
function leveledKeysSql(ctx, renames) {
|
|
769
|
+
const rp = qualified$1(ctx, "role_permissions");
|
|
770
|
+
const ceiling = qualified$1(ctx, CUSTOM_ROLES.ceiling);
|
|
771
|
+
const keys = qualified$1(ctx, CUSTOM_ROLES.keys);
|
|
772
|
+
const renamed = renames.length > 0;
|
|
773
|
+
const prelude = renamed ? `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
|
|
774
|
+
` : "";
|
|
775
|
+
const current = (raw, alias) => renamed ? `coalesce(r_${alias}.key, ${raw})` : raw;
|
|
776
|
+
const join = (raw, alias) => renamed ? ` left join renamed r_${alias} on r_${alias}.former = ${raw}` : "";
|
|
777
|
+
return `-- one custom role: as above, where an allow entry key@level reaches only the ceiling keys of that level
|
|
778
|
+
create or replace function ${keys}(p_allow text[], p_deny text[], p_include text[], p_scope text)
|
|
779
|
+
returns setof text
|
|
780
|
+
language sql
|
|
781
|
+
stable
|
|
782
|
+
set search_path = ''
|
|
783
|
+
as $$
|
|
784
|
+
with ${prelude}entries as (
|
|
785
|
+
select ${current("split_part(e.entry, '@', 1)", "e")} as permission, nullif(split_part(e.entry, '@', 2), '') as level
|
|
786
|
+
from unnest(coalesce(p_allow, '{}'::text[])) as e(entry)${join("split_part(e.entry, '@', 1)", "e")}
|
|
787
|
+
),
|
|
788
|
+
ceiling as (
|
|
789
|
+
select c.role, ${current("c.permission", "c")} as permission, c.grant_key, c.effect
|
|
790
|
+
from ${ceiling} c${join("c.permission", "c")}
|
|
791
|
+
where c.scope = p_scope
|
|
792
|
+
),
|
|
793
|
+
levels as (
|
|
794
|
+
select distinct c.permission, split_part(c.grant_key, '@', 2) as level
|
|
795
|
+
from ceiling c
|
|
796
|
+
where c.effect = 'allow' and strpos(c.grant_key, '@') > 0
|
|
797
|
+
),
|
|
798
|
+
denied as (
|
|
799
|
+
select ${current("d.permission", "d")} as permission
|
|
800
|
+
from unnest(coalesce(p_deny, '{}'::text[])) as d(permission)${join("d.permission", "d")}
|
|
801
|
+
union
|
|
802
|
+
select e.permission from entries e
|
|
803
|
+
where e.level is not null
|
|
804
|
+
and not exists (select 1 from levels l where l.permission = e.permission and l.level = e.level)
|
|
805
|
+
),
|
|
806
|
+
included as (
|
|
807
|
+
select rp.role, ${current("rp.permission", "rp")} as permission, rp.grant_key, rp.scope, rp.effect
|
|
808
|
+
from ${rp} rp${join("rp.permission", "rp")}
|
|
809
|
+
where rp.role = any(coalesce(p_include, '{}'::text[]))
|
|
810
|
+
),
|
|
811
|
+
kept as (
|
|
812
|
+
select i.permission, i.grant_key
|
|
813
|
+
from included i
|
|
814
|
+
where i.effect = 'allow'
|
|
815
|
+
and i.scope = p_scope
|
|
816
|
+
and exists (
|
|
817
|
+
select 1 from ceiling c
|
|
818
|
+
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
819
|
+
)
|
|
820
|
+
),
|
|
821
|
+
wanted as (
|
|
822
|
+
select e.permission from entries e where e.level is null
|
|
823
|
+
union
|
|
824
|
+
select i.permission
|
|
825
|
+
from included i
|
|
826
|
+
where i.effect = 'allow'
|
|
827
|
+
and not (i.scope = p_scope and exists (
|
|
828
|
+
select 1 from ceiling c
|
|
829
|
+
where c.role = i.role and c.grant_key = i.grant_key and c.effect = 'allow'
|
|
830
|
+
))
|
|
831
|
+
),
|
|
832
|
+
allowed as (
|
|
833
|
+
select w.permission
|
|
834
|
+
from wanted w
|
|
835
|
+
where w.permission not in (select d.permission from denied d)
|
|
836
|
+
and not exists (select 1 from kept k where k.permission = w.permission)
|
|
837
|
+
),
|
|
838
|
+
allowed_levels as (
|
|
839
|
+
select e.permission, e.level
|
|
840
|
+
from entries e
|
|
841
|
+
where e.level is not null
|
|
842
|
+
and e.permission not in (select d.permission from denied d)
|
|
843
|
+
and e.permission not in (select a.permission from allowed a)
|
|
844
|
+
and not exists (select 1 from kept k where k.permission = e.permission)
|
|
845
|
+
)
|
|
846
|
+
select k.grant_key from kept k
|
|
847
|
+
where k.permission not in (select d.permission from denied d)
|
|
848
|
+
union
|
|
849
|
+
select i.grant_key from included i
|
|
850
|
+
where i.effect = 'deny' and i.scope = p_scope
|
|
851
|
+
union
|
|
852
|
+
select c.grant_key from ceiling c
|
|
853
|
+
where c.permission in (select a.permission from allowed a)
|
|
854
|
+
union
|
|
855
|
+
select c.grant_key from ceiling c
|
|
856
|
+
join allowed_levels a on a.permission = c.permission
|
|
857
|
+
where c.effect = 'deny' or split_part(c.grant_key, '@', 2) = a.level
|
|
858
|
+
$$;
|
|
859
|
+
revoke execute on function ${keys}(text[], text[], text[], text) from public, anon, authenticated;`;
|
|
860
|
+
}
|
|
861
|
+
function currentKeysSql(array) {
|
|
862
|
+
return `select coalesce(r.key, e.permission) as permission
|
|
863
|
+
from unnest(coalesce(${array}, '{}'::text[])) as e(permission)
|
|
864
|
+
left join renamed r on r.former = e.permission`;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* SQL fragments that read a stored former key as its current key inside
|
|
868
|
+
* `permdock_custom_keys`. Without renames they are the plain column and
|
|
869
|
+
* array reads.
|
|
870
|
+
*/
|
|
871
|
+
function renamesSql(renames) {
|
|
872
|
+
if (renames.length === 0) return {
|
|
873
|
+
prelude: "",
|
|
874
|
+
permission: (alias) => `${alias}.permission`,
|
|
875
|
+
join: () => "",
|
|
876
|
+
allow: "select unnest(coalesce(p_allow, '{}'::text[])) as permission",
|
|
877
|
+
denied: (alias) => `${alias}.permission = any(coalesce(p_deny, '{}'::text[]))`
|
|
878
|
+
};
|
|
879
|
+
return {
|
|
880
|
+
prelude: `renamed (former, key) as (values ${renames.map(([former, current]) => `(${quoteLiteral(former)}, ${quoteLiteral(current)})`).join(", ")}),
|
|
881
|
+
denied as (
|
|
882
|
+
${currentKeysSql("p_deny")}
|
|
883
|
+
),
|
|
884
|
+
`,
|
|
885
|
+
permission: (alias) => `coalesce(r_${alias}.key, ${alias}.permission) as permission`,
|
|
886
|
+
join: (alias) => ` left join renamed r_${alias} on r_${alias}.former = ${alias}.permission`,
|
|
887
|
+
allow: currentKeysSql("p_allow"),
|
|
888
|
+
denied: (alias) => `${alias}.permission in (select d.permission from denied d)`
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* The catalog reader `permdock_permission_keys()` and the role reader
|
|
893
|
+
* `permdock_role_permissions(p_role, p_scope, p_tenant, p_scope_id)`, both
|
|
894
|
+
* for `authenticated`. A declared role answers from `role_permissions`, which
|
|
895
|
+
* is the catalog. A custom role (`database` mode) answers from the tenant's
|
|
896
|
+
* own rows through `permdock_custom_keys`, only to a member of the tenant or
|
|
897
|
+
* a holder of a `meta.manageRoles` permission for a platform role. The
|
|
898
|
+
* effect is `deny` for a deny the role carries after the ceiling, as
|
|
899
|
+
* `resolveCustomRole` returns it, never for a deny that merely removes an allow.
|
|
900
|
+
*/
|
|
901
|
+
function roleReadersSql(ctx, permissions, trustedReaders) {
|
|
902
|
+
const rp = qualified$1(ctx, "role_permissions");
|
|
903
|
+
const reader = qualified$1(ctx, READERS.roles);
|
|
904
|
+
const tenantType = tenantTypeOf(ctx);
|
|
905
|
+
const custom = ctx.customRoles;
|
|
906
|
+
const root = ctx.scopes[0]?.name;
|
|
907
|
+
const database = ctx.authorize === "database";
|
|
908
|
+
const chunks = [];
|
|
909
|
+
if (permissions !== void 0) {
|
|
910
|
+
const catalog = qualified$1(ctx, READERS.keys);
|
|
911
|
+
chunks.push(`-- every declared permission key
|
|
912
|
+
create or replace function ${catalog}()
|
|
913
|
+
returns setof text
|
|
914
|
+
language sql
|
|
915
|
+
stable
|
|
916
|
+
set search_path = ''
|
|
917
|
+
as $$
|
|
918
|
+
select k.permission from unnest(${textArray(permissions.toSorted())}) k(permission) order by 1
|
|
919
|
+
$$;
|
|
920
|
+
revoke execute on function ${catalog}() from public, anon;
|
|
921
|
+
grant execute on function ${catalog}() to authenticated;
|
|
922
|
+
|
|
923
|
+
create or replace function ${catalog}(p_scope text)
|
|
924
|
+
returns setof text
|
|
925
|
+
language sql
|
|
926
|
+
stable
|
|
927
|
+
security definer
|
|
928
|
+
set search_path = ''
|
|
929
|
+
as $$
|
|
930
|
+
select distinct rp.permission
|
|
931
|
+
from ${rp} rp
|
|
932
|
+
where rp.scope = p_scope
|
|
933
|
+
and rp.effect = 'allow'
|
|
934
|
+
and rp.permission = any(${textArray(permissions.toSorted())})
|
|
935
|
+
order by 1
|
|
936
|
+
$$;
|
|
937
|
+
revoke execute on function ${catalog}(text) from public, anon;
|
|
938
|
+
grant execute on function ${catalog}(text) to authenticated;`);
|
|
939
|
+
}
|
|
940
|
+
const declared = ` select distinct rp.permission, rp.effect
|
|
941
|
+
from ${rp} rp
|
|
942
|
+
where rp.role = p_role and rp.scope = p_scope
|
|
943
|
+
order by 1, 2`;
|
|
944
|
+
let body = `begin
|
|
945
|
+
perform p_tenant, p_scope_id; -- only a custom role reads them
|
|
946
|
+
return query
|
|
947
|
+
${declared};
|
|
948
|
+
end;`;
|
|
949
|
+
let trustedBody = body;
|
|
950
|
+
if (custom !== void 0 && database && root !== void 0) {
|
|
951
|
+
const tenant = qualified$1(ctx, CUSTOM_ROLES.permissions);
|
|
952
|
+
const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
|
|
953
|
+
const keys = qualified$1(ctx, CUSTOM_ROLES.keys);
|
|
954
|
+
const shape = qualified$1(ctx, CUSTOM_ROLES.shape);
|
|
955
|
+
const ids = (helper) => `(select x::text from ${qualified$1(ctx, helper)}() x)`;
|
|
956
|
+
const memberOf = `p_tenant::text in ${ids(memberIdsHelper(root))}
|
|
957
|
+
or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${ids(memberIdsHelper(scope))}`)}`;
|
|
958
|
+
const manage = custom.manage ?? [];
|
|
959
|
+
const platform = manage.length === 0 ? "false" : `exists (
|
|
960
|
+
select 1 from ${rp} m
|
|
961
|
+
where m.effect = 'allow'
|
|
962
|
+
and m.permission = any(${textArray(manage)})
|
|
963
|
+
and ${qualified$1(ctx, HELPERS.has)}(m.grant_key)
|
|
964
|
+
)`;
|
|
965
|
+
const match = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.scope_id is not distinct from p_scope_id and c.role = p_role`;
|
|
966
|
+
const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
|
|
967
|
+
const checked = (gate) => `begin
|
|
968
|
+
if p_role = any(${textArray(custom.declared)}) then
|
|
969
|
+
return query
|
|
970
|
+
${declared};
|
|
971
|
+
return;
|
|
972
|
+
end if;
|
|
973
|
+
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);${gate}
|
|
974
|
+
return query
|
|
975
|
+
select distinct rp.permission, rp.effect
|
|
976
|
+
from ${keys}(
|
|
977
|
+
array(select ${allowEntryOf} from ${tenant} c where ${match} and c.effect = 'allow'),
|
|
978
|
+
array(select c.permission from ${tenant} c where ${match} and c.effect = 'deny'),
|
|
979
|
+
array(select c.include_role from ${includes} c where ${match}),
|
|
980
|
+
p_scope
|
|
981
|
+
) k(grant_key)
|
|
982
|
+
join ${rp} rp on rp.grant_key = k.grant_key and rp.scope = p_scope
|
|
983
|
+
order by 1, 2;
|
|
984
|
+
end;`;
|
|
985
|
+
body = checked(`
|
|
986
|
+
if not (${signedIn(ctx)} and (
|
|
987
|
+
${platform}
|
|
988
|
+
or (p_scope <> 'global' and (${memberOf}))
|
|
989
|
+
)) then
|
|
990
|
+
${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
|
|
991
|
+
end if;`);
|
|
992
|
+
trustedBody = checked("");
|
|
993
|
+
}
|
|
994
|
+
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
|
|
995
|
+
create or replace function ${reader}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
|
|
996
|
+
returns table (permission text, effect text)
|
|
997
|
+
language plpgsql
|
|
998
|
+
stable
|
|
999
|
+
security definer
|
|
1000
|
+
set search_path = ''
|
|
1001
|
+
as $$
|
|
1002
|
+
${body}
|
|
1003
|
+
$$;
|
|
1004
|
+
revoke execute on function ${reader}(text, text, ${tenantType}, text) from public, anon;
|
|
1005
|
+
grant execute on function ${reader}(text, text, ${tenantType}, text) to authenticated;`);
|
|
1006
|
+
const trusted = qualified$1(ctx, READERS.trusted);
|
|
1007
|
+
const signature = `text, text, ${tenantType}, text`;
|
|
1008
|
+
const grants = trustedReaders === void 0 || trustedReaders.length === 0 ? "" : `\ngrant execute on function ${trusted}(${signature}) to ${trustedReaders.map(quoteIdent).join(", ")};`;
|
|
1009
|
+
chunks.push(`create or replace function ${trusted}(p_role text, p_scope text, p_tenant ${tenantType} default null, p_scope_id text default null)
|
|
1010
|
+
returns table (permission text, effect text)
|
|
1011
|
+
language plpgsql
|
|
1012
|
+
stable
|
|
1013
|
+
security definer
|
|
1014
|
+
set search_path = ''
|
|
1015
|
+
as $$
|
|
1016
|
+
${trustedBody}
|
|
1017
|
+
$$;
|
|
1018
|
+
revoke execute on function ${trusted}(${signature}) from public, anon, authenticated;${grants}`);
|
|
1019
|
+
return chunks.join("\n\n");
|
|
1020
|
+
}
|
|
1021
|
+
/** `expr` read as its current key: a former key maps to the key it was renamed to. */
|
|
1022
|
+
function currentKeyExpr(renames, expr) {
|
|
1023
|
+
if (renames.length === 0) return expr;
|
|
1024
|
+
return `(case ${expr} ${renames.map(([former, current]) => `when ${quoteLiteral(former)} then ${quoteLiteral(current)}`).join(" ")} else ${expr} end)`;
|
|
1025
|
+
}
|
|
1026
|
+
/** `case p_scope when '<scope>' then <sql for scope> ... else false end` over the declared scopes. */
|
|
1027
|
+
function perScope(ctx, sql) {
|
|
1028
|
+
const arms = ctx.scopes.map((scope) => `when ${quoteLiteral(scope.name)} then ${sql(scope.name)}`).join(" ");
|
|
1029
|
+
return arms === "" ? "false" : `(case p_scope ${arms} else false end)`;
|
|
1030
|
+
}
|
|
1031
|
+
function raiseSql(indent, errcode, message, hint) {
|
|
1032
|
+
return `${indent}raise exception using
|
|
1033
|
+
${indent} errcode = '${errcode}',
|
|
1034
|
+
${indent} message = ${message},
|
|
1035
|
+
${indent} hint = ${quoteLiteral(hint)};`;
|
|
1036
|
+
}
|
|
1037
|
+
function hasCustomRoleChecksFor(ctx) {
|
|
1038
|
+
return ctx.authorize === "database" && ctx.customRoles !== void 0 && ctx.scopes.length > 0 && ctx.scopes.every((scope) => hasMemberFor(ctx, scope.name));
|
|
1039
|
+
}
|
|
1040
|
+
function callerFunctionsSql(ctx, custom, root, levelReach, forUser) {
|
|
1041
|
+
const leveled = custom.levels === true;
|
|
1042
|
+
const tenantType = tenantTypeOf(ctx);
|
|
1043
|
+
const rp = qualified$1(ctx, "role_permissions");
|
|
1044
|
+
const keys = qualified$1(ctx, CUSTOM_ROLES.keys);
|
|
1045
|
+
const perms = qualified$1(ctx, CUSTOM_ROLES.permissions);
|
|
1046
|
+
const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
|
|
1047
|
+
const shape = qualified$1(ctx, CUSTOM_ROLES.shape);
|
|
1048
|
+
const beyond = qualified$1(ctx, forUser ? CUSTOM_ROLES.beyondFor : CUSTOM_ROLES.beyond);
|
|
1049
|
+
const guard = qualified$1(ctx, forUser ? CUSTOM_ROLES.guardFor : CUSTOM_ROLES.guard);
|
|
1050
|
+
const user = forUser ? "p_user" : subjectIdSql(ctx);
|
|
1051
|
+
const userParam = forUser ? "p_user uuid, " : "";
|
|
1052
|
+
const userArg = forUser ? "uuid, " : "";
|
|
1053
|
+
const userCall = forUser ? "p_user, " : "";
|
|
1054
|
+
const has = (key) => forUser ? `${qualified$1(ctx, HELPERS.hasFor)}(p_user, ${key})` : `${qualified$1(ctx, HELPERS.has)}(${key})`;
|
|
1055
|
+
const ids = (helper, arg) => `(select x::text from ${qualified$1(ctx, helper)}(${arg}) x)`;
|
|
1056
|
+
const permitted = (scope, key) => forUser ? ids(permittedForHelper(scope), `p_user, ${key}`) : ids(permittedIdsHelper(scope), key);
|
|
1057
|
+
const members = (scope) => forUser ? ids(memberForHelper(scope), "p_user") : ids(memberIdsHelper(scope), "");
|
|
1058
|
+
const heldKey = (key) => `(${has(key)}
|
|
1059
|
+
or (p_scope <> 'global'
|
|
1060
|
+
and (p_tenant::text in ${permitted(root, key)}
|
|
1061
|
+
or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${permitted(scope, key)}`)})))`;
|
|
1062
|
+
const holdsAny = (permissions, held) => permissions.length === 0 ? "false" : `exists (
|
|
1063
|
+
select 1 from ${rp} rp
|
|
1064
|
+
where rp.effect = 'allow'
|
|
1065
|
+
and rp.permission = any(${textArray(permissions)})
|
|
1066
|
+
and ${held("rp.grant_key")}
|
|
1067
|
+
)`;
|
|
1068
|
+
const manageKeys = custom.manage ?? [];
|
|
1069
|
+
const manage = holdsAny(manageKeys, heldKey);
|
|
1070
|
+
const platform = holdsAny(manageKeys, has);
|
|
1071
|
+
const requires = custom.requires === void 0 ? "" : `
|
|
1072
|
+
if not ${holdsAny(custom.requires, heldKey)} then
|
|
1073
|
+
${raiseSql(" ", "42501", `'permdock: the caller may not manage roles in ' || coalesce(p_tenant::text, 'the platform')`, "manage-roles")}
|
|
1074
|
+
end if;`;
|
|
1075
|
+
const reachRows = levelReach.map(([grantKey, level]) => `(${quoteLiteral(grantKey)}, ${quoteLiteral(level)})`).join(", ");
|
|
1076
|
+
const levelCheck = leveled && reachRows !== "" ? `
|
|
1077
|
+
or exists (
|
|
1078
|
+
select 1
|
|
1079
|
+
from reach x
|
|
1080
|
+
where x.grant_key = r.grant_key
|
|
1081
|
+
and not exists (
|
|
1082
|
+
select 1 from held h
|
|
1083
|
+
join reach o on o.grant_key = h.grant_key
|
|
1084
|
+
where h.permission = r.permission and o.level = x.level
|
|
1085
|
+
)
|
|
1086
|
+
)` : "";
|
|
1087
|
+
const reachCte = leveled && reachRows !== "" ? `,
|
|
1088
|
+
reach (grant_key, level) as (values ${reachRows})` : "";
|
|
1089
|
+
const match = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.scope_id is not distinct from p_scope_id`;
|
|
1090
|
+
const memberOf = `p_tenant::text in ${members(root)}
|
|
1091
|
+
or ${perScope(ctx, (scope) => scope === root ? "false" : `p_scope_id in ${members(scope)}`)}`;
|
|
1092
|
+
return {
|
|
1093
|
+
beyond: `-- custom-role writes: the permission keys of a definition the caller may not hand out in the tenant, as assignablePermissions computes them
|
|
1094
|
+
create or replace function ${beyond}(${userParam}p_tenant ${tenantType}, p_scope text, p_scope_id text, p_allow text[], p_deny text[], p_include text[])
|
|
1095
|
+
returns setof text
|
|
1096
|
+
language plpgsql
|
|
1097
|
+
stable
|
|
1098
|
+
security definer
|
|
1099
|
+
set search_path = ''
|
|
1100
|
+
as $$
|
|
1101
|
+
begin
|
|
1102
|
+
if ${manage} then
|
|
1103
|
+
return;
|
|
1104
|
+
end if;
|
|
1105
|
+
return query
|
|
1106
|
+
with resolved as (
|
|
1107
|
+
select distinct rp.permission, k.key as grant_key
|
|
1108
|
+
from ${keys}(p_allow, p_deny, p_include, p_scope) k(key)
|
|
1109
|
+
join ${rp} rp on rp.grant_key = k.key and rp.effect = 'allow'
|
|
1110
|
+
),
|
|
1111
|
+
held as (
|
|
1112
|
+
select distinct rp.permission, rp.grant_key
|
|
1113
|
+
from ${rp} rp
|
|
1114
|
+
where rp.effect = 'allow'
|
|
1115
|
+
and rp.permission in (select r.permission from resolved r)
|
|
1116
|
+
and ${heldKey("rp.grant_key")}
|
|
1117
|
+
)${reachCte}
|
|
1118
|
+
select distinct r.permission
|
|
1119
|
+
from resolved r
|
|
1120
|
+
where not exists (select 1 from held h where h.permission = r.permission)${levelCheck};
|
|
1121
|
+
end;
|
|
1122
|
+
$$;
|
|
1123
|
+
revoke execute on function ${beyond}(${userArg}${tenantType}, text, text, text[], text[], text[]) from public, anon, authenticated;`,
|
|
1124
|
+
guard: `-- a custom role the caller may change: membership of the tenant or the instance (a platform role, or a caller holding a manageRoles permission through a global role, needs none), and authority over the stored definition
|
|
1125
|
+
create or replace function ${guard}(${userParam}p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
|
|
1126
|
+
returns void
|
|
1127
|
+
language plpgsql
|
|
1128
|
+
stable
|
|
1129
|
+
security definer
|
|
1130
|
+
set search_path = ''
|
|
1131
|
+
as $$
|
|
1132
|
+
declare
|
|
1133
|
+
v_allow text[];
|
|
1134
|
+
v_deny text[];
|
|
1135
|
+
v_include text[];
|
|
1136
|
+
v_beyond text;
|
|
1137
|
+
begin
|
|
1138
|
+
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1139
|
+
if not (${signedIn(ctx, user)} and (
|
|
1140
|
+
${platform}
|
|
1141
|
+
or (p_scope <> 'global' and (${memberOf}))
|
|
1142
|
+
)) then
|
|
1143
|
+
${raiseSql(" ", "42501", `'permdock: the caller is not a member of ' || coalesce(p_tenant::text, 'the platform')`, "not-member")}
|
|
1144
|
+
end if;${requires}
|
|
1145
|
+
select array_agg(${allowEntry(ctx)}) filter (where c.effect = 'allow'),
|
|
1146
|
+
array_agg(c.permission) filter (where c.effect = 'deny')
|
|
1147
|
+
into v_allow, v_deny
|
|
1148
|
+
from ${perms} c
|
|
1149
|
+
where ${match} and c.role = p_role;
|
|
1150
|
+
select array_agg(c.include_role) into v_include
|
|
1151
|
+
from ${includes} c
|
|
1152
|
+
where ${match} and c.role = p_role;
|
|
1153
|
+
select string_agg(b, ', ' order by b) into v_beyond
|
|
1154
|
+
from ${beyond}(${userCall}p_tenant, p_scope, p_scope_id, v_allow, v_deny, v_include) b;
|
|
1155
|
+
if v_beyond is not null then
|
|
1156
|
+
${raiseSql(" ", "42501", `'permdock: ' || p_role || ' allows ' || v_beyond || ', which the caller may not hand out'`, "not-assignable-by")}
|
|
1157
|
+
end if;
|
|
1158
|
+
end;
|
|
1159
|
+
$$;
|
|
1160
|
+
revoke execute on function ${guard}(${userArg}${tenantType}, text, text, text) from public, anon, authenticated;`
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* The functions an application calls to save, rename and delete a custom
|
|
1165
|
+
* role in \`database\` mode, with the rules of \`validateCustomRole\` and
|
|
1166
|
+
* \`assignablePermissions\`: entries inside the ceiling of the role's scope,
|
|
1167
|
+
* includes naming declared roles, and only permissions and levels the caller
|
|
1168
|
+
* may hand out, for the new definition and the stored one alike. A platform
|
|
1169
|
+
* custom role (\`p_scope = 'global'\`, no tenant) is written by a caller who
|
|
1170
|
+
* holds a \`meta.manageRoles\` permission through a global role. The
|
|
1171
|
+
* \`permdock_trusted_*\` variants skip the caller checks and keep the
|
|
1172
|
+
* definition checks; no client role may execute them.
|
|
1173
|
+
*/
|
|
1174
|
+
function customRoleWritesSql(ctx, levelReach) {
|
|
1175
|
+
const custom = ctx.customRoles;
|
|
1176
|
+
const root = ctx.scopes[0]?.name;
|
|
1177
|
+
if (custom === void 0 || ctx.authorize !== "database" || root === void 0) return "";
|
|
1178
|
+
const leveled = custom.levels === true;
|
|
1179
|
+
const tenantType = tenantTypeOf(ctx);
|
|
1180
|
+
const rp = qualified$1(ctx, "role_permissions");
|
|
1181
|
+
const ceiling = qualified$1(ctx, CUSTOM_ROLES.ceiling);
|
|
1182
|
+
const perms = qualified$1(ctx, CUSTOM_ROLES.permissions);
|
|
1183
|
+
const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
|
|
1184
|
+
const beyond = qualified$1(ctx, CUSTOM_ROLES.beyond);
|
|
1185
|
+
const guard = qualified$1(ctx, CUSTOM_ROLES.guard);
|
|
1186
|
+
const shape = qualified$1(ctx, CUSTOM_ROLES.shape);
|
|
1187
|
+
const entries = qualified$1(ctx, CUSTOM_ROLES.entries);
|
|
1188
|
+
const renames = Object.entries(custom.renamed ?? {}).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1189
|
+
const current = (expr) => currentKeyExpr(renames, expr);
|
|
1190
|
+
const known = textArray([...custom.permissions ?? [], ...renames.map(([former]) => former)]);
|
|
1191
|
+
const caller = callerFunctionsSql(ctx, custom, root, levelReach, false);
|
|
1192
|
+
const match = `c.tenant_id is not distinct from p_tenant and c.scope = p_scope and c.scope_id is not distinct from p_scope_id`;
|
|
1193
|
+
const scopes = textArray(ctx.scopes.map((scope) => scope.name));
|
|
1194
|
+
const roleName = (param) => `coalesce(${param}, '') = '' or ${param} = any(${textArray(custom.declared)})`;
|
|
1195
|
+
const levelOf = leveled ? ", nullif(split_part(e, '@', 2), '')" : "";
|
|
1196
|
+
const write = ` delete from ${perms} c where ${match} and c.role = p_role;
|
|
1197
|
+
delete from ${includes} c where ${match} and c.role = p_role;
|
|
1198
|
+
insert into ${perms} (tenant_id, scope, scope_id, role, permission, effect${leveled ? ", level" : ""})
|
|
1199
|
+
select distinct p_tenant, p_scope, p_scope_id, p_role, ${current(leveled ? "split_part(e, '@', 1)" : "e")}, 'allow'${levelOf}
|
|
1200
|
+
from unnest(coalesce(p_allow, '{}'::text[])) e
|
|
1201
|
+
union
|
|
1202
|
+
select distinct p_tenant, p_scope, p_scope_id, p_role, ${current("e")}, 'deny'${leveled ? ", null" : ""}
|
|
1203
|
+
from unnest(coalesce(p_deny, '{}'::text[])) e;
|
|
1204
|
+
insert into ${includes} (tenant_id, scope, scope_id, role, include_role)
|
|
1205
|
+
select distinct p_tenant, p_scope, p_scope_id, p_role, e
|
|
1206
|
+
from unnest(coalesce(p_include, '{}'::text[])) e;`;
|
|
1207
|
+
const move = ` if ${roleName("p_to")} then
|
|
1208
|
+
${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_to, 'null') || ' is not a custom role name'`, "declared-role")}
|
|
1209
|
+
end if;
|
|
1210
|
+
if exists (select 1 from ${perms} c where ${match} and c.role = p_to)
|
|
1211
|
+
or exists (select 1 from ${includes} c where ${match} and c.role = p_to) then
|
|
1212
|
+
${raiseSql(" ", "22023", `'permdock: ' || p_to || ' already has grants'`, "role-exists")}
|
|
1213
|
+
end if;
|
|
1214
|
+
update ${perms} c set role = p_to where ${match} and c.role = p_from;
|
|
1215
|
+
update ${includes} c set role = p_to where ${match} and c.role = p_from;`;
|
|
1216
|
+
const remove = ` delete from ${perms} c where ${match} and c.role = p_role;
|
|
1217
|
+
delete from ${includes} c where ${match} and c.role = p_role;`;
|
|
1218
|
+
const writeArgs = `${tenantType}, text, text, text, text[], text[], text[]`;
|
|
1219
|
+
const renameArgs = `${tenantType}, text, text, text, text`;
|
|
1220
|
+
const removeArgs = `${tenantType}, text, text, text`;
|
|
1221
|
+
const fn = (comment, name, params, args, body, member) => `-- ${comment}
|
|
1222
|
+
create or replace function ${qualified$1(ctx, name)}(${params})
|
|
1223
|
+
returns void
|
|
1224
|
+
language plpgsql
|
|
1225
|
+
volatile
|
|
1226
|
+
security definer
|
|
1227
|
+
set search_path = ''
|
|
1228
|
+
as $$
|
|
1229
|
+
${body}
|
|
1230
|
+
$$;
|
|
1231
|
+
${member ? `revoke execute on function ${qualified$1(ctx, name)}(${args}) from public, anon;
|
|
1232
|
+
grant execute on function ${qualified$1(ctx, name)}(${args}) to authenticated;` : `revoke execute on function ${qualified$1(ctx, name)}(${args}) from public, anon, authenticated;`}`;
|
|
1233
|
+
const writeParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text, p_allow text[], p_deny text[], p_include text[]`;
|
|
1234
|
+
const renameParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_from text, p_to text`;
|
|
1235
|
+
const removeParams = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
|
|
1236
|
+
return `${caller.beyond}
|
|
1237
|
+
|
|
1238
|
+
-- where a custom role may live: a scope of a tenant, or the platform (scope global, no tenant or instance), under an undeclared name
|
|
1239
|
+
create or replace function ${shape}(p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text)
|
|
1240
|
+
returns void
|
|
1241
|
+
language plpgsql
|
|
1242
|
+
stable
|
|
1243
|
+
set search_path = ''
|
|
1244
|
+
as $$
|
|
1245
|
+
begin
|
|
1246
|
+
if p_scope = 'global' then
|
|
1247
|
+
if p_tenant is not null or p_scope_id is not null then
|
|
1248
|
+
${raiseSql(" ", "22023", `'permdock: a platform custom role has no tenant or instance'`, "unknown-scope")}
|
|
1249
|
+
end if;
|
|
1250
|
+
elsif p_tenant is null or not (p_scope = any(${scopes})) then
|
|
1251
|
+
${raiseSql(" ", "22023", `'permdock: custom roles are held at a scope of a tenant or at global, not ' || coalesce(p_scope, 'null')`, "unknown-scope")}
|
|
1252
|
+
end if;
|
|
1253
|
+
if ${roleName("p_role")} then
|
|
1254
|
+
${raiseSql(" ", "22023", `'permdock: ' || coalesce(p_role, 'null') || ' is not a custom role name'`, "declared-role")}
|
|
1255
|
+
end if;
|
|
1256
|
+
end;
|
|
1257
|
+
$$;
|
|
1258
|
+
revoke execute on function ${shape}(${tenantType}, text, text, text) from public, anon, authenticated;
|
|
1259
|
+
|
|
1260
|
+
${caller.guard}
|
|
1261
|
+
${hasCustomRoleChecksFor(ctx) ? `
|
|
1262
|
+
-- the same checks for a user the caller names: trusted SQL acting later for a stored user; no client role may execute them
|
|
1263
|
+
${callerFunctionsSql(ctx, custom, root, levelReach, true).beyond}
|
|
1264
|
+
|
|
1265
|
+
${callerFunctionsSql(ctx, custom, root, levelReach, true).guard}
|
|
1266
|
+
` : ""}
|
|
1267
|
+
-- the entries of a definition: declared keys and levels inside the ceiling of the scope, and declared includes
|
|
1268
|
+
create or replace function ${entries}(p_scope text, p_allow text[], p_deny text[], p_include text[])
|
|
1269
|
+
returns void
|
|
1270
|
+
language plpgsql
|
|
1271
|
+
stable
|
|
1272
|
+
security definer
|
|
1273
|
+
set search_path = ''
|
|
1274
|
+
as $$
|
|
1275
|
+
declare
|
|
1276
|
+
v_entry text;
|
|
1277
|
+
v_key text;
|
|
1278
|
+
begin
|
|
1279
|
+
foreach v_entry in array coalesce(p_allow, '{}'::text[]) loop
|
|
1280
|
+
v_key := ${current(leveled ? "split_part(v_entry, '@', 1)" : "v_entry")};
|
|
1281
|
+
if not (v_key = any(${known})) then
|
|
1282
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared permission'`, "unknown-permission")}
|
|
1283
|
+
end if;
|
|
1284
|
+
if not exists (
|
|
1285
|
+
select 1 from ${ceiling} c
|
|
1286
|
+
where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
|
|
1287
|
+
) then
|
|
1288
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_key || ' is outside the ceiling of ' || p_scope`, "outside-ceiling")}
|
|
1289
|
+
end if;${leveled ? `
|
|
1290
|
+
if strpos(v_entry, '@') > 0 and not exists (
|
|
1291
|
+
select 1 from ${ceiling} c
|
|
1292
|
+
where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = v_key
|
|
1293
|
+
and split_part(c.grant_key, '@', 2) = split_part(v_entry, '@', 2)
|
|
1294
|
+
) then
|
|
1295
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' names a level the resource does not declare'`, "unknown-level")}
|
|
1296
|
+
end if;` : ""}
|
|
1297
|
+
end loop;
|
|
1298
|
+
foreach v_entry in array coalesce(p_deny, '{}'::text[]) loop
|
|
1299
|
+
if not (v_entry = any(${known})) then
|
|
1300
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared permission'`, "unknown-permission")}
|
|
1301
|
+
end if;
|
|
1302
|
+
end loop;
|
|
1303
|
+
foreach v_entry in array coalesce(p_include, '{}'::text[]) loop
|
|
1304
|
+
if not (v_entry = any(${textArray(custom.declared)})) then
|
|
1305
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_entry || ' is not a declared role'`, "unknown-role")}
|
|
1306
|
+
end if;
|
|
1307
|
+
end loop;
|
|
1308
|
+
select string_agg(distinct ${current("rp.permission")}, ', ') into v_key
|
|
1309
|
+
from ${rp} rp
|
|
1310
|
+
where rp.role = any(coalesce(p_include, '{}'::text[]))
|
|
1311
|
+
and rp.effect = 'allow'
|
|
1312
|
+
and not exists (
|
|
1313
|
+
select 1 from ${ceiling} c
|
|
1314
|
+
where c.scope = p_scope and c.effect = 'allow' and ${current("c.permission")} = ${current("rp.permission")}
|
|
1315
|
+
);
|
|
1316
|
+
if v_key is not null then
|
|
1317
|
+
${raiseSql(" ", "22023", `'permdock: an included role allows ' || v_key || ', outside the ceiling of ' || p_scope`, "outside-ceiling")}
|
|
1318
|
+
end if;
|
|
1319
|
+
end;
|
|
1320
|
+
$$;
|
|
1321
|
+
revoke execute on function ${entries}(text, text[], text[], text[]) from public, anon, authenticated;
|
|
1322
|
+
|
|
1323
|
+
${fn("save a custom role: replace its grants and includes after checking each entry against the ceiling and the caller's own permissions", CUSTOM_ROLES.replace, writeParams, writeArgs, `declare
|
|
1324
|
+
v_beyond text;
|
|
1325
|
+
begin
|
|
1326
|
+
perform ${guard}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1327
|
+
perform ${entries}(p_scope, p_allow, p_deny, p_include);
|
|
1328
|
+
select string_agg(b, ', ' order by b) into v_beyond
|
|
1329
|
+
from ${beyond}(p_tenant, p_scope, p_scope_id, p_allow, p_deny, p_include) b;
|
|
1330
|
+
if v_beyond is not null then
|
|
1331
|
+
${raiseSql(" ", "42501", `'permdock: ' || p_role || ' would allow ' || v_beyond || ', which the caller may not hand out'`, "not-assignable-by")}
|
|
1332
|
+
end if;
|
|
1333
|
+
${write}
|
|
1334
|
+
end;`, true)}
|
|
1335
|
+
|
|
1336
|
+
${fn("rename a custom role: move its grants and includes to the new name", CUSTOM_ROLES.rename, renameParams, renameArgs, `begin
|
|
1337
|
+
perform ${guard}(p_tenant, p_scope, p_scope_id, p_from);
|
|
1338
|
+
${move}
|
|
1339
|
+
end;`, true)}
|
|
1340
|
+
|
|
1341
|
+
${fn("delete a custom role: remove its grants and includes", CUSTOM_ROLES.remove, removeParams, removeArgs, `begin
|
|
1342
|
+
perform ${guard}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1343
|
+
${remove}
|
|
1344
|
+
end;`, true)}
|
|
1345
|
+
|
|
1346
|
+
${fn("save a custom role for a trusted caller (a migration, a job, a backend role granted execute): the definition checks without the caller checks", CUSTOM_ROLES.trustedReplace, writeParams, writeArgs, `begin
|
|
1347
|
+
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1348
|
+
perform ${entries}(p_scope, p_allow, p_deny, p_include);
|
|
1349
|
+
${write}
|
|
1350
|
+
end;`, false)}
|
|
1351
|
+
|
|
1352
|
+
${fn("rename a custom role for a trusted caller", CUSTOM_ROLES.trustedRename, renameParams, renameArgs, `begin
|
|
1353
|
+
perform ${shape}(p_tenant, p_scope, p_scope_id, p_from);
|
|
1354
|
+
${move}
|
|
1355
|
+
end;`, false)}
|
|
1356
|
+
|
|
1357
|
+
${fn("delete a custom role for a trusted caller", CUSTOM_ROLES.trustedRemove, removeParams, removeArgs, `begin
|
|
1358
|
+
perform ${shape}(p_tenant, p_scope, p_scope_id, p_role);
|
|
1359
|
+
${remove}
|
|
1360
|
+
end;`, false)}`;
|
|
1361
|
+
}
|
|
1362
|
+
/** `row.column`, for a trigger's `old` or `new`. */
|
|
1363
|
+
function rowColumn(row, name) {
|
|
1364
|
+
return `${row}.${quoteIdent(name)}`;
|
|
1365
|
+
}
|
|
1366
|
+
/** The custom-role rows of the role a trigger's `v_<prefix>_*` variables name. */
|
|
1367
|
+
function cascadeMatch(prefix) {
|
|
1368
|
+
return `c.tenant_id is not distinct from v_${prefix}_tenant and c.scope = v_${prefix}_scope and c.scope_id is not distinct from v_${prefix}_id and c.role = v_${prefix}_key`;
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* The trigger on the application's roles table (`rls.customRoleWrites.roles`):
|
|
1372
|
+
* a rename, a move to another tenant, scope or instance, or a delete of a
|
|
1373
|
+
* custom role's row carries its grants and includes along. A signed-in
|
|
1374
|
+
* caller at trigger depth 1 passes the write functions' checks: authority
|
|
1375
|
+
* over the stored definition where it was, and, after a move, membership
|
|
1376
|
+
* and authority where it lands and entries inside that scope's ceiling.
|
|
1377
|
+
* Other paths (a migration, a job, a nested trigger) move the rows as the
|
|
1378
|
+
* function owner.
|
|
1379
|
+
*/
|
|
1380
|
+
function customRoleCascadeSql(ctx) {
|
|
1381
|
+
const custom = ctx.customRoles;
|
|
1382
|
+
const table = custom?.table;
|
|
1383
|
+
const root = ctx.scopes[0]?.name;
|
|
1384
|
+
if (custom === void 0 || table === void 0 || root === void 0) return "";
|
|
1385
|
+
const tenantType = tenantTypeOf(ctx);
|
|
1386
|
+
const perms = qualified$1(ctx, CUSTOM_ROLES.permissions);
|
|
1387
|
+
const includes = qualified$1(ctx, CUSTOM_ROLES.includes);
|
|
1388
|
+
const fn = qualified$1(ctx, CUSTOM_ROLES.cascade);
|
|
1389
|
+
const target = quoteTable(table.table.includes(".") ? table.table : `public.${table.table}`);
|
|
1390
|
+
const tenant = (row) => table.tenant === void 0 ? `null::${tenantType}` : `${rowColumn(row, table.tenant)}::${tenantType}`;
|
|
1391
|
+
const scope = (row) => table.scope === void 0 ? `case when ${tenant(row)} is null then 'global' else ${quoteLiteral(root)} end` : `case when ${tenant(row)} is null then 'global' else ${rowColumn(row, table.scope)}::text end`;
|
|
1392
|
+
const id = (row) => table.id === void 0 ? "null::text" : `${rowColumn(row, table.id)}::text`;
|
|
1393
|
+
const skipped = (row) => table.skip === void 0 ? "false" : `coalesce(${rowColumn(row, table.skip)}, false)`;
|
|
1394
|
+
const guard = qualified$1(ctx, CUSTOM_ROLES.guard);
|
|
1395
|
+
const entries = qualified$1(ctx, CUSTOM_ROLES.entries);
|
|
1396
|
+
const allowEntryOf = custom.levels === true ? "c.permission || coalesce('@' || c.level, '')" : "c.permission";
|
|
1397
|
+
return `-- the application's roles table: renames, moves and deletes of a custom role carry its grants and includes
|
|
1398
|
+
create or replace function ${fn}()
|
|
1399
|
+
returns trigger
|
|
1400
|
+
language plpgsql
|
|
1401
|
+
volatile
|
|
1402
|
+
security definer
|
|
1403
|
+
set search_path = ''
|
|
1404
|
+
as $$
|
|
1405
|
+
declare
|
|
1406
|
+
v_old_tenant ${tenantType} := ${tenant("old")};
|
|
1407
|
+
v_old_scope text := ${scope("old")};
|
|
1408
|
+
v_old_id text := ${id("old")};
|
|
1409
|
+
v_old_key text := ${rowColumn("old", table.key)}::text;
|
|
1410
|
+
v_new_tenant ${tenantType};
|
|
1411
|
+
v_new_scope text;
|
|
1412
|
+
v_new_id text;
|
|
1413
|
+
v_new_key text;
|
|
1414
|
+
v_checked boolean := ${signedIn(ctx)} and pg_trigger_depth() = 1;
|
|
1415
|
+
v_allow text[];
|
|
1416
|
+
v_deny text[];
|
|
1417
|
+
v_include text[];
|
|
1418
|
+
begin
|
|
1419
|
+
if ${skipped("old")} then
|
|
1420
|
+
return null;
|
|
1421
|
+
end if;
|
|
1422
|
+
if tg_op = 'UPDATE' and not ${skipped("new")} then
|
|
1423
|
+
v_new_tenant := ${tenant("new")};
|
|
1424
|
+
v_new_scope := ${scope("new")};
|
|
1425
|
+
v_new_id := ${id("new")};
|
|
1426
|
+
v_new_key := ${rowColumn("new", table.key)}::text;
|
|
1427
|
+
if v_new_key = v_old_key
|
|
1428
|
+
and v_new_tenant is not distinct from v_old_tenant
|
|
1429
|
+
and v_new_scope = v_old_scope
|
|
1430
|
+
and v_new_id is not distinct from v_old_id then
|
|
1431
|
+
return null;
|
|
1432
|
+
end if;
|
|
1433
|
+
end if;
|
|
1434
|
+
if not exists (select 1 from ${perms} c where ${cascadeMatch("old")})
|
|
1435
|
+
and not exists (select 1 from ${includes} c where ${cascadeMatch("old")}) then
|
|
1436
|
+
return null;
|
|
1437
|
+
end if;
|
|
1438
|
+
if v_checked then
|
|
1439
|
+
perform ${guard}(v_old_tenant, v_old_scope, v_old_id, v_old_key);
|
|
1440
|
+
end if;
|
|
1441
|
+
if v_new_key is null then
|
|
1442
|
+
delete from ${perms} c where ${cascadeMatch("old")};
|
|
1443
|
+
delete from ${includes} c where ${cascadeMatch("old")};
|
|
1444
|
+
return null;
|
|
1445
|
+
end if;
|
|
1446
|
+
if exists (select 1 from ${perms} c where ${cascadeMatch("new")})
|
|
1447
|
+
or exists (select 1 from ${includes} c where ${cascadeMatch("new")}) then
|
|
1448
|
+
${raiseSql(" ", "22023", `'permdock: ' || v_new_key || ' already has grants'`, "role-exists")}
|
|
1449
|
+
end if;
|
|
1450
|
+
update ${perms} c set tenant_id = v_new_tenant, scope = v_new_scope, scope_id = v_new_id, role = v_new_key where ${cascadeMatch("old")};
|
|
1451
|
+
update ${includes} c set tenant_id = v_new_tenant, scope = v_new_scope, scope_id = v_new_id, role = v_new_key where ${cascadeMatch("old")};
|
|
1452
|
+
if v_checked then
|
|
1453
|
+
perform ${guard}(v_new_tenant, v_new_scope, v_new_id, v_new_key);
|
|
1454
|
+
select array_agg(${allowEntryOf}) filter (where c.effect = 'allow'),
|
|
1455
|
+
array_agg(c.permission) filter (where c.effect = 'deny')
|
|
1456
|
+
into v_allow, v_deny
|
|
1457
|
+
from ${perms} c
|
|
1458
|
+
where ${cascadeMatch("new")};
|
|
1459
|
+
select array_agg(c.include_role) into v_include
|
|
1460
|
+
from ${includes} c
|
|
1461
|
+
where ${cascadeMatch("new")};
|
|
1462
|
+
perform ${entries}(v_new_scope, v_allow, v_deny, v_include);
|
|
1463
|
+
end if;
|
|
1464
|
+
return null;
|
|
1465
|
+
end;
|
|
1466
|
+
$$;
|
|
1467
|
+
revoke execute on function ${fn}() from public, anon, authenticated;
|
|
1468
|
+
drop trigger if exists "permdock_custom_role_cascade" on ${target};
|
|
1469
|
+
create trigger "permdock_custom_role_cascade"
|
|
1470
|
+
after update or delete on ${target}
|
|
1471
|
+
for each row execute function ${fn}();
|
|
1472
|
+
`;
|
|
1473
|
+
}
|
|
1474
|
+
//#endregion
|
|
1475
|
+
//#region src/cli/rls-global-roles.ts
|
|
1476
|
+
function qualifiedName(name) {
|
|
1477
|
+
return name.includes(".") ? name : `public.${name}`;
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* `permdock_replace_global_roles(p_user, p_roles)`: sets the global roles of
|
|
1481
|
+
* one user in `rls.roles` in one statement pair. It runs as the invoker, so
|
|
1482
|
+
* the table's policies and the `rls.assignments` trigger judge every row it
|
|
1483
|
+
* deletes or inserts.
|
|
1484
|
+
*/
|
|
1485
|
+
function globalRolesSql(ctx) {
|
|
1486
|
+
if (ctx.roles === void 0) return "";
|
|
1487
|
+
const source = globalRoleSource(ctx.roles, "public", "t");
|
|
1488
|
+
const table = quoteTable(qualifiedName(ctx.roles.table));
|
|
1489
|
+
const user = quoteIdent(source.user);
|
|
1490
|
+
const fn = qualified$1(ctx, GLOBAL_ROLES.replace);
|
|
1491
|
+
const type = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
1492
|
+
const wanted = `select distinct r from unnest(coalesce(p_roles, '{}'::text[])) r where r is not null`;
|
|
1493
|
+
const through = source.through;
|
|
1494
|
+
let body;
|
|
1495
|
+
if (through === void 0) {
|
|
1496
|
+
const role = typeof ctx.roles.role === "string" ? ctx.roles.role : "role";
|
|
1497
|
+
const column = quoteIdent(role);
|
|
1498
|
+
body = ` delete from ${table} t
|
|
1499
|
+
where t.${user} = p_user
|
|
1500
|
+
and t.${column}::text <> all(coalesce(p_roles, '{}'::text[]));
|
|
1501
|
+
insert into ${table} (${user}, ${column})
|
|
1502
|
+
select p_user, w.r
|
|
1503
|
+
from (${wanted}) w(r)
|
|
1504
|
+
where not exists (
|
|
1505
|
+
select 1 from ${table} t where t.${user} = p_user and t.${column}::text = w.r
|
|
1506
|
+
);`;
|
|
1507
|
+
} else {
|
|
1508
|
+
const keys = quoteTable(through.table);
|
|
1509
|
+
const id = quoteIdent(through.id);
|
|
1510
|
+
const key = quoteIdent(through.key);
|
|
1511
|
+
const ref = quoteIdent(through.ref);
|
|
1512
|
+
body = ` select string_agg(w.r, ', ' order by w.r) into v_unknown
|
|
1513
|
+
from (${wanted}) w(r)
|
|
1514
|
+
where not exists (select 1 from ${keys} k where k.${key}::text = w.r);
|
|
1515
|
+
if v_unknown is not null then
|
|
1516
|
+
raise exception using
|
|
1517
|
+
errcode = '22023',
|
|
1518
|
+
message = 'permdock: ' || v_unknown || ' is not a role',
|
|
1519
|
+
hint = 'unknown-role';
|
|
1520
|
+
end if;
|
|
1521
|
+
delete from ${table} t
|
|
1522
|
+
where t.${user} = p_user
|
|
1523
|
+
and not exists (
|
|
1524
|
+
select 1 from ${keys} k
|
|
1525
|
+
where k.${id} = t.${ref}
|
|
1526
|
+
and k.${key}::text = any(coalesce(p_roles, '{}'::text[]))
|
|
1527
|
+
);
|
|
1528
|
+
insert into ${table} (${user}, ${ref})
|
|
1529
|
+
select p_user, k.${id}
|
|
1530
|
+
from ${keys} k
|
|
1531
|
+
where k.${key}::text in (${wanted})
|
|
1532
|
+
and not exists (
|
|
1533
|
+
select 1 from ${table} t where t.${user} = p_user and t.${ref} = k.${id}
|
|
1534
|
+
);`;
|
|
1535
|
+
}
|
|
1536
|
+
const declare = through === void 0 ? "" : "declare\n v_unknown text;\n";
|
|
1537
|
+
const args = `${type}, text[]`;
|
|
1538
|
+
return `-- set the global roles of a user: delete the others and insert the missing ones, as the caller, so the table's policies and the assignment trigger still apply
|
|
1539
|
+
create or replace function ${fn}(p_user ${type}, p_roles text[])
|
|
1540
|
+
returns void
|
|
1541
|
+
language plpgsql
|
|
1542
|
+
volatile
|
|
1543
|
+
set search_path = ''
|
|
1544
|
+
as $$
|
|
1545
|
+
${declare}begin
|
|
1546
|
+
${body}
|
|
1547
|
+
end;
|
|
1548
|
+
$$;
|
|
1549
|
+
revoke execute on function ${fn}(${args}) from public, anon;
|
|
1550
|
+
grant execute on function ${fn}(${args}) to authenticated;`;
|
|
1551
|
+
}
|
|
1552
|
+
//#endregion
|
|
1553
|
+
//#region src/cli/rls-helpers.ts
|
|
1554
|
+
/** The helper `--capabilities` adds: ids of one resource a link capability claim reaches. Part of the SQL contract. */
|
|
1555
|
+
const CAPABILITIES = { ids: "permdock_capability_ids" };
|
|
1556
|
+
/**
|
|
1557
|
+
* The policy-side call for a grant key. Every form is uncorrelated, so
|
|
1558
|
+
* Postgres runs the helper once per statement (an InitPlan, or a hashed
|
|
1559
|
+
* SubPlan for `in`), never once per row.
|
|
1560
|
+
*/
|
|
1561
|
+
function accessSql(ctx, scope, grantKey, column) {
|
|
1562
|
+
const key = quoteLiteral(grantKey);
|
|
1563
|
+
if (scope === "global") return `(select ${qualified$1(ctx, HELPERS.has)}(${key}))`;
|
|
1564
|
+
if (column === void 0) throw new Error(`PermDock CLI: ${scope}-scoped grant needs definePolicy({ scopes.${scope} })`);
|
|
1565
|
+
return `${quoteIdent(column)} in (select ${qualified$1(ctx, permittedIdsHelper(scope))}(${key}))`;
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* The `anon` branch access for a resource-scoped grant reached through a link
|
|
1569
|
+
* capability: the row's `field` holds an id the capability claim names for
|
|
1570
|
+
* `resource`, `role` and `permission`. Uncorrelated, so it runs once per
|
|
1571
|
+
* statement; the column is compared as text because the claim holds text.
|
|
1572
|
+
*/
|
|
1573
|
+
function capabilityAccessSql(ctx, field, resource, role, permission) {
|
|
1574
|
+
const args = [
|
|
1575
|
+
resource,
|
|
1576
|
+
role,
|
|
1577
|
+
permission
|
|
1578
|
+
].map(quoteLiteral).join(", ");
|
|
1579
|
+
return `${quoteIdent(field)}::text in (select ${qualified$1(ctx, CAPABILITIES.ids)}(${args}))`;
|
|
1580
|
+
}
|
|
1581
|
+
function capabilitiesSql(ctx) {
|
|
1582
|
+
if (ctx.capabilities !== true) return "";
|
|
1583
|
+
const fn = qualified$1(ctx, CAPABILITIES.ids);
|
|
1584
|
+
return `-- link capabilities: the capability claim exchangeCapability mints (role anon, short-lived)
|
|
1585
|
+
create or replace function ${fn}(p_resource text, p_role text, p_permission text)
|
|
1586
|
+
returns setof text
|
|
1587
|
+
language sql
|
|
1588
|
+
stable
|
|
1589
|
+
set search_path = ''
|
|
1590
|
+
as $$
|
|
1591
|
+
select c -> 'on' ->> 'id'
|
|
1592
|
+
from (select ${subjectClaimJsonSql(ctx, "capability")} as c) capability
|
|
1593
|
+
where jsonb_typeof(c) = 'object'
|
|
1594
|
+
and c ->> 'v' = '1'
|
|
1595
|
+
and c ->> 'holder' = 'link'
|
|
1596
|
+
and c -> 'on' ->> 'resource' = p_resource
|
|
1597
|
+
and jsonb_typeof(c -> 'roles') = 'array'
|
|
1598
|
+
and c -> 'roles' @> jsonb_build_array(p_role)
|
|
1599
|
+
and (c -> 'permissions' is null or c -> 'permissions' @> jsonb_build_array(p_permission))
|
|
1600
|
+
and jsonb_typeof(c -> 'expiresAt') = 'number'
|
|
1601
|
+
and (c ->> 'expiresAt')::numeric > extract(epoch from now())
|
|
1602
|
+
$$;
|
|
1603
|
+
revoke execute on function ${fn}(text, text, text) from public;
|
|
1604
|
+
grant execute on function ${fn}(text, text, text) to anon, authenticated;`;
|
|
1605
|
+
}
|
|
1606
|
+
function userIdSql(ctx, anonExecute) {
|
|
1607
|
+
const fn = userIdHelperSql(ctx);
|
|
1608
|
+
return `-- the caller's user id: the sub of request.jwt.claims, or null when it is empty or absent
|
|
1609
|
+
create or replace function ${fn}()
|
|
1610
|
+
returns uuid
|
|
1611
|
+
language sql
|
|
1612
|
+
stable
|
|
1613
|
+
set search_path = ''
|
|
1614
|
+
as $$
|
|
1615
|
+
select nullif(
|
|
1616
|
+
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub',
|
|
1617
|
+
''
|
|
1618
|
+
)::uuid
|
|
1619
|
+
$$;
|
|
1620
|
+
${anonExecute ? `revoke execute on function ${fn}() from public;
|
|
1621
|
+
grant execute on function ${fn}() to anon, authenticated;` : `revoke execute on function ${fn}() from public, anon;
|
|
1622
|
+
grant execute on function ${fn}() to authenticated;`}`;
|
|
1623
|
+
}
|
|
1624
|
+
function membershipTable(name) {
|
|
1625
|
+
return quoteTable(name.includes(".") ? name : `public.${name}`);
|
|
1626
|
+
}
|
|
1627
|
+
function activeTenant(ctx) {
|
|
1628
|
+
return `nullif(${subjectClaimSql(ctx, ctx.tenantClaim)}, '')`;
|
|
1629
|
+
}
|
|
1630
|
+
function keyTenantLine(ctx, scope, tenant) {
|
|
1631
|
+
const keyed = keyTenantSql(ctx, underRoot(ctx, scope) ? tenant : void 0);
|
|
1632
|
+
return keyed === void 0 ? "" : `\n and ${keyed}`;
|
|
1633
|
+
}
|
|
1634
|
+
/** Whether the helper for `scope` narrows to the tenant claim: unless `rls.tenants` is `'all'`, when the first scope is on its chain. */
|
|
1635
|
+
function narrowsTo(ctx, scope) {
|
|
1636
|
+
return ctx.tenants !== "all" && underRoot(ctx, scope);
|
|
1637
|
+
}
|
|
1638
|
+
/** Whether the active tenant narrows memberships of `scope`: the first scope is on its chain. */
|
|
1639
|
+
function underRoot(ctx, scope) {
|
|
1640
|
+
const root = ctx.scopes[0]?.name;
|
|
1641
|
+
let current = ctx.scopes.find((item) => item.name === scope);
|
|
1642
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1643
|
+
while (current !== void 0 && !seen.has(current.name)) {
|
|
1644
|
+
if (current.name === root) return true;
|
|
1645
|
+
seen.add(current.name);
|
|
1646
|
+
const parent = current.within;
|
|
1647
|
+
current = parent === void 0 ? void 0 : ctx.scopes.find((item) => item.name === parent);
|
|
1648
|
+
}
|
|
1649
|
+
return false;
|
|
1650
|
+
}
|
|
1651
|
+
/** `and exists (...)` lines for an active user; empty without `rls.suspension.users`. */
|
|
1652
|
+
function userActive(ctx, indent, user) {
|
|
1653
|
+
return activeUserSql(ctx, user).map((part) => `${indent}and ${part}`);
|
|
1654
|
+
}
|
|
1655
|
+
/** `and exists (...)` lines for every suspendable instance on the chain of `scope`'s membership. */
|
|
1656
|
+
function instancesActive(ctx, scope, idOf, indent, permission) {
|
|
1657
|
+
return activeInstancesSql(ctx, scope, idOf, permission).map((part) => `${indent}and ${part}`);
|
|
1658
|
+
}
|
|
1659
|
+
function roleRows(ctx) {
|
|
1660
|
+
const claim = ctx.roleClaim ?? "user_role";
|
|
1661
|
+
switch (ctx.dialect) {
|
|
1662
|
+
case "guc": return `unnest(string_to_array(nullif(current_setting(${quoteLiteral(`${ctx.gucPrefix}.${claim}`)}, true), ''), ',')) r(role)`;
|
|
1663
|
+
case "supabase":
|
|
1664
|
+
case "neon": {
|
|
1665
|
+
const raw = subjectClaimJsonSql(ctx, claim);
|
|
1666
|
+
const value = ctx.dialect === "supabase" ? `coalesce(nullif(${raw}, 'null'::jsonb), (select auth.jwt()) -> 'app_metadata' -> ${quoteLiteral(claim)})` : raw;
|
|
1667
|
+
return `jsonb_array_elements_text(
|
|
1668
|
+
case jsonb_typeof(${value})
|
|
1669
|
+
when 'array' then ${value}
|
|
1670
|
+
when 'string' then jsonb_build_array(${value})
|
|
1671
|
+
else '[]'::jsonb
|
|
1672
|
+
end
|
|
1673
|
+
) r(role)`;
|
|
1674
|
+
}
|
|
1675
|
+
default: return ctx.dialect;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
function membershipRows(ctx) {
|
|
1679
|
+
const raw = subjectClaimJsonSql(ctx, "memberships");
|
|
1680
|
+
const value = ctx.dialect === "supabase" ? `coalesce(${raw}, (select auth.jwt()) -> 'app_metadata' -> 'memberships')` : raw;
|
|
1681
|
+
return `jsonb_array_elements(
|
|
1682
|
+
case jsonb_typeof(${value}) when 'array' then ${value} else '[]'::jsonb end
|
|
1683
|
+
) m
|
|
1684
|
+
cross join lateral jsonb_array_elements_text(
|
|
1685
|
+
case jsonb_typeof(m -> 'roles') when 'array' then m -> 'roles' else '[]'::jsonb end
|
|
1686
|
+
) r(role)`;
|
|
1687
|
+
}
|
|
1688
|
+
function andLine(indent, condition) {
|
|
1689
|
+
return condition === void 0 ? "" : `\n${indent}and ${condition}`;
|
|
1690
|
+
}
|
|
1691
|
+
/** `rls.roles`, or the generated `user_roles (user_id, role)`, aliased `ur`. */
|
|
1692
|
+
function globalRoleRows(ctx) {
|
|
1693
|
+
return ctx.roles === void 0 ? {
|
|
1694
|
+
from: `${qualified$1(ctx, "user_roles")} ur`,
|
|
1695
|
+
userSql: "ur.user_id",
|
|
1696
|
+
roleSql: "ur.role::text"
|
|
1697
|
+
} : globalRoleSource(ctx.roles, "public", "ur");
|
|
1698
|
+
}
|
|
1699
|
+
function customKeysJoin(ctx, scope, allows, denies, includes) {
|
|
1700
|
+
return ` cross join lateral ${qualified$1(ctx, CUSTOM_ROLES.keys)}(
|
|
1701
|
+
${allows},
|
|
1702
|
+
${denies},
|
|
1703
|
+
${includes},
|
|
1704
|
+
${quoteLiteral(scope)}
|
|
1705
|
+
) ck(grant_key)`;
|
|
1706
|
+
}
|
|
1707
|
+
function hasSetBody(ctx) {
|
|
1708
|
+
const rp = qualified$1(ctx, "role_permissions");
|
|
1709
|
+
const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
|
|
1710
|
+
const custom = ctx.customRoles;
|
|
1711
|
+
if (ctx.authorize === "database") {
|
|
1712
|
+
const ur = globalRoleRows(ctx);
|
|
1713
|
+
const declared = ` select rp.grant_key
|
|
1714
|
+
from ${ur.from}
|
|
1715
|
+
join ${rp} rp on rp.role = ${ur.roleSql}
|
|
1716
|
+
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1717
|
+
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}`;
|
|
1718
|
+
if (custom === void 0) return declared;
|
|
1719
|
+
const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
|
|
1720
|
+
const rows = (source, value, extra) => `array(select c.${value} from ${qualified$1(ctx, source)} c where ${match}${extra})`;
|
|
1721
|
+
return `${declared}
|
|
1722
|
+
union
|
|
1723
|
+
select ck.grant_key
|
|
1724
|
+
from ${ur.from}
|
|
1725
|
+
${customKeysJoin(ctx, "global", rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"), rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), rows(CUSTOM_ROLES.includes, "include_role", ""))}
|
|
1726
|
+
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1727
|
+
and not (${ur.roleSql} = any(${textArray(custom.declared)}))${active}`;
|
|
1728
|
+
}
|
|
1729
|
+
const declared = ` select rp.grant_key
|
|
1730
|
+
from ${roleRows(ctx)}
|
|
1731
|
+
join ${rp} rp on rp.role = r.role
|
|
1732
|
+
where ${signedIn(ctx)}
|
|
1733
|
+
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, "r.role"))}${active}`;
|
|
1734
|
+
if (custom === void 0) return declared;
|
|
1735
|
+
const claim = subjectClaimJsonSql(ctx, GLOBAL_GRANTS_CLAIM);
|
|
1736
|
+
return `${declared}
|
|
1737
|
+
union
|
|
1738
|
+
select ck.grant_key
|
|
1739
|
+
from ${roleRows(ctx)}
|
|
1740
|
+
cross join lateral (select ${claim} -> r.role as g) cg
|
|
1741
|
+
${customKeysJoin(ctx, "global", claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
|
|
1742
|
+
where ${signedIn(ctx)}
|
|
1743
|
+
and jsonb_typeof(cg.g) = 'array'
|
|
1744
|
+
and not (r.role = any(${textArray(custom.declared)}))${active}`;
|
|
1745
|
+
}
|
|
1746
|
+
function hasBody(ctx) {
|
|
1747
|
+
if (ctx.grantSet === true) return hasSetBody(ctx);
|
|
1748
|
+
const rp = qualified$1(ctx, "role_permissions");
|
|
1749
|
+
const active = userActive(ctx, " ").map((line) => `\n${line}`).join("");
|
|
1750
|
+
const custom = ctx.customRoles;
|
|
1751
|
+
if (ctx.authorize === "database") {
|
|
1752
|
+
const ur = globalRoleRows(ctx);
|
|
1753
|
+
const declared = ` select exists (
|
|
1754
|
+
select 1
|
|
1755
|
+
from ${ur.from}
|
|
1756
|
+
join ${rp} rp on rp.role = ${ur.roleSql}
|
|
1757
|
+
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1758
|
+
and rp.grant_key = p_grant
|
|
1759
|
+
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, ur.roleSql))}${active}
|
|
1760
|
+
)`;
|
|
1761
|
+
if (custom === void 0) return declared;
|
|
1762
|
+
const match = `c.tenant_id is null and c.scope = 'global' and c.scope_id is null and c.role = ${ur.roleSql}`;
|
|
1763
|
+
const rows = (source, value, extra) => `array(select c.${value} from ${qualified$1(ctx, source)} c where ${match}${extra})`;
|
|
1764
|
+
return `${declared}
|
|
1765
|
+
or exists (
|
|
1766
|
+
select 1
|
|
1767
|
+
from ${ur.from}
|
|
1768
|
+
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
1769
|
+
and not (${ur.roleSql} = any(${textArray(custom.declared)}))${active}
|
|
1770
|
+
${customKeysSql(ctx, "global", rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"), rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"), rows(CUSTOM_ROLES.includes, "include_role", "")).trimStart()}
|
|
1771
|
+
)`;
|
|
1772
|
+
}
|
|
1773
|
+
const declared = ` select ${signedIn(ctx)} and exists (
|
|
1774
|
+
select 1
|
|
1775
|
+
from ${roleRows(ctx)}
|
|
1776
|
+
join ${rp} rp on rp.role = r.role
|
|
1777
|
+
where rp.grant_key = p_grant
|
|
1778
|
+
and rp.scope = 'global'${andLine(" ", globalKindFilterSql(ctx, "r.role"))}${active}
|
|
1779
|
+
)`;
|
|
1780
|
+
if (custom === void 0) return declared;
|
|
1781
|
+
const claim = subjectClaimJsonSql(ctx, GLOBAL_GRANTS_CLAIM);
|
|
1782
|
+
return `${declared}
|
|
1783
|
+
or (${signedIn(ctx)} and exists (
|
|
1784
|
+
select 1
|
|
1785
|
+
from ${roleRows(ctx)}
|
|
1786
|
+
cross join lateral (select ${claim} -> r.role as g) cg
|
|
1787
|
+
where jsonb_typeof(cg.g) = 'array'
|
|
1788
|
+
and not (r.role = any(${textArray(custom.declared)}))${active}
|
|
1789
|
+
${customKeysSql(ctx, "global", claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)")).trimStart()}
|
|
1790
|
+
))`;
|
|
1791
|
+
}
|
|
1792
|
+
/** The claim `jwt` mode reads platform custom roles from: `customRoleClaim(globalRoles)`. */
|
|
1793
|
+
const GLOBAL_GRANTS_CLAIM = "role_grants";
|
|
1794
|
+
function memberColumn(name) {
|
|
1795
|
+
return `m.${quoteIdent(name)}`;
|
|
1796
|
+
}
|
|
1797
|
+
function disabledColumn(table) {
|
|
1798
|
+
return table.disabledAt === void 0 ? void 0 : memberColumn(table.disabledAt);
|
|
1799
|
+
}
|
|
1800
|
+
function claimKeptLines(ctx, indent, permission) {
|
|
1801
|
+
return disabledKeep(ctx).length === 0 ? [] : [`${indent}and ${keptRowSql("m -> 'keep'", permission)}`];
|
|
1802
|
+
}
|
|
1803
|
+
/** Entries of a compact custom-role claim (`cg.g`) that match `where`. */
|
|
1804
|
+
function claimEntries(where, value) {
|
|
1805
|
+
return `array(select ${value} from jsonb_array_elements_text(cg.g) e where ${where})`;
|
|
1806
|
+
}
|
|
1807
|
+
/** `p_grant in (select permdock_custom_keys(allows, denies, includes, scope))`. */
|
|
1808
|
+
function customKeysSql(ctx, scope, allows, denies, includes) {
|
|
1809
|
+
return ` and p_grant in (select ${qualified$1(ctx, CUSTOM_ROLES.keys)}(
|
|
1810
|
+
${allows},
|
|
1811
|
+
${denies},
|
|
1812
|
+
${includes},
|
|
1813
|
+
${quoteLiteral(scope)}
|
|
1814
|
+
))`;
|
|
1815
|
+
}
|
|
1816
|
+
function tableBody(ctx, scope, type) {
|
|
1817
|
+
const mapped = scopeTable(ctx, scope);
|
|
1818
|
+
if (mapped === void 0) return ` select null::${type} where false -- no ${scope} memberships table configured`;
|
|
1819
|
+
const { table, column, tenantColumn } = mapped;
|
|
1820
|
+
const set = ctx.grantSet === true;
|
|
1821
|
+
const filtersFor = (grant) => {
|
|
1822
|
+
const filters = [` where ${memberColumn(table.user)} = ${subjectIdSql(ctx)}`];
|
|
1823
|
+
if (table.expiresAt !== void 0) {
|
|
1824
|
+
const expires = memberColumn(table.expiresAt);
|
|
1825
|
+
filters.push(` and (${expires} is null or ${expires} > now())`);
|
|
1826
|
+
}
|
|
1827
|
+
filters.push(...activeMembershipSql(ctx, disabledColumn(table), grantPermissionSql(grant)).map((part) => ` and ${part}`));
|
|
1828
|
+
if (tenantColumn !== void 0 && ctx.tenants !== "all") filters.push(` and (${activeTenant(ctx)} is null or ${memberColumn(tenantColumn)}::text = ${activeTenant(ctx)})`);
|
|
1829
|
+
const keyed = keyTenantSql(ctx, tenantColumn === void 0 ? void 0 : memberColumn(tenantColumn));
|
|
1830
|
+
if (keyed !== void 0) filters.push(` and ${keyed}`);
|
|
1831
|
+
filters.push(...userActive(ctx, " "), ...instancesActive(ctx, scope, (name) => {
|
|
1832
|
+
const held = scopeColumn(table, ctx.scopes, name);
|
|
1833
|
+
return held === void 0 ? void 0 : memberColumn(held);
|
|
1834
|
+
}, " ", grantPermissionSql(grant)));
|
|
1835
|
+
return filters;
|
|
1836
|
+
};
|
|
1837
|
+
const filters = filtersFor(set ? "rp.grant_key" : "p_grant");
|
|
1838
|
+
const [owner, ...rest] = filters;
|
|
1839
|
+
const role = memberRoleOf(table, "m", " ");
|
|
1840
|
+
const kind = kindFilterSql(ctx, role.sql, memberVia(table));
|
|
1841
|
+
const lines = [
|
|
1842
|
+
` select${role.lateral ? " distinct" : ""} ${memberColumn(column)}::${type}${set ? ", rp.grant_key" : ""}`,
|
|
1843
|
+
` from ${membershipTable(table.table)} m${role.join}`,
|
|
1844
|
+
` join ${qualified$1(ctx, "role_permissions")} rp on rp.role = ${role.sql}`,
|
|
1845
|
+
owner ?? "",
|
|
1846
|
+
...set ? [] : [" and rp.grant_key = p_grant"],
|
|
1847
|
+
` and rp.scope = ${quoteLiteral(scope)}`,
|
|
1848
|
+
...kind === void 0 ? [] : [` and ${kind}`],
|
|
1849
|
+
...rest
|
|
1850
|
+
];
|
|
1851
|
+
const custom = ctx.customRoles;
|
|
1852
|
+
if (custom === void 0) return lines.join("\n");
|
|
1853
|
+
const match = [
|
|
1854
|
+
`c.tenant_id::text = ${memberColumn(tenantColumn ?? column)}::text`,
|
|
1855
|
+
`c.scope = ${quoteLiteral(scope)}`,
|
|
1856
|
+
`(c.scope_id is null or c.scope_id = ${memberColumn(column)}::text)`,
|
|
1857
|
+
`c.role = ${role.sql}`
|
|
1858
|
+
].join(" and ");
|
|
1859
|
+
const rows = (source, value, extra) => `array(select c.${value} from ${qualified$1(ctx, source)} c where ${match}${extra})`;
|
|
1860
|
+
const entries = [
|
|
1861
|
+
rows(CUSTOM_ROLES.permissions, allowEntry(ctx), " and c.effect = 'allow'"),
|
|
1862
|
+
rows(CUSTOM_ROLES.permissions, "permission", " and c.effect = 'deny'"),
|
|
1863
|
+
rows(CUSTOM_ROLES.includes, "include_role", "")
|
|
1864
|
+
];
|
|
1865
|
+
if (set) return [
|
|
1866
|
+
...lines,
|
|
1867
|
+
" union",
|
|
1868
|
+
` select ${memberColumn(column)}::${type}, ck.grant_key`,
|
|
1869
|
+
` from ${membershipTable(table.table)} m${role.join}`,
|
|
1870
|
+
customKeysJoin(ctx, scope, ...entries),
|
|
1871
|
+
...filtersFor("ck.grant_key"),
|
|
1872
|
+
` and not (${role.sql} = any(${textArray(custom.declared)}))`
|
|
1873
|
+
].join("\n");
|
|
1874
|
+
return [
|
|
1875
|
+
...lines,
|
|
1876
|
+
" union",
|
|
1877
|
+
` select ${memberColumn(column)}::${type}`,
|
|
1878
|
+
` from ${membershipTable(table.table)} m${role.join}`,
|
|
1879
|
+
...filters,
|
|
1880
|
+
` and not (${role.sql} = any(${textArray(custom.declared)}))`,
|
|
1881
|
+
customKeysSql(ctx, scope, ...entries)
|
|
1882
|
+
].join("\n");
|
|
1883
|
+
}
|
|
1884
|
+
/** The membership's instance of the first scope, from the canonical claim. */
|
|
1885
|
+
function claimTenant(ctx, scope) {
|
|
1886
|
+
const root = rootName(ctx);
|
|
1887
|
+
return scope === root ? `m ->> 'id'` : `m -> 'within' ->> ${quoteLiteral(root)}`;
|
|
1888
|
+
}
|
|
1889
|
+
function claimBody(ctx, scope, type) {
|
|
1890
|
+
const set = ctx.grantSet === true;
|
|
1891
|
+
const narrow = narrowsTo(ctx, scope) ? `
|
|
1892
|
+
and (${activeTenant(ctx)} is null or ${claimTenant(ctx, scope)} = ${activeTenant(ctx)})` : "";
|
|
1893
|
+
const filtersFor = (grant) => ` and m ->> 'scope' = ${quoteLiteral(scope)}
|
|
1894
|
+
and m ->> 'id' is not null${narrow}${keyTenantLine(ctx, scope, claimTenant(ctx, scope))}
|
|
1895
|
+
and case jsonb_typeof(m -> 'expiresAt')
|
|
1896
|
+
when 'number' then (m ->> 'expiresAt')::numeric > extract(epoch from now())
|
|
1897
|
+
else true
|
|
1898
|
+
end${[
|
|
1899
|
+
...claimKeptLines(ctx, " ", grantPermissionSql(grant)),
|
|
1900
|
+
...userActive(ctx, " "),
|
|
1901
|
+
...instancesActive(ctx, scope, (name) => name === scope ? `m ->> 'id'` : `m -> 'within' ->> ${quoteLiteral(name)}`, " ", grantPermissionSql(grant))
|
|
1902
|
+
].map((line) => `\n${line}`).join("")}`;
|
|
1903
|
+
const filters = filtersFor(set ? "rp.grant_key" : "p_grant");
|
|
1904
|
+
const declared = ` select (m ->> 'id')::${type}${set ? ", rp.grant_key" : ""}
|
|
1905
|
+
from ${membershipRows(ctx)}
|
|
1906
|
+
join ${qualified$1(ctx, "role_permissions")} rp on rp.role = r.role
|
|
1907
|
+
where ${signedIn(ctx)}${set ? "" : "\n and rp.grant_key = p_grant"}
|
|
1908
|
+
and rp.scope = ${quoteLiteral(scope)}${andLine(" ", kindFilterSql(ctx, "r.role", "m ->> 'via'"))}
|
|
1909
|
+
${filters}`;
|
|
1910
|
+
const custom = ctx.customRoles;
|
|
1911
|
+
if (custom === void 0) return declared;
|
|
1912
|
+
if (set) return `${declared}
|
|
1913
|
+
union
|
|
1914
|
+
select (m ->> 'id')::${type}, ck.grant_key
|
|
1915
|
+
from ${membershipRows(ctx)}
|
|
1916
|
+
cross join lateral (select m -> 'grants' -> r.role as g) cg
|
|
1917
|
+
${customKeysJoin(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}
|
|
1918
|
+
where ${signedIn(ctx)}
|
|
1919
|
+
and jsonb_typeof(cg.g) = 'array'
|
|
1920
|
+
and not (r.role = any(${textArray(custom.declared)}))
|
|
1921
|
+
${filtersFor("ck.grant_key")}`;
|
|
1922
|
+
return `${declared}
|
|
1923
|
+
union
|
|
1924
|
+
select (m ->> 'id')::${type}
|
|
1925
|
+
from ${membershipRows(ctx)}
|
|
1926
|
+
cross join lateral (select m -> 'grants' -> r.role as g) cg
|
|
1927
|
+
where ${signedIn(ctx)}
|
|
1928
|
+
and jsonb_typeof(cg.g) = 'array'
|
|
1929
|
+
and not (r.role = any(${textArray(custom.declared)}))
|
|
1930
|
+
${filters}
|
|
1931
|
+
${customKeysSql(ctx, scope, claimEntries("left(e, 1) not in ('-', '@')", "e"), claimEntries("left(e, 1) = '-'", "substr(e, 2)"), claimEntries("left(e, 1) = '@'", "substr(e, 2)"))}`;
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* The rows the membership sources select for the subject, one per instance
|
|
1935
|
+
* (`scope`, `id`, `within`, `roles`, `via`), with each source's own expiry
|
|
1936
|
+
* and suspension filters: the statements the token hook runs.
|
|
1937
|
+
*/
|
|
1938
|
+
function sourcesKeep(sources) {
|
|
1939
|
+
return sources.some((source) => source.sql.keeps);
|
|
1940
|
+
}
|
|
1941
|
+
function sourceRows(sources) {
|
|
1942
|
+
const keep = sourcesKeep(sources);
|
|
1943
|
+
return sources.map((source, index) => source.sql.select(sourceUser(index), keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
1944
|
+
}
|
|
1945
|
+
function sourceUser(index) {
|
|
1946
|
+
return `v_user_${String(index)}`;
|
|
1947
|
+
}
|
|
1948
|
+
function sqlBody(sql) {
|
|
1949
|
+
return {
|
|
1950
|
+
sql,
|
|
1951
|
+
vars: []
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
function sourcesBodyOf(sql, sources, user) {
|
|
1955
|
+
return {
|
|
1956
|
+
sql,
|
|
1957
|
+
vars: sources.map((source, index) => `${sourceUser(index)} ${source.sql.userType} := ${user};`)
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
/** `language sql`, or `plpgsql` returning the query when the body declares variables. */
|
|
1961
|
+
function functionBody(body) {
|
|
1962
|
+
if (body.vars.length === 0) return `language sql
|
|
1963
|
+
stable
|
|
1964
|
+
security definer
|
|
1965
|
+
set search_path = ''
|
|
1966
|
+
as $$
|
|
1967
|
+
${body.sql}
|
|
1968
|
+
$$;`;
|
|
1969
|
+
return `language plpgsql
|
|
1970
|
+
stable
|
|
1971
|
+
security definer
|
|
1972
|
+
set search_path = ''
|
|
1973
|
+
as $$
|
|
1974
|
+
declare
|
|
1975
|
+
${body.vars.map((line) => ` ${line}`).join("\n")}
|
|
1976
|
+
begin
|
|
1977
|
+
return query
|
|
1978
|
+
${body.sql};
|
|
1979
|
+
end;
|
|
1980
|
+
$$;`;
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* `ms.id` for the scope itself and `ms.within ->> name` for an ancestor. The
|
|
1984
|
+
* alias is not `s`, which `activeRowSql` binds inside its `exists`.
|
|
1985
|
+
*/
|
|
1986
|
+
function sourceIdOf(scope) {
|
|
1987
|
+
return (name) => name === scope ? "ms.id" : `ms.within ->> ${quoteLiteral(name)}`;
|
|
1988
|
+
}
|
|
1989
|
+
function sourceFilters(ctx, scope, user = subjectIdSql(ctx), permission, keeps = false) {
|
|
1990
|
+
return [
|
|
1991
|
+
...keeps ? [` and ${keptRowSql("ms.keep", permission)}`] : [],
|
|
1992
|
+
...userActive(ctx, " ", user),
|
|
1993
|
+
...instancesActive(ctx, scope, sourceIdOf(scope), " ", permission)
|
|
1994
|
+
].map((line) => `\n${line}`).join("");
|
|
1995
|
+
}
|
|
1996
|
+
function sourcesBody(ctx, scope, type) {
|
|
1997
|
+
const root = rootName(ctx);
|
|
1998
|
+
const tenant = sourceIdOf(scope)(root);
|
|
1999
|
+
const narrow = narrowsTo(ctx, scope) ? `\n and (${activeTenant(ctx)} is null or ${tenant} = ${activeTenant(ctx)})${keyTenantLine(ctx, scope, tenant)}` : keyTenantLine(ctx, scope, tenant);
|
|
2000
|
+
const sources = scopeSources(ctx, scope);
|
|
2001
|
+
const set = ctx.grantSet === true;
|
|
2002
|
+
const rows = ` from (
|
|
2003
|
+
${sourceRows(sources)}
|
|
2004
|
+
) ms
|
|
2005
|
+
cross join lateral jsonb_array_elements_text(
|
|
2006
|
+
case jsonb_typeof(ms.roles) when 'array' then ms.roles else '[]'::jsonb end
|
|
2007
|
+
) r(role)`;
|
|
2008
|
+
const declared = ` select (ms.id)::${type}${set ? ", rp.grant_key" : ""}
|
|
2009
|
+
${rows}
|
|
2010
|
+
join ${qualified$1(ctx, "role_permissions")} rp on rp.role = r.role
|
|
2011
|
+
where ${signedIn(ctx)}
|
|
2012
|
+
and ms.scope = ${quoteLiteral(scope)}${set ? "" : "\n and rp.grant_key = p_grant"}
|
|
2013
|
+
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))}`;
|
|
2014
|
+
const custom = ctx.customRoles;
|
|
2015
|
+
if (custom === void 0) return sourcesBodyOf(declared, sources, subjectIdSql(ctx));
|
|
2016
|
+
const match = [
|
|
2017
|
+
`c.tenant_id::text = (${tenant})::text`,
|
|
2018
|
+
`c.scope = ${quoteLiteral(scope)}`,
|
|
2019
|
+
"(c.scope_id is null or c.scope_id = (ms.id)::text)",
|
|
2020
|
+
"c.role = r.role"
|
|
2021
|
+
].join(" and ");
|
|
2022
|
+
const entries = (source, value, extra) => `array(select c.${value} from ${qualified$1(ctx, source)} c where ${match}${extra})`;
|
|
2023
|
+
if (set) return sourcesBodyOf(`${declared}
|
|
2024
|
+
union
|
|
2025
|
+
select (ms.id)::${type}, ck.grant_key
|
|
2026
|
+
${rows}
|
|
2027
|
+
${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", ""))}
|
|
2028
|
+
where ${signedIn(ctx)}
|
|
2029
|
+
and ms.scope = ${quoteLiteral(scope)}
|
|
2030
|
+
and not (r.role = any(${textArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("ck.grant_key"), sourcesKeep(sources))}`, sources, subjectIdSql(ctx));
|
|
2031
|
+
return sourcesBodyOf(`${declared}
|
|
2032
|
+
union
|
|
2033
|
+
select (ms.id)::${type}
|
|
2034
|
+
${rows}
|
|
2035
|
+
where ${signedIn(ctx)}
|
|
2036
|
+
and ms.scope = ${quoteLiteral(scope)}
|
|
2037
|
+
and not (r.role = any(${textArray(custom.declared)}))${narrow}${sourceFilters(ctx, scope, subjectIdSql(ctx), grantPermissionSql("p_grant"), sourcesKeep(sources))}
|
|
2038
|
+
${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));
|
|
2039
|
+
}
|
|
2040
|
+
function scopedBody(ctx, scope, type) {
|
|
2041
|
+
if (ctx.authorize !== "database") return sqlBody(claimBody(ctx, scope, type));
|
|
2042
|
+
return scopeSources(ctx, scope).length > 0 ? sourcesBody(ctx, scope, type) : sqlBody(tableBody(ctx, scope, type));
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* The body of `member_<scope>_ids()`: instances of `scope` the subject holds
|
|
2046
|
+
* a live membership of, with any role. Not narrowed to the active tenant, and
|
|
2047
|
+
* no cascade: a membership of a child scope is not one of its parent.
|
|
2048
|
+
*/
|
|
2049
|
+
function memberBody(ctx, scope, type) {
|
|
2050
|
+
if (ctx.authorize !== "database") return sqlBody(` select distinct (m ->> 'id')::${type}
|
|
2051
|
+
from jsonb_array_elements(
|
|
2052
|
+
case jsonb_typeof(${membershipClaim(ctx)}) when 'array' then ${membershipClaim(ctx)} else '[]'::jsonb end
|
|
2053
|
+
) m
|
|
2054
|
+
where ${signedIn(ctx)}
|
|
2055
|
+
and m ->> 'scope' = ${quoteLiteral(scope)}
|
|
2056
|
+
and m ->> 'id' is not null${keyTenantLine(ctx, scope, claimTenant(ctx, scope))}
|
|
2057
|
+
and jsonb_typeof(m -> 'roles') = 'array'
|
|
2058
|
+
and jsonb_array_length(m -> 'roles') > 0
|
|
2059
|
+
and case jsonb_typeof(m -> 'expiresAt')
|
|
2060
|
+
when 'number' then (m ->> 'expiresAt')::numeric > extract(epoch from now())
|
|
2061
|
+
else true
|
|
2062
|
+
end${[
|
|
2063
|
+
...claimKeptLines(ctx, " "),
|
|
2064
|
+
...userActive(ctx, " "),
|
|
2065
|
+
...instancesActive(ctx, scope, (name) => name === scope ? `m ->> 'id'` : `m -> 'within' ->> ${quoteLiteral(name)}`, " ")
|
|
2066
|
+
].map((line) => `\n${line}`).join("")}`);
|
|
2067
|
+
return memberRowsBody(ctx, scope, type, subjectIdSql(ctx), scopeSources(ctx, scope), true);
|
|
2068
|
+
}
|
|
2069
|
+
/**
|
|
2070
|
+
* The membership rows of `user` for `scope`, from the sources or else the
|
|
2071
|
+
* mapped table, with the same expiry and suspension filters either way.
|
|
2072
|
+
*/
|
|
2073
|
+
function memberRowsBody(ctx, scope, type, user, sources, keyed = false) {
|
|
2074
|
+
if (sources.length > 0) {
|
|
2075
|
+
const narrow = keyed ? keyTenantLine(ctx, scope, sourceIdOf(scope)(rootName(ctx))) : "";
|
|
2076
|
+
return sourcesBodyOf(` select distinct (ms.id)::${type}
|
|
2077
|
+
from (
|
|
2078
|
+
${sourceRows(sources)}
|
|
2079
|
+
) ms
|
|
2080
|
+
where ${signedIn(ctx, user)}
|
|
2081
|
+
and ms.scope = ${quoteLiteral(scope)}
|
|
2082
|
+
and jsonb_typeof(ms.roles) = 'array'
|
|
2083
|
+
and jsonb_array_length(ms.roles) > 0${narrow}${sourceFilters(ctx, scope, user, void 0, sourcesKeep(sources))}`, sources, user);
|
|
2084
|
+
}
|
|
2085
|
+
const mapped = scopeTable(ctx, scope);
|
|
2086
|
+
if (mapped === void 0) return sqlBody(` select null::${type} where false -- no ${scope} memberships table configured`);
|
|
2087
|
+
const { table, column, tenantColumn } = mapped;
|
|
2088
|
+
const role = memberRoleOf(table, "m", " ");
|
|
2089
|
+
const lines = [
|
|
2090
|
+
` select distinct ${memberColumn(column)}::${type}`,
|
|
2091
|
+
` from ${membershipTable(table.table)} m${role.join}`,
|
|
2092
|
+
` where ${memberColumn(table.user)} = ${user}`,
|
|
2093
|
+
` and ${role.through === void 0 && !role.lateral ? memberColumn(role.column) : role.sql} is not null`
|
|
2094
|
+
];
|
|
2095
|
+
const narrow = keyed ? keyTenantSql(ctx, tenantColumn === void 0 ? void 0 : memberColumn(tenantColumn)) : void 0;
|
|
2096
|
+
if (narrow !== void 0) lines.push(` and ${narrow}`);
|
|
2097
|
+
if (table.expiresAt !== void 0) {
|
|
2098
|
+
const expires = memberColumn(table.expiresAt);
|
|
2099
|
+
lines.push(` and (${expires} is null or ${expires} > now())`);
|
|
2100
|
+
}
|
|
2101
|
+
lines.push(...activeMembershipSql(ctx, disabledColumn(table), void 0).map((part) => ` and ${part}`), ...userActive(ctx, " ", user), ...instancesActive(ctx, scope, (name) => {
|
|
2102
|
+
const held = scopeColumn(table, ctx.scopes, name);
|
|
2103
|
+
return held === void 0 ? void 0 : memberColumn(held);
|
|
2104
|
+
}, " "));
|
|
2105
|
+
return sqlBody(lines.join("\n"));
|
|
2106
|
+
}
|
|
2107
|
+
function membershipClaim(ctx) {
|
|
2108
|
+
const raw = subjectClaimJsonSql(ctx, "memberships");
|
|
2109
|
+
return ctx.dialect === "supabase" ? `coalesce(${raw}, (select auth.jwt()) -> 'app_metadata' -> 'memberships')` : raw;
|
|
2110
|
+
}
|
|
2111
|
+
function memberFunction(ctx, scope, type, anonExecute) {
|
|
2112
|
+
const fn = qualified$1(ctx, memberIdsHelper(scope));
|
|
2113
|
+
const body = memberBody(ctx, scope, type);
|
|
2114
|
+
const keys = ctx.apiKeys;
|
|
2115
|
+
const keyed = keys === void 0 || scope !== rootName(ctx) ? body : {
|
|
2116
|
+
sql: `${body.sql}\n union\n${serviceKeyMemberSql(ctx, keys, scope, type)}`,
|
|
2117
|
+
vars: body.vars
|
|
2118
|
+
};
|
|
2119
|
+
const grants = anonExecute ? `revoke execute on function ${fn}() from public;
|
|
2120
|
+
grant execute on function ${fn}() to anon, authenticated;` : `revoke execute on function ${fn}() from public, anon;
|
|
2121
|
+
grant execute on function ${fn}() to authenticated;`;
|
|
2122
|
+
return `create or replace function ${fn}()
|
|
2123
|
+
returns setof ${type}
|
|
2124
|
+
${functionBody(keyed)}
|
|
2125
|
+
${grants}`;
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* `member_<scope>_ids_for(p_user uuid)`: `member_<scope>_ids()` for a user the
|
|
2129
|
+
* caller names, read from the membership sources (or the mapped table) in
|
|
2130
|
+
* either mode, so a token hook can call it before any claim exists. Naming
|
|
2131
|
+
* the user is why no client role may execute it; the hook's grants give
|
|
2132
|
+
* `supabase_auth_admin` execute.
|
|
2133
|
+
*/
|
|
2134
|
+
function memberForFunction(ctx, scope, type) {
|
|
2135
|
+
const fn = qualified$1(ctx, memberForHelper(scope));
|
|
2136
|
+
return `create or replace function ${fn}(p_user uuid)
|
|
2137
|
+
returns setof ${type}
|
|
2138
|
+
${functionBody(memberRowsBody(ctx, scope, type, "p_user", memberForSources(ctx, scope)))}
|
|
2139
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
2140
|
+
}
|
|
2141
|
+
function helperFunction(ctx, name, returns, body, anonExecute) {
|
|
2142
|
+
const fn = qualified$1(ctx, name);
|
|
2143
|
+
const grants = anonExecute ? `revoke execute on function ${fn}(text) from public;
|
|
2144
|
+
grant execute on function ${fn}(text) to anon, authenticated;` : `revoke execute on function ${fn}(text) from public, anon;
|
|
2145
|
+
grant execute on function ${fn}(text) to authenticated;`;
|
|
2146
|
+
return `create or replace function ${fn}(p_grant text)
|
|
2147
|
+
returns ${returns}
|
|
2148
|
+
${functionBody(body)}
|
|
2149
|
+
${grants}`;
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* `permdock_has_for(p_user, p_grant)` and one
|
|
2153
|
+
* `permitted_<scope>_ids_for(p_user, p_grant)` per scope in `database` mode:
|
|
2154
|
+
* the bodies of `permdock_has` and `permitted_<scope>_ids` with `p_user` for
|
|
2155
|
+
* the signed-in user and no tenant-claim narrowing, since a named user has
|
|
2156
|
+
* no token. For trusted SQL that acts for a stored user (jobs, triggers,
|
|
2157
|
+
* approvals resolved later). Naming the user is why no client role may
|
|
2158
|
+
* execute them: `security definer` callers run them as their owner, and a
|
|
2159
|
+
* backend role needs its own grant.
|
|
2160
|
+
*/
|
|
2161
|
+
function forUserSql(ctx) {
|
|
2162
|
+
if (ctx.authorize !== "database") return "";
|
|
2163
|
+
const forUser = forUserContext(ctx);
|
|
2164
|
+
const type = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
2165
|
+
const fn = (name, returns, body) => {
|
|
2166
|
+
const qualifiedName = qualified$1(ctx, name);
|
|
2167
|
+
return `create or replace function ${qualifiedName}(p_user ${type}, p_grant text)
|
|
2168
|
+
returns ${returns}
|
|
2169
|
+
${functionBody(body)}
|
|
2170
|
+
revoke execute on function ${qualifiedName}(${type}, text) from public, anon, authenticated;`;
|
|
2171
|
+
};
|
|
2172
|
+
return [`-- the helpers for a user the caller names: trusted SQL acting for a stored user; no client role may execute them
|
|
2173
|
+
${fn(HELPERS.hasFor, "boolean", sqlBody(hasBody(forUser)))}`, ...ctx.scopes.map((scope) => {
|
|
2174
|
+
const scopeType = scopeTypeOf(ctx, scope.name);
|
|
2175
|
+
return fn(permittedForHelper(scope.name), `setof ${scopeType}`, scopedBody(forUser, scope.name, scopeType));
|
|
2176
|
+
})].join("\n\n");
|
|
2177
|
+
}
|
|
2178
|
+
function userIdType(ctx) {
|
|
2179
|
+
return ctx.dialect === "supabase" ? "uuid not null references auth.users on delete cascade" : "text not null";
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* The `role_permissions` rows the policy compiles to: an upsert and a delete
|
|
2183
|
+
* of every other row. Data, not schema, so `supabase db diff` drops it from a
|
|
2184
|
+
* declarative schema; `--split ...,seeds` writes it as its own migration.
|
|
2185
|
+
*/
|
|
2186
|
+
function seedSql(ctx, rows) {
|
|
2187
|
+
const table = qualified$1(ctx, "role_permissions");
|
|
2188
|
+
if (rows.length === 0) return `delete from ${table};`;
|
|
2189
|
+
return `insert into ${table} (role, permission, grant_key, scope, effect) values
|
|
2190
|
+
${rows.map((row) => ` (${[
|
|
2191
|
+
row.role,
|
|
2192
|
+
row.permission,
|
|
2193
|
+
row.grantKey,
|
|
2194
|
+
row.scope,
|
|
2195
|
+
row.effect
|
|
2196
|
+
].map(quoteLiteral).join(", ")})`).join(",\n")}
|
|
2197
|
+
on conflict (role, grant_key, scope) do update
|
|
2198
|
+
set permission = excluded.permission, effect = excluded.effect;
|
|
2199
|
+
delete from ${table}
|
|
2200
|
+
where (role, grant_key, scope) not in (values
|
|
2201
|
+
${rows.map((row) => ` (${[
|
|
2202
|
+
row.role,
|
|
2203
|
+
row.grantKey,
|
|
2204
|
+
row.scope
|
|
2205
|
+
].map(quoteLiteral).join(", ")})`).join(",\n")}
|
|
2206
|
+
);`;
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* `role_permissions`, its seeds, and the three `security definer` helpers
|
|
2210
|
+
* with `search_path = ''`. `execute` goes to `authenticated` only.
|
|
2211
|
+
*/
|
|
2212
|
+
function helpersSql(ctx, rows, options) {
|
|
2213
|
+
const schema = helperSchema(ctx);
|
|
2214
|
+
const s = quoteIdent(schema);
|
|
2215
|
+
const rp = qualified$1(ctx, "role_permissions");
|
|
2216
|
+
const chunks = [`-- permdock helpers (${ctx.authorize === "database" ? "database: reads the membership and user_roles tables" : "jwt: reads the role and memberships claims"})
|
|
2217
|
+
-- policies call them uncorrelated, so Postgres evaluates each once per statement`];
|
|
2218
|
+
if (schema !== "public") chunks.push(`create schema if not exists ${s};\nrevoke all on schema ${s} from public;\ngrant usage on schema ${s} to ${options.anonExecute === true ? "anon, authenticated" : "authenticated"};`);
|
|
2219
|
+
if (ctx.dialect === "supabase") chunks.push(userIdSql(ctx, options.anonExecute === true));
|
|
2220
|
+
chunks.push(`create table if not exists ${rp} (
|
|
2221
|
+
role text not null,
|
|
2222
|
+
permission text not null,
|
|
2223
|
+
grant_key text not null,
|
|
2224
|
+
scope text not null check (scope ~ '^[a-z][a-z0-9_]*$'),
|
|
2225
|
+
effect text not null default 'allow' check (effect in ('allow', 'deny')),
|
|
2226
|
+
primary key (role, grant_key, scope)
|
|
2227
|
+
);
|
|
2228
|
+
alter table ${rp} enable row level security;
|
|
2229
|
+
revoke all on table ${rp} from anon, authenticated, public;`);
|
|
2230
|
+
if (options.withoutSeeds !== true) chunks.push(seedSql(ctx, rows));
|
|
2231
|
+
if (ctx.authorize === "database" && options.userRoles && ctx.roles === void 0) {
|
|
2232
|
+
const ur = qualified$1(ctx, "user_roles");
|
|
2233
|
+
chunks.push(`create table if not exists ${ur} (
|
|
2234
|
+
user_id ${userIdType(ctx)},
|
|
2235
|
+
role text not null,
|
|
2236
|
+
primary key (user_id, role)
|
|
2237
|
+
);
|
|
2238
|
+
alter table ${ur} enable row level security;
|
|
2239
|
+
revoke all on table ${ur} from anon, authenticated, public;`);
|
|
2240
|
+
}
|
|
2241
|
+
const custom = customRolesSql(ctx);
|
|
2242
|
+
if (custom !== "") chunks.push(custom);
|
|
2243
|
+
const anon = options.anonExecute === true;
|
|
2244
|
+
const keys = ctx.apiKeys;
|
|
2245
|
+
if (keys !== void 0) chunks.push(apiKeyAllowsSql(ctx, keys, anon));
|
|
2246
|
+
const bodies = grantBodies(ctx);
|
|
2247
|
+
chunks.push(helperFunction(ctx, HELPERS.has, "boolean", bodies.has, anon));
|
|
2248
|
+
const capabilities = capabilitiesSql(ctx);
|
|
2249
|
+
if (capabilities !== "") chunks.push(capabilities);
|
|
2250
|
+
for (const scope of ctx.scopes) {
|
|
2251
|
+
const type = scopeTypeOf(ctx, scope.name);
|
|
2252
|
+
chunks.push(helperFunction(ctx, permittedIdsHelper(scope.name), `setof ${type}`, bodies.ids(scope.name), anon));
|
|
2253
|
+
chunks.push(memberFunction(ctx, scope.name, type, anon));
|
|
2254
|
+
if (hasMemberFor(ctx, scope.name)) chunks.push(memberForFunction(ctx, scope.name, type));
|
|
2255
|
+
}
|
|
2256
|
+
const forUser = forUserSql(ctx);
|
|
2257
|
+
if (forUser !== "") chunks.push(forUser);
|
|
2258
|
+
const writes = customRoleWritesSql(ctx, options.levelReach ?? []);
|
|
2259
|
+
if (writes !== "") {
|
|
2260
|
+
chunks.push(writes);
|
|
2261
|
+
const cascade = customRoleCascadeSql(ctx);
|
|
2262
|
+
if (cascade !== "") chunks.push(cascade);
|
|
2263
|
+
}
|
|
2264
|
+
const globalRoles = globalRolesSql(ctx);
|
|
2265
|
+
if (globalRoles !== "") chunks.push(globalRoles);
|
|
2266
|
+
const readers = roleReadersSql(ctx, options.permissions, options.trustedReaders);
|
|
2267
|
+
if (readers !== "") chunks.push(readers);
|
|
2268
|
+
return `${chunks.join("\n\n")}\n`;
|
|
2269
|
+
}
|
|
2270
|
+
function grantBodies(ctx) {
|
|
2271
|
+
const keys = ctx.apiKeys;
|
|
2272
|
+
const set = ctx.grantSet === true;
|
|
2273
|
+
const allows = apiKeyAllowsCall(ctx, set ? "ids.grant_key" : "p_grant");
|
|
2274
|
+
const keyTenant = keyTenantSql(ctx, void 0) ?? "true";
|
|
2275
|
+
return {
|
|
2276
|
+
has: sqlBody(keys === void 0 ? hasBody(ctx) : set ? ` select g.grant_key
|
|
2277
|
+
from (
|
|
2278
|
+
${hasBody(ctx)}
|
|
2279
|
+
) g(grant_key)
|
|
2280
|
+
where ${apiKeyAllowsCall(ctx, "g.grant_key")} and ${keyTenant}` : ceilingHasSql(hasBody(ctx), `${allows} and ${keyTenant}`)),
|
|
2281
|
+
ids: (scope) => {
|
|
2282
|
+
const type = scopeTypeOf(ctx, scope);
|
|
2283
|
+
const body = scopedBody(ctx, scope, type);
|
|
2284
|
+
if (keys === void 0) return body;
|
|
2285
|
+
const sql = scope === rootName(ctx) ? `${body.sql}\n union\n${serviceKeyIdsSql(ctx, keys, scope, type)}` : body.sql;
|
|
2286
|
+
return {
|
|
2287
|
+
sql: set ? ` select ids.id, ids.grant_key
|
|
2288
|
+
from (
|
|
2289
|
+
${sql}
|
|
2290
|
+
) ids(id, grant_key)
|
|
2291
|
+
where ${allows}` : ceilingIdsSql(sql, allows),
|
|
2292
|
+
vars: body.vars
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
function forUserContext(ctx) {
|
|
2298
|
+
const { apiKeys: _apiKeys, ...unkeyed } = ctx;
|
|
2299
|
+
return {
|
|
2300
|
+
...unkeyed,
|
|
2301
|
+
subjectId: "p_user",
|
|
2302
|
+
tenants: "all"
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
//#endregion
|
|
2306
|
+
//#region src/cli/pg.ts
|
|
2307
|
+
const CONNECT_TIMEOUT_MS = 1e4;
|
|
2308
|
+
const STATEMENT_TIMEOUT_MS = 6e4;
|
|
2309
|
+
function loadPg(command) {
|
|
2310
|
+
return requirePeer(() => import("pg"), "pg", command);
|
|
2311
|
+
}
|
|
2312
|
+
function options(db) {
|
|
2313
|
+
return {
|
|
2314
|
+
connectionString: db,
|
|
2315
|
+
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
|
|
2316
|
+
statement_timeout: STATEMENT_TIMEOUT_MS
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
/** Ctrl-C ends the connection, so a query that hangs cannot keep the process alive. */
|
|
2320
|
+
function endOnInterrupt(end) {
|
|
2321
|
+
const interrupt = () => {
|
|
2322
|
+
end().catch(() => void 0);
|
|
2323
|
+
process.exitCode = 130;
|
|
2324
|
+
};
|
|
2325
|
+
process.once("SIGINT", interrupt);
|
|
2326
|
+
return async () => {
|
|
2327
|
+
process.off("SIGINT", interrupt);
|
|
2328
|
+
await end();
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
function unreachable(command, cause) {
|
|
2332
|
+
return new CliError("unavailable", `PermDock CLI: ${command.replace(/^permdock /u, "")} could not connect`, { cause });
|
|
2333
|
+
}
|
|
2334
|
+
/** One connection, for commands that run a transaction. */
|
|
2335
|
+
async function connectPg(db, command) {
|
|
2336
|
+
const client = new (await (loadPg(command))).Client(options(db));
|
|
2337
|
+
try {
|
|
2338
|
+
await client.connect();
|
|
2339
|
+
} catch (cause) {
|
|
2340
|
+
throw unreachable(command, cause);
|
|
2341
|
+
}
|
|
2342
|
+
return {
|
|
2343
|
+
query: (sql, values) => client.query(sql, values),
|
|
2344
|
+
end: endOnInterrupt(() => client.end())
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
/** A small pool, for independent catalog reads that can run in parallel. */
|
|
2348
|
+
async function connectPgPool(db, command) {
|
|
2349
|
+
const pool = new (await (loadPg(command))).Pool({
|
|
2350
|
+
...options(db),
|
|
2351
|
+
max: 4
|
|
2352
|
+
});
|
|
2353
|
+
try {
|
|
2354
|
+
(await pool.connect()).release();
|
|
2355
|
+
} catch (cause) {
|
|
2356
|
+
await pool.end();
|
|
2357
|
+
throw unreachable(command, cause);
|
|
2358
|
+
}
|
|
2359
|
+
return {
|
|
2360
|
+
query: (sql, values) => pool.query(sql, values),
|
|
2361
|
+
end: endOnInterrupt(() => pool.end())
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
//#endregion
|
|
2365
|
+
//#region src/cli/rls-indexes.ts
|
|
2366
|
+
/** Postgres truncates identifiers longer than this, in bytes. */
|
|
2367
|
+
const MAX_IDENT = 63;
|
|
2368
|
+
/**
|
|
2369
|
+
* The row fields `condition` compares with the subject, a claim or a
|
|
2370
|
+
* membership (`ownerId: principal.id`, `memberOf`), through `and` and `or`:
|
|
2371
|
+
* the selective comparisons an index serves. A comparison with a constant
|
|
2372
|
+
* (`status: 'sent'`) narrows rows the scope key already found, a negation
|
|
2373
|
+
* scans anyway, and a dotted field is a JSON path a plain index does not serve.
|
|
2374
|
+
*/
|
|
2375
|
+
function indexedFields(condition) {
|
|
2376
|
+
if (condition === void 0) return [];
|
|
2377
|
+
switch (condition.op) {
|
|
2378
|
+
case "and":
|
|
2379
|
+
case "or": return condition.conditions.flatMap(indexedFields);
|
|
2380
|
+
case "memberOf": return plainField(condition.field);
|
|
2381
|
+
case "eq":
|
|
2382
|
+
case "in": return isConditionRef(condition.value) ? plainField(condition.field) : [];
|
|
2383
|
+
case "not":
|
|
2384
|
+
case "sqlFunction":
|
|
2385
|
+
case "opaque":
|
|
2386
|
+
case "related":
|
|
2387
|
+
case "ne":
|
|
2388
|
+
case "gt":
|
|
2389
|
+
case "gte":
|
|
2390
|
+
case "lt":
|
|
2391
|
+
case "lte":
|
|
2392
|
+
case "contains":
|
|
2393
|
+
case "notIn":
|
|
2394
|
+
case "isNull":
|
|
2395
|
+
case "liveSession": return [];
|
|
2396
|
+
default: return condition;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
function plainField(field) {
|
|
2400
|
+
return field.includes(".") ? [] : [field];
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Every index the generated SQL reads through: the row columns policies
|
|
2404
|
+
* filter on (scope keys and selective condition fields), and the membership
|
|
2405
|
+
* tables' user column, followed by the scope column the helpers select.
|
|
2406
|
+
*/
|
|
2407
|
+
function indexTargets(ctx, rowColumns) {
|
|
2408
|
+
const targets = /* @__PURE__ */ new Map();
|
|
2409
|
+
const add = (table, columns) => {
|
|
2410
|
+
const target = {
|
|
2411
|
+
table: qualifiedTable(table),
|
|
2412
|
+
columns
|
|
2413
|
+
};
|
|
2414
|
+
targets.set(`${target.table}\u0000${columns[0] ?? ""}`, target);
|
|
2415
|
+
};
|
|
2416
|
+
for (const { table, column } of rowColumns) add(table, [column]);
|
|
2417
|
+
for (const scope of ctx.scopes) {
|
|
2418
|
+
const mapped = scopeTable(ctx, scope.name);
|
|
2419
|
+
if (mapped !== void 0) add(mapped.table.table, [mapped.table.user, mapped.column]);
|
|
2420
|
+
}
|
|
2421
|
+
for (const source of [...ctx.sources ?? [], ...ctx.memberSources ?? []]) {
|
|
2422
|
+
add(source.sql.table, [source.sql.user]);
|
|
2423
|
+
const users = source.sql.userThrough;
|
|
2424
|
+
if (users !== void 0) add(users.table, [users.key]);
|
|
2425
|
+
}
|
|
2426
|
+
return [...targets.values()].toSorted((a, b) => `${a.table}.${a.columns.join(",")}` < `${b.table}.${b.columns.join(",")}` ? -1 : 1);
|
|
2427
|
+
}
|
|
2428
|
+
/** A stable index name under Postgres' identifier limit. */
|
|
2429
|
+
function indexName(target) {
|
|
2430
|
+
const name = `permdock_${target.table.slice(target.table.lastIndexOf(".") + 1)}_${target.columns.join("_")}_idx`;
|
|
2431
|
+
if (Buffer.byteLength(name) <= MAX_IDENT) return name;
|
|
2432
|
+
return `permdock_${createHash("sha256").update(name).digest("hex").slice(0, 8)}_idx`;
|
|
2433
|
+
}
|
|
2434
|
+
/** `create index if not exists` for every target, one per line. */
|
|
2435
|
+
function indexesSql(targets) {
|
|
2436
|
+
return targets.map((target) => `create index if not exists ${quoteIdent(indexName(target))} on ${quoteTable(target.table)} (${target.columns.map(quoteIdent).join(", ")});`).join("\n");
|
|
2437
|
+
}
|
|
2438
|
+
/** Every live column of the tables in `$1` (`schema.table`), as `target` and `name`. */
|
|
2439
|
+
const COLUMNS_SQL = `select n.nspname || '.' || c.relname as target, a.attname as name
|
|
2440
|
+
from pg_attribute a
|
|
2441
|
+
join pg_class c on c.oid = a.attrelid
|
|
2442
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
2443
|
+
where a.attnum > 0 and not a.attisdropped
|
|
2444
|
+
and n.nspname || '.' || c.relname = any($1::text[])`;
|
|
2445
|
+
const INDEX_COLUMNS_SQL = `select n.nspname || '.' || c.relname as target,
|
|
2446
|
+
array(
|
|
2447
|
+
select a.attname::text
|
|
2448
|
+
from unnest(i.indkey) with ordinality k(attnum, ord)
|
|
2449
|
+
left join pg_attribute a on a.attrelid = i.indrelid and a.attnum = k.attnum and k.attnum > 0
|
|
2450
|
+
where k.ord <= i.indnkeyatts
|
|
2451
|
+
order by k.ord
|
|
2452
|
+
) as columns
|
|
2453
|
+
from pg_index i
|
|
2454
|
+
join pg_class c on c.oid = i.indrelid
|
|
2455
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
2456
|
+
where i.indpred is null and n.nspname || '.' || c.relname = any($1::text[])`;
|
|
2457
|
+
function strings(value) {
|
|
2458
|
+
return Array.isArray(value) ? value.map((item) => typeof item === "string" ? item : null) : [];
|
|
2459
|
+
}
|
|
2460
|
+
/** Reads the columns and index key columns of `tables` (`schema.table`). */
|
|
2461
|
+
async function readTableIndexFacts(db, tables, connect = (url) => connectPgPool(url, "permdock rls generate --db")) {
|
|
2462
|
+
const client = await connect(db);
|
|
2463
|
+
try {
|
|
2464
|
+
const [columns, indexes] = await Promise.all([client.query(COLUMNS_SQL, [[...tables]]), client.query(INDEX_COLUMNS_SQL, [[...tables]])]);
|
|
2465
|
+
const byTable = {};
|
|
2466
|
+
for (const row of columns.rows) (byTable[String(row["target"])] ??= []).push(String(row["name"]));
|
|
2467
|
+
const keys = {};
|
|
2468
|
+
for (const row of indexes.rows) {
|
|
2469
|
+
const list = strings(row["columns"]);
|
|
2470
|
+
const end = list.indexOf(null);
|
|
2471
|
+
const lead = end === -1 ? list : list.slice(0, end);
|
|
2472
|
+
if (lead.length > 0) (keys[String(row["target"])] ??= []).push(lead);
|
|
2473
|
+
}
|
|
2474
|
+
return {
|
|
2475
|
+
columns: byTable,
|
|
2476
|
+
indexes: keys
|
|
2477
|
+
};
|
|
2478
|
+
} finally {
|
|
2479
|
+
await client.end();
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Drops the targets a database already serves or cannot hold: a target on a
|
|
2484
|
+
* table or column the database lacks (with a warning), and one an existing
|
|
2485
|
+
* index leads with (its key columns start with the target's).
|
|
2486
|
+
*/
|
|
2487
|
+
function pruneIndexTargets(targets, facts) {
|
|
2488
|
+
const warnings = [];
|
|
2489
|
+
return {
|
|
2490
|
+
targets: targets.filter((target) => {
|
|
2491
|
+
const columns = facts.columns[target.table];
|
|
2492
|
+
if (columns === void 0) {
|
|
2493
|
+
warnings.push(`no index suggested on ${target.table}: the database has no such table`);
|
|
2494
|
+
return false;
|
|
2495
|
+
}
|
|
2496
|
+
const absent = target.columns.filter((column) => !columns.includes(column));
|
|
2497
|
+
if (absent.length > 0) {
|
|
2498
|
+
warnings.push(`no index suggested on ${target.table} (${target.columns.join(", ")}): the table has no column ${absent.join(", ")}`);
|
|
2499
|
+
return false;
|
|
2500
|
+
}
|
|
2501
|
+
return !(facts.indexes[target.table] ?? []).some((index) => target.columns.every((column, at) => index[at] === column));
|
|
2502
|
+
}),
|
|
2503
|
+
warnings
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
//#endregion
|
|
2507
|
+
//#region src/cli/rls-permission-keys.ts
|
|
2508
|
+
/** The helpers that take a permission key instead of a grant key. Names are part of the SQL contract. */
|
|
2509
|
+
const PERMISSION_HELPERS = {
|
|
2510
|
+
grantKeys: "grant_keys",
|
|
2511
|
+
has: "permdock_has_permission",
|
|
2512
|
+
hasFor: "permdock_has_permission_for"
|
|
2513
|
+
};
|
|
2514
|
+
/** `permitted_<scope>_permission_keys(p_id)`: every permission key the subject holds on one instance. */
|
|
2515
|
+
function permissionKeysHelper(scope) {
|
|
2516
|
+
permittedIdsHelper(scope);
|
|
2517
|
+
return `permitted_${scope}_permission_keys`;
|
|
2518
|
+
}
|
|
2519
|
+
/** `permitted_<scope>_permission_keys_for(p_user, p_id)`, written in `database` mode. */
|
|
2520
|
+
function permissionKeysForHelper(scope) {
|
|
2521
|
+
return `${permissionKeysHelper(scope)}_for`;
|
|
2522
|
+
}
|
|
2523
|
+
/** `permitted_<scope>_ids_by_permission(p_permission)`. */
|
|
2524
|
+
function permittedByPermissionHelper(scope) {
|
|
2525
|
+
return `${permittedIdsHelper(scope)}_by_permission`;
|
|
2526
|
+
}
|
|
2527
|
+
/**
|
|
2528
|
+
* A grant's `requires` as SQL: the subject holds `key` globally, or on the
|
|
2529
|
+
* row's instance of a scope that partitions it, through the helpers by
|
|
2530
|
+
* permission key, as `requirementCondition` does in process.
|
|
2531
|
+
*/
|
|
2532
|
+
function requiresSql(ctx, key, resource) {
|
|
2533
|
+
const literal = quoteLiteral(key);
|
|
2534
|
+
return `(${[`(select ${qualified$1(ctx, PERMISSION_HELPERS.has)}(${literal}))`, ...(resource === void 0 ? [] : partitionsOf(resource, ctx.scopes)).map((scope) => {
|
|
2535
|
+
const field = scopeField(resource, scope, ctx.scopes) ?? scope;
|
|
2536
|
+
return `${quoteIdent(field)} in (select ${qualified$1(ctx, checkName(permittedByPermissionHelper(scope)))}(${literal}))`;
|
|
2537
|
+
})].join(" or ")})`;
|
|
2538
|
+
}
|
|
2539
|
+
/** Postgres truncates an identifier past 63 bytes, which would merge two helpers. */
|
|
2540
|
+
function checkName(name) {
|
|
2541
|
+
if (new TextEncoder().encode(name).length > 63) throw new Error(`PermDock CLI: the helper name ${name} is longer than 63 bytes; use a shorter scope name`);
|
|
2542
|
+
return name;
|
|
2543
|
+
}
|
|
2544
|
+
function grantsFunction(ctx, grants, renamed, anonExecute) {
|
|
2545
|
+
const fn = qualified$1(ctx, PERMISSION_HELPERS.grantKeys);
|
|
2546
|
+
const map = Object.fromEntries([...grants].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
2547
|
+
const current = Object.keys(renamed).length === 0 ? "p_permission" : `coalesce(${quoteLiteral(JSON.stringify(renamed))}::jsonb ->> p_permission, p_permission)`;
|
|
2548
|
+
return `-- the grant keys a permission key reaches on one scope: its unconditional allows, with p_effect 'deny' every deny, and with 'conditioned-allow' or 'conditioned-deny' the allows or denies that carry a row condition
|
|
2549
|
+
create or replace function ${fn}(p_permission text, p_scope text, p_effect text default 'allow')
|
|
2550
|
+
returns setof text
|
|
2551
|
+
language sql
|
|
2552
|
+
stable
|
|
2553
|
+
set search_path = ''
|
|
2554
|
+
as $$
|
|
2555
|
+
select g.grant_key
|
|
2556
|
+
from pg_catalog.jsonb_array_elements_text(coalesce(
|
|
2557
|
+
${quoteLiteral(JSON.stringify(map))}::jsonb -> p_scope -> ${current} -> p_effect,
|
|
2558
|
+
'[]'::jsonb
|
|
2559
|
+
)) g(grant_key)
|
|
2560
|
+
$$;
|
|
2561
|
+
${grantsSql(fn, "text, text, text", anonExecute)}`;
|
|
2562
|
+
}
|
|
2563
|
+
function grantsSql(fn, args, anonExecute) {
|
|
2564
|
+
return anonExecute ? `revoke execute on function ${fn}(${args}) from public;
|
|
2565
|
+
grant execute on function ${fn}(${args}) to anon, authenticated;` : `revoke execute on function ${fn}(${args}) from public, anon;
|
|
2566
|
+
grant execute on function ${fn}(${args}) to authenticated;`;
|
|
2567
|
+
}
|
|
2568
|
+
function definer(fn, params, returns, sql) {
|
|
2569
|
+
return `create or replace function ${fn}(${params})
|
|
2570
|
+
returns ${returns}
|
|
2571
|
+
language sql
|
|
2572
|
+
stable
|
|
2573
|
+
security definer
|
|
2574
|
+
set search_path = ''
|
|
2575
|
+
as $$
|
|
2576
|
+
${sql}
|
|
2577
|
+
$$;`;
|
|
2578
|
+
}
|
|
2579
|
+
function heldSql(at, effect) {
|
|
2580
|
+
return `select 1 from pdk_h where pdk_h.key = pdk_k.key and pdk_h.scope = ${quoteLiteral(at)} and pdk_h.effect = '${effect}'`;
|
|
2581
|
+
}
|
|
2582
|
+
function keysFunction(fn, params, body, access) {
|
|
2583
|
+
return `create or replace function ${fn}(${params})
|
|
2584
|
+
returns setof text
|
|
2585
|
+
${functionBody(body).replace("set search_path = ''", "set search_path = ''\nset jit = off")}
|
|
2586
|
+
${access}`;
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* The permission-key forms of the helpers: `grant_keys`, which maps a
|
|
2590
|
+
* permission key to the grant keys of its unconditional allows on a scope
|
|
2591
|
+
* (or every deny key, or the conditioned allow or deny keys),
|
|
2592
|
+
* `permdock_has_permission(p_permission)` and one
|
|
2593
|
+
* `permitted_<scope>_ids_by_permission(p_permission)` per scope, plus their
|
|
2594
|
+
* `_for(p_user, ...)` forms in `database` mode. They answer with the
|
|
2595
|
+
* unconditional allows minus any deny, so a permission whose allows all carry
|
|
2596
|
+
* a row condition answers nothing. The
|
|
2597
|
+
* `permitted_<scope>_ids_by_permission(p_permission, p_conditioned)` overload
|
|
2598
|
+
* with `true` also lists the instances a conditioned allow reaches and
|
|
2599
|
+
* subtracts only unconditional denies, leaving the row condition to the
|
|
2600
|
+
* caller, as `permittedIds(..., { conditioned: true })` does in process.
|
|
2601
|
+
*/
|
|
2602
|
+
function permissionHelpersSql(ctx, grants, renamed, anonExecute) {
|
|
2603
|
+
const keys = qualified$1(ctx, PERMISSION_HELPERS.grantKeys);
|
|
2604
|
+
const allowKeys = (scope) => `${keys}(p_permission, ${quoteLiteral(scope)}) g(grant_key)`;
|
|
2605
|
+
const denyKeys = (scope) => `${keys}(p_permission, ${quoteLiteral(scope)}, 'deny') g(grant_key)`;
|
|
2606
|
+
const has = (helper, user) => ` select exists (select 1 from ${allowKeys("global")} where ${helper}(${user}g.grant_key))
|
|
2607
|
+
and not exists (select 1 from ${denyKeys("global")} where ${helper}(${user}g.grant_key))`;
|
|
2608
|
+
const ids = (helper, scope, user) => ` select a.id
|
|
2609
|
+
from ${allowKeys(scope)}
|
|
2610
|
+
cross join lateral ${helper}(${user}g.grant_key) a(id)
|
|
2611
|
+
except
|
|
2612
|
+
select d.id
|
|
2613
|
+
from ${denyKeys(scope)}
|
|
2614
|
+
cross join lateral ${helper}(${user}g.grant_key) d(id)`;
|
|
2615
|
+
const conditionedIds = (helper, scope, user) => ` select a.id
|
|
2616
|
+
from (
|
|
2617
|
+
select g.grant_key from ${allowKeys(scope)}
|
|
2618
|
+
union all
|
|
2619
|
+
select g.grant_key from ${keys}(p_permission, ${quoteLiteral(scope)}, 'conditioned-allow') g(grant_key)
|
|
2620
|
+
where p_conditioned
|
|
2621
|
+
) g
|
|
2622
|
+
cross join lateral ${helper}(${user}g.grant_key) a(id)
|
|
2623
|
+
except
|
|
2624
|
+
select d.id
|
|
2625
|
+
from (
|
|
2626
|
+
select g.grant_key from ${denyKeys(scope)}
|
|
2627
|
+
except
|
|
2628
|
+
select g.grant_key from ${keys}(p_permission, ${quoteLiteral(scope)}, 'conditioned-deny') g(grant_key)
|
|
2629
|
+
where p_conditioned
|
|
2630
|
+
) g
|
|
2631
|
+
cross join lateral ${helper}(${user}g.grant_key) d(id)`;
|
|
2632
|
+
const keysBody = (scope, bodies, filtered) => {
|
|
2633
|
+
const rows = ["global", scope].flatMap((at) => Object.entries(grants.get(at) ?? {}).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).flatMap(([key, effects]) => ["allow", "deny"].flatMap((effect) => effects[effect].map((grant) => `(${[
|
|
2634
|
+
key,
|
|
2635
|
+
at,
|
|
2636
|
+
effect,
|
|
2637
|
+
grant
|
|
2638
|
+
].map(quoteLiteral).join(", ")})`))));
|
|
2639
|
+
if (new Set(["global", scope].flatMap((at) => Object.entries(grants.get(at) ?? {}).flatMap(([key, effects]) => effects.allow.length > 0 ? [key] : []))).size === 0) return {
|
|
2640
|
+
sql: " select null::text where false",
|
|
2641
|
+
vars: []
|
|
2642
|
+
};
|
|
2643
|
+
const hasBody = bodies.has;
|
|
2644
|
+
const idsBody = bodies.ids(scope);
|
|
2645
|
+
return {
|
|
2646
|
+
sql: ` with pdk_m(key, scope, effect, grant_key) as (
|
|
2647
|
+
values
|
|
2648
|
+
${rows.join(",\n ")}
|
|
2649
|
+
),
|
|
2650
|
+
pdk_w as (
|
|
2651
|
+
select pdk_m.key, pdk_m.scope, pdk_m.effect, pdk_m.grant_key
|
|
2652
|
+
from pdk_m${filtered ? `\n where pdk_m.key in (select ${Object.keys(renamed).length === 0 ? "k.key" : `coalesce(${quoteLiteral(JSON.stringify(renamed))}::jsonb ->> k.key, k.key)`} from pg_catalog.unnest(p_keys) k(key))` : ""}
|
|
2653
|
+
),
|
|
2654
|
+
pdk_g as (
|
|
2655
|
+
${hasBody.sql}
|
|
2656
|
+
),
|
|
2657
|
+
pdk_s as (
|
|
2658
|
+
select pdk_i.grant_key
|
|
2659
|
+
from (
|
|
2660
|
+
${idsBody.sql}
|
|
2661
|
+
) pdk_i(id, grant_key)
|
|
2662
|
+
where pdk_i.id = p_id
|
|
2663
|
+
),
|
|
2664
|
+
pdk_h as (
|
|
2665
|
+
select pdk_w.key, pdk_w.scope, pdk_w.effect
|
|
2666
|
+
from pdk_w
|
|
2667
|
+
where (pdk_w.scope = 'global' and pdk_w.grant_key in (select pdk_g.grant_key from pdk_g))
|
|
2668
|
+
or (pdk_w.scope = ${quoteLiteral(scope)} and pdk_w.grant_key in (select pdk_s.grant_key from pdk_s))
|
|
2669
|
+
)
|
|
2670
|
+
select pdk_k.key
|
|
2671
|
+
from (select distinct pdk_w.key from pdk_w where pdk_w.effect = 'allow') pdk_k
|
|
2672
|
+
where (exists (${heldSql("global", "allow")}) and not exists (${heldSql("global", "deny")}))
|
|
2673
|
+
or (exists (${heldSql(scope, "allow")}) and not exists (${heldSql(scope, "deny")}))
|
|
2674
|
+
order by 1`,
|
|
2675
|
+
vars: [.../* @__PURE__ */ new Set([...hasBody.vars, ...idsBody.vars])]
|
|
2676
|
+
};
|
|
2677
|
+
};
|
|
2678
|
+
const chunks = [grantsFunction(ctx, grants, renamed, anonExecute), `-- the helpers by permission key: unconditional allows minus any deny
|
|
2679
|
+
${definer(qualified$1(ctx, PERMISSION_HELPERS.has), "p_permission text", "boolean", has(qualified$1(ctx, HELPERS.has), ""))}
|
|
2680
|
+
${grantsSql(qualified$1(ctx, PERMISSION_HELPERS.has), "text", anonExecute)}`];
|
|
2681
|
+
for (const scope of ctx.scopes) {
|
|
2682
|
+
const fn = qualified$1(ctx, checkName(permittedByPermissionHelper(scope.name)));
|
|
2683
|
+
chunks.push(`${definer(fn, "p_permission text", `setof ${scopeTypeOf(ctx, scope.name)}`, ids(qualified$1(ctx, permittedIdsHelper(scope.name)), scope.name, ""))}
|
|
2684
|
+
${grantsSql(fn, "text", anonExecute)}`);
|
|
2685
|
+
const keysFn = qualified$1(ctx, checkName(permissionKeysHelper(scope.name)));
|
|
2686
|
+
const type = scopeTypeOf(ctx, scope.name);
|
|
2687
|
+
const caller = grantBodies({
|
|
2688
|
+
...ctx,
|
|
2689
|
+
grantSet: true
|
|
2690
|
+
});
|
|
2691
|
+
chunks.push(`-- the permission keys the caller holds on one ${scope.name}: what permdock_has_permission or permitted_${scope.name}_ids_by_permission answers for each key, in one call
|
|
2692
|
+
${keysFunction(keysFn, `p_id ${type}`, keysBody(scope.name, caller, false), grantsSql(keysFn, type, anonExecute))}
|
|
2693
|
+
${keysFunction(keysFn, `p_id ${type}, p_keys text[]`, keysBody(scope.name, caller, true), grantsSql(keysFn, `${type}, text[]`, anonExecute))}`);
|
|
2694
|
+
chunks.push(`-- with p_conditioned true, also the instances a conditioned allow reaches, minus only unconditional denies: the caller applies the row condition
|
|
2695
|
+
${definer(fn, "p_permission text, p_conditioned boolean", `setof ${scopeTypeOf(ctx, scope.name)}`, conditionedIds(qualified$1(ctx, permittedIdsHelper(scope.name)), scope.name, ""))}
|
|
2696
|
+
${grantsSql(fn, "text, boolean", anonExecute)}`);
|
|
2697
|
+
}
|
|
2698
|
+
if (ctx.authorize !== "database") return `${chunks.join("\n\n")}\n`;
|
|
2699
|
+
const user = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
2700
|
+
const revoke = (fn) => `revoke execute on function ${fn}(${user}, text) from public, anon, authenticated;`;
|
|
2701
|
+
const hasFor = qualified$1(ctx, PERMISSION_HELPERS.hasFor);
|
|
2702
|
+
chunks.push(`-- the same for a user the caller names; no client role may execute them
|
|
2703
|
+
${definer(hasFor, `p_user ${user}, p_permission text`, "boolean", has(qualified$1(ctx, HELPERS.hasFor), "p_user, "))}
|
|
2704
|
+
${revoke(hasFor)}`);
|
|
2705
|
+
for (const scope of ctx.scopes) {
|
|
2706
|
+
const fn = qualified$1(ctx, checkName(`${permittedByPermissionHelper(scope.name)}_for`));
|
|
2707
|
+
chunks.push(`${definer(fn, `p_user ${user}, p_permission text`, `setof ${scopeTypeOf(ctx, scope.name)}`, ids(qualified$1(ctx, permittedForHelper(scope.name)), scope.name, "p_user, "))}
|
|
2708
|
+
${revoke(fn)}`);
|
|
2709
|
+
chunks.push(`${definer(fn, `p_user ${user}, p_permission text, p_conditioned boolean`, `setof ${scopeTypeOf(ctx, scope.name)}`, conditionedIds(qualified$1(ctx, permittedForHelper(scope.name)), scope.name, "p_user, "))}
|
|
2710
|
+
revoke execute on function ${fn}(${user}, text, boolean) from public, anon, authenticated;`);
|
|
2711
|
+
const keysFor = qualified$1(ctx, checkName(permissionKeysForHelper(scope.name)));
|
|
2712
|
+
const type = scopeTypeOf(ctx, scope.name);
|
|
2713
|
+
const named = grantBodies({
|
|
2714
|
+
...forUserContext(ctx),
|
|
2715
|
+
grantSet: true
|
|
2716
|
+
});
|
|
2717
|
+
chunks.push(`${keysFunction(keysFor, `p_user ${user}, p_id ${type}`, keysBody(scope.name, named, false), `revoke execute on function ${keysFor}(${user}, ${type}) from public, anon, authenticated;`)}
|
|
2718
|
+
${keysFunction(keysFor, `p_user ${user}, p_id ${type}, p_keys text[]`, keysBody(scope.name, named, true), `revoke execute on function ${keysFor}(${user}, ${type}, text[]) from public, anon, authenticated;`)}`);
|
|
2719
|
+
}
|
|
2720
|
+
return `${chunks.join("\n\n")}\n`;
|
|
2721
|
+
}
|
|
2722
|
+
//#endregion
|
|
2723
|
+
//#region src/cli/rls-compile.ts
|
|
2724
|
+
/**
|
|
2725
|
+
* The levels an allow reaches: every level without a row condition, else the
|
|
2726
|
+
* levels whose condition equals its condition.
|
|
2727
|
+
*/
|
|
2728
|
+
function reachedLevels(where, levels) {
|
|
2729
|
+
if (where === void 0) return levels.map(([name]) => name);
|
|
2730
|
+
const held = canonicalJson(where);
|
|
2731
|
+
return levels.filter(([, condition]) => canonicalJson(condition) === held).map(([name]) => name);
|
|
2732
|
+
}
|
|
2733
|
+
function levelReachOf(policy, keys) {
|
|
2734
|
+
const reach = /* @__PURE__ */ new Map();
|
|
2735
|
+
for (const [entry, grantKey] of keys) {
|
|
2736
|
+
const { item } = entry;
|
|
2737
|
+
const { grant } = item;
|
|
2738
|
+
if (item.access.kind !== "role" || grant.effect !== "allow" || grant.permission.kind !== "instance" || grant.level !== void 0) continue;
|
|
2739
|
+
const levels = policyLevels(policy, grant.permission.resource);
|
|
2740
|
+
if (levels.length > 0) {
|
|
2741
|
+
const set = reach.get(grantKey) ?? /* @__PURE__ */ new Set();
|
|
2742
|
+
for (const name of reachedLevels(grant.where, levels)) set.add(name);
|
|
2743
|
+
reach.set(grantKey, set);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
const out = [];
|
|
2747
|
+
for (const grantKey of new Set(keys.values())) {
|
|
2748
|
+
const at = grantKey.lastIndexOf("@");
|
|
2749
|
+
const base = at === -1 ? grantKey : grantKey.slice(0, at);
|
|
2750
|
+
const level = at === -1 ? void 0 : grantKey.slice(at + 1);
|
|
2751
|
+
for (const name of reach.get(base) ?? []) if (level === void 0 || level === name) out.push([grantKey, name]);
|
|
2752
|
+
}
|
|
2753
|
+
return out.toSorted(([a, x], [b, y]) => a === b ? x < y ? -1 : 1 : a < b ? -1 : 1);
|
|
2754
|
+
}
|
|
2755
|
+
/** The SQL command `action` compiles to: `rls.actions` first, then the six default verbs. */
|
|
2756
|
+
function commandFor(action, actions) {
|
|
2757
|
+
if (actions !== void 0 && Object.hasOwn(actions, action)) {
|
|
2758
|
+
const configured = actions[action];
|
|
2759
|
+
return configured === "none" ? void 0 : configured;
|
|
2760
|
+
}
|
|
2761
|
+
switch (action) {
|
|
2762
|
+
case "read":
|
|
2763
|
+
case "list":
|
|
2764
|
+
case "get": return "select";
|
|
2765
|
+
case "create": return "insert";
|
|
2766
|
+
case "update": return "update";
|
|
2767
|
+
case "delete": return "delete";
|
|
2768
|
+
default: return;
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
function tableFor(resource, tables) {
|
|
2772
|
+
return tables?.[resource] ?? resource;
|
|
2773
|
+
}
|
|
2774
|
+
function ancestorsOf(policy, name) {
|
|
2775
|
+
const names = [];
|
|
2776
|
+
let current = policy.resources.get(name);
|
|
2777
|
+
while (current?.parent !== void 0 && !names.includes(current.parent.resource)) {
|
|
2778
|
+
names.push(current.parent.resource);
|
|
2779
|
+
current = policy.resources.get(current.parent.resource);
|
|
2780
|
+
}
|
|
2781
|
+
return names;
|
|
2782
|
+
}
|
|
2783
|
+
function membershipField(policy, resource, holder) {
|
|
2784
|
+
if (resource === void 0) return;
|
|
2785
|
+
if (resource.name === holder) return resource.id ?? "id";
|
|
2786
|
+
let current = resource;
|
|
2787
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2788
|
+
while (current?.parent !== void 0 && !seen.has(current.name)) {
|
|
2789
|
+
seen.add(current.name);
|
|
2790
|
+
if (current.parent.resource === holder) return current.parent.field;
|
|
2791
|
+
current = policy.resources.get(current.parent.resource);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
function resourceCondition(item, policy, ctx) {
|
|
2795
|
+
if (item.access.kind !== "resource") throw new Error("PermDock CLI: resourceCondition needs a resource role");
|
|
2796
|
+
const { role, resource: roleResource } = item.access;
|
|
2797
|
+
const target = policy.resources.get(item.grant.permission.resource);
|
|
2798
|
+
const holders = [roleResource, ...ancestorsOf(policy, roleResource)];
|
|
2799
|
+
const hops = [];
|
|
2800
|
+
for (const [index, holder] of holders.entries()) {
|
|
2801
|
+
const field = membershipField(policy, target, holder);
|
|
2802
|
+
if (field === void 0) continue;
|
|
2803
|
+
if (index > 0 && ctx.memberships?.resource?.[holder] === void 0) continue;
|
|
2804
|
+
hops.push({
|
|
2805
|
+
op: "memberOf",
|
|
2806
|
+
scope: "resource",
|
|
2807
|
+
field,
|
|
2808
|
+
roles: [role],
|
|
2809
|
+
resource: holder
|
|
2810
|
+
});
|
|
2811
|
+
}
|
|
2812
|
+
return sole(hops) ?? {
|
|
2813
|
+
op: "or",
|
|
2814
|
+
conditions: hops
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
/**
|
|
2818
|
+
* The access a link capability has to a resource-scoped grant, over the same
|
|
2819
|
+
* holders as `resourceCondition`. It reads the claim, so ancestor hops need
|
|
2820
|
+
* no memberships table. `undefined` when no holder is on the row's chain.
|
|
2821
|
+
*/
|
|
2822
|
+
function capabilityAccess(item, policy, ctx) {
|
|
2823
|
+
if (item.access.kind !== "resource") return;
|
|
2824
|
+
const { role, resource: roleResource } = item.access;
|
|
2825
|
+
const kinds = ctx.ownership?.kinds[role];
|
|
2826
|
+
if (kinds !== void 0 && !kinds.includes("link")) return;
|
|
2827
|
+
const target = policy.resources.get(item.grant.permission.resource);
|
|
2828
|
+
const hops = [];
|
|
2829
|
+
for (const holder of [roleResource, ...ancestorsOf(policy, roleResource)]) {
|
|
2830
|
+
const field = membershipField(policy, target, holder);
|
|
2831
|
+
if (field !== void 0) hops.push(capabilityAccessSql(ctx, field, holder, role, item.grant.permission.key));
|
|
2832
|
+
}
|
|
2833
|
+
if (hops.length === 0) return;
|
|
2834
|
+
return hops.length === 1 ? hops[0] : hops.map(wrapSql).join(" or ");
|
|
2835
|
+
}
|
|
2836
|
+
function prepare(item, tables, warnings, skipClosures, actions, rowActions = false) {
|
|
2837
|
+
const { grant, label } = item;
|
|
2838
|
+
if (grant.breakGlass !== void 0) {
|
|
2839
|
+
warnings.push(`break-glass grant ${label}/${grant.permission.key} reads through permdock_break_glass_${grant.permission.resource}, not a policy`);
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
if (grant.closure !== void 0 || grant.portable === false) {
|
|
2843
|
+
if (skipClosures) {
|
|
2844
|
+
warnings.push(`skipped non-portable grant ${label}/${grant.permission.key}`);
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
throw new Error(`PermDock CLI: closure grant ${label}/${grant.permission.key} is not portable; rewrite it or pass --skip-closures`);
|
|
2848
|
+
}
|
|
2849
|
+
const context = [.../* @__PURE__ */ new Set([...contextRefs(item.where), ...contextRefs(grant.check)])];
|
|
2850
|
+
if (context.length > 0) {
|
|
2851
|
+
if (skipClosures) {
|
|
2852
|
+
warnings.push(`skipped grant ${label}/${grant.permission.key}: it reads ${context.join(", ")}, which is not in the token`);
|
|
2853
|
+
return;
|
|
2854
|
+
}
|
|
2855
|
+
throw new Error(`PermDock CLI: grant ${label}/${grant.permission.key} reads ${context.join(", ")}; request context is not in the token, so RLS cannot compile it. Move the value to a server-set claim (principal.claims.*) or pass --skip-closures (permdock doctor PD027)`);
|
|
2856
|
+
}
|
|
2857
|
+
if (requiresApproval(grant.approval)) {
|
|
2858
|
+
warnings.push(`skipped approval grant ${label}/${grant.permission.key}`);
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
const command = commandFor(grant.permission.action, actions);
|
|
2862
|
+
const table = tableFor(grant.permission.resource, tables);
|
|
2863
|
+
if (command === void 0) {
|
|
2864
|
+
if (item.access.kind !== "role" && rowActions) {
|
|
2865
|
+
warnings.push(`no policy for ${label}/${grant.permission.key}: action '${grant.permission.action}' has no SQL command (rls.actions); only permitted_<resource>_rows answers it`);
|
|
2866
|
+
return {
|
|
2867
|
+
item,
|
|
2868
|
+
command: "none",
|
|
2869
|
+
table,
|
|
2870
|
+
...item.where === void 0 ? {} : { using: item.where }
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
if (item.access.kind !== "role") {
|
|
2874
|
+
warnings.push(`skipped ${label}/${grant.permission.key}: action '${grant.permission.action}' has no SQL command (rls.actions) and only role grants are seeded`);
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
if (grant.validity !== void 0 && grant.effect === "allow") {
|
|
2878
|
+
warnings.push(`skipped ${label}/${grant.permission.key}: a role_permissions row cannot carry validFrom / validUntil, so a time-bounded grant on an action with no SQL command is not seeded`);
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
warnings.push(`no policy for ${label}/${grant.permission.key}: action '${grant.permission.action}' has no SQL command (rls.actions); seeded for permdock_has and permitted_<scope>_ids`);
|
|
2882
|
+
return {
|
|
2883
|
+
item,
|
|
2884
|
+
command: "none",
|
|
2885
|
+
table,
|
|
2886
|
+
...item.where === void 0 ? {} : { using: item.where },
|
|
2887
|
+
...grant.check === void 0 ? {} : { check: grant.check }
|
|
2888
|
+
};
|
|
2889
|
+
}
|
|
2890
|
+
const using = command === "insert" ? void 0 : item.where;
|
|
2891
|
+
const check = command === "insert" ? grant.check ?? item.where : command === "update" ? grant.check ?? item.where : void 0;
|
|
2892
|
+
return {
|
|
2893
|
+
item,
|
|
2894
|
+
command,
|
|
2895
|
+
table,
|
|
2896
|
+
...using === void 0 ? {} : { using },
|
|
2897
|
+
...check === void 0 ? {} : { check }
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2900
|
+
function signature(entry, byFields) {
|
|
2901
|
+
const base = [
|
|
2902
|
+
entry.item.grant.effect,
|
|
2903
|
+
entry.using ?? null,
|
|
2904
|
+
entry.check ?? null
|
|
2905
|
+
];
|
|
2906
|
+
return JSON.stringify(byFields ? [...base, entry.item.grant.fields ?? null] : base);
|
|
2907
|
+
}
|
|
2908
|
+
/**
|
|
2909
|
+
* Grant keys per permission: the key is the permission, split into
|
|
2910
|
+
* `permission#n` when role grants carry different portable conditions (or
|
|
2911
|
+
* effects), one key per condition group; a group whose grants set `group`
|
|
2912
|
+
* is `permission#<group>` instead. With field views, grants that differ
|
|
2913
|
+
* only in `fields` get their own keys too, so a view can tell them apart.
|
|
2914
|
+
*/
|
|
2915
|
+
function assignKeys(entries, byFields) {
|
|
2916
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2917
|
+
for (const entry of entries) {
|
|
2918
|
+
if (entry.item.access.kind !== "role") continue;
|
|
2919
|
+
const key = entry.item.grant.permission.key;
|
|
2920
|
+
const byCondition = groups.get(key) ?? /* @__PURE__ */ new Map();
|
|
2921
|
+
const sig = signature(entry, byFields);
|
|
2922
|
+
byCondition.set(sig, [...byCondition.get(sig) ?? [], entry]);
|
|
2923
|
+
groups.set(key, byCondition);
|
|
2924
|
+
}
|
|
2925
|
+
const keys = /* @__PURE__ */ new Map();
|
|
2926
|
+
for (const [permission, byCondition] of groups) {
|
|
2927
|
+
const lists = [...byCondition.values()];
|
|
2928
|
+
const named = /* @__PURE__ */ new Set();
|
|
2929
|
+
for (const [index, list] of lists.entries()) {
|
|
2930
|
+
const group = groupOf(permission, list);
|
|
2931
|
+
if (group !== void 0 && named.has(group)) throw new Error(`PermDock CLI: grants of ${permission} with different conditions share group '${group}'; a group names one condition`);
|
|
2932
|
+
if (group !== void 0) named.add(group);
|
|
2933
|
+
const grantKey = group !== void 0 ? `${permission}#${group}` : lists.length === 1 ? permission : `${permission}#${index + 1}`;
|
|
2934
|
+
for (const entry of list) keys.set(entry, grantKey);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
return keys;
|
|
2938
|
+
}
|
|
2939
|
+
function groupOf(permission, list) {
|
|
2940
|
+
const names = [...new Set(list.flatMap((entry) => entry.item.grant.group === void 0 ? [] : [entry.item.grant.group]))].toSorted();
|
|
2941
|
+
if (names.length > 1) throw new Error(`PermDock CLI: grants of ${permission} with one condition name the groups ${names.join(", ")}; give them one group`);
|
|
2942
|
+
return names[0];
|
|
2943
|
+
}
|
|
2944
|
+
/**
|
|
2945
|
+
* One entry per level for every allow of an assignable role on a resource
|
|
2946
|
+
* with levels, keyed `<grant key>@<level>`. Only `permdock_custom_keys`
|
|
2947
|
+
* hands these keys out, to a custom role whose stored allow names the level.
|
|
2948
|
+
*/
|
|
2949
|
+
function levelEntries(policy, ctx, keys, tables, warnings, skipClosures) {
|
|
2950
|
+
const custom = ctx.customRoles;
|
|
2951
|
+
if (custom?.levels !== true) return [];
|
|
2952
|
+
const assignable = new Set(custom.assignable);
|
|
2953
|
+
const out = [];
|
|
2954
|
+
for (const [entry, grantKey] of Array.from(keys)) {
|
|
2955
|
+
const { item } = entry;
|
|
2956
|
+
const { grant } = item;
|
|
2957
|
+
if (item.access.kind !== "role" || grant.effect !== "allow" || grant.permission.kind !== "instance" || !assignable.has(item.access.role)) continue;
|
|
2958
|
+
if (grant.permission.key.includes("@")) throw new Error(`PermDock CLI: permission key '${grant.permission.key}' holds '@', which separates a level in custom-role grant keys`);
|
|
2959
|
+
for (const [level, condition] of policyLevels(policy, grant.permission.resource)) {
|
|
2960
|
+
const where = andWhere(item.where, condition);
|
|
2961
|
+
const prepared = prepare({
|
|
2962
|
+
...item,
|
|
2963
|
+
grant: leveled(grant, level, condition),
|
|
2964
|
+
...where === void 0 ? {} : { where }
|
|
2965
|
+
}, tables, warnings, skipClosures, ctx.actions);
|
|
2966
|
+
if (prepared !== void 0) {
|
|
2967
|
+
keys.set(prepared, `${grantKey}@${level}`);
|
|
2968
|
+
out.push(prepared);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
return out;
|
|
2973
|
+
}
|
|
2974
|
+
function compileOptional(condition, ctx) {
|
|
2975
|
+
return condition === void 0 ? void 0 : compileConditionSql(condition, ctx);
|
|
2976
|
+
}
|
|
2977
|
+
function noteConditions(entry, warnings) {
|
|
2978
|
+
const { label, grant } = entry.item;
|
|
2979
|
+
const names = [...sqlFunctionNames(entry.using), ...sqlFunctionNames(entry.check)];
|
|
2980
|
+
if (names.length > 0) warnings.push(`sqlFunction ${[...new Set(names)].join(", ")} on ${label}/${grant.permission.key} is portable via twin`);
|
|
2981
|
+
if (hasConditionOp(entry.using, "opaque") || hasConditionOp(entry.check, "opaque")) warnings.push(`opaque SQL on ${label}/${grant.permission.key} is untestable app-side`);
|
|
2982
|
+
}
|
|
2983
|
+
/**
|
|
2984
|
+
* Compiles every grant (role and top-level) to branches whose role check is
|
|
2985
|
+
* a helper call keyed by grant key. Nothing in a branch is per-row except the
|
|
2986
|
+
* portable row condition and resource-membership joins.
|
|
2987
|
+
*/
|
|
2988
|
+
/** A Supabase token from a permanent user: anonymous sign-ins carry `is_anonymous: true`. */
|
|
2989
|
+
const PERMANENT_USER = `((select auth.jwt()) ->> 'is_anonymous') is distinct from 'true'`;
|
|
2990
|
+
function compileGrants(policy, ctx, tables, warnings, skipClosures, rowActions = false) {
|
|
2991
|
+
const items = collectGrants(policy);
|
|
2992
|
+
const entries = items.flatMap((item) => {
|
|
2993
|
+
const entry = prepare(item, tables, warnings, skipClosures, ctx.actions, rowActions);
|
|
2994
|
+
return entry === void 0 ? [] : [entry];
|
|
2995
|
+
});
|
|
2996
|
+
const keys = assignKeys(entries, ctx.fields === "views");
|
|
2997
|
+
entries.push(...levelEntries(policy, ctx, keys, tables, warnings, skipClosures));
|
|
2998
|
+
const conditionedKeys = /* @__PURE__ */ new Set();
|
|
2999
|
+
for (const [entry, grantKey] of keys) if (entry.using !== void 0 || entry.check !== void 0 || entry.item.grant.validity !== void 0 || entry.item.grant.requires !== void 0) conditionedKeys.add(grantKey);
|
|
3000
|
+
const rows = /* @__PURE__ */ new Map();
|
|
3001
|
+
for (const item of items) {
|
|
3002
|
+
const holder = item.grant.breakGlass === void 0 ? void 0 : breakGlassHolder(item);
|
|
3003
|
+
if (holder !== void 0) {
|
|
3004
|
+
const row = {
|
|
3005
|
+
role: holder.role,
|
|
3006
|
+
permission: item.grant.permission.key,
|
|
3007
|
+
grantKey: breakGlassKey(item.grant.permission.key),
|
|
3008
|
+
scope: holder.scope,
|
|
3009
|
+
effect: item.grant.effect
|
|
3010
|
+
};
|
|
3011
|
+
rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
const branches = [];
|
|
3015
|
+
const actionBranches = [];
|
|
3016
|
+
const rowColumns = /* @__PURE__ */ new Map();
|
|
3017
|
+
const filters = (resource, table, column) => {
|
|
3018
|
+
rowColumns.set(`${table}\u0000${column}`, {
|
|
3019
|
+
resource,
|
|
3020
|
+
table,
|
|
3021
|
+
column
|
|
3022
|
+
});
|
|
3023
|
+
};
|
|
3024
|
+
const typed = /* @__PURE__ */ new Map();
|
|
3025
|
+
const contextFor = (name) => {
|
|
3026
|
+
const known = typed.get(name);
|
|
3027
|
+
if (known !== void 0) return known;
|
|
3028
|
+
const node = policy.resources.get(name);
|
|
3029
|
+
const schema = node === void 0 ? void 0 : jsonSchemaOf(node);
|
|
3030
|
+
const next = {
|
|
3031
|
+
...ctx,
|
|
3032
|
+
columnTypes: columnTypesOf(schema),
|
|
3033
|
+
arrayColumns: arrayColumnsOf(schema)
|
|
3034
|
+
};
|
|
3035
|
+
typed.set(name, next);
|
|
3036
|
+
return next;
|
|
3037
|
+
};
|
|
3038
|
+
for (const entry of entries) {
|
|
3039
|
+
const { item, table } = entry;
|
|
3040
|
+
const { grant, access, label } = item;
|
|
3041
|
+
if (entry.command === "none") {
|
|
3042
|
+
const grantKey = keys.get(entry);
|
|
3043
|
+
if (access.kind === "role" && grantKey !== void 0) {
|
|
3044
|
+
const row = {
|
|
3045
|
+
role: access.role,
|
|
3046
|
+
permission: grant.permission.key,
|
|
3047
|
+
grantKey,
|
|
3048
|
+
scope: access.scope,
|
|
3049
|
+
effect: grant.effect
|
|
3050
|
+
};
|
|
3051
|
+
rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
|
|
3052
|
+
}
|
|
3053
|
+
if (!rowActions) continue;
|
|
3054
|
+
}
|
|
3055
|
+
const actionOnly = entry.command === "none";
|
|
3056
|
+
const command = actionOnly ? "select" : entry.command;
|
|
3057
|
+
const target = actionOnly ? actionBranches : branches;
|
|
3058
|
+
const rowCtx = {
|
|
3059
|
+
...contextFor(item.grant.permission.resource),
|
|
3060
|
+
permission: grant.permission.key
|
|
3061
|
+
};
|
|
3062
|
+
noteConditions(entry, warnings);
|
|
3063
|
+
for (const field of [...indexedFields(entry.using), ...indexedFields(entry.check)]) filters(grant.permission.resource, table, field);
|
|
3064
|
+
const grantKey = keys.get(entry);
|
|
3065
|
+
let accessExpr;
|
|
3066
|
+
if (access.kind === "role" && grantKey !== void 0) {
|
|
3067
|
+
const column = access.scope === "global" ? void 0 : scopeField(policy.resources.get(grant.permission.resource), access.scope, policy.scopes);
|
|
3068
|
+
accessExpr = accessSql(ctx, access.scope, grantKey, column);
|
|
3069
|
+
if (column !== void 0) filters(grant.permission.resource, table, column);
|
|
3070
|
+
const row = {
|
|
3071
|
+
role: access.role,
|
|
3072
|
+
permission: grant.permission.key,
|
|
3073
|
+
grantKey,
|
|
3074
|
+
scope: access.scope,
|
|
3075
|
+
effect: grant.effect
|
|
3076
|
+
};
|
|
3077
|
+
rows.set(`${row.role}\u0000${row.grantKey}\u0000${row.scope}`, row);
|
|
3078
|
+
}
|
|
3079
|
+
const linkOnly = access.kind === "resource" && ctx.capabilities === true && ctx.memberships?.resource?.[access.resource] === void 0;
|
|
3080
|
+
if (access.kind === "actor") accessExpr = `${subjectClaimSql(ctx, "client_id")} is not null or ${subjectClaimJsonSql(ctx, "act")} is not null`;
|
|
3081
|
+
if (access.kind === "resource" && !linkOnly) accessExpr = compileConditionSql(resourceCondition(item, policy, ctx), {
|
|
3082
|
+
...ctx,
|
|
3083
|
+
permission: grant.permission.key
|
|
3084
|
+
});
|
|
3085
|
+
if (linkOnly) warnings.push(`no ${access.resource} memberships table: only link capabilities reach ${label}/${grant.permission.key}`);
|
|
3086
|
+
const validity = validitySql(grant.validity);
|
|
3087
|
+
accessExpr = andSql(accessExpr, validity);
|
|
3088
|
+
for (const key of grant.requires ?? []) accessExpr = andSql(accessExpr, requiresSql(ctx, key, policy.resources.get(grant.permission.resource)));
|
|
3089
|
+
if (ctx.anonymousSignIns === "deny" && access.kind !== "anyone") accessExpr = andSql(accessExpr, PERMANENT_USER);
|
|
3090
|
+
if (ctx.apiKeys !== void 0 && grant.effect === "allow" && access.kind !== "anyone" && access.kind !== "role") {
|
|
3091
|
+
const calls = (requiresStandIn(grant) ? grant.requires ?? [] : [grant.permission.key, ...grant.requires ?? []]).map((key) => `(select ${apiKeyAllowsCall(ctx, quoteLiteral(key))})`);
|
|
3092
|
+
accessExpr = andSql(accessExpr, calls.length === 1 ? calls[0] ?? "" : `(${calls.join(" and ")})`);
|
|
3093
|
+
}
|
|
3094
|
+
const using = compileOptional(entry.using, rowCtx);
|
|
3095
|
+
const check = compileOptional(entry.check, rowCtx);
|
|
3096
|
+
const fields = grant.fields === void 0 ? {} : { fields: grant.fields };
|
|
3097
|
+
if (!linkOnly) target.push({
|
|
3098
|
+
table,
|
|
3099
|
+
command,
|
|
3100
|
+
effect: grant.effect,
|
|
3101
|
+
roles: access.kind === "anyone" ? ["anon", "authenticated"] : ["authenticated"],
|
|
3102
|
+
label,
|
|
3103
|
+
resource: grant.permission.resource,
|
|
3104
|
+
permissionKey: grant.permission.key,
|
|
3105
|
+
...fields,
|
|
3106
|
+
...grantKey === void 0 ? {} : { grantKey },
|
|
3107
|
+
...accessExpr === void 0 ? {} : { access: accessExpr },
|
|
3108
|
+
...using === void 0 ? {} : { using },
|
|
3109
|
+
...check === void 0 ? {} : { check }
|
|
3110
|
+
});
|
|
3111
|
+
const linked = ctx.capabilities === true ? capabilityAccess(item, policy, ctx) : void 0;
|
|
3112
|
+
if (linked !== void 0) target.push({
|
|
3113
|
+
table,
|
|
3114
|
+
command,
|
|
3115
|
+
effect: grant.effect,
|
|
3116
|
+
roles: ["anon"],
|
|
3117
|
+
label,
|
|
3118
|
+
resource: grant.permission.resource,
|
|
3119
|
+
permissionKey: grant.permission.key,
|
|
3120
|
+
...fields,
|
|
3121
|
+
access: andSql(linked, validity) ?? linked,
|
|
3122
|
+
...using === void 0 ? {} : { using },
|
|
3123
|
+
...check === void 0 ? {} : { check }
|
|
3124
|
+
});
|
|
3125
|
+
}
|
|
3126
|
+
return {
|
|
3127
|
+
branches: ensureSelectCoverage(branches, warnings),
|
|
3128
|
+
actionBranches,
|
|
3129
|
+
rolePermissions: withAliasRows(policy, [...rows.values()]),
|
|
3130
|
+
conditionedKeys,
|
|
3131
|
+
rowColumns: [...rowColumns.values()],
|
|
3132
|
+
levelReach: ctx.customRoles?.levels === true ? levelReachOf(policy, keys) : []
|
|
3133
|
+
};
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* Each row again under every key its permission was renamed from, so SQL that
|
|
3137
|
+
* still calls `permdock_has('<old key>')` keeps the access the current key
|
|
3138
|
+
* has. A split grant key (`key#n`) keeps its suffix; break-glass rows are not
|
|
3139
|
+
* copied.
|
|
3140
|
+
*/
|
|
3141
|
+
function withAliasRows(policy, rows) {
|
|
3142
|
+
const former = new Map(listPermissions(policy.permissions).map((leaf) => [leaf.key, formerKeys(leaf)]));
|
|
3143
|
+
const out = [...rows];
|
|
3144
|
+
for (const row of rows) {
|
|
3145
|
+
const suffix = row.grantKey.slice(row.permission.length);
|
|
3146
|
+
if (!row.grantKey.startsWith(row.permission) || suffix !== "" && (!/^#[a-z0-9][a-z0-9_-]*$/u.test(suffix) || suffix === "#break-glass")) continue;
|
|
3147
|
+
for (const old of former.get(row.permission) ?? []) out.push({
|
|
3148
|
+
...row,
|
|
3149
|
+
permission: old,
|
|
3150
|
+
grantKey: `${old}${suffix}`
|
|
3151
|
+
});
|
|
3152
|
+
}
|
|
3153
|
+
return out;
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* Postgres needs SELECT access to find rows for UPDATE / DELETE and for
|
|
3157
|
+
* RETURNING, so a table with only update or delete grants gets matching
|
|
3158
|
+
* SELECT branches.
|
|
3159
|
+
*/
|
|
3160
|
+
function ensureSelectCoverage(branches, warnings) {
|
|
3161
|
+
const extra = [];
|
|
3162
|
+
const readable = new Set(branches.filter((item) => item.command === "select" && item.effect === "allow").map((item) => item.table));
|
|
3163
|
+
const covered = /* @__PURE__ */ new Set();
|
|
3164
|
+
for (const item of branches) {
|
|
3165
|
+
if (item.effect !== "allow" || item.command !== "update" && item.command !== "delete" || readable.has(item.table)) continue;
|
|
3166
|
+
const { check: _check, ...rest } = item;
|
|
3167
|
+
extra.push({
|
|
3168
|
+
...rest,
|
|
3169
|
+
command: "select",
|
|
3170
|
+
coverage: true
|
|
3171
|
+
});
|
|
3172
|
+
if (!covered.has(item.table)) {
|
|
3173
|
+
covered.add(item.table);
|
|
3174
|
+
warnings.push(`added SELECT coverage for ${item.table} (${item.permissionKey})`);
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
return [...branches, ...extra];
|
|
3178
|
+
}
|
|
3179
|
+
/** True when `sql` is one parenthesised expression, so AND / OR need not wrap it again. */
|
|
3180
|
+
function isWrapped(sql) {
|
|
3181
|
+
if (!sql.startsWith("(") || !sql.endsWith(")")) return false;
|
|
3182
|
+
let depth = 0;
|
|
3183
|
+
let quoted = false;
|
|
3184
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
3185
|
+
const ch = sql[index];
|
|
3186
|
+
if (ch === "'") quoted = !quoted;
|
|
3187
|
+
else if (!quoted && ch === "(") depth += 1;
|
|
3188
|
+
else if (!quoted && ch === ")") {
|
|
3189
|
+
depth -= 1;
|
|
3190
|
+
if (depth === 0 && index < sql.length - 1) return false;
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
return depth === 0;
|
|
3194
|
+
}
|
|
3195
|
+
function wrapSql(sql) {
|
|
3196
|
+
return isWrapped(sql) ? sql : `(${sql})`;
|
|
3197
|
+
}
|
|
3198
|
+
/**
|
|
3199
|
+
* The grant's `validFrom` / `validUntil` as a clock check: `from` inclusive,
|
|
3200
|
+
* `until` exclusive, matching the in-process evaluator.
|
|
3201
|
+
*/
|
|
3202
|
+
function validitySql(validity) {
|
|
3203
|
+
if (validity === void 0) return;
|
|
3204
|
+
return andSql(validity.from === void 0 ? void 0 : `now() >= to_timestamp(${validity.from})`, validity.until === void 0 ? void 0 : `now() < to_timestamp(${validity.until})`);
|
|
3205
|
+
}
|
|
3206
|
+
function andSql(...parts) {
|
|
3207
|
+
const present = parts.filter((part) => part !== void 0 && part !== "true");
|
|
3208
|
+
if (present.length === 0) return;
|
|
3209
|
+
return present.length === 1 ? present[0] : present.map(wrapSql).join(" and ");
|
|
3210
|
+
}
|
|
3211
|
+
/** `USING` and `WITH CHECK` for a branch: its access check ANDed with its row conditions. */
|
|
3212
|
+
function branchClauses(branch) {
|
|
3213
|
+
const using = branch.command === "insert" ? void 0 : andSql(branch.access, branch.using) ?? "true";
|
|
3214
|
+
const check = branch.command === "insert" || branch.command === "update" ? andSql(branch.access, branch.check) ?? "true" : void 0;
|
|
3215
|
+
return {
|
|
3216
|
+
...using === void 0 ? {} : { using },
|
|
3217
|
+
...check === void 0 ? {} : { check }
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
//#endregion
|
|
3221
|
+
//#region src/cli/rls-policies.ts
|
|
3222
|
+
/** Each database role gets its own policy, so no role sees two permissive policies for one command. */
|
|
3223
|
+
const AUDIENCES = ["authenticated", "anon"];
|
|
3224
|
+
const DEFAULT_POLICY_NAME = "{table}_{op}";
|
|
3225
|
+
const DEFAULT_PER_ROLE_NAME = "{role}_{permission}";
|
|
3226
|
+
const PLACEHOLDER = /\{(table|op|role|permission)\}/gu;
|
|
3227
|
+
function sanitize(name) {
|
|
3228
|
+
return name.replaceAll(/[^A-Za-z0-9_]/g, "_");
|
|
3229
|
+
}
|
|
3230
|
+
function render(template, values) {
|
|
3231
|
+
return sanitize(template.replaceAll(PLACEHOLDER, (_match, key) => values[key]));
|
|
3232
|
+
}
|
|
3233
|
+
/** Rejects a template that cannot name every policy of the chosen shape uniquely. */
|
|
3234
|
+
function assertPolicyName(template, perRole) {
|
|
3235
|
+
if (!perRole && /\{(role|permission)\}/u.test(template)) throw new Error("PermDock CLI: --policy-name {role} and {permission} need --policy-per-role; a collapsed policy covers several roles");
|
|
3236
|
+
if (!perRole && !(template.includes("{table}") && template.includes("{op}"))) throw new Error("PermDock CLI: --policy-name needs {table} and {op} so each table and command gets its own policy");
|
|
3237
|
+
}
|
|
3238
|
+
function orSql(parts) {
|
|
3239
|
+
const distinct = [...new Set(parts)];
|
|
3240
|
+
if (distinct.includes("true")) return "true";
|
|
3241
|
+
return sole(distinct) ?? distinct.map(wrapSql).join(" or ");
|
|
3242
|
+
}
|
|
3243
|
+
function negate(sql) {
|
|
3244
|
+
return sql === void 0 ? void 0 : `(${sql}) is not true`;
|
|
3245
|
+
}
|
|
3246
|
+
function policyOf(name, branch, using, check) {
|
|
3247
|
+
const deny = branch.effect === "deny";
|
|
3248
|
+
const policy = {
|
|
3249
|
+
name,
|
|
3250
|
+
table: branch.table,
|
|
3251
|
+
command: branch.command,
|
|
3252
|
+
effect: branch.effect,
|
|
3253
|
+
roles: branch.roles
|
|
3254
|
+
};
|
|
3255
|
+
const finalUsing = deny ? negate(using) : using;
|
|
3256
|
+
const finalCheck = deny ? negate(check) : check;
|
|
3257
|
+
if (finalUsing !== void 0) policy.using = finalUsing;
|
|
3258
|
+
if (finalCheck !== void 0) policy.check = finalCheck;
|
|
3259
|
+
return policy;
|
|
3260
|
+
}
|
|
3261
|
+
function uniqueName(names, name) {
|
|
3262
|
+
const seen = names.get(name) ?? 0;
|
|
3263
|
+
names.set(name, seen + 1);
|
|
3264
|
+
return seen === 0 ? name : `${name}_${seen + 1}`;
|
|
3265
|
+
}
|
|
3266
|
+
function perRolePolicies(branches, template) {
|
|
3267
|
+
const names = /* @__PURE__ */ new Map();
|
|
3268
|
+
return branches.map((branch) => {
|
|
3269
|
+
const clauses = branchClauses(branch);
|
|
3270
|
+
const deny = branch.effect === "deny";
|
|
3271
|
+
const base = render(template, {
|
|
3272
|
+
table: branch.table,
|
|
3273
|
+
op: branch.command,
|
|
3274
|
+
role: branch.label,
|
|
3275
|
+
permission: branch.permissionKey
|
|
3276
|
+
});
|
|
3277
|
+
const name = `${deny ? "deny_" : ""}${base}${branch.coverage === true ? "_select_coverage" : ""}`;
|
|
3278
|
+
return policyOf(uniqueName(names, name), branch, clauses.using, clauses.check);
|
|
3279
|
+
});
|
|
3280
|
+
}
|
|
3281
|
+
/**
|
|
3282
|
+
* Branches of one grant-key group share their row condition, so the group
|
|
3283
|
+
* becomes one OR branch whose access ORs the helper calls of each scope.
|
|
3284
|
+
*/
|
|
3285
|
+
function mergeGroups(branches) {
|
|
3286
|
+
const merged = [];
|
|
3287
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
3288
|
+
for (const branch of branches) {
|
|
3289
|
+
const id = branch.grantKey === void 0 ? void 0 : `${branch.table}\u0000${branch.command}\u0000${branch.grantKey}\u0000${branch.coverage === true}`;
|
|
3290
|
+
const entry = id === void 0 ? void 0 : byKey.get(id);
|
|
3291
|
+
if (entry === void 0) {
|
|
3292
|
+
const created = {
|
|
3293
|
+
branch,
|
|
3294
|
+
accesses: branch.access === void 0 ? [] : [branch.access]
|
|
3295
|
+
};
|
|
3296
|
+
if (id !== void 0) byKey.set(id, created);
|
|
3297
|
+
merged.push(created);
|
|
3298
|
+
continue;
|
|
3299
|
+
}
|
|
3300
|
+
if (branch.access !== void 0 && !entry.accesses.includes(branch.access)) entry.accesses.push(branch.access);
|
|
3301
|
+
}
|
|
3302
|
+
return merged.map(({ branch, accesses }) => accesses.length <= 1 ? branch : withAccess(branch, orSql(accesses)));
|
|
3303
|
+
}
|
|
3304
|
+
function withAccess(branch, access) {
|
|
3305
|
+
return {
|
|
3306
|
+
...branch,
|
|
3307
|
+
access
|
|
3308
|
+
};
|
|
3309
|
+
}
|
|
3310
|
+
function collapsedPolicies(branches, template) {
|
|
3311
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3312
|
+
for (const branch of mergeGroups(branches)) {
|
|
3313
|
+
const id = `${branch.table}\u0000${branch.command}\u0000${branch.effect}`;
|
|
3314
|
+
groups.set(id, [...groups.get(id) ?? [], branch]);
|
|
3315
|
+
}
|
|
3316
|
+
const names = /* @__PURE__ */ new Map();
|
|
3317
|
+
return [...groups.values()].flatMap((group) => AUDIENCES.flatMap((audience) => {
|
|
3318
|
+
const members = group.filter((branch) => branch.roles.includes(audience));
|
|
3319
|
+
const [first] = members;
|
|
3320
|
+
if (first === void 0) return [];
|
|
3321
|
+
const deny = first.effect === "deny";
|
|
3322
|
+
const clauses = members.map(branchClauses);
|
|
3323
|
+
const usings = clauses.flatMap((item) => item.using === void 0 ? [] : [item.using]);
|
|
3324
|
+
const checks = clauses.flatMap((item) => item.check === void 0 ? [] : [item.check]);
|
|
3325
|
+
const base = render(template, {
|
|
3326
|
+
table: first.table,
|
|
3327
|
+
op: first.command,
|
|
3328
|
+
role: first.label,
|
|
3329
|
+
permission: first.permissionKey
|
|
3330
|
+
});
|
|
3331
|
+
return [policyOf(uniqueName(names, `${deny ? "deny_" : ""}${base}${audience === "anon" ? "_anon" : ""}`), {
|
|
3332
|
+
...first,
|
|
3333
|
+
roles: [audience]
|
|
3334
|
+
}, usings.length === 0 ? void 0 : orSql(usings), checks.length === 0 ? void 0 : orSql(checks))];
|
|
3335
|
+
}));
|
|
3336
|
+
}
|
|
3337
|
+
/**
|
|
3338
|
+
* Assembles policies. The default is one PERMISSIVE policy per table,
|
|
3339
|
+
* command and database role (Splinter `multiple_permissive_policies` stays
|
|
3340
|
+
* quiet), with denies in one RESTRICTIVE policy per table, command and role.
|
|
3341
|
+
*/
|
|
3342
|
+
function assemblePolicies(branches, shape) {
|
|
3343
|
+
const template = shape.name ?? (shape.perRole ? DEFAULT_PER_ROLE_NAME : DEFAULT_POLICY_NAME);
|
|
3344
|
+
assertPolicyName(template, shape.perRole);
|
|
3345
|
+
return shape.perRole ? perRolePolicies(branches, template) : collapsedPolicies(branches, template);
|
|
3346
|
+
}
|
|
3347
|
+
const DEFAULT_READ_ONLY_ACTORS = ["support", "impersonation"];
|
|
3348
|
+
const ACTOR_KIND = /^[a-z][a-z0-9_-]*$/u;
|
|
3349
|
+
/** The actor kinds `rls.readOnlyActors` names, or `undefined` when it is off. */
|
|
3350
|
+
function readOnlyActorKinds(setting) {
|
|
3351
|
+
if (setting === void 0 || setting === false) return;
|
|
3352
|
+
const kinds = setting === true ? DEFAULT_READ_ONLY_ACTORS : setting;
|
|
3353
|
+
if (kinds.length === 0 || kinds.some((kind) => !ACTOR_KIND.test(kind))) throw new Error("PermDock CLI: rls.readOnlyActors must be true or a non-empty list of actor kinds such as 'support'");
|
|
3354
|
+
return [...new Set(kinds)];
|
|
3355
|
+
}
|
|
3356
|
+
/**
|
|
3357
|
+
* One restrictive policy per table and write command the allow policies
|
|
3358
|
+
* grant to `authenticated`: the write passes unless the token's `act` claim
|
|
3359
|
+
* (`act`, as SQL `jsonb`) is a session of one of `kinds` that is not
|
|
3360
|
+
* `read_only: false`. Reads are never touched.
|
|
3361
|
+
*/
|
|
3362
|
+
function readOnlyActorPolicies(policies, act, kinds) {
|
|
3363
|
+
const list = `array[${kinds.map((kind) => `'${kind}'`).join(", ")}]::text[]`;
|
|
3364
|
+
const passes = `not coalesce(${kinds.includes("support") ? `(${act} ->> 'kind') = any(${list}) or ((${act} ->> 'kind') is null and ${act} ? 'session_id')` : `(${act} ->> 'kind') = any(${list})`}, false) or coalesce((${act} ->> 'read_only') = 'false', false)`;
|
|
3365
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3366
|
+
const out = [];
|
|
3367
|
+
for (const policy of policies) {
|
|
3368
|
+
const key = `${policy.table}\u0000${policy.command}`;
|
|
3369
|
+
if (policy.effect !== "allow" || policy.command === "select" || !policy.roles.includes("authenticated") || seen.has(key)) continue;
|
|
3370
|
+
seen.add(key);
|
|
3371
|
+
out.push({
|
|
3372
|
+
name: sanitize(`${policy.table}_${policy.command}_read_only_actors`),
|
|
3373
|
+
table: policy.table,
|
|
3374
|
+
command: policy.command,
|
|
3375
|
+
effect: "deny",
|
|
3376
|
+
roles: ["authenticated"],
|
|
3377
|
+
...policy.command === "insert" ? {} : { using: passes },
|
|
3378
|
+
...policy.command === "delete" ? {} : { check: passes }
|
|
3379
|
+
});
|
|
3380
|
+
}
|
|
3381
|
+
return out;
|
|
3382
|
+
}
|
|
3383
|
+
//#endregion
|
|
3384
|
+
//#region src/cli/rls-fields.ts
|
|
3385
|
+
/** Names of the field-view objects. Part of the SQL contract. */
|
|
3386
|
+
const FIELD_VIEWS = {
|
|
3387
|
+
/** `<table>_visible`: the `security_invoker` view clients read. */
|
|
3388
|
+
view: "_visible",
|
|
3389
|
+
/** `<table>_visible_fields`: the owner-rights companion `--revoke-columns` adds. */
|
|
3390
|
+
companion: "_visible_fields",
|
|
3391
|
+
/** The companion's row-key column, joined to the base table's key. */
|
|
3392
|
+
key: "permdock_key",
|
|
3393
|
+
/** Marks the companion so doctor PD022 knows it reads as its owner on purpose. */
|
|
3394
|
+
comment: "permdock:field-companion"
|
|
3395
|
+
};
|
|
3396
|
+
function covers(branch, column) {
|
|
3397
|
+
return branch.fields === void 0 || branch.fields.includes(column);
|
|
3398
|
+
}
|
|
3399
|
+
/**
|
|
3400
|
+
* A read grant that only shapes columns: a field-limited deny (it never
|
|
3401
|
+
* denies the row) or an allow with an empty list (it grants nothing). With
|
|
3402
|
+
* field views it leaves the row policy and lives in the view's masks.
|
|
3403
|
+
*/
|
|
3404
|
+
function fieldOnly(branch) {
|
|
3405
|
+
if (branch.command !== "select" || branch.fields === void 0) return false;
|
|
3406
|
+
return branch.effect === "deny" || branch.fields.length === 0;
|
|
3407
|
+
}
|
|
3408
|
+
/** Branches the row policies keep when field views compile. */
|
|
3409
|
+
function rowBranches(branches) {
|
|
3410
|
+
return branches.filter((branch) => !fieldOnly(branch));
|
|
3411
|
+
}
|
|
3412
|
+
/**
|
|
3413
|
+
* Whether the reader is signed in, for branches with no helper call: a grant
|
|
3414
|
+
* to any signed-in user relies on `to authenticated` in its policy, which a
|
|
3415
|
+
* view does not have.
|
|
3416
|
+
*/
|
|
3417
|
+
function audience(ctx, branch) {
|
|
3418
|
+
if (branch.access !== void 0 || branch.roles.length !== 1) return;
|
|
3419
|
+
if (branch.roles[0] === "authenticated") return signedIn(ctx);
|
|
3420
|
+
return branch.roles[0] === "anon" ? `not (${signedIn(ctx)})` : void 0;
|
|
3421
|
+
}
|
|
3422
|
+
function clause(ctx, branch) {
|
|
3423
|
+
return andSql(audience(ctx, branch), branchClauses(branch).using) ?? "true";
|
|
3424
|
+
}
|
|
3425
|
+
/**
|
|
3426
|
+
* `can(permission, row, { field })` in SQL: an allow that covers the column
|
|
3427
|
+
* holds for the row and no covering deny does. Every access term is the
|
|
3428
|
+
* uncorrelated helper call the row policy uses, so it runs once per statement.
|
|
3429
|
+
*/
|
|
3430
|
+
function maskSql(ctx, allows, denies, column) {
|
|
3431
|
+
const allowed = allows.filter((branch) => covers(branch, column)).map((branch) => clause(ctx, branch));
|
|
3432
|
+
if (allowed.length === 0) return "false";
|
|
3433
|
+
const denied = denies.filter((branch) => covers(branch, column)).map((branch) => clause(ctx, branch));
|
|
3434
|
+
const allow = orSql(allowed);
|
|
3435
|
+
if (denied.length === 0) return allow;
|
|
3436
|
+
const deny = orSql(denied);
|
|
3437
|
+
return allow === "true" ? `not ${wrapSql(deny)}` : `${wrapSql(allow)} and not ${wrapSql(deny)}`;
|
|
3438
|
+
}
|
|
3439
|
+
function schemaColumns(policy, resource) {
|
|
3440
|
+
const node = policy.resources.get(resource);
|
|
3441
|
+
const schema = node === void 0 ? null : jsonSchemaOf(node);
|
|
3442
|
+
const properties = schema !== null && typeof schema === "object" ? schema.properties : void 0;
|
|
3443
|
+
return properties !== null && typeof properties === "object" ? Object.keys(properties) : void 0;
|
|
3444
|
+
}
|
|
3445
|
+
function viewName(table, suffix) {
|
|
3446
|
+
return `${table}${suffix}`;
|
|
3447
|
+
}
|
|
3448
|
+
/**
|
|
3449
|
+
* `<helper schema>.<table>_visible_fields`: outside the Data API's schemas,
|
|
3450
|
+
* so its owner rights reach clients only through the `_visible` join.
|
|
3451
|
+
*/
|
|
3452
|
+
function companionName(ctx, table) {
|
|
3453
|
+
const base = table.startsWith("public.") ? table.slice(7) : table.replaceAll(".", "_");
|
|
3454
|
+
return `${helperSchema(ctx)}.${viewName(base, FIELD_VIEWS.companion)}`;
|
|
3455
|
+
}
|
|
3456
|
+
/**
|
|
3457
|
+
* One view per table whose read grants limit fields. A column is restricted
|
|
3458
|
+
* when some read allow lists fields without it or some read deny lists it;
|
|
3459
|
+
* every other column, and the row key, passes through unchanged.
|
|
3460
|
+
*/
|
|
3461
|
+
function fieldViews(policy, ctx, branches, options, warnings) {
|
|
3462
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
3463
|
+
for (const branch of branches) {
|
|
3464
|
+
if (branch.command !== "select" || branch.coverage === true) continue;
|
|
3465
|
+
byTable.set(branch.table, [...byTable.get(branch.table) ?? [], branch]);
|
|
3466
|
+
}
|
|
3467
|
+
const views = [];
|
|
3468
|
+
for (const [table, reads] of byTable) {
|
|
3469
|
+
const allows = reads.filter((branch) => branch.effect === "allow" && branch.fields?.length !== 0);
|
|
3470
|
+
const denies = reads.filter((branch) => branch.effect === "deny");
|
|
3471
|
+
const limited = reads.some((branch) => branch.fields !== void 0);
|
|
3472
|
+
const resource = reads.find((branch) => branch.resource !== void 0)?.resource;
|
|
3473
|
+
if (!limited || allows.length === 0 || resource === void 0) continue;
|
|
3474
|
+
const names = schemaColumns(policy, resource);
|
|
3475
|
+
if (names === void 0) throw new Error(`PermDock CLI: --fields views needs the ${resource} schema's JSON Schema (Standard JSON Schema) to list the columns of ${table}`);
|
|
3476
|
+
const key = policy.resources.get(resource)?.id ?? "id";
|
|
3477
|
+
const restricted = (column) => column !== key && (allows.some((branch) => branch.fields !== void 0 && !branch.fields.includes(column)) || denies.some((branch) => branch.fields?.includes(column) === true));
|
|
3478
|
+
const columns = names.map((name) => {
|
|
3479
|
+
quoteIdent(name);
|
|
3480
|
+
return restricted(name) ? {
|
|
3481
|
+
name,
|
|
3482
|
+
mask: maskSql(ctx, allows, denies, name)
|
|
3483
|
+
} : { name };
|
|
3484
|
+
});
|
|
3485
|
+
if (columns.every((column) => column.mask === void 0)) continue;
|
|
3486
|
+
const view = viewName(table, FIELD_VIEWS.view);
|
|
3487
|
+
if (allows.some((branch) => branch.fields !== void 0 && !branch.fields.includes(key))) warnings.push(`field view ${view}: the key ${key} passes through, although a read grant on ${resource} omits it`);
|
|
3488
|
+
const hidden = columns.filter((column) => column.mask !== void 0).map((column) => column.name);
|
|
3489
|
+
if (options.helpersOnly === true) warnings.push(`field view ${view}: ${table} still returns ${hidden.join(", ")} to direct reads; revoke select on those columns from client roles in your own grants, or read them through a function`);
|
|
3490
|
+
else if (!options.revokeColumns) warnings.push(`field view ${view}: ${table} still returns ${hidden.join(", ")} to direct reads; add --revoke-columns so clients read through the view`);
|
|
3491
|
+
views.push({
|
|
3492
|
+
resource,
|
|
3493
|
+
table,
|
|
3494
|
+
view,
|
|
3495
|
+
key,
|
|
3496
|
+
columns,
|
|
3497
|
+
roles: reads.some((branch) => branch.roles.includes("anon")) ? ["anon", "authenticated"] : ["authenticated"],
|
|
3498
|
+
...options.revokeColumns ? { companion: companionName(ctx, table) } : {}
|
|
3499
|
+
});
|
|
3500
|
+
}
|
|
3501
|
+
return views;
|
|
3502
|
+
}
|
|
3503
|
+
/** Columns of `view`'s table that `anon` and `authenticated` keep on the base table with `--revoke-columns`. */
|
|
3504
|
+
function readableColumns(view) {
|
|
3505
|
+
return view.columns.filter((column) => column.mask === void 0).map((column) => column.name);
|
|
3506
|
+
}
|
|
3507
|
+
function restrictedColumns(view) {
|
|
3508
|
+
return view.columns.filter((column) => column.mask !== void 0).map((column) => column.name);
|
|
3509
|
+
}
|
|
3510
|
+
function columnList(names) {
|
|
3511
|
+
return names.map(quoteIdent).join(", ");
|
|
3512
|
+
}
|
|
3513
|
+
function selectList(lines) {
|
|
3514
|
+
return lines.map((line) => ` ${line}`).join(",\n");
|
|
3515
|
+
}
|
|
3516
|
+
function grantView(name, roles) {
|
|
3517
|
+
return [`revoke all on table ${quoteTable(qualifiedTable(name))} from anon, authenticated, public;`, `grant select on table ${quoteTable(qualifiedTable(name))} to ${roles.join(", ")};`].join("\n");
|
|
3518
|
+
}
|
|
3519
|
+
function inlineViewSql(view) {
|
|
3520
|
+
const targets = view.columns.map((column) => column.mask === void 0 ? quoteIdent(column.name) : `case when ${column.mask} then ${quoteIdent(column.name)} end as ${quoteIdent(column.name)}`);
|
|
3521
|
+
return `create or replace view ${quoteTable(qualifiedTable(view.view))} with (security_invoker = true) as
|
|
3522
|
+
select
|
|
3523
|
+
${selectList(targets)}
|
|
3524
|
+
from ${quoteTable(qualifiedTable(view.table))};`;
|
|
3525
|
+
}
|
|
3526
|
+
function companionSql(view, companion) {
|
|
3527
|
+
const restricted = view.columns.filter((column) => column.mask !== void 0);
|
|
3528
|
+
const targets = [`${quoteIdent(view.key)} as ${quoteIdent(FIELD_VIEWS.key)}`, ...restricted.map((column) => `case when ${column.mask} then ${quoteIdent(column.name)} end as ${quoteIdent(column.name)}`)];
|
|
3529
|
+
const any = orSql(restricted.map((column) => column.mask ?? "false"));
|
|
3530
|
+
const name = companion.slice(0, companion.indexOf("."));
|
|
3531
|
+
const schema = quoteIdent(name);
|
|
3532
|
+
return `${name === "public" ? "" : `create schema if not exists ${schema};
|
|
3533
|
+
revoke all on schema ${schema} from public;
|
|
3534
|
+
grant usage on schema ${schema} to ${view.roles.join(", ")};
|
|
3535
|
+
`}create or replace view ${quoteTable(qualifiedTable(companion))} with (security_barrier = true) as
|
|
3536
|
+
select
|
|
3537
|
+
${selectList(targets)}
|
|
3538
|
+
from ${quoteTable(qualifiedTable(view.table))}
|
|
3539
|
+
where ${any};
|
|
3540
|
+
comment on view ${quoteTable(qualifiedTable(companion))} is ${quoteLiteral(`${FIELD_VIEWS.comment} ${view.view}`)};`;
|
|
3541
|
+
}
|
|
3542
|
+
function joinedViewSql(view, companion) {
|
|
3543
|
+
const targets = view.columns.map((column) => column.mask === void 0 ? `t.${quoteIdent(column.name)}` : `f.${quoteIdent(column.name)}`);
|
|
3544
|
+
return `create or replace view ${quoteTable(qualifiedTable(view.view))} with (security_invoker = true) as
|
|
3545
|
+
select
|
|
3546
|
+
${selectList(targets)}
|
|
3547
|
+
from ${quoteTable(qualifiedTable(view.table))} t
|
|
3548
|
+
left join ${quoteTable(qualifiedTable(companion))} f on f.${quoteIdent(FIELD_VIEWS.key)} = t.${quoteIdent(view.key)};`;
|
|
3549
|
+
}
|
|
3550
|
+
/** `select` on only the unrestricted columns (and the key) of the base table, for `--revoke-columns`. */
|
|
3551
|
+
function columnGrantSql(view, role) {
|
|
3552
|
+
return `grant select (${columnList(readableColumns(view))}) on table ${quoteTable(qualifiedTable(view.table))} to ${role};`;
|
|
3553
|
+
}
|
|
3554
|
+
function columnRevokeSql(view) {
|
|
3555
|
+
return `revoke select (${columnList(restrictedColumns(view))}) on table ${quoteTable(qualifiedTable(view.table))} from anon, authenticated;`;
|
|
3556
|
+
}
|
|
3557
|
+
/**
|
|
3558
|
+
* The view SQL: the `security_invoker` view, and with `--revoke-columns` the
|
|
3559
|
+
* owner-rights companion it joins for the restricted columns. The companion
|
|
3560
|
+
* masks every column with the full field decision and keeps only rows where
|
|
3561
|
+
* one is readable, so reading it directly shows nothing `pick` would not.
|
|
3562
|
+
*/
|
|
3563
|
+
function fieldViewsSql(views) {
|
|
3564
|
+
return views.map((view) => {
|
|
3565
|
+
const head = `-- field view over ${view.table}: ${restrictedColumns(view).join(", ")} read as null unless a read grant covers the column for the row`;
|
|
3566
|
+
if (view.companion === void 0) return [
|
|
3567
|
+
head,
|
|
3568
|
+
inlineViewSql(view),
|
|
3569
|
+
grantView(view.view, view.roles)
|
|
3570
|
+
].join("\n");
|
|
3571
|
+
return [
|
|
3572
|
+
head,
|
|
3573
|
+
`-- ${view.companion} reads ${view.table} as its owner; ${view.view} reads the rows as the caller and joins it`,
|
|
3574
|
+
companionSql(view, view.companion),
|
|
3575
|
+
grantView(view.companion, view.roles),
|
|
3576
|
+
joinedViewSql(view, view.companion),
|
|
3577
|
+
grantView(view.view, view.roles)
|
|
3578
|
+
].join("\n");
|
|
3579
|
+
}).join("\n\n");
|
|
3580
|
+
}
|
|
3581
|
+
//#endregion
|
|
3582
|
+
export { helpersSql as A, breakGlassHolder as B, readTableIndexFacts as C, claimKeptLines as D, accessSql as E, seedSql as F, API_KEY_ALLOWS as G, collectGrants as H, sourceFilters as I, apiKeyFields as K, sourcesKeep as L, membershipRows as M, membershipTable as N, disabledColumn as O, roleRows as P, hasCustomRoleChecksFor as R, pruneIndexTargets as S, connectPgPool as T, roleNames as U, breakGlassKey as V, contextRefs as W, permissionKeysForHelper as _, fieldViewsSql as a, indexTargets as b, assemblePolicies as c, readOnlyActorPolicies as d, branchClauses as f, permissionHelpersSql as g, tableFor as h, fieldViews as i, memberColumn as j, globalRoleRows as k, orSql as l, compileGrants as m, columnGrantSql as n, rowBranches as o, commandFor as p, apiKeysPlan as q, columnRevokeSql as r, viewName as s, FIELD_VIEWS as t, readOnlyActorKinds as u, permittedByPermissionHelper as v, connectPg as w, indexesSql as x, COLUMNS_SQL as y, globalRoleSource as z };
|