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
|
@@ -1,404 +1,253 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import {
|
|
3
|
-
import { i as
|
|
4
|
-
import { a as
|
|
1
|
+
import { a as hasConditionOp } from "./refs-KYb4ntaz.js";
|
|
2
|
+
import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-CVdPrWN-.js";
|
|
3
|
+
import { i as SUPABASE_MANIFEST_SCHEMA } from "./version-BvacVTZR.js";
|
|
4
|
+
import { a as sqlFiles } from "./files-DWUN9PzI.js";
|
|
5
|
+
import { a as loadModule, n as asPolicy, o as pickNamed } from "./load-DhWEB9P1.js";
|
|
5
6
|
import { t as shortDiff } from "./text-diff-BdkkYSbi.js";
|
|
6
|
-
import { r as scopeColumn } from "./compile-
|
|
7
|
-
import {
|
|
7
|
+
import { r as scopeColumn } from "./compile-Ba4blROR.js";
|
|
8
|
+
import { t as authorizeSql } from "./supabase-C0qESOa-.js";
|
|
8
9
|
import "./budget-NbEtbJC_.js";
|
|
9
|
-
import { t as AUTHZ_VERSION_TABLE } from "./sources-
|
|
10
|
+
import { c as hookApiNames, d as keptKeys, t as AUTHZ_VERSION_TABLE, u as roleManifest } from "./sources-4p0-4xQm.js";
|
|
11
|
+
import { i as readMemberships } from "./manifest-iaoBXHKc.js";
|
|
10
12
|
import { a as hookMarkerFields, n as HOOK_MARKER, t as GRANTS_MARKER } from "./markers-B0hYLvMD.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
+
import { f as splitUndiffed, h as tableKey, m as decidingColumns, n as supabaseConfig, r as MIGRATION_DIRS } from "./supabase-config-BpPXt3x1.js";
|
|
14
|
+
import { D as hasMemberFor, F as memberForHelper, G as quoteIdent, H as permittedForHelper, I as memberForSources, K as quoteLiteral, L as memberForTable, Q as scopeTypeOf, R as memberIdsHelper, U as permittedIdsHelper, W as qualifiedTable, at as tenantTypeOf, g as activeRowSql, p as USER_ID_HELPER, q as quoteTable, r as HELPERS, y as checkSuspension, z as memberRoleOf } from "./rls-shared-Cewd_-zI.js";
|
|
15
|
+
import { G as API_KEY_ALLOWS, K as apiKeyFields, U as roleNames, _ as permissionKeysForHelper, w as connectPg, z as globalRoleSource } from "./rls-fields-74oZPXWL.js";
|
|
16
|
+
import { a as ownershipRules, i as guardedTables, n as OWNERSHIP, t as ASSIGNMENTS } from "./rls-ownership-RuFqaqGz.js";
|
|
13
17
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
-
import { dirname, join,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
* {@link tableKey}. A client that can write one can give itself a membership.
|
|
27
|
-
*/
|
|
28
|
-
function membershipColumns(config) {
|
|
29
|
-
const sources = config.supabase?.hook?.memberships ?? config.rls?.membershipSources ?? [];
|
|
30
|
-
const byTable = /* @__PURE__ */ new Map();
|
|
31
|
-
for (const source of sources) {
|
|
32
|
-
const table = tableKey(source.sql.table);
|
|
33
|
-
const columns = byTable.get(table) ?? /* @__PURE__ */ new Set();
|
|
34
|
-
for (const column of source.sql.columns) columns.add(column);
|
|
35
|
-
byTable.set(table, columns);
|
|
36
|
-
}
|
|
37
|
-
return byTable;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Every `schema.table.column` a claim or a membership is computed from: the
|
|
41
|
-
* membership columns and the `attrs` table columns. Sorted.
|
|
42
|
-
*/
|
|
43
|
-
function decidingColumns(config, attrs) {
|
|
44
|
-
const entries = /* @__PURE__ */ new Set();
|
|
45
|
-
for (const [table, columns] of membershipColumns(config)) for (const column of columns) entries.add(`${table}.${column}`);
|
|
46
|
-
if (attrs?.table !== void 0) {
|
|
47
|
-
const table = tableKey(attrs.table);
|
|
48
|
-
for (const column of attrs.columns) entries.add(`${table}.${column}`);
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
/** `--check`: one entry per file that is missing or differs, naming its part; a differing file carries a short diff. */
|
|
20
|
+
function driftOf(cwd, files) {
|
|
21
|
+
const drift = [];
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
if (file.rel === "-") continue;
|
|
24
|
+
const path = resolve(cwd, file.rel);
|
|
25
|
+
if (!existsSync(path)) drift.push(`${file.part}: missing ${file.rel}`);
|
|
26
|
+
else {
|
|
27
|
+
const onDisk = readFileSync(path, "utf8");
|
|
28
|
+
if (onDisk !== file.text) drift.push(`${file.part}: ${file.rel}\n${shortDiff(file.rel, onDisk, file.text)}`);
|
|
29
|
+
}
|
|
49
30
|
}
|
|
50
|
-
return
|
|
31
|
+
return drift;
|
|
51
32
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const text = blankComments(sql);
|
|
60
|
-
const statements = [];
|
|
61
|
-
const push = (start, end) => {
|
|
62
|
-
const body = text.slice(start, end);
|
|
63
|
-
const lead = body.length - body.trimStart().length;
|
|
64
|
-
const trimmed = body.trim();
|
|
65
|
-
if (trimmed !== "") statements.push({
|
|
66
|
-
line: text.slice(0, start + lead).split("\n").length,
|
|
67
|
-
text: trimmed
|
|
68
|
-
});
|
|
69
|
-
};
|
|
70
|
-
let start = 0;
|
|
71
|
-
let index = 0;
|
|
72
|
-
while (index < text.length) {
|
|
73
|
-
const char = text[index];
|
|
74
|
-
if (char === "'") {
|
|
75
|
-
const end = text.indexOf("'", index + 1);
|
|
76
|
-
index = end === -1 ? text.length : end + 1;
|
|
33
|
+
/** Writes every file but the stdout ones, whose text it returns for printing. */
|
|
34
|
+
function writeSqlFiles(cwd, files) {
|
|
35
|
+
const wrote = [];
|
|
36
|
+
const printed = [];
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
if (file.rel === "-") {
|
|
39
|
+
printed.push(file.text.trimEnd());
|
|
77
40
|
continue;
|
|
78
41
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
index = end === -1 ? text.length : end + tag[0].length;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (char === ";") {
|
|
88
|
-
push(start, index);
|
|
89
|
-
start = index + 1;
|
|
90
|
-
}
|
|
91
|
-
index += 1;
|
|
42
|
+
const path = resolve(cwd, file.rel);
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
44
|
+
writeFileSync(path, file.text);
|
|
45
|
+
wrote.push(file.rel);
|
|
92
46
|
}
|
|
93
|
-
|
|
94
|
-
|
|
47
|
+
return {
|
|
48
|
+
wrote,
|
|
49
|
+
printed: printed.join("\n")
|
|
50
|
+
};
|
|
95
51
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
52
|
+
const PARTS = [
|
|
53
|
+
"helpers",
|
|
54
|
+
"seeds",
|
|
55
|
+
"indexes",
|
|
56
|
+
"policies",
|
|
57
|
+
"hook"
|
|
58
|
+
];
|
|
59
|
+
/** `--split helpers,seeds,indexes,policies,hook`: the parts in that fixed order, or an error. */
|
|
60
|
+
function parseSplit(raw) {
|
|
61
|
+
if (raw === void 0) return;
|
|
62
|
+
const names = raw.split(",").map((name) => name.trim()).filter((name) => name !== "");
|
|
63
|
+
if (names.filter((name) => !PARTS.some((part) => part === name)).length > 0 || names.length === 0) return `rls generate --split takes helpers, seeds, indexes, policies and hook, got '${raw}'`;
|
|
64
|
+
return PARTS.filter((part) => names.includes(part));
|
|
99
65
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const lockPath = join(cwd, ".permdock/skills-lock.json");
|
|
104
|
-
if (![
|
|
105
|
-
join(cwd, ".agents/skills"),
|
|
106
|
-
join(cwd, ".claude/skills"),
|
|
107
|
-
join(cwd, ".cursor/skills")
|
|
108
|
-
].some((folder) => existsSync(join(folder, "permdock/SKILL.md")))) return [{
|
|
109
|
-
code: "PD005",
|
|
110
|
-
severity: "warning",
|
|
111
|
-
message: "Agent Skills are not installed",
|
|
112
|
-
fix: "pnpm exec permdock skills install"
|
|
113
|
-
}];
|
|
114
|
-
if (!existsSync(lockPath)) return [{
|
|
115
|
-
code: "PD005",
|
|
116
|
-
severity: "warning",
|
|
117
|
-
message: "skills lock is missing",
|
|
118
|
-
fix: "pnpm exec permdock skills install"
|
|
119
|
-
}];
|
|
120
|
-
return [];
|
|
66
|
+
/** The path of one split part: `{part}` in `out` replaced by the part name. */
|
|
67
|
+
function partPath(out, part) {
|
|
68
|
+
return out.replaceAll("{part}", part);
|
|
121
69
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
severity: "error",
|
|
135
|
-
message: `TypeScript ${pkg.version} is not in the supported matrix (5.9, 6, 7)`,
|
|
136
|
-
fix: "use TypeScript 5.9, 6 or 7"
|
|
137
|
-
}];
|
|
138
|
-
return [];
|
|
139
|
-
} catch {
|
|
140
|
-
return [{
|
|
141
|
-
code: "PD006",
|
|
142
|
-
severity: "error",
|
|
143
|
-
message: "typescript is not installed",
|
|
144
|
-
fix: "add typescript 5.9, 6 or 7"
|
|
145
|
-
}];
|
|
70
|
+
/**
|
|
71
|
+
* pg-delta's per-schema, unnumbered layout: pg-delta orders statements by
|
|
72
|
+
* their dependencies, so no file needs a number to apply first.
|
|
73
|
+
*/
|
|
74
|
+
function pgDeltaPath(root, part, schema) {
|
|
75
|
+
switch (part) {
|
|
76
|
+
case "helpers":
|
|
77
|
+
case "indexes":
|
|
78
|
+
case "seeds": return `${root}/${schema}/${part}.sql`;
|
|
79
|
+
case "policies": return `${root}/public/policies/permdock.sql`;
|
|
80
|
+
case "hook": return `${root}/${schema}/functions/custom_access_token_hook.sql`;
|
|
81
|
+
default: return part;
|
|
146
82
|
}
|
|
147
83
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (!existsSync(join(dir, "node_modules"))) return;
|
|
155
|
-
for (const name of readdirSync(join(dir, "node_modules"))) {
|
|
156
|
-
if (name.startsWith(".")) continue;
|
|
157
|
-
const nested = join(dir, "node_modules", name);
|
|
158
|
-
try {
|
|
159
|
-
if (statSync(nested).isDirectory()) walk(nested, depth + 1);
|
|
160
|
-
} catch {}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
walk(cwd, 0);
|
|
164
|
-
if (copies.length > 1) return [{
|
|
165
|
-
code: "PD009",
|
|
166
|
-
severity: "error",
|
|
167
|
-
message: `duplicate permdock copies: ${copies.map((item) => rel(cwd, dirname(item))).join(", ")}`,
|
|
168
|
-
fix: "dedupe so only one permdock version is installed"
|
|
169
|
-
}];
|
|
170
|
-
return [];
|
|
84
|
+
/** Whether `--seeds-out` names a migrations directory: it ends with \`/\` or is an existing directory. */
|
|
85
|
+
function isSeedsDirectory(cwd, out) {
|
|
86
|
+
if (out === "-") return false;
|
|
87
|
+
if (out.endsWith("/")) return true;
|
|
88
|
+
const path = resolve(cwd, out);
|
|
89
|
+
return existsSync(path) && statSync(path).isDirectory();
|
|
171
90
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
for (const doc of docs) {
|
|
176
|
-
const abs = resolve(cwd, doc);
|
|
177
|
-
if (!existsSync(abs)) continue;
|
|
178
|
-
const text = readFileSync(abs, "utf8");
|
|
179
|
-
if (text.includes("\"drafts\"") && !text.includes("overlay")) findings.push({
|
|
180
|
-
code: "PD012",
|
|
181
|
-
severity: "warning",
|
|
182
|
-
message: `${doc} carries a draft pin the CLI no longer emits`,
|
|
183
|
-
fix: "regenerate with permdock openapi"
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
return findings;
|
|
91
|
+
/** `YYYYMMDDHHMMSS` in UTC, the Supabase migration version. */
|
|
92
|
+
function migrationVersion(now) {
|
|
93
|
+
return now.toISOString().replaceAll(/[^0-9]/gu, "").slice(0, 14);
|
|
187
94
|
}
|
|
188
|
-
const
|
|
189
|
-
"supabase/migrations",
|
|
190
|
-
"supabase/schemas",
|
|
191
|
-
"migrations",
|
|
192
|
-
"drizzle",
|
|
193
|
-
"prisma/migrations",
|
|
194
|
-
"db/migrations"
|
|
195
|
-
];
|
|
196
|
-
const VIEW_NAME = String.raw`((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)`;
|
|
197
|
-
const CREATE_VIEW = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?(?:temp(?:orary)?\s+)?(?:recursive\s+)?view\s+${VIEW_NAME}([\s\S]*?)\bas\b`, "giu");
|
|
198
|
-
const ALTER_VIEW = new RegExp(String.raw`\balter\s+view\s+(?:if\s+exists\s+)?${VIEW_NAME}\s+set\s*\(([^)]*)\)`, "giu");
|
|
199
|
-
const INVOKER = /\bsecurity_invoker\s*(?:=\s*(?:true|on|'true'|'on'|1)\b|[,)]|$)/iu;
|
|
200
|
-
const COMPANION = new RegExp(String.raw`\bcomment\s+on\s+view\s+${VIEW_NAME}\s+is\s+'${FIELD_VIEWS.comment}\b`, "giu");
|
|
95
|
+
const VERSION_PREFIX = /^\d{14}(?=_)/u;
|
|
201
96
|
/**
|
|
202
|
-
*
|
|
203
|
-
* past
|
|
204
|
-
*
|
|
205
|
-
* purpose and carries a comment that says so.
|
|
97
|
+
* The version of a new migration that sorts after every migration in
|
|
98
|
+
* `names`: one second past the newest, so the same directory always gives the
|
|
99
|
+
* same name. Only a directory with no versioned migration falls back to `now`.
|
|
206
100
|
*/
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const raw = readFileSync(file, "utf8");
|
|
214
|
-
const text = raw.replaceAll(/--[^\n]*/gu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
|
|
215
|
-
for (const [, name = "", options = ""] of text.matchAll(CREATE_VIEW)) {
|
|
216
|
-
const key = tableKey(name);
|
|
217
|
-
created.set(key, rel(cwd, file));
|
|
218
|
-
if (INVOKER.test(options)) invoker.add(key);
|
|
219
|
-
else invoker.delete(key);
|
|
220
|
-
}
|
|
221
|
-
for (const [, name = "", options = ""] of text.matchAll(ALTER_VIEW)) if (INVOKER.test(options)) invoker.add(tableKey(name));
|
|
222
|
-
for (const [, name = ""] of raw.matchAll(COMPANION)) companions.add(tableKey(name));
|
|
223
|
-
}
|
|
224
|
-
return [...created].filter(([key]) => !invoker.has(key) && !companions.has(key)).map(([key, file]) => ({
|
|
225
|
-
code: "PD022",
|
|
226
|
-
severity: "warning",
|
|
227
|
-
message: `view ${key} in ${file} is not security_invoker, so it reads past row level security`,
|
|
228
|
-
fix: `create the view with (security_invoker = true), or alter view ${key} set (security_invoker = true); Postgres 15 or later. For column-level reads, generate field views with permdock rls generate --fields views`
|
|
229
|
-
}));
|
|
101
|
+
function nextMigrationVersion(names, now) {
|
|
102
|
+
const times = names.flatMap((name) => {
|
|
103
|
+
const stamp = VERSION_PREFIX.exec(name)?.[0];
|
|
104
|
+
return stamp === void 0 ? [] : [Date.UTC(Number(stamp.slice(0, 4)), Number(stamp.slice(4, 6)) - 1, Number(stamp.slice(6, 8)), Number(stamp.slice(8, 10)), Number(stamp.slice(10, 12)), Number(stamp.slice(12, 14)))];
|
|
105
|
+
});
|
|
106
|
+
return times.length === 0 ? migrationVersion(now) : migrationVersion(new Date(Math.max(...times) + 1e3));
|
|
230
107
|
}
|
|
231
|
-
const AUTH_ROLE = /\bauth\.role\s*\(\s*\)/giu;
|
|
232
108
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
109
|
+
* The seeds part in a migrations directory: the newest migration there that
|
|
110
|
+
* starts with \`marker\` when its text is \`text\` already, otherwise a new
|
|
111
|
+
* \`<version>_permdock_seeds.sql\` one second after the newest migration
|
|
112
|
+
* (\`nextMigrationVersion\`), so a regeneration of the same directory names it
|
|
113
|
+
* the same. \`current\` is false when a new one is due.
|
|
236
114
|
*/
|
|
237
|
-
function
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return findings;
|
|
115
|
+
function seedsMigration(cwd, dir, marker, text, now) {
|
|
116
|
+
const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
|
117
|
+
const path = resolve(cwd, base);
|
|
118
|
+
const latest = existsSync(path) ? readdirSync(path).filter((name) => name.endsWith(".sql")).toSorted().toReversed().find((name) => readFileSync(resolve(path, name), "utf8").startsWith(marker)) : void 0;
|
|
119
|
+
if (latest !== void 0 && readFileSync(resolve(path, latest), "utf8") === text) return {
|
|
120
|
+
rel: `${base}/${latest}`,
|
|
121
|
+
current: true
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
rel: `${base}/${nextMigrationVersion(existsSync(path) ? readdirSync(path) : [], now)}_permdock_seeds.sql`,
|
|
125
|
+
current: false
|
|
126
|
+
};
|
|
252
127
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
"anon",
|
|
256
|
-
"authenticated",
|
|
257
|
-
"public"
|
|
258
|
-
]);
|
|
259
|
-
function applyPrivilege(writable, verb, part) {
|
|
260
|
-
const match = /^\s*(all(?:\s+privileges)?|insert|update)\s*(?:\(([^)]*)\))?\s*$/iu.exec(part);
|
|
261
|
-
if (match === null) return;
|
|
262
|
-
const kind = (match[1] ?? "").toLowerCase();
|
|
263
|
-
const sets = kind === "insert" ? [writable.insert] : kind === "update" ? [writable.update] : [writable.insert, writable.update];
|
|
264
|
-
const columns = match[2] === void 0 ? ["*"] : match[2].split(",").map((column) => column.trim().replaceAll("\"", ""));
|
|
265
|
-
for (const set of sets) for (const column of columns) if (verb.toLowerCase() === "grant") set.add(column);
|
|
266
|
-
else if (column === "*") set.clear();
|
|
267
|
-
else set.delete(column);
|
|
268
|
-
}
|
|
269
|
-
const CREATE_TABLE = /^create\s+(?:(?:global|local)\s+)?(?:(?:temp|temporary|unlogged)\s+)?table\s+(?:if\s+not\s+exists\s+)?((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)\s*\(/iu;
|
|
270
|
-
const REVOKE_DEFAULT_TABLES = /^alter\s+default\s+privileges\s+(?:for\s+(?:role|user)\s+[\w\s,"]+?\s+)?in\s+schema\s+"?public"?\s+revoke\s+[\s\S]+?\s+on\s+tables\s+from\s+[\s\S]*\b(?:anon|authenticated)\b/iu;
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/cli/claims-schema.ts
|
|
271
130
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* everything, until a migration revokes them.
|
|
131
|
+
* The PermDock claims of `schemas/supabase-claims-v1.json` (its
|
|
132
|
+
* `permdockClaims` and `membership` definitions), self-contained so
|
|
133
|
+
* `supabase.hook.validate` can inline it into the hook for pg_jsonschema.
|
|
276
134
|
*/
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
135
|
+
const PERMDOCK_CLAIMS_SCHEMA = {
|
|
136
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
137
|
+
$ref: "#/$defs/permdockClaims",
|
|
138
|
+
$defs: {
|
|
139
|
+
permdockClaims: {
|
|
140
|
+
type: "object",
|
|
141
|
+
properties: {
|
|
142
|
+
user_role: {
|
|
143
|
+
description: "Global roles: one name, a list, or null for none.",
|
|
144
|
+
anyOf: [
|
|
145
|
+
{ type: "string" },
|
|
146
|
+
{
|
|
147
|
+
type: "array",
|
|
148
|
+
items: { type: "string" }
|
|
149
|
+
},
|
|
150
|
+
{ type: "null" }
|
|
151
|
+
]
|
|
152
|
+
},
|
|
153
|
+
roles: {
|
|
154
|
+
type: "array",
|
|
155
|
+
items: { type: "string" }
|
|
156
|
+
},
|
|
157
|
+
memberships: {
|
|
158
|
+
type: "array",
|
|
159
|
+
items: { $ref: "#/$defs/membership" }
|
|
160
|
+
},
|
|
161
|
+
memberships_truncated: {
|
|
162
|
+
description: "true when the size budget cut memberships or attrs.",
|
|
163
|
+
type: "boolean"
|
|
164
|
+
},
|
|
165
|
+
tenant_id: {
|
|
166
|
+
description: "The active tenant (the default rls.tenantClaim): the canonical text of one instance of the first scope.",
|
|
167
|
+
type: "string"
|
|
168
|
+
},
|
|
169
|
+
attrs: {
|
|
170
|
+
description: "Allow-listed server-owned attributes, read as principal.claims.attrs.<key>.",
|
|
171
|
+
type: "object"
|
|
172
|
+
},
|
|
173
|
+
authz_ver: {
|
|
174
|
+
description: "The principal's authorization version.",
|
|
175
|
+
type: "integer"
|
|
176
|
+
}
|
|
293
177
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
178
|
+
},
|
|
179
|
+
membership: {
|
|
180
|
+
type: "object",
|
|
181
|
+
required: ["roles"],
|
|
182
|
+
properties: {
|
|
183
|
+
scope: { type: "string" },
|
|
184
|
+
id: {
|
|
185
|
+
description: "Canonical text of the scope instance id (uuid::text for a uuid column); compared exactly, never lower-cased.",
|
|
186
|
+
type: "string"
|
|
187
|
+
},
|
|
188
|
+
within: {
|
|
189
|
+
description: "The id of every ancestor scope instance, by scope name.",
|
|
190
|
+
type: "object",
|
|
191
|
+
additionalProperties: { type: "string" }
|
|
192
|
+
},
|
|
193
|
+
on: {
|
|
194
|
+
type: "object",
|
|
195
|
+
required: ["resource", "id"],
|
|
196
|
+
properties: {
|
|
197
|
+
resource: { type: "string" },
|
|
198
|
+
id: { type: "string" }
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
tenant: { type: "string" },
|
|
202
|
+
team: { type: "string" },
|
|
203
|
+
roles: {
|
|
204
|
+
type: "array",
|
|
205
|
+
minItems: 1,
|
|
206
|
+
items: { type: "string" }
|
|
207
|
+
},
|
|
208
|
+
via: { type: "string" },
|
|
209
|
+
expiresAt: {
|
|
210
|
+
description: "Seconds since the epoch.",
|
|
211
|
+
type: "number"
|
|
212
|
+
},
|
|
213
|
+
grantedBy: { type: "string" },
|
|
214
|
+
reason: { type: "string" },
|
|
215
|
+
member: {
|
|
216
|
+
description: "The subgroup a membership source's group fills.",
|
|
217
|
+
type: "object",
|
|
218
|
+
required: ["group"],
|
|
219
|
+
properties: { group: {
|
|
220
|
+
type: "string",
|
|
221
|
+
minLength: 1
|
|
222
|
+
} }
|
|
223
|
+
},
|
|
224
|
+
managedBy: { const: "idp" },
|
|
225
|
+
entitlements: {
|
|
226
|
+
type: "array",
|
|
227
|
+
items: { type: "string" }
|
|
228
|
+
},
|
|
229
|
+
keep: {
|
|
230
|
+
description: "Set when the instance or an ancestor is suspended: the permission keys the membership still grants.",
|
|
231
|
+
type: "array",
|
|
232
|
+
items: {
|
|
233
|
+
type: "string",
|
|
234
|
+
minLength: 1
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
grants: {
|
|
238
|
+
description: "Custom roles in compact form, read by the helpers in jwt mode.",
|
|
239
|
+
type: "object"
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
anyOf: [
|
|
243
|
+
{ required: ["scope", "id"] },
|
|
244
|
+
{ required: ["tenant"] },
|
|
245
|
+
{ required: ["team"] },
|
|
246
|
+
{ required: ["on"] }
|
|
247
|
+
]
|
|
300
248
|
}
|
|
301
249
|
}
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
/** Membership columns a client can write: a user who sets `user_id` or `role` grants themselves access. */
|
|
305
|
-
function membershipFindings(cwd, config) {
|
|
306
|
-
const findings = [];
|
|
307
|
-
for (const [table, columns] of membershipColumns(config)) {
|
|
308
|
-
const writable = clientWritable(cwd, config, table);
|
|
309
|
-
const exposed = [...columns].filter((column) => writable.has("*") || writable.has(column));
|
|
310
|
-
if (exposed.length > 0) findings.push({
|
|
311
|
-
code: "PD028",
|
|
312
|
-
severity: "warning",
|
|
313
|
-
message: `${table}.${exposed.join(", ")} decide${exposed.length === 1 ? "s" : ""} memberships, and the migrations let anon or authenticated insert or update ${exposed.length === 1 ? "it" : "them"}: a user could give themselves a membership`,
|
|
314
|
-
fix: `revoke insert, update on ${table} from anon, authenticated, then grant update (<columns clients edit>) on ${table} to authenticated; a column-level revoke alone leaves a table-level grant in place`
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
return findings;
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* `attrs` claims must come from server-owned columns: never `user_metadata`,
|
|
321
|
-
* never a column clients can write. Membership sources' deciding columns are
|
|
322
|
-
* held to the same rule.
|
|
323
|
-
*/
|
|
324
|
-
function pd028(cwd, config, plan) {
|
|
325
|
-
const memberships = membershipFindings(cwd, config);
|
|
326
|
-
const attrs = config.supabase?.hook?.attrs;
|
|
327
|
-
if (attrs === void 0) return memberships;
|
|
328
|
-
const planned = plan(attrs);
|
|
329
|
-
const findings = planned.errors.map((message) => ({
|
|
330
|
-
code: "PD028",
|
|
331
|
-
severity: "error",
|
|
332
|
-
message,
|
|
333
|
-
fix: "list server-owned columns or app_metadata.<key> entries; user_metadata is user-editable"
|
|
334
|
-
}));
|
|
335
|
-
if (planned.table !== void 0 && planned.columns.length > 0) {
|
|
336
|
-
const writable = clientWritable(cwd, config, tableKey(planned.table));
|
|
337
|
-
const exposed = planned.columns.filter((column) => writable.has("*") || writable.has(column));
|
|
338
|
-
if (exposed.length > 0) findings.push({
|
|
339
|
-
code: "PD028",
|
|
340
|
-
severity: "warning",
|
|
341
|
-
message: `attrs reads ${exposed.join(", ")} from ${tableKey(planned.table)}, which the migrations let anon or authenticated insert or update: a user could set their own attribute`,
|
|
342
|
-
fix: `revoke insert, update on ${tableKey(planned.table)} from anon, authenticated, and grant column-level update only on columns that are not attributes; the generated hook migration refuses to install otherwise`
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
return [...findings, ...memberships];
|
|
346
|
-
}
|
|
347
|
-
//#endregion
|
|
348
|
-
//#region src/cli/supabase-config.ts
|
|
349
|
-
const SUPABASE_CONFIG = "supabase/config.toml";
|
|
350
|
-
function table$1(value) {
|
|
351
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
352
|
-
return value;
|
|
353
|
-
}
|
|
354
|
-
function strings(value) {
|
|
355
|
-
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
|
|
356
|
-
}
|
|
357
|
-
/** `supabase/config.toml`, parsed; `undefined` when the project has none. */
|
|
358
|
-
function readSupabaseConfig(cwd) {
|
|
359
|
-
const file = join(cwd, SUPABASE_CONFIG);
|
|
360
|
-
if (!existsSync(file)) return;
|
|
361
|
-
let root;
|
|
362
|
-
try {
|
|
363
|
-
root = parse(readFileSync(file, "utf8"));
|
|
364
|
-
} catch (error) {
|
|
365
|
-
return {
|
|
366
|
-
ok: false,
|
|
367
|
-
error: error instanceof Error ? error.message : String(error)
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
const expiry = table$1(root["auth"])?.["jwt_expiry"];
|
|
371
|
-
const paths = table$1(table$1(root["db"])?.["migrations"])?.["schema_paths"];
|
|
372
|
-
const schemaPaths = strings(paths);
|
|
373
|
-
const apiSchemas = strings(table$1(root["api"])?.["schemas"]);
|
|
374
|
-
const pgDelta = table$1(table$1(root["experimental"])?.["pgdelta"]);
|
|
375
|
-
const schemaPath = pgDelta?.["declarative_schema_path"];
|
|
376
|
-
return {
|
|
377
|
-
ok: true,
|
|
378
|
-
config: {
|
|
379
|
-
...typeof expiry === "number" ? { jwtExpiry: expiry } : {},
|
|
380
|
-
...schemaPaths === void 0 ? {} : { schemaPaths },
|
|
381
|
-
...apiSchemas === void 0 ? {} : { apiSchemas },
|
|
382
|
-
...pgDelta?.["enabled"] === true ? { pgDelta: { schemaDir: posix.join("supabase", typeof schemaPath === "string" ? schemaPath : "schemas").replace(/\/$/u, "") } } : {}
|
|
383
|
-
}
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
/** The parsed config, or `{}` when it is missing or does not parse (PD045 reports that). */
|
|
387
|
-
function supabaseConfig(cwd) {
|
|
388
|
-
const read = readSupabaseConfig(cwd);
|
|
389
|
-
return read?.ok === true ? read.config : {};
|
|
390
|
-
}
|
|
391
|
-
/** PD045: a `supabase/config.toml` the CLI cannot parse, so every check that reads it falls back to defaults. */
|
|
392
|
-
function pd045(cwd) {
|
|
393
|
-
const read = readSupabaseConfig(cwd);
|
|
394
|
-
if (read === void 0 || read.ok) return [];
|
|
395
|
-
return [{
|
|
396
|
-
code: "PD045",
|
|
397
|
-
severity: "warning",
|
|
398
|
-
message: `${SUPABASE_CONFIG} does not parse: ${read.error.replace(/\n[\s\S]*/u, "")}`,
|
|
399
|
-
fix: `fix the TOML syntax in ${SUPABASE_CONFIG}; until then doctor assumes Supabase's defaults for jwt_expiry and schema_paths`
|
|
400
|
-
}];
|
|
401
|
-
}
|
|
250
|
+
};
|
|
402
251
|
//#endregion
|
|
403
252
|
//#region src/cli/rls-rbac.ts
|
|
404
253
|
/**
|
|
@@ -483,196 +332,8 @@ revoke all on table ${q("user_roles")} from authenticated, anon, public;
|
|
|
483
332
|
warnings: []
|
|
484
333
|
};
|
|
485
334
|
}
|
|
486
|
-
/** `--check`: one entry per file that is missing or differs, naming its part; a differing file carries a short diff. */
|
|
487
|
-
function driftOf(cwd, files) {
|
|
488
|
-
const drift = [];
|
|
489
|
-
for (const file of files) {
|
|
490
|
-
if (file.rel === "-") continue;
|
|
491
|
-
const path = resolve(cwd, file.rel);
|
|
492
|
-
if (!existsSync(path)) drift.push(`${file.part}: missing ${file.rel}`);
|
|
493
|
-
else {
|
|
494
|
-
const onDisk = readFileSync(path, "utf8");
|
|
495
|
-
if (onDisk !== file.text) drift.push(`${file.part}: ${file.rel}\n${shortDiff(file.rel, onDisk, file.text)}`);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
return drift;
|
|
499
|
-
}
|
|
500
|
-
/** Writes every file but the stdout ones, whose text it returns for printing. */
|
|
501
|
-
function writeSqlFiles(cwd, files) {
|
|
502
|
-
const wrote = [];
|
|
503
|
-
const printed = [];
|
|
504
|
-
for (const file of files) {
|
|
505
|
-
if (file.rel === "-") {
|
|
506
|
-
printed.push(file.text.trimEnd());
|
|
507
|
-
continue;
|
|
508
|
-
}
|
|
509
|
-
const path = resolve(cwd, file.rel);
|
|
510
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
511
|
-
writeFileSync(path, file.text);
|
|
512
|
-
wrote.push(file.rel);
|
|
513
|
-
}
|
|
514
|
-
return {
|
|
515
|
-
wrote,
|
|
516
|
-
printed: printed.join("\n")
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
const PARTS = [
|
|
520
|
-
"helpers",
|
|
521
|
-
"seeds",
|
|
522
|
-
"indexes",
|
|
523
|
-
"policies",
|
|
524
|
-
"hook"
|
|
525
|
-
];
|
|
526
|
-
/** `--split helpers,seeds,indexes,policies,hook`: the parts in that fixed order, or an error. */
|
|
527
|
-
function parseSplit(raw) {
|
|
528
|
-
if (raw === void 0) return;
|
|
529
|
-
const names = raw.split(",").map((name) => name.trim()).filter((name) => name !== "");
|
|
530
|
-
if (names.filter((name) => !PARTS.some((part) => part === name)).length > 0 || names.length === 0) return `rls generate --split takes helpers, seeds, indexes, policies and hook, got '${raw}'`;
|
|
531
|
-
return PARTS.filter((part) => names.includes(part));
|
|
532
|
-
}
|
|
533
|
-
/** The path of one split part: `{part}` in `out` replaced by the part name. */
|
|
534
|
-
function partPath(out, part) {
|
|
535
|
-
return out.replaceAll("{part}", part);
|
|
536
|
-
}
|
|
537
|
-
/**
|
|
538
|
-
* pg-delta's per-schema, unnumbered layout: pg-delta orders statements by
|
|
539
|
-
* their dependencies, so no file needs a number to apply first.
|
|
540
|
-
*/
|
|
541
|
-
function pgDeltaPath(root, part, schema) {
|
|
542
|
-
switch (part) {
|
|
543
|
-
case "helpers":
|
|
544
|
-
case "indexes":
|
|
545
|
-
case "seeds": return `${root}/${schema}/${part}.sql`;
|
|
546
|
-
case "policies": return `${root}/public/policies/permdock.sql`;
|
|
547
|
-
case "hook": return `${root}/${schema}/functions/custom_access_token_hook.sql`;
|
|
548
|
-
default: return part;
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
335
|
//#endregion
|
|
552
|
-
//#region src/cli/supabase-
|
|
553
|
-
const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
|
|
554
|
-
function escape(text) {
|
|
555
|
-
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
556
|
-
}
|
|
557
|
-
/** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
|
|
558
|
-
function helpersDefined(sql, schema) {
|
|
559
|
-
const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
|
|
560
|
-
const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
|
|
561
|
-
const names = /* @__PURE__ */ new Set();
|
|
562
|
-
for (const match of sql.matchAll(create)) {
|
|
563
|
-
const name = (match[1] ?? "").toLowerCase();
|
|
564
|
-
if (HELPER_NAME.test(name)) names.add(name);
|
|
565
|
-
}
|
|
566
|
-
return names;
|
|
567
|
-
}
|
|
568
|
-
/** SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`) and the migration folders, not the hook itself. */
|
|
569
|
-
function helperSources(cwd, config, hookOut) {
|
|
570
|
-
const hook = resolve(cwd, hookOut);
|
|
571
|
-
const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
|
|
572
|
-
const rlsOut = resolve(cwd, config.rls?.out ?? "rls.sql");
|
|
573
|
-
if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
|
|
574
|
-
files.delete(hook);
|
|
575
|
-
return [...files];
|
|
576
|
-
}
|
|
577
|
-
function missingHelpersInFiles(cwd, config, manifest) {
|
|
578
|
-
const found = /* @__PURE__ */ new Set();
|
|
579
|
-
for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
|
|
580
|
-
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
581
|
-
}
|
|
582
|
-
async function missingHelpersInDb(db, manifest, connect = (url) => connectPg(url, "permdock supabase hook generate --db")) {
|
|
583
|
-
const client = await connect(db);
|
|
584
|
-
try {
|
|
585
|
-
const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
586
|
-
where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
|
|
587
|
-
const found = new Set(result.rows.map((row) => row["proname"]));
|
|
588
|
-
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
589
|
-
} finally {
|
|
590
|
-
await client.end();
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
function missingHelpersMessage(manifest, missing) {
|
|
594
|
-
return `PD039 schema ${manifest.helpers.schema} has no ${missing.join(", ")}: the hook's claims are read by these helpers; run permdock rls generate and apply its migration`;
|
|
595
|
-
}
|
|
596
|
-
/** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
|
|
597
|
-
function oversizedClaims(samples, manifest) {
|
|
598
|
-
const encoder = new TextEncoder();
|
|
599
|
-
const oversized = [];
|
|
600
|
-
for (const claim of manifest.claims) {
|
|
601
|
-
if (claim.source === "permdock") continue;
|
|
602
|
-
let bytes = 0;
|
|
603
|
-
for (const sample of samples) {
|
|
604
|
-
if (sample === null || typeof sample !== "object") continue;
|
|
605
|
-
const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
|
|
606
|
-
if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
|
|
607
|
-
}
|
|
608
|
-
if (bytes > manifest.budget.bytes) oversized.push({
|
|
609
|
-
name: claim.name,
|
|
610
|
-
bytes
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
return oversized;
|
|
614
|
-
}
|
|
615
|
-
/** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
|
|
616
|
-
function droppedMemberships(samples) {
|
|
617
|
-
const out = [];
|
|
618
|
-
for (const [index, sample] of samples.entries()) {
|
|
619
|
-
if (sample === null || typeof sample !== "object") continue;
|
|
620
|
-
const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
|
|
621
|
-
const { dropped } = readMemberships(claim);
|
|
622
|
-
if (dropped.length > 0) out.push({
|
|
623
|
-
sample: index,
|
|
624
|
-
entries: dropped
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
return out;
|
|
628
|
-
}
|
|
629
|
-
function pd039(input) {
|
|
630
|
-
const findings = [];
|
|
631
|
-
const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
|
|
632
|
-
if (missing.length > 0) findings.push({
|
|
633
|
-
code: "PD039",
|
|
634
|
-
severity: "warning",
|
|
635
|
-
message: missingHelpersMessage(input.manifest, missing).slice(6),
|
|
636
|
-
fix: "run permdock rls generate, or set rls.out to the migration that holds the helpers"
|
|
637
|
-
});
|
|
638
|
-
const fixture = input.config.doctor?.claims;
|
|
639
|
-
if (fixture !== void 0) {
|
|
640
|
-
const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
|
|
641
|
-
const samples = Array.isArray(parsed) ? parsed : [];
|
|
642
|
-
for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
|
|
643
|
-
code: "PD039",
|
|
644
|
-
severity: "warning",
|
|
645
|
-
message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
|
|
646
|
-
fix: "return less from the claim function, or read the data per request instead of from the token"
|
|
647
|
-
});
|
|
648
|
-
for (const { sample, entries } of droppedMemberships(samples)) findings.push({
|
|
649
|
-
code: "PD039",
|
|
650
|
-
severity: "warning",
|
|
651
|
-
message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
|
|
652
|
-
fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
return findings;
|
|
656
|
-
}
|
|
657
|
-
//#endregion
|
|
658
|
-
//#region src/cli/supabase-hook.ts
|
|
659
|
-
const SUPABASE_HELP = `permdock supabase hook generate | inspect
|
|
660
|
-
|
|
661
|
-
hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
|
|
662
|
-
[--active-from app_metadata.active_<scope>|<table>.<column>]
|
|
663
|
-
[--budget 1024] [--schema public] [--grants-out <file>|-]
|
|
664
|
-
inspect [--json] [--out [permdock.manifest.json]] [--check]
|
|
665
|
-
|
|
666
|
-
Reads supabase.hook from permdock.config.ts: the fromTable / fromJunction sources the app
|
|
667
|
-
passes as memberships. hook generate emits custom_access_token_hook(jsonb), the grants it
|
|
668
|
-
needs (in --grants-out instead, for declarative schemas), the permdock_authz_version table
|
|
669
|
-
and its triggers. Never grants anything to
|
|
670
|
-
service_role. inspect prints the manifest: helper schema, names and signatures, tenant claim,
|
|
671
|
-
budget, the claims the hook writes, the membership sources and the columns that decide them.
|
|
672
|
-
--out writes it as JSON, to permdock.manifest.json without a path; --check exits 1 when that
|
|
673
|
-
file differs.
|
|
674
|
-
`;
|
|
675
|
-
const MANIFEST_FILE = "permdock.manifest.json";
|
|
336
|
+
//#region src/cli/supabase-hook-sql.ts
|
|
676
337
|
const MANAGED_TRIGGER = "permdock_protect_managed";
|
|
677
338
|
const CLAIM_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
678
339
|
const PROTOTYPE_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -767,7 +428,7 @@ function extraClaimsPlan(claims, tenantClaim) {
|
|
|
767
428
|
continue;
|
|
768
429
|
}
|
|
769
430
|
if (typeof fn !== "string" || !CLAIM_FUNCTION.test(fn)) {
|
|
770
|
-
errors.push(`supabase.hook.claims.${claim} must be a schema-qualified function name such as
|
|
431
|
+
errors.push(`supabase.hook.claims.${claim} must be a schema-qualified function name such as public.feature_claims`);
|
|
771
432
|
continue;
|
|
772
433
|
}
|
|
773
434
|
planned.push({
|
|
@@ -780,6 +441,50 @@ function extraClaimsPlan(claims, tenantClaim) {
|
|
|
780
441
|
errors
|
|
781
442
|
};
|
|
782
443
|
}
|
|
444
|
+
function beforePlan(before) {
|
|
445
|
+
const list = before === void 0 ? [] : typeof before === "string" ? [before] : before;
|
|
446
|
+
const bad = list.filter((fn) => typeof fn !== "string" || !CLAIM_FUNCTION.test(fn));
|
|
447
|
+
if (bad.length > 0) throw new Error(`PermDock CLI: supabase.hook.before must name schema-qualified functions such as auth_checks.before_token (got ${bad.map(String).join(", ")})`);
|
|
448
|
+
return [...new Set(list)];
|
|
449
|
+
}
|
|
450
|
+
function beforeSql(before) {
|
|
451
|
+
return before.map((fn) => `
|
|
452
|
+
checked := ${quoteTable(fn)}(event);
|
|
453
|
+
if checked is null or jsonb_typeof(checked) is distinct from 'object' then
|
|
454
|
+
return jsonb_build_object('error', jsonb_build_object('http_code', 500, 'message', ${quoteLiteral(`${fn} returned no event`)}));
|
|
455
|
+
end if;
|
|
456
|
+
if checked ? 'error' then
|
|
457
|
+
return checked;
|
|
458
|
+
end if;
|
|
459
|
+
event := checked;`).join("").concat(before.length === 0 ? "" : "\n claims := event -> 'claims';");
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* `hook.validate`: the claims the hook writes, dropped together when they do
|
|
463
|
+
* not match `supabase-claims-v1.json`, so a bad claim denies instead of
|
|
464
|
+
* failing sign-in. `hook.claims` entries belong to other packages and stay.
|
|
465
|
+
*/
|
|
466
|
+
function validateSql(parts) {
|
|
467
|
+
if (parts.hook.validate !== true) return "";
|
|
468
|
+
const keys = /* @__PURE__ */ new Set([
|
|
469
|
+
"user_role",
|
|
470
|
+
"roles",
|
|
471
|
+
"memberships",
|
|
472
|
+
"memberships_truncated",
|
|
473
|
+
"attrs",
|
|
474
|
+
"authz_ver",
|
|
475
|
+
parts.tenantClaim
|
|
476
|
+
]);
|
|
477
|
+
return `
|
|
478
|
+
if not extensions.jsonb_matches_schema(${quoteLiteral(JSON.stringify(PERMDOCK_CLAIMS_SCHEMA))}::json, claims) then
|
|
479
|
+
claims := claims${[...keys].map((key) => ` - ${quoteLiteral(key)}`).join("")};
|
|
480
|
+
end if;`;
|
|
481
|
+
}
|
|
482
|
+
function validateGrantsSql(parts) {
|
|
483
|
+
return parts.hook.validate === true ? `
|
|
484
|
+
create extension if not exists pg_jsonschema with schema extensions;
|
|
485
|
+
grant usage on schema extensions to supabase_auth_admin;
|
|
486
|
+
grant execute on function extensions.jsonb_matches_schema(json, jsonb) to supabase_auth_admin;` : "";
|
|
487
|
+
}
|
|
783
488
|
const VERSION_TRIGGER = "permdock_authz_version";
|
|
784
489
|
const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
|
|
785
490
|
function memberForPlan(input) {
|
|
@@ -806,23 +511,26 @@ function mappedMembership(mapped, scopes, name) {
|
|
|
806
511
|
const column = scopeColumn(mapped, scopes, ancestor);
|
|
807
512
|
if (column !== void 0) within[ancestor] = column;
|
|
808
513
|
}
|
|
514
|
+
const role = memberRoleOf(mapped);
|
|
809
515
|
const columns = [
|
|
810
516
|
mapped.user,
|
|
811
517
|
id,
|
|
812
|
-
|
|
518
|
+
...role.columns,
|
|
813
519
|
...Object.values(within),
|
|
814
|
-
...mapped.via ===
|
|
815
|
-
...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt]
|
|
520
|
+
...typeof mapped.via === "string" ? [mapped.via] : [],
|
|
521
|
+
...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
|
|
522
|
+
...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
|
|
816
523
|
];
|
|
817
524
|
return {
|
|
818
525
|
table: mapped.table.includes(".") ? mapped.table : `public.${mapped.table}`,
|
|
819
526
|
user: { column: mapped.user },
|
|
820
527
|
scope: { value: name },
|
|
821
528
|
id: { column: id },
|
|
822
|
-
role:
|
|
529
|
+
role: roleManifest(role),
|
|
823
530
|
...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
|
|
824
|
-
...mapped.via === void 0 ? {} : { via: { column: mapped.via } },
|
|
531
|
+
...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
|
|
825
532
|
...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
|
|
533
|
+
...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
|
|
826
534
|
columns: [...new Set(columns)]
|
|
827
535
|
};
|
|
828
536
|
}
|
|
@@ -893,7 +601,7 @@ function entriesSql(parts) {
|
|
|
893
601
|
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
894
602
|
'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
|
|
895
603
|
'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
|
|
896
|
-
'managedBy', s.managed_by, 'entitlements', s.seats
|
|
604
|
+
'managedBy', s.managed_by, 'entitlements', s.seats${source.sql.keeps ? ", 'keep', s.keep" : ""}
|
|
897
605
|
)) as entry
|
|
898
606
|
from (
|
|
899
607
|
${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
|
|
@@ -957,8 +665,8 @@ declare
|
|
|
957
665
|
in_active boolean := false;
|
|
958
666
|
budget integer := ${String(parts.budget)};
|
|
959
667
|
used integer := 0;
|
|
960
|
-
item record;${typedUsers(parts)}${plan === void 0 ? "" : "\n attrs jsonb;"}${parts.extra.length === 0 ? "" : "\n extra jsonb;"}${parts.version ? "\n ver bigint;" : ""}
|
|
961
|
-
begin${suspended}
|
|
668
|
+
item record;${typedUsers(parts)}${plan === void 0 ? "" : "\n attrs jsonb;"}${parts.extra.length === 0 ? "" : "\n extra jsonb;"}${parts.version ? "\n ver bigint;" : ""}${parts.before.length === 0 ? "" : "\n checked jsonb;"}
|
|
669
|
+
begin${beforeSql(parts.before)}${suspended}
|
|
962
670
|
claims := claims - 'attrs'${dropExtra};
|
|
963
671
|
${roles}
|
|
964
672
|
claims := jsonb_set(claims, '{roles}', held);
|
|
@@ -990,85 +698,412 @@ ${entriesSql(parts).replaceAll(/^/gmu, " ")}
|
|
|
990
698
|
end if;
|
|
991
699
|
if in_active then
|
|
992
700
|
claims := jsonb_set(claims, ${quoteLiteral(`{${parts.tenantClaim}}`)}, to_jsonb(active));
|
|
993
|
-
end if;${extra}${version}
|
|
701
|
+
end if;${extra}${version}${validateSql(parts)}
|
|
994
702
|
return jsonb_set(event, '{claims}', claims);
|
|
995
703
|
end;
|
|
996
704
|
$$;`;
|
|
997
705
|
}
|
|
706
|
+
/** Everything the hook grants `supabase_auth_admin`, and the execute revoke. */
|
|
707
|
+
function grantsSql(parts) {
|
|
708
|
+
const schema = quoteIdent(parts.schema);
|
|
709
|
+
const fn = `${schema}.custom_access_token_hook`;
|
|
710
|
+
return [
|
|
711
|
+
`-- supabase_auth_admin: the grants and read policies the hook needs
|
|
712
|
+
grant usage on schema ${schema} to supabase_auth_admin;
|
|
713
|
+
grant execute on function ${fn}(jsonb) to supabase_auth_admin;
|
|
714
|
+
revoke execute on function ${fn}(jsonb) from authenticated, anon, public;${extraGrantsSql(parts.extra)}${beforeGrantsSql(parts.before, parts.extra)}${memberForGrantsSql(parts)}${validateGrantsSql(parts)}`,
|
|
715
|
+
readsSql(parts),
|
|
716
|
+
parts.version ? authAdminRead(`${parts.schema}.${AUTHZ_VERSION_TABLE}`, "version") : ""
|
|
717
|
+
].filter((chunk) => chunk !== "").join("\n");
|
|
718
|
+
}
|
|
719
|
+
function extraGrantsSql(extra) {
|
|
720
|
+
return [...[...new Set(extra.map((entry) => entry.fn.split(".")[0]))].map((name) => `\ngrant usage on schema ${quoteIdent(name ?? "")} to supabase_auth_admin;`), ...extra.map((entry) => `\ngrant execute on function ${quoteTable(entry.fn)}(uuid) to supabase_auth_admin;`)].join("");
|
|
721
|
+
}
|
|
722
|
+
function beforeGrantsSql(before, extra) {
|
|
723
|
+
const granted = new Set(extra.map((entry) => entry.fn.split(".")[0]));
|
|
724
|
+
return [...[...new Set(before.map((fn) => fn.split(".")[0] ?? ""))].filter((name) => !granted.has(name)).map((name) => `\ngrant usage on schema ${quoteIdent(name)} to supabase_auth_admin;`), ...before.map((fn) => `\ngrant execute on function ${quoteTable(fn)}(jsonb) to supabase_auth_admin;`)].join("");
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Execute on each `member_<scope>_ids_for(uuid)` for the `hook.claims`
|
|
728
|
+
* functions, which run as `supabase_auth_admin` and may call them. Only with
|
|
729
|
+
* `hook.claims`: the grant needs the helpers migration applied first, and a
|
|
730
|
+
* hook without extra claims does not.
|
|
731
|
+
*/
|
|
732
|
+
function memberForGrantsSql(parts) {
|
|
733
|
+
const { schema, memberFor } = parts.helpers;
|
|
734
|
+
if (memberFor.length === 0 || parts.extra.length === 0) return "";
|
|
735
|
+
return [...schema === parts.schema ? [] : [`\ngrant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`], ...memberFor.map((scope) => `\ngrant execute on function ${quoteIdent(schema)}.${memberForHelper(scope)}(uuid) to supabase_auth_admin;`)].join("");
|
|
736
|
+
}
|
|
737
|
+
function readsSql(parts) {
|
|
738
|
+
const reads = /* @__PURE__ */ new Map();
|
|
739
|
+
for (const source of parts.sources) reads.set(source.sql.table, "memberships");
|
|
740
|
+
for (const source of parts.sources) {
|
|
741
|
+
for (const name of source.sql.reads) reads.set(name, reads.get(name) ?? "status");
|
|
742
|
+
for (const through of source.sql.throughs) reads.set(through.table, reads.get(through.table) ?? "role_keys");
|
|
743
|
+
const users = source.sql.userThrough;
|
|
744
|
+
if (users !== void 0) reads.set(users.table, reads.get(users.table) ?? "member_users");
|
|
745
|
+
}
|
|
746
|
+
if (parts.roles !== void 0) {
|
|
747
|
+
const { table: rolesTable, through } = parts.roles;
|
|
748
|
+
reads.set(rolesTable, reads.get(rolesTable) ?? "roles");
|
|
749
|
+
if (through !== void 0) reads.set(through.table, reads.get(through.table) ?? "role_keys");
|
|
750
|
+
}
|
|
751
|
+
if (parts.attrs?.table !== void 0 && parts.attrs.columns.length > 0) reads.set(parts.attrs.table, reads.get(parts.attrs.table) ?? "attrs");
|
|
752
|
+
if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
|
|
753
|
+
return [...[...new Set([...reads.keys()].map((name) => name.includes(".") ? name.split(".")[0] : "public").filter((schema) => schema !== void 0 && schema !== parts.schema && schema !== "auth"))].map((schema) => `grant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`), ...[...reads].map(([name, label]) => authAdminRead(name, label))].join("\n");
|
|
754
|
+
}
|
|
755
|
+
function versionSql(parts) {
|
|
756
|
+
if (!parts.version) return "";
|
|
757
|
+
const schema = quoteIdent(parts.schema);
|
|
758
|
+
const versionTable = `${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)}`;
|
|
759
|
+
const bump = `${schema}.permdock_bump_authz_version`;
|
|
760
|
+
const bumpFor = `${schema}.${AUTHZ_VERSION_BUMP}`;
|
|
761
|
+
const tables = [...new Map(parts.sources.filter((source) => source.sql.userThrough === void 0).map((source) => [source.sql.table, source.sql.user]))];
|
|
762
|
+
if (parts.roles !== void 0) tables.push([parts.roles.table, parts.roles.user]);
|
|
763
|
+
return `-- the authorization version: bumped on every membership change, written to authz_ver
|
|
764
|
+
create table if not exists ${versionTable} (
|
|
765
|
+
user_id uuid primary key references auth.users on delete cascade,
|
|
766
|
+
version bigint not null default 0
|
|
767
|
+
);
|
|
768
|
+
alter table ${versionTable} enable row level security;
|
|
769
|
+
revoke all on table ${versionTable} from anon, authenticated, public;
|
|
770
|
+
|
|
771
|
+
-- bumps each listed user once, skipping users already deleted from auth.users; no client role may call it
|
|
772
|
+
create or replace function ${bumpFor}(p_users uuid[])
|
|
773
|
+
returns void
|
|
774
|
+
language sql
|
|
775
|
+
security definer
|
|
776
|
+
set search_path = ''
|
|
777
|
+
as $$
|
|
778
|
+
insert into ${versionTable} as v (user_id, version)
|
|
779
|
+
select distinct u, 1 from unnest(p_users) u
|
|
780
|
+
where u is not null and exists (select 1 from auth.users au where au.id = u)
|
|
781
|
+
on conflict (user_id) do update set version = v.version + 1
|
|
782
|
+
$$;
|
|
783
|
+
revoke execute on function ${bumpFor}(uuid[]) from public, anon, authenticated;
|
|
784
|
+
|
|
785
|
+
create or replace function ${bump}()
|
|
786
|
+
returns trigger
|
|
787
|
+
language plpgsql
|
|
788
|
+
security definer
|
|
789
|
+
set search_path = ''
|
|
790
|
+
as $$
|
|
791
|
+
declare
|
|
792
|
+
column_name text := tg_argv[0];
|
|
793
|
+
begin
|
|
794
|
+
perform ${bumpFor}(array[
|
|
795
|
+
case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
|
|
796
|
+
case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
|
|
797
|
+
]::uuid[]);
|
|
798
|
+
return null;
|
|
799
|
+
end;
|
|
800
|
+
$$;
|
|
801
|
+
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
802
|
+
${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${table(name)};
|
|
803
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
804
|
+
after insert or update or delete on ${table(name)}
|
|
805
|
+
for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${memberUsersVersionSql(parts, bumpFor)}${roleKeysVersionSql(parts, versionTable)}`;
|
|
806
|
+
}
|
|
807
|
+
const SUBJECT_FOR = "subject_for";
|
|
808
|
+
/**
|
|
809
|
+
* `subject_for(p_user uuid) returns jsonb`: what the hook would put in a
|
|
810
|
+
* token for `p_user`, from the same membership, role, suspension and version
|
|
811
|
+
* reads, plus the custom roles the user holds when `database` mode keeps them
|
|
812
|
+
* in tables. A backend that only has PostgREST (`postgrestSources`) builds a
|
|
813
|
+
* subject for an acting user or a job from it. Naming the user is why no
|
|
814
|
+
* client role may execute it.
|
|
815
|
+
*/
|
|
816
|
+
function subjectForSql(parts, config) {
|
|
817
|
+
const schema = quoteIdent(parts.schema);
|
|
818
|
+
const fn = `${schema}.${SUBJECT_FOR}`;
|
|
819
|
+
const rows = parts.roles;
|
|
820
|
+
const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
|
|
821
|
+
into held
|
|
822
|
+
from ${rows.from}
|
|
823
|
+
where ${rows.userSql} = v_roles_user;`;
|
|
824
|
+
const suspended = parts.users === void 0 ? "" : `
|
|
825
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
826
|
+
return jsonb_build_object('id', uid::text, 'active', false);
|
|
827
|
+
end if;`;
|
|
828
|
+
const version = parts.version ? `
|
|
829
|
+
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
830
|
+
ver := coalesce(ver, 0);` : "";
|
|
831
|
+
const custom = config.rls?.customRoles === true && resolveAuthorize(config) === "database" ? customRolesOfSql(parts) : "";
|
|
832
|
+
return `-- the subject the hook would mint for p_user, for a backend acting for a stored user; no client role may execute it
|
|
833
|
+
create or replace function ${fn}(p_user uuid)
|
|
834
|
+
returns jsonb
|
|
835
|
+
language plpgsql
|
|
836
|
+
stable
|
|
837
|
+
security definer
|
|
838
|
+
set search_path = ''
|
|
839
|
+
as $$
|
|
840
|
+
declare
|
|
841
|
+
uid uuid := p_user;
|
|
842
|
+
held jsonb;
|
|
843
|
+
memberships jsonb;
|
|
844
|
+
custom jsonb := '[]'::jsonb;
|
|
845
|
+
ver bigint;${typedUsers(parts)}
|
|
846
|
+
begin
|
|
847
|
+
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
848
|
+
return null;
|
|
849
|
+
end if;${suspended}
|
|
850
|
+
${roles}
|
|
851
|
+
select coalesce(jsonb_agg(x.entry order by x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text), '[]'::jsonb)
|
|
852
|
+
into memberships
|
|
853
|
+
from (
|
|
854
|
+
${entriesSql(parts).replaceAll(/^/gmu, " ")}
|
|
855
|
+
) x;${custom}${version}
|
|
856
|
+
return jsonb_strip_nulls(jsonb_build_object(
|
|
857
|
+
'id', uid::text,
|
|
858
|
+
'active', true,
|
|
859
|
+
'roles', held,
|
|
860
|
+
'memberships', memberships,
|
|
861
|
+
'customRoles', custom,
|
|
862
|
+
'authzVersion', ver
|
|
863
|
+
));
|
|
864
|
+
end;
|
|
865
|
+
$$;
|
|
866
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* The PostgREST wrappers `supabase.hook.api` asks for: one `security definer`
|
|
870
|
+
* function per reader, in the API schema, executable by `service_role` only.
|
|
871
|
+
*/
|
|
872
|
+
function apiWrappersSql(parts) {
|
|
873
|
+
const api = parts.hook.api;
|
|
874
|
+
if (api === void 0) return "";
|
|
875
|
+
const target = api.schema ?? "public";
|
|
876
|
+
const names = hookApiNames(api.prefix);
|
|
877
|
+
const source = quoteIdent(parts.schema);
|
|
878
|
+
const wrapper = (name, inner, params, args, types, returns) => {
|
|
879
|
+
const fn = `${quoteIdent(target)}.${quoteIdent(name)}`;
|
|
880
|
+
if (target === parts.schema && name === inner) throw new Error(`PermDock CLI: supabase.hook.api would replace ${source}.${inner}: set api.schema or api.prefix`);
|
|
881
|
+
return `create or replace function ${fn}(${params})
|
|
882
|
+
returns ${returns}
|
|
883
|
+
language sql
|
|
884
|
+
stable
|
|
885
|
+
security definer
|
|
886
|
+
set search_path = ''
|
|
887
|
+
as $$ select ${source}.${inner}(${args}) $$;
|
|
888
|
+
revoke execute on function ${fn}(${types}) from public, anon, authenticated;
|
|
889
|
+
grant execute on function ${fn}(${types}) to service_role;`;
|
|
890
|
+
};
|
|
891
|
+
return `-- supabase.hook.api: wrappers in ${quoteIdent(target)} for a backend that reaches the hook schema through the Data API; only service_role may execute them
|
|
892
|
+
${wrapper(names.subjectFor, SUBJECT_FOR, "p_user uuid", "p_user", "uuid", "jsonb")}
|
|
893
|
+
|
|
894
|
+
${wrapper(names.membersOf, MEMBERS_OF, "p_scope text, p_id text", "p_scope, p_id", "text, text", "jsonb")}
|
|
895
|
+
|
|
896
|
+
${wrapper(names.authzVersionFor, AUTHZ_VERSION_FOR, "p_user uuid", "p_user", "uuid", "bigint")}`;
|
|
897
|
+
}
|
|
898
|
+
const AUTHZ_VERSION_FOR = "authz_version_for";
|
|
899
|
+
/**
|
|
900
|
+
* `authz_version_for(p_user uuid) returns bigint`: the authorization version
|
|
901
|
+
* `subject_for` reports, without reading roles or memberships, so a
|
|
902
|
+
* `claimsFirst` reread can check a token's freshness on every request and read
|
|
903
|
+
* the full subject only when it is behind. `null` for an unknown or suspended
|
|
904
|
+
* user, and when the version is off.
|
|
905
|
+
*/
|
|
906
|
+
function authzVersionForSql(parts) {
|
|
907
|
+
const schema = quoteIdent(parts.schema);
|
|
908
|
+
const fn = `${schema}.${AUTHZ_VERSION_FOR}`;
|
|
909
|
+
return `-- the authorization version subject_for reports for p_user, for a cheap freshness check; no client role may execute it
|
|
910
|
+
create or replace function ${fn}(p_user uuid)
|
|
911
|
+
returns bigint
|
|
912
|
+
language plpgsql
|
|
913
|
+
stable
|
|
914
|
+
security definer
|
|
915
|
+
set search_path = ''
|
|
916
|
+
as $$
|
|
917
|
+
declare
|
|
918
|
+
uid uuid := p_user;
|
|
919
|
+
ver bigint;
|
|
920
|
+
begin
|
|
921
|
+
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
922
|
+
return null;
|
|
923
|
+
end if;${parts.users === void 0 ? "" : `
|
|
924
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
925
|
+
return null;
|
|
926
|
+
end if;`}${parts.version ? `
|
|
927
|
+
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
928
|
+
ver := coalesce(ver, 0);` : ""}
|
|
929
|
+
return ver;
|
|
930
|
+
end;
|
|
931
|
+
$$;
|
|
932
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
933
|
+
}
|
|
934
|
+
const MEMBERS_OF = "members_of";
|
|
935
|
+
function membersOfSql(parts) {
|
|
936
|
+
const fn = `${quoteIdent(parts.schema)}.${MEMBERS_OF}`;
|
|
937
|
+
const keep = parts.sources.some((source) => source.sql.keeps);
|
|
938
|
+
const rows = parts.sources.map((source) => source.sql.list(keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
939
|
+
return `-- the live memberships of one scope instance, for a backend that lists members over PostgREST; no client role may execute it
|
|
940
|
+
create or replace function ${fn}(p_scope text, p_id text)
|
|
941
|
+
returns jsonb
|
|
942
|
+
language sql
|
|
943
|
+
stable
|
|
944
|
+
security definer
|
|
945
|
+
set search_path = ''
|
|
946
|
+
as $$
|
|
947
|
+
select coalesce(jsonb_agg(jsonb_build_object(
|
|
948
|
+
'principal', jsonb_build_object('id', s.user_id),
|
|
949
|
+
'membership', jsonb_strip_nulls(jsonb_build_object(
|
|
950
|
+
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
951
|
+
'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
|
|
952
|
+
'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
|
|
953
|
+
'managedBy', s.managed_by, 'entitlements', s.seats${keep ? ", 'keep', s.keep" : ""}
|
|
954
|
+
))
|
|
955
|
+
) order by s.user_id, s.roles::text), '[]'::jsonb)
|
|
956
|
+
from (
|
|
957
|
+
${rows}
|
|
958
|
+
) s
|
|
959
|
+
$$;
|
|
960
|
+
revoke execute on function ${fn}(text, text) from public, anon, authenticated;`;
|
|
961
|
+
}
|
|
998
962
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
963
|
+
* The custom roles `p_user` holds: tenant roles named on a membership of the
|
|
964
|
+
* scope (and instance) they live at, and platform roles among the global
|
|
965
|
+
* roles, each with its grants and includes in `CustomRole` form.
|
|
1002
966
|
*/
|
|
1003
|
-
function
|
|
1004
|
-
const
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
967
|
+
function customRolesOfSql(parts) {
|
|
968
|
+
const helpers = quoteIdent(parts.helpers.schema);
|
|
969
|
+
const perms = `${helpers}.custom_role_permissions`;
|
|
970
|
+
const includes = `${helpers}.custom_role_includes`;
|
|
971
|
+
const root = quoteLiteral(parts.root);
|
|
972
|
+
const match = `p.tenant_id is not distinct from c.tenant_id and p.scope = c.scope and p.scope_id is not distinct from c.scope_id and p.role = c.role`;
|
|
973
|
+
return `
|
|
974
|
+
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
975
|
+
'name', c.role,
|
|
976
|
+
'tenant', c.tenant_id::text,
|
|
977
|
+
'scope', c.scope,
|
|
978
|
+
'id', c.scope_id,
|
|
979
|
+
'grants', (
|
|
980
|
+
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
981
|
+
'permission', p.permission,
|
|
982
|
+
'effect', p.effect,
|
|
983
|
+
'level', to_jsonb(p) ->> 'level'
|
|
984
|
+
)) order by p.permission, p.effect), '[]'::jsonb)
|
|
985
|
+
from ${perms} p
|
|
986
|
+
where ${match}
|
|
987
|
+
),
|
|
988
|
+
'includes', (
|
|
989
|
+
select jsonb_agg(p.include_role order by p.include_role)
|
|
990
|
+
from ${includes} p
|
|
991
|
+
where ${match}
|
|
992
|
+
)
|
|
993
|
+
)) order by c.tenant_id::text, c.scope, c.scope_id, c.role), '[]'::jsonb)
|
|
994
|
+
into custom
|
|
995
|
+
from (
|
|
996
|
+
select tenant_id, scope, scope_id, role from ${perms}
|
|
997
|
+
union
|
|
998
|
+
select tenant_id, scope, scope_id, role from ${includes}
|
|
999
|
+
) c
|
|
1000
|
+
where (c.scope = 'global' and c.tenant_id is null and held ? c.role)
|
|
1001
|
+
or exists (
|
|
1002
|
+
select 1
|
|
1003
|
+
from jsonb_array_elements(memberships) m
|
|
1004
|
+
where m -> 'roles' ? c.role
|
|
1005
|
+
and m ->> 'scope' = c.scope
|
|
1006
|
+
and c.tenant_id::text = case when m ->> 'scope' = ${root} then m ->> 'id' else m -> 'within' ->> ${root} end
|
|
1007
|
+
and (c.scope_id is null or c.scope_id = m ->> 'id')
|
|
1008
|
+
);`;
|
|
1009
|
+
}
|
|
1010
|
+
function signedUp(user) {
|
|
1011
|
+
return `exists (select 1 from auth.users a where a.id = ${user})`;
|
|
1017
1012
|
}
|
|
1018
1013
|
/**
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1014
|
+
* Every membership source whose user id lives in another table: a row of
|
|
1015
|
+
* the source bumps the users its old and new references hold, and a change
|
|
1016
|
+
* of that table's user id (a contact re-linked to another login) bumps the
|
|
1017
|
+
* old and the new user.
|
|
1023
1018
|
*/
|
|
1024
|
-
function
|
|
1025
|
-
const
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1019
|
+
function memberUsersVersionSql(parts, bumpFor) {
|
|
1020
|
+
const holders = /* @__PURE__ */ new Map();
|
|
1021
|
+
for (const source of parts.sources) {
|
|
1022
|
+
const users = source.sql.userThrough;
|
|
1023
|
+
if (users !== void 0) holders.set(JSON.stringify([
|
|
1024
|
+
tableKey(source.sql.table),
|
|
1025
|
+
tableKey(users.table),
|
|
1026
|
+
users.id,
|
|
1027
|
+
users.key,
|
|
1028
|
+
users.ref
|
|
1029
|
+
]), {
|
|
1030
|
+
table: source.sql.table,
|
|
1031
|
+
users
|
|
1032
|
+
});
|
|
1037
1033
|
}
|
|
1038
|
-
if (
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
)
|
|
1055
|
-
|
|
1056
|
-
|
|
1034
|
+
if (holders.size === 0) return "";
|
|
1035
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
1036
|
+
for (const holder of holders.values()) {
|
|
1037
|
+
const key = tableKey(holder.table);
|
|
1038
|
+
const group = byTable.get(key);
|
|
1039
|
+
if (group === void 0) byTable.set(key, {
|
|
1040
|
+
table: holder.table,
|
|
1041
|
+
users: [holder.users]
|
|
1042
|
+
});
|
|
1043
|
+
else group.users.push(holder.users);
|
|
1044
|
+
}
|
|
1045
|
+
const memberBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_member_users`;
|
|
1046
|
+
const linkedBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_linked_users`;
|
|
1047
|
+
const groups = [...byTable];
|
|
1048
|
+
const lookup = (users, indent) => `${indent}perform ${bumpFor}(array(
|
|
1049
|
+
${indent} select u.${quoteIdent(users.key)}::uuid from ${table(users.table)} u
|
|
1050
|
+
${indent} where ((tg_op <> 'INSERT' and u.${quoteIdent(users.id)} = old.${quoteIdent(users.ref)})
|
|
1051
|
+
${indent} or (tg_op <> 'DELETE' and u.${quoteIdent(users.id)} = new.${quoteIdent(users.ref)}))
|
|
1052
|
+
${indent} and ${signedUp(`u.${quoteIdent(users.key)}::uuid`)}
|
|
1053
|
+
${indent}));`;
|
|
1054
|
+
const body = groups.map(([key, group]) => {
|
|
1055
|
+
if (groups.length === 1) return group.users.map((users) => lookup(users, " ")).join("\n");
|
|
1056
|
+
const dot = key.indexOf(".");
|
|
1057
|
+
return ` if tg_table_schema = ${quoteLiteral(key.slice(0, dot))} and tg_table_name = ${quoteLiteral(key.slice(dot + 1))} then
|
|
1058
|
+
${group.users.map((users) => lookup(users, " ")).join("\n")}
|
|
1059
|
+
end if;`;
|
|
1060
|
+
}).join("\n");
|
|
1061
|
+
const memberTriggers = groups.map(([, group]) => {
|
|
1062
|
+
const target = table(group.table);
|
|
1063
|
+
return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
|
|
1064
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1065
|
+
after insert or update or delete on ${target}
|
|
1066
|
+
for each row execute function ${memberBump}();`;
|
|
1067
|
+
}).join("\n");
|
|
1068
|
+
const userTables = /* @__PURE__ */ new Map();
|
|
1069
|
+
for (const { users } of holders.values()) {
|
|
1070
|
+
const key = JSON.stringify([tableKey(users.table), users.key]);
|
|
1071
|
+
const entry = userTables.get(key) ?? {
|
|
1072
|
+
table: users.table,
|
|
1073
|
+
key: users.key,
|
|
1074
|
+
ids: /* @__PURE__ */ new Set()
|
|
1075
|
+
};
|
|
1076
|
+
entry.ids.add(users.id);
|
|
1077
|
+
userTables.set(key, entry);
|
|
1078
|
+
}
|
|
1079
|
+
const userTriggers = [...userTables.values()].map((entry) => {
|
|
1080
|
+
const target = table(entry.table);
|
|
1081
|
+
const name = quoteIdent(`${VERSION_TRIGGER}_${entry.key}`);
|
|
1082
|
+
return `drop trigger if exists ${name} on ${target};
|
|
1083
|
+
create trigger ${name}
|
|
1084
|
+
after update of ${[entry.key, ...entry.ids].map(quoteIdent).join(", ")} or delete on ${target}
|
|
1085
|
+
for each row execute function ${linkedBump}(${quoteLiteral(entry.key)});`;
|
|
1086
|
+
}).join("\n");
|
|
1087
|
+
return `
|
|
1057
1088
|
|
|
1058
|
-
--
|
|
1059
|
-
create or replace function ${
|
|
1060
|
-
returns
|
|
1061
|
-
language
|
|
1089
|
+
-- a membership whose user id lives in another table bumps the users its old and new rows reference
|
|
1090
|
+
create or replace function ${memberBump}()
|
|
1091
|
+
returns trigger
|
|
1092
|
+
language plpgsql
|
|
1062
1093
|
security definer
|
|
1063
1094
|
set search_path = ''
|
|
1064
1095
|
as $$
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1096
|
+
begin
|
|
1097
|
+
${body}
|
|
1098
|
+
return null;
|
|
1099
|
+
end;
|
|
1068
1100
|
$$;
|
|
1069
|
-
revoke execute on function ${
|
|
1101
|
+
revoke execute on function ${memberBump}() from public, anon, authenticated;
|
|
1102
|
+
${memberTriggers}
|
|
1070
1103
|
|
|
1071
|
-
|
|
1104
|
+
-- re-linking that table's user id moves every membership that references the row: bump the old and the new user,
|
|
1105
|
+
-- skipping a login being deleted (its version row goes with it)
|
|
1106
|
+
create or replace function ${linkedBump}()
|
|
1072
1107
|
returns trigger
|
|
1073
1108
|
language plpgsql
|
|
1074
1109
|
security definer
|
|
@@ -1077,29 +1112,92 @@ as $$
|
|
|
1077
1112
|
declare
|
|
1078
1113
|
column_name text := tg_argv[0];
|
|
1079
1114
|
begin
|
|
1080
|
-
perform ${bumpFor}(array
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1115
|
+
perform ${bumpFor}(array(
|
|
1116
|
+
select u
|
|
1117
|
+
from unnest(array[
|
|
1118
|
+
case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
|
|
1119
|
+
case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
|
|
1120
|
+
]::uuid[]) u
|
|
1121
|
+
where ${signedUp("u")}
|
|
1122
|
+
));
|
|
1084
1123
|
return null;
|
|
1085
1124
|
end;
|
|
1086
1125
|
$$;
|
|
1087
|
-
revoke execute on function ${
|
|
1088
|
-
${
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1126
|
+
revoke execute on function ${linkedBump}() from public, anon, authenticated;
|
|
1127
|
+
${userTriggers}`;
|
|
1128
|
+
}
|
|
1129
|
+
/** Every table whose role column references a roles table: the global roles and the membership sources. */
|
|
1130
|
+
function roleKeyHolders(parts) {
|
|
1131
|
+
const holders = [];
|
|
1132
|
+
if (parts.roles?.through !== void 0) holders.push({
|
|
1133
|
+
table: parts.roles.table,
|
|
1134
|
+
user: parts.roles.user,
|
|
1135
|
+
through: parts.roles.through
|
|
1136
|
+
});
|
|
1137
|
+
for (const source of parts.sources) for (const through of source.sql.throughs) holders.push({
|
|
1138
|
+
table: source.sql.table,
|
|
1139
|
+
user: source.sql.user,
|
|
1140
|
+
...source.sql.userThrough === void 0 ? {} : { userThrough: source.sql.userThrough },
|
|
1141
|
+
through
|
|
1142
|
+
});
|
|
1143
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1144
|
+
return holders.filter((holder) => {
|
|
1145
|
+
const key = JSON.stringify([
|
|
1146
|
+
tableKey(holder.table),
|
|
1147
|
+
holder.user,
|
|
1148
|
+
holder.userThrough === void 0 ? null : [
|
|
1149
|
+
tableKey(holder.userThrough.table),
|
|
1150
|
+
holder.userThrough.id,
|
|
1151
|
+
holder.userThrough.key
|
|
1152
|
+
],
|
|
1153
|
+
tableKey(holder.through.table),
|
|
1154
|
+
holder.through.id,
|
|
1155
|
+
holder.through.key,
|
|
1156
|
+
holder.through.ref
|
|
1157
|
+
]);
|
|
1158
|
+
if (seen.has(key)) return false;
|
|
1159
|
+
seen.add(key);
|
|
1160
|
+
return true;
|
|
1161
|
+
});
|
|
1092
1162
|
}
|
|
1093
|
-
/**
|
|
1163
|
+
/** The bump for one roles table: unchanged keys and ids return early, else every holder of `old` is bumped. */
|
|
1164
|
+
function roleKeysBumpSql(holders, versionTable, indent) {
|
|
1165
|
+
return `${indent}if tg_op = 'UPDATE'
|
|
1166
|
+
${indent} and ${[...new Set(holders.flatMap((holder) => [holder.through.key, holder.through.id]))].map((column) => `new.${quoteIdent(column)} is not distinct from old.${quoteIdent(column)}`).join(`\n${indent} and `)} then
|
|
1167
|
+
${indent} return null;
|
|
1168
|
+
${indent}end if;
|
|
1169
|
+
${indent}insert into ${versionTable} as v (user_id, version)
|
|
1170
|
+
${indent}select distinct h.user_id, 1
|
|
1171
|
+
${indent}from (
|
|
1172
|
+
${indent} ${holders.map((holder) => holder.userThrough === void 0 ? `select h.${quoteIdent(holder.user)}::uuid as user_id from ${table(holder.table)} h where h.${quoteIdent(holder.through.ref)} = old.${quoteIdent(holder.through.id)}` : `select u.${quoteIdent(holder.userThrough.key)}::uuid as user_id from ${table(holder.table)} h join ${table(holder.userThrough.table)} u on u.${quoteIdent(holder.userThrough.id)} = h.${quoteIdent(holder.user)} where h.${quoteIdent(holder.through.ref)} = old.${quoteIdent(holder.through.id)}`).join(`\n${indent} union\n${indent} `)}
|
|
1173
|
+
${indent}) h
|
|
1174
|
+
${indent}where h.user_id is not null
|
|
1175
|
+
${indent} and exists (select 1 from auth.users au where au.id = h.user_id)
|
|
1176
|
+
${indent}on conflict (user_id) do update set version = v.version + 1;`;
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* A renamed role key changes the `roles` claim of everyone who holds it and
|
|
1180
|
+
* the `roles` of every membership that references it, so updates to a roles
|
|
1181
|
+
* table bump each holder.
|
|
1182
|
+
*/
|
|
1094
1183
|
function roleKeysVersionSql(parts, versionTable) {
|
|
1095
|
-
const
|
|
1096
|
-
if (
|
|
1184
|
+
const holders = roleKeyHolders(parts);
|
|
1185
|
+
if (holders.length === 0) return "";
|
|
1186
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
1187
|
+
for (const holder of holders) {
|
|
1188
|
+
const key = tableKey(holder.through.table);
|
|
1189
|
+
const group = byTable.get(key);
|
|
1190
|
+
if (group === void 0) byTable.set(key, {
|
|
1191
|
+
table: holder.through.table,
|
|
1192
|
+
holders: [holder]
|
|
1193
|
+
});
|
|
1194
|
+
else group.holders.push(holder);
|
|
1195
|
+
}
|
|
1097
1196
|
const bump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_role_keys`;
|
|
1098
|
-
const
|
|
1099
|
-
const key = quoteIdent(through.key);
|
|
1197
|
+
const groups = [...byTable];
|
|
1100
1198
|
return `
|
|
1101
1199
|
|
|
1102
|
-
-- a renamed role key changes the roles
|
|
1200
|
+
-- a renamed role key changes the roles of everyone who holds it, globally or through a membership
|
|
1103
1201
|
create or replace function ${bump}()
|
|
1104
1202
|
returns trigger
|
|
1105
1203
|
language plpgsql
|
|
@@ -1107,24 +1205,24 @@ security definer
|
|
|
1107
1205
|
set search_path = ''
|
|
1108
1206
|
as $$
|
|
1109
1207
|
begin
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
from ${table(parts.roles.table)} h
|
|
1118
|
-
where h.${quoteIdent(through.ref)} = old.${id}
|
|
1119
|
-
on conflict (user_id) do update set version = v.version + 1;
|
|
1208
|
+
${groups.map(([key, group]) => {
|
|
1209
|
+
if (groups.length === 1) return roleKeysBumpSql(group.holders, versionTable, " ");
|
|
1210
|
+
const dot = key.indexOf(".");
|
|
1211
|
+
return ` if tg_table_schema = ${quoteLiteral(key.slice(0, dot))} and tg_table_name = ${quoteLiteral(key.slice(dot + 1))} then
|
|
1212
|
+
${roleKeysBumpSql(group.holders, versionTable, " ")}
|
|
1213
|
+
end if;`;
|
|
1214
|
+
}).join("\n")}
|
|
1120
1215
|
return null;
|
|
1121
1216
|
end;
|
|
1122
1217
|
$$;
|
|
1123
1218
|
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
1124
|
-
|
|
1219
|
+
${groups.map(([, group]) => {
|
|
1220
|
+
const target = table(group.table);
|
|
1221
|
+
return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
|
|
1125
1222
|
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1126
|
-
after update or delete on ${
|
|
1223
|
+
after update or delete on ${target}
|
|
1127
1224
|
for each row execute function ${bump}();`;
|
|
1225
|
+
}).join("\n")}`;
|
|
1128
1226
|
}
|
|
1129
1227
|
/**
|
|
1130
1228
|
* Fails the migration when a client role can write an `attrs` column: a
|
|
@@ -1216,6 +1314,7 @@ function hookParts(scopes, config, overrides) {
|
|
|
1216
1314
|
active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
|
|
1217
1315
|
attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
|
|
1218
1316
|
extra: extraPlan.claims,
|
|
1317
|
+
before: beforePlan(hook.before),
|
|
1219
1318
|
helpers: {
|
|
1220
1319
|
schema: config.rls?.schema ?? "permdock",
|
|
1221
1320
|
...memberForPlan({
|
|
@@ -1253,38 +1352,121 @@ function hookClaims(parts) {
|
|
|
1253
1352
|
}))
|
|
1254
1353
|
];
|
|
1255
1354
|
}
|
|
1355
|
+
//#endregion
|
|
1356
|
+
//#region src/cli/supabase-hook-manifest.ts
|
|
1256
1357
|
function helperList(parts, types) {
|
|
1257
1358
|
const client = ["authenticated"];
|
|
1258
|
-
return [{
|
|
1259
|
-
name: "permdock_has",
|
|
1260
|
-
args: "p_grant text",
|
|
1261
|
-
returns: "boolean",
|
|
1262
|
-
execute: client
|
|
1263
|
-
}, ...parts.scopes.flatMap((scope) => {
|
|
1359
|
+
return [helperEntry(HELPERS.has, "p_grant text", "boolean", client), ...parts.scopes.flatMap((scope) => {
|
|
1264
1360
|
const returns = `setof ${types.get(scope.name) ?? "uuid"}`;
|
|
1265
1361
|
return [
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
returns,
|
|
1270
|
-
execute: client
|
|
1271
|
-
},
|
|
1272
|
-
{
|
|
1273
|
-
name: memberIdsHelper(scope.name),
|
|
1274
|
-
args: "",
|
|
1275
|
-
returns,
|
|
1276
|
-
execute: client
|
|
1277
|
-
},
|
|
1278
|
-
...parts.helpers.memberFor.includes(scope.name) ? [{
|
|
1279
|
-
name: memberForHelper(scope.name),
|
|
1280
|
-
args: "p_user uuid",
|
|
1281
|
-
returns,
|
|
1282
|
-
execute: parts.extra.length === 0 ? [] : ["supabase_auth_admin"]
|
|
1283
|
-
}] : []
|
|
1362
|
+
helperEntry(permittedIdsHelper(scope.name), "p_grant text", returns, client),
|
|
1363
|
+
helperEntry(memberIdsHelper(scope.name), "", returns, client),
|
|
1364
|
+
...parts.helpers.memberFor.includes(scope.name) ? [helperEntry(memberForHelper(scope.name), "p_user uuid", returns, parts.extra.length === 0 ? [] : ["supabase_auth_admin"])] : []
|
|
1284
1365
|
];
|
|
1285
1366
|
})];
|
|
1286
1367
|
}
|
|
1287
|
-
function
|
|
1368
|
+
function helperEntry(name, args, returns, execute) {
|
|
1369
|
+
return {
|
|
1370
|
+
name,
|
|
1371
|
+
args,
|
|
1372
|
+
returns,
|
|
1373
|
+
execute
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* The helpers `rls generate` writes beyond the ones the hook's claims feed:
|
|
1378
|
+
* the `_for` forms in `database` mode, and the assignment checks when a role
|
|
1379
|
+
* declares `assigns` (known only with the policy).
|
|
1380
|
+
*/
|
|
1381
|
+
function trustedHelpers(parts, config, types, tenantType, policy) {
|
|
1382
|
+
const database = resolveAuthorize(config) === "database";
|
|
1383
|
+
const custom = database && config.rls?.customRoles === true;
|
|
1384
|
+
const everyFor = parts.scopes.every((scope) => parts.helpers.memberFor.includes(scope.name));
|
|
1385
|
+
const client = ["authenticated"];
|
|
1386
|
+
const scopeTypes = parts.scopes.map((scope) => [scope.name, types.get(scope.name) ?? "uuid"]);
|
|
1387
|
+
const forUser = database ? [
|
|
1388
|
+
helperEntry(HELPERS.hasFor, "p_user uuid, p_grant text", "boolean", []),
|
|
1389
|
+
...scopeTypes.map(([scope, type]) => helperEntry(permittedForHelper(scope), "p_user uuid, p_grant text", `setof ${type}`, [])),
|
|
1390
|
+
...scopeTypes.map(([scope, type]) => helperEntry(permissionKeysForHelper(scope), `p_user uuid, p_id ${type}`, "setof text", []))
|
|
1391
|
+
] : [];
|
|
1392
|
+
const keyed = [
|
|
1393
|
+
helperEntry(USER_ID_HELPER, "", "uuid", client),
|
|
1394
|
+
...config.rls?.apiKeys === void 0 ? [] : [helperEntry(API_KEY_ALLOWS, "p_grant text", "boolean", client)],
|
|
1395
|
+
...forUser
|
|
1396
|
+
];
|
|
1397
|
+
if (!(policy !== void 0 && (ownershipRules(policy, parts.scopes)?.assigns.length ?? 0) > 0)) return keyed;
|
|
1398
|
+
const customArgs = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
|
|
1399
|
+
const anyArgs = `p_role text, p_tenant ${tenantType}, p_scope text, p_scope_id text`;
|
|
1400
|
+
return [
|
|
1401
|
+
...keyed,
|
|
1402
|
+
helperEntry(OWNERSHIP.canAssign, "p_role text, p_scope_id text", "boolean", client),
|
|
1403
|
+
...database ? [helperEntry(OWNERSHIP.canAssignFor, "p_user uuid, p_role text, p_scope_id text", "boolean", [])] : [],
|
|
1404
|
+
...custom ? [helperEntry(ASSIGNMENTS.custom, customArgs, "boolean", client)] : [],
|
|
1405
|
+
...custom && everyFor ? [helperEntry(ASSIGNMENTS.customFor, `p_user uuid, ${customArgs}`, "boolean", [])] : [],
|
|
1406
|
+
helperEntry(OWNERSHIP.canAssignAny, anyArgs, "boolean", client),
|
|
1407
|
+
...database && (!custom || everyFor) ? [helperEntry(OWNERSHIP.canAssignAnyFor, `p_user uuid, ${anyArgs}`, "boolean", [])] : []
|
|
1408
|
+
];
|
|
1409
|
+
}
|
|
1410
|
+
function activeRowManifest(row) {
|
|
1411
|
+
const keep = "keep" in row ? keptKeys(row) : [];
|
|
1412
|
+
return {
|
|
1413
|
+
table: qualifiedTable(row.table),
|
|
1414
|
+
id: row.id,
|
|
1415
|
+
...row.disabledAt === void 0 ? {} : { disabledAt: row.disabledAt },
|
|
1416
|
+
...row.status === void 0 ? {} : { status: row.status },
|
|
1417
|
+
...row.active === void 0 ? {} : { active: row.active },
|
|
1418
|
+
...keep.length === 0 ? {} : { keep }
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
/** The `rls` settings a package writing SQL next to the helpers reads: custom roles, global roles, suspension and assignment triggers. */
|
|
1422
|
+
function rlsSettings(parts, config) {
|
|
1423
|
+
const rls = config.rls;
|
|
1424
|
+
const suspension = checkSuspension(rls?.suspension, parts.scopes);
|
|
1425
|
+
const scopes = Object.entries(suspension?.scopes ?? {});
|
|
1426
|
+
const assigned = rls?.assignments === void 0 ? void 0 : guardedTables(config, parts.scopes);
|
|
1427
|
+
return {
|
|
1428
|
+
customRoles: resolveAuthorize(config) === "database" && rls?.customRoles === true,
|
|
1429
|
+
...parts.roles === void 0 ? {} : { roles: {
|
|
1430
|
+
table: qualifiedTable(parts.roles.table),
|
|
1431
|
+
user: { column: parts.roles.user },
|
|
1432
|
+
role: parts.roles.role
|
|
1433
|
+
} },
|
|
1434
|
+
...suspension === void 0 ? {} : { suspension: {
|
|
1435
|
+
...suspension.users === void 0 ? {} : { users: activeRowManifest(suspension.users) },
|
|
1436
|
+
...scopes.length === 0 ? {} : { scopes: Object.fromEntries(scopes.map(([name, row]) => [name, activeRowManifest(row)])) },
|
|
1437
|
+
...suspension.memberships === void 0 ? {} : { memberships: { keep: keptKeys(suspension.memberships) } }
|
|
1438
|
+
} },
|
|
1439
|
+
...assigned === void 0 ? {} : { assignments: { tables: assigned } },
|
|
1440
|
+
...rls?.apiKeys === void 0 ? {} : { apiKeys: apiKeyFields(rls.apiKeys) }
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
/** The `supabase/sdk` release `requires.capabilities` names features of. */
|
|
1444
|
+
const CAPABILITY_MATRIX = "capability-matrix-v1.12.0";
|
|
1445
|
+
function usesLiveSession(policy) {
|
|
1446
|
+
return [...policy?.grants ?? [], ...policy?.roles.flatMap((binding) => binding.grants) ?? []].some((grant) => hasConditionOp(grant.where, "liveSession") || hasConditionOp(grant.check, "liveSession"));
|
|
1447
|
+
}
|
|
1448
|
+
function requiredCapabilities(config, policy) {
|
|
1449
|
+
const ids = /* @__PURE__ */ new Set(["auth.session.get_claims"]);
|
|
1450
|
+
if (usesLiveSession(policy)) ids.add("auth.session.get_user");
|
|
1451
|
+
if (config.rls?.realtime !== void 0) ids.add("realtime.subscriptions.private_channel");
|
|
1452
|
+
for (const bucket of Object.values(config.rls?.storage?.buckets ?? {})) {
|
|
1453
|
+
if (bucket.read !== void 0) {
|
|
1454
|
+
ids.add("storage.file_buckets.download");
|
|
1455
|
+
ids.add("storage.file_buckets.list_files");
|
|
1456
|
+
}
|
|
1457
|
+
if (bucket.write !== void 0) {
|
|
1458
|
+
ids.add("storage.file_buckets.upload");
|
|
1459
|
+
ids.add("storage.file_buckets.update_file");
|
|
1460
|
+
ids.add("storage.file_buckets.move");
|
|
1461
|
+
}
|
|
1462
|
+
if (bucket.delete !== void 0) ids.add("storage.file_buckets.remove");
|
|
1463
|
+
}
|
|
1464
|
+
return {
|
|
1465
|
+
matrix: CAPABILITY_MATRIX,
|
|
1466
|
+
capabilities: [...ids].toSorted()
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function manifestOf(parts, out, config, policy) {
|
|
1288
1470
|
const rls = config.rls;
|
|
1289
1471
|
const ctx = {
|
|
1290
1472
|
dialect: "supabase",
|
|
@@ -1303,11 +1485,12 @@ function manifestOf(parts, out, config) {
|
|
|
1303
1485
|
hook: {
|
|
1304
1486
|
schema: parts.schema,
|
|
1305
1487
|
function: "custom_access_token_hook",
|
|
1306
|
-
out
|
|
1488
|
+
out,
|
|
1489
|
+
...parts.before.length === 0 ? {} : { before: parts.before }
|
|
1307
1490
|
},
|
|
1308
1491
|
helpers: {
|
|
1309
1492
|
schema: parts.helpers.schema,
|
|
1310
|
-
functions: helpers.map((
|
|
1493
|
+
functions: helpers.map((entry) => entry.name)
|
|
1311
1494
|
},
|
|
1312
1495
|
tenantClaim: parts.tenantClaim,
|
|
1313
1496
|
budget: {
|
|
@@ -1331,8 +1514,9 @@ function manifestOf(parts, out, config) {
|
|
|
1331
1514
|
type: types.get(scope.name) ?? "uuid",
|
|
1332
1515
|
...scope.within === void 0 ? {} : { within: scope.within }
|
|
1333
1516
|
})),
|
|
1334
|
-
helpers,
|
|
1335
|
-
memberships: parts.helpers.memberships
|
|
1517
|
+
helpers: [...helpers, ...trustedHelpers(parts, config, types, tenantTypeOf(ctx), policy)],
|
|
1518
|
+
memberships: parts.helpers.memberships,
|
|
1519
|
+
...rlsSettings(parts, config)
|
|
1336
1520
|
},
|
|
1337
1521
|
decidingColumns: decidingColumns(config, parts.attrs === void 0 ? void 0 : {
|
|
1338
1522
|
...parts.attrs.table === void 0 ? {} : { table: parts.attrs.table },
|
|
@@ -1341,7 +1525,8 @@ function manifestOf(parts, out, config) {
|
|
|
1341
1525
|
markers: {
|
|
1342
1526
|
hook: "v1",
|
|
1343
1527
|
grants: "v1"
|
|
1344
|
-
}
|
|
1528
|
+
},
|
|
1529
|
+
requires: requiredCapabilities(config, policy)
|
|
1345
1530
|
};
|
|
1346
1531
|
}
|
|
1347
1532
|
/** The first line of the generated hook: fields `--check` compares before the full text. */
|
|
@@ -1351,17 +1536,155 @@ function hookMarker(manifest) {
|
|
|
1351
1536
|
function defaultOut(config) {
|
|
1352
1537
|
return config.supabase?.hook?.out ?? "supabase/permdock-hook.sql";
|
|
1353
1538
|
}
|
|
1539
|
+
//#endregion
|
|
1540
|
+
//#region src/cli/supabase-setup.ts
|
|
1541
|
+
const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
|
|
1542
|
+
function escape(text) {
|
|
1543
|
+
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
1544
|
+
}
|
|
1545
|
+
/** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
|
|
1546
|
+
function helpersDefined(sql, schema) {
|
|
1547
|
+
const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
|
|
1548
|
+
const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
|
|
1549
|
+
const names = /* @__PURE__ */ new Set();
|
|
1550
|
+
for (const match of sql.matchAll(create)) {
|
|
1551
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
1552
|
+
if (HELPER_NAME.test(name)) names.add(name);
|
|
1553
|
+
}
|
|
1554
|
+
return names;
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`;
|
|
1558
|
+
* its `helpers` part when it has `{part}`), the SQL files next to the hook,
|
|
1559
|
+
* and the migration folders, not the hook itself.
|
|
1560
|
+
*/
|
|
1561
|
+
function helperSources(cwd, config, hookOut) {
|
|
1562
|
+
const hook = resolve(cwd, hookOut);
|
|
1563
|
+
const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
|
|
1564
|
+
const rlsOut = resolve(cwd, partPath(config.rls?.out ?? "rls.sql", "helpers"));
|
|
1565
|
+
if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
|
|
1566
|
+
const beside = dirname(hook);
|
|
1567
|
+
if (existsSync(beside)) {
|
|
1568
|
+
for (const entry of readdirSync(beside, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".sql")) files.add(join(beside, entry.name));
|
|
1569
|
+
}
|
|
1570
|
+
files.delete(hook);
|
|
1571
|
+
return [...files];
|
|
1572
|
+
}
|
|
1573
|
+
function missingHelpersInFiles(cwd, config, manifest) {
|
|
1574
|
+
const found = /* @__PURE__ */ new Set();
|
|
1575
|
+
for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
|
|
1576
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
1577
|
+
}
|
|
1578
|
+
async function missingHelpersInDb(db, manifest, connect = (url) => connectPg(url, "permdock supabase hook generate --db")) {
|
|
1579
|
+
const client = await connect(db);
|
|
1580
|
+
try {
|
|
1581
|
+
const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
1582
|
+
where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
|
|
1583
|
+
const found = new Set(result.rows.map((row) => row["proname"]));
|
|
1584
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
1585
|
+
} finally {
|
|
1586
|
+
await client.end();
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
function missingHelpersMessage(manifest, missing) {
|
|
1590
|
+
return `PD039 schema ${manifest.helpers.schema} has no ${missing.join(", ")}: the hook's claims are read by these helpers; run permdock rls generate and apply its migration`;
|
|
1591
|
+
}
|
|
1592
|
+
/** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
|
|
1593
|
+
function oversizedClaims(samples, manifest) {
|
|
1594
|
+
const encoder = new TextEncoder();
|
|
1595
|
+
const oversized = [];
|
|
1596
|
+
for (const claim of manifest.claims) {
|
|
1597
|
+
if (claim.source === "permdock") continue;
|
|
1598
|
+
let bytes = 0;
|
|
1599
|
+
for (const sample of samples) {
|
|
1600
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
1601
|
+
const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
|
|
1602
|
+
if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
|
|
1603
|
+
}
|
|
1604
|
+
if (bytes > manifest.budget.bytes) oversized.push({
|
|
1605
|
+
name: claim.name,
|
|
1606
|
+
bytes
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
return oversized;
|
|
1610
|
+
}
|
|
1611
|
+
/** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
|
|
1612
|
+
function droppedMemberships(samples) {
|
|
1613
|
+
const out = [];
|
|
1614
|
+
for (const [index, sample] of samples.entries()) {
|
|
1615
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
1616
|
+
const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
|
|
1617
|
+
const { dropped } = readMemberships(claim);
|
|
1618
|
+
if (dropped.length > 0) out.push({
|
|
1619
|
+
sample: index,
|
|
1620
|
+
entries: dropped
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
return out;
|
|
1624
|
+
}
|
|
1625
|
+
function pd039(input) {
|
|
1626
|
+
const findings = [];
|
|
1627
|
+
const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
|
|
1628
|
+
if (missing.length > 0) findings.push({
|
|
1629
|
+
code: "PD039",
|
|
1630
|
+
severity: "warning",
|
|
1631
|
+
message: missingHelpersMessage(input.manifest, missing).slice(6),
|
|
1632
|
+
fix: "run permdock rls generate, or set rls.out to the file that holds the helpers (with {part} for --split output) or write them next to the hook"
|
|
1633
|
+
});
|
|
1634
|
+
const fixture = input.config.doctor?.claims;
|
|
1635
|
+
if (fixture !== void 0) {
|
|
1636
|
+
const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
|
|
1637
|
+
const samples = Array.isArray(parsed) ? parsed : [];
|
|
1638
|
+
for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
|
|
1639
|
+
code: "PD039",
|
|
1640
|
+
severity: "warning",
|
|
1641
|
+
message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
|
|
1642
|
+
fix: "return less from the claim function, or read the data per request instead of from the token"
|
|
1643
|
+
});
|
|
1644
|
+
for (const { sample, entries } of droppedMemberships(samples)) findings.push({
|
|
1645
|
+
code: "PD039",
|
|
1646
|
+
severity: "warning",
|
|
1647
|
+
message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
|
|
1648
|
+
fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
return findings;
|
|
1652
|
+
}
|
|
1653
|
+
//#endregion
|
|
1654
|
+
//#region src/cli/supabase-hook.ts
|
|
1655
|
+
const SUPABASE_HELP = `permdock supabase hook generate | inspect
|
|
1656
|
+
|
|
1657
|
+
hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
|
|
1658
|
+
[--active-from app_metadata.active_<scope>|<table>.<column>]
|
|
1659
|
+
[--budget 1024] [--schema public] [--grants-out <file>|-]
|
|
1660
|
+
inspect [--json] [--out [permdock.manifest.json]] [--check]
|
|
1661
|
+
|
|
1662
|
+
Reads supabase.hook from permdock.config.ts: the fromTable / fromJunction sources the app
|
|
1663
|
+
passes as memberships. hook generate emits custom_access_token_hook(jsonb), the grants it
|
|
1664
|
+
needs (the ones db diff drops in --grants-out, for declarative schemas), the permdock_authz_version table
|
|
1665
|
+
and its triggers. Never grants anything to
|
|
1666
|
+
service_role unless supabase.hook.api is set, which grants its wrappers to it. inspect prints the manifest: helper schema, names and signatures, tenant claim,
|
|
1667
|
+
budget, the claims the hook writes, the membership sources and the columns that decide them.
|
|
1668
|
+
--out writes it as JSON, to permdock.manifest.json without a path; --check exits 1 when that
|
|
1669
|
+
file differs.
|
|
1670
|
+
`;
|
|
1671
|
+
const MANIFEST_FILE = "permdock.manifest.json";
|
|
1354
1672
|
/** `supabase.hook.out`, else pg-delta's per-schema path, else `supabase/permdock-hook.sql`. */
|
|
1355
1673
|
function hookOut(cwd, config, schema) {
|
|
1356
1674
|
const pgDelta = supabaseConfig(cwd).pgDelta;
|
|
1357
1675
|
return config.supabase?.hook?.out !== void 0 || pgDelta === void 0 ? defaultOut(config) : pgDeltaPath(pgDelta.schemaDir, "hook", schema ?? config.supabase?.hook?.schema ?? config.rls?.schema ?? "permdock");
|
|
1358
1676
|
}
|
|
1359
|
-
|
|
1360
|
-
|
|
1677
|
+
/**
|
|
1678
|
+
* The manifest `supabase inspect` prints. With `policy`, `rls.helpers` also
|
|
1679
|
+
* lists the assignment checks `rls generate` writes for roles that declare
|
|
1680
|
+
* `assigns`.
|
|
1681
|
+
*/
|
|
1682
|
+
function supabaseHookManifest(scopes, config, overrides = {}, policy) {
|
|
1683
|
+
return manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config, policy);
|
|
1361
1684
|
}
|
|
1362
1685
|
/**
|
|
1363
|
-
* The hook migration. With `grantsOut`, the `
|
|
1364
|
-
* left out of `sql` and returned in `grants` for that file.
|
|
1686
|
+
* The hook migration. With `grantsOut`, the statements `supabase db diff`
|
|
1687
|
+
* drops are left out of `sql` and returned in `grants` for that file.
|
|
1365
1688
|
*/
|
|
1366
1689
|
function supabaseHookSql(scopes, config, overrides = {}, grantsOut) {
|
|
1367
1690
|
const parts = hookParts(scopes, config, overrides);
|
|
@@ -1371,18 +1694,24 @@ function supabaseHookSql(scopes, config, overrides = {}, grantsOut) {
|
|
|
1371
1694
|
`${hookMarker(manifest)}
|
|
1372
1695
|
-- custom_access_token_hook(jsonb): memberships go active ${parts.root} first and stop at the budget
|
|
1373
1696
|
-- supabase/config.toml:
|
|
1374
|
-
${toml}${grantsOut === void 0 ? "" : `\n--
|
|
1697
|
+
${toml}${grantsOut === void 0 ? "" : `\n-- what supabase db diff drops from this part is in ${grantsOut}`}`,
|
|
1375
1698
|
attrsGuardSql(parts.attrs),
|
|
1376
1699
|
hookSql(parts),
|
|
1377
1700
|
versionSql(parts),
|
|
1378
|
-
|
|
1701
|
+
subjectForSql(parts, config),
|
|
1702
|
+
authzVersionForSql(parts),
|
|
1703
|
+
membersOfSql(parts),
|
|
1704
|
+
apiWrappersSql(parts),
|
|
1705
|
+
grantsSql(parts),
|
|
1379
1706
|
managedSql(parts)
|
|
1380
1707
|
].filter((chunk) => chunk !== "").join("\n\n");
|
|
1708
|
+
const { kept, moved } = splitUndiffed(`${sql}\n`);
|
|
1381
1709
|
const grants = `${GRANTS_MARKER} schema=${parts.schema}
|
|
1382
|
-
|
|
1710
|
+
-- the privileges and view options supabase db diff drops from the hook part
|
|
1711
|
+
${moved.join("\n")}
|
|
1383
1712
|
`;
|
|
1384
1713
|
return {
|
|
1385
|
-
sql: `${sql}\n
|
|
1714
|
+
sql: grantsOut === void 0 ? `${sql}\n` : kept,
|
|
1386
1715
|
grants,
|
|
1387
1716
|
manifest
|
|
1388
1717
|
};
|
|
@@ -1447,9 +1776,11 @@ function manifestFile(cwd, rel, manifest, check) {
|
|
|
1447
1776
|
};
|
|
1448
1777
|
}
|
|
1449
1778
|
async function loadScopes(cwd, config) {
|
|
1779
|
+
return scopeList((await loadHookPolicy(cwd, config)).scopes);
|
|
1780
|
+
}
|
|
1781
|
+
async function loadHookPolicy(cwd, config) {
|
|
1450
1782
|
if (config.policy === void 0) throw new Error("PermDock CLI: supabase hook generate needs policy in permdock.config.ts");
|
|
1451
|
-
|
|
1452
|
-
return scopeList(policy.scopes);
|
|
1783
|
+
return asPolicy(pickNamed(await loadModule(resolve(cwd, config.policy)), ["policy"]));
|
|
1453
1784
|
}
|
|
1454
1785
|
async function runSupabase(input) {
|
|
1455
1786
|
const [area, action] = input.rest;
|
|
@@ -1459,10 +1790,11 @@ async function runSupabase(input) {
|
|
|
1459
1790
|
schema: input.schema
|
|
1460
1791
|
}).filter(([, value]) => value !== void 0));
|
|
1461
1792
|
if (area === "inspect" && action === void 0) {
|
|
1462
|
-
const
|
|
1793
|
+
const policy = await loadHookPolicy(input.cwd, input.config);
|
|
1794
|
+
const manifest = supabaseHookManifest(scopeList(policy.scopes), input.config, {
|
|
1463
1795
|
...overrides,
|
|
1464
1796
|
out: hookOut(input.cwd, input.config, input.schema)
|
|
1465
|
-
});
|
|
1797
|
+
}, policy);
|
|
1466
1798
|
if (input.out !== void 0 || input.check) return manifestFile(input.cwd, typeof input.out === "string" ? input.out : MANIFEST_FILE, manifest, input.check);
|
|
1467
1799
|
return {
|
|
1468
1800
|
code: 0,
|
|
@@ -1525,4 +1857,4 @@ async function runSupabase(input) {
|
|
|
1525
1857
|
};
|
|
1526
1858
|
}
|
|
1527
1859
|
//#endregion
|
|
1528
|
-
export {
|
|
1860
|
+
export { seedsMigration as _, supabaseHookManifest as a, attrsPlan as c, resolveAuthorize as d, driftOf as f, pgDeltaPath as g, partPath as h, runSupabase as i, parseRbacAuthorize as l, parseSplit as m, hookOut as n, supabaseHookSql as o, isSeedsDirectory as p, loadScopes as r, pd039 as s, grantsLabel as t, rbacScaffold as u, writeSqlFiles as v };
|