permdock 0.3.1 → 0.4.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/dist/a2a/index.d.ts +7 -5
- package/dist/a2a/index.js +14 -21
- package/dist/ai-sdk/index.d.ts +18 -6
- package/dist/ai-sdk/index.js +8 -5
- package/dist/app-data-CV8pqqMi.js +59 -0
- package/dist/approvals/index.d.ts +2 -2
- package/dist/approvals/index.js +3 -3
- package/dist/{approvals-DbYAuNkG.js → approvals-DZL76lKW.js} +35 -19
- package/dist/{approvers-DT4AVyIg.js → approvers-CqPOLD-o.js} +1 -1
- package/dist/{arazzo-BAgw9ap6.js → arazzo-C4BcskbX.js} +6 -5
- package/dist/{arazzo-C9chp3ST.js → arazzo-CD8d0WLb.js} +2 -2
- package/dist/authzen/index.d.ts +3 -3
- package/dist/authzen/index.js +161 -23
- package/dist/better-auth/index.d.ts +2 -2
- package/dist/better-auth/index.js +6 -19
- package/dist/better-supabase/index.d.ts +112 -87
- package/dist/better-supabase/index.js +257 -156
- package/dist/{bind-C_ob3LKa.d.ts → bind-BzqG8SMb.d.ts} +16 -5
- package/dist/{bind-CuzGytRf.js → bind-kk-uyCwH.js} +14 -6
- package/dist/build-instance-DIIV9DbC.js +51 -0
- package/dist/{canonical-json-DemiK4Vp.js → canonical-json-CFKyFOke.js} +1 -3
- package/dist/{capability-2kYTMWDv.d.ts → capability-CksyCWDH.d.ts} +2 -2
- package/dist/{capability-4qPdO9gk.js → capability-txATtKV4.js} +3 -2
- package/dist/catalog/index.d.ts +1 -1
- package/dist/catalog/index.js +1 -1
- package/dist/catalog-6yWAX6Ps.js +2 -0
- package/dist/{catalog-CYj-mX2O.js → catalog-WHG4kBq7.js} +2 -2
- package/dist/{catalog-doc-DdGm82XT.js → catalog-doc-DWtg0gGR.js} +17 -10
- package/dist/{catalog-DWF5cYqB.js → catalog-tZUyOfOv.js} +4 -4
- package/dist/claude-agent/index.d.ts +4 -4
- package/dist/claude-agent/index.js +4 -5
- package/dist/clerk/index.d.ts +3 -3
- package/dist/clerk/index.js +8 -33
- package/dist/cli/bin.js +1 -1
- package/dist/cli/index.d.ts +3 -3
- package/dist/cli/index.js +3 -3
- package/dist/cloud/index.d.ts +4 -4
- package/dist/cloud/index.js +9 -15
- package/dist/{cloud-CfwZ39Rl.js → cloud-C7bSDgX4.js} +2 -2
- package/dist/{cloud-DWLXO2af.js → cloud-PeBN3IEn.js} +5 -5
- package/dist/{collect-DSTE3ml9.js → collect-2SrB7lfk.js} +6 -8
- package/dist/collect-Bw2Jdq1q.js +2 -0
- package/dist/{collect-CHB22Sx4.js → collect-CQx4hX4N.js} +3 -3
- package/dist/{commands-BeaGNj_o.js → commands-Cpip4ULx.js} +13 -13
- package/dist/{compact-y6vfu2_f.js → compact-CAf557ZB.js} +11 -1
- package/dist/compile/index.d.ts +1 -1
- package/dist/compile/index.js +1 -1
- package/dist/{compile-Ba4blROR.js → compile-CcEu2ZQ1.js} +6 -5
- package/dist/{compile-BkRyBDBw.d.ts → compile-CtTP7eur.d.ts} +2 -2
- package/dist/{config-BJiM-xAm.js → config-BIjREpyE.js} +4 -4
- package/dist/{config-ir40y8m0.js → config-DLt6BvV7.js} +2 -2
- package/dist/{config-report-CdCgbRnV.js → config-report--Xb9APJS.js} +5 -6
- package/dist/{connection-CU8MQxRi.js → connection-DUsczpB5.js} +3 -2
- package/dist/constants-DcJABbLW.js +20 -0
- package/dist/{context-Bcy85rGi.js → context-DieZVJXu.js} +16 -5
- package/dist/convex/index.d.ts +4 -4
- package/dist/convex/index.js +14 -16
- package/dist/create-BBO24vj6.js +324 -0
- package/dist/{credential-8P81ff4u.js → credential-kue0qpbm.js} +4 -4
- package/dist/deciding-columns-Sq12mEA6.js +123 -0
- package/dist/delegation-input-FTxMK3ji.js +19 -0
- package/dist/denied-text-DfRk15uw.d.ts +19 -0
- package/dist/{describe-DMYiCjJV.d.ts → describe-C3ldA9os.d.ts} +11 -2
- package/dist/{describe-B_gVZEVi.js → describe-Cgkp7eP0.js} +5 -4
- package/dist/{diff-C9RDjjhs.js → diff-Cnghh_Ip.js} +2 -2
- package/dist/{diff-2VaeChzX.js → diff-rpqoe7Ag.js} +9 -9
- package/dist/{doctor-Djh6JDEk.js → doctor-CuVJvhV_.js} +2 -2
- package/dist/doctor-owlv6ddh.js +2166 -0
- package/dist/drizzle/index.d.ts +2 -2
- package/dist/drizzle/index.js +6 -14
- package/dist/elysia/index.d.ts +4 -4
- package/dist/elysia/index.js +7 -7
- package/dist/{errors-DPdqI50v.d.ts → errors-OD_4c__H.d.ts} +2 -2
- package/dist/{errors-Cz5zn0mW.js → errors-drnfxYtm.js} +3 -3
- package/dist/{evaluations-tz6D9A2G.js → evaluations-C9YJsv3H.js} +45 -7
- package/dist/eve/index.d.ts +5 -5
- package/dist/eve/index.js +5 -5
- package/dist/express/index.d.ts +4 -4
- package/dist/express/index.js +8 -7
- package/dist/fastify/index.d.ts +15 -5
- package/dist/fastify/index.js +9 -29
- package/dist/fields-fwfXN01n.js +1016 -0
- package/dist/{files-DWUN9PzI.js → files-D_JIzUrP.js} +12 -3
- package/dist/{fixtures-ht28Cplr.js → fixtures-h2eG2OZV.js} +5 -6
- package/dist/{from-snapshot-CTLijvdm.js → from-snapshot-7REQZe0H.js} +32 -22
- package/dist/{generate-CH7ThJ14.js → generate-CNU1pIOo.js} +1 -3
- package/dist/grantee-CXY-0C87.js +254 -0
- package/dist/grantee-conditions-MQs5NIuX.js +270 -0
- package/dist/grantee-match-fxYp9Nmt.js +141 -0
- package/dist/{helpers-H_KARDVC.js → helpers-C9YmyBIa.js} +7 -6
- package/dist/hono/index.d.ts +4 -4
- package/dist/hono/index.js +8 -8
- package/dist/hosted-CBHgkzDl.js +655 -0
- package/dist/{http-SO7qGttf.js → http-BBoKefeS.js} +35 -2
- package/dist/{http-BIhmLfCc.js → http-BEbdMZ05.js} +2 -24
- package/dist/{rls-import-4lLW_pbb.js → import-BFIlhetD.js} +86 -80
- package/dist/{openapi-import-cmwetbzw.js → import-UHk1N7vH.js} +6 -7
- package/dist/{index-C8z4RjQV.d.ts → index-BwMe94Xq.d.ts} +1 -1
- package/dist/{index-0nNqHspG.d.ts → index-DgM7w96K.d.ts} +2 -2
- package/dist/{index-C7Tw7MaW.d.ts → index-Dz7didpC.d.ts} +3 -3
- package/dist/{index-BNc7Wer_.d.ts → index-UdVQgq48.d.ts} +105 -38
- package/dist/index.d.ts +15 -13
- package/dist/index.js +22 -22
- package/dist/{instance-options-jARdwfPN.d.ts → instance-options-plkrsAog.d.ts} +1 -1
- package/dist/{json-schema-BsAflUGW.js → json-schema-DBkdUuCO.js} +1 -3
- package/dist/jwt/index.d.ts +4 -4
- package/dist/jwt/index.js +13 -13
- package/dist/{kernel-_RRHmnMY.js → kernel-CB3LrIJM.js} +81 -32
- package/dist/kysely/index.d.ts +2 -2
- package/dist/kysely/index.js +6 -14
- package/dist/{live-options-H2eG8dfJ.js → live-options-BaAFCIiu.js} +16 -3
- package/dist/load-B_shHrmd.js +2 -0
- package/dist/{load-DhWEB9P1.js → load-C3_oTHyO.js} +11 -3
- package/dist/{manifest-iaoBXHKc.js → manifest-BngWKryL.js} +5 -18
- package/dist/{map-claims-Z4QZOiFF.js → map-claims-AVlBaJoa.js} +7 -16
- package/dist/{map-error-pgUWNNtG.js → map-error-BE27PYkE.js} +5 -5
- package/dist/{markers-B0hYLvMD.js → markers-CiwAOZtO.js} +6 -2
- package/dist/{may-use-DndQ5tQ-.js → may-use-BOn_dR_w.js} +4 -3
- package/dist/mcp/index.d.ts +26 -9
- package/dist/mcp/index.js +456 -446
- package/dist/{rls-migrate-CN4TlJTO.js → migrate-BwlB5-Wk.js} +75 -15
- package/dist/nest/index.d.ts +4 -4
- package/dist/nest/index.js +11 -12
- package/dist/next/index.d.ts +31 -8
- package/dist/next/index.js +29 -33
- package/dist/next/plugin.d.ts +1 -1
- package/dist/next/plugin.js +1 -1
- package/dist/node/index.d.ts +4 -4
- package/dist/node/index.js +4 -4
- package/dist/ocsf-uYFXGVw2.js +115 -0
- package/dist/openai/index.d.ts +5 -5
- package/dist/openai/index.js +4 -4
- package/dist/openapi/index.d.ts +3 -3
- package/dist/openapi/index.js +1 -1
- package/dist/{openapi-CqqEbHsL.js → openapi-BJdBmYDG.js} +19 -23
- package/dist/{openapi-BqRBvAng.js → openapi-DBzHQbov.js} +2 -5
- package/dist/{openapi-CQTsgKoB.js → openapi-DcQ1Kg40.js} +3 -3
- package/dist/orpc/index.d.ts +5 -5
- package/dist/orpc/index.js +9 -11
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +26 -23
- package/dist/output-files-CgIsvdSa.js +140 -0
- package/dist/{rls-ownership-DcYlZ09B.js → ownership-BgnbOJFT.js} +387 -379
- package/dist/{package-root-zGkxO3oN.js → package-root-DtNnpmUd.js} +3 -2
- package/dist/{parse-BuyjxhZV.js → parse-CVFQHk6c.js} +3 -3
- package/dist/{parse-local-manifest-BrMozJCo.js → parse-local-manifest-C-q02Pal.js} +2 -4
- package/dist/{parse-local-manifest-JLnaBH0S.d.ts → parse-local-manifest-DvYUR0QL.d.ts} +1 -1
- package/dist/pdp/index.d.ts +2 -2
- package/dist/pdp/index.js +8 -9
- package/dist/{permdock-CbgvLnuo.js → permdock-L26udHSk.js} +1387 -1220
- package/dist/{rls-fields-CZOMlwHl.js → permission-keys-B26jeyUv.js} +1268 -2053
- package/dist/permission-registry-BsblpB-z.js +385 -0
- package/dist/{permissions-DttnqqxC.d.ts → permission-types-Dg39z9Kv.d.ts} +59 -31
- package/dist/{permissions-RJrovw98.js → permissions-CCJ6lmxB.js} +3 -3
- package/dist/{permissions-CDgvKG5L.d.ts → permissions-COH-KRgT.d.ts} +2 -2
- package/dist/{policy-IB6yxpC3.d.ts → policy-types-sp9P1iIV.d.ts} +661 -582
- package/dist/{policy-D2W7uv19.d.ts → policy-ur0BBK_e.d.ts} +2 -2
- package/dist/{powersync-DE19b9dT.js → powersync-BFSbSElQ.js} +2 -2
- package/dist/powersync-DCDgZgfZ.js +2 -0
- package/dist/{powersync-Dv1_aWF7.js → powersync-cZe4kzhL.js} +26 -27
- package/dist/prisma/index.d.ts +1 -1
- package/dist/prisma/index.js +6 -12
- package/dist/{problem-B43ztKIm.js → problem-CnWraVhC.js} +8 -7
- package/dist/{problem-details-_AMOYz-w.js → problem-details-DECUnXAn.js} +2 -2
- package/dist/{problem-details-DX075l9V.d.ts → problem-details-rIIo8B2C.d.ts} +1 -1
- package/dist/{protected-BdnIEFkY.d.ts → protected-B3_P7xuw.d.ts} +9 -6
- package/dist/{protected-utU-SLuy.js → protected-BN7BUdY1.js} +20 -8
- package/dist/{provider-CSQd7m92.js → provider-Bi-0HNL3.js} +23 -10
- package/dist/{provider-DZY281jO.d.ts → provider-kwHC9CZD.d.ts} +1 -1
- package/dist/{provider-store-Cfbvo4DE.js → provider-store-Ctt-81Ni.js} +6 -3
- package/dist/react/index.d.ts +7 -7
- package/dist/react/index.js +5 -5
- package/dist/react/provider-client.d.ts +1 -1
- package/dist/react/provider-client.js +1 -1
- package/dist/react-native/index.d.ts +20 -11
- package/dist/react-native/index.js +33 -17
- package/dist/{refs-KYb4ntaz.js → refs-f3imMjNW.js} +1 -1
- package/dist/relation-walk-BXnlLtg2.js +186 -0
- package/dist/relations-CvmSpG67.js +163 -0
- package/dist/{replay-CYyiLiwO.d.ts → replay-COpmZUfx.d.ts} +1 -1
- package/dist/request-context-BVM7aWNs.js +71 -0
- package/dist/request-context-C2_1lchh.d.ts +5 -0
- package/dist/resource-relations-D1Un3pcz.js +243 -0
- package/dist/retire-BFcxL6un.js +240 -0
- package/dist/{rls-P-BeBx0n.js → rls-55DQTuf5.js} +14 -2
- package/dist/{rls-DiinlRML.js → rls-BEktUaqg.js} +884 -484
- package/dist/{policy-xyWg7QdR.js → role-builder-B4GbKM-i.js} +235 -541
- package/dist/{rpc-DL9Sqvb1.js → rpc-nYC3cR_u.js} +15 -5
- package/dist/{run-BWc9qqvE.js → run-DMcNW_8T.js} +3 -3
- package/dist/{runtime-CB8gQFvS.js → runtime-D_tMXnsq.js} +1 -1
- package/dist/{saas-DhpJjnBA.js → saas-DtaA4JuW.js} +9 -8
- package/dist/{openapi-schema--ARjyfQr.js → schema-Bol6gqXA.js} +3 -5
- package/dist/{schema-D3MnDMxa.js → schema-DjQBcAP9.js} +4 -2
- package/dist/scim/index.d.ts +1 -1
- package/dist/scim/index.js +1 -1
- package/dist/{scim-BFGBBAqR.js → scim-vFR1O5oG.js} +6 -12
- package/dist/{scopes-CVdPrWN-.js → scopes-tvpa34ep.js} +10 -2
- package/dist/server/index.d.ts +18 -8
- package/dist/server/index.js +8 -8
- package/dist/{server-DlZ28Oz5.js → server-ZYEHtNHS.js} +4 -4
- package/dist/{create-B1d519XK.d.ts → server-types-DdqBwZN_.d.ts} +69 -33
- package/dist/shared-1uhI-e-b.js +294 -0
- package/dist/{shared-DRu8VFVM.js → shared-JhMeOsDF.js} +1 -1
- package/dist/{sink-Bd9PSyfq.js → sink-BTtmfPnE.js} +1 -1
- package/dist/{sink-B1fZzIMf.d.ts → sink-CPN4zLfs.d.ts} +1 -1
- package/dist/{skills-Biq5uup3.js → skills-BZdRQkxs.js} +4 -3
- package/dist/{skills-C5CbhhpG.js → skills-Dle4ZoLF.js} +2 -2
- package/dist/{snapshot-Enxd0n-s.js → snapshot-Dc9CHPME.js} +116 -13
- package/dist/{snapshot-cache-D6jLelln.js → snapshot-cache-Cod40D-X.js} +1 -1
- package/dist/{snapshot-cache-B-LxUahj.d.ts → snapshot-cache-J5T7t_vD.d.ts} +1 -1
- package/dist/solid/index.d.ts +22 -22
- package/dist/solid/index.js +40 -10
- package/dist/{sources-4p0-4xQm.js → sources-B1-b-R0t.js} +25 -26
- package/dist/{sources-BdOYLw9j.d.ts → sources-Dljy06zL.d.ts} +6 -2
- package/dist/{sql-xdg5f4Fi.js → sql-t8teWOfo.js} +19 -3
- package/dist/{src-BDwEZWrZ.js → src-Bb7no-hS.js} +194 -123
- package/dist/ssf/index.d.ts +3 -3
- package/dist/ssf/index.js +58 -61
- package/dist/store-9KO4BlkB.d.ts +147 -0
- package/dist/{store-DMAWwAJV.js → store-ILkLUj_o.js} +4 -4
- package/dist/{store-options-ByhZq1jV.js → store-options-CbUHDoSe.js} +16 -7
- package/dist/{stores-BT2pSCub.js → stores-BHXdSbw9.js} +7 -3
- package/dist/stores-Cxz_80g-.d.ts +31 -0
- package/dist/{stream-DDpvynSB.js → stream-BMui98KK.js} +2 -1
- package/dist/{stream-D1RXubw2.d.ts → stream-Dwsg6WwE.d.ts} +2 -2
- package/dist/{subject-settings-BwBmCdTR.js → subject-settings-CkEwrw3W.js} +23 -5
- package/dist/supabase/index.d.ts +4 -4
- package/dist/supabase/index.js +3 -3
- package/dist/supabase/middleware.d.ts +11 -5
- package/dist/supabase/middleware.js +10 -7
- package/dist/{supabase-C0qESOa-.js → supabase-C1TWlnYt.js} +36 -52
- package/dist/{supabase-s4o9s1O1.js → supabase-Dbqr141s.js} +2 -2
- package/dist/supabase-hook-BULg7Ut_.js +2 -0
- package/dist/{supabase-hook-ntETYCQu.js → supabase-hook-Cva3ikXz.js} +730 -781
- package/dist/{supautils-u8jXTGrt.js → supautils-B6HYL9eL.js} +2 -2
- package/dist/svelte/Protected.svelte +9 -2
- package/dist/svelte/index.d.ts +6 -6
- package/dist/svelte/index.js +25 -11
- package/dist/svelte/runtime.d.ts +10 -3
- package/dist/svelte/runtime.js +4 -3
- package/dist/svelte/source.d.ts +6 -6
- package/dist/svelte/source.js +4 -4
- package/dist/{validity-uOp8UpYn.js → tenancy-DshjqY0n.js} +3 -39
- package/dist/terminal/index.d.ts +5 -5
- package/dist/terminal/index.js +18 -34
- package/dist/testing/index.d.ts +89 -77
- package/dist/testing/index.js +1276 -1262
- 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/thenable-QSmvt-vZ.js +65 -0
- package/dist/{token-CIWxJOCh.js → token-Bx9qy30p.js} +1 -1
- package/dist/tool-refusal-CQ7OEia9.js +41 -0
- package/dist/trpc/index.d.ts +5 -5
- package/dist/trpc/index.js +7 -8
- package/dist/{types-DbjRN55U.d.ts → types-7rJVLWe5.d.ts} +82 -4
- package/dist/{types-aqOHTHcp.d.ts → types-Br9IXUBG.d.ts} +1 -1
- package/dist/types-ByUo56un.d.ts +88 -0
- package/dist/{types-Cq_H8N_X.d.ts → types-C0dee0Qf.d.ts} +2 -2
- package/dist/{types-DxiCGmmd.d.ts → types-CIlTojhq.d.ts} +2 -2
- package/dist/{types-QCuYJh4q.d.ts → types-CUcFZfpU.d.ts} +1 -1
- package/dist/{types-C97IskBw.d.ts → types-CqrjB9o6.d.ts} +1 -1
- package/dist/{types-BnIEz1sU.d.ts → types-DHrmBJLH.d.ts} +24 -6
- package/dist/{types-Dnemwd77.d.ts → types-DpWfQdSh.d.ts} +5 -1
- package/dist/{types-Bvc4oWT6.d.ts → types-DvupZE0w.d.ts} +1 -1
- package/dist/unplugin/index.d.ts +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/{usage-B0YuTQRO.js → usage-4G6t643F.js} +2 -2
- package/dist/{usage-ItS7nLcl.js → usage-CG9nOYZw.js} +7 -7
- package/dist/usage-iFLJ0-rr.js +2 -0
- package/dist/{validation-error-Ba264OaJ.d.ts → validation-error-BSSw0l_8.d.ts} +1 -1
- package/dist/{validation-error-CnjZ4t92.js → validation-error-ZfUHbKvf.js} +1 -1
- package/dist/validity-dVrUEPJt.js +62 -0
- package/dist/{verifier-BxBo6I44.js → verifier-DMr_Zmb_.js} +51 -15
- package/dist/{rls-verify-CMv9H3wV.js → verify-BztUB3GX.js} +668 -239
- package/dist/{version-BvacVTZR.js → version-BEogC-rB.js} +1 -1
- package/dist/{views-Soe7bGSC.js → views-CJsrBS1f.js} +23 -1
- package/dist/{vocabulary-DA8a8zif.d.ts → vocabulary-hhEFLFoL.d.ts} +15 -6
- package/dist/vue/index.d.ts +24 -19
- package/dist/vue/index.js +24 -8
- package/dist/watch-C8TpsAl4.js +2 -0
- package/dist/{watch-DVBJ9cBq.js → watch-zeucB7eF.js} +6 -6
- package/dist/webmcp/index.d.ts +2 -2
- package/dist/webmcp/index.js +13 -37
- package/dist/wrap-Cr93qqdP.d.ts +14 -0
- package/dist/wrap-DWsU_5Bd.js +26 -0
- package/package.json +8 -8
- package/schemas/catalog-v1.json +6 -0
- package/skills/permdock-credentials/SKILL.md +1 -1
- package/skills/permdock-credentials/references/tokens-keys-links.md +7 -2
- package/skills/permdock-data/references/queries-and-rls.md +4 -0
- package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +1 -0
- package/skills/permdock-wire/SKILL.md +2 -2
- package/skills/permdock-wire/references/adapters.md +9 -1
- package/skills/permdock-wire/references/policy-options.md +10 -1
- package/dist/catalog-CRW5Mx_B.js +0 -2
- package/dist/collect-DSWOk19R.js +0 -2
- package/dist/create-CMwIl-w2.js +0 -253
- package/dist/delegation-D8pUEuce.js +0 -103
- package/dist/doctor-DvH0IQff.js +0 -1194
- package/dist/doctor-collect-CjdO0wQX.js +0 -665
- package/dist/evaluation-items-CfCCejQd.js +0 -40
- package/dist/fields-BMl1RxoX.js +0 -26
- package/dist/grantee-Cv5Sogfv.js +0 -602
- package/dist/hosted-DJQw46hn.js +0 -266
- package/dist/instance-options-Dhhcnm3y.js +0 -22
- package/dist/load-DwJUafYN.js +0 -2
- package/dist/map-AcxK9o_D.js +0 -147
- package/dist/permissions-DVoF6dMt.js +0 -662
- package/dist/powersync-CrBX8hY6.js +0 -2
- package/dist/protected-xAr24FEC.d.ts +0 -40
- package/dist/relations-cjOL01hZ.js +0 -505
- package/dist/rls-graph-CHwx0V8q.js +0 -435
- package/dist/rls-shared-Cewd_-zI.js +0 -495
- package/dist/store-jOBYpiXC.d.ts +0 -36
- package/dist/stores-C0IMQrGI.d.ts +0 -52
- package/dist/supabase-config-Bj3iDH2I.js +0 -472
- package/dist/supabase-hook-gd3s8bxB.js +0 -2
- package/dist/thenable-okc2xDsj.js +0 -10
- package/dist/types-CKNSyIf_.d.ts +0 -52
- package/dist/usage-DorAJiU_.js +0 -2
- package/dist/watch-BwxLTYMZ.js +0 -2
|
@@ -0,0 +1,2166 @@
|
|
|
1
|
+
import { a as hasConditionOp } from "./refs-f3imMjNW.js";
|
|
2
|
+
import { a as normalizeMemberships, d as scopeList, o as resolveScope } from "./scopes-tvpa34ep.js";
|
|
3
|
+
import { c as listPermissions, h as formerKeys } from "./permission-registry-BsblpB-z.js";
|
|
4
|
+
import { u as isPrincipalRelation } from "./resource-relations-D1Un3pcz.js";
|
|
5
|
+
import { o as flattenGrantee } from "./grantee-CXY-0C87.js";
|
|
6
|
+
import { d as separationConflicts } from "./hosted-CBHgkzDl.js";
|
|
7
|
+
import { b as validateCustomRole } from "./permdock-L26udHSk.js";
|
|
8
|
+
import { o as parseCredential } from "./credential-kue0qpbm.js";
|
|
9
|
+
import { i as describeError } from "./errors-CWfoNg9N.js";
|
|
10
|
+
import { t as policyRowConditionKeys } from "./row-conditions-h2HythXQ.js";
|
|
11
|
+
import { a as rel, n as doctorSrcPath, o as sqlFiles, r as listSourceFiles } from "./files-D_JIzUrP.js";
|
|
12
|
+
import { r as DOCTOR_REPORT_SCHEMA } from "./version-BEogC-rB.js";
|
|
13
|
+
import { o as jsonSchemaOf } from "./catalog-doc-DWtg0gGR.js";
|
|
14
|
+
import { a as loadModule, i as loadConfiguredPolicy, n as asPolicy, s as pickNamed } from "./load-C3_oTHyO.js";
|
|
15
|
+
import { r as runCollect } from "./collect-2SrB7lfk.js";
|
|
16
|
+
import { n as supabaseTenantClaim } from "./budget-NbEtbJC_.js";
|
|
17
|
+
import { o as quoteSqlLiteral } from "./sql-t8teWOfo.js";
|
|
18
|
+
import { d as keptKeys, r as PERMDOCK_SCHEMA } from "./sources-B1-b-R0t.js";
|
|
19
|
+
import "./markers-CiwAOZtO.js";
|
|
20
|
+
import { t as createStyle } from "./style-CTm1AXDT.js";
|
|
21
|
+
import { i as runSkillsInstall } from "./skills-BZdRQkxs.js";
|
|
22
|
+
import { n as MIGRATION_DIRS, t as DEFAULT_SENSITIVE_ACTIONS } from "./constants-DcJABbLW.js";
|
|
23
|
+
import { a as supabaseHookManifest, d as attrsPlan, f as pd045, n as hookOut, p as supabaseConfig, u as pd039 } from "./supabase-hook-Cva3ikXz.js";
|
|
24
|
+
import { o as partPath } from "./output-files-CgIsvdSa.js";
|
|
25
|
+
import { i as group, n as membershipColumns, o as sqlStatements, r as tableKey } from "./deciding-columns-Sq12mEA6.js";
|
|
26
|
+
import { N as graphSqlName, k as contextRefs } from "./permission-keys-B26jeyUv.js";
|
|
27
|
+
import { i as guardedTables, t as ownershipRules } from "./ownership-BgnbOJFT.js";
|
|
28
|
+
import { a as pd010, c as pd014, d as pd038, f as pd041, i as pd008, l as pd015, n as pd001, o as pd011, p as pd057, r as pd007, s as pd013, t as runUsage, u as pd036 } from "./usage-CG9nOYZw.js";
|
|
29
|
+
import { h as tableFor, m as compileGrants, p as commandFor, t as FIELD_VIEWS } from "./fields-fwfXN01n.js";
|
|
30
|
+
import { d as rowConditionMessage, i as graphPlan, s as ROW_CONDITION_FIX, u as helperTablePolicies } from "./verify-BztUB3GX.js";
|
|
31
|
+
import { r as staleFiles, t as powersyncFiles } from "./powersync-cZe4kzhL.js";
|
|
32
|
+
import { n as legacyHelperNames, r as retirePlan, t as legacyHelperCalls } from "./retire-BFcxL6un.js";
|
|
33
|
+
import { t as supautilsRejections } from "./supautils-B6HYL9eL.js";
|
|
34
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
35
|
+
import { dirname, join, resolve } from "node:path";
|
|
36
|
+
import { createRequire } from "node:module";
|
|
37
|
+
//#region src/cli/shared/doctor-files.ts
|
|
38
|
+
const nodeFiles = Object.freeze({
|
|
39
|
+
exists: (path) => existsSync(path),
|
|
40
|
+
read: (path) => readFileSync(path, "utf8"),
|
|
41
|
+
list: (path) => readdirSync(path),
|
|
42
|
+
isDirectory: (path) => statSync(path, { throwIfNoEntry: false })?.isDirectory() === true
|
|
43
|
+
});
|
|
44
|
+
function filesOf(input) {
|
|
45
|
+
return input.files ?? nodeFiles;
|
|
46
|
+
}
|
|
47
|
+
/** Parses the JSON file at `path`; throws when it is missing or invalid. */
|
|
48
|
+
function readJson(files, path) {
|
|
49
|
+
return JSON.parse(files.read(path));
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/cli/project.ts
|
|
53
|
+
function loadProject(input) {
|
|
54
|
+
const { cwd, config } = input;
|
|
55
|
+
const files = input.files ?? nodeFiles;
|
|
56
|
+
let sources;
|
|
57
|
+
let policyLoad;
|
|
58
|
+
let collected;
|
|
59
|
+
const loadPolicy = () => {
|
|
60
|
+
policyLoad ??= readPolicy(cwd, config.policy);
|
|
61
|
+
return policyLoad;
|
|
62
|
+
};
|
|
63
|
+
const policy = async () => {
|
|
64
|
+
const load = await loadPolicy();
|
|
65
|
+
return load.status === "loaded" ? load.policy : void 0;
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
cwd,
|
|
69
|
+
config,
|
|
70
|
+
files,
|
|
71
|
+
now: input.now,
|
|
72
|
+
io: input.io,
|
|
73
|
+
sources: () => {
|
|
74
|
+
sources ??= listSourceFiles(cwd, doctorSrcPath(config)).map((file) => ({
|
|
75
|
+
file: rel(cwd, file),
|
|
76
|
+
text: files.read(file)
|
|
77
|
+
}));
|
|
78
|
+
return sources;
|
|
79
|
+
},
|
|
80
|
+
policyLoad: loadPolicy,
|
|
81
|
+
policy,
|
|
82
|
+
collected: () => {
|
|
83
|
+
collected ??= runCollect({
|
|
84
|
+
cwd,
|
|
85
|
+
config,
|
|
86
|
+
collect: config.collect ?? {},
|
|
87
|
+
scanPath: doctorSrcPath(config),
|
|
88
|
+
check: true,
|
|
89
|
+
now: input.now,
|
|
90
|
+
io: input.io,
|
|
91
|
+
loadPolicy: policy
|
|
92
|
+
});
|
|
93
|
+
return collected;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function readPolicy(cwd, path) {
|
|
98
|
+
if (path === void 0) return { status: "unset" };
|
|
99
|
+
try {
|
|
100
|
+
return {
|
|
101
|
+
status: "loaded",
|
|
102
|
+
policy: asPolicy(pickNamed(await loadModule(resolve(cwd, path)), ["policy"]))
|
|
103
|
+
};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
status: "failed",
|
|
107
|
+
path,
|
|
108
|
+
message: describeError(error)
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/cli/doctor/inputs.ts
|
|
114
|
+
/** The configured policy, through the project's memoised load when there is one. */
|
|
115
|
+
function policyOf(input) {
|
|
116
|
+
const path = input.config.policy;
|
|
117
|
+
if (path === void 0) return Promise.resolve(void 0);
|
|
118
|
+
return input.policy === void 0 ? loadConfiguredPolicy(input.cwd, path) : input.policy();
|
|
119
|
+
}
|
|
120
|
+
/** `collect --check`, through the project's memoised run when there is one. */
|
|
121
|
+
function collectedOf(input) {
|
|
122
|
+
return input.collected === void 0 ? runCollect({
|
|
123
|
+
cwd: input.cwd,
|
|
124
|
+
config: input.config,
|
|
125
|
+
collect: input.config.collect ?? {},
|
|
126
|
+
scanPath: doctorSrcPath(input.config),
|
|
127
|
+
check: true,
|
|
128
|
+
now: input.now,
|
|
129
|
+
io: input.io
|
|
130
|
+
}) : input.collected();
|
|
131
|
+
}
|
|
132
|
+
function asMembershipsFixture(parsed) {
|
|
133
|
+
return parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
134
|
+
}
|
|
135
|
+
function readFixture(input, path) {
|
|
136
|
+
const files = filesOf(input);
|
|
137
|
+
if (path === void 0) return;
|
|
138
|
+
const absolute = resolve(input.cwd, path);
|
|
139
|
+
if (!files.exists(absolute)) return;
|
|
140
|
+
try {
|
|
141
|
+
return readJson(files, absolute);
|
|
142
|
+
} catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function listField(value, field) {
|
|
147
|
+
if (value === null || typeof value !== "object") return [];
|
|
148
|
+
const list = value[field];
|
|
149
|
+
return Array.isArray(list) ? list : [];
|
|
150
|
+
}
|
|
151
|
+
/** Reads the doctor memberships fixture, if one is configured and parseable. */
|
|
152
|
+
function membershipsFixture(input) {
|
|
153
|
+
const files = filesOf(input);
|
|
154
|
+
const fixturePath = input.config.doctor?.memberships;
|
|
155
|
+
if (fixturePath === void 0) return {};
|
|
156
|
+
const absolute = resolve(input.cwd, fixturePath);
|
|
157
|
+
if (!files.exists(absolute)) return {};
|
|
158
|
+
try {
|
|
159
|
+
return asMembershipsFixture(readJson(files, absolute));
|
|
160
|
+
} catch {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/cli/doctor/assignments.ts
|
|
166
|
+
const TRIGGER = new RegExp(String.raw`^create\s+(?:or\s+replace\s+)?trigger\s+"?permdock_assignment"?\s[\s\S]*?\bon\s+((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)`, "iu");
|
|
167
|
+
/** The tables a `permdock_assignment` trigger is created on in the migrations and the `rls generate` output. */
|
|
168
|
+
function triggeredTables(input) {
|
|
169
|
+
const out = input.config.rls?.out ?? "rls.sql";
|
|
170
|
+
const files = sqlFiles(input.cwd, [...input.config.doctor?.migrations ?? MIGRATION_DIRS, ...new Set([
|
|
171
|
+
out,
|
|
172
|
+
partPath(out, "helpers"),
|
|
173
|
+
partPath(out, "policies")
|
|
174
|
+
].filter((file) => file.endsWith(".sql")))]);
|
|
175
|
+
const tables = /* @__PURE__ */ new Set();
|
|
176
|
+
for (const file of files) for (const statement of sqlStatements(filesOf(input).read(file))) {
|
|
177
|
+
const match = TRIGGER.exec(statement.text);
|
|
178
|
+
if (match !== null) tables.add(tableKey(group(match, 1)));
|
|
179
|
+
}
|
|
180
|
+
return tables;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* PD064: `rls.assignments` guards a table (a membership table, the
|
|
184
|
+
* global-roles table or a listed table) that no `permdock_assignment`
|
|
185
|
+
* trigger is created on in the migrations or the `rls generate` output, so a
|
|
186
|
+
* client may write any role there.
|
|
187
|
+
*/
|
|
188
|
+
async function pd064(input) {
|
|
189
|
+
if (input.config.rls?.assignments === void 0) return [];
|
|
190
|
+
const policy = await policyOf(input);
|
|
191
|
+
if (policy === void 0) return [];
|
|
192
|
+
const scopes = scopeList(policy.scopes);
|
|
193
|
+
if ((ownershipRules(policy, scopes)?.assigns.length ?? 0) === 0) return [];
|
|
194
|
+
const found = triggeredTables(input);
|
|
195
|
+
return guardedTables(input.config, scopes).filter((table) => !found.has(tableKey(table))).map((table) => ({
|
|
196
|
+
code: "PD064",
|
|
197
|
+
severity: "warning",
|
|
198
|
+
message: `rls.assignments guards ${table}, but no migration creates its permdock_assignment trigger, so a client may write any role there`,
|
|
199
|
+
fix: "run permdock rls generate and apply its output in a migration, or set rls.out or doctor.migrations to where it lives"
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/cli/doctor/catalog.ts
|
|
204
|
+
async function pd002(input) {
|
|
205
|
+
const collected = await collectedOf(input);
|
|
206
|
+
if (collected.scan === void 0) return [];
|
|
207
|
+
return collected.scan.unknown.map((usage) => ({
|
|
208
|
+
code: "PD002",
|
|
209
|
+
severity: "error",
|
|
210
|
+
message: `unknown permission ${usage.call} at ${usage.file}:${String(usage.line)}`,
|
|
211
|
+
fix: "use a defined permission reference"
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
async function pd003(input) {
|
|
215
|
+
if (input.config.policy === void 0) return [];
|
|
216
|
+
const usage = await runUsage({
|
|
217
|
+
cwd: input.cwd,
|
|
218
|
+
config: input.config,
|
|
219
|
+
ignore: [],
|
|
220
|
+
strict: false,
|
|
221
|
+
json: true,
|
|
222
|
+
dynamicAsUsed: false,
|
|
223
|
+
now: input.now,
|
|
224
|
+
io: input.io,
|
|
225
|
+
collected: () => collectedOf(input),
|
|
226
|
+
policy: () => policyOf(input)
|
|
227
|
+
});
|
|
228
|
+
if (usage.code === 2) return [];
|
|
229
|
+
return JSON.parse(usage.output).ungranted.map((item) => ({
|
|
230
|
+
code: "PD003",
|
|
231
|
+
severity: "warning",
|
|
232
|
+
message: `${item.key} is used but never granted (${item.detail})`,
|
|
233
|
+
fix: "add an allow() with a to: selector, or a role() binding, in definePolicy"
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
async function pd004(input) {
|
|
237
|
+
const collected = await collectedOf(input);
|
|
238
|
+
if (collected.code === 0) return [];
|
|
239
|
+
if (collected.code === 2) return [];
|
|
240
|
+
return [{
|
|
241
|
+
code: "PD004",
|
|
242
|
+
severity: "error",
|
|
243
|
+
message: collected.message,
|
|
244
|
+
fix: "pnpm exec permdock collect"
|
|
245
|
+
}];
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/cli/doctor/declarative.ts
|
|
249
|
+
const SCHEMAS = "supabase/schemas";
|
|
250
|
+
const MIGRATIONS = "supabase/migrations";
|
|
251
|
+
const CREATES_HOOK = /\bcreate\s+(?:or\s+replace\s+)?function\s+[\w."]*custom_access_token_hook\b/iu;
|
|
252
|
+
const DROPS_HOOK = /\bdrop\s+function\s+(?:if\s+exists\s+)?[\w."]*custom_access_token_hook\b/iu;
|
|
253
|
+
const GRANTS_HOOK = /\bgrant\s+execute\s+on\s+function\s+[\w."]*custom_access_token_hook\s*\(\s*jsonb\s*\)\s+to\s+supabase_auth_admin\b/iu;
|
|
254
|
+
const DEFINES_HELPER = /\bcreate\s+(?:or\s+replace\s+)?function\s+[\w."]*permdock_has\s*\(/iu;
|
|
255
|
+
const CALLS_HELPER = /\b(?:permdock_has|(?:permitted|member)_[a-z][a-z0-9_]*_ids)\s*\(/iu;
|
|
256
|
+
function uncommented(text) {
|
|
257
|
+
return text.replaceAll(/--[^\n]*/gu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* PD042: a hook declared under `supabase/schemas` reaches the database through
|
|
261
|
+
* `supabase db diff`, which drops its `supabase_auth_admin` grants. pg-delta
|
|
262
|
+
* keeps them, so the check is off under `[experimental.pgdelta]`. A function
|
|
263
|
+
* created (or dropped and created again) without them is executable by
|
|
264
|
+
* `public` and not by the auth server, so a migration at or after the one
|
|
265
|
+
* that creates it must carry the grants.
|
|
266
|
+
*/
|
|
267
|
+
function pd042(cwd, config, files = nodeFiles) {
|
|
268
|
+
if (config.supabase?.hook === void 0 || supabaseConfig(cwd).pgDelta !== void 0) return [];
|
|
269
|
+
if (!sqlFiles(cwd, [SCHEMAS]).some((file) => files.read(file).startsWith(`-- permdock:hook v1 `))) return [];
|
|
270
|
+
const migrations = sqlFiles(cwd, [MIGRATIONS]).map((file) => ({
|
|
271
|
+
file,
|
|
272
|
+
text: uncommented(files.read(file))
|
|
273
|
+
}));
|
|
274
|
+
const created = migrations.findIndex(({ text }) => CREATES_HOOK.test(text));
|
|
275
|
+
const dropped = migrations.findLastIndex(({ text }) => DROPS_HOOK.test(text));
|
|
276
|
+
const needed = Math.max(created, dropped);
|
|
277
|
+
if (needed === -1) return [];
|
|
278
|
+
if (migrations.findLastIndex(({ text }) => GRANTS_HOOK.test(text)) >= needed) return [];
|
|
279
|
+
return [{
|
|
280
|
+
code: "PD042",
|
|
281
|
+
severity: "error",
|
|
282
|
+
message: `${rel(cwd, migrations[needed]?.file ?? MIGRATIONS)} creates custom_access_token_hook from supabase/schemas, and no migration from it on grants it to supabase_auth_admin: supabase db diff drops those grants, so the auth server cannot call the hook and public can`,
|
|
283
|
+
fix: "generate the grants with permdock supabase hook generate --grants-out (or rls generate --split ...,hook --grants-out) into a new file from supabase migration new, after the db diff migration"
|
|
284
|
+
}];
|
|
285
|
+
}
|
|
286
|
+
/** The `[db.migrations] schema_paths` globs of `supabase/config.toml`, or Supabase's default. */
|
|
287
|
+
function schemaPaths(cwd) {
|
|
288
|
+
const paths = supabaseConfig(cwd).schemaPaths ?? [];
|
|
289
|
+
return paths.length === 0 ? ["./schemas/**/*.sql"] : paths;
|
|
290
|
+
}
|
|
291
|
+
/** Schema files in the order `supabase db diff` applies them: each glob in turn, sorted within it. */
|
|
292
|
+
function orderedSchemaFiles(cwd) {
|
|
293
|
+
return sqlFiles(join(cwd, "supabase"), schemaPaths(cwd), { order: "entry" });
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* PD043: `db diff` applies schema files in `schema_paths` order, so a policy
|
|
297
|
+
* that calls `permdock_has` or `permitted_<scope>_ids` before the file that
|
|
298
|
+
* defines them fails to apply. pg-delta orders by dependencies instead.
|
|
299
|
+
*/
|
|
300
|
+
function pd043(cwd, files = nodeFiles) {
|
|
301
|
+
if (supabaseConfig(cwd).pgDelta !== void 0) return [];
|
|
302
|
+
if (!files.exists(join(cwd, SCHEMAS))) return [];
|
|
303
|
+
const schemas = orderedSchemaFiles(cwd).map((file) => ({
|
|
304
|
+
file,
|
|
305
|
+
text: uncommented(files.read(file))
|
|
306
|
+
}));
|
|
307
|
+
const helpers = schemas.findIndex(({ text }) => DEFINES_HELPER.test(text));
|
|
308
|
+
const defined = sqlFiles(cwd, [SCHEMAS]).find((file) => DEFINES_HELPER.test(uncommented(files.read(file))));
|
|
309
|
+
if (helpers === -1) return defined === void 0 ? [] : [{
|
|
310
|
+
code: "PD043",
|
|
311
|
+
severity: "warning",
|
|
312
|
+
message: `${rel(cwd, defined)} defines the PermDock helpers, but schema_paths in supabase/config.toml does not list it, so supabase db diff never applies it`,
|
|
313
|
+
fix: `add it to [db.migrations] schema_paths before every file that calls permdock_has, permitted_<scope>_ids or member_<scope>_ids`
|
|
314
|
+
}];
|
|
315
|
+
const helperFile = rel(cwd, schemas[helpers]?.file ?? "");
|
|
316
|
+
return schemas.slice(0, helpers).filter(({ text }) => CALLS_HELPER.test(text)).map(({ file }) => ({
|
|
317
|
+
code: "PD043",
|
|
318
|
+
severity: "warning",
|
|
319
|
+
message: `${rel(cwd, file)} calls the PermDock helpers, but schema_paths applies it before ${helperFile}, which defines them`,
|
|
320
|
+
fix: `list ${helperFile} earlier in [db.migrations] schema_paths, or give it a lower number, such as 056_permdock_helpers.sql before 060_policies.sql`
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/cli/doctor/fixtures.ts
|
|
325
|
+
async function pd018(input) {
|
|
326
|
+
const files = filesOf(input);
|
|
327
|
+
if (input.config.policy === void 0) return [];
|
|
328
|
+
const policy = await policyOf(input);
|
|
329
|
+
if (policy === void 0) return [];
|
|
330
|
+
const findings = [];
|
|
331
|
+
const declared = new Set(policy.roles.map((item) => item.name));
|
|
332
|
+
for (const binding of policy.roles) for (const name of binding.exclusiveWith ?? []) if (!declared.has(name)) findings.push({
|
|
333
|
+
code: "PD018",
|
|
334
|
+
severity: "error",
|
|
335
|
+
message: `exclusiveWith names undeclared role ${name} on ${binding.name}`,
|
|
336
|
+
fix: "declare the exclusive role with role() or remove it from exclusiveWith"
|
|
337
|
+
});
|
|
338
|
+
const fixturePath = input.config.doctor?.memberships;
|
|
339
|
+
if (fixturePath === void 0) return findings;
|
|
340
|
+
const absolute = resolve(input.cwd, fixturePath);
|
|
341
|
+
if (!files.exists(absolute)) {
|
|
342
|
+
findings.push({
|
|
343
|
+
code: "PD018",
|
|
344
|
+
severity: "warning",
|
|
345
|
+
message: `doctor.memberships fixture ${fixturePath} is missing`,
|
|
346
|
+
fix: "add the JSON fixture or remove doctor.memberships"
|
|
347
|
+
});
|
|
348
|
+
return findings;
|
|
349
|
+
}
|
|
350
|
+
let parsed;
|
|
351
|
+
try {
|
|
352
|
+
parsed = readJson(files, absolute);
|
|
353
|
+
} catch {
|
|
354
|
+
findings.push({
|
|
355
|
+
code: "PD018",
|
|
356
|
+
severity: "warning",
|
|
357
|
+
message: `doctor.memberships fixture ${fixturePath} is not valid JSON`,
|
|
358
|
+
fix: "fix the JSON fixture"
|
|
359
|
+
});
|
|
360
|
+
return findings;
|
|
361
|
+
}
|
|
362
|
+
const record = asMembershipsFixture(parsed);
|
|
363
|
+
for (const custom of record.customRoles ?? []) {
|
|
364
|
+
const conflicts = separationConflicts(policy, [{
|
|
365
|
+
principal: custom.name,
|
|
366
|
+
...custom.tenant === void 0 ? {} : { tenant: custom.tenant },
|
|
367
|
+
roles: custom.includes ?? []
|
|
368
|
+
}]);
|
|
369
|
+
for (const conflict of conflicts) findings.push({
|
|
370
|
+
code: "PD018",
|
|
371
|
+
severity: "warning",
|
|
372
|
+
message: `custom role ${custom.name} includes exclusive roles ${conflict.roles.join(" and ")}`,
|
|
373
|
+
fix: "split the custom role so it does not include exclusiveWith pairs"
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
for (const conflict of separationConflicts(policy, record.memberships ?? [])) findings.push({
|
|
377
|
+
code: "PD018",
|
|
378
|
+
severity: "warning",
|
|
379
|
+
message: `membership ${conflict.principal || "unknown"} holds exclusive roles ${conflict.roles.join(" and ")}`,
|
|
380
|
+
fix: "remove one of the exclusive roles from the membership fixture"
|
|
381
|
+
});
|
|
382
|
+
return findings;
|
|
383
|
+
}
|
|
384
|
+
function customRoleLabel(role) {
|
|
385
|
+
return role.tenant === void 0 ? `global custom role ${role.name}` : `custom role ${role.name} in ${role.tenant}`;
|
|
386
|
+
}
|
|
387
|
+
async function pd023(input) {
|
|
388
|
+
const files = filesOf(input);
|
|
389
|
+
const fixturePath = input.config.doctor?.memberships;
|
|
390
|
+
if (input.config.policy === void 0 || fixturePath === void 0) return [];
|
|
391
|
+
const absolute = resolve(input.cwd, fixturePath);
|
|
392
|
+
if (!files.exists(absolute)) return [];
|
|
393
|
+
let parsed;
|
|
394
|
+
try {
|
|
395
|
+
parsed = readJson(files, absolute);
|
|
396
|
+
} catch {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
const policy = await policyOf(input);
|
|
400
|
+
if (policy === void 0) return [];
|
|
401
|
+
const findings = [];
|
|
402
|
+
for (const custom of asMembershipsFixture(parsed).customRoles ?? []) for (const entry of validateCustomRole(policy, custom).dropped) {
|
|
403
|
+
const what = "role" in entry ? `include ${entry.role}` : `permission ${entry.permission}`;
|
|
404
|
+
findings.push({
|
|
405
|
+
code: "PD023",
|
|
406
|
+
severity: "warning",
|
|
407
|
+
message: `${customRoleLabel(custom)} drops ${what} (${entry.reason})`,
|
|
408
|
+
fix: entry.reason === "outside-ceiling" ? "grant it to a declared assignable role, or remove it from the custom role" : entry.reason === "condition-not-allowed" ? "remove the condition; custom-role grants inherit the declared grant condition" : "use a declared permission key or assignable role name"
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return findings;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* PD055: a stored custom role names a permission by a key it was renamed
|
|
415
|
+
* from. It still resolves; the alias can only be dropped once storage holds
|
|
416
|
+
* the current key.
|
|
417
|
+
*/
|
|
418
|
+
async function pd055(input) {
|
|
419
|
+
const files = filesOf(input);
|
|
420
|
+
const fixturePath = input.config.doctor?.memberships;
|
|
421
|
+
if (input.config.policy === void 0 || fixturePath === void 0) return [];
|
|
422
|
+
const absolute = resolve(input.cwd, fixturePath);
|
|
423
|
+
if (!files.exists(absolute)) return [];
|
|
424
|
+
let parsed;
|
|
425
|
+
try {
|
|
426
|
+
parsed = readJson(files, absolute);
|
|
427
|
+
} catch {
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
const policy = await policyOf(input);
|
|
431
|
+
if (policy === void 0) return [];
|
|
432
|
+
const table = `${input.config.rls?.schema ?? "permdock"}.custom_role_permissions`;
|
|
433
|
+
const findings = [];
|
|
434
|
+
for (const custom of asMembershipsFixture(parsed).customRoles ?? []) for (const { from, to } of validateCustomRole(policy, custom).renamed) findings.push({
|
|
435
|
+
code: "PD055",
|
|
436
|
+
severity: "warning",
|
|
437
|
+
message: `${customRoleLabel(custom)} stores ${from}, which was renamed to ${to}`,
|
|
438
|
+
fix: `rewrite the stored key before removing the alias: update ${table} set permission = ${quoteSqlLiteral(to)} where permission = ${quoteSqlLiteral(from)};`
|
|
439
|
+
});
|
|
440
|
+
return findings;
|
|
441
|
+
}
|
|
442
|
+
/** Fixture API keys that never expire, credential policies that allow it, and records that are not v1 credentials. */
|
|
443
|
+
function pd029(input) {
|
|
444
|
+
const parsed = readFixture(input, input.config.doctor?.credentials);
|
|
445
|
+
const findings = [];
|
|
446
|
+
for (const [index, record] of listField(parsed, "credentials").entries()) {
|
|
447
|
+
const credential = parseCredential(record);
|
|
448
|
+
if (credential === void 0) {
|
|
449
|
+
findings.push({
|
|
450
|
+
code: "PD029",
|
|
451
|
+
severity: "warning",
|
|
452
|
+
message: `credentials[${String(index)}] is not a valid v1 credential, so it verifies to no subject`,
|
|
453
|
+
fix: "give it v: 1, id, kind, principal, permissions, createdBy and createdAt; a service key also needs tenant and roles"
|
|
454
|
+
});
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (credential.expiresAt === void 0) findings.push({
|
|
458
|
+
code: "PD029",
|
|
459
|
+
severity: "warning",
|
|
460
|
+
message: `API key ${credential.id} never expires`,
|
|
461
|
+
fix: "set expiresAt and rotate the key before it; a leaked key without expiry stays live until someone revokes it"
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
const settings = parsed !== null && typeof parsed === "object" ? parsed.settings : void 0;
|
|
465
|
+
const tenants = settings !== null && typeof settings === "object" ? Object.entries(settings) : [];
|
|
466
|
+
for (const [tenant, value] of tenants) if ((value !== null && typeof value === "object" ? value.credentials : void 0)?.allowNoExpiry === true) findings.push({
|
|
467
|
+
code: "PD029",
|
|
468
|
+
severity: "warning",
|
|
469
|
+
message: `tenant ${tenant} allows API keys that never expire`,
|
|
470
|
+
fix: "drop allowNoExpiry and set maxTtl, or keep it only for a tenant that rotates keys on its own schedule"
|
|
471
|
+
});
|
|
472
|
+
return findings;
|
|
473
|
+
}
|
|
474
|
+
async function pd025(input) {
|
|
475
|
+
const files = filesOf(input);
|
|
476
|
+
const fixturePath = input.config.doctor?.memberships;
|
|
477
|
+
if (input.config.policy === void 0 || fixturePath === void 0) return [];
|
|
478
|
+
const absolute = resolve(input.cwd, fixturePath);
|
|
479
|
+
if (!files.exists(absolute)) return [];
|
|
480
|
+
let parsed;
|
|
481
|
+
try {
|
|
482
|
+
parsed = readJson(files, absolute);
|
|
483
|
+
} catch {
|
|
484
|
+
return [];
|
|
485
|
+
}
|
|
486
|
+
const policy = await policyOf(input);
|
|
487
|
+
if (policy === void 0) return [];
|
|
488
|
+
const scopes = scopeList(policy.scopes);
|
|
489
|
+
const names = scopes.map((scope) => scope.name).join(", ");
|
|
490
|
+
const findings = [];
|
|
491
|
+
for (const [index, membership] of (asMembershipsFixture(parsed).memberships ?? []).entries()) {
|
|
492
|
+
if (normalizeMemberships([membership], scopes).length > 0) continue;
|
|
493
|
+
findings.push({
|
|
494
|
+
code: "PD025",
|
|
495
|
+
severity: "warning",
|
|
496
|
+
message: `membership ${membership.principal ?? String(index)} grants nothing: its scope is not one of ${names}, a parent id is missing from within, or it mixes shapes`,
|
|
497
|
+
fix: "use { scope, id, within: { <parent>: id }, roles } with a declared scope, or { on: { resource, id }, roles }"
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
return findings;
|
|
501
|
+
}
|
|
502
|
+
//#endregion
|
|
503
|
+
//#region src/cli/doctor/next.ts
|
|
504
|
+
const ROUTE_GLOBS = ["**/api/permdock/route.{ts,tsx,js,jsx,mjs}"];
|
|
505
|
+
const ENDPOINT = /\bendpoint\s*(?::|=)\s*\{?\s*['"`]/u;
|
|
506
|
+
const NATIVE_IMPORT = /\bfrom\s*['"]permdock\/react-native['"]/u;
|
|
507
|
+
const CLIENT_READS = /* @__PURE__ */ new Set(["usePermission", "reference"]);
|
|
508
|
+
/** A grant the snapshot marks `portable: false`, so a client check needs the endpoint. */
|
|
509
|
+
function serverOnly(policy, grant) {
|
|
510
|
+
if (!grant.portable || hasConditionOp(grant.where, "related")) return true;
|
|
511
|
+
const relations = policy.resources.get(grant.permission.resource)?.relations;
|
|
512
|
+
return flattenGrantee(grant.to).some((item) => {
|
|
513
|
+
if (item.kind !== "relation") return false;
|
|
514
|
+
const spec = relations?.[item.relation];
|
|
515
|
+
return item.through !== void 0 || isPrincipalRelation(spec) && spec.period !== void 0;
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
function serverOnlyKeys(policy) {
|
|
519
|
+
const keys = /* @__PURE__ */ new Set();
|
|
520
|
+
for (const grant of [...policy.grants, ...policy.roles.flatMap((binding) => binding.grants)]) if (grant.hosted === void 0 && grant.effect === "allow" && serverOnly(policy, grant)) keys.add(grant.permission.key);
|
|
521
|
+
return keys;
|
|
522
|
+
}
|
|
523
|
+
/** An `app/**\/api/permdock/route.ts` that exports `permdockHandler`'s result, or an `endpoint` string in the source. */
|
|
524
|
+
function hasEndpoint(files, cwd, sources) {
|
|
525
|
+
if (listSourceFiles(cwd, ROUTE_GLOBS).filter((file) => /(?:^|[\\/])app[\\/]/u.test(rel(cwd, file))).some((file) => files.read(file).includes("permdockHandler"))) return true;
|
|
526
|
+
return sources.some((file) => ENDPOINT.test(files.read(file)));
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* PD044: `usePermission` reads a permission whose grant the snapshot cannot
|
|
530
|
+
* answer, and the app has no endpoint to ask, so the client always denies it.
|
|
531
|
+
*/
|
|
532
|
+
async function pd044(input) {
|
|
533
|
+
const policy = await policyOf(input);
|
|
534
|
+
if (policy === void 0) return [];
|
|
535
|
+
const keys = serverOnlyKeys(policy);
|
|
536
|
+
if (keys.size === 0) return [];
|
|
537
|
+
const usages = (await collectedOf(input)).scan?.usages ?? {};
|
|
538
|
+
const hooked = [...keys].toSorted().flatMap((key) => (usages[key] ?? []).filter((usage) => usage.call === "usePermission").map((usage) => ({
|
|
539
|
+
key,
|
|
540
|
+
usage
|
|
541
|
+
})));
|
|
542
|
+
if (hooked.length === 0) return [];
|
|
543
|
+
const sources = listSourceFiles(input.cwd, doctorSrcPath(input.config));
|
|
544
|
+
if (hasEndpoint(filesOf(input), input.cwd, sources)) return [];
|
|
545
|
+
return hooked.map(({ key, usage }) => ({
|
|
546
|
+
code: "PD044",
|
|
547
|
+
severity: "warning",
|
|
548
|
+
message: `usePermission reads ${key} at ${usage.file}:${String(usage.line)}, whose grant needs the server (a closure, graph relation or period), and no app/**/api/permdock/route.ts exports permdockHandler and no endpoint is configured: the client always answers denied (server-only)`,
|
|
549
|
+
fix: "add app/api/permdock/route.ts exporting permdockHandler(), pass endpoint to createPermDock, or check it on the server with getPermission"
|
|
550
|
+
}));
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* PD065: a file that imports `permdock/react-native` reads a permission whose
|
|
554
|
+
* grant the snapshot cannot answer, so the device answers it only online.
|
|
555
|
+
*/
|
|
556
|
+
async function pd065(input) {
|
|
557
|
+
const policy = await policyOf(input);
|
|
558
|
+
if (policy === void 0) return [];
|
|
559
|
+
const keys = serverOnlyKeys(policy);
|
|
560
|
+
if (keys.size === 0) return [];
|
|
561
|
+
const usages = (await collectedOf(input)).scan?.usages ?? {};
|
|
562
|
+
const native = /* @__PURE__ */ new Map();
|
|
563
|
+
const isNative = (file) => {
|
|
564
|
+
let hit = native.get(file);
|
|
565
|
+
if (hit === void 0) {
|
|
566
|
+
try {
|
|
567
|
+
hit = NATIVE_IMPORT.test(filesOf(input).read(join(input.cwd, file)));
|
|
568
|
+
} catch {
|
|
569
|
+
hit = false;
|
|
570
|
+
}
|
|
571
|
+
native.set(file, hit);
|
|
572
|
+
}
|
|
573
|
+
return hit;
|
|
574
|
+
};
|
|
575
|
+
return [...keys].toSorted().flatMap((key) => (usages[key] ?? []).filter((usage) => CLIENT_READS.has(usage.call) && isNative(usage.file)).map((usage) => ({
|
|
576
|
+
code: "PD065",
|
|
577
|
+
severity: "warning",
|
|
578
|
+
message: `React Native code reads ${key} at ${usage.file}:${String(usage.line)}, whose grant needs the server (a closure, graph relation or period): offline the device answers denied (server-only)`,
|
|
579
|
+
fix: "express the grant with a portable condition if the screen must work offline, or show the offline state for it"
|
|
580
|
+
})));
|
|
581
|
+
}
|
|
582
|
+
//#endregion
|
|
583
|
+
//#region src/cli/doctor/policy.ts
|
|
584
|
+
async function pd017(input) {
|
|
585
|
+
if (input.config.policy === void 0) return [];
|
|
586
|
+
const policy = await policyOf(input);
|
|
587
|
+
if (policy === void 0) return [];
|
|
588
|
+
const sensitive = new Set(input.config.doctor?.sensitiveActions ?? DEFAULT_SENSITIVE_ACTIONS);
|
|
589
|
+
const denied = new Set(policy.grants.filter((grant) => grant.effect === "deny").map((grant) => grant.permission.key));
|
|
590
|
+
const findings = [];
|
|
591
|
+
const seen = /* @__PURE__ */ new Set();
|
|
592
|
+
for (const grant of policy.grants) {
|
|
593
|
+
if (grant.effect !== "allow") continue;
|
|
594
|
+
if (!sensitive.has(grant.permission.action)) continue;
|
|
595
|
+
if (grant.approval !== void 0) continue;
|
|
596
|
+
if (denied.has(grant.permission.key)) continue;
|
|
597
|
+
if (seen.has(grant.permission.key)) continue;
|
|
598
|
+
seen.add(grant.permission.key);
|
|
599
|
+
findings.push({
|
|
600
|
+
code: "PD017",
|
|
601
|
+
severity: "warning",
|
|
602
|
+
message: `${grant.permission.key} is a sensitive verb without approval`,
|
|
603
|
+
fix: "add approval: { by } on the allow, or a deny on the same leaf"
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
return findings;
|
|
607
|
+
}
|
|
608
|
+
async function pd024(input) {
|
|
609
|
+
if (input.config.policy === void 0) return [];
|
|
610
|
+
const policy = await policyOf(input);
|
|
611
|
+
if (policy === void 0) return [];
|
|
612
|
+
const findings = [];
|
|
613
|
+
for (const grant of policy.grants) {
|
|
614
|
+
const approval = grant.approval;
|
|
615
|
+
if (grant.effect !== "allow" || approval === void 0 || approval === "human" || approval.distinct !== false) continue;
|
|
616
|
+
findings.push({
|
|
617
|
+
code: "PD024",
|
|
618
|
+
severity: "warning",
|
|
619
|
+
message: `${grant.permission.key}${grant.role === null ? "" : ` (role ${grant.role})`} sets approval.distinct: false, so the requester can approve their own request`,
|
|
620
|
+
fix: "remove distinct: false unless the requester confirming their own call (an agent asking its user) is the intent"
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return findings;
|
|
624
|
+
}
|
|
625
|
+
async function pd021(input) {
|
|
626
|
+
if (input.config.policy === void 0) return [];
|
|
627
|
+
const policy = await policyOf(input);
|
|
628
|
+
if (policy === void 0 || (policy.hostable ?? []).length === 0) return [];
|
|
629
|
+
const missing = ["PERMDOCK_CLOUD_URL", "PERMDOCK_CLOUD_KEY"].filter((name) => (input.env[name] ?? "") === "");
|
|
630
|
+
if (missing.length === 0) return [];
|
|
631
|
+
return [{
|
|
632
|
+
code: "PD021",
|
|
633
|
+
severity: "warning",
|
|
634
|
+
message: `the policy lists hostable permissions but ${missing.join(" and ")} is not set, so no hosted grant can be published or fetched`,
|
|
635
|
+
fix: "set the variables from the PermDock Cloud dashboard, or remove hostable"
|
|
636
|
+
}];
|
|
637
|
+
}
|
|
638
|
+
async function pd019(input) {
|
|
639
|
+
const rls = input.config.rls;
|
|
640
|
+
if ((rls?.authorize ?? rls?.rbac?.authorize) !== "jwt") return [];
|
|
641
|
+
const expiry = supabaseConfig(input.cwd).jwtExpiry ?? 3600;
|
|
642
|
+
if (expiry <= 3600 || input.config.policy === void 0) return [];
|
|
643
|
+
const policy = await policyOf(input);
|
|
644
|
+
if (policy === void 0) return [];
|
|
645
|
+
const sensitive = new Set(input.config.doctor?.sensitiveActions ?? DEFAULT_SENSITIVE_ACTIONS);
|
|
646
|
+
const keys = [...new Set(policy.grants.filter((grant) => grant.effect === "allow" && sensitive.has(grant.permission.action)).map((grant) => grant.permission.key))];
|
|
647
|
+
if (keys.length === 0) return [];
|
|
648
|
+
return [{
|
|
649
|
+
code: "PD019",
|
|
650
|
+
severity: "warning",
|
|
651
|
+
message: `the RLS helpers and authorize() read roles from the JWT and jwt_expiry is ${String(expiry)}s: a revoked role keeps ${keys.join(", ")} until the token expires`,
|
|
652
|
+
fix: "set rls.authorize: 'database', or lower [auth] jwt_expiry in supabase/config.toml to 3600 or less"
|
|
653
|
+
}];
|
|
654
|
+
}
|
|
655
|
+
/** Fixture memberships the policy's scopes would drop: an undeclared scope, a missing parent id, mixed shapes. */
|
|
656
|
+
/**
|
|
657
|
+
* A scope whose roles can all be removed can end up with nobody able to
|
|
658
|
+
* manage it. Setting `min` on any of its roles (even `min: 0`) records the
|
|
659
|
+
* decision and silences the warning.
|
|
660
|
+
*/
|
|
661
|
+
async function pd026(input) {
|
|
662
|
+
if (input.config.policy === void 0) return [];
|
|
663
|
+
const policy = await policyOf(input);
|
|
664
|
+
if (policy === void 0) return [];
|
|
665
|
+
const scopes = scopeList(policy.scopes);
|
|
666
|
+
const findings = [];
|
|
667
|
+
for (const { name } of scopes) {
|
|
668
|
+
const held = policy.roles.filter((binding) => typeof binding.on === "string" && resolveScope(scopes, binding.on) === name);
|
|
669
|
+
if (held.length === 0 || held.some((binding) => binding.min !== void 0)) continue;
|
|
670
|
+
findings.push({
|
|
671
|
+
code: "PD026",
|
|
672
|
+
severity: "warning",
|
|
673
|
+
message: `no role on scope '${name}' keeps a holder: every ${name} can lose its last ${held.map((binding) => binding.name).join(" / ")}`,
|
|
674
|
+
fix: `set min: 1 on the role that manages a ${name} (role(..., { on: '${name}', min: 1 })), or min: 0 to record that none must stay`
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return findings;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* PD033: a role activation without a `maxDuration` (an elevation with no
|
|
681
|
+
* ceiling never expires on its own), and an activation role that a fixture
|
|
682
|
+
* membership also holds standing (an eligible-only role must never be held
|
|
683
|
+
* directly).
|
|
684
|
+
*/
|
|
685
|
+
async function pd033(input) {
|
|
686
|
+
if (input.config.policy === void 0) return [];
|
|
687
|
+
const policy = await policyOf(input);
|
|
688
|
+
if (policy === void 0) return [];
|
|
689
|
+
const findings = [];
|
|
690
|
+
const activation = /* @__PURE__ */ new Set();
|
|
691
|
+
for (const binding of policy.roles) {
|
|
692
|
+
if (binding.activation === void 0) continue;
|
|
693
|
+
activation.add(binding.name);
|
|
694
|
+
if (binding.activation.maxDuration === void 0) findings.push({
|
|
695
|
+
code: "PD033",
|
|
696
|
+
severity: "warning",
|
|
697
|
+
message: `role '${binding.name}' has activation without maxDuration: an elevation never expires on its own`,
|
|
698
|
+
fix: `set activation: { maxDuration: '4h', ... } on role '${binding.name}'`
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
const fixture = membershipsFixture(input);
|
|
702
|
+
const standing = /* @__PURE__ */ new Set();
|
|
703
|
+
const eligible = /* @__PURE__ */ new Set();
|
|
704
|
+
for (const membership of fixture.memberships ?? []) {
|
|
705
|
+
for (const name of membership.roles ?? []) standing.add(name);
|
|
706
|
+
for (const name of membership.eligible ?? []) eligible.add(name);
|
|
707
|
+
}
|
|
708
|
+
for (const name of activation) if (standing.has(name)) findings.push({
|
|
709
|
+
code: "PD033",
|
|
710
|
+
severity: "warning",
|
|
711
|
+
message: `activation role '${name}' is held standing by a fixture membership: an eligible-only role must never be held directly`,
|
|
712
|
+
fix: `list '${name}' under eligible, never roles, and activate it with permdock.activate`
|
|
713
|
+
});
|
|
714
|
+
return findings;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* PD035: a `supportAccess` role without `actorRequired`. Support access is
|
|
718
|
+
* impersonation; without an actor the session is unattributed.
|
|
719
|
+
*/
|
|
720
|
+
async function pd035(input) {
|
|
721
|
+
if (input.config.policy === void 0) return [];
|
|
722
|
+
const policy = await policyOf(input);
|
|
723
|
+
if (policy === void 0) return [];
|
|
724
|
+
const findings = [];
|
|
725
|
+
for (const binding of policy.roles) if (binding.support !== void 0 && !binding.support.actorRequired) findings.push({
|
|
726
|
+
code: "PD035",
|
|
727
|
+
severity: "warning",
|
|
728
|
+
message: `support access role '${binding.name}' has actorRequired: false: a support session then runs as the tenant, unattributed`,
|
|
729
|
+
fix: `set actorRequired: true on supportAccess({ role: '${binding.name}', ... })`
|
|
730
|
+
});
|
|
731
|
+
return findings;
|
|
732
|
+
}
|
|
733
|
+
//#endregion
|
|
734
|
+
//#region src/cli/doctor/powersync.ts
|
|
735
|
+
/**
|
|
736
|
+
* PD058: `sync-config.yaml` or the `powersync.manifest` file is not what the
|
|
737
|
+
* policy compiles to, so PowerSync syncs rows, or the device builds
|
|
738
|
+
* snapshots, by an older policy. A policy that does not compile is a finding
|
|
739
|
+
* too.
|
|
740
|
+
*/
|
|
741
|
+
async function pd058(input) {
|
|
742
|
+
const { cwd, config } = input;
|
|
743
|
+
if (config.powersync === void 0 || config.policy === void 0) return [];
|
|
744
|
+
const policy = await policyOf(input);
|
|
745
|
+
if (policy === void 0) return [];
|
|
746
|
+
let compiled;
|
|
747
|
+
try {
|
|
748
|
+
compiled = powersyncFiles(policy, config);
|
|
749
|
+
} catch (cause) {
|
|
750
|
+
return [{
|
|
751
|
+
code: "PD058",
|
|
752
|
+
severity: "warning",
|
|
753
|
+
message: `the policy does not compile to Sync Streams: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
754
|
+
fix: "fix the powersync and rls config, then run permdock powersync generate"
|
|
755
|
+
}];
|
|
756
|
+
}
|
|
757
|
+
return staleFiles(cwd, compiled.files).map((file) => ({
|
|
758
|
+
code: "PD058",
|
|
759
|
+
severity: "warning",
|
|
760
|
+
message: file.missing ? `${file.path} is missing, so ${file.path === config.powersync?.manifest ? "the device builds no local snapshot" : "PowerSync syncs no stream the policy compiles to"}` : `${file.path} is not what the policy compiles to, so ${file.path === config.powersync?.manifest ? "the device builds snapshots" : "PowerSync syncs rows"} by an older policy`,
|
|
761
|
+
fix: "run permdock powersync generate and deploy the files"
|
|
762
|
+
}));
|
|
763
|
+
}
|
|
764
|
+
//#endregion
|
|
765
|
+
//#region src/cli/doctor/project.ts
|
|
766
|
+
/**
|
|
767
|
+
* The directories from `cwd` up to the workspace root: the first one with a
|
|
768
|
+
* `pnpm-workspace.yaml`, a `package.json` that declares `workspaces`, or a
|
|
769
|
+
* `.git`. Skills are often installed once at a monorepo's root.
|
|
770
|
+
*/
|
|
771
|
+
function workspaceDirs(files, cwd) {
|
|
772
|
+
const dirs = [];
|
|
773
|
+
let dir = resolve(cwd);
|
|
774
|
+
for (;;) {
|
|
775
|
+
dirs.push(dir);
|
|
776
|
+
if (isWorkspaceRoot(files, dir)) return dirs;
|
|
777
|
+
const parent = dirname(dir);
|
|
778
|
+
if (parent === dir) return [resolve(cwd)];
|
|
779
|
+
dir = parent;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
function isWorkspaceRoot(files, dir) {
|
|
783
|
+
if (files.exists(join(dir, "pnpm-workspace.yaml")) || files.exists(join(dir, ".git"))) return true;
|
|
784
|
+
const manifest = join(dir, "package.json");
|
|
785
|
+
if (!files.exists(manifest)) return false;
|
|
786
|
+
try {
|
|
787
|
+
const parsed = readJson(files, manifest);
|
|
788
|
+
return parsed !== null && typeof parsed === "object" && Object.hasOwn(parsed, "workspaces");
|
|
789
|
+
} catch {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/** Whether `dir` holds a skills lock: PermDock's own, or the `skills` CLI's naming the `permdock` skill. */
|
|
794
|
+
function hasSkillsLock(files, dir) {
|
|
795
|
+
if (files.exists(join(dir, ".permdock/skills-lock.json"))) return true;
|
|
796
|
+
const shared = join(dir, "skills-lock.json");
|
|
797
|
+
if (!files.exists(shared)) return false;
|
|
798
|
+
try {
|
|
799
|
+
const parsed = readJson(files, shared);
|
|
800
|
+
const skills = parsed !== null && typeof parsed === "object" ? Reflect.get(parsed, "skills") : void 0;
|
|
801
|
+
return skills !== null && typeof skills === "object" && Object.hasOwn(skills, "permdock");
|
|
802
|
+
} catch {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
function pd005(cwd, files = nodeFiles) {
|
|
807
|
+
const dirs = workspaceDirs(files, cwd);
|
|
808
|
+
if (!dirs.some((dir) => [
|
|
809
|
+
".agents/skills",
|
|
810
|
+
".claude/skills",
|
|
811
|
+
".cursor/skills"
|
|
812
|
+
].some((folder) => files.exists(join(dir, folder, "permdock/SKILL.md"))))) return [{
|
|
813
|
+
code: "PD005",
|
|
814
|
+
severity: "warning",
|
|
815
|
+
message: "Agent Skills are not installed",
|
|
816
|
+
fix: "pnpm exec permdock skills install"
|
|
817
|
+
}];
|
|
818
|
+
if (!dirs.some((dir) => hasSkillsLock(files, dir))) return [{
|
|
819
|
+
code: "PD005",
|
|
820
|
+
severity: "warning",
|
|
821
|
+
message: "skills lock is missing",
|
|
822
|
+
fix: "pnpm exec permdock skills install"
|
|
823
|
+
}];
|
|
824
|
+
return [];
|
|
825
|
+
}
|
|
826
|
+
function pd006(cwd) {
|
|
827
|
+
try {
|
|
828
|
+
const pkg = createRequire(resolve(cwd, "package.json"))("typescript/package.json");
|
|
829
|
+
const major = Number(pkg.version.split(".")[0]);
|
|
830
|
+
if (major < 5 || major === 5 && Number(pkg.version.split(".")[1]) < 9) return [{
|
|
831
|
+
code: "PD006",
|
|
832
|
+
severity: "error",
|
|
833
|
+
message: `TypeScript ${pkg.version} is below the supported matrix (5.9, 6, 7)`,
|
|
834
|
+
fix: "upgrade typescript to 5.9 or later"
|
|
835
|
+
}];
|
|
836
|
+
if (major > 7) return [{
|
|
837
|
+
code: "PD006",
|
|
838
|
+
severity: "error",
|
|
839
|
+
message: `TypeScript ${pkg.version} is not in the supported matrix (5.9, 6, 7)`,
|
|
840
|
+
fix: "use TypeScript 5.9, 6 or 7"
|
|
841
|
+
}];
|
|
842
|
+
return [];
|
|
843
|
+
} catch {
|
|
844
|
+
return [{
|
|
845
|
+
code: "PD006",
|
|
846
|
+
severity: "error",
|
|
847
|
+
message: "typescript is not installed",
|
|
848
|
+
fix: "add typescript 5.9, 6 or 7"
|
|
849
|
+
}];
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
function pd009(cwd, files = nodeFiles) {
|
|
853
|
+
const copies = [];
|
|
854
|
+
function walk(dir, depth) {
|
|
855
|
+
if (depth > 6 || !files.exists(dir)) return;
|
|
856
|
+
const pkg = join(dir, "node_modules/permdock/package.json");
|
|
857
|
+
if (files.exists(pkg)) copies.push(pkg);
|
|
858
|
+
if (!files.exists(join(dir, "node_modules"))) return;
|
|
859
|
+
for (const name of files.list(join(dir, "node_modules"))) {
|
|
860
|
+
if (name.startsWith(".")) continue;
|
|
861
|
+
const nested = join(dir, "node_modules", name);
|
|
862
|
+
try {
|
|
863
|
+
if (files.isDirectory(nested)) walk(nested, depth + 1);
|
|
864
|
+
} catch {}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
walk(cwd, 0);
|
|
868
|
+
if (copies.length > 1) return [{
|
|
869
|
+
code: "PD009",
|
|
870
|
+
severity: "error",
|
|
871
|
+
message: `duplicate permdock copies: ${copies.map((item) => rel(cwd, dirname(item))).join(", ")}`,
|
|
872
|
+
fix: "dedupe so only one permdock version is installed"
|
|
873
|
+
}];
|
|
874
|
+
return [];
|
|
875
|
+
}
|
|
876
|
+
function pd012(cwd, config, files = nodeFiles) {
|
|
877
|
+
const docs = config.openapi?.doc ?? [];
|
|
878
|
+
const findings = [];
|
|
879
|
+
for (const doc of docs) {
|
|
880
|
+
const abs = resolve(cwd, doc);
|
|
881
|
+
if (!files.exists(abs)) continue;
|
|
882
|
+
const text = files.read(abs);
|
|
883
|
+
if (text.includes("\"drafts\"") && !text.includes("overlay")) findings.push({
|
|
884
|
+
code: "PD012",
|
|
885
|
+
severity: "warning",
|
|
886
|
+
message: `${doc} carries a draft pin the CLI no longer emits`,
|
|
887
|
+
fix: "regenerate with permdock openapi"
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
return findings;
|
|
891
|
+
}
|
|
892
|
+
const VIEW_NAME = String.raw`((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)`;
|
|
893
|
+
const CREATE_VIEW = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?(?:temp(?:orary)?\s+)?(?:recursive\s+)?view\s+${VIEW_NAME}([\s\S]*?)\bas\b`, "giu");
|
|
894
|
+
const ALTER_VIEW = new RegExp(String.raw`\balter\s+view\s+(?:if\s+exists\s+)?${VIEW_NAME}\s+set\s*\(([^)]*)\)`, "giu");
|
|
895
|
+
const INVOKER = /\bsecurity_invoker\s*(?:=\s*(?:true|on|'true'|'on'|1)\b|[,)]|$)/iu;
|
|
896
|
+
const COMPANION = new RegExp(String.raw`\bcomment\s+on\s+view\s+${VIEW_NAME}\s+is\s+'${FIELD_VIEWS.comment}\b`, "giu");
|
|
897
|
+
/**
|
|
898
|
+
* Views run as their owner unless `security_invoker` is set, so they read
|
|
899
|
+
* past RLS on the tables beneath them. The `<table>_visible_fields`
|
|
900
|
+
* companion `rls generate --revoke-columns` writes reads as its owner on
|
|
901
|
+
* purpose and carries a comment that says so.
|
|
902
|
+
*/
|
|
903
|
+
function pd022(cwd, config, files = nodeFiles) {
|
|
904
|
+
if (config.rls === void 0 && config.doctor?.migrations === void 0) return [];
|
|
905
|
+
const created = /* @__PURE__ */ new Map();
|
|
906
|
+
const invoker = /* @__PURE__ */ new Set();
|
|
907
|
+
const companions = /* @__PURE__ */ new Set();
|
|
908
|
+
for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) {
|
|
909
|
+
const raw = files.read(file);
|
|
910
|
+
const text = raw.replaceAll(/--[^\n]*/gu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
|
|
911
|
+
for (const [, name = "", options = ""] of text.matchAll(CREATE_VIEW)) {
|
|
912
|
+
const key = tableKey(name);
|
|
913
|
+
created.set(key, rel(cwd, file));
|
|
914
|
+
if (INVOKER.test(options)) invoker.add(key);
|
|
915
|
+
else invoker.delete(key);
|
|
916
|
+
}
|
|
917
|
+
for (const [, name = "", options = ""] of text.matchAll(ALTER_VIEW)) if (INVOKER.test(options)) invoker.add(tableKey(name));
|
|
918
|
+
for (const [, name = ""] of raw.matchAll(COMPANION)) companions.add(tableKey(name));
|
|
919
|
+
}
|
|
920
|
+
return [...created].filter(([key]) => !invoker.has(key) && !companions.has(key)).map(([key, file]) => ({
|
|
921
|
+
code: "PD022",
|
|
922
|
+
severity: "warning",
|
|
923
|
+
message: `view ${key} in ${file} is not security_invoker, so it reads past row level security`,
|
|
924
|
+
fix: `create the view with (security_invoker = true), or alter view ${key} set (security_invoker = true); Postgres 15 or later. For column-level reads, generate field views with permdock rls generate --fields views`
|
|
925
|
+
}));
|
|
926
|
+
}
|
|
927
|
+
const AUTH_ROLE = /\bauth\.role\s*\(\s*\)/giu;
|
|
928
|
+
/**
|
|
929
|
+
* PD040: `auth.role()` in a migration. Supabase deprecated it; a policy
|
|
930
|
+
* names its Postgres roles with `to authenticated` instead, and the role
|
|
931
|
+
* claim it reads is the one PostgREST already switched to.
|
|
932
|
+
*/
|
|
933
|
+
function pd040(cwd, config, files = nodeFiles) {
|
|
934
|
+
const findings = [];
|
|
935
|
+
for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) {
|
|
936
|
+
const text = files.read(file).replaceAll(/--[^\n]*/gu, (comment) => " ".repeat(comment.length)).replaceAll(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replaceAll(/[^\n]/gu, " "));
|
|
937
|
+
for (const match of text.matchAll(AUTH_ROLE)) {
|
|
938
|
+
const line = text.slice(0, match.index).split("\n").length;
|
|
939
|
+
findings.push({
|
|
940
|
+
code: "PD040",
|
|
941
|
+
severity: "warning",
|
|
942
|
+
message: `${rel(cwd, file)}:${String(line)} calls auth.role(), which Supabase deprecated`,
|
|
943
|
+
fix: "name the roles on the policy (create policy ... to authenticated) and drop the auth.role() = 'authenticated' test; call permdock_has or permitted_<scope>_ids for permissions"
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return findings;
|
|
948
|
+
}
|
|
949
|
+
const GRANT = /\b(grant|revoke)\s+([\s\S]+?)\s+on\s+(?:table\s+)?([\w."]+)\s+(?:to|from)\s+([\w\s,"]+?)(?:\s+with\s+grant\s+option|\s+cascade|\s+restrict)?\s*;/giu;
|
|
950
|
+
const CLIENT_ROLES = /* @__PURE__ */ new Set([
|
|
951
|
+
"anon",
|
|
952
|
+
"authenticated",
|
|
953
|
+
"public"
|
|
954
|
+
]);
|
|
955
|
+
function applyPrivilege(writable, verb, part) {
|
|
956
|
+
const match = /^\s*(all(?:\s+privileges)?|insert|update)\s*(?:\(([^)]*)\))?\s*$/iu.exec(part);
|
|
957
|
+
if (match === null) return;
|
|
958
|
+
const kind = (match[1] ?? "").toLowerCase();
|
|
959
|
+
const sets = kind === "insert" ? [writable.insert] : kind === "update" ? [writable.update] : [writable.insert, writable.update];
|
|
960
|
+
const columns = match[2] === void 0 ? ["*"] : match[2].split(",").map((column) => column.trim().replaceAll("\"", ""));
|
|
961
|
+
for (const set of sets) for (const column of columns) if (verb.toLowerCase() === "grant") set.add(column);
|
|
962
|
+
else if (column === "*") set.clear();
|
|
963
|
+
else set.delete(column);
|
|
964
|
+
}
|
|
965
|
+
const CREATE_TABLE$1 = /^create\s+(?:(?:global|local)\s+)?(?:(?:temp|temporary|unlogged)\s+)?table\s+(?:if\s+not\s+exists\s+)?((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)\s*\(/iu;
|
|
966
|
+
const REVOKE_DEFAULT_TABLES = /^alter\s+default\s+privileges\s+(?:for\s+(?:role|user)\s+[\w\s,"]+?\s+)?in\s+schema\s+"?public"?\s+revoke\s+[\s\S]+?\s+on\s+tables\s+from\s+[\s\S]*\b(?:anon|authenticated)\b/iu;
|
|
967
|
+
/**
|
|
968
|
+
* Columns of `table` a client role may insert or update, per the migrations
|
|
969
|
+
* in order; `*` for every column. On Supabase a table created in `public`
|
|
970
|
+
* starts writable: the default privileges grant `anon` and `authenticated`
|
|
971
|
+
* everything, until a migration revokes them.
|
|
972
|
+
*/
|
|
973
|
+
function clientWritable(files, cwd, config, target) {
|
|
974
|
+
const writable = {
|
|
975
|
+
insert: /* @__PURE__ */ new Set(),
|
|
976
|
+
update: /* @__PURE__ */ new Set()
|
|
977
|
+
};
|
|
978
|
+
let defaults = config.supabase !== void 0 && target.startsWith("public.");
|
|
979
|
+
for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) for (const { text } of sqlStatements(files.read(file))) {
|
|
980
|
+
if (REVOKE_DEFAULT_TABLES.test(text)) {
|
|
981
|
+
defaults = false;
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
const created = CREATE_TABLE$1.exec(text);
|
|
985
|
+
if (created !== null) {
|
|
986
|
+
if (defaults && tableKey(created[1] ?? "") === target) {
|
|
987
|
+
writable.insert.add("*");
|
|
988
|
+
writable.update.add("*");
|
|
989
|
+
}
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
for (const [, verb = "", privileges = "", name = "", roles = ""] of `${text};`.matchAll(GRANT)) {
|
|
993
|
+
if (tableKey(name) !== target) continue;
|
|
994
|
+
if (!roles.split(",").map((role) => role.trim().replaceAll("\"", "").toLowerCase()).some((role) => CLIENT_ROLES.has(role))) continue;
|
|
995
|
+
for (const part of privileges.split(/,(?![^(]*\))/u)) applyPrivilege(writable, verb, part);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return /* @__PURE__ */ new Set([...writable.insert, ...writable.update]);
|
|
999
|
+
}
|
|
1000
|
+
/** Membership columns a client can write: a user who sets `user_id` or `role` grants themselves access. */
|
|
1001
|
+
function membershipFindings(files, cwd, config) {
|
|
1002
|
+
const findings = [];
|
|
1003
|
+
for (const [table, columns] of membershipColumns(config)) {
|
|
1004
|
+
const writable = clientWritable(files, cwd, config, table);
|
|
1005
|
+
const exposed = [...columns].filter((column) => writable.has("*") || writable.has(column));
|
|
1006
|
+
if (exposed.length > 0) findings.push({
|
|
1007
|
+
code: "PD028",
|
|
1008
|
+
severity: "warning",
|
|
1009
|
+
message: `${table}.${exposed.join(", ")} decide${exposed.length === 1 ? "s" : ""} memberships, and the migrations let anon or authenticated insert or update ${exposed.length === 1 ? "it" : "them"}: a user could give themselves a membership`,
|
|
1010
|
+
fix: `revoke insert, update on ${table} from anon, authenticated, then grant update (<columns clients edit>) on ${table} to authenticated; a column-level revoke alone leaves a table-level grant in place`
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
return findings;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* `attrs` claims must come from server-owned columns: never `user_metadata`,
|
|
1017
|
+
* never a column clients can write. Membership sources' deciding columns are
|
|
1018
|
+
* held to the same rule.
|
|
1019
|
+
*/
|
|
1020
|
+
function pd028(cwd, config, plan, files = nodeFiles) {
|
|
1021
|
+
const memberships = membershipFindings(files, cwd, config);
|
|
1022
|
+
const attrs = config.supabase?.hook?.attrs;
|
|
1023
|
+
if (attrs === void 0) return memberships;
|
|
1024
|
+
const planned = plan(attrs);
|
|
1025
|
+
const findings = planned.errors.map((message) => ({
|
|
1026
|
+
code: "PD028",
|
|
1027
|
+
severity: "error",
|
|
1028
|
+
message,
|
|
1029
|
+
fix: "list server-owned columns or app_metadata.<key> entries; user_metadata is user-editable"
|
|
1030
|
+
}));
|
|
1031
|
+
if (planned.table !== void 0 && planned.columns.length > 0) {
|
|
1032
|
+
const writable = clientWritable(files, cwd, config, tableKey(planned.table));
|
|
1033
|
+
const exposed = planned.columns.filter((column) => writable.has("*") || writable.has(column));
|
|
1034
|
+
if (exposed.length > 0) findings.push({
|
|
1035
|
+
code: "PD028",
|
|
1036
|
+
severity: "warning",
|
|
1037
|
+
message: `attrs reads ${exposed.join(", ")} from ${tableKey(planned.table)}, which the migrations let anon or authenticated insert or update: a user could set their own attribute`,
|
|
1038
|
+
fix: `revoke insert, update on ${tableKey(planned.table)} from anon, authenticated, and grant column-level update only on columns that are not attributes; the generated hook migration refuses to install otherwise`
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
return [...findings, ...memberships];
|
|
1042
|
+
}
|
|
1043
|
+
//#endregion
|
|
1044
|
+
//#region src/cli/doctor/rls.ts
|
|
1045
|
+
async function pd027(input) {
|
|
1046
|
+
if (input.config.rls === void 0 || input.config.policy === void 0) return [];
|
|
1047
|
+
const policy = await policyOf(input);
|
|
1048
|
+
if (policy === void 0) return [];
|
|
1049
|
+
const findings = [];
|
|
1050
|
+
for (const grant of policy.grants) {
|
|
1051
|
+
const refs = [.../* @__PURE__ */ new Set([...contextRefs(grant.where), ...contextRefs(grant.check)])];
|
|
1052
|
+
if (refs.length === 0) continue;
|
|
1053
|
+
findings.push({
|
|
1054
|
+
code: "PD027",
|
|
1055
|
+
severity: "warning",
|
|
1056
|
+
message: `${grant.permission.key}${grant.role === null ? "" : ` (role ${grant.role})`} reads ${refs.join(", ")}: request context is not in the token, so permdock rls generate cannot compile it and the database cannot enforce it`,
|
|
1057
|
+
fix: "move the value to a server-set claim and compare with principal.claims.<name>, or keep the check API-side and pass --skip-closures to rls generate"
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
return findings;
|
|
1061
|
+
}
|
|
1062
|
+
async function pd016(input) {
|
|
1063
|
+
const files = filesOf(input);
|
|
1064
|
+
if (input.config.rls === void 0 || input.config.policy === void 0) return [];
|
|
1065
|
+
const policy = await policyOf(input);
|
|
1066
|
+
if (policy === void 0) return [];
|
|
1067
|
+
const findings = [];
|
|
1068
|
+
let opaque = false;
|
|
1069
|
+
let sqlFunction = false;
|
|
1070
|
+
for (const grant of policy.grants) {
|
|
1071
|
+
if (hasConditionOp(grant.where, "opaque") || hasConditionOp(grant.check, "opaque")) opaque = true;
|
|
1072
|
+
if (hasConditionOp(grant.where, "sqlFunction") || hasConditionOp(grant.check, "sqlFunction")) sqlFunction = true;
|
|
1073
|
+
}
|
|
1074
|
+
if (opaque) findings.push({
|
|
1075
|
+
code: "PD016",
|
|
1076
|
+
severity: "warning",
|
|
1077
|
+
message: "policy has opaque RLS conditions that deny in memory",
|
|
1078
|
+
fix: "add rls.functions.<name> with a portable twin, or rewrite as sqlFunction()"
|
|
1079
|
+
});
|
|
1080
|
+
if (sqlFunction) {
|
|
1081
|
+
const fixtures = input.config.rls.fixtures ?? "rls.fixtures.json";
|
|
1082
|
+
if (!files.exists(resolve(input.cwd, fixtures)) && !files.exists(resolve(input.cwd, "rls.fixtures.ts"))) findings.push({
|
|
1083
|
+
code: "PD016",
|
|
1084
|
+
severity: "warning",
|
|
1085
|
+
message: "sqlFunction grants have no rls fixtures for verify --db",
|
|
1086
|
+
fix: "add rls.fixtures.json and run permdock rls verify --db"
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
return findings;
|
|
1090
|
+
}
|
|
1091
|
+
async function pd020(input) {
|
|
1092
|
+
if (input.config.rls === void 0 || input.config.policy === void 0) return [];
|
|
1093
|
+
const policy = await policyOf(input);
|
|
1094
|
+
if (policy === void 0) return [];
|
|
1095
|
+
const tables = input.config.rls.tables;
|
|
1096
|
+
const compiled = (policy.hostable ?? []).filter((key) => {
|
|
1097
|
+
const resource = key.slice(0, key.lastIndexOf("."));
|
|
1098
|
+
return tables === void 0 || tables[resource] !== void 0;
|
|
1099
|
+
});
|
|
1100
|
+
if (compiled.length === 0) return [];
|
|
1101
|
+
return [{
|
|
1102
|
+
code: "PD020",
|
|
1103
|
+
severity: "warning",
|
|
1104
|
+
message: `hostable permissions are also compiled into RLS, which never sees hosted grants: ${compiled.join(", ")}`,
|
|
1105
|
+
fix: "remove them from hostable, or drop their tables from rls.tables and enforce them in the application"
|
|
1106
|
+
}];
|
|
1107
|
+
}
|
|
1108
|
+
/** Columns of `resource` a field-limited read grant can hide: missing from an allow's list, or on a deny's. */
|
|
1109
|
+
function limitedColumns(policy, resource, actions) {
|
|
1110
|
+
const reads = policy.grants.filter((grant) => grant.permission.resource === resource && grant.fields !== void 0 && commandFor(grant.permission.action, actions) === "select");
|
|
1111
|
+
const node = policy.resources.get(resource);
|
|
1112
|
+
const schema = node === void 0 ? null : jsonSchemaOf(node);
|
|
1113
|
+
const properties = schema !== null && typeof schema === "object" ? schema.properties : void 0;
|
|
1114
|
+
const columns = properties !== null && typeof properties === "object" ? Object.keys(properties) : [...new Set(reads.flatMap((grant) => grant.fields ?? []))];
|
|
1115
|
+
const key = node?.id ?? "id";
|
|
1116
|
+
return columns.filter((column) => column !== key && reads.some((grant) => grant.effect === "deny" ? (grant.fields ?? []).includes(column) : !(grant.fields ?? []).includes(column)));
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* `fields` redact in the application; RLS is row-level unless field views
|
|
1120
|
+
* compile, and a field view protects nothing while the base table still
|
|
1121
|
+
* returns the columns to a direct read.
|
|
1122
|
+
*/
|
|
1123
|
+
async function pd030(input) {
|
|
1124
|
+
const rls = input.config.rls;
|
|
1125
|
+
if (rls === void 0 || input.config.policy === void 0) return [];
|
|
1126
|
+
const policy = await policyOf(input);
|
|
1127
|
+
if (policy === void 0) return [];
|
|
1128
|
+
const resources = [...new Set(policy.grants.filter((grant) => grant.fields !== void 0 && commandFor(grant.permission.action, rls.actions) === "select").map((grant) => grant.permission.resource))].toSorted();
|
|
1129
|
+
const findings = [];
|
|
1130
|
+
for (const resource of resources) {
|
|
1131
|
+
const columns = limitedColumns(policy, resource, rls.actions);
|
|
1132
|
+
if (columns.length === 0) continue;
|
|
1133
|
+
const table = tableFor(resource, rls.tables);
|
|
1134
|
+
if (rls.fields !== "views") {
|
|
1135
|
+
findings.push({
|
|
1136
|
+
code: "PD030",
|
|
1137
|
+
severity: "warning",
|
|
1138
|
+
message: `read grants on ${resource} limit ${columns.join(", ")}, but RLS on ${table} is row-level: any client that reads ${table} directly gets those columns`,
|
|
1139
|
+
fix: `set rls.fields: 'views' and rls.revokeColumns: true (permdock rls generate --fields views --revoke-columns), or keep direct reads server-side and redact with pick`
|
|
1140
|
+
});
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
if (rls.revokeColumns !== true) findings.push({
|
|
1144
|
+
code: "PD030",
|
|
1145
|
+
severity: "warning",
|
|
1146
|
+
message: `${table}_visible masks ${columns.join(", ")}, but ${table} still returns them to a direct read`,
|
|
1147
|
+
fix: `set rls.revokeColumns: true (--revoke-columns) so clients read ${columns.join(", ")} only through ${table}_visible`
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
return findings;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* A self-parented resource no `through` grant walks, or a `restricted`
|
|
1154
|
+
* column no graph grant reaches: the declaration has no effect on any
|
|
1155
|
+
* decision, and `rls generate` keeps no closure for it.
|
|
1156
|
+
*/
|
|
1157
|
+
async function pd031(input) {
|
|
1158
|
+
if (input.config.policy === void 0) return [];
|
|
1159
|
+
const policy = await policyOf(input);
|
|
1160
|
+
if (policy === void 0) return [];
|
|
1161
|
+
const plan = graphPlan(policy);
|
|
1162
|
+
const findings = [];
|
|
1163
|
+
for (const node of policy.resources.values()) {
|
|
1164
|
+
const walked = plan.get(node.name)?.closure !== void 0;
|
|
1165
|
+
if (node.parent?.resource === node.name && !walked) findings.push({
|
|
1166
|
+
code: "PD031",
|
|
1167
|
+
severity: "warning",
|
|
1168
|
+
message: `${node.name} parents itself through ${node.parent.field}, but no grant walks it with relation(..., { through: 'parent' }): its ancestors grant nothing and rls generate keeps no closure for it`,
|
|
1169
|
+
fix: `grant through the chain (relation(permissions.${node.name}, '<relation>', { through: 'parent', depth: 16 })) or drop the self-parent`
|
|
1170
|
+
});
|
|
1171
|
+
const reached = [...plan.values()].some((entry) => entry.node.name === node.name || node.parent?.resource === entry.node.name && entry.node.name !== node.name);
|
|
1172
|
+
if (node.restricted !== void 0 && !reached) findings.push({
|
|
1173
|
+
code: "PD031",
|
|
1174
|
+
severity: "warning",
|
|
1175
|
+
message: `${node.name} declares restricted: '${node.restricted}', but no graph grant reaches ${node.name} rows, so the column keeps nothing out`,
|
|
1176
|
+
fix: `add a relation(..., { through: 'parent' }) grant that reaches ${node.name}, or remove restricted`
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
return findings;
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Under an `rls` config, a graph resource `rls generate` cannot name a helper
|
|
1183
|
+
* for: one that shares a scope's name (its `permitted_<name>_ids` would
|
|
1184
|
+
* replace the scope helper) or is not a lowercase SQL name.
|
|
1185
|
+
*/
|
|
1186
|
+
async function pd032(input) {
|
|
1187
|
+
if (input.config.rls === void 0 || input.config.policy === void 0) return [];
|
|
1188
|
+
const policy = await policyOf(input);
|
|
1189
|
+
if (policy === void 0) return [];
|
|
1190
|
+
const scopes = scopeList(policy.scopes);
|
|
1191
|
+
const findings = [];
|
|
1192
|
+
const sqlNames = /* @__PURE__ */ new Map();
|
|
1193
|
+
for (const name of graphPlan(policy).keys()) {
|
|
1194
|
+
const sql = graphSqlName(name);
|
|
1195
|
+
if (sql === void 0) {
|
|
1196
|
+
findings.push({
|
|
1197
|
+
code: "PD032",
|
|
1198
|
+
severity: "error",
|
|
1199
|
+
message: `graph resource ${name} is not a SQL name, so rls generate cannot name its permitted_<name>_ids helper`,
|
|
1200
|
+
fix: "rename the resource to letters, digits and underscores, starting with a letter"
|
|
1201
|
+
});
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
if (scopes.some((scope) => scope.name === sql)) findings.push({
|
|
1205
|
+
code: "PD032",
|
|
1206
|
+
severity: "error",
|
|
1207
|
+
message: `graph resource ${name} shares its SQL name with the ${sql} scope: permitted_${sql}_ids would replace the scope helper, so rls generate refuses it`,
|
|
1208
|
+
fix: `rename the resource (for example ${sql}s or ${sql}_node) or the scope`
|
|
1209
|
+
});
|
|
1210
|
+
const other = sqlNames.get(sql);
|
|
1211
|
+
if (other !== void 0) findings.push({
|
|
1212
|
+
code: "PD032",
|
|
1213
|
+
severity: "error",
|
|
1214
|
+
message: `graph resources ${other} and ${name} both become permitted_${sql}_ids in SQL, so rls generate refuses them`,
|
|
1215
|
+
fix: "rename one of the resources"
|
|
1216
|
+
});
|
|
1217
|
+
sqlNames.set(sql, name);
|
|
1218
|
+
}
|
|
1219
|
+
return findings;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* PD034: a break-glass grant under an `rls` config. RLS never compiles a
|
|
1223
|
+
* break-glass override; the server must read through a `security definer`
|
|
1224
|
+
* function that checks a signed break-glass session and writes an audit row.
|
|
1225
|
+
*/
|
|
1226
|
+
async function pd034(input) {
|
|
1227
|
+
if (input.config.policy === void 0 || input.config.rls === void 0) return [];
|
|
1228
|
+
const policy = await policyOf(input);
|
|
1229
|
+
if (policy === void 0) return [];
|
|
1230
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1231
|
+
for (const grant of policy.grants) if (grant.breakGlass !== void 0) keys.add(grant.permission.key);
|
|
1232
|
+
return [...keys].map((key) => ({
|
|
1233
|
+
code: "PD034",
|
|
1234
|
+
severity: "warning",
|
|
1235
|
+
message: `break-glass grant on ${key} cannot be compiled to RLS: it would leak the override into a database policy`,
|
|
1236
|
+
fix: "read break-glass rows through a security definer function that checks a signed break-glass session and writes an audit row"
|
|
1237
|
+
}));
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Storage and Realtime policies written outside PermDock that call the SQL helpers for a permission whose grants
|
|
1241
|
+
* carry row conditions: the helpers check only role and scope.
|
|
1242
|
+
*/
|
|
1243
|
+
async function pd037(input) {
|
|
1244
|
+
const files = filesOf(input);
|
|
1245
|
+
if (input.config.policy === void 0) return [];
|
|
1246
|
+
const policy = await policyOf(input);
|
|
1247
|
+
if (policy === void 0) return [];
|
|
1248
|
+
const conditioned = policyRowConditionKeys(policy);
|
|
1249
|
+
const findings = [];
|
|
1250
|
+
for (const file of sqlFiles(input.cwd, input.config.doctor?.migrations ?? MIGRATION_DIRS)) for (const found of helperTablePolicies(files.read(file))) {
|
|
1251
|
+
const keys = found.keys.filter((key) => conditioned.has(key));
|
|
1252
|
+
if (keys.length > 0) findings.push({
|
|
1253
|
+
code: "PD037",
|
|
1254
|
+
severity: "error",
|
|
1255
|
+
message: rowConditionMessage(found, keys),
|
|
1256
|
+
fix: ROW_CONDITION_FIX
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
return findings;
|
|
1260
|
+
}
|
|
1261
|
+
//#endregion
|
|
1262
|
+
//#region src/cli/doctor/seeds.ts
|
|
1263
|
+
const SEED_INSERT = /^insert\s+into\s+((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)\s*\(\s*role\s*,\s*permission\s*,\s*grant_key\s*,\s*scope\s*,\s*effect\s*\)\s*values\s*([\s\S]*?)\s*on\s+conflict\b/iu;
|
|
1264
|
+
const SEED_CLEAR = /^delete\s+from\s+((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)\s*$/iu;
|
|
1265
|
+
const LITERAL = String.raw`'((?:[^']|'')*)'`;
|
|
1266
|
+
const SEED_ROW = new RegExp(String.raw`\(\s*${LITERAL}\s*,\s*${LITERAL}\s*,\s*${LITERAL}\s*,\s*${LITERAL}\s*,\s*${LITERAL}\s*\)`, "gu");
|
|
1267
|
+
function rowKey(values) {
|
|
1268
|
+
return values.join(" ");
|
|
1269
|
+
}
|
|
1270
|
+
/** The rows of the last statement that seeds or clears `table`, in migration order. */
|
|
1271
|
+
function lastSeed(files, cwd, config, table) {
|
|
1272
|
+
let seeded;
|
|
1273
|
+
for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) for (const { text } of sqlStatements(files.read(file))) {
|
|
1274
|
+
const insert = SEED_INSERT.exec(text);
|
|
1275
|
+
if (insert !== null && tableKey(group(insert, 1)) === table) {
|
|
1276
|
+
const rows = /* @__PURE__ */ new Set();
|
|
1277
|
+
for (const row of group(insert, 2).matchAll(SEED_ROW)) rows.add(rowKey(row.slice(1, 6).map((value) => value.replaceAll("''", "'"))));
|
|
1278
|
+
seeded = {
|
|
1279
|
+
file: rel(cwd, file),
|
|
1280
|
+
rows
|
|
1281
|
+
};
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
const clear = SEED_CLEAR.exec(text);
|
|
1285
|
+
if (clear !== null && tableKey(group(clear, 1)) === table) seeded = {
|
|
1286
|
+
file: rel(cwd, file),
|
|
1287
|
+
rows: /* @__PURE__ */ new Set()
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
return seeded;
|
|
1291
|
+
}
|
|
1292
|
+
/** The first three rows, as `(role permission grant_key scope effect)`. */
|
|
1293
|
+
function sample(rows) {
|
|
1294
|
+
return rows.slice(0, 3).map((row) => `(${row})`).join(", ");
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* PD054: the `role_permissions` rows the migrations seed last are not the
|
|
1298
|
+
* rows the policy compiles to, so the helpers answer from a stale policy.
|
|
1299
|
+
*/
|
|
1300
|
+
async function pd054(input) {
|
|
1301
|
+
const { cwd, config } = input;
|
|
1302
|
+
const rls = config.rls;
|
|
1303
|
+
if (config.policy === void 0 || rls === void 0 && config.supabase === void 0) return [];
|
|
1304
|
+
const schema = rls?.schema ?? rls?.rbac?.schema ?? "permdock";
|
|
1305
|
+
const seeded = lastSeed(filesOf(input), cwd, config, `${schema}.role_permissions`);
|
|
1306
|
+
if (seeded === void 0) return [];
|
|
1307
|
+
const policy = await policyOf(input);
|
|
1308
|
+
if (policy === void 0) return [];
|
|
1309
|
+
const ctx = {
|
|
1310
|
+
dialect: rls?.dialect ?? "supabase",
|
|
1311
|
+
scopes: scopeList(policy.scopes),
|
|
1312
|
+
tenantClaim: rls?.tenantClaim ?? "tenant_id",
|
|
1313
|
+
gucPrefix: rls?.gucPrefix ?? "app",
|
|
1314
|
+
...rls?.actions === void 0 ? {} : { actions: rls.actions },
|
|
1315
|
+
schema,
|
|
1316
|
+
...rls?.fields === "views" ? { fields: "views" } : {}
|
|
1317
|
+
};
|
|
1318
|
+
let expected;
|
|
1319
|
+
try {
|
|
1320
|
+
expected = new Set(compileGrants(policy, ctx, rls?.tables, [], true).rolePermissions.map((row) => rowKey([
|
|
1321
|
+
row.role,
|
|
1322
|
+
row.permission,
|
|
1323
|
+
row.grantKey,
|
|
1324
|
+
row.scope,
|
|
1325
|
+
row.effect
|
|
1326
|
+
])));
|
|
1327
|
+
} catch {
|
|
1328
|
+
return [];
|
|
1329
|
+
}
|
|
1330
|
+
const missing = [...expected].filter((row) => !seeded.rows.has(row));
|
|
1331
|
+
const stale = [...seeded.rows].filter((row) => !expected.has(row));
|
|
1332
|
+
if (missing.length === 0 && stale.length === 0) return [];
|
|
1333
|
+
const parts = [...missing.length === 0 ? [] : [`${String(missing.length)} missing, such as ${sample(missing)}`], ...stale.length === 0 ? [] : [`${String(stale.length)} stale, such as ${sample(stale)}`]];
|
|
1334
|
+
return [{
|
|
1335
|
+
code: "PD054",
|
|
1336
|
+
severity: "warning",
|
|
1337
|
+
message: `${seeded.file} seeds ${schema}.role_permissions with rows the policy no longer compiles to: ${parts.join("; ")}`,
|
|
1338
|
+
fix: "run permdock rls generate (with --split ...,seeds --seeds-out into a new migration for declarative schemas) and apply it"
|
|
1339
|
+
}];
|
|
1340
|
+
}
|
|
1341
|
+
//#endregion
|
|
1342
|
+
//#region src/cli/doctor/sql.ts
|
|
1343
|
+
/** Supabase's `[api] schemas` when `config.toml` sets none. */
|
|
1344
|
+
const API_SCHEMAS = ["public", "graphql_public"];
|
|
1345
|
+
const NAME = String.raw`((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)`;
|
|
1346
|
+
const IDENT = String.raw`("[^"]+"|\w+)`;
|
|
1347
|
+
/** A statement without its dollar-quoted bodies: what a function header says. */
|
|
1348
|
+
function header(text) {
|
|
1349
|
+
return text.replaceAll(/\$([A-Za-z_]*)\$[\s\S]*?\$\1\$/gu, "$$$$");
|
|
1350
|
+
}
|
|
1351
|
+
function isSupabase(files, cwd, config) {
|
|
1352
|
+
return files.exists(join(cwd, "supabase")) || config.rls?.dialect === "supabase" || config.supabase !== void 0;
|
|
1353
|
+
}
|
|
1354
|
+
function migrationStatements(files, cwd, config) {
|
|
1355
|
+
return sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS).flatMap((file) => sqlStatements(files.read(file)).map((statement) => Object.assign({ file: rel(cwd, file) }, statement)));
|
|
1356
|
+
}
|
|
1357
|
+
/** The schema of a `tableKey`, which always names one. */
|
|
1358
|
+
function schemaOf(key) {
|
|
1359
|
+
return key.slice(0, key.indexOf("."));
|
|
1360
|
+
}
|
|
1361
|
+
function unquote(name) {
|
|
1362
|
+
return name.replaceAll("\"", "").toLowerCase();
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* PD046: the schema of PermDock's `security definer` helpers is one the Data
|
|
1366
|
+
* API exposes, so PostgREST serves them as RPCs.
|
|
1367
|
+
*/
|
|
1368
|
+
function pd046(cwd, config, files = nodeFiles) {
|
|
1369
|
+
if (!files.exists(join(cwd, "supabase/config.toml"))) return [];
|
|
1370
|
+
const schema = config.rls?.schema ?? config.rls?.rbac?.schema ?? "permdock";
|
|
1371
|
+
if (!(supabaseConfig(cwd).apiSchemas ?? API_SCHEMAS).includes(schema)) return [];
|
|
1372
|
+
return [{
|
|
1373
|
+
code: "PD046",
|
|
1374
|
+
severity: "warning",
|
|
1375
|
+
message: `the PermDock helper schema ${schema} is in [api] schemas in supabase/config.toml, so the Data API serves its security definer helpers as RPCs`,
|
|
1376
|
+
fix: schema === "public" ? `drop rls.schema: 'public' so the helpers move to the private ${PERMDOCK_SCHEMA} schema, regenerate with permdock rls generate, and keep ${PERMDOCK_SCHEMA} out of [api] schemas` : `remove ${schema} from [api] schemas; policies reach the helpers through grant usage, not the Data API`
|
|
1377
|
+
}];
|
|
1378
|
+
}
|
|
1379
|
+
const USER_METADATA = /\b(raw_user_meta_data|user_metadata)\b/giu;
|
|
1380
|
+
/** PD047: SQL that reads `user_metadata`, which every user can edit for themselves. */
|
|
1381
|
+
function pd047(cwd, config, files = nodeFiles) {
|
|
1382
|
+
if (!isSupabase(files, cwd, config)) return [];
|
|
1383
|
+
return migrationStatements(files, cwd, config).flatMap((statement) => [...statement.text.matchAll(USER_METADATA)].map((match) => ({
|
|
1384
|
+
code: "PD047",
|
|
1385
|
+
severity: "warning",
|
|
1386
|
+
message: `${statement.file}:${String(statement.line + statement.text.slice(0, match.index).split("\n").length - 1)} reads ${match[0]}, which a user can set for themselves`,
|
|
1387
|
+
fix: "decide access from app_metadata (raw_app_meta_data), a server-owned table or a hook claim; user_metadata is for display only"
|
|
1388
|
+
})));
|
|
1389
|
+
}
|
|
1390
|
+
const CREATE_FUNCTION = new RegExp(String.raw`^create\s+(?:or\s+replace\s+)?function\s+${NAME}\s*\(`, "iu");
|
|
1391
|
+
const ALTER_SEARCH_PATH = new RegExp(String.raw`^alter\s+function\s+${NAME}\s*(?:\([^)]*\))?\s+set\s+search_path\b`, "iu");
|
|
1392
|
+
const REVOKE_EXECUTE = new RegExp(String.raw`^revoke\s+(?:all(?:\s+privileges)?|execute)\s+on\s+(?:function|routine)\s+${NAME}\s*(?:\([^)]*\))?\s+from\s+([\s\S]+)$`, "iu");
|
|
1393
|
+
const REVOKE_ALL_FUNCTIONS = new RegExp(String.raw`^(?:alter\s+default\s+privileges\s+(?:for\s+role\s+\S+\s+)?in\s+schema\s+${IDENT}\s+)?revoke\s+(?:all(?:\s+privileges)?|execute)\s+on\s+(?:all\s+)?(?:functions|routines)(?:\s+in\s+schema\s+${IDENT})?\s+from\s+([\s\S]+)$`, "iu");
|
|
1394
|
+
function createdFunctions(statements) {
|
|
1395
|
+
return statements.flatMap((statement) => {
|
|
1396
|
+
const match = CREATE_FUNCTION.exec(statement.text);
|
|
1397
|
+
return match === null ? [] : [{
|
|
1398
|
+
...statement,
|
|
1399
|
+
key: tableKey(group(match, 1)),
|
|
1400
|
+
head: header(statement.text)
|
|
1401
|
+
}];
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
/** PD048: a function without `set search_path`, so a caller's objects can shadow the ones it names. */
|
|
1405
|
+
function pd048(cwd, config, files = nodeFiles) {
|
|
1406
|
+
if (!isSupabase(files, cwd, config)) return [];
|
|
1407
|
+
const statements = migrationStatements(files, cwd, config);
|
|
1408
|
+
const altered = new Set(statements.flatMap((statement) => {
|
|
1409
|
+
const match = ALTER_SEARCH_PATH.exec(statement.text);
|
|
1410
|
+
return match === null ? [] : [tableKey(group(match, 1))];
|
|
1411
|
+
}));
|
|
1412
|
+
return createdFunctions(statements).filter((fn) => !/\bset\s+search_path\b/iu.test(fn.head) && !altered.has(fn.key) && !/\blanguage\s+(?:c|internal)\b/iu.test(fn.head)).map((fn) => {
|
|
1413
|
+
const definer = /\bsecurity\s+definer\b/iu.test(fn.head);
|
|
1414
|
+
return {
|
|
1415
|
+
code: "PD048",
|
|
1416
|
+
severity: "warning",
|
|
1417
|
+
message: `${fn.file}:${String(fn.line)} ${definer ? "security definer " : ""}function ${fn.key} sets no search_path${definer ? ", so a caller can shadow what it reads with their own objects" : ""}`,
|
|
1418
|
+
fix: `add set search_path = '' and qualify every name in the body (Supabase advisor function_search_path_mutable)`
|
|
1419
|
+
};
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
/** Roles each `revoke` statement takes `execute` from, per function key or `schema.*`. */
|
|
1423
|
+
function revokedExecute(statements) {
|
|
1424
|
+
const revoked = /* @__PURE__ */ new Map();
|
|
1425
|
+
const add = (key, roles) => {
|
|
1426
|
+
const set = revoked.get(key) ?? /* @__PURE__ */ new Set();
|
|
1427
|
+
for (const role of roles.split(",")) set.add(unquote(role.trim().replace(/\s+(?:cascade|restrict)$/iu, "")));
|
|
1428
|
+
revoked.set(key, set);
|
|
1429
|
+
};
|
|
1430
|
+
for (const statement of statements) {
|
|
1431
|
+
const one = REVOKE_EXECUTE.exec(statement.text);
|
|
1432
|
+
if (one !== null) {
|
|
1433
|
+
add(tableKey(group(one, 1)), group(one, 2));
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
const all = REVOKE_ALL_FUNCTIONS.exec(statement.text);
|
|
1437
|
+
if (all !== null) {
|
|
1438
|
+
const schema = all[1] ?? all[2];
|
|
1439
|
+
add(`${schema === void 0 ? "*" : unquote(schema)}.*`, group(all, 3));
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return revoked;
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* PD049: a `security definer` function `public` (every role) or, in Supabase's
|
|
1446
|
+
* `public` schema, `anon` may still execute: Postgres grants `execute` to
|
|
1447
|
+
* `public` on create, and Supabase's default privileges grant it to `anon`.
|
|
1448
|
+
*/
|
|
1449
|
+
function pd049(cwd, config, files = nodeFiles) {
|
|
1450
|
+
if (!isSupabase(files, cwd, config)) return [];
|
|
1451
|
+
const statements = migrationStatements(files, cwd, config);
|
|
1452
|
+
const revoked = revokedExecute(statements);
|
|
1453
|
+
const findings = [];
|
|
1454
|
+
for (const fn of createdFunctions(statements)) {
|
|
1455
|
+
if (!/\bsecurity\s+definer\b/iu.test(fn.head) || /\breturns\s+(?:event_)?trigger\b/iu.test(fn.head)) continue;
|
|
1456
|
+
const schema = schemaOf(fn.key);
|
|
1457
|
+
const from = /* @__PURE__ */ new Set([
|
|
1458
|
+
...revoked.get(fn.key) ?? [],
|
|
1459
|
+
...revoked.get(`${schema}.*`) ?? [],
|
|
1460
|
+
...revoked.get("*.*") ?? []
|
|
1461
|
+
]);
|
|
1462
|
+
const missing = ["public", ...schema === "public" ? ["anon"] : []].filter((role) => !from.has(role));
|
|
1463
|
+
if (missing.length > 0) findings.push({
|
|
1464
|
+
code: "PD049",
|
|
1465
|
+
severity: "warning",
|
|
1466
|
+
message: `${fn.file}:${String(fn.line)} security definer function ${fn.key} can be executed by ${missing.join(" and ")}`,
|
|
1467
|
+
fix: `revoke execute on function ${fn.key}(…) from public, anon; then grant execute to the roles that call it`
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
return findings;
|
|
1471
|
+
}
|
|
1472
|
+
const CREATE_TABLE = new RegExp(String.raw`^create\s+(?:(?:global|local)\s+)?(?:(?:temp|temporary|unlogged)\s+)?table\s+(?:if\s+not\s+exists\s+)?${NAME}\s*\(`, "iu");
|
|
1473
|
+
const ENABLE_RLS = new RegExp(String.raw`^alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?${NAME}[\s\S]*\benable\s+row\s+level\s+security\b`, "iu");
|
|
1474
|
+
const DROP_TABLE = /^drop\s+table\s+(?:if\s+exists\s+)?([\s\S]+?)(?:\s+(?:cascade|restrict))?$/iu;
|
|
1475
|
+
/** PD050: a `public` table without row level security, which the Data API serves whole. */
|
|
1476
|
+
function pd050(cwd, config, files = nodeFiles) {
|
|
1477
|
+
if (!isSupabase(files, cwd, config)) return [];
|
|
1478
|
+
const created = /* @__PURE__ */ new Map();
|
|
1479
|
+
const enabled = /* @__PURE__ */ new Set();
|
|
1480
|
+
for (const statement of migrationStatements(files, cwd, config)) {
|
|
1481
|
+
const table = CREATE_TABLE.exec(statement.text);
|
|
1482
|
+
if (table !== null) {
|
|
1483
|
+
created.set(tableKey(group(table, 1)), statement);
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
const rls = ENABLE_RLS.exec(statement.text);
|
|
1487
|
+
if (rls !== null) {
|
|
1488
|
+
enabled.add(tableKey(group(rls, 1)));
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
const dropped = DROP_TABLE.exec(statement.text);
|
|
1492
|
+
if (dropped !== null) for (const name of group(dropped, 1).split(",")) created.delete(tableKey(name.trim()));
|
|
1493
|
+
}
|
|
1494
|
+
return [...created].filter(([key]) => schemaOf(key) === "public" && !enabled.has(key)).map(([key, statement]) => ({
|
|
1495
|
+
code: "PD050",
|
|
1496
|
+
severity: "error",
|
|
1497
|
+
message: `${statement.file}:${String(statement.line)} creates ${key} without row level security, so the Data API serves every row to anon and authenticated`,
|
|
1498
|
+
fix: `alter table ${key} enable row level security; permdock rls generate writes its policies`
|
|
1499
|
+
}));
|
|
1500
|
+
}
|
|
1501
|
+
const CREATE_POLICY = new RegExp(String.raw`^create\s+policy\s+${IDENT}\s+on\s+${NAME}([\s\S]*)$`, "iu");
|
|
1502
|
+
const UNWRAPPED_AUTH = /(?<!\bselect\s{1,20})\bauth\.(uid|jwt)\s*\(\s*\)/giu;
|
|
1503
|
+
/** PD051: a policy that calls `auth.uid()` or `auth.jwt()` bare, so Postgres runs it once per row. */
|
|
1504
|
+
function pd051(cwd, config, files = nodeFiles) {
|
|
1505
|
+
if (!isSupabase(files, cwd, config)) return [];
|
|
1506
|
+
return migrationStatements(files, cwd, config).flatMap((statement) => {
|
|
1507
|
+
const policy = CREATE_POLICY.exec(statement.text);
|
|
1508
|
+
if (policy === null) return [];
|
|
1509
|
+
const calls = [...new Set([...group(policy, 3).matchAll(UNWRAPPED_AUTH)].map((match) => `auth.${group(match, 1).toLowerCase()}()`))];
|
|
1510
|
+
const [first] = calls;
|
|
1511
|
+
return first === void 0 ? [] : [{
|
|
1512
|
+
code: "PD051",
|
|
1513
|
+
severity: "warning",
|
|
1514
|
+
message: `${statement.file}:${String(statement.line)} policy ${unquote(group(policy, 1))} on ${tableKey(group(policy, 2))} calls ${calls.join(" and ")} once per row`,
|
|
1515
|
+
fix: `wrap each call as (select ${first}) so Postgres evaluates it once per statement (Supabase advisor auth_rls_initplan)`
|
|
1516
|
+
}];
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
/** PD052: an update policy without `with check`, so nothing states what a row may become. */
|
|
1520
|
+
function pd052(cwd, config, files = nodeFiles) {
|
|
1521
|
+
if (!isSupabase(files, cwd, config)) return [];
|
|
1522
|
+
return migrationStatements(files, cwd, config).flatMap((statement) => {
|
|
1523
|
+
const policy = CREATE_POLICY.exec(statement.text);
|
|
1524
|
+
const rest = policy?.[3] ?? "";
|
|
1525
|
+
if (policy === null || !/\bfor\s+update\b/iu.test(rest) || /\bwith\s+check\b/iu.test(rest)) return [];
|
|
1526
|
+
return [{
|
|
1527
|
+
code: "PD052",
|
|
1528
|
+
severity: "warning",
|
|
1529
|
+
message: `${statement.file}:${String(statement.line)} update policy ${unquote(group(policy, 1))} on ${tableKey(group(policy, 2))} has no with check, so Postgres checks the new row against using alone`,
|
|
1530
|
+
fix: "add with check (...) stating what the updated row must satisfy, such as the same tenant and owner test, so an update cannot move a row out of what the user may write"
|
|
1531
|
+
}];
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
/** Top-level comma-separated parts of a parenthesised list. */
|
|
1535
|
+
function topLevelParts(body) {
|
|
1536
|
+
const parts = [];
|
|
1537
|
+
let depth = 0;
|
|
1538
|
+
let start = 0;
|
|
1539
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
1540
|
+
const char = body[index];
|
|
1541
|
+
if (char === "(") depth += 1;
|
|
1542
|
+
else if (char === ")") depth -= 1;
|
|
1543
|
+
else if (char === "," && depth === 0) {
|
|
1544
|
+
parts.push(body.slice(start, index).trim());
|
|
1545
|
+
start = index + 1;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
parts.push(body.slice(start).trim());
|
|
1549
|
+
return parts.filter((part) => part !== "");
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* One column definition or table constraint of a `create table` body: its
|
|
1553
|
+
* first column, whether it is a foreign key, and whether a primary key or
|
|
1554
|
+
* unique constraint indexes that column.
|
|
1555
|
+
*/
|
|
1556
|
+
function tablePart(raw) {
|
|
1557
|
+
const part = raw.replace(/^constraint\s+(?:"[^"]+"|\w+)\s+/iu, "");
|
|
1558
|
+
const listed = /^(foreign\s+key|primary\s+key|unique)\s*\(([^)]*)\)/iu.exec(part);
|
|
1559
|
+
if (listed !== null) {
|
|
1560
|
+
const foreign = /^foreign/iu.test(group(listed, 1));
|
|
1561
|
+
return {
|
|
1562
|
+
column: firstColumn(group(listed, 2)),
|
|
1563
|
+
foreign,
|
|
1564
|
+
indexed: !foreign
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
return {
|
|
1568
|
+
column: unquote(/^("[^"]+"|\w+)/u.exec(part)?.[1] ?? ""),
|
|
1569
|
+
foreign: /\breferences\b/iu.test(part),
|
|
1570
|
+
indexed: /\b(?:primary\s+key|unique)\b/iu.test(part)
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
function firstColumn(list) {
|
|
1574
|
+
return unquote(list.replace(/,[\s\S]*$/u, "").trim());
|
|
1575
|
+
}
|
|
1576
|
+
const ADD_CONSTRAINT = new RegExp(String.raw`^alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?${NAME}\s+add\s+(?:constraint\s+${IDENT}\s+)?(foreign\s+key|primary\s+key|unique)\s*\(([^)]*)\)`, "iu");
|
|
1577
|
+
const CREATE_INDEX = new RegExp(String.raw`^create\s+(?:unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?(?:${IDENT}\s+)?on\s+(?:only\s+)?${NAME}\s*(?:using\s+\w+\s*)?\(\s*${IDENT}`, "iu");
|
|
1578
|
+
/** PD053: a foreign key no index starts with, so joins and deletes on the referenced row scan the table. */
|
|
1579
|
+
function pd053(cwd, config, files = nodeFiles) {
|
|
1580
|
+
if (!isSupabase(files, cwd, config)) return [];
|
|
1581
|
+
const keys = [];
|
|
1582
|
+
const indexed = /* @__PURE__ */ new Set();
|
|
1583
|
+
for (const statement of migrationStatements(files, cwd, config)) {
|
|
1584
|
+
const table = CREATE_TABLE.exec(statement.text);
|
|
1585
|
+
if (table !== null) {
|
|
1586
|
+
const key = tableKey(group(table, 1));
|
|
1587
|
+
const open = statement.text.indexOf("(", table[0].length - 1);
|
|
1588
|
+
const body = statement.text.slice(open + 1, statement.text.lastIndexOf(")"));
|
|
1589
|
+
for (const raw of topLevelParts(body)) {
|
|
1590
|
+
const found = tablePart(raw);
|
|
1591
|
+
if (found.foreign) keys.push({
|
|
1592
|
+
...statement,
|
|
1593
|
+
table: key,
|
|
1594
|
+
column: found.column
|
|
1595
|
+
});
|
|
1596
|
+
if (found.indexed) indexed.add(`${key}.${found.column}`);
|
|
1597
|
+
}
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
const added = ADD_CONSTRAINT.exec(statement.text);
|
|
1601
|
+
if (added !== null) {
|
|
1602
|
+
const key = tableKey(group(added, 1));
|
|
1603
|
+
const column = firstColumn(group(added, 4));
|
|
1604
|
+
if (/^foreign/iu.test(group(added, 3))) keys.push({
|
|
1605
|
+
...statement,
|
|
1606
|
+
table: key,
|
|
1607
|
+
column
|
|
1608
|
+
});
|
|
1609
|
+
else indexed.add(`${key}.${column}`);
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
const index = CREATE_INDEX.exec(statement.text);
|
|
1613
|
+
if (index !== null) indexed.add(`${tableKey(group(index, 2))}.${unquote(group(index, 3))}`);
|
|
1614
|
+
}
|
|
1615
|
+
return keys.filter((fk) => !indexed.has(`${fk.table}.${fk.column}`)).map((fk) => ({
|
|
1616
|
+
code: "PD053",
|
|
1617
|
+
severity: "warning",
|
|
1618
|
+
message: `${fk.file}:${String(fk.line)} ${fk.table}.${fk.column} is a foreign key no index starts with, so policies that join on it and deletes of the referenced row scan ${fk.table}`,
|
|
1619
|
+
fix: `create index on ${fk.table} (${fk.column}); (Supabase advisor unindexed_foreign_keys)`
|
|
1620
|
+
}));
|
|
1621
|
+
}
|
|
1622
|
+
const LEGACY_CLAIM = /\brequest\.jwt\.claim\.(\w+)/giu;
|
|
1623
|
+
function at(statement) {
|
|
1624
|
+
return `${statement.file}:${String(statement.line)}`;
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* PD062: SQL that reads or sets a per-claim `request.jwt.claim.<name>`
|
|
1628
|
+
* setting. Current PostgREST, `withPostgresClient`, PermDock's `withSubject`
|
|
1629
|
+
* and `rls verify` set only `request.jwt.claims`. A function replaced by a
|
|
1630
|
+
* later migration is judged by its last definition.
|
|
1631
|
+
*/
|
|
1632
|
+
function pd062(cwd, config, files = nodeFiles) {
|
|
1633
|
+
if (!isSupabase(files, cwd, config)) return [];
|
|
1634
|
+
const statements = migrationStatements(files, cwd, config);
|
|
1635
|
+
const latest = /* @__PURE__ */ new Map();
|
|
1636
|
+
for (const fn of createdFunctions(statements)) latest.set(fn.key, at(fn));
|
|
1637
|
+
return statements.flatMap((statement) => {
|
|
1638
|
+
const fn = CREATE_FUNCTION.exec(statement.text);
|
|
1639
|
+
if (fn !== null && latest.get(tableKey(group(fn, 1))) !== at(statement)) return [];
|
|
1640
|
+
return [...new Set([...statement.text.matchAll(LEGACY_CLAIM)].map((match) => group(match, 1).toLowerCase()))].map((name) => ({
|
|
1641
|
+
code: "PD062",
|
|
1642
|
+
severity: "warning",
|
|
1643
|
+
message: `${statement.file}:${String(statement.line)} uses the legacy request.jwt.claim.${name} setting, which PostgREST 12 and PermDock's withSubject no longer set, so it reads null`,
|
|
1644
|
+
fix: name === "sub" ? "read the subject with (select permdock.permdock_user_id()) or (select auth.uid()), and set request.jwt.claims in tests and jobs" : `read (select auth.jwt()) ->> '${name}', and set request.jwt.claims in tests and jobs`
|
|
1645
|
+
}));
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* PD063: a migration statement supautils rejects for `postgres`, so
|
|
1650
|
+
* `supabase db push` fails on it.
|
|
1651
|
+
*/
|
|
1652
|
+
function pd063(cwd, config, files = nodeFiles) {
|
|
1653
|
+
if (!isSupabase(files, cwd, config)) return [];
|
|
1654
|
+
return sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS).flatMap((file) => supautilsRejections(files.read(file)).map((found) => ({
|
|
1655
|
+
code: "PD063",
|
|
1656
|
+
severity: "error",
|
|
1657
|
+
message: `${rel(cwd, file)}:${String(found.line)} ${found.reason}, which supautils rejects for the postgres role on Supabase`,
|
|
1658
|
+
fix: "remove the statement: reserved roles belong to Supabase. Grant your own role to authenticator instead, and change anon, authenticated, authenticator or service_role only with alter role … set"
|
|
1659
|
+
})));
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* PD056: SQL still calls an `rls.migrate` helper by its legacy name, so the
|
|
1663
|
+
* shim (or the legacy function) cannot be dropped yet.
|
|
1664
|
+
*/
|
|
1665
|
+
function pd056(cwd, config, files = nodeFiles) {
|
|
1666
|
+
const migrate = config.rls?.migrate;
|
|
1667
|
+
const helpers = migrate === void 0 ? [] : legacyHelperNames(migrate);
|
|
1668
|
+
if (helpers.length === 0) return [];
|
|
1669
|
+
const statements = migrationStatements(files, cwd, config);
|
|
1670
|
+
const findings = [];
|
|
1671
|
+
for (const name of helpers) {
|
|
1672
|
+
const { count, files: callers } = legacyHelperCalls(statements, name);
|
|
1673
|
+
if (count === 0) continue;
|
|
1674
|
+
findings.push({
|
|
1675
|
+
code: "PD056",
|
|
1676
|
+
severity: "warning",
|
|
1677
|
+
message: `legacy helper ${name} is still called ${String(count)} time(s), in ${callers.join(", ")}`,
|
|
1678
|
+
fix: `run permdock rls migrate --write for policies, rewrite function bodies, views and triggers onto the permdock helpers by hand, then drop function ${name}`
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
return findings;
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* PD066: the migrations still create an `rls.migrate.tables` table: something
|
|
1685
|
+
* still reads it, or `rls migrate --retire-out` can drop it now.
|
|
1686
|
+
*/
|
|
1687
|
+
function pd066(cwd, config, files = nodeFiles) {
|
|
1688
|
+
const migrate = config.rls?.migrate;
|
|
1689
|
+
if (migrate?.tables === void 0) return [];
|
|
1690
|
+
return retirePlan(migrationStatements(files, cwd, config), migrate).tables.filter((table) => table.present).map((table) => {
|
|
1691
|
+
const [first] = table.readers;
|
|
1692
|
+
return first === void 0 ? {
|
|
1693
|
+
code: "PD066",
|
|
1694
|
+
severity: "warning",
|
|
1695
|
+
message: `${table.table} has no reader left, but the migrations still create it and its ${String(table.refreshTriggers.length)} refresh trigger(s)`,
|
|
1696
|
+
fix: "run permdock rls migrate --sql <dir> --retire-out supabase/migrations/ and apply the migration it writes"
|
|
1697
|
+
} : {
|
|
1698
|
+
code: "PD066",
|
|
1699
|
+
severity: "warning",
|
|
1700
|
+
message: `${table.table} is still read by ${String(table.readers.length)} object(s), first ${first.kind} ${first.name} at ${first.file}:${String(first.line)}`,
|
|
1701
|
+
fix: "rewrite each reader onto the permdock helpers, then run permdock rls migrate --retire-out"
|
|
1702
|
+
};
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
//#endregion
|
|
1706
|
+
//#region src/cli/doctor/suspension.ts
|
|
1707
|
+
/**
|
|
1708
|
+
* PD061: a suspended scope keeps permissions. Each scope's kept keys are
|
|
1709
|
+
* listed for review, since its members hold them while the instance is
|
|
1710
|
+
* suspended, and a key the definitions do not declare is named apart.
|
|
1711
|
+
*/
|
|
1712
|
+
async function pd061(input) {
|
|
1713
|
+
const suspension = input.config.rls?.suspension;
|
|
1714
|
+
const kept = [...Object.entries(suspension?.scopes ?? {}).map(([scope, row]) => ({
|
|
1715
|
+
path: `scopes.${scope}`,
|
|
1716
|
+
who: `members of a suspended ${scope}`,
|
|
1717
|
+
row
|
|
1718
|
+
})), ...suspension?.memberships === void 0 ? [] : [{
|
|
1719
|
+
path: "memberships",
|
|
1720
|
+
who: "suspended memberships",
|
|
1721
|
+
row: suspension.memberships
|
|
1722
|
+
}]].flatMap((entry) => {
|
|
1723
|
+
try {
|
|
1724
|
+
const keys = keptKeys(entry.row);
|
|
1725
|
+
return keys.length === 0 ? [] : [{
|
|
1726
|
+
path: entry.path,
|
|
1727
|
+
who: entry.who,
|
|
1728
|
+
keys
|
|
1729
|
+
}];
|
|
1730
|
+
} catch {
|
|
1731
|
+
return [{
|
|
1732
|
+
path: entry.path,
|
|
1733
|
+
who: entry.who,
|
|
1734
|
+
keys: [],
|
|
1735
|
+
invalid: true
|
|
1736
|
+
}];
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
if (kept.length === 0) return [];
|
|
1740
|
+
const policy = await policyOf(input);
|
|
1741
|
+
const declared = /* @__PURE__ */ new Set();
|
|
1742
|
+
const former = /* @__PURE__ */ new Map();
|
|
1743
|
+
for (const leaf of policy === void 0 ? [] : listPermissions(policy.permissions)) {
|
|
1744
|
+
declared.add(leaf.key);
|
|
1745
|
+
for (const old of formerKeys(leaf)) former.set(old, leaf.key);
|
|
1746
|
+
}
|
|
1747
|
+
return kept.flatMap((item) => {
|
|
1748
|
+
if (item.invalid === true) return [{
|
|
1749
|
+
code: "PD061",
|
|
1750
|
+
severity: "warning",
|
|
1751
|
+
message: `rls.suspension.${item.path}.keep has an entry that is neither a permission nor a permission key, so generate refuses it`,
|
|
1752
|
+
fix: "list permission references or their keys in keep"
|
|
1753
|
+
}];
|
|
1754
|
+
const findings = [{
|
|
1755
|
+
code: "PD061",
|
|
1756
|
+
severity: "warning",
|
|
1757
|
+
message: `${item.who} still hold ${item.keys.join(", ")}`,
|
|
1758
|
+
fix: item.path === "memberships" ? "keep only what a suspended member must still do, such as leaving or exporting their own data, in rls.suspension.memberships.keep" : `keep only what a suspended ${item.path.slice(7)} must still do, such as restoring it or cancelling its deletion, in rls.suspension.${item.path}.keep`
|
|
1759
|
+
}];
|
|
1760
|
+
if (policy === void 0) return findings;
|
|
1761
|
+
const unknown = item.keys.filter((key) => !declared.has(key));
|
|
1762
|
+
if (unknown.length > 0) findings.push({
|
|
1763
|
+
code: "PD061",
|
|
1764
|
+
severity: "warning",
|
|
1765
|
+
message: `rls.suspension.${item.path}.keep names ${unknown.map((key) => former.has(key) ? `${key} (renamed to ${String(former.get(key))})` : key).join(", ")}, which the definitions do not declare, so a suspended ${item.path === "memberships" ? "membership" : item.path.slice(7)} keeps nothing for them`,
|
|
1766
|
+
fix: "keep permission references instead of keys, or replace each key with its current name"
|
|
1767
|
+
});
|
|
1768
|
+
return findings;
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
const DOCTOR_CHECKS = [
|
|
1772
|
+
{
|
|
1773
|
+
code: "PD001",
|
|
1774
|
+
groups: ["imports"],
|
|
1775
|
+
run: (project) => pd001(project.sources(), new Set(listSourceFiles(project.cwd, project.config.doctor?.clientEntries ?? []).map((file) => rel(project.cwd, file))), project.config.policy === void 0 ? void 0 : {
|
|
1776
|
+
cwd: project.cwd,
|
|
1777
|
+
path: project.config.policy
|
|
1778
|
+
})
|
|
1779
|
+
},
|
|
1780
|
+
{
|
|
1781
|
+
code: "PD059",
|
|
1782
|
+
groups: ["project"],
|
|
1783
|
+
run: pd059
|
|
1784
|
+
},
|
|
1785
|
+
{
|
|
1786
|
+
code: "PD060",
|
|
1787
|
+
groups: ["project"],
|
|
1788
|
+
run: pd060
|
|
1789
|
+
},
|
|
1790
|
+
{
|
|
1791
|
+
code: "PD002",
|
|
1792
|
+
groups: ["references"],
|
|
1793
|
+
run: pd002
|
|
1794
|
+
},
|
|
1795
|
+
{
|
|
1796
|
+
code: "PD003",
|
|
1797
|
+
groups: ["ungranted"],
|
|
1798
|
+
run: pd003
|
|
1799
|
+
},
|
|
1800
|
+
{
|
|
1801
|
+
code: "PD004",
|
|
1802
|
+
groups: ["catalog"],
|
|
1803
|
+
run: pd004
|
|
1804
|
+
},
|
|
1805
|
+
{
|
|
1806
|
+
code: "PD005",
|
|
1807
|
+
groups: ["skills"],
|
|
1808
|
+
run: (project) => pd005(project.cwd, project.files)
|
|
1809
|
+
},
|
|
1810
|
+
{
|
|
1811
|
+
code: "PD006",
|
|
1812
|
+
groups: ["typescript"],
|
|
1813
|
+
run: (project) => pd006(project.cwd)
|
|
1814
|
+
},
|
|
1815
|
+
{
|
|
1816
|
+
code: "PD007",
|
|
1817
|
+
groups: ["validation"],
|
|
1818
|
+
run: (project) => pd007(project.sources())
|
|
1819
|
+
},
|
|
1820
|
+
{
|
|
1821
|
+
code: "PD008",
|
|
1822
|
+
groups: ["naming"],
|
|
1823
|
+
run: (project) => pd008(project.sources())
|
|
1824
|
+
},
|
|
1825
|
+
{
|
|
1826
|
+
code: "PD009",
|
|
1827
|
+
groups: ["duplicates"],
|
|
1828
|
+
run: (project) => pd009(project.cwd, project.files)
|
|
1829
|
+
},
|
|
1830
|
+
{
|
|
1831
|
+
code: "PD010",
|
|
1832
|
+
groups: ["claims"],
|
|
1833
|
+
run: (project) => pd010(project.sources())
|
|
1834
|
+
},
|
|
1835
|
+
{
|
|
1836
|
+
code: "PD011",
|
|
1837
|
+
groups: ["tenant"],
|
|
1838
|
+
run: (project) => pd011(project.sources())
|
|
1839
|
+
},
|
|
1840
|
+
{
|
|
1841
|
+
code: "PD012",
|
|
1842
|
+
groups: ["drafts"],
|
|
1843
|
+
run: (project) => pd012(project.cwd, project.config, project.files)
|
|
1844
|
+
},
|
|
1845
|
+
{
|
|
1846
|
+
code: "PD013",
|
|
1847
|
+
groups: ["algorithms"],
|
|
1848
|
+
run: (project) => pd013(project.sources())
|
|
1849
|
+
},
|
|
1850
|
+
{
|
|
1851
|
+
code: "PD014",
|
|
1852
|
+
groups: ["discovery"],
|
|
1853
|
+
run: (project) => pd014(project.sources())
|
|
1854
|
+
},
|
|
1855
|
+
{
|
|
1856
|
+
code: "PD015",
|
|
1857
|
+
groups: ["typ"],
|
|
1858
|
+
run: (project) => pd015(project.sources())
|
|
1859
|
+
},
|
|
1860
|
+
{
|
|
1861
|
+
code: "PD016",
|
|
1862
|
+
groups: ["rls"],
|
|
1863
|
+
run: pd016
|
|
1864
|
+
},
|
|
1865
|
+
{
|
|
1866
|
+
code: "PD017",
|
|
1867
|
+
groups: ["sensitive"],
|
|
1868
|
+
run: pd017
|
|
1869
|
+
},
|
|
1870
|
+
{
|
|
1871
|
+
code: "PD018",
|
|
1872
|
+
groups: ["separation"],
|
|
1873
|
+
run: pd018
|
|
1874
|
+
},
|
|
1875
|
+
{
|
|
1876
|
+
code: "PD019",
|
|
1877
|
+
groups: ["jwt-roles"],
|
|
1878
|
+
run: pd019
|
|
1879
|
+
},
|
|
1880
|
+
{
|
|
1881
|
+
code: "PD020",
|
|
1882
|
+
groups: ["hosted"],
|
|
1883
|
+
run: pd020
|
|
1884
|
+
},
|
|
1885
|
+
{
|
|
1886
|
+
code: "PD021",
|
|
1887
|
+
groups: ["hosted"],
|
|
1888
|
+
run: (project) => pd021({
|
|
1889
|
+
...project,
|
|
1890
|
+
env: project.io.env ?? process.env
|
|
1891
|
+
})
|
|
1892
|
+
},
|
|
1893
|
+
{
|
|
1894
|
+
code: "PD022",
|
|
1895
|
+
groups: ["views"],
|
|
1896
|
+
run: (project) => pd022(project.cwd, project.config, project.files)
|
|
1897
|
+
},
|
|
1898
|
+
{
|
|
1899
|
+
code: "PD023",
|
|
1900
|
+
groups: ["custom-roles"],
|
|
1901
|
+
run: pd023
|
|
1902
|
+
},
|
|
1903
|
+
{
|
|
1904
|
+
code: "PD024",
|
|
1905
|
+
groups: ["self-approval"],
|
|
1906
|
+
run: pd024
|
|
1907
|
+
},
|
|
1908
|
+
{
|
|
1909
|
+
code: "PD025",
|
|
1910
|
+
groups: ["scopes"],
|
|
1911
|
+
run: pd025
|
|
1912
|
+
},
|
|
1913
|
+
{
|
|
1914
|
+
code: "PD026",
|
|
1915
|
+
groups: ["ownership"],
|
|
1916
|
+
run: pd026
|
|
1917
|
+
},
|
|
1918
|
+
{
|
|
1919
|
+
code: "PD027",
|
|
1920
|
+
groups: ["rls", "context-refs"],
|
|
1921
|
+
run: pd027
|
|
1922
|
+
},
|
|
1923
|
+
{
|
|
1924
|
+
code: "PD028",
|
|
1925
|
+
groups: ["attrs", "supabase"],
|
|
1926
|
+
run: (project) => pd028(project.cwd, project.config, attrsPlan, project.files)
|
|
1927
|
+
},
|
|
1928
|
+
{
|
|
1929
|
+
code: "PD029",
|
|
1930
|
+
groups: ["credentials"],
|
|
1931
|
+
run: pd029
|
|
1932
|
+
},
|
|
1933
|
+
{
|
|
1934
|
+
code: "PD030",
|
|
1935
|
+
groups: ["fields"],
|
|
1936
|
+
run: pd030
|
|
1937
|
+
},
|
|
1938
|
+
{
|
|
1939
|
+
code: "PD031",
|
|
1940
|
+
groups: ["graph"],
|
|
1941
|
+
run: pd031
|
|
1942
|
+
},
|
|
1943
|
+
{
|
|
1944
|
+
code: "PD032",
|
|
1945
|
+
groups: ["graph", "rls"],
|
|
1946
|
+
run: pd032
|
|
1947
|
+
},
|
|
1948
|
+
{
|
|
1949
|
+
code: "PD033",
|
|
1950
|
+
groups: ["activation"],
|
|
1951
|
+
run: pd033
|
|
1952
|
+
},
|
|
1953
|
+
{
|
|
1954
|
+
code: "PD034",
|
|
1955
|
+
groups: ["break-glass"],
|
|
1956
|
+
run: pd034
|
|
1957
|
+
},
|
|
1958
|
+
{
|
|
1959
|
+
code: "PD035",
|
|
1960
|
+
groups: ["support"],
|
|
1961
|
+
run: pd035
|
|
1962
|
+
},
|
|
1963
|
+
{
|
|
1964
|
+
code: "PD036",
|
|
1965
|
+
groups: ["bola"],
|
|
1966
|
+
run: (project) => pd036(project.sources())
|
|
1967
|
+
},
|
|
1968
|
+
{
|
|
1969
|
+
code: "PD037",
|
|
1970
|
+
groups: ["supabase", "row-conditions"],
|
|
1971
|
+
run: pd037
|
|
1972
|
+
},
|
|
1973
|
+
{
|
|
1974
|
+
code: "PD039",
|
|
1975
|
+
groups: ["supabase", "helpers"],
|
|
1976
|
+
when: (config) => config.supabase?.hook !== void 0,
|
|
1977
|
+
run: supabaseSetup
|
|
1978
|
+
},
|
|
1979
|
+
{
|
|
1980
|
+
code: "PD038",
|
|
1981
|
+
groups: ["supabase", "tenant"],
|
|
1982
|
+
when: (config) => config.rls !== void 0 || config.supabase?.hook !== void 0,
|
|
1983
|
+
run: (project) => pd038(project.sources(), project.config.rls?.tenantClaim ?? "tenant_id", supabaseTenantClaim)
|
|
1984
|
+
},
|
|
1985
|
+
{
|
|
1986
|
+
code: "PD057",
|
|
1987
|
+
groups: ["supabase", "anonymous"],
|
|
1988
|
+
when: (config) => config.rls?.anonymousSignIns === "deny",
|
|
1989
|
+
run: (project) => pd057(project.sources())
|
|
1990
|
+
},
|
|
1991
|
+
{
|
|
1992
|
+
code: "PD040",
|
|
1993
|
+
groups: ["supabase", "auth-role"],
|
|
1994
|
+
run: (project) => pd040(project.cwd, project.config, project.files)
|
|
1995
|
+
},
|
|
1996
|
+
{
|
|
1997
|
+
code: "PD041",
|
|
1998
|
+
groups: ["supabase", "capabilities"],
|
|
1999
|
+
run: (project) => pd041(project.sources())
|
|
2000
|
+
},
|
|
2001
|
+
{
|
|
2002
|
+
code: "PD042",
|
|
2003
|
+
groups: ["supabase", "declarative"],
|
|
2004
|
+
run: (project) => pd042(project.cwd, project.config, project.files)
|
|
2005
|
+
},
|
|
2006
|
+
{
|
|
2007
|
+
code: "PD043",
|
|
2008
|
+
groups: ["supabase", "declarative"],
|
|
2009
|
+
run: (project) => pd043(project.cwd, project.files)
|
|
2010
|
+
},
|
|
2011
|
+
{
|
|
2012
|
+
code: "PD045",
|
|
2013
|
+
groups: ["supabase"],
|
|
2014
|
+
run: (project) => pd045(project.cwd)
|
|
2015
|
+
},
|
|
2016
|
+
...[
|
|
2017
|
+
["PD046", pd046],
|
|
2018
|
+
["PD047", pd047],
|
|
2019
|
+
["PD048", pd048],
|
|
2020
|
+
["PD049", pd049],
|
|
2021
|
+
["PD050", pd050],
|
|
2022
|
+
["PD051", pd051],
|
|
2023
|
+
["PD052", pd052],
|
|
2024
|
+
["PD053", pd053],
|
|
2025
|
+
["PD062", pd062],
|
|
2026
|
+
["PD063", pd063]
|
|
2027
|
+
].map(([code, check]) => ({
|
|
2028
|
+
code,
|
|
2029
|
+
groups: ["supabase", "sql"],
|
|
2030
|
+
run: (project) => check(project.cwd, project.config, project.files)
|
|
2031
|
+
})),
|
|
2032
|
+
{
|
|
2033
|
+
code: "PD054",
|
|
2034
|
+
groups: ["supabase", "seeds"],
|
|
2035
|
+
run: pd054
|
|
2036
|
+
},
|
|
2037
|
+
{
|
|
2038
|
+
code: "PD055",
|
|
2039
|
+
groups: ["custom-roles", "renamed"],
|
|
2040
|
+
run: pd055
|
|
2041
|
+
},
|
|
2042
|
+
{
|
|
2043
|
+
code: "PD056",
|
|
2044
|
+
groups: ["sql", "shims"],
|
|
2045
|
+
run: (project) => pd056(project.cwd, project.config, project.files)
|
|
2046
|
+
},
|
|
2047
|
+
{
|
|
2048
|
+
code: "PD066",
|
|
2049
|
+
groups: ["sql", "shims"],
|
|
2050
|
+
when: (config) => config.rls?.migrate?.tables !== void 0,
|
|
2051
|
+
run: (project) => pd066(project.cwd, project.config, project.files)
|
|
2052
|
+
},
|
|
2053
|
+
{
|
|
2054
|
+
code: "PD058",
|
|
2055
|
+
groups: ["powersync"],
|
|
2056
|
+
run: pd058
|
|
2057
|
+
},
|
|
2058
|
+
{
|
|
2059
|
+
code: "PD061",
|
|
2060
|
+
groups: ["rls", "suspension"],
|
|
2061
|
+
when: (config) => config.rls?.suspension?.scopes !== void 0,
|
|
2062
|
+
run: pd061
|
|
2063
|
+
},
|
|
2064
|
+
{
|
|
2065
|
+
code: "PD064",
|
|
2066
|
+
groups: ["rls", "assignments"],
|
|
2067
|
+
when: (config) => config.rls?.assignments !== void 0,
|
|
2068
|
+
run: pd064
|
|
2069
|
+
},
|
|
2070
|
+
{
|
|
2071
|
+
code: "PD044",
|
|
2072
|
+
groups: ["next", "endpoint"],
|
|
2073
|
+
run: pd044
|
|
2074
|
+
},
|
|
2075
|
+
{
|
|
2076
|
+
code: "PD065",
|
|
2077
|
+
groups: ["react-native", "endpoint"],
|
|
2078
|
+
run: pd065
|
|
2079
|
+
}
|
|
2080
|
+
];
|
|
2081
|
+
async function runDoctor(input) {
|
|
2082
|
+
if (input.fix) {
|
|
2083
|
+
runSkillsInstall({
|
|
2084
|
+
cwd: input.cwd,
|
|
2085
|
+
agents: []
|
|
2086
|
+
});
|
|
2087
|
+
await runCollect({
|
|
2088
|
+
cwd: input.cwd,
|
|
2089
|
+
config: input.config,
|
|
2090
|
+
collect: input.config.collect ?? {},
|
|
2091
|
+
check: false,
|
|
2092
|
+
now: input.now,
|
|
2093
|
+
io: input.io
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
const project = loadProject(input);
|
|
2097
|
+
const wanted = new Set(input.only.map((item) => item.toLowerCase()));
|
|
2098
|
+
const selected = (check) => wanted.size === 0 || wanted.has(check.code.toLowerCase()) || check.groups.some((group) => wanted.has(group));
|
|
2099
|
+
const findings = [];
|
|
2100
|
+
for (const check of DOCTOR_CHECKS) {
|
|
2101
|
+
if (check.when !== void 0 && !check.when(input.config)) continue;
|
|
2102
|
+
if (selected(check)) findings.push(...await check.run(project));
|
|
2103
|
+
}
|
|
2104
|
+
const errors = findings.filter((item) => item.severity === "error").length;
|
|
2105
|
+
const warnings = findings.filter((item) => item.severity === "warning").length;
|
|
2106
|
+
const report = {
|
|
2107
|
+
$schema: DOCTOR_REPORT_SCHEMA,
|
|
2108
|
+
findings,
|
|
2109
|
+
errors,
|
|
2110
|
+
warnings
|
|
2111
|
+
};
|
|
2112
|
+
return {
|
|
2113
|
+
code: errors > 0 || input.strict && warnings > 0 ? 1 : 0,
|
|
2114
|
+
output: input.json ? `${JSON.stringify(report, null, 2)}\n` : formatDoctor(report, input.color)
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
/** PD059: the configured policy module throws or exports no policy, so every policy check is skipped. */
|
|
2118
|
+
async function pd059(project) {
|
|
2119
|
+
const load = await project.policyLoad();
|
|
2120
|
+
if (load.status !== "failed") return [];
|
|
2121
|
+
return [{
|
|
2122
|
+
code: "PD059",
|
|
2123
|
+
severity: "error",
|
|
2124
|
+
message: `policy module ${load.path} did not load (${load.message}), so doctor skipped every check that reads the policy`,
|
|
2125
|
+
fix: "fix the module so it exports policy from definePolicy, or point policy in permdock.config.ts at the module that does"
|
|
2126
|
+
}];
|
|
2127
|
+
}
|
|
2128
|
+
/** PD060: a source file under srcPath has a syntax error, so collect and usage miss what it references. */
|
|
2129
|
+
async function pd060(project) {
|
|
2130
|
+
return ((await project.collected()).scan?.unparsed ?? []).map((item) => ({
|
|
2131
|
+
code: "PD060",
|
|
2132
|
+
severity: "warning",
|
|
2133
|
+
message: `${item.file}:${String(item.line)} does not parse (${item.message}), so the catalog and usage report miss the permissions it references`,
|
|
2134
|
+
fix: "fix the syntax error, or exclude the file from collect.srcPath"
|
|
2135
|
+
}));
|
|
2136
|
+
}
|
|
2137
|
+
function formatDoctor(report, color) {
|
|
2138
|
+
const style = createStyle(color);
|
|
2139
|
+
const lines = [style.paint("bold", "permdock doctor"), ""];
|
|
2140
|
+
for (const finding of report.findings) {
|
|
2141
|
+
const mark = finding.severity === "error" ? style.errorMark : style.warnMark;
|
|
2142
|
+
lines.push(` ${mark} ${style.paint("bold", finding.code)} ${finding.message}`);
|
|
2143
|
+
lines.push(` ${style.paint("dim", `fix: ${finding.fix}`)}`);
|
|
2144
|
+
}
|
|
2145
|
+
if (report.findings.length === 0) lines.push(" no findings");
|
|
2146
|
+
lines.push("");
|
|
2147
|
+
lines.push(` ${String(report.errors)} error${report.errors === 1 ? "" : "s"}, ${String(report.warnings)} warning${report.warnings === 1 ? "" : "s"}`);
|
|
2148
|
+
return `${lines.join("\n")}\n`;
|
|
2149
|
+
}
|
|
2150
|
+
async function supabaseSetup(input) {
|
|
2151
|
+
const policy = await input.policy();
|
|
2152
|
+
if (policy === void 0) return [];
|
|
2153
|
+
let manifest;
|
|
2154
|
+
try {
|
|
2155
|
+
manifest = supabaseHookManifest(scopeList(policy.scopes), input.config, { out: hookOut(input.cwd, input.config) }, policy);
|
|
2156
|
+
} catch {
|
|
2157
|
+
return [];
|
|
2158
|
+
}
|
|
2159
|
+
return pd039({
|
|
2160
|
+
cwd: input.cwd,
|
|
2161
|
+
config: input.config,
|
|
2162
|
+
manifest
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
//#endregion
|
|
2166
|
+
export { runDoctor };
|