feat: permission hooks, isAllowed and permittedOrgs

This commit is contained in:
2026-10-21 14:38:15 +02:00
parent ee15ee0adf
commit bc05d312a6
10 changed files with 259 additions and 71 deletions
+83
View File
@@ -0,0 +1,83 @@
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { addPermissionHook, isAllowed, permittedOrgs } from '../dist/index.js';
import { hasPermission, canActOnUser, canGrantRule } from '../dist/permissions.js';
const A = '00000000-0000-4000-8000-00000000000a';
const B = '00000000-0000-4000-8000-00000000000b';
// morbac allows everything in `allowed` (null = the no-org perimeter).
function fakeDb(allowed, extra = {}) {
return {
async query(sql, params) {
if (sql.includes('unnest')) return { rows: params[1].filter((o) => allowed.has(o)).map((o) => ({ o })) };
if (sql.includes('morbac.is_allowed')) return { rows: [{ allowed: allowed.has(params[1]) }] };
if (sql.includes('user_roles')) return { rows: (extra.memberships ?? []).map((org_id) => ({ org_id })) };
if (sql.includes('get_org_scope')) return { rows: (extra.scope ?? []).map((org_id) => ({ org_id })) };
throw new Error(`unexpected query ${sql}`);
},
};
}
let removers = [];
beforeEach(() => { removers.forEach((r) => r()); removers = []; });
const hook = (phase, fn) => removers.push(addPermissionHook(phase, fn));
test('without hooks the answer is morbac\'s', async () => {
assert.equal(await isAllowed(fakeDb(new Set([A])), 'u', A, 'read', 'x'), true);
assert.equal(await isAllowed(fakeDb(new Set()), 'u', A, 'read', 'x'), false);
});
test('a pre hook refuses without asking morbac', async () => {
const db = fakeDb(new Set([A]));
let asked = false;
const query = db.query.bind(db);
db.query = async (...a) => { asked = true; return query(...a); };
hook('pre', (c) => c.orgId !== A);
assert.equal(await isAllowed(db, 'u', A, 'read', 'x'), false);
assert.equal(asked, false);
});
test('no hook can grant what morbac refused', async () => {
hook('pre', () => true);
hook('post', () => true);
assert.equal(await isAllowed(fakeDb(new Set()), 'u', A, 'read', 'x'), false);
});
test('a post hook sees the answer and can refuse it', async () => {
const seen = [];
hook('post', (c, allowed) => { seen.push(allowed); return c.activity !== 'delete'; });
assert.equal(await isAllowed(fakeDb(new Set([A])), 'u', A, 'delete', 'x'), false);
assert.equal(await isAllowed(fakeDb(new Set([A])), 'u', A, 'read', 'x'), true);
assert.deepEqual(seen, [true, true]);
});
test('a hook that is not exactly true refuses', async () => {
hook('pre', () => undefined);
assert.equal(await isAllowed(fakeDb(new Set([A])), 'u', A, 'read', 'x'), false);
});
test('a throwing hook fails the decision rather than allowing it', async () => {
hook('pre', () => { throw new Error('boom'); });
await assert.rejects(isAllowed(fakeDb(new Set([A])), 'u', A, 'read', 'x'), /boom/);
});
test('permittedOrgs drops the orgs a hook refuses', async () => {
hook('pre', (c) => c.orgId !== B);
assert.deepEqual(await permittedOrgs(fakeDb(new Set([A, B])), 'u', [A, B], 'read', 'x'), [A]);
});
test('hasPermission answers 403 when a hook refuses', async () => {
hook('pre', () => false);
let code;
const reply = { code(c) { code = c; return this; }, send() { return this; } };
assert.equal(await hasPermission(fakeDb(new Set([A])), { user: { id: 'u' } }, reply, A, 'read', 'x'), false);
assert.equal(code, 403);
});
test('canActOnUser and canGrantRule go through the hooks', async () => {
hook('pre', (c) => c.orgId !== B);
assert.equal(await canActOnUser(fakeDb(new Set([B]), { memberships: [B] }), 'u', 't', 'update', 'users'), false);
assert.equal(await canGrantRule(fakeDb(new Set([A, B]), { scope: [A, B] }), 'u', A, 'subtree', 'read', 'x'), false);
assert.equal(await canGrantRule(fakeDb(new Set([A])), 'u', A, 'self', 'read', 'x'), true);
});