Vex · guide

A policy from a charter

Who may read what, written once as a charter, and compiled into the policy Vex enforces on every run.

Install it
pnpm add @niscorp/vex @niscorp/charter @niscorp/prism @niscorp/strata zod @electric-sql/pglite
Step 1 of 5

The database

Three bookings, each with the member it belongs to. The charter decides who may read which of them.

The database
import { PGlite } from '@electric-sql/pglite';
import { resolvePrincipal, resolveScoping } from '@niscorp/charter';
import type { Charter } from '@niscorp/charter';
import { createPostgresAdapter, createQueryEngine, seedCache } from '@niscorp/vex';
import { createScopePolicy, mergeScopePolicies, scopeGrants } from '@niscorp/vex';
import type { ScopeBehaviors, ScopePolicy } from '@niscorp/vex';
import { createPglitePool } from '@niscorp/vex/pglite';

const db = new PGlite();
await db.exec(`
  CREATE TABLE bookings (id text PRIMARY KEY, member_id text, seats integer);
  INSERT INTO bookings VALUES ('B-2040', 'ada', 1), ('B-2041', 'grace', 2), ('B-2042', 'linus', 4);
`);
Step 2 of 5

The charter, and what a grant does

A charter says what a role may do, as one document. Here a member and an usher may both read bookings, and a member’s reach is personal.

A grant says whether a read exists. The behaviours say what a granted read does, by reach: at personal, a read of bookings gains the condition that the row’s member_id is the reader’s own.

The charter, and what a grant does
const charter: Charter = {
  member: { data: ['bookings.read'], scoping: 'personal' },
  usher: { data: ['bookings.read'] },
};
const behaviors: ScopeBehaviors = {
  bookings: { default: { read: [] }, personal: { read: [{ match: 'member_id', to: 'memberId' }] } },
};
Step 3 of 5

A policy per role, merged

Charter resolves a role’s grants and its reach. Vex compiles them into a ScopePolicy, the document it enforces. One policy per role, at that role’s own reach, then merged: a person may read what any of their roles may.

A policy per role, merged
const grants = scopeGrants(['bookings']);
const policyFor = (roles: string[]): ScopePolicy =>
  mergeScopePolicies(
    roles.map((role) => createScopePolicy(resolvePrincipal(charter, grants, [role], 'data'), behaviors, resolveScoping(charter, role))),
  );
Step 4 of 5

An engine that says what it ran

The read is stored once, with no mention of a member. The engine reports the SQL it compiled as an event, so the file can print what the policy made of it.

An engine that says what it ran
let lastSql = '';
const engine = createQueryEngine({
  adapter: createPostgresAdapter({ pool: createPglitePool(db) }),
  onEvent: (event) => {
    if (event.type === 'query.sql') lastSql = event.sql;
  },
});
await engine.introspect();
await seedCache(engine.cache, [{
  fingerprint: 'door/list',
  dsl: { from: ['bookings'], fields: ['bookings.member_id', 'bookings.seats'] },
}]);
Step 5 of 5

Ask as two people

The policy goes with the call, bound to who is asking. Grace is a member, so her read gains a condition bound to her id. Linus is an usher too, and an usher reads every booking: the wider rule wins, and his statement has no condition at all.

Ask as two people
const people = [
  { name: 'Grace, a member', roles: ['member'], memberId: 'grace' },
  { name: 'Linus, an usher and a member', roles: ['usher', 'member'], memberId: 'linus' },
];
for (const person of people) {
  const options = { scope: { memberId: person.memberId }, scopePolicy: policyFor(person.roles) };
  const response = await engine.execute({ fingerprint: 'door/list', context: {} }, options);
  console.log(`${person.name}: ${JSON.stringify(response.result)}`);
  console.log(`  ${lastSql}`);
}
Then

Run it.

Grace gets her own row, and the SQL carries a condition bound to who she is. Linus gets everybody, and the SQL carries none.

Run it
npx tsx policy.mts
It prints
Grace, a member: [{"member_id":"grace","seats":2}]
  SELECT b1.member_id AS "member_id", b1.seats AS "seats" FROM bookings AS b1 WHERE b1.member_id = $1 LIMIT 100
Linus, an usher and a member: [{"member_id":"ada","seats":1},{"member_id":"grace","seats":2},{"member_id":"linus","seats":4}]
  SELECT b1.member_id AS "member_id", b1.seats AS "seats" FROM bookings AS b1 LIMIT 100