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,1528 @@
|
|
|
1
|
+
import { a as resolveScope, c as scopeChain, s as rootScope, u as scopeList } from "./scopes-cKVp_WTw.js";
|
|
2
|
+
import { r as SUPABASE_MANIFEST_SCHEMA } from "./version-CnSh33Fj.js";
|
|
3
|
+
import { i as sqlFiles, r as rel } from "./files-B_wAGYmt.js";
|
|
4
|
+
import { a as loadModule, n as asPolicy, o as pickNamed } from "./load-BZ24CpZP.js";
|
|
5
|
+
import { t as shortDiff } from "./text-diff-BdkkYSbi.js";
|
|
6
|
+
import { r as scopeColumn } from "./compile-D22iwvuV.js";
|
|
7
|
+
import { o as authorizeSql, r as readMemberships } from "./supabase-DTxGtYdi.js";
|
|
8
|
+
import "./budget-NbEtbJC_.js";
|
|
9
|
+
import { t as AUTHZ_VERSION_TABLE } from "./sources-DVHM3Mwq.js";
|
|
10
|
+
import { a as hookMarkerFields, n as HOOK_MARKER, t as GRANTS_MARKER } from "./markers-B0hYLvMD.js";
|
|
11
|
+
import { A as memberForSources, E as hasMemberFor, H as scopeTypeOf, I as quoteIdent, L as quoteLiteral, M as memberIdsHelper, R as quoteTable, h as activeRowSql, j as memberForTable, k as memberForHelper, p as globalRoleSource, y as checkSuspension } from "./rls-helpers-BxRuif0l.js";
|
|
12
|
+
import { r as FIELD_VIEWS, t as connectPg, y as roleNames } from "./pg-4GJWDUxG.js";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join, posix, resolve } from "node:path";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { parse } from "smol-toml";
|
|
17
|
+
//#region src/cli/deciding-columns.ts
|
|
18
|
+
/** `schema.table`, unquoted and lower case; `public` when unqualified. */
|
|
19
|
+
function tableKey(name) {
|
|
20
|
+
const parts = name.split(".").map((part) => part.replaceAll("\"", ""));
|
|
21
|
+
return (parts.length === 1 ? ["public", ...parts] : parts).join(".").toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The columns of each membership source's table that decide who holds which
|
|
25
|
+
* membership (user, scope, id, `within`, role, `via`, expiry), keyed by
|
|
26
|
+
* {@link tableKey}. A client that can write one can give itself a membership.
|
|
27
|
+
*/
|
|
28
|
+
function membershipColumns(config) {
|
|
29
|
+
const sources = config.supabase?.hook?.memberships ?? config.rls?.membershipSources ?? [];
|
|
30
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
31
|
+
for (const source of sources) {
|
|
32
|
+
const table = tableKey(source.sql.table);
|
|
33
|
+
const columns = byTable.get(table) ?? /* @__PURE__ */ new Set();
|
|
34
|
+
for (const column of source.sql.columns) columns.add(column);
|
|
35
|
+
byTable.set(table, columns);
|
|
36
|
+
}
|
|
37
|
+
return byTable;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Every `schema.table.column` a claim or a membership is computed from: the
|
|
41
|
+
* membership columns and the `attrs` table columns. Sorted.
|
|
42
|
+
*/
|
|
43
|
+
function decidingColumns(config, attrs) {
|
|
44
|
+
const entries = /* @__PURE__ */ new Set();
|
|
45
|
+
for (const [table, columns] of membershipColumns(config)) for (const column of columns) entries.add(`${table}.${column}`);
|
|
46
|
+
if (attrs?.table !== void 0) {
|
|
47
|
+
const table = tableKey(attrs.table);
|
|
48
|
+
for (const column of attrs.columns) entries.add(`${table}.${column}`);
|
|
49
|
+
}
|
|
50
|
+
return [...entries].toSorted();
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/cli/sql-statements.ts
|
|
54
|
+
function blankComments(sql) {
|
|
55
|
+
return sql.replaceAll(/--[^\n]*/gu, (comment) => " ".repeat(comment.length)).replaceAll(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replaceAll(/[^\n]/gu, " "));
|
|
56
|
+
}
|
|
57
|
+
/** Split on `;` outside string literals and dollar-quoted bodies. */
|
|
58
|
+
function sqlStatements(sql) {
|
|
59
|
+
const text = blankComments(sql);
|
|
60
|
+
const statements = [];
|
|
61
|
+
const push = (start, end) => {
|
|
62
|
+
const body = text.slice(start, end);
|
|
63
|
+
const lead = body.length - body.trimStart().length;
|
|
64
|
+
const trimmed = body.trim();
|
|
65
|
+
if (trimmed !== "") statements.push({
|
|
66
|
+
line: text.slice(0, start + lead).split("\n").length,
|
|
67
|
+
text: trimmed
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
let start = 0;
|
|
71
|
+
let index = 0;
|
|
72
|
+
while (index < text.length) {
|
|
73
|
+
const char = text[index];
|
|
74
|
+
if (char === "'") {
|
|
75
|
+
const end = text.indexOf("'", index + 1);
|
|
76
|
+
index = end === -1 ? text.length : end + 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (char === "$") {
|
|
80
|
+
const tag = /^\$[A-Za-z_]*\$/u.exec(text.slice(index, index + 64));
|
|
81
|
+
if (tag !== null) {
|
|
82
|
+
const end = text.indexOf(tag[0], index + tag[0].length);
|
|
83
|
+
index = end === -1 ? text.length : end + tag[0].length;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (char === ";") {
|
|
88
|
+
push(start, index);
|
|
89
|
+
start = index + 1;
|
|
90
|
+
}
|
|
91
|
+
index += 1;
|
|
92
|
+
}
|
|
93
|
+
push(start, text.length);
|
|
94
|
+
return statements;
|
|
95
|
+
}
|
|
96
|
+
/** Capture group `index` of a match, or '' when an optional group took no part. */
|
|
97
|
+
function group(match, index) {
|
|
98
|
+
return match[index] ?? "";
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/cli/doctor-project.ts
|
|
102
|
+
function pd005(cwd) {
|
|
103
|
+
const lockPath = join(cwd, ".permdock/skills-lock.json");
|
|
104
|
+
if (![
|
|
105
|
+
join(cwd, ".agents/skills"),
|
|
106
|
+
join(cwd, ".claude/skills"),
|
|
107
|
+
join(cwd, ".cursor/skills")
|
|
108
|
+
].some((folder) => existsSync(join(folder, "permdock/SKILL.md")))) return [{
|
|
109
|
+
code: "PD005",
|
|
110
|
+
severity: "warning",
|
|
111
|
+
message: "Agent Skills are not installed",
|
|
112
|
+
fix: "pnpm exec permdock skills install"
|
|
113
|
+
}];
|
|
114
|
+
if (!existsSync(lockPath)) return [{
|
|
115
|
+
code: "PD005",
|
|
116
|
+
severity: "warning",
|
|
117
|
+
message: "skills lock is missing",
|
|
118
|
+
fix: "pnpm exec permdock skills install"
|
|
119
|
+
}];
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
function pd006(cwd) {
|
|
123
|
+
try {
|
|
124
|
+
const pkg = createRequire(resolve(cwd, "package.json"))("typescript/package.json");
|
|
125
|
+
const major = Number(pkg.version.split(".")[0]);
|
|
126
|
+
if (major < 5 || major === 5 && Number(pkg.version.split(".")[1]) < 9) return [{
|
|
127
|
+
code: "PD006",
|
|
128
|
+
severity: "error",
|
|
129
|
+
message: `TypeScript ${pkg.version} is below the supported matrix (5.9, 6, 7)`,
|
|
130
|
+
fix: "upgrade typescript to 5.9 or later"
|
|
131
|
+
}];
|
|
132
|
+
if (major > 7) return [{
|
|
133
|
+
code: "PD006",
|
|
134
|
+
severity: "error",
|
|
135
|
+
message: `TypeScript ${pkg.version} is not in the supported matrix (5.9, 6, 7)`,
|
|
136
|
+
fix: "use TypeScript 5.9, 6 or 7"
|
|
137
|
+
}];
|
|
138
|
+
return [];
|
|
139
|
+
} catch {
|
|
140
|
+
return [{
|
|
141
|
+
code: "PD006",
|
|
142
|
+
severity: "error",
|
|
143
|
+
message: "typescript is not installed",
|
|
144
|
+
fix: "add typescript 5.9, 6 or 7"
|
|
145
|
+
}];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function pd009(cwd) {
|
|
149
|
+
const copies = [];
|
|
150
|
+
function walk(dir, depth) {
|
|
151
|
+
if (depth > 6 || !existsSync(dir)) return;
|
|
152
|
+
const pkg = join(dir, "node_modules/permdock/package.json");
|
|
153
|
+
if (existsSync(pkg)) copies.push(pkg);
|
|
154
|
+
if (!existsSync(join(dir, "node_modules"))) return;
|
|
155
|
+
for (const name of readdirSync(join(dir, "node_modules"))) {
|
|
156
|
+
if (name.startsWith(".")) continue;
|
|
157
|
+
const nested = join(dir, "node_modules", name);
|
|
158
|
+
try {
|
|
159
|
+
if (statSync(nested).isDirectory()) walk(nested, depth + 1);
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
walk(cwd, 0);
|
|
164
|
+
if (copies.length > 1) return [{
|
|
165
|
+
code: "PD009",
|
|
166
|
+
severity: "error",
|
|
167
|
+
message: `duplicate permdock copies: ${copies.map((item) => rel(cwd, dirname(item))).join(", ")}`,
|
|
168
|
+
fix: "dedupe so only one permdock version is installed"
|
|
169
|
+
}];
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
function pd012(cwd, config) {
|
|
173
|
+
const docs = config.openapi?.doc ?? [];
|
|
174
|
+
const findings = [];
|
|
175
|
+
for (const doc of docs) {
|
|
176
|
+
const abs = resolve(cwd, doc);
|
|
177
|
+
if (!existsSync(abs)) continue;
|
|
178
|
+
const text = readFileSync(abs, "utf8");
|
|
179
|
+
if (text.includes("\"drafts\"") && !text.includes("overlay")) findings.push({
|
|
180
|
+
code: "PD012",
|
|
181
|
+
severity: "warning",
|
|
182
|
+
message: `${doc} carries a draft pin the CLI no longer emits`,
|
|
183
|
+
fix: "regenerate with permdock openapi"
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return findings;
|
|
187
|
+
}
|
|
188
|
+
const MIGRATION_DIRS = [
|
|
189
|
+
"supabase/migrations",
|
|
190
|
+
"supabase/schemas",
|
|
191
|
+
"migrations",
|
|
192
|
+
"drizzle",
|
|
193
|
+
"prisma/migrations",
|
|
194
|
+
"db/migrations"
|
|
195
|
+
];
|
|
196
|
+
const VIEW_NAME = String.raw`((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)`;
|
|
197
|
+
const CREATE_VIEW = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?(?:temp(?:orary)?\s+)?(?:recursive\s+)?view\s+${VIEW_NAME}([\s\S]*?)\bas\b`, "giu");
|
|
198
|
+
const ALTER_VIEW = new RegExp(String.raw`\balter\s+view\s+(?:if\s+exists\s+)?${VIEW_NAME}\s+set\s*\(([^)]*)\)`, "giu");
|
|
199
|
+
const INVOKER = /\bsecurity_invoker\s*(?:=\s*(?:true|on|'true'|'on'|1)\b|[,)]|$)/iu;
|
|
200
|
+
const COMPANION = new RegExp(String.raw`\bcomment\s+on\s+view\s+${VIEW_NAME}\s+is\s+'${FIELD_VIEWS.comment}\b`, "giu");
|
|
201
|
+
/**
|
|
202
|
+
* Views run as their owner unless `security_invoker` is set, so they read
|
|
203
|
+
* past RLS on the tables beneath them. The `<table>_visible_fields`
|
|
204
|
+
* companion `rls generate --revoke-columns` writes reads as its owner on
|
|
205
|
+
* purpose and carries a comment that says so.
|
|
206
|
+
*/
|
|
207
|
+
function pd022(cwd, config) {
|
|
208
|
+
if (config.rls === void 0 && config.doctor?.migrations === void 0) return [];
|
|
209
|
+
const created = /* @__PURE__ */ new Map();
|
|
210
|
+
const invoker = /* @__PURE__ */ new Set();
|
|
211
|
+
const companions = /* @__PURE__ */ new Set();
|
|
212
|
+
for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) {
|
|
213
|
+
const raw = readFileSync(file, "utf8");
|
|
214
|
+
const text = raw.replaceAll(/--[^\n]*/gu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
|
|
215
|
+
for (const [, name = "", options = ""] of text.matchAll(CREATE_VIEW)) {
|
|
216
|
+
const key = tableKey(name);
|
|
217
|
+
created.set(key, rel(cwd, file));
|
|
218
|
+
if (INVOKER.test(options)) invoker.add(key);
|
|
219
|
+
else invoker.delete(key);
|
|
220
|
+
}
|
|
221
|
+
for (const [, name = "", options = ""] of text.matchAll(ALTER_VIEW)) if (INVOKER.test(options)) invoker.add(tableKey(name));
|
|
222
|
+
for (const [, name = ""] of raw.matchAll(COMPANION)) companions.add(tableKey(name));
|
|
223
|
+
}
|
|
224
|
+
return [...created].filter(([key]) => !invoker.has(key) && !companions.has(key)).map(([key, file]) => ({
|
|
225
|
+
code: "PD022",
|
|
226
|
+
severity: "warning",
|
|
227
|
+
message: `view ${key} in ${file} is not security_invoker, so it reads past row level security`,
|
|
228
|
+
fix: `create the view with (security_invoker = true), or alter view ${key} set (security_invoker = true); Postgres 15 or later. For column-level reads, generate field views with permdock rls generate --fields views`
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
const AUTH_ROLE = /\bauth\.role\s*\(\s*\)/giu;
|
|
232
|
+
/**
|
|
233
|
+
* PD040: `auth.role()` in a migration. Supabase deprecated it; a policy
|
|
234
|
+
* names its Postgres roles with `to authenticated` instead, and the role
|
|
235
|
+
* claim it reads is the one PostgREST already switched to.
|
|
236
|
+
*/
|
|
237
|
+
function pd040(cwd, config) {
|
|
238
|
+
const findings = [];
|
|
239
|
+
for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) {
|
|
240
|
+
const text = readFileSync(file, "utf8").replaceAll(/--[^\n]*/gu, (comment) => " ".repeat(comment.length)).replaceAll(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replaceAll(/[^\n]/gu, " "));
|
|
241
|
+
for (const match of text.matchAll(AUTH_ROLE)) {
|
|
242
|
+
const line = text.slice(0, match.index).split("\n").length;
|
|
243
|
+
findings.push({
|
|
244
|
+
code: "PD040",
|
|
245
|
+
severity: "warning",
|
|
246
|
+
message: `${rel(cwd, file)}:${String(line)} calls auth.role(), which Supabase deprecated`,
|
|
247
|
+
fix: "name the roles on the policy (create policy ... to authenticated) and drop the auth.role() = 'authenticated' test; call permdock_has or permitted_<scope>_ids for permissions"
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return findings;
|
|
252
|
+
}
|
|
253
|
+
const GRANT = /\b(grant|revoke)\s+([\s\S]+?)\s+on\s+(?:table\s+)?([\w."]+)\s+(?:to|from)\s+([\w\s,"]+?)(?:\s+with\s+grant\s+option|\s+cascade|\s+restrict)?\s*;/giu;
|
|
254
|
+
const CLIENT_ROLES = /* @__PURE__ */ new Set([
|
|
255
|
+
"anon",
|
|
256
|
+
"authenticated",
|
|
257
|
+
"public"
|
|
258
|
+
]);
|
|
259
|
+
function applyPrivilege(writable, verb, part) {
|
|
260
|
+
const match = /^\s*(all(?:\s+privileges)?|insert|update)\s*(?:\(([^)]*)\))?\s*$/iu.exec(part);
|
|
261
|
+
if (match === null) return;
|
|
262
|
+
const kind = (match[1] ?? "").toLowerCase();
|
|
263
|
+
const sets = kind === "insert" ? [writable.insert] : kind === "update" ? [writable.update] : [writable.insert, writable.update];
|
|
264
|
+
const columns = match[2] === void 0 ? ["*"] : match[2].split(",").map((column) => column.trim().replaceAll("\"", ""));
|
|
265
|
+
for (const set of sets) for (const column of columns) if (verb.toLowerCase() === "grant") set.add(column);
|
|
266
|
+
else if (column === "*") set.clear();
|
|
267
|
+
else set.delete(column);
|
|
268
|
+
}
|
|
269
|
+
const CREATE_TABLE = /^create\s+(?:(?:global|local)\s+)?(?:(?:temp|temporary|unlogged)\s+)?table\s+(?:if\s+not\s+exists\s+)?((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)\s*\(/iu;
|
|
270
|
+
const REVOKE_DEFAULT_TABLES = /^alter\s+default\s+privileges\s+(?:for\s+(?:role|user)\s+[\w\s,"]+?\s+)?in\s+schema\s+"?public"?\s+revoke\s+[\s\S]+?\s+on\s+tables\s+from\s+[\s\S]*\b(?:anon|authenticated)\b/iu;
|
|
271
|
+
/**
|
|
272
|
+
* Columns of `table` a client role may insert or update, per the migrations
|
|
273
|
+
* in order; `*` for every column. On Supabase a table created in `public`
|
|
274
|
+
* starts writable: the default privileges grant `anon` and `authenticated`
|
|
275
|
+
* everything, until a migration revokes them.
|
|
276
|
+
*/
|
|
277
|
+
function clientWritable(cwd, config, target) {
|
|
278
|
+
const writable = {
|
|
279
|
+
insert: /* @__PURE__ */ new Set(),
|
|
280
|
+
update: /* @__PURE__ */ new Set()
|
|
281
|
+
};
|
|
282
|
+
let defaults = config.supabase !== void 0 && target.startsWith("public.");
|
|
283
|
+
for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) for (const { text } of sqlStatements(readFileSync(file, "utf8"))) {
|
|
284
|
+
if (REVOKE_DEFAULT_TABLES.test(text)) {
|
|
285
|
+
defaults = false;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const created = CREATE_TABLE.exec(text);
|
|
289
|
+
if (created !== null) {
|
|
290
|
+
if (defaults && tableKey(created[1] ?? "") === target) {
|
|
291
|
+
writable.insert.add("*");
|
|
292
|
+
writable.update.add("*");
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
for (const [, verb = "", privileges = "", name = "", roles = ""] of `${text};`.matchAll(GRANT)) {
|
|
297
|
+
if (tableKey(name) !== target) continue;
|
|
298
|
+
if (!roles.split(",").map((role) => role.trim().replaceAll("\"", "").toLowerCase()).some((role) => CLIENT_ROLES.has(role))) continue;
|
|
299
|
+
for (const part of privileges.split(/,(?![^(]*\))/u)) applyPrivilege(writable, verb, part);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return /* @__PURE__ */ new Set([...writable.insert, ...writable.update]);
|
|
303
|
+
}
|
|
304
|
+
/** Membership columns a client can write: a user who sets `user_id` or `role` grants themselves access. */
|
|
305
|
+
function membershipFindings(cwd, config) {
|
|
306
|
+
const findings = [];
|
|
307
|
+
for (const [table, columns] of membershipColumns(config)) {
|
|
308
|
+
const writable = clientWritable(cwd, config, table);
|
|
309
|
+
const exposed = [...columns].filter((column) => writable.has("*") || writable.has(column));
|
|
310
|
+
if (exposed.length > 0) findings.push({
|
|
311
|
+
code: "PD028",
|
|
312
|
+
severity: "warning",
|
|
313
|
+
message: `${table}.${exposed.join(", ")} decide${exposed.length === 1 ? "s" : ""} memberships, and the migrations let anon or authenticated insert or update ${exposed.length === 1 ? "it" : "them"}: a user could give themselves a membership`,
|
|
314
|
+
fix: `revoke insert, update on ${table} from anon, authenticated, then grant update (<columns clients edit>) on ${table} to authenticated; a column-level revoke alone leaves a table-level grant in place`
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return findings;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* `attrs` claims must come from server-owned columns: never `user_metadata`,
|
|
321
|
+
* never a column clients can write. Membership sources' deciding columns are
|
|
322
|
+
* held to the same rule.
|
|
323
|
+
*/
|
|
324
|
+
function pd028(cwd, config, plan) {
|
|
325
|
+
const memberships = membershipFindings(cwd, config);
|
|
326
|
+
const attrs = config.supabase?.hook?.attrs;
|
|
327
|
+
if (attrs === void 0) return memberships;
|
|
328
|
+
const planned = plan(attrs);
|
|
329
|
+
const findings = planned.errors.map((message) => ({
|
|
330
|
+
code: "PD028",
|
|
331
|
+
severity: "error",
|
|
332
|
+
message,
|
|
333
|
+
fix: "list server-owned columns or app_metadata.<key> entries; user_metadata is user-editable"
|
|
334
|
+
}));
|
|
335
|
+
if (planned.table !== void 0 && planned.columns.length > 0) {
|
|
336
|
+
const writable = clientWritable(cwd, config, tableKey(planned.table));
|
|
337
|
+
const exposed = planned.columns.filter((column) => writable.has("*") || writable.has(column));
|
|
338
|
+
if (exposed.length > 0) findings.push({
|
|
339
|
+
code: "PD028",
|
|
340
|
+
severity: "warning",
|
|
341
|
+
message: `attrs reads ${exposed.join(", ")} from ${tableKey(planned.table)}, which the migrations let anon or authenticated insert or update: a user could set their own attribute`,
|
|
342
|
+
fix: `revoke insert, update on ${tableKey(planned.table)} from anon, authenticated, and grant column-level update only on columns that are not attributes; the generated hook migration refuses to install otherwise`
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return [...findings, ...memberships];
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/cli/supabase-config.ts
|
|
349
|
+
const SUPABASE_CONFIG = "supabase/config.toml";
|
|
350
|
+
function table$1(value) {
|
|
351
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
352
|
+
return value;
|
|
353
|
+
}
|
|
354
|
+
function strings(value) {
|
|
355
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
|
|
356
|
+
}
|
|
357
|
+
/** `supabase/config.toml`, parsed; `undefined` when the project has none. */
|
|
358
|
+
function readSupabaseConfig(cwd) {
|
|
359
|
+
const file = join(cwd, SUPABASE_CONFIG);
|
|
360
|
+
if (!existsSync(file)) return;
|
|
361
|
+
let root;
|
|
362
|
+
try {
|
|
363
|
+
root = parse(readFileSync(file, "utf8"));
|
|
364
|
+
} catch (error) {
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
error: error instanceof Error ? error.message : String(error)
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const expiry = table$1(root["auth"])?.["jwt_expiry"];
|
|
371
|
+
const paths = table$1(table$1(root["db"])?.["migrations"])?.["schema_paths"];
|
|
372
|
+
const schemaPaths = strings(paths);
|
|
373
|
+
const apiSchemas = strings(table$1(root["api"])?.["schemas"]);
|
|
374
|
+
const pgDelta = table$1(table$1(root["experimental"])?.["pgdelta"]);
|
|
375
|
+
const schemaPath = pgDelta?.["declarative_schema_path"];
|
|
376
|
+
return {
|
|
377
|
+
ok: true,
|
|
378
|
+
config: {
|
|
379
|
+
...typeof expiry === "number" ? { jwtExpiry: expiry } : {},
|
|
380
|
+
...schemaPaths === void 0 ? {} : { schemaPaths },
|
|
381
|
+
...apiSchemas === void 0 ? {} : { apiSchemas },
|
|
382
|
+
...pgDelta?.["enabled"] === true ? { pgDelta: { schemaDir: posix.join("supabase", typeof schemaPath === "string" ? schemaPath : "schemas").replace(/\/$/u, "") } } : {}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/** The parsed config, or `{}` when it is missing or does not parse (PD045 reports that). */
|
|
387
|
+
function supabaseConfig(cwd) {
|
|
388
|
+
const read = readSupabaseConfig(cwd);
|
|
389
|
+
return read?.ok === true ? read.config : {};
|
|
390
|
+
}
|
|
391
|
+
/** PD045: a `supabase/config.toml` the CLI cannot parse, so every check that reads it falls back to defaults. */
|
|
392
|
+
function pd045(cwd) {
|
|
393
|
+
const read = readSupabaseConfig(cwd);
|
|
394
|
+
if (read === void 0 || read.ok) return [];
|
|
395
|
+
return [{
|
|
396
|
+
code: "PD045",
|
|
397
|
+
severity: "warning",
|
|
398
|
+
message: `${SUPABASE_CONFIG} does not parse: ${read.error.replace(/\n[\s\S]*/u, "")}`,
|
|
399
|
+
fix: `fix the TOML syntax in ${SUPABASE_CONFIG}; until then doctor assumes Supabase's defaults for jwt_expiry and schema_paths`
|
|
400
|
+
}];
|
|
401
|
+
}
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region src/cli/rls-rbac.ts
|
|
404
|
+
/**
|
|
405
|
+
* Where the helpers read roles and memberships: the flag, `rls.authorize`,
|
|
406
|
+
* `rls.rbac.authorize`, then `database` with `rls.membershipSources`, with
|
|
407
|
+
* `--rbac` or with a mapped memberships table, else `jwt`.
|
|
408
|
+
*/
|
|
409
|
+
function resolveAuthorize(config, flags = {}) {
|
|
410
|
+
const rls = config.rls;
|
|
411
|
+
const explicit = flags.authorize ?? rls?.authorize ?? rls?.rbac?.authorize;
|
|
412
|
+
if (explicit !== void 0) return explicit;
|
|
413
|
+
if (rls?.membershipSources !== void 0 || flags.rbac === true) return "database";
|
|
414
|
+
const memberships = flags.memberships ?? rls?.memberships;
|
|
415
|
+
return memberships?.tenant !== void 0 || memberships?.team !== void 0 || Object.keys(memberships?.scopes ?? {}).length > 0 ? "database" : "jwt";
|
|
416
|
+
}
|
|
417
|
+
function parseRbacAuthorize(raw) {
|
|
418
|
+
if (raw === void 0) return;
|
|
419
|
+
if (raw === "database" || raw === "jwt") return raw;
|
|
420
|
+
throw new Error(`PermDock CLI: --authorize must be database or jwt, got '${raw}'`);
|
|
421
|
+
}
|
|
422
|
+
function hookUri(schema) {
|
|
423
|
+
return `pg-functions://postgres/${schema}/custom_access_token_hook`;
|
|
424
|
+
}
|
|
425
|
+
function createEnum(schema, name, values) {
|
|
426
|
+
const list = values.map((value) => quoteLiteral(value)).join(", ");
|
|
427
|
+
return `do $$ begin
|
|
428
|
+
create type ${quoteTable(`${schema}.${name}`)} as enum (${list});
|
|
429
|
+
exception when duplicate_object then null;
|
|
430
|
+
end $$;`;
|
|
431
|
+
}
|
|
432
|
+
function hookTable(name) {
|
|
433
|
+
return quoteTable(name.includes(".") ? name : `public.${name}`);
|
|
434
|
+
}
|
|
435
|
+
/** A read policy and `select` grant for `supabase_auth_admin` on a table the hook reads. */
|
|
436
|
+
function authAdminRead(table, label) {
|
|
437
|
+
const t = hookTable(table);
|
|
438
|
+
const policy = quoteIdent(`permdock_auth_admin_read_${label}`);
|
|
439
|
+
return `grant select on table ${t} to supabase_auth_admin;
|
|
440
|
+
drop policy if exists ${policy} on ${t};
|
|
441
|
+
create policy ${policy} on ${t}
|
|
442
|
+
as permissive for select
|
|
443
|
+
to supabase_auth_admin
|
|
444
|
+
using (true);`;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Supabase's Custom Claims and RBAC scaffold on top of the PermDock helpers:
|
|
448
|
+
* enums, `user_roles` and `authorize()` over the shared `role_permissions`.
|
|
449
|
+
* The token hook comes only from `permdock supabase hook generate`, so one
|
|
450
|
+
* function writes the claims. Policies never call `authorize()` per row; they
|
|
451
|
+
* call the helpers. Never grants anything to `service_role`.
|
|
452
|
+
*/
|
|
453
|
+
function rbacScaffold(policy, options) {
|
|
454
|
+
const schema = options.schema;
|
|
455
|
+
const q = (name) => quoteTable(`${schema}.${name}`);
|
|
456
|
+
const s = quoteIdent(schema);
|
|
457
|
+
const permissions = [...new Set(policy.grants.map((grant) => grant.permission.key))];
|
|
458
|
+
const authorizeFn = authorizeSql({
|
|
459
|
+
schema,
|
|
460
|
+
authorize: options.authorize,
|
|
461
|
+
...options.scope === void 0 ? {} : { scope: options.scope },
|
|
462
|
+
...options.memberships === void 0 ? {} : { tenant: options.memberships },
|
|
463
|
+
...options.customRoles === void 0 ? {} : { customRoles: options.customRoles },
|
|
464
|
+
...options.context?.suspension === void 0 ? {} : { suspension: options.context.suspension }
|
|
465
|
+
});
|
|
466
|
+
return {
|
|
467
|
+
head: `-- rbac scaffold (Supabase Custom Claims and RBAC)
|
|
468
|
+
-- authorize: ${options.authorize}${options.authorize === "jwt" ? " (reads the hook claims; stale until the token refreshes)" : " (reads user_roles on every statement)"}
|
|
469
|
+
-- the custom access token hook that writes user_role${options.authorize === "jwt" ? " and memberships" : ""}: permdock supabase hook generate
|
|
470
|
+
${schema === "public" ? "" : `create schema if not exists ${s};\n`}${createEnum(schema, "app_role", roleNames(policy))}
|
|
471
|
+
${createEnum(schema, "app_permission", permissions)}
|
|
472
|
+
|
|
473
|
+
create table if not exists ${q("user_roles")} (
|
|
474
|
+
user_id uuid not null references auth.users on delete cascade,
|
|
475
|
+
role ${options.customRoles === void 0 ? q("app_role") : "text"} not null,
|
|
476
|
+
primary key (user_id, role)
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
alter table ${q("user_roles")} enable row level security;
|
|
480
|
+
revoke all on table ${q("user_roles")} from authenticated, anon, public;
|
|
481
|
+
`,
|
|
482
|
+
tail: authorizeFn,
|
|
483
|
+
warnings: []
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/** `--check`: one entry per file that is missing or differs, naming its part; a differing file carries a short diff. */
|
|
487
|
+
function driftOf(cwd, files) {
|
|
488
|
+
const drift = [];
|
|
489
|
+
for (const file of files) {
|
|
490
|
+
if (file.rel === "-") continue;
|
|
491
|
+
const path = resolve(cwd, file.rel);
|
|
492
|
+
if (!existsSync(path)) drift.push(`${file.part}: missing ${file.rel}`);
|
|
493
|
+
else {
|
|
494
|
+
const onDisk = readFileSync(path, "utf8");
|
|
495
|
+
if (onDisk !== file.text) drift.push(`${file.part}: ${file.rel}\n${shortDiff(file.rel, onDisk, file.text)}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return drift;
|
|
499
|
+
}
|
|
500
|
+
/** Writes every file but the stdout ones, whose text it returns for printing. */
|
|
501
|
+
function writeSqlFiles(cwd, files) {
|
|
502
|
+
const wrote = [];
|
|
503
|
+
const printed = [];
|
|
504
|
+
for (const file of files) {
|
|
505
|
+
if (file.rel === "-") {
|
|
506
|
+
printed.push(file.text.trimEnd());
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const path = resolve(cwd, file.rel);
|
|
510
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
511
|
+
writeFileSync(path, file.text);
|
|
512
|
+
wrote.push(file.rel);
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
wrote,
|
|
516
|
+
printed: printed.join("\n")
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
const PARTS = [
|
|
520
|
+
"helpers",
|
|
521
|
+
"seeds",
|
|
522
|
+
"indexes",
|
|
523
|
+
"policies",
|
|
524
|
+
"hook"
|
|
525
|
+
];
|
|
526
|
+
/** `--split helpers,seeds,indexes,policies,hook`: the parts in that fixed order, or an error. */
|
|
527
|
+
function parseSplit(raw) {
|
|
528
|
+
if (raw === void 0) return;
|
|
529
|
+
const names = raw.split(",").map((name) => name.trim()).filter((name) => name !== "");
|
|
530
|
+
if (names.filter((name) => !PARTS.some((part) => part === name)).length > 0 || names.length === 0) return `rls generate --split takes helpers, seeds, indexes, policies and hook, got '${raw}'`;
|
|
531
|
+
return PARTS.filter((part) => names.includes(part));
|
|
532
|
+
}
|
|
533
|
+
/** The path of one split part: `{part}` in `out` replaced by the part name. */
|
|
534
|
+
function partPath(out, part) {
|
|
535
|
+
return out.replaceAll("{part}", part);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* pg-delta's per-schema, unnumbered layout: pg-delta orders statements by
|
|
539
|
+
* their dependencies, so no file needs a number to apply first.
|
|
540
|
+
*/
|
|
541
|
+
function pgDeltaPath(root, part, schema) {
|
|
542
|
+
switch (part) {
|
|
543
|
+
case "helpers":
|
|
544
|
+
case "indexes":
|
|
545
|
+
case "seeds": return `${root}/${schema}/${part}.sql`;
|
|
546
|
+
case "policies": return `${root}/public/policies/permdock.sql`;
|
|
547
|
+
case "hook": return `${root}/${schema}/functions/custom_access_token_hook.sql`;
|
|
548
|
+
default: return part;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
//#endregion
|
|
552
|
+
//#region src/cli/supabase-setup.ts
|
|
553
|
+
const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
|
|
554
|
+
function escape(text) {
|
|
555
|
+
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
556
|
+
}
|
|
557
|
+
/** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
|
|
558
|
+
function helpersDefined(sql, schema) {
|
|
559
|
+
const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
|
|
560
|
+
const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
|
|
561
|
+
const names = /* @__PURE__ */ new Set();
|
|
562
|
+
for (const match of sql.matchAll(create)) {
|
|
563
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
564
|
+
if (HELPER_NAME.test(name)) names.add(name);
|
|
565
|
+
}
|
|
566
|
+
return names;
|
|
567
|
+
}
|
|
568
|
+
/** SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`) and the migration folders, not the hook itself. */
|
|
569
|
+
function helperSources(cwd, config, hookOut) {
|
|
570
|
+
const hook = resolve(cwd, hookOut);
|
|
571
|
+
const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
|
|
572
|
+
const rlsOut = resolve(cwd, config.rls?.out ?? "rls.sql");
|
|
573
|
+
if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
|
|
574
|
+
files.delete(hook);
|
|
575
|
+
return [...files];
|
|
576
|
+
}
|
|
577
|
+
function missingHelpersInFiles(cwd, config, manifest) {
|
|
578
|
+
const found = /* @__PURE__ */ new Set();
|
|
579
|
+
for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
|
|
580
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
581
|
+
}
|
|
582
|
+
async function missingHelpersInDb(db, manifest, connect = (url) => connectPg(url, "permdock supabase hook generate --db")) {
|
|
583
|
+
const client = await connect(db);
|
|
584
|
+
try {
|
|
585
|
+
const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
586
|
+
where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
|
|
587
|
+
const found = new Set(result.rows.map((row) => row["proname"]));
|
|
588
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
589
|
+
} finally {
|
|
590
|
+
await client.end();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
function missingHelpersMessage(manifest, missing) {
|
|
594
|
+
return `PD039 schema ${manifest.helpers.schema} has no ${missing.join(", ")}: the hook's claims are read by these helpers; run permdock rls generate and apply its migration`;
|
|
595
|
+
}
|
|
596
|
+
/** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
|
|
597
|
+
function oversizedClaims(samples, manifest) {
|
|
598
|
+
const encoder = new TextEncoder();
|
|
599
|
+
const oversized = [];
|
|
600
|
+
for (const claim of manifest.claims) {
|
|
601
|
+
if (claim.source === "permdock") continue;
|
|
602
|
+
let bytes = 0;
|
|
603
|
+
for (const sample of samples) {
|
|
604
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
605
|
+
const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
|
|
606
|
+
if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
|
|
607
|
+
}
|
|
608
|
+
if (bytes > manifest.budget.bytes) oversized.push({
|
|
609
|
+
name: claim.name,
|
|
610
|
+
bytes
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
return oversized;
|
|
614
|
+
}
|
|
615
|
+
/** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
|
|
616
|
+
function droppedMemberships(samples) {
|
|
617
|
+
const out = [];
|
|
618
|
+
for (const [index, sample] of samples.entries()) {
|
|
619
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
620
|
+
const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
|
|
621
|
+
const { dropped } = readMemberships(claim);
|
|
622
|
+
if (dropped.length > 0) out.push({
|
|
623
|
+
sample: index,
|
|
624
|
+
entries: dropped
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
function pd039(input) {
|
|
630
|
+
const findings = [];
|
|
631
|
+
const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
|
|
632
|
+
if (missing.length > 0) findings.push({
|
|
633
|
+
code: "PD039",
|
|
634
|
+
severity: "warning",
|
|
635
|
+
message: missingHelpersMessage(input.manifest, missing).slice(6),
|
|
636
|
+
fix: "run permdock rls generate, or set rls.out to the migration that holds the helpers"
|
|
637
|
+
});
|
|
638
|
+
const fixture = input.config.doctor?.claims;
|
|
639
|
+
if (fixture !== void 0) {
|
|
640
|
+
const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
|
|
641
|
+
const samples = Array.isArray(parsed) ? parsed : [];
|
|
642
|
+
for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
|
|
643
|
+
code: "PD039",
|
|
644
|
+
severity: "warning",
|
|
645
|
+
message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
|
|
646
|
+
fix: "return less from the claim function, or read the data per request instead of from the token"
|
|
647
|
+
});
|
|
648
|
+
for (const { sample, entries } of droppedMemberships(samples)) findings.push({
|
|
649
|
+
code: "PD039",
|
|
650
|
+
severity: "warning",
|
|
651
|
+
message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
|
|
652
|
+
fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return findings;
|
|
656
|
+
}
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/cli/supabase-hook.ts
|
|
659
|
+
const SUPABASE_HELP = `permdock supabase hook generate | inspect
|
|
660
|
+
|
|
661
|
+
hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
|
|
662
|
+
[--active-from app_metadata.active_<scope>|<table>.<column>]
|
|
663
|
+
[--budget 1024] [--schema public] [--grants-out <file>|-]
|
|
664
|
+
inspect [--json] [--out [permdock.manifest.json]] [--check]
|
|
665
|
+
|
|
666
|
+
Reads supabase.hook from permdock.config.ts: the fromTable / fromJunction sources the app
|
|
667
|
+
passes as memberships. hook generate emits custom_access_token_hook(jsonb), the grants it
|
|
668
|
+
needs (in --grants-out instead, for declarative schemas), the permdock_authz_version table
|
|
669
|
+
and its triggers. Never grants anything to
|
|
670
|
+
service_role. inspect prints the manifest: helper schema, names and signatures, tenant claim,
|
|
671
|
+
budget, the claims the hook writes, the membership sources and the columns that decide them.
|
|
672
|
+
--out writes it as JSON, to permdock.manifest.json without a path; --check exits 1 when that
|
|
673
|
+
file differs.
|
|
674
|
+
`;
|
|
675
|
+
const MANIFEST_FILE = "permdock.manifest.json";
|
|
676
|
+
const MANAGED_TRIGGER = "permdock_protect_managed";
|
|
677
|
+
const CLAIM_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
678
|
+
const PROTOTYPE_KEYS = /* @__PURE__ */ new Set([
|
|
679
|
+
"__proto__",
|
|
680
|
+
"constructor",
|
|
681
|
+
"prototype"
|
|
682
|
+
]);
|
|
683
|
+
const USER_EDITABLE = /^(raw_)?user_meta(_)?data$/iu;
|
|
684
|
+
/**
|
|
685
|
+
* Splits `supabase.hook.attrs` into table columns and `app_metadata` keys and
|
|
686
|
+
* lists what cannot be compiled: `user_metadata` (the user can edit it), a
|
|
687
|
+
* direct `auth.users` column, an unsafe or prototype key, a duplicate key,
|
|
688
|
+
* or a table column without a table.
|
|
689
|
+
*/
|
|
690
|
+
function attrsPlan(attrs) {
|
|
691
|
+
const errors = [];
|
|
692
|
+
const columns = [];
|
|
693
|
+
const meta = [];
|
|
694
|
+
const seen = /* @__PURE__ */ new Set();
|
|
695
|
+
if (attrs.table?.replaceAll("\"", "").toLowerCase() === "auth.users") errors.push("supabase.hook.attrs.table cannot be auth.users: list app_metadata.<key> entries instead");
|
|
696
|
+
for (const entry of attrs.columns) {
|
|
697
|
+
const [head = "", ...rest] = entry.split(".");
|
|
698
|
+
const isMeta = head === "app_metadata" || head === "raw_app_meta_data";
|
|
699
|
+
const key = isMeta ? rest.join(".") : entry;
|
|
700
|
+
if (USER_EDITABLE.test(head) || USER_EDITABLE.test(key) || /user_?meta/iu.test(entry)) {
|
|
701
|
+
errors.push(`supabase.hook.attrs lists ${entry}: user_metadata is user-editable and never becomes a claim`);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (!CLAIM_KEY.test(key) || PROTOTYPE_KEYS.has(key)) {
|
|
705
|
+
errors.push(`supabase.hook.attrs lists ${entry}: a key must match ${CLAIM_KEY.source} and not be a prototype key`);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (seen.has(key)) {
|
|
709
|
+
errors.push(`supabase.hook.attrs names the key ${key} twice`);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
seen.add(key);
|
|
713
|
+
if (isMeta) meta.push(key);
|
|
714
|
+
else columns.push(key);
|
|
715
|
+
}
|
|
716
|
+
if (columns.length > 0 && attrs.table === void 0) errors.push(`supabase.hook.attrs lists ${columns.join(", ")} without a table`);
|
|
717
|
+
return {
|
|
718
|
+
...attrs.table === void 0 ? {} : { table: attrs.table },
|
|
719
|
+
id: attrs.id ?? "id",
|
|
720
|
+
columns,
|
|
721
|
+
meta,
|
|
722
|
+
errors
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Claim names `supabase.hook.claims` cannot write: the ones this hook owns
|
|
727
|
+
* (plus the tenant claim) and the ones Supabase Auth issues.
|
|
728
|
+
*/
|
|
729
|
+
const RESERVED_CLAIMS = /* @__PURE__ */ new Set([
|
|
730
|
+
"roles",
|
|
731
|
+
"user_role",
|
|
732
|
+
"memberships",
|
|
733
|
+
"memberships_truncated",
|
|
734
|
+
"attrs",
|
|
735
|
+
"authz_ver",
|
|
736
|
+
"sub",
|
|
737
|
+
"aud",
|
|
738
|
+
"role",
|
|
739
|
+
"exp",
|
|
740
|
+
"iat",
|
|
741
|
+
"iss",
|
|
742
|
+
"aal",
|
|
743
|
+
"amr",
|
|
744
|
+
"session_id",
|
|
745
|
+
"is_anonymous",
|
|
746
|
+
"email",
|
|
747
|
+
"phone",
|
|
748
|
+
"app_metadata",
|
|
749
|
+
"user_metadata"
|
|
750
|
+
]);
|
|
751
|
+
const CLAIM_FUNCTION = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
752
|
+
/**
|
|
753
|
+
* Checks `supabase.hook.claims`: each name is a safe claim key outside
|
|
754
|
+
* {@link RESERVED_CLAIMS} and the tenant claim, each function is
|
|
755
|
+
* schema-qualified (the hook runs with an empty `search_path`).
|
|
756
|
+
*/
|
|
757
|
+
function extraClaimsPlan(claims, tenantClaim) {
|
|
758
|
+
const errors = [];
|
|
759
|
+
const planned = [];
|
|
760
|
+
for (const [claim, fn] of Object.entries(claims ?? {})) {
|
|
761
|
+
if (!CLAIM_KEY.test(claim) || PROTOTYPE_KEYS.has(claim)) {
|
|
762
|
+
errors.push(`supabase.hook.claims names ${claim}: a claim must match ${CLAIM_KEY.source} and not be a prototype key`);
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (RESERVED_CLAIMS.has(claim) || claim === tenantClaim) {
|
|
766
|
+
errors.push(`supabase.hook.claims names ${claim}, which PermDock or Supabase Auth writes`);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (typeof fn !== "string" || !CLAIM_FUNCTION.test(fn)) {
|
|
770
|
+
errors.push(`supabase.hook.claims.${claim} must be a schema-qualified function name such as better_supabase.feature_claims`);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
planned.push({
|
|
774
|
+
claim,
|
|
775
|
+
fn
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
claims: planned,
|
|
780
|
+
errors
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
const VERSION_TRIGGER = "permdock_authz_version";
|
|
784
|
+
const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
|
|
785
|
+
function memberForPlan(input) {
|
|
786
|
+
const memberFor = input.scopes.map((scope) => scope.name).filter((name) => hasMemberFor({
|
|
787
|
+
dialect: "supabase",
|
|
788
|
+
...input
|
|
789
|
+
}, name));
|
|
790
|
+
const entries = /* @__PURE__ */ new Map();
|
|
791
|
+
for (const name of memberFor) {
|
|
792
|
+
const mapped = memberForTable(input, name);
|
|
793
|
+
const found = mapped === void 0 ? memberForSources(input, name).map((source) => source.sql.manifest) : [mappedMembership(mapped, input.scopes, name)];
|
|
794
|
+
for (const entry of found) entries.set(JSON.stringify(entry), entry);
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
memberFor,
|
|
798
|
+
memberships: [...entries.values()]
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
/** An `rls.memberships` table in the manifest's `fromTable` / `fromJunction` shape, for scope `name`. */
|
|
802
|
+
function mappedMembership(mapped, scopes, name) {
|
|
803
|
+
const id = scopeColumn(mapped, scopes, name) ?? "";
|
|
804
|
+
const within = {};
|
|
805
|
+
for (const ancestor of scopeChain(scopes, name).slice(1)) {
|
|
806
|
+
const column = scopeColumn(mapped, scopes, ancestor);
|
|
807
|
+
if (column !== void 0) within[ancestor] = column;
|
|
808
|
+
}
|
|
809
|
+
const columns = [
|
|
810
|
+
mapped.user,
|
|
811
|
+
id,
|
|
812
|
+
mapped.role,
|
|
813
|
+
...Object.values(within),
|
|
814
|
+
...mapped.via === void 0 ? [] : [mapped.via],
|
|
815
|
+
...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt]
|
|
816
|
+
];
|
|
817
|
+
return {
|
|
818
|
+
table: mapped.table.includes(".") ? mapped.table : `public.${mapped.table}`,
|
|
819
|
+
user: { column: mapped.user },
|
|
820
|
+
scope: { value: name },
|
|
821
|
+
id: { column: id },
|
|
822
|
+
role: { column: mapped.role },
|
|
823
|
+
...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
|
|
824
|
+
...mapped.via === void 0 ? {} : { via: { column: mapped.via } },
|
|
825
|
+
...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
|
|
826
|
+
columns: [...new Set(columns)]
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function table(name) {
|
|
830
|
+
return quoteTable(name.includes(".") ? name : `public.${name}`);
|
|
831
|
+
}
|
|
832
|
+
function positiveInteger(value, label) {
|
|
833
|
+
const number = typeof value === "string" ? Number(value) : value;
|
|
834
|
+
if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) throw new Error(`PermDock CLI: ${label} must be a positive integer`);
|
|
835
|
+
return number;
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* SQL for the active first-scope id, as text, for user `uid`; a table source
|
|
839
|
+
* compares its id column with `v_active_user`, declared as `userType`.
|
|
840
|
+
*/
|
|
841
|
+
function activeFromSql(input, root) {
|
|
842
|
+
const spec = input ?? `app_metadata.active_${root}`;
|
|
843
|
+
if (typeof spec === "string" && spec.startsWith("app_metadata.")) {
|
|
844
|
+
const key = spec.slice(13);
|
|
845
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`PermDock CLI: unsafe app_metadata key '${key}'`);
|
|
846
|
+
return { sql: `claims -> 'app_metadata' ->> ${quoteLiteral(key)}` };
|
|
847
|
+
}
|
|
848
|
+
const parsed = typeof spec === "string" ? (() => {
|
|
849
|
+
const dot = spec.lastIndexOf(".");
|
|
850
|
+
if (dot <= 0) throw new Error(`PermDock CLI: --active-from must be app_metadata.<key> or <table>.<column>, got '${spec}'`);
|
|
851
|
+
return {
|
|
852
|
+
table: spec.slice(0, dot),
|
|
853
|
+
column: spec.slice(dot + 1)
|
|
854
|
+
};
|
|
855
|
+
})() : spec;
|
|
856
|
+
const id = "id" in parsed && parsed.id !== void 0 ? parsed.id : "id";
|
|
857
|
+
return {
|
|
858
|
+
sql: `(select a.${quoteIdent(parsed.column)}::text from ${table(parsed.table)} a where a.${quoteIdent(id)} = v_active_user)`,
|
|
859
|
+
userType: `${table(parsed.table)}.${quoteIdent(id)}%type`
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* One variable per user-keyed table the hook reads, holding `uid` (a uuid, as
|
|
864
|
+
* Supabase Auth issues) as that column's type, so each lookup compares the
|
|
865
|
+
* column uncast and its index applies.
|
|
866
|
+
*/
|
|
867
|
+
function typedUsers(parts) {
|
|
868
|
+
return [
|
|
869
|
+
...parts.sources.map((source, index) => `v_user_${String(index)} ${source.sql.userType} := uid;`),
|
|
870
|
+
...parts.roles === void 0 ? [] : [`v_roles_user ${table(parts.roles.table)}.${quoteIdent(parts.roles.user)}%type := uid;`],
|
|
871
|
+
...parts.attrs?.table === void 0 || parts.attrs.columns.length === 0 ? [] : [`v_attrs_user ${table(parts.attrs.table)}.${quoteIdent(parts.attrs.id)}%type := uid;`],
|
|
872
|
+
...parts.active.userType === void 0 ? [] : [`v_active_user ${parts.active.userType} := uid;`]
|
|
873
|
+
].map((line) => `\n ${line}`).join("");
|
|
874
|
+
}
|
|
875
|
+
function checkSources(parts) {
|
|
876
|
+
if (parts.sources.length === 0) throw new Error("PermDock CLI: supabase.hook.memberships needs at least one fromTable or fromJunction source");
|
|
877
|
+
for (const source of parts.sources) {
|
|
878
|
+
if (source.sql === void 0 || typeof source.sql.select !== "function") throw new Error("PermDock CLI: supabase.hook.memberships takes fromTable / fromJunction sources from permdock/supabase");
|
|
879
|
+
const scope = source.sql.scope;
|
|
880
|
+
if (scope === void 0) continue;
|
|
881
|
+
if (resolveScope(parts.scopes, scope) !== scope) throw new Error(`PermDock CLI: the ${source.sql.table} source names scope '${scope}', which the policy does not declare by that name`);
|
|
882
|
+
const chain = scopeChain(parts.scopes, scope);
|
|
883
|
+
const holds = new Set(source.sql.holds ?? [scope]);
|
|
884
|
+
const missing = chain.filter((ancestor) => !holds.has(ancestor));
|
|
885
|
+
if (missing.length > 0) throw new Error(`PermDock CLI: the ${source.sql.table} source needs within columns for ${missing.join(", ")}: a ${scope} membership without every ancestor grants nothing`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function entriesSql(parts) {
|
|
889
|
+
const tenant = `case when s.scope = ${quoteLiteral(parts.root)} then s.id else s.within ->> ${quoteLiteral(parts.root)} end`;
|
|
890
|
+
return parts.sources.map((source, index) => `select ${String(index)} as ord,
|
|
891
|
+
(${tenant}) as tenant,
|
|
892
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
893
|
+
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
894
|
+
'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
|
|
895
|
+
'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
|
|
896
|
+
'managedBy', s.managed_by, 'entitlements', s.seats
|
|
897
|
+
)) as entry
|
|
898
|
+
from (
|
|
899
|
+
${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
|
|
900
|
+
) s`).join("\nunion all\n");
|
|
901
|
+
}
|
|
902
|
+
function hookSql(parts) {
|
|
903
|
+
const schema = quoteIdent(parts.schema);
|
|
904
|
+
const fn = `${schema}.custom_access_token_hook`;
|
|
905
|
+
const rows = parts.roles;
|
|
906
|
+
const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
|
|
907
|
+
into held
|
|
908
|
+
from ${rows.from}
|
|
909
|
+
where ${rows.userSql} = v_roles_user;`;
|
|
910
|
+
const plan = parts.attrs;
|
|
911
|
+
const fromTable = plan === void 0 || plan.columns.length === 0 || plan.table === void 0 ? "" : `
|
|
912
|
+
select jsonb_strip_nulls(jsonb_build_object(${plan.columns.map((column) => `${quoteLiteral(column)}, to_jsonb(p.${quoteIdent(column)})`).join(", ")}))
|
|
913
|
+
into attrs
|
|
914
|
+
from ${table(plan.table)} p
|
|
915
|
+
where p.${quoteIdent(plan.id)} = v_attrs_user;`;
|
|
916
|
+
const fromMeta = plan === void 0 || plan.meta.length === 0 ? "" : `
|
|
917
|
+
attrs := coalesce(attrs, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(${plan.meta.map((key) => `${quoteLiteral(key)}, claims -> 'app_metadata' -> ${quoteLiteral(key)}`).join(", ")}));`;
|
|
918
|
+
const attrs = plan === void 0 ? "" : `${fromTable}${fromMeta}
|
|
919
|
+
if attrs is not null and attrs <> '{}'::jsonb then
|
|
920
|
+
used := octet_length(attrs::text);
|
|
921
|
+
if used > budget then
|
|
922
|
+
truncated := true;
|
|
923
|
+
used := 0;
|
|
924
|
+
else
|
|
925
|
+
claims := jsonb_set(claims, '{attrs}', attrs);
|
|
926
|
+
end if;
|
|
927
|
+
end if;`;
|
|
928
|
+
const versionTable = `${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)}`;
|
|
929
|
+
const version = parts.version ? `
|
|
930
|
+
select v.version into ver from ${versionTable} v where v.user_id = uid;
|
|
931
|
+
claims := jsonb_set(claims, '{authz_ver}', to_jsonb(coalesce(ver, 0)));` : "";
|
|
932
|
+
const dropExtra = parts.extra.map((entry) => ` - ${quoteLiteral(entry.claim)}`).join("");
|
|
933
|
+
const extra = parts.extra.map((entry) => `
|
|
934
|
+
extra := ${quoteTable(entry.fn)}(uid);
|
|
935
|
+
if extra is not null then
|
|
936
|
+
claims := jsonb_set(claims, ${quoteLiteral(`{${entry.claim}}`)}, extra);
|
|
937
|
+
end if;`).join("");
|
|
938
|
+
const suspended = parts.users === void 0 ? "" : `
|
|
939
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
940
|
+
claims := claims - 'memberships_truncated' - 'attrs' - ${quoteLiteral(parts.tenantClaim)}${dropExtra};
|
|
941
|
+
claims := claims || jsonb_build_object('user_role', '[]'::jsonb, 'roles', '[]'::jsonb, 'memberships', '[]'::jsonb);${version}
|
|
942
|
+
return jsonb_set(event, '{claims}', claims);
|
|
943
|
+
end if;`;
|
|
944
|
+
return `${parts.schema === "public" ? "" : `create schema if not exists ${schema};\nrevoke all on schema ${schema} from public;\n\n`}create or replace function ${fn}(event jsonb)
|
|
945
|
+
returns jsonb
|
|
946
|
+
language plpgsql
|
|
947
|
+
stable
|
|
948
|
+
set search_path = ''
|
|
949
|
+
as $$
|
|
950
|
+
declare
|
|
951
|
+
claims jsonb := event -> 'claims';
|
|
952
|
+
uid uuid := (event ->> 'user_id')::uuid;
|
|
953
|
+
active text;
|
|
954
|
+
held jsonb;
|
|
955
|
+
kept jsonb := '[]'::jsonb;
|
|
956
|
+
truncated boolean := false;
|
|
957
|
+
in_active boolean := false;
|
|
958
|
+
budget integer := ${String(parts.budget)};
|
|
959
|
+
used integer := 0;
|
|
960
|
+
item record;${typedUsers(parts)}${plan === void 0 ? "" : "\n attrs jsonb;"}${parts.extra.length === 0 ? "" : "\n extra jsonb;"}${parts.version ? "\n ver bigint;" : ""}
|
|
961
|
+
begin${suspended}
|
|
962
|
+
claims := claims - 'attrs'${dropExtra};
|
|
963
|
+
${roles}
|
|
964
|
+
claims := jsonb_set(claims, '{roles}', held);
|
|
965
|
+
if jsonb_array_length(held) = 1 then
|
|
966
|
+
claims := jsonb_set(claims, '{user_role}', held -> 0);
|
|
967
|
+
elsif jsonb_array_length(held) > 1 then
|
|
968
|
+
claims := jsonb_set(claims, '{user_role}', held);
|
|
969
|
+
end if;
|
|
970
|
+
active := ${parts.active.sql};${attrs}
|
|
971
|
+
for item in
|
|
972
|
+
select x.entry, x.tenant is not distinct from active as current
|
|
973
|
+
from (
|
|
974
|
+
${entriesSql(parts).replaceAll(/^/gmu, " ")}
|
|
975
|
+
) x
|
|
976
|
+
order by (x.tenant is not distinct from active) desc, x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text
|
|
977
|
+
loop
|
|
978
|
+
if octet_length((kept || jsonb_build_array(item.entry))::text) + used > budget then
|
|
979
|
+
truncated := true;
|
|
980
|
+
exit;
|
|
981
|
+
end if;
|
|
982
|
+
kept := kept || jsonb_build_array(item.entry);
|
|
983
|
+
in_active := in_active or (active is not null and item.current);
|
|
984
|
+
end loop;
|
|
985
|
+
claims := jsonb_set(claims, '{memberships}', kept);
|
|
986
|
+
if truncated then
|
|
987
|
+
claims := jsonb_set(claims, '{memberships_truncated}', 'true'::jsonb);
|
|
988
|
+
else
|
|
989
|
+
claims := claims - 'memberships_truncated';
|
|
990
|
+
end if;
|
|
991
|
+
if in_active then
|
|
992
|
+
claims := jsonb_set(claims, ${quoteLiteral(`{${parts.tenantClaim}}`)}, to_jsonb(active));
|
|
993
|
+
end if;${extra}${version}
|
|
994
|
+
return jsonb_set(event, '{claims}', claims);
|
|
995
|
+
end;
|
|
996
|
+
$$;`;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Everything the hook grants `supabase_auth_admin`, and the execute revoke:
|
|
1000
|
+
* the statements `supabase db diff` drops, so `--grants-out` moves them to
|
|
1001
|
+
* their own file.
|
|
1002
|
+
*/
|
|
1003
|
+
function grantsSql(parts) {
|
|
1004
|
+
const schema = quoteIdent(parts.schema);
|
|
1005
|
+
const fn = `${schema}.custom_access_token_hook`;
|
|
1006
|
+
return [
|
|
1007
|
+
`-- supabase_auth_admin: the grants and read policies the hook needs
|
|
1008
|
+
grant usage on schema ${schema} to supabase_auth_admin;
|
|
1009
|
+
grant execute on function ${fn}(jsonb) to supabase_auth_admin;
|
|
1010
|
+
revoke execute on function ${fn}(jsonb) from authenticated, anon, public;${extraGrantsSql(parts.extra)}${memberForGrantsSql(parts)}`,
|
|
1011
|
+
readsSql(parts),
|
|
1012
|
+
parts.version ? authAdminRead(`${parts.schema}.${AUTHZ_VERSION_TABLE}`, "version") : ""
|
|
1013
|
+
].filter((chunk) => chunk !== "").join("\n");
|
|
1014
|
+
}
|
|
1015
|
+
function extraGrantsSql(extra) {
|
|
1016
|
+
return [...[...new Set(extra.map((entry) => entry.fn.split(".")[0]))].map((name) => `\ngrant usage on schema ${quoteIdent(name ?? "")} to supabase_auth_admin;`), ...extra.map((entry) => `\ngrant execute on function ${quoteTable(entry.fn)}(uuid) to supabase_auth_admin;`)].join("");
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Execute on each `member_<scope>_ids_for(uuid)` for the `hook.claims`
|
|
1020
|
+
* functions, which run as `supabase_auth_admin` and may call them. Only with
|
|
1021
|
+
* `hook.claims`: the grant needs the helpers migration applied first, and a
|
|
1022
|
+
* hook without extra claims does not.
|
|
1023
|
+
*/
|
|
1024
|
+
function memberForGrantsSql(parts) {
|
|
1025
|
+
const { schema, memberFor } = parts.helpers;
|
|
1026
|
+
if (memberFor.length === 0 || parts.extra.length === 0) return "";
|
|
1027
|
+
return [...schema === parts.schema ? [] : [`\ngrant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`], ...memberFor.map((scope) => `\ngrant execute on function ${quoteIdent(schema)}.${memberForHelper(scope)}(uuid) to supabase_auth_admin;`)].join("");
|
|
1028
|
+
}
|
|
1029
|
+
function readsSql(parts) {
|
|
1030
|
+
const reads = /* @__PURE__ */ new Map();
|
|
1031
|
+
for (const source of parts.sources) reads.set(source.sql.table, "memberships");
|
|
1032
|
+
for (const source of parts.sources) for (const name of source.sql.reads) reads.set(name, reads.get(name) ?? "status");
|
|
1033
|
+
if (parts.roles !== void 0) {
|
|
1034
|
+
const { table: rolesTable, through } = parts.roles;
|
|
1035
|
+
reads.set(rolesTable, reads.get(rolesTable) ?? "roles");
|
|
1036
|
+
if (through !== void 0) reads.set(through.table, reads.get(through.table) ?? "role_keys");
|
|
1037
|
+
}
|
|
1038
|
+
if (parts.attrs?.table !== void 0 && parts.attrs.columns.length > 0) reads.set(parts.attrs.table, reads.get(parts.attrs.table) ?? "attrs");
|
|
1039
|
+
if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
|
|
1040
|
+
return [...[...new Set([...reads.keys()].map((name) => name.includes(".") ? name.split(".")[0] : "public").filter((schema) => schema !== void 0 && schema !== parts.schema && schema !== "auth"))].map((schema) => `grant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`), ...[...reads].map(([name, label]) => authAdminRead(name, label))].join("\n");
|
|
1041
|
+
}
|
|
1042
|
+
function versionSql(parts) {
|
|
1043
|
+
if (!parts.version) return "";
|
|
1044
|
+
const schema = quoteIdent(parts.schema);
|
|
1045
|
+
const versionTable = `${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)}`;
|
|
1046
|
+
const bump = `${schema}.permdock_bump_authz_version`;
|
|
1047
|
+
const bumpFor = `${schema}.${AUTHZ_VERSION_BUMP}`;
|
|
1048
|
+
const tables = [...new Map(parts.sources.map((source) => [source.sql.table, source.sql.user]))];
|
|
1049
|
+
if (parts.roles !== void 0) tables.push([parts.roles.table, parts.roles.user]);
|
|
1050
|
+
return `-- the authorization version: bumped on every membership change, written to authz_ver
|
|
1051
|
+
create table if not exists ${versionTable} (
|
|
1052
|
+
user_id uuid primary key references auth.users on delete cascade,
|
|
1053
|
+
version bigint not null default 0
|
|
1054
|
+
);
|
|
1055
|
+
alter table ${versionTable} enable row level security;
|
|
1056
|
+
revoke all on table ${versionTable} from anon, authenticated, public;
|
|
1057
|
+
|
|
1058
|
+
-- bumps each listed user once, for a membership source outside the hook; no client role may call it
|
|
1059
|
+
create or replace function ${bumpFor}(p_users uuid[])
|
|
1060
|
+
returns void
|
|
1061
|
+
language sql
|
|
1062
|
+
security definer
|
|
1063
|
+
set search_path = ''
|
|
1064
|
+
as $$
|
|
1065
|
+
insert into ${versionTable} as v (user_id, version)
|
|
1066
|
+
select distinct u, 1 from unnest(p_users) u where u is not null
|
|
1067
|
+
on conflict (user_id) do update set version = v.version + 1
|
|
1068
|
+
$$;
|
|
1069
|
+
revoke execute on function ${bumpFor}(uuid[]) from public, anon, authenticated;
|
|
1070
|
+
|
|
1071
|
+
create or replace function ${bump}()
|
|
1072
|
+
returns trigger
|
|
1073
|
+
language plpgsql
|
|
1074
|
+
security definer
|
|
1075
|
+
set search_path = ''
|
|
1076
|
+
as $$
|
|
1077
|
+
declare
|
|
1078
|
+
column_name text := tg_argv[0];
|
|
1079
|
+
begin
|
|
1080
|
+
perform ${bumpFor}(array[
|
|
1081
|
+
case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
|
|
1082
|
+
case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
|
|
1083
|
+
]::uuid[]);
|
|
1084
|
+
return null;
|
|
1085
|
+
end;
|
|
1086
|
+
$$;
|
|
1087
|
+
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
1088
|
+
${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${table(name)};
|
|
1089
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1090
|
+
after insert or update or delete on ${table(name)}
|
|
1091
|
+
for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${roleKeysVersionSql(parts, versionTable)}`;
|
|
1092
|
+
}
|
|
1093
|
+
/** A renamed role key changes every holder's `roles` claim, so updates to the roles table bump each holder. */
|
|
1094
|
+
function roleKeysVersionSql(parts, versionTable) {
|
|
1095
|
+
const through = parts.roles?.through;
|
|
1096
|
+
if (parts.roles === void 0 || through === void 0) return "";
|
|
1097
|
+
const bump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_role_keys`;
|
|
1098
|
+
const id = quoteIdent(through.id);
|
|
1099
|
+
const key = quoteIdent(through.key);
|
|
1100
|
+
return `
|
|
1101
|
+
|
|
1102
|
+
-- a renamed role key changes the roles claim of everyone who holds it
|
|
1103
|
+
create or replace function ${bump}()
|
|
1104
|
+
returns trigger
|
|
1105
|
+
language plpgsql
|
|
1106
|
+
security definer
|
|
1107
|
+
set search_path = ''
|
|
1108
|
+
as $$
|
|
1109
|
+
begin
|
|
1110
|
+
if tg_op = 'UPDATE'
|
|
1111
|
+
and new.${key} is not distinct from old.${key}
|
|
1112
|
+
and new.${id} is not distinct from old.${id} then
|
|
1113
|
+
return null;
|
|
1114
|
+
end if;
|
|
1115
|
+
insert into ${versionTable} as v (user_id, version)
|
|
1116
|
+
select distinct h.${quoteIdent(parts.roles.user)}::uuid, 1
|
|
1117
|
+
from ${table(parts.roles.table)} h
|
|
1118
|
+
where h.${quoteIdent(through.ref)} = old.${id}
|
|
1119
|
+
on conflict (user_id) do update set version = v.version + 1;
|
|
1120
|
+
return null;
|
|
1121
|
+
end;
|
|
1122
|
+
$$;
|
|
1123
|
+
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
1124
|
+
drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${table(through.table)};
|
|
1125
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1126
|
+
after update or delete on ${table(through.table)}
|
|
1127
|
+
for each row execute function ${bump}();`;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Fails the migration when a client role can write an `attrs` column: a
|
|
1131
|
+
* claim the user can set is not a server-owned attribute.
|
|
1132
|
+
*/
|
|
1133
|
+
function attrsGuardSql(plan) {
|
|
1134
|
+
if (plan?.table === void 0 || plan.columns.length === 0) return "";
|
|
1135
|
+
const target = quoteLiteral(table(plan.table));
|
|
1136
|
+
return `-- attrs must be server-owned: refuse columns anon or authenticated can insert or update
|
|
1137
|
+
do $$
|
|
1138
|
+
begin
|
|
1139
|
+
if exists (
|
|
1140
|
+
select 1
|
|
1141
|
+
from (values ${plan.columns.map((column) => `(${quoteLiteral(column)})`).join(", ")}) c(name)
|
|
1142
|
+
cross join (values ('anon'), ('authenticated')) r(role)
|
|
1143
|
+
where has_column_privilege(r.role, ${target}, c.name, 'INSERT')
|
|
1144
|
+
or has_column_privilege(r.role, ${target}, c.name, 'UPDATE')
|
|
1145
|
+
) then
|
|
1146
|
+
raise exception 'PermDock: an attrs column of % is writable by anon or authenticated; revoke insert and update on it before it becomes a claim', ${target}
|
|
1147
|
+
using errcode = '42501';
|
|
1148
|
+
end if;
|
|
1149
|
+
end
|
|
1150
|
+
$$;`;
|
|
1151
|
+
}
|
|
1152
|
+
function managedSql(parts) {
|
|
1153
|
+
const managed = parts.sources.filter((source) => source.sql.managed !== void 0);
|
|
1154
|
+
if (managed.length === 0) return "";
|
|
1155
|
+
const guard = `${quoteIdent(parts.schema)}.permdock_protect_managed`;
|
|
1156
|
+
return `-- memberships the identity provider owns: clients (anon, authenticated) cannot write them
|
|
1157
|
+
create or replace function ${guard}()
|
|
1158
|
+
returns trigger
|
|
1159
|
+
language plpgsql
|
|
1160
|
+
set search_path = ''
|
|
1161
|
+
as $$
|
|
1162
|
+
declare
|
|
1163
|
+
column_name text := tg_argv[0];
|
|
1164
|
+
begin
|
|
1165
|
+
if current_user in ('anon', 'authenticated') and (
|
|
1166
|
+
column_name = ''
|
|
1167
|
+
or (tg_op <> 'INSERT' and to_jsonb(old) ->> column_name = 'idp')
|
|
1168
|
+
or (tg_op <> 'DELETE' and to_jsonb(new) ->> column_name = 'idp')
|
|
1169
|
+
) then
|
|
1170
|
+
raise exception 'PermDock: % rows managed by the identity provider cannot be changed here', tg_table_name
|
|
1171
|
+
using errcode = '42501';
|
|
1172
|
+
end if;
|
|
1173
|
+
return coalesce(new, old);
|
|
1174
|
+
end;
|
|
1175
|
+
$$;
|
|
1176
|
+
${managed.map((source) => `drop trigger if exists ${quoteIdent(MANAGED_TRIGGER)} on ${table(source.sql.table)};
|
|
1177
|
+
create trigger ${quoteIdent(MANAGED_TRIGGER)}
|
|
1178
|
+
before insert or update or delete on ${table(source.sql.table)}
|
|
1179
|
+
for each row execute function ${guard}(${quoteLiteral(source.sql.managed ?? "")});`).join("\n")}`;
|
|
1180
|
+
}
|
|
1181
|
+
/** The `config.toml` block that enables the hook and bounds token staleness. */
|
|
1182
|
+
function configToml(schema, jwtExpiry) {
|
|
1183
|
+
return `[auth]
|
|
1184
|
+
jwt_expiry = ${String(jwtExpiry)}
|
|
1185
|
+
|
|
1186
|
+
[auth.hook.custom_access_token]
|
|
1187
|
+
enabled = true
|
|
1188
|
+
uri = "${hookUri(schema)}"`;
|
|
1189
|
+
}
|
|
1190
|
+
const BUDGET_MEASURE = "octet_length(memberships::text) + octet_length(attrs::text)";
|
|
1191
|
+
function hookParts(scopes, config, overrides) {
|
|
1192
|
+
const hook = config.supabase?.hook;
|
|
1193
|
+
if (hook === void 0) throw new Error("PermDock CLI: supabase hook generate needs supabase.hook in permdock.config.ts");
|
|
1194
|
+
const root = rootScope(scopes) ?? "tenant";
|
|
1195
|
+
const suspension = checkSuspension(hook.suspension ?? config.rls?.suspension, scopes);
|
|
1196
|
+
const schema = overrides.schema ?? hook.schema ?? config.rls?.schema ?? "permdock";
|
|
1197
|
+
quoteIdent(schema);
|
|
1198
|
+
const tenantClaim = config.rls?.tenantClaim ?? "tenant_id";
|
|
1199
|
+
const extraPlan = extraClaimsPlan(hook.claims, tenantClaim);
|
|
1200
|
+
checkSources({
|
|
1201
|
+
sources: hook.memberships,
|
|
1202
|
+
scopes
|
|
1203
|
+
});
|
|
1204
|
+
const parts = {
|
|
1205
|
+
schema,
|
|
1206
|
+
scopes,
|
|
1207
|
+
root,
|
|
1208
|
+
sources: hook.memberships,
|
|
1209
|
+
budget: positiveInteger(overrides.budget ?? hook.budget ?? 1024, "budget"),
|
|
1210
|
+
version: hook.version !== false,
|
|
1211
|
+
jwtExpiry: positiveInteger(hook.jwtExpiry ?? 900, "jwtExpiry"),
|
|
1212
|
+
tenantClaim,
|
|
1213
|
+
users: suspension?.users,
|
|
1214
|
+
hook,
|
|
1215
|
+
roles: hook.roles === false ? void 0 : globalRoleSource(hook.roles ?? config.rls?.roles ?? { table: `${schema}.user_roles` }, "public", "r"),
|
|
1216
|
+
active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
|
|
1217
|
+
attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
|
|
1218
|
+
extra: extraPlan.claims,
|
|
1219
|
+
helpers: {
|
|
1220
|
+
schema: config.rls?.schema ?? "permdock",
|
|
1221
|
+
...memberForPlan({
|
|
1222
|
+
scopes,
|
|
1223
|
+
...config.rls?.memberships === void 0 ? {} : { memberships: config.rls.memberships },
|
|
1224
|
+
memberSources: config.rls?.membershipSources ?? hook.memberships
|
|
1225
|
+
})
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
if (parts.attrs !== void 0 && parts.attrs.errors.length > 0) throw new Error(`PermDock CLI: ${parts.attrs.errors.join("; ")}`);
|
|
1229
|
+
if (extraPlan.errors.length > 0) throw new Error(`PermDock CLI: ${extraPlan.errors.join("; ")}`);
|
|
1230
|
+
quoteIdent(parts.tenantClaim);
|
|
1231
|
+
return parts;
|
|
1232
|
+
}
|
|
1233
|
+
function permdockClaim(name, budget = false) {
|
|
1234
|
+
return {
|
|
1235
|
+
name,
|
|
1236
|
+
source: "permdock",
|
|
1237
|
+
budget
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
function hookClaims(parts) {
|
|
1241
|
+
return [
|
|
1242
|
+
permdockClaim("user_role"),
|
|
1243
|
+
permdockClaim("roles"),
|
|
1244
|
+
permdockClaim("memberships", true),
|
|
1245
|
+
permdockClaim("memberships_truncated"),
|
|
1246
|
+
permdockClaim(parts.tenantClaim),
|
|
1247
|
+
...parts.attrs === void 0 ? [] : [permdockClaim("attrs", true)],
|
|
1248
|
+
...parts.version ? [permdockClaim("authz_ver")] : [],
|
|
1249
|
+
...parts.extra.map((entry) => ({
|
|
1250
|
+
name: entry.claim,
|
|
1251
|
+
source: entry.fn,
|
|
1252
|
+
budget: false
|
|
1253
|
+
}))
|
|
1254
|
+
];
|
|
1255
|
+
}
|
|
1256
|
+
function helperList(parts, types) {
|
|
1257
|
+
const client = ["authenticated"];
|
|
1258
|
+
return [{
|
|
1259
|
+
name: "permdock_has",
|
|
1260
|
+
args: "p_grant text",
|
|
1261
|
+
returns: "boolean",
|
|
1262
|
+
execute: client
|
|
1263
|
+
}, ...parts.scopes.flatMap((scope) => {
|
|
1264
|
+
const returns = `setof ${types.get(scope.name) ?? "uuid"}`;
|
|
1265
|
+
return [
|
|
1266
|
+
{
|
|
1267
|
+
name: `permitted_${scope.name}_ids`,
|
|
1268
|
+
args: "p_grant text",
|
|
1269
|
+
returns,
|
|
1270
|
+
execute: client
|
|
1271
|
+
},
|
|
1272
|
+
{
|
|
1273
|
+
name: memberIdsHelper(scope.name),
|
|
1274
|
+
args: "",
|
|
1275
|
+
returns,
|
|
1276
|
+
execute: client
|
|
1277
|
+
},
|
|
1278
|
+
...parts.helpers.memberFor.includes(scope.name) ? [{
|
|
1279
|
+
name: memberForHelper(scope.name),
|
|
1280
|
+
args: "p_user uuid",
|
|
1281
|
+
returns,
|
|
1282
|
+
execute: parts.extra.length === 0 ? [] : ["supabase_auth_admin"]
|
|
1283
|
+
}] : []
|
|
1284
|
+
];
|
|
1285
|
+
})];
|
|
1286
|
+
}
|
|
1287
|
+
function manifestOf(parts, out, config) {
|
|
1288
|
+
const rls = config.rls;
|
|
1289
|
+
const ctx = {
|
|
1290
|
+
dialect: "supabase",
|
|
1291
|
+
scopes: parts.scopes,
|
|
1292
|
+
tenantClaim: parts.tenantClaim,
|
|
1293
|
+
gucPrefix: rls?.gucPrefix ?? "app",
|
|
1294
|
+
tenantType: rls?.tenantType ?? "uuid",
|
|
1295
|
+
...rls?.teamType === void 0 ? {} : { teamType: rls.teamType },
|
|
1296
|
+
...rls?.scopeTypes === void 0 ? {} : { scopeTypes: rls.scopeTypes }
|
|
1297
|
+
};
|
|
1298
|
+
const types = new Map(parts.scopes.map((scope) => [scope.name, scopeTypeOf(ctx, scope.name)]));
|
|
1299
|
+
const helpers = helperList(parts, types);
|
|
1300
|
+
return {
|
|
1301
|
+
$schema: SUPABASE_MANIFEST_SCHEMA,
|
|
1302
|
+
version: 1,
|
|
1303
|
+
hook: {
|
|
1304
|
+
schema: parts.schema,
|
|
1305
|
+
function: "custom_access_token_hook",
|
|
1306
|
+
out
|
|
1307
|
+
},
|
|
1308
|
+
helpers: {
|
|
1309
|
+
schema: parts.helpers.schema,
|
|
1310
|
+
functions: helpers.map((helper) => helper.name)
|
|
1311
|
+
},
|
|
1312
|
+
tenantClaim: parts.tenantClaim,
|
|
1313
|
+
budget: {
|
|
1314
|
+
bytes: parts.budget,
|
|
1315
|
+
measure: BUDGET_MEASURE
|
|
1316
|
+
},
|
|
1317
|
+
claims: hookClaims(parts),
|
|
1318
|
+
authzVersion: parts.version,
|
|
1319
|
+
...parts.version ? { authzVersionBump: {
|
|
1320
|
+
schema: parts.schema,
|
|
1321
|
+
function: AUTHZ_VERSION_BUMP,
|
|
1322
|
+
args: "p_users uuid[]"
|
|
1323
|
+
} } : {},
|
|
1324
|
+
memberships: parts.sources.map((source) => source.sql.manifest),
|
|
1325
|
+
rls: {
|
|
1326
|
+
schema: parts.helpers.schema,
|
|
1327
|
+
mode: resolveAuthorize(config),
|
|
1328
|
+
tenantClaim: parts.tenantClaim,
|
|
1329
|
+
scopes: parts.scopes.map((scope) => ({
|
|
1330
|
+
name: scope.name,
|
|
1331
|
+
type: types.get(scope.name) ?? "uuid",
|
|
1332
|
+
...scope.within === void 0 ? {} : { within: scope.within }
|
|
1333
|
+
})),
|
|
1334
|
+
helpers,
|
|
1335
|
+
memberships: parts.helpers.memberships
|
|
1336
|
+
},
|
|
1337
|
+
decidingColumns: decidingColumns(config, parts.attrs === void 0 ? void 0 : {
|
|
1338
|
+
...parts.attrs.table === void 0 ? {} : { table: parts.attrs.table },
|
|
1339
|
+
columns: parts.attrs.columns
|
|
1340
|
+
}),
|
|
1341
|
+
markers: {
|
|
1342
|
+
hook: "v1",
|
|
1343
|
+
grants: "v1"
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
/** The first line of the generated hook: fields `--check` compares before the full text. */
|
|
1348
|
+
function hookMarker(manifest) {
|
|
1349
|
+
return `${HOOK_MARKER} schema=${manifest.hook.schema} tenant=${manifest.tenantClaim} budget=${String(manifest.budget.bytes)} claims=${manifest.claims.map((claim) => claim.name).join(",")}`;
|
|
1350
|
+
}
|
|
1351
|
+
function defaultOut(config) {
|
|
1352
|
+
return config.supabase?.hook?.out ?? "supabase/permdock-hook.sql";
|
|
1353
|
+
}
|
|
1354
|
+
/** `supabase.hook.out`, else pg-delta's per-schema path, else `supabase/permdock-hook.sql`. */
|
|
1355
|
+
function hookOut(cwd, config, schema) {
|
|
1356
|
+
const pgDelta = supabaseConfig(cwd).pgDelta;
|
|
1357
|
+
return config.supabase?.hook?.out !== void 0 || pgDelta === void 0 ? defaultOut(config) : pgDeltaPath(pgDelta.schemaDir, "hook", schema ?? config.supabase?.hook?.schema ?? config.rls?.schema ?? "permdock");
|
|
1358
|
+
}
|
|
1359
|
+
function supabaseHookManifest(scopes, config, overrides = {}) {
|
|
1360
|
+
return manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config);
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* The hook migration. With `grantsOut`, the `supabase_auth_admin` grants are
|
|
1364
|
+
* left out of `sql` and returned in `grants` for that file.
|
|
1365
|
+
*/
|
|
1366
|
+
function supabaseHookSql(scopes, config, overrides = {}, grantsOut) {
|
|
1367
|
+
const parts = hookParts(scopes, config, overrides);
|
|
1368
|
+
const manifest = manifestOf(parts, defaultOut(config), config);
|
|
1369
|
+
const toml = configToml(parts.schema, parts.jwtExpiry).split("\n").map((line) => line === "" ? "--" : `-- ${line}`).join("\n");
|
|
1370
|
+
const sql = [
|
|
1371
|
+
`${hookMarker(manifest)}
|
|
1372
|
+
-- custom_access_token_hook(jsonb): memberships go active ${parts.root} first and stop at the budget
|
|
1373
|
+
-- supabase/config.toml:
|
|
1374
|
+
${toml}${grantsOut === void 0 ? "" : `\n-- the supabase_auth_admin grants are in ${grantsOut}`}`,
|
|
1375
|
+
attrsGuardSql(parts.attrs),
|
|
1376
|
+
hookSql(parts),
|
|
1377
|
+
versionSql(parts),
|
|
1378
|
+
grantsOut === void 0 ? grantsSql(parts) : "",
|
|
1379
|
+
managedSql(parts)
|
|
1380
|
+
].filter((chunk) => chunk !== "").join("\n\n");
|
|
1381
|
+
const grants = `${GRANTS_MARKER} schema=${parts.schema}
|
|
1382
|
+
${grantsSql(parts)}
|
|
1383
|
+
`;
|
|
1384
|
+
return {
|
|
1385
|
+
sql: `${sql}\n`,
|
|
1386
|
+
grants,
|
|
1387
|
+
manifest
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
/** What the hook file's header says about where its grants went. */
|
|
1391
|
+
function grantsLabel(grantsOut) {
|
|
1392
|
+
if (grantsOut === void 0) return;
|
|
1393
|
+
return grantsOut === "-" ? "a separate migration" : grantsOut;
|
|
1394
|
+
}
|
|
1395
|
+
function markerDrift(onDisk, expected, outRel) {
|
|
1396
|
+
const found = hookMarkerFields(onDisk);
|
|
1397
|
+
if (found === void 0) return `supabase hook drift: ${outRel} has no ${HOOK_MARKER} line`;
|
|
1398
|
+
const want = hookMarkerFields(hookMarker(expected)) ?? {};
|
|
1399
|
+
const changed = Object.keys(want).filter((key) => found[key] !== want[key]).map((key) => `${key} ${found[key] ?? "(none)"} -> ${want[key] ?? ""}`);
|
|
1400
|
+
return changed.length === 0 ? "supabase hook drift" : `supabase hook drift: ${changed.join("; ")}`;
|
|
1401
|
+
}
|
|
1402
|
+
function inspectText(manifest) {
|
|
1403
|
+
return [
|
|
1404
|
+
`hook ${manifest.hook.schema}.${manifest.hook.function} (${manifest.hook.out})`,
|
|
1405
|
+
`helpers ${manifest.helpers.functions.map((fn) => `${manifest.helpers.schema}.${fn}`).join(", ")}`,
|
|
1406
|
+
`tenant claim ${manifest.tenantClaim}`,
|
|
1407
|
+
`budget ${String(manifest.budget.bytes)} bytes of ${manifest.budget.measure}`,
|
|
1408
|
+
`claims ${manifest.claims.map((claim) => `${claim.name}${claim.source === "permdock" ? "" : ` (${claim.source})`}${claim.budget ? " [budget]" : ""}`).join(", ")}`,
|
|
1409
|
+
`authz_ver ${manifest.authzVersion ? "on" : "off"}`
|
|
1410
|
+
].join("\n");
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Writes the manifest to `rel`, or with `check` compares it as JSON, so a
|
|
1414
|
+
* formatter that reflows the file is not drift.
|
|
1415
|
+
*/
|
|
1416
|
+
function manifestFile(cwd, rel, manifest, check) {
|
|
1417
|
+
const path = resolve(cwd, rel);
|
|
1418
|
+
if (!check) {
|
|
1419
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1420
|
+
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1421
|
+
return {
|
|
1422
|
+
code: 0,
|
|
1423
|
+
output: `wrote ${rel}`
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
if (!existsSync(path)) return {
|
|
1427
|
+
code: 1,
|
|
1428
|
+
output: `supabase manifest drift: missing ${rel}`
|
|
1429
|
+
};
|
|
1430
|
+
let onDisk;
|
|
1431
|
+
try {
|
|
1432
|
+
onDisk = JSON.parse(readFileSync(path, "utf8"));
|
|
1433
|
+
} catch {
|
|
1434
|
+
return {
|
|
1435
|
+
code: 1,
|
|
1436
|
+
output: `supabase manifest drift: ${rel} is not JSON`
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
const found = typeof onDisk === "object" && onDisk !== null && !Array.isArray(onDisk) ? Object.fromEntries(Object.entries(onDisk)) : {};
|
|
1440
|
+
const changed = [.../* @__PURE__ */ new Set([...Object.keys(manifest), ...Object.keys(found)])].filter((key) => JSON.stringify(Reflect.get(manifest, key)) !== JSON.stringify(found[key]));
|
|
1441
|
+
return changed.length === 0 ? {
|
|
1442
|
+
code: 0,
|
|
1443
|
+
output: "supabase manifest up to date"
|
|
1444
|
+
} : {
|
|
1445
|
+
code: 1,
|
|
1446
|
+
output: `supabase manifest drift: ${rel} differs in ${changed.join(", ")}; run permdock supabase inspect --out ${rel}`
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
async function loadScopes(cwd, config) {
|
|
1450
|
+
if (config.policy === void 0) throw new Error("PermDock CLI: supabase hook generate needs policy in permdock.config.ts");
|
|
1451
|
+
const policy = asPolicy(pickNamed(await loadModule(resolve(cwd, config.policy)), ["policy"]));
|
|
1452
|
+
return scopeList(policy.scopes);
|
|
1453
|
+
}
|
|
1454
|
+
async function runSupabase(input) {
|
|
1455
|
+
const [area, action] = input.rest;
|
|
1456
|
+
const overrides = Object.fromEntries(Object.entries({
|
|
1457
|
+
activeFrom: input.activeFrom,
|
|
1458
|
+
budget: input.budget,
|
|
1459
|
+
schema: input.schema
|
|
1460
|
+
}).filter(([, value]) => value !== void 0));
|
|
1461
|
+
if (area === "inspect" && action === void 0) {
|
|
1462
|
+
const manifest = supabaseHookManifest(await loadScopes(input.cwd, input.config), input.config, {
|
|
1463
|
+
...overrides,
|
|
1464
|
+
out: hookOut(input.cwd, input.config, input.schema)
|
|
1465
|
+
});
|
|
1466
|
+
if (input.out !== void 0 || input.check) return manifestFile(input.cwd, typeof input.out === "string" ? input.out : MANIFEST_FILE, manifest, input.check);
|
|
1467
|
+
return {
|
|
1468
|
+
code: 0,
|
|
1469
|
+
output: input.json === true ? JSON.stringify(manifest, null, 2) : inspectText(manifest)
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
if (area !== "hook" || action !== "generate") return {
|
|
1473
|
+
code: 2,
|
|
1474
|
+
output: SUPABASE_HELP
|
|
1475
|
+
};
|
|
1476
|
+
const { sql, grants, manifest } = supabaseHookSql(await loadScopes(input.cwd, input.config), input.config, overrides, grantsLabel(input.grantsOut));
|
|
1477
|
+
const outRel = typeof input.out === "string" ? input.out : hookOut(input.cwd, input.config, input.schema);
|
|
1478
|
+
const outPath = resolve(input.cwd, outRel);
|
|
1479
|
+
const hook = input.config.supabase?.hook;
|
|
1480
|
+
const grantsFile = input.grantsOut === void 0 ? [] : [{
|
|
1481
|
+
part: "grants",
|
|
1482
|
+
rel: input.grantsOut,
|
|
1483
|
+
text: grants
|
|
1484
|
+
}];
|
|
1485
|
+
const toml = configToml(input.schema ?? hook?.schema ?? input.config.rls?.schema ?? "permdock", hook?.jwtExpiry ?? 900);
|
|
1486
|
+
if (input.check) {
|
|
1487
|
+
if (!existsSync(outPath)) return {
|
|
1488
|
+
code: 1,
|
|
1489
|
+
output: `supabase hook drift: missing ${outRel}`
|
|
1490
|
+
};
|
|
1491
|
+
const onDisk = readFileSync(outPath, "utf8");
|
|
1492
|
+
if (onDisk !== sql) return {
|
|
1493
|
+
code: 1,
|
|
1494
|
+
output: markerDrift(onDisk, manifest, outRel)
|
|
1495
|
+
};
|
|
1496
|
+
const drift = driftOf(input.cwd, grantsFile);
|
|
1497
|
+
return drift.length === 0 ? {
|
|
1498
|
+
code: 0,
|
|
1499
|
+
output: "supabase hook up to date"
|
|
1500
|
+
} : {
|
|
1501
|
+
code: 1,
|
|
1502
|
+
output: `supabase hook drift: ${drift.join("; ")}`
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
1506
|
+
writeFileSync(outPath, sql);
|
|
1507
|
+
const grantsWritten = writeSqlFiles(input.cwd, grantsFile);
|
|
1508
|
+
const placed = {
|
|
1509
|
+
...manifest,
|
|
1510
|
+
hook: {
|
|
1511
|
+
...manifest.hook,
|
|
1512
|
+
out: outRel
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
const missing = input.db === void 0 ? missingHelpersInFiles(input.cwd, input.config, placed) : await missingHelpersInDb(input.db, placed);
|
|
1516
|
+
return {
|
|
1517
|
+
code: 0,
|
|
1518
|
+
output: [
|
|
1519
|
+
`wrote ${[outRel, ...grantsWritten.wrote].join(", ")}`,
|
|
1520
|
+
...grantsWritten.printed === "" ? [] : [grantsWritten.printed],
|
|
1521
|
+
"add to supabase/config.toml:",
|
|
1522
|
+
toml,
|
|
1523
|
+
...missing.length === 0 ? [] : [missingHelpersMessage(placed, missing)]
|
|
1524
|
+
].join("\n")
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
//#endregion
|
|
1528
|
+
export { tableKey as A, pd009 as C, pd040 as D, pd028 as E, group as O, pd006 as S, pd022 as T, resolveAuthorize as _, loadScopes as a, MIGRATION_DIRS as b, supabaseHookSql as c, parseSplit as d, partPath as f, rbacScaffold as g, parseRbacAuthorize as h, hookOut as i, sqlStatements as k, pd039 as l, writeSqlFiles as m, attrsPlan as n, runSupabase as o, pgDeltaPath as p, grantsLabel as r, supabaseHookManifest as s, activeFromSql as t, driftOf as u, pd045 as v, pd012 as w, pd005 as x, supabaseConfig as y };
|