permdock 0.1.0 → 0.2.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-cmvX2jDe.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 +206 -0
- package/dist/better-supabase/index.js +620 -0
- package/dist/bind-CVy3Espv.d.ts +25 -0
- package/dist/bind-VnYn2HKq.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-B68OFnZx.js → capability-4qPdO9gk.js} +7 -5
- package/dist/{capability-BDG_nkxt.d.ts → capability-Dq43tT1u.d.ts} +2 -2
- package/dist/catalog/index.d.ts +1 -1
- package/dist/catalog/index.js +1 -1
- package/dist/catalog-BjZSovXQ.js +2 -0
- package/dist/{catalog-doc-BSo_Q_-6.js → catalog-doc-Dsc9y7sd.js} +23 -12
- package/dist/{catalog-B0E6bggc.js → catalog-h2tlKFkq.js} +2 -2
- package/dist/{catalog-H-vFGwWK.js → catalog-z8q6bi_r.js} +4 -4
- 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-BGhHS-lY.js} +2 -2
- package/dist/{cloud-Cuxr6Czw.js → cloud-Bo3e9Ak3.js} +7 -6
- package/dist/{collect-O-tOtbnk.js → collect-DDDv_ESs.js} +194 -42
- package/dist/collect-DLlujdHC.js +83 -0
- package/dist/collect-rG2IQ3t0.js +2 -0
- package/dist/{commands-BMh_Etm2.js → commands-DWOrSQic.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-CNnl6uIt.d.ts +94 -0
- package/dist/config-BJiM-xAm.js +157 -0
- package/dist/config-Bn00Z3ks.js +36 -0
- package/dist/config-report-Dw_JcUqT.js +44 -0
- package/dist/{connection-JvqwHODC.js → connection-CU8MQxRi.js} +1 -1
- package/dist/{context-CKEAabd7.js → context-Ttp0ekMT.js} +36 -27
- package/dist/convex/index.d.ts +9 -4
- package/dist/convex/index.js +14 -4
- package/dist/create-BWyx3jua.d.ts +245 -0
- package/dist/create-CPhDL6qx.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-PdoqESvg.d.ts} +9 -10
- package/dist/{diff-x5Il8LVa.js → diff-B8Tunc3o.js} +44 -11
- package/dist/{diff-BUzf5Poi.js → diff-CzqLxZ77.js} +2 -2
- package/dist/doctor-WpvG6p4k.js +1194 -0
- package/dist/doctor-collect-ByfYlNv1.js +664 -0
- package/dist/{doctor-Bqo6FwUa.js → doctor-rOr-4ZfS.js} +2 -2
- 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-DAqevpNk.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-C3sHpZNS.js} +65 -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-LF_hS-yn.d.ts → index-A0YNL8jZ.d.ts} +1 -1
- package/dist/index-CtE07CBF.d.ts +6 -0
- package/dist/{index-xIxJLwOQ.d.ts → index-D3vbkoSI.d.ts} +75 -7
- package/dist/{index-CltcQ4Bf.d.ts → index-D6_cSa1z.d.ts} +68 -5
- package/dist/index.d.ts +14 -9
- package/dist/index.js +22 -17
- package/dist/instance-options-BK_rNOSC.d.ts +11 -0
- package/dist/instance-options-Dhhcnm3y.js +22 -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-GSQnMz3O.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-CwePaF7C.js → openapi-Bbg4wGVL.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-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-BkGQNzAh.d.ts +10 -0
- package/dist/parse-local-manifest-BrMozJCo.js +36 -0
- package/dist/pdp/index.d.ts +2 -2
- package/dist/pdp/index.js +14 -127
- package/dist/{permdock-yoZ7EjxX.js → permdock-CzqXjCpU.js} +641 -126
- package/dist/{permissions-B8vCg_bI.d.ts → permissions-CDgvKG5L.d.ts} +2 -2
- package/dist/{permissions-CofRqesP.js → permissions-CpZrivYv.js} +3 -3
- package/dist/{permissions-DC7GROPA.js → permissions-DVoF6dMt.js} +77 -15
- package/dist/{permissions-B4apnahQ.d.ts → permissions-DttnqqxC.d.ts} +34 -5
- package/dist/{pins-CqE1jZuF.js → pins-8ILTftC9.js} +2 -2
- package/dist/{policy-CInmNGkM.d.ts → policy-CFZAr_Al.d.ts} +330 -37
- package/dist/{policy-Cq2fB8Q5.d.ts → policy-D8UFyERw.d.ts} +2 -2
- package/dist/{policy-ANp36PnI.js → policy-xyWg7QdR.js} +173 -22
- package/dist/powersync-A8x_Yyvh.js +2 -0
- package/dist/powersync-C7kLglqX.js +540 -0
- package/dist/powersync-yW1UUFzp.js +62 -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-CkL50EEf.d.ts +7 -0
- package/dist/problem-details-_AMOYz-w.js +144 -0
- package/dist/{protected-BuPAycJT.d.ts → protected-CNsMzrVd.d.ts} +5 -4
- package/dist/protected-Cs6kTaHo.js +100 -0
- package/dist/protected-bZs6rbT1.d.ts +40 -0
- package/dist/{provider-wTAUda2v.d.ts → provider-Cjr62YMV.d.ts} +1 -1
- package/dist/provider-DLzpb2Xz.js +52 -0
- package/dist/provider-store-DNyC1y48.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-sqBGMnNs.d.ts +5 -0
- package/dist/resource-ref-CaS9yVPi.js +20 -0
- package/dist/revocations-Bhr0aMIC.js +27 -0
- package/dist/rls-BMNydpHk.js +2521 -0
- package/dist/{rls-BOFU3chG.js → rls-CEvs64Kj.js} +8 -3
- package/dist/rls-fields-BMasREhR.js +3502 -0
- package/dist/{rls-graph-DxQ39PJj.js → rls-graph-pc9fuXYm.js} +100 -36
- package/dist/{rls-import-CXV6LBlS.js → rls-import-D8F7j35Q.js} +7 -6
- package/dist/{rls-migrate-DrFPbf8p.js → rls-migrate-7wikfrVI.js} +6 -5
- package/dist/rls-ownership-B5Lkq62W.js +843 -0
- package/dist/rls-shared-aOQ4hGTO.js +493 -0
- package/dist/rls-verify-D8B7dOTh.js +745 -0
- package/dist/{row-check-BajTgQZ-.d.ts → row-check-Dt35czLy.d.ts} +12 -15
- package/dist/rpc-DOK54_CS.js +89 -0
- package/dist/{run-CJ6UvS3a.js → run-YXCJDNYk.js} +31 -12
- package/dist/runtime-CB8gQFvS.js +11 -0
- package/dist/{saas-BRrZzJkM.js → saas-Bj_FFkjN.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-Gs6TTVa-.d.ts} +1 -1
- package/dist/skills-Biq5uup3.js +192 -0
- package/dist/{skills-C_lpV4Jx.js → skills-JaymNCdK.js} +10 -4
- package/dist/{snapshot-DE8dE_hz.js → snapshot-Enxd0n-s.js} +138 -214
- package/dist/snapshot-cache-D6jLelln.js +44 -0
- package/dist/snapshot-cache-DwQH8ufx.d.ts +45 -0
- package/dist/solid/index.d.ts +39 -9
- package/dist/solid/index.js +76 -85
- package/dist/sources-B0UvrJ21.js +534 -0
- package/dist/sources-BcJgw0lt.d.ts +192 -0
- package/dist/{sql-Bll1d7y0.js → sql-xdg5f4Fi.js} +6 -1
- package/dist/{src-CTNT-PBW.js → src-CSbUiozF.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-D_pjn91-.d.ts} +13 -4
- package/dist/store-options-yO0V6gRn.js +716 -0
- package/dist/{stores-CkG2HrSg.js → stores-4_sqiOA1.js} +10 -63
- package/dist/{stores-CvOnu_2T.d.ts → stores-DVR5NHLE.d.ts} +20 -9
- package/dist/{stream-Q_xlB6Ko.js → stream-DDpvynSB.js} +16 -2
- package/dist/{stream-BhMfZOwr.d.ts → stream-DlvG2hs-.d.ts} +2 -2
- package/dist/subject-settings-BwBmCdTR.js +128 -0
- package/dist/supabase/index.d.ts +122 -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-CwafwxxF.js} +3 -3
- package/dist/{supabase-DTxGtYdi.js → supabase-DTBaC20O.js} +345 -367
- package/dist/supabase-config-Bs44dare.js +472 -0
- package/dist/{supabase-hook-D_OqGb_V.js → supabase-hook-BuxLQLpU.js} +994 -708
- package/dist/supabase-hook-D5qVd_D8.js +2 -0
- package/dist/supautils-DYZnzRWV.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-9AWO9EGt.d.ts +197 -0
- package/dist/{types-D6Z6-rKJ.d.ts → types-BPxgszlT.d.ts} +260 -22
- package/dist/{types-BvBk1wgn.d.ts → types-BVyImy5I.d.ts} +5 -3
- package/dist/{types-CxquUByI.d.ts → types-Bvc4oWT6.d.ts} +1 -1
- package/dist/types-C3s9xVJG.d.ts +48 -0
- package/dist/{types-BBK-exvJ.d.ts → types-Dnemwd77.d.ts} +7 -1
- package/dist/types-kxznc7cn.d.ts +52 -0
- package/dist/{types-B8w5PFtS.d.ts → types-vKyAMtcX.d.ts} +17 -1
- package/dist/{types-B9dmiiab.d.ts → types-vNSDF7Dq.d.ts} +1 -1
- package/dist/{sources-DOpW5Ila.d.ts → types-yF5LmQVD.d.ts} +86 -127
- package/dist/unplugin/index.d.ts +1 -1
- package/dist/unplugin/index.js +10 -9
- package/dist/{usage-1ozMubLY.js → usage-D07N9QJ3.js} +2 -2
- package/dist/usage-D82l1CwK.js +2 -0
- package/dist/{usage-CUnK6Ou9.js → usage-DG1oeCca.js} +225 -62
- package/dist/validation-error-CnjZ4t92.js +33 -0
- package/dist/validation-error-CtF23SrC.d.ts +26 -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-Etoa_Mvr.js +2 -0
- package/dist/watch-UihESpsJ.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,238 @@
|
|
|
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-DTBaC20O.js";
|
|
8
9
|
import "./budget-NbEtbJC_.js";
|
|
9
|
-
import { t as AUTHZ_VERSION_TABLE } from "./sources-
|
|
10
|
+
import { c as roleManifest, l as keptKeys, t as AUTHZ_VERSION_TABLE } from "./sources-B0UvrJ21.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-Bs44dare.js";
|
|
14
|
+
import { E as hasMemberFor, F as memberForSources, G as quoteLiteral, H as permittedIdsHelper, I as memberForTable, K as quoteTable, L as memberIdsHelper, P as memberForHelper, R as memberRoleOf, U as qualifiedTable, V as permittedForHelper, W as quoteIdent, Z as scopeTypeOf, f as USER_ID_HELPER, h as activeRowSql, it as tenantTypeOf, n as HELPERS, v as checkSuspension } from "./rls-shared-aOQ4hGTO.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-BMasREhR.js";
|
|
16
|
+
import { a as ownershipRules, i as guardedTables, n as OWNERSHIP, t as ASSIGNMENTS } from "./rls-ownership-B5Lkq62W.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;
|
|
92
|
-
}
|
|
93
|
-
push(start, text.length);
|
|
94
|
-
return statements;
|
|
95
|
-
}
|
|
96
|
-
/** Capture group `index` of a match, or '' when an optional group took no part. */
|
|
97
|
-
function group(match, index) {
|
|
98
|
-
return match[index] ?? "";
|
|
99
|
-
}
|
|
100
|
-
//#endregion
|
|
101
|
-
//#region src/cli/doctor-project.ts
|
|
102
|
-
function pd005(cwd) {
|
|
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 [];
|
|
121
|
-
}
|
|
122
|
-
function pd006(cwd) {
|
|
123
|
-
try {
|
|
124
|
-
const pkg = createRequire(resolve(cwd, "package.json"))("typescript/package.json");
|
|
125
|
-
const major = Number(pkg.version.split(".")[0]);
|
|
126
|
-
if (major < 5 || major === 5 && Number(pkg.version.split(".")[1]) < 9) return [{
|
|
127
|
-
code: "PD006",
|
|
128
|
-
severity: "error",
|
|
129
|
-
message: `TypeScript ${pkg.version} is below the supported matrix (5.9, 6, 7)`,
|
|
130
|
-
fix: "upgrade typescript to 5.9 or later"
|
|
131
|
-
}];
|
|
132
|
-
if (major > 7) return [{
|
|
133
|
-
code: "PD006",
|
|
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
|
-
}];
|
|
42
|
+
const path = resolve(cwd, file.rel);
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
44
|
+
writeFileSync(path, file.text);
|
|
45
|
+
wrote.push(file.rel);
|
|
146
46
|
}
|
|
47
|
+
return {
|
|
48
|
+
wrote,
|
|
49
|
+
printed: printed.join("\n")
|
|
50
|
+
};
|
|
147
51
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 [];
|
|
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));
|
|
171
65
|
}
|
|
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;
|
|
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);
|
|
187
69
|
}
|
|
188
|
-
const MIGRATION_DIRS = [
|
|
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");
|
|
201
70
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* companion `rls generate --revoke-columns` writes reads as its owner on
|
|
205
|
-
* purpose and carries a comment that says so.
|
|
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.
|
|
206
73
|
*/
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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));
|
|
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;
|
|
223
82
|
}
|
|
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
|
-
}));
|
|
230
83
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
for (const match of text.matchAll(AUTH_ROLE)) {
|
|
242
|
-
const line = text.slice(0, match.index).split("\n").length;
|
|
243
|
-
findings.push({
|
|
244
|
-
code: "PD040",
|
|
245
|
-
severity: "warning",
|
|
246
|
-
message: `${rel(cwd, file)}:${String(line)} calls auth.role(), which Supabase deprecated`,
|
|
247
|
-
fix: "name the roles on the policy (create policy ... to authenticated) and drop the auth.role() = 'authenticated' test; call permdock_has or permitted_<scope>_ids for permissions"
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return findings;
|
|
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();
|
|
90
|
+
}
|
|
91
|
+
/** `YYYYMMDDHHMMSS` in UTC, the Supabase migration version. */
|
|
92
|
+
function migrationVersion(now) {
|
|
93
|
+
return now.toISOString().replaceAll(/[^0-9]/gu, "").slice(0, 14);
|
|
252
94
|
}
|
|
253
|
-
const GRANT = /\b(grant|revoke)\s+([\s\S]+?)\s+on\s+(?:table\s+)?([\w."]+)\s+(?:to|from)\s+([\w\s,"]+?)(?:\s+with\s+grant\s+option|\s+cascade|\s+restrict)?\s*;/giu;
|
|
254
|
-
const CLIENT_ROLES = /* @__PURE__ */ new Set([
|
|
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;
|
|
271
95
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* everything, until a migration revokes them.
|
|
96
|
+
* The seeds part in a migrations directory: the newest migration there that
|
|
97
|
+
* starts with \`marker\` when its text is \`text\` already, otherwise a new
|
|
98
|
+
* \`<version>_permdock_seeds.sql\`. \`current\` is false when a new one is due.
|
|
276
99
|
*/
|
|
277
|
-
function
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
100
|
+
function seedsMigration(cwd, dir, marker, text, now) {
|
|
101
|
+
const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
|
102
|
+
const path = resolve(cwd, base);
|
|
103
|
+
const latest = existsSync(path) ? readdirSync(path).filter((name) => name.endsWith(".sql")).toSorted().toReversed().find((name) => readFileSync(resolve(path, name), "utf8").startsWith(marker)) : void 0;
|
|
104
|
+
if (latest !== void 0 && readFileSync(resolve(path, latest), "utf8") === text) return {
|
|
105
|
+
rel: `${base}/${latest}`,
|
|
106
|
+
current: true
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
rel: `${base}/${migrationVersion(now)}_permdock_seeds.sql`,
|
|
110
|
+
current: false
|
|
281
111
|
};
|
|
282
|
-
let defaults = config.supabase !== void 0 && target.startsWith("public.");
|
|
283
|
-
for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) for (const { text } of sqlStatements(readFileSync(file, "utf8"))) {
|
|
284
|
-
if (REVOKE_DEFAULT_TABLES.test(text)) {
|
|
285
|
-
defaults = false;
|
|
286
|
-
continue;
|
|
287
|
-
}
|
|
288
|
-
const created = CREATE_TABLE.exec(text);
|
|
289
|
-
if (created !== null) {
|
|
290
|
-
if (defaults && tableKey(created[1] ?? "") === target) {
|
|
291
|
-
writable.insert.add("*");
|
|
292
|
-
writable.update.add("*");
|
|
293
|
-
}
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
for (const [, verb = "", privileges = "", name = "", roles = ""] of `${text};`.matchAll(GRANT)) {
|
|
297
|
-
if (tableKey(name) !== target) continue;
|
|
298
|
-
if (!roles.split(",").map((role) => role.trim().replaceAll("\"", "").toLowerCase()).some((role) => CLIENT_ROLES.has(role))) continue;
|
|
299
|
-
for (const part of privileges.split(/,(?![^(]*\))/u)) applyPrivilege(writable, verb, part);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
return /* @__PURE__ */ new Set([...writable.insert, ...writable.update]);
|
|
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
112
|
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/cli/claims-schema.ts
|
|
319
115
|
/**
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
116
|
+
* The PermDock claims of `schemas/supabase-claims-v1.json` (its
|
|
117
|
+
* `permdockClaims` and `membership` definitions), self-contained so
|
|
118
|
+
* `supabase.hook.validate` can inline it into the hook for pg_jsonschema.
|
|
323
119
|
*/
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
120
|
+
const PERMDOCK_CLAIMS_SCHEMA = {
|
|
121
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
122
|
+
$ref: "#/$defs/permdockClaims",
|
|
123
|
+
$defs: {
|
|
124
|
+
permdockClaims: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
user_role: {
|
|
128
|
+
description: "Global roles: one name, a list, or null for none.",
|
|
129
|
+
anyOf: [
|
|
130
|
+
{ type: "string" },
|
|
131
|
+
{
|
|
132
|
+
type: "array",
|
|
133
|
+
items: { type: "string" }
|
|
134
|
+
},
|
|
135
|
+
{ type: "null" }
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
roles: {
|
|
139
|
+
type: "array",
|
|
140
|
+
items: { type: "string" }
|
|
141
|
+
},
|
|
142
|
+
memberships: {
|
|
143
|
+
type: "array",
|
|
144
|
+
items: { $ref: "#/$defs/membership" }
|
|
145
|
+
},
|
|
146
|
+
memberships_truncated: {
|
|
147
|
+
description: "true when the size budget cut memberships or attrs.",
|
|
148
|
+
type: "boolean"
|
|
149
|
+
},
|
|
150
|
+
tenant_id: {
|
|
151
|
+
description: "The active tenant (the default rls.tenantClaim): the canonical text of one instance of the first scope.",
|
|
152
|
+
type: "string"
|
|
153
|
+
},
|
|
154
|
+
attrs: {
|
|
155
|
+
description: "Allow-listed server-owned attributes, read as principal.claims.attrs.<key>.",
|
|
156
|
+
type: "object"
|
|
157
|
+
},
|
|
158
|
+
authz_ver: {
|
|
159
|
+
description: "The principal's authorization version.",
|
|
160
|
+
type: "integer"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
membership: {
|
|
165
|
+
type: "object",
|
|
166
|
+
required: ["roles"],
|
|
167
|
+
properties: {
|
|
168
|
+
scope: { type: "string" },
|
|
169
|
+
id: {
|
|
170
|
+
description: "Canonical text of the scope instance id (uuid::text for a uuid column); compared exactly, never lower-cased.",
|
|
171
|
+
type: "string"
|
|
172
|
+
},
|
|
173
|
+
within: {
|
|
174
|
+
description: "The id of every ancestor scope instance, by scope name.",
|
|
175
|
+
type: "object",
|
|
176
|
+
additionalProperties: { type: "string" }
|
|
177
|
+
},
|
|
178
|
+
on: {
|
|
179
|
+
type: "object",
|
|
180
|
+
required: ["resource", "id"],
|
|
181
|
+
properties: {
|
|
182
|
+
resource: { type: "string" },
|
|
183
|
+
id: { type: "string" }
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
tenant: { type: "string" },
|
|
187
|
+
team: { type: "string" },
|
|
188
|
+
roles: {
|
|
189
|
+
type: "array",
|
|
190
|
+
minItems: 1,
|
|
191
|
+
items: { type: "string" }
|
|
192
|
+
},
|
|
193
|
+
via: { type: "string" },
|
|
194
|
+
expiresAt: {
|
|
195
|
+
description: "Seconds since the epoch.",
|
|
196
|
+
type: "number"
|
|
197
|
+
},
|
|
198
|
+
grantedBy: { type: "string" },
|
|
199
|
+
reason: { type: "string" },
|
|
200
|
+
member: {
|
|
201
|
+
description: "The subgroup a membership source's group fills.",
|
|
202
|
+
type: "object",
|
|
203
|
+
required: ["group"],
|
|
204
|
+
properties: { group: {
|
|
205
|
+
type: "string",
|
|
206
|
+
minLength: 1
|
|
207
|
+
} }
|
|
208
|
+
},
|
|
209
|
+
managedBy: { const: "idp" },
|
|
210
|
+
entitlements: {
|
|
211
|
+
type: "array",
|
|
212
|
+
items: { type: "string" }
|
|
213
|
+
},
|
|
214
|
+
keep: {
|
|
215
|
+
description: "Set when the instance or an ancestor is suspended: the permission keys the membership still grants.",
|
|
216
|
+
type: "array",
|
|
217
|
+
items: {
|
|
218
|
+
type: "string",
|
|
219
|
+
minLength: 1
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
grants: {
|
|
223
|
+
description: "Custom roles in compact form, read by the helpers in jwt mode.",
|
|
224
|
+
type: "object"
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
anyOf: [
|
|
228
|
+
{ required: ["scope", "id"] },
|
|
229
|
+
{ required: ["tenant"] },
|
|
230
|
+
{ required: ["team"] },
|
|
231
|
+
{ required: ["on"] }
|
|
232
|
+
]
|
|
383
233
|
}
|
|
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
|
-
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
402
236
|
//#endregion
|
|
403
237
|
//#region src/cli/rls-rbac.ts
|
|
404
238
|
/**
|
|
@@ -483,196 +317,8 @@ revoke all on table ${q("user_roles")} from authenticated, anon, public;
|
|
|
483
317
|
warnings: []
|
|
484
318
|
};
|
|
485
319
|
}
|
|
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
320
|
//#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";
|
|
321
|
+
//#region src/cli/supabase-hook-sql.ts
|
|
676
322
|
const MANAGED_TRIGGER = "permdock_protect_managed";
|
|
677
323
|
const CLAIM_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
678
324
|
const PROTOTYPE_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -767,7 +413,7 @@ function extraClaimsPlan(claims, tenantClaim) {
|
|
|
767
413
|
continue;
|
|
768
414
|
}
|
|
769
415
|
if (typeof fn !== "string" || !CLAIM_FUNCTION.test(fn)) {
|
|
770
|
-
errors.push(`supabase.hook.claims.${claim} must be a schema-qualified function name such as
|
|
416
|
+
errors.push(`supabase.hook.claims.${claim} must be a schema-qualified function name such as public.feature_claims`);
|
|
771
417
|
continue;
|
|
772
418
|
}
|
|
773
419
|
planned.push({
|
|
@@ -780,6 +426,50 @@ function extraClaimsPlan(claims, tenantClaim) {
|
|
|
780
426
|
errors
|
|
781
427
|
};
|
|
782
428
|
}
|
|
429
|
+
function beforePlan(before) {
|
|
430
|
+
const list = before === void 0 ? [] : typeof before === "string" ? [before] : before;
|
|
431
|
+
const bad = list.filter((fn) => typeof fn !== "string" || !CLAIM_FUNCTION.test(fn));
|
|
432
|
+
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(", ")})`);
|
|
433
|
+
return [...new Set(list)];
|
|
434
|
+
}
|
|
435
|
+
function beforeSql(before) {
|
|
436
|
+
return before.map((fn) => `
|
|
437
|
+
checked := ${quoteTable(fn)}(event);
|
|
438
|
+
if checked is null or jsonb_typeof(checked) is distinct from 'object' then
|
|
439
|
+
return jsonb_build_object('error', jsonb_build_object('http_code', 500, 'message', ${quoteLiteral(`${fn} returned no event`)}));
|
|
440
|
+
end if;
|
|
441
|
+
if checked ? 'error' then
|
|
442
|
+
return checked;
|
|
443
|
+
end if;
|
|
444
|
+
event := checked;`).join("").concat(before.length === 0 ? "" : "\n claims := event -> 'claims';");
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* `hook.validate`: the claims the hook writes, dropped together when they do
|
|
448
|
+
* not match `supabase-claims-v1.json`, so a bad claim denies instead of
|
|
449
|
+
* failing sign-in. `hook.claims` entries belong to other packages and stay.
|
|
450
|
+
*/
|
|
451
|
+
function validateSql(parts) {
|
|
452
|
+
if (parts.hook.validate !== true) return "";
|
|
453
|
+
const keys = /* @__PURE__ */ new Set([
|
|
454
|
+
"user_role",
|
|
455
|
+
"roles",
|
|
456
|
+
"memberships",
|
|
457
|
+
"memberships_truncated",
|
|
458
|
+
"attrs",
|
|
459
|
+
"authz_ver",
|
|
460
|
+
parts.tenantClaim
|
|
461
|
+
]);
|
|
462
|
+
return `
|
|
463
|
+
if not extensions.jsonb_matches_schema(${quoteLiteral(JSON.stringify(PERMDOCK_CLAIMS_SCHEMA))}::json, claims) then
|
|
464
|
+
claims := claims${[...keys].map((key) => ` - ${quoteLiteral(key)}`).join("")};
|
|
465
|
+
end if;`;
|
|
466
|
+
}
|
|
467
|
+
function validateGrantsSql(parts) {
|
|
468
|
+
return parts.hook.validate === true ? `
|
|
469
|
+
create extension if not exists pg_jsonschema with schema extensions;
|
|
470
|
+
grant usage on schema extensions to supabase_auth_admin;
|
|
471
|
+
grant execute on function extensions.jsonb_matches_schema(json, jsonb) to supabase_auth_admin;` : "";
|
|
472
|
+
}
|
|
783
473
|
const VERSION_TRIGGER = "permdock_authz_version";
|
|
784
474
|
const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
|
|
785
475
|
function memberForPlan(input) {
|
|
@@ -806,23 +496,26 @@ function mappedMembership(mapped, scopes, name) {
|
|
|
806
496
|
const column = scopeColumn(mapped, scopes, ancestor);
|
|
807
497
|
if (column !== void 0) within[ancestor] = column;
|
|
808
498
|
}
|
|
499
|
+
const role = memberRoleOf(mapped);
|
|
809
500
|
const columns = [
|
|
810
501
|
mapped.user,
|
|
811
502
|
id,
|
|
812
|
-
|
|
503
|
+
...role.columns,
|
|
813
504
|
...Object.values(within),
|
|
814
|
-
...mapped.via ===
|
|
815
|
-
...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt]
|
|
505
|
+
...typeof mapped.via === "string" ? [mapped.via] : [],
|
|
506
|
+
...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
|
|
507
|
+
...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
|
|
816
508
|
];
|
|
817
509
|
return {
|
|
818
510
|
table: mapped.table.includes(".") ? mapped.table : `public.${mapped.table}`,
|
|
819
511
|
user: { column: mapped.user },
|
|
820
512
|
scope: { value: name },
|
|
821
513
|
id: { column: id },
|
|
822
|
-
role:
|
|
514
|
+
role: roleManifest(role),
|
|
823
515
|
...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
|
|
824
|
-
...mapped.via === void 0 ? {} : { via: { column: mapped.via } },
|
|
516
|
+
...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
|
|
825
517
|
...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
|
|
518
|
+
...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
|
|
826
519
|
columns: [...new Set(columns)]
|
|
827
520
|
};
|
|
828
521
|
}
|
|
@@ -893,7 +586,7 @@ function entriesSql(parts) {
|
|
|
893
586
|
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
894
587
|
'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
|
|
895
588
|
'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
|
|
589
|
+
'managedBy', s.managed_by, 'entitlements', s.seats${source.sql.keeps ? ", 'keep', s.keep" : ""}
|
|
897
590
|
)) as entry
|
|
898
591
|
from (
|
|
899
592
|
${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
|
|
@@ -957,8 +650,8 @@ declare
|
|
|
957
650
|
in_active boolean := false;
|
|
958
651
|
budget integer := ${String(parts.budget)};
|
|
959
652
|
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}
|
|
653
|
+
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;"}
|
|
654
|
+
begin${beforeSql(parts.before)}${suspended}
|
|
962
655
|
claims := claims - 'attrs'${dropExtra};
|
|
963
656
|
${roles}
|
|
964
657
|
claims := jsonb_set(claims, '{roles}', held);
|
|
@@ -990,85 +683,382 @@ ${entriesSql(parts).replaceAll(/^/gmu, " ")}
|
|
|
990
683
|
end if;
|
|
991
684
|
if in_active then
|
|
992
685
|
claims := jsonb_set(claims, ${quoteLiteral(`{${parts.tenantClaim}}`)}, to_jsonb(active));
|
|
993
|
-
end if;${extra}${version}
|
|
686
|
+
end if;${extra}${version}${validateSql(parts)}
|
|
994
687
|
return jsonb_set(event, '{claims}', claims);
|
|
995
688
|
end;
|
|
996
689
|
$$;`;
|
|
997
690
|
}
|
|
691
|
+
/** Everything the hook grants `supabase_auth_admin`, and the execute revoke. */
|
|
692
|
+
function grantsSql(parts) {
|
|
693
|
+
const schema = quoteIdent(parts.schema);
|
|
694
|
+
const fn = `${schema}.custom_access_token_hook`;
|
|
695
|
+
return [
|
|
696
|
+
`-- supabase_auth_admin: the grants and read policies the hook needs
|
|
697
|
+
grant usage on schema ${schema} to supabase_auth_admin;
|
|
698
|
+
grant execute on function ${fn}(jsonb) to supabase_auth_admin;
|
|
699
|
+
revoke execute on function ${fn}(jsonb) from authenticated, anon, public;${extraGrantsSql(parts.extra)}${beforeGrantsSql(parts.before, parts.extra)}${memberForGrantsSql(parts)}${validateGrantsSql(parts)}`,
|
|
700
|
+
readsSql(parts),
|
|
701
|
+
parts.version ? authAdminRead(`${parts.schema}.${AUTHZ_VERSION_TABLE}`, "version") : ""
|
|
702
|
+
].filter((chunk) => chunk !== "").join("\n");
|
|
703
|
+
}
|
|
704
|
+
function extraGrantsSql(extra) {
|
|
705
|
+
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("");
|
|
706
|
+
}
|
|
707
|
+
function beforeGrantsSql(before, extra) {
|
|
708
|
+
const granted = new Set(extra.map((entry) => entry.fn.split(".")[0]));
|
|
709
|
+
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("");
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Execute on each `member_<scope>_ids_for(uuid)` for the `hook.claims`
|
|
713
|
+
* functions, which run as `supabase_auth_admin` and may call them. Only with
|
|
714
|
+
* `hook.claims`: the grant needs the helpers migration applied first, and a
|
|
715
|
+
* hook without extra claims does not.
|
|
716
|
+
*/
|
|
717
|
+
function memberForGrantsSql(parts) {
|
|
718
|
+
const { schema, memberFor } = parts.helpers;
|
|
719
|
+
if (memberFor.length === 0 || parts.extra.length === 0) return "";
|
|
720
|
+
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("");
|
|
721
|
+
}
|
|
722
|
+
function readsSql(parts) {
|
|
723
|
+
const reads = /* @__PURE__ */ new Map();
|
|
724
|
+
for (const source of parts.sources) reads.set(source.sql.table, "memberships");
|
|
725
|
+
for (const source of parts.sources) {
|
|
726
|
+
for (const name of source.sql.reads) reads.set(name, reads.get(name) ?? "status");
|
|
727
|
+
for (const through of source.sql.throughs) reads.set(through.table, reads.get(through.table) ?? "role_keys");
|
|
728
|
+
const users = source.sql.userThrough;
|
|
729
|
+
if (users !== void 0) reads.set(users.table, reads.get(users.table) ?? "member_users");
|
|
730
|
+
}
|
|
731
|
+
if (parts.roles !== void 0) {
|
|
732
|
+
const { table: rolesTable, through } = parts.roles;
|
|
733
|
+
reads.set(rolesTable, reads.get(rolesTable) ?? "roles");
|
|
734
|
+
if (through !== void 0) reads.set(through.table, reads.get(through.table) ?? "role_keys");
|
|
735
|
+
}
|
|
736
|
+
if (parts.attrs?.table !== void 0 && parts.attrs.columns.length > 0) reads.set(parts.attrs.table, reads.get(parts.attrs.table) ?? "attrs");
|
|
737
|
+
if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
|
|
738
|
+
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");
|
|
739
|
+
}
|
|
740
|
+
function versionSql(parts) {
|
|
741
|
+
if (!parts.version) return "";
|
|
742
|
+
const schema = quoteIdent(parts.schema);
|
|
743
|
+
const versionTable = `${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)}`;
|
|
744
|
+
const bump = `${schema}.permdock_bump_authz_version`;
|
|
745
|
+
const bumpFor = `${schema}.${AUTHZ_VERSION_BUMP}`;
|
|
746
|
+
const tables = [...new Map(parts.sources.filter((source) => source.sql.userThrough === void 0).map((source) => [source.sql.table, source.sql.user]))];
|
|
747
|
+
if (parts.roles !== void 0) tables.push([parts.roles.table, parts.roles.user]);
|
|
748
|
+
return `-- the authorization version: bumped on every membership change, written to authz_ver
|
|
749
|
+
create table if not exists ${versionTable} (
|
|
750
|
+
user_id uuid primary key references auth.users on delete cascade,
|
|
751
|
+
version bigint not null default 0
|
|
752
|
+
);
|
|
753
|
+
alter table ${versionTable} enable row level security;
|
|
754
|
+
revoke all on table ${versionTable} from anon, authenticated, public;
|
|
755
|
+
|
|
756
|
+
-- bumps each listed user once, skipping users already deleted from auth.users; no client role may call it
|
|
757
|
+
create or replace function ${bumpFor}(p_users uuid[])
|
|
758
|
+
returns void
|
|
759
|
+
language sql
|
|
760
|
+
security definer
|
|
761
|
+
set search_path = ''
|
|
762
|
+
as $$
|
|
763
|
+
insert into ${versionTable} as v (user_id, version)
|
|
764
|
+
select distinct u, 1 from unnest(p_users) u
|
|
765
|
+
where u is not null and exists (select 1 from auth.users au where au.id = u)
|
|
766
|
+
on conflict (user_id) do update set version = v.version + 1
|
|
767
|
+
$$;
|
|
768
|
+
revoke execute on function ${bumpFor}(uuid[]) from public, anon, authenticated;
|
|
769
|
+
|
|
770
|
+
create or replace function ${bump}()
|
|
771
|
+
returns trigger
|
|
772
|
+
language plpgsql
|
|
773
|
+
security definer
|
|
774
|
+
set search_path = ''
|
|
775
|
+
as $$
|
|
776
|
+
declare
|
|
777
|
+
column_name text := tg_argv[0];
|
|
778
|
+
begin
|
|
779
|
+
perform ${bumpFor}(array[
|
|
780
|
+
case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
|
|
781
|
+
case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
|
|
782
|
+
]::uuid[]);
|
|
783
|
+
return null;
|
|
784
|
+
end;
|
|
785
|
+
$$;
|
|
786
|
+
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
787
|
+
${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${table(name)};
|
|
788
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
789
|
+
after insert or update or delete on ${table(name)}
|
|
790
|
+
for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${memberUsersVersionSql(parts, bumpFor)}${roleKeysVersionSql(parts, versionTable)}`;
|
|
791
|
+
}
|
|
792
|
+
const SUBJECT_FOR = "subject_for";
|
|
793
|
+
/**
|
|
794
|
+
* `subject_for(p_user uuid) returns jsonb`: what the hook would put in a
|
|
795
|
+
* token for `p_user`, from the same membership, role, suspension and version
|
|
796
|
+
* reads, plus the custom roles the user holds when `database` mode keeps them
|
|
797
|
+
* in tables. A backend that only has PostgREST (`postgrestSources`) builds a
|
|
798
|
+
* subject for an acting user or a job from it. Naming the user is why no
|
|
799
|
+
* client role may execute it.
|
|
800
|
+
*/
|
|
801
|
+
function subjectForSql(parts, config) {
|
|
802
|
+
const schema = quoteIdent(parts.schema);
|
|
803
|
+
const fn = `${schema}.${SUBJECT_FOR}`;
|
|
804
|
+
const rows = parts.roles;
|
|
805
|
+
const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
|
|
806
|
+
into held
|
|
807
|
+
from ${rows.from}
|
|
808
|
+
where ${rows.userSql} = v_roles_user;`;
|
|
809
|
+
const suspended = parts.users === void 0 ? "" : `
|
|
810
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
811
|
+
return jsonb_build_object('id', uid::text, 'active', false);
|
|
812
|
+
end if;`;
|
|
813
|
+
const version = parts.version ? `
|
|
814
|
+
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
815
|
+
ver := coalesce(ver, 0);` : "";
|
|
816
|
+
const custom = config.rls?.customRoles === true && resolveAuthorize(config) === "database" ? customRolesOfSql(parts) : "";
|
|
817
|
+
return `-- the subject the hook would mint for p_user, for a backend acting for a stored user; no client role may execute it
|
|
818
|
+
create or replace function ${fn}(p_user uuid)
|
|
819
|
+
returns jsonb
|
|
820
|
+
language plpgsql
|
|
821
|
+
stable
|
|
822
|
+
security definer
|
|
823
|
+
set search_path = ''
|
|
824
|
+
as $$
|
|
825
|
+
declare
|
|
826
|
+
uid uuid := p_user;
|
|
827
|
+
held jsonb;
|
|
828
|
+
memberships jsonb;
|
|
829
|
+
custom jsonb := '[]'::jsonb;
|
|
830
|
+
ver bigint;${typedUsers(parts)}
|
|
831
|
+
begin
|
|
832
|
+
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
833
|
+
return null;
|
|
834
|
+
end if;${suspended}
|
|
835
|
+
${roles}
|
|
836
|
+
select coalesce(jsonb_agg(x.entry order by x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text), '[]'::jsonb)
|
|
837
|
+
into memberships
|
|
838
|
+
from (
|
|
839
|
+
${entriesSql(parts).replaceAll(/^/gmu, " ")}
|
|
840
|
+
) x;${custom}${version}
|
|
841
|
+
return jsonb_strip_nulls(jsonb_build_object(
|
|
842
|
+
'id', uid::text,
|
|
843
|
+
'active', true,
|
|
844
|
+
'roles', held,
|
|
845
|
+
'memberships', memberships,
|
|
846
|
+
'customRoles', custom,
|
|
847
|
+
'authzVersion', ver
|
|
848
|
+
));
|
|
849
|
+
end;
|
|
850
|
+
$$;
|
|
851
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
852
|
+
}
|
|
853
|
+
const AUTHZ_VERSION_FOR = "authz_version_for";
|
|
854
|
+
/**
|
|
855
|
+
* `authz_version_for(p_user uuid) returns bigint`: the authorization version
|
|
856
|
+
* `subject_for` reports, without reading roles or memberships, so a
|
|
857
|
+
* `claimsFirst` reread can check a token's freshness on every request and read
|
|
858
|
+
* the full subject only when it is behind. `null` for an unknown or suspended
|
|
859
|
+
* user, and when the version is off.
|
|
860
|
+
*/
|
|
861
|
+
function authzVersionForSql(parts) {
|
|
862
|
+
const schema = quoteIdent(parts.schema);
|
|
863
|
+
const fn = `${schema}.${AUTHZ_VERSION_FOR}`;
|
|
864
|
+
return `-- the authorization version subject_for reports for p_user, for a cheap freshness check; no client role may execute it
|
|
865
|
+
create or replace function ${fn}(p_user uuid)
|
|
866
|
+
returns bigint
|
|
867
|
+
language plpgsql
|
|
868
|
+
stable
|
|
869
|
+
security definer
|
|
870
|
+
set search_path = ''
|
|
871
|
+
as $$
|
|
872
|
+
declare
|
|
873
|
+
uid uuid := p_user;
|
|
874
|
+
ver bigint;
|
|
875
|
+
begin
|
|
876
|
+
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
877
|
+
return null;
|
|
878
|
+
end if;${parts.users === void 0 ? "" : `
|
|
879
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
880
|
+
return null;
|
|
881
|
+
end if;`}${parts.version ? `
|
|
882
|
+
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
883
|
+
ver := coalesce(ver, 0);` : ""}
|
|
884
|
+
return ver;
|
|
885
|
+
end;
|
|
886
|
+
$$;
|
|
887
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
888
|
+
}
|
|
889
|
+
const MEMBERS_OF = "members_of";
|
|
890
|
+
function membersOfSql(parts) {
|
|
891
|
+
const fn = `${quoteIdent(parts.schema)}.${MEMBERS_OF}`;
|
|
892
|
+
const keep = parts.sources.some((source) => source.sql.keeps);
|
|
893
|
+
const rows = parts.sources.map((source) => source.sql.list(keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
894
|
+
return `-- the live memberships of one scope instance, for a backend that lists members over PostgREST; no client role may execute it
|
|
895
|
+
create or replace function ${fn}(p_scope text, p_id text)
|
|
896
|
+
returns jsonb
|
|
897
|
+
language sql
|
|
898
|
+
stable
|
|
899
|
+
security definer
|
|
900
|
+
set search_path = ''
|
|
901
|
+
as $$
|
|
902
|
+
select coalesce(jsonb_agg(jsonb_build_object(
|
|
903
|
+
'principal', jsonb_build_object('id', s.user_id),
|
|
904
|
+
'membership', jsonb_strip_nulls(jsonb_build_object(
|
|
905
|
+
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
906
|
+
'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
|
|
907
|
+
'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
|
|
908
|
+
'managedBy', s.managed_by, 'entitlements', s.seats${keep ? ", 'keep', s.keep" : ""}
|
|
909
|
+
))
|
|
910
|
+
) order by s.user_id, s.roles::text), '[]'::jsonb)
|
|
911
|
+
from (
|
|
912
|
+
${rows}
|
|
913
|
+
) s
|
|
914
|
+
$$;
|
|
915
|
+
revoke execute on function ${fn}(text, text) from public, anon, authenticated;`;
|
|
916
|
+
}
|
|
998
917
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
918
|
+
* The custom roles `p_user` holds: tenant roles named on a membership of the
|
|
919
|
+
* scope (and instance) they live at, and platform roles among the global
|
|
920
|
+
* roles, each with its grants and includes in `CustomRole` form.
|
|
1002
921
|
*/
|
|
1003
|
-
function
|
|
1004
|
-
const
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
922
|
+
function customRolesOfSql(parts) {
|
|
923
|
+
const helpers = quoteIdent(parts.helpers.schema);
|
|
924
|
+
const perms = `${helpers}.custom_role_permissions`;
|
|
925
|
+
const includes = `${helpers}.custom_role_includes`;
|
|
926
|
+
const root = quoteLiteral(parts.root);
|
|
927
|
+
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`;
|
|
928
|
+
return `
|
|
929
|
+
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
930
|
+
'name', c.role,
|
|
931
|
+
'tenant', c.tenant_id::text,
|
|
932
|
+
'scope', c.scope,
|
|
933
|
+
'id', c.scope_id,
|
|
934
|
+
'grants', (
|
|
935
|
+
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
936
|
+
'permission', p.permission,
|
|
937
|
+
'effect', p.effect,
|
|
938
|
+
'level', to_jsonb(p) ->> 'level'
|
|
939
|
+
)) order by p.permission, p.effect), '[]'::jsonb)
|
|
940
|
+
from ${perms} p
|
|
941
|
+
where ${match}
|
|
942
|
+
),
|
|
943
|
+
'includes', (
|
|
944
|
+
select jsonb_agg(p.include_role order by p.include_role)
|
|
945
|
+
from ${includes} p
|
|
946
|
+
where ${match}
|
|
947
|
+
)
|
|
948
|
+
)) order by c.tenant_id::text, c.scope, c.scope_id, c.role), '[]'::jsonb)
|
|
949
|
+
into custom
|
|
950
|
+
from (
|
|
951
|
+
select tenant_id, scope, scope_id, role from ${perms}
|
|
952
|
+
union
|
|
953
|
+
select tenant_id, scope, scope_id, role from ${includes}
|
|
954
|
+
) c
|
|
955
|
+
where (c.scope = 'global' and c.tenant_id is null and held ? c.role)
|
|
956
|
+
or exists (
|
|
957
|
+
select 1
|
|
958
|
+
from jsonb_array_elements(memberships) m
|
|
959
|
+
where m -> 'roles' ? c.role
|
|
960
|
+
and m ->> 'scope' = c.scope
|
|
961
|
+
and c.tenant_id::text = case when m ->> 'scope' = ${root} then m ->> 'id' else m -> 'within' ->> ${root} end
|
|
962
|
+
and (c.scope_id is null or c.scope_id = m ->> 'id')
|
|
963
|
+
);`;
|
|
964
|
+
}
|
|
965
|
+
function signedUp(user) {
|
|
966
|
+
return `exists (select 1 from auth.users a where a.id = ${user})`;
|
|
1017
967
|
}
|
|
1018
968
|
/**
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
969
|
+
* Every membership source whose user id lives in another table: a row of
|
|
970
|
+
* the source bumps the users its old and new references hold, and a change
|
|
971
|
+
* of that table's user id (a contact re-linked to another login) bumps the
|
|
972
|
+
* old and the new user.
|
|
1023
973
|
*/
|
|
1024
|
-
function
|
|
1025
|
-
const
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
974
|
+
function memberUsersVersionSql(parts, bumpFor) {
|
|
975
|
+
const holders = /* @__PURE__ */ new Map();
|
|
976
|
+
for (const source of parts.sources) {
|
|
977
|
+
const users = source.sql.userThrough;
|
|
978
|
+
if (users !== void 0) holders.set(JSON.stringify([
|
|
979
|
+
tableKey(source.sql.table),
|
|
980
|
+
tableKey(users.table),
|
|
981
|
+
users.id,
|
|
982
|
+
users.key,
|
|
983
|
+
users.ref
|
|
984
|
+
]), {
|
|
985
|
+
table: source.sql.table,
|
|
986
|
+
users
|
|
987
|
+
});
|
|
1037
988
|
}
|
|
1038
|
-
if (
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
)
|
|
1055
|
-
|
|
1056
|
-
|
|
989
|
+
if (holders.size === 0) return "";
|
|
990
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
991
|
+
for (const holder of holders.values()) {
|
|
992
|
+
const key = tableKey(holder.table);
|
|
993
|
+
const group = byTable.get(key);
|
|
994
|
+
if (group === void 0) byTable.set(key, {
|
|
995
|
+
table: holder.table,
|
|
996
|
+
users: [holder.users]
|
|
997
|
+
});
|
|
998
|
+
else group.users.push(holder.users);
|
|
999
|
+
}
|
|
1000
|
+
const memberBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_member_users`;
|
|
1001
|
+
const linkedBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_linked_users`;
|
|
1002
|
+
const groups = [...byTable];
|
|
1003
|
+
const lookup = (users, indent) => `${indent}perform ${bumpFor}(array(
|
|
1004
|
+
${indent} select u.${quoteIdent(users.key)}::uuid from ${table(users.table)} u
|
|
1005
|
+
${indent} where ((tg_op <> 'INSERT' and u.${quoteIdent(users.id)} = old.${quoteIdent(users.ref)})
|
|
1006
|
+
${indent} or (tg_op <> 'DELETE' and u.${quoteIdent(users.id)} = new.${quoteIdent(users.ref)}))
|
|
1007
|
+
${indent} and ${signedUp(`u.${quoteIdent(users.key)}::uuid`)}
|
|
1008
|
+
${indent}));`;
|
|
1009
|
+
const body = groups.map(([key, group]) => {
|
|
1010
|
+
if (groups.length === 1) return group.users.map((users) => lookup(users, " ")).join("\n");
|
|
1011
|
+
const dot = key.indexOf(".");
|
|
1012
|
+
return ` if tg_table_schema = ${quoteLiteral(key.slice(0, dot))} and tg_table_name = ${quoteLiteral(key.slice(dot + 1))} then
|
|
1013
|
+
${group.users.map((users) => lookup(users, " ")).join("\n")}
|
|
1014
|
+
end if;`;
|
|
1015
|
+
}).join("\n");
|
|
1016
|
+
const memberTriggers = groups.map(([, group]) => {
|
|
1017
|
+
const target = table(group.table);
|
|
1018
|
+
return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
|
|
1019
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1020
|
+
after insert or update or delete on ${target}
|
|
1021
|
+
for each row execute function ${memberBump}();`;
|
|
1022
|
+
}).join("\n");
|
|
1023
|
+
const userTables = /* @__PURE__ */ new Map();
|
|
1024
|
+
for (const { users } of holders.values()) {
|
|
1025
|
+
const key = JSON.stringify([tableKey(users.table), users.key]);
|
|
1026
|
+
const entry = userTables.get(key) ?? {
|
|
1027
|
+
table: users.table,
|
|
1028
|
+
key: users.key,
|
|
1029
|
+
ids: /* @__PURE__ */ new Set()
|
|
1030
|
+
};
|
|
1031
|
+
entry.ids.add(users.id);
|
|
1032
|
+
userTables.set(key, entry);
|
|
1033
|
+
}
|
|
1034
|
+
const userTriggers = [...userTables.values()].map((entry) => {
|
|
1035
|
+
const target = table(entry.table);
|
|
1036
|
+
const name = quoteIdent(`${VERSION_TRIGGER}_${entry.key}`);
|
|
1037
|
+
return `drop trigger if exists ${name} on ${target};
|
|
1038
|
+
create trigger ${name}
|
|
1039
|
+
after update of ${[entry.key, ...entry.ids].map(quoteIdent).join(", ")} or delete on ${target}
|
|
1040
|
+
for each row execute function ${linkedBump}(${quoteLiteral(entry.key)});`;
|
|
1041
|
+
}).join("\n");
|
|
1042
|
+
return `
|
|
1057
1043
|
|
|
1058
|
-
--
|
|
1059
|
-
create or replace function ${
|
|
1060
|
-
returns
|
|
1061
|
-
language
|
|
1044
|
+
-- a membership whose user id lives in another table bumps the users its old and new rows reference
|
|
1045
|
+
create or replace function ${memberBump}()
|
|
1046
|
+
returns trigger
|
|
1047
|
+
language plpgsql
|
|
1062
1048
|
security definer
|
|
1063
1049
|
set search_path = ''
|
|
1064
1050
|
as $$
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1051
|
+
begin
|
|
1052
|
+
${body}
|
|
1053
|
+
return null;
|
|
1054
|
+
end;
|
|
1068
1055
|
$$;
|
|
1069
|
-
revoke execute on function ${
|
|
1056
|
+
revoke execute on function ${memberBump}() from public, anon, authenticated;
|
|
1057
|
+
${memberTriggers}
|
|
1070
1058
|
|
|
1071
|
-
|
|
1059
|
+
-- re-linking that table's user id moves every membership that references the row: bump the old and the new user,
|
|
1060
|
+
-- skipping a login being deleted (its version row goes with it)
|
|
1061
|
+
create or replace function ${linkedBump}()
|
|
1072
1062
|
returns trigger
|
|
1073
1063
|
language plpgsql
|
|
1074
1064
|
security definer
|
|
@@ -1077,29 +1067,92 @@ as $$
|
|
|
1077
1067
|
declare
|
|
1078
1068
|
column_name text := tg_argv[0];
|
|
1079
1069
|
begin
|
|
1080
|
-
perform ${bumpFor}(array
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1070
|
+
perform ${bumpFor}(array(
|
|
1071
|
+
select u
|
|
1072
|
+
from unnest(array[
|
|
1073
|
+
case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
|
|
1074
|
+
case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
|
|
1075
|
+
]::uuid[]) u
|
|
1076
|
+
where ${signedUp("u")}
|
|
1077
|
+
));
|
|
1084
1078
|
return null;
|
|
1085
1079
|
end;
|
|
1086
1080
|
$$;
|
|
1087
|
-
revoke execute on function ${
|
|
1088
|
-
${
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1081
|
+
revoke execute on function ${linkedBump}() from public, anon, authenticated;
|
|
1082
|
+
${userTriggers}`;
|
|
1083
|
+
}
|
|
1084
|
+
/** Every table whose role column references a roles table: the global roles and the membership sources. */
|
|
1085
|
+
function roleKeyHolders(parts) {
|
|
1086
|
+
const holders = [];
|
|
1087
|
+
if (parts.roles?.through !== void 0) holders.push({
|
|
1088
|
+
table: parts.roles.table,
|
|
1089
|
+
user: parts.roles.user,
|
|
1090
|
+
through: parts.roles.through
|
|
1091
|
+
});
|
|
1092
|
+
for (const source of parts.sources) for (const through of source.sql.throughs) holders.push({
|
|
1093
|
+
table: source.sql.table,
|
|
1094
|
+
user: source.sql.user,
|
|
1095
|
+
...source.sql.userThrough === void 0 ? {} : { userThrough: source.sql.userThrough },
|
|
1096
|
+
through
|
|
1097
|
+
});
|
|
1098
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1099
|
+
return holders.filter((holder) => {
|
|
1100
|
+
const key = JSON.stringify([
|
|
1101
|
+
tableKey(holder.table),
|
|
1102
|
+
holder.user,
|
|
1103
|
+
holder.userThrough === void 0 ? null : [
|
|
1104
|
+
tableKey(holder.userThrough.table),
|
|
1105
|
+
holder.userThrough.id,
|
|
1106
|
+
holder.userThrough.key
|
|
1107
|
+
],
|
|
1108
|
+
tableKey(holder.through.table),
|
|
1109
|
+
holder.through.id,
|
|
1110
|
+
holder.through.key,
|
|
1111
|
+
holder.through.ref
|
|
1112
|
+
]);
|
|
1113
|
+
if (seen.has(key)) return false;
|
|
1114
|
+
seen.add(key);
|
|
1115
|
+
return true;
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
/** The bump for one roles table: unchanged keys and ids return early, else every holder of `old` is bumped. */
|
|
1119
|
+
function roleKeysBumpSql(holders, versionTable, indent) {
|
|
1120
|
+
return `${indent}if tg_op = 'UPDATE'
|
|
1121
|
+
${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
|
|
1122
|
+
${indent} return null;
|
|
1123
|
+
${indent}end if;
|
|
1124
|
+
${indent}insert into ${versionTable} as v (user_id, version)
|
|
1125
|
+
${indent}select distinct h.user_id, 1
|
|
1126
|
+
${indent}from (
|
|
1127
|
+
${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} `)}
|
|
1128
|
+
${indent}) h
|
|
1129
|
+
${indent}where h.user_id is not null
|
|
1130
|
+
${indent} and exists (select 1 from auth.users au where au.id = h.user_id)
|
|
1131
|
+
${indent}on conflict (user_id) do update set version = v.version + 1;`;
|
|
1092
1132
|
}
|
|
1093
|
-
/**
|
|
1133
|
+
/**
|
|
1134
|
+
* A renamed role key changes the `roles` claim of everyone who holds it and
|
|
1135
|
+
* the `roles` of every membership that references it, so updates to a roles
|
|
1136
|
+
* table bump each holder.
|
|
1137
|
+
*/
|
|
1094
1138
|
function roleKeysVersionSql(parts, versionTable) {
|
|
1095
|
-
const
|
|
1096
|
-
if (
|
|
1139
|
+
const holders = roleKeyHolders(parts);
|
|
1140
|
+
if (holders.length === 0) return "";
|
|
1141
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
1142
|
+
for (const holder of holders) {
|
|
1143
|
+
const key = tableKey(holder.through.table);
|
|
1144
|
+
const group = byTable.get(key);
|
|
1145
|
+
if (group === void 0) byTable.set(key, {
|
|
1146
|
+
table: holder.through.table,
|
|
1147
|
+
holders: [holder]
|
|
1148
|
+
});
|
|
1149
|
+
else group.holders.push(holder);
|
|
1150
|
+
}
|
|
1097
1151
|
const bump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_role_keys`;
|
|
1098
|
-
const
|
|
1099
|
-
const key = quoteIdent(through.key);
|
|
1152
|
+
const groups = [...byTable];
|
|
1100
1153
|
return `
|
|
1101
1154
|
|
|
1102
|
-
-- a renamed role key changes the roles
|
|
1155
|
+
-- a renamed role key changes the roles of everyone who holds it, globally or through a membership
|
|
1103
1156
|
create or replace function ${bump}()
|
|
1104
1157
|
returns trigger
|
|
1105
1158
|
language plpgsql
|
|
@@ -1107,24 +1160,24 @@ security definer
|
|
|
1107
1160
|
set search_path = ''
|
|
1108
1161
|
as $$
|
|
1109
1162
|
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;
|
|
1163
|
+
${groups.map(([key, group]) => {
|
|
1164
|
+
if (groups.length === 1) return roleKeysBumpSql(group.holders, versionTable, " ");
|
|
1165
|
+
const dot = key.indexOf(".");
|
|
1166
|
+
return ` if tg_table_schema = ${quoteLiteral(key.slice(0, dot))} and tg_table_name = ${quoteLiteral(key.slice(dot + 1))} then
|
|
1167
|
+
${roleKeysBumpSql(group.holders, versionTable, " ")}
|
|
1168
|
+
end if;`;
|
|
1169
|
+
}).join("\n")}
|
|
1120
1170
|
return null;
|
|
1121
1171
|
end;
|
|
1122
1172
|
$$;
|
|
1123
1173
|
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
1124
|
-
|
|
1174
|
+
${groups.map(([, group]) => {
|
|
1175
|
+
const target = table(group.table);
|
|
1176
|
+
return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
|
|
1125
1177
|
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1126
|
-
after update or delete on ${
|
|
1178
|
+
after update or delete on ${target}
|
|
1127
1179
|
for each row execute function ${bump}();`;
|
|
1180
|
+
}).join("\n")}`;
|
|
1128
1181
|
}
|
|
1129
1182
|
/**
|
|
1130
1183
|
* Fails the migration when a client role can write an `attrs` column: a
|
|
@@ -1216,6 +1269,7 @@ function hookParts(scopes, config, overrides) {
|
|
|
1216
1269
|
active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
|
|
1217
1270
|
attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
|
|
1218
1271
|
extra: extraPlan.claims,
|
|
1272
|
+
before: beforePlan(hook.before),
|
|
1219
1273
|
helpers: {
|
|
1220
1274
|
schema: config.rls?.schema ?? "permdock",
|
|
1221
1275
|
...memberForPlan({
|
|
@@ -1253,38 +1307,121 @@ function hookClaims(parts) {
|
|
|
1253
1307
|
}))
|
|
1254
1308
|
];
|
|
1255
1309
|
}
|
|
1310
|
+
//#endregion
|
|
1311
|
+
//#region src/cli/supabase-hook-manifest.ts
|
|
1256
1312
|
function helperList(parts, types) {
|
|
1257
1313
|
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) => {
|
|
1314
|
+
return [helperEntry(HELPERS.has, "p_grant text", "boolean", client), ...parts.scopes.flatMap((scope) => {
|
|
1264
1315
|
const returns = `setof ${types.get(scope.name) ?? "uuid"}`;
|
|
1265
1316
|
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
|
-
}] : []
|
|
1317
|
+
helperEntry(permittedIdsHelper(scope.name), "p_grant text", returns, client),
|
|
1318
|
+
helperEntry(memberIdsHelper(scope.name), "", returns, client),
|
|
1319
|
+
...parts.helpers.memberFor.includes(scope.name) ? [helperEntry(memberForHelper(scope.name), "p_user uuid", returns, parts.extra.length === 0 ? [] : ["supabase_auth_admin"])] : []
|
|
1284
1320
|
];
|
|
1285
1321
|
})];
|
|
1286
1322
|
}
|
|
1287
|
-
function
|
|
1323
|
+
function helperEntry(name, args, returns, execute) {
|
|
1324
|
+
return {
|
|
1325
|
+
name,
|
|
1326
|
+
args,
|
|
1327
|
+
returns,
|
|
1328
|
+
execute
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* The helpers `rls generate` writes beyond the ones the hook's claims feed:
|
|
1333
|
+
* the `_for` forms in `database` mode, and the assignment checks when a role
|
|
1334
|
+
* declares `assigns` (known only with the policy).
|
|
1335
|
+
*/
|
|
1336
|
+
function trustedHelpers(parts, config, types, tenantType, policy) {
|
|
1337
|
+
const database = resolveAuthorize(config) === "database";
|
|
1338
|
+
const custom = database && config.rls?.customRoles === true;
|
|
1339
|
+
const everyFor = parts.scopes.every((scope) => parts.helpers.memberFor.includes(scope.name));
|
|
1340
|
+
const client = ["authenticated"];
|
|
1341
|
+
const scopeTypes = parts.scopes.map((scope) => [scope.name, types.get(scope.name) ?? "uuid"]);
|
|
1342
|
+
const forUser = database ? [
|
|
1343
|
+
helperEntry(HELPERS.hasFor, "p_user uuid, p_grant text", "boolean", []),
|
|
1344
|
+
...scopeTypes.map(([scope, type]) => helperEntry(permittedForHelper(scope), "p_user uuid, p_grant text", `setof ${type}`, [])),
|
|
1345
|
+
...scopeTypes.map(([scope, type]) => helperEntry(permissionKeysForHelper(scope), `p_user uuid, p_id ${type}`, "setof text", []))
|
|
1346
|
+
] : [];
|
|
1347
|
+
const keyed = [
|
|
1348
|
+
helperEntry(USER_ID_HELPER, "", "uuid", client),
|
|
1349
|
+
...config.rls?.apiKeys === void 0 ? [] : [helperEntry(API_KEY_ALLOWS, "p_grant text", "boolean", client)],
|
|
1350
|
+
...forUser
|
|
1351
|
+
];
|
|
1352
|
+
if (!(policy !== void 0 && (ownershipRules(policy, parts.scopes)?.assigns.length ?? 0) > 0)) return keyed;
|
|
1353
|
+
const customArgs = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
|
|
1354
|
+
const anyArgs = `p_role text, p_tenant ${tenantType}, p_scope text, p_scope_id text`;
|
|
1355
|
+
return [
|
|
1356
|
+
...keyed,
|
|
1357
|
+
helperEntry(OWNERSHIP.canAssign, "p_role text, p_scope_id text", "boolean", client),
|
|
1358
|
+
...database ? [helperEntry(OWNERSHIP.canAssignFor, "p_user uuid, p_role text, p_scope_id text", "boolean", [])] : [],
|
|
1359
|
+
...custom ? [helperEntry(ASSIGNMENTS.custom, customArgs, "boolean", client)] : [],
|
|
1360
|
+
...custom && everyFor ? [helperEntry(ASSIGNMENTS.customFor, `p_user uuid, ${customArgs}`, "boolean", [])] : [],
|
|
1361
|
+
helperEntry(OWNERSHIP.canAssignAny, anyArgs, "boolean", client),
|
|
1362
|
+
...database && (!custom || everyFor) ? [helperEntry(OWNERSHIP.canAssignAnyFor, `p_user uuid, ${anyArgs}`, "boolean", [])] : []
|
|
1363
|
+
];
|
|
1364
|
+
}
|
|
1365
|
+
function activeRowManifest(row) {
|
|
1366
|
+
const keep = "keep" in row ? keptKeys(row) : [];
|
|
1367
|
+
return {
|
|
1368
|
+
table: qualifiedTable(row.table),
|
|
1369
|
+
id: row.id,
|
|
1370
|
+
...row.disabledAt === void 0 ? {} : { disabledAt: row.disabledAt },
|
|
1371
|
+
...row.status === void 0 ? {} : { status: row.status },
|
|
1372
|
+
...row.active === void 0 ? {} : { active: row.active },
|
|
1373
|
+
...keep.length === 0 ? {} : { keep }
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
/** The `rls` settings a package writing SQL next to the helpers reads: custom roles, global roles, suspension and assignment triggers. */
|
|
1377
|
+
function rlsSettings(parts, config) {
|
|
1378
|
+
const rls = config.rls;
|
|
1379
|
+
const suspension = checkSuspension(rls?.suspension, parts.scopes);
|
|
1380
|
+
const scopes = Object.entries(suspension?.scopes ?? {});
|
|
1381
|
+
const assigned = rls?.assignments === void 0 ? void 0 : guardedTables(config, parts.scopes);
|
|
1382
|
+
return {
|
|
1383
|
+
customRoles: resolveAuthorize(config) === "database" && rls?.customRoles === true,
|
|
1384
|
+
...parts.roles === void 0 ? {} : { roles: {
|
|
1385
|
+
table: qualifiedTable(parts.roles.table),
|
|
1386
|
+
user: { column: parts.roles.user },
|
|
1387
|
+
role: parts.roles.role
|
|
1388
|
+
} },
|
|
1389
|
+
...suspension === void 0 ? {} : { suspension: {
|
|
1390
|
+
...suspension.users === void 0 ? {} : { users: activeRowManifest(suspension.users) },
|
|
1391
|
+
...scopes.length === 0 ? {} : { scopes: Object.fromEntries(scopes.map(([name, row]) => [name, activeRowManifest(row)])) },
|
|
1392
|
+
...suspension.memberships === void 0 ? {} : { memberships: { keep: keptKeys(suspension.memberships) } }
|
|
1393
|
+
} },
|
|
1394
|
+
...assigned === void 0 ? {} : { assignments: { tables: assigned } },
|
|
1395
|
+
...rls?.apiKeys === void 0 ? {} : { apiKeys: apiKeyFields(rls.apiKeys) }
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
/** The `supabase/sdk` release `requires.capabilities` names features of. */
|
|
1399
|
+
const CAPABILITY_MATRIX = "capability-matrix-v1.12.0";
|
|
1400
|
+
function usesLiveSession(policy) {
|
|
1401
|
+
return [...policy?.grants ?? [], ...policy?.roles.flatMap((binding) => binding.grants) ?? []].some((grant) => hasConditionOp(grant.where, "liveSession") || hasConditionOp(grant.check, "liveSession"));
|
|
1402
|
+
}
|
|
1403
|
+
function requiredCapabilities(config, policy) {
|
|
1404
|
+
const ids = /* @__PURE__ */ new Set(["auth.session.get_claims"]);
|
|
1405
|
+
if (usesLiveSession(policy)) ids.add("auth.session.get_user");
|
|
1406
|
+
if (config.rls?.realtime !== void 0) ids.add("realtime.subscriptions.private_channel");
|
|
1407
|
+
for (const bucket of Object.values(config.rls?.storage?.buckets ?? {})) {
|
|
1408
|
+
if (bucket.read !== void 0) {
|
|
1409
|
+
ids.add("storage.file_buckets.download");
|
|
1410
|
+
ids.add("storage.file_buckets.list_files");
|
|
1411
|
+
}
|
|
1412
|
+
if (bucket.write !== void 0) {
|
|
1413
|
+
ids.add("storage.file_buckets.upload");
|
|
1414
|
+
ids.add("storage.file_buckets.update_file");
|
|
1415
|
+
ids.add("storage.file_buckets.move");
|
|
1416
|
+
}
|
|
1417
|
+
if (bucket.delete !== void 0) ids.add("storage.file_buckets.remove");
|
|
1418
|
+
}
|
|
1419
|
+
return {
|
|
1420
|
+
matrix: CAPABILITY_MATRIX,
|
|
1421
|
+
capabilities: [...ids].toSorted()
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
function manifestOf(parts, out, config, policy) {
|
|
1288
1425
|
const rls = config.rls;
|
|
1289
1426
|
const ctx = {
|
|
1290
1427
|
dialect: "supabase",
|
|
@@ -1303,11 +1440,12 @@ function manifestOf(parts, out, config) {
|
|
|
1303
1440
|
hook: {
|
|
1304
1441
|
schema: parts.schema,
|
|
1305
1442
|
function: "custom_access_token_hook",
|
|
1306
|
-
out
|
|
1443
|
+
out,
|
|
1444
|
+
...parts.before.length === 0 ? {} : { before: parts.before }
|
|
1307
1445
|
},
|
|
1308
1446
|
helpers: {
|
|
1309
1447
|
schema: parts.helpers.schema,
|
|
1310
|
-
functions: helpers.map((
|
|
1448
|
+
functions: helpers.map((entry) => entry.name)
|
|
1311
1449
|
},
|
|
1312
1450
|
tenantClaim: parts.tenantClaim,
|
|
1313
1451
|
budget: {
|
|
@@ -1331,8 +1469,9 @@ function manifestOf(parts, out, config) {
|
|
|
1331
1469
|
type: types.get(scope.name) ?? "uuid",
|
|
1332
1470
|
...scope.within === void 0 ? {} : { within: scope.within }
|
|
1333
1471
|
})),
|
|
1334
|
-
helpers,
|
|
1335
|
-
memberships: parts.helpers.memberships
|
|
1472
|
+
helpers: [...helpers, ...trustedHelpers(parts, config, types, tenantTypeOf(ctx), policy)],
|
|
1473
|
+
memberships: parts.helpers.memberships,
|
|
1474
|
+
...rlsSettings(parts, config)
|
|
1336
1475
|
},
|
|
1337
1476
|
decidingColumns: decidingColumns(config, parts.attrs === void 0 ? void 0 : {
|
|
1338
1477
|
...parts.attrs.table === void 0 ? {} : { table: parts.attrs.table },
|
|
@@ -1341,7 +1480,8 @@ function manifestOf(parts, out, config) {
|
|
|
1341
1480
|
markers: {
|
|
1342
1481
|
hook: "v1",
|
|
1343
1482
|
grants: "v1"
|
|
1344
|
-
}
|
|
1483
|
+
},
|
|
1484
|
+
requires: requiredCapabilities(config, policy)
|
|
1345
1485
|
};
|
|
1346
1486
|
}
|
|
1347
1487
|
/** The first line of the generated hook: fields `--check` compares before the full text. */
|
|
@@ -1351,17 +1491,155 @@ function hookMarker(manifest) {
|
|
|
1351
1491
|
function defaultOut(config) {
|
|
1352
1492
|
return config.supabase?.hook?.out ?? "supabase/permdock-hook.sql";
|
|
1353
1493
|
}
|
|
1494
|
+
//#endregion
|
|
1495
|
+
//#region src/cli/supabase-setup.ts
|
|
1496
|
+
const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
|
|
1497
|
+
function escape(text) {
|
|
1498
|
+
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
1499
|
+
}
|
|
1500
|
+
/** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
|
|
1501
|
+
function helpersDefined(sql, schema) {
|
|
1502
|
+
const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
|
|
1503
|
+
const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
|
|
1504
|
+
const names = /* @__PURE__ */ new Set();
|
|
1505
|
+
for (const match of sql.matchAll(create)) {
|
|
1506
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
1507
|
+
if (HELPER_NAME.test(name)) names.add(name);
|
|
1508
|
+
}
|
|
1509
|
+
return names;
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`;
|
|
1513
|
+
* its `helpers` part when it has `{part}`), the SQL files next to the hook,
|
|
1514
|
+
* and the migration folders, not the hook itself.
|
|
1515
|
+
*/
|
|
1516
|
+
function helperSources(cwd, config, hookOut) {
|
|
1517
|
+
const hook = resolve(cwd, hookOut);
|
|
1518
|
+
const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
|
|
1519
|
+
const rlsOut = resolve(cwd, partPath(config.rls?.out ?? "rls.sql", "helpers"));
|
|
1520
|
+
if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
|
|
1521
|
+
const beside = dirname(hook);
|
|
1522
|
+
if (existsSync(beside)) {
|
|
1523
|
+
for (const entry of readdirSync(beside, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".sql")) files.add(join(beside, entry.name));
|
|
1524
|
+
}
|
|
1525
|
+
files.delete(hook);
|
|
1526
|
+
return [...files];
|
|
1527
|
+
}
|
|
1528
|
+
function missingHelpersInFiles(cwd, config, manifest) {
|
|
1529
|
+
const found = /* @__PURE__ */ new Set();
|
|
1530
|
+
for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
|
|
1531
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
1532
|
+
}
|
|
1533
|
+
async function missingHelpersInDb(db, manifest, connect = (url) => connectPg(url, "permdock supabase hook generate --db")) {
|
|
1534
|
+
const client = await connect(db);
|
|
1535
|
+
try {
|
|
1536
|
+
const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
1537
|
+
where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
|
|
1538
|
+
const found = new Set(result.rows.map((row) => row["proname"]));
|
|
1539
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
1540
|
+
} finally {
|
|
1541
|
+
await client.end();
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
function missingHelpersMessage(manifest, missing) {
|
|
1545
|
+
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`;
|
|
1546
|
+
}
|
|
1547
|
+
/** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
|
|
1548
|
+
function oversizedClaims(samples, manifest) {
|
|
1549
|
+
const encoder = new TextEncoder();
|
|
1550
|
+
const oversized = [];
|
|
1551
|
+
for (const claim of manifest.claims) {
|
|
1552
|
+
if (claim.source === "permdock") continue;
|
|
1553
|
+
let bytes = 0;
|
|
1554
|
+
for (const sample of samples) {
|
|
1555
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
1556
|
+
const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
|
|
1557
|
+
if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
|
|
1558
|
+
}
|
|
1559
|
+
if (bytes > manifest.budget.bytes) oversized.push({
|
|
1560
|
+
name: claim.name,
|
|
1561
|
+
bytes
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
return oversized;
|
|
1565
|
+
}
|
|
1566
|
+
/** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
|
|
1567
|
+
function droppedMemberships(samples) {
|
|
1568
|
+
const out = [];
|
|
1569
|
+
for (const [index, sample] of samples.entries()) {
|
|
1570
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
1571
|
+
const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
|
|
1572
|
+
const { dropped } = readMemberships(claim);
|
|
1573
|
+
if (dropped.length > 0) out.push({
|
|
1574
|
+
sample: index,
|
|
1575
|
+
entries: dropped
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
return out;
|
|
1579
|
+
}
|
|
1580
|
+
function pd039(input) {
|
|
1581
|
+
const findings = [];
|
|
1582
|
+
const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
|
|
1583
|
+
if (missing.length > 0) findings.push({
|
|
1584
|
+
code: "PD039",
|
|
1585
|
+
severity: "warning",
|
|
1586
|
+
message: missingHelpersMessage(input.manifest, missing).slice(6),
|
|
1587
|
+
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"
|
|
1588
|
+
});
|
|
1589
|
+
const fixture = input.config.doctor?.claims;
|
|
1590
|
+
if (fixture !== void 0) {
|
|
1591
|
+
const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
|
|
1592
|
+
const samples = Array.isArray(parsed) ? parsed : [];
|
|
1593
|
+
for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
|
|
1594
|
+
code: "PD039",
|
|
1595
|
+
severity: "warning",
|
|
1596
|
+
message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
|
|
1597
|
+
fix: "return less from the claim function, or read the data per request instead of from the token"
|
|
1598
|
+
});
|
|
1599
|
+
for (const { sample, entries } of droppedMemberships(samples)) findings.push({
|
|
1600
|
+
code: "PD039",
|
|
1601
|
+
severity: "warning",
|
|
1602
|
+
message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
|
|
1603
|
+
fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
return findings;
|
|
1607
|
+
}
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region src/cli/supabase-hook.ts
|
|
1610
|
+
const SUPABASE_HELP = `permdock supabase hook generate | inspect
|
|
1611
|
+
|
|
1612
|
+
hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
|
|
1613
|
+
[--active-from app_metadata.active_<scope>|<table>.<column>]
|
|
1614
|
+
[--budget 1024] [--schema public] [--grants-out <file>|-]
|
|
1615
|
+
inspect [--json] [--out [permdock.manifest.json]] [--check]
|
|
1616
|
+
|
|
1617
|
+
Reads supabase.hook from permdock.config.ts: the fromTable / fromJunction sources the app
|
|
1618
|
+
passes as memberships. hook generate emits custom_access_token_hook(jsonb), the grants it
|
|
1619
|
+
needs (the ones db diff drops in --grants-out, for declarative schemas), the permdock_authz_version table
|
|
1620
|
+
and its triggers. Never grants anything to
|
|
1621
|
+
service_role. inspect prints the manifest: helper schema, names and signatures, tenant claim,
|
|
1622
|
+
budget, the claims the hook writes, the membership sources and the columns that decide them.
|
|
1623
|
+
--out writes it as JSON, to permdock.manifest.json without a path; --check exits 1 when that
|
|
1624
|
+
file differs.
|
|
1625
|
+
`;
|
|
1626
|
+
const MANIFEST_FILE = "permdock.manifest.json";
|
|
1354
1627
|
/** `supabase.hook.out`, else pg-delta's per-schema path, else `supabase/permdock-hook.sql`. */
|
|
1355
1628
|
function hookOut(cwd, config, schema) {
|
|
1356
1629
|
const pgDelta = supabaseConfig(cwd).pgDelta;
|
|
1357
1630
|
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
1631
|
}
|
|
1359
|
-
|
|
1360
|
-
|
|
1632
|
+
/**
|
|
1633
|
+
* The manifest `supabase inspect` prints. With `policy`, `rls.helpers` also
|
|
1634
|
+
* lists the assignment checks `rls generate` writes for roles that declare
|
|
1635
|
+
* `assigns`.
|
|
1636
|
+
*/
|
|
1637
|
+
function supabaseHookManifest(scopes, config, overrides = {}, policy) {
|
|
1638
|
+
return manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config, policy);
|
|
1361
1639
|
}
|
|
1362
1640
|
/**
|
|
1363
|
-
* The hook migration. With `grantsOut`, the `
|
|
1364
|
-
* left out of `sql` and returned in `grants` for that file.
|
|
1641
|
+
* The hook migration. With `grantsOut`, the statements `supabase db diff`
|
|
1642
|
+
* drops are left out of `sql` and returned in `grants` for that file.
|
|
1365
1643
|
*/
|
|
1366
1644
|
function supabaseHookSql(scopes, config, overrides = {}, grantsOut) {
|
|
1367
1645
|
const parts = hookParts(scopes, config, overrides);
|
|
@@ -1371,18 +1649,23 @@ function supabaseHookSql(scopes, config, overrides = {}, grantsOut) {
|
|
|
1371
1649
|
`${hookMarker(manifest)}
|
|
1372
1650
|
-- custom_access_token_hook(jsonb): memberships go active ${parts.root} first and stop at the budget
|
|
1373
1651
|
-- supabase/config.toml:
|
|
1374
|
-
${toml}${grantsOut === void 0 ? "" : `\n--
|
|
1652
|
+
${toml}${grantsOut === void 0 ? "" : `\n-- what supabase db diff drops from this part is in ${grantsOut}`}`,
|
|
1375
1653
|
attrsGuardSql(parts.attrs),
|
|
1376
1654
|
hookSql(parts),
|
|
1377
1655
|
versionSql(parts),
|
|
1378
|
-
|
|
1656
|
+
subjectForSql(parts, config),
|
|
1657
|
+
authzVersionForSql(parts),
|
|
1658
|
+
membersOfSql(parts),
|
|
1659
|
+
grantsSql(parts),
|
|
1379
1660
|
managedSql(parts)
|
|
1380
1661
|
].filter((chunk) => chunk !== "").join("\n\n");
|
|
1662
|
+
const { kept, moved } = splitUndiffed(`${sql}\n`);
|
|
1381
1663
|
const grants = `${GRANTS_MARKER} schema=${parts.schema}
|
|
1382
|
-
|
|
1664
|
+
-- the privileges and view options supabase db diff drops from the hook part
|
|
1665
|
+
${moved.join("\n")}
|
|
1383
1666
|
`;
|
|
1384
1667
|
return {
|
|
1385
|
-
sql: `${sql}\n
|
|
1668
|
+
sql: grantsOut === void 0 ? `${sql}\n` : kept,
|
|
1386
1669
|
grants,
|
|
1387
1670
|
manifest
|
|
1388
1671
|
};
|
|
@@ -1447,9 +1730,11 @@ function manifestFile(cwd, rel, manifest, check) {
|
|
|
1447
1730
|
};
|
|
1448
1731
|
}
|
|
1449
1732
|
async function loadScopes(cwd, config) {
|
|
1733
|
+
return scopeList((await loadHookPolicy(cwd, config)).scopes);
|
|
1734
|
+
}
|
|
1735
|
+
async function loadHookPolicy(cwd, config) {
|
|
1450
1736
|
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);
|
|
1737
|
+
return asPolicy(pickNamed(await loadModule(resolve(cwd, config.policy)), ["policy"]));
|
|
1453
1738
|
}
|
|
1454
1739
|
async function runSupabase(input) {
|
|
1455
1740
|
const [area, action] = input.rest;
|
|
@@ -1459,10 +1744,11 @@ async function runSupabase(input) {
|
|
|
1459
1744
|
schema: input.schema
|
|
1460
1745
|
}).filter(([, value]) => value !== void 0));
|
|
1461
1746
|
if (area === "inspect" && action === void 0) {
|
|
1462
|
-
const
|
|
1747
|
+
const policy = await loadHookPolicy(input.cwd, input.config);
|
|
1748
|
+
const manifest = supabaseHookManifest(scopeList(policy.scopes), input.config, {
|
|
1463
1749
|
...overrides,
|
|
1464
1750
|
out: hookOut(input.cwd, input.config, input.schema)
|
|
1465
|
-
});
|
|
1751
|
+
}, policy);
|
|
1466
1752
|
if (input.out !== void 0 || input.check) return manifestFile(input.cwd, typeof input.out === "string" ? input.out : MANIFEST_FILE, manifest, input.check);
|
|
1467
1753
|
return {
|
|
1468
1754
|
code: 0,
|
|
@@ -1525,4 +1811,4 @@ async function runSupabase(input) {
|
|
|
1525
1811
|
};
|
|
1526
1812
|
}
|
|
1527
1813
|
//#endregion
|
|
1528
|
-
export {
|
|
1814
|
+
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 };
|