powersync
permdock powersync generate compiles the policy's read grants into PowerSync Sync Streams, and verify checks that no stream syncs a row the policy denies.
permdock powersync generate [--out sync-config.yaml] [--check] [--from <policy module>]
permdock powersync verify [--db <url>] [--fixtures rls.fixtures.json] [--from <policy module>]generate writes an edition 3 Sync Streams file with one auto-subscribed stream per synced resource. Each stream's queries are the ways a user may read a row: one query per allow grant of the read permission, and one per role source of the membership table. The tables and memberships come from rls (rls.tables, rls.memberships), the same database-mode mapping permdock rls generate reads.
// permdock.config.ts
export default {
policy: "./src/policy.ts",
rls: {
authorize: "database",
tables: { job: "jobs" },
memberships: {
scopes: {
organization: {
table: "organization_users",
user: "user_id",
role: [
"tier",
{ through: "roles", on: { role_id: "id" }, column: "key" },
],
columns: { organization: "organization_id" },
},
},
},
},
powersync: { out: "sync-config.yaml" },
};# Generated by permdock powersync generate from the policy. Do not edit.
config:
edition: 3
streams:
job:
auto_subscribe: true
queries:
- "SELECT * FROM jobs WHERE jobs.organization_id IN (SELECT organization_users.organization_id FROM organization_users WHERE organization_users.user_id = auth.user_id() AND organization_users.tier IN ('admin'))"
- "SELECT * FROM jobs WHERE jobs.organization_id IN (SELECT organization_users.organization_id FROM organization_users INNER JOIN roles ON roles.id = organization_users.role_id WHERE organization_users.user_id = auth.user_id() AND roles.key IN ('admin'))"| Flag | Values | Default | Effect |
|---|---|---|---|
--out | a path | powersync.out, else sync-config.yaml | Where generate writes and what verify compares |
--check | flag | off | generate: exit 1 when the file on disk is stale; write nothing |
--db | a Postgres URL | none | verify: run every stream query against this database per fixture |
--fixtures | a path | rls.fixtures, else rls.fixtures.json | verify: the rls verify fixtures |
--from | a module | policy in the config | The module exporting policy |
powersync also takes resources (the resources that get a stream; default every resource with a grant of the action), action (default read) and manifest (a path; none by default).
Local snapshot rows
generate also writes the streams a device needs to build the user's snapshot with localSnapshot, each named permdock_<table> and starting from the user's own rows:
permdock_<memberships table>: the user's membership rows (<user> = auth.user_id()) for each table inrls.memberships.permdock_<global roles table>: the user's global-role rows fromrls.roles, elsepermdock.user_rolesindatabasemode.permdock_<roles table>: the rows of eachthroughroles table that one of the user's membership or global-role rows references.- With
rls.customRolesindatabasemode, the custom role tables, limited to rows with no tenant and rows of the user's tenants.
With powersync.manifest set, generate writes localSnapshotManifest(policy) as JSON to that path, so the app bundles the manifest without importing the policy. powersyncSource in permdock/react-native reads it (PowerSync source).
What compiles
A query uses only IN (SELECT …), INNER JOIN, auth.user_id() and auth.parameter(…), the subset the Sync Streams compiler runs without EXISTS.
| Policy | Stream query |
|---|---|
role(…, { on: '<scope>' }) | <row key> IN (SELECT <scope id> FROM <memberships> WHERE <user> = auth.user_id() AND <role> IN (…)) |
A role column through a roles table | The same subquery with INNER JOIN <roles> ON …, comparing the roles table's key |
| Several role sources | One query per source |
A role with for kinds | AND <via> IN (…) in the subquery |
A resource role (on: permissions.<resource>) | The same subquery over rls.memberships.resource.<resource> |
eq, ne, gt, gte, lt, lte, isNull, in, notIn literals | The comparison on the row column |
principal.id, principal.claims.<name> | auth.user_id(), auth.parameter('<name>'); in a claim is IN (SELECT value FROM json_each(…)) |
or | One query per branch |
fields | The id and the listed columns instead of * |
Everything else leaves the grant out of the stream, and generate prints why: a global role (no row filter), a custom role, not, contains, related, opaque and sqlFunction conditions, request context, approval, break-glass, validFrom / validUntil, and a memberships table with expiresAt (the sync service cannot compare with the current time). A resource whose read permission has any deny grant gets no stream, because a stream can only add rows. A resource with more than 32 queries gets no stream. A resource with no stream is listed as a comment at the end of the file.
Verify
verify compiles the policy again and fails when sync-config.yaml differs. With --db, it runs each fixture of a synced permission: the stream queries with the subject's id against the database, and can() in process with the row's organization active, since a stream holds rows of every organization the user belongs to. A row a stream syncs and the policy denies is a mismatch and exits 1. A row the policy grants and no stream syncs is a note: the app reads it from the server. The database must hold the fixture rows and memberships, as for rls verify --db.
verify passes a fixture's subject.claims to auth.parameter() and fails when a permdock_* stream syncs another user's rows. It compares the powersync.manifest file as JSON.
permdock doctor reports PD058 when sync-config.yaml or the manifest is stale or missing, and when the config does not compile to Sync Streams.
Why
- Under-sync, never over-sync. A device keeps synced rows offline, so a row the policy denies must never reach it. Every grant that does not compile is left out, deny grants drop the stream, and the server still decides every request (threat model).
- The
database-mode tables, not the token. The sync service evaluates the queries when a row or membership changes, so a membership change re-syncs without a new token. Ajwt-modemembershipsclaim is not compiled; it is planned. - One file, checked in.
generate --check,verifyand PD058 all compare the same bytes, so CI fails before a stale file is deployed.
Last updated on
supabase
permdock supabase hook generate compiles the app's fromTable and fromJunction membership sources into a Supabase Custom Access Token Hook migration.
Next.js plugin
createPermDockPlugin runs permdock collect during next dev and next build; it is a build hook only and never wires the PermDock API.