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,132 @@
|
|
|
1
|
+
# Tokens, API keys and share links
|
|
2
|
+
|
|
3
|
+
Owning pages: [JWT adapter](https://permdock.com/docs/adapters/jwt), [authentication](https://permdock.com/docs/concepts/authentication), [API keys](https://permdock.com/docs/concepts/credentials), [link capabilities](https://permdock.com/docs/concepts/capabilities).
|
|
4
|
+
|
|
5
|
+
## Access tokens — `permdock/jwt`
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { subjectFromJwt } from "permdock/jwt";
|
|
9
|
+
|
|
10
|
+
const subject = await subjectFromJwt(token, {
|
|
11
|
+
discovery: "https://issuer.example.com",
|
|
12
|
+
audience: "https://api.example.com",
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- Prefer `discovery: '<issuer>'` over a hand-copied `jwks` URL. Set `audience` to the resource identifier. Write algorithms as `Ed25519`, `ES256` or `PS256`; never `EdDSA`, `none` or `RSA1_5`.
|
|
17
|
+
- Keep the default `accept: 'access-token'`, so an ID token never authorises an API call.
|
|
18
|
+
- `jose` is an optional peer. A failure is the anonymous subject, never a throw; the cause is reported through `on('auth')`. A claimed `act` chain that does not nest is anonymous with cause `invalid-chain`.
|
|
19
|
+
- When the app already called RFC 7662 or RFC 9767 introspection, pass the response JSON to `subjectFromIntrospection`. `active` other than `true` is anonymous, and the HTTP call stays the app's.
|
|
20
|
+
- In CI, `subjectFromCiOidc(jwt, { provider: 'github', audience })` returns a `workload` principal, never a user.
|
|
21
|
+
- A Supabase OAuth server token is an `oauth-client` actor. `subjectFromSupabase` drops the OpenID Connect identity scopes (`openid`, `profile`, `email`, `address`, `phone`, `offline_access`) from its delegation, so a token with only those reaches nothing. Let a client act for users with a policy `delegations` entry whose `to` is `{ kind: 'oauth-client', client: '<name>' }`, and map the verified id to that name with `subjectFromSupabase(claims, { clients: { <name>: env.CLIENT_ID } })` (or a function for dynamically registered clients); a literal `{ kind, id }` also works but ties the policy to one environment, never by reading identity scopes as permissions ([Supabase provider](https://permdock.com/docs/adapters/supabase#oauth-server-tokens)).
|
|
22
|
+
|
|
23
|
+
## API keys and service accounts
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { decideCredential } from "permdock";
|
|
27
|
+
import { generateApiKey, hashApiKey } from "permdock/server";
|
|
28
|
+
|
|
29
|
+
const decision = await decideCredential(
|
|
30
|
+
permdock,
|
|
31
|
+
{
|
|
32
|
+
kind: "service", // or 'user'
|
|
33
|
+
id: "svc_01J8",
|
|
34
|
+
tenant: "o_1",
|
|
35
|
+
roles: ["developer"],
|
|
36
|
+
permissions: [permissions.repo.read],
|
|
37
|
+
expiresAt: Math.floor(Date.now() / 1000) + 30 * 86_400,
|
|
38
|
+
},
|
|
39
|
+
{ settings },
|
|
40
|
+
);
|
|
41
|
+
if (decision.outcome === "granted") {
|
|
42
|
+
const key = generateApiKey(decision.credential.id);
|
|
43
|
+
await db.insert(apiKeys).values({
|
|
44
|
+
id: decision.credential.id,
|
|
45
|
+
hash: await hashApiKey(key),
|
|
46
|
+
credential: decision.credential,
|
|
47
|
+
});
|
|
48
|
+
return key; // shown once
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- Guard the endpoint that creates keys with its own permission (`apiKey.create`).
|
|
53
|
+
- Every key lists at least one permission, with no upper bound. A `user` key is its owner's live rights narrowed to the key, and with `tenant` to the owner's memberships inside that tenant and no global role (never put a tenant id in `ids`, which holds resource ids); a `service` key is a `kind: 'service'` principal whose tenant, roles and permissions must be within the creator's `assignableRoles` / `assignablePermissions` (otherwise `exceeds-creator`). A key cannot mint keys.
|
|
54
|
+
- Tenant rules come from a `SettingsSource` (`memorySettings({ o_1: { credentials: { maxTtl, kinds, approval, allowNoExpiry } } })`). A key without `expiresAt` needs `allowNoExpiry`. `approval: true` makes creation `approval-required`; after approval, call `decideCredential` again with `approved: token`.
|
|
55
|
+
- Store only `hashApiKey(key)`. The key is `pdk_<id>_<secret><checksum>`; `parseApiKey` rejects a bad checksum before any lookup.
|
|
56
|
+
|
|
57
|
+
Resolve keys on each request:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { apiKeyVerifier, subjectFromApiKey } from "permdock/server";
|
|
61
|
+
|
|
62
|
+
const resolveKey = subjectFromApiKey({
|
|
63
|
+
verifier: apiKeyVerifier({ find: (id) => findKeyRow(id) }), // returns { credential, hash }
|
|
64
|
+
permissions,
|
|
65
|
+
owner: (id) => loadOwner(id), // the live owner of a user key
|
|
66
|
+
revoked: (id) => isRevoked(id),
|
|
67
|
+
settings,
|
|
68
|
+
sink,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const subject = await resolveKey(bearer, { tenant });
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Never pass a `memberships` source to `createPermDock` for a service-key subject: its one membership comes from the credential. `memoryCredentials()` is an in-process store for tests.
|
|
75
|
+
|
|
76
|
+
When the backend then queries Postgres for the key, put the key's permission keys (and, for a service key, its tenant and roles) in an `api_key` claim and set `rls.apiKeys` in `permdock.config.ts`: the generated helpers cap every allow at the key's permissions and hold a key that names a tenant to that tenant, so RLS agrees with `can()`. The key itself never goes to the database.
|
|
77
|
+
|
|
78
|
+
When the keys live in better-supabase's `api_keys` table, use `apiKeyVerifier({ keys, manifest, allPermissions })` from `permdock/better-supabase` instead, and create the keys with `createApiKeys({ transport, prefix: "pdk" })`: PermDock parses only its own prefix. A third-party token that better-supabase's `CredentialProvider` hands out goes through `credentialGuard(provider, { permdock, use, revoke? })` from the same entry, so PermDock decides each use.
|
|
79
|
+
|
|
80
|
+
## Share links
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { signCapability } from "permdock";
|
|
84
|
+
import { subjectFromCapability } from "permdock/jwt";
|
|
85
|
+
|
|
86
|
+
// Declare the link's roles on the resource
|
|
87
|
+
role("guest", [allow(permissions.quote.read, { where: { status: "sent" } })], {
|
|
88
|
+
on: permissions.quote,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Mint, behind a guard of its own (quote.share)
|
|
92
|
+
const token = await signCapability(
|
|
93
|
+
{
|
|
94
|
+
id: linkId,
|
|
95
|
+
on: { resource: permissions.quote, id: quote.id },
|
|
96
|
+
roles: ["guest"],
|
|
97
|
+
expiresAt,
|
|
98
|
+
},
|
|
99
|
+
signer,
|
|
100
|
+
{ audience },
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Resolve
|
|
104
|
+
const subject = await subjectFromCapability(token, {
|
|
105
|
+
jwks,
|
|
106
|
+
issuer,
|
|
107
|
+
audience,
|
|
108
|
+
revoked,
|
|
109
|
+
replay,
|
|
110
|
+
viewer,
|
|
111
|
+
linkPolicy,
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- The result is a `kind: 'link'` principal with one `{ on, roles, via: 'link' }` membership. It never holds a scope or global role.
|
|
116
|
+
- `linkPolicy` returns the tenant's `LinkPolicy` (`maxLifetime`, `redeemers`, `once`). Passing the same policy to `signCapability` refuses to mint a link the resolver would refuse.
|
|
117
|
+
- Never an unguessable id without expiry, and never a security-definer RPC for the guest page.
|
|
118
|
+
|
|
119
|
+
With Supabase, let RLS serve the link through an exchange, never the link token itself:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { exchangeCapability } from "permdock/supabase";
|
|
123
|
+
|
|
124
|
+
const accessToken = await exchangeCapability(subject, {
|
|
125
|
+
key: signingJwk,
|
|
126
|
+
alg: "ES256",
|
|
127
|
+
kid: "permdock-links",
|
|
128
|
+
ttl: 300,
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`accessToken` is `role: 'anon'` with a `capability` claim and no `sub`, or `undefined` for anything but a live link. Generate the matching policies with `permdock rls generate --capabilities`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: permdock-data
|
|
3
|
+
description: Enforces PermDock permissions in database queries and Postgres row-level security. Use when listing rows with permdock.where() and filter(), compiling conditions with toWhere from permdock/drizzle, permdock/prisma or permdock/kysely, guarding Convex functions with permdock/convex, generating or verifying RLS with permdock rls generate, verify or migrate, modelling folders, sub-teams or reporting lines with parent and relation() through a RelationSource, loadRelations or whoCan, or proving query and database parity with ormParity and rlsParity. Also for doctor PD027, PD028, PD031 and PD032.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: ScaleDockHQ
|
|
7
|
+
homepage: https://permdock.com/docs/adapters/rls
|
|
8
|
+
repository: https://github.com/ScaleDockHQ/permdock
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# PermDock data
|
|
12
|
+
|
|
13
|
+
The same portable conditions decide `can()` in memory, narrow list queries through `where()`, and compile to Postgres RLS. Keep them portable and prove the three agree ([conditions](https://permdock.com/docs/concepts/conditions), [RLS](https://permdock.com/docs/adapters/rls)). Set up the policy and factory first with the `permdock-wire` skill (`npx skills add ScaleDockHQ/PermDock --skill permdock-wire`); named scopes and memberships come from the `permdock-tenancy` skill.
|
|
14
|
+
|
|
15
|
+
## Inputs (find out, or ask before starting)
|
|
16
|
+
|
|
17
|
+
- The ORM or query layer: Drizzle, Prisma, Kysely, Convex, or raw SQL.
|
|
18
|
+
- Whether Postgres RLS enforces access as well (clients reading tables directly, a data API), and whether the app's SQL or PermDock's policy is the authority.
|
|
19
|
+
- Whether existing policies call hand-written helpers.
|
|
20
|
+
- Object hierarchies: nested folders, sub-teams, reporting lines, account delegates, rows that must not inherit.
|
|
21
|
+
- A test database for parity runs (a container or a branch database).
|
|
22
|
+
|
|
23
|
+
## Invariants
|
|
24
|
+
|
|
25
|
+
1. Portable first. Write `where` conditions with the portable operators; a closure is for checks they cannot express, and it is filtered in memory, never in SQL or RLS.
|
|
26
|
+
2. A list query uses `where()` before the query and `filter()` after it. One row is loaded and checked with `assert` or the adapter's `protect`.
|
|
27
|
+
3. Generated RLS never emits `service_role`, and a request runs as `authenticated` or `anon`.
|
|
28
|
+
4. RLS cannot see request `context`. A condition the database must enforce compares a server-set `principal.claims.*` value, never `user_metadata`.
|
|
29
|
+
5. Graph grants walk at most 32 hops (16 by default) and deny with `relation-unavailable` when the relation source is missing or unloaded.
|
|
30
|
+
|
|
31
|
+
## Workflow
|
|
32
|
+
|
|
33
|
+
1. **Narrow list queries.** Compile `permdock.where(permission)` with the ORM's `toWhere`, then run `filter` and `pick` on the rows. -> [references/queries-and-rls.md](references/queries-and-rls.md)
|
|
34
|
+
✓ A user with no grant gets an empty list, and the query compiles without `non-portable-condition`.
|
|
35
|
+
2. **Model hierarchies, when the product has them.** Give the resource a `parent`, declare the relations principals hold, add `restricted` where rows must not inherit, and grant with `relation(resource, name, { through: 'parent' })`. Pass a `RelationSource` as `relations` and call `loadRelations` before `can` / `filter` when it is async. Use `whoCan` for share dialogs. -> [references/relationships.md](references/relationships.md)
|
|
36
|
+
✓ `permdock doctor --only graph` is clean, and a viewer of a parent folder can read a child document.
|
|
37
|
+
3. **Generate or adopt RLS.** Run `permdock rls generate` from the policy, or keep SQL as the authority with `sqlFunction` twins and `rls.functions`, or move hand-written helpers with `permdock rls migrate`. -> [references/queries-and-rls.md](references/queries-and-rls.md#generate-and-verify-rls)
|
|
38
|
+
✓ `permdock rls generate --check` passes in CI.
|
|
39
|
+
4. **Verify against a real database.** Run `permdock rls verify --db $DATABASE_URL` with fixtures, `--tree` for graph grants, and `--introspect` against a deployed database.
|
|
40
|
+
✓ Every verify run exits `0`.
|
|
41
|
+
5. **Prove parity in tests.** Add `ormParity(policy, scenarios, { run, id })` for each list endpoint and `rlsParity(policy, options)` for RLS tables, from `permdock/testing` ([testing adapter](https://permdock.com/docs/adapters/testing)). Run `testRelationSource` on a custom `RelationSource`.
|
|
42
|
+
✓ The query and the database return exactly the rows `filter()` keeps.
|
|
43
|
+
|
|
44
|
+
## Verify before done
|
|
45
|
+
|
|
46
|
+
- [ ] No closure only compares fields a portable operator covers (`eq`, `in`, `memberOf`, `sqlFunction`, …); rewrite those as JSON conditions.
|
|
47
|
+
- [ ] An owner-equals-principal `where` on a resource with a matching relation uses `relation()` instead.
|
|
48
|
+
- [ ] No `opaque` RLS grant wraps a named helper; use `sqlFunction` with a twin and map it in `rls.functions`.
|
|
49
|
+
- [ ] No condition the database must enforce reads `context.*` (PD027); on Supabase, `supabase.hook.attrs` lists only server-owned values (PD028).
|
|
50
|
+
- [ ] Graph declarations are walked by a grant (PD031) and nameable in SQL (PD032).
|
|
51
|
+
- [ ] Every list endpoint has an `ormParity` run, every RLS table an `rlsParity` or `rls verify --db` run, and a custom `RelationSource` passes `testRelationSource`.
|
|
52
|
+
|
|
53
|
+
## Reference index
|
|
54
|
+
|
|
55
|
+
- [references/queries-and-rls.md](references/queries-and-rls.md): `toWhere` per ORM, `checkRow`, `withSubject`, Convex, `rls generate` / `import` / `verify` / `migrate` flags and config, field views, SQL as the authority.
|
|
56
|
+
- [references/relationships.md](references/relationships.md): `parent`, relation kinds, `restricted`, `links`, `relation(..., { through })`, `RelationSource`, `loadRelations`, `whoCan`, the closure table and `--tree`.
|
|
57
|
+
- Docs: [conditions](https://permdock.com/docs/concepts/conditions), [relationships](https://permdock.com/docs/concepts/relationships), [RLS adapter](https://permdock.com/docs/adapters/rls), [`permdock rls`](https://permdock.com/docs/cli/rls), [Postgres RLS](https://permdock.com/docs/standards/postgres-rls).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Queries and generated RLS
|
|
2
|
+
|
|
3
|
+
Owning pages: [protected queries](https://permdock.com/docs/concepts/policies#protected-queries), [RLS adapter](https://permdock.com/docs/adapters/rls), [`permdock rls`](https://permdock.com/docs/cli/rls), and the ORM pages [Drizzle](https://permdock.com/docs/adapters/drizzle), [Prisma](https://permdock.com/docs/adapters/prisma), [Kysely](https://permdock.com/docs/adapters/kysely), [Convex](https://permdock.com/docs/adapters/convex).
|
|
4
|
+
|
|
5
|
+
## List queries
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { toWhere } from "permdock/drizzle"; // or permdock/prisma, permdock/kysely
|
|
9
|
+
|
|
10
|
+
export async function listPosts(permdock: PermDock) {
|
|
11
|
+
const read = permissions.post.read;
|
|
12
|
+
const rows = await db
|
|
13
|
+
.select()
|
|
14
|
+
.from(posts)
|
|
15
|
+
.where(toWhere(permdock.where(read), posts));
|
|
16
|
+
return permdock.filter(read, rows).map((row) => permdock.pick(read, row));
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- `permdock.where(permission)` narrows the query before it runs. It leaves out closure grants and marks the result `partial`; `filter` then drops what a closure refuses, and `pick` redacts fields.
|
|
21
|
+
- When nothing is granted, `toWhere` returns a constant false expression (no rows). A closure grant cannot compile: `toWhere` throws `PermDockValidationError`.
|
|
22
|
+
- `toWhere(condition, table, { columns })` maps schema fields to differently named columns. Graph grants need `{ relations: { closure: 'permdock.permdock_closure' } }`, otherwise a `related` node throws `non-portable-condition`.
|
|
23
|
+
- `checkRow` (Drizzle) answers one row as `{ found: false }` or `{ found: true, granted }`, so a handler can tell `404` from `403` with one query.
|
|
24
|
+
- Prisma also exports `permdockExtension`. Drizzle, Prisma and Kysely export `withSubject`, which runs a transaction as the subject (`set local role` plus claims) so RLS sees it; its `dialect` must match `rls generate`.
|
|
25
|
+
- Convex: `createPermDock` from `permdock/convex` returns `withPermDock` and `snapshotQuery`. Identity comes from `ctx` only; function arguments never influence the subject.
|
|
26
|
+
|
|
27
|
+
## Generate and verify RLS
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm exec permdock rls generate --target sql --dialect supabase --out migrations/rls.sql
|
|
31
|
+
pnpm exec permdock rls generate --target drizzle --dialect supabase
|
|
32
|
+
pnpm exec permdock rls import --sql migrations/rls.sql --out src/permissions.generated.ts
|
|
33
|
+
pnpm exec permdock rls verify --fixtures rls.fixtures.json
|
|
34
|
+
pnpm exec permdock rls verify --db $DATABASE_URL --fixtures rls.fixtures.json
|
|
35
|
+
pnpm exec permdock rls verify --introspect --db $DATABASE_URL
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- List the scope instances a permission reaches in process with `permittedIds(permdock, permission, scope, { within })`, never a loop of `can` over memberships with a made-up row.
|
|
39
|
+
- Generated policies call `permdock_has('<key>')` and one `permitted_<scope>_ids('<key>')` per declared scope, which Postgres runs once per statement. Hand-written policies that only need membership (an organization switcher) call `member_<scope>_ids()` instead of inventing a permission key. Trusted SQL that acts for a stored user (a job, a trigger, a deferred approval) calls `permdock_has_for(p_user, '<key>')` and `permitted_<scope>_ids_for(p_user, '<key>')` in `database` mode, or the generated `permitted_<resource>_rows_for`, never a hand-written function that rewrites `request.jwt.claims`. With `rls.rowHelpers`, a hand-written policy or function that needs the rows a permission reaches through the graph calls `permitted_<resource>_rows('<permission key>')` instead of restating closure walks, link hops and `includes`. A table's own `select` or `insert` policy calls the row-valued `permitted_<resource>_row(<table>, '<permission key>')` instead, so `insert ... returning` sees the new row. Hand-written policies and functions that apply no row condition call `permitted_<scope>_ids_by_permission('<permission key>')` or `permdock_has_permission('<permission key>')` instead of spelling a positional `#n` grant key. A screen or RPC that checks many permissions on one organization calls `permitted_<scope>_permission_keys(<id>)` once (or `(<id>, array['<key>', ...])` for a few keys) instead of one helper call per key. A role editor or admin screen lists a role's keys with `permdock_role_permissions(p_role, p_scope, p_tenant, p_scope_id)` (declared roles, and custom roles of the caller's tenant in `database` mode) and the catalog with `permdock_permission_keys()` (`permdock_permission_keys('<scope>')` for the keys a role on that scope can hold), instead of joining `role_permissions` and the custom-role tables by hand; server code reads any tenant's roles with `permdock_trusted_role_permissions`, granted through `rls.trustedReaders`. Set `--tenant-type` (or `rls.tenantType`, `rls.scopeTypes`) to the scope columns' types.
|
|
40
|
+
- `--authorize database` (default) reads the membership tables per statement; map each under `rls.memberships.scopes.<scope>` with `columns` for its id and its ancestors', and `via` so roles with `for` apply: a column name, or `{ value: 'staff' }` when every row has that kind (never add a generated column for it). `--authorize jwt` reads token claims and stays stale until the token refreshes; keep the token lifetime at 3600 seconds or less (PD019).
|
|
41
|
+
- The helpers narrow to the tenant claim (`rls.tenantClaim`) whenever the token carries one; the Supabase hook writes it from `activeFrom`. When the app takes the tenant from the URL and one session spans tenants, set `rls.tenants: 'all'` so the database admits every member tenant, and keep filtering each query by the URL's tenant.
|
|
42
|
+
- `--policy-per-role` keeps one policy per role for review. `--custom-roles` (or `rls.customRoles: true`) resolves tenant-defined roles inside the `permdock_ceiling` view. In `database` mode, save a custom role with `permdock_replace_custom_role_grants(tenant, scope, scope_id, role, allow, deny, include)` and rename or delete it with `permdock_rename_custom_role_grants` and `permdock_delete_custom_role_grants`, never with hand-written `security definer` functions or direct writes; failures raise `22023` or `42501` with the reason as the `hint`. A platform custom role passes a null tenant and scope `global`. Seeds, jobs and backends with no member caller use the `permdock_trusted_*` variants, which keep the definition checks; grant `execute` on them to a backend role in a migration of your own, never to `authenticated`. Set `rls.customRoleWrites.requires` to the permission your role editor asserts (or `'manageRoles'`) so the database refuses members who may not manage roles (`manage-roles`).
|
|
43
|
+
- `--force` (or `rls.force: true`) only when the app connects as the table owner. Create views over RLS tables `with (security_invoker = true)` (PD022).
|
|
44
|
+
- When read grants set `fields` and clients read tables directly, add `--fields views --revoke-columns`: clients read `<table>_visible`, whose restricted columns are null unless a grant covers them. PD030 names columns still readable.
|
|
45
|
+
- `rls.suspension` drops suspended users' roles and memberships of suspended scope instances, live in both modes. `scopes.<scope>.keep: [permissions.org.restore]` lets members of a suspended instance still hold those permissions (restore, cancel deletion, export) in the helpers, `authorize()`, the token hook and in process, where the membership carries `keep`; `doctor` PD061 lists them.
|
|
46
|
+
- When support or impersonation tokens (an `act` claim) reach the Data API, set `rls.readOnlyActors: true`: each table and write command the policies grant gets a restrictive `{table}_{op}_read_only_actors` policy that refuses the write unless `act.read_only` is `false`. Never hand-write that policy per table.
|
|
47
|
+
- When a backend queries Postgres for a verified API key, set `rls.apiKeys` and put the key in the `api_key` claim (`scopes` as permission keys, `tenant` and `roles` for a key that belongs to a tenant): the helpers cap every allow at the scopes and treat a tenant key without `sub` as a service principal of that tenant. A user key whose claim names a `tenant` is held to that tenant even with `rls.tenants: 'all'`, and `permdock_has` is false for it. Never write a `has_scope` check per policy.
|
|
48
|
+
- On Supabase every generated helper and policy reads the subject through `permdock_user_id()`: the `sub` in `request.jwt.claims`, and null for the empty `sub` of a tenant service key. It ignores the legacy `request.jwt.claim.sub` setting; SQL that impersonates a user sets `request.jwt.claims`. A hand-written policy such a token reaches calls `(select permdock.permdock_user_id())`, never `auth.uid()`, which fails the uuid cast.
|
|
49
|
+
- `rls.assignments` puts a `permdock_assignment` trigger on each mapped membership table, the global-roles table `rls.roles` and each table in `tables`; `ownRole: 'refuse'` (or a map of tables) also refuses a client write to the caller's own row. `doctor` PD064 names a guarded table no migration puts the trigger on.
|
|
50
|
+
- SQL that assigns roles calls `permdock_can_assign_any(role, tenant, scope, scope_id)`, which covers declared and custom roles; SQL acting later for a stored user (an invitation accept re-checking the inviter) calls `permdock_can_assign_any_for(user, ...)`. `permdock supabase inspect` lists both in the manifest's `rls.helpers`.
|
|
51
|
+
- Index suggestions (the `indexes` part of `--split`, or `index suggestion` warnings) cover only the columns the policies and helpers look rows up by. Add `--db $DATABASE_URL` to `generate` so it leaves out indexes an existing index already leads with and columns the table lacks; never hand-copy a suggestion that duplicates an existing composite index.
|
|
52
|
+
- Never emit `service_role`. Fixtures may carry `memberships`, `tenant` and `customRoles`.
|
|
53
|
+
|
|
54
|
+
## SQL as the authority
|
|
55
|
+
|
|
56
|
+
When the database already holds the rules, skip `generate`. Map each helper in `rls.functions`, write `sqlFunction` twins in the policy, and fail CI on `rls verify --db`. `--inline-functions` inlines the twin for targets that cannot call a SQL function.
|
|
57
|
+
|
|
58
|
+
When hand-written policies call the app's own helpers (`org_ids_with_permission`, `has_org_permission`), map each under `rls.migrate.helpers` with its `form` (`ids`, `row`, `scoped`, `global`, `membership`) and the legacy keys under `rls.migrate.keys` or `prefixes`. Run `permdock rls migrate`, read the skipped calls, and apply with `--write`. Generate with `--helpers-only` while the policies stay hand-written. A `row-conditions` skip means that table's policy should be generated, not migrated.
|
|
59
|
+
|
|
60
|
+
## Attributes RLS cannot see
|
|
61
|
+
|
|
62
|
+
A grant condition on `context.*` cannot be enforced by the database (PD027). Compare with a server-set `principal.claims.*` claim instead, never one from `user_metadata`. On Supabase, `supabase.hook.attrs` lists only server-owned columns or `app_metadata.<key>` entries clients cannot update (PD028). A check that must refuse a sign-in (single sign-on enforcement, a locked account) goes in `supabase.hook.before` as a schema-qualified `(event jsonb) returns jsonb` function the generated hook calls first; never a second hook or a hand edit of the generated one. The hook, declarative schemas (`--split`) and `fromTable` / `fromJunction` membership sources are on the [Supabase adapter](https://permdock.com/docs/adapters/supabase) and [Supabase hook](https://permdock.com/docs/adapters/supabase-hook) pages.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Relationship graph
|
|
2
|
+
|
|
3
|
+
Owning page: [relationships](https://permdock.com/docs/concepts/relationships). Named scopes are tenancy (fixed levels); the graph is the objects under them, with no fixed depth.
|
|
4
|
+
|
|
5
|
+
## Declare
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
export const permissions = definePermissions({
|
|
9
|
+
doc: resource(Doc, {
|
|
10
|
+
actions: ["read", "update"],
|
|
11
|
+
parent: { field: "folderId", resource: "folder" },
|
|
12
|
+
relations: { owner: "ownerId" },
|
|
13
|
+
}),
|
|
14
|
+
folder: resource(Folder, {
|
|
15
|
+
actions: ["read", "share"],
|
|
16
|
+
parent: { field: "parentId", resource: "folder" },
|
|
17
|
+
relations: {
|
|
18
|
+
editor: { edge: "folder_editors" },
|
|
19
|
+
viewer: { edge: "folder_viewers", expiresAt: "expires_at" },
|
|
20
|
+
},
|
|
21
|
+
restricted: "restricted",
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Relation | Declared as | Held when |
|
|
27
|
+
| ---------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
28
|
+
| Field | `owner: 'ownerId'` | the row's field is the principal's id |
|
|
29
|
+
| Edge table | `{ edge, object?, subject?, expiresAt?, match?, groups? }` | a row of `edge` links the object and the principal, and has not expired |
|
|
30
|
+
| Principal | `{ principal, period? }` | the row's column is the principal's id, inside `period` (a manager, an account delegate) |
|
|
31
|
+
| Implied | `{ includes: ['editor'] }` | the principal holds a listed relation |
|
|
32
|
+
|
|
33
|
+
`groups` lets an edge row name a team or another group: `{ column: 'kind', resources: { team: 'member' } }` when one column names the kind, or `{ resources: { team: { relation: 'member', subject: 'team_id' } } }` when the share table keeps each subject kind in its own typed column.
|
|
34
|
+
|
|
35
|
+
A resource that parents itself makes a chain. `restricted` names a boolean column: a restricted row is reached only by grants on itself, never through its ancestors or its links. `restricted: { field, stops: ['parent'] }` closes only the paths it lists (`'parent'` and link names), for example a folder hidden from shares above it but still readable by everyone with drive access; a closed link also stays closed for the rows below a restricted row, within 32 levels. `links` names to-one references a grant can cross with `through: ['folder', 'team']`.
|
|
36
|
+
|
|
37
|
+
## Grant
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
grants: [
|
|
41
|
+
allow(permissions.doc.read, { to: relation(permissions.doc, 'owner') }),
|
|
42
|
+
allow(permissions.doc.read, {
|
|
43
|
+
to: relation(permissions.folder, 'viewer', { through: 'parent', depth: 16 }),
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Add `requires: permissions.file.read` to a graph grant to count a share only where the user also holds that permission through a role in the row's organization; a list (`requires: [permissions.file.read, permissions.file.download]`) requires each. `to: inherit(permissions.drive.read, { through: ['drive'] })` grants on a row wherever the subject may read the drive its `drive` link points to, by any grant of `drive.read`; the source needs `row`, and `rls generate` writes the target's `permitted_<resource>_rows`. `depth` defaults to 16 and is at most 32. An array in `to:` is an intersection, so "owner or viewer" is two `allow` grants. Prefer `relation()` over `where: { authorId: principal.id }` when the resource declares the relation.
|
|
49
|
+
|
|
50
|
+
## Load
|
|
51
|
+
|
|
52
|
+
Pass `relations: memoryRelations(permissions, { rows, edges })` in tests, or a `RelationSource` (`ancestors`, `related`) over the app's tables, to `createPermDock`. Evaluation stays synchronous; with an async source, call `await permdock.loadRelations(permission, rows)` before `can` or `filter`. Without a source, or with one that threw, graph checks deny with `relation-unavailable`; a chain past `depth` denies with `relation-depth`. Answers are cached per instance, never at module level.
|
|
53
|
+
|
|
54
|
+
## Share dialogs
|
|
55
|
+
|
|
56
|
+
`const { holders, complete } = await permdock.whoCan(permission, row)` lists who holds a permission on one object and how. Show `complete: false` honestly: some holders could not be listed. `whoCan` never grants.
|
|
57
|
+
|
|
58
|
+
## In Postgres
|
|
59
|
+
|
|
60
|
+
`permdock rls generate` emits the `permdock_closure` table, its statement triggers and `permitted_<resource>_ids(relation)` helpers. Snapshots carry no graph: graph grants are `portable: false` on the client and go to the decision endpoint. Check with:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm exec permdock rls verify --tree --db $DATABASE_URL
|
|
64
|
+
pnpm exec permdock doctor --only graph
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`--tree` seeds a generated object graph in a rolled-back transaction and compares it with `can()` per principal. PD031 flags a self-parent no `through: 'parent'` grant walks, or a `restricted` column no graph grant reaches; PD032 flags a graph resource RLS cannot name.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: permdock-tenancy
|
|
3
|
+
description: Models tenants, memberships and role rules in PermDock. Use when declaring named scopes (organizations, workspaces, customers inside an organization), loading memberships through a MembershipSource, putting a role on one scope, keeping an owner per organization with min, max or transferOnly, controlling who hands out which role with assigns and for, checking a membership change with decideRoleChange, letting tenant admins define custom roles with customRoles and validateCustomRole, or fixing doctor PD023, PD025 or PD026 and denials such as tenant-mismatch, last-holder or not-assignable-by.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: ScaleDockHQ
|
|
7
|
+
homepage: https://permdock.com/docs/concepts/scopes
|
|
8
|
+
repository: https://github.com/ScaleDockHQ/permdock
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# PermDock tenancy
|
|
12
|
+
|
|
13
|
+
A policy declares its scopes in order (an organization, the customers inside it). A role is held at one scope, a membership names one instance of that scope, and a grant reaches a row only through that scope's key. Roles never cascade between scopes ([named scopes](https://permdock.com/docs/concepts/scopes), [tenancy](https://permdock.com/docs/concepts/tenancy)). Set up the policy and factory first with the `permdock-wire` skill (`npx skills add ScaleDockHQ/PermDock --skill permdock-wire`).
|
|
14
|
+
|
|
15
|
+
## Inputs (find out, or ask before starting)
|
|
16
|
+
|
|
17
|
+
- The tenant levels, outermost first, and the row column that holds each id (`organization_id`, `customer_id`).
|
|
18
|
+
- Where memberships are stored and how the app loads them: token claims from a `subjectFrom*` provider, or the app's tables.
|
|
19
|
+
- The membership kinds (`via`): staff, contact, guest, partner, support.
|
|
20
|
+
- Which role manages each scope, who may hand out which role, and which roles a contact or guest may never hold.
|
|
21
|
+
- Whether tenant admins define their own roles.
|
|
22
|
+
|
|
23
|
+
## Invariants
|
|
24
|
+
|
|
25
|
+
1. Memberships and custom roles come only from a `subjectFrom*` provider, the policy's `subject` or `context` function, a `MembershipSource` or a `RoleSource`. Never from a request body, an unsigned header, a model argument or a CLI flag.
|
|
26
|
+
2. A requested tenant with no matching membership is no tenant, never a default one. Team and tenant ids are identifiers, never display names.
|
|
27
|
+
3. No cascade. An organization owner sees no customer portal unless they also hold a contact membership, and a global role never reaches scoped rows. An instance check without a row answers from first-scope memberships only; a nested membership needs the row or `permdock.team(id)`. A collection check (`create`, `list`) still answers from any membership in the tenant; for a staff-only guard pass `{ scope: '<first scope>' }` as the decide option.
|
|
28
|
+
4. Role rules live on the role (`min`, `assigns`, `for`), not in handlers. `decideRoleChange` takes the actor from the instance and never writes.
|
|
29
|
+
5. A custom role never exceeds the ceiling of declared `assignable` roles in its scope. Its grants carry no condition, approval or limit of their own; a `level` picks a condition the resource declares in code.
|
|
30
|
+
|
|
31
|
+
## Workflow
|
|
32
|
+
|
|
33
|
+
1. **Declare the scopes.** Add them to `definePolicy` in order, each with its row `key` and, after the first, `within`:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
definePolicy(
|
|
37
|
+
{ permissions, roles },
|
|
38
|
+
{
|
|
39
|
+
scopes: {
|
|
40
|
+
organization: { key: "organization_id" },
|
|
41
|
+
customer: { key: "customer_id", within: "organization" },
|
|
42
|
+
},
|
|
43
|
+
roles: [
|
|
44
|
+
role(roles.admin, adminGrants, { on: "organization" }),
|
|
45
|
+
role(roles.contact, contactGrants, { on: "customer" }),
|
|
46
|
+
],
|
|
47
|
+
subject,
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
✓ `definePolicy` accepts the scopes, and each role names one scope with `on`.
|
|
53
|
+
|
|
54
|
+
2. **Give resources their scope keys.** Every resource a scoped grant touches declares a `memberOf` relation per scope key it carries, for example `relations: { organization: { field: 'organization_id', memberOf: 'organization' } }`.
|
|
55
|
+
✓ `definePolicy` does not throw for a missing `memberOf` relation.
|
|
56
|
+
3. **Load memberships.** Return memberships as `{ scope, id, within, roles, via, expiresAt }` from the subject resolver, or pass a `MembershipSource` (one, or an array) as `memberships` on `createPermDock`. A person's access ending is the membership's `expiresAt`.
|
|
57
|
+
✓ `permdock doctor` reports no PD025 for the `doctor.memberships` fixture.
|
|
58
|
+
|
|
59
|
+
An organisation role that reaches only the principal's teams is an organisation role with `where: { team_id: { in: principal.claims.team_ids } }`, not a team role. On Supabase, write the claim with `supabase.hook.claims` ([team reach](https://permdock.com/docs/concepts/tenancy#organisation-roles-with-team-reach)).
|
|
60
|
+
|
|
61
|
+
4. **Put ownership on the roles.** Set `min: 1` on the role that manages each scope, `assigns` for who hands out what, `for: ['staff']` on admin-like roles, and `meta.audience` for the surface. Every server action that changes a membership calls `permdock.decideRoleChange(...)` with the target's current membership and the holder count before it writes. -> [references/ownership-and-custom-roles.md](references/ownership-and-custom-roles.md)
|
|
62
|
+
✓ PD026 is clean, and removing the last owner is denied with `last-holder`.
|
|
63
|
+
5. **Custom roles, when tenant admins define roles.** Keep declared roles small and mark them in `defineRoles` (`editor: { on: 'organization', assignable: true }`). Pass a `RoleSource` as `customRoles`, build the editor from `useAssignablePermissions()`, and save through a server action that runs `validateCustomRole`. -> [references/ownership-and-custom-roles.md](references/ownership-and-custom-roles.md#custom-roles)
|
|
64
|
+
✓ PD023 is clean, and saving a role with a permission outside the ceiling is refused.
|
|
65
|
+
6. **Test.** Add `describePolicy` rows for a member of another tenant, a contact reaching an organization row, and a role change that breaks `min` ([scenario testing](https://permdock.com/docs/guides/scenario-testing)). Run `testMembershipSource` and `testRoleSource` from `permdock/testing` on custom sources. For list queries and RLS over scoped tables, follow the `permdock-data` skill.
|
|
66
|
+
✓ The tests pass.
|
|
67
|
+
|
|
68
|
+
## Verify before done
|
|
69
|
+
|
|
70
|
+
- [ ] No membership, tenant or role is read from a request body, tool argument, unsigned header or `user_metadata`.
|
|
71
|
+
- [ ] Each scope's managing role sets `min: 1` (PD026), and admin-like roles set `for` so contact, guest or partner memberships cannot hold them.
|
|
72
|
+
- [ ] No `assigns` list lets a role appoint a role ranked above it.
|
|
73
|
+
- [ ] Every server action that writes a membership role calls `decideRoleChange` with a `holders` count from the store, never a guess.
|
|
74
|
+
- [ ] Every custom-role save runs `validateCustomRole` and checks `assignablePermissions()`. No single assignable `admin` role makes the ceiling the whole product.
|
|
75
|
+
- [ ] Custom `MembershipSource` and `RoleSource` implementations pass `testMembershipSource` / `testRoleSource`.
|
|
76
|
+
|
|
77
|
+
## Reference index
|
|
78
|
+
|
|
79
|
+
- [references/ownership-and-custom-roles.md](references/ownership-and-custom-roles.md): role options, `decideRoleChange` input and denial reasons, the SQL objects RLS uses, and the custom-role shape, ceiling and editor.
|
|
80
|
+
- Docs: [named scopes](https://permdock.com/docs/concepts/scopes), [tenancy](https://permdock.com/docs/concepts/tenancy), [ownership](https://permdock.com/docs/concepts/ownership), [custom roles](https://permdock.com/docs/concepts/custom-roles), [extension interfaces](https://permdock.com/docs/concepts/extension-interfaces).
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Ownership and custom roles
|
|
2
|
+
|
|
3
|
+
Owning pages: [ownership](https://permdock.com/docs/concepts/ownership) and [custom roles](https://permdock.com/docs/concepts/custom-roles).
|
|
4
|
+
|
|
5
|
+
## Role options
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const staff = { for: ["staff"], meta: { audience: "staff" } } as const;
|
|
9
|
+
|
|
10
|
+
roles: [
|
|
11
|
+
role(roles.owner, ownerGrants, {
|
|
12
|
+
on: "organization",
|
|
13
|
+
...staff,
|
|
14
|
+
min: 1,
|
|
15
|
+
assigns: ["owner", "admin", "member", "viewer", "contact"],
|
|
16
|
+
}),
|
|
17
|
+
role(roles.admin, adminGrants, {
|
|
18
|
+
on: "organization",
|
|
19
|
+
...staff,
|
|
20
|
+
assigns: ["member", "viewer", "contact"],
|
|
21
|
+
}),
|
|
22
|
+
role(roles.contact, contactGrants, {
|
|
23
|
+
on: "customer",
|
|
24
|
+
for: ["contact"],
|
|
25
|
+
meta: { audience: "portal" },
|
|
26
|
+
}),
|
|
27
|
+
];
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Option | Meaning |
|
|
31
|
+
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| `min` | Fewest holders each scope instance keeps (default `0`). Removing the last owner is refused. |
|
|
33
|
+
| `max` | Most holders an instance may have. |
|
|
34
|
+
| `transferOnly` | Once an instance has holders, the role only moves from one holder to another. |
|
|
35
|
+
| `assigns` | Roles a holder may assign and revoke. Once any role declares `assigns`, this graph replaces the held-grant rule for `assignableRoles()`. |
|
|
36
|
+
| `for` | Membership kinds (`Membership.via`) that may hold the role. A membership with another `via`, or none, grants nothing through it. |
|
|
37
|
+
| `exclusiveWith` | Roles nobody may hold together with this one in the same instance (PD018). |
|
|
38
|
+
| `meta.audience` | The surface the role's holders use; `permdock.audiences()` and `snapshot.audiences` report it and never grant. |
|
|
39
|
+
|
|
40
|
+
`min`, `max` and `transferOnly` need a named-scope `on`.
|
|
41
|
+
|
|
42
|
+
## Checking a role change
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const result = permdock.decideRoleChange({
|
|
46
|
+
kind: "assign", // 'assign' | 'revoke' | 'transfer'
|
|
47
|
+
role: roles.admin,
|
|
48
|
+
scope: "organization",
|
|
49
|
+
id: "o_acme",
|
|
50
|
+
target: { id: "u_bob", via: "staff", roles: ["member"] }, // their membership there now
|
|
51
|
+
holders: await countHolders(memberships, {
|
|
52
|
+
scope: "organization",
|
|
53
|
+
id,
|
|
54
|
+
role: "owner",
|
|
55
|
+
}), // from MembershipSource.list
|
|
56
|
+
});
|
|
57
|
+
// { outcome: 'granted', change, role } or { outcome: 'denied', change, denials }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The actor is the instance's own subject; nothing in the change stands in for it. `decideRoleChange` never writes. Without a valid `holders` count, a role with `min`, `max` or `transferOnly` is denied. Denial reasons: `last-holder`, `max-holders`, `transfer-only`, `not-assignable-by`, `self-demotion`, `not-allowed-for-membership`, `conflicting-role`.
|
|
61
|
+
|
|
62
|
+
For a role on a nested scope that the subject holds nothing at, pass `within` and `{ trusted: true }` only after loading the ancestors from the app's own store.
|
|
63
|
+
|
|
64
|
+
Generated RLS applies the same rules at commit, with triggers on the scope's `rls.memberships` table or on the tables of its `fromTable` / `fromJunction` sources (base tables, not views). Map `rls.memberships.scopes.<scope>.via` so the helpers apply `for`, and use `permdock_can_assign(role, organization_id::text)` in the membership table's own insert policy, or `permdock_can_assign_any(role, organization_id, 'organization', organization_id::text)` when the role may be a custom one. Set `rls.assignments` to have triggers check every client write to the membership tables, the global-roles table `rls.roles` and an invitations table, and `ownRole: 'refuse'` to refuse a client write to the caller's own row; never keep a hand-written trigger for either. The SQL is owned by the `permdock-data` skill.
|
|
65
|
+
|
|
66
|
+
## Custom roles
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
type CustomRole = {
|
|
70
|
+
tenant: string; // absent when scope is 'global'
|
|
71
|
+
scope?: string; // default: the first scope; 'global' for a platform role
|
|
72
|
+
id?: string; // pins it to one instance
|
|
73
|
+
name: string;
|
|
74
|
+
includes?: string[]; // declared roles to start from
|
|
75
|
+
grants?: { permission: string; effect?: "allow" | "deny"; level?: string }[];
|
|
76
|
+
};
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- The ceiling of a scope is the code allows of the declared roles marked `assignable` in that scope. A custom role never exceeds it, and hosted grants never widen it.
|
|
80
|
+
- An own allow inherits the conditions of the ceiling grants for that permission. To keep a narrower condition ("own posts only"), include the declared role that has it.
|
|
81
|
+
- A resource may declare `levels: { own: { ownerId: principal.id }, team: { teamId: { in: principal.teamIds } }, all: {} }`. A grant `{ permission: 'job.read', level: 'team' }` ANDs that condition into the ceiling grant. An undeclared level, or a level on a collection action, is dropped as `unknown-level` and removes the permission; a level on a deny is `condition-not-allowed`. `permdock.assignableLevels(leaf)` lists the levels the subject may hand out.
|
|
82
|
+
- An own deny removes the permission from this custom role only. Deny it in code when it must win across roles.
|
|
83
|
+
- Pass a `RoleSource` (`memoryRoleSource(roles)` in tests), or a `RoleSourceFactory` `(subject) => RoleSource` when the source reads one principal's roles, as `customRoles` on `createPermDock` or any adapter. To add every role of a tenant on a role-management request, `await permdock.derive({ customRoles })` instead of building a second instance. Build it with `customRoleSource({ rolesOf })`, where `rolesOf(tenant)` reads every custom role of the tenant, not only the held ones: `assignableRoles` and `decideRoleChange` see only what the source returns. `{ read: 'held', policy }` skips the read on requests that never manage roles. Check it with `testRoleSource(source, { every })`.
|
|
84
|
+
- `permdock.assignableRoles({ tenant })` lists declared roles, then the tenant's custom roles whose every permission and level the subject may hand out at the custom role's scope. `decideRoleChange` assigns and revokes a custom role like a declared one, with no holder count; it inherits `for` and `exclusiveWith` from its included roles.
|
|
85
|
+
- `permdock.assignablePermissions({ tenant })` is the ceiling intersected with what the subject holds. `useAssignablePermissions()` in `permdock/react` builds the editor from it.
|
|
86
|
+
- The save action runs `validateCustomRole(policy, role)` and refuses the role when `ok` is false or `permissions` contains a key outside `assignablePermissions()`. `dropped` names each entry left out (`unknown-permission`, `outside-ceiling`, `condition-not-allowed`, `unknown-level`, `unknown-role`).
|
|
87
|
+
- A platform custom role (`scope: 'global'`, no `tenant`, `team` or `id`) is held through `principal.roles` and capped by the allows of declared global roles marked `assignable`. `RoleSource.globalRoles({ held })` returns them; `customRoleSource(reader, { read: 'held', policy })` skips that read while every held global role is declared.
|
|
88
|
+
- A stored grant under a key renamed with `definePermissions(..., { renamed })` still resolves; `validateCustomRole` lists it in `renamed` as `{ from, to }`, and `permdock doctor` PD055 prints the `update` that rewrites it.
|
|
89
|
+
- For generated RLS, add `--custom-roles` (or `rls.customRoles: true`) to `permdock rls generate`. Platform roles are rows with `scope = 'global'` and a null `tenant_id`, or the top-level `role_grants` claim in `jwt` mode. With levels, `custom_role_permissions` gains a `level` column and the claim writes `key@level`.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: permdock-wire
|
|
3
|
+
description: Adds PermDock authorization to a TypeScript app, from definitions to the first guard. Use when installing permdock, adding permissions, roles, plans, RBAC or access control, writing definePermissions, definePolicy or createPermDock, guarding a route, Server Action, procedure or React component, adding usage limits, plan gates or time-limited grants, or running permdock collect and doctor for the first time.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: ScaleDockHQ
|
|
7
|
+
homepage: https://permdock.com/docs/getting-started/quick-start
|
|
8
|
+
repository: https://github.com/ScaleDockHQ/permdock
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Wire PermDock
|
|
12
|
+
|
|
13
|
+
Three files and one guard: `src/permissions.ts` (definitions), `src/policy.ts` (rules) and the factory file, then one check on the path the user asked for ([quick start](https://permdock.com/docs/getting-started/quick-start)). Import paths and identifiers follow the [naming convention](https://permdock.com/docs/getting-started/naming).
|
|
14
|
+
|
|
15
|
+
## Inputs (find out, or ask before starting)
|
|
16
|
+
|
|
17
|
+
- The framework: Next.js, Hono, Express, Fastify, Elysia, Nest, Node, tRPC, oRPC, an AuthZEN PDP, Vite with React, Vue, Svelte or Solid, React Native, or a collect-only framework (Nuxt, Astro, React Router, TanStack Start, Effect).
|
|
18
|
+
- The Standard Schema validator already in the repo: Zod, Valibot, ArkType, Effect Schema.
|
|
19
|
+
- Where the signed-in user comes from (the session or a verified token) and which roles and plans exist.
|
|
20
|
+
- The first path to guard.
|
|
21
|
+
|
|
22
|
+
## Invariants
|
|
23
|
+
|
|
24
|
+
1. Permissions are references (`permissions.post.update`), never strings. Roles and plans are typed leaves (`roles.owner`, `plans.pro`).
|
|
25
|
+
2. Outcomes are `granted`, `denied` or `approval-required`. Anything unknown, invalid or thrown denies, and a matching `deny` wins over every `allow`.
|
|
26
|
+
3. `policy.ts` is server-only. Client entries import `permissions.ts`, snapshots and hooks only.
|
|
27
|
+
4. The subject comes from a trusted resolver (`subjectFrom*` or the server session), never from a request body, header the client sets, or model output.
|
|
28
|
+
5. Portable `where` conditions first; a closure only when no portable operator expresses the rule.
|
|
29
|
+
|
|
30
|
+
## Workflow
|
|
31
|
+
|
|
32
|
+
1. **Detect.** Find the framework and the validator. Collect-only frameworks use `permdock/unplugin` for collect and `permdock/server` (or the matching HTTP adapter) at runtime; never add a per-vendor package.
|
|
33
|
+
✓ The adapter import path (`permdock/<framework>`) and the validator import are named.
|
|
34
|
+
2. **Define.** Create `src/permissions.ts`. Importable everywhere; no rules, no secrets.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import {
|
|
38
|
+
definePermissions,
|
|
39
|
+
defineRoles,
|
|
40
|
+
definePlans,
|
|
41
|
+
resource,
|
|
42
|
+
crud,
|
|
43
|
+
} from "permdock";
|
|
44
|
+
import { Post } from "./schemas";
|
|
45
|
+
|
|
46
|
+
export const permissions = definePermissions({
|
|
47
|
+
post: resource(Post, crud({ relations: { author: "authorId" } })),
|
|
48
|
+
});
|
|
49
|
+
export const roles = defineRoles({ member: {}, admin: {} });
|
|
50
|
+
export const plans = definePlans({ pro: {} });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
✓ Every resource the first guard needs has a leaf; instance actions sit in `actions`, list and create in `collection`.
|
|
54
|
+
|
|
55
|
+
3. **Policy.** Create `src/policy.ts`. Prefer `grants` with `to:` selectors (`anyone()`, `authenticated()`, `relation()`, a role or plan leaf, `actor()`, `assurance()`); `role(roles.member, …)` sugar also works.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { definePolicy, role, allow, principal, relation } from "permdock";
|
|
59
|
+
import { permissions, roles } from "./permissions";
|
|
60
|
+
|
|
61
|
+
const member = role(roles.member, [
|
|
62
|
+
allow(permissions.post.read),
|
|
63
|
+
allow(permissions.post.list),
|
|
64
|
+
allow(permissions.post.create),
|
|
65
|
+
allow(permissions.post.update, {
|
|
66
|
+
to: relation(permissions.post, "author"),
|
|
67
|
+
}),
|
|
68
|
+
allow(permissions.post.delete, {
|
|
69
|
+
where: { authorId: principal.id },
|
|
70
|
+
approval: "human",
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
export const policy = definePolicy(
|
|
75
|
+
{ permissions, roles },
|
|
76
|
+
{
|
|
77
|
+
roles: [member],
|
|
78
|
+
principal: (user) => user && { id: user.id, roles: user.roles },
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Short policy features that need no other skill -> [references/policy-options.md](references/policy-options.md): usage limits, `disclosure: 'hide'`, plan gates, `validFrom` / `validUntil`, step-up with `assurance()`.
|
|
84
|
+
✓ At least one role grants the first guard's permission, and no client entry imports `policy.ts`.
|
|
85
|
+
|
|
86
|
+
4. **Factory.** Create `src/permdock/server.ts` (or the adapter's documented factory file) with `createPermDock` from `permdock/<framework>`. Copy the exact return names. -> [references/adapters.md](references/adapters.md)
|
|
87
|
+
✓ The factory file compiles and exports the adapter's public members.
|
|
88
|
+
5. **Guard.** Add one check on the path the user asked for: `assert` or `protect` / `getPermission` with a permission reference on the server, `<Protected permission={permissions.post.update}>` inside `PermDockProvider` in React.
|
|
89
|
+
✓ That path cannot run without a `granted` decision, and the adapter handles a denial (Problem Details or fallback UI).
|
|
90
|
+
6. **Check.** Run `permdock collect`, `permdock catalog`, `permdock usage` and `permdock doctor`, and add `permdock collect --check` to CI. For a Vite SPA, Expo or another framework without `'use client'`, set `doctor.clientEntries` to the client source globs so PD001 checks them. When client code lives outside `collect.srcPath`, list every source folder in `doctor.srcPath` instead of widening the catalog. Add a `describePolicy` suite from `permdock/testing` ([scenario testing](https://permdock.com/docs/guides/scenario-testing)). -> [references/policy-options.md](references/policy-options.md#cli-starting-points)
|
|
91
|
+
✓ Collect and doctor findings are fixed, or the three files typecheck and one matrix test passes.
|
|
92
|
+
|
|
93
|
+
## Next skills
|
|
94
|
+
|
|
95
|
+
Install each with `npx skills add ScaleDockHQ/PermDock --skill <name>` when the app needs it:
|
|
96
|
+
|
|
97
|
+
- `permdock-agents`: AI SDK, Claude Agent SDK, Eve, OpenAI Agents SDK, MCP, A2A and WebMCP tool maps, actors and `delegation`.
|
|
98
|
+
- `permdock-approvals`: `approval: { by }`, quorum, escalation, `staleOn`, approval stores and the resume flow.
|
|
99
|
+
- `permdock-tenancy`: named scopes, memberships, ownership rules (`min`, `assigns`, `for`) and custom roles.
|
|
100
|
+
- `permdock-data`: `where()` and `filter()` in ORM queries, generated RLS, relationship graphs, parity tests.
|
|
101
|
+
- `permdock-credentials`: `subjectFromJwt`, API keys and service accounts, share links.
|
|
102
|
+
- `permdock-audit`: review the result or a pull request.
|
|
103
|
+
|
|
104
|
+
## Verify before done
|
|
105
|
+
|
|
106
|
+
- [ ] No string permission keys in app code; every check passes a reference.
|
|
107
|
+
- [ ] `policy.ts` is imported only from server code, and PD001 is clean.
|
|
108
|
+
- [ ] The subject resolver reads only the server session or a verified token.
|
|
109
|
+
- [ ] `permdock collect --check` runs in CI, and a `describePolicy` suite covers the new grants.
|
|
110
|
+
|
|
111
|
+
## Reference index
|
|
112
|
+
|
|
113
|
+
- [references/adapters.md](references/adapters.md): factory shapes for Next.js, HTTP frameworks, RPC frameworks, UI frameworks, AuthZEN, OpenAPI, OpenTelemetry, SCIM, Cloud, Better Auth, Clerk, Supabase, the remote PDP and collect-only frameworks.
|
|
114
|
+
- [references/policy-options.md](references/policy-options.md): limits, disclosure, plans, validity windows, step-up, and CLI starting points (OpenAPI import, Arazzo, Cloud push).
|
|
115
|
+
- The `permdock` skill: the mental model, `explain` for an unexpected denial, and the docs MCP.
|
|
116
|
+
- Docs: [policies](https://permdock.com/docs/concepts/policies), [conditions](https://permdock.com/docs/concepts/conditions), [decisions](https://permdock.com/docs/concepts/decisions), [adapters](https://permdock.com/docs/adapters).
|