A policy from a charter
Who may read what, written once as a charter, and compiled into the policy Vex enforces on every run.
pnpm add @niscorp/vex @niscorp/charter @niscorp/prism @niscorp/strata zod @electric-sql/pgliteThe database
Three bookings, each with the member it belongs to. The charter decides who may read which of them.
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);
`);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.
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' }] } },
};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.
const grants = scopeGrants(['bookings']);
const policyFor = (roles: string[]): ScopePolicy =>
mergeScopePolicies(
roles.map((role) => createScopePolicy(resolvePrincipal(charter, grants, [role], 'data'), behaviors, resolveScoping(charter, role))),
);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.
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'] },
}]);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.
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}`);
}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.
npx tsx policy.mtsGrace, 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