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
package/dist/rls-CJVja9Ww.js
DELETED
|
@@ -1,2119 +0,0 @@
|
|
|
1
|
-
import { t as compact } from "./compact-y6vfu2_f.js";
|
|
2
|
-
import { d as isFieldRelation, g as listPermissions, i as findPermission, p as isPrincipalRelation, u as isEdgeRelation, v as renamedKeys } from "./permissions-DC7GROPA.js";
|
|
3
|
-
import { a as hasConditionOp } from "./refs-ChLSxbST.js";
|
|
4
|
-
import { m as memoryRoleSource } from "./src-CTNT-PBW.js";
|
|
5
|
-
import { a as resolveScope, c as scopeChain, u as scopeList } from "./scopes-cKVp_WTw.js";
|
|
6
|
-
import { b as findRole, w as listRoles } from "./grantee-Bv04_Vzf.js";
|
|
7
|
-
import { d as membershipsClaim, t as createPermDock, u as customRoleScope } from "./permdock-yoZ7EjxX.js";
|
|
8
|
-
import { t as memoryRelations } from "./relations-r4soIFZB.js";
|
|
9
|
-
import { o as jsonSchemaOf, s as policyRowConditionKeys } from "./catalog-doc-BSo_Q_-6.js";
|
|
10
|
-
import { a as loadModule, n as asPolicy, o as pickNamed } from "./load-BZ24CpZP.js";
|
|
11
|
-
import "./budget-NbEtbJC_.js";
|
|
12
|
-
import { n as escapeSqlIdent } from "./sql-Bll1d7y0.js";
|
|
13
|
-
import "./sources-DVHM3Mwq.js";
|
|
14
|
-
import { i as SEEDS_MARKER, r as INDEXES_MARKER } from "./markers-B0hYLvMD.js";
|
|
15
|
-
import { a as usageResult } from "./errors-DSMnAVNa.js";
|
|
16
|
-
import { n as requirePeer } from "./peer-BOr_kJE1.js";
|
|
17
|
-
import { n as fixtureSubject, r as loadFixtures, t as fixtureRow } from "./fixtures-Cvaw0gDG.js";
|
|
18
|
-
import { _ as resolveAuthorize, c as supabaseHookSql, d as parseSplit, f as partPath, g as rbacScaffold, h as parseRbacAuthorize, m as writeSqlFiles, p as pgDeltaPath, r as grantsLabel, u as driftOf, y as supabaseConfig } from "./supabase-hook-D_OqGb_V.js";
|
|
19
|
-
import { B as scopeSources, C as globalKindFilterSql, D as kindFilterSql, F as qualifiedTable$1, H as scopeTypeOf, I as quoteIdent$1, K as subjectIdSql, L as quoteLiteral, M as memberIdsHelper, N as parseMembershipsFlag, P as permittedIdsHelper, R as quoteTable, V as scopeTable, W as subjectClaimJsonSql, a as helpersSql, b as columnTypesOf, c as membershipTable, d as seedSql, f as signedIn, i as globalRoleRows, l as qualified$1, n as accessSql, o as memberColumn, s as membershipRows, t as HELPERS, u as roleRows, w as graphHelper, y as checkSuspension, z as roleKindSql } from "./rls-helpers-BxRuif0l.js";
|
|
20
|
-
import { _ as breakGlassKey, a as columnRevokeSql, c as rowBranches, d as commandFor, f as compileGrants, g as breakGlassHolder, h as indexesSql, i as columnGrantSql, l as viewName, m as indexTargets, n as connectPgPool, o as fieldViews, p as tableFor, r as FIELD_VIEWS, s as fieldViewsSql, t as connectPg, u as assemblePolicies, v as collectGrants, y as roleNames } from "./pg-4GJWDUxG.js";
|
|
21
|
-
import { a as HELPER_TABLE_POLICIES_SQL, c as helperCallKeys, d as rowConditionMessage, i as HELPER_TABLES, n as graphPlan, r as graphSql, s as callsHelper, t as closureDepths, u as helperTablePoliciesFromRows } from "./rls-graph-DxQ39PJj.js";
|
|
22
|
-
import { basename, resolve } from "node:path";
|
|
23
|
-
import { randomUUID } from "node:crypto";
|
|
24
|
-
//#region src/cli/rls-break-glass.ts
|
|
25
|
-
/** The audit table every generated break-glass read writes to. Part of the SQL contract. */
|
|
26
|
-
const BREAK_GLASS_AUDIT = "permdock_break_glass_audit";
|
|
27
|
-
const IDENT = /^[a-z][a-z0-9_]*$/u;
|
|
28
|
-
/**
|
|
29
|
-
* The resources a `breakGlass` grant targets, with the table each reads. A
|
|
30
|
-
* break-glass grant is non-portable, so `compileGrants` never turns it into a
|
|
31
|
-
* policy; the server reads restricted rows through the generated function
|
|
32
|
-
* instead, which is why plain RLS keeps denying them.
|
|
33
|
-
*/
|
|
34
|
-
function breakGlassEntries(policy, tables) {
|
|
35
|
-
const byResource = /* @__PURE__ */ new Map();
|
|
36
|
-
for (const item of collectGrants(policy)) {
|
|
37
|
-
const { grant } = item;
|
|
38
|
-
if (grant.breakGlass === void 0) continue;
|
|
39
|
-
const holder = breakGlassHolder(item);
|
|
40
|
-
const resource = grant.permission.resource;
|
|
41
|
-
const grants = byResource.get(resource) ?? [];
|
|
42
|
-
byResource.set(resource, grants);
|
|
43
|
-
if (holder === void 0) {
|
|
44
|
-
const root = policy.scopes[0];
|
|
45
|
-
grants.push({
|
|
46
|
-
permission: grant.permission.key,
|
|
47
|
-
scope: "anyone",
|
|
48
|
-
column: root?.key,
|
|
49
|
-
...root === void 0 ? {} : { root: root.name }
|
|
50
|
-
});
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
grants.push({
|
|
54
|
-
permission: grant.permission.key,
|
|
55
|
-
scope: holder.scope,
|
|
56
|
-
column: holder.scope === "global" ? void 0 : policy.scopes.find((scope) => scope.name === holder.scope)?.key
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
return [...byResource].map(([resource, grants]) => ({
|
|
60
|
-
resource,
|
|
61
|
-
table: tableFor(resource, tables),
|
|
62
|
-
grants
|
|
63
|
-
}));
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Rows the subject's roles reach for `p_permission`: a break-glass grant
|
|
67
|
-
* lifts denies, never the tenant boundary. A grant to no role reads the rows
|
|
68
|
-
* of the root scopes the subject is a member of, or every row when the
|
|
69
|
-
* policy declares no scopes.
|
|
70
|
-
*/
|
|
71
|
-
function rowsFilter(ctx, entry) {
|
|
72
|
-
const arms = entry.grants.flatMap((grant) => {
|
|
73
|
-
const asked = `p_permission = ${quoteLiteral(grant.permission)}`;
|
|
74
|
-
if (grant.scope === "anyone") {
|
|
75
|
-
if (grant.root === void 0) return [`(${asked})`];
|
|
76
|
-
return grant.column === void 0 ? [] : [`(${asked} and ${quoteIdent$1(grant.column)} in (select ${qualified$1(ctx, memberIdsHelper(grant.root))}()))`];
|
|
77
|
-
}
|
|
78
|
-
if (grant.scope !== "global" && grant.column === void 0) return [];
|
|
79
|
-
return [`(${asked} and ${accessSql(ctx, grant.scope, breakGlassKey(grant.permission), grant.column)})`];
|
|
80
|
-
});
|
|
81
|
-
return arms.length === 0 ? "false" : [...new Set(arms)].join("\n or ");
|
|
82
|
-
}
|
|
83
|
-
function functionName(resource) {
|
|
84
|
-
if (!IDENT.test(resource)) throw new Error(`PermDock CLI: unsafe break-glass resource '${resource}'`);
|
|
85
|
-
return `permdock_break_glass_${resource}`;
|
|
86
|
-
}
|
|
87
|
-
function qualifiedTable(name) {
|
|
88
|
-
return quoteTable(name.includes(".") ? name : `public.${name}`);
|
|
89
|
-
}
|
|
90
|
-
function auditSql(ctx) {
|
|
91
|
-
const audit = qualified$1(ctx, quoteIdent$1(BREAK_GLASS_AUDIT));
|
|
92
|
-
return `-- break-glass audit: one row per session that read restricted data
|
|
93
|
-
-- RLS never compiles a break-glass override, so restricted rows stay denied to a direct read
|
|
94
|
-
create table if not exists ${audit} (
|
|
95
|
-
id bigint generated always as identity primary key,
|
|
96
|
-
at timestamptz not null default now(),
|
|
97
|
-
subject text,
|
|
98
|
-
permission text not null,
|
|
99
|
-
purpose text,
|
|
100
|
-
reason text not null
|
|
101
|
-
);
|
|
102
|
-
alter table ${audit} enable row level security;
|
|
103
|
-
revoke all on table ${audit} from anon, authenticated, public;`;
|
|
104
|
-
}
|
|
105
|
-
function readFunctionSql(ctx, entry) {
|
|
106
|
-
const fn = qualified$1(ctx, functionName(entry.resource));
|
|
107
|
-
const audit = qualified$1(ctx, quoteIdent$1(BREAK_GLASS_AUDIT));
|
|
108
|
-
const table = qualifiedTable(entry.table);
|
|
109
|
-
const session = subjectClaimJsonSql(ctx, "break_glass");
|
|
110
|
-
const subject = subjectIdSql(ctx);
|
|
111
|
-
return `-- ${entry.resource}: reads restricted rows through a checked, audited break-glass session
|
|
112
|
-
create or replace function ${fn}(p_permission text)
|
|
113
|
-
returns setof ${table}
|
|
114
|
-
language plpgsql
|
|
115
|
-
security definer
|
|
116
|
-
set search_path = ''
|
|
117
|
-
as $$
|
|
118
|
-
declare
|
|
119
|
-
session jsonb := ${session};
|
|
120
|
-
begin
|
|
121
|
-
if session is null
|
|
122
|
-
or jsonb_typeof(session) <> 'object'
|
|
123
|
-
or session ->> 'v' <> '1'
|
|
124
|
-
or session ->> 'permission' <> p_permission
|
|
125
|
-
or coalesce(session ->> 'reason', '') = ''
|
|
126
|
-
or jsonb_typeof(session -> 'expiresAt') <> 'number'
|
|
127
|
-
or (session ->> 'expiresAt')::numeric <= extract(epoch from now())
|
|
128
|
-
then
|
|
129
|
-
raise exception 'PermDock: no valid break-glass session for %', p_permission
|
|
130
|
-
using errcode = '42501';
|
|
131
|
-
end if;
|
|
132
|
-
insert into ${audit} (subject, permission, purpose, reason)
|
|
133
|
-
values (
|
|
134
|
-
nullif((${subject})::text, ''),
|
|
135
|
-
p_permission,
|
|
136
|
-
session ->> 'purpose',
|
|
137
|
-
session ->> 'reason'
|
|
138
|
-
);
|
|
139
|
-
return query select * from ${table}
|
|
140
|
-
where ${rowsFilter(ctx, entry)};
|
|
141
|
-
end;
|
|
142
|
-
$$;
|
|
143
|
-
revoke execute on function ${fn}(text) from public, anon;
|
|
144
|
-
grant execute on function ${fn}(text) to authenticated;`;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* The `security definer` read path for every `breakGlass` grant: an audit
|
|
148
|
-
* table and one `permdock_break_glass_<resource>(permission)` per resource
|
|
149
|
-
* that checks a signed break-glass session, writes an audit row and returns
|
|
150
|
-
* the rows in the scope instances where the subject holds a role with the
|
|
151
|
-
* break-glass grant (bypassing the table's policies as the definer). Empty
|
|
152
|
-
* when the policy has no break-glass grant.
|
|
153
|
-
*/
|
|
154
|
-
function breakGlassSql(ctx, entries) {
|
|
155
|
-
if (entries.length === 0) return "";
|
|
156
|
-
return [auditSql(ctx), ...entries.map((entry) => readFunctionSql(ctx, entry))].join("\n\n");
|
|
157
|
-
}
|
|
158
|
-
//#endregion
|
|
159
|
-
//#region src/cli/rls-emit.ts
|
|
160
|
-
const HEADER = "-- generated by permdock rls generate\n-- fail-closed: never target a bypass role\n";
|
|
161
|
-
function assertNoServiceRole(text) {
|
|
162
|
-
if (/\bservice_role\b/iu.test(text)) throw new Error("PermDock CLI: generated RLS must never emit service_role");
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Neon's Data API and `pg_session_jwt` name the anonymous role `anonymous`;
|
|
166
|
-
* Supabase, PostgREST and the GUC pattern use `anon`. Role lists end a line
|
|
167
|
-
* or a statement after `to` or `from`, so only those are renamed.
|
|
168
|
-
*/
|
|
169
|
-
function dialectRoles(text, dialect) {
|
|
170
|
-
if (dialect !== "neon") return text;
|
|
171
|
-
return text.replaceAll(/\b(to|from) ((?:[a-z_]+, )*[a-z_]+)(?=;|\n)/gu, (_match, keyword, list) => `${keyword} ${list.split(", ").map((role) => role === "anon" ? "anonymous" : role).join(", ")}`);
|
|
172
|
-
}
|
|
173
|
-
function tablesOf(policies) {
|
|
174
|
-
return [...new Set(policies.map((item) => item.table))];
|
|
175
|
-
}
|
|
176
|
-
/** Enable (and force) row level security, then the table grants for the commands a policy allows. */
|
|
177
|
-
function tableSql$1(table, policies, force, views) {
|
|
178
|
-
const closed = views.find((view) => view.table === table && view.companion !== void 0);
|
|
179
|
-
const target = quoteTable(qualifiedTable$1(table));
|
|
180
|
-
const lines = [`alter table ${target} enable row level security;`];
|
|
181
|
-
if (force) lines.push(`alter table ${target} force row level security;`);
|
|
182
|
-
lines.push(`revoke all on table ${target} from anon, authenticated;`);
|
|
183
|
-
for (const role of ["anon", "authenticated"]) {
|
|
184
|
-
const cmds = new Set(policies.filter((item) => item.table === table && item.effect === "allow" && item.roles.includes(role)).map((item) => item.command));
|
|
185
|
-
const grants = [
|
|
186
|
-
"select",
|
|
187
|
-
"insert",
|
|
188
|
-
"update",
|
|
189
|
-
"delete"
|
|
190
|
-
].filter((cmd) => cmds.has(cmd) && (closed === void 0 || cmd !== "select"));
|
|
191
|
-
if (grants.length > 0) lines.push(`grant ${grants.join(", ")} on table ${target} to ${role};`);
|
|
192
|
-
if (closed !== void 0 && cmds.has("select")) lines.push(columnGrantSql(closed, role));
|
|
193
|
-
}
|
|
194
|
-
if (closed !== void 0) lines.push(columnRevokeSql(closed));
|
|
195
|
-
return lines.join("\n");
|
|
196
|
-
}
|
|
197
|
-
function emitSql(policies, preamble, force = false, views = []) {
|
|
198
|
-
const chunks = [HEADER];
|
|
199
|
-
if (preamble !== "") chunks.push(preamble);
|
|
200
|
-
for (const table of tablesOf(policies)) chunks.push(tableSql$1(table, policies, force, views), "");
|
|
201
|
-
for (const item of policies) {
|
|
202
|
-
const as = item.effect === "deny" ? "restrictive" : "permissive";
|
|
203
|
-
const to = item.roles.join(", ");
|
|
204
|
-
const lines = [
|
|
205
|
-
`drop policy if exists ${quoteIdent$1(item.name)} on ${quoteTable(qualifiedTable$1(item.table))};`,
|
|
206
|
-
`create policy ${quoteIdent$1(item.name)}`,
|
|
207
|
-
` on ${quoteTable(qualifiedTable$1(item.table))}`,
|
|
208
|
-
` as ${as}`,
|
|
209
|
-
` for ${item.command}`,
|
|
210
|
-
` to ${to}`
|
|
211
|
-
];
|
|
212
|
-
if (item.using !== void 0) lines.push(` using (${item.using})`);
|
|
213
|
-
if (item.check !== void 0) lines.push(` with check (${item.check})`);
|
|
214
|
-
chunks.push(`${lines.join("\n")};\n`);
|
|
215
|
-
}
|
|
216
|
-
if (views.length > 0) chunks.push(fieldViewsSql(views));
|
|
217
|
-
const text = `${chunks.join("\n").trim()}\n`;
|
|
218
|
-
assertNoServiceRole(text);
|
|
219
|
-
return text;
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* The SQL a Drizzle or Prisma schema cannot declare: helpers, grants, `force` and field views.
|
|
223
|
-
* `rls generate` writes it next to `out` for a custom migration.
|
|
224
|
-
*/
|
|
225
|
-
function migrationSql(policies, preamble, force, views) {
|
|
226
|
-
const parts = [
|
|
227
|
-
preamble.trim(),
|
|
228
|
-
...tablesOf(policies).map((table) => tableSql$1(table, policies, force, views)),
|
|
229
|
-
fieldViewsSql(views)
|
|
230
|
-
].filter((part) => part !== "");
|
|
231
|
-
if (parts.length === 0) return "";
|
|
232
|
-
const text = `${HEADER}\n${parts.join("\n\n")}\n`;
|
|
233
|
-
assertNoServiceRole(text);
|
|
234
|
-
return text;
|
|
235
|
-
}
|
|
236
|
-
function helpersNote(helpers, how) {
|
|
237
|
-
return helpers === void 0 ? [] : [`// run ${helpers} ${how}`];
|
|
238
|
-
}
|
|
239
|
-
function camelCase(name) {
|
|
240
|
-
return name.replace(/[_-]+([a-z0-9])/gi, (_, char) => char.toUpperCase());
|
|
241
|
-
}
|
|
242
|
-
function pascalCase(name) {
|
|
243
|
-
const camel = camelCase(name);
|
|
244
|
-
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
245
|
-
}
|
|
246
|
-
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
247
|
-
function identifier(name, what) {
|
|
248
|
-
if (!IDENTIFIER.test(name)) throw new Error(`PermDock CLI: ${what} ${JSON.stringify(name)} is not an identifier`);
|
|
249
|
-
return name;
|
|
250
|
-
}
|
|
251
|
-
function quoted(text) {
|
|
252
|
-
return `'${text.replace(/[\\']/g, (match) => `\\${match}`)}'`;
|
|
253
|
-
}
|
|
254
|
-
function templateText(text) {
|
|
255
|
-
return text.replace(/[\\`]|\$\{/g, (match) => `\\${match}`);
|
|
256
|
-
}
|
|
257
|
-
const AUTH_UID = "(select auth.uid())";
|
|
258
|
-
function drizzleRoles(list) {
|
|
259
|
-
return list.includes("anon") ? "[anonRole, authenticatedRole]" : "authenticatedRole";
|
|
260
|
-
}
|
|
261
|
-
function emitDrizzle(policies, options) {
|
|
262
|
-
const supabase = options.dialect === "supabase";
|
|
263
|
-
const anon = policies.some((item) => item.roles.includes("anon"));
|
|
264
|
-
const sqlOf = (text) => {
|
|
265
|
-
const parts = text.split(AUTH_UID).map(templateText);
|
|
266
|
-
return `sql\`${supabase ? parts.join("${authUid}") : parts.join(AUTH_UID)}\``;
|
|
267
|
-
};
|
|
268
|
-
const usesUid = supabase && policies.some((item) => item.using?.includes(AUTH_UID) === true || item.check?.includes(AUTH_UID) === true);
|
|
269
|
-
const lines = [
|
|
270
|
-
"// generated by permdock rls generate",
|
|
271
|
-
"// fail-closed: never target a bypass role",
|
|
272
|
-
...helpersNote(options.helpers, "in a custom migration (drizzle-kit generate --custom)"),
|
|
273
|
-
"import { sql } from 'drizzle-orm'"
|
|
274
|
-
];
|
|
275
|
-
if (supabase) {
|
|
276
|
-
const named = [
|
|
277
|
-
anon ? "anonRole" : "",
|
|
278
|
-
"authenticatedRole",
|
|
279
|
-
usesUid ? "authUid" : ""
|
|
280
|
-
].filter((name) => name !== "").join(", ");
|
|
281
|
-
lines.push("import { pgPolicy } from 'drizzle-orm/pg-core'", `import { ${named} } from 'drizzle-orm/supabase'`);
|
|
282
|
-
} else lines.push("import { pgPolicy, pgRole } from 'drizzle-orm/pg-core'");
|
|
283
|
-
lines.push(`import * as schema from ${quoted(options.schema ?? "./schema")}`, "");
|
|
284
|
-
if (!supabase) {
|
|
285
|
-
if (anon) lines.push(`export const anonRole = pgRole('${options.dialect === "neon" ? "anonymous" : "anon"}').existing()`);
|
|
286
|
-
lines.push("export const authenticatedRole = pgRole('authenticated').existing()", "");
|
|
287
|
-
}
|
|
288
|
-
for (const item of policies) {
|
|
289
|
-
const as = item.effect === "deny" ? "restrictive" : "permissive";
|
|
290
|
-
const table = identifier(options.exports?.[item.table] ?? camelCase(item.table), "table export");
|
|
291
|
-
const name = identifier(camelCase(item.name), "policy export");
|
|
292
|
-
const using = item.using === void 0 ? "" : `\n using: ${sqlOf(item.using)},`;
|
|
293
|
-
const check = item.check === void 0 ? "" : `\n withCheck: ${sqlOf(item.check)},`;
|
|
294
|
-
lines.push(`export const ${name} = pgPolicy(${quoted(item.name)}, {`, ` as: '${as}',`, ` for: '${item.command}',`, ` to: ${drizzleRoles(item.roles)},${using}${check}`, `}).link(schema.${table})`, "");
|
|
295
|
-
}
|
|
296
|
-
const text = `${lines.join("\n").trimEnd()}\n`;
|
|
297
|
-
assertNoServiceRole(text);
|
|
298
|
-
return text;
|
|
299
|
-
}
|
|
300
|
-
/** Prisma 8 `policy_<command>` blocks; each target model declares `@@rls` itself. */
|
|
301
|
-
function emitPrisma(policies, options = {}) {
|
|
302
|
-
const modelOf = (table) => identifier(options.models?.[table] ?? pascalCase(table), "model");
|
|
303
|
-
const models = [...new Set(policies.map((item) => modelOf(item.table)))];
|
|
304
|
-
const lines = [
|
|
305
|
-
"// generated by permdock rls generate (Prisma 8 policy blocks)",
|
|
306
|
-
"// fail-closed: never target a bypass role",
|
|
307
|
-
`// add @@rls to ${models.length === 1 ? "model" : "models"} ${models.join(", ")}; keep these blocks in their namespace`,
|
|
308
|
-
...helpersNote(options.helpers, "in a migration before these policies"),
|
|
309
|
-
""
|
|
310
|
-
];
|
|
311
|
-
for (const item of policies) {
|
|
312
|
-
if (item.effect === "deny") throw new Error(`PermDock CLI: Prisma policy blocks are permissive only, so ${item.name} (a deny) needs --target sql`);
|
|
313
|
-
lines.push(`policy_${item.command} ${identifier(item.name, "policy name")} {`, ` target = ${modelOf(item.table)}`, ` roles = [${item.roles.join(", ")}]`);
|
|
314
|
-
if (item.using !== void 0) lines.push(` using = ${JSON.stringify(item.using)}`);
|
|
315
|
-
if (item.check !== void 0) lines.push(` withCheck = ${JSON.stringify(item.check)}`);
|
|
316
|
-
lines.push("}", "");
|
|
317
|
-
}
|
|
318
|
-
const text = `${lines.join("\n").trimEnd()}\n`;
|
|
319
|
-
assertNoServiceRole(text);
|
|
320
|
-
return text;
|
|
321
|
-
}
|
|
322
|
-
/** The migration file written next to `out`. */
|
|
323
|
-
function migrationOut(out) {
|
|
324
|
-
const dot = out.lastIndexOf(".");
|
|
325
|
-
return `${dot > Math.max(out.lastIndexOf("/"), out.lastIndexOf("\\")) ? out.slice(0, dot) : out}.migration.sql`;
|
|
326
|
-
}
|
|
327
|
-
function defaultOut(target) {
|
|
328
|
-
switch (target) {
|
|
329
|
-
case "sql": return "rls.sql";
|
|
330
|
-
case "drizzle": return "src/db/policies.ts";
|
|
331
|
-
case "prisma": return "prisma/policies.prisma";
|
|
332
|
-
default: return target;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
//#endregion
|
|
336
|
-
//#region src/cli/rls-ownership.ts
|
|
337
|
-
/** Objects the ownership rules add next to the helpers. Names are part of the SQL contract. */
|
|
338
|
-
const OWNERSHIP = {
|
|
339
|
-
canAssign: "permdock_can_assign",
|
|
340
|
-
holders: "permdock_holders",
|
|
341
|
-
transferOnly: "permdock_transfer_only"
|
|
342
|
-
};
|
|
343
|
-
function roleScope(policy, scopes, name) {
|
|
344
|
-
const on = policy.rolesByName.get(name)?.on ?? findRole(policy.vocabulary.roles, name)?.on;
|
|
345
|
-
return typeof on === "string" ? resolveScope(scopes, on) : void 0;
|
|
346
|
-
}
|
|
347
|
-
/** The rules `rls generate` enforces; `undefined` when no role declares one. */
|
|
348
|
-
function ownershipRules(policy, scopes) {
|
|
349
|
-
const kinds = {};
|
|
350
|
-
const assigns = [];
|
|
351
|
-
const counted = [];
|
|
352
|
-
for (const binding of policy.roles) {
|
|
353
|
-
const scope = roleScope(policy, scopes, binding.name);
|
|
354
|
-
if (binding.for !== void 0) kinds[binding.name] = binding.for;
|
|
355
|
-
const assigner = scope ?? (binding.on === void 0 ? "global" : void 0);
|
|
356
|
-
for (const target of binding.assigns ?? []) {
|
|
357
|
-
const at = roleScope(policy, scopes, target);
|
|
358
|
-
if (assigner !== void 0 && at !== void 0 && (assigner === "global" || scopeChain(scopes, at).includes(assigner))) assigns.push({
|
|
359
|
-
assigner: binding.name,
|
|
360
|
-
scope: assigner,
|
|
361
|
-
role: target
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
const min = binding.min ?? 0;
|
|
365
|
-
const transferOnly = binding.transferOnly === true;
|
|
366
|
-
if (scope !== void 0 && (min > 0 || binding.max !== void 0 || transferOnly)) counted.push(binding.max === void 0 ? {
|
|
367
|
-
role: binding.name,
|
|
368
|
-
scope,
|
|
369
|
-
min,
|
|
370
|
-
transferOnly
|
|
371
|
-
} : {
|
|
372
|
-
role: binding.name,
|
|
373
|
-
scope,
|
|
374
|
-
min,
|
|
375
|
-
max: binding.max,
|
|
376
|
-
transferOnly
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
if (Object.keys(kinds).length === 0 && assigns.length === 0 && counted.length === 0) return;
|
|
380
|
-
return {
|
|
381
|
-
kinds,
|
|
382
|
-
assigns,
|
|
383
|
-
counted
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
function objectName(prefix, scope) {
|
|
387
|
-
return quoteIdent$1(`${prefix}_${scope}`);
|
|
388
|
-
}
|
|
389
|
-
/** Live holders of `roleExpr` in instance `idExpr`, with the same kind and expiry rules as the helpers. */
|
|
390
|
-
function holdersSql(ctx, table, column, idExpr, role, into) {
|
|
391
|
-
const roleExpr = typeof role === "string" ? quoteLiteral(role) : role.expr;
|
|
392
|
-
const filters = [`m.${quoteIdent$1(column)} = ${idExpr}`, `m.${quoteIdent$1(table.role)}::text = ${roleExpr}`];
|
|
393
|
-
if (table.expiresAt !== void 0) {
|
|
394
|
-
const expires = memberColumn(table.expiresAt);
|
|
395
|
-
filters.push(`(${expires} is null or ${expires} > now())`);
|
|
396
|
-
}
|
|
397
|
-
const via = table.via === void 0 ? "null::text" : `${memberColumn(table.via)}::text`;
|
|
398
|
-
const kind = typeof role === "string" ? roleKindSql(ctx, role, via) : kindFilterSql(ctx, roleExpr, via);
|
|
399
|
-
if (kind !== void 0) filters.push(kind);
|
|
400
|
-
return `select count(distinct m.${quoteIdent$1(table.user)}) into ${into}
|
|
401
|
-
from ${membershipTable(table.table)} m
|
|
402
|
-
where ${filters.join("\n and ")}`;
|
|
403
|
-
}
|
|
404
|
-
/**
|
|
405
|
-
* `min` and `max` as a deferred constraint trigger: checked at commit, so a
|
|
406
|
-
* transfer (demote one holder, promote another) passes in one transaction. An
|
|
407
|
-
* instance with no memberships left is being torn down and skips `min`.
|
|
408
|
-
*/
|
|
409
|
-
function holdersTriggerSql(ctx, scope, table, column, counted) {
|
|
410
|
-
const fn = qualified$1(ctx, `${OWNERSHIP.holders}_${scope}`);
|
|
411
|
-
const col = quoteIdent$1(column);
|
|
412
|
-
const checks = counted.map((rule) => {
|
|
413
|
-
const lines = [` select count(*) into v_total from ${membershipTable(table.table)} m where m.${col} = v_key;`, ` ${holdersSql(ctx, table, column, "v_key", rule.role, "v_count")};`];
|
|
414
|
-
if (rule.min > 0) lines.push(` if v_total > 0 and v_count < ${String(rule.min)} then
|
|
415
|
-
raise exception using
|
|
416
|
-
errcode = '23514',
|
|
417
|
-
message = ${quoteLiteral(`permdock: ${scope} `)} || v_id || ${quoteLiteral(` keeps at least ${String(rule.min)} ${rule.role}`)},
|
|
418
|
-
hint = 'last-holder';
|
|
419
|
-
end if;`);
|
|
420
|
-
if (rule.max !== void 0) lines.push(` if v_count > ${String(rule.max)} then
|
|
421
|
-
raise exception using
|
|
422
|
-
errcode = '23514',
|
|
423
|
-
message = ${quoteLiteral(`permdock: ${scope} `)} || v_id || ${quoteLiteral(` has at most ${String(rule.max)} ${rule.role}`)},
|
|
424
|
-
hint = 'max-holders';
|
|
425
|
-
end if;`);
|
|
426
|
-
return lines.join("\n");
|
|
427
|
-
});
|
|
428
|
-
return `-- ${scope}: holder counts (min / max), checked at commit
|
|
429
|
-
create or replace function ${fn}()
|
|
430
|
-
returns trigger
|
|
431
|
-
language plpgsql
|
|
432
|
-
security definer
|
|
433
|
-
set search_path = ''
|
|
434
|
-
as $$
|
|
435
|
-
declare
|
|
436
|
-
v_ids text[] := '{}';
|
|
437
|
-
v_id text;
|
|
438
|
-
v_key ${membershipTable(table.table)}.${col}%type;
|
|
439
|
-
v_total bigint;
|
|
440
|
-
v_count bigint;
|
|
441
|
-
begin
|
|
442
|
-
if tg_op in ('UPDATE', 'DELETE') then
|
|
443
|
-
v_ids := v_ids || old.${col}::text;
|
|
444
|
-
end if;
|
|
445
|
-
if tg_op in ('INSERT', 'UPDATE') then
|
|
446
|
-
v_ids := v_ids || new.${col}::text;
|
|
447
|
-
end if;
|
|
448
|
-
foreach v_id in array v_ids loop
|
|
449
|
-
continue when v_id is null;
|
|
450
|
-
v_key := v_id;
|
|
451
|
-
${checks.join("\n")}
|
|
452
|
-
end loop;
|
|
453
|
-
return null;
|
|
454
|
-
end;
|
|
455
|
-
$$;
|
|
456
|
-
drop trigger if exists ${objectName(OWNERSHIP.holders, scope)} on ${membershipTable(table.table)};
|
|
457
|
-
create constraint trigger ${objectName(OWNERSHIP.holders, scope)}
|
|
458
|
-
after insert or update or delete on ${membershipTable(table.table)}
|
|
459
|
-
deferrable initially deferred
|
|
460
|
-
for each row execute function ${fn}();`;
|
|
461
|
-
}
|
|
462
|
-
/**
|
|
463
|
-
* `transferOnly` as statement triggers over transition tables: a statement
|
|
464
|
-
* that changes a role's holder count in an instance that had holders before
|
|
465
|
-
* and still has some after is refused. Creating the first holder and removing
|
|
466
|
-
* the last (which `min` guards) are not transfers.
|
|
467
|
-
*/
|
|
468
|
-
function transferTriggerSql(ctx, scope, table, column, roles) {
|
|
469
|
-
const fn = qualified$1(ctx, `${OWNERSHIP.transferOnly}_${scope}`);
|
|
470
|
-
const col = quoteIdent$1(column);
|
|
471
|
-
const role = quoteIdent$1(table.role);
|
|
472
|
-
const list = `array[${roles.map(quoteLiteral).join(", ")}]::text[]`;
|
|
473
|
-
const rows = (alias, source, delta) => `select ${alias}.${col} as id, ${alias}.${role}::text as role, ${delta} as delta from ${source} ${alias} where ${alias}.${role}::text = any(${list})`;
|
|
474
|
-
const loop = (source) => ` for v_change in
|
|
475
|
-
select c.id, c.role, sum(c.delta) as delta
|
|
476
|
-
from (${source}) c
|
|
477
|
-
group by c.id, c.role
|
|
478
|
-
loop
|
|
479
|
-
continue when v_change.delta = 0 or v_change.id is null;
|
|
480
|
-
${holdersSql(ctx, table, column, "v_change.id", { expr: "v_change.role" }, "v_after")};
|
|
481
|
-
if v_after > 0 and v_after - v_change.delta > 0 then
|
|
482
|
-
raise exception using
|
|
483
|
-
errcode = '23514',
|
|
484
|
-
message = 'permdock: ' || v_change.role || ${quoteLiteral(` in ${scope} `)} || v_change.id || ' moves only by transfer',
|
|
485
|
-
hint = 'transfer-only';
|
|
486
|
-
end if;
|
|
487
|
-
end loop;`;
|
|
488
|
-
const inserted = rows("n", "permdock_new", "1");
|
|
489
|
-
const deleted = rows("o", "permdock_old", "-1");
|
|
490
|
-
const target = membershipTable(table.table);
|
|
491
|
-
const trigger = (op) => {
|
|
492
|
-
const name = quoteIdent$1(`${OWNERSHIP.transferOnly}_${scope}_${op}`);
|
|
493
|
-
return `drop trigger if exists ${name} on ${target};
|
|
494
|
-
create trigger ${name}
|
|
495
|
-
after ${op} on ${target}
|
|
496
|
-
referencing ${op === "insert" ? "new table as permdock_new" : op === "delete" ? "old table as permdock_old" : "old table as permdock_old new table as permdock_new"}
|
|
497
|
-
for each statement execute function ${fn}();`;
|
|
498
|
-
};
|
|
499
|
-
return `-- ${scope}: transfer-only roles (${roles.join(", ")}) keep their holder count per statement
|
|
500
|
-
create or replace function ${fn}()
|
|
501
|
-
returns trigger
|
|
502
|
-
language plpgsql
|
|
503
|
-
security definer
|
|
504
|
-
set search_path = ''
|
|
505
|
-
as $$
|
|
506
|
-
declare
|
|
507
|
-
v_change record;
|
|
508
|
-
v_after bigint;
|
|
509
|
-
begin
|
|
510
|
-
if tg_op = 'INSERT' then
|
|
511
|
-
${loop(inserted)}
|
|
512
|
-
elsif tg_op = 'DELETE' then
|
|
513
|
-
${loop(deleted)}
|
|
514
|
-
else
|
|
515
|
-
${loop(`${inserted} union all ${deleted}`)}
|
|
516
|
-
end if;
|
|
517
|
-
return null;
|
|
518
|
-
end;
|
|
519
|
-
$$;
|
|
520
|
-
${trigger("insert")}
|
|
521
|
-
${trigger("update")}
|
|
522
|
-
${trigger("delete")}`;
|
|
523
|
-
}
|
|
524
|
-
function pairsSql(pairs) {
|
|
525
|
-
return `values ${pairs.map((pair) => `(${quoteLiteral(pair.assigner)}, ${quoteLiteral(pair.role)})`).join(", ")}`;
|
|
526
|
-
}
|
|
527
|
-
/** Whether the signed-in user holds, in instance `p_scope_id`, a role whose `assigns` lists `p_role`. */
|
|
528
|
-
function canAssignSql(ctx, own) {
|
|
529
|
-
const fn = qualified$1(ctx, OWNERSHIP.canAssign);
|
|
530
|
-
const parts = [];
|
|
531
|
-
const global = own.assigns.filter((pair) => pair.scope === "global");
|
|
532
|
-
if (global.length > 0) {
|
|
533
|
-
if (ctx.authorize === "database") {
|
|
534
|
-
const ur = globalRoleRows(ctx);
|
|
535
|
-
const kind = globalKindFilterSql(ctx, ur.roleSql);
|
|
536
|
-
parts.push(`exists (
|
|
537
|
-
select 1 from ${ur.from}
|
|
538
|
-
where ${ur.userSql} = ${subjectIdSql(ctx)}
|
|
539
|
-
and (${ur.roleSql}, p_role) in (${pairsSql(global)})${kind === void 0 ? "" : `\n and ${kind}`}
|
|
540
|
-
)`);
|
|
541
|
-
} else {
|
|
542
|
-
const kind = globalKindFilterSql(ctx, "r.role");
|
|
543
|
-
parts.push(`exists (
|
|
544
|
-
select 1 from ${roleRows(ctx)}
|
|
545
|
-
where (r.role, p_role) in (${pairsSql(global)})${kind === void 0 ? "" : `\n and ${kind}`}
|
|
546
|
-
)`);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
for (const { name } of ctx.scopes) {
|
|
550
|
-
const pairs = own.assigns.filter((pair) => pair.scope === name);
|
|
551
|
-
if (pairs.length === 0) continue;
|
|
552
|
-
if (ctx.authorize === "database") {
|
|
553
|
-
const mapped = scopeTable(ctx, name);
|
|
554
|
-
if (mapped === void 0) continue;
|
|
555
|
-
const { table, column } = mapped;
|
|
556
|
-
const filters = [
|
|
557
|
-
`${memberColumn(table.user)} = ${subjectIdSql(ctx)}`,
|
|
558
|
-
`${memberColumn(column)}::text = p_scope_id`,
|
|
559
|
-
`(${memberColumn(table.role)}::text, p_role) in (${pairsSql(pairs)})`
|
|
560
|
-
];
|
|
561
|
-
if (table.expiresAt !== void 0) {
|
|
562
|
-
const expires = memberColumn(table.expiresAt);
|
|
563
|
-
filters.push(`(${expires} is null or ${expires} > now())`);
|
|
564
|
-
}
|
|
565
|
-
const kind = kindFilterSql(ctx, `${memberColumn(table.role)}::text`, table.via === void 0 ? "null::text" : `${memberColumn(table.via)}::text`);
|
|
566
|
-
if (kind !== void 0) filters.push(kind);
|
|
567
|
-
parts.push(`exists (
|
|
568
|
-
select 1 from ${membershipTable(table.table)} m
|
|
569
|
-
where ${filters.join("\n and ")}
|
|
570
|
-
)`);
|
|
571
|
-
} else {
|
|
572
|
-
const kind = kindFilterSql(ctx, "r.role", "m ->> 'via'");
|
|
573
|
-
parts.push(`exists (
|
|
574
|
-
select 1 from ${membershipRows(ctx)}
|
|
575
|
-
where m ->> 'scope' = ${quoteLiteral(name)}
|
|
576
|
-
and m ->> 'id' = p_scope_id
|
|
577
|
-
and (r.role, p_role) in (${pairsSql(pairs)})
|
|
578
|
-
and case jsonb_typeof(m -> 'expiresAt')
|
|
579
|
-
when 'number' then (m ->> 'expiresAt')::numeric > extract(epoch from now())
|
|
580
|
-
else true
|
|
581
|
-
end${kind === void 0 ? "" : `\n and ${kind}`}
|
|
582
|
-
)`);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
const body = parts.length === 0 ? "false" : parts.join("\n or ");
|
|
586
|
-
return `-- who may assign: a held role whose assigns lists p_role, in instance p_scope_id (its scope or an ancestor's)
|
|
587
|
-
create or replace function ${fn}(p_role text, p_scope_id text)
|
|
588
|
-
returns boolean
|
|
589
|
-
language sql
|
|
590
|
-
stable
|
|
591
|
-
security definer
|
|
592
|
-
set search_path = ''
|
|
593
|
-
as $$
|
|
594
|
-
select ${signedIn(ctx)} and (
|
|
595
|
-
${body}
|
|
596
|
-
)
|
|
597
|
-
$$;
|
|
598
|
-
revoke execute on function ${fn}(text, text) from public, anon;
|
|
599
|
-
grant execute on function ${fn}(text, text) to authenticated;`;
|
|
600
|
-
}
|
|
601
|
-
/**
|
|
602
|
-
* The ownership objects: holder-count and transfer-only triggers on each
|
|
603
|
-
* scope's membership table, and `permdock_can_assign` for the application's
|
|
604
|
-
* own policies on those tables. Empty when no role declares a rule.
|
|
605
|
-
*/
|
|
606
|
-
function ownershipSql(ctx) {
|
|
607
|
-
const own = ctx.ownership;
|
|
608
|
-
if (own === void 0) return "";
|
|
609
|
-
const chunks = [];
|
|
610
|
-
for (const { name } of ctx.scopes) {
|
|
611
|
-
const counted = own.counted.filter((rule) => rule.scope === name);
|
|
612
|
-
if (counted.length === 0) continue;
|
|
613
|
-
const mapped = scopeTable(ctx, name);
|
|
614
|
-
if (mapped === void 0) {
|
|
615
|
-
chunks.push(`-- ${name}: no memberships table configured, so min, max and transferOnly are checked only by decideRoleChange`);
|
|
616
|
-
continue;
|
|
617
|
-
}
|
|
618
|
-
chunks.push(holdersTriggerSql(ctx, name, mapped.table, mapped.column, counted));
|
|
619
|
-
const transfer = counted.filter((rule) => rule.transferOnly).map((rule) => rule.role);
|
|
620
|
-
if (transfer.length > 0) chunks.push(transferTriggerSql(ctx, name, mapped.table, mapped.column, transfer));
|
|
621
|
-
}
|
|
622
|
-
if (own.assigns.length > 0) chunks.push(canAssignSql(ctx, own));
|
|
623
|
-
return chunks.length === 0 ? "" : `${chunks.join("\n\n")}\n`;
|
|
624
|
-
}
|
|
625
|
-
//#endregion
|
|
626
|
-
//#region src/cli/rls-shims.ts
|
|
627
|
-
const NAME = /^[a-z_][a-z0-9_]*$/u;
|
|
628
|
-
/**
|
|
629
|
-
* The key a legacy caller passes, mapped the way `rls migrate` maps it:
|
|
630
|
-
* `keys` and `renamed` exactly, then the longest `prefixes` entry.
|
|
631
|
-
*/
|
|
632
|
-
function mappedKey(config, renamed) {
|
|
633
|
-
const exact = {
|
|
634
|
-
...renamed,
|
|
635
|
-
...config.keys
|
|
636
|
-
};
|
|
637
|
-
const prefixes = Object.entries(config.prefixes ?? {}).toSorted(([a], [b]) => b.length - a.length);
|
|
638
|
-
const fallback = prefixes.length === 0 ? "p_key" : `case ${prefixes.map(([from, to]) => `when pg_catalog.starts_with(p_key, ${quoteLiteral(from)}) then ${quoteLiteral(to)} || pg_catalog.substr(p_key, ${String(from.length + 1)})`).join(" ")} else p_key end`;
|
|
639
|
-
if (Object.keys(exact).length === 0) return fallback;
|
|
640
|
-
return `coalesce(${quoteLiteral(JSON.stringify(exact))}::jsonb ->> p_key, ${fallback})`;
|
|
641
|
-
}
|
|
642
|
-
function scopeOf(ctx, scope) {
|
|
643
|
-
if (!ctx.scopes.some((item) => item.name === scope)) throw new Error(`PermDock CLI: rls.migrate helper scope '${scope}' is not a declared scope`);
|
|
644
|
-
return scope;
|
|
645
|
-
}
|
|
646
|
-
function shimFunction(name, args, argTypes, returns, sql) {
|
|
647
|
-
return `create or replace function ${name}(${args})
|
|
648
|
-
returns ${returns}
|
|
649
|
-
language sql
|
|
650
|
-
stable
|
|
651
|
-
security invoker
|
|
652
|
-
set search_path = ''
|
|
653
|
-
as $$
|
|
654
|
-
${sql}
|
|
655
|
-
$$;
|
|
656
|
-
revoke execute on function ${name}(${argTypes}) from public, anon;
|
|
657
|
-
grant execute on function ${name}(${argTypes}) to authenticated;`;
|
|
658
|
-
}
|
|
659
|
-
function shim(ctx, config, key, fn, helper) {
|
|
660
|
-
switch (helper.form) {
|
|
661
|
-
case "ids": {
|
|
662
|
-
const scope = scopeOf(ctx, helper.scope);
|
|
663
|
-
return shimFunction(fn, "p_key text", "text", `setof ${scopeTypeOf(ctx, scope)}`, ` select * from ${qualified$1(ctx, permittedIdsHelper(scope))}(${key})`);
|
|
664
|
-
}
|
|
665
|
-
case "row": {
|
|
666
|
-
const scope = scopeOf(ctx, helper.scope);
|
|
667
|
-
const type = scopeTypeOf(ctx, scope);
|
|
668
|
-
return shimFunction(fn, `p_id ${type}, p_key text`, `${type}, text`, "boolean", ` select coalesce(p_id in (select ${qualified$1(ctx, permittedIdsHelper(scope))}(${key})), false)`);
|
|
669
|
-
}
|
|
670
|
-
case "membership": {
|
|
671
|
-
const scope = scopeOf(ctx, helper.scope);
|
|
672
|
-
const type = scopeTypeOf(ctx, scope);
|
|
673
|
-
return shimFunction(fn, `p_id ${type}`, type, "boolean", ` select coalesce(p_id in (select ${qualified$1(ctx, memberIdsHelper(scope))}()), false)`);
|
|
674
|
-
}
|
|
675
|
-
case "global": return shimFunction(fn, "p_key text", "text", "boolean", ` select ${qualified$1(ctx, HELPERS.has)}(${key})`);
|
|
676
|
-
case "scoped": {
|
|
677
|
-
const literals = /* @__PURE__ */ new Map();
|
|
678
|
-
for (const scope of ctx.scopes) literals.set(scope.name, scope.name);
|
|
679
|
-
for (const [literal, scope] of Object.entries(config.scopes ?? {})) literals.set(literal, scopeOf(ctx, scope));
|
|
680
|
-
const global = config.globalScopes ?? [];
|
|
681
|
-
return shimFunction(fn, "p_scope text, p_id text, p_key text", "text, text, text", "boolean", ` select case\n${[...global.length === 0 ? [] : [` when p_scope in (${global.map(quoteLiteral).join(", ")}) then ${qualified$1(ctx, HELPERS.has)}(${key})`], ...[...literals].filter(([literal]) => !global.includes(literal)).map(([literal, scope]) => ` when p_scope = ${quoteLiteral(literal)} then coalesce(p_id in (select ids::text from ${qualified$1(ctx, permittedIdsHelper(scope))}(${key}) as ids), false)`)].join("\n")}\n else false\n end`);
|
|
682
|
-
}
|
|
683
|
-
default: return helper;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
/**
|
|
687
|
-
* One wrapper per `rls.migrate.helpers` entry under its legacy name, so SQL
|
|
688
|
-
* that `rls migrate` has not rewritten yet (function bodies, views, triggers)
|
|
689
|
-
* answers from the PermDock helpers. Each is `security invoker`: it reads no
|
|
690
|
-
* table, and the helpers it calls are the `security definer` ones.
|
|
691
|
-
*/
|
|
692
|
-
function shimsSql(ctx, config, shims, renamed) {
|
|
693
|
-
const schema = shims.schema ?? "public";
|
|
694
|
-
const key = mappedKey(config, renamed);
|
|
695
|
-
const chunks = [`-- permdock shims: legacy helper names over the permdock helpers; drop each once doctor reports no caller (PD056)`];
|
|
696
|
-
for (const [name, helper] of Object.entries(config.helpers).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
697
|
-
if (!NAME.test(name)) throw new Error(`PermDock CLI: unsafe rls.migrate helper name '${name}'`);
|
|
698
|
-
chunks.push(shim(ctx, config, key, `${quoteIdent$1(schema)}.${name}`, helper));
|
|
699
|
-
}
|
|
700
|
-
return `${chunks.join("\n\n")}\n`;
|
|
701
|
-
}
|
|
702
|
-
//#endregion
|
|
703
|
-
//#region src/cli/rls-generate.ts
|
|
704
|
-
async function loadPolicy(cwd, config, from) {
|
|
705
|
-
const policyPath = from === "drizzle" ? config.policy : from ?? config.policy;
|
|
706
|
-
if (policyPath === void 0) throw new Error("PermDock CLI: rls generate needs policy in permdock.config.ts or --from");
|
|
707
|
-
return asPolicy(pickNamed(await loadModule(resolve(cwd, policyPath)), ["policy"]));
|
|
708
|
-
}
|
|
709
|
-
/** Declared role names, and those a tenant admin may compose into custom roles. */
|
|
710
|
-
function customRoleNames(policy) {
|
|
711
|
-
const declared = [...roleNames(policy)].toSorted();
|
|
712
|
-
const assignable = declared.filter((name) => policy.rolesByName.get(name)?.assignable ?? listRoles(policy.vocabulary.roles).some((leaf) => leaf.key === name && leaf.assignable));
|
|
713
|
-
const renamed = renamedKeys(policy.vocabulary.permissions);
|
|
714
|
-
return renamed.size === 0 ? {
|
|
715
|
-
declared,
|
|
716
|
-
assignable
|
|
717
|
-
} : {
|
|
718
|
-
declared,
|
|
719
|
-
assignable,
|
|
720
|
-
renamed: Object.fromEntries(renamed)
|
|
721
|
-
};
|
|
722
|
-
}
|
|
723
|
-
async function runRlsGenerate(input) {
|
|
724
|
-
const rls = input.config.rls;
|
|
725
|
-
const fieldsMode = input.fields ?? rls?.fields;
|
|
726
|
-
if (fieldsMode !== void 0 && fieldsMode !== "views") return {
|
|
727
|
-
code: 2,
|
|
728
|
-
output: `rls generate --fields must be views (got ${fieldsMode})`,
|
|
729
|
-
text: ""
|
|
730
|
-
};
|
|
731
|
-
const revokeColumns = input.revokeColumns === true || rls?.revokeColumns === true;
|
|
732
|
-
if (revokeColumns && fieldsMode === void 0) return {
|
|
733
|
-
code: 2,
|
|
734
|
-
output: "rls generate --revoke-columns needs --fields views",
|
|
735
|
-
text: ""
|
|
736
|
-
};
|
|
737
|
-
const policy = await loadPolicy(input.cwd, input.config, input.from);
|
|
738
|
-
const memberships = parseMembershipsFlag(input.memberships) ?? rls?.memberships;
|
|
739
|
-
if (input.rbac && input.dialect !== "supabase") return {
|
|
740
|
-
code: 2,
|
|
741
|
-
output: `rls generate --rbac supabase needs --dialect supabase (got ${input.dialect})`,
|
|
742
|
-
text: ""
|
|
743
|
-
};
|
|
744
|
-
const schema = input.rbacSchema ?? rls?.schema ?? rls?.rbac?.schema ?? "permdock";
|
|
745
|
-
const authorize = resolveAuthorize(input.config, {
|
|
746
|
-
authorize: input.authorize,
|
|
747
|
-
rbac: input.rbac,
|
|
748
|
-
memberships
|
|
749
|
-
});
|
|
750
|
-
const memberSources = rls?.membershipSources ?? input.config.supabase?.hook?.memberships;
|
|
751
|
-
const sources = authorize === "database" ? memberSources : void 0;
|
|
752
|
-
const scopes = scopeList(policy.scopes);
|
|
753
|
-
const suspension = checkSuspension(rls?.suspension, scopes);
|
|
754
|
-
const hookRoles = input.config.supabase?.hook?.roles;
|
|
755
|
-
const roles = rls?.roles ?? (hookRoles === false ? void 0 : hookRoles);
|
|
756
|
-
if (roles !== void 0 && input.rbac) return {
|
|
757
|
-
code: 2,
|
|
758
|
-
output: "rls generate --rbac supabase creates its own user_roles (user_id, role app_role); drop rls.roles (or supabase.hook.roles) or the --rbac flag",
|
|
759
|
-
text: ""
|
|
760
|
-
};
|
|
761
|
-
const ownership = ownershipRules(policy, scopes);
|
|
762
|
-
const graph = graphPlan(policy);
|
|
763
|
-
const ctx = {
|
|
764
|
-
dialect: input.dialect,
|
|
765
|
-
scopes,
|
|
766
|
-
tenantClaim: rls?.tenantClaim ?? "tenant_id",
|
|
767
|
-
gucPrefix: input.gucPrefix ?? rls?.gucPrefix ?? "app",
|
|
768
|
-
...rls?.actions === void 0 ? {} : { actions: rls.actions },
|
|
769
|
-
inlineFunctions: input.inlineFunctions || rls?.inlineFunctions === true,
|
|
770
|
-
schema,
|
|
771
|
-
authorize,
|
|
772
|
-
roleClaim: rls?.roleClaim ?? "user_role",
|
|
773
|
-
tenantType: input.tenantType ?? rls?.tenantType ?? "uuid",
|
|
774
|
-
...rls?.teamType === void 0 ? {} : { teamType: rls.teamType },
|
|
775
|
-
...rls?.scopeTypes === void 0 ? {} : { scopeTypes: rls.scopeTypes },
|
|
776
|
-
...memberships === void 0 ? {} : { memberships },
|
|
777
|
-
...sources === void 0 || sources.length === 0 ? {} : { sources },
|
|
778
|
-
...memberSources === void 0 || memberSources.length === 0 ? {} : { memberSources },
|
|
779
|
-
...suspension === void 0 ? {} : { suspension },
|
|
780
|
-
...roles === void 0 ? {} : { roles },
|
|
781
|
-
...input.customRoles === true || rls?.customRoles === true ? { customRoles: customRoleNames(policy) } : {},
|
|
782
|
-
...input.capabilities === true || rls?.capabilities === true ? { capabilities: true } : {},
|
|
783
|
-
...rls?.anonymousSignIns === "deny" ? { anonymousSignIns: "deny" } : {},
|
|
784
|
-
...ownership === void 0 ? {} : { ownership },
|
|
785
|
-
...fieldsMode === void 0 ? {} : { fields: fieldsMode },
|
|
786
|
-
...graph.size === 0 ? {} : { graph: {
|
|
787
|
-
closures: closureDepths(graph),
|
|
788
|
-
resources: policy.resources,
|
|
789
|
-
...rls?.tables === void 0 ? {} : { tables: rls.tables }
|
|
790
|
-
} }
|
|
791
|
-
};
|
|
792
|
-
if (ctx.anonymousSignIns === "deny" && ctx.dialect !== "supabase") return {
|
|
793
|
-
code: 2,
|
|
794
|
-
output: `rls.anonymousSignIns needs --dialect supabase (got ${ctx.dialect})`,
|
|
795
|
-
text: ""
|
|
796
|
-
};
|
|
797
|
-
const warnings = [];
|
|
798
|
-
if (authorize === "database") {
|
|
799
|
-
for (const { name } of ctx.scopes) if (policy.grants.some((grant) => grant.scope === name) && scopeTable(ctx, name) === void 0 && scopeSources(ctx, name).length === 0) warnings.push(`${name}-scoped grants read the ${name} memberships table in database mode: set rls.memberships.scopes.${name}, rls.membershipSources or supabase.hook.memberships (or --memberships for the first scope), otherwise they deny`);
|
|
800
|
-
}
|
|
801
|
-
const compiled = compileGrants(policy, ctx, rls?.tables, warnings, input.skipClosures);
|
|
802
|
-
const views = fieldsMode === void 0 ? [] : fieldViews(policy, ctx, compiled.branches, { revokeColumns }, warnings);
|
|
803
|
-
const force = input.force === true || rls?.force === true;
|
|
804
|
-
if (force && views.some((view) => view.companion !== void 0)) warnings.push("--force with --revoke-columns: each <table>_visible_fields companion reads as its owner, so its owner needs BYPASSRLS or the restricted columns read as null");
|
|
805
|
-
const policyName = input.policyName ?? rls?.policyName;
|
|
806
|
-
const helpersOnly = input.helpersOnly === true || rls?.helpersOnly === true;
|
|
807
|
-
if (helpersOnly && (input.target !== "sql" || fieldsMode !== void 0)) return {
|
|
808
|
-
code: 2,
|
|
809
|
-
output: "rls generate --helpers-only needs --target sql and no --fields",
|
|
810
|
-
text: ""
|
|
811
|
-
};
|
|
812
|
-
const policies = helpersOnly ? [] : assemblePolicies(fieldsMode === void 0 ? compiled.branches : rowBranches(compiled.branches), {
|
|
813
|
-
perRole: input.policyPerRole === true || rls?.policyPerRole === true,
|
|
814
|
-
...policyName === void 0 ? {} : { name: policyName }
|
|
815
|
-
});
|
|
816
|
-
const rootMapped = ctx.scopes[0] === void 0 ? void 0 : scopeTable(ctx, ctx.scopes[0].name);
|
|
817
|
-
const rootTable = rootMapped === void 0 ? void 0 : {
|
|
818
|
-
...rootMapped.table,
|
|
819
|
-
tenant: rootMapped.column
|
|
820
|
-
};
|
|
821
|
-
const rbac = input.rbac ? rbacScaffold(policy, {
|
|
822
|
-
schema,
|
|
823
|
-
authorize,
|
|
824
|
-
...rootTable === void 0 ? {} : { memberships: rootTable },
|
|
825
|
-
...ctx.scopes[0] === void 0 ? {} : { scope: ctx.scopes[0].name },
|
|
826
|
-
...ctx.customRoles === void 0 ? {} : { customRoles: { declared: ctx.customRoles.declared } },
|
|
827
|
-
context: ctx
|
|
828
|
-
}) : void 0;
|
|
829
|
-
if (rbac !== void 0) warnings.push(...rbac.warnings);
|
|
830
|
-
if (input.rbac) warnings.push("the token hook that writes user_role and memberships comes from permdock supabase hook generate");
|
|
831
|
-
const owned = ownershipSql(ctx);
|
|
832
|
-
const graphed = graphSql(ctx, graph, rls?.tables);
|
|
833
|
-
const breakGlass = breakGlassSql(ctx, breakGlassEntries(policy, rls?.tables));
|
|
834
|
-
const configured = rls?.shims;
|
|
835
|
-
const shimsConfig = typeof configured === "object" ? configured : configured === true || input.shims === true ? {} : void 0;
|
|
836
|
-
if (shimsConfig !== void 0 && rls?.migrate === void 0) return {
|
|
837
|
-
code: 2,
|
|
838
|
-
output: "PermDock CLI: rls generate --shims needs rls.migrate.helpers in permdock.config.ts",
|
|
839
|
-
text: ""
|
|
840
|
-
};
|
|
841
|
-
const shims = shimsConfig === void 0 || rls?.migrate === void 0 ? void 0 : shimsSql(ctx, rls.migrate, shimsConfig, Object.fromEntries(renamedKeys(policy.vocabulary.permissions)));
|
|
842
|
-
const preamble = [
|
|
843
|
-
rbac?.head,
|
|
844
|
-
helpersSql(ctx, compiled.rolePermissions, {
|
|
845
|
-
userRoles: !input.rbac,
|
|
846
|
-
anonExecute: views.some((view) => view.roles.includes("anon")),
|
|
847
|
-
withoutSeeds: splitsPart(input.split, "seeds")
|
|
848
|
-
}),
|
|
849
|
-
owned === "" ? void 0 : owned,
|
|
850
|
-
graphed === "" ? void 0 : graphed,
|
|
851
|
-
breakGlass === "" ? void 0 : breakGlass,
|
|
852
|
-
shims,
|
|
853
|
-
rbac?.tail
|
|
854
|
-
].filter((part) => part !== void 0).join("\n");
|
|
855
|
-
if (force) for (const entry of graph.values()) {
|
|
856
|
-
const own = [...entry.relations].filter((name) => !("edge" in (entry.node.relations[name] ?? {})));
|
|
857
|
-
if (own.length > 0) warnings.push(`--force: ${entry.node.name} relations ${own.join(", ")} are read from the ${entry.node.name} table itself, so ${graphHelper(entry.node.name)} would recurse through its own policy; keep ${entry.node.name} unforced or move them to edge tables`);
|
|
858
|
-
}
|
|
859
|
-
const outRel = input.out ?? rls?.out ?? defaultOut(input.target);
|
|
860
|
-
const migration = input.target === "sql" ? "" : dialectRoles(migrationSql(policies, preamble, force, views), ctx.dialect);
|
|
861
|
-
const migrationRel = migration === "" ? void 0 : migrationOut(outRel);
|
|
862
|
-
const helpers = migrationRel === void 0 ? void 0 : basename(migrationRel);
|
|
863
|
-
let text;
|
|
864
|
-
switch (input.target) {
|
|
865
|
-
case "sql":
|
|
866
|
-
text = dialectRoles(emitSql(policies, preamble, force, views), ctx.dialect);
|
|
867
|
-
break;
|
|
868
|
-
case "drizzle":
|
|
869
|
-
text = emitDrizzle(policies, compact({
|
|
870
|
-
dialect: ctx.dialect,
|
|
871
|
-
schema: rls?.drizzle?.schema,
|
|
872
|
-
exports: rls?.drizzle?.exports,
|
|
873
|
-
helpers
|
|
874
|
-
}));
|
|
875
|
-
break;
|
|
876
|
-
case "prisma":
|
|
877
|
-
text = emitPrisma(policies, compact({
|
|
878
|
-
models: rls?.prisma?.models,
|
|
879
|
-
helpers
|
|
880
|
-
}));
|
|
881
|
-
break;
|
|
882
|
-
default: return input.target;
|
|
883
|
-
}
|
|
884
|
-
const indexes = indexTargets(ctx, compiled.rowColumns);
|
|
885
|
-
if (!splitsPart(input.split, "indexes")) for (const target of indexes) warnings.push(`index suggestion: create index on ${target.table} (${target.columns.join(", ")}), or add indexes to --split`);
|
|
886
|
-
const extras = migration === "" ? {} : { migration };
|
|
887
|
-
if (input.write === false) return {
|
|
888
|
-
code: 0,
|
|
889
|
-
output: warnings.join("\n"),
|
|
890
|
-
text,
|
|
891
|
-
policies,
|
|
892
|
-
seeds: compiled.rolePermissions,
|
|
893
|
-
indexes,
|
|
894
|
-
keys: {
|
|
895
|
-
permissions: listPermissions(policy.vocabulary.permissions).map((leaf) => leaf.key),
|
|
896
|
-
rowConditions: [...policyRowConditionKeys(policy)],
|
|
897
|
-
renamed: Object.fromEntries(renamedKeys(policy.vocabulary.permissions))
|
|
898
|
-
},
|
|
899
|
-
schema,
|
|
900
|
-
helpersOnly,
|
|
901
|
-
...extras
|
|
902
|
-
};
|
|
903
|
-
const planned = outputFiles({
|
|
904
|
-
input,
|
|
905
|
-
outRel,
|
|
906
|
-
text,
|
|
907
|
-
helpersOnly,
|
|
908
|
-
sql: () => ({
|
|
909
|
-
policies: dialectRoles(emitSql(policies, "", force, views), ctx.dialect),
|
|
910
|
-
helpers: dialectRoles(emitSql([], preamble), ctx.dialect),
|
|
911
|
-
seeds: `${SEEDS_MARKER} schema=${schema}\n${seedSql(ctx, compiled.rolePermissions)}\n`,
|
|
912
|
-
indexes: `${INDEXES_MARKER}\n${indexesSql(indexes)}\n`
|
|
913
|
-
}),
|
|
914
|
-
scopes,
|
|
915
|
-
schema
|
|
916
|
-
});
|
|
917
|
-
if (typeof planned === "string") return {
|
|
918
|
-
code: 2,
|
|
919
|
-
output: planned,
|
|
920
|
-
text,
|
|
921
|
-
...extras
|
|
922
|
-
};
|
|
923
|
-
const files = [...planned];
|
|
924
|
-
if (migrationRel !== void 0) files.push({
|
|
925
|
-
part: "migration",
|
|
926
|
-
rel: migrationRel,
|
|
927
|
-
text: migration
|
|
928
|
-
});
|
|
929
|
-
if (input.check) {
|
|
930
|
-
const drift = driftOf(input.cwd, files);
|
|
931
|
-
return drift.length === 0 ? {
|
|
932
|
-
code: 0,
|
|
933
|
-
output: "rls generate up to date",
|
|
934
|
-
text,
|
|
935
|
-
...extras
|
|
936
|
-
} : {
|
|
937
|
-
code: 1,
|
|
938
|
-
output: drift.map((line) => `rls generate drift: ${line}`).join("\n"),
|
|
939
|
-
text,
|
|
940
|
-
...extras
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
|
-
const written = writeSqlFiles(input.cwd, files);
|
|
944
|
-
const extra = warnings.length === 0 ? "" : `\n${warnings.join("\n")}`;
|
|
945
|
-
return {
|
|
946
|
-
code: 0,
|
|
947
|
-
output: `wrote ${written.wrote.join(", ")}${written.printed === "" ? "" : `\n${written.printed}`}${extra}`,
|
|
948
|
-
text,
|
|
949
|
-
...extras
|
|
950
|
-
};
|
|
951
|
-
}
|
|
952
|
-
/** Whether `--split` names `part`, which then leaves the helpers or the warnings. */
|
|
953
|
-
function splitsPart(raw, part) {
|
|
954
|
-
const split = parseSplit(raw);
|
|
955
|
-
return typeof split !== "string" && split?.includes(part) === true;
|
|
956
|
-
}
|
|
957
|
-
/** The files `generate` writes: one, or one per `--split` part, plus the hook's grants. */
|
|
958
|
-
function outputFiles(plan) {
|
|
959
|
-
const { input } = plan;
|
|
960
|
-
const split = parseSplit(input.split);
|
|
961
|
-
if (typeof split === "string") return split;
|
|
962
|
-
if (split?.includes("policies") === true && plan.helpersOnly) return "rls generate --helpers-only writes no policies part; drop it from --split";
|
|
963
|
-
if (split === void 0) {
|
|
964
|
-
if (input.grantsOut !== void 0) return "rls generate --grants-out needs --split with the hook part: the grants are the token hook's";
|
|
965
|
-
if (input.seedsOut !== void 0) return "rls generate --seeds-out needs --split with the seeds part";
|
|
966
|
-
return [{
|
|
967
|
-
part: "rls",
|
|
968
|
-
rel: plan.outRel,
|
|
969
|
-
text: plan.text
|
|
970
|
-
}];
|
|
971
|
-
}
|
|
972
|
-
if (input.target !== "sql") return "rls generate --split needs --target sql";
|
|
973
|
-
const pgDelta = input.out === void 0 && input.config.rls?.out === void 0 ? supabaseConfig(input.cwd).pgDelta : void 0;
|
|
974
|
-
if (pgDelta === void 0 && !plan.outRel.includes("{part}")) return `rls generate --split needs {part} in --out, for example supabase/schemas/identity/056_permdock_{part}.sql (got ${plan.outRel})`;
|
|
975
|
-
if (pgDelta !== void 0 && split.includes("helpers") && !split.includes("seeds")) return "rls generate --split helpers under pg-delta needs the seeds part with --seeds-out: pg-delta rejects the role_permissions rows in a declarative file";
|
|
976
|
-
if (pgDelta !== void 0 && split.includes("seeds") && input.seedsOut === void 0) return "rls generate --split seeds under pg-delta needs --seeds-out: pg-delta does not diff the role_permissions rows, so they go in a migration";
|
|
977
|
-
const at = (part, schema = plan.schema) => pgDelta === void 0 ? partPath(plan.outRel, part) : pgDeltaPath(pgDelta.schemaDir, part, schema);
|
|
978
|
-
if (input.grantsOut !== void 0 && !split.includes("hook")) return "rls generate --grants-out needs the hook part in --split";
|
|
979
|
-
if (input.seedsOut !== void 0 && !split.includes("seeds")) return "rls generate --seeds-out needs the seeds part in --split";
|
|
980
|
-
if (split.includes("hook") && input.config.supabase?.hook === void 0) return "rls generate --split hook needs supabase.hook in permdock.config.ts";
|
|
981
|
-
const sql = plan.sql();
|
|
982
|
-
const files = [];
|
|
983
|
-
for (const part of split) switch (part) {
|
|
984
|
-
case "helpers":
|
|
985
|
-
files.push({
|
|
986
|
-
part,
|
|
987
|
-
rel: at(part),
|
|
988
|
-
text: sql.helpers
|
|
989
|
-
});
|
|
990
|
-
break;
|
|
991
|
-
case "seeds":
|
|
992
|
-
files.push({
|
|
993
|
-
part,
|
|
994
|
-
rel: input.seedsOut ?? at(part),
|
|
995
|
-
text: sql.seeds
|
|
996
|
-
});
|
|
997
|
-
break;
|
|
998
|
-
case "indexes":
|
|
999
|
-
files.push({
|
|
1000
|
-
part,
|
|
1001
|
-
rel: at(part),
|
|
1002
|
-
text: sql.indexes
|
|
1003
|
-
});
|
|
1004
|
-
break;
|
|
1005
|
-
case "policies":
|
|
1006
|
-
files.push({
|
|
1007
|
-
part,
|
|
1008
|
-
rel: at(part),
|
|
1009
|
-
text: sql.policies
|
|
1010
|
-
});
|
|
1011
|
-
break;
|
|
1012
|
-
case "hook": {
|
|
1013
|
-
const hook = supabaseHookSql(plan.scopes, input.config, {}, grantsLabel(input.grantsOut));
|
|
1014
|
-
files.push({
|
|
1015
|
-
part,
|
|
1016
|
-
rel: at(part, hook.manifest.hook.schema),
|
|
1017
|
-
text: hook.sql
|
|
1018
|
-
});
|
|
1019
|
-
if (input.grantsOut !== void 0) files.push({
|
|
1020
|
-
part: "grants",
|
|
1021
|
-
rel: input.grantsOut,
|
|
1022
|
-
text: hook.grants
|
|
1023
|
-
});
|
|
1024
|
-
break;
|
|
1025
|
-
}
|
|
1026
|
-
default: return part;
|
|
1027
|
-
}
|
|
1028
|
-
return files;
|
|
1029
|
-
}
|
|
1030
|
-
//#endregion
|
|
1031
|
-
//#region src/cli/rls-introspect.ts
|
|
1032
|
-
const ROLES = ["anon", "authenticated"];
|
|
1033
|
-
/** Neon's `anonymous` is the role `rls generate` writes as `anon` for the other dialects. */
|
|
1034
|
-
function liveRole(role) {
|
|
1035
|
-
return role === "anonymous" ? "anon" : role;
|
|
1036
|
-
}
|
|
1037
|
-
const PRIVILEGES = [
|
|
1038
|
-
"select",
|
|
1039
|
-
"insert",
|
|
1040
|
-
"update",
|
|
1041
|
-
"delete"
|
|
1042
|
-
];
|
|
1043
|
-
/** `posts` and `"app"."posts"` as `public.posts` and `app.posts`. */
|
|
1044
|
-
function qualified(table) {
|
|
1045
|
-
const parts = table.split(".").map((part) => part.replaceAll("\"", ""));
|
|
1046
|
-
return parts.length === 1 ? `public.${parts[0] ?? ""}` : parts.join(".");
|
|
1047
|
-
}
|
|
1048
|
-
const FUNCTION_HEADER = /create or replace function ("?)([A-Za-z_][\w$]*)\1\.("?)([A-Za-z_][\w$]*)\3\s*\(([\s\S]*?)\nas \$/gu;
|
|
1049
|
-
function helpersOf(sql) {
|
|
1050
|
-
const found = /* @__PURE__ */ new Set();
|
|
1051
|
-
for (const match of sql.matchAll(FUNCTION_HEADER)) {
|
|
1052
|
-
const [header = "", , schema = "", , name = ""] = match;
|
|
1053
|
-
if (/\bsecurity definer\b/u.test(header)) found.add(`${schema}.${name}`);
|
|
1054
|
-
}
|
|
1055
|
-
return [...found];
|
|
1056
|
-
}
|
|
1057
|
-
function expectedRls(policies, sql, indexes = []) {
|
|
1058
|
-
const tables = [...new Set(policies.map((item) => qualified(item.table)))];
|
|
1059
|
-
const grants = {};
|
|
1060
|
-
for (const table of tables) {
|
|
1061
|
-
const byRole = {};
|
|
1062
|
-
for (const role of ROLES) {
|
|
1063
|
-
const commands = new Set(policies.filter((item) => qualified(item.table) === table && item.effect === "allow" && item.roles.includes(role)).map((item) => item.command));
|
|
1064
|
-
byRole[role] = PRIVILEGES.filter((privilege) => commands.has(privilege));
|
|
1065
|
-
}
|
|
1066
|
-
grants[table] = byRole;
|
|
1067
|
-
}
|
|
1068
|
-
return {
|
|
1069
|
-
policies: policies.map((item) => ({
|
|
1070
|
-
table: qualified(item.table),
|
|
1071
|
-
name: item.name,
|
|
1072
|
-
command: item.command,
|
|
1073
|
-
permissive: item.effect === "allow",
|
|
1074
|
-
roles: item.roles.toSorted()
|
|
1075
|
-
})),
|
|
1076
|
-
tables,
|
|
1077
|
-
grants,
|
|
1078
|
-
helpers: helpersOf(sql),
|
|
1079
|
-
indexes: indexes.map((target) => ({
|
|
1080
|
-
...target,
|
|
1081
|
-
table: qualified(target.table)
|
|
1082
|
-
}))
|
|
1083
|
-
};
|
|
1084
|
-
}
|
|
1085
|
-
/**
|
|
1086
|
-
* One warning per index target no index starts with. A missing index slows
|
|
1087
|
-
* the policy down but grants nothing, so these do not fail `verify`.
|
|
1088
|
-
*/
|
|
1089
|
-
function missingIndexes(expected, actual) {
|
|
1090
|
-
return expected.indexes.flatMap((target) => {
|
|
1091
|
-
const column = target.columns[0];
|
|
1092
|
-
return column === void 0 || actual.leadingColumns[target.table]?.includes(column) === true ? [] : [`warning: ${target.table}: no index starts with ${column}, which the policies or helpers filter on; add it, or write it with rls generate --split ...,indexes`];
|
|
1093
|
-
});
|
|
1094
|
-
}
|
|
1095
|
-
function sameList(left, right) {
|
|
1096
|
-
const a = left.toSorted();
|
|
1097
|
-
const b = right.toSorted();
|
|
1098
|
-
return a.length === b.length && a.every((item, index) => item === b[index]);
|
|
1099
|
-
}
|
|
1100
|
-
/** One line per difference; policy expressions are not compared, since Postgres rewrites their text. */
|
|
1101
|
-
function diffRls(expected, actual, options = {}) {
|
|
1102
|
-
const out = [];
|
|
1103
|
-
const found = new Map(actual.policies.map((item) => [`${item.table}\u0000${item.name}`, item]));
|
|
1104
|
-
for (const want of expected.policies) {
|
|
1105
|
-
const have = found.get(`${want.table}\u0000${want.name}`);
|
|
1106
|
-
if (have === void 0) {
|
|
1107
|
-
out.push(`${want.table}: policy ${want.name} is missing`);
|
|
1108
|
-
continue;
|
|
1109
|
-
}
|
|
1110
|
-
if (have.command !== want.command) out.push(`${want.table}: policy ${want.name} is for ${have.command}, expected ${want.command}`);
|
|
1111
|
-
if (have.permissive !== want.permissive) out.push(`${want.table}: policy ${want.name} is ${have.permissive ? "permissive" : "restrictive"}, expected ${want.permissive ? "permissive" : "restrictive"}`);
|
|
1112
|
-
if (!sameList(have.roles, want.roles)) out.push(`${want.table}: policy ${want.name} applies to ${have.roles.join(", ")}, expected ${want.roles.join(", ")}`);
|
|
1113
|
-
}
|
|
1114
|
-
const wanted = new Set(expected.policies.map((item) => `${item.table}\u0000${item.name}`));
|
|
1115
|
-
for (const have of actual.policies) if (expected.tables.includes(have.table) && !wanted.has(`${have.table}\u0000${have.name}`)) out.push(`${have.table}: policy ${have.name} is not generated (${have.permissive ? "permissive" : "restrictive"} ${have.command}); a permissive one widens access`);
|
|
1116
|
-
for (const table of expected.tables) {
|
|
1117
|
-
if (actual.rlsEnabled[table] !== true) {
|
|
1118
|
-
out.push(actual.rlsEnabled[table] === void 0 ? `${table}: table is missing` : `${table}: row level security is disabled`);
|
|
1119
|
-
continue;
|
|
1120
|
-
}
|
|
1121
|
-
for (const role of ROLES) {
|
|
1122
|
-
const want = expected.grants[table]?.[role] ?? [];
|
|
1123
|
-
const have = actual.grants[table]?.[role] ?? [];
|
|
1124
|
-
for (const privilege of want) if (!have.includes(privilege) && !(privilege === "select" && options.columnGrants === true)) out.push(`${table}: ${role} lacks ${privilege}, so the policy answers 42501 instead of filtering`);
|
|
1125
|
-
for (const privilege of have) if (!want.includes(privilege)) out.push(`${table}: ${role} holds ${privilege}, which no generated policy allows`);
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
for (const helper of expected.helpers) {
|
|
1129
|
-
const have = actual.helpers[helper];
|
|
1130
|
-
if (have === void 0) {
|
|
1131
|
-
out.push(`${helper}: helper is missing`);
|
|
1132
|
-
continue;
|
|
1133
|
-
}
|
|
1134
|
-
if (!have.securityDefiner) out.push(`${helper}: helper is not security definer`);
|
|
1135
|
-
if (!have.emptySearchPath) out.push(`${helper}: helper does not set search_path = ''`);
|
|
1136
|
-
}
|
|
1137
|
-
return out;
|
|
1138
|
-
}
|
|
1139
|
-
const POLICIES_SQL = `select schemaname || '.' || tablename as target, policyname, cmd, permissive, roles::text[] as roles
|
|
1140
|
-
from pg_policies
|
|
1141
|
-
where schemaname || '.' || tablename = any($1::text[])`;
|
|
1142
|
-
const TABLES_SQL = `select n.nspname || '.' || c.relname as target, c.relrowsecurity as enabled
|
|
1143
|
-
from pg_class c
|
|
1144
|
-
join pg_namespace n on n.oid = c.relnamespace
|
|
1145
|
-
where c.relkind in ('r', 'p') and n.nspname || '.' || c.relname = any($1::text[])`;
|
|
1146
|
-
const GRANTS_SQL = `select table_schema || '.' || table_name as target, grantee, lower(privilege_type) as privilege
|
|
1147
|
-
from information_schema.role_table_grants
|
|
1148
|
-
where grantee in ('anon', 'anonymous', 'authenticated')
|
|
1149
|
-
and lower(privilege_type) in ('select', 'insert', 'update', 'delete')
|
|
1150
|
-
and table_schema || '.' || table_name = any($1::text[])`;
|
|
1151
|
-
const HELPERS_SQL = `select n.nspname || '.' || p.proname as target, p.prosecdef as definer,
|
|
1152
|
-
coalesce(array_to_string(p.proconfig, ','), '') as config
|
|
1153
|
-
from pg_proc p
|
|
1154
|
-
join pg_namespace n on n.oid = p.pronamespace
|
|
1155
|
-
where n.nspname || '.' || p.proname = any($1::text[])`;
|
|
1156
|
-
const INDEXES_SQL = `select n.nspname || '.' || c.relname as target, a.attname as leading
|
|
1157
|
-
from pg_index i
|
|
1158
|
-
join pg_class c on c.oid = i.indrelid
|
|
1159
|
-
join pg_namespace n on n.oid = c.relnamespace
|
|
1160
|
-
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = i.indkey[0]
|
|
1161
|
-
where i.indpred is null and n.nspname || '.' || c.relname = any($1::text[])`;
|
|
1162
|
-
function commandOf(cmd) {
|
|
1163
|
-
const text = String(cmd ?? "").toLowerCase();
|
|
1164
|
-
return text === "*" ? "all" : text;
|
|
1165
|
-
}
|
|
1166
|
-
function connectIntrospect(db) {
|
|
1167
|
-
return connectPgPool(db, "permdock rls verify --introspect");
|
|
1168
|
-
}
|
|
1169
|
-
/** Reads the catalogs for the tables and helpers `expected` names (postgres-meta's queries, trimmed). */
|
|
1170
|
-
async function introspectRls(db, expected, connect = connectIntrospect) {
|
|
1171
|
-
const client = await connect(db);
|
|
1172
|
-
try {
|
|
1173
|
-
const tables = [...expected.tables];
|
|
1174
|
-
const [policies, enabled, grants, helpers, indexes] = await Promise.all([
|
|
1175
|
-
client.query(POLICIES_SQL, [tables]),
|
|
1176
|
-
client.query(TABLES_SQL, [tables]),
|
|
1177
|
-
client.query(GRANTS_SQL, [tables]),
|
|
1178
|
-
client.query(HELPERS_SQL, [[...expected.helpers]]),
|
|
1179
|
-
client.query(INDEXES_SQL, [[...new Set(expected.indexes.map((target) => target.table))]])
|
|
1180
|
-
]);
|
|
1181
|
-
const leadingColumns = {};
|
|
1182
|
-
for (const row of indexes.rows) (leadingColumns[String(row["target"])] ??= []).push(String(row["leading"]));
|
|
1183
|
-
const byTable = {};
|
|
1184
|
-
for (const row of grants.rows) {
|
|
1185
|
-
const table = String(row["target"]);
|
|
1186
|
-
const role = liveRole(String(row["grantee"]));
|
|
1187
|
-
const entry = byTable[table] ??= {};
|
|
1188
|
-
(entry[role] ??= []).push(String(row["privilege"]));
|
|
1189
|
-
}
|
|
1190
|
-
return {
|
|
1191
|
-
policies: policies.rows.map((row) => ({
|
|
1192
|
-
table: String(row["target"]),
|
|
1193
|
-
name: String(row["policyname"]),
|
|
1194
|
-
command: commandOf(row["cmd"]),
|
|
1195
|
-
permissive: String(row["permissive"]).toUpperCase() === "PERMISSIVE",
|
|
1196
|
-
roles: Array.isArray(row["roles"]) ? row["roles"].map((role) => liveRole(String(role))) : []
|
|
1197
|
-
})),
|
|
1198
|
-
rlsEnabled: Object.fromEntries(enabled.rows.map((row) => [String(row["target"]), row["enabled"] === true])),
|
|
1199
|
-
grants: byTable,
|
|
1200
|
-
helpers: Object.fromEntries(helpers.rows.map((row) => [String(row["target"]), {
|
|
1201
|
-
securityDefiner: row["definer"] === true,
|
|
1202
|
-
emptySearchPath: String(row["config"]).split(",").some((item) => item === "search_path=\"\"" || item === "search_path=''")
|
|
1203
|
-
}])),
|
|
1204
|
-
leadingColumns
|
|
1205
|
-
};
|
|
1206
|
-
} finally {
|
|
1207
|
-
await client.end();
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
const SYSTEM_SCHEMAS = [
|
|
1211
|
-
"pg_catalog",
|
|
1212
|
-
"information_schema",
|
|
1213
|
-
"pg_toast",
|
|
1214
|
-
"auth",
|
|
1215
|
-
"storage",
|
|
1216
|
-
"realtime",
|
|
1217
|
-
"extensions",
|
|
1218
|
-
"graphql",
|
|
1219
|
-
"graphql_public",
|
|
1220
|
-
"vault",
|
|
1221
|
-
"pgsodium",
|
|
1222
|
-
"pgsodium_masks",
|
|
1223
|
-
"net",
|
|
1224
|
-
"cron",
|
|
1225
|
-
"supabase_functions",
|
|
1226
|
-
"supabase_migrations"
|
|
1227
|
-
];
|
|
1228
|
-
function inScope(table) {
|
|
1229
|
-
const schema = table.split(".")[0] ?? "";
|
|
1230
|
-
return HELPER_TABLES.some((item) => item === table) || !(SYSTEM_SCHEMAS.some((item) => item === schema) || schema.startsWith("pg_"));
|
|
1231
|
-
}
|
|
1232
|
-
function seedLine(row) {
|
|
1233
|
-
return `${row.role} ${row.effect} ${row.grantKey} on ${row.scope}`;
|
|
1234
|
-
}
|
|
1235
|
-
/** Drift is an error; `info` names RLS tables whose policies call no PermDock helper. */
|
|
1236
|
-
function diffMixed(expected, actual) {
|
|
1237
|
-
const drift = [];
|
|
1238
|
-
const want = new Set(expected.seeds.map(seedLine));
|
|
1239
|
-
const have = new Set(actual.seeds.map(seedLine));
|
|
1240
|
-
for (const line of want) if (!have.has(line)) drift.push(`${expected.schema}.role_permissions: missing ${line}`);
|
|
1241
|
-
for (const line of have) if (!want.has(line)) drift.push(`${expected.schema}.role_permissions: unexpected ${line}`);
|
|
1242
|
-
const permissions = new Set(expected.permissions);
|
|
1243
|
-
const conditioned = new Set(expected.rowConditions);
|
|
1244
|
-
const covered = /* @__PURE__ */ new Set();
|
|
1245
|
-
for (const policy of actual.policies) {
|
|
1246
|
-
if (callsHelper(policy.expression)) covered.add(policy.table);
|
|
1247
|
-
for (const key of helperCallKeys(policy.expression)) if (!permissions.has(key)) drift.push(`${policy.table}: policy ${policy.name} passes ${key}, which the policy does not declare, so it always denies`);
|
|
1248
|
-
else if (conditioned.has(key)) drift.push(`${policy.table}: policy ${policy.name} passes ${key}, whose grants carry row conditions the helpers do not check: the policy grants more than the application does`);
|
|
1249
|
-
}
|
|
1250
|
-
return {
|
|
1251
|
-
drift,
|
|
1252
|
-
info: actual.rlsTables.filter((table) => !covered.has(table)).map((table) => `${table}: no policy calls a PermDock helper`)
|
|
1253
|
-
};
|
|
1254
|
-
}
|
|
1255
|
-
const ALL_POLICIES_SQL = `select schemaname || '.' || tablename as target, policyname,
|
|
1256
|
-
coalesce(qual, '') || ' ' || coalesce(with_check, '') as expression
|
|
1257
|
-
from pg_policies`;
|
|
1258
|
-
const RLS_TABLES_SQL = `select n.nspname || '.' || c.relname as target
|
|
1259
|
-
from pg_class c
|
|
1260
|
-
join pg_namespace n on n.oid = c.relnamespace
|
|
1261
|
-
where c.relkind in ('r', 'p') and c.relrowsecurity`;
|
|
1262
|
-
const quoteIdent = escapeSqlIdent;
|
|
1263
|
-
/** Reads `role_permissions`, `pg_policies` and the RLS-enabled tables for {@link diffMixed}. */
|
|
1264
|
-
async function introspectMixed(db, schema, connect = connectIntrospect) {
|
|
1265
|
-
const client = await connect(db);
|
|
1266
|
-
try {
|
|
1267
|
-
const [seeds, policies, tables] = await Promise.all([
|
|
1268
|
-
client.query(`select role, permission, grant_key, scope, effect from ${quoteIdent(schema)}.role_permissions`, []),
|
|
1269
|
-
client.query(ALL_POLICIES_SQL, []),
|
|
1270
|
-
client.query(RLS_TABLES_SQL, [])
|
|
1271
|
-
]);
|
|
1272
|
-
return {
|
|
1273
|
-
seeds: seeds.rows.map((row) => ({
|
|
1274
|
-
role: String(row["role"]),
|
|
1275
|
-
permission: String(row["permission"]),
|
|
1276
|
-
grantKey: String(row["grant_key"]),
|
|
1277
|
-
scope: String(row["scope"]),
|
|
1278
|
-
effect: String(row["effect"]) === "deny" ? "deny" : "allow"
|
|
1279
|
-
})),
|
|
1280
|
-
policies: policies.rows.map((row) => ({
|
|
1281
|
-
table: String(row["target"]),
|
|
1282
|
-
name: String(row["policyname"]),
|
|
1283
|
-
expression: String(row["expression"])
|
|
1284
|
-
})).filter((row) => inScope(row.table)),
|
|
1285
|
-
rlsTables: tables.rows.map((row) => String(row["target"])).filter(inScope).toSorted()
|
|
1286
|
-
};
|
|
1287
|
-
} finally {
|
|
1288
|
-
await client.end();
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
//#endregion
|
|
1292
|
-
//#region src/cli/rls-verify-tree.ts
|
|
1293
|
-
const SUBJECTS = 3;
|
|
1294
|
-
function tableSql(name) {
|
|
1295
|
-
return quoteTable(name.includes(".") ? name : `public.${name}`);
|
|
1296
|
-
}
|
|
1297
|
-
function idFactory(node) {
|
|
1298
|
-
const type = columnTypesOf(jsonSchemaOf(node))[node.id];
|
|
1299
|
-
let next = 9e5;
|
|
1300
|
-
return type === "numeric" ? () => {
|
|
1301
|
-
next += 1;
|
|
1302
|
-
return next;
|
|
1303
|
-
} : () => randomUUID();
|
|
1304
|
-
}
|
|
1305
|
-
/**
|
|
1306
|
-
* Two spines one level deeper than the walk depth, so the depth cap is
|
|
1307
|
-
* crossed; side branches under the first levels alternate restricted, and
|
|
1308
|
-
* the second spine is restricted two levels down.
|
|
1309
|
-
*/
|
|
1310
|
-
function generateTree(node, depth) {
|
|
1311
|
-
const id = idFactory(node);
|
|
1312
|
-
const nodes = [];
|
|
1313
|
-
for (const spine of [0, 1]) {
|
|
1314
|
-
let parent = null;
|
|
1315
|
-
for (let level = 0; level <= depth + 1; level += 1) {
|
|
1316
|
-
const current = id();
|
|
1317
|
-
nodes.push({
|
|
1318
|
-
id: current,
|
|
1319
|
-
parent,
|
|
1320
|
-
restricted: node.restricted !== void 0 && spine === 1 && level === 2
|
|
1321
|
-
});
|
|
1322
|
-
if (spine === 0 && level >= 1 && level <= 3) {
|
|
1323
|
-
const side = id();
|
|
1324
|
-
nodes.push({
|
|
1325
|
-
id: side,
|
|
1326
|
-
parent: current,
|
|
1327
|
-
restricted: node.restricted !== void 0 && level % 2 === 1
|
|
1328
|
-
});
|
|
1329
|
-
nodes.push({
|
|
1330
|
-
id: id(),
|
|
1331
|
-
parent: side,
|
|
1332
|
-
restricted: false
|
|
1333
|
-
});
|
|
1334
|
-
}
|
|
1335
|
-
parent = current;
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
return nodes;
|
|
1339
|
-
}
|
|
1340
|
-
function insertSql(table, rows) {
|
|
1341
|
-
const columns = Object.keys(rows[0] ?? {});
|
|
1342
|
-
const values = [];
|
|
1343
|
-
const tuples = rows.map((row) => `(${columns.map((column) => {
|
|
1344
|
-
values.push(row[column]);
|
|
1345
|
-
return `$${String(values.length)}`;
|
|
1346
|
-
}).join(", ")})`);
|
|
1347
|
-
return {
|
|
1348
|
-
sql: `insert into ${tableSql(table)} (${columns.map(quoteIdent$1).join(", ")}) values ${tuples.join(", ")} returning *`,
|
|
1349
|
-
values
|
|
1350
|
-
};
|
|
1351
|
-
}
|
|
1352
|
-
const REQUIRED_COLUMNS_SQL = `select a.attname as name,
|
|
1353
|
-
format_type(a.atttypid, a.atttypmod) as type,
|
|
1354
|
-
k.confrelid::regclass::text as "refTable",
|
|
1355
|
-
r.attname as "refColumn"
|
|
1356
|
-
from pg_attribute a
|
|
1357
|
-
left join pg_constraint k
|
|
1358
|
-
on k.conrelid = a.attrelid and k.contype = 'f' and k.conkey = array[a.attnum]
|
|
1359
|
-
left join pg_attribute r
|
|
1360
|
-
on r.attrelid = k.confrelid and r.attnum = k.confkey[1]
|
|
1361
|
-
where a.attrelid = $1::regclass and a.attnum > 0 and not a.attisdropped
|
|
1362
|
-
and a.attnotnull and not a.atthasdef
|
|
1363
|
-
and a.attidentity = '' and a.attgenerated = ''`;
|
|
1364
|
-
function placeholder(type) {
|
|
1365
|
-
if (type === "uuid") return randomUUID();
|
|
1366
|
-
if (type === "boolean") return false;
|
|
1367
|
-
if (/^(smallint|integer|bigint|numeric|real|double precision)/u.test(type)) return 0;
|
|
1368
|
-
if (type.startsWith("timestamp") || type === "date") return (/* @__PURE__ */ new Date()).toISOString();
|
|
1369
|
-
if (type === "json" || type === "jsonb") return {};
|
|
1370
|
-
return "permdock-tree";
|
|
1371
|
-
}
|
|
1372
|
-
function asRequiredColumn(row) {
|
|
1373
|
-
const { name, type, refTable, refColumn } = row;
|
|
1374
|
-
if (typeof name !== "string" || typeof type !== "string") return;
|
|
1375
|
-
return {
|
|
1376
|
-
name,
|
|
1377
|
-
type,
|
|
1378
|
-
refTable: typeof refTable === "string" ? refTable : null,
|
|
1379
|
-
refColumn: typeof refColumn === "string" ? refColumn : null
|
|
1380
|
-
};
|
|
1381
|
-
}
|
|
1382
|
-
/**
|
|
1383
|
-
* Values for the required columns a generated row leaves out, such as a
|
|
1384
|
-
* tenant column or a name: an existing value of the referenced table for a
|
|
1385
|
-
* foreign key, a placeholder of the column's type otherwise.
|
|
1386
|
-
*/
|
|
1387
|
-
async function requiredValues(query, table, set) {
|
|
1388
|
-
const columns = (await query(REQUIRED_COLUMNS_SQL, [table])).rows.map(asRequiredColumn).filter((column) => column !== void 0 && !set.has(column.name));
|
|
1389
|
-
const values = {};
|
|
1390
|
-
for (const column of columns) if (column.refTable !== null && column.refColumn !== null) {
|
|
1391
|
-
const existing = await query(`select ${quoteIdent$1(column.refColumn)} as value from ${column.refTable} limit 1`);
|
|
1392
|
-
values[column.name] = existing.rows[0]?.["value"] ?? null;
|
|
1393
|
-
} else values[column.name] = placeholder(column.type);
|
|
1394
|
-
return values;
|
|
1395
|
-
}
|
|
1396
|
-
/** Inserts `rows` after filling the required columns they leave out. */
|
|
1397
|
-
async function seedRows(query, table, rows) {
|
|
1398
|
-
const fill = await requiredValues(query, tableSql(table), new Set(Object.keys(rows[0] ?? {})));
|
|
1399
|
-
const inserted = insertSql(table, rows.map((row) => ({
|
|
1400
|
-
...fill,
|
|
1401
|
-
...row
|
|
1402
|
-
})));
|
|
1403
|
-
return (await query(inserted.sql, inserted.values)).rows;
|
|
1404
|
-
}
|
|
1405
|
-
/**
|
|
1406
|
-
* Parity between `decide` and RLS on a generated object graph: for every
|
|
1407
|
-
* self-parented resource a graph grant walks, a tree with restricted
|
|
1408
|
-
* branches and a chain past the walk depth, child rows under it, and edges
|
|
1409
|
-
* for a few generated principals. Everything runs in one transaction that
|
|
1410
|
-
* is rolled back.
|
|
1411
|
-
*/
|
|
1412
|
-
async function verifyTree(input) {
|
|
1413
|
-
const { policy, query } = input;
|
|
1414
|
-
const tables = input.config.rls?.tables;
|
|
1415
|
-
const walked = [...graphPlan(policy).values()].filter((entry) => entry.closure !== void 0);
|
|
1416
|
-
if (walked.length === 0) return {
|
|
1417
|
-
checked: 0,
|
|
1418
|
-
granted: 0,
|
|
1419
|
-
mismatches: [],
|
|
1420
|
-
notes: ["no graph grant walks a self-parented resource"]
|
|
1421
|
-
};
|
|
1422
|
-
const subjects = Array.from({ length: SUBJECTS }, () => randomUUID());
|
|
1423
|
-
const rows = {};
|
|
1424
|
-
const edges = {};
|
|
1425
|
-
const now = Date.now();
|
|
1426
|
-
for (const entry of walked) {
|
|
1427
|
-
const node = entry.node;
|
|
1428
|
-
const parent = node.parent;
|
|
1429
|
-
if (parent === void 0) continue;
|
|
1430
|
-
const tree = generateTree(node, entry.closure ?? 0);
|
|
1431
|
-
const seeded = tree.map((item, index) => {
|
|
1432
|
-
const row = {
|
|
1433
|
-
[node.id]: item.id,
|
|
1434
|
-
[parent.field]: item.parent
|
|
1435
|
-
};
|
|
1436
|
-
if (node.restricted !== void 0) row[node.restricted] = item.restricted;
|
|
1437
|
-
for (const name of entry.relations) {
|
|
1438
|
-
const spec = node.relations[name];
|
|
1439
|
-
const column = isFieldRelation(spec) ? spec.memberOf === void 0 ? spec.field : void 0 : isPrincipalRelation(spec) ? spec.principal : void 0;
|
|
1440
|
-
if (column !== void 0 && column !== parent.field) row[column] = index % 4 === 0 ? subjects[index % SUBJECTS] : randomUUID();
|
|
1441
|
-
}
|
|
1442
|
-
return row;
|
|
1443
|
-
});
|
|
1444
|
-
if ([...entry.relations].some((name) => {
|
|
1445
|
-
const spec = node.relations[name];
|
|
1446
|
-
return isPrincipalRelation(spec) && spec.principal === parent.field || isFieldRelation(spec) && spec.field === parent.field;
|
|
1447
|
-
})) subjects.push(...tree.filter((_, index) => index % 3 === 0).map((item) => String(item.id)));
|
|
1448
|
-
rows[node.name] = [...await seedRows(query, tables?.[node.name] ?? node.name, seeded)];
|
|
1449
|
-
for (const name of entry.relations) {
|
|
1450
|
-
const spec = node.relations[name];
|
|
1451
|
-
if (!isEdgeRelation(spec)) continue;
|
|
1452
|
-
const object = spec.object ?? `${node.name}_id`;
|
|
1453
|
-
const subject = spec.subject ?? "user_id";
|
|
1454
|
-
const list = [];
|
|
1455
|
-
const edgeRows = [];
|
|
1456
|
-
for (const [index, item] of tree.entries()) {
|
|
1457
|
-
const holder = subjects[index % SUBJECTS];
|
|
1458
|
-
if (holder === void 0 || index % 5 !== 1) continue;
|
|
1459
|
-
const expiresAt = spec.expiresAt !== void 0 && index % 10 === 6 ? /* @__PURE__ */ new Date(now - 864e5) : void 0;
|
|
1460
|
-
edgeRows.push({
|
|
1461
|
-
[object]: item.id,
|
|
1462
|
-
[subject]: holder,
|
|
1463
|
-
...spec.expiresAt === void 0 ? {} : { [spec.expiresAt]: expiresAt ?? null }
|
|
1464
|
-
});
|
|
1465
|
-
list.push({
|
|
1466
|
-
id: String(item.id),
|
|
1467
|
-
principal: holder,
|
|
1468
|
-
...expiresAt === void 0 ? {} : { expiresAt: expiresAt.getTime() / 1e3 }
|
|
1469
|
-
});
|
|
1470
|
-
}
|
|
1471
|
-
if (edgeRows.length > 0) await seedRows(query, spec.edge, edgeRows);
|
|
1472
|
-
edges[node.name] = {
|
|
1473
|
-
...edges[node.name],
|
|
1474
|
-
[name]: list
|
|
1475
|
-
};
|
|
1476
|
-
}
|
|
1477
|
-
for (const child of policy.resources.values()) {
|
|
1478
|
-
if (child.name === node.name || child.parent?.resource !== node.name) continue;
|
|
1479
|
-
const make = idFactory(child);
|
|
1480
|
-
const childRows = tree.map((item, index) => {
|
|
1481
|
-
const row = {
|
|
1482
|
-
[child.id]: make(),
|
|
1483
|
-
[child.parent?.field ?? ""]: item.id
|
|
1484
|
-
};
|
|
1485
|
-
if (child.restricted !== void 0) row[child.restricted] = index % 7 === 3;
|
|
1486
|
-
return row;
|
|
1487
|
-
});
|
|
1488
|
-
rows[child.name] = [...await seedRows(query, tables?.[child.name] ?? child.name, childRows)];
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
const relations = memoryRelations(policy.permissions, {
|
|
1492
|
-
rows,
|
|
1493
|
-
edges
|
|
1494
|
-
});
|
|
1495
|
-
const mismatches = [];
|
|
1496
|
-
let checked = 0;
|
|
1497
|
-
let granted = 0;
|
|
1498
|
-
const reads = listPermissions(policy.permissions).filter((leaf) => leaf.kind === "instance" && commandFor(leaf.action, input.config.rls?.actions) === "select" && rows[leaf.resource] !== void 0);
|
|
1499
|
-
for (const subject of subjects) {
|
|
1500
|
-
const permdock = await createPermDock(policy, {
|
|
1501
|
-
principal: {
|
|
1502
|
-
id: subject,
|
|
1503
|
-
roles: []
|
|
1504
|
-
},
|
|
1505
|
-
context: {}
|
|
1506
|
-
}, { relations });
|
|
1507
|
-
await input.bind(subject);
|
|
1508
|
-
for (const permission of reads) {
|
|
1509
|
-
const node = policy.resources.get(permission.resource);
|
|
1510
|
-
const list = rows[permission.resource] ?? [];
|
|
1511
|
-
if (node === void 0 || list.length === 0) continue;
|
|
1512
|
-
const ids = list.map((row) => String(row[node.id]));
|
|
1513
|
-
const visible = await query(`select ${quoteIdent$1(node.id)}::text as id from ${tableSql(tables?.[node.name] ?? node.name)} where ${quoteIdent$1(node.id)} = any($1)`, [ids]);
|
|
1514
|
-
const seen = new Set(visible.rows.map((row) => String(row["id"])));
|
|
1515
|
-
for (const row of list) {
|
|
1516
|
-
checked += 1;
|
|
1517
|
-
const id = String(row[node.id]);
|
|
1518
|
-
const allowed = permdock.can(permission, row);
|
|
1519
|
-
if (allowed !== seen.has(id)) mismatches.push(`${permission.key} ${id}: in-process ${allowed ? "granted" : "denied"}, database ${seen.has(id) ? "allowed" : "filtered"}`);
|
|
1520
|
-
else if (allowed) granted += 1;
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
}
|
|
1524
|
-
return {
|
|
1525
|
-
checked,
|
|
1526
|
-
granted,
|
|
1527
|
-
mismatches,
|
|
1528
|
-
notes: []
|
|
1529
|
-
};
|
|
1530
|
-
}
|
|
1531
|
-
//#endregion
|
|
1532
|
-
//#region src/cli/rls-verify.ts
|
|
1533
|
-
function isRecord(value) {
|
|
1534
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1535
|
-
}
|
|
1536
|
-
/** A custom role's scope and pinned instance, as the `custom_role_*` tables store them. */
|
|
1537
|
-
function customRoleAt(role, scopes) {
|
|
1538
|
-
return {
|
|
1539
|
-
scope: customRoleScope(role, scopes),
|
|
1540
|
-
id: role.scope === void 0 ? role.team : role.id
|
|
1541
|
-
};
|
|
1542
|
-
}
|
|
1543
|
-
function canFixture(permdock, permission, row) {
|
|
1544
|
-
if (permission.kind === "collection") return permdock.can(permission, row);
|
|
1545
|
-
return permdock.can(permission, row);
|
|
1546
|
-
}
|
|
1547
|
-
function grantKind(policy, key) {
|
|
1548
|
-
const grants = policy.grants.filter((grant) => grant.permission.key === key);
|
|
1549
|
-
if (grants.some((grant) => hasConditionOp(grant.where, "opaque") || hasConditionOp(grant.check, "opaque"))) return "opaque";
|
|
1550
|
-
if (grants.some((grant) => hasConditionOp(grant.where, "sqlFunction") || hasConditionOp(grant.check, "sqlFunction"))) return "sqlFunction";
|
|
1551
|
-
return "portable";
|
|
1552
|
-
}
|
|
1553
|
-
function columnsOf(row) {
|
|
1554
|
-
return isRecord(row) ? Object.entries(row).filter(([, value]) => value !== void 0 && typeof value !== "object") : [];
|
|
1555
|
-
}
|
|
1556
|
-
/**
|
|
1557
|
-
* The statement a fixture runs: `create` inserts the whole row, `update` writes
|
|
1558
|
-
* `newRow` when given. Each reads back only the key, so `--revoke-columns`
|
|
1559
|
-
* (which keeps only unrestricted columns readable) does not reject it.
|
|
1560
|
-
*/
|
|
1561
|
-
function statementFor(fixture, action, table) {
|
|
1562
|
-
const quoted = quoteIdent$1(table);
|
|
1563
|
-
const id = quoteIdent$1("id");
|
|
1564
|
-
const key = rowId(fixture.row);
|
|
1565
|
-
switch (action) {
|
|
1566
|
-
case "read":
|
|
1567
|
-
case "list":
|
|
1568
|
-
case "get": return {
|
|
1569
|
-
sql: `select ${id} from ${quoted} where ${id} = $1`,
|
|
1570
|
-
values: [key]
|
|
1571
|
-
};
|
|
1572
|
-
case "update": {
|
|
1573
|
-
const next = columnsOf(fixture.newRow).filter(([name]) => name !== "id");
|
|
1574
|
-
return {
|
|
1575
|
-
sql: `update ${quoted} set ${next.length === 0 ? `${id} = ${id}` : next.map(([name], index) => `${quoteIdent$1(name)} = $${index + 2}`).join(", ")} where ${id} = $1 returning ${id}`,
|
|
1576
|
-
values: [key, ...next.map(([, value]) => value)]
|
|
1577
|
-
};
|
|
1578
|
-
}
|
|
1579
|
-
case "create": {
|
|
1580
|
-
const cols = columnsOf(fixture.row);
|
|
1581
|
-
if (cols.length === 0) return {
|
|
1582
|
-
sql: `insert into ${quoted} (${id}) values ($1) returning ${id}`,
|
|
1583
|
-
values: [key]
|
|
1584
|
-
};
|
|
1585
|
-
return {
|
|
1586
|
-
sql: `insert into ${quoted} (${cols.map(([name]) => quoteIdent$1(name)).join(", ")}) values (${cols.map((_, index) => `$${index + 1}`).join(", ")}) returning ${id}`,
|
|
1587
|
-
values: cols.map(([, value]) => value)
|
|
1588
|
-
};
|
|
1589
|
-
}
|
|
1590
|
-
case "delete": return {
|
|
1591
|
-
sql: `delete from ${quoted} where ${id} = $1 returning ${id}`,
|
|
1592
|
-
values: [key]
|
|
1593
|
-
};
|
|
1594
|
-
default: return {
|
|
1595
|
-
sql: `select ${id} from ${quoted} where ${id} = $1`,
|
|
1596
|
-
values: [key]
|
|
1597
|
-
};
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
function rowId(row) {
|
|
1601
|
-
if (isRecord(row) && "id" in row) return row["id"];
|
|
1602
|
-
}
|
|
1603
|
-
function emitPgtap(fixtures, customRoles, scopes) {
|
|
1604
|
-
const lines = [
|
|
1605
|
-
"begin;",
|
|
1606
|
-
`select plan(${fixtures.length});`,
|
|
1607
|
-
"-- fixtures carry memberships and tenant for the exists join"
|
|
1608
|
-
];
|
|
1609
|
-
for (const [index, fixture] of fixtures.entries()) {
|
|
1610
|
-
const claims = JSON.stringify({
|
|
1611
|
-
sub: fixture.subject.id,
|
|
1612
|
-
role: "authenticated",
|
|
1613
|
-
tenant_id: fixture.subject.tenant ?? null,
|
|
1614
|
-
memberships: membershipsClaim(fixture.subject.memberships ?? [], customRoles, scopes)
|
|
1615
|
-
});
|
|
1616
|
-
lines.push(`-- ${fixture.action}`, `select set_config('request.jwt.claims', ${JSON.stringify(claims)}, true);`, `select set_config('request.jwt.claim.sub', ${JSON.stringify(fixture.subject.id)}, true);`, `select ok(true, 'fixture ${index} ${fixture.action}');`);
|
|
1617
|
-
}
|
|
1618
|
-
lines.push("select * from finish();", "rollback;");
|
|
1619
|
-
return `${lines.join("\n")}\n`;
|
|
1620
|
-
}
|
|
1621
|
-
/** Columns of `row` that hold a value; a masked column reads as null. */
|
|
1622
|
-
function valuedColumns(row) {
|
|
1623
|
-
return isRecord(row) ? Object.keys(row).filter((name) => row[name] !== null && row[name] !== void 0).toSorted() : [];
|
|
1624
|
-
}
|
|
1625
|
-
/**
|
|
1626
|
-
* What a field view returns for a row: nothing for a denied row, otherwise
|
|
1627
|
-
* `pick`'s columns with a value plus the key, which always passes through.
|
|
1628
|
-
*/
|
|
1629
|
-
function expectedFields(granted, picked, key, row) {
|
|
1630
|
-
if (!granted) return [];
|
|
1631
|
-
const kept = new Set(valuedColumns(picked));
|
|
1632
|
-
if (isRecord(row) && row[key] !== null && row[key] !== void 0) kept.add(key);
|
|
1633
|
-
return [...kept].toSorted();
|
|
1634
|
-
}
|
|
1635
|
-
function fieldsMismatch(fixture, want, seen) {
|
|
1636
|
-
const kept = want.join(", ");
|
|
1637
|
-
if (typeof seen === "string") return [`${fixture.action}: field view read failed (${seen}), pick keeps [${kept}]`];
|
|
1638
|
-
return seen.join(", ") === kept ? [] : [`${fixture.action} ${String(rowId(fixture.row))}: field view returns [${seen.join(", ")}], pick keeps [${kept}]`];
|
|
1639
|
-
}
|
|
1640
|
-
/** The field view's columns for the fixture's row: the view, or the table itself when no grant limits its fields. */
|
|
1641
|
-
async function viewFields(query, table, key) {
|
|
1642
|
-
const view = quoteIdent$1(viewName(table, FIELD_VIEWS.view));
|
|
1643
|
-
const id = quoteIdent$1("id");
|
|
1644
|
-
await query("savepoint permdock_fields");
|
|
1645
|
-
const result = await query(`select * from ${view} where ${id} = $1`, [key]);
|
|
1646
|
-
if (result.code === void 0) return valuedColumns(result.rows[0]);
|
|
1647
|
-
await query("rollback to savepoint permdock_fields");
|
|
1648
|
-
if (result.code !== "42P01") return result.code;
|
|
1649
|
-
const base = await query(`select * from ${quoteIdent$1(table)} where ${id} = $1`, [key]);
|
|
1650
|
-
return base.code ?? valuedColumns(base.rows[0]);
|
|
1651
|
-
}
|
|
1652
|
-
/** Writes the custom roles into the `database`-mode tables; the fixture transaction rolls them back. */
|
|
1653
|
-
async function seedCustomRoles(query, schema, customRoles, scopes) {
|
|
1654
|
-
const table = (name) => `${quoteIdent$1(schema)}.${quoteIdent$1(name)}`;
|
|
1655
|
-
const write = async (sql, values) => {
|
|
1656
|
-
const result = await query(sql, values);
|
|
1657
|
-
if (result.code !== void 0) throw new Error(`PermDock CLI: rls verify --db could not seed custom roles (${result.code}); connect as a role that owns the custom_role_* tables`);
|
|
1658
|
-
};
|
|
1659
|
-
for (const role of customRoles) {
|
|
1660
|
-
const at = customRoleAt(role, scopes);
|
|
1661
|
-
if (at.scope === void 0) continue;
|
|
1662
|
-
for (const grant of role.grants ?? []) await write(`insert into ${table("custom_role_permissions")} (tenant_id, scope, scope_id, role, permission, effect) values ($1, $2, $3, $4, $5, $6)`, [
|
|
1663
|
-
role.tenant ?? null,
|
|
1664
|
-
at.scope,
|
|
1665
|
-
at.id ?? null,
|
|
1666
|
-
role.name,
|
|
1667
|
-
grant.permission,
|
|
1668
|
-
grant.effect ?? "allow"
|
|
1669
|
-
]);
|
|
1670
|
-
for (const name of role.includes ?? []) await write(`insert into ${table("custom_role_includes")} (tenant_id, scope, scope_id, role, include_role) values ($1, $2, $3, $4, $5)`, [
|
|
1671
|
-
role.tenant ?? null,
|
|
1672
|
-
at.scope,
|
|
1673
|
-
at.id ?? null,
|
|
1674
|
-
role.name,
|
|
1675
|
-
name
|
|
1676
|
-
]);
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
async function bindSubject(query, fixture, dialect, gucPrefix, tenantClaim, roleClaim, customRoles, scopes) {
|
|
1680
|
-
await query("set local role \"authenticated\"");
|
|
1681
|
-
const roles = fixture.subject.roles ?? [];
|
|
1682
|
-
const memberships = membershipsClaim(fixture.subject.memberships ?? [], customRoles, scopes);
|
|
1683
|
-
if (dialect === "guc") {
|
|
1684
|
-
const settings = [
|
|
1685
|
-
[`${gucPrefix}.user_id`, fixture.subject.id],
|
|
1686
|
-
[`${gucPrefix}.${roleClaim}`, roles.join(",")],
|
|
1687
|
-
[`${gucPrefix}.memberships`, JSON.stringify(memberships)]
|
|
1688
|
-
];
|
|
1689
|
-
if (fixture.subject.tenant !== void 0) settings.push([`${gucPrefix}.${tenantClaim}`, fixture.subject.tenant]);
|
|
1690
|
-
for (const [name, value] of settings) await query("select set_config($1, $2, true)", [name, value]);
|
|
1691
|
-
return;
|
|
1692
|
-
}
|
|
1693
|
-
const claims = {
|
|
1694
|
-
sub: fixture.subject.id,
|
|
1695
|
-
role: "authenticated",
|
|
1696
|
-
[roleClaim]: roles.length === 1 ? roles[0] : roles,
|
|
1697
|
-
[tenantClaim]: fixture.subject.tenant,
|
|
1698
|
-
memberships
|
|
1699
|
-
};
|
|
1700
|
-
await query("select set_config($1, $2, true)", ["request.jwt.claims", JSON.stringify(claims)]);
|
|
1701
|
-
await query("select set_config($1, $2, true)", ["request.jwt.claim.sub", fixture.subject.id]);
|
|
1702
|
-
}
|
|
1703
|
-
async function verifyAgainstDatabase(input) {
|
|
1704
|
-
const client = await input.connect(input.db);
|
|
1705
|
-
const dialect = input.config.rls?.dialect ?? "supabase";
|
|
1706
|
-
const gucPrefix = input.config.rls?.gucPrefix ?? "app";
|
|
1707
|
-
const tenantClaim = input.config.rls?.tenantClaim ?? "tenant_id";
|
|
1708
|
-
const roleClaim = input.config.rls?.roleClaim ?? "user_role";
|
|
1709
|
-
const rls = input.config.rls;
|
|
1710
|
-
const seedsTables = rls?.customRoles === true && (rls.authorize ?? rls.rbac?.authorize ?? "jwt") === "database";
|
|
1711
|
-
const schema = rls?.schema ?? rls?.rbac?.schema ?? "permdock";
|
|
1712
|
-
const scopes = scopeList(input.policy.scopes);
|
|
1713
|
-
const query = async (sql, values) => {
|
|
1714
|
-
try {
|
|
1715
|
-
const result = await client.query(sql, values === void 0 ? [] : [...values]);
|
|
1716
|
-
return {
|
|
1717
|
-
rows: result.rows,
|
|
1718
|
-
rowCount: result.rowCount ?? result.rows.length
|
|
1719
|
-
};
|
|
1720
|
-
} catch (cause) {
|
|
1721
|
-
const code = cause !== null && typeof cause === "object" && "code" in cause && typeof cause.code === "string" ? cause.code : void 0;
|
|
1722
|
-
return {
|
|
1723
|
-
rows: [],
|
|
1724
|
-
rowCount: 0,
|
|
1725
|
-
...code === void 0 ? {} : { code }
|
|
1726
|
-
};
|
|
1727
|
-
}
|
|
1728
|
-
};
|
|
1729
|
-
const mismatches = [];
|
|
1730
|
-
const notes = [];
|
|
1731
|
-
try {
|
|
1732
|
-
const conditioned = policyRowConditionKeys(input.policy);
|
|
1733
|
-
for (const found of helperTablePoliciesFromRows((await query(HELPER_TABLE_POLICIES_SQL)).rows)) {
|
|
1734
|
-
const keys = found.keys.filter((key) => conditioned.has(key));
|
|
1735
|
-
if (keys.length > 0) mismatches.push(`PD037 ${rowConditionMessage(found, keys)}`);
|
|
1736
|
-
}
|
|
1737
|
-
for (const [index, fixture] of input.fixtures.entries()) {
|
|
1738
|
-
const permission = findPermission(input.policy.permissions, fixture.action);
|
|
1739
|
-
const status = input.inProcess[index];
|
|
1740
|
-
if (permission === void 0 || status === void 0) continue;
|
|
1741
|
-
if (status.kind === "opaque") {
|
|
1742
|
-
notes.push(`${fixture.action}: opaque grant untestable app-side`);
|
|
1743
|
-
continue;
|
|
1744
|
-
}
|
|
1745
|
-
const table = input.config.rls?.tables?.[permission.resource] ?? permission.resource;
|
|
1746
|
-
await query("begin");
|
|
1747
|
-
try {
|
|
1748
|
-
if (seedsTables) await seedCustomRoles(query, schema, input.customRoles, scopes);
|
|
1749
|
-
await bindSubject(query, fixture, dialect, gucPrefix, tenantClaim, roleClaim, input.customRoles, scopes);
|
|
1750
|
-
const statement = statementFor(fixture, permission.action, table);
|
|
1751
|
-
const result = await query(statement.sql, statement.values);
|
|
1752
|
-
const count = result.rowCount ?? result.rows.length;
|
|
1753
|
-
const database = result.code === "42501" ? "rejected" : count > 0 ? "allowed" : "filtered";
|
|
1754
|
-
if (!(status.granted ? database === "allowed" : database === "filtered" || database === "rejected")) mismatches.push(`${fixture.action}: in-process ${status.granted ? "granted" : "denied"}, database ${database}`);
|
|
1755
|
-
else if (status.kind === "sqlFunction") notes.push(`${fixture.action}: verified through twin`);
|
|
1756
|
-
mismatches.push(...status.fields === void 0 ? [] : fieldsMismatch(fixture, status.fields, await viewFields(query, table, rowId(fixture.row))));
|
|
1757
|
-
} finally {
|
|
1758
|
-
await query("rollback");
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1761
|
-
} finally {
|
|
1762
|
-
await client.end();
|
|
1763
|
-
}
|
|
1764
|
-
return {
|
|
1765
|
-
mismatches,
|
|
1766
|
-
notes
|
|
1767
|
-
};
|
|
1768
|
-
}
|
|
1769
|
-
/** `--tree`: seeds a generated object graph in a rolled-back transaction and compares `can()` with RLS. */
|
|
1770
|
-
async function verifyTreeAgainstDatabase(db, policy, config, connect) {
|
|
1771
|
-
const client = await connect(db);
|
|
1772
|
-
const scopes = scopeList(policy.scopes);
|
|
1773
|
-
const query = async (sql, values) => {
|
|
1774
|
-
return { rows: (await client.query(sql, values === void 0 ? [] : [...values])).rows };
|
|
1775
|
-
};
|
|
1776
|
-
const loose = async (sql, values) => {
|
|
1777
|
-
await query(sql, values);
|
|
1778
|
-
return { rows: [] };
|
|
1779
|
-
};
|
|
1780
|
-
try {
|
|
1781
|
-
await query("begin");
|
|
1782
|
-
const result = await verifyTree({
|
|
1783
|
-
policy,
|
|
1784
|
-
config,
|
|
1785
|
-
query,
|
|
1786
|
-
bind: (subject) => bindSubject(loose, {
|
|
1787
|
-
subject: { id: subject },
|
|
1788
|
-
row: {},
|
|
1789
|
-
action: "read"
|
|
1790
|
-
}, config.rls?.dialect ?? "supabase", config.rls?.gucPrefix ?? "app", config.rls?.tenantClaim ?? "tenant_id", config.rls?.roleClaim ?? "user_role", [], scopes)
|
|
1791
|
-
});
|
|
1792
|
-
if (result.mismatches.length > 0) return {
|
|
1793
|
-
code: 1,
|
|
1794
|
-
output: [...result.mismatches, ...result.notes].join("\n")
|
|
1795
|
-
};
|
|
1796
|
-
const verified = `verified ${String(result.checked)} tree check(s) against the database (${String(result.granted)} granted)`;
|
|
1797
|
-
return {
|
|
1798
|
-
code: 0,
|
|
1799
|
-
output: result.notes.length === 0 ? verified : `${verified}\n${result.notes.join("\n")}`
|
|
1800
|
-
};
|
|
1801
|
-
} catch (cause) {
|
|
1802
|
-
return {
|
|
1803
|
-
code: 2,
|
|
1804
|
-
output: `PermDock CLI: rls verify --tree could not seed the tree (${cause instanceof Error ? cause.message : String(cause)}); connect as a role that owns the tables and is a member of authenticated`
|
|
1805
|
-
};
|
|
1806
|
-
} finally {
|
|
1807
|
-
await client.query("rollback", []).catch(() => void 0);
|
|
1808
|
-
await client.end();
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
async function runRlsVerify(input) {
|
|
1812
|
-
const connect = input.connect ?? ((db) => connectPg(db, "permdock rls verify --db"));
|
|
1813
|
-
const policyPath = input.from ?? input.config.policy;
|
|
1814
|
-
if (policyPath === void 0) return {
|
|
1815
|
-
code: 2,
|
|
1816
|
-
output: "PermDock CLI: rls verify needs policy in the config or --from"
|
|
1817
|
-
};
|
|
1818
|
-
const policy = asPolicy(pickNamed(await loadModule(resolve(input.cwd, policyPath)), ["policy"]));
|
|
1819
|
-
if (input.tree === true) {
|
|
1820
|
-
if (input.db === void 0) return {
|
|
1821
|
-
code: 2,
|
|
1822
|
-
output: "PermDock CLI: rls verify --tree needs --db"
|
|
1823
|
-
};
|
|
1824
|
-
try {
|
|
1825
|
-
return await verifyTreeAgainstDatabase(input.db, policy, input.config, connect);
|
|
1826
|
-
} catch (cause) {
|
|
1827
|
-
return usageResult(cause);
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
const fixturesPath = input.fixtures ?? input.config.rls?.fixtures ?? "rls.fixtures.json";
|
|
1831
|
-
const { fixtures, customRoles } = await loadFixtures(input.cwd, fixturesPath);
|
|
1832
|
-
if (input.format === "pgtap") return {
|
|
1833
|
-
code: 0,
|
|
1834
|
-
output: emitPgtap(fixtures, customRoles, scopeList(policy.scopes))
|
|
1835
|
-
};
|
|
1836
|
-
const mismatches = [];
|
|
1837
|
-
const notes = [];
|
|
1838
|
-
const inProcess = [];
|
|
1839
|
-
const fieldsMode = input.config.rls?.fields === "views";
|
|
1840
|
-
for (const fixture of fixtures) {
|
|
1841
|
-
const permission = findPermission(policy.permissions, fixture.action);
|
|
1842
|
-
if (permission === void 0) {
|
|
1843
|
-
mismatches.push(`${fixture.action}: unknown permission`);
|
|
1844
|
-
inProcess.push({
|
|
1845
|
-
action: fixture.action,
|
|
1846
|
-
granted: false,
|
|
1847
|
-
kind: "portable"
|
|
1848
|
-
});
|
|
1849
|
-
continue;
|
|
1850
|
-
}
|
|
1851
|
-
const kind = grantKind(policy, fixture.action);
|
|
1852
|
-
const permdock = await createPermDock(policy, fixtureSubject(fixture.subject), { customRoles: memoryRoleSource(customRoles) });
|
|
1853
|
-
const granted = canFixture(permdock, permission, fixtureRow(fixture, permission.kind));
|
|
1854
|
-
const outcome = granted ? "granted" : "denied";
|
|
1855
|
-
if (fixture.expected !== void 0 && fixture.expected !== outcome) mismatches.push(`${fixture.action}: in-process ${outcome}, expected ${fixture.expected}`);
|
|
1856
|
-
if (kind === "opaque") notes.push(`${fixture.action}: opaque grant untestable app-side`);
|
|
1857
|
-
const fields = fieldsMode && permission.kind === "instance" && commandFor(permission.action, input.config.rls?.actions) === "select" ? expectedFields(granted, permdock.pick(permission, fixture.row), policy.resources.get(permission.resource)?.id ?? "id", fixture.row) : void 0;
|
|
1858
|
-
inProcess.push({
|
|
1859
|
-
action: fixture.action,
|
|
1860
|
-
granted,
|
|
1861
|
-
kind,
|
|
1862
|
-
...fields === void 0 ? {} : { fields }
|
|
1863
|
-
});
|
|
1864
|
-
}
|
|
1865
|
-
if (input.db !== void 0) try {
|
|
1866
|
-
const database = await verifyAgainstDatabase({
|
|
1867
|
-
db: input.db,
|
|
1868
|
-
policy,
|
|
1869
|
-
fixtures,
|
|
1870
|
-
customRoles,
|
|
1871
|
-
config: input.config,
|
|
1872
|
-
inProcess,
|
|
1873
|
-
connect
|
|
1874
|
-
});
|
|
1875
|
-
mismatches.push(...database.mismatches);
|
|
1876
|
-
notes.push(...database.notes);
|
|
1877
|
-
} catch (cause) {
|
|
1878
|
-
return usageResult(cause);
|
|
1879
|
-
}
|
|
1880
|
-
if (mismatches.length > 0) return {
|
|
1881
|
-
code: 1,
|
|
1882
|
-
output: [...mismatches, ...notes].join("\n")
|
|
1883
|
-
};
|
|
1884
|
-
const verified = `verified ${fixtures.length} fixture(s) in-process`;
|
|
1885
|
-
return {
|
|
1886
|
-
code: 0,
|
|
1887
|
-
output: notes.length === 0 ? verified : `${verified}\n${notes.join("\n")}`
|
|
1888
|
-
};
|
|
1889
|
-
}
|
|
1890
|
-
//#endregion
|
|
1891
|
-
//#region src/cli/rls.ts
|
|
1892
|
-
const RLS_HELP = `permdock rls generate | import | verify | migrate
|
|
1893
|
-
|
|
1894
|
-
generate --target drizzle|sql|prisma --dialect supabase|neon|guc
|
|
1895
|
-
[--rbac supabase] [--rbac-schema public] [--authorize database|jwt]
|
|
1896
|
-
[--memberships <table>:tenant,user,role]
|
|
1897
|
-
[--policy-per-role] [--policy-name '{table}_{op}'] [--tenant-type uuid] [--custom-roles]
|
|
1898
|
-
[--capabilities] [--fields views [--revoke-columns]]
|
|
1899
|
-
[--out <path>] [--check] [--skip-closures] [--inline-functions] [--force] [--guc-prefix app]
|
|
1900
|
-
[--split helpers,seeds,policies,hook --out <dir>/056_permdock_{part}.sql]
|
|
1901
|
-
[--grants-out <file>|-] [--seeds-out <file>|-]
|
|
1902
|
-
[--helpers-only] [--shims]
|
|
1903
|
-
import --sql schema.sql | --db $DATABASE_URL --out src/permissions.generated.ts
|
|
1904
|
-
[--schema zod|valibot|arktype] [--memberships <table>:tenant,user,role]
|
|
1905
|
-
verify [--db $DATABASE_URL] [--fixtures rls.fixtures.ts] [--format pgtap|node] [--tree]
|
|
1906
|
-
[--introspect --db $DATABASE_URL, with the generate flags]
|
|
1907
|
-
migrate --sql <dir> [--write] [--json], with the generate flags
|
|
1908
|
-
rewrites the rls.migrate helpers' calls in policies onto the generated helpers
|
|
1909
|
-
|
|
1910
|
-
Never emits service_role. memberOf compiles through the dialect memberships mapping.
|
|
1911
|
-
`;
|
|
1912
|
-
function asTarget(value) {
|
|
1913
|
-
if (value === void 0 || value === "sql" || value === "drizzle" || value === "prisma") return value ?? "sql";
|
|
1914
|
-
}
|
|
1915
|
-
function asDialect(value) {
|
|
1916
|
-
if (value === void 0 || value === "supabase" || value === "neon" || value === "guc") return value ?? "supabase";
|
|
1917
|
-
}
|
|
1918
|
-
function generateInput(input, target, dialect) {
|
|
1919
|
-
return {
|
|
1920
|
-
cwd: input.cwd,
|
|
1921
|
-
config: input.config,
|
|
1922
|
-
target,
|
|
1923
|
-
dialect,
|
|
1924
|
-
rbac: input.rbac,
|
|
1925
|
-
...input.rbacSchema === void 0 ? {} : { rbacSchema: input.rbacSchema },
|
|
1926
|
-
...input.authorize === void 0 ? {} : { authorize: parseRbacAuthorize(input.authorize) ?? "database" },
|
|
1927
|
-
check: input.check,
|
|
1928
|
-
skipClosures: input.skipClosures,
|
|
1929
|
-
inlineFunctions: input.inlineFunctions,
|
|
1930
|
-
force: input.force,
|
|
1931
|
-
io: input.io,
|
|
1932
|
-
...input.out === void 0 ? {} : { out: input.out },
|
|
1933
|
-
...input.from === void 0 ? {} : { from: input.from },
|
|
1934
|
-
...input.memberships === void 0 ? {} : { memberships: input.memberships },
|
|
1935
|
-
...input.gucPrefix === void 0 ? {} : { gucPrefix: input.gucPrefix },
|
|
1936
|
-
policyPerRole: input.policyPerRole,
|
|
1937
|
-
...input.policyName === void 0 ? {} : { policyName: input.policyName },
|
|
1938
|
-
...input.tenantType === void 0 ? {} : { tenantType: input.tenantType },
|
|
1939
|
-
customRoles: input.customRoles,
|
|
1940
|
-
capabilities: input.capabilities,
|
|
1941
|
-
...input.fields === void 0 ? {} : { fields: input.fields },
|
|
1942
|
-
revokeColumns: input.revokeColumns,
|
|
1943
|
-
...input.split === void 0 ? {} : { split: input.split },
|
|
1944
|
-
...input.grantsOut === void 0 ? {} : { grantsOut: input.grantsOut },
|
|
1945
|
-
...input.seedsOut === void 0 ? {} : { seedsOut: input.seedsOut },
|
|
1946
|
-
helpersOnly: input.helpersOnly,
|
|
1947
|
-
shims: input.shims === true
|
|
1948
|
-
};
|
|
1949
|
-
}
|
|
1950
|
-
async function migrate(input) {
|
|
1951
|
-
const config = input.config.rls?.migrate;
|
|
1952
|
-
if (config === void 0) return {
|
|
1953
|
-
code: 2,
|
|
1954
|
-
output: "rls migrate needs rls.migrate.helpers in permdock.config.ts"
|
|
1955
|
-
};
|
|
1956
|
-
if (input.sql === void 0) return {
|
|
1957
|
-
code: 2,
|
|
1958
|
-
output: "rls migrate needs --sql <dir>"
|
|
1959
|
-
};
|
|
1960
|
-
const dialect = asDialect(input.dialect ?? input.config.rls?.dialect);
|
|
1961
|
-
if (dialect === void 0) return {
|
|
1962
|
-
code: 2,
|
|
1963
|
-
output: "rls migrate --dialect (or rls.dialect) must be supabase, neon or guc"
|
|
1964
|
-
};
|
|
1965
|
-
const generated = await runRlsGenerate({
|
|
1966
|
-
...generateInput(input, "sql", dialect),
|
|
1967
|
-
check: false,
|
|
1968
|
-
write: false
|
|
1969
|
-
});
|
|
1970
|
-
if (generated.code !== 0) return {
|
|
1971
|
-
code: 2,
|
|
1972
|
-
output: generated.output
|
|
1973
|
-
};
|
|
1974
|
-
await requirePeer(() => import("pgsql-parser"), "pgsql-parser", "permdock rls migrate", input.cwd);
|
|
1975
|
-
const { migrateTarget, runRlsMigrate } = await import("./rls-migrate-DrFPbf8p.js");
|
|
1976
|
-
return runRlsMigrate({
|
|
1977
|
-
cwd: input.cwd,
|
|
1978
|
-
sql: input.sql,
|
|
1979
|
-
config,
|
|
1980
|
-
target: migrateTarget(generated),
|
|
1981
|
-
write: input.write,
|
|
1982
|
-
json: input.json
|
|
1983
|
-
});
|
|
1984
|
-
}
|
|
1985
|
-
/** `verify --introspect` with `--helpers-only`: helpers and seeds exactly, hand-written policies by their keys. */
|
|
1986
|
-
async function introspectHelpersOnly(db, expected, generated, connect) {
|
|
1987
|
-
const schema = generated.schema ?? "permdock";
|
|
1988
|
-
try {
|
|
1989
|
-
const actual = await introspectRls(db, expected, connect);
|
|
1990
|
-
const helpers = diffRls(expected, actual);
|
|
1991
|
-
const warnings = missingIndexes(expected, actual);
|
|
1992
|
-
const mixed = diffMixed({
|
|
1993
|
-
schema,
|
|
1994
|
-
seeds: generated.seeds ?? [],
|
|
1995
|
-
permissions: generated.keys?.permissions ?? [],
|
|
1996
|
-
rowConditions: generated.keys?.rowConditions ?? []
|
|
1997
|
-
}, await introspectMixed(db, schema, connect));
|
|
1998
|
-
const drift = [...helpers, ...mixed.drift];
|
|
1999
|
-
const info = mixed.info.map((line) => `info: ${line}`);
|
|
2000
|
-
return drift.length > 0 ? {
|
|
2001
|
-
code: 1,
|
|
2002
|
-
output: [
|
|
2003
|
-
...drift,
|
|
2004
|
-
...warnings,
|
|
2005
|
-
...info
|
|
2006
|
-
].join("\n")
|
|
2007
|
-
} : {
|
|
2008
|
-
code: 0,
|
|
2009
|
-
output: [
|
|
2010
|
-
...warnings,
|
|
2011
|
-
...info,
|
|
2012
|
-
`introspected ${String(expected.helpers.length)} helper(s) and ${String(generated.seeds?.length ?? 0)} seeded row(s), helpers only: no drift`
|
|
2013
|
-
].join("\n")
|
|
2014
|
-
};
|
|
2015
|
-
} catch (cause) {
|
|
2016
|
-
return usageResult(cause);
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
/** `verify --introspect`: the catalogs against what `generate` would write with the same flags. */
|
|
2020
|
-
async function introspect(input) {
|
|
2021
|
-
if (input.db === void 0) return {
|
|
2022
|
-
code: 2,
|
|
2023
|
-
output: "PermDock CLI: rls verify --introspect needs --db"
|
|
2024
|
-
};
|
|
2025
|
-
const dialect = asDialect(input.dialect ?? input.config.rls?.dialect);
|
|
2026
|
-
if (dialect === void 0) return {
|
|
2027
|
-
code: 2,
|
|
2028
|
-
output: "rls verify --dialect (or rls.dialect) must be supabase, neon or guc"
|
|
2029
|
-
};
|
|
2030
|
-
const generated = await runRlsGenerate({
|
|
2031
|
-
...generateInput(input, "sql", dialect),
|
|
2032
|
-
check: false,
|
|
2033
|
-
write: false
|
|
2034
|
-
});
|
|
2035
|
-
if (generated.code !== 0 || generated.policies === void 0) return {
|
|
2036
|
-
code: 2,
|
|
2037
|
-
output: generated.output
|
|
2038
|
-
};
|
|
2039
|
-
const expected = expectedRls(generated.policies, generated.text, generated.indexes);
|
|
2040
|
-
if (generated.helpersOnly === true) return introspectHelpersOnly(input.db, expected, generated, input.connect);
|
|
2041
|
-
try {
|
|
2042
|
-
const actual = await introspectRls(input.db, expected, input.connect);
|
|
2043
|
-
const drift = diffRls(expected, actual, { columnGrants: (input.fields ?? input.config.rls?.fields) === "views" });
|
|
2044
|
-
const warnings = missingIndexes(expected, actual);
|
|
2045
|
-
if (drift.length > 0) return {
|
|
2046
|
-
code: 1,
|
|
2047
|
-
output: [...drift, ...warnings].join("\n")
|
|
2048
|
-
};
|
|
2049
|
-
return {
|
|
2050
|
-
code: 0,
|
|
2051
|
-
output: `${warnings.map((line) => `${line}\n`).join("")}introspected ${String(expected.policies.length)} policies on ${String(expected.tables.length)} table(s) and ${String(expected.helpers.length)} helper(s): no drift`
|
|
2052
|
-
};
|
|
2053
|
-
} catch (cause) {
|
|
2054
|
-
return usageResult(cause);
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
async function runRls(input) {
|
|
2058
|
-
const action = input.rest[0];
|
|
2059
|
-
if (action === void 0 || action === "help") return {
|
|
2060
|
-
code: 2,
|
|
2061
|
-
output: RLS_HELP
|
|
2062
|
-
};
|
|
2063
|
-
switch (action) {
|
|
2064
|
-
case "generate": {
|
|
2065
|
-
const target = asTarget(input.target);
|
|
2066
|
-
const dialect = asDialect(input.dialect ?? input.config.rls?.dialect);
|
|
2067
|
-
if (target === void 0) return {
|
|
2068
|
-
code: 2,
|
|
2069
|
-
output: "rls generate --target must be drizzle, sql or prisma"
|
|
2070
|
-
};
|
|
2071
|
-
if (dialect === void 0) return {
|
|
2072
|
-
code: 2,
|
|
2073
|
-
output: "rls generate --dialect (or rls.dialect) must be supabase, neon or guc"
|
|
2074
|
-
};
|
|
2075
|
-
return runRlsGenerate(generateInput(input, target, dialect));
|
|
2076
|
-
}
|
|
2077
|
-
case "import": {
|
|
2078
|
-
await requirePeer(() => import("pgsql-parser"), "pgsql-parser", "permdock rls import", input.cwd);
|
|
2079
|
-
const { runRlsImport } = await import("./rls-import-CXV6LBlS.js");
|
|
2080
|
-
return runRlsImport({
|
|
2081
|
-
cwd: input.cwd,
|
|
2082
|
-
config: input.config,
|
|
2083
|
-
schema: input.schema ?? "zod",
|
|
2084
|
-
io: input.io,
|
|
2085
|
-
...input.sql === void 0 ? {} : { sql: input.sql },
|
|
2086
|
-
...input.db === void 0 ? {} : { db: input.db },
|
|
2087
|
-
...input.out === void 0 ? {} : { out: input.out },
|
|
2088
|
-
...input.memberships === void 0 ? {} : { memberships: input.memberships },
|
|
2089
|
-
...input.connect === void 0 ? {} : { connect: input.connect }
|
|
2090
|
-
});
|
|
2091
|
-
}
|
|
2092
|
-
case "verify": {
|
|
2093
|
-
if (input.introspect) return introspect(input);
|
|
2094
|
-
const format = input.format ?? "node";
|
|
2095
|
-
if (format !== "node" && format !== "pgtap") return {
|
|
2096
|
-
code: 2,
|
|
2097
|
-
output: "rls verify --format must be pgtap or node"
|
|
2098
|
-
};
|
|
2099
|
-
return await runRlsVerify({
|
|
2100
|
-
cwd: input.cwd,
|
|
2101
|
-
config: input.config,
|
|
2102
|
-
format,
|
|
2103
|
-
tree: input.tree,
|
|
2104
|
-
io: input.io,
|
|
2105
|
-
...input.fixtures === void 0 ? {} : { fixtures: input.fixtures },
|
|
2106
|
-
...input.db === void 0 ? {} : { db: input.db },
|
|
2107
|
-
...input.from === void 0 ? {} : { from: input.from },
|
|
2108
|
-
...input.connect === void 0 ? {} : { connect: input.connect }
|
|
2109
|
-
});
|
|
2110
|
-
}
|
|
2111
|
-
case "migrate": return migrate(input);
|
|
2112
|
-
default: return {
|
|
2113
|
-
code: 2,
|
|
2114
|
-
output: RLS_HELP
|
|
2115
|
-
};
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2118
|
-
//#endregion
|
|
2119
|
-
export { runRls };
|