permdock 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +327 -1
- package/dist/a2a/index.d.ts +169 -0
- package/dist/a2a/index.js +232 -0
- package/dist/adapter-context-mlWsVGGg.js +23 -0
- package/dist/ai-sdk/index.d.ts +95 -0
- package/dist/ai-sdk/index.js +154 -0
- package/dist/approval-required-TzNB9oQA.js +6 -0
- package/dist/approvals/index.d.ts +3 -0
- package/dist/approvals/index.js +5 -0
- package/dist/approvals-DbYAuNkG.js +224 -0
- package/dist/approvers-DT4AVyIg.js +52 -0
- package/dist/arazzo-BAgw9ap6.js +70 -0
- package/dist/arazzo-cmvX2jDe.js +54 -0
- package/dist/authzen/index.d.ts +39 -0
- package/dist/authzen/index.js +302 -0
- package/dist/better-auth/index.d.ts +109 -0
- package/dist/better-auth/index.js +344 -0
- package/dist/better-supabase/index.d.ts +206 -0
- package/dist/better-supabase/index.js +620 -0
- package/dist/bind-CVy3Espv.d.ts +25 -0
- package/dist/bind-VnYn2HKq.js +97 -0
- package/dist/boundary-BM8a_P6E.js +13 -0
- package/dist/boundary-BmHxVH1J.d.ts +15 -0
- package/dist/budget-JqcRqyhK.d.ts +11 -0
- package/dist/budget-NbEtbJC_.js +11 -0
- package/dist/canonical-json-DemiK4Vp.js +26 -0
- package/dist/capability-4qPdO9gk.js +173 -0
- package/dist/capability-Dq43tT1u.d.ts +99 -0
- package/dist/catalog/index.d.ts +12 -0
- package/dist/catalog/index.js +2 -0
- package/dist/catalog-BjZSovXQ.js +2 -0
- package/dist/catalog-doc-Dsc9y7sd.js +309 -0
- package/dist/catalog-h2tlKFkq.js +48 -0
- package/dist/catalog-path-CnI0XTFK.js +12 -0
- package/dist/catalog-z8q6bi_r.js +55 -0
- package/dist/claude-agent/index.d.ts +72 -0
- package/dist/claude-agent/index.js +77 -0
- package/dist/clerk/index.d.ts +65 -0
- package/dist/clerk/index.js +301 -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/clients-C7zLFuWT.d.ts +12 -0
- package/dist/clients-DjU5ABMy.js +23 -0
- package/dist/cloud/index.d.ts +90 -0
- package/dist/cloud/index.js +404 -0
- package/dist/cloud-BGhHS-lY.js +50 -0
- package/dist/cloud-Bo3e9Ak3.js +118 -0
- package/dist/collect-DDDv_ESs.js +502 -0
- package/dist/collect-DLlujdHC.js +83 -0
- package/dist/collect-rG2IQ3t0.js +2 -0
- package/dist/commands-DWOrSQic.js +97 -0
- package/dist/compact-y6vfu2_f.js +19 -0
- package/dist/compare-pfyVnBjE.js +12 -0
- package/dist/compile/index.d.ts +3 -0
- package/dist/compile/index.js +2 -0
- package/dist/compile-Ba4blROR.js +613 -0
- package/dist/compile-CNnl6uIt.d.ts +94 -0
- package/dist/config-BJiM-xAm.js +157 -0
- package/dist/config-Bn00Z3ks.js +36 -0
- package/dist/config-report-Dw_JcUqT.js +44 -0
- package/dist/connection-CU8MQxRi.js +194 -0
- package/dist/context-Ttp0ekMT.js +82 -0
- package/dist/convex/index.d.ts +45 -0
- package/dist/convex/index.js +65 -0
- package/dist/create-BWyx3jua.d.ts +245 -0
- package/dist/create-CPhDL6qx.js +253 -0
- package/dist/credential-8P81ff4u.js +306 -0
- package/dist/delegation-D8pUEuce.js +103 -0
- package/dist/describe-B_gVZEVi.js +94 -0
- package/dist/describe-PdoqESvg.d.ts +36 -0
- package/dist/diff-B8Tunc3o.js +451 -0
- package/dist/diff-CzqLxZ77.js +48 -0
- package/dist/digest-B3kc_PgQ.d.ts +19 -0
- package/dist/digest-BI-6aa6d.js +27 -0
- package/dist/doctor-WpvG6p4k.js +1194 -0
- package/dist/doctor-collect-ByfYlNv1.js +664 -0
- package/dist/doctor-rOr-4ZfS.js +53 -0
- package/dist/drizzle/index.d.ts +73 -0
- package/dist/drizzle/index.js +160 -0
- package/dist/duration-xK2jGFVc.js +25 -0
- package/dist/elysia/index.d.ts +49 -0
- package/dist/elysia/index.js +59 -0
- package/dist/errors-CWfoNg9N.js +47 -0
- package/dist/errors-Cz5zn0mW.js +123 -0
- package/dist/errors-DAqevpNk.d.ts +125 -0
- package/dist/errors-DRnGZHzF.js +30 -0
- package/dist/evaluation-items-CfCCejQd.js +40 -0
- package/dist/evaluations-tz6D9A2G.js +461 -0
- package/dist/eve/index.d.ts +86 -0
- package/dist/eve/index.js +176 -0
- package/dist/expiry-BoBJ8Tvc.js +7 -0
- package/dist/express/index.d.ts +23 -0
- package/dist/express/index.js +78 -0
- package/dist/fastify/index.d.ts +33 -0
- package/dist/fastify/index.js +92 -0
- package/dist/fields-BMl1RxoX.js +26 -0
- package/dist/files-DWUN9PzI.js +125 -0
- package/dist/fixtures-ht28Cplr.js +67 -0
- package/dist/freeze-CrU8nQeX.js +60 -0
- package/dist/from-snapshot-C3sHpZNS.js +569 -0
- package/dist/generate-CH7ThJ14.js +173 -0
- package/dist/grantee-Cv5Sogfv.js +602 -0
- package/dist/graph-sql-B-dsAT6j.d.ts +29 -0
- package/dist/headers-BZSDieNT.js +6 -0
- package/dist/headers-Cf-zWvsO.d.ts +6 -0
- package/dist/helpers-H_KARDVC.js +421 -0
- package/dist/hono/index.d.ts +48 -0
- package/dist/hono/index.js +99 -0
- package/dist/hosted-DJQw46hn.js +266 -0
- package/dist/http-BIhmLfCc.js +95 -0
- package/dist/http-DPuDaChb.d.ts +13 -0
- package/dist/http-SO7qGttf.js +44 -0
- package/dist/ids-DC4V4CFu.js +18 -0
- package/dist/index-A0YNL8jZ.d.ts +177 -0
- package/dist/index-CtE07CBF.d.ts +6 -0
- package/dist/index-D3vbkoSI.d.ts +483 -0
- package/dist/index-D6_cSa1z.d.ts +201 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +26 -0
- package/dist/instance-options-BK_rNOSC.d.ts +11 -0
- package/dist/instance-options-Dhhcnm3y.js +22 -0
- package/dist/json-schema-BsAflUGW.js +106 -0
- package/dist/jwt/index.d.ts +91 -0
- package/dist/jwt/index.js +590 -0
- package/dist/kernel-GSQnMz3O.js +208 -0
- package/dist/kysely/index.d.ts +78 -0
- package/dist/kysely/index.js +129 -0
- package/dist/live-options-H2eG8dfJ.js +60 -0
- package/dist/load-DhWEB9P1.js +75 -0
- package/dist/load-DwJUafYN.js +2 -0
- package/dist/load-jose-BL6LH4bL.js +11 -0
- package/dist/lru-8q4WZjBV.js +28 -0
- package/dist/manifest-iaoBXHKc.js +783 -0
- package/dist/manifest-uDS9y5de.d.ts +206 -0
- package/dist/map-AcxK9o_D.js +147 -0
- package/dist/map-claims-Z4QZOiFF.js +369 -0
- package/dist/map-error-pgUWNNtG.js +23 -0
- package/dist/markers-B0hYLvMD.js +60 -0
- package/dist/may-use-DndQ5tQ-.js +77 -0
- package/dist/mcp/index.d.ts +159 -0
- package/dist/mcp/index.js +603 -0
- package/dist/nest/index.d.ts +75 -0
- package/dist/nest/index.js +261 -0
- package/dist/next/client.d.ts +23 -0
- package/dist/next/client.js +33 -0
- package/dist/next/index.d.ts +93 -0
- package/dist/next/index.js +180 -0
- package/dist/next/plugin.d.ts +14 -0
- package/dist/next/plugin.js +33 -0
- package/dist/node/index.d.ts +24 -0
- package/dist/node/index.js +34 -0
- package/dist/openai/index.d.ts +62 -0
- package/dist/openai/index.js +92 -0
- package/dist/openapi/index.d.ts +176 -0
- package/dist/openapi/index.js +3 -0
- package/dist/openapi-Bbg4wGVL.js +164 -0
- package/dist/openapi-BqRBvAng.js +538 -0
- package/dist/openapi-CqqEbHsL.js +250 -0
- package/dist/openapi-import-cmwetbzw.js +5959 -0
- package/dist/openapi-schema--ARjyfQr.js +6902 -0
- package/dist/orpc/index.d.ts +51 -0
- package/dist/orpc/index.js +145 -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-BuyjxhZV.js +53 -0
- package/dist/parse-local-manifest-BkGQNzAh.d.ts +10 -0
- package/dist/parse-local-manifest-BrMozJCo.js +36 -0
- package/dist/pdp/index.d.ts +22 -0
- package/dist/pdp/index.js +682 -0
- package/dist/peer-BOr_kJE1.js +31 -0
- package/dist/permdock-CzqXjCpU.js +3471 -0
- package/dist/permissions-CDgvKG5L.d.ts +52 -0
- package/dist/permissions-CpZrivYv.js +142 -0
- package/dist/permissions-DVoF6dMt.js +662 -0
- package/dist/permissions-DttnqqxC.d.ts +214 -0
- package/dist/pins-8ILTftC9.js +12 -0
- package/dist/policy-CFZAr_Al.d.ts +2252 -0
- package/dist/policy-D8UFyERw.d.ts +20 -0
- package/dist/policy-xyWg7QdR.js +1122 -0
- package/dist/powersync-A8x_Yyvh.js +2 -0
- package/dist/powersync-C7kLglqX.js +540 -0
- package/dist/powersync-yW1UUFzp.js +62 -0
- package/dist/prisma/index.d.ts +136 -0
- package/dist/prisma/index.js +455 -0
- package/dist/problem-B43ztKIm.js +191 -0
- package/dist/problem-details-CkL50EEf.d.ts +7 -0
- package/dist/problem-details-_AMOYz-w.js +144 -0
- package/dist/prompt-CXYhLfsS.js +32 -0
- package/dist/protected-CNsMzrVd.d.ts +27 -0
- package/dist/protected-Cs6kTaHo.js +100 -0
- package/dist/protected-bZs6rbT1.d.ts +40 -0
- package/dist/provider-Cjr62YMV.d.ts +6 -0
- package/dist/provider-DLzpb2Xz.js +52 -0
- package/dist/provider-store-DNyC1y48.js +43 -0
- package/dist/react/index.d.ts +8 -0
- package/dist/react/index.js +7 -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 +198 -0
- package/dist/react-native/index.js +696 -0
- package/dist/refs-KYb4ntaz.js +91 -0
- package/dist/relations-cjOL01hZ.js +505 -0
- package/dist/replay-BsOyTrdV.js +28 -0
- package/dist/replay-sqBGMnNs.d.ts +5 -0
- package/dist/resource-ref-CaS9yVPi.js +20 -0
- package/dist/revocations-Bhr0aMIC.js +27 -0
- package/dist/revocations-Cy06JNx2.d.ts +21 -0
- package/dist/rls-BMNydpHk.js +2521 -0
- package/dist/rls-CEvs64Kj.js +238 -0
- package/dist/rls-fields-BMasREhR.js +3502 -0
- package/dist/rls-graph-pc9fuXYm.js +435 -0
- package/dist/rls-import-D8F7j35Q.js +943 -0
- package/dist/rls-migrate-7wikfrVI.js +367 -0
- package/dist/rls-ownership-B5Lkq62W.js +843 -0
- package/dist/rls-shared-aOQ4hGTO.js +493 -0
- package/dist/rls-verify-D8B7dOTh.js +745 -0
- package/dist/row-check-Dt35czLy.d.ts +40 -0
- package/dist/rpc-DOK54_CS.js +89 -0
- package/dist/run-YXCJDNYk.js +174 -0
- package/dist/runtime-CB8gQFvS.js +11 -0
- package/dist/saas-Bj_FFkjN.js +1203 -0
- package/dist/schema-D3MnDMxa.js +300 -0
- package/dist/scim/index.d.ts +2 -0
- package/dist/scim/index.js +2 -0
- package/dist/scim-BFGBBAqR.js +1445 -0
- package/dist/scopes-CVdPrWN-.js +239 -0
- package/dist/server/index.d.ts +134 -0
- package/dist/server/index.js +11 -0
- package/dist/server-DlZ28Oz5.js +268 -0
- package/dist/sha256-BO0swRXL.js +150 -0
- package/dist/shared-DRu8VFVM.js +123 -0
- package/dist/sink-Bd9PSyfq.js +141 -0
- package/dist/sink-Gs6TTVa-.d.ts +104 -0
- package/dist/skills-Biq5uup3.js +192 -0
- package/dist/skills-JaymNCdK.js +56 -0
- package/dist/snapshot-Enxd0n-s.js +975 -0
- package/dist/snapshot-cache-D6jLelln.js +44 -0
- package/dist/snapshot-cache-DwQH8ufx.d.ts +45 -0
- package/dist/solid/index.d.ts +93 -0
- package/dist/solid/index.js +188 -0
- package/dist/sources-B0UvrJ21.js +534 -0
- package/dist/sources-BcJgw0lt.d.ts +192 -0
- package/dist/sql-xdg5f4Fi.js +27 -0
- package/dist/src-CSbUiozF.js +568 -0
- package/dist/ssf/index.d.ts +4 -0
- package/dist/ssf/index.js +596 -0
- package/dist/store-DMAWwAJV.js +371 -0
- package/dist/store-D_pjn91-.d.ts +36 -0
- package/dist/store-options-yO0V6gRn.js +716 -0
- package/dist/stores-4_sqiOA1.js +87 -0
- package/dist/stores-DVR5NHLE.d.ts +52 -0
- package/dist/stream-DDpvynSB.js +66 -0
- package/dist/stream-DlvG2hs-.d.ts +12 -0
- package/dist/style-CTm1AXDT.js +22 -0
- package/dist/subject-C6dLxqHU.js +24 -0
- package/dist/subject-settings-BwBmCdTR.js +128 -0
- package/dist/supabase/index.d.ts +183 -0
- package/dist/supabase/index.js +5 -0
- package/dist/supabase/middleware.d.ts +82 -0
- package/dist/supabase/middleware.js +102 -0
- package/dist/supabase-CwafwxxF.js +83 -0
- package/dist/supabase-DTBaC20O.js +774 -0
- package/dist/supabase-config-Bs44dare.js +472 -0
- package/dist/supabase-hook-BuxLQLpU.js +1814 -0
- package/dist/supabase-hook-D5qVd_D8.js +2 -0
- package/dist/supautils-DYZnzRWV.js +91 -0
- package/dist/svelte/PermissionBoundary.svelte +37 -0
- package/dist/svelte/Protected.svelte +40 -0
- package/dist/svelte/index.d.ts +13 -0
- package/dist/svelte/index.js +107 -0
- package/dist/svelte/runtime.d.ts +7 -0
- package/dist/svelte/runtime.js +4 -0
- package/dist/svelte/source.d.ts +13 -0
- package/dist/svelte/source.js +11 -0
- package/dist/terminal/index.d.ts +163 -0
- package/dist/terminal/index.js +833 -0
- package/dist/testing/index.d.ts +745 -0
- package/dist/testing/index.js +3672 -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/token-CIWxJOCh.js +50 -0
- package/dist/trpc/index.d.ts +61 -0
- package/dist/trpc/index.js +122 -0
- package/dist/types-9AWO9EGt.d.ts +197 -0
- package/dist/types-BPxgszlT.d.ts +550 -0
- package/dist/types-BVyImy5I.d.ts +120 -0
- package/dist/types-Bvc4oWT6.d.ts +9 -0
- package/dist/types-C3s9xVJG.d.ts +48 -0
- package/dist/types-Dnemwd77.d.ts +183 -0
- package/dist/types-kxznc7cn.d.ts +52 -0
- package/dist/types-vKyAMtcX.d.ts +157 -0
- package/dist/types-vNSDF7Dq.d.ts +66 -0
- package/dist/types-yF5LmQVD.d.ts +306 -0
- package/dist/unplugin/index.d.ts +7 -0
- package/dist/unplugin/index.js +31 -0
- package/dist/usage-D07N9QJ3.js +42 -0
- package/dist/usage-D82l1CwK.js +2 -0
- package/dist/usage-DG1oeCca.js +693 -0
- package/dist/validation-error-CnjZ4t92.js +33 -0
- package/dist/validation-error-CtF23SrC.d.ts +26 -0
- package/dist/validity-uOp8UpYn.js +245 -0
- package/dist/verifier-BxBo6I44.js +466 -0
- package/dist/version-BvacVTZR.js +17 -0
- package/dist/views-Soe7bGSC.js +95 -0
- package/dist/vocabulary-DA8a8zif.d.ts +56 -0
- package/dist/vue/index.d.ts +79 -0
- package/dist/vue/index.js +196 -0
- package/dist/watch-Etoa_Mvr.js +2 -0
- package/dist/watch-UihESpsJ.js +128 -0
- package/dist/webmcp/index.d.ts +75 -0
- package/dist/webmcp/index.js +307 -0
- package/package.json +450 -5
- package/rulesets/permdock.yaml +29 -0
- package/schemas/approval-request-v1.json +175 -0
- package/schemas/catalog-v1.json +535 -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 +144 -0
- package/schemas/supabase-manifest-v1.json +361 -0
- package/skills/permdock/SKILL.md +66 -0
- package/skills/permdock-agents/SKILL.md +77 -0
- package/skills/permdock-agents/references/runtimes.md +213 -0
- package/skills/permdock-approvals/SKILL.md +80 -0
- package/skills/permdock-approvals/references/stores-and-resume.md +94 -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 +132 -0
- package/skills/permdock-data/SKILL.md +57 -0
- package/skills/permdock-data/references/queries-and-rls.md +62 -0
- package/skills/permdock-data/references/relationships.md +67 -0
- package/skills/permdock-tenancy/SKILL.md +80 -0
- package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +89 -0
- package/skills/permdock-wire/SKILL.md +116 -0
- package/skills/permdock-wire/references/adapters.md +492 -0
- package/skills/permdock-wire/references/policy-options.md +45 -0
|
@@ -0,0 +1,1814 @@
|
|
|
1
|
+
import { a as hasConditionOp } from "./refs-KYb4ntaz.js";
|
|
2
|
+
import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-CVdPrWN-.js";
|
|
3
|
+
import { i as SUPABASE_MANIFEST_SCHEMA } from "./version-BvacVTZR.js";
|
|
4
|
+
import { a as sqlFiles } from "./files-DWUN9PzI.js";
|
|
5
|
+
import { a as loadModule, n as asPolicy, o as pickNamed } from "./load-DhWEB9P1.js";
|
|
6
|
+
import { t as shortDiff } from "./text-diff-BdkkYSbi.js";
|
|
7
|
+
import { r as scopeColumn } from "./compile-Ba4blROR.js";
|
|
8
|
+
import { t as authorizeSql } from "./supabase-DTBaC20O.js";
|
|
9
|
+
import "./budget-NbEtbJC_.js";
|
|
10
|
+
import { c as roleManifest, l as keptKeys, t as AUTHZ_VERSION_TABLE } from "./sources-B0UvrJ21.js";
|
|
11
|
+
import { i as readMemberships } from "./manifest-iaoBXHKc.js";
|
|
12
|
+
import { a as hookMarkerFields, n as HOOK_MARKER, t as GRANTS_MARKER } from "./markers-B0hYLvMD.js";
|
|
13
|
+
import { f as splitUndiffed, h as tableKey, m as decidingColumns, n as supabaseConfig, r as MIGRATION_DIRS } from "./supabase-config-Bs44dare.js";
|
|
14
|
+
import { E as hasMemberFor, F as memberForSources, G as quoteLiteral, H as permittedIdsHelper, I as memberForTable, K as quoteTable, L as memberIdsHelper, P as memberForHelper, R as memberRoleOf, U as qualifiedTable, V as permittedForHelper, W as quoteIdent, Z as scopeTypeOf, f as USER_ID_HELPER, h as activeRowSql, it as tenantTypeOf, n as HELPERS, v as checkSuspension } from "./rls-shared-aOQ4hGTO.js";
|
|
15
|
+
import { G as API_KEY_ALLOWS, K as apiKeyFields, U as roleNames, _ as permissionKeysForHelper, w as connectPg, z as globalRoleSource } from "./rls-fields-BMasREhR.js";
|
|
16
|
+
import { a as ownershipRules, i as guardedTables, n as OWNERSHIP, t as ASSIGNMENTS } from "./rls-ownership-B5Lkq62W.js";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
/** `--check`: one entry per file that is missing or differs, naming its part; a differing file carries a short diff. */
|
|
20
|
+
function driftOf(cwd, files) {
|
|
21
|
+
const drift = [];
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
if (file.rel === "-") continue;
|
|
24
|
+
const path = resolve(cwd, file.rel);
|
|
25
|
+
if (!existsSync(path)) drift.push(`${file.part}: missing ${file.rel}`);
|
|
26
|
+
else {
|
|
27
|
+
const onDisk = readFileSync(path, "utf8");
|
|
28
|
+
if (onDisk !== file.text) drift.push(`${file.part}: ${file.rel}\n${shortDiff(file.rel, onDisk, file.text)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return drift;
|
|
32
|
+
}
|
|
33
|
+
/** Writes every file but the stdout ones, whose text it returns for printing. */
|
|
34
|
+
function writeSqlFiles(cwd, files) {
|
|
35
|
+
const wrote = [];
|
|
36
|
+
const printed = [];
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
if (file.rel === "-") {
|
|
39
|
+
printed.push(file.text.trimEnd());
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const path = resolve(cwd, file.rel);
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
44
|
+
writeFileSync(path, file.text);
|
|
45
|
+
wrote.push(file.rel);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
wrote,
|
|
49
|
+
printed: printed.join("\n")
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const PARTS = [
|
|
53
|
+
"helpers",
|
|
54
|
+
"seeds",
|
|
55
|
+
"indexes",
|
|
56
|
+
"policies",
|
|
57
|
+
"hook"
|
|
58
|
+
];
|
|
59
|
+
/** `--split helpers,seeds,indexes,policies,hook`: the parts in that fixed order, or an error. */
|
|
60
|
+
function parseSplit(raw) {
|
|
61
|
+
if (raw === void 0) return;
|
|
62
|
+
const names = raw.split(",").map((name) => name.trim()).filter((name) => name !== "");
|
|
63
|
+
if (names.filter((name) => !PARTS.some((part) => part === name)).length > 0 || names.length === 0) return `rls generate --split takes helpers, seeds, indexes, policies and hook, got '${raw}'`;
|
|
64
|
+
return PARTS.filter((part) => names.includes(part));
|
|
65
|
+
}
|
|
66
|
+
/** The path of one split part: `{part}` in `out` replaced by the part name. */
|
|
67
|
+
function partPath(out, part) {
|
|
68
|
+
return out.replaceAll("{part}", part);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* pg-delta's per-schema, unnumbered layout: pg-delta orders statements by
|
|
72
|
+
* their dependencies, so no file needs a number to apply first.
|
|
73
|
+
*/
|
|
74
|
+
function pgDeltaPath(root, part, schema) {
|
|
75
|
+
switch (part) {
|
|
76
|
+
case "helpers":
|
|
77
|
+
case "indexes":
|
|
78
|
+
case "seeds": return `${root}/${schema}/${part}.sql`;
|
|
79
|
+
case "policies": return `${root}/public/policies/permdock.sql`;
|
|
80
|
+
case "hook": return `${root}/${schema}/functions/custom_access_token_hook.sql`;
|
|
81
|
+
default: return part;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Whether `--seeds-out` names a migrations directory: it ends with \`/\` or is an existing directory. */
|
|
85
|
+
function isSeedsDirectory(cwd, out) {
|
|
86
|
+
if (out === "-") return false;
|
|
87
|
+
if (out.endsWith("/")) return true;
|
|
88
|
+
const path = resolve(cwd, out);
|
|
89
|
+
return existsSync(path) && statSync(path).isDirectory();
|
|
90
|
+
}
|
|
91
|
+
/** `YYYYMMDDHHMMSS` in UTC, the Supabase migration version. */
|
|
92
|
+
function migrationVersion(now) {
|
|
93
|
+
return now.toISOString().replaceAll(/[^0-9]/gu, "").slice(0, 14);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The seeds part in a migrations directory: the newest migration there that
|
|
97
|
+
* starts with \`marker\` when its text is \`text\` already, otherwise a new
|
|
98
|
+
* \`<version>_permdock_seeds.sql\`. \`current\` is false when a new one is due.
|
|
99
|
+
*/
|
|
100
|
+
function seedsMigration(cwd, dir, marker, text, now) {
|
|
101
|
+
const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
|
102
|
+
const path = resolve(cwd, base);
|
|
103
|
+
const latest = existsSync(path) ? readdirSync(path).filter((name) => name.endsWith(".sql")).toSorted().toReversed().find((name) => readFileSync(resolve(path, name), "utf8").startsWith(marker)) : void 0;
|
|
104
|
+
if (latest !== void 0 && readFileSync(resolve(path, latest), "utf8") === text) return {
|
|
105
|
+
rel: `${base}/${latest}`,
|
|
106
|
+
current: true
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
rel: `${base}/${migrationVersion(now)}_permdock_seeds.sql`,
|
|
110
|
+
current: false
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/cli/claims-schema.ts
|
|
115
|
+
/**
|
|
116
|
+
* The PermDock claims of `schemas/supabase-claims-v1.json` (its
|
|
117
|
+
* `permdockClaims` and `membership` definitions), self-contained so
|
|
118
|
+
* `supabase.hook.validate` can inline it into the hook for pg_jsonschema.
|
|
119
|
+
*/
|
|
120
|
+
const PERMDOCK_CLAIMS_SCHEMA = {
|
|
121
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
122
|
+
$ref: "#/$defs/permdockClaims",
|
|
123
|
+
$defs: {
|
|
124
|
+
permdockClaims: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
user_role: {
|
|
128
|
+
description: "Global roles: one name, a list, or null for none.",
|
|
129
|
+
anyOf: [
|
|
130
|
+
{ type: "string" },
|
|
131
|
+
{
|
|
132
|
+
type: "array",
|
|
133
|
+
items: { type: "string" }
|
|
134
|
+
},
|
|
135
|
+
{ type: "null" }
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
roles: {
|
|
139
|
+
type: "array",
|
|
140
|
+
items: { type: "string" }
|
|
141
|
+
},
|
|
142
|
+
memberships: {
|
|
143
|
+
type: "array",
|
|
144
|
+
items: { $ref: "#/$defs/membership" }
|
|
145
|
+
},
|
|
146
|
+
memberships_truncated: {
|
|
147
|
+
description: "true when the size budget cut memberships or attrs.",
|
|
148
|
+
type: "boolean"
|
|
149
|
+
},
|
|
150
|
+
tenant_id: {
|
|
151
|
+
description: "The active tenant (the default rls.tenantClaim): the canonical text of one instance of the first scope.",
|
|
152
|
+
type: "string"
|
|
153
|
+
},
|
|
154
|
+
attrs: {
|
|
155
|
+
description: "Allow-listed server-owned attributes, read as principal.claims.attrs.<key>.",
|
|
156
|
+
type: "object"
|
|
157
|
+
},
|
|
158
|
+
authz_ver: {
|
|
159
|
+
description: "The principal's authorization version.",
|
|
160
|
+
type: "integer"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
membership: {
|
|
165
|
+
type: "object",
|
|
166
|
+
required: ["roles"],
|
|
167
|
+
properties: {
|
|
168
|
+
scope: { type: "string" },
|
|
169
|
+
id: {
|
|
170
|
+
description: "Canonical text of the scope instance id (uuid::text for a uuid column); compared exactly, never lower-cased.",
|
|
171
|
+
type: "string"
|
|
172
|
+
},
|
|
173
|
+
within: {
|
|
174
|
+
description: "The id of every ancestor scope instance, by scope name.",
|
|
175
|
+
type: "object",
|
|
176
|
+
additionalProperties: { type: "string" }
|
|
177
|
+
},
|
|
178
|
+
on: {
|
|
179
|
+
type: "object",
|
|
180
|
+
required: ["resource", "id"],
|
|
181
|
+
properties: {
|
|
182
|
+
resource: { type: "string" },
|
|
183
|
+
id: { type: "string" }
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
tenant: { type: "string" },
|
|
187
|
+
team: { type: "string" },
|
|
188
|
+
roles: {
|
|
189
|
+
type: "array",
|
|
190
|
+
minItems: 1,
|
|
191
|
+
items: { type: "string" }
|
|
192
|
+
},
|
|
193
|
+
via: { type: "string" },
|
|
194
|
+
expiresAt: {
|
|
195
|
+
description: "Seconds since the epoch.",
|
|
196
|
+
type: "number"
|
|
197
|
+
},
|
|
198
|
+
grantedBy: { type: "string" },
|
|
199
|
+
reason: { type: "string" },
|
|
200
|
+
member: {
|
|
201
|
+
description: "The subgroup a membership source's group fills.",
|
|
202
|
+
type: "object",
|
|
203
|
+
required: ["group"],
|
|
204
|
+
properties: { group: {
|
|
205
|
+
type: "string",
|
|
206
|
+
minLength: 1
|
|
207
|
+
} }
|
|
208
|
+
},
|
|
209
|
+
managedBy: { const: "idp" },
|
|
210
|
+
entitlements: {
|
|
211
|
+
type: "array",
|
|
212
|
+
items: { type: "string" }
|
|
213
|
+
},
|
|
214
|
+
keep: {
|
|
215
|
+
description: "Set when the instance or an ancestor is suspended: the permission keys the membership still grants.",
|
|
216
|
+
type: "array",
|
|
217
|
+
items: {
|
|
218
|
+
type: "string",
|
|
219
|
+
minLength: 1
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
grants: {
|
|
223
|
+
description: "Custom roles in compact form, read by the helpers in jwt mode.",
|
|
224
|
+
type: "object"
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
anyOf: [
|
|
228
|
+
{ required: ["scope", "id"] },
|
|
229
|
+
{ required: ["tenant"] },
|
|
230
|
+
{ required: ["team"] },
|
|
231
|
+
{ required: ["on"] }
|
|
232
|
+
]
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/cli/rls-rbac.ts
|
|
238
|
+
/**
|
|
239
|
+
* Where the helpers read roles and memberships: the flag, `rls.authorize`,
|
|
240
|
+
* `rls.rbac.authorize`, then `database` with `rls.membershipSources`, with
|
|
241
|
+
* `--rbac` or with a mapped memberships table, else `jwt`.
|
|
242
|
+
*/
|
|
243
|
+
function resolveAuthorize(config, flags = {}) {
|
|
244
|
+
const rls = config.rls;
|
|
245
|
+
const explicit = flags.authorize ?? rls?.authorize ?? rls?.rbac?.authorize;
|
|
246
|
+
if (explicit !== void 0) return explicit;
|
|
247
|
+
if (rls?.membershipSources !== void 0 || flags.rbac === true) return "database";
|
|
248
|
+
const memberships = flags.memberships ?? rls?.memberships;
|
|
249
|
+
return memberships?.tenant !== void 0 || memberships?.team !== void 0 || Object.keys(memberships?.scopes ?? {}).length > 0 ? "database" : "jwt";
|
|
250
|
+
}
|
|
251
|
+
function parseRbacAuthorize(raw) {
|
|
252
|
+
if (raw === void 0) return;
|
|
253
|
+
if (raw === "database" || raw === "jwt") return raw;
|
|
254
|
+
throw new Error(`PermDock CLI: --authorize must be database or jwt, got '${raw}'`);
|
|
255
|
+
}
|
|
256
|
+
function hookUri(schema) {
|
|
257
|
+
return `pg-functions://postgres/${schema}/custom_access_token_hook`;
|
|
258
|
+
}
|
|
259
|
+
function createEnum(schema, name, values) {
|
|
260
|
+
const list = values.map((value) => quoteLiteral(value)).join(", ");
|
|
261
|
+
return `do $$ begin
|
|
262
|
+
create type ${quoteTable(`${schema}.${name}`)} as enum (${list});
|
|
263
|
+
exception when duplicate_object then null;
|
|
264
|
+
end $$;`;
|
|
265
|
+
}
|
|
266
|
+
function hookTable(name) {
|
|
267
|
+
return quoteTable(name.includes(".") ? name : `public.${name}`);
|
|
268
|
+
}
|
|
269
|
+
/** A read policy and `select` grant for `supabase_auth_admin` on a table the hook reads. */
|
|
270
|
+
function authAdminRead(table, label) {
|
|
271
|
+
const t = hookTable(table);
|
|
272
|
+
const policy = quoteIdent(`permdock_auth_admin_read_${label}`);
|
|
273
|
+
return `grant select on table ${t} to supabase_auth_admin;
|
|
274
|
+
drop policy if exists ${policy} on ${t};
|
|
275
|
+
create policy ${policy} on ${t}
|
|
276
|
+
as permissive for select
|
|
277
|
+
to supabase_auth_admin
|
|
278
|
+
using (true);`;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Supabase's Custom Claims and RBAC scaffold on top of the PermDock helpers:
|
|
282
|
+
* enums, `user_roles` and `authorize()` over the shared `role_permissions`.
|
|
283
|
+
* The token hook comes only from `permdock supabase hook generate`, so one
|
|
284
|
+
* function writes the claims. Policies never call `authorize()` per row; they
|
|
285
|
+
* call the helpers. Never grants anything to `service_role`.
|
|
286
|
+
*/
|
|
287
|
+
function rbacScaffold(policy, options) {
|
|
288
|
+
const schema = options.schema;
|
|
289
|
+
const q = (name) => quoteTable(`${schema}.${name}`);
|
|
290
|
+
const s = quoteIdent(schema);
|
|
291
|
+
const permissions = [...new Set(policy.grants.map((grant) => grant.permission.key))];
|
|
292
|
+
const authorizeFn = authorizeSql({
|
|
293
|
+
schema,
|
|
294
|
+
authorize: options.authorize,
|
|
295
|
+
...options.scope === void 0 ? {} : { scope: options.scope },
|
|
296
|
+
...options.memberships === void 0 ? {} : { tenant: options.memberships },
|
|
297
|
+
...options.customRoles === void 0 ? {} : { customRoles: options.customRoles },
|
|
298
|
+
...options.context?.suspension === void 0 ? {} : { suspension: options.context.suspension }
|
|
299
|
+
});
|
|
300
|
+
return {
|
|
301
|
+
head: `-- rbac scaffold (Supabase Custom Claims and RBAC)
|
|
302
|
+
-- authorize: ${options.authorize}${options.authorize === "jwt" ? " (reads the hook claims; stale until the token refreshes)" : " (reads user_roles on every statement)"}
|
|
303
|
+
-- the custom access token hook that writes user_role${options.authorize === "jwt" ? " and memberships" : ""}: permdock supabase hook generate
|
|
304
|
+
${schema === "public" ? "" : `create schema if not exists ${s};\n`}${createEnum(schema, "app_role", roleNames(policy))}
|
|
305
|
+
${createEnum(schema, "app_permission", permissions)}
|
|
306
|
+
|
|
307
|
+
create table if not exists ${q("user_roles")} (
|
|
308
|
+
user_id uuid not null references auth.users on delete cascade,
|
|
309
|
+
role ${options.customRoles === void 0 ? q("app_role") : "text"} not null,
|
|
310
|
+
primary key (user_id, role)
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
alter table ${q("user_roles")} enable row level security;
|
|
314
|
+
revoke all on table ${q("user_roles")} from authenticated, anon, public;
|
|
315
|
+
`,
|
|
316
|
+
tail: authorizeFn,
|
|
317
|
+
warnings: []
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/cli/supabase-hook-sql.ts
|
|
322
|
+
const MANAGED_TRIGGER = "permdock_protect_managed";
|
|
323
|
+
const CLAIM_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
324
|
+
const PROTOTYPE_KEYS = /* @__PURE__ */ new Set([
|
|
325
|
+
"__proto__",
|
|
326
|
+
"constructor",
|
|
327
|
+
"prototype"
|
|
328
|
+
]);
|
|
329
|
+
const USER_EDITABLE = /^(raw_)?user_meta(_)?data$/iu;
|
|
330
|
+
/**
|
|
331
|
+
* Splits `supabase.hook.attrs` into table columns and `app_metadata` keys and
|
|
332
|
+
* lists what cannot be compiled: `user_metadata` (the user can edit it), a
|
|
333
|
+
* direct `auth.users` column, an unsafe or prototype key, a duplicate key,
|
|
334
|
+
* or a table column without a table.
|
|
335
|
+
*/
|
|
336
|
+
function attrsPlan(attrs) {
|
|
337
|
+
const errors = [];
|
|
338
|
+
const columns = [];
|
|
339
|
+
const meta = [];
|
|
340
|
+
const seen = /* @__PURE__ */ new Set();
|
|
341
|
+
if (attrs.table?.replaceAll("\"", "").toLowerCase() === "auth.users") errors.push("supabase.hook.attrs.table cannot be auth.users: list app_metadata.<key> entries instead");
|
|
342
|
+
for (const entry of attrs.columns) {
|
|
343
|
+
const [head = "", ...rest] = entry.split(".");
|
|
344
|
+
const isMeta = head === "app_metadata" || head === "raw_app_meta_data";
|
|
345
|
+
const key = isMeta ? rest.join(".") : entry;
|
|
346
|
+
if (USER_EDITABLE.test(head) || USER_EDITABLE.test(key) || /user_?meta/iu.test(entry)) {
|
|
347
|
+
errors.push(`supabase.hook.attrs lists ${entry}: user_metadata is user-editable and never becomes a claim`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (!CLAIM_KEY.test(key) || PROTOTYPE_KEYS.has(key)) {
|
|
351
|
+
errors.push(`supabase.hook.attrs lists ${entry}: a key must match ${CLAIM_KEY.source} and not be a prototype key`);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (seen.has(key)) {
|
|
355
|
+
errors.push(`supabase.hook.attrs names the key ${key} twice`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
seen.add(key);
|
|
359
|
+
if (isMeta) meta.push(key);
|
|
360
|
+
else columns.push(key);
|
|
361
|
+
}
|
|
362
|
+
if (columns.length > 0 && attrs.table === void 0) errors.push(`supabase.hook.attrs lists ${columns.join(", ")} without a table`);
|
|
363
|
+
return {
|
|
364
|
+
...attrs.table === void 0 ? {} : { table: attrs.table },
|
|
365
|
+
id: attrs.id ?? "id",
|
|
366
|
+
columns,
|
|
367
|
+
meta,
|
|
368
|
+
errors
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Claim names `supabase.hook.claims` cannot write: the ones this hook owns
|
|
373
|
+
* (plus the tenant claim) and the ones Supabase Auth issues.
|
|
374
|
+
*/
|
|
375
|
+
const RESERVED_CLAIMS = /* @__PURE__ */ new Set([
|
|
376
|
+
"roles",
|
|
377
|
+
"user_role",
|
|
378
|
+
"memberships",
|
|
379
|
+
"memberships_truncated",
|
|
380
|
+
"attrs",
|
|
381
|
+
"authz_ver",
|
|
382
|
+
"sub",
|
|
383
|
+
"aud",
|
|
384
|
+
"role",
|
|
385
|
+
"exp",
|
|
386
|
+
"iat",
|
|
387
|
+
"iss",
|
|
388
|
+
"aal",
|
|
389
|
+
"amr",
|
|
390
|
+
"session_id",
|
|
391
|
+
"is_anonymous",
|
|
392
|
+
"email",
|
|
393
|
+
"phone",
|
|
394
|
+
"app_metadata",
|
|
395
|
+
"user_metadata"
|
|
396
|
+
]);
|
|
397
|
+
const CLAIM_FUNCTION = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
398
|
+
/**
|
|
399
|
+
* Checks `supabase.hook.claims`: each name is a safe claim key outside
|
|
400
|
+
* {@link RESERVED_CLAIMS} and the tenant claim, each function is
|
|
401
|
+
* schema-qualified (the hook runs with an empty `search_path`).
|
|
402
|
+
*/
|
|
403
|
+
function extraClaimsPlan(claims, tenantClaim) {
|
|
404
|
+
const errors = [];
|
|
405
|
+
const planned = [];
|
|
406
|
+
for (const [claim, fn] of Object.entries(claims ?? {})) {
|
|
407
|
+
if (!CLAIM_KEY.test(claim) || PROTOTYPE_KEYS.has(claim)) {
|
|
408
|
+
errors.push(`supabase.hook.claims names ${claim}: a claim must match ${CLAIM_KEY.source} and not be a prototype key`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (RESERVED_CLAIMS.has(claim) || claim === tenantClaim) {
|
|
412
|
+
errors.push(`supabase.hook.claims names ${claim}, which PermDock or Supabase Auth writes`);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (typeof fn !== "string" || !CLAIM_FUNCTION.test(fn)) {
|
|
416
|
+
errors.push(`supabase.hook.claims.${claim} must be a schema-qualified function name such as public.feature_claims`);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
planned.push({
|
|
420
|
+
claim,
|
|
421
|
+
fn
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
claims: planned,
|
|
426
|
+
errors
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function beforePlan(before) {
|
|
430
|
+
const list = before === void 0 ? [] : typeof before === "string" ? [before] : before;
|
|
431
|
+
const bad = list.filter((fn) => typeof fn !== "string" || !CLAIM_FUNCTION.test(fn));
|
|
432
|
+
if (bad.length > 0) throw new Error(`PermDock CLI: supabase.hook.before must name schema-qualified functions such as auth_checks.before_token (got ${bad.map(String).join(", ")})`);
|
|
433
|
+
return [...new Set(list)];
|
|
434
|
+
}
|
|
435
|
+
function beforeSql(before) {
|
|
436
|
+
return before.map((fn) => `
|
|
437
|
+
checked := ${quoteTable(fn)}(event);
|
|
438
|
+
if checked is null or jsonb_typeof(checked) is distinct from 'object' then
|
|
439
|
+
return jsonb_build_object('error', jsonb_build_object('http_code', 500, 'message', ${quoteLiteral(`${fn} returned no event`)}));
|
|
440
|
+
end if;
|
|
441
|
+
if checked ? 'error' then
|
|
442
|
+
return checked;
|
|
443
|
+
end if;
|
|
444
|
+
event := checked;`).join("").concat(before.length === 0 ? "" : "\n claims := event -> 'claims';");
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* `hook.validate`: the claims the hook writes, dropped together when they do
|
|
448
|
+
* not match `supabase-claims-v1.json`, so a bad claim denies instead of
|
|
449
|
+
* failing sign-in. `hook.claims` entries belong to other packages and stay.
|
|
450
|
+
*/
|
|
451
|
+
function validateSql(parts) {
|
|
452
|
+
if (parts.hook.validate !== true) return "";
|
|
453
|
+
const keys = /* @__PURE__ */ new Set([
|
|
454
|
+
"user_role",
|
|
455
|
+
"roles",
|
|
456
|
+
"memberships",
|
|
457
|
+
"memberships_truncated",
|
|
458
|
+
"attrs",
|
|
459
|
+
"authz_ver",
|
|
460
|
+
parts.tenantClaim
|
|
461
|
+
]);
|
|
462
|
+
return `
|
|
463
|
+
if not extensions.jsonb_matches_schema(${quoteLiteral(JSON.stringify(PERMDOCK_CLAIMS_SCHEMA))}::json, claims) then
|
|
464
|
+
claims := claims${[...keys].map((key) => ` - ${quoteLiteral(key)}`).join("")};
|
|
465
|
+
end if;`;
|
|
466
|
+
}
|
|
467
|
+
function validateGrantsSql(parts) {
|
|
468
|
+
return parts.hook.validate === true ? `
|
|
469
|
+
create extension if not exists pg_jsonschema with schema extensions;
|
|
470
|
+
grant usage on schema extensions to supabase_auth_admin;
|
|
471
|
+
grant execute on function extensions.jsonb_matches_schema(json, jsonb) to supabase_auth_admin;` : "";
|
|
472
|
+
}
|
|
473
|
+
const VERSION_TRIGGER = "permdock_authz_version";
|
|
474
|
+
const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
|
|
475
|
+
function memberForPlan(input) {
|
|
476
|
+
const memberFor = input.scopes.map((scope) => scope.name).filter((name) => hasMemberFor({
|
|
477
|
+
dialect: "supabase",
|
|
478
|
+
...input
|
|
479
|
+
}, name));
|
|
480
|
+
const entries = /* @__PURE__ */ new Map();
|
|
481
|
+
for (const name of memberFor) {
|
|
482
|
+
const mapped = memberForTable(input, name);
|
|
483
|
+
const found = mapped === void 0 ? memberForSources(input, name).map((source) => source.sql.manifest) : [mappedMembership(mapped, input.scopes, name)];
|
|
484
|
+
for (const entry of found) entries.set(JSON.stringify(entry), entry);
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
memberFor,
|
|
488
|
+
memberships: [...entries.values()]
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
/** An `rls.memberships` table in the manifest's `fromTable` / `fromJunction` shape, for scope `name`. */
|
|
492
|
+
function mappedMembership(mapped, scopes, name) {
|
|
493
|
+
const id = scopeColumn(mapped, scopes, name) ?? "";
|
|
494
|
+
const within = {};
|
|
495
|
+
for (const ancestor of scopeChain(scopes, name).slice(1)) {
|
|
496
|
+
const column = scopeColumn(mapped, scopes, ancestor);
|
|
497
|
+
if (column !== void 0) within[ancestor] = column;
|
|
498
|
+
}
|
|
499
|
+
const role = memberRoleOf(mapped);
|
|
500
|
+
const columns = [
|
|
501
|
+
mapped.user,
|
|
502
|
+
id,
|
|
503
|
+
...role.columns,
|
|
504
|
+
...Object.values(within),
|
|
505
|
+
...typeof mapped.via === "string" ? [mapped.via] : [],
|
|
506
|
+
...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
|
|
507
|
+
...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
|
|
508
|
+
];
|
|
509
|
+
return {
|
|
510
|
+
table: mapped.table.includes(".") ? mapped.table : `public.${mapped.table}`,
|
|
511
|
+
user: { column: mapped.user },
|
|
512
|
+
scope: { value: name },
|
|
513
|
+
id: { column: id },
|
|
514
|
+
role: roleManifest(role),
|
|
515
|
+
...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
|
|
516
|
+
...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
|
|
517
|
+
...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
|
|
518
|
+
...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
|
|
519
|
+
columns: [...new Set(columns)]
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function table(name) {
|
|
523
|
+
return quoteTable(name.includes(".") ? name : `public.${name}`);
|
|
524
|
+
}
|
|
525
|
+
function positiveInteger(value, label) {
|
|
526
|
+
const number = typeof value === "string" ? Number(value) : value;
|
|
527
|
+
if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) throw new Error(`PermDock CLI: ${label} must be a positive integer`);
|
|
528
|
+
return number;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* SQL for the active first-scope id, as text, for user `uid`; a table source
|
|
532
|
+
* compares its id column with `v_active_user`, declared as `userType`.
|
|
533
|
+
*/
|
|
534
|
+
function activeFromSql(input, root) {
|
|
535
|
+
const spec = input ?? `app_metadata.active_${root}`;
|
|
536
|
+
if (typeof spec === "string" && spec.startsWith("app_metadata.")) {
|
|
537
|
+
const key = spec.slice(13);
|
|
538
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`PermDock CLI: unsafe app_metadata key '${key}'`);
|
|
539
|
+
return { sql: `claims -> 'app_metadata' ->> ${quoteLiteral(key)}` };
|
|
540
|
+
}
|
|
541
|
+
const parsed = typeof spec === "string" ? (() => {
|
|
542
|
+
const dot = spec.lastIndexOf(".");
|
|
543
|
+
if (dot <= 0) throw new Error(`PermDock CLI: --active-from must be app_metadata.<key> or <table>.<column>, got '${spec}'`);
|
|
544
|
+
return {
|
|
545
|
+
table: spec.slice(0, dot),
|
|
546
|
+
column: spec.slice(dot + 1)
|
|
547
|
+
};
|
|
548
|
+
})() : spec;
|
|
549
|
+
const id = "id" in parsed && parsed.id !== void 0 ? parsed.id : "id";
|
|
550
|
+
return {
|
|
551
|
+
sql: `(select a.${quoteIdent(parsed.column)}::text from ${table(parsed.table)} a where a.${quoteIdent(id)} = v_active_user)`,
|
|
552
|
+
userType: `${table(parsed.table)}.${quoteIdent(id)}%type`
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* One variable per user-keyed table the hook reads, holding `uid` (a uuid, as
|
|
557
|
+
* Supabase Auth issues) as that column's type, so each lookup compares the
|
|
558
|
+
* column uncast and its index applies.
|
|
559
|
+
*/
|
|
560
|
+
function typedUsers(parts) {
|
|
561
|
+
return [
|
|
562
|
+
...parts.sources.map((source, index) => `v_user_${String(index)} ${source.sql.userType} := uid;`),
|
|
563
|
+
...parts.roles === void 0 ? [] : [`v_roles_user ${table(parts.roles.table)}.${quoteIdent(parts.roles.user)}%type := uid;`],
|
|
564
|
+
...parts.attrs?.table === void 0 || parts.attrs.columns.length === 0 ? [] : [`v_attrs_user ${table(parts.attrs.table)}.${quoteIdent(parts.attrs.id)}%type := uid;`],
|
|
565
|
+
...parts.active.userType === void 0 ? [] : [`v_active_user ${parts.active.userType} := uid;`]
|
|
566
|
+
].map((line) => `\n ${line}`).join("");
|
|
567
|
+
}
|
|
568
|
+
function checkSources(parts) {
|
|
569
|
+
if (parts.sources.length === 0) throw new Error("PermDock CLI: supabase.hook.memberships needs at least one fromTable or fromJunction source");
|
|
570
|
+
for (const source of parts.sources) {
|
|
571
|
+
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");
|
|
572
|
+
const scope = source.sql.scope;
|
|
573
|
+
if (scope === void 0) continue;
|
|
574
|
+
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`);
|
|
575
|
+
const chain = scopeChain(parts.scopes, scope);
|
|
576
|
+
const holds = new Set(source.sql.holds ?? [scope]);
|
|
577
|
+
const missing = chain.filter((ancestor) => !holds.has(ancestor));
|
|
578
|
+
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`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function entriesSql(parts) {
|
|
582
|
+
const tenant = `case when s.scope = ${quoteLiteral(parts.root)} then s.id else s.within ->> ${quoteLiteral(parts.root)} end`;
|
|
583
|
+
return parts.sources.map((source, index) => `select ${String(index)} as ord,
|
|
584
|
+
(${tenant}) as tenant,
|
|
585
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
586
|
+
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
587
|
+
'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
|
|
588
|
+
'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
|
|
589
|
+
'managedBy', s.managed_by, 'entitlements', s.seats${source.sql.keeps ? ", 'keep', s.keep" : ""}
|
|
590
|
+
)) as entry
|
|
591
|
+
from (
|
|
592
|
+
${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
|
|
593
|
+
) s`).join("\nunion all\n");
|
|
594
|
+
}
|
|
595
|
+
function hookSql(parts) {
|
|
596
|
+
const schema = quoteIdent(parts.schema);
|
|
597
|
+
const fn = `${schema}.custom_access_token_hook`;
|
|
598
|
+
const rows = parts.roles;
|
|
599
|
+
const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
|
|
600
|
+
into held
|
|
601
|
+
from ${rows.from}
|
|
602
|
+
where ${rows.userSql} = v_roles_user;`;
|
|
603
|
+
const plan = parts.attrs;
|
|
604
|
+
const fromTable = plan === void 0 || plan.columns.length === 0 || plan.table === void 0 ? "" : `
|
|
605
|
+
select jsonb_strip_nulls(jsonb_build_object(${plan.columns.map((column) => `${quoteLiteral(column)}, to_jsonb(p.${quoteIdent(column)})`).join(", ")}))
|
|
606
|
+
into attrs
|
|
607
|
+
from ${table(plan.table)} p
|
|
608
|
+
where p.${quoteIdent(plan.id)} = v_attrs_user;`;
|
|
609
|
+
const fromMeta = plan === void 0 || plan.meta.length === 0 ? "" : `
|
|
610
|
+
attrs := coalesce(attrs, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(${plan.meta.map((key) => `${quoteLiteral(key)}, claims -> 'app_metadata' -> ${quoteLiteral(key)}`).join(", ")}));`;
|
|
611
|
+
const attrs = plan === void 0 ? "" : `${fromTable}${fromMeta}
|
|
612
|
+
if attrs is not null and attrs <> '{}'::jsonb then
|
|
613
|
+
used := octet_length(attrs::text);
|
|
614
|
+
if used > budget then
|
|
615
|
+
truncated := true;
|
|
616
|
+
used := 0;
|
|
617
|
+
else
|
|
618
|
+
claims := jsonb_set(claims, '{attrs}', attrs);
|
|
619
|
+
end if;
|
|
620
|
+
end if;`;
|
|
621
|
+
const versionTable = `${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)}`;
|
|
622
|
+
const version = parts.version ? `
|
|
623
|
+
select v.version into ver from ${versionTable} v where v.user_id = uid;
|
|
624
|
+
claims := jsonb_set(claims, '{authz_ver}', to_jsonb(coalesce(ver, 0)));` : "";
|
|
625
|
+
const dropExtra = parts.extra.map((entry) => ` - ${quoteLiteral(entry.claim)}`).join("");
|
|
626
|
+
const extra = parts.extra.map((entry) => `
|
|
627
|
+
extra := ${quoteTable(entry.fn)}(uid);
|
|
628
|
+
if extra is not null then
|
|
629
|
+
claims := jsonb_set(claims, ${quoteLiteral(`{${entry.claim}}`)}, extra);
|
|
630
|
+
end if;`).join("");
|
|
631
|
+
const suspended = parts.users === void 0 ? "" : `
|
|
632
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
633
|
+
claims := claims - 'memberships_truncated' - 'attrs' - ${quoteLiteral(parts.tenantClaim)}${dropExtra};
|
|
634
|
+
claims := claims || jsonb_build_object('user_role', '[]'::jsonb, 'roles', '[]'::jsonb, 'memberships', '[]'::jsonb);${version}
|
|
635
|
+
return jsonb_set(event, '{claims}', claims);
|
|
636
|
+
end if;`;
|
|
637
|
+
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)
|
|
638
|
+
returns jsonb
|
|
639
|
+
language plpgsql
|
|
640
|
+
stable
|
|
641
|
+
set search_path = ''
|
|
642
|
+
as $$
|
|
643
|
+
declare
|
|
644
|
+
claims jsonb := event -> 'claims';
|
|
645
|
+
uid uuid := (event ->> 'user_id')::uuid;
|
|
646
|
+
active text;
|
|
647
|
+
held jsonb;
|
|
648
|
+
kept jsonb := '[]'::jsonb;
|
|
649
|
+
truncated boolean := false;
|
|
650
|
+
in_active boolean := false;
|
|
651
|
+
budget integer := ${String(parts.budget)};
|
|
652
|
+
used integer := 0;
|
|
653
|
+
item record;${typedUsers(parts)}${plan === void 0 ? "" : "\n attrs jsonb;"}${parts.extra.length === 0 ? "" : "\n extra jsonb;"}${parts.version ? "\n ver bigint;" : ""}${parts.before.length === 0 ? "" : "\n checked jsonb;"}
|
|
654
|
+
begin${beforeSql(parts.before)}${suspended}
|
|
655
|
+
claims := claims - 'attrs'${dropExtra};
|
|
656
|
+
${roles}
|
|
657
|
+
claims := jsonb_set(claims, '{roles}', held);
|
|
658
|
+
if jsonb_array_length(held) = 1 then
|
|
659
|
+
claims := jsonb_set(claims, '{user_role}', held -> 0);
|
|
660
|
+
elsif jsonb_array_length(held) > 1 then
|
|
661
|
+
claims := jsonb_set(claims, '{user_role}', held);
|
|
662
|
+
end if;
|
|
663
|
+
active := ${parts.active.sql};${attrs}
|
|
664
|
+
for item in
|
|
665
|
+
select x.entry, x.tenant is not distinct from active as current
|
|
666
|
+
from (
|
|
667
|
+
${entriesSql(parts).replaceAll(/^/gmu, " ")}
|
|
668
|
+
) x
|
|
669
|
+
order by (x.tenant is not distinct from active) desc, x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text
|
|
670
|
+
loop
|
|
671
|
+
if octet_length((kept || jsonb_build_array(item.entry))::text) + used > budget then
|
|
672
|
+
truncated := true;
|
|
673
|
+
exit;
|
|
674
|
+
end if;
|
|
675
|
+
kept := kept || jsonb_build_array(item.entry);
|
|
676
|
+
in_active := in_active or (active is not null and item.current);
|
|
677
|
+
end loop;
|
|
678
|
+
claims := jsonb_set(claims, '{memberships}', kept);
|
|
679
|
+
if truncated then
|
|
680
|
+
claims := jsonb_set(claims, '{memberships_truncated}', 'true'::jsonb);
|
|
681
|
+
else
|
|
682
|
+
claims := claims - 'memberships_truncated';
|
|
683
|
+
end if;
|
|
684
|
+
if in_active then
|
|
685
|
+
claims := jsonb_set(claims, ${quoteLiteral(`{${parts.tenantClaim}}`)}, to_jsonb(active));
|
|
686
|
+
end if;${extra}${version}${validateSql(parts)}
|
|
687
|
+
return jsonb_set(event, '{claims}', claims);
|
|
688
|
+
end;
|
|
689
|
+
$$;`;
|
|
690
|
+
}
|
|
691
|
+
/** Everything the hook grants `supabase_auth_admin`, and the execute revoke. */
|
|
692
|
+
function grantsSql(parts) {
|
|
693
|
+
const schema = quoteIdent(parts.schema);
|
|
694
|
+
const fn = `${schema}.custom_access_token_hook`;
|
|
695
|
+
return [
|
|
696
|
+
`-- supabase_auth_admin: the grants and read policies the hook needs
|
|
697
|
+
grant usage on schema ${schema} to supabase_auth_admin;
|
|
698
|
+
grant execute on function ${fn}(jsonb) to supabase_auth_admin;
|
|
699
|
+
revoke execute on function ${fn}(jsonb) from authenticated, anon, public;${extraGrantsSql(parts.extra)}${beforeGrantsSql(parts.before, parts.extra)}${memberForGrantsSql(parts)}${validateGrantsSql(parts)}`,
|
|
700
|
+
readsSql(parts),
|
|
701
|
+
parts.version ? authAdminRead(`${parts.schema}.${AUTHZ_VERSION_TABLE}`, "version") : ""
|
|
702
|
+
].filter((chunk) => chunk !== "").join("\n");
|
|
703
|
+
}
|
|
704
|
+
function extraGrantsSql(extra) {
|
|
705
|
+
return [...[...new Set(extra.map((entry) => entry.fn.split(".")[0]))].map((name) => `\ngrant usage on schema ${quoteIdent(name ?? "")} to supabase_auth_admin;`), ...extra.map((entry) => `\ngrant execute on function ${quoteTable(entry.fn)}(uuid) to supabase_auth_admin;`)].join("");
|
|
706
|
+
}
|
|
707
|
+
function beforeGrantsSql(before, extra) {
|
|
708
|
+
const granted = new Set(extra.map((entry) => entry.fn.split(".")[0]));
|
|
709
|
+
return [...[...new Set(before.map((fn) => fn.split(".")[0] ?? ""))].filter((name) => !granted.has(name)).map((name) => `\ngrant usage on schema ${quoteIdent(name)} to supabase_auth_admin;`), ...before.map((fn) => `\ngrant execute on function ${quoteTable(fn)}(jsonb) to supabase_auth_admin;`)].join("");
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Execute on each `member_<scope>_ids_for(uuid)` for the `hook.claims`
|
|
713
|
+
* functions, which run as `supabase_auth_admin` and may call them. Only with
|
|
714
|
+
* `hook.claims`: the grant needs the helpers migration applied first, and a
|
|
715
|
+
* hook without extra claims does not.
|
|
716
|
+
*/
|
|
717
|
+
function memberForGrantsSql(parts) {
|
|
718
|
+
const { schema, memberFor } = parts.helpers;
|
|
719
|
+
if (memberFor.length === 0 || parts.extra.length === 0) return "";
|
|
720
|
+
return [...schema === parts.schema ? [] : [`\ngrant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`], ...memberFor.map((scope) => `\ngrant execute on function ${quoteIdent(schema)}.${memberForHelper(scope)}(uuid) to supabase_auth_admin;`)].join("");
|
|
721
|
+
}
|
|
722
|
+
function readsSql(parts) {
|
|
723
|
+
const reads = /* @__PURE__ */ new Map();
|
|
724
|
+
for (const source of parts.sources) reads.set(source.sql.table, "memberships");
|
|
725
|
+
for (const source of parts.sources) {
|
|
726
|
+
for (const name of source.sql.reads) reads.set(name, reads.get(name) ?? "status");
|
|
727
|
+
for (const through of source.sql.throughs) reads.set(through.table, reads.get(through.table) ?? "role_keys");
|
|
728
|
+
const users = source.sql.userThrough;
|
|
729
|
+
if (users !== void 0) reads.set(users.table, reads.get(users.table) ?? "member_users");
|
|
730
|
+
}
|
|
731
|
+
if (parts.roles !== void 0) {
|
|
732
|
+
const { table: rolesTable, through } = parts.roles;
|
|
733
|
+
reads.set(rolesTable, reads.get(rolesTable) ?? "roles");
|
|
734
|
+
if (through !== void 0) reads.set(through.table, reads.get(through.table) ?? "role_keys");
|
|
735
|
+
}
|
|
736
|
+
if (parts.attrs?.table !== void 0 && parts.attrs.columns.length > 0) reads.set(parts.attrs.table, reads.get(parts.attrs.table) ?? "attrs");
|
|
737
|
+
if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
|
|
738
|
+
return [...[...new Set([...reads.keys()].map((name) => name.includes(".") ? name.split(".")[0] : "public").filter((schema) => schema !== void 0 && schema !== parts.schema && schema !== "auth"))].map((schema) => `grant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`), ...[...reads].map(([name, label]) => authAdminRead(name, label))].join("\n");
|
|
739
|
+
}
|
|
740
|
+
function versionSql(parts) {
|
|
741
|
+
if (!parts.version) return "";
|
|
742
|
+
const schema = quoteIdent(parts.schema);
|
|
743
|
+
const versionTable = `${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)}`;
|
|
744
|
+
const bump = `${schema}.permdock_bump_authz_version`;
|
|
745
|
+
const bumpFor = `${schema}.${AUTHZ_VERSION_BUMP}`;
|
|
746
|
+
const tables = [...new Map(parts.sources.filter((source) => source.sql.userThrough === void 0).map((source) => [source.sql.table, source.sql.user]))];
|
|
747
|
+
if (parts.roles !== void 0) tables.push([parts.roles.table, parts.roles.user]);
|
|
748
|
+
return `-- the authorization version: bumped on every membership change, written to authz_ver
|
|
749
|
+
create table if not exists ${versionTable} (
|
|
750
|
+
user_id uuid primary key references auth.users on delete cascade,
|
|
751
|
+
version bigint not null default 0
|
|
752
|
+
);
|
|
753
|
+
alter table ${versionTable} enable row level security;
|
|
754
|
+
revoke all on table ${versionTable} from anon, authenticated, public;
|
|
755
|
+
|
|
756
|
+
-- bumps each listed user once, skipping users already deleted from auth.users; no client role may call it
|
|
757
|
+
create or replace function ${bumpFor}(p_users uuid[])
|
|
758
|
+
returns void
|
|
759
|
+
language sql
|
|
760
|
+
security definer
|
|
761
|
+
set search_path = ''
|
|
762
|
+
as $$
|
|
763
|
+
insert into ${versionTable} as v (user_id, version)
|
|
764
|
+
select distinct u, 1 from unnest(p_users) u
|
|
765
|
+
where u is not null and exists (select 1 from auth.users au where au.id = u)
|
|
766
|
+
on conflict (user_id) do update set version = v.version + 1
|
|
767
|
+
$$;
|
|
768
|
+
revoke execute on function ${bumpFor}(uuid[]) from public, anon, authenticated;
|
|
769
|
+
|
|
770
|
+
create or replace function ${bump}()
|
|
771
|
+
returns trigger
|
|
772
|
+
language plpgsql
|
|
773
|
+
security definer
|
|
774
|
+
set search_path = ''
|
|
775
|
+
as $$
|
|
776
|
+
declare
|
|
777
|
+
column_name text := tg_argv[0];
|
|
778
|
+
begin
|
|
779
|
+
perform ${bumpFor}(array[
|
|
780
|
+
case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
|
|
781
|
+
case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
|
|
782
|
+
]::uuid[]);
|
|
783
|
+
return null;
|
|
784
|
+
end;
|
|
785
|
+
$$;
|
|
786
|
+
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
787
|
+
${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${table(name)};
|
|
788
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
789
|
+
after insert or update or delete on ${table(name)}
|
|
790
|
+
for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${memberUsersVersionSql(parts, bumpFor)}${roleKeysVersionSql(parts, versionTable)}`;
|
|
791
|
+
}
|
|
792
|
+
const SUBJECT_FOR = "subject_for";
|
|
793
|
+
/**
|
|
794
|
+
* `subject_for(p_user uuid) returns jsonb`: what the hook would put in a
|
|
795
|
+
* token for `p_user`, from the same membership, role, suspension and version
|
|
796
|
+
* reads, plus the custom roles the user holds when `database` mode keeps them
|
|
797
|
+
* in tables. A backend that only has PostgREST (`postgrestSources`) builds a
|
|
798
|
+
* subject for an acting user or a job from it. Naming the user is why no
|
|
799
|
+
* client role may execute it.
|
|
800
|
+
*/
|
|
801
|
+
function subjectForSql(parts, config) {
|
|
802
|
+
const schema = quoteIdent(parts.schema);
|
|
803
|
+
const fn = `${schema}.${SUBJECT_FOR}`;
|
|
804
|
+
const rows = parts.roles;
|
|
805
|
+
const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
|
|
806
|
+
into held
|
|
807
|
+
from ${rows.from}
|
|
808
|
+
where ${rows.userSql} = v_roles_user;`;
|
|
809
|
+
const suspended = parts.users === void 0 ? "" : `
|
|
810
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
811
|
+
return jsonb_build_object('id', uid::text, 'active', false);
|
|
812
|
+
end if;`;
|
|
813
|
+
const version = parts.version ? `
|
|
814
|
+
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
815
|
+
ver := coalesce(ver, 0);` : "";
|
|
816
|
+
const custom = config.rls?.customRoles === true && resolveAuthorize(config) === "database" ? customRolesOfSql(parts) : "";
|
|
817
|
+
return `-- the subject the hook would mint for p_user, for a backend acting for a stored user; no client role may execute it
|
|
818
|
+
create or replace function ${fn}(p_user uuid)
|
|
819
|
+
returns jsonb
|
|
820
|
+
language plpgsql
|
|
821
|
+
stable
|
|
822
|
+
security definer
|
|
823
|
+
set search_path = ''
|
|
824
|
+
as $$
|
|
825
|
+
declare
|
|
826
|
+
uid uuid := p_user;
|
|
827
|
+
held jsonb;
|
|
828
|
+
memberships jsonb;
|
|
829
|
+
custom jsonb := '[]'::jsonb;
|
|
830
|
+
ver bigint;${typedUsers(parts)}
|
|
831
|
+
begin
|
|
832
|
+
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
833
|
+
return null;
|
|
834
|
+
end if;${suspended}
|
|
835
|
+
${roles}
|
|
836
|
+
select coalesce(jsonb_agg(x.entry order by x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text), '[]'::jsonb)
|
|
837
|
+
into memberships
|
|
838
|
+
from (
|
|
839
|
+
${entriesSql(parts).replaceAll(/^/gmu, " ")}
|
|
840
|
+
) x;${custom}${version}
|
|
841
|
+
return jsonb_strip_nulls(jsonb_build_object(
|
|
842
|
+
'id', uid::text,
|
|
843
|
+
'active', true,
|
|
844
|
+
'roles', held,
|
|
845
|
+
'memberships', memberships,
|
|
846
|
+
'customRoles', custom,
|
|
847
|
+
'authzVersion', ver
|
|
848
|
+
));
|
|
849
|
+
end;
|
|
850
|
+
$$;
|
|
851
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
852
|
+
}
|
|
853
|
+
const AUTHZ_VERSION_FOR = "authz_version_for";
|
|
854
|
+
/**
|
|
855
|
+
* `authz_version_for(p_user uuid) returns bigint`: the authorization version
|
|
856
|
+
* `subject_for` reports, without reading roles or memberships, so a
|
|
857
|
+
* `claimsFirst` reread can check a token's freshness on every request and read
|
|
858
|
+
* the full subject only when it is behind. `null` for an unknown or suspended
|
|
859
|
+
* user, and when the version is off.
|
|
860
|
+
*/
|
|
861
|
+
function authzVersionForSql(parts) {
|
|
862
|
+
const schema = quoteIdent(parts.schema);
|
|
863
|
+
const fn = `${schema}.${AUTHZ_VERSION_FOR}`;
|
|
864
|
+
return `-- the authorization version subject_for reports for p_user, for a cheap freshness check; no client role may execute it
|
|
865
|
+
create or replace function ${fn}(p_user uuid)
|
|
866
|
+
returns bigint
|
|
867
|
+
language plpgsql
|
|
868
|
+
stable
|
|
869
|
+
security definer
|
|
870
|
+
set search_path = ''
|
|
871
|
+
as $$
|
|
872
|
+
declare
|
|
873
|
+
uid uuid := p_user;
|
|
874
|
+
ver bigint;
|
|
875
|
+
begin
|
|
876
|
+
if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
|
|
877
|
+
return null;
|
|
878
|
+
end if;${parts.users === void 0 ? "" : `
|
|
879
|
+
if not ${activeRowSql(parts.users, "uid")} then
|
|
880
|
+
return null;
|
|
881
|
+
end if;`}${parts.version ? `
|
|
882
|
+
select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
|
|
883
|
+
ver := coalesce(ver, 0);` : ""}
|
|
884
|
+
return ver;
|
|
885
|
+
end;
|
|
886
|
+
$$;
|
|
887
|
+
revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
|
|
888
|
+
}
|
|
889
|
+
const MEMBERS_OF = "members_of";
|
|
890
|
+
function membersOfSql(parts) {
|
|
891
|
+
const fn = `${quoteIdent(parts.schema)}.${MEMBERS_OF}`;
|
|
892
|
+
const keep = parts.sources.some((source) => source.sql.keeps);
|
|
893
|
+
const rows = parts.sources.map((source) => source.sql.list(keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
|
|
894
|
+
return `-- the live memberships of one scope instance, for a backend that lists members over PostgREST; no client role may execute it
|
|
895
|
+
create or replace function ${fn}(p_scope text, p_id text)
|
|
896
|
+
returns jsonb
|
|
897
|
+
language sql
|
|
898
|
+
stable
|
|
899
|
+
security definer
|
|
900
|
+
set search_path = ''
|
|
901
|
+
as $$
|
|
902
|
+
select coalesce(jsonb_agg(jsonb_build_object(
|
|
903
|
+
'principal', jsonb_build_object('id', s.user_id),
|
|
904
|
+
'membership', jsonb_strip_nulls(jsonb_build_object(
|
|
905
|
+
'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
|
|
906
|
+
'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
|
|
907
|
+
'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
|
|
908
|
+
'managedBy', s.managed_by, 'entitlements', s.seats${keep ? ", 'keep', s.keep" : ""}
|
|
909
|
+
))
|
|
910
|
+
) order by s.user_id, s.roles::text), '[]'::jsonb)
|
|
911
|
+
from (
|
|
912
|
+
${rows}
|
|
913
|
+
) s
|
|
914
|
+
$$;
|
|
915
|
+
revoke execute on function ${fn}(text, text) from public, anon, authenticated;`;
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* The custom roles `p_user` holds: tenant roles named on a membership of the
|
|
919
|
+
* scope (and instance) they live at, and platform roles among the global
|
|
920
|
+
* roles, each with its grants and includes in `CustomRole` form.
|
|
921
|
+
*/
|
|
922
|
+
function customRolesOfSql(parts) {
|
|
923
|
+
const helpers = quoteIdent(parts.helpers.schema);
|
|
924
|
+
const perms = `${helpers}.custom_role_permissions`;
|
|
925
|
+
const includes = `${helpers}.custom_role_includes`;
|
|
926
|
+
const root = quoteLiteral(parts.root);
|
|
927
|
+
const match = `p.tenant_id is not distinct from c.tenant_id and p.scope = c.scope and p.scope_id is not distinct from c.scope_id and p.role = c.role`;
|
|
928
|
+
return `
|
|
929
|
+
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
930
|
+
'name', c.role,
|
|
931
|
+
'tenant', c.tenant_id::text,
|
|
932
|
+
'scope', c.scope,
|
|
933
|
+
'id', c.scope_id,
|
|
934
|
+
'grants', (
|
|
935
|
+
select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
936
|
+
'permission', p.permission,
|
|
937
|
+
'effect', p.effect,
|
|
938
|
+
'level', to_jsonb(p) ->> 'level'
|
|
939
|
+
)) order by p.permission, p.effect), '[]'::jsonb)
|
|
940
|
+
from ${perms} p
|
|
941
|
+
where ${match}
|
|
942
|
+
),
|
|
943
|
+
'includes', (
|
|
944
|
+
select jsonb_agg(p.include_role order by p.include_role)
|
|
945
|
+
from ${includes} p
|
|
946
|
+
where ${match}
|
|
947
|
+
)
|
|
948
|
+
)) order by c.tenant_id::text, c.scope, c.scope_id, c.role), '[]'::jsonb)
|
|
949
|
+
into custom
|
|
950
|
+
from (
|
|
951
|
+
select tenant_id, scope, scope_id, role from ${perms}
|
|
952
|
+
union
|
|
953
|
+
select tenant_id, scope, scope_id, role from ${includes}
|
|
954
|
+
) c
|
|
955
|
+
where (c.scope = 'global' and c.tenant_id is null and held ? c.role)
|
|
956
|
+
or exists (
|
|
957
|
+
select 1
|
|
958
|
+
from jsonb_array_elements(memberships) m
|
|
959
|
+
where m -> 'roles' ? c.role
|
|
960
|
+
and m ->> 'scope' = c.scope
|
|
961
|
+
and c.tenant_id::text = case when m ->> 'scope' = ${root} then m ->> 'id' else m -> 'within' ->> ${root} end
|
|
962
|
+
and (c.scope_id is null or c.scope_id = m ->> 'id')
|
|
963
|
+
);`;
|
|
964
|
+
}
|
|
965
|
+
function signedUp(user) {
|
|
966
|
+
return `exists (select 1 from auth.users a where a.id = ${user})`;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Every membership source whose user id lives in another table: a row of
|
|
970
|
+
* the source bumps the users its old and new references hold, and a change
|
|
971
|
+
* of that table's user id (a contact re-linked to another login) bumps the
|
|
972
|
+
* old and the new user.
|
|
973
|
+
*/
|
|
974
|
+
function memberUsersVersionSql(parts, bumpFor) {
|
|
975
|
+
const holders = /* @__PURE__ */ new Map();
|
|
976
|
+
for (const source of parts.sources) {
|
|
977
|
+
const users = source.sql.userThrough;
|
|
978
|
+
if (users !== void 0) holders.set(JSON.stringify([
|
|
979
|
+
tableKey(source.sql.table),
|
|
980
|
+
tableKey(users.table),
|
|
981
|
+
users.id,
|
|
982
|
+
users.key,
|
|
983
|
+
users.ref
|
|
984
|
+
]), {
|
|
985
|
+
table: source.sql.table,
|
|
986
|
+
users
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
if (holders.size === 0) return "";
|
|
990
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
991
|
+
for (const holder of holders.values()) {
|
|
992
|
+
const key = tableKey(holder.table);
|
|
993
|
+
const group = byTable.get(key);
|
|
994
|
+
if (group === void 0) byTable.set(key, {
|
|
995
|
+
table: holder.table,
|
|
996
|
+
users: [holder.users]
|
|
997
|
+
});
|
|
998
|
+
else group.users.push(holder.users);
|
|
999
|
+
}
|
|
1000
|
+
const memberBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_member_users`;
|
|
1001
|
+
const linkedBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_linked_users`;
|
|
1002
|
+
const groups = [...byTable];
|
|
1003
|
+
const lookup = (users, indent) => `${indent}perform ${bumpFor}(array(
|
|
1004
|
+
${indent} select u.${quoteIdent(users.key)}::uuid from ${table(users.table)} u
|
|
1005
|
+
${indent} where ((tg_op <> 'INSERT' and u.${quoteIdent(users.id)} = old.${quoteIdent(users.ref)})
|
|
1006
|
+
${indent} or (tg_op <> 'DELETE' and u.${quoteIdent(users.id)} = new.${quoteIdent(users.ref)}))
|
|
1007
|
+
${indent} and ${signedUp(`u.${quoteIdent(users.key)}::uuid`)}
|
|
1008
|
+
${indent}));`;
|
|
1009
|
+
const body = groups.map(([key, group]) => {
|
|
1010
|
+
if (groups.length === 1) return group.users.map((users) => lookup(users, " ")).join("\n");
|
|
1011
|
+
const dot = key.indexOf(".");
|
|
1012
|
+
return ` if tg_table_schema = ${quoteLiteral(key.slice(0, dot))} and tg_table_name = ${quoteLiteral(key.slice(dot + 1))} then
|
|
1013
|
+
${group.users.map((users) => lookup(users, " ")).join("\n")}
|
|
1014
|
+
end if;`;
|
|
1015
|
+
}).join("\n");
|
|
1016
|
+
const memberTriggers = groups.map(([, group]) => {
|
|
1017
|
+
const target = table(group.table);
|
|
1018
|
+
return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
|
|
1019
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1020
|
+
after insert or update or delete on ${target}
|
|
1021
|
+
for each row execute function ${memberBump}();`;
|
|
1022
|
+
}).join("\n");
|
|
1023
|
+
const userTables = /* @__PURE__ */ new Map();
|
|
1024
|
+
for (const { users } of holders.values()) {
|
|
1025
|
+
const key = JSON.stringify([tableKey(users.table), users.key]);
|
|
1026
|
+
const entry = userTables.get(key) ?? {
|
|
1027
|
+
table: users.table,
|
|
1028
|
+
key: users.key,
|
|
1029
|
+
ids: /* @__PURE__ */ new Set()
|
|
1030
|
+
};
|
|
1031
|
+
entry.ids.add(users.id);
|
|
1032
|
+
userTables.set(key, entry);
|
|
1033
|
+
}
|
|
1034
|
+
const userTriggers = [...userTables.values()].map((entry) => {
|
|
1035
|
+
const target = table(entry.table);
|
|
1036
|
+
const name = quoteIdent(`${VERSION_TRIGGER}_${entry.key}`);
|
|
1037
|
+
return `drop trigger if exists ${name} on ${target};
|
|
1038
|
+
create trigger ${name}
|
|
1039
|
+
after update of ${[entry.key, ...entry.ids].map(quoteIdent).join(", ")} or delete on ${target}
|
|
1040
|
+
for each row execute function ${linkedBump}(${quoteLiteral(entry.key)});`;
|
|
1041
|
+
}).join("\n");
|
|
1042
|
+
return `
|
|
1043
|
+
|
|
1044
|
+
-- a membership whose user id lives in another table bumps the users its old and new rows reference
|
|
1045
|
+
create or replace function ${memberBump}()
|
|
1046
|
+
returns trigger
|
|
1047
|
+
language plpgsql
|
|
1048
|
+
security definer
|
|
1049
|
+
set search_path = ''
|
|
1050
|
+
as $$
|
|
1051
|
+
begin
|
|
1052
|
+
${body}
|
|
1053
|
+
return null;
|
|
1054
|
+
end;
|
|
1055
|
+
$$;
|
|
1056
|
+
revoke execute on function ${memberBump}() from public, anon, authenticated;
|
|
1057
|
+
${memberTriggers}
|
|
1058
|
+
|
|
1059
|
+
-- re-linking that table's user id moves every membership that references the row: bump the old and the new user,
|
|
1060
|
+
-- skipping a login being deleted (its version row goes with it)
|
|
1061
|
+
create or replace function ${linkedBump}()
|
|
1062
|
+
returns trigger
|
|
1063
|
+
language plpgsql
|
|
1064
|
+
security definer
|
|
1065
|
+
set search_path = ''
|
|
1066
|
+
as $$
|
|
1067
|
+
declare
|
|
1068
|
+
column_name text := tg_argv[0];
|
|
1069
|
+
begin
|
|
1070
|
+
perform ${bumpFor}(array(
|
|
1071
|
+
select u
|
|
1072
|
+
from unnest(array[
|
|
1073
|
+
case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
|
|
1074
|
+
case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
|
|
1075
|
+
]::uuid[]) u
|
|
1076
|
+
where ${signedUp("u")}
|
|
1077
|
+
));
|
|
1078
|
+
return null;
|
|
1079
|
+
end;
|
|
1080
|
+
$$;
|
|
1081
|
+
revoke execute on function ${linkedBump}() from public, anon, authenticated;
|
|
1082
|
+
${userTriggers}`;
|
|
1083
|
+
}
|
|
1084
|
+
/** Every table whose role column references a roles table: the global roles and the membership sources. */
|
|
1085
|
+
function roleKeyHolders(parts) {
|
|
1086
|
+
const holders = [];
|
|
1087
|
+
if (parts.roles?.through !== void 0) holders.push({
|
|
1088
|
+
table: parts.roles.table,
|
|
1089
|
+
user: parts.roles.user,
|
|
1090
|
+
through: parts.roles.through
|
|
1091
|
+
});
|
|
1092
|
+
for (const source of parts.sources) for (const through of source.sql.throughs) holders.push({
|
|
1093
|
+
table: source.sql.table,
|
|
1094
|
+
user: source.sql.user,
|
|
1095
|
+
...source.sql.userThrough === void 0 ? {} : { userThrough: source.sql.userThrough },
|
|
1096
|
+
through
|
|
1097
|
+
});
|
|
1098
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1099
|
+
return holders.filter((holder) => {
|
|
1100
|
+
const key = JSON.stringify([
|
|
1101
|
+
tableKey(holder.table),
|
|
1102
|
+
holder.user,
|
|
1103
|
+
holder.userThrough === void 0 ? null : [
|
|
1104
|
+
tableKey(holder.userThrough.table),
|
|
1105
|
+
holder.userThrough.id,
|
|
1106
|
+
holder.userThrough.key
|
|
1107
|
+
],
|
|
1108
|
+
tableKey(holder.through.table),
|
|
1109
|
+
holder.through.id,
|
|
1110
|
+
holder.through.key,
|
|
1111
|
+
holder.through.ref
|
|
1112
|
+
]);
|
|
1113
|
+
if (seen.has(key)) return false;
|
|
1114
|
+
seen.add(key);
|
|
1115
|
+
return true;
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
/** The bump for one roles table: unchanged keys and ids return early, else every holder of `old` is bumped. */
|
|
1119
|
+
function roleKeysBumpSql(holders, versionTable, indent) {
|
|
1120
|
+
return `${indent}if tg_op = 'UPDATE'
|
|
1121
|
+
${indent} and ${[...new Set(holders.flatMap((holder) => [holder.through.key, holder.through.id]))].map((column) => `new.${quoteIdent(column)} is not distinct from old.${quoteIdent(column)}`).join(`\n${indent} and `)} then
|
|
1122
|
+
${indent} return null;
|
|
1123
|
+
${indent}end if;
|
|
1124
|
+
${indent}insert into ${versionTable} as v (user_id, version)
|
|
1125
|
+
${indent}select distinct h.user_id, 1
|
|
1126
|
+
${indent}from (
|
|
1127
|
+
${indent} ${holders.map((holder) => holder.userThrough === void 0 ? `select h.${quoteIdent(holder.user)}::uuid as user_id from ${table(holder.table)} h where h.${quoteIdent(holder.through.ref)} = old.${quoteIdent(holder.through.id)}` : `select u.${quoteIdent(holder.userThrough.key)}::uuid as user_id from ${table(holder.table)} h join ${table(holder.userThrough.table)} u on u.${quoteIdent(holder.userThrough.id)} = h.${quoteIdent(holder.user)} where h.${quoteIdent(holder.through.ref)} = old.${quoteIdent(holder.through.id)}`).join(`\n${indent} union\n${indent} `)}
|
|
1128
|
+
${indent}) h
|
|
1129
|
+
${indent}where h.user_id is not null
|
|
1130
|
+
${indent} and exists (select 1 from auth.users au where au.id = h.user_id)
|
|
1131
|
+
${indent}on conflict (user_id) do update set version = v.version + 1;`;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* A renamed role key changes the `roles` claim of everyone who holds it and
|
|
1135
|
+
* the `roles` of every membership that references it, so updates to a roles
|
|
1136
|
+
* table bump each holder.
|
|
1137
|
+
*/
|
|
1138
|
+
function roleKeysVersionSql(parts, versionTable) {
|
|
1139
|
+
const holders = roleKeyHolders(parts);
|
|
1140
|
+
if (holders.length === 0) return "";
|
|
1141
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
1142
|
+
for (const holder of holders) {
|
|
1143
|
+
const key = tableKey(holder.through.table);
|
|
1144
|
+
const group = byTable.get(key);
|
|
1145
|
+
if (group === void 0) byTable.set(key, {
|
|
1146
|
+
table: holder.through.table,
|
|
1147
|
+
holders: [holder]
|
|
1148
|
+
});
|
|
1149
|
+
else group.holders.push(holder);
|
|
1150
|
+
}
|
|
1151
|
+
const bump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_role_keys`;
|
|
1152
|
+
const groups = [...byTable];
|
|
1153
|
+
return `
|
|
1154
|
+
|
|
1155
|
+
-- a renamed role key changes the roles of everyone who holds it, globally or through a membership
|
|
1156
|
+
create or replace function ${bump}()
|
|
1157
|
+
returns trigger
|
|
1158
|
+
language plpgsql
|
|
1159
|
+
security definer
|
|
1160
|
+
set search_path = ''
|
|
1161
|
+
as $$
|
|
1162
|
+
begin
|
|
1163
|
+
${groups.map(([key, group]) => {
|
|
1164
|
+
if (groups.length === 1) return roleKeysBumpSql(group.holders, versionTable, " ");
|
|
1165
|
+
const dot = key.indexOf(".");
|
|
1166
|
+
return ` if tg_table_schema = ${quoteLiteral(key.slice(0, dot))} and tg_table_name = ${quoteLiteral(key.slice(dot + 1))} then
|
|
1167
|
+
${roleKeysBumpSql(group.holders, versionTable, " ")}
|
|
1168
|
+
end if;`;
|
|
1169
|
+
}).join("\n")}
|
|
1170
|
+
return null;
|
|
1171
|
+
end;
|
|
1172
|
+
$$;
|
|
1173
|
+
revoke execute on function ${bump}() from public, anon, authenticated;
|
|
1174
|
+
${groups.map(([, group]) => {
|
|
1175
|
+
const target = table(group.table);
|
|
1176
|
+
return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
|
|
1177
|
+
create trigger ${quoteIdent(VERSION_TRIGGER)}
|
|
1178
|
+
after update or delete on ${target}
|
|
1179
|
+
for each row execute function ${bump}();`;
|
|
1180
|
+
}).join("\n")}`;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Fails the migration when a client role can write an `attrs` column: a
|
|
1184
|
+
* claim the user can set is not a server-owned attribute.
|
|
1185
|
+
*/
|
|
1186
|
+
function attrsGuardSql(plan) {
|
|
1187
|
+
if (plan?.table === void 0 || plan.columns.length === 0) return "";
|
|
1188
|
+
const target = quoteLiteral(table(plan.table));
|
|
1189
|
+
return `-- attrs must be server-owned: refuse columns anon or authenticated can insert or update
|
|
1190
|
+
do $$
|
|
1191
|
+
begin
|
|
1192
|
+
if exists (
|
|
1193
|
+
select 1
|
|
1194
|
+
from (values ${plan.columns.map((column) => `(${quoteLiteral(column)})`).join(", ")}) c(name)
|
|
1195
|
+
cross join (values ('anon'), ('authenticated')) r(role)
|
|
1196
|
+
where has_column_privilege(r.role, ${target}, c.name, 'INSERT')
|
|
1197
|
+
or has_column_privilege(r.role, ${target}, c.name, 'UPDATE')
|
|
1198
|
+
) then
|
|
1199
|
+
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}
|
|
1200
|
+
using errcode = '42501';
|
|
1201
|
+
end if;
|
|
1202
|
+
end
|
|
1203
|
+
$$;`;
|
|
1204
|
+
}
|
|
1205
|
+
function managedSql(parts) {
|
|
1206
|
+
const managed = parts.sources.filter((source) => source.sql.managed !== void 0);
|
|
1207
|
+
if (managed.length === 0) return "";
|
|
1208
|
+
const guard = `${quoteIdent(parts.schema)}.permdock_protect_managed`;
|
|
1209
|
+
return `-- memberships the identity provider owns: clients (anon, authenticated) cannot write them
|
|
1210
|
+
create or replace function ${guard}()
|
|
1211
|
+
returns trigger
|
|
1212
|
+
language plpgsql
|
|
1213
|
+
set search_path = ''
|
|
1214
|
+
as $$
|
|
1215
|
+
declare
|
|
1216
|
+
column_name text := tg_argv[0];
|
|
1217
|
+
begin
|
|
1218
|
+
if current_user in ('anon', 'authenticated') and (
|
|
1219
|
+
column_name = ''
|
|
1220
|
+
or (tg_op <> 'INSERT' and to_jsonb(old) ->> column_name = 'idp')
|
|
1221
|
+
or (tg_op <> 'DELETE' and to_jsonb(new) ->> column_name = 'idp')
|
|
1222
|
+
) then
|
|
1223
|
+
raise exception 'PermDock: % rows managed by the identity provider cannot be changed here', tg_table_name
|
|
1224
|
+
using errcode = '42501';
|
|
1225
|
+
end if;
|
|
1226
|
+
return coalesce(new, old);
|
|
1227
|
+
end;
|
|
1228
|
+
$$;
|
|
1229
|
+
${managed.map((source) => `drop trigger if exists ${quoteIdent(MANAGED_TRIGGER)} on ${table(source.sql.table)};
|
|
1230
|
+
create trigger ${quoteIdent(MANAGED_TRIGGER)}
|
|
1231
|
+
before insert or update or delete on ${table(source.sql.table)}
|
|
1232
|
+
for each row execute function ${guard}(${quoteLiteral(source.sql.managed ?? "")});`).join("\n")}`;
|
|
1233
|
+
}
|
|
1234
|
+
/** The `config.toml` block that enables the hook and bounds token staleness. */
|
|
1235
|
+
function configToml(schema, jwtExpiry) {
|
|
1236
|
+
return `[auth]
|
|
1237
|
+
jwt_expiry = ${String(jwtExpiry)}
|
|
1238
|
+
|
|
1239
|
+
[auth.hook.custom_access_token]
|
|
1240
|
+
enabled = true
|
|
1241
|
+
uri = "${hookUri(schema)}"`;
|
|
1242
|
+
}
|
|
1243
|
+
const BUDGET_MEASURE = "octet_length(memberships::text) + octet_length(attrs::text)";
|
|
1244
|
+
function hookParts(scopes, config, overrides) {
|
|
1245
|
+
const hook = config.supabase?.hook;
|
|
1246
|
+
if (hook === void 0) throw new Error("PermDock CLI: supabase hook generate needs supabase.hook in permdock.config.ts");
|
|
1247
|
+
const root = rootScope(scopes) ?? "tenant";
|
|
1248
|
+
const suspension = checkSuspension(hook.suspension ?? config.rls?.suspension, scopes);
|
|
1249
|
+
const schema = overrides.schema ?? hook.schema ?? config.rls?.schema ?? "permdock";
|
|
1250
|
+
quoteIdent(schema);
|
|
1251
|
+
const tenantClaim = config.rls?.tenantClaim ?? "tenant_id";
|
|
1252
|
+
const extraPlan = extraClaimsPlan(hook.claims, tenantClaim);
|
|
1253
|
+
checkSources({
|
|
1254
|
+
sources: hook.memberships,
|
|
1255
|
+
scopes
|
|
1256
|
+
});
|
|
1257
|
+
const parts = {
|
|
1258
|
+
schema,
|
|
1259
|
+
scopes,
|
|
1260
|
+
root,
|
|
1261
|
+
sources: hook.memberships,
|
|
1262
|
+
budget: positiveInteger(overrides.budget ?? hook.budget ?? 1024, "budget"),
|
|
1263
|
+
version: hook.version !== false,
|
|
1264
|
+
jwtExpiry: positiveInteger(hook.jwtExpiry ?? 900, "jwtExpiry"),
|
|
1265
|
+
tenantClaim,
|
|
1266
|
+
users: suspension?.users,
|
|
1267
|
+
hook,
|
|
1268
|
+
roles: hook.roles === false ? void 0 : globalRoleSource(hook.roles ?? config.rls?.roles ?? { table: `${schema}.user_roles` }, "public", "r"),
|
|
1269
|
+
active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
|
|
1270
|
+
attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
|
|
1271
|
+
extra: extraPlan.claims,
|
|
1272
|
+
before: beforePlan(hook.before),
|
|
1273
|
+
helpers: {
|
|
1274
|
+
schema: config.rls?.schema ?? "permdock",
|
|
1275
|
+
...memberForPlan({
|
|
1276
|
+
scopes,
|
|
1277
|
+
...config.rls?.memberships === void 0 ? {} : { memberships: config.rls.memberships },
|
|
1278
|
+
memberSources: config.rls?.membershipSources ?? hook.memberships
|
|
1279
|
+
})
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
if (parts.attrs !== void 0 && parts.attrs.errors.length > 0) throw new Error(`PermDock CLI: ${parts.attrs.errors.join("; ")}`);
|
|
1283
|
+
if (extraPlan.errors.length > 0) throw new Error(`PermDock CLI: ${extraPlan.errors.join("; ")}`);
|
|
1284
|
+
quoteIdent(parts.tenantClaim);
|
|
1285
|
+
return parts;
|
|
1286
|
+
}
|
|
1287
|
+
function permdockClaim(name, budget = false) {
|
|
1288
|
+
return {
|
|
1289
|
+
name,
|
|
1290
|
+
source: "permdock",
|
|
1291
|
+
budget
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function hookClaims(parts) {
|
|
1295
|
+
return [
|
|
1296
|
+
permdockClaim("user_role"),
|
|
1297
|
+
permdockClaim("roles"),
|
|
1298
|
+
permdockClaim("memberships", true),
|
|
1299
|
+
permdockClaim("memberships_truncated"),
|
|
1300
|
+
permdockClaim(parts.tenantClaim),
|
|
1301
|
+
...parts.attrs === void 0 ? [] : [permdockClaim("attrs", true)],
|
|
1302
|
+
...parts.version ? [permdockClaim("authz_ver")] : [],
|
|
1303
|
+
...parts.extra.map((entry) => ({
|
|
1304
|
+
name: entry.claim,
|
|
1305
|
+
source: entry.fn,
|
|
1306
|
+
budget: false
|
|
1307
|
+
}))
|
|
1308
|
+
];
|
|
1309
|
+
}
|
|
1310
|
+
//#endregion
|
|
1311
|
+
//#region src/cli/supabase-hook-manifest.ts
|
|
1312
|
+
function helperList(parts, types) {
|
|
1313
|
+
const client = ["authenticated"];
|
|
1314
|
+
return [helperEntry(HELPERS.has, "p_grant text", "boolean", client), ...parts.scopes.flatMap((scope) => {
|
|
1315
|
+
const returns = `setof ${types.get(scope.name) ?? "uuid"}`;
|
|
1316
|
+
return [
|
|
1317
|
+
helperEntry(permittedIdsHelper(scope.name), "p_grant text", returns, client),
|
|
1318
|
+
helperEntry(memberIdsHelper(scope.name), "", returns, client),
|
|
1319
|
+
...parts.helpers.memberFor.includes(scope.name) ? [helperEntry(memberForHelper(scope.name), "p_user uuid", returns, parts.extra.length === 0 ? [] : ["supabase_auth_admin"])] : []
|
|
1320
|
+
];
|
|
1321
|
+
})];
|
|
1322
|
+
}
|
|
1323
|
+
function helperEntry(name, args, returns, execute) {
|
|
1324
|
+
return {
|
|
1325
|
+
name,
|
|
1326
|
+
args,
|
|
1327
|
+
returns,
|
|
1328
|
+
execute
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* The helpers `rls generate` writes beyond the ones the hook's claims feed:
|
|
1333
|
+
* the `_for` forms in `database` mode, and the assignment checks when a role
|
|
1334
|
+
* declares `assigns` (known only with the policy).
|
|
1335
|
+
*/
|
|
1336
|
+
function trustedHelpers(parts, config, types, tenantType, policy) {
|
|
1337
|
+
const database = resolveAuthorize(config) === "database";
|
|
1338
|
+
const custom = database && config.rls?.customRoles === true;
|
|
1339
|
+
const everyFor = parts.scopes.every((scope) => parts.helpers.memberFor.includes(scope.name));
|
|
1340
|
+
const client = ["authenticated"];
|
|
1341
|
+
const scopeTypes = parts.scopes.map((scope) => [scope.name, types.get(scope.name) ?? "uuid"]);
|
|
1342
|
+
const forUser = database ? [
|
|
1343
|
+
helperEntry(HELPERS.hasFor, "p_user uuid, p_grant text", "boolean", []),
|
|
1344
|
+
...scopeTypes.map(([scope, type]) => helperEntry(permittedForHelper(scope), "p_user uuid, p_grant text", `setof ${type}`, [])),
|
|
1345
|
+
...scopeTypes.map(([scope, type]) => helperEntry(permissionKeysForHelper(scope), `p_user uuid, p_id ${type}`, "setof text", []))
|
|
1346
|
+
] : [];
|
|
1347
|
+
const keyed = [
|
|
1348
|
+
helperEntry(USER_ID_HELPER, "", "uuid", client),
|
|
1349
|
+
...config.rls?.apiKeys === void 0 ? [] : [helperEntry(API_KEY_ALLOWS, "p_grant text", "boolean", client)],
|
|
1350
|
+
...forUser
|
|
1351
|
+
];
|
|
1352
|
+
if (!(policy !== void 0 && (ownershipRules(policy, parts.scopes)?.assigns.length ?? 0) > 0)) return keyed;
|
|
1353
|
+
const customArgs = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
|
|
1354
|
+
const anyArgs = `p_role text, p_tenant ${tenantType}, p_scope text, p_scope_id text`;
|
|
1355
|
+
return [
|
|
1356
|
+
...keyed,
|
|
1357
|
+
helperEntry(OWNERSHIP.canAssign, "p_role text, p_scope_id text", "boolean", client),
|
|
1358
|
+
...database ? [helperEntry(OWNERSHIP.canAssignFor, "p_user uuid, p_role text, p_scope_id text", "boolean", [])] : [],
|
|
1359
|
+
...custom ? [helperEntry(ASSIGNMENTS.custom, customArgs, "boolean", client)] : [],
|
|
1360
|
+
...custom && everyFor ? [helperEntry(ASSIGNMENTS.customFor, `p_user uuid, ${customArgs}`, "boolean", [])] : [],
|
|
1361
|
+
helperEntry(OWNERSHIP.canAssignAny, anyArgs, "boolean", client),
|
|
1362
|
+
...database && (!custom || everyFor) ? [helperEntry(OWNERSHIP.canAssignAnyFor, `p_user uuid, ${anyArgs}`, "boolean", [])] : []
|
|
1363
|
+
];
|
|
1364
|
+
}
|
|
1365
|
+
function activeRowManifest(row) {
|
|
1366
|
+
const keep = "keep" in row ? keptKeys(row) : [];
|
|
1367
|
+
return {
|
|
1368
|
+
table: qualifiedTable(row.table),
|
|
1369
|
+
id: row.id,
|
|
1370
|
+
...row.disabledAt === void 0 ? {} : { disabledAt: row.disabledAt },
|
|
1371
|
+
...row.status === void 0 ? {} : { status: row.status },
|
|
1372
|
+
...row.active === void 0 ? {} : { active: row.active },
|
|
1373
|
+
...keep.length === 0 ? {} : { keep }
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
/** The `rls` settings a package writing SQL next to the helpers reads: custom roles, global roles, suspension and assignment triggers. */
|
|
1377
|
+
function rlsSettings(parts, config) {
|
|
1378
|
+
const rls = config.rls;
|
|
1379
|
+
const suspension = checkSuspension(rls?.suspension, parts.scopes);
|
|
1380
|
+
const scopes = Object.entries(suspension?.scopes ?? {});
|
|
1381
|
+
const assigned = rls?.assignments === void 0 ? void 0 : guardedTables(config, parts.scopes);
|
|
1382
|
+
return {
|
|
1383
|
+
customRoles: resolveAuthorize(config) === "database" && rls?.customRoles === true,
|
|
1384
|
+
...parts.roles === void 0 ? {} : { roles: {
|
|
1385
|
+
table: qualifiedTable(parts.roles.table),
|
|
1386
|
+
user: { column: parts.roles.user },
|
|
1387
|
+
role: parts.roles.role
|
|
1388
|
+
} },
|
|
1389
|
+
...suspension === void 0 ? {} : { suspension: {
|
|
1390
|
+
...suspension.users === void 0 ? {} : { users: activeRowManifest(suspension.users) },
|
|
1391
|
+
...scopes.length === 0 ? {} : { scopes: Object.fromEntries(scopes.map(([name, row]) => [name, activeRowManifest(row)])) },
|
|
1392
|
+
...suspension.memberships === void 0 ? {} : { memberships: { keep: keptKeys(suspension.memberships) } }
|
|
1393
|
+
} },
|
|
1394
|
+
...assigned === void 0 ? {} : { assignments: { tables: assigned } },
|
|
1395
|
+
...rls?.apiKeys === void 0 ? {} : { apiKeys: apiKeyFields(rls.apiKeys) }
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
/** The `supabase/sdk` release `requires.capabilities` names features of. */
|
|
1399
|
+
const CAPABILITY_MATRIX = "capability-matrix-v1.12.0";
|
|
1400
|
+
function usesLiveSession(policy) {
|
|
1401
|
+
return [...policy?.grants ?? [], ...policy?.roles.flatMap((binding) => binding.grants) ?? []].some((grant) => hasConditionOp(grant.where, "liveSession") || hasConditionOp(grant.check, "liveSession"));
|
|
1402
|
+
}
|
|
1403
|
+
function requiredCapabilities(config, policy) {
|
|
1404
|
+
const ids = /* @__PURE__ */ new Set(["auth.session.get_claims"]);
|
|
1405
|
+
if (usesLiveSession(policy)) ids.add("auth.session.get_user");
|
|
1406
|
+
if (config.rls?.realtime !== void 0) ids.add("realtime.subscriptions.private_channel");
|
|
1407
|
+
for (const bucket of Object.values(config.rls?.storage?.buckets ?? {})) {
|
|
1408
|
+
if (bucket.read !== void 0) {
|
|
1409
|
+
ids.add("storage.file_buckets.download");
|
|
1410
|
+
ids.add("storage.file_buckets.list_files");
|
|
1411
|
+
}
|
|
1412
|
+
if (bucket.write !== void 0) {
|
|
1413
|
+
ids.add("storage.file_buckets.upload");
|
|
1414
|
+
ids.add("storage.file_buckets.update_file");
|
|
1415
|
+
ids.add("storage.file_buckets.move");
|
|
1416
|
+
}
|
|
1417
|
+
if (bucket.delete !== void 0) ids.add("storage.file_buckets.remove");
|
|
1418
|
+
}
|
|
1419
|
+
return {
|
|
1420
|
+
matrix: CAPABILITY_MATRIX,
|
|
1421
|
+
capabilities: [...ids].toSorted()
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
function manifestOf(parts, out, config, policy) {
|
|
1425
|
+
const rls = config.rls;
|
|
1426
|
+
const ctx = {
|
|
1427
|
+
dialect: "supabase",
|
|
1428
|
+
scopes: parts.scopes,
|
|
1429
|
+
tenantClaim: parts.tenantClaim,
|
|
1430
|
+
gucPrefix: rls?.gucPrefix ?? "app",
|
|
1431
|
+
tenantType: rls?.tenantType ?? "uuid",
|
|
1432
|
+
...rls?.teamType === void 0 ? {} : { teamType: rls.teamType },
|
|
1433
|
+
...rls?.scopeTypes === void 0 ? {} : { scopeTypes: rls.scopeTypes }
|
|
1434
|
+
};
|
|
1435
|
+
const types = new Map(parts.scopes.map((scope) => [scope.name, scopeTypeOf(ctx, scope.name)]));
|
|
1436
|
+
const helpers = helperList(parts, types);
|
|
1437
|
+
return {
|
|
1438
|
+
$schema: SUPABASE_MANIFEST_SCHEMA,
|
|
1439
|
+
version: 1,
|
|
1440
|
+
hook: {
|
|
1441
|
+
schema: parts.schema,
|
|
1442
|
+
function: "custom_access_token_hook",
|
|
1443
|
+
out,
|
|
1444
|
+
...parts.before.length === 0 ? {} : { before: parts.before }
|
|
1445
|
+
},
|
|
1446
|
+
helpers: {
|
|
1447
|
+
schema: parts.helpers.schema,
|
|
1448
|
+
functions: helpers.map((entry) => entry.name)
|
|
1449
|
+
},
|
|
1450
|
+
tenantClaim: parts.tenantClaim,
|
|
1451
|
+
budget: {
|
|
1452
|
+
bytes: parts.budget,
|
|
1453
|
+
measure: BUDGET_MEASURE
|
|
1454
|
+
},
|
|
1455
|
+
claims: hookClaims(parts),
|
|
1456
|
+
authzVersion: parts.version,
|
|
1457
|
+
...parts.version ? { authzVersionBump: {
|
|
1458
|
+
schema: parts.schema,
|
|
1459
|
+
function: AUTHZ_VERSION_BUMP,
|
|
1460
|
+
args: "p_users uuid[]"
|
|
1461
|
+
} } : {},
|
|
1462
|
+
memberships: parts.sources.map((source) => source.sql.manifest),
|
|
1463
|
+
rls: {
|
|
1464
|
+
schema: parts.helpers.schema,
|
|
1465
|
+
mode: resolveAuthorize(config),
|
|
1466
|
+
tenantClaim: parts.tenantClaim,
|
|
1467
|
+
scopes: parts.scopes.map((scope) => ({
|
|
1468
|
+
name: scope.name,
|
|
1469
|
+
type: types.get(scope.name) ?? "uuid",
|
|
1470
|
+
...scope.within === void 0 ? {} : { within: scope.within }
|
|
1471
|
+
})),
|
|
1472
|
+
helpers: [...helpers, ...trustedHelpers(parts, config, types, tenantTypeOf(ctx), policy)],
|
|
1473
|
+
memberships: parts.helpers.memberships,
|
|
1474
|
+
...rlsSettings(parts, config)
|
|
1475
|
+
},
|
|
1476
|
+
decidingColumns: decidingColumns(config, parts.attrs === void 0 ? void 0 : {
|
|
1477
|
+
...parts.attrs.table === void 0 ? {} : { table: parts.attrs.table },
|
|
1478
|
+
columns: parts.attrs.columns
|
|
1479
|
+
}),
|
|
1480
|
+
markers: {
|
|
1481
|
+
hook: "v1",
|
|
1482
|
+
grants: "v1"
|
|
1483
|
+
},
|
|
1484
|
+
requires: requiredCapabilities(config, policy)
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
/** The first line of the generated hook: fields `--check` compares before the full text. */
|
|
1488
|
+
function hookMarker(manifest) {
|
|
1489
|
+
return `${HOOK_MARKER} schema=${manifest.hook.schema} tenant=${manifest.tenantClaim} budget=${String(manifest.budget.bytes)} claims=${manifest.claims.map((claim) => claim.name).join(",")}`;
|
|
1490
|
+
}
|
|
1491
|
+
function defaultOut(config) {
|
|
1492
|
+
return config.supabase?.hook?.out ?? "supabase/permdock-hook.sql";
|
|
1493
|
+
}
|
|
1494
|
+
//#endregion
|
|
1495
|
+
//#region src/cli/supabase-setup.ts
|
|
1496
|
+
const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
|
|
1497
|
+
function escape(text) {
|
|
1498
|
+
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
1499
|
+
}
|
|
1500
|
+
/** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
|
|
1501
|
+
function helpersDefined(sql, schema) {
|
|
1502
|
+
const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
|
|
1503
|
+
const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
|
|
1504
|
+
const names = /* @__PURE__ */ new Set();
|
|
1505
|
+
for (const match of sql.matchAll(create)) {
|
|
1506
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
1507
|
+
if (HELPER_NAME.test(name)) names.add(name);
|
|
1508
|
+
}
|
|
1509
|
+
return names;
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`;
|
|
1513
|
+
* its `helpers` part when it has `{part}`), the SQL files next to the hook,
|
|
1514
|
+
* and the migration folders, not the hook itself.
|
|
1515
|
+
*/
|
|
1516
|
+
function helperSources(cwd, config, hookOut) {
|
|
1517
|
+
const hook = resolve(cwd, hookOut);
|
|
1518
|
+
const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
|
|
1519
|
+
const rlsOut = resolve(cwd, partPath(config.rls?.out ?? "rls.sql", "helpers"));
|
|
1520
|
+
if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
|
|
1521
|
+
const beside = dirname(hook);
|
|
1522
|
+
if (existsSync(beside)) {
|
|
1523
|
+
for (const entry of readdirSync(beside, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".sql")) files.add(join(beside, entry.name));
|
|
1524
|
+
}
|
|
1525
|
+
files.delete(hook);
|
|
1526
|
+
return [...files];
|
|
1527
|
+
}
|
|
1528
|
+
function missingHelpersInFiles(cwd, config, manifest) {
|
|
1529
|
+
const found = /* @__PURE__ */ new Set();
|
|
1530
|
+
for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
|
|
1531
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
1532
|
+
}
|
|
1533
|
+
async function missingHelpersInDb(db, manifest, connect = (url) => connectPg(url, "permdock supabase hook generate --db")) {
|
|
1534
|
+
const client = await connect(db);
|
|
1535
|
+
try {
|
|
1536
|
+
const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
1537
|
+
where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
|
|
1538
|
+
const found = new Set(result.rows.map((row) => row["proname"]));
|
|
1539
|
+
return manifest.helpers.functions.filter((name) => !found.has(name));
|
|
1540
|
+
} finally {
|
|
1541
|
+
await client.end();
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
function missingHelpersMessage(manifest, missing) {
|
|
1545
|
+
return `PD039 schema ${manifest.helpers.schema} has no ${missing.join(", ")}: the hook's claims are read by these helpers; run permdock rls generate and apply its migration`;
|
|
1546
|
+
}
|
|
1547
|
+
/** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
|
|
1548
|
+
function oversizedClaims(samples, manifest) {
|
|
1549
|
+
const encoder = new TextEncoder();
|
|
1550
|
+
const oversized = [];
|
|
1551
|
+
for (const claim of manifest.claims) {
|
|
1552
|
+
if (claim.source === "permdock") continue;
|
|
1553
|
+
let bytes = 0;
|
|
1554
|
+
for (const sample of samples) {
|
|
1555
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
1556
|
+
const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
|
|
1557
|
+
if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
|
|
1558
|
+
}
|
|
1559
|
+
if (bytes > manifest.budget.bytes) oversized.push({
|
|
1560
|
+
name: claim.name,
|
|
1561
|
+
bytes
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
return oversized;
|
|
1565
|
+
}
|
|
1566
|
+
/** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
|
|
1567
|
+
function droppedMemberships(samples) {
|
|
1568
|
+
const out = [];
|
|
1569
|
+
for (const [index, sample] of samples.entries()) {
|
|
1570
|
+
if (sample === null || typeof sample !== "object") continue;
|
|
1571
|
+
const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
|
|
1572
|
+
const { dropped } = readMemberships(claim);
|
|
1573
|
+
if (dropped.length > 0) out.push({
|
|
1574
|
+
sample: index,
|
|
1575
|
+
entries: dropped
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
return out;
|
|
1579
|
+
}
|
|
1580
|
+
function pd039(input) {
|
|
1581
|
+
const findings = [];
|
|
1582
|
+
const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
|
|
1583
|
+
if (missing.length > 0) findings.push({
|
|
1584
|
+
code: "PD039",
|
|
1585
|
+
severity: "warning",
|
|
1586
|
+
message: missingHelpersMessage(input.manifest, missing).slice(6),
|
|
1587
|
+
fix: "run permdock rls generate, or set rls.out to the file that holds the helpers (with {part} for --split output) or write them next to the hook"
|
|
1588
|
+
});
|
|
1589
|
+
const fixture = input.config.doctor?.claims;
|
|
1590
|
+
if (fixture !== void 0) {
|
|
1591
|
+
const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
|
|
1592
|
+
const samples = Array.isArray(parsed) ? parsed : [];
|
|
1593
|
+
for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
|
|
1594
|
+
code: "PD039",
|
|
1595
|
+
severity: "warning",
|
|
1596
|
+
message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
|
|
1597
|
+
fix: "return less from the claim function, or read the data per request instead of from the token"
|
|
1598
|
+
});
|
|
1599
|
+
for (const { sample, entries } of droppedMemberships(samples)) findings.push({
|
|
1600
|
+
code: "PD039",
|
|
1601
|
+
severity: "warning",
|
|
1602
|
+
message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
|
|
1603
|
+
fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
return findings;
|
|
1607
|
+
}
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region src/cli/supabase-hook.ts
|
|
1610
|
+
const SUPABASE_HELP = `permdock supabase hook generate | inspect
|
|
1611
|
+
|
|
1612
|
+
hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
|
|
1613
|
+
[--active-from app_metadata.active_<scope>|<table>.<column>]
|
|
1614
|
+
[--budget 1024] [--schema public] [--grants-out <file>|-]
|
|
1615
|
+
inspect [--json] [--out [permdock.manifest.json]] [--check]
|
|
1616
|
+
|
|
1617
|
+
Reads supabase.hook from permdock.config.ts: the fromTable / fromJunction sources the app
|
|
1618
|
+
passes as memberships. hook generate emits custom_access_token_hook(jsonb), the grants it
|
|
1619
|
+
needs (the ones db diff drops in --grants-out, for declarative schemas), the permdock_authz_version table
|
|
1620
|
+
and its triggers. Never grants anything to
|
|
1621
|
+
service_role. inspect prints the manifest: helper schema, names and signatures, tenant claim,
|
|
1622
|
+
budget, the claims the hook writes, the membership sources and the columns that decide them.
|
|
1623
|
+
--out writes it as JSON, to permdock.manifest.json without a path; --check exits 1 when that
|
|
1624
|
+
file differs.
|
|
1625
|
+
`;
|
|
1626
|
+
const MANIFEST_FILE = "permdock.manifest.json";
|
|
1627
|
+
/** `supabase.hook.out`, else pg-delta's per-schema path, else `supabase/permdock-hook.sql`. */
|
|
1628
|
+
function hookOut(cwd, config, schema) {
|
|
1629
|
+
const pgDelta = supabaseConfig(cwd).pgDelta;
|
|
1630
|
+
return config.supabase?.hook?.out !== void 0 || pgDelta === void 0 ? defaultOut(config) : pgDeltaPath(pgDelta.schemaDir, "hook", schema ?? config.supabase?.hook?.schema ?? config.rls?.schema ?? "permdock");
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* The manifest `supabase inspect` prints. With `policy`, `rls.helpers` also
|
|
1634
|
+
* lists the assignment checks `rls generate` writes for roles that declare
|
|
1635
|
+
* `assigns`.
|
|
1636
|
+
*/
|
|
1637
|
+
function supabaseHookManifest(scopes, config, overrides = {}, policy) {
|
|
1638
|
+
return manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config, policy);
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* The hook migration. With `grantsOut`, the statements `supabase db diff`
|
|
1642
|
+
* drops are left out of `sql` and returned in `grants` for that file.
|
|
1643
|
+
*/
|
|
1644
|
+
function supabaseHookSql(scopes, config, overrides = {}, grantsOut) {
|
|
1645
|
+
const parts = hookParts(scopes, config, overrides);
|
|
1646
|
+
const manifest = manifestOf(parts, defaultOut(config), config);
|
|
1647
|
+
const toml = configToml(parts.schema, parts.jwtExpiry).split("\n").map((line) => line === "" ? "--" : `-- ${line}`).join("\n");
|
|
1648
|
+
const sql = [
|
|
1649
|
+
`${hookMarker(manifest)}
|
|
1650
|
+
-- custom_access_token_hook(jsonb): memberships go active ${parts.root} first and stop at the budget
|
|
1651
|
+
-- supabase/config.toml:
|
|
1652
|
+
${toml}${grantsOut === void 0 ? "" : `\n-- what supabase db diff drops from this part is in ${grantsOut}`}`,
|
|
1653
|
+
attrsGuardSql(parts.attrs),
|
|
1654
|
+
hookSql(parts),
|
|
1655
|
+
versionSql(parts),
|
|
1656
|
+
subjectForSql(parts, config),
|
|
1657
|
+
authzVersionForSql(parts),
|
|
1658
|
+
membersOfSql(parts),
|
|
1659
|
+
grantsSql(parts),
|
|
1660
|
+
managedSql(parts)
|
|
1661
|
+
].filter((chunk) => chunk !== "").join("\n\n");
|
|
1662
|
+
const { kept, moved } = splitUndiffed(`${sql}\n`);
|
|
1663
|
+
const grants = `${GRANTS_MARKER} schema=${parts.schema}
|
|
1664
|
+
-- the privileges and view options supabase db diff drops from the hook part
|
|
1665
|
+
${moved.join("\n")}
|
|
1666
|
+
`;
|
|
1667
|
+
return {
|
|
1668
|
+
sql: grantsOut === void 0 ? `${sql}\n` : kept,
|
|
1669
|
+
grants,
|
|
1670
|
+
manifest
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
/** What the hook file's header says about where its grants went. */
|
|
1674
|
+
function grantsLabel(grantsOut) {
|
|
1675
|
+
if (grantsOut === void 0) return;
|
|
1676
|
+
return grantsOut === "-" ? "a separate migration" : grantsOut;
|
|
1677
|
+
}
|
|
1678
|
+
function markerDrift(onDisk, expected, outRel) {
|
|
1679
|
+
const found = hookMarkerFields(onDisk);
|
|
1680
|
+
if (found === void 0) return `supabase hook drift: ${outRel} has no ${HOOK_MARKER} line`;
|
|
1681
|
+
const want = hookMarkerFields(hookMarker(expected)) ?? {};
|
|
1682
|
+
const changed = Object.keys(want).filter((key) => found[key] !== want[key]).map((key) => `${key} ${found[key] ?? "(none)"} -> ${want[key] ?? ""}`);
|
|
1683
|
+
return changed.length === 0 ? "supabase hook drift" : `supabase hook drift: ${changed.join("; ")}`;
|
|
1684
|
+
}
|
|
1685
|
+
function inspectText(manifest) {
|
|
1686
|
+
return [
|
|
1687
|
+
`hook ${manifest.hook.schema}.${manifest.hook.function} (${manifest.hook.out})`,
|
|
1688
|
+
`helpers ${manifest.helpers.functions.map((fn) => `${manifest.helpers.schema}.${fn}`).join(", ")}`,
|
|
1689
|
+
`tenant claim ${manifest.tenantClaim}`,
|
|
1690
|
+
`budget ${String(manifest.budget.bytes)} bytes of ${manifest.budget.measure}`,
|
|
1691
|
+
`claims ${manifest.claims.map((claim) => `${claim.name}${claim.source === "permdock" ? "" : ` (${claim.source})`}${claim.budget ? " [budget]" : ""}`).join(", ")}`,
|
|
1692
|
+
`authz_ver ${manifest.authzVersion ? "on" : "off"}`
|
|
1693
|
+
].join("\n");
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Writes the manifest to `rel`, or with `check` compares it as JSON, so a
|
|
1697
|
+
* formatter that reflows the file is not drift.
|
|
1698
|
+
*/
|
|
1699
|
+
function manifestFile(cwd, rel, manifest, check) {
|
|
1700
|
+
const path = resolve(cwd, rel);
|
|
1701
|
+
if (!check) {
|
|
1702
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1703
|
+
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1704
|
+
return {
|
|
1705
|
+
code: 0,
|
|
1706
|
+
output: `wrote ${rel}`
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
if (!existsSync(path)) return {
|
|
1710
|
+
code: 1,
|
|
1711
|
+
output: `supabase manifest drift: missing ${rel}`
|
|
1712
|
+
};
|
|
1713
|
+
let onDisk;
|
|
1714
|
+
try {
|
|
1715
|
+
onDisk = JSON.parse(readFileSync(path, "utf8"));
|
|
1716
|
+
} catch {
|
|
1717
|
+
return {
|
|
1718
|
+
code: 1,
|
|
1719
|
+
output: `supabase manifest drift: ${rel} is not JSON`
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
const found = typeof onDisk === "object" && onDisk !== null && !Array.isArray(onDisk) ? Object.fromEntries(Object.entries(onDisk)) : {};
|
|
1723
|
+
const changed = [.../* @__PURE__ */ new Set([...Object.keys(manifest), ...Object.keys(found)])].filter((key) => JSON.stringify(Reflect.get(manifest, key)) !== JSON.stringify(found[key]));
|
|
1724
|
+
return changed.length === 0 ? {
|
|
1725
|
+
code: 0,
|
|
1726
|
+
output: "supabase manifest up to date"
|
|
1727
|
+
} : {
|
|
1728
|
+
code: 1,
|
|
1729
|
+
output: `supabase manifest drift: ${rel} differs in ${changed.join(", ")}; run permdock supabase inspect --out ${rel}`
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
async function loadScopes(cwd, config) {
|
|
1733
|
+
return scopeList((await loadHookPolicy(cwd, config)).scopes);
|
|
1734
|
+
}
|
|
1735
|
+
async function loadHookPolicy(cwd, config) {
|
|
1736
|
+
if (config.policy === void 0) throw new Error("PermDock CLI: supabase hook generate needs policy in permdock.config.ts");
|
|
1737
|
+
return asPolicy(pickNamed(await loadModule(resolve(cwd, config.policy)), ["policy"]));
|
|
1738
|
+
}
|
|
1739
|
+
async function runSupabase(input) {
|
|
1740
|
+
const [area, action] = input.rest;
|
|
1741
|
+
const overrides = Object.fromEntries(Object.entries({
|
|
1742
|
+
activeFrom: input.activeFrom,
|
|
1743
|
+
budget: input.budget,
|
|
1744
|
+
schema: input.schema
|
|
1745
|
+
}).filter(([, value]) => value !== void 0));
|
|
1746
|
+
if (area === "inspect" && action === void 0) {
|
|
1747
|
+
const policy = await loadHookPolicy(input.cwd, input.config);
|
|
1748
|
+
const manifest = supabaseHookManifest(scopeList(policy.scopes), input.config, {
|
|
1749
|
+
...overrides,
|
|
1750
|
+
out: hookOut(input.cwd, input.config, input.schema)
|
|
1751
|
+
}, policy);
|
|
1752
|
+
if (input.out !== void 0 || input.check) return manifestFile(input.cwd, typeof input.out === "string" ? input.out : MANIFEST_FILE, manifest, input.check);
|
|
1753
|
+
return {
|
|
1754
|
+
code: 0,
|
|
1755
|
+
output: input.json === true ? JSON.stringify(manifest, null, 2) : inspectText(manifest)
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
if (area !== "hook" || action !== "generate") return {
|
|
1759
|
+
code: 2,
|
|
1760
|
+
output: SUPABASE_HELP
|
|
1761
|
+
};
|
|
1762
|
+
const { sql, grants, manifest } = supabaseHookSql(await loadScopes(input.cwd, input.config), input.config, overrides, grantsLabel(input.grantsOut));
|
|
1763
|
+
const outRel = typeof input.out === "string" ? input.out : hookOut(input.cwd, input.config, input.schema);
|
|
1764
|
+
const outPath = resolve(input.cwd, outRel);
|
|
1765
|
+
const hook = input.config.supabase?.hook;
|
|
1766
|
+
const grantsFile = input.grantsOut === void 0 ? [] : [{
|
|
1767
|
+
part: "grants",
|
|
1768
|
+
rel: input.grantsOut,
|
|
1769
|
+
text: grants
|
|
1770
|
+
}];
|
|
1771
|
+
const toml = configToml(input.schema ?? hook?.schema ?? input.config.rls?.schema ?? "permdock", hook?.jwtExpiry ?? 900);
|
|
1772
|
+
if (input.check) {
|
|
1773
|
+
if (!existsSync(outPath)) return {
|
|
1774
|
+
code: 1,
|
|
1775
|
+
output: `supabase hook drift: missing ${outRel}`
|
|
1776
|
+
};
|
|
1777
|
+
const onDisk = readFileSync(outPath, "utf8");
|
|
1778
|
+
if (onDisk !== sql) return {
|
|
1779
|
+
code: 1,
|
|
1780
|
+
output: markerDrift(onDisk, manifest, outRel)
|
|
1781
|
+
};
|
|
1782
|
+
const drift = driftOf(input.cwd, grantsFile);
|
|
1783
|
+
return drift.length === 0 ? {
|
|
1784
|
+
code: 0,
|
|
1785
|
+
output: "supabase hook up to date"
|
|
1786
|
+
} : {
|
|
1787
|
+
code: 1,
|
|
1788
|
+
output: `supabase hook drift: ${drift.join("; ")}`
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
1792
|
+
writeFileSync(outPath, sql);
|
|
1793
|
+
const grantsWritten = writeSqlFiles(input.cwd, grantsFile);
|
|
1794
|
+
const placed = {
|
|
1795
|
+
...manifest,
|
|
1796
|
+
hook: {
|
|
1797
|
+
...manifest.hook,
|
|
1798
|
+
out: outRel
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
1801
|
+
const missing = input.db === void 0 ? missingHelpersInFiles(input.cwd, input.config, placed) : await missingHelpersInDb(input.db, placed);
|
|
1802
|
+
return {
|
|
1803
|
+
code: 0,
|
|
1804
|
+
output: [
|
|
1805
|
+
`wrote ${[outRel, ...grantsWritten.wrote].join(", ")}`,
|
|
1806
|
+
...grantsWritten.printed === "" ? [] : [grantsWritten.printed],
|
|
1807
|
+
"add to supabase/config.toml:",
|
|
1808
|
+
toml,
|
|
1809
|
+
...missing.length === 0 ? [] : [missingHelpersMessage(placed, missing)]
|
|
1810
|
+
].join("\n")
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
//#endregion
|
|
1814
|
+
export { seedsMigration as _, supabaseHookManifest as a, attrsPlan as c, resolveAuthorize as d, driftOf as f, pgDeltaPath as g, partPath as h, runSupabase as i, parseRbacAuthorize as l, parseSplit as m, hookOut as n, supabaseHookSql as o, isSeedsDirectory as p, loadScopes as r, pd039 as s, grantsLabel as t, rbacScaffold as u, writeSqlFiles as v };
|