PermDock
CLI

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'))"
FlagValuesDefaultEffect
--outa pathpowersync.out, else sync-config.yamlWhere generate writes and what verify compares
--checkflagoffgenerate: exit 1 when the file on disk is stale; write nothing
--dba Postgres URLnoneverify: run every stream query against this database per fixture
--fixturesa pathrls.fixtures, else rls.fixtures.jsonverify: the rls verify fixtures
--froma modulepolicy in the configThe 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 in rls.memberships.
  • permdock_<global roles table>: the user's global-role rows from rls.roles, else permdock.user_roles in database mode.
  • permdock_<roles table>: the rows of each through roles table that one of the user's membership or global-role rows references.
  • With rls.customRoles in database mode, 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.

PolicyStream 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 tableThe same subquery with INNER JOIN <roles> ON …, comparing the roles table's key
Several role sourcesOne query per source
A role with for kindsAND <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 literalsThe 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(…))
orOne query per branch
fieldsThe 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. A jwt-mode memberships claim is not compiled; it is planned.
  • One file, checked in. generate --check, verify and PD058 all compare the same bytes, so CI fails before a stale file is deployed.

Last updated on

On this page