permdock 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +268 -1
- package/dist/a2a/index.d.ts +178 -0
- package/dist/a2a/index.js +271 -0
- package/dist/ai-sdk/index.d.ts +74 -0
- package/dist/ai-sdk/index.js +137 -0
- package/dist/approval-required-TzNB9oQA.js +6 -0
- package/dist/approvals/index.d.ts +3 -0
- package/dist/approvals/index.js +4 -0
- package/dist/approvals-DaxY-Bdg.js +218 -0
- package/dist/approvers-aH0PJkmf.js +20 -0
- package/dist/arazzo-CTeVuXr5.js +70 -0
- package/dist/arazzo-aoTkqYsJ.js +54 -0
- package/dist/authzen/index.d.ts +48 -0
- package/dist/authzen/index.js +336 -0
- package/dist/better-auth/index.d.ts +92 -0
- package/dist/better-auth/index.js +324 -0
- package/dist/budget-JqcRqyhK.d.ts +11 -0
- package/dist/budget-NbEtbJC_.js +11 -0
- package/dist/capability-B68OFnZx.js +171 -0
- package/dist/capability-BDG_nkxt.d.ts +99 -0
- package/dist/catalog/index.d.ts +12 -0
- package/dist/catalog/index.js +2 -0
- package/dist/catalog-B0E6bggc.js +48 -0
- package/dist/catalog-H-vFGwWK.js +55 -0
- package/dist/catalog-doc-BSo_Q_-6.js +298 -0
- package/dist/catalog-idsHc0vo.js +2 -0
- package/dist/catalog-path-CnI0XTFK.js +12 -0
- package/dist/claude-agent/index.d.ts +81 -0
- package/dist/claude-agent/index.js +83 -0
- package/dist/clerk/index.d.ts +56 -0
- package/dist/clerk/index.js +280 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +20 -0
- package/dist/cli/index.d.ts +46 -0
- package/dist/cli/index.js +5 -0
- package/dist/cloud/index.d.ts +91 -0
- package/dist/cloud/index.js +390 -0
- package/dist/cloud-BNQTrmrr.js +50 -0
- package/dist/cloud-Cuxr6Czw.js +117 -0
- package/dist/collect-B7_TIfmg.js +49 -0
- package/dist/collect-O-tOtbnk.js +350 -0
- package/dist/collect-fRPAQIWb.js +2 -0
- package/dist/commands-BMh_Etm2.js +93 -0
- package/dist/compact-y6vfu2_f.js +19 -0
- package/dist/compare-pfyVnBjE.js +12 -0
- package/dist/compile-CQbvyIBr.d.ts +30 -0
- package/dist/compile-D22iwvuV.js +558 -0
- package/dist/config-BPYmjTOp.js +26 -0
- package/dist/connection-JvqwHODC.js +194 -0
- package/dist/context-CKEAabd7.js +73 -0
- package/dist/context-gtAwAILx.js +6 -0
- package/dist/convex/index.d.ts +40 -0
- package/dist/convex/index.js +55 -0
- package/dist/create-BKF3dgFk.d.ts +162 -0
- package/dist/create-CSkhDU9T.js +222 -0
- package/dist/credential-B_rkUNlY.js +326 -0
- package/dist/credentials-BgRX3WEg.js +268 -0
- package/dist/describe-Bt1YD5uS.js +99 -0
- package/dist/describe-DernyItf.d.ts +37 -0
- package/dist/diff-BUzf5Poi.js +48 -0
- package/dist/diff-x5Il8LVa.js +418 -0
- package/dist/digest-B3kc_PgQ.d.ts +19 -0
- package/dist/digest-BI-6aa6d.js +27 -0
- package/dist/doctor-BGle8Gqi.js +1314 -0
- package/dist/doctor-Bqo6FwUa.js +53 -0
- package/dist/drizzle/index.d.ts +72 -0
- package/dist/drizzle/index.js +160 -0
- package/dist/duration-xK2jGFVc.js +25 -0
- package/dist/elysia/index.d.ts +71 -0
- package/dist/elysia/index.js +103 -0
- package/dist/errors-DSMnAVNa.js +43 -0
- package/dist/errors-DbZ1YOnb.d.ts +138 -0
- package/dist/errors-Iy71s7Oj.js +177 -0
- package/dist/errors-m3u9TmDu.js +27 -0
- package/dist/evaluations-DsAU9ppl.js +479 -0
- package/dist/eve/index.d.ts +95 -0
- package/dist/eve/index.js +181 -0
- package/dist/express/index.d.ts +46 -0
- package/dist/express/index.js +108 -0
- package/dist/fastify/index.d.ts +56 -0
- package/dist/fastify/index.js +141 -0
- package/dist/files-B_wAGYmt.js +121 -0
- package/dist/fixtures-Cvaw0gDG.js +64 -0
- package/dist/freeze-y1_jQL4v.js +31 -0
- package/dist/from-snapshot-BMGnhmgv.js +552 -0
- package/dist/generate-CH7ThJ14.js +173 -0
- package/dist/grantee-Bv04_Vzf.js +521 -0
- package/dist/headers-B5RRv3Xm.js +6 -0
- package/dist/headers-C8SHQccD.d.ts +6 -0
- package/dist/helpers-DnSOrhB9.js +682 -0
- package/dist/hono/index.d.ts +70 -0
- package/dist/hono/index.js +131 -0
- package/dist/hosted-BwKQ1BTA.js +265 -0
- package/dist/http-DPuDaChb.d.ts +13 -0
- package/dist/http-GgVApX1k.js +111 -0
- package/dist/index-CltcQ4Bf.d.ts +138 -0
- package/dist/index-LF_hS-yn.d.ts +177 -0
- package/dist/index-fG6NSXlc.d.ts +9 -0
- package/dist/index-xIxJLwOQ.d.ts +415 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +21 -0
- package/dist/jwt/index.d.ts +87 -0
- package/dist/jwt/index.js +571 -0
- package/dist/kernel-YOGCWNLf.js +211 -0
- package/dist/kysely/index.d.ts +77 -0
- package/dist/kysely/index.js +129 -0
- package/dist/load-BZ24CpZP.js +69 -0
- package/dist/load-QOOm1z70.js +2 -0
- package/dist/load-jose-BL6LH4bL.js +11 -0
- package/dist/lru-8q4WZjBV.js +28 -0
- package/dist/manifest-DDQbC6K0.d.ts +118 -0
- package/dist/map-DowSO96j.js +158 -0
- package/dist/map-claims-B_6I1Rn-.js +360 -0
- package/dist/map-error-RJ3ZsUyA.js +16 -0
- package/dist/markers-B0hYLvMD.js +60 -0
- package/dist/may-use-CnFEjVEp.js +37 -0
- package/dist/mcp/index.d.ts +127 -0
- package/dist/mcp/index.js +563 -0
- package/dist/nest/index.d.ts +80 -0
- package/dist/nest/index.js +270 -0
- package/dist/next/client.d.ts +24 -0
- package/dist/next/client.js +31 -0
- package/dist/next/index.d.ts +110 -0
- package/dist/next/index.js +195 -0
- package/dist/next/plugin.d.ts +14 -0
- package/dist/next/plugin.js +2 -0
- package/dist/node/index.d.ts +43 -0
- package/dist/node/index.js +66 -0
- package/dist/openai/index.d.ts +71 -0
- package/dist/openai/index.js +105 -0
- package/dist/openapi/index.d.ts +93 -0
- package/dist/openapi/index.js +3 -0
- package/dist/openapi-CwePaF7C.js +164 -0
- package/dist/openapi-DWD0Uc_4.js +250 -0
- package/dist/openapi-DcqM8nU7.js +306 -0
- package/dist/openapi-import-BWRUNNeW.js +5959 -0
- package/dist/openapi-schema-D8j_T9tO.js +6902 -0
- package/dist/orpc/index.d.ts +64 -0
- package/dist/orpc/index.js +188 -0
- package/dist/otel/index.d.ts +13 -0
- package/dist/otel/index.js +202 -0
- package/dist/package-root-zGkxO3oN.js +30 -0
- package/dist/parse-DPfIvFMU.js +135 -0
- package/dist/pdp/index.d.ts +22 -0
- package/dist/pdp/index.js +795 -0
- package/dist/peer-BOr_kJE1.js +31 -0
- package/dist/permdock-yoZ7EjxX.js +2956 -0
- package/dist/permissions-B4apnahQ.d.ts +185 -0
- package/dist/permissions-B8vCg_bI.d.ts +52 -0
- package/dist/permissions-CofRqesP.js +142 -0
- package/dist/permissions-DC7GROPA.js +600 -0
- package/dist/pg-4GJWDUxG.js +953 -0
- package/dist/pins-CqE1jZuF.js +12 -0
- package/dist/plugin-4gr7IxFJ.js +65 -0
- package/dist/policy-ANp36PnI.js +971 -0
- package/dist/policy-CInmNGkM.d.ts +1959 -0
- package/dist/policy-Cq2fB8Q5.d.ts +20 -0
- package/dist/prisma/index.d.ts +135 -0
- package/dist/prisma/index.js +430 -0
- package/dist/problem-Cgp9-KZA.js +226 -0
- package/dist/prompt-CXYhLfsS.js +32 -0
- package/dist/protected-B4K3BKoX.js +138 -0
- package/dist/protected-BuPAycJT.d.ts +26 -0
- package/dist/protected-wY7nXhY9.d.ts +23 -0
- package/dist/provider-_wx46HpB.js +48 -0
- package/dist/provider-wTAUda2v.d.ts +6 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +6 -0
- package/dist/react/provider-client.d.ts +2 -0
- package/dist/react/provider-client.js +3 -0
- package/dist/react-native/index.d.ts +42 -0
- package/dist/react-native/index.js +174 -0
- package/dist/refs-ChLSxbST.js +90 -0
- package/dist/relations-r4soIFZB.js +426 -0
- package/dist/revocations-Cy06JNx2.d.ts +21 -0
- package/dist/rls-BOFU3chG.js +233 -0
- package/dist/rls-CJVja9Ww.js +2119 -0
- package/dist/rls-graph-DxQ39PJj.js +371 -0
- package/dist/rls-helpers-BxRuif0l.js +1346 -0
- package/dist/rls-import-CXV6LBlS.js +942 -0
- package/dist/rls-migrate-DrFPbf8p.js +366 -0
- package/dist/row-check-BajTgQZ-.d.ts +43 -0
- package/dist/run-CJ6UvS3a.js +155 -0
- package/dist/runtime-DP9ttFh1.js +27 -0
- package/dist/saas-BRrZzJkM.js +1193 -0
- package/dist/schema-P8yoma__.js +296 -0
- package/dist/scim/index.d.ts +2 -0
- package/dist/scim/index.js +2 -0
- package/dist/scim-9NFa0XVZ.js +1445 -0
- package/dist/scopes-cKVp_WTw.js +212 -0
- package/dist/server/index.d.ts +123 -0
- package/dist/server/index.js +8 -0
- package/dist/sha256-BO0swRXL.js +150 -0
- package/dist/sink-Bd9PSyfq.js +141 -0
- package/dist/sink-Cfr9-7YC.d.ts +104 -0
- package/dist/skills-C_lpV4Jx.js +50 -0
- package/dist/skills-Dx3QLnMY.js +110 -0
- package/dist/snapshot-DE8dE_hz.js +1051 -0
- package/dist/solid/index.d.ts +63 -0
- package/dist/solid/index.js +197 -0
- package/dist/source-DoBBYJi2.js +6 -0
- package/dist/sources-DOpW5Ila.d.ts +347 -0
- package/dist/sources-DVHM3Mwq.js +284 -0
- package/dist/sql-Bll1d7y0.js +22 -0
- package/dist/src-CTNT-PBW.js +430 -0
- package/dist/ssf/index.d.ts +3 -0
- package/dist/ssf/index.js +622 -0
- package/dist/store-Bq90395i.d.ts +27 -0
- package/dist/store-C6hFW6ta.js +508 -0
- package/dist/stores-CkG2HrSg.js +140 -0
- package/dist/stores-CvOnu_2T.d.ts +41 -0
- package/dist/stream-BhMfZOwr.d.ts +12 -0
- package/dist/stream-Q_xlB6Ko.js +52 -0
- package/dist/style-CTm1AXDT.js +22 -0
- package/dist/subject-C6dLxqHU.js +24 -0
- package/dist/subject-settings-xt7V845g.js +82 -0
- package/dist/supabase/index.d.ts +67 -0
- package/dist/supabase/index.js +4 -0
- package/dist/supabase/middleware.d.ts +77 -0
- package/dist/supabase/middleware.js +68 -0
- package/dist/supabase-D8UlBA7Z.js +83 -0
- package/dist/supabase-DTxGtYdi.js +796 -0
- package/dist/supabase-hook-DW-xP4hU.js +2 -0
- package/dist/supabase-hook-D_OqGb_V.js +1528 -0
- package/dist/svelte/Protected.svelte +34 -0
- package/dist/svelte/index.d.ts +10 -0
- package/dist/svelte/index.js +53 -0
- package/dist/svelte/runtime.d.ts +6 -0
- package/dist/svelte/runtime.js +3 -0
- package/dist/svelte/source.d.ts +10 -0
- package/dist/svelte/source.js +8 -0
- package/dist/terminal/index.d.ts +171 -0
- package/dist/terminal/index.js +821 -0
- package/dist/testing/index.d.ts +656 -0
- package/dist/testing/index.js +3208 -0
- package/dist/testing/saas/index.d.ts +194 -0
- package/dist/testing/saas/index.js +3 -0
- package/dist/testing/saas/permissions.d.ts +2 -0
- package/dist/testing/saas/permissions.js +2 -0
- package/dist/text-diff-BdkkYSbi.js +15 -0
- package/dist/thenable-okc2xDsj.js +10 -0
- package/dist/timeout-D51niuQP.js +8 -0
- package/dist/trpc/index.d.ts +80 -0
- package/dist/trpc/index.js +205 -0
- package/dist/types-B8w5PFtS.d.ts +141 -0
- package/dist/types-B9dmiiab.d.ts +66 -0
- package/dist/types-BBK-exvJ.d.ts +177 -0
- package/dist/types-BvBk1wgn.d.ts +118 -0
- package/dist/types-CxquUByI.d.ts +9 -0
- package/dist/types-D6Z6-rKJ.d.ts +312 -0
- package/dist/types-DbWdEfcm.d.ts +87 -0
- package/dist/types-bva3i4Gf.d.ts +98 -0
- package/dist/types-i4BAOvdb.d.ts +97 -0
- package/dist/unplugin/index.d.ts +7 -0
- package/dist/unplugin/index.js +30 -0
- package/dist/usage-1ozMubLY.js +42 -0
- package/dist/usage-CUnK6Ou9.js +530 -0
- package/dist/usage-PyEor6GA.js +2 -0
- package/dist/validity-D0uRmJKJ.js +224 -0
- package/dist/verifier-B90agnhl.js +461 -0
- package/dist/version-CnSh33Fj.js +16 -0
- package/dist/vocabulary-Dk0fs-Jk.d.ts +56 -0
- package/dist/vue/index.d.ts +57 -0
- package/dist/vue/index.js +203 -0
- package/dist/webmcp/index.d.ts +75 -0
- package/dist/webmcp/index.js +313 -0
- package/package.json +423 -4
- package/rulesets/permdock.yaml +29 -0
- package/schemas/catalog-v1.json +507 -0
- package/schemas/openapi/oas-3.1.json +1298 -0
- package/schemas/openapi/oas-3.2.json +1516 -0
- package/schemas/openapi/overlay-1.1.json +79 -0
- package/schemas/supabase-claims-v1.json +139 -0
- package/schemas/supabase-manifest-v1.json +217 -0
- package/skills/permdock/SKILL.md +66 -0
- package/skills/permdock-agents/SKILL.md +77 -0
- package/skills/permdock-agents/references/runtimes.md +211 -0
- package/skills/permdock-approvals/SKILL.md +80 -0
- package/skills/permdock-approvals/references/stores-and-resume.md +83 -0
- package/skills/permdock-audit/SKILL.md +67 -0
- package/skills/permdock-credentials/SKILL.md +55 -0
- package/skills/permdock-credentials/references/tokens-keys-links.md +127 -0
- package/skills/permdock-data/SKILL.md +57 -0
- package/skills/permdock-data/references/queries-and-rls.md +54 -0
- package/skills/permdock-data/references/relationships.md +65 -0
- package/skills/permdock-tenancy/SKILL.md +77 -0
- package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +83 -0
- package/skills/permdock-wire/SKILL.md +116 -0
- package/skills/permdock-wire/references/adapters.md +480 -0
- package/skills/permdock-wire/references/policy-options.md +43 -0
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
import { p as isPrincipalRelation } from "./permissions-DC7GROPA.js";
|
|
2
|
+
import { a as hasConditionOp } from "./refs-ChLSxbST.js";
|
|
3
|
+
import { f as separationConflicts } from "./policy-ANp36PnI.js";
|
|
4
|
+
import { a as resolveScope, i as normalizeMemberships, u as scopeList } from "./scopes-cKVp_WTw.js";
|
|
5
|
+
import { s as flattenGrantee } from "./grantee-Bv04_Vzf.js";
|
|
6
|
+
import { o as parseCredential } from "./credential-B_rkUNlY.js";
|
|
7
|
+
import { p as validateCustomRole } from "./permdock-yoZ7EjxX.js";
|
|
8
|
+
import { n as DOCTOR_REPORT_SCHEMA } from "./version-CnSh33Fj.js";
|
|
9
|
+
import { o as jsonSchemaOf, s as policyRowConditionKeys } from "./catalog-doc-BSo_Q_-6.js";
|
|
10
|
+
import { i as sqlFiles, n as listSourceFiles, r as rel, t as defaultSrcPath } from "./files-B_wAGYmt.js";
|
|
11
|
+
import { a as loadModule, n as asPolicy, o as pickNamed } from "./load-BZ24CpZP.js";
|
|
12
|
+
import { t as runCollect } from "./collect-O-tOtbnk.js";
|
|
13
|
+
import { n as supabaseTenantClaim } from "./budget-NbEtbJC_.js";
|
|
14
|
+
import { i as quoteSqlLiteral } from "./sql-Bll1d7y0.js";
|
|
15
|
+
import { n as PERMDOCK_SCHEMA } from "./sources-DVHM3Mwq.js";
|
|
16
|
+
import "./markers-B0hYLvMD.js";
|
|
17
|
+
import { t as createStyle } from "./style-CTm1AXDT.js";
|
|
18
|
+
import { i as runSkillsInstall } from "./skills-Dx3QLnMY.js";
|
|
19
|
+
import { A as tableKey, C as pd009, D as pd040, E as pd028, O as group, S as pd006, T as pd022, a as loadScopes, b as MIGRATION_DIRS, i as hookOut, k as sqlStatements, l as pd039, n as attrsPlan, s as supabaseHookManifest, v as pd045, w as pd012, x as pd005, y as supabaseConfig } from "./supabase-hook-D_OqGb_V.js";
|
|
20
|
+
import { S as contextRefs } from "./rls-helpers-BxRuif0l.js";
|
|
21
|
+
import { d as commandFor, f as compileGrants, p as tableFor } from "./pg-4GJWDUxG.js";
|
|
22
|
+
import { d as rowConditionMessage, l as helperTablePolicies, n as graphPlan, o as ROW_CONDITION_FIX } from "./rls-graph-DxQ39PJj.js";
|
|
23
|
+
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-CUnK6Ou9.js";
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join, resolve } from "node:path";
|
|
26
|
+
//#region src/cli/doctor-collect.ts
|
|
27
|
+
async function pd002(input) {
|
|
28
|
+
const collected = await runCollect({
|
|
29
|
+
cwd: input.cwd,
|
|
30
|
+
config: input.config,
|
|
31
|
+
collect: input.config.collect ?? {},
|
|
32
|
+
check: true,
|
|
33
|
+
now: input.now,
|
|
34
|
+
io: input.io
|
|
35
|
+
});
|
|
36
|
+
if (collected.scan === void 0) return [];
|
|
37
|
+
return collected.scan.unknown.map((usage) => ({
|
|
38
|
+
code: "PD002",
|
|
39
|
+
severity: "error",
|
|
40
|
+
message: `unknown permission ${usage.call} at ${usage.file}:${String(usage.line)}`,
|
|
41
|
+
fix: "use a defined permission reference"
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
async function pd003(input) {
|
|
45
|
+
if (input.config.policy === void 0) return [];
|
|
46
|
+
const usage = await runUsage({
|
|
47
|
+
cwd: input.cwd,
|
|
48
|
+
config: input.config,
|
|
49
|
+
ignore: [],
|
|
50
|
+
strict: false,
|
|
51
|
+
json: true,
|
|
52
|
+
dynamicAsUsed: false,
|
|
53
|
+
now: input.now,
|
|
54
|
+
io: input.io
|
|
55
|
+
});
|
|
56
|
+
if (usage.code === 2) return [];
|
|
57
|
+
return JSON.parse(usage.output).ungranted.map((item) => ({
|
|
58
|
+
code: "PD003",
|
|
59
|
+
severity: "warning",
|
|
60
|
+
message: `${item.key} is used but never granted (${item.detail})`,
|
|
61
|
+
fix: "add an allow() with a to: selector, or a role() binding, in definePolicy"
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
async function pd004(input) {
|
|
65
|
+
const collected = await runCollect({
|
|
66
|
+
cwd: input.cwd,
|
|
67
|
+
config: input.config,
|
|
68
|
+
collect: input.config.collect ?? {},
|
|
69
|
+
check: true,
|
|
70
|
+
now: input.now,
|
|
71
|
+
io: input.io
|
|
72
|
+
});
|
|
73
|
+
if (collected.code === 0) return [];
|
|
74
|
+
if (collected.code === 2) return [];
|
|
75
|
+
return [{
|
|
76
|
+
code: "PD004",
|
|
77
|
+
severity: "error",
|
|
78
|
+
message: collected.message,
|
|
79
|
+
fix: "pnpm exec permdock collect"
|
|
80
|
+
}];
|
|
81
|
+
}
|
|
82
|
+
async function pd027(input) {
|
|
83
|
+
if (input.config.rls === void 0 || input.config.policy === void 0) return [];
|
|
84
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
85
|
+
if (policy === void 0) return [];
|
|
86
|
+
const findings = [];
|
|
87
|
+
for (const grant of policy.grants) {
|
|
88
|
+
const refs = [.../* @__PURE__ */ new Set([...contextRefs(grant.where), ...contextRefs(grant.check)])];
|
|
89
|
+
if (refs.length === 0) continue;
|
|
90
|
+
findings.push({
|
|
91
|
+
code: "PD027",
|
|
92
|
+
severity: "warning",
|
|
93
|
+
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`,
|
|
94
|
+
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"
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return findings;
|
|
98
|
+
}
|
|
99
|
+
async function pd016(input) {
|
|
100
|
+
if (input.config.rls === void 0 || input.config.policy === void 0) return [];
|
|
101
|
+
let policy;
|
|
102
|
+
try {
|
|
103
|
+
policy = asPolicy(pickNamed(await loadModule(resolve(input.cwd, input.config.policy)), ["policy"]));
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
const findings = [];
|
|
108
|
+
let opaque = false;
|
|
109
|
+
let sqlFunction = false;
|
|
110
|
+
for (const grant of policy.grants) {
|
|
111
|
+
if (hasConditionOp(grant.where, "opaque") || hasConditionOp(grant.check, "opaque")) opaque = true;
|
|
112
|
+
if (hasConditionOp(grant.where, "sqlFunction") || hasConditionOp(grant.check, "sqlFunction")) sqlFunction = true;
|
|
113
|
+
}
|
|
114
|
+
if (opaque) findings.push({
|
|
115
|
+
code: "PD016",
|
|
116
|
+
severity: "warning",
|
|
117
|
+
message: "policy has opaque RLS conditions that deny in memory",
|
|
118
|
+
fix: "add rls.functions.<name> with a portable twin, or rewrite as sqlFunction()"
|
|
119
|
+
});
|
|
120
|
+
if (sqlFunction) {
|
|
121
|
+
const fixtures = input.config.rls.fixtures ?? "rls.fixtures.json";
|
|
122
|
+
if (!existsSync(resolve(input.cwd, fixtures)) && !existsSync(resolve(input.cwd, "rls.fixtures.ts"))) findings.push({
|
|
123
|
+
code: "PD016",
|
|
124
|
+
severity: "warning",
|
|
125
|
+
message: "sqlFunction grants have no rls fixtures for verify --db",
|
|
126
|
+
fix: "add rls.fixtures.json and run permdock rls verify --db"
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return findings;
|
|
130
|
+
}
|
|
131
|
+
const DEFAULT_SENSITIVE_ACTIONS = [
|
|
132
|
+
"approve",
|
|
133
|
+
"pay",
|
|
134
|
+
"settle",
|
|
135
|
+
"submit",
|
|
136
|
+
"transfer",
|
|
137
|
+
"refund",
|
|
138
|
+
"disburse"
|
|
139
|
+
];
|
|
140
|
+
async function loadPolicy(cwd, path) {
|
|
141
|
+
try {
|
|
142
|
+
return asPolicy(pickNamed(await loadModule(resolve(cwd, path)), ["policy"]));
|
|
143
|
+
} catch {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function pd017(input) {
|
|
148
|
+
if (input.config.policy === void 0) return [];
|
|
149
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
150
|
+
if (policy === void 0) return [];
|
|
151
|
+
const sensitive = new Set(input.config.doctor?.sensitiveActions ?? DEFAULT_SENSITIVE_ACTIONS);
|
|
152
|
+
const denied = new Set(policy.grants.filter((grant) => grant.effect === "deny").map((grant) => grant.permission.key));
|
|
153
|
+
const findings = [];
|
|
154
|
+
const seen = /* @__PURE__ */ new Set();
|
|
155
|
+
for (const grant of policy.grants) {
|
|
156
|
+
if (grant.effect !== "allow") continue;
|
|
157
|
+
if (!sensitive.has(grant.permission.action)) continue;
|
|
158
|
+
if (grant.approval !== void 0) continue;
|
|
159
|
+
if (denied.has(grant.permission.key)) continue;
|
|
160
|
+
if (seen.has(grant.permission.key)) continue;
|
|
161
|
+
seen.add(grant.permission.key);
|
|
162
|
+
findings.push({
|
|
163
|
+
code: "PD017",
|
|
164
|
+
severity: "warning",
|
|
165
|
+
message: `${grant.permission.key} is a sensitive verb without approval`,
|
|
166
|
+
fix: "add approval: { by } on the allow, or a deny on the same leaf"
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return findings;
|
|
170
|
+
}
|
|
171
|
+
async function pd024(input) {
|
|
172
|
+
if (input.config.policy === void 0) return [];
|
|
173
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
174
|
+
if (policy === void 0) return [];
|
|
175
|
+
const findings = [];
|
|
176
|
+
for (const grant of policy.grants) {
|
|
177
|
+
const approval = grant.approval;
|
|
178
|
+
if (grant.effect !== "allow" || approval === void 0 || approval === "human" || approval.distinct !== false) continue;
|
|
179
|
+
findings.push({
|
|
180
|
+
code: "PD024",
|
|
181
|
+
severity: "warning",
|
|
182
|
+
message: `${grant.permission.key}${grant.role === null ? "" : ` (role ${grant.role})`} sets approval.distinct: false, so the requester can approve their own request`,
|
|
183
|
+
fix: "remove distinct: false unless the requester confirming their own call (an agent asking its user) is the intent"
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return findings;
|
|
187
|
+
}
|
|
188
|
+
function asMembershipsFixture(parsed) {
|
|
189
|
+
return parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
190
|
+
}
|
|
191
|
+
async function pd018(input) {
|
|
192
|
+
if (input.config.policy === void 0) return [];
|
|
193
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
194
|
+
if (policy === void 0) return [];
|
|
195
|
+
const findings = [];
|
|
196
|
+
const declared = new Set(policy.roles.map((item) => item.name));
|
|
197
|
+
for (const binding of policy.roles) for (const name of binding.exclusiveWith ?? []) if (!declared.has(name)) findings.push({
|
|
198
|
+
code: "PD018",
|
|
199
|
+
severity: "error",
|
|
200
|
+
message: `exclusiveWith names undeclared role ${name} on ${binding.name}`,
|
|
201
|
+
fix: "declare the exclusive role with role() or remove it from exclusiveWith"
|
|
202
|
+
});
|
|
203
|
+
const fixturePath = input.config.doctor?.memberships;
|
|
204
|
+
if (fixturePath === void 0) return findings;
|
|
205
|
+
const absolute = resolve(input.cwd, fixturePath);
|
|
206
|
+
if (!existsSync(absolute)) {
|
|
207
|
+
findings.push({
|
|
208
|
+
code: "PD018",
|
|
209
|
+
severity: "warning",
|
|
210
|
+
message: `doctor.memberships fixture ${fixturePath} is missing`,
|
|
211
|
+
fix: "add the JSON fixture or remove doctor.memberships"
|
|
212
|
+
});
|
|
213
|
+
return findings;
|
|
214
|
+
}
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(readFileSync(absolute, "utf8"));
|
|
218
|
+
} catch {
|
|
219
|
+
findings.push({
|
|
220
|
+
code: "PD018",
|
|
221
|
+
severity: "warning",
|
|
222
|
+
message: `doctor.memberships fixture ${fixturePath} is not valid JSON`,
|
|
223
|
+
fix: "fix the JSON fixture"
|
|
224
|
+
});
|
|
225
|
+
return findings;
|
|
226
|
+
}
|
|
227
|
+
const record = asMembershipsFixture(parsed);
|
|
228
|
+
for (const custom of record.customRoles ?? []) {
|
|
229
|
+
const conflicts = separationConflicts(policy, [{
|
|
230
|
+
principal: custom.name,
|
|
231
|
+
...custom.tenant === void 0 ? {} : { tenant: custom.tenant },
|
|
232
|
+
roles: custom.includes ?? []
|
|
233
|
+
}]);
|
|
234
|
+
for (const conflict of conflicts) findings.push({
|
|
235
|
+
code: "PD018",
|
|
236
|
+
severity: "warning",
|
|
237
|
+
message: `custom role ${custom.name} includes exclusive roles ${conflict.roles.join(" and ")}`,
|
|
238
|
+
fix: "split the custom role so it does not include exclusiveWith pairs"
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
for (const conflict of separationConflicts(policy, record.memberships ?? [])) findings.push({
|
|
242
|
+
code: "PD018",
|
|
243
|
+
severity: "warning",
|
|
244
|
+
message: `membership ${conflict.principal || "unknown"} holds exclusive roles ${conflict.roles.join(" and ")}`,
|
|
245
|
+
fix: "remove one of the exclusive roles from the membership fixture"
|
|
246
|
+
});
|
|
247
|
+
return findings;
|
|
248
|
+
}
|
|
249
|
+
async function pd020(input) {
|
|
250
|
+
if (input.config.rls === void 0 || input.config.policy === void 0) return [];
|
|
251
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
252
|
+
if (policy === void 0) return [];
|
|
253
|
+
const tables = input.config.rls.tables;
|
|
254
|
+
const compiled = (policy.hostable ?? []).filter((key) => {
|
|
255
|
+
const resource = key.slice(0, key.lastIndexOf("."));
|
|
256
|
+
return tables === void 0 || tables[resource] !== void 0;
|
|
257
|
+
});
|
|
258
|
+
if (compiled.length === 0) return [];
|
|
259
|
+
return [{
|
|
260
|
+
code: "PD020",
|
|
261
|
+
severity: "warning",
|
|
262
|
+
message: `hostable permissions are also compiled into RLS, which never sees hosted grants: ${compiled.join(", ")}`,
|
|
263
|
+
fix: "remove them from hostable, or drop their tables from rls.tables and enforce them in the application"
|
|
264
|
+
}];
|
|
265
|
+
}
|
|
266
|
+
async function pd021(input) {
|
|
267
|
+
if (input.config.policy === void 0) return [];
|
|
268
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
269
|
+
if (policy === void 0 || (policy.hostable ?? []).length === 0) return [];
|
|
270
|
+
const missing = ["PERMDOCK_CLOUD_URL", "PERMDOCK_CLOUD_KEY"].filter((name) => (input.env[name] ?? "") === "");
|
|
271
|
+
if (missing.length === 0) return [];
|
|
272
|
+
return [{
|
|
273
|
+
code: "PD021",
|
|
274
|
+
severity: "warning",
|
|
275
|
+
message: `the policy lists hostable permissions but ${missing.join(" and ")} is not set, so no hosted grant can be published or fetched`,
|
|
276
|
+
fix: "set the variables from the PermDock Cloud dashboard, or remove hostable"
|
|
277
|
+
}];
|
|
278
|
+
}
|
|
279
|
+
async function pd019(input) {
|
|
280
|
+
const rls = input.config.rls;
|
|
281
|
+
if ((rls?.authorize ?? rls?.rbac?.authorize) !== "jwt") return [];
|
|
282
|
+
const expiry = supabaseConfig(input.cwd).jwtExpiry ?? 3600;
|
|
283
|
+
if (expiry <= 3600 || input.config.policy === void 0) return [];
|
|
284
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
285
|
+
if (policy === void 0) return [];
|
|
286
|
+
const sensitive = new Set(input.config.doctor?.sensitiveActions ?? DEFAULT_SENSITIVE_ACTIONS);
|
|
287
|
+
const keys = [...new Set(policy.grants.filter((grant) => grant.effect === "allow" && sensitive.has(grant.permission.action)).map((grant) => grant.permission.key))];
|
|
288
|
+
if (keys.length === 0) return [];
|
|
289
|
+
return [{
|
|
290
|
+
code: "PD019",
|
|
291
|
+
severity: "warning",
|
|
292
|
+
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`,
|
|
293
|
+
fix: "set rls.authorize: 'database', or lower [auth] jwt_expiry in supabase/config.toml to 3600 or less"
|
|
294
|
+
}];
|
|
295
|
+
}
|
|
296
|
+
function customRoleLabel(role) {
|
|
297
|
+
return role.tenant === void 0 ? `global custom role ${role.name}` : `custom role ${role.name} in ${role.tenant}`;
|
|
298
|
+
}
|
|
299
|
+
async function pd023(input) {
|
|
300
|
+
const fixturePath = input.config.doctor?.memberships;
|
|
301
|
+
if (input.config.policy === void 0 || fixturePath === void 0) return [];
|
|
302
|
+
const absolute = resolve(input.cwd, fixturePath);
|
|
303
|
+
if (!existsSync(absolute)) return [];
|
|
304
|
+
let parsed;
|
|
305
|
+
try {
|
|
306
|
+
parsed = JSON.parse(readFileSync(absolute, "utf8"));
|
|
307
|
+
} catch {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
311
|
+
if (policy === void 0) return [];
|
|
312
|
+
const findings = [];
|
|
313
|
+
for (const custom of asMembershipsFixture(parsed).customRoles ?? []) for (const entry of validateCustomRole(policy, custom).dropped) {
|
|
314
|
+
const what = "role" in entry ? `include ${entry.role}` : `permission ${entry.permission}`;
|
|
315
|
+
findings.push({
|
|
316
|
+
code: "PD023",
|
|
317
|
+
severity: "warning",
|
|
318
|
+
message: `${customRoleLabel(custom)} drops ${what} (${entry.reason})`,
|
|
319
|
+
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"
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return findings;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* PD055: a stored custom role names a permission by a key it was renamed
|
|
326
|
+
* from. It still resolves; the alias can only be dropped once storage holds
|
|
327
|
+
* the current key.
|
|
328
|
+
*/
|
|
329
|
+
async function pd055(input) {
|
|
330
|
+
const fixturePath = input.config.doctor?.memberships;
|
|
331
|
+
if (input.config.policy === void 0 || fixturePath === void 0) return [];
|
|
332
|
+
const absolute = resolve(input.cwd, fixturePath);
|
|
333
|
+
if (!existsSync(absolute)) return [];
|
|
334
|
+
let parsed;
|
|
335
|
+
try {
|
|
336
|
+
parsed = JSON.parse(readFileSync(absolute, "utf8"));
|
|
337
|
+
} catch {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
341
|
+
if (policy === void 0) return [];
|
|
342
|
+
const table = `${input.config.rls?.schema ?? "permdock"}.custom_role_permissions`;
|
|
343
|
+
const findings = [];
|
|
344
|
+
for (const custom of asMembershipsFixture(parsed).customRoles ?? []) for (const { from, to } of validateCustomRole(policy, custom).renamed) findings.push({
|
|
345
|
+
code: "PD055",
|
|
346
|
+
severity: "warning",
|
|
347
|
+
message: `${customRoleLabel(custom)} stores ${from}, which was renamed to ${to}`,
|
|
348
|
+
fix: `rewrite the stored key before removing the alias: update ${table} set permission = ${quoteSqlLiteral(to)} where permission = ${quoteSqlLiteral(from)};`
|
|
349
|
+
});
|
|
350
|
+
return findings;
|
|
351
|
+
}
|
|
352
|
+
function readJson(cwd, path) {
|
|
353
|
+
if (path === void 0) return;
|
|
354
|
+
const absolute = resolve(cwd, path);
|
|
355
|
+
if (!existsSync(absolute)) return;
|
|
356
|
+
try {
|
|
357
|
+
return JSON.parse(readFileSync(absolute, "utf8"));
|
|
358
|
+
} catch {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function listField(value, field) {
|
|
363
|
+
if (value === null || typeof value !== "object") return [];
|
|
364
|
+
const list = value[field];
|
|
365
|
+
return Array.isArray(list) ? list : [];
|
|
366
|
+
}
|
|
367
|
+
/** Fixture API keys that never expire, credential policies that allow it, and records that are not v1 credentials. */
|
|
368
|
+
function pd029(input) {
|
|
369
|
+
const parsed = readJson(input.cwd, input.config.doctor?.credentials);
|
|
370
|
+
const findings = [];
|
|
371
|
+
for (const [index, record] of listField(parsed, "credentials").entries()) {
|
|
372
|
+
const credential = parseCredential(record);
|
|
373
|
+
if (credential === void 0) {
|
|
374
|
+
findings.push({
|
|
375
|
+
code: "PD029",
|
|
376
|
+
severity: "warning",
|
|
377
|
+
message: `credentials[${String(index)}] is not a valid v1 credential, so it verifies to no subject`,
|
|
378
|
+
fix: "give it v: 1, id, kind, principal, permissions, createdBy and createdAt; a service key also needs tenant and roles"
|
|
379
|
+
});
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (credential.expiresAt === void 0) findings.push({
|
|
383
|
+
code: "PD029",
|
|
384
|
+
severity: "warning",
|
|
385
|
+
message: `API key ${credential.id} never expires`,
|
|
386
|
+
fix: "set expiresAt and rotate the key before it; a leaked key without expiry stays live until someone revokes it"
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const settings = parsed !== null && typeof parsed === "object" ? parsed.settings : void 0;
|
|
390
|
+
const tenants = settings !== null && typeof settings === "object" ? Object.entries(settings) : [];
|
|
391
|
+
for (const [tenant, value] of tenants) if ((value !== null && typeof value === "object" ? value.credentials : void 0)?.allowNoExpiry === true) findings.push({
|
|
392
|
+
code: "PD029",
|
|
393
|
+
severity: "warning",
|
|
394
|
+
message: `tenant ${tenant} allows API keys that never expire`,
|
|
395
|
+
fix: "drop allowNoExpiry and set maxTtl, or keep it only for a tenant that rotates keys on its own schedule"
|
|
396
|
+
});
|
|
397
|
+
return findings;
|
|
398
|
+
}
|
|
399
|
+
/** Fixture memberships the policy's scopes would drop: an undeclared scope, a missing parent id, mixed shapes. */
|
|
400
|
+
/**
|
|
401
|
+
* A scope whose roles can all be removed can end up with nobody able to
|
|
402
|
+
* manage it. Setting `min` on any of its roles (even `min: 0`) records the
|
|
403
|
+
* decision and silences the warning.
|
|
404
|
+
*/
|
|
405
|
+
async function pd026(input) {
|
|
406
|
+
if (input.config.policy === void 0) return [];
|
|
407
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
408
|
+
if (policy === void 0) return [];
|
|
409
|
+
const scopes = scopeList(policy.scopes);
|
|
410
|
+
const findings = [];
|
|
411
|
+
for (const { name } of scopes) {
|
|
412
|
+
const held = policy.roles.filter((binding) => typeof binding.on === "string" && resolveScope(scopes, binding.on) === name);
|
|
413
|
+
if (held.length === 0 || held.some((binding) => binding.min !== void 0)) continue;
|
|
414
|
+
findings.push({
|
|
415
|
+
code: "PD026",
|
|
416
|
+
severity: "warning",
|
|
417
|
+
message: `no role on scope '${name}' keeps a holder: every ${name} can lose its last ${held.map((binding) => binding.name).join(" / ")}`,
|
|
418
|
+
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`
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return findings;
|
|
422
|
+
}
|
|
423
|
+
async function pd025(input) {
|
|
424
|
+
const fixturePath = input.config.doctor?.memberships;
|
|
425
|
+
if (input.config.policy === void 0 || fixturePath === void 0) return [];
|
|
426
|
+
const absolute = resolve(input.cwd, fixturePath);
|
|
427
|
+
if (!existsSync(absolute)) return [];
|
|
428
|
+
let parsed;
|
|
429
|
+
try {
|
|
430
|
+
parsed = JSON.parse(readFileSync(absolute, "utf8"));
|
|
431
|
+
} catch {
|
|
432
|
+
return [];
|
|
433
|
+
}
|
|
434
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
435
|
+
if (policy === void 0) return [];
|
|
436
|
+
const scopes = scopeList(policy.scopes);
|
|
437
|
+
const names = scopes.map((scope) => scope.name).join(", ");
|
|
438
|
+
const findings = [];
|
|
439
|
+
for (const [index, membership] of (asMembershipsFixture(parsed).memberships ?? []).entries()) {
|
|
440
|
+
if (normalizeMemberships([membership], scopes).length > 0) continue;
|
|
441
|
+
findings.push({
|
|
442
|
+
code: "PD025",
|
|
443
|
+
severity: "warning",
|
|
444
|
+
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`,
|
|
445
|
+
fix: "use { scope, id, within: { <parent>: id }, roles } with a declared scope, or { on: { resource, id }, roles }"
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
return findings;
|
|
449
|
+
}
|
|
450
|
+
/** Columns of `resource` a field-limited read grant can hide: missing from an allow's list, or on a deny's. */
|
|
451
|
+
function limitedColumns(policy, resource, actions) {
|
|
452
|
+
const reads = policy.grants.filter((grant) => grant.permission.resource === resource && grant.fields !== void 0 && commandFor(grant.permission.action, actions) === "select");
|
|
453
|
+
const node = policy.resources.get(resource);
|
|
454
|
+
const schema = node === void 0 ? null : jsonSchemaOf(node);
|
|
455
|
+
const properties = schema !== null && typeof schema === "object" ? schema.properties : void 0;
|
|
456
|
+
const columns = properties !== null && typeof properties === "object" ? Object.keys(properties) : [...new Set(reads.flatMap((grant) => grant.fields ?? []))];
|
|
457
|
+
const key = node?.id ?? "id";
|
|
458
|
+
return columns.filter((column) => column !== key && reads.some((grant) => grant.effect === "deny" ? (grant.fields ?? []).includes(column) : !(grant.fields ?? []).includes(column)));
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* `fields` redact in the application; RLS is row-level unless field views
|
|
462
|
+
* compile, and a field view protects nothing while the base table still
|
|
463
|
+
* returns the columns to a direct read.
|
|
464
|
+
*/
|
|
465
|
+
async function pd030(input) {
|
|
466
|
+
const rls = input.config.rls;
|
|
467
|
+
if (rls === void 0 || input.config.policy === void 0) return [];
|
|
468
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
469
|
+
if (policy === void 0) return [];
|
|
470
|
+
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();
|
|
471
|
+
const findings = [];
|
|
472
|
+
for (const resource of resources) {
|
|
473
|
+
const columns = limitedColumns(policy, resource, rls.actions);
|
|
474
|
+
if (columns.length === 0) continue;
|
|
475
|
+
const table = tableFor(resource, rls.tables);
|
|
476
|
+
if (rls.fields !== "views") {
|
|
477
|
+
findings.push({
|
|
478
|
+
code: "PD030",
|
|
479
|
+
severity: "warning",
|
|
480
|
+
message: `read grants on ${resource} limit ${columns.join(", ")}, but RLS on ${table} is row-level: any client that reads ${table} directly gets those columns`,
|
|
481
|
+
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`
|
|
482
|
+
});
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (rls.revokeColumns !== true) findings.push({
|
|
486
|
+
code: "PD030",
|
|
487
|
+
severity: "warning",
|
|
488
|
+
message: `${table}_visible masks ${columns.join(", ")}, but ${table} still returns them to a direct read`,
|
|
489
|
+
fix: `set rls.revokeColumns: true (--revoke-columns) so clients read ${columns.join(", ")} only through ${table}_visible`
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return findings;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* A self-parented resource no `through` grant walks, or a `restricted`
|
|
496
|
+
* column no graph grant reaches: the declaration has no effect on any
|
|
497
|
+
* decision, and `rls generate` keeps no closure for it.
|
|
498
|
+
*/
|
|
499
|
+
async function pd031(input) {
|
|
500
|
+
if (input.config.policy === void 0) return [];
|
|
501
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
502
|
+
if (policy === void 0) return [];
|
|
503
|
+
const plan = graphPlan(policy);
|
|
504
|
+
const findings = [];
|
|
505
|
+
for (const node of policy.resources.values()) {
|
|
506
|
+
const walked = plan.get(node.name)?.closure !== void 0;
|
|
507
|
+
if (node.parent?.resource === node.name && !walked) findings.push({
|
|
508
|
+
code: "PD031",
|
|
509
|
+
severity: "warning",
|
|
510
|
+
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`,
|
|
511
|
+
fix: `grant through the chain (relation(permissions.${node.name}, '<relation>', { through: 'parent', depth: 16 })) or drop the self-parent`
|
|
512
|
+
});
|
|
513
|
+
const reached = [...plan.values()].some((entry) => entry.node.name === node.name || node.parent?.resource === entry.node.name && entry.node.name !== node.name);
|
|
514
|
+
if (node.restricted !== void 0 && !reached) findings.push({
|
|
515
|
+
code: "PD031",
|
|
516
|
+
severity: "warning",
|
|
517
|
+
message: `${node.name} declares restricted: '${node.restricted}', but no graph grant reaches ${node.name} rows, so the column keeps nothing out`,
|
|
518
|
+
fix: `add a relation(..., { through: 'parent' }) grant that reaches ${node.name}, or remove restricted`
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
return findings;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Under an `rls` config, a graph resource `rls generate` cannot name a helper
|
|
525
|
+
* for: one that shares a scope's name (its `permitted_<name>_ids` would
|
|
526
|
+
* replace the scope helper) or is not a lowercase SQL name.
|
|
527
|
+
*/
|
|
528
|
+
async function pd032(input) {
|
|
529
|
+
if (input.config.rls === void 0 || input.config.policy === void 0) return [];
|
|
530
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
531
|
+
if (policy === void 0) return [];
|
|
532
|
+
const scopes = scopeList(policy.scopes);
|
|
533
|
+
const findings = [];
|
|
534
|
+
for (const name of graphPlan(policy).keys()) if (scopes.some((scope) => scope.name === name)) findings.push({
|
|
535
|
+
code: "PD032",
|
|
536
|
+
severity: "error",
|
|
537
|
+
message: `graph resource ${name} shares its name with the ${name} scope: permitted_${name}_ids would replace the scope helper, so rls generate refuses it`,
|
|
538
|
+
fix: `rename the resource (for example ${name}s or ${name}_node) or the scope`
|
|
539
|
+
});
|
|
540
|
+
else if (!/^[a-z][a-z0-9_]*$/u.test(name)) findings.push({
|
|
541
|
+
code: "PD032",
|
|
542
|
+
severity: "error",
|
|
543
|
+
message: `graph resource ${name} is not a lowercase SQL name, so rls generate cannot name permitted_${name}_ids`,
|
|
544
|
+
fix: "rename the resource to lowercase letters, digits and underscores"
|
|
545
|
+
});
|
|
546
|
+
return findings;
|
|
547
|
+
}
|
|
548
|
+
/** Reads the doctor memberships fixture, if one is configured and parseable. */
|
|
549
|
+
function membershipsFixture(input) {
|
|
550
|
+
const fixturePath = input.config.doctor?.memberships;
|
|
551
|
+
if (fixturePath === void 0) return {};
|
|
552
|
+
const absolute = resolve(input.cwd, fixturePath);
|
|
553
|
+
if (!existsSync(absolute)) return {};
|
|
554
|
+
try {
|
|
555
|
+
return asMembershipsFixture(JSON.parse(readFileSync(absolute, "utf8")));
|
|
556
|
+
} catch {
|
|
557
|
+
return {};
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* PD033: a role activation without a `maxDuration` (an elevation with no
|
|
562
|
+
* ceiling never expires on its own), and an activation role that a fixture
|
|
563
|
+
* membership also holds standing (an eligible-only role must never be held
|
|
564
|
+
* directly).
|
|
565
|
+
*/
|
|
566
|
+
async function pd033(input) {
|
|
567
|
+
if (input.config.policy === void 0) return [];
|
|
568
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
569
|
+
if (policy === void 0) return [];
|
|
570
|
+
const findings = [];
|
|
571
|
+
const activation = /* @__PURE__ */ new Set();
|
|
572
|
+
for (const binding of policy.roles) {
|
|
573
|
+
if (binding.activation === void 0) continue;
|
|
574
|
+
activation.add(binding.name);
|
|
575
|
+
if (binding.activation.maxDuration === void 0) findings.push({
|
|
576
|
+
code: "PD033",
|
|
577
|
+
severity: "warning",
|
|
578
|
+
message: `role '${binding.name}' has activation without maxDuration: an elevation never expires on its own`,
|
|
579
|
+
fix: `set activation: { maxDuration: '4h', ... } on role '${binding.name}'`
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
const fixture = membershipsFixture(input);
|
|
583
|
+
const standing = /* @__PURE__ */ new Set();
|
|
584
|
+
const eligible = /* @__PURE__ */ new Set();
|
|
585
|
+
for (const membership of fixture.memberships ?? []) {
|
|
586
|
+
for (const name of membership.roles ?? []) standing.add(name);
|
|
587
|
+
for (const name of membership.eligible ?? []) eligible.add(name);
|
|
588
|
+
}
|
|
589
|
+
for (const name of activation) if (standing.has(name)) findings.push({
|
|
590
|
+
code: "PD033",
|
|
591
|
+
severity: "warning",
|
|
592
|
+
message: `activation role '${name}' is held standing by a fixture membership: an eligible-only role must never be held directly`,
|
|
593
|
+
fix: `list '${name}' under eligible, never roles, and activate it with permdock.activate`
|
|
594
|
+
});
|
|
595
|
+
return findings;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* PD034: a break-glass grant under an `rls` config. RLS never compiles a
|
|
599
|
+
* break-glass override; the server must read through a `security definer`
|
|
600
|
+
* function that checks a signed break-glass session and writes an audit row.
|
|
601
|
+
*/
|
|
602
|
+
async function pd034(input) {
|
|
603
|
+
if (input.config.policy === void 0 || input.config.rls === void 0) return [];
|
|
604
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
605
|
+
if (policy === void 0) return [];
|
|
606
|
+
const keys = /* @__PURE__ */ new Set();
|
|
607
|
+
for (const grant of policy.grants) if (grant.breakGlass !== void 0) keys.add(grant.permission.key);
|
|
608
|
+
return [...keys].map((key) => ({
|
|
609
|
+
code: "PD034",
|
|
610
|
+
severity: "warning",
|
|
611
|
+
message: `break-glass grant on ${key} cannot be compiled to RLS: it would leak the override into a database policy`,
|
|
612
|
+
fix: "read break-glass rows through a security definer function that checks a signed break-glass session and writes an audit row"
|
|
613
|
+
}));
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* PD035: a `supportAccess` role without `actorRequired`. Support access is
|
|
617
|
+
* impersonation; without an actor the session is unattributed.
|
|
618
|
+
*/
|
|
619
|
+
async function pd035(input) {
|
|
620
|
+
if (input.config.policy === void 0) return [];
|
|
621
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
622
|
+
if (policy === void 0) return [];
|
|
623
|
+
const findings = [];
|
|
624
|
+
for (const binding of policy.roles) if (binding.support !== void 0 && !binding.support.actorRequired) findings.push({
|
|
625
|
+
code: "PD035",
|
|
626
|
+
severity: "warning",
|
|
627
|
+
message: `support access role '${binding.name}' has actorRequired: false: a support session then runs as the tenant, unattributed`,
|
|
628
|
+
fix: `set actorRequired: true on supportAccess({ role: '${binding.name}', ... })`
|
|
629
|
+
});
|
|
630
|
+
return findings;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Storage and Realtime policies written outside PermDock (better-supabase
|
|
634
|
+
* `permdock` mode) that call the SQL helpers for a permission whose grants
|
|
635
|
+
* carry row conditions: the helpers check only role and scope.
|
|
636
|
+
*/
|
|
637
|
+
async function pd037(input) {
|
|
638
|
+
if (input.config.policy === void 0) return [];
|
|
639
|
+
const policy = await loadPolicy(input.cwd, input.config.policy);
|
|
640
|
+
if (policy === void 0) return [];
|
|
641
|
+
const conditioned = policyRowConditionKeys(policy);
|
|
642
|
+
const findings = [];
|
|
643
|
+
for (const file of sqlFiles(input.cwd, input.config.doctor?.migrations ?? MIGRATION_DIRS)) for (const found of helperTablePolicies(readFileSync(file, "utf8"))) {
|
|
644
|
+
const keys = found.keys.filter((key) => conditioned.has(key));
|
|
645
|
+
if (keys.length > 0) findings.push({
|
|
646
|
+
code: "PD037",
|
|
647
|
+
severity: "error",
|
|
648
|
+
message: rowConditionMessage(found, keys),
|
|
649
|
+
fix: ROW_CONDITION_FIX
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return findings;
|
|
653
|
+
}
|
|
654
|
+
//#endregion
|
|
655
|
+
//#region src/cli/doctor-declarative.ts
|
|
656
|
+
const SCHEMAS = "supabase/schemas";
|
|
657
|
+
const MIGRATIONS = "supabase/migrations";
|
|
658
|
+
const CREATES_HOOK = /\bcreate\s+(?:or\s+replace\s+)?function\s+[\w."]*custom_access_token_hook\b/iu;
|
|
659
|
+
const DROPS_HOOK = /\bdrop\s+function\s+(?:if\s+exists\s+)?[\w."]*custom_access_token_hook\b/iu;
|
|
660
|
+
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;
|
|
661
|
+
const DEFINES_HELPER = /\bcreate\s+(?:or\s+replace\s+)?function\s+[\w."]*permdock_has\s*\(/iu;
|
|
662
|
+
const CALLS_HELPER = /\b(?:permdock_has|(?:permitted|member)_[a-z][a-z0-9_]*_ids)\s*\(/iu;
|
|
663
|
+
function uncommented(text) {
|
|
664
|
+
return text.replaceAll(/--[^\n]*/gu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* PD042: a hook declared under `supabase/schemas` reaches the database through
|
|
668
|
+
* `supabase db diff`, which drops its `supabase_auth_admin` grants. pg-delta
|
|
669
|
+
* keeps them, so the check is off under `[experimental.pgdelta]`. A function
|
|
670
|
+
* created (or dropped and created again) without them is executable by
|
|
671
|
+
* `public` and not by the auth server, so a migration at or after the one
|
|
672
|
+
* that creates it must carry the grants.
|
|
673
|
+
*/
|
|
674
|
+
function pd042(cwd, config) {
|
|
675
|
+
if (config.supabase?.hook === void 0 || supabaseConfig(cwd).pgDelta !== void 0) return [];
|
|
676
|
+
if (!sqlFiles(cwd, [SCHEMAS]).some((file) => readFileSync(file, "utf8").startsWith(`-- permdock:hook v1 `))) return [];
|
|
677
|
+
const migrations = sqlFiles(cwd, [MIGRATIONS]).map((file) => ({
|
|
678
|
+
file,
|
|
679
|
+
text: uncommented(readFileSync(file, "utf8"))
|
|
680
|
+
}));
|
|
681
|
+
const created = migrations.findIndex(({ text }) => CREATES_HOOK.test(text));
|
|
682
|
+
const dropped = migrations.findLastIndex(({ text }) => DROPS_HOOK.test(text));
|
|
683
|
+
const needed = Math.max(created, dropped);
|
|
684
|
+
if (needed === -1) return [];
|
|
685
|
+
if (migrations.findLastIndex(({ text }) => GRANTS_HOOK.test(text)) >= needed) return [];
|
|
686
|
+
return [{
|
|
687
|
+
code: "PD042",
|
|
688
|
+
severity: "error",
|
|
689
|
+
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`,
|
|
690
|
+
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"
|
|
691
|
+
}];
|
|
692
|
+
}
|
|
693
|
+
/** The `[db.migrations] schema_paths` globs of `supabase/config.toml`, or Supabase's default. */
|
|
694
|
+
function schemaPaths(cwd) {
|
|
695
|
+
const paths = supabaseConfig(cwd).schemaPaths ?? [];
|
|
696
|
+
return paths.length === 0 ? ["./schemas/**/*.sql"] : paths;
|
|
697
|
+
}
|
|
698
|
+
/** Schema files in the order `supabase db diff` applies them: each glob in turn, sorted within it. */
|
|
699
|
+
function orderedSchemaFiles(cwd) {
|
|
700
|
+
return sqlFiles(join(cwd, "supabase"), schemaPaths(cwd), { order: "entry" });
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* PD043: `db diff` applies schema files in `schema_paths` order, so a policy
|
|
704
|
+
* that calls `permdock_has` or `permitted_<scope>_ids` before the file that
|
|
705
|
+
* defines them fails to apply. pg-delta orders by dependencies instead.
|
|
706
|
+
*/
|
|
707
|
+
function pd043(cwd) {
|
|
708
|
+
if (supabaseConfig(cwd).pgDelta !== void 0) return [];
|
|
709
|
+
if (!existsSync(join(cwd, SCHEMAS))) return [];
|
|
710
|
+
const files = orderedSchemaFiles(cwd).map((file) => ({
|
|
711
|
+
file,
|
|
712
|
+
text: uncommented(readFileSync(file, "utf8"))
|
|
713
|
+
}));
|
|
714
|
+
const helpers = files.findIndex(({ text }) => DEFINES_HELPER.test(text));
|
|
715
|
+
const defined = sqlFiles(cwd, [SCHEMAS]).find((file) => DEFINES_HELPER.test(uncommented(readFileSync(file, "utf8"))));
|
|
716
|
+
if (helpers === -1) return defined === void 0 ? [] : [{
|
|
717
|
+
code: "PD043",
|
|
718
|
+
severity: "warning",
|
|
719
|
+
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`,
|
|
720
|
+
fix: `add it to [db.migrations] schema_paths before every file that calls permdock_has, permitted_<scope>_ids or member_<scope>_ids`
|
|
721
|
+
}];
|
|
722
|
+
const helperFile = rel(cwd, files[helpers]?.file ?? "");
|
|
723
|
+
return files.slice(0, helpers).filter(({ text }) => CALLS_HELPER.test(text)).map(({ file }) => ({
|
|
724
|
+
code: "PD043",
|
|
725
|
+
severity: "warning",
|
|
726
|
+
message: `${rel(cwd, file)} calls the PermDock helpers, but schema_paths applies it before ${helperFile}, which defines them`,
|
|
727
|
+
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`
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region src/cli/doctor-next.ts
|
|
732
|
+
const ROUTE_GLOBS = ["**/api/permdock/route.{ts,tsx,js,jsx,mjs}"];
|
|
733
|
+
const ENDPOINT = /\bendpoint\s*(?::|=)\s*\{?\s*['"`]/u;
|
|
734
|
+
/** A grant the snapshot marks `portable: false`, so a client check needs the endpoint. */
|
|
735
|
+
function serverOnly(policy, grant) {
|
|
736
|
+
if (!grant.portable || hasConditionOp(grant.where, "related")) return true;
|
|
737
|
+
const relations = policy.resources.get(grant.permission.resource)?.relations;
|
|
738
|
+
return flattenGrantee(grant.to).some((item) => {
|
|
739
|
+
if (item.kind !== "relation") return false;
|
|
740
|
+
const spec = relations?.[item.relation];
|
|
741
|
+
return item.through !== void 0 || isPrincipalRelation(spec) && spec.period !== void 0;
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
function serverOnlyKeys(policy) {
|
|
745
|
+
const keys = /* @__PURE__ */ new Set();
|
|
746
|
+
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);
|
|
747
|
+
return keys;
|
|
748
|
+
}
|
|
749
|
+
/** An `app/**\/api/permdock/route.ts` that exports `permdockHandler`'s result, or an `endpoint` string in the source. */
|
|
750
|
+
function hasEndpoint(cwd, sources) {
|
|
751
|
+
if (listSourceFiles(cwd, ROUTE_GLOBS).filter((file) => /(?:^|[\\/])app[\\/]/u.test(rel(cwd, file))).some((file) => readFileSync(file, "utf8").includes("permdockHandler"))) return true;
|
|
752
|
+
return sources.some((file) => ENDPOINT.test(readFileSync(file, "utf8")));
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* PD044: `usePermission` reads a permission whose grant the snapshot cannot
|
|
756
|
+
* answer, and the app has no endpoint to ask, so the client always denies it.
|
|
757
|
+
*/
|
|
758
|
+
async function pd044(input) {
|
|
759
|
+
if (input.config.policy === void 0) return [];
|
|
760
|
+
let policy;
|
|
761
|
+
try {
|
|
762
|
+
policy = asPolicy(pickNamed(await loadModule(resolve(input.cwd, input.config.policy)), ["policy"]));
|
|
763
|
+
} catch {
|
|
764
|
+
return [];
|
|
765
|
+
}
|
|
766
|
+
const keys = serverOnlyKeys(policy);
|
|
767
|
+
if (keys.size === 0) return [];
|
|
768
|
+
const usages = (await runCollect({
|
|
769
|
+
cwd: input.cwd,
|
|
770
|
+
config: input.config,
|
|
771
|
+
collect: input.config.collect ?? {},
|
|
772
|
+
check: true,
|
|
773
|
+
now: input.now,
|
|
774
|
+
io: input.io
|
|
775
|
+
})).scan?.usages ?? {};
|
|
776
|
+
const hooked = [...keys].toSorted().flatMap((key) => (usages[key] ?? []).filter((usage) => usage.call === "usePermission").map((usage) => ({
|
|
777
|
+
key,
|
|
778
|
+
usage
|
|
779
|
+
})));
|
|
780
|
+
if (hooked.length === 0) return [];
|
|
781
|
+
const sources = listSourceFiles(input.cwd, input.config.collect?.srcPath ?? defaultSrcPath());
|
|
782
|
+
if (hasEndpoint(input.cwd, sources)) return [];
|
|
783
|
+
return hooked.map(({ key, usage }) => ({
|
|
784
|
+
code: "PD044",
|
|
785
|
+
severity: "warning",
|
|
786
|
+
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)`,
|
|
787
|
+
fix: "add app/api/permdock/route.ts exporting permdockHandler(), pass endpoint to createPermDock, or check it on the server with getPermission"
|
|
788
|
+
}));
|
|
789
|
+
}
|
|
790
|
+
//#endregion
|
|
791
|
+
//#region src/cli/doctor-seeds.ts
|
|
792
|
+
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;
|
|
793
|
+
const SEED_CLEAR = /^delete\s+from\s+((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)\s*$/iu;
|
|
794
|
+
const LITERAL = String.raw`'((?:[^']|'')*)'`;
|
|
795
|
+
const SEED_ROW = new RegExp(String.raw`\(\s*${LITERAL}\s*,\s*${LITERAL}\s*,\s*${LITERAL}\s*,\s*${LITERAL}\s*,\s*${LITERAL}\s*\)`, "gu");
|
|
796
|
+
function rowKey(values) {
|
|
797
|
+
return values.join(" ");
|
|
798
|
+
}
|
|
799
|
+
/** The rows of the last statement that seeds or clears `table`, in migration order. */
|
|
800
|
+
function lastSeed(cwd, config, table) {
|
|
801
|
+
let seeded;
|
|
802
|
+
for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) for (const { text } of sqlStatements(readFileSync(file, "utf8"))) {
|
|
803
|
+
const insert = SEED_INSERT.exec(text);
|
|
804
|
+
if (insert !== null && tableKey(group(insert, 1)) === table) {
|
|
805
|
+
const rows = /* @__PURE__ */ new Set();
|
|
806
|
+
for (const row of group(insert, 2).matchAll(SEED_ROW)) rows.add(rowKey(row.slice(1, 6).map((value) => value.replaceAll("''", "'"))));
|
|
807
|
+
seeded = {
|
|
808
|
+
file: rel(cwd, file),
|
|
809
|
+
rows
|
|
810
|
+
};
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const clear = SEED_CLEAR.exec(text);
|
|
814
|
+
if (clear !== null && tableKey(group(clear, 1)) === table) seeded = {
|
|
815
|
+
file: rel(cwd, file),
|
|
816
|
+
rows: /* @__PURE__ */ new Set()
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
return seeded;
|
|
820
|
+
}
|
|
821
|
+
/** The first three rows, as `(role permission grant_key scope effect)`. */
|
|
822
|
+
function sample(rows) {
|
|
823
|
+
return rows.slice(0, 3).map((row) => `(${row})`).join(", ");
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* PD054: the `role_permissions` rows the migrations seed last are not the
|
|
827
|
+
* rows the policy compiles to, so the helpers answer from a stale policy.
|
|
828
|
+
*/
|
|
829
|
+
async function pd054(input) {
|
|
830
|
+
const { cwd, config } = input;
|
|
831
|
+
const rls = config.rls;
|
|
832
|
+
if (config.policy === void 0 || rls === void 0 && config.supabase === void 0) return [];
|
|
833
|
+
const schema = rls?.schema ?? rls?.rbac?.schema ?? "permdock";
|
|
834
|
+
const seeded = lastSeed(cwd, config, `${schema}.role_permissions`);
|
|
835
|
+
if (seeded === void 0) return [];
|
|
836
|
+
const policy = await loadPolicy(cwd, config.policy);
|
|
837
|
+
if (policy === void 0) return [];
|
|
838
|
+
const ctx = {
|
|
839
|
+
dialect: rls?.dialect ?? "supabase",
|
|
840
|
+
scopes: scopeList(policy.scopes),
|
|
841
|
+
tenantClaim: rls?.tenantClaim ?? "tenant_id",
|
|
842
|
+
gucPrefix: rls?.gucPrefix ?? "app",
|
|
843
|
+
...rls?.actions === void 0 ? {} : { actions: rls.actions },
|
|
844
|
+
schema,
|
|
845
|
+
...rls?.fields === "views" ? { fields: "views" } : {}
|
|
846
|
+
};
|
|
847
|
+
let expected;
|
|
848
|
+
try {
|
|
849
|
+
expected = new Set(compileGrants(policy, ctx, rls?.tables, [], true).rolePermissions.map((row) => rowKey([
|
|
850
|
+
row.role,
|
|
851
|
+
row.permission,
|
|
852
|
+
row.grantKey,
|
|
853
|
+
row.scope,
|
|
854
|
+
row.effect
|
|
855
|
+
])));
|
|
856
|
+
} catch {
|
|
857
|
+
return [];
|
|
858
|
+
}
|
|
859
|
+
const missing = [...expected].filter((row) => !seeded.rows.has(row));
|
|
860
|
+
const stale = [...seeded.rows].filter((row) => !expected.has(row));
|
|
861
|
+
if (missing.length === 0 && stale.length === 0) return [];
|
|
862
|
+
const parts = [...missing.length === 0 ? [] : [`${String(missing.length)} missing, such as ${sample(missing)}`], ...stale.length === 0 ? [] : [`${String(stale.length)} stale, such as ${sample(stale)}`]];
|
|
863
|
+
return [{
|
|
864
|
+
code: "PD054",
|
|
865
|
+
severity: "warning",
|
|
866
|
+
message: `${seeded.file} seeds ${schema}.role_permissions with rows the policy no longer compiles to: ${parts.join("; ")}`,
|
|
867
|
+
fix: "run permdock rls generate (with --split ...,seeds --seeds-out into a new migration for declarative schemas) and apply it"
|
|
868
|
+
}];
|
|
869
|
+
}
|
|
870
|
+
//#endregion
|
|
871
|
+
//#region src/cli/doctor-sql.ts
|
|
872
|
+
/** Supabase's `[api] schemas` when `config.toml` sets none. */
|
|
873
|
+
const API_SCHEMAS = ["public", "graphql_public"];
|
|
874
|
+
const NAME = String.raw`((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)`;
|
|
875
|
+
const IDENT = String.raw`("[^"]+"|\w+)`;
|
|
876
|
+
/** A statement without its dollar-quoted bodies: what a function header says. */
|
|
877
|
+
function header(text) {
|
|
878
|
+
return text.replaceAll(/\$([A-Za-z_]*)\$[\s\S]*?\$\1\$/gu, "$$$$");
|
|
879
|
+
}
|
|
880
|
+
function isSupabase(cwd, config) {
|
|
881
|
+
return existsSync(join(cwd, "supabase")) || config.rls?.dialect === "supabase" || config.supabase !== void 0;
|
|
882
|
+
}
|
|
883
|
+
function migrationStatements(cwd, config) {
|
|
884
|
+
return sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS).flatMap((file) => sqlStatements(readFileSync(file, "utf8")).map((statement) => Object.assign({ file: rel(cwd, file) }, statement)));
|
|
885
|
+
}
|
|
886
|
+
/** The schema of a `tableKey`, which always names one. */
|
|
887
|
+
function schemaOf(key) {
|
|
888
|
+
return key.slice(0, key.indexOf("."));
|
|
889
|
+
}
|
|
890
|
+
function unquote(name) {
|
|
891
|
+
return name.replaceAll("\"", "").toLowerCase();
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* PD046: the schema of PermDock's `security definer` helpers is one the Data
|
|
895
|
+
* API exposes, so PostgREST serves them as RPCs.
|
|
896
|
+
*/
|
|
897
|
+
function pd046(cwd, config) {
|
|
898
|
+
if (!existsSync(join(cwd, "supabase/config.toml"))) return [];
|
|
899
|
+
const schema = config.rls?.schema ?? config.rls?.rbac?.schema ?? "permdock";
|
|
900
|
+
if (!(supabaseConfig(cwd).apiSchemas ?? API_SCHEMAS).includes(schema)) return [];
|
|
901
|
+
return [{
|
|
902
|
+
code: "PD046",
|
|
903
|
+
severity: "warning",
|
|
904
|
+
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`,
|
|
905
|
+
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`
|
|
906
|
+
}];
|
|
907
|
+
}
|
|
908
|
+
const USER_METADATA = /\b(raw_user_meta_data|user_metadata)\b/giu;
|
|
909
|
+
/** PD047: SQL that reads `user_metadata`, which every user can edit for themselves. */
|
|
910
|
+
function pd047(cwd, config) {
|
|
911
|
+
if (!isSupabase(cwd, config)) return [];
|
|
912
|
+
return migrationStatements(cwd, config).flatMap((statement) => [...statement.text.matchAll(USER_METADATA)].map((match) => ({
|
|
913
|
+
code: "PD047",
|
|
914
|
+
severity: "warning",
|
|
915
|
+
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`,
|
|
916
|
+
fix: "decide access from app_metadata (raw_app_meta_data), a server-owned table or a hook claim; user_metadata is for display only"
|
|
917
|
+
})));
|
|
918
|
+
}
|
|
919
|
+
const CREATE_FUNCTION = new RegExp(String.raw`^create\s+(?:or\s+replace\s+)?function\s+${NAME}\s*\(`, "iu");
|
|
920
|
+
const ALTER_SEARCH_PATH = new RegExp(String.raw`^alter\s+function\s+${NAME}\s*(?:\([^)]*\))?\s+set\s+search_path\b`, "iu");
|
|
921
|
+
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");
|
|
922
|
+
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");
|
|
923
|
+
function createdFunctions(statements) {
|
|
924
|
+
return statements.flatMap((statement) => {
|
|
925
|
+
const match = CREATE_FUNCTION.exec(statement.text);
|
|
926
|
+
return match === null ? [] : [{
|
|
927
|
+
...statement,
|
|
928
|
+
key: tableKey(group(match, 1)),
|
|
929
|
+
head: header(statement.text)
|
|
930
|
+
}];
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
/** PD048: a function without `set search_path`, so a caller's objects can shadow the ones it names. */
|
|
934
|
+
function pd048(cwd, config) {
|
|
935
|
+
if (!isSupabase(cwd, config)) return [];
|
|
936
|
+
const statements = migrationStatements(cwd, config);
|
|
937
|
+
const altered = new Set(statements.flatMap((statement) => {
|
|
938
|
+
const match = ALTER_SEARCH_PATH.exec(statement.text);
|
|
939
|
+
return match === null ? [] : [tableKey(group(match, 1))];
|
|
940
|
+
}));
|
|
941
|
+
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) => {
|
|
942
|
+
const definer = /\bsecurity\s+definer\b/iu.test(fn.head);
|
|
943
|
+
return {
|
|
944
|
+
code: "PD048",
|
|
945
|
+
severity: "warning",
|
|
946
|
+
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" : ""}`,
|
|
947
|
+
fix: `add set search_path = '' and qualify every name in the body (Supabase advisor function_search_path_mutable)`
|
|
948
|
+
};
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
/** Roles each `revoke` statement takes `execute` from, per function key or `schema.*`. */
|
|
952
|
+
function revokedExecute(statements) {
|
|
953
|
+
const revoked = /* @__PURE__ */ new Map();
|
|
954
|
+
const add = (key, roles) => {
|
|
955
|
+
const set = revoked.get(key) ?? /* @__PURE__ */ new Set();
|
|
956
|
+
for (const role of roles.split(",")) set.add(unquote(role.trim().replace(/\s+(?:cascade|restrict)$/iu, "")));
|
|
957
|
+
revoked.set(key, set);
|
|
958
|
+
};
|
|
959
|
+
for (const statement of statements) {
|
|
960
|
+
const one = REVOKE_EXECUTE.exec(statement.text);
|
|
961
|
+
if (one !== null) {
|
|
962
|
+
add(tableKey(group(one, 1)), group(one, 2));
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
const all = REVOKE_ALL_FUNCTIONS.exec(statement.text);
|
|
966
|
+
if (all !== null) {
|
|
967
|
+
const schema = all[1] ?? all[2];
|
|
968
|
+
add(`${schema === void 0 ? "*" : unquote(schema)}.*`, group(all, 3));
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return revoked;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* PD049: a `security definer` function `public` (every role) or, in Supabase's
|
|
975
|
+
* `public` schema, `anon` may still execute: Postgres grants `execute` to
|
|
976
|
+
* `public` on create, and Supabase's default privileges grant it to `anon`.
|
|
977
|
+
*/
|
|
978
|
+
function pd049(cwd, config) {
|
|
979
|
+
if (!isSupabase(cwd, config)) return [];
|
|
980
|
+
const statements = migrationStatements(cwd, config);
|
|
981
|
+
const revoked = revokedExecute(statements);
|
|
982
|
+
const findings = [];
|
|
983
|
+
for (const fn of createdFunctions(statements)) {
|
|
984
|
+
if (!/\bsecurity\s+definer\b/iu.test(fn.head) || /\breturns\s+(?:event_)?trigger\b/iu.test(fn.head)) continue;
|
|
985
|
+
const schema = schemaOf(fn.key);
|
|
986
|
+
const from = /* @__PURE__ */ new Set([
|
|
987
|
+
...revoked.get(fn.key) ?? [],
|
|
988
|
+
...revoked.get(`${schema}.*`) ?? [],
|
|
989
|
+
...revoked.get("*.*") ?? []
|
|
990
|
+
]);
|
|
991
|
+
const missing = ["public", ...schema === "public" ? ["anon"] : []].filter((role) => !from.has(role));
|
|
992
|
+
if (missing.length > 0) findings.push({
|
|
993
|
+
code: "PD049",
|
|
994
|
+
severity: "warning",
|
|
995
|
+
message: `${fn.file}:${String(fn.line)} security definer function ${fn.key} can be executed by ${missing.join(" and ")}`,
|
|
996
|
+
fix: `revoke execute on function ${fn.key}(…) from public, anon; then grant execute to the roles that call it`
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
return findings;
|
|
1000
|
+
}
|
|
1001
|
+
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");
|
|
1002
|
+
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");
|
|
1003
|
+
const DROP_TABLE = /^drop\s+table\s+(?:if\s+exists\s+)?([\s\S]+?)(?:\s+(?:cascade|restrict))?$/iu;
|
|
1004
|
+
/** PD050: a `public` table without row level security, which the Data API serves whole. */
|
|
1005
|
+
function pd050(cwd, config) {
|
|
1006
|
+
if (!isSupabase(cwd, config)) return [];
|
|
1007
|
+
const created = /* @__PURE__ */ new Map();
|
|
1008
|
+
const enabled = /* @__PURE__ */ new Set();
|
|
1009
|
+
for (const statement of migrationStatements(cwd, config)) {
|
|
1010
|
+
const table = CREATE_TABLE.exec(statement.text);
|
|
1011
|
+
if (table !== null) {
|
|
1012
|
+
created.set(tableKey(group(table, 1)), statement);
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
const rls = ENABLE_RLS.exec(statement.text);
|
|
1016
|
+
if (rls !== null) {
|
|
1017
|
+
enabled.add(tableKey(group(rls, 1)));
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
const dropped = DROP_TABLE.exec(statement.text);
|
|
1021
|
+
if (dropped !== null) for (const name of group(dropped, 1).split(",")) created.delete(tableKey(name.trim()));
|
|
1022
|
+
}
|
|
1023
|
+
return [...created].filter(([key]) => schemaOf(key) === "public" && !enabled.has(key)).map(([key, statement]) => ({
|
|
1024
|
+
code: "PD050",
|
|
1025
|
+
severity: "error",
|
|
1026
|
+
message: `${statement.file}:${String(statement.line)} creates ${key} without row level security, so the Data API serves every row to anon and authenticated`,
|
|
1027
|
+
fix: `alter table ${key} enable row level security; permdock rls generate writes its policies`
|
|
1028
|
+
}));
|
|
1029
|
+
}
|
|
1030
|
+
const CREATE_POLICY = new RegExp(String.raw`^create\s+policy\s+${IDENT}\s+on\s+${NAME}([\s\S]*)$`, "iu");
|
|
1031
|
+
const UNWRAPPED_AUTH = /(?<!\bselect\s{1,20})\bauth\.(uid|jwt)\s*\(\s*\)/giu;
|
|
1032
|
+
/** PD051: a policy that calls `auth.uid()` or `auth.jwt()` bare, so Postgres runs it once per row. */
|
|
1033
|
+
function pd051(cwd, config) {
|
|
1034
|
+
if (!isSupabase(cwd, config)) return [];
|
|
1035
|
+
return migrationStatements(cwd, config).flatMap((statement) => {
|
|
1036
|
+
const policy = CREATE_POLICY.exec(statement.text);
|
|
1037
|
+
if (policy === null) return [];
|
|
1038
|
+
const calls = [...new Set([...group(policy, 3).matchAll(UNWRAPPED_AUTH)].map((match) => `auth.${group(match, 1).toLowerCase()}()`))];
|
|
1039
|
+
const [first] = calls;
|
|
1040
|
+
return first === void 0 ? [] : [{
|
|
1041
|
+
code: "PD051",
|
|
1042
|
+
severity: "warning",
|
|
1043
|
+
message: `${statement.file}:${String(statement.line)} policy ${unquote(group(policy, 1))} on ${tableKey(group(policy, 2))} calls ${calls.join(" and ")} once per row`,
|
|
1044
|
+
fix: `wrap each call as (select ${first}) so Postgres evaluates it once per statement (Supabase advisor auth_rls_initplan)`
|
|
1045
|
+
}];
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
/** PD052: an update policy without `with check`, so nothing states what a row may become. */
|
|
1049
|
+
function pd052(cwd, config) {
|
|
1050
|
+
if (!isSupabase(cwd, config)) return [];
|
|
1051
|
+
return migrationStatements(cwd, config).flatMap((statement) => {
|
|
1052
|
+
const policy = CREATE_POLICY.exec(statement.text);
|
|
1053
|
+
const rest = policy?.[3] ?? "";
|
|
1054
|
+
if (policy === null || !/\bfor\s+update\b/iu.test(rest) || /\bwith\s+check\b/iu.test(rest)) return [];
|
|
1055
|
+
return [{
|
|
1056
|
+
code: "PD052",
|
|
1057
|
+
severity: "warning",
|
|
1058
|
+
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`,
|
|
1059
|
+
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"
|
|
1060
|
+
}];
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
/** Top-level comma-separated parts of a parenthesised list. */
|
|
1064
|
+
function topLevelParts(body) {
|
|
1065
|
+
const parts = [];
|
|
1066
|
+
let depth = 0;
|
|
1067
|
+
let start = 0;
|
|
1068
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
1069
|
+
const char = body[index];
|
|
1070
|
+
if (char === "(") depth += 1;
|
|
1071
|
+
else if (char === ")") depth -= 1;
|
|
1072
|
+
else if (char === "," && depth === 0) {
|
|
1073
|
+
parts.push(body.slice(start, index).trim());
|
|
1074
|
+
start = index + 1;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
parts.push(body.slice(start).trim());
|
|
1078
|
+
return parts.filter((part) => part !== "");
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* One column definition or table constraint of a `create table` body: its
|
|
1082
|
+
* first column, whether it is a foreign key, and whether a primary key or
|
|
1083
|
+
* unique constraint indexes that column.
|
|
1084
|
+
*/
|
|
1085
|
+
function tablePart(raw) {
|
|
1086
|
+
const part = raw.replace(/^constraint\s+(?:"[^"]+"|\w+)\s+/iu, "");
|
|
1087
|
+
const listed = /^(foreign\s+key|primary\s+key|unique)\s*\(([^)]*)\)/iu.exec(part);
|
|
1088
|
+
if (listed !== null) {
|
|
1089
|
+
const foreign = /^foreign/iu.test(group(listed, 1));
|
|
1090
|
+
return {
|
|
1091
|
+
column: firstColumn(group(listed, 2)),
|
|
1092
|
+
foreign,
|
|
1093
|
+
indexed: !foreign
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
return {
|
|
1097
|
+
column: unquote(/^("[^"]+"|\w+)/u.exec(part)?.[1] ?? ""),
|
|
1098
|
+
foreign: /\breferences\b/iu.test(part),
|
|
1099
|
+
indexed: /\b(?:primary\s+key|unique)\b/iu.test(part)
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
function firstColumn(list) {
|
|
1103
|
+
return unquote(list.replace(/,[\s\S]*$/u, "").trim());
|
|
1104
|
+
}
|
|
1105
|
+
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");
|
|
1106
|
+
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");
|
|
1107
|
+
/** PD053: a foreign key no index starts with, so joins and deletes on the referenced row scan the table. */
|
|
1108
|
+
function pd053(cwd, config) {
|
|
1109
|
+
if (!isSupabase(cwd, config)) return [];
|
|
1110
|
+
const keys = [];
|
|
1111
|
+
const indexed = /* @__PURE__ */ new Set();
|
|
1112
|
+
for (const statement of migrationStatements(cwd, config)) {
|
|
1113
|
+
const table = CREATE_TABLE.exec(statement.text);
|
|
1114
|
+
if (table !== null) {
|
|
1115
|
+
const key = tableKey(group(table, 1));
|
|
1116
|
+
const open = statement.text.indexOf("(", table[0].length - 1);
|
|
1117
|
+
const body = statement.text.slice(open + 1, statement.text.lastIndexOf(")"));
|
|
1118
|
+
for (const raw of topLevelParts(body)) {
|
|
1119
|
+
const found = tablePart(raw);
|
|
1120
|
+
if (found.foreign) keys.push({
|
|
1121
|
+
...statement,
|
|
1122
|
+
table: key,
|
|
1123
|
+
column: found.column
|
|
1124
|
+
});
|
|
1125
|
+
if (found.indexed) indexed.add(`${key}.${found.column}`);
|
|
1126
|
+
}
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
const added = ADD_CONSTRAINT.exec(statement.text);
|
|
1130
|
+
if (added !== null) {
|
|
1131
|
+
const key = tableKey(group(added, 1));
|
|
1132
|
+
const column = firstColumn(group(added, 4));
|
|
1133
|
+
if (/^foreign/iu.test(group(added, 3))) keys.push({
|
|
1134
|
+
...statement,
|
|
1135
|
+
table: key,
|
|
1136
|
+
column
|
|
1137
|
+
});
|
|
1138
|
+
else indexed.add(`${key}.${column}`);
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
const index = CREATE_INDEX.exec(statement.text);
|
|
1142
|
+
if (index !== null) indexed.add(`${tableKey(group(index, 2))}.${unquote(group(index, 3))}`);
|
|
1143
|
+
}
|
|
1144
|
+
return keys.filter((fk) => !indexed.has(`${fk.table}.${fk.column}`)).map((fk) => ({
|
|
1145
|
+
code: "PD053",
|
|
1146
|
+
severity: "warning",
|
|
1147
|
+
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}`,
|
|
1148
|
+
fix: `create index on ${fk.table} (${fk.column}); (Supabase advisor unindexed_foreign_keys)`
|
|
1149
|
+
}));
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* PD056: SQL still calls an `rls.migrate` helper by its legacy name, so the
|
|
1153
|
+
* shim (or the legacy function) cannot be dropped yet.
|
|
1154
|
+
*/
|
|
1155
|
+
function pd056(cwd, config) {
|
|
1156
|
+
const helpers = Object.keys(config.rls?.migrate?.helpers ?? {}).filter((name) => /^[a-z_][a-z0-9_]*$/u.test(name));
|
|
1157
|
+
if (helpers.length === 0) return [];
|
|
1158
|
+
const statements = migrationStatements(cwd, config);
|
|
1159
|
+
const findings = [];
|
|
1160
|
+
for (const name of helpers) {
|
|
1161
|
+
const own = new RegExp(String.raw`^\s*(?:create\s+(?:or\s+replace\s+)?function|drop\s+function|revoke|grant|alter\s+function|comment\s+on\s+function)\b[^(]*?(?:^|[\s."])${name}"?\s*\(`, "iu");
|
|
1162
|
+
const call = new RegExp(String.raw`(?<![\w$])(?:(?:"[^"]+"|\w+)\s*\.\s*)?"?${name}"?\s*\(`, "giu");
|
|
1163
|
+
let count = 0;
|
|
1164
|
+
const files = /* @__PURE__ */ new Set();
|
|
1165
|
+
for (const statement of statements) {
|
|
1166
|
+
const hits = own.test(statement.text) ? 0 : (statement.text.match(call) ?? []).length;
|
|
1167
|
+
if (hits > 0) {
|
|
1168
|
+
count += hits;
|
|
1169
|
+
files.add(statement.file);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (count === 0) continue;
|
|
1173
|
+
findings.push({
|
|
1174
|
+
code: "PD056",
|
|
1175
|
+
severity: "warning",
|
|
1176
|
+
message: `legacy helper ${name} is still called ${String(count)} time(s), in ${[...files].toSorted().join(", ")}`,
|
|
1177
|
+
fix: `run permdock rls migrate --write for policies, rewrite function bodies, views and triggers onto the permdock helpers by hand, then drop function ${name}`
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
return findings;
|
|
1181
|
+
}
|
|
1182
|
+
//#endregion
|
|
1183
|
+
//#region src/cli/doctor.ts
|
|
1184
|
+
/** Static checks over the Supabase migrations; `--only sql` runs them alone. */
|
|
1185
|
+
const SQL_CHECKS = [
|
|
1186
|
+
["PD046", pd046],
|
|
1187
|
+
["PD047", pd047],
|
|
1188
|
+
["PD048", pd048],
|
|
1189
|
+
["PD049", pd049],
|
|
1190
|
+
["PD050", pd050],
|
|
1191
|
+
["PD051", pd051],
|
|
1192
|
+
["PD052", pd052],
|
|
1193
|
+
["PD053", pd053]
|
|
1194
|
+
];
|
|
1195
|
+
async function runDoctor(input) {
|
|
1196
|
+
if (input.fix) {
|
|
1197
|
+
runSkillsInstall({
|
|
1198
|
+
cwd: input.cwd,
|
|
1199
|
+
agents: []
|
|
1200
|
+
});
|
|
1201
|
+
await runCollect({
|
|
1202
|
+
cwd: input.cwd,
|
|
1203
|
+
config: input.config,
|
|
1204
|
+
collect: input.config.collect ?? {},
|
|
1205
|
+
check: false,
|
|
1206
|
+
now: input.now,
|
|
1207
|
+
io: input.io
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
const findings = [];
|
|
1211
|
+
const wanted = new Set(input.only);
|
|
1212
|
+
const include = (group) => wanted.size === 0 || wanted.has(group) || wanted.has(group.toLowerCase());
|
|
1213
|
+
const srcPath = input.config.collect?.srcPath ?? defaultSrcPath();
|
|
1214
|
+
const sources = listSourceFiles(input.cwd, srcPath).map((file) => ({
|
|
1215
|
+
file: rel(input.cwd, file),
|
|
1216
|
+
text: readFileSync(file, "utf8")
|
|
1217
|
+
}));
|
|
1218
|
+
if (include("imports") || include("PD001")) {
|
|
1219
|
+
const clientEntries = new Set(listSourceFiles(input.cwd, input.config.doctor?.clientEntries ?? []).map((file) => rel(input.cwd, file)));
|
|
1220
|
+
findings.push(...pd001(sources, clientEntries));
|
|
1221
|
+
}
|
|
1222
|
+
if (include("references") || include("PD002")) findings.push(...await pd002(input));
|
|
1223
|
+
if (include("ungranted") || include("PD003")) findings.push(...await pd003(input));
|
|
1224
|
+
if (include("catalog") || include("PD004")) findings.push(...await pd004(input));
|
|
1225
|
+
if (include("skills") || include("PD005")) findings.push(...pd005(input.cwd));
|
|
1226
|
+
if (include("typescript") || include("PD006")) findings.push(...pd006(input.cwd));
|
|
1227
|
+
if (include("validation") || include("PD007")) findings.push(...pd007(sources));
|
|
1228
|
+
if (include("naming") || include("PD008")) findings.push(...pd008(sources));
|
|
1229
|
+
if (include("duplicates") || include("PD009")) findings.push(...pd009(input.cwd));
|
|
1230
|
+
if (include("claims") || include("PD010")) findings.push(...pd010(sources));
|
|
1231
|
+
if (include("tenant") || include("PD011")) findings.push(...pd011(sources));
|
|
1232
|
+
if (include("drafts") || include("PD012")) findings.push(...pd012(input.cwd, input.config));
|
|
1233
|
+
if (include("algorithms") || include("PD013")) findings.push(...pd013(sources));
|
|
1234
|
+
if (include("discovery") || include("PD014")) findings.push(...pd014(sources));
|
|
1235
|
+
if (include("typ") || include("PD015")) findings.push(...pd015(sources));
|
|
1236
|
+
if (include("rls") || include("PD016")) findings.push(...await pd016(input));
|
|
1237
|
+
if (include("sensitive") || include("PD017")) findings.push(...await pd017(input));
|
|
1238
|
+
if (include("separation") || include("PD018")) findings.push(...await pd018(input));
|
|
1239
|
+
if (include("jwt-roles") || include("PD019")) findings.push(...await pd019(input));
|
|
1240
|
+
if (include("hosted") || include("PD020")) findings.push(...await pd020(input));
|
|
1241
|
+
if (include("hosted") || include("PD021")) findings.push(...await pd021({
|
|
1242
|
+
...input,
|
|
1243
|
+
env: input.io.env ?? process.env
|
|
1244
|
+
}));
|
|
1245
|
+
if (include("views") || include("PD022")) findings.push(...pd022(input.cwd, input.config));
|
|
1246
|
+
if (include("custom-roles") || include("PD023")) findings.push(...await pd023(input));
|
|
1247
|
+
if (include("self-approval") || include("PD024")) findings.push(...await pd024(input));
|
|
1248
|
+
if (include("scopes") || include("PD025")) findings.push(...await pd025(input));
|
|
1249
|
+
if (include("ownership") || include("PD026")) findings.push(...await pd026(input));
|
|
1250
|
+
if (include("rls") || include("context-refs") || include("PD027")) findings.push(...await pd027(input));
|
|
1251
|
+
if (include("attrs") || include("supabase") || include("PD028")) findings.push(...pd028(input.cwd, input.config, attrsPlan));
|
|
1252
|
+
if (include("credentials") || include("PD029")) findings.push(...pd029(input));
|
|
1253
|
+
if (include("fields") || include("PD030")) findings.push(...await pd030(input));
|
|
1254
|
+
if (include("graph") || include("PD031")) findings.push(...await pd031(input));
|
|
1255
|
+
if (include("graph") || include("rls") || include("PD032")) findings.push(...await pd032(input));
|
|
1256
|
+
if (include("activation") || include("PD033")) findings.push(...await pd033(input));
|
|
1257
|
+
if (include("break-glass") || include("PD034")) findings.push(...await pd034(input));
|
|
1258
|
+
if (include("support") || include("PD035")) findings.push(...await pd035(input));
|
|
1259
|
+
if (include("bola") || include("PD036")) findings.push(...pd036(sources));
|
|
1260
|
+
if (include("supabase") || include("row-conditions") || include("PD037")) findings.push(...await pd037(input));
|
|
1261
|
+
if (input.config.supabase?.hook !== void 0 && (include("supabase") || include("helpers") || include("PD039"))) findings.push(...await supabaseSetup(input));
|
|
1262
|
+
if ((input.config.rls !== void 0 || input.config.supabase?.hook !== void 0) && (include("supabase") || include("tenant") || include("PD038"))) findings.push(...pd038(sources, input.config.rls?.tenantClaim ?? "tenant_id", supabaseTenantClaim));
|
|
1263
|
+
if (input.config.rls?.anonymousSignIns === "deny" && (include("supabase") || include("anonymous") || include("PD057"))) findings.push(...pd057(sources));
|
|
1264
|
+
if (include("supabase") || include("auth-role") || include("PD040")) findings.push(...pd040(input.cwd, input.config));
|
|
1265
|
+
if (include("supabase") || include("capabilities") || include("PD041")) findings.push(...pd041(sources));
|
|
1266
|
+
if (include("supabase") || include("declarative") || include("PD042")) findings.push(...pd042(input.cwd, input.config));
|
|
1267
|
+
if (include("supabase") || include("declarative") || include("PD043")) findings.push(...pd043(input.cwd));
|
|
1268
|
+
if (include("supabase") || include("PD045")) findings.push(...pd045(input.cwd));
|
|
1269
|
+
for (const [code, check] of SQL_CHECKS) if (include("supabase") || include("sql") || include(code)) findings.push(...check(input.cwd, input.config));
|
|
1270
|
+
if (include("supabase") || include("seeds") || include("PD054")) findings.push(...await pd054(input));
|
|
1271
|
+
if (include("custom-roles") || include("renamed") || include("PD055")) findings.push(...await pd055(input));
|
|
1272
|
+
if (include("sql") || include("shims") || include("PD056")) findings.push(...pd056(input.cwd, input.config));
|
|
1273
|
+
if (include("next") || include("endpoint") || include("PD044")) findings.push(...await pd044(input));
|
|
1274
|
+
const errors = findings.filter((item) => item.severity === "error").length;
|
|
1275
|
+
const warnings = findings.filter((item) => item.severity === "warning").length;
|
|
1276
|
+
const report = {
|
|
1277
|
+
$schema: DOCTOR_REPORT_SCHEMA,
|
|
1278
|
+
findings,
|
|
1279
|
+
errors,
|
|
1280
|
+
warnings
|
|
1281
|
+
};
|
|
1282
|
+
return {
|
|
1283
|
+
code: errors > 0 || input.strict && warnings > 0 ? 1 : 0,
|
|
1284
|
+
output: input.json ? `${JSON.stringify(report, null, 2)}\n` : formatDoctor(report, input.color)
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
function formatDoctor(report, color) {
|
|
1288
|
+
const style = createStyle(color);
|
|
1289
|
+
const lines = [style.paint("bold", "permdock doctor"), ""];
|
|
1290
|
+
for (const finding of report.findings) {
|
|
1291
|
+
const mark = finding.severity === "error" ? style.errorMark : style.warnMark;
|
|
1292
|
+
lines.push(` ${mark} ${style.paint("bold", finding.code)} ${finding.message}`);
|
|
1293
|
+
lines.push(` ${style.paint("dim", `fix: ${finding.fix}`)}`);
|
|
1294
|
+
}
|
|
1295
|
+
if (report.findings.length === 0) lines.push(" no findings");
|
|
1296
|
+
lines.push("");
|
|
1297
|
+
lines.push(` ${String(report.errors)} error${report.errors === 1 ? "" : "s"}, ${String(report.warnings)} warning${report.warnings === 1 ? "" : "s"}`);
|
|
1298
|
+
return `${lines.join("\n")}\n`;
|
|
1299
|
+
}
|
|
1300
|
+
async function supabaseSetup(input) {
|
|
1301
|
+
let manifest;
|
|
1302
|
+
try {
|
|
1303
|
+
manifest = supabaseHookManifest(await loadScopes(input.cwd, input.config), input.config, { out: hookOut(input.cwd, input.config) });
|
|
1304
|
+
} catch {
|
|
1305
|
+
return [];
|
|
1306
|
+
}
|
|
1307
|
+
return pd039({
|
|
1308
|
+
cwd: input.cwd,
|
|
1309
|
+
config: input.config,
|
|
1310
|
+
manifest
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
//#endregion
|
|
1314
|
+
export { runDoctor };
|