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,2521 @@
|
|
|
1
|
+
import { t as compact } from "./compact-y6vfu2_f.js";
|
|
2
|
+
import { _ as listPermissions, y as renamedKeys } from "./permissions-DVoF6dMt.js";
|
|
3
|
+
import { d as scopeList, o as resolveScope } from "./scopes-CVdPrWN-.js";
|
|
4
|
+
import { E as listRoles, s as flattenGrantee } from "./grantee-Cv5Sogfv.js";
|
|
5
|
+
import { h as scopeField } from "./validity-uOp8UpYn.js";
|
|
6
|
+
import { i as describeError, o as usageResult } from "./errors-CWfoNg9N.js";
|
|
7
|
+
import { t as policyRowConditionKeys } from "./row-conditions-h2HythXQ.js";
|
|
8
|
+
import { a as loadModule, n as asPolicy, o as pickNamed } from "./load-DhWEB9P1.js";
|
|
9
|
+
import "./budget-NbEtbJC_.js";
|
|
10
|
+
import { n as escapeSqlIdent } from "./sql-xdg5f4Fi.js";
|
|
11
|
+
import "./sources-4p0-4xQm.js";
|
|
12
|
+
import { i as SEEDS_MARKER, r as INDEXES_MARKER, t as GRANTS_MARKER } from "./markers-B0hYLvMD.js";
|
|
13
|
+
import { n as requirePeer } from "./peer-BOr_kJE1.js";
|
|
14
|
+
import { f as splitUndiffed, n as supabaseConfig } from "./supabase-config-BpPXt3x1.js";
|
|
15
|
+
import { $ as sessionLiveHelperSql, G as quoteIdent$1, K as quoteLiteral, Q as scopeTypeOf, R as memberIdsHelper, T as graphSqlName, U as permittedIdsHelper, V as parseMembershipsFlag, W as qualifiedTable$1, X as scopeSources, Z as scopeTable, f as SESSION_LIVE_HELPER, o as qualified$1, q as quoteTable, r as HELPERS, rt as subjectIdSql, tt as subjectClaimJsonSql, w as graphHelper, y as checkSuspension } from "./rls-shared-Cewd_-zI.js";
|
|
16
|
+
import { A as helpersSql, B as breakGlassHolder, C as readTableIndexFacts, E as accessSql, F as seedSql, H as collectGrants, S as pruneIndexTargets, T as connectPgPool, U as roleNames, V as breakGlassKey, a as fieldViewsSql, b as indexTargets, c as assemblePolicies, d as readOnlyActorPolicies, f as branchClauses, g as permissionHelpersSql, h as tableFor, i as fieldViews, l as orSql, m as compileGrants, n as columnGrantSql, o as rowBranches, q as apiKeysPlan, r as columnRevokeSql, u as readOnlyActorKinds, v as permittedByPermissionHelper, x as indexesSql, y as COLUMNS_SQL } from "./rls-fields-74oZPXWL.js";
|
|
17
|
+
import { c as helperCallKeys, i as HELPER_TABLES, n as graphPlan, r as graphSql, s as callsHelper, t as closureDepths } from "./rls-graph-CHwx0V8q.js";
|
|
18
|
+
import { a as ownershipRules, o as ownershipSql, r as assignmentSql } from "./rls-ownership-RuFqaqGz.js";
|
|
19
|
+
import { _ as seedsMigration, d as resolveAuthorize, f as driftOf, g as pgDeltaPath, h as partPath, l as parseRbacAuthorize, m as parseSplit, o as supabaseHookSql, p as isSeedsDirectory, t as grantsLabel, u as rbacScaffold, v as writeSqlFiles } from "./supabase-hook-CSLQ4zJ3.js";
|
|
20
|
+
import { n as runRlsVerify } from "./rls-verify-CVQTHTh5.js";
|
|
21
|
+
import { t as supautilsRejections } from "./supautils-Cn4R4cJ4.js";
|
|
22
|
+
import { basename, delimiter, join, resolve } from "node:path";
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
//#region src/cli/rls-advisors.ts
|
|
25
|
+
/** The PermDock doctor check that reports the same problem in migrations. */
|
|
26
|
+
const ADVISOR_CODES = {
|
|
27
|
+
function_search_path_mutable: "PD048",
|
|
28
|
+
auth_rls_initplan: "PD051",
|
|
29
|
+
rls_disabled_in_public: "PD050",
|
|
30
|
+
security_definer_view: "PD022",
|
|
31
|
+
anon_security_definer_function_executable: "PD049",
|
|
32
|
+
authenticated_security_definer_function_executable: "PD046"
|
|
33
|
+
};
|
|
34
|
+
const defaultExec = (command, args, options) => {
|
|
35
|
+
const result = spawnSync(command, [...args], {
|
|
36
|
+
cwd: options.cwd,
|
|
37
|
+
env: options.env,
|
|
38
|
+
encoding: "utf8"
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
status: result.status,
|
|
42
|
+
stdout: result.stdout ?? "",
|
|
43
|
+
stderr: result.stderr ?? "",
|
|
44
|
+
...result.error === void 0 ? {} : { error: result.error }
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
function text(value, key) {
|
|
48
|
+
const field = typeof value === "object" && value !== null ? Reflect.get(value, key) : void 0;
|
|
49
|
+
return typeof field === "string" ? field : "";
|
|
50
|
+
}
|
|
51
|
+
function findingsOf(value) {
|
|
52
|
+
const results = typeof value === "object" && value !== null ? Reflect.get(value, "results") : void 0;
|
|
53
|
+
if (!Array.isArray(results)) return;
|
|
54
|
+
return results.map((row) => {
|
|
55
|
+
const name = text(row, "name");
|
|
56
|
+
const finding = {
|
|
57
|
+
name,
|
|
58
|
+
level: text(row, "level"),
|
|
59
|
+
detail: text(row, "detail"),
|
|
60
|
+
remediation: text(row, "remediation"),
|
|
61
|
+
cacheKey: text(row, "cacheKey")
|
|
62
|
+
};
|
|
63
|
+
const code = ADVISOR_CODES[name];
|
|
64
|
+
return code === void 0 ? finding : Object.assign(finding, { code });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const INSTALL = "rls verify --advisors runs the Supabase CLI: install it with npm install --save-dev supabase (or brew install supabase/tap/supabase)";
|
|
68
|
+
/**
|
|
69
|
+
* `supabase db advisors --type security` against `--db` or the local stack.
|
|
70
|
+
* Exit 1 on a WARN or ERROR row, 2 when the CLI is missing or fails.
|
|
71
|
+
*/
|
|
72
|
+
function runRlsAdvisors(input) {
|
|
73
|
+
const exec = input.exec ?? defaultExec;
|
|
74
|
+
const bin = join(input.cwd, "node_modules", ".bin");
|
|
75
|
+
const path = input.env["PATH"];
|
|
76
|
+
const result = exec("supabase", [
|
|
77
|
+
"db",
|
|
78
|
+
"advisors",
|
|
79
|
+
...input.db === void 0 ? ["--local"] : ["--db-url", input.db],
|
|
80
|
+
"--type",
|
|
81
|
+
"security",
|
|
82
|
+
"--output-format",
|
|
83
|
+
"json"
|
|
84
|
+
], {
|
|
85
|
+
cwd: input.cwd,
|
|
86
|
+
env: {
|
|
87
|
+
...input.env,
|
|
88
|
+
PATH: path === void 0 ? bin : `${bin}${delimiter}${path}`
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
if (result.error !== void 0) return {
|
|
92
|
+
code: 2,
|
|
93
|
+
output: INSTALL
|
|
94
|
+
};
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(result.stdout);
|
|
98
|
+
} catch {
|
|
99
|
+
parsed = void 0;
|
|
100
|
+
}
|
|
101
|
+
const findings = findingsOf(parsed);
|
|
102
|
+
if (findings === void 0) {
|
|
103
|
+
const error = typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "error") : void 0;
|
|
104
|
+
const message = [text(error, "message"), text(error, "suggestion")].filter((part) => part !== "").join(" ");
|
|
105
|
+
return {
|
|
106
|
+
code: 2,
|
|
107
|
+
output: `supabase db advisors failed: ${message === "" ? result.stderr.trim() || `exit ${String(result.status)}` : message}`
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const failing = findings.filter((finding) => finding.level === "WARN" || finding.level === "ERROR");
|
|
111
|
+
const code = failing.length > 0 ? 1 : 0;
|
|
112
|
+
if (input.json) return {
|
|
113
|
+
code,
|
|
114
|
+
output: JSON.stringify({
|
|
115
|
+
findings,
|
|
116
|
+
errors: findings.filter((finding) => finding.level === "ERROR").length,
|
|
117
|
+
warnings: findings.filter((finding) => finding.level === "WARN").length
|
|
118
|
+
}, null, 2)
|
|
119
|
+
};
|
|
120
|
+
if (findings.length === 0) return {
|
|
121
|
+
code,
|
|
122
|
+
output: "supabase db advisors: no security findings"
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
code,
|
|
126
|
+
output: `${findings.map((finding) => `${finding.level} ${finding.name}${finding.code === void 0 ? "" : ` (${finding.code})`}: ${finding.detail}\n ${finding.remediation}`).join("\n")}\n\n${String(failing.length)} finding(s) at WARN or above`
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/cli/approval-schema.ts
|
|
131
|
+
/** An RFC 3339 instant as `Temporal.Instant` prints it; pg_jsonschema asserts no `format`. */
|
|
132
|
+
const INSTANT = String.raw`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$`;
|
|
133
|
+
const STRINGS = {
|
|
134
|
+
type: "array",
|
|
135
|
+
items: { type: "string" }
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* `schemas/approval-request-v1.json`: the `ApprovalRequest` an approval store
|
|
139
|
+
* holds. Self-contained (`$defs`, no remote `$ref`) so `rls.jsonSchema` can
|
|
140
|
+
* inline it into a pg_jsonschema check constraint.
|
|
141
|
+
*/
|
|
142
|
+
const APPROVAL_REQUEST_SCHEMA = {
|
|
143
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
144
|
+
$id: "https://permdock.com/schemas/approval-request-v1.json",
|
|
145
|
+
title: "PermDock approval request",
|
|
146
|
+
description: "An ApprovalRequest as an ApprovalStore holds it, wire format v1. Unknown properties are allowed so a v1 minor release can add one.",
|
|
147
|
+
type: "object",
|
|
148
|
+
required: [
|
|
149
|
+
"v",
|
|
150
|
+
"token",
|
|
151
|
+
"permission",
|
|
152
|
+
"scope",
|
|
153
|
+
"resource",
|
|
154
|
+
"subject",
|
|
155
|
+
"detail",
|
|
156
|
+
"createdAt",
|
|
157
|
+
"expiresAt",
|
|
158
|
+
"status"
|
|
159
|
+
],
|
|
160
|
+
properties: {
|
|
161
|
+
v: { const: 1 },
|
|
162
|
+
token: {
|
|
163
|
+
type: "string",
|
|
164
|
+
minLength: 1
|
|
165
|
+
},
|
|
166
|
+
permission: {
|
|
167
|
+
type: "string",
|
|
168
|
+
minLength: 1
|
|
169
|
+
},
|
|
170
|
+
scope: { type: "string" },
|
|
171
|
+
resource: {
|
|
172
|
+
type: "object",
|
|
173
|
+
required: ["type"],
|
|
174
|
+
properties: {
|
|
175
|
+
type: { type: "string" },
|
|
176
|
+
id: { type: "string" }
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
subject: { $ref: "#/$defs/subject" },
|
|
180
|
+
membership: {
|
|
181
|
+
type: "object",
|
|
182
|
+
required: ["roles"],
|
|
183
|
+
properties: { roles: STRINGS }
|
|
184
|
+
},
|
|
185
|
+
approvers: { type: "object" },
|
|
186
|
+
detail: { type: "string" },
|
|
187
|
+
adapter: { type: "string" },
|
|
188
|
+
createdAt: { $ref: "#/$defs/instant" },
|
|
189
|
+
expiresAt: { $ref: "#/$defs/instant" },
|
|
190
|
+
status: { enum: [
|
|
191
|
+
"pending",
|
|
192
|
+
"approved",
|
|
193
|
+
"rejected",
|
|
194
|
+
"expired"
|
|
195
|
+
] },
|
|
196
|
+
approvals: {
|
|
197
|
+
type: "array",
|
|
198
|
+
items: { $ref: "#/$defs/signature" }
|
|
199
|
+
},
|
|
200
|
+
resolvedAt: { $ref: "#/$defs/instant" },
|
|
201
|
+
resolvedBy: { type: "string" },
|
|
202
|
+
vouched: { type: "string" },
|
|
203
|
+
note: { type: "string" },
|
|
204
|
+
consumedAt: { $ref: "#/$defs/instant" }
|
|
205
|
+
},
|
|
206
|
+
$defs: {
|
|
207
|
+
instant: {
|
|
208
|
+
type: "string",
|
|
209
|
+
pattern: INSTANT
|
|
210
|
+
},
|
|
211
|
+
subject: {
|
|
212
|
+
type: "object",
|
|
213
|
+
required: ["principal"],
|
|
214
|
+
properties: {
|
|
215
|
+
principal: { anyOf: [{ type: "null" }, {
|
|
216
|
+
type: "object",
|
|
217
|
+
required: ["id", "roles"],
|
|
218
|
+
properties: {
|
|
219
|
+
id: { type: "string" },
|
|
220
|
+
roles: STRINGS,
|
|
221
|
+
tenant: { type: "string" }
|
|
222
|
+
}
|
|
223
|
+
}] },
|
|
224
|
+
actor: {
|
|
225
|
+
type: "object",
|
|
226
|
+
required: ["id", "kind"],
|
|
227
|
+
properties: {
|
|
228
|
+
id: { type: "string" },
|
|
229
|
+
kind: { type: "string" }
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
session: { type: "string" },
|
|
233
|
+
delegation: { type: "object" }
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
signature: {
|
|
237
|
+
type: "object",
|
|
238
|
+
required: ["by", "at"],
|
|
239
|
+
properties: {
|
|
240
|
+
by: { type: "string" },
|
|
241
|
+
at: { $ref: "#/$defs/instant" },
|
|
242
|
+
stage: {
|
|
243
|
+
type: "integer",
|
|
244
|
+
minimum: 0
|
|
245
|
+
},
|
|
246
|
+
vouched: { type: "string" }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/cli/rls-approvals.ts
|
|
253
|
+
/** The objects `rls.approvals` adds. Names are part of the SQL contract `supabaseApprovalStore` calls. */
|
|
254
|
+
const APPROVAL_STORE = {
|
|
255
|
+
table: "approval_requests",
|
|
256
|
+
open: "permdock_approval_open",
|
|
257
|
+
get: "permdock_approval_get",
|
|
258
|
+
resolve: "permdock_approval_resolve",
|
|
259
|
+
consume: "permdock_approval_consume",
|
|
260
|
+
list: "permdock_approval_list",
|
|
261
|
+
expire: "permdock_approval_expire",
|
|
262
|
+
cancel: "permdock_approval_cancel"
|
|
263
|
+
};
|
|
264
|
+
/** `body` as the row's filter columns hold it. */
|
|
265
|
+
const COLUMNS = `p_request ->> 'token',
|
|
266
|
+
p_request,
|
|
267
|
+
p_request ->> 'status',
|
|
268
|
+
p_request #>> '{subject,principal,tenant}',
|
|
269
|
+
p_request #>> '{subject,principal,id}',
|
|
270
|
+
p_request #>> '{subject,actor,id}',
|
|
271
|
+
p_request #>> '{subject,session}',
|
|
272
|
+
coalesce(jsonb_array_length(p_request -> 'approvals'), 0),
|
|
273
|
+
p_request ->> 'createdAt',
|
|
274
|
+
(p_request ->> 'expiresAt')::timestamptz,
|
|
275
|
+
(p_request ->> 'consumedAt')::timestamptz`;
|
|
276
|
+
/** The `ApprovalListFilter` in `p_filter`, over the row aliased `a`. */
|
|
277
|
+
const FILTER = `(p_filter ->> 'status' is null or a.status = p_filter ->> 'status')
|
|
278
|
+
and (p_filter ->> 'tenant' is null or a.tenant = p_filter ->> 'tenant')
|
|
279
|
+
and (p_filter ->> 'principalId' is null or a.principal_id = p_filter ->> 'principalId')
|
|
280
|
+
and (p_filter ->> 'actorId' is null or a.actor_id = p_filter ->> 'actorId')
|
|
281
|
+
and (p_filter ->> 'session' is null or a.session = p_filter ->> 'session')`;
|
|
282
|
+
function serverOnly(fn, args) {
|
|
283
|
+
return `revoke execute on function ${fn}(${args}) from public, anon, authenticated;`;
|
|
284
|
+
}
|
|
285
|
+
const BODY_CHECK = `${APPROVAL_STORE.table}_body_schema`;
|
|
286
|
+
/**
|
|
287
|
+
* `rls.jsonSchema`: a pg_jsonschema check that `body` matches
|
|
288
|
+
* `approval-request-v1.json`, added `not valid` and then validated. `auto`
|
|
289
|
+
* adds it only where the extension is available.
|
|
290
|
+
*/
|
|
291
|
+
function bodySchemaSql(table, mode, body = "body", check = BODY_CHECK) {
|
|
292
|
+
const statements = [
|
|
293
|
+
"create schema if not exists extensions;",
|
|
294
|
+
"create extension if not exists pg_jsonschema with schema extensions;",
|
|
295
|
+
`alter table ${table} drop constraint if exists "${check}";`,
|
|
296
|
+
`alter table ${table} add constraint "${check}" check (extensions.jsonb_matches_schema(${quoteLiteral(JSON.stringify(APPROVAL_REQUEST_SCHEMA))}::json, ${body})) not valid;`,
|
|
297
|
+
`alter table ${table} validate constraint "${check}";`
|
|
298
|
+
];
|
|
299
|
+
const comment = "-- approval store: body matches schemas/approval-request-v1.json through pg_jsonschema";
|
|
300
|
+
if (mode === true) return `\n${comment}\n${statements.join("\n")}\n`;
|
|
301
|
+
return `\n${comment}, where the extension is available
|
|
302
|
+
do $permdock$
|
|
303
|
+
begin
|
|
304
|
+
if exists (select 1 from pg_available_extensions where name = 'pg_jsonschema') then
|
|
305
|
+
${statements.map((statement) => ` ${statement}`).join("\n")}
|
|
306
|
+
end if;
|
|
307
|
+
end
|
|
308
|
+
$permdock$;\n`;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The approval store (`rls.approvals`): one table of `ApprovalRequest`
|
|
312
|
+
* bodies with the columns a list filters on, and one `security definer`
|
|
313
|
+
* function per `ApprovalStore` method, each one atomic statement. No client
|
|
314
|
+
* role may read the table or execute the functions: approvals are opened and
|
|
315
|
+
* resolved by the server, which grants its own role.
|
|
316
|
+
*/
|
|
317
|
+
function approvalStoreSql(ctx, jsonSchema = false) {
|
|
318
|
+
const table = qualified$1(ctx, APPROVAL_STORE.table);
|
|
319
|
+
const fn = (name) => qualified$1(ctx, name);
|
|
320
|
+
const index = (name) => `"${APPROVAL_STORE.table}_${name}"`;
|
|
321
|
+
return `-- approval store: ApprovalRequest bodies, opened, resolved and consumed through the functions below; supabaseApprovalStore calls them
|
|
322
|
+
create table if not exists ${table} (
|
|
323
|
+
token text primary key,
|
|
324
|
+
body jsonb not null,
|
|
325
|
+
status text not null check (status in ('pending', 'approved', 'rejected', 'expired')),
|
|
326
|
+
tenant text,
|
|
327
|
+
principal_id text,
|
|
328
|
+
actor_id text,
|
|
329
|
+
session text,
|
|
330
|
+
approvals integer not null default 0,
|
|
331
|
+
created_at text not null,
|
|
332
|
+
expires_at timestamptz not null,
|
|
333
|
+
consumed_at timestamptz
|
|
334
|
+
);
|
|
335
|
+
create index if not exists ${index("page")} on ${table} (created_at collate "C", token collate "C");
|
|
336
|
+
create index if not exists ${index("pending")} on ${table} (status, expires_at);
|
|
337
|
+
alter table ${table} enable row level security;
|
|
338
|
+
revoke all on table ${table} from anon, authenticated, public;
|
|
339
|
+
${jsonSchema === false ? "" : bodySchemaSql(table, jsonSchema)}
|
|
340
|
+
-- open a request; a repeated ask keeps the open record and replaces only an expired one
|
|
341
|
+
create or replace function ${fn(APPROVAL_STORE.open)}(p_request jsonb)
|
|
342
|
+
returns void
|
|
343
|
+
language sql
|
|
344
|
+
volatile
|
|
345
|
+
security definer
|
|
346
|
+
set search_path = ''
|
|
347
|
+
as $$
|
|
348
|
+
insert into ${table} as a (token, body, status, tenant, principal_id, actor_id, session, approvals, created_at, expires_at, consumed_at)
|
|
349
|
+
values (
|
|
350
|
+
${COLUMNS}
|
|
351
|
+
)
|
|
352
|
+
on conflict (token) do update
|
|
353
|
+
set body = excluded.body, status = excluded.status, tenant = excluded.tenant,
|
|
354
|
+
principal_id = excluded.principal_id, actor_id = excluded.actor_id, session = excluded.session,
|
|
355
|
+
approvals = excluded.approvals, created_at = excluded.created_at,
|
|
356
|
+
expires_at = excluded.expires_at, consumed_at = excluded.consumed_at
|
|
357
|
+
where a.status = 'expired' or a.expires_at <= now()
|
|
358
|
+
$$;
|
|
359
|
+
${serverOnly(fn(APPROVAL_STORE.open), "jsonb")}
|
|
360
|
+
|
|
361
|
+
create or replace function ${fn(APPROVAL_STORE.get)}(p_token text)
|
|
362
|
+
returns jsonb
|
|
363
|
+
language sql
|
|
364
|
+
stable
|
|
365
|
+
security definer
|
|
366
|
+
set search_path = ''
|
|
367
|
+
as $$
|
|
368
|
+
select a.body from ${table} a where a.token = p_token
|
|
369
|
+
$$;
|
|
370
|
+
${serverOnly(fn(APPROVAL_STORE.get), "text")}
|
|
371
|
+
|
|
372
|
+
-- write a verdict over the record it was computed from: still pending, unexpired, with p_seen approvals
|
|
373
|
+
create or replace function ${fn(APPROVAL_STORE.resolve)}(p_token text, p_next jsonb, p_seen integer)
|
|
374
|
+
returns jsonb
|
|
375
|
+
language sql
|
|
376
|
+
volatile
|
|
377
|
+
security definer
|
|
378
|
+
set search_path = ''
|
|
379
|
+
as $$
|
|
380
|
+
update ${table} a
|
|
381
|
+
set body = p_next,
|
|
382
|
+
status = p_next ->> 'status',
|
|
383
|
+
approvals = coalesce(jsonb_array_length(p_next -> 'approvals'), 0)
|
|
384
|
+
where a.token = p_token
|
|
385
|
+
and a.status = 'pending'
|
|
386
|
+
and a.expires_at > now()
|
|
387
|
+
and a.approvals = p_seen
|
|
388
|
+
returning a.body
|
|
389
|
+
$$;
|
|
390
|
+
${serverOnly(fn(APPROVAL_STORE.resolve), "text, jsonb, integer")}
|
|
391
|
+
|
|
392
|
+
-- consume an approved, unexpired, unconsumed request once
|
|
393
|
+
create or replace function ${fn(APPROVAL_STORE.consume)}(p_token text, p_now text)
|
|
394
|
+
returns jsonb
|
|
395
|
+
language sql
|
|
396
|
+
volatile
|
|
397
|
+
security definer
|
|
398
|
+
set search_path = ''
|
|
399
|
+
as $$
|
|
400
|
+
update ${table} a
|
|
401
|
+
set consumed_at = p_now::timestamptz,
|
|
402
|
+
body = a.body || jsonb_build_object('consumedAt', p_now)
|
|
403
|
+
where a.token = p_token
|
|
404
|
+
and a.status = 'approved'
|
|
405
|
+
and a.consumed_at is null
|
|
406
|
+
and a.expires_at > p_now::timestamptz
|
|
407
|
+
returning a.body
|
|
408
|
+
$$;
|
|
409
|
+
${serverOnly(fn(APPROVAL_STORE.consume), "text, text")}
|
|
410
|
+
|
|
411
|
+
-- one page in (createdAt, token) order after the cursor position
|
|
412
|
+
create or replace function ${fn(APPROVAL_STORE.list)}(p_filter jsonb, p_after_created text, p_after_token text, p_limit integer)
|
|
413
|
+
returns setof jsonb
|
|
414
|
+
language sql
|
|
415
|
+
stable
|
|
416
|
+
security definer
|
|
417
|
+
set search_path = ''
|
|
418
|
+
as $$
|
|
419
|
+
select a.body
|
|
420
|
+
from ${table} a
|
|
421
|
+
where ${FILTER}
|
|
422
|
+
and (p_after_created is null
|
|
423
|
+
or (a.created_at collate "C", a.token collate "C") > (p_after_created collate "C", p_after_token collate "C"))
|
|
424
|
+
order by a.created_at collate "C", a.token collate "C"
|
|
425
|
+
limit p_limit
|
|
426
|
+
$$;
|
|
427
|
+
${serverOnly(fn(APPROVAL_STORE.list), "jsonb, text, text, integer")}
|
|
428
|
+
|
|
429
|
+
create or replace function ${fn(APPROVAL_STORE.expire)}(p_now text)
|
|
430
|
+
returns integer
|
|
431
|
+
language sql
|
|
432
|
+
volatile
|
|
433
|
+
security definer
|
|
434
|
+
set search_path = ''
|
|
435
|
+
as $$
|
|
436
|
+
with expired as (
|
|
437
|
+
update ${table} a
|
|
438
|
+
set status = 'expired', body = a.body || '{"status":"expired"}'::jsonb
|
|
439
|
+
where a.status = 'pending' and a.expires_at <= p_now::timestamptz
|
|
440
|
+
returning 1
|
|
441
|
+
)
|
|
442
|
+
select count(*)::integer from expired
|
|
443
|
+
$$;
|
|
444
|
+
${serverOnly(fn(APPROVAL_STORE.expire), "text")}
|
|
445
|
+
|
|
446
|
+
-- reject every pending request the filter matches, as system:<by>
|
|
447
|
+
create or replace function ${fn(APPROVAL_STORE.cancel)}(p_filter jsonb, p_by text, p_note text, p_now text)
|
|
448
|
+
returns integer
|
|
449
|
+
language sql
|
|
450
|
+
volatile
|
|
451
|
+
security definer
|
|
452
|
+
set search_path = ''
|
|
453
|
+
as $$
|
|
454
|
+
with cancelled as (
|
|
455
|
+
update ${table} a
|
|
456
|
+
set status = 'rejected',
|
|
457
|
+
body = (a.body - 'note') || jsonb_strip_nulls(jsonb_build_object(
|
|
458
|
+
'status', 'rejected', 'resolvedAt', p_now, 'resolvedBy', 'system:' || p_by, 'note', p_note
|
|
459
|
+
))
|
|
460
|
+
where a.status = 'pending'
|
|
461
|
+
and ${FILTER.replaceAll("\n ", "\n ")}
|
|
462
|
+
returning 1
|
|
463
|
+
)
|
|
464
|
+
select count(*)::integer from cancelled
|
|
465
|
+
$$;
|
|
466
|
+
${serverOnly(fn(APPROVAL_STORE.cancel), "jsonb, text, text, text")}
|
|
467
|
+
`;
|
|
468
|
+
}
|
|
469
|
+
/** The request fields an adopted table may mirror into its own columns, as SQL over a body. */
|
|
470
|
+
const MIRRORS = {
|
|
471
|
+
status: (body) => `${body} ->> 'status'`,
|
|
472
|
+
permission: (body) => `${body} ->> 'permission'`,
|
|
473
|
+
tenant: (body) => `${body} #>> '{subject,principal,tenant}'`,
|
|
474
|
+
principalId: (body) => `${body} #>> '{subject,principal,id}'`,
|
|
475
|
+
actorId: (body) => `${body} #>> '{subject,actor,id}'`,
|
|
476
|
+
session: (body) => `${body} #>> '{subject,session}'`,
|
|
477
|
+
approvals: (body) => `coalesce(jsonb_array_length(${body} -> 'approvals'), 0)`,
|
|
478
|
+
createdAt: (body) => `${body} ->> 'createdAt'`,
|
|
479
|
+
expiresAt: (body) => `${body} ->> 'expiresAt'`,
|
|
480
|
+
resolvedAt: (body) => `${body} ->> 'resolvedAt'`,
|
|
481
|
+
resolvedBy: (body) => `${body} ->> 'resolvedBy'`,
|
|
482
|
+
consumedAt: (body) => `${body} ->> 'consumedAt'`
|
|
483
|
+
};
|
|
484
|
+
function mirrorsOf(adopt) {
|
|
485
|
+
const out = [];
|
|
486
|
+
for (const [field, column] of Object.entries(adopt.mirror ?? {})) {
|
|
487
|
+
if (!Object.hasOwn(MIRRORS, field)) throw new Error(`PermDock CLI: rls.approvals.mirror.${field} is not a request field; use ${Object.keys(MIRRORS).join(", ")}`);
|
|
488
|
+
if (typeof column !== "string") throw new TypeError(`PermDock CLI: rls.approvals.mirror.${field} must be a column name`);
|
|
489
|
+
out.push([column, field]);
|
|
490
|
+
}
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
/** One row of `table` from `values` (column, SQL), each converted to the column's own type. */
|
|
494
|
+
function populate(table, values) {
|
|
495
|
+
return `pg_catalog.jsonb_populate_record(null::${table}, pg_catalog.jsonb_build_object(${values.map(([column, sql]) => `${quoteLiteral(column)}, ${sql}`).join(", ")}))`;
|
|
496
|
+
}
|
|
497
|
+
/** The mirrored columns' values for the request `body`. */
|
|
498
|
+
function mirrorRow(table, mirror, body) {
|
|
499
|
+
return populate(table, mirror.map(([column, field]) => [column, MIRRORS[field](body)]));
|
|
500
|
+
}
|
|
501
|
+
/** `, (<mirrored columns>) = (select …)` for an update that writes `body`; empty without mirrors. */
|
|
502
|
+
function mirrorSet(table, mirror, body) {
|
|
503
|
+
if (mirror.length === 0) return "";
|
|
504
|
+
const columns = mirror.map(([column]) => quoteIdent$1(column));
|
|
505
|
+
const picked = columns.map((column) => `r.${column}`).join(", ");
|
|
506
|
+
return `,\n ${columns.length === 1 ? columns[0] ?? "" : `(${columns.join(", ")})`} = (select ${picked} from ${mirrorRow(table, mirror, body)} r)`;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The approval store over a table the app already has (`rls.approvals:
|
|
510
|
+
* { table, token?, body?, mirror? }`): the same `permdock_approval_*`
|
|
511
|
+
* functions, reading every field from the request body in `body`, keyed by
|
|
512
|
+
* the text column `token`, and copying the fields `mirror` names into the
|
|
513
|
+
* app's own columns, converted to their types, on every write. The table's
|
|
514
|
+
* grants and policies stay the app's.
|
|
515
|
+
*/
|
|
516
|
+
function adoptedApprovalStoreSql(ctx, adopt, jsonSchema = false) {
|
|
517
|
+
const table = quoteTable(qualifiedTable$1(adopt.table));
|
|
518
|
+
const base = adopt.table.split(".").at(-1) ?? adopt.table;
|
|
519
|
+
const token = quoteIdent$1(adopt.token ?? "token");
|
|
520
|
+
const body = quoteIdent$1(adopt.body ?? "body");
|
|
521
|
+
const mirror = mirrorsOf(adopt);
|
|
522
|
+
for (const [column] of mirror) if (column === (adopt.token ?? "token") || column === (adopt.body ?? "body")) throw new Error(`PermDock CLI: rls.approvals.mirror names the ${column} column, which already holds the token or the body`);
|
|
523
|
+
const open = adopt.open ?? "insert";
|
|
524
|
+
if (open !== "insert" && open !== "attach") throw new Error(`PermDock CLI: rls.approvals.open must be 'insert' or 'attach', got '${String(open)}'`);
|
|
525
|
+
const schema = adopt.schema;
|
|
526
|
+
if (schema !== void 0 && (typeof schema !== "string" || schema === "")) throw new Error("PermDock CLI: rls.approvals.schema must be a schema name");
|
|
527
|
+
const fn = (name) => schema === void 0 ? qualified$1(ctx, name) : `${quoteIdent$1(schema)}.${name}`;
|
|
528
|
+
const index = (name) => quoteIdent$1(`${base}_permdock_${name}`);
|
|
529
|
+
const status = `a.${body} ->> 'status'`;
|
|
530
|
+
const expires = `(a.${body} ->> 'expiresAt')::timestamptz`;
|
|
531
|
+
const created = `(a.${body} ->> 'createdAt')`;
|
|
532
|
+
const filter = `(p_filter ->> 'status' is null or ${status} = p_filter ->> 'status')
|
|
533
|
+
and (p_filter ->> 'tenant' is null or a.${body} #>> '{subject,principal,tenant}' = p_filter ->> 'tenant')
|
|
534
|
+
and (p_filter ->> 'principalId' is null or a.${body} #>> '{subject,principal,id}' = p_filter ->> 'principalId')
|
|
535
|
+
and (p_filter ->> 'actorId' is null or a.${body} #>> '{subject,actor,id}' = p_filter ->> 'actorId')
|
|
536
|
+
and (p_filter ->> 'session' is null or a.${body} #>> '{subject,session}' = p_filter ->> 'session')`;
|
|
537
|
+
const columns = [
|
|
538
|
+
token,
|
|
539
|
+
body,
|
|
540
|
+
...mirror.map(([column]) => quoteIdent$1(column))
|
|
541
|
+
];
|
|
542
|
+
const inserted = populate(table, [
|
|
543
|
+
[adopt.token ?? "token", "p_request ->> 'token'"],
|
|
544
|
+
[adopt.body ?? "body", "p_request"],
|
|
545
|
+
...mirror.map(([column, field]) => [column, MIRRORS[field]("p_request")])
|
|
546
|
+
]);
|
|
547
|
+
const insertSql = `-- open a request; a repeated ask keeps the open record and replaces only an expired one
|
|
548
|
+
create or replace function ${fn(APPROVAL_STORE.open)}(p_request jsonb)
|
|
549
|
+
returns void
|
|
550
|
+
language sql
|
|
551
|
+
volatile
|
|
552
|
+
security definer
|
|
553
|
+
set search_path = ''
|
|
554
|
+
as $$
|
|
555
|
+
insert into ${table} as a (${columns.join(", ")})
|
|
556
|
+
select ${columns.map((column) => `r.${column}`).join(", ")}
|
|
557
|
+
from ${inserted} r
|
|
558
|
+
on conflict (${token}) do update
|
|
559
|
+
set ${columns.filter((column) => column !== token).map((column) => `${column} = excluded.${column}`).join(", ")}
|
|
560
|
+
where ${status} = 'expired' or ${expires} <= now()
|
|
561
|
+
$$;`;
|
|
562
|
+
const attachSql = `create or replace function ${fn(APPROVAL_STORE.open)}(p_request jsonb)
|
|
563
|
+
returns void
|
|
564
|
+
language plpgsql
|
|
565
|
+
volatile
|
|
566
|
+
security definer
|
|
567
|
+
set search_path = ''
|
|
568
|
+
as $$
|
|
569
|
+
begin
|
|
570
|
+
update ${table} a
|
|
571
|
+
set ${body} = p_request${mirrorSet(table, mirror, "p_request")}
|
|
572
|
+
where a.${token} = p_request ->> 'token'
|
|
573
|
+
and (a.${body} is null or ${status} = 'expired' or ${expires} <= now());
|
|
574
|
+
if not found and not exists (select 1 from ${table} a where a.${token} = p_request ->> 'token') then
|
|
575
|
+
raise exception using
|
|
576
|
+
errcode = 'P0002',
|
|
577
|
+
message = 'permdock: no row of ${adopt.table.replaceAll("'", "''")} holds approval token ' || (p_request ->> 'token'),
|
|
578
|
+
hint = 'insert the application row with this token before opening the request';
|
|
579
|
+
end if;
|
|
580
|
+
end;
|
|
581
|
+
$$;`;
|
|
582
|
+
return `-- approval store adopted onto ${adopt.table}: ApprovalRequest bodies in ${adopt.body ?? "body"}, keyed by ${adopt.token ?? "token"}; supabaseApprovalStore calls the functions below
|
|
583
|
+
alter table ${table} add column if not exists ${token} text;
|
|
584
|
+
alter table ${table} add column if not exists ${body} jsonb;
|
|
585
|
+
create unique index if not exists ${index("token")} on ${table} (${token});
|
|
586
|
+
create index if not exists ${index("page")} on ${table} ((${body} ->> 'createdAt') collate "C", ${token} collate "C");
|
|
587
|
+
create index if not exists ${index("status")} on ${table} ((${body} ->> 'status'));
|
|
588
|
+
${jsonSchema === false ? "" : bodySchemaSql(table, jsonSchema, body, `${base}_permdock_body_schema`)}
|
|
589
|
+
${open === "attach" ? attachSql : insertSql}
|
|
590
|
+
${serverOnly(fn(APPROVAL_STORE.open), "jsonb")}
|
|
591
|
+
|
|
592
|
+
create or replace function ${fn(APPROVAL_STORE.get)}(p_token text)
|
|
593
|
+
returns jsonb
|
|
594
|
+
language sql
|
|
595
|
+
stable
|
|
596
|
+
security definer
|
|
597
|
+
set search_path = ''
|
|
598
|
+
as $$
|
|
599
|
+
select a.${body} from ${table} a where a.${token} = p_token
|
|
600
|
+
$$;
|
|
601
|
+
${serverOnly(fn(APPROVAL_STORE.get), "text")}
|
|
602
|
+
|
|
603
|
+
-- write a verdict over the record it was computed from: still pending, unexpired, with p_seen approvals
|
|
604
|
+
create or replace function ${fn(APPROVAL_STORE.resolve)}(p_token text, p_next jsonb, p_seen integer)
|
|
605
|
+
returns jsonb
|
|
606
|
+
language sql
|
|
607
|
+
volatile
|
|
608
|
+
security definer
|
|
609
|
+
set search_path = ''
|
|
610
|
+
as $$
|
|
611
|
+
update ${table} a
|
|
612
|
+
set ${body} = p_next${mirrorSet(table, mirror, "p_next")}
|
|
613
|
+
where a.${token} = p_token
|
|
614
|
+
and ${status} = 'pending'
|
|
615
|
+
and ${expires} > now()
|
|
616
|
+
and coalesce(jsonb_array_length(a.${body} -> 'approvals'), 0) = p_seen
|
|
617
|
+
returning a.${body}
|
|
618
|
+
$$;
|
|
619
|
+
${serverOnly(fn(APPROVAL_STORE.resolve), "text, jsonb, integer")}
|
|
620
|
+
|
|
621
|
+
-- consume an approved, unexpired, unconsumed request once
|
|
622
|
+
create or replace function ${fn(APPROVAL_STORE.consume)}(p_token text, p_now text)
|
|
623
|
+
returns jsonb
|
|
624
|
+
language sql
|
|
625
|
+
volatile
|
|
626
|
+
security definer
|
|
627
|
+
set search_path = ''
|
|
628
|
+
as $$
|
|
629
|
+
update ${table} a
|
|
630
|
+
set ${body} = a.${body} || jsonb_build_object('consumedAt', p_now)${mirrorSet(table, mirror, `(a.${body} || jsonb_build_object('consumedAt', p_now))`)}
|
|
631
|
+
where a.${token} = p_token
|
|
632
|
+
and ${status} = 'approved'
|
|
633
|
+
and a.${body} ->> 'consumedAt' is null
|
|
634
|
+
and ${expires} > p_now::timestamptz
|
|
635
|
+
returning a.${body}
|
|
636
|
+
$$;
|
|
637
|
+
${serverOnly(fn(APPROVAL_STORE.consume), "text, text")}
|
|
638
|
+
|
|
639
|
+
-- one page in (createdAt, token) order after the cursor position
|
|
640
|
+
create or replace function ${fn(APPROVAL_STORE.list)}(p_filter jsonb, p_after_created text, p_after_token text, p_limit integer)
|
|
641
|
+
returns setof jsonb
|
|
642
|
+
language sql
|
|
643
|
+
stable
|
|
644
|
+
security definer
|
|
645
|
+
set search_path = ''
|
|
646
|
+
as $$
|
|
647
|
+
select a.${body}
|
|
648
|
+
from ${table} a
|
|
649
|
+
where a.${body} is not null
|
|
650
|
+
and ${filter}
|
|
651
|
+
and (p_after_created is null
|
|
652
|
+
or (${created} collate "C", a.${token} collate "C") > (p_after_created collate "C", p_after_token collate "C"))
|
|
653
|
+
order by ${created} collate "C", a.${token} collate "C"
|
|
654
|
+
limit p_limit
|
|
655
|
+
$$;
|
|
656
|
+
${serverOnly(fn(APPROVAL_STORE.list), "jsonb, text, text, integer")}
|
|
657
|
+
|
|
658
|
+
create or replace function ${fn(APPROVAL_STORE.expire)}(p_now text)
|
|
659
|
+
returns integer
|
|
660
|
+
language sql
|
|
661
|
+
volatile
|
|
662
|
+
security definer
|
|
663
|
+
set search_path = ''
|
|
664
|
+
as $$
|
|
665
|
+
with expired as (
|
|
666
|
+
update ${table} a
|
|
667
|
+
set ${body} = a.${body} || '{"status":"expired"}'::jsonb${mirrorSet(table, mirror, `(a.${body} || '{"status":"expired"}'::jsonb)`)}
|
|
668
|
+
where ${status} = 'pending' and ${expires} <= p_now::timestamptz
|
|
669
|
+
returning 1
|
|
670
|
+
)
|
|
671
|
+
select count(*)::integer from expired
|
|
672
|
+
$$;
|
|
673
|
+
${serverOnly(fn(APPROVAL_STORE.expire), "text")}
|
|
674
|
+
|
|
675
|
+
-- reject every pending request the filter matches, as system:<by>
|
|
676
|
+
create or replace function ${fn(APPROVAL_STORE.cancel)}(p_filter jsonb, p_by text, p_note text, p_now text)
|
|
677
|
+
returns integer
|
|
678
|
+
language sql
|
|
679
|
+
volatile
|
|
680
|
+
security definer
|
|
681
|
+
set search_path = ''
|
|
682
|
+
as $$
|
|
683
|
+
with cancelled as (
|
|
684
|
+
update ${table} a
|
|
685
|
+
set ${body} = (a.${body} - 'note') || jsonb_strip_nulls(jsonb_build_object(
|
|
686
|
+
'status', 'rejected', 'resolvedAt', p_now, 'resolvedBy', 'system:' || p_by, 'note', p_note
|
|
687
|
+
))${mirrorSet(table, mirror, `((a.${body} - 'note') || jsonb_strip_nulls(jsonb_build_object('status', 'rejected', 'resolvedAt', p_now, 'resolvedBy', 'system:' || p_by, 'note', p_note)))`)}
|
|
688
|
+
where ${status} = 'pending'
|
|
689
|
+
and ${filter.replaceAll("\n ", "\n ")}
|
|
690
|
+
returning 1
|
|
691
|
+
)
|
|
692
|
+
select count(*)::integer from cancelled
|
|
693
|
+
$$;
|
|
694
|
+
${serverOnly(fn(APPROVAL_STORE.cancel), "jsonb, text, text, text")}
|
|
695
|
+
`;
|
|
696
|
+
}
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region src/cli/rls-break-glass.ts
|
|
699
|
+
/** The audit table every generated break-glass read writes to. Part of the SQL contract. */
|
|
700
|
+
const BREAK_GLASS_AUDIT = "permdock_break_glass_audit";
|
|
701
|
+
const IDENT = /^[a-z][a-z0-9_]*$/u;
|
|
702
|
+
/**
|
|
703
|
+
* The resources a `breakGlass` grant targets, with the table each reads. A
|
|
704
|
+
* break-glass grant is non-portable, so `compileGrants` never turns it into a
|
|
705
|
+
* policy; the server reads restricted rows through the generated function
|
|
706
|
+
* instead, which is why plain RLS keeps denying them.
|
|
707
|
+
*/
|
|
708
|
+
function breakGlassEntries(policy, tables) {
|
|
709
|
+
const byResource = /* @__PURE__ */ new Map();
|
|
710
|
+
for (const item of collectGrants(policy)) {
|
|
711
|
+
const { grant } = item;
|
|
712
|
+
if (grant.breakGlass === void 0) continue;
|
|
713
|
+
const holder = breakGlassHolder(item);
|
|
714
|
+
const resource = grant.permission.resource;
|
|
715
|
+
const grants = byResource.get(resource) ?? [];
|
|
716
|
+
byResource.set(resource, grants);
|
|
717
|
+
if (holder === void 0) {
|
|
718
|
+
const root = policy.scopes[0];
|
|
719
|
+
grants.push({
|
|
720
|
+
permission: grant.permission.key,
|
|
721
|
+
scope: "anyone",
|
|
722
|
+
column: root === void 0 ? void 0 : scopeField(policy.resources.get(resource), root.name, policy.scopes),
|
|
723
|
+
...root === void 0 ? {} : { root: root.name }
|
|
724
|
+
});
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
grants.push({
|
|
728
|
+
permission: grant.permission.key,
|
|
729
|
+
scope: holder.scope,
|
|
730
|
+
column: holder.scope === "global" ? void 0 : scopeField(policy.resources.get(resource), holder.scope, policy.scopes)
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
return [...byResource].map(([resource, grants]) => ({
|
|
734
|
+
resource,
|
|
735
|
+
table: tableFor(resource, tables),
|
|
736
|
+
grants
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Rows the subject's roles reach for `p_permission`: a break-glass grant
|
|
741
|
+
* lifts denies, never the tenant boundary. A grant to no role reads the rows
|
|
742
|
+
* of the root scopes the subject is a member of, or every row when the
|
|
743
|
+
* policy declares no scopes.
|
|
744
|
+
*/
|
|
745
|
+
function rowsFilter(ctx, entry) {
|
|
746
|
+
const arms = entry.grants.flatMap((grant) => {
|
|
747
|
+
const asked = `p_permission = ${quoteLiteral(grant.permission)}`;
|
|
748
|
+
if (grant.scope === "anyone") {
|
|
749
|
+
if (grant.root === void 0) return [`(${asked})`];
|
|
750
|
+
return grant.column === void 0 ? [] : [`(${asked} and ${quoteIdent$1(grant.column)} in (select ${qualified$1(ctx, memberIdsHelper(grant.root))}()))`];
|
|
751
|
+
}
|
|
752
|
+
if (grant.scope !== "global" && grant.column === void 0) return [];
|
|
753
|
+
return [`(${asked} and ${accessSql(ctx, grant.scope, breakGlassKey(grant.permission), grant.column)})`];
|
|
754
|
+
});
|
|
755
|
+
return arms.length === 0 ? "false" : [...new Set(arms)].join("\n or ");
|
|
756
|
+
}
|
|
757
|
+
function functionName(resource) {
|
|
758
|
+
if (!IDENT.test(resource)) throw new Error(`PermDock CLI: unsafe break-glass resource '${resource}'`);
|
|
759
|
+
return `permdock_break_glass_${resource}`;
|
|
760
|
+
}
|
|
761
|
+
function qualifiedTable(name) {
|
|
762
|
+
return quoteTable(name.includes(".") ? name : `public.${name}`);
|
|
763
|
+
}
|
|
764
|
+
function auditSql(ctx) {
|
|
765
|
+
const audit = qualified$1(ctx, quoteIdent$1(BREAK_GLASS_AUDIT));
|
|
766
|
+
return `-- break-glass audit: one row per session that read restricted data
|
|
767
|
+
-- RLS never compiles a break-glass override, so restricted rows stay denied to a direct read
|
|
768
|
+
create table if not exists ${audit} (
|
|
769
|
+
id bigint generated always as identity primary key,
|
|
770
|
+
at timestamptz not null default now(),
|
|
771
|
+
subject text,
|
|
772
|
+
permission text not null,
|
|
773
|
+
purpose text,
|
|
774
|
+
reason text not null
|
|
775
|
+
);
|
|
776
|
+
alter table ${audit} enable row level security;
|
|
777
|
+
revoke all on table ${audit} from anon, authenticated, public;`;
|
|
778
|
+
}
|
|
779
|
+
function readFunctionSql(ctx, entry) {
|
|
780
|
+
const fn = qualified$1(ctx, functionName(entry.resource));
|
|
781
|
+
const audit = qualified$1(ctx, quoteIdent$1(BREAK_GLASS_AUDIT));
|
|
782
|
+
const table = qualifiedTable(entry.table);
|
|
783
|
+
const session = subjectClaimJsonSql(ctx, "break_glass");
|
|
784
|
+
const subject = subjectIdSql(ctx);
|
|
785
|
+
return `-- ${entry.resource}: reads restricted rows through a checked, audited break-glass session
|
|
786
|
+
create or replace function ${fn}(p_permission text)
|
|
787
|
+
returns setof ${table}
|
|
788
|
+
language plpgsql
|
|
789
|
+
security definer
|
|
790
|
+
set search_path = ''
|
|
791
|
+
as $$
|
|
792
|
+
declare
|
|
793
|
+
session jsonb := ${session};
|
|
794
|
+
begin
|
|
795
|
+
if session is null
|
|
796
|
+
or jsonb_typeof(session) <> 'object'
|
|
797
|
+
or session ->> 'v' <> '1'
|
|
798
|
+
or session ->> 'permission' <> p_permission
|
|
799
|
+
or coalesce(session ->> 'reason', '') = ''
|
|
800
|
+
or jsonb_typeof(session -> 'expiresAt') <> 'number'
|
|
801
|
+
or (session ->> 'expiresAt')::numeric <= extract(epoch from now())
|
|
802
|
+
then
|
|
803
|
+
raise exception 'PermDock: no valid break-glass session for %', p_permission
|
|
804
|
+
using errcode = '42501';
|
|
805
|
+
end if;
|
|
806
|
+
insert into ${audit} (subject, permission, purpose, reason)
|
|
807
|
+
values (
|
|
808
|
+
nullif((${subject})::text, ''),
|
|
809
|
+
p_permission,
|
|
810
|
+
session ->> 'purpose',
|
|
811
|
+
session ->> 'reason'
|
|
812
|
+
);
|
|
813
|
+
return query select * from ${table}
|
|
814
|
+
where ${rowsFilter(ctx, entry)};
|
|
815
|
+
end;
|
|
816
|
+
$$;
|
|
817
|
+
revoke execute on function ${fn}(text) from public, anon;
|
|
818
|
+
grant execute on function ${fn}(text) to authenticated;`;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* The `security definer` read path for every `breakGlass` grant: an audit
|
|
822
|
+
* table and one `permdock_break_glass_<resource>(permission)` per resource
|
|
823
|
+
* that checks a signed break-glass session, writes an audit row and returns
|
|
824
|
+
* the rows in the scope instances where the subject holds a role with the
|
|
825
|
+
* break-glass grant (bypassing the table's policies as the definer). Empty
|
|
826
|
+
* when the policy has no break-glass grant.
|
|
827
|
+
*/
|
|
828
|
+
function breakGlassSql(ctx, entries) {
|
|
829
|
+
if (entries.length === 0) return "";
|
|
830
|
+
return [auditSql(ctx), ...entries.map((entry) => readFunctionSql(ctx, entry))].join("\n\n");
|
|
831
|
+
}
|
|
832
|
+
//#endregion
|
|
833
|
+
//#region src/cli/rls-emit.ts
|
|
834
|
+
const HEADER = "-- generated by permdock rls generate\n-- fail-closed: never target a bypass role\n";
|
|
835
|
+
const TRUSTED_READER_GRANT = /^grant execute on function [^\n]+\.permdock_trusted_role_permissions\([^)\n]*\) to [^\n;]+;$/gmu;
|
|
836
|
+
function assertNoServiceRole(text) {
|
|
837
|
+
if (/\bservice_role\b/iu.test(text.replace(TRUSTED_READER_GRANT, ""))) throw new Error("PermDock CLI: generated RLS must never emit service_role");
|
|
838
|
+
}
|
|
839
|
+
function assertSupautilsSafe(text) {
|
|
840
|
+
const [first] = supautilsRejections(text);
|
|
841
|
+
if (first !== void 0) throw new Error(`PermDock CLI: generated SQL line ${String(first.line)} ${first.reason}, which supautils rejects on Supabase`);
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Neon's Data API and `pg_session_jwt` name the anonymous role `anonymous`;
|
|
845
|
+
* Supabase, PostgREST and the GUC pattern use `anon`. Role lists end a line
|
|
846
|
+
* or a statement after `to` or `from`, so only those are renamed.
|
|
847
|
+
*/
|
|
848
|
+
function dialectRoles(text, dialect) {
|
|
849
|
+
if (dialect !== "neon") return text;
|
|
850
|
+
return text.replaceAll(/\b(to|from) ((?:[a-z_]+, )*[a-z_]+)(?=;|\n)/gu, (_match, keyword, list) => `${keyword} ${list.split(", ").map((role) => role === "anon" ? "anonymous" : role).join(", ")}`);
|
|
851
|
+
}
|
|
852
|
+
function tablesOf(policies) {
|
|
853
|
+
return [...new Set(policies.map((item) => item.table))];
|
|
854
|
+
}
|
|
855
|
+
/** Enable (and force) row level security, then the table grants for the commands a policy allows. */
|
|
856
|
+
function tableSql(table, policies, force, views) {
|
|
857
|
+
const closed = views.find((view) => view.table === table && view.companion !== void 0);
|
|
858
|
+
const target = quoteTable(qualifiedTable$1(table));
|
|
859
|
+
const lines = [`alter table ${target} enable row level security;`];
|
|
860
|
+
if (force) lines.push(`alter table ${target} force row level security;`);
|
|
861
|
+
lines.push(`revoke all on table ${target} from anon, authenticated;`);
|
|
862
|
+
for (const role of ["anon", "authenticated"]) {
|
|
863
|
+
const cmds = new Set(policies.filter((item) => item.table === table && item.effect === "allow" && item.roles.includes(role)).map((item) => item.command));
|
|
864
|
+
const grants = [
|
|
865
|
+
"select",
|
|
866
|
+
"insert",
|
|
867
|
+
"update",
|
|
868
|
+
"delete"
|
|
869
|
+
].filter((cmd) => cmds.has(cmd) && (closed === void 0 || cmd !== "select"));
|
|
870
|
+
if (grants.length > 0) lines.push(`grant ${grants.join(", ")} on table ${target} to ${role};`);
|
|
871
|
+
if (closed !== void 0 && cmds.has("select")) lines.push(columnGrantSql(closed, role));
|
|
872
|
+
}
|
|
873
|
+
if (closed !== void 0) lines.push(columnRevokeSql(closed));
|
|
874
|
+
return lines.join("\n");
|
|
875
|
+
}
|
|
876
|
+
/** `drop policy if exists` and `create policy` for one policy, without the table's grants. */
|
|
877
|
+
function policySql(item) {
|
|
878
|
+
const target = quoteTable(qualifiedTable$1(item.table));
|
|
879
|
+
const lines = [
|
|
880
|
+
`drop policy if exists ${quoteIdent$1(item.name)} on ${target};`,
|
|
881
|
+
`create policy ${quoteIdent$1(item.name)}`,
|
|
882
|
+
` on ${target}`,
|
|
883
|
+
` as ${item.effect === "deny" ? "restrictive" : "permissive"}`,
|
|
884
|
+
` for ${item.command}`,
|
|
885
|
+
` to ${item.roles.join(", ")}`
|
|
886
|
+
];
|
|
887
|
+
if (item.using !== void 0) lines.push(` using (${item.using})`);
|
|
888
|
+
if (item.check !== void 0) lines.push(` with check (${item.check})`);
|
|
889
|
+
return `${lines.join("\n")};\n`;
|
|
890
|
+
}
|
|
891
|
+
function emitSql(policies, preamble, force = false, views = []) {
|
|
892
|
+
const chunks = [HEADER];
|
|
893
|
+
if (preamble !== "") chunks.push(preamble);
|
|
894
|
+
for (const table of tablesOf(policies)) chunks.push(tableSql(table, policies, force, views), "");
|
|
895
|
+
for (const item of policies) chunks.push(policySql(item));
|
|
896
|
+
if (views.length > 0) chunks.push(fieldViewsSql(views));
|
|
897
|
+
const text = `${chunks.join("\n").trim()}\n`;
|
|
898
|
+
assertNoServiceRole(text);
|
|
899
|
+
assertSupautilsSafe(text);
|
|
900
|
+
return text;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* The SQL a Drizzle or Prisma schema cannot declare: helpers, grants, `force` and field views.
|
|
904
|
+
* `rls generate` writes it next to `out` for a custom migration.
|
|
905
|
+
*/
|
|
906
|
+
function migrationSql(policies, preamble, force, views) {
|
|
907
|
+
const parts = [
|
|
908
|
+
preamble.trim(),
|
|
909
|
+
...tablesOf(policies).map((table) => tableSql(table, policies, force, views)),
|
|
910
|
+
fieldViewsSql(views)
|
|
911
|
+
].filter((part) => part !== "");
|
|
912
|
+
if (parts.length === 0) return "";
|
|
913
|
+
const text = `${HEADER}\n${parts.join("\n\n")}\n`;
|
|
914
|
+
assertNoServiceRole(text);
|
|
915
|
+
assertSupautilsSafe(text);
|
|
916
|
+
return text;
|
|
917
|
+
}
|
|
918
|
+
function helpersNote(helpers, how) {
|
|
919
|
+
return helpers === void 0 ? [] : [`// run ${helpers} ${how}`];
|
|
920
|
+
}
|
|
921
|
+
function camelCase(name) {
|
|
922
|
+
return name.replace(/[_-]+([a-z0-9])/gi, (_, char) => char.toUpperCase());
|
|
923
|
+
}
|
|
924
|
+
function tableName(table) {
|
|
925
|
+
return table.slice(table.lastIndexOf(".") + 1);
|
|
926
|
+
}
|
|
927
|
+
function pascalCase(name) {
|
|
928
|
+
const camel = camelCase(name);
|
|
929
|
+
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
930
|
+
}
|
|
931
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
932
|
+
function identifier(name, what) {
|
|
933
|
+
if (!IDENTIFIER.test(name)) throw new Error(`PermDock CLI: ${what} ${JSON.stringify(name)} is not an identifier`);
|
|
934
|
+
return name;
|
|
935
|
+
}
|
|
936
|
+
function quoted(text) {
|
|
937
|
+
return `'${text.replace(/[\\']/g, (match) => `\\${match}`)}'`;
|
|
938
|
+
}
|
|
939
|
+
function templateText(text) {
|
|
940
|
+
return text.replace(/[\\`]|\$\{/g, (match) => `\\${match}`);
|
|
941
|
+
}
|
|
942
|
+
const AUTH_UID = "(select auth.uid())";
|
|
943
|
+
function drizzleRoles(list) {
|
|
944
|
+
return list.includes("anon") ? "[anonRole, authenticatedRole]" : "authenticatedRole";
|
|
945
|
+
}
|
|
946
|
+
function emitDrizzle(policies, options) {
|
|
947
|
+
const supabase = options.dialect === "supabase";
|
|
948
|
+
const anon = policies.some((item) => item.roles.includes("anon"));
|
|
949
|
+
const sqlOf = (text) => {
|
|
950
|
+
const parts = text.split(AUTH_UID).map(templateText);
|
|
951
|
+
return `sql\`${supabase ? parts.join("${authUid}") : parts.join(AUTH_UID)}\``;
|
|
952
|
+
};
|
|
953
|
+
const usesUid = supabase && policies.some((item) => item.using?.includes(AUTH_UID) === true || item.check?.includes(AUTH_UID) === true);
|
|
954
|
+
const lines = [
|
|
955
|
+
"// generated by permdock rls generate",
|
|
956
|
+
"// fail-closed: never target a bypass role",
|
|
957
|
+
...helpersNote(options.helpers, "in a custom migration (drizzle-kit generate --custom)"),
|
|
958
|
+
"import { sql } from 'drizzle-orm'"
|
|
959
|
+
];
|
|
960
|
+
if (supabase) {
|
|
961
|
+
const named = [
|
|
962
|
+
anon ? "anonRole" : "",
|
|
963
|
+
"authenticatedRole",
|
|
964
|
+
usesUid ? "authUid" : ""
|
|
965
|
+
].filter((name) => name !== "").join(", ");
|
|
966
|
+
lines.push("import { pgPolicy } from 'drizzle-orm/pg-core'", `import { ${named} } from 'drizzle-orm/supabase'`);
|
|
967
|
+
} else lines.push("import { pgPolicy, pgRole } from 'drizzle-orm/pg-core'");
|
|
968
|
+
lines.push(`import * as schema from ${quoted(options.schema ?? "./schema")}`, "");
|
|
969
|
+
if (!supabase) {
|
|
970
|
+
if (anon) lines.push(`export const anonRole = pgRole('${options.dialect === "neon" ? "anonymous" : "anon"}').existing()`);
|
|
971
|
+
lines.push("export const authenticatedRole = pgRole('authenticated').existing()", "");
|
|
972
|
+
}
|
|
973
|
+
for (const item of policies) {
|
|
974
|
+
const as = item.effect === "deny" ? "restrictive" : "permissive";
|
|
975
|
+
const table = identifier(options.exports?.[item.table] ?? camelCase(tableName(item.table)), "table export");
|
|
976
|
+
const name = identifier(camelCase(item.name), "policy export");
|
|
977
|
+
const using = item.using === void 0 ? "" : `\n using: ${sqlOf(item.using)},`;
|
|
978
|
+
const check = item.check === void 0 ? "" : `\n withCheck: ${sqlOf(item.check)},`;
|
|
979
|
+
lines.push(`export const ${name} = pgPolicy(${quoted(item.name)}, {`, ` as: '${as}',`, ` for: '${item.command}',`, ` to: ${drizzleRoles(item.roles)},${using}${check}`, `}).link(schema.${table})`, "");
|
|
980
|
+
}
|
|
981
|
+
const text = `${lines.join("\n").trimEnd()}\n`;
|
|
982
|
+
assertNoServiceRole(text);
|
|
983
|
+
return text;
|
|
984
|
+
}
|
|
985
|
+
/** Prisma 8 `policy_<command>` blocks; each target model declares `@@rls` itself. */
|
|
986
|
+
function emitPrisma(policies, options = {}) {
|
|
987
|
+
const modelOf = (table) => identifier(options.models?.[table] ?? pascalCase(tableName(table)), "model");
|
|
988
|
+
const models = [...new Set(policies.map((item) => modelOf(item.table)))];
|
|
989
|
+
const lines = [
|
|
990
|
+
"// generated by permdock rls generate (Prisma 8 policy blocks)",
|
|
991
|
+
"// fail-closed: never target a bypass role",
|
|
992
|
+
`// add @@rls to ${models.length === 1 ? "model" : "models"} ${models.join(", ")}; keep these blocks in their namespace`,
|
|
993
|
+
...helpersNote(options.helpers, "in a migration before these policies"),
|
|
994
|
+
""
|
|
995
|
+
];
|
|
996
|
+
for (const item of policies) {
|
|
997
|
+
if (item.effect === "deny") throw new Error(`PermDock CLI: Prisma policy blocks are permissive only, so ${item.name} (a deny) needs --target sql`);
|
|
998
|
+
lines.push(`policy_${item.command} ${identifier(item.name, "policy name")} {`, ` target = ${modelOf(item.table)}`, ` roles = [${item.roles.join(", ")}]`);
|
|
999
|
+
if (item.using !== void 0) lines.push(` using = ${JSON.stringify(item.using)}`);
|
|
1000
|
+
if (item.check !== void 0) lines.push(` withCheck = ${JSON.stringify(item.check)}`);
|
|
1001
|
+
lines.push("}", "");
|
|
1002
|
+
}
|
|
1003
|
+
const text = `${lines.join("\n").trimEnd()}\n`;
|
|
1004
|
+
assertNoServiceRole(text);
|
|
1005
|
+
return text;
|
|
1006
|
+
}
|
|
1007
|
+
/** The migration file written next to `out`. */
|
|
1008
|
+
function migrationOut(out) {
|
|
1009
|
+
const dot = out.lastIndexOf(".");
|
|
1010
|
+
return `${dot > Math.max(out.lastIndexOf("/"), out.lastIndexOf("\\")) ? out.slice(0, dot) : out}.migration.sql`;
|
|
1011
|
+
}
|
|
1012
|
+
function defaultOut(target) {
|
|
1013
|
+
switch (target) {
|
|
1014
|
+
case "sql": return "rls.sql";
|
|
1015
|
+
case "drizzle": return "src/db/policies.ts";
|
|
1016
|
+
case "prisma": return "prisma/policies.prisma";
|
|
1017
|
+
default: return target;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
//#endregion
|
|
1021
|
+
//#region src/cli/rls-realtime-storage.ts
|
|
1022
|
+
const SCOPE_SEGMENT = /^\{([a-z][a-z0-9_]*)\}$/u;
|
|
1023
|
+
const LITERAL_SEGMENT = /^[^{}:\s]+$/u;
|
|
1024
|
+
const TOPIC = "(select realtime.topic())";
|
|
1025
|
+
function policyName(prefix, name, command) {
|
|
1026
|
+
const full = `permdock_${prefix}_${name.replaceAll(/[^A-Za-z0-9]+/gu, "_").replaceAll(/^_|_$/gu, "")}_${command}`;
|
|
1027
|
+
if (new TextEncoder().encode(full).length > 63) throw new Error(`PermDock CLI: the ${prefix} policy name ${full} is longer than Postgres's 63 bytes; shorten '${name}'`);
|
|
1028
|
+
return full;
|
|
1029
|
+
}
|
|
1030
|
+
function grantKey(facts, ref, where, warnings) {
|
|
1031
|
+
if (ref === void 0) return;
|
|
1032
|
+
const key = typeof ref === "object" && ref !== null ? Reflect.get(ref, "key") : ref;
|
|
1033
|
+
if (typeof key !== "string" || !facts.keys.has(key)) throw new Error(`PermDock CLI: ${where} must be a permission reference from the definitions (got ${JSON.stringify(key)})`);
|
|
1034
|
+
if (facts.rowConditions.has(key)) warnings.push(`${where} is ${key}, whose grants carry row conditions (a relation, a where or another condition): the policy admits only the instances its role allows without a condition reach, through permitted_<scope>_ids_by_permission`);
|
|
1035
|
+
return key;
|
|
1036
|
+
}
|
|
1037
|
+
function declaredScope(facts, scope, where) {
|
|
1038
|
+
if (!facts.scopes.has(scope)) throw new Error(`PermDock CLI: ${where} names the scope '${scope}', which definePolicy({ scopes }) does not declare`);
|
|
1039
|
+
return scope;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* The ids of `scope` where the subject holds `key` through an allow without
|
|
1043
|
+
* a row condition, minus a deny of it, as text: `<value> in (…)`. A
|
|
1044
|
+
* relationship or a `where` on the same permission never widens it.
|
|
1045
|
+
*/
|
|
1046
|
+
function permitted(ctx, value, scope, key) {
|
|
1047
|
+
return `${value} in (select x::text from ${qualified$1(ctx, permittedByPermissionHelper(scope))}(${quoteLiteral(key)}) x)`;
|
|
1048
|
+
}
|
|
1049
|
+
function escapeRegex(text) {
|
|
1050
|
+
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
1051
|
+
}
|
|
1052
|
+
function realtimePolicies(ctx, facts, realtime, warnings) {
|
|
1053
|
+
return Object.entries(realtime.topics).flatMap(([pattern, topic]) => {
|
|
1054
|
+
const where = `rls.realtime.topics['${pattern}']`;
|
|
1055
|
+
const segments = pattern.split(":");
|
|
1056
|
+
const placeholders = segments.flatMap((segment, index) => {
|
|
1057
|
+
const match = SCOPE_SEGMENT.exec(segment);
|
|
1058
|
+
return match === null ? [] : [{
|
|
1059
|
+
index,
|
|
1060
|
+
scope: match[1] ?? ""
|
|
1061
|
+
}];
|
|
1062
|
+
});
|
|
1063
|
+
const [placeholder] = placeholders;
|
|
1064
|
+
if (placeholder === void 0 || placeholders.length > 1 || segments.some((segment, index) => index !== placeholder.index && !LITERAL_SEGMENT.test(segment))) throw new Error(`PermDock CLI: ${where} must be ':'-separated segments with exactly one '{<scope>}' segment, such as 'org:{organization}:chat'`);
|
|
1065
|
+
const scope = declaredScope(facts, placeholder.scope, where);
|
|
1066
|
+
const regex = `^${segments.map((segment, index) => index === placeholder.index ? "[^:]+" : escapeRegex(segment)).join(":")}$`;
|
|
1067
|
+
const id = `split_part(${TOPIC}, ':', ${String(placeholder.index + 1)})`;
|
|
1068
|
+
const condition = (key) => `extension in ('broadcast', 'presence')
|
|
1069
|
+
and ${TOPIC} ~ ${quoteLiteral(regex)}
|
|
1070
|
+
and ${permitted(ctx, id, scope, key)}`;
|
|
1071
|
+
const read = grantKey(facts, topic.read, `${where}.read`, warnings);
|
|
1072
|
+
const write = grantKey(facts, topic.write, `${where}.write`, warnings);
|
|
1073
|
+
if (read === void 0) throw new Error(`PermDock CLI: ${where}.read is required`);
|
|
1074
|
+
const policies = [{
|
|
1075
|
+
name: policyName("realtime", pattern, "select"),
|
|
1076
|
+
table: "realtime.messages",
|
|
1077
|
+
command: "select",
|
|
1078
|
+
effect: "allow",
|
|
1079
|
+
roles: ["authenticated"],
|
|
1080
|
+
using: condition(read)
|
|
1081
|
+
}];
|
|
1082
|
+
if (write !== void 0) policies.push({
|
|
1083
|
+
name: policyName("realtime", pattern, "insert"),
|
|
1084
|
+
table: "realtime.messages",
|
|
1085
|
+
command: "insert",
|
|
1086
|
+
effect: "allow",
|
|
1087
|
+
roles: ["authenticated"],
|
|
1088
|
+
check: condition(write)
|
|
1089
|
+
});
|
|
1090
|
+
return policies;
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
function folderOf(bucket, where) {
|
|
1094
|
+
const folder = bucket.folder ?? 1;
|
|
1095
|
+
if (!Number.isInteger(folder) || folder < 1) throw new Error(`PermDock CLI: ${where}.folder must be a positive integer (got ${String(folder)})`);
|
|
1096
|
+
return folder;
|
|
1097
|
+
}
|
|
1098
|
+
function storagePolicies(ctx, facts, storage, warnings) {
|
|
1099
|
+
return Object.entries(storage.buckets).flatMap(([id, bucket]) => {
|
|
1100
|
+
const where = `rls.storage.buckets['${id}']`;
|
|
1101
|
+
const scope = declaredScope(facts, bucket.scope, `${where}.scope`);
|
|
1102
|
+
const folder = folderOf(bucket, where);
|
|
1103
|
+
const condition = (key) => `bucket_id = ${quoteLiteral(id)}
|
|
1104
|
+
and ${permitted(ctx, `(storage.foldername(name))[${String(folder)}]`, scope, key)}`;
|
|
1105
|
+
const policies = [
|
|
1106
|
+
[
|
|
1107
|
+
"select",
|
|
1108
|
+
bucket.read,
|
|
1109
|
+
"read"
|
|
1110
|
+
],
|
|
1111
|
+
[
|
|
1112
|
+
"insert",
|
|
1113
|
+
bucket.write,
|
|
1114
|
+
"write"
|
|
1115
|
+
],
|
|
1116
|
+
[
|
|
1117
|
+
"update",
|
|
1118
|
+
bucket.write,
|
|
1119
|
+
"write"
|
|
1120
|
+
],
|
|
1121
|
+
[
|
|
1122
|
+
"delete",
|
|
1123
|
+
bucket.delete,
|
|
1124
|
+
"delete"
|
|
1125
|
+
]
|
|
1126
|
+
].flatMap(([command, ref, field]) => {
|
|
1127
|
+
const key = grantKey(facts, ref, `${where}.${field}`, warnings);
|
|
1128
|
+
if (key === void 0) return [];
|
|
1129
|
+
const expression = condition(key);
|
|
1130
|
+
return [{
|
|
1131
|
+
name: policyName("storage", id, command),
|
|
1132
|
+
table: "storage.objects",
|
|
1133
|
+
command,
|
|
1134
|
+
effect: "allow",
|
|
1135
|
+
roles: ["authenticated"],
|
|
1136
|
+
...command === "insert" ? {} : { using: expression },
|
|
1137
|
+
...command === "select" || command === "delete" ? {} : { check: expression }
|
|
1138
|
+
}];
|
|
1139
|
+
});
|
|
1140
|
+
if (policies.length === 0) throw new Error(`PermDock CLI: ${where} needs read, write or delete`);
|
|
1141
|
+
return policies;
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* The `realtime.messages` and `storage.objects` policies `rls.realtime` and
|
|
1146
|
+
* `rls.storage` describe. Each one admits a row only where the subject holds
|
|
1147
|
+
* the permission in the scope instance its topic segment or folder names,
|
|
1148
|
+
* through an allow without a row condition, so the helpers' API-key ceiling
|
|
1149
|
+
* and suspension rules apply and a relationship grant on the same permission
|
|
1150
|
+
* never reaches a topic or a folder.
|
|
1151
|
+
*/
|
|
1152
|
+
function realtimeStoragePolicies(ctx, facts, config, warnings = []) {
|
|
1153
|
+
if (config.realtime === void 0 && config.storage === void 0) return [];
|
|
1154
|
+
if (ctx.dialect !== "supabase") throw new Error(`PermDock CLI: rls.realtime and rls.storage need --dialect supabase (got ${ctx.dialect})`);
|
|
1155
|
+
const policies = [...config.realtime === void 0 ? [] : realtimePolicies(ctx, facts, config.realtime, warnings), ...config.storage === void 0 ? [] : storagePolicies(ctx, facts, config.storage, warnings)];
|
|
1156
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1157
|
+
for (const { name } of policies) {
|
|
1158
|
+
if (seen.has(name)) throw new Error(`PermDock CLI: two rls.realtime or rls.storage entries both name the policy ${name}; rename one`);
|
|
1159
|
+
seen.add(name);
|
|
1160
|
+
}
|
|
1161
|
+
return policies;
|
|
1162
|
+
}
|
|
1163
|
+
//#endregion
|
|
1164
|
+
//#region src/cli/rls-rows.ts
|
|
1165
|
+
/** `permitted_<resource>_rows(p_permission)`: the resource's row ids the subject may act on with one permission. */
|
|
1166
|
+
function rowsHelper(resource) {
|
|
1167
|
+
const name = graphSqlName(resource);
|
|
1168
|
+
const helper = `permitted_${name ?? resource}_rows`;
|
|
1169
|
+
if (name === void 0 || new TextEncoder().encode(`${helper}_for`).length > 63) throw new Error(`PermDock CLI: rls.rowHelpers cannot name a helper for resource '${resource}'; use a SQL name of at most 45 bytes`);
|
|
1170
|
+
return helper;
|
|
1171
|
+
}
|
|
1172
|
+
/** The branches a row check of a permission reads: its allows and denies on rows that exist, for signed-in callers. */
|
|
1173
|
+
function rowBranchesOf(branches, resource) {
|
|
1174
|
+
const out = /* @__PURE__ */ new Map();
|
|
1175
|
+
for (const branch of branches) {
|
|
1176
|
+
if (branch.resource !== resource || branch.command === "insert" || branch.coverage === true || !branch.roles.includes("authenticated")) continue;
|
|
1177
|
+
out.set(branch.permissionKey, [...out.get(branch.permissionKey) ?? [], branch]);
|
|
1178
|
+
}
|
|
1179
|
+
return out;
|
|
1180
|
+
}
|
|
1181
|
+
function usingOf(branches) {
|
|
1182
|
+
const parts = branches.map((branch) => branchClauses(branch).using ?? "true");
|
|
1183
|
+
return parts.length === 0 ? void 0 : orSql(parts);
|
|
1184
|
+
}
|
|
1185
|
+
function permissionCase(key, branches) {
|
|
1186
|
+
const allow = usingOf(branches.filter((branch) => branch.effect === "allow"));
|
|
1187
|
+
if (allow === void 0) return;
|
|
1188
|
+
const deny = usingOf(branches.filter((branch) => branch.effect === "deny"));
|
|
1189
|
+
const granted = `coalesce((${allow}), false)`;
|
|
1190
|
+
return ` when ${quoteLiteral(key)} then ${deny === void 0 ? granted : `${granted} and (${deny}) is not true`}`;
|
|
1191
|
+
}
|
|
1192
|
+
function forUserSql(ctx, fn, helper) {
|
|
1193
|
+
const user = ctx.dialect === "supabase" ? "uuid" : "text";
|
|
1194
|
+
const signature = `${user}, text, jsonb`;
|
|
1195
|
+
const head = `create or replace function ${fn}(p_user ${user}, p_permission text, p_claims jsonb default '{}')
|
|
1196
|
+
returns setof text
|
|
1197
|
+
language plpgsql
|
|
1198
|
+
volatile
|
|
1199
|
+
security definer
|
|
1200
|
+
set search_path = ''
|
|
1201
|
+
as $$`;
|
|
1202
|
+
const tail = `revoke execute on function ${fn}(${signature}) from public, anon, authenticated;`;
|
|
1203
|
+
if (ctx.dialect === "supabase") return `${head}
|
|
1204
|
+
declare
|
|
1205
|
+
v_claims text := pg_catalog.current_setting('request.jwt.claims', true);
|
|
1206
|
+
begin
|
|
1207
|
+
perform pg_catalog.set_config(
|
|
1208
|
+
'request.jwt.claims',
|
|
1209
|
+
(coalesce(p_claims, '{}'::jsonb) || pg_catalog.jsonb_build_object('sub', p_user::text, 'role', 'authenticated'))::text,
|
|
1210
|
+
true
|
|
1211
|
+
);
|
|
1212
|
+
return query select r.id from ${helper}(p_permission) r(id);
|
|
1213
|
+
perform pg_catalog.set_config('request.jwt.claims', coalesce(v_claims, ''), true);
|
|
1214
|
+
end
|
|
1215
|
+
$$;
|
|
1216
|
+
${tail}`;
|
|
1217
|
+
const prefix = ctx.gucPrefix;
|
|
1218
|
+
return `${head}
|
|
1219
|
+
declare
|
|
1220
|
+
v_saved jsonb := '{}'::jsonb;
|
|
1221
|
+
v_key text;
|
|
1222
|
+
v_value text;
|
|
1223
|
+
begin
|
|
1224
|
+
for v_key, v_value in
|
|
1225
|
+
select 'user_id', p_user
|
|
1226
|
+
union all
|
|
1227
|
+
select c.key, c.value from pg_catalog.jsonb_each_text(coalesce(p_claims, '{}'::jsonb)) c where c.key <> 'user_id'
|
|
1228
|
+
loop
|
|
1229
|
+
v_saved := v_saved || pg_catalog.jsonb_build_object(v_key, coalesce(pg_catalog.current_setting(${quoteLiteral(`${prefix}.`)} || v_key, true), ''));
|
|
1230
|
+
perform pg_catalog.set_config(${quoteLiteral(`${prefix}.`)} || v_key, v_value, true);
|
|
1231
|
+
end loop;
|
|
1232
|
+
return query select r.id from ${helper}(p_permission) r(id);
|
|
1233
|
+
for v_key, v_value in select s.key, s.value from pg_catalog.jsonb_each_text(v_saved) s loop
|
|
1234
|
+
perform pg_catalog.set_config(${quoteLiteral(`${prefix}.`)} || v_key, v_value, true);
|
|
1235
|
+
end loop;
|
|
1236
|
+
end
|
|
1237
|
+
$$;
|
|
1238
|
+
${tail}`;
|
|
1239
|
+
}
|
|
1240
|
+
function inheritTargets(policy) {
|
|
1241
|
+
const out = /* @__PURE__ */ new Set();
|
|
1242
|
+
for (const grant of policy.grants) for (const item of flattenGrantee(grant.to)) if (item.kind === "inherit") out.add(item.resource);
|
|
1243
|
+
return [...out].toSorted();
|
|
1244
|
+
}
|
|
1245
|
+
function stubSql(ctx, resource) {
|
|
1246
|
+
return `create or replace function ${qualified$1(ctx, rowsHelper(resource))}(p_permission text)
|
|
1247
|
+
returns setof text
|
|
1248
|
+
language sql
|
|
1249
|
+
stable
|
|
1250
|
+
security definer
|
|
1251
|
+
set search_path = ''
|
|
1252
|
+
as $$
|
|
1253
|
+
select null::text where false
|
|
1254
|
+
$$;`;
|
|
1255
|
+
}
|
|
1256
|
+
function rowSql(ctx, rows, table, where) {
|
|
1257
|
+
const fn = qualified$1(ctx, rows.replace(/_rows$/u, "_row"));
|
|
1258
|
+
return `create or replace function ${fn}(p_row ${table}, p_permission text)
|
|
1259
|
+
returns boolean
|
|
1260
|
+
language sql
|
|
1261
|
+
stable
|
|
1262
|
+
security definer
|
|
1263
|
+
set search_path = ''
|
|
1264
|
+
as $$
|
|
1265
|
+
select exists (select 1 from (select (p_row).*) r where ${where})
|
|
1266
|
+
$$;
|
|
1267
|
+
revoke execute on function ${fn}(${table}, text) from public, anon;
|
|
1268
|
+
grant execute on function ${fn}(${table}, text) to authenticated;`;
|
|
1269
|
+
}
|
|
1270
|
+
function resourceSql(ctx, node, branches, tables) {
|
|
1271
|
+
const name = rowsHelper(node.name);
|
|
1272
|
+
const fn = qualified$1(ctx, name);
|
|
1273
|
+
const table = quoteTable(qualifiedTable$1(tableFor(node.name, tables)));
|
|
1274
|
+
const cases = [...branches.keys()].toSorted().flatMap((key) => {
|
|
1275
|
+
const sql = permissionCase(key, branches.get(key) ?? []);
|
|
1276
|
+
return sql === void 0 ? [] : [sql];
|
|
1277
|
+
});
|
|
1278
|
+
const where = cases.length === 0 ? "false" : `case p_permission\n${cases.join("\n")}\n else false\n end`;
|
|
1279
|
+
const rows = `-- ${node.name}: ids of the rows the caller may act on with p_permission, as the table's policies decide it
|
|
1280
|
+
create or replace function ${fn}(p_permission text)
|
|
1281
|
+
returns setof text
|
|
1282
|
+
language sql
|
|
1283
|
+
stable
|
|
1284
|
+
security definer
|
|
1285
|
+
set search_path = ''
|
|
1286
|
+
as $$
|
|
1287
|
+
select ${quoteIdent$1(node.id)}::text from ${table}
|
|
1288
|
+
where ${where}
|
|
1289
|
+
$$;
|
|
1290
|
+
revoke execute on function ${fn}(text) from public, anon;
|
|
1291
|
+
grant execute on function ${fn}(text) to authenticated;
|
|
1292
|
+
${rowSql(ctx, name, table, where)}`;
|
|
1293
|
+
if (ctx.dialect === "neon") return rows;
|
|
1294
|
+
return `${rows}
|
|
1295
|
+
-- the same rows for a user the caller names, with claims it supplies; no client role may execute it
|
|
1296
|
+
${forUserSql(ctx, qualified$1(ctx, `${name}_for`), fn)}`;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* `permitted_<resource>_rows(p_permission)` for each resource `select`
|
|
1300
|
+
* names (or every resource a grant reaches with `true`): the ids of its rows
|
|
1301
|
+
* the caller may read, update or delete with that permission, from the same
|
|
1302
|
+
* compiled allows and denies the table policies use, so closure walks, link
|
|
1303
|
+
* hops, the restricted stop, `includes`, groups and `requires` all apply. A
|
|
1304
|
+
* `security definer` helper, so hand-written policies and functions call it
|
|
1305
|
+
* without restating the graph. Outside the `neon` dialect a
|
|
1306
|
+
* `permitted_<resource>_rows_for(p_user, p_permission, p_claims)` form runs it
|
|
1307
|
+
* as the named user, with the claims the caller supplies, for trusted SQL.
|
|
1308
|
+
*/
|
|
1309
|
+
function rowHelpersSql(ctx, policy, branches, select, tables, warnings) {
|
|
1310
|
+
const names = select === true ? [...new Set(branches.flatMap((branch) => branch.resource === void 0 ? [] : [branch.resource]))].toSorted() : [...new Set(select)];
|
|
1311
|
+
const called = inheritTargets(policy);
|
|
1312
|
+
for (const name of called) if (!names.includes(name)) names.push(name);
|
|
1313
|
+
const chunks = called.map((name) => stubSql(ctx, name));
|
|
1314
|
+
for (const name of names) {
|
|
1315
|
+
const node = policy.resources.get(name);
|
|
1316
|
+
if (node === void 0) throw new Error(`PermDock CLI: rls.rowHelpers names '${name}', which the policy does not declare`);
|
|
1317
|
+
chunks.push(resourceSql(ctx, node, rowBranchesOf(branches, name), tables));
|
|
1318
|
+
}
|
|
1319
|
+
if (chunks.length > 0 && ctx.dialect === "neon") warnings.push("rls.rowHelpers: the neon dialect reads the subject from auth.session(), which SQL cannot set, so no permitted_<resource>_rows_for is written");
|
|
1320
|
+
return chunks.length === 0 ? "" : `${chunks.join("\n\n")}\n`;
|
|
1321
|
+
}
|
|
1322
|
+
//#endregion
|
|
1323
|
+
//#region src/cli/rls-shims.ts
|
|
1324
|
+
const NAME = /^[a-z_][a-z0-9_]*$/u;
|
|
1325
|
+
/**
|
|
1326
|
+
* The key a legacy caller passes, mapped the way `rls migrate` maps it:
|
|
1327
|
+
* `keys` and `renamed` exactly, then the longest `prefixes` entry.
|
|
1328
|
+
*/
|
|
1329
|
+
function mappedKey(config, renamed) {
|
|
1330
|
+
const exact = {
|
|
1331
|
+
...renamed,
|
|
1332
|
+
...config.keys
|
|
1333
|
+
};
|
|
1334
|
+
const prefixes = Object.entries(config.prefixes ?? {}).toSorted(([a], [b]) => b.length - a.length);
|
|
1335
|
+
const fallback = prefixes.length === 0 ? "p_key" : `case ${prefixes.map(([from, to]) => `when pg_catalog.starts_with(p_key, ${quoteLiteral(from)}) then ${quoteLiteral(to)} || pg_catalog.substr(p_key, ${String(from.length + 1)})`).join(" ")} else p_key end`;
|
|
1336
|
+
if (Object.keys(exact).length === 0) return fallback;
|
|
1337
|
+
return `coalesce(${quoteLiteral(JSON.stringify(exact))}::jsonb ->> p_key, ${fallback})`;
|
|
1338
|
+
}
|
|
1339
|
+
function scopeOf(ctx, scope) {
|
|
1340
|
+
if (!ctx.scopes.some((item) => item.name === scope)) throw new Error(`PermDock CLI: rls.migrate helper scope '${scope}' is not a declared scope`);
|
|
1341
|
+
return scope;
|
|
1342
|
+
}
|
|
1343
|
+
function addKey(list, key) {
|
|
1344
|
+
if (!list.includes(key)) list.push(key);
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* The grant keys behind each permission, per scope: the keys of its
|
|
1348
|
+
* unconditional allows, and every deny key. A shim cannot apply a row
|
|
1349
|
+
* condition or a validity window, so a conditional allow answers nothing and
|
|
1350
|
+
* a conditional deny always subtracts; the conditioned keys are listed apart.
|
|
1351
|
+
* A break-glass key answers only the break-glass read, so it is left out too.
|
|
1352
|
+
* Rows seeded under a former key are left out; the shim maps a former key to
|
|
1353
|
+
* the current one first.
|
|
1354
|
+
*/
|
|
1355
|
+
function shimGrants(rows, conditioned, renamed) {
|
|
1356
|
+
const byScope = /* @__PURE__ */ new Map();
|
|
1357
|
+
for (const row of rows.toSorted((a, b) => a.grantKey < b.grantKey ? -1 : a.grantKey > b.grantKey ? 1 : 0)) {
|
|
1358
|
+
if (Object.hasOwn(renamed, row.permission) || row.effect === "allow" && row.grantKey === breakGlassKey(row.permission)) continue;
|
|
1359
|
+
const scope = byScope.get(row.scope) ?? {};
|
|
1360
|
+
byScope.set(row.scope, scope);
|
|
1361
|
+
const keys = (Object.hasOwn(scope, row.permission) ? scope[row.permission] : void 0) ?? {
|
|
1362
|
+
allow: [],
|
|
1363
|
+
deny: []
|
|
1364
|
+
};
|
|
1365
|
+
scope[row.permission] = keys;
|
|
1366
|
+
const isConditioned = conditioned.has(row.grantKey);
|
|
1367
|
+
if (row.effect === "allow") {
|
|
1368
|
+
if (isConditioned) {
|
|
1369
|
+
keys["conditioned-allow"] ??= [];
|
|
1370
|
+
addKey(keys["conditioned-allow"], row.grantKey);
|
|
1371
|
+
} else addKey(keys.allow, row.grantKey);
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
addKey(keys.deny, row.grantKey);
|
|
1375
|
+
if (isConditioned) {
|
|
1376
|
+
keys["conditioned-deny"] ??= [];
|
|
1377
|
+
addKey(keys["conditioned-deny"], row.grantKey);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
return byScope;
|
|
1381
|
+
}
|
|
1382
|
+
function shimMap(grants, scope) {
|
|
1383
|
+
const entries = Object.entries(grants.get(scope) ?? {}).flatMap(([permission, keys]) => keys.allow.length === 0 && keys.deny.length === 0 ? [] : [[permission, {
|
|
1384
|
+
allow: keys.allow,
|
|
1385
|
+
deny: keys.deny
|
|
1386
|
+
}]]);
|
|
1387
|
+
return JSON.stringify(Object.fromEntries(entries));
|
|
1388
|
+
}
|
|
1389
|
+
function shimFunction(name, args, argTypes, returns, sql) {
|
|
1390
|
+
return `create or replace function ${name}(${args})
|
|
1391
|
+
returns ${returns}
|
|
1392
|
+
language sql
|
|
1393
|
+
stable
|
|
1394
|
+
security definer
|
|
1395
|
+
set search_path = ''
|
|
1396
|
+
as $$
|
|
1397
|
+
${sql}
|
|
1398
|
+
$$;
|
|
1399
|
+
revoke execute on function ${name}(${argTypes}) from public, anon;
|
|
1400
|
+
grant execute on function ${name}(${argTypes}) to authenticated;`;
|
|
1401
|
+
}
|
|
1402
|
+
function grantKeys(keys, scope, effect) {
|
|
1403
|
+
return `pg_catalog.jsonb_array_elements_text(coalesce(${keys.map(scope)} -> (${keys.key}) -> ${quoteLiteral(effect)}, '[]'::jsonb)) g(grant_key)`;
|
|
1404
|
+
}
|
|
1405
|
+
/** The instances of `scope` the caller holds an unconditional allow of the key in, minus those it holds a deny in. */
|
|
1406
|
+
function idsSql(ctx, keys, scope) {
|
|
1407
|
+
const helper = qualified$1(ctx, permittedIdsHelper(scope));
|
|
1408
|
+
return ` select a.id
|
|
1409
|
+
from ${grantKeys(keys, scope, "allow")}
|
|
1410
|
+
cross join lateral ${helper}(g.grant_key) a(id)
|
|
1411
|
+
except
|
|
1412
|
+
select d.id
|
|
1413
|
+
from ${grantKeys(keys, scope, "deny")}
|
|
1414
|
+
cross join lateral ${helper}(g.grant_key) d(id)`;
|
|
1415
|
+
}
|
|
1416
|
+
function hasSql(ctx, keys) {
|
|
1417
|
+
const has = qualified$1(ctx, HELPERS.has);
|
|
1418
|
+
return `exists (select 1 from ${grantKeys(keys, "global", "allow")} where ${has}(g.grant_key))
|
|
1419
|
+
and not exists (select 1 from ${grantKeys(keys, "global", "deny")} where ${has}(g.grant_key))`;
|
|
1420
|
+
}
|
|
1421
|
+
function shim(ctx, config, keys, fn, helper) {
|
|
1422
|
+
switch (helper.form) {
|
|
1423
|
+
case "ids": {
|
|
1424
|
+
const scope = scopeOf(ctx, helper.scope);
|
|
1425
|
+
return shimFunction(fn, "p_key text", "text", `setof ${scopeTypeOf(ctx, scope)}`, idsSql(ctx, keys, scope));
|
|
1426
|
+
}
|
|
1427
|
+
case "row": {
|
|
1428
|
+
const scope = scopeOf(ctx, helper.scope);
|
|
1429
|
+
const type = scopeTypeOf(ctx, scope);
|
|
1430
|
+
return shimFunction(fn, `p_id ${type}, p_key text`, `${type}, text`, "boolean", ` select coalesce(p_id in (\n${idsSql(ctx, keys, scope)}\n ), false)`);
|
|
1431
|
+
}
|
|
1432
|
+
case "membership": {
|
|
1433
|
+
const scope = scopeOf(ctx, helper.scope);
|
|
1434
|
+
const type = scopeTypeOf(ctx, scope);
|
|
1435
|
+
return shimFunction(fn, `p_id ${type}`, type, "boolean", ` select coalesce(p_id in (select ${qualified$1(ctx, memberIdsHelper(scope))}()), false)`);
|
|
1436
|
+
}
|
|
1437
|
+
case "global": return shimFunction(fn, "p_key text", "text", "boolean", ` select ${hasSql(ctx, keys)}`);
|
|
1438
|
+
case "scoped": {
|
|
1439
|
+
const literals = /* @__PURE__ */ new Map();
|
|
1440
|
+
for (const scope of ctx.scopes) literals.set(scope.name, scope.name);
|
|
1441
|
+
for (const [literal, scope] of Object.entries(config.scopes ?? {})) literals.set(literal, scopeOf(ctx, scope));
|
|
1442
|
+
const global = config.globalScopes ?? [];
|
|
1443
|
+
return shimFunction(fn, "p_scope text, p_id text, p_key text", "text, text, text", "boolean", ` select case\n${[...global.length === 0 ? [] : [` when p_scope in (${global.map(quoteLiteral).join(", ")}) then ${hasSql(ctx, keys)}`], ...[...literals].filter(([literal]) => !global.includes(literal)).map(([literal, scope]) => ` when p_scope = ${quoteLiteral(literal)} then coalesce(p_id in (select s.id::text from (\n${idsSql(ctx, keys, scope)}\n ) s(id)), false)`)].join("\n")}\n else false\n end`);
|
|
1444
|
+
}
|
|
1445
|
+
default: return helper;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* One wrapper per `rls.migrate.helpers` entry under its legacy name, so SQL
|
|
1450
|
+
* that `rls migrate` has not rewritten yet (function bodies, views, triggers)
|
|
1451
|
+
* answers from the PermDock helpers. A wrapper maps the legacy key to the
|
|
1452
|
+
* permission key, then to the grant keys `grants` lists for the helper's
|
|
1453
|
+
* scope, so a permission whose grants are split by condition still answers.
|
|
1454
|
+
* Each is `security definer` with an empty `search_path`, so a caller needs
|
|
1455
|
+
* `execute` on the wrapper only, not usage on the helper schema.
|
|
1456
|
+
*/
|
|
1457
|
+
function shimsSql(ctx, config, shims, renamed, grants) {
|
|
1458
|
+
const schema = shims.schema ?? "public";
|
|
1459
|
+
const keys = {
|
|
1460
|
+
key: mappedKey(config, renamed),
|
|
1461
|
+
map: (scope) => `${quoteLiteral(shimMap(grants, scope))}::jsonb`
|
|
1462
|
+
};
|
|
1463
|
+
const chunks = [`-- permdock shims: legacy helper names over the permdock helpers; drop each once doctor reports no caller (PD056)`];
|
|
1464
|
+
for (const [name, helper] of Object.entries(config.helpers).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
1465
|
+
if (!NAME.test(name)) throw new Error(`PermDock CLI: unsafe rls.migrate helper name '${name}'`);
|
|
1466
|
+
chunks.push(shim(ctx, config, keys, `${quoteIdent$1(schema)}.${name}`, helper));
|
|
1467
|
+
}
|
|
1468
|
+
return `${chunks.join("\n\n")}\n`;
|
|
1469
|
+
}
|
|
1470
|
+
//#endregion
|
|
1471
|
+
//#region src/cli/rls-generate.ts
|
|
1472
|
+
async function loadPolicy(cwd, config, from) {
|
|
1473
|
+
const policyPath = from === "drizzle" ? config.policy : from ?? config.policy;
|
|
1474
|
+
if (policyPath === void 0) throw new Error("PermDock CLI: rls generate needs policy in permdock.config.ts or --from");
|
|
1475
|
+
return asPolicy(pickNamed(await loadModule(resolve(cwd, policyPath)), ["policy"]));
|
|
1476
|
+
}
|
|
1477
|
+
/** `rls.customRoleWrites.requires` as permission keys, or why it names none. */
|
|
1478
|
+
function writeRequirement(policy, requires) {
|
|
1479
|
+
if (requires === void 0) return {};
|
|
1480
|
+
const leaves = listPermissions(policy.permissions);
|
|
1481
|
+
const keys = requires === "manageRoles" ? leaves.filter((leaf) => leaf.meta.manageRoles === true).map((leaf) => leaf.key) : [...requires];
|
|
1482
|
+
if (keys.length === 0) return { error: requires === "manageRoles" ? "rls.customRoleWrites.requires is 'manageRoles', but no permission declares meta.manageRoles" : "rls.customRoleWrites.requires names no permission" };
|
|
1483
|
+
const known = new Set(leaves.map((leaf) => leaf.key));
|
|
1484
|
+
const unknown = keys.filter((key) => !known.has(key));
|
|
1485
|
+
if (unknown.length > 0) return { error: `rls.customRoleWrites.requires names undeclared permissions: ${unknown.join(", ")}` };
|
|
1486
|
+
return { keys: [...new Set(keys)].toSorted() };
|
|
1487
|
+
}
|
|
1488
|
+
/** Declared role names, and those a tenant admin may compose into custom roles. */
|
|
1489
|
+
function customRoleNames(policy, requires) {
|
|
1490
|
+
const declared = [...roleNames(policy)].toSorted();
|
|
1491
|
+
const assignable = declared.filter((name) => policy.rolesByName.get(name)?.assignable ?? listRoles(policy.vocabulary.roles).some((leaf) => leaf.key === name && leaf.assignable));
|
|
1492
|
+
const renamed = renamedKeys(policy.vocabulary.permissions);
|
|
1493
|
+
const leaves = listPermissions(policy.permissions);
|
|
1494
|
+
const manage = leaves.filter((leaf) => leaf.meta.manageRoles === true).map((leaf) => leaf.key);
|
|
1495
|
+
return {
|
|
1496
|
+
declared,
|
|
1497
|
+
assignable,
|
|
1498
|
+
...renamed.size === 0 ? {} : { renamed: Object.fromEntries(renamed) },
|
|
1499
|
+
permissions: leaves.map((leaf) => leaf.key).toSorted(),
|
|
1500
|
+
...manage.length === 0 ? {} : { manage: manage.toSorted() },
|
|
1501
|
+
...requires === void 0 ? {} : { requires },
|
|
1502
|
+
...policy.levels === void 0 ? {} : { levels: true }
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
function moveUndiffed(sql, label, part) {
|
|
1506
|
+
const { kept, moved } = splitUndiffed(sql);
|
|
1507
|
+
const [first = "", ...rest] = kept.split("\n");
|
|
1508
|
+
return {
|
|
1509
|
+
sql: [
|
|
1510
|
+
first,
|
|
1511
|
+
`-- what supabase db diff drops from this part is in ${label}`,
|
|
1512
|
+
...rest
|
|
1513
|
+
].join("\n"),
|
|
1514
|
+
grants: `-- the privileges and view options supabase db diff drops from the ${part} part\n${moved.join("\n")}`
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* The row columns that belong to a table. With `rls.tables` set, a resource
|
|
1519
|
+
* it does not name is a table only when generated policies are written for
|
|
1520
|
+
* it; with `--helpers-only` it has none, so it gets no index.
|
|
1521
|
+
*/
|
|
1522
|
+
function tableColumns(rowColumns, tables, helpersOnly, warnings) {
|
|
1523
|
+
if (tables === void 0) return rowColumns;
|
|
1524
|
+
const unmapped = [...new Set(rowColumns.map((item) => item.resource).filter((resource) => !Object.hasOwn(tables, resource)))].toSorted();
|
|
1525
|
+
if (unmapped.length === 0) return rowColumns;
|
|
1526
|
+
if (helpersOnly) {
|
|
1527
|
+
warnings.push(`no index is suggested for resources without an rls.tables entry: ${unmapped.join(", ")}; map each one that is a table`);
|
|
1528
|
+
return rowColumns.filter((item) => Object.hasOwn(tables, item.resource));
|
|
1529
|
+
}
|
|
1530
|
+
warnings.push(`policies for resources without an rls.tables entry target a table named after the resource: ${unmapped.map((resource) => qualifiedTable$1(resource)).join(", ")}; map each one to its table`);
|
|
1531
|
+
return rowColumns;
|
|
1532
|
+
}
|
|
1533
|
+
async function runRlsGenerate(input) {
|
|
1534
|
+
const rls = input.config.rls;
|
|
1535
|
+
const fieldsMode = input.fields ?? rls?.fields;
|
|
1536
|
+
if (fieldsMode !== void 0 && fieldsMode !== "views") return {
|
|
1537
|
+
code: 2,
|
|
1538
|
+
output: `rls generate --fields must be views (got ${fieldsMode})`,
|
|
1539
|
+
text: ""
|
|
1540
|
+
};
|
|
1541
|
+
const revokeColumns = input.revokeColumns === true || rls?.revokeColumns === true;
|
|
1542
|
+
if (revokeColumns && fieldsMode === void 0) return {
|
|
1543
|
+
code: 2,
|
|
1544
|
+
output: "rls generate --revoke-columns needs --fields views",
|
|
1545
|
+
text: ""
|
|
1546
|
+
};
|
|
1547
|
+
const policy = await loadPolicy(input.cwd, input.config, input.from);
|
|
1548
|
+
const memberships = parseMembershipsFlag(input.memberships) ?? rls?.memberships;
|
|
1549
|
+
if (input.rbac && input.dialect !== "supabase") return {
|
|
1550
|
+
code: 2,
|
|
1551
|
+
output: `rls generate --rbac supabase needs --dialect supabase (got ${input.dialect})`,
|
|
1552
|
+
text: ""
|
|
1553
|
+
};
|
|
1554
|
+
const schema = input.rbacSchema ?? rls?.schema ?? rls?.rbac?.schema ?? "permdock";
|
|
1555
|
+
const authorize = resolveAuthorize(input.config, {
|
|
1556
|
+
authorize: input.authorize,
|
|
1557
|
+
rbac: input.rbac,
|
|
1558
|
+
memberships
|
|
1559
|
+
});
|
|
1560
|
+
const memberSources = rls?.membershipSources ?? input.config.supabase?.hook?.memberships;
|
|
1561
|
+
const sources = authorize === "database" ? memberSources : void 0;
|
|
1562
|
+
const scopes = scopeList(policy.scopes);
|
|
1563
|
+
const suspension = checkSuspension(rls?.suspension, scopes);
|
|
1564
|
+
const hookRoles = input.config.supabase?.hook?.roles;
|
|
1565
|
+
const roles = rls?.roles ?? (hookRoles === false ? void 0 : hookRoles);
|
|
1566
|
+
if (roles !== void 0 && input.rbac) return {
|
|
1567
|
+
code: 2,
|
|
1568
|
+
output: "rls generate --rbac supabase creates its own user_roles (user_id, role app_role); drop rls.roles (or supabase.hook.roles) or the --rbac flag",
|
|
1569
|
+
text: ""
|
|
1570
|
+
};
|
|
1571
|
+
const requires = writeRequirement(policy, rls?.customRoleWrites?.requires);
|
|
1572
|
+
if (requires.error !== void 0) return {
|
|
1573
|
+
code: 2,
|
|
1574
|
+
output: requires.error,
|
|
1575
|
+
text: ""
|
|
1576
|
+
};
|
|
1577
|
+
const readers = rls?.trustedReaders;
|
|
1578
|
+
if (readers !== void 0 && (!Array.isArray(readers) || !readers.every((name) => typeof name === "string" && name !== ""))) return {
|
|
1579
|
+
code: 2,
|
|
1580
|
+
output: "rls.trustedReaders must list Postgres role names, such as ['service_role']",
|
|
1581
|
+
text: ""
|
|
1582
|
+
};
|
|
1583
|
+
const ownership = ownershipRules(policy, scopes);
|
|
1584
|
+
const graph = graphPlan(policy);
|
|
1585
|
+
const apiKeys = apiKeysPlan(rls?.apiKeys, new Set(roleNames(policy)), Object.fromEntries(renamedKeys(policy.vocabulary.permissions)));
|
|
1586
|
+
const ctx = {
|
|
1587
|
+
dialect: input.dialect,
|
|
1588
|
+
scopes,
|
|
1589
|
+
tenantClaim: rls?.tenantClaim ?? "tenant_id",
|
|
1590
|
+
...rls?.tenants === "all" ? { tenants: "all" } : {},
|
|
1591
|
+
gucPrefix: input.gucPrefix ?? rls?.gucPrefix ?? "app",
|
|
1592
|
+
...rls?.actions === void 0 ? {} : { actions: rls.actions },
|
|
1593
|
+
inlineFunctions: input.inlineFunctions || rls?.inlineFunctions === true,
|
|
1594
|
+
schema,
|
|
1595
|
+
authorize,
|
|
1596
|
+
roleClaim: rls?.roleClaim ?? "user_role",
|
|
1597
|
+
tenantType: input.tenantType ?? rls?.tenantType ?? "uuid",
|
|
1598
|
+
...rls?.teamType === void 0 ? {} : { teamType: rls.teamType },
|
|
1599
|
+
...rls?.scopeTypes === void 0 ? {} : { scopeTypes: rls.scopeTypes },
|
|
1600
|
+
...memberships === void 0 ? {} : { memberships },
|
|
1601
|
+
...sources === void 0 || sources.length === 0 ? {} : { sources },
|
|
1602
|
+
...memberSources === void 0 || memberSources.length === 0 ? {} : { memberSources },
|
|
1603
|
+
...suspension === void 0 ? {} : { suspension },
|
|
1604
|
+
...roles === void 0 ? {} : { roles },
|
|
1605
|
+
...input.customRoles === true || rls?.customRoles === true ? { customRoles: {
|
|
1606
|
+
...customRoleNames(policy, requires.keys),
|
|
1607
|
+
...rls?.customRoleWrites?.roles === void 0 ? {} : { table: rls.customRoleWrites.roles }
|
|
1608
|
+
} } : {},
|
|
1609
|
+
...input.capabilities === true || rls?.capabilities === true ? { capabilities: true } : {},
|
|
1610
|
+
...apiKeys === void 0 ? {} : { apiKeys },
|
|
1611
|
+
...rls?.anonymousSignIns === "deny" ? { anonymousSignIns: "deny" } : {},
|
|
1612
|
+
...ownership === void 0 ? {} : { ownership },
|
|
1613
|
+
...rls?.ownershipTriggers === void 0 ? {} : { skipOwnershipTriggers: rls.ownershipTriggers === false ? "all" : Object.keys(rls.ownershipTriggers).map((key) => {
|
|
1614
|
+
const name = resolveScope(scopes, key);
|
|
1615
|
+
if (name === void 0) throw new Error(`PermDock CLI: rls.ownershipTriggers.${key} names a scope the policy does not declare`);
|
|
1616
|
+
return name;
|
|
1617
|
+
}) },
|
|
1618
|
+
...rls?.assignments === void 0 ? {} : { assignments: rls.assignments === true ? { tables: [] } : {
|
|
1619
|
+
tables: rls.assignments.tables ?? [],
|
|
1620
|
+
...rls.assignments.ownRole === void 0 ? {} : { ownRole: rls.assignments.ownRole }
|
|
1621
|
+
} },
|
|
1622
|
+
...fieldsMode === void 0 ? {} : { fields: fieldsMode },
|
|
1623
|
+
...graph.size === 0 ? {} : { graph: {
|
|
1624
|
+
closures: closureDepths(graph),
|
|
1625
|
+
resources: policy.resources,
|
|
1626
|
+
...rls?.tables === void 0 ? {} : { tables: rls.tables }
|
|
1627
|
+
} }
|
|
1628
|
+
};
|
|
1629
|
+
if (ctx.anonymousSignIns === "deny" && ctx.dialect !== "supabase") return {
|
|
1630
|
+
code: 2,
|
|
1631
|
+
output: `rls.anonymousSignIns needs --dialect supabase (got ${ctx.dialect})`,
|
|
1632
|
+
text: ""
|
|
1633
|
+
};
|
|
1634
|
+
const jsonSchema = rls?.jsonSchema;
|
|
1635
|
+
if (jsonSchema !== void 0 && jsonSchema !== "auto" && typeof jsonSchema !== "boolean") return {
|
|
1636
|
+
code: 2,
|
|
1637
|
+
output: `rls.jsonSchema must be 'auto', true or false (got ${JSON.stringify(jsonSchema)})`,
|
|
1638
|
+
text: ""
|
|
1639
|
+
};
|
|
1640
|
+
const warnings = [];
|
|
1641
|
+
if (jsonSchema !== void 0 && jsonSchema !== false && (rls?.approvals === void 0 || rls.approvals === false)) warnings.push("rls.jsonSchema constrains the approval store's body, which needs rls.approvals: true; nothing else is jsonb");
|
|
1642
|
+
if (authorize === "database") {
|
|
1643
|
+
for (const { name } of ctx.scopes) if (policy.grants.some((grant) => grant.scope === name) && scopeTable(ctx, name) === void 0 && scopeSources(ctx, name).length === 0) warnings.push(`${name}-scoped grants read the ${name} memberships table in database mode: set rls.memberships.scopes.${name}, rls.membershipSources or supabase.hook.memberships (or --memberships for the first scope), otherwise they deny`);
|
|
1644
|
+
}
|
|
1645
|
+
const compiled = compileGrants(policy, ctx, rls?.tables, warnings, input.skipClosures, rls?.rowHelpers !== void 0 || inheritTargets(policy).length > 0);
|
|
1646
|
+
const helpersOnly = input.helpersOnly === true || rls?.helpersOnly === true;
|
|
1647
|
+
if (helpersOnly && (input.target !== "sql" || revokeColumns)) return {
|
|
1648
|
+
code: 2,
|
|
1649
|
+
output: "rls generate --helpers-only needs --target sql and no --revoke-columns: the table grants are hand-written, so revoke the restricted columns there",
|
|
1650
|
+
text: ""
|
|
1651
|
+
};
|
|
1652
|
+
const views = fieldsMode === void 0 ? [] : fieldViews(policy, ctx, compiled.branches, {
|
|
1653
|
+
revokeColumns,
|
|
1654
|
+
helpersOnly
|
|
1655
|
+
}, warnings);
|
|
1656
|
+
const force = input.force === true || rls?.force === true;
|
|
1657
|
+
if (force && views.some((view) => view.companion !== void 0)) warnings.push("--force with --revoke-columns: each <table>_visible_fields companion reads as its owner, so its owner needs BYPASSRLS or the restricted columns read as null");
|
|
1658
|
+
const policyName = input.policyName ?? rls?.policyName;
|
|
1659
|
+
const assembled = helpersOnly ? [] : assemblePolicies(fieldsMode === void 0 ? compiled.branches : rowBranches(compiled.branches), {
|
|
1660
|
+
perRole: input.policyPerRole === true || rls?.policyPerRole === true,
|
|
1661
|
+
...policyName === void 0 ? {} : { name: policyName }
|
|
1662
|
+
});
|
|
1663
|
+
const actorKinds = readOnlyActorKinds(rls?.readOnlyActors);
|
|
1664
|
+
if (actorKinds !== void 0 && helpersOnly) warnings.push("rls.readOnlyActors adds policies, and --helpers-only writes none: add the restrictive read-only policies to the hand-written ones");
|
|
1665
|
+
const policies = actorKinds === void 0 ? assembled : [...assembled, ...readOnlyActorPolicies(assembled, subjectClaimJsonSql(ctx, "act"), actorKinds)];
|
|
1666
|
+
let tablePolicies;
|
|
1667
|
+
try {
|
|
1668
|
+
tablePolicies = realtimeStoragePolicies(ctx, {
|
|
1669
|
+
scopes: new Set(ctx.scopes.map((scope) => scope.name)),
|
|
1670
|
+
keys: new Set(listPermissions(policy.vocabulary.permissions).map((leaf) => leaf.key)),
|
|
1671
|
+
rowConditions: new Set(policyRowConditionKeys(policy))
|
|
1672
|
+
}, compact({
|
|
1673
|
+
realtime: rls?.realtime,
|
|
1674
|
+
storage: rls?.storage
|
|
1675
|
+
}), warnings);
|
|
1676
|
+
} catch (cause) {
|
|
1677
|
+
return {
|
|
1678
|
+
code: 2,
|
|
1679
|
+
output: describeError(cause),
|
|
1680
|
+
text: ""
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
if (tablePolicies.length > 0 && helpersOnly) warnings.push("rls.realtime and rls.storage add policies, and --helpers-only writes none: no realtime.messages or storage.objects policy is written");
|
|
1684
|
+
const servicePolicies = tablePolicies.length === 0 || helpersOnly ? [] : [...tablePolicies, ...actorKinds === void 0 ? [] : readOnlyActorPolicies(tablePolicies, subjectClaimJsonSql(ctx, "act"), actorKinds)];
|
|
1685
|
+
const helperTables = servicePolicies.length === 0 ? void 0 : [SERVICE_TABLES_HEADER, ...servicePolicies.map(policySql)].join("\n");
|
|
1686
|
+
const guardedTables = servicePolicies.length === 0 ? void 0 : [`${SERVICE_TABLES_HEADER} A stack without the service has no table, so each policy is skipped there.`, ...servicePolicies.map(guardedPolicySql)].join("\n");
|
|
1687
|
+
const rootMapped = ctx.scopes[0] === void 0 ? void 0 : scopeTable(ctx, ctx.scopes[0].name);
|
|
1688
|
+
const rootTable = rootMapped === void 0 ? void 0 : {
|
|
1689
|
+
...rootMapped.table,
|
|
1690
|
+
tenant: rootMapped.column
|
|
1691
|
+
};
|
|
1692
|
+
const rbac = input.rbac ? rbacScaffold(policy, {
|
|
1693
|
+
schema,
|
|
1694
|
+
authorize,
|
|
1695
|
+
...rootTable === void 0 ? {} : { memberships: rootTable },
|
|
1696
|
+
...ctx.scopes[0] === void 0 ? {} : { scope: ctx.scopes[0].name },
|
|
1697
|
+
...ctx.customRoles === void 0 ? {} : { customRoles: {
|
|
1698
|
+
declared: ctx.customRoles.declared,
|
|
1699
|
+
...ctx.customRoles.levels === true ? { levels: true } : {}
|
|
1700
|
+
} },
|
|
1701
|
+
context: ctx
|
|
1702
|
+
}) : void 0;
|
|
1703
|
+
if (rbac !== void 0) warnings.push(...rbac.warnings);
|
|
1704
|
+
if (input.rbac) warnings.push("the token hook that writes user_role and memberships comes from permdock supabase hook generate");
|
|
1705
|
+
const owned = [ownershipSql(ctx), assignmentSql(ctx)].filter((part) => part !== "").join("\n");
|
|
1706
|
+
if (ctx.assignments !== void 0 && (ownership === void 0 || ownership.assigns.length === 0)) warnings.push("rls.assignments needs a role that declares assigns: no assignment trigger is written");
|
|
1707
|
+
let approvals;
|
|
1708
|
+
try {
|
|
1709
|
+
approvals = rls?.approvals === true ? approvalStoreSql(ctx, jsonSchema ?? false) : typeof rls?.approvals === "object" ? adoptedApprovalStoreSql(ctx, rls.approvals, jsonSchema ?? false) : void 0;
|
|
1710
|
+
} catch (cause) {
|
|
1711
|
+
return {
|
|
1712
|
+
code: 2,
|
|
1713
|
+
output: describeError(cause),
|
|
1714
|
+
text: ""
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
1717
|
+
const graphed = graphSql(ctx, graph, rls?.tables);
|
|
1718
|
+
let rowHelpers = "";
|
|
1719
|
+
if (rls?.rowHelpers !== void 0 || inheritTargets(policy).length > 0) try {
|
|
1720
|
+
rowHelpers = rowHelpersSql(ctx, policy, [...fieldsMode === void 0 ? compiled.branches : rowBranches(compiled.branches), ...compiled.actionBranches], rls?.rowHelpers ?? [], rls?.tables, warnings);
|
|
1721
|
+
} catch (cause) {
|
|
1722
|
+
return {
|
|
1723
|
+
code: 2,
|
|
1724
|
+
output: describeError(cause),
|
|
1725
|
+
text: ""
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
const breakGlass = breakGlassSql(ctx, breakGlassEntries(policy, rls?.tables));
|
|
1729
|
+
const configured = rls?.shims;
|
|
1730
|
+
const shimsConfig = typeof configured === "object" ? configured : configured === true || input.shims === true ? {} : void 0;
|
|
1731
|
+
if (shimsConfig !== void 0 && rls?.migrate === void 0) return {
|
|
1732
|
+
code: 2,
|
|
1733
|
+
output: "PermDock CLI: rls generate --shims needs rls.migrate.helpers in permdock.config.ts",
|
|
1734
|
+
text: ""
|
|
1735
|
+
};
|
|
1736
|
+
const renamed = Object.fromEntries(renamedKeys(policy.vocabulary.permissions));
|
|
1737
|
+
const grants = shimGrants(compiled.rolePermissions, compiled.conditionedKeys, renamed);
|
|
1738
|
+
const anonExecute = rls?.anonExecute === true || views.some((view) => view.roles.includes("anon")) || compiled.branches.some((branch) => branch.roles.includes("anon") && [
|
|
1739
|
+
branch.access,
|
|
1740
|
+
branch.using,
|
|
1741
|
+
branch.check
|
|
1742
|
+
].some((sql) => sql?.includes("permdock_user_id") === true));
|
|
1743
|
+
const usesLiveSession = compiled.branches.some((branch) => [
|
|
1744
|
+
branch.access,
|
|
1745
|
+
branch.using,
|
|
1746
|
+
branch.check
|
|
1747
|
+
].some((sql) => sql?.includes(SESSION_LIVE_HELPER) === true));
|
|
1748
|
+
const shims = shimsConfig === void 0 || rls?.migrate === void 0 ? void 0 : shimsSql(ctx, rls.migrate, shimsConfig, renamed, grants);
|
|
1749
|
+
const preamble = [
|
|
1750
|
+
rbac?.head,
|
|
1751
|
+
helpersSql(ctx, compiled.rolePermissions, {
|
|
1752
|
+
levelReach: compiled.levelReach,
|
|
1753
|
+
permissions: listPermissions(policy.permissions).map((leaf) => leaf.key).toSorted(),
|
|
1754
|
+
userRoles: !input.rbac,
|
|
1755
|
+
anonExecute,
|
|
1756
|
+
withoutSeeds: splitsPart(input.split, "seeds"),
|
|
1757
|
+
...rls?.trustedReaders === void 0 ? {} : { trustedReaders: rls.trustedReaders }
|
|
1758
|
+
}),
|
|
1759
|
+
permissionHelpersSql(ctx, grants, renamed, anonExecute),
|
|
1760
|
+
usesLiveSession ? sessionLiveHelperSql(ctx) : void 0,
|
|
1761
|
+
approvals,
|
|
1762
|
+
owned === "" ? void 0 : owned,
|
|
1763
|
+
graphed === "" ? void 0 : graphed,
|
|
1764
|
+
breakGlass === "" ? void 0 : breakGlass,
|
|
1765
|
+
rowHelpers === "" ? void 0 : rowHelpers,
|
|
1766
|
+
shims,
|
|
1767
|
+
rbac?.tail
|
|
1768
|
+
].filter((part) => part !== void 0).join("\n");
|
|
1769
|
+
if (force) for (const entry of graph.values()) {
|
|
1770
|
+
const own = [...entry.relations].filter((name) => !("edge" in (entry.node.relations[name] ?? {})));
|
|
1771
|
+
if (own.length > 0) warnings.push(`--force: ${entry.node.name} relations ${own.join(", ")} are read from the ${entry.node.name} table itself, so ${graphHelper(entry.node.name)} would recurse through its own policy; keep ${entry.node.name} unforced or move them to edge tables`);
|
|
1772
|
+
}
|
|
1773
|
+
const outRel = input.out ?? rls?.out ?? defaultOut(input.target);
|
|
1774
|
+
const migration = input.target === "sql" ? "" : dialectRoles(migrationSql(policies, withHelperTables(preamble, helperTables), force, views), ctx.dialect);
|
|
1775
|
+
const migrationRel = migration === "" ? void 0 : migrationOut(outRel);
|
|
1776
|
+
const helpers = migrationRel === void 0 ? void 0 : basename(migrationRel);
|
|
1777
|
+
let text;
|
|
1778
|
+
switch (input.target) {
|
|
1779
|
+
case "sql":
|
|
1780
|
+
text = dialectRoles(withHelperTables(emitSql(policies, preamble, force, views), helperTables), ctx.dialect);
|
|
1781
|
+
break;
|
|
1782
|
+
case "drizzle":
|
|
1783
|
+
text = emitDrizzle(policies, compact({
|
|
1784
|
+
dialect: ctx.dialect,
|
|
1785
|
+
schema: rls?.drizzle?.schema,
|
|
1786
|
+
exports: rls?.drizzle?.exports,
|
|
1787
|
+
helpers
|
|
1788
|
+
}));
|
|
1789
|
+
break;
|
|
1790
|
+
case "prisma":
|
|
1791
|
+
text = emitPrisma(policies, compact({
|
|
1792
|
+
models: rls?.prisma?.models,
|
|
1793
|
+
helpers
|
|
1794
|
+
}));
|
|
1795
|
+
break;
|
|
1796
|
+
default: return input.target;
|
|
1797
|
+
}
|
|
1798
|
+
let indexes = indexTargets(ctx, tableColumns(compiled.rowColumns, rls?.tables, helpersOnly, warnings));
|
|
1799
|
+
if (input.db !== void 0) try {
|
|
1800
|
+
const pruned = pruneIndexTargets(indexes, await readTableIndexFacts(input.db, [...new Set(indexes.map((target) => target.table))], input.connect));
|
|
1801
|
+
indexes = pruned.targets;
|
|
1802
|
+
warnings.push(...pruned.warnings);
|
|
1803
|
+
} catch (cause) {
|
|
1804
|
+
return {
|
|
1805
|
+
code: 2,
|
|
1806
|
+
output: describeError(cause),
|
|
1807
|
+
text: ""
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
if (!splitsPart(input.split, "indexes")) for (const target of indexes) warnings.push(`index suggestion: create index on ${target.table} (${target.columns.join(", ")}), or add indexes to --split`);
|
|
1811
|
+
const extras = migration === "" ? {} : { migration };
|
|
1812
|
+
if (input.write === false) return {
|
|
1813
|
+
code: 0,
|
|
1814
|
+
output: warnings.join("\n"),
|
|
1815
|
+
text,
|
|
1816
|
+
policies,
|
|
1817
|
+
seeds: compiled.rolePermissions,
|
|
1818
|
+
indexes,
|
|
1819
|
+
keys: {
|
|
1820
|
+
permissions: listPermissions(policy.vocabulary.permissions).map((leaf) => leaf.key),
|
|
1821
|
+
rowConditions: [...policyRowConditionKeys(policy)],
|
|
1822
|
+
renamed: Object.fromEntries(renamedKeys(policy.vocabulary.permissions))
|
|
1823
|
+
},
|
|
1824
|
+
schema,
|
|
1825
|
+
helpersOnly,
|
|
1826
|
+
...extras
|
|
1827
|
+
};
|
|
1828
|
+
const planned = outputFiles({
|
|
1829
|
+
input,
|
|
1830
|
+
outRel,
|
|
1831
|
+
text,
|
|
1832
|
+
helpersOnly,
|
|
1833
|
+
sql: () => ({
|
|
1834
|
+
policies: dialectRoles(withHelperTables(emitSql(policies, "", force, views), helperTables), ctx.dialect),
|
|
1835
|
+
declarativePolicies: dialectRoles(emitSql(policies, "", force, views), ctx.dialect),
|
|
1836
|
+
...guardedTables === void 0 ? {} : { servicePolicies: dialectRoles(guardedTables, ctx.dialect) },
|
|
1837
|
+
helpers: dialectRoles(emitSql([], preamble, false, helpersOnly ? views : []), ctx.dialect),
|
|
1838
|
+
seeds: `${SEEDS_MARKER} schema=${schema}\n${seedSql(ctx, compiled.rolePermissions)}\n`,
|
|
1839
|
+
indexes: `${INDEXES_MARKER}\n${indexesSql(indexes)}\n`
|
|
1840
|
+
}),
|
|
1841
|
+
scopes,
|
|
1842
|
+
schema
|
|
1843
|
+
});
|
|
1844
|
+
if (typeof planned === "string") return {
|
|
1845
|
+
code: 2,
|
|
1846
|
+
output: planned,
|
|
1847
|
+
text,
|
|
1848
|
+
...extras
|
|
1849
|
+
};
|
|
1850
|
+
const files = [...planned];
|
|
1851
|
+
if (migrationRel !== void 0) files.push({
|
|
1852
|
+
part: "migration",
|
|
1853
|
+
rel: migrationRel,
|
|
1854
|
+
text: migration
|
|
1855
|
+
});
|
|
1856
|
+
if (input.check) {
|
|
1857
|
+
const drift = driftOf(input.cwd, files);
|
|
1858
|
+
return drift.length === 0 ? {
|
|
1859
|
+
code: 0,
|
|
1860
|
+
output: "rls generate up to date",
|
|
1861
|
+
text,
|
|
1862
|
+
...extras
|
|
1863
|
+
} : {
|
|
1864
|
+
code: 1,
|
|
1865
|
+
output: drift.map((line) => `rls generate drift: ${line}`).join("\n"),
|
|
1866
|
+
text,
|
|
1867
|
+
...extras
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
const written = writeSqlFiles(input.cwd, files);
|
|
1871
|
+
const extra = warnings.length === 0 ? "" : `\n${warnings.join("\n")}`;
|
|
1872
|
+
return {
|
|
1873
|
+
code: 0,
|
|
1874
|
+
output: `wrote ${written.wrote.join(", ")}${written.printed === "" ? "" : `\n${written.printed}`}${extra}`,
|
|
1875
|
+
text,
|
|
1876
|
+
...extras
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
/** Whether `--split` names `part`, which then leaves the helpers or the warnings. */
|
|
1880
|
+
const SERVICE_TABLES_HEADER = "-- Supabase Realtime and Storage: rls.realtime and rls.storage. RLS on both tables is Supabase's.";
|
|
1881
|
+
/**
|
|
1882
|
+
* A `realtime.messages` or `storage.objects` policy that applies only where
|
|
1883
|
+
* the table exists. The Realtime and Storage services create those tables, so
|
|
1884
|
+
* a local stack with the service off, and pg-delta's shadow database, have
|
|
1885
|
+
* none.
|
|
1886
|
+
*/
|
|
1887
|
+
function guardedPolicySql(item) {
|
|
1888
|
+
const body = policySql(item).trimEnd().split("\n").map((line) => ` ${line}`).join("\n");
|
|
1889
|
+
return `do $permdock$
|
|
1890
|
+
begin
|
|
1891
|
+
if to_regclass(${quoteLiteral(qualifiedTable$1(item.table))}) is not null then
|
|
1892
|
+
${body}
|
|
1893
|
+
end if;
|
|
1894
|
+
end
|
|
1895
|
+
$permdock$;
|
|
1896
|
+
`;
|
|
1897
|
+
}
|
|
1898
|
+
/** Appends the `realtime.messages` and `storage.objects` policies, which never get `tableSql`. */
|
|
1899
|
+
function withHelperTables(sql, helperTables) {
|
|
1900
|
+
if (helperTables === void 0) return sql;
|
|
1901
|
+
const tables = `${helperTables.trimEnd()}\n`;
|
|
1902
|
+
return sql.trim() === "" ? tables : `${sql.trimEnd()}\n\n${tables}`;
|
|
1903
|
+
}
|
|
1904
|
+
function splitsPart(raw, part) {
|
|
1905
|
+
const split = parseSplit(raw);
|
|
1906
|
+
return typeof split !== "string" && split?.includes(part) === true;
|
|
1907
|
+
}
|
|
1908
|
+
/** The files `generate` writes: one, or one per `--split` part, plus the hook's grants. */
|
|
1909
|
+
function outputFiles(plan) {
|
|
1910
|
+
const { input } = plan;
|
|
1911
|
+
const split = parseSplit(input.split);
|
|
1912
|
+
if (typeof split === "string") return split;
|
|
1913
|
+
if (split?.includes("policies") === true && plan.helpersOnly) return "rls generate --helpers-only writes no policies part; drop it from --split";
|
|
1914
|
+
if (split === void 0) {
|
|
1915
|
+
if (input.grantsOut !== void 0) return "rls generate --grants-out needs --split with the helpers or hook part";
|
|
1916
|
+
if (input.seedsOut !== void 0) return "rls generate --seeds-out needs --split with the seeds part";
|
|
1917
|
+
return [{
|
|
1918
|
+
part: "rls",
|
|
1919
|
+
rel: plan.outRel,
|
|
1920
|
+
text: plan.text
|
|
1921
|
+
}];
|
|
1922
|
+
}
|
|
1923
|
+
if (input.target !== "sql") return "rls generate --split needs --target sql";
|
|
1924
|
+
const pgDelta = input.out === void 0 && input.config.rls?.out === void 0 ? supabaseConfig(input.cwd).pgDelta : void 0;
|
|
1925
|
+
if (pgDelta === void 0 && !plan.outRel.includes("{part}")) return `rls generate --split needs {part} in --out, for example supabase/schemas/identity/056_permdock_{part}.sql (got ${plan.outRel})`;
|
|
1926
|
+
if (pgDelta !== void 0 && split.includes("helpers") && !split.includes("seeds")) return "rls generate --split helpers under pg-delta needs the seeds part with --seeds-out: pg-delta rejects the role_permissions rows in a declarative file";
|
|
1927
|
+
if (pgDelta !== void 0 && split.includes("seeds") && input.seedsOut === void 0) return "rls generate --split seeds under pg-delta needs --seeds-out: pg-delta does not diff the role_permissions rows, so they go in a migration";
|
|
1928
|
+
const at = (part, schema = plan.schema) => pgDelta === void 0 ? partPath(plan.outRel, part) : pgDeltaPath(pgDelta.schemaDir, part, schema);
|
|
1929
|
+
if (input.grantsOut !== void 0 && !split.includes("hook") && !split.includes("helpers")) return "rls generate --grants-out needs the helpers or hook part in --split";
|
|
1930
|
+
if (input.seedsOut !== void 0 && !split.includes("seeds")) return "rls generate --seeds-out needs the seeds part in --split";
|
|
1931
|
+
if (split.includes("hook") && input.config.supabase?.hook === void 0) return "rls generate --split hook needs supabase.hook in permdock.config.ts";
|
|
1932
|
+
const sql = plan.sql();
|
|
1933
|
+
const relocated = pgDelta === void 0 ? void 0 : sql.servicePolicies;
|
|
1934
|
+
if (relocated !== void 0 && split.includes("policies") && !split.includes("seeds")) return "rls generate --split policies under pg-delta with rls.realtime or rls.storage needs the seeds part with --seeds-out: the shadow database has no realtime.messages or storage.objects, so their policies go in that migration";
|
|
1935
|
+
const seeds = relocated === void 0 || !split.includes("policies") ? sql.seeds : `${sql.seeds}\n${relocated}`;
|
|
1936
|
+
const files = [];
|
|
1937
|
+
const label = grantsLabel(input.grantsOut);
|
|
1938
|
+
let grantsMarker = `${GRANTS_MARKER} schema=${plan.schema}`;
|
|
1939
|
+
const grants = [];
|
|
1940
|
+
for (const part of split) switch (part) {
|
|
1941
|
+
case "helpers": {
|
|
1942
|
+
if (label === void 0) {
|
|
1943
|
+
files.push({
|
|
1944
|
+
part,
|
|
1945
|
+
rel: at(part),
|
|
1946
|
+
text: sql.helpers
|
|
1947
|
+
});
|
|
1948
|
+
break;
|
|
1949
|
+
}
|
|
1950
|
+
const moved = moveUndiffed(sql.helpers, label, part);
|
|
1951
|
+
files.push({
|
|
1952
|
+
part,
|
|
1953
|
+
rel: at(part),
|
|
1954
|
+
text: moved.sql
|
|
1955
|
+
});
|
|
1956
|
+
grants.push(moved.grants);
|
|
1957
|
+
break;
|
|
1958
|
+
}
|
|
1959
|
+
case "seeds": {
|
|
1960
|
+
const out = input.seedsOut;
|
|
1961
|
+
if (out !== void 0 && isSeedsDirectory(input.cwd, out)) {
|
|
1962
|
+
const planned = seedsMigration(input.cwd, out, SEEDS_MARKER, seeds, input.io.now?.() ?? /* @__PURE__ */ new Date());
|
|
1963
|
+
if (!planned.current || input.check) files.push({
|
|
1964
|
+
part,
|
|
1965
|
+
rel: planned.rel,
|
|
1966
|
+
text: seeds
|
|
1967
|
+
});
|
|
1968
|
+
break;
|
|
1969
|
+
}
|
|
1970
|
+
files.push({
|
|
1971
|
+
part,
|
|
1972
|
+
rel: out ?? at(part),
|
|
1973
|
+
text: seeds
|
|
1974
|
+
});
|
|
1975
|
+
break;
|
|
1976
|
+
}
|
|
1977
|
+
case "indexes":
|
|
1978
|
+
files.push({
|
|
1979
|
+
part,
|
|
1980
|
+
rel: at(part),
|
|
1981
|
+
text: sql.indexes
|
|
1982
|
+
});
|
|
1983
|
+
break;
|
|
1984
|
+
case "policies":
|
|
1985
|
+
files.push({
|
|
1986
|
+
part,
|
|
1987
|
+
rel: at(part),
|
|
1988
|
+
text: pgDelta === void 0 ? sql.policies : sql.declarativePolicies
|
|
1989
|
+
});
|
|
1990
|
+
break;
|
|
1991
|
+
case "hook": {
|
|
1992
|
+
const hook = supabaseHookSql(plan.scopes, input.config, {}, label);
|
|
1993
|
+
files.push({
|
|
1994
|
+
part,
|
|
1995
|
+
rel: at(part, hook.manifest.hook.schema),
|
|
1996
|
+
text: hook.sql
|
|
1997
|
+
});
|
|
1998
|
+
const [marker = grantsMarker, ...rest] = hook.grants.split("\n");
|
|
1999
|
+
grantsMarker = marker;
|
|
2000
|
+
grants.push(rest.join("\n").trimEnd());
|
|
2001
|
+
break;
|
|
2002
|
+
}
|
|
2003
|
+
default: return part;
|
|
2004
|
+
}
|
|
2005
|
+
if (input.grantsOut !== void 0) files.push({
|
|
2006
|
+
part: "grants",
|
|
2007
|
+
rel: input.grantsOut,
|
|
2008
|
+
text: `${[grantsMarker, ...grants].join("\n")}\n`
|
|
2009
|
+
});
|
|
2010
|
+
return files;
|
|
2011
|
+
}
|
|
2012
|
+
//#endregion
|
|
2013
|
+
//#region src/cli/rls-introspect.ts
|
|
2014
|
+
const ROLES = ["anon", "authenticated"];
|
|
2015
|
+
/** Neon's `anonymous` is the role `rls generate` writes as `anon` for the other dialects. */
|
|
2016
|
+
function liveRole(role) {
|
|
2017
|
+
return role === "anonymous" ? "anon" : role;
|
|
2018
|
+
}
|
|
2019
|
+
const PRIVILEGES = [
|
|
2020
|
+
"select",
|
|
2021
|
+
"insert",
|
|
2022
|
+
"update",
|
|
2023
|
+
"delete"
|
|
2024
|
+
];
|
|
2025
|
+
/** `posts` and `"app"."posts"` as `public.posts` and `app.posts`. */
|
|
2026
|
+
function qualified(table) {
|
|
2027
|
+
const parts = table.split(".").map((part) => part.replaceAll("\"", ""));
|
|
2028
|
+
return parts.length === 1 ? `public.${parts[0] ?? ""}` : parts.join(".");
|
|
2029
|
+
}
|
|
2030
|
+
const FUNCTION_HEADER = /create or replace function ("?)([A-Za-z_][\w$]*)\1\.("?)([A-Za-z_][\w$]*)\3\s*\(([\s\S]*?)\nas \$/gu;
|
|
2031
|
+
function helpersOf(sql) {
|
|
2032
|
+
const found = /* @__PURE__ */ new Set();
|
|
2033
|
+
for (const match of sql.matchAll(FUNCTION_HEADER)) {
|
|
2034
|
+
const [header = "", , schema = "", , name = ""] = match;
|
|
2035
|
+
if (/\bsecurity definer\b/u.test(header)) found.add(`${schema}.${name}`);
|
|
2036
|
+
}
|
|
2037
|
+
return [...found];
|
|
2038
|
+
}
|
|
2039
|
+
function expectedRls(policies, sql, indexes = []) {
|
|
2040
|
+
const tables = [...new Set(policies.map((item) => qualified(item.table)))];
|
|
2041
|
+
const grants = {};
|
|
2042
|
+
for (const table of tables) {
|
|
2043
|
+
const byRole = {};
|
|
2044
|
+
for (const role of ROLES) {
|
|
2045
|
+
const commands = new Set(policies.filter((item) => qualified(item.table) === table && item.effect === "allow" && item.roles.includes(role)).map((item) => item.command));
|
|
2046
|
+
byRole[role] = PRIVILEGES.filter((privilege) => commands.has(privilege));
|
|
2047
|
+
}
|
|
2048
|
+
grants[table] = byRole;
|
|
2049
|
+
}
|
|
2050
|
+
return {
|
|
2051
|
+
policies: policies.map((item) => ({
|
|
2052
|
+
table: qualified(item.table),
|
|
2053
|
+
name: item.name,
|
|
2054
|
+
command: item.command,
|
|
2055
|
+
permissive: item.effect === "allow",
|
|
2056
|
+
roles: item.roles.toSorted()
|
|
2057
|
+
})),
|
|
2058
|
+
tables,
|
|
2059
|
+
grants,
|
|
2060
|
+
helpers: helpersOf(sql),
|
|
2061
|
+
indexes: indexes.map((target) => ({
|
|
2062
|
+
...target,
|
|
2063
|
+
table: qualified(target.table)
|
|
2064
|
+
}))
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* One warning per index target no index starts with. A missing index slows
|
|
2069
|
+
* the policy down but grants nothing, so these do not fail `verify`.
|
|
2070
|
+
*/
|
|
2071
|
+
function missingIndexes(expected, actual) {
|
|
2072
|
+
return expected.indexes.flatMap((target) => {
|
|
2073
|
+
const column = target.columns[0];
|
|
2074
|
+
const columns = actual.columns?.[target.table];
|
|
2075
|
+
return column === void 0 || columns !== void 0 && !columns.includes(column) || actual.leadingColumns[target.table]?.includes(column) === true ? [] : [`warning: ${target.table}: no index starts with ${column}, which the policies or helpers filter on; add it, or write it with rls generate --split ...,indexes`];
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
function sameList(left, right) {
|
|
2079
|
+
const a = left.toSorted();
|
|
2080
|
+
const b = right.toSorted();
|
|
2081
|
+
return a.length === b.length && a.every((item, index) => item === b[index]);
|
|
2082
|
+
}
|
|
2083
|
+
/** One line per difference; policy expressions are not compared, since Postgres rewrites their text. */
|
|
2084
|
+
function diffRls(expected, actual, options = {}) {
|
|
2085
|
+
const out = [];
|
|
2086
|
+
const found = new Map(actual.policies.map((item) => [`${item.table}\u0000${item.name}`, item]));
|
|
2087
|
+
for (const want of expected.policies) {
|
|
2088
|
+
const have = found.get(`${want.table}\u0000${want.name}`);
|
|
2089
|
+
if (have === void 0) {
|
|
2090
|
+
out.push(`${want.table}: policy ${want.name} is missing`);
|
|
2091
|
+
continue;
|
|
2092
|
+
}
|
|
2093
|
+
if (have.command !== want.command) out.push(`${want.table}: policy ${want.name} is for ${have.command}, expected ${want.command}`);
|
|
2094
|
+
if (have.permissive !== want.permissive) out.push(`${want.table}: policy ${want.name} is ${have.permissive ? "permissive" : "restrictive"}, expected ${want.permissive ? "permissive" : "restrictive"}`);
|
|
2095
|
+
if (!sameList(have.roles, want.roles)) out.push(`${want.table}: policy ${want.name} applies to ${have.roles.join(", ")}, expected ${want.roles.join(", ")}`);
|
|
2096
|
+
}
|
|
2097
|
+
const wanted = new Set(expected.policies.map((item) => `${item.table}\u0000${item.name}`));
|
|
2098
|
+
for (const have of actual.policies) if (expected.tables.includes(have.table) && !wanted.has(`${have.table}\u0000${have.name}`)) out.push(`${have.table}: policy ${have.name} is not generated (${have.permissive ? "permissive" : "restrictive"} ${have.command}); a permissive one widens access`);
|
|
2099
|
+
for (const table of expected.tables) {
|
|
2100
|
+
if (actual.rlsEnabled[table] !== true) {
|
|
2101
|
+
out.push(actual.rlsEnabled[table] === void 0 ? `${table}: table is missing` : `${table}: row level security is disabled`);
|
|
2102
|
+
continue;
|
|
2103
|
+
}
|
|
2104
|
+
for (const role of ROLES) {
|
|
2105
|
+
const want = expected.grants[table]?.[role] ?? [];
|
|
2106
|
+
const have = actual.grants[table]?.[role] ?? [];
|
|
2107
|
+
for (const privilege of want) if (!have.includes(privilege) && !(privilege === "select" && options.columnGrants === true)) out.push(`${table}: ${role} lacks ${privilege}, so the policy answers 42501 instead of filtering`);
|
|
2108
|
+
for (const privilege of have) if (!want.includes(privilege)) out.push(`${table}: ${role} holds ${privilege}, which no generated policy allows`);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
for (const helper of expected.helpers) {
|
|
2112
|
+
const have = actual.helpers[helper];
|
|
2113
|
+
if (have === void 0) {
|
|
2114
|
+
out.push(`${helper}: helper is missing`);
|
|
2115
|
+
continue;
|
|
2116
|
+
}
|
|
2117
|
+
if (!have.securityDefiner) out.push(`${helper}: helper is not security definer`);
|
|
2118
|
+
if (!have.emptySearchPath) out.push(`${helper}: helper does not set search_path = ''`);
|
|
2119
|
+
}
|
|
2120
|
+
return out;
|
|
2121
|
+
}
|
|
2122
|
+
const POLICIES_SQL = `select schemaname || '.' || tablename as target, policyname, cmd, permissive, roles::text[] as roles
|
|
2123
|
+
from pg_policies
|
|
2124
|
+
where schemaname || '.' || tablename = any($1::text[])`;
|
|
2125
|
+
const TABLES_SQL = `select n.nspname || '.' || c.relname as target, c.relrowsecurity as enabled
|
|
2126
|
+
from pg_class c
|
|
2127
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
2128
|
+
where c.relkind in ('r', 'p') and n.nspname || '.' || c.relname = any($1::text[])`;
|
|
2129
|
+
const GRANTS_SQL = `select table_schema || '.' || table_name as target, grantee, lower(privilege_type) as privilege
|
|
2130
|
+
from information_schema.role_table_grants
|
|
2131
|
+
where grantee in ('anon', 'anonymous', 'authenticated')
|
|
2132
|
+
and lower(privilege_type) in ('select', 'insert', 'update', 'delete')
|
|
2133
|
+
and table_schema || '.' || table_name = any($1::text[])`;
|
|
2134
|
+
const HELPERS_SQL = `select n.nspname || '.' || p.proname as target, p.prosecdef as definer,
|
|
2135
|
+
coalesce(array_to_string(p.proconfig, ','), '') as config
|
|
2136
|
+
from pg_proc p
|
|
2137
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
2138
|
+
where n.nspname || '.' || p.proname = any($1::text[])`;
|
|
2139
|
+
const INDEXES_SQL = `select n.nspname || '.' || c.relname as target, a.attname as leading
|
|
2140
|
+
from pg_index i
|
|
2141
|
+
join pg_class c on c.oid = i.indrelid
|
|
2142
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
2143
|
+
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = i.indkey[0]
|
|
2144
|
+
where i.indpred is null and n.nspname || '.' || c.relname = any($1::text[])`;
|
|
2145
|
+
function commandOf(cmd) {
|
|
2146
|
+
const text = String(cmd ?? "").toLowerCase();
|
|
2147
|
+
return text === "*" ? "all" : text;
|
|
2148
|
+
}
|
|
2149
|
+
function connectIntrospect(db) {
|
|
2150
|
+
return connectPgPool(db, "permdock rls verify --introspect");
|
|
2151
|
+
}
|
|
2152
|
+
/** Reads the catalogs for the tables and helpers `expected` names (postgres-meta's queries, trimmed). */
|
|
2153
|
+
async function introspectRls(db, expected, connect = connectIntrospect) {
|
|
2154
|
+
const client = await connect(db);
|
|
2155
|
+
try {
|
|
2156
|
+
const tables = [...expected.tables];
|
|
2157
|
+
const indexed = [...new Set(expected.indexes.map((target) => target.table))];
|
|
2158
|
+
const [policies, enabled, grants, helpers, indexes, columns] = await Promise.all([
|
|
2159
|
+
client.query(POLICIES_SQL, [tables]),
|
|
2160
|
+
client.query(TABLES_SQL, [tables]),
|
|
2161
|
+
client.query(GRANTS_SQL, [tables]),
|
|
2162
|
+
client.query(HELPERS_SQL, [[...expected.helpers]]),
|
|
2163
|
+
client.query(INDEXES_SQL, [indexed]),
|
|
2164
|
+
client.query(COLUMNS_SQL, [indexed])
|
|
2165
|
+
]);
|
|
2166
|
+
const columnsOf = {};
|
|
2167
|
+
for (const row of columns.rows) (columnsOf[String(row["target"])] ??= []).push(String(row["name"]));
|
|
2168
|
+
const leadingColumns = {};
|
|
2169
|
+
for (const row of indexes.rows) (leadingColumns[String(row["target"])] ??= []).push(String(row["leading"]));
|
|
2170
|
+
const byTable = {};
|
|
2171
|
+
for (const row of grants.rows) {
|
|
2172
|
+
const table = String(row["target"]);
|
|
2173
|
+
const role = liveRole(String(row["grantee"]));
|
|
2174
|
+
const entry = byTable[table] ??= {};
|
|
2175
|
+
(entry[role] ??= []).push(String(row["privilege"]));
|
|
2176
|
+
}
|
|
2177
|
+
return {
|
|
2178
|
+
policies: policies.rows.map((row) => ({
|
|
2179
|
+
table: String(row["target"]),
|
|
2180
|
+
name: String(row["policyname"]),
|
|
2181
|
+
command: commandOf(row["cmd"]),
|
|
2182
|
+
permissive: String(row["permissive"]).toUpperCase() === "PERMISSIVE",
|
|
2183
|
+
roles: Array.isArray(row["roles"]) ? row["roles"].map((role) => liveRole(String(role))) : []
|
|
2184
|
+
})),
|
|
2185
|
+
rlsEnabled: Object.fromEntries(enabled.rows.map((row) => [String(row["target"]), row["enabled"] === true])),
|
|
2186
|
+
grants: byTable,
|
|
2187
|
+
helpers: Object.fromEntries(helpers.rows.map((row) => [String(row["target"]), {
|
|
2188
|
+
securityDefiner: row["definer"] === true,
|
|
2189
|
+
emptySearchPath: String(row["config"]).split(",").some((item) => item === "search_path=\"\"" || item === "search_path=''")
|
|
2190
|
+
}])),
|
|
2191
|
+
leadingColumns,
|
|
2192
|
+
columns: columnsOf
|
|
2193
|
+
};
|
|
2194
|
+
} finally {
|
|
2195
|
+
await client.end();
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
const SYSTEM_SCHEMAS = [
|
|
2199
|
+
"pg_catalog",
|
|
2200
|
+
"information_schema",
|
|
2201
|
+
"pg_toast",
|
|
2202
|
+
"auth",
|
|
2203
|
+
"storage",
|
|
2204
|
+
"realtime",
|
|
2205
|
+
"extensions",
|
|
2206
|
+
"graphql",
|
|
2207
|
+
"graphql_public",
|
|
2208
|
+
"vault",
|
|
2209
|
+
"pgsodium",
|
|
2210
|
+
"pgsodium_masks",
|
|
2211
|
+
"net",
|
|
2212
|
+
"cron",
|
|
2213
|
+
"supabase_functions",
|
|
2214
|
+
"supabase_migrations"
|
|
2215
|
+
];
|
|
2216
|
+
function inScope(table) {
|
|
2217
|
+
const schema = table.split(".")[0] ?? "";
|
|
2218
|
+
return HELPER_TABLES.some((item) => item === table) || !(SYSTEM_SCHEMAS.some((item) => item === schema) || schema.startsWith("pg_"));
|
|
2219
|
+
}
|
|
2220
|
+
function seedLine(row) {
|
|
2221
|
+
return `${row.role} ${row.effect} ${row.grantKey} on ${row.scope}`;
|
|
2222
|
+
}
|
|
2223
|
+
/** Drift is an error; `info` names RLS tables whose policies call no PermDock helper. */
|
|
2224
|
+
function diffMixed(expected, actual) {
|
|
2225
|
+
const drift = [];
|
|
2226
|
+
const want = new Set(expected.seeds.map(seedLine));
|
|
2227
|
+
const have = new Set(actual.seeds.map(seedLine));
|
|
2228
|
+
for (const line of want) if (!have.has(line)) drift.push(`${expected.schema}.role_permissions: missing ${line}`);
|
|
2229
|
+
for (const line of have) if (!want.has(line)) drift.push(`${expected.schema}.role_permissions: unexpected ${line}`);
|
|
2230
|
+
const permissions = new Set(expected.permissions);
|
|
2231
|
+
const conditioned = new Set(expected.rowConditions);
|
|
2232
|
+
const covered = /* @__PURE__ */ new Set();
|
|
2233
|
+
for (const policy of actual.policies) {
|
|
2234
|
+
if (callsHelper(policy.expression)) covered.add(policy.table);
|
|
2235
|
+
const blind = new Set(helperCallKeys(policy.expression, true));
|
|
2236
|
+
for (const key of helperCallKeys(policy.expression)) if (!permissions.has(key)) drift.push(`${policy.table}: policy ${policy.name} passes ${key}, which the policy does not declare, so it always denies`);
|
|
2237
|
+
else if (conditioned.has(key) && blind.has(key)) drift.push(`${policy.table}: policy ${policy.name} passes ${key}, whose grants carry row conditions the helpers do not check: the policy grants more than the application does`);
|
|
2238
|
+
}
|
|
2239
|
+
return {
|
|
2240
|
+
drift,
|
|
2241
|
+
info: actual.rlsTables.filter((table) => !covered.has(table)).map((table) => `${table}: no policy calls a PermDock helper`)
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
const ALL_POLICIES_SQL = `select schemaname || '.' || tablename as target, policyname,
|
|
2245
|
+
coalesce(qual, '') || ' ' || coalesce(with_check, '') as expression
|
|
2246
|
+
from pg_policies`;
|
|
2247
|
+
const RLS_TABLES_SQL = `select n.nspname || '.' || c.relname as target
|
|
2248
|
+
from pg_class c
|
|
2249
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
2250
|
+
where c.relkind in ('r', 'p') and c.relrowsecurity`;
|
|
2251
|
+
const quoteIdent = escapeSqlIdent;
|
|
2252
|
+
/** Reads `role_permissions`, `pg_policies` and the RLS-enabled tables for {@link diffMixed}. */
|
|
2253
|
+
async function introspectMixed(db, schema, connect = connectIntrospect) {
|
|
2254
|
+
const client = await connect(db);
|
|
2255
|
+
try {
|
|
2256
|
+
const [seeds, policies, tables] = await Promise.all([
|
|
2257
|
+
client.query(`select role, permission, grant_key, scope, effect from ${quoteIdent(schema)}.role_permissions`, []),
|
|
2258
|
+
client.query(ALL_POLICIES_SQL, []),
|
|
2259
|
+
client.query(RLS_TABLES_SQL, [])
|
|
2260
|
+
]);
|
|
2261
|
+
return {
|
|
2262
|
+
seeds: seeds.rows.map((row) => ({
|
|
2263
|
+
role: String(row["role"]),
|
|
2264
|
+
permission: String(row["permission"]),
|
|
2265
|
+
grantKey: String(row["grant_key"]),
|
|
2266
|
+
scope: String(row["scope"]),
|
|
2267
|
+
effect: String(row["effect"]) === "deny" ? "deny" : "allow"
|
|
2268
|
+
})),
|
|
2269
|
+
policies: policies.rows.map((row) => ({
|
|
2270
|
+
table: String(row["target"]),
|
|
2271
|
+
name: String(row["policyname"]),
|
|
2272
|
+
expression: String(row["expression"])
|
|
2273
|
+
})).filter((row) => inScope(row.table)),
|
|
2274
|
+
rlsTables: tables.rows.map((row) => String(row["target"])).filter(inScope).toSorted()
|
|
2275
|
+
};
|
|
2276
|
+
} finally {
|
|
2277
|
+
await client.end();
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
//#endregion
|
|
2281
|
+
//#region src/cli/rls.ts
|
|
2282
|
+
const RLS_HELP = `permdock rls generate | import | verify | migrate
|
|
2283
|
+
|
|
2284
|
+
generate --target drizzle|sql|prisma --dialect supabase|neon|guc
|
|
2285
|
+
[--rbac supabase] [--rbac-schema public] [--authorize database|jwt]
|
|
2286
|
+
[--memberships <table>:tenant,user,role]
|
|
2287
|
+
[--policy-per-role] [--policy-name '{table}_{op}'] [--tenant-type uuid] [--custom-roles]
|
|
2288
|
+
[--capabilities] [--fields views [--revoke-columns]]
|
|
2289
|
+
[--out <path>] [--check] [--skip-closures] [--inline-functions] [--force] [--guc-prefix app]
|
|
2290
|
+
[--split helpers,seeds,policies,hook --out <dir>/056_permdock_{part}.sql]
|
|
2291
|
+
[--grants-out <file>|-] [--seeds-out <file>|-]
|
|
2292
|
+
[--helpers-only] [--shims] [--db $DATABASE_URL]
|
|
2293
|
+
import --sql schema.sql | --db $DATABASE_URL --out src/permissions.generated.ts
|
|
2294
|
+
[--schema zod|valibot|arktype] [--memberships <table>:tenant,user,role]
|
|
2295
|
+
verify [--db $DATABASE_URL] [--fixtures rls.fixtures.ts] [--format pgtap|node] [--tree]
|
|
2296
|
+
[--introspect --db $DATABASE_URL, with the generate flags]
|
|
2297
|
+
[--advisors [--db $DATABASE_URL] [--json]]: supabase db advisors, security lints
|
|
2298
|
+
migrate --sql <dir> [--write] [--json], with the generate flags
|
|
2299
|
+
rewrites the rls.migrate helpers' calls in policies onto the generated helpers
|
|
2300
|
+
|
|
2301
|
+
Never emits service_role, except in the execute grant rls.trustedReaders asks for. memberOf compiles through the dialect memberships mapping.
|
|
2302
|
+
`;
|
|
2303
|
+
function asTarget(value) {
|
|
2304
|
+
if (value === void 0 || value === "sql" || value === "drizzle" || value === "prisma") return value ?? "sql";
|
|
2305
|
+
}
|
|
2306
|
+
function asDialect(value) {
|
|
2307
|
+
if (value === void 0 || value === "supabase" || value === "neon" || value === "guc") return value ?? "supabase";
|
|
2308
|
+
}
|
|
2309
|
+
function generateInput(input, target, dialect) {
|
|
2310
|
+
return {
|
|
2311
|
+
cwd: input.cwd,
|
|
2312
|
+
config: input.config,
|
|
2313
|
+
target,
|
|
2314
|
+
dialect,
|
|
2315
|
+
rbac: input.rbac,
|
|
2316
|
+
...input.rbacSchema === void 0 ? {} : { rbacSchema: input.rbacSchema },
|
|
2317
|
+
...input.authorize === void 0 ? {} : { authorize: parseRbacAuthorize(input.authorize) ?? "database" },
|
|
2318
|
+
check: input.check,
|
|
2319
|
+
skipClosures: input.skipClosures,
|
|
2320
|
+
inlineFunctions: input.inlineFunctions,
|
|
2321
|
+
force: input.force,
|
|
2322
|
+
io: input.io,
|
|
2323
|
+
...input.out === void 0 ? {} : { out: input.out },
|
|
2324
|
+
...input.from === void 0 ? {} : { from: input.from },
|
|
2325
|
+
...input.memberships === void 0 ? {} : { memberships: input.memberships },
|
|
2326
|
+
...input.gucPrefix === void 0 ? {} : { gucPrefix: input.gucPrefix },
|
|
2327
|
+
policyPerRole: input.policyPerRole,
|
|
2328
|
+
...input.policyName === void 0 ? {} : { policyName: input.policyName },
|
|
2329
|
+
...input.tenantType === void 0 ? {} : { tenantType: input.tenantType },
|
|
2330
|
+
customRoles: input.customRoles,
|
|
2331
|
+
capabilities: input.capabilities,
|
|
2332
|
+
...input.fields === void 0 ? {} : { fields: input.fields },
|
|
2333
|
+
revokeColumns: input.revokeColumns,
|
|
2334
|
+
...input.split === void 0 ? {} : { split: input.split },
|
|
2335
|
+
...input.grantsOut === void 0 ? {} : { grantsOut: input.grantsOut },
|
|
2336
|
+
...input.seedsOut === void 0 ? {} : { seedsOut: input.seedsOut },
|
|
2337
|
+
helpersOnly: input.helpersOnly,
|
|
2338
|
+
shims: input.shims === true
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
async function migrate(input) {
|
|
2342
|
+
const config = input.config.rls?.migrate;
|
|
2343
|
+
if (config === void 0) return {
|
|
2344
|
+
code: 2,
|
|
2345
|
+
output: "rls migrate needs rls.migrate.helpers in permdock.config.ts"
|
|
2346
|
+
};
|
|
2347
|
+
if (input.sql === void 0) return {
|
|
2348
|
+
code: 2,
|
|
2349
|
+
output: "rls migrate needs --sql <dir>"
|
|
2350
|
+
};
|
|
2351
|
+
const dialect = asDialect(input.dialect ?? input.config.rls?.dialect);
|
|
2352
|
+
if (dialect === void 0) return {
|
|
2353
|
+
code: 2,
|
|
2354
|
+
output: "rls migrate --dialect (or rls.dialect) must be supabase, neon or guc"
|
|
2355
|
+
};
|
|
2356
|
+
const generated = await runRlsGenerate({
|
|
2357
|
+
...generateInput(input, "sql", dialect),
|
|
2358
|
+
check: false,
|
|
2359
|
+
write: false
|
|
2360
|
+
});
|
|
2361
|
+
if (generated.code !== 0) return {
|
|
2362
|
+
code: 2,
|
|
2363
|
+
output: generated.output
|
|
2364
|
+
};
|
|
2365
|
+
await requirePeer(() => import("pgsql-parser"), "pgsql-parser", "permdock rls migrate", input.cwd);
|
|
2366
|
+
const { migrateTarget, runRlsMigrate } = await import("./rls-migrate-CN4TlJTO.js");
|
|
2367
|
+
return runRlsMigrate({
|
|
2368
|
+
cwd: input.cwd,
|
|
2369
|
+
sql: input.sql,
|
|
2370
|
+
config,
|
|
2371
|
+
target: migrateTarget(generated),
|
|
2372
|
+
write: input.write,
|
|
2373
|
+
json: input.json
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
/** `verify --introspect` with `--helpers-only`: helpers and seeds exactly, hand-written policies by their keys. */
|
|
2377
|
+
async function introspectHelpersOnly(db, expected, generated, connect) {
|
|
2378
|
+
const schema = generated.schema ?? "permdock";
|
|
2379
|
+
try {
|
|
2380
|
+
const actual = await introspectRls(db, expected, connect);
|
|
2381
|
+
const helpers = diffRls(expected, actual);
|
|
2382
|
+
const warnings = missingIndexes(expected, actual);
|
|
2383
|
+
const mixed = diffMixed({
|
|
2384
|
+
schema,
|
|
2385
|
+
seeds: generated.seeds ?? [],
|
|
2386
|
+
permissions: generated.keys?.permissions ?? [],
|
|
2387
|
+
rowConditions: generated.keys?.rowConditions ?? []
|
|
2388
|
+
}, await introspectMixed(db, schema, connect));
|
|
2389
|
+
const drift = [...helpers, ...mixed.drift];
|
|
2390
|
+
const info = mixed.info.map((line) => `info: ${line}`);
|
|
2391
|
+
return drift.length > 0 ? {
|
|
2392
|
+
code: 1,
|
|
2393
|
+
output: [
|
|
2394
|
+
...drift,
|
|
2395
|
+
...warnings,
|
|
2396
|
+
...info
|
|
2397
|
+
].join("\n")
|
|
2398
|
+
} : {
|
|
2399
|
+
code: 0,
|
|
2400
|
+
output: [
|
|
2401
|
+
...warnings,
|
|
2402
|
+
...info,
|
|
2403
|
+
`introspected ${String(expected.helpers.length)} helper(s) and ${String(generated.seeds?.length ?? 0)} seeded row(s), helpers only: no drift`
|
|
2404
|
+
].join("\n")
|
|
2405
|
+
};
|
|
2406
|
+
} catch (cause) {
|
|
2407
|
+
return usageResult(cause);
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
/** `verify --introspect`: the catalogs against what `generate` would write with the same flags. */
|
|
2411
|
+
async function introspect(input) {
|
|
2412
|
+
if (input.db === void 0) return {
|
|
2413
|
+
code: 2,
|
|
2414
|
+
output: "PermDock CLI: rls verify --introspect needs --db"
|
|
2415
|
+
};
|
|
2416
|
+
const dialect = asDialect(input.dialect ?? input.config.rls?.dialect);
|
|
2417
|
+
if (dialect === void 0) return {
|
|
2418
|
+
code: 2,
|
|
2419
|
+
output: "rls verify --dialect (or rls.dialect) must be supabase, neon or guc"
|
|
2420
|
+
};
|
|
2421
|
+
const generated = await runRlsGenerate({
|
|
2422
|
+
...generateInput(input, "sql", dialect),
|
|
2423
|
+
check: false,
|
|
2424
|
+
write: false
|
|
2425
|
+
});
|
|
2426
|
+
if (generated.code !== 0 || generated.policies === void 0) return {
|
|
2427
|
+
code: 2,
|
|
2428
|
+
output: generated.output
|
|
2429
|
+
};
|
|
2430
|
+
const expected = expectedRls(generated.policies, generated.text, generated.indexes);
|
|
2431
|
+
if (generated.helpersOnly === true) return introspectHelpersOnly(input.db, expected, generated, input.connect);
|
|
2432
|
+
try {
|
|
2433
|
+
const actual = await introspectRls(input.db, expected, input.connect);
|
|
2434
|
+
const drift = diffRls(expected, actual, { columnGrants: (input.fields ?? input.config.rls?.fields) === "views" });
|
|
2435
|
+
const warnings = missingIndexes(expected, actual);
|
|
2436
|
+
if (drift.length > 0) return {
|
|
2437
|
+
code: 1,
|
|
2438
|
+
output: [...drift, ...warnings].join("\n")
|
|
2439
|
+
};
|
|
2440
|
+
return {
|
|
2441
|
+
code: 0,
|
|
2442
|
+
output: `${warnings.map((line) => `${line}\n`).join("")}introspected ${String(expected.policies.length)} policies on ${String(expected.tables.length)} table(s) and ${String(expected.helpers.length)} helper(s): no drift`
|
|
2443
|
+
};
|
|
2444
|
+
} catch (cause) {
|
|
2445
|
+
return usageResult(cause);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
async function runRls(input) {
|
|
2449
|
+
const action = input.rest[0];
|
|
2450
|
+
if (action === void 0 || action === "help") return {
|
|
2451
|
+
code: 2,
|
|
2452
|
+
output: RLS_HELP
|
|
2453
|
+
};
|
|
2454
|
+
switch (action) {
|
|
2455
|
+
case "generate": {
|
|
2456
|
+
const target = asTarget(input.target);
|
|
2457
|
+
const dialect = asDialect(input.dialect ?? input.config.rls?.dialect);
|
|
2458
|
+
if (target === void 0) return {
|
|
2459
|
+
code: 2,
|
|
2460
|
+
output: "rls generate --target must be drizzle, sql or prisma"
|
|
2461
|
+
};
|
|
2462
|
+
if (dialect === void 0) return {
|
|
2463
|
+
code: 2,
|
|
2464
|
+
output: "rls generate --dialect (or rls.dialect) must be supabase, neon or guc"
|
|
2465
|
+
};
|
|
2466
|
+
return runRlsGenerate({
|
|
2467
|
+
...generateInput(input, target, dialect),
|
|
2468
|
+
...input.db === void 0 ? {} : { db: input.db },
|
|
2469
|
+
...input.connect === void 0 ? {} : { connect: input.connect }
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
case "import": {
|
|
2473
|
+
await requirePeer(() => import("pgsql-parser"), "pgsql-parser", "permdock rls import", input.cwd);
|
|
2474
|
+
const { runRlsImport } = await import("./rls-import-BTFVk3jH.js");
|
|
2475
|
+
return runRlsImport({
|
|
2476
|
+
cwd: input.cwd,
|
|
2477
|
+
config: input.config,
|
|
2478
|
+
schema: input.schema ?? "zod",
|
|
2479
|
+
io: input.io,
|
|
2480
|
+
...input.sql === void 0 ? {} : { sql: input.sql },
|
|
2481
|
+
...input.db === void 0 ? {} : { db: input.db },
|
|
2482
|
+
...input.out === void 0 ? {} : { out: input.out },
|
|
2483
|
+
...input.memberships === void 0 ? {} : { memberships: input.memberships },
|
|
2484
|
+
...input.connect === void 0 ? {} : { connect: input.connect }
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
case "verify": {
|
|
2488
|
+
if (input.advisors === true) return runRlsAdvisors({
|
|
2489
|
+
cwd: input.cwd,
|
|
2490
|
+
json: input.json,
|
|
2491
|
+
env: input.io.env ?? process.env,
|
|
2492
|
+
...input.db === void 0 ? {} : { db: input.db },
|
|
2493
|
+
...input.exec === void 0 ? {} : { exec: input.exec }
|
|
2494
|
+
});
|
|
2495
|
+
if (input.introspect) return introspect(input);
|
|
2496
|
+
const format = input.format ?? "node";
|
|
2497
|
+
if (format !== "node" && format !== "pgtap") return {
|
|
2498
|
+
code: 2,
|
|
2499
|
+
output: "rls verify --format must be pgtap or node"
|
|
2500
|
+
};
|
|
2501
|
+
return await runRlsVerify({
|
|
2502
|
+
cwd: input.cwd,
|
|
2503
|
+
config: input.config,
|
|
2504
|
+
format,
|
|
2505
|
+
tree: input.tree,
|
|
2506
|
+
io: input.io,
|
|
2507
|
+
...input.fixtures === void 0 ? {} : { fixtures: input.fixtures },
|
|
2508
|
+
...input.db === void 0 ? {} : { db: input.db },
|
|
2509
|
+
...input.from === void 0 ? {} : { from: input.from },
|
|
2510
|
+
...input.connect === void 0 ? {} : { connect: input.connect }
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
case "migrate": return migrate(input);
|
|
2514
|
+
default: return {
|
|
2515
|
+
code: 2,
|
|
2516
|
+
output: RLS_HELP
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
//#endregion
|
|
2521
|
+
export { runRls };
|