commit 05e5722a0e970b22b3ed16863c4f58898b1e53d0 Author: Marc Villain Date: Sat Oct 3 09:59:59 2026 +0200 feat: @crudy/pgmorbac ships the SQL of each signed pgmorbac release prepack downloads the release whose version matches package.json, verifies the Ed25519 manifest against the pinned release key and the zip against its sha256, and only then extracts the control file and scripts into sql/. No SQL is committed here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6e9db3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +sql/ +package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..34f26bf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +The package version is the pgmorbac extension version it ships. Extension +changes are in the [pgmorbac changelog](https://github.com/crudylabs/pgmorbac/blob/master/CHANGELOG.md). + +## [1.0.0] - 2026-10-03 + +### Added +- The install and upgrade scripts of the signed pgmorbac 1.0.0 release, verified + against the pinned release key when the package is built. +- `extensionSql()`, `upgradeScripts()`, `version`, `sqlDir` and `controlFile`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7b3487a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pgmorbac contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1678f82 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# @crudy/pgmorbac + +The [pgmorbac](https://pgmorbac.crudy.fr) PostgreSQL extension (Multi-OrBAC access +control) for Node.js projects that apply SQL through a migration runner instead of +`CREATE EXTENSION`. + +```bash +npm install @crudy/pgmorbac +``` + +```js +import { extensionSql, upgradeScripts, version } from '@crudy/pgmorbac'; + +await client.query(extensionSql()); // pgmorbac--.sql +for (const u of upgradeScripts()) { // pgmorbac----.sql, in order + await client.query(u.sql); +} +``` + +| Export | | +|---|---| +| `extensionSql()` | the install script, as `CREATE EXTENSION` would run it | +| `upgradeScripts()` | `{ from, to, filename, sql }` per upgrade script, in version order | +| `version` | the extension version | +| `sqlDir`, `controlFile` | paths to the shipped files, for tools that read them directly | + +For Fastify applications, [`@crudy/pgmorbac-fastify`](https://www.npmjs.com/package/@crudy/pgmorbac-fastify) +adds permission checks, the RLS session context and management routes. + +## Where the SQL comes from + +The package version is the extension version. Building the package downloads +that version's release from +[crudy/pgmorbac](https://git.villains.fr/crudy/pgmorbac/releases), checks the +Ed25519-signed manifest against the pinned `release-key.pub.pem` and the zip +against the manifest's sha256, and only then extracts the SQL. The extension +source lives in [pgmorbac](https://github.com/crudylabs/pgmorbac); this repository +holds no SQL. + +## Releasing + +After pgmorbac `vX.Y.Z` is released on Gitea, set `version` to `X.Y.Z` here, then +`npm publish`. To build from a local release (the extension's `dist/`), set +`PGMORBAC_DIST_DIR`; it is verified the same way. + +## License + +MIT, like the extension. See [LICENSE](LICENSE). diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..e8995f0 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,16 @@ +/** Absolute path to the directory holding pgmorbac.control and the SQL scripts. */ +export declare const sqlDir: string; +/** Absolute path to pgmorbac.control. */ +export declare const controlFile: string; +/** The extension version (default_version in pgmorbac.control). */ +export declare const version: string; +/** The install script pgmorbac--.sql, as CREATE EXTENSION would run it. */ +export declare function extensionSql(): string; +export interface UpgradeScript { + from: string; + to: string; + filename: string; + sql: string; +} +/** Upgrade scripts pgmorbac----.sql, in version order. */ +export declare function upgradeScripts(): UpgradeScript[]; diff --git a/index.js b/index.js new file mode 100644 index 0000000..2fbabd7 --- /dev/null +++ b/index.js @@ -0,0 +1,30 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +export const sqlDir = join(dirname(fileURLToPath(import.meta.url)), 'sql'); +export const controlFile = join(sqlDir, 'pgmorbac.control'); + +const control = readFileSync(controlFile, 'utf8').match(/default_version\s*=\s*'([^']+)'/); +if (!control) throw new Error('[pgmorbac] pgmorbac.control has no default_version'); +export const version = control[1]; + +/** The install script pgmorbac--.sql, as CREATE EXTENSION would run it. */ +export function extensionSql() { + return readFileSync(join(sqlDir, `pgmorbac--${version}.sql`), 'utf8'); +} + +const semver = (v) => v.split('.').map(Number); +const byFrom = (a, b) => { + const [x, y] = [semver(a.from), semver(b.from)]; + return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; +}; + +/** Upgrade scripts pgmorbac----.sql, in version order. */ +export function upgradeScripts() { + return readdirSync(sqlDir) + .map((filename) => ({ filename, m: /^pgmorbac--(\d+\.\d+\.\d+)--(\d+\.\d+\.\d+)\.sql$/.exec(filename) })) + .filter(({ m }) => m) + .map(({ filename, m }) => ({ from: m[1], to: m[2], filename, sql: readFileSync(join(sqlDir, filename), 'utf8') })) + .sort(byFrom); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..87c12ba --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "name": "@crudy/pgmorbac", + "version": "1.0.0", + "type": "module", + "description": "Multi-OrBAC for PostgreSQL: the pgmorbac extension SQL from each signed release, for Node.js projects and migration runners", + "keywords": [ + "pgmorbac", + "morbac", + "orbac", + "rbac", + "authorization", + "access-control", + "postgresql", + "postgresql-extension", + "row-level-security", + "multi-tenant" + ], + "author": "Marc Villain", + "license": "MIT", + "homepage": "https://pgmorbac.crudy.fr", + "repository": { + "type": "git", + "url": "git+https://github.com/crudylabs/pgmorbac-node.git" + }, + "bugs": { + "url": "https://github.com/crudylabs/pgmorbac-node/issues" + }, + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + }, + "main": "./index.js", + "types": "./index.d.ts", + "files": [ + "index.js", + "index.d.ts", + "sql", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "prepack": "node scripts/fetch-release.mjs", + "test": "node --test test/*.test.mjs" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "fflate": "^0.8.2", + "jose": "^5" + } +} diff --git a/release-key.pub.pem b/release-key.pub.pem new file mode 100644 index 0000000..3489153 --- /dev/null +++ b/release-key.pub.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAO9kbnYgjWfgElzh3xDANSgwg/eKd5SfiNN9K9BPxEv0= +-----END PUBLIC KEY----- diff --git a/scripts/fetch-release.mjs b/scripts/fetch-release.mjs new file mode 100644 index 0000000..4eb9e04 --- /dev/null +++ b/scripts/fetch-release.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// prepack: fills sql/ from the signed pgmorbac release whose version matches +// package.json. The release comes from the crudy/pgmorbac Gitea releases, or +// from a local directory of release files (PGMORBAC_DIST_DIR, the dist/ that +// the extension's build-dist.mjs writes). Either way it must verify against the +// pinned release-key.pub.pem before anything is written. +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { extractSql, releaseFiles, verifyRelease } from './release.mjs'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const RELEASES = 'https://git.villains.fr/crudy/pgmorbac/releases/download'; + +const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +const files = releaseFiles(version); + +async function load(name) { + const dir = process.env.PGMORBAC_DIST_DIR; + if (dir) return readFileSync(join(dir, name)); + const res = await fetch(`${RELEASES}/v${version}/${name}`); + if (!res.ok) throw new Error(`${name}: release v${version} answered ${res.status}`); + return Buffer.from(await res.arrayBuffer()); +} + +const zip = await load(files.zip); +const manifestJwt = (await load(files.manifest)).toString('utf8'); +await verifyRelease({ zip, manifestJwt, publicKeyPem: readFileSync(join(root, 'release-key.pub.pem'), 'utf8'), version }); + +const sqlDir = join(root, 'sql'); +rmSync(sqlDir, { recursive: true, force: true }); +mkdirSync(sqlDir); +for (const [name, bytes] of Object.entries(extractSql(zip, version))) writeFileSync(join(sqlDir, name), bytes); +console.log(`[pgmorbac] sql/ filled from the verified v${version} release`); diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..585da4d --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,47 @@ +// Verifies a signed pgmorbac release and extracts its SQL. Pure functions, so +// the checks are tested without a network or the real signing key. +import { createHash } from 'node:crypto'; +import { importSPKI, jwtVerify } from 'jose'; +import { unzipSync } from 'fflate'; + +export const AUDIENCE = 'pgmorbac-release'; + +export function releaseFiles(version) { + const stem = `pgmorbac-${version}`; + return { zip: `${stem}.zip`, manifest: `${stem}.manifest.jwt` }; +} + +/** + * Throws unless the manifest is signed by publicKeyPem for this audience and + * version, and names the zip with its exact sha256. + */ +export async function verifyRelease({ zip, manifestJwt, publicKeyPem, version }) { + const key = await importSPKI(publicKeyPem.trim(), 'EdDSA'); + const { payload } = await jwtVerify(manifestJwt.trim(), key, { algorithms: ['EdDSA'], audience: AUDIENCE }); + const manifest = payload.manifest; + if (manifest?.name !== 'pgmorbac' || manifest.version !== version) { + throw new Error(`manifest is for ${manifest?.name}@${manifest?.version}, expected pgmorbac@${version}`); + } + const expected = manifest.artifacts?.find((a) => a.name === releaseFiles(version).zip)?.sha256; + const actual = createHash('sha256').update(zip).digest('hex'); + if (!expected || expected !== actual) throw new Error(`zip sha256 ${actual} does not match the signed manifest`); +} + +/** + * The control file, install script and upgrade scripts of a verified release + * zip, as { name: bytes }. + */ +export function extractSql(zip, version) { + const prefix = `pgmorbac-${version}/`; + const wanted = new RegExp(`^(pgmorbac\\.control|pgmorbac--${version.replace(/\./g, '\\.')}\\.sql|pgmorbac--\\d+\\.\\d+\\.\\d+--\\d+\\.\\d+\\.\\d+\\.sql)$`); + const out = {}; + for (const [path, bytes] of Object.entries(unzipSync(zip))) { + if (!path.startsWith(prefix)) continue; + const name = path.slice(prefix.length); + if (wanted.test(name)) out[name] = bytes; + } + if (!out['pgmorbac.control'] || !out[`pgmorbac--${version}.sql`]) { + throw new Error(`release zip lacks pgmorbac.control or pgmorbac--${version}.sql`); + } + return out; +} diff --git a/test/release.test.mjs b/test/release.test.mjs new file mode 100644 index 0000000..eda2aad --- /dev/null +++ b/test/release.test.mjs @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, generateKeyPairSync } from 'node:crypto'; +import { SignJWT, importPKCS8 } from 'jose'; +import { strToU8, zipSync } from 'fflate'; +import { AUDIENCE, extractSql, verifyRelease } from '../scripts/release.mjs'; + +const version = '1.2.3'; +const zip = Buffer.from(zipSync({ + 'pgmorbac-1.2.3/pgmorbac.control': strToU8("default_version = '1.2.3'\n"), + 'pgmorbac-1.2.3/pgmorbac--1.2.3.sql': strToU8('CREATE SCHEMA morbac;\n'), + 'pgmorbac-1.2.3/pgmorbac--1.2.2--1.2.3.sql': strToU8('SELECT 1;\n'), + 'pgmorbac-1.2.3/Makefile': strToU8('EXTENSION = pgmorbac\n'), +})); + +function keys() { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + return { + privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(), + }; +} + +async function sign(privatePem, manifest, audience = AUDIENCE) { + return new SignJWT({ manifest }) + .setProtectedHeader({ alg: 'EdDSA', typ: 'JWT' }) + .setAudience(audience) + .sign(await importPKCS8(privatePem, 'EdDSA')); +} + +const sha = (b) => createHash('sha256').update(b).digest('hex'); +const good = (z = zip, v = version) => ({ name: 'pgmorbac', version: v, artifacts: [{ name: `pgmorbac-${v}.zip`, sha256: sha(z) }] }); + +test('a release signed by the pinned key verifies and yields its SQL', async () => { + const k = keys(); + await verifyRelease({ zip, manifestJwt: await sign(k.privatePem, good()), publicKeyPem: k.publicKeyPem, version }); + assert.deepEqual(Object.keys(extractSql(zip, version)).sort(), ['pgmorbac--1.2.2--1.2.3.sql', 'pgmorbac--1.2.3.sql', 'pgmorbac.control']); +}); + +test('another key is refused', async () => { + const signer = keys(); + const pinned = keys(); + await assert.rejects(verifyRelease({ zip, manifestJwt: await sign(signer.privatePem, good()), publicKeyPem: pinned.publicKeyPem, version })); +}); + +test('a zip that is not the signed one is refused', async () => { + const k = keys(); + const tampered = Buffer.from(zipSync({ 'pgmorbac-1.2.3/pgmorbac--1.2.3.sql': strToU8('DROP SCHEMA public;\n') })); + await assert.rejects(verifyRelease({ zip: tampered, manifestJwt: await sign(k.privatePem, good()), publicKeyPem: k.publicKeyPem, version }), /sha256/); +}); + +test('a manifest for another version or audience is refused', async () => { + const k = keys(); + await assert.rejects(verifyRelease({ zip, manifestJwt: await sign(k.privatePem, good(zip, '1.2.4')), publicKeyPem: k.publicKeyPem, version }), /expected pgmorbac@1.2.3/); + await assert.rejects(verifyRelease({ zip, manifestJwt: await sign(k.privatePem, good(), 'other'), publicKeyPem: k.publicKeyPem, version })); +}); + +test('a zip without the install script is refused', () => { + const empty = Buffer.from(zipSync({ 'pgmorbac-1.2.3/README': strToU8('x') })); + assert.throws(() => extractSql(empty, version), /lacks/); +});