fix: lists decide every org through the permission system; no system-org shortcut

This commit is contained in:
2026-11-02 10:43:29 +01:00
parent 12964793cf
commit 0f7db7e99e
12 changed files with 98 additions and 31 deletions
+8
View File
@@ -5,6 +5,14 @@ All notable changes to this package will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.0] - 2026-11-02
### Added
- `listScope(db, req, scope, activity, view, column)`: the filter a list applies, over the orgs under the requested ones (every org when none is named), each kept only where the caller holds the permission once the hooks have run.
### Fixed
- The org, user, role, role hierarchy, rule, user rule, cross-org rule and delegation lists answered for the whole subtree of each permitted org, and a request naming the system org skipped the org filter entirely. Every org they answer for is now decided by the permission system with the hooks applied, so an org the hooks refuse never shows in a list, pinned or not, and the system org is an org like any other.
## [1.2.0] - 2026-11-02
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@crudy/pgmorbac-fastify",
"version": "1.2.0",
"version": "1.3.0",
"type": "module",
"description": "Fastify layer over pgmorbac: permission checks, RLS session context, management routes, PostgREST data proxy, migration loader",
"keywords": [
+1
View File
@@ -9,6 +9,7 @@ export {
isAllowed,
permittedOrgs,
permittedScope,
listScope,
type OrgTarget,
type OrgSelection,
} from './permissions.js';
+26
View File
@@ -75,6 +75,32 @@ export function orgScopeExpr(orgIds: string[], column: string, paramOffset = 0):
return { expr: `${column} IN (${parts.join(' UNION ')})`, params: orgIds };
}
/**
* The filter a list applies to `column`: the orgs it answers for are those under
* the orgs of `scope` (every org when the request named none), kept only where
* the caller holds (activity, target) once the permission hooks have run. A row
* with no org shows only on an unpinned list, which `permittedScope` let through
* on the no-org perimeter.
*/
export async function listScope(
db: Pool,
req: FastifyRequest,
scope: string[],
activity: string,
target: string,
column: string,
paramOffset = 0,
): Promise<{ expr: string; params: [string[]] }> {
const user = getUser(req);
const { rows } = scope.length
? await db.query<{ id: string }>(
"SELECT DISTINCT s.org_id AS id FROM unnest($1::UUID[]) o, morbac.get_org_scope(o, 'subtree') s", [scope])
: await db.query<{ id: string }>('SELECT id FROM morbac.orgs');
const orgs = user ? await permittedOrgs(db, user.id, rows.map((r) => r.id), activity, target, req) : [];
const own = `${column} = ANY($${paramOffset + 1}::UUID[])`;
return { expr: scope.length ? own : `(${own} OR ${column} IS NULL)`, params: [orgs] };
}
export function viewForType(type: 'user' | 'service'): string {
return type === 'service' ? 'service_users' : 'users';
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, permittedScope, canActOnUser, SYSTEM_ORG_ID } from '../permissions.js';
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, canActOnUser, SYSTEM_ORG_ID } from '../permissions.js';
import { delegationResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
const SELECT_COLUMNS = `
@@ -33,7 +33,7 @@ export function createDelegationRoutes(db: Pool) {
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'delegations');
if (!scope) return reply;
const { expr, params } = orgScopeExpr(scope, 'd.org_id');
const { expr, params } = await listScope(db, req, scope, 'read', 'delegations', 'd.org_id');
const { rows } = await db.query(
`SELECT ${SELECT_COLUMNS}
FROM morbac.delegations d
+4 -5
View File
@@ -3,7 +3,7 @@ import { withMorbacContext } from '../rls-context.js';
import type { Pool } from 'pg';
import type { CheckLimitFn, OrgEventSink } from '../plugin.js';
import {
getUser, getOrgScope, orgScopeExpr, hasPermission, permittedScope, permittedOrgs, SYSTEM_ORG_ID,
getUser, getOrgScope, listScope, hasPermission, permittedScope, permittedOrgs, SYSTEM_ORG_ID,
} from '../permissions.js';
import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
@@ -69,12 +69,11 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'orgs');
if (!scope) return reply;
const isSystemScope = scope.includes(SYSTEM_ORG_ID);
const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(scope, 'id');
const { params: [visible] } = await listScope(db, req, scope, 'read', 'orgs', 'id');
const { rows } = await withMorbacContext(db, { userId: user.id, orgId: scope[0] }, (client) =>
client.query(
withOrgPaths(`SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''}`),
params,
withOrgPaths('SELECT id, name, parent_id, metadata FROM morbac.orgs WHERE id = ANY($1::UUID[])'),
[visible],
),
);
return rows;
+3 -3
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
import {
getUser, getOrgScope, orgScopeExpr, hasPermission, permittedScope, SYSTEM_ORG_ID,
getUser, getOrgScope, listScope, hasPermission, permittedScope, SYSTEM_ORG_ID,
} from '../permissions.js';
import { parseListParams, composeWhere, setContentRange, ListQueryError } from '../list-query.js';
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
@@ -33,7 +33,7 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
if (!scope) return reply;
const { expr, params } = orgScopeExpr(scope, 'r.org_id');
const { expr, params } = await listScope(db, req, scope, 'read', 'roles', 'r.org_id');
let list;
try {
list = parseListParams(req.query as Record<string, unknown>, {
@@ -218,7 +218,7 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
if (!scope) return reply;
const { expr, params } = orgScopeExpr(scope, 'rs.org_id');
const { expr, params } = await listScope(db, req, scope, 'read', 'roles', 'rs.org_id');
const { rows } = await db.query(
`SELECT rh.senior_role_id, rs.name AS senior_name,
rh.junior_role_id, rj.name AS junior_name,
+4 -8
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, permittedScope, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
import { crossOrgRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createCrossOrgRuleRoutes(db: Pool) {
@@ -26,12 +26,8 @@ export function createCrossOrgRuleRoutes(db: Pool) {
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'cross_org_rules');
if (!scope) return reply;
const { expr: srcExpr, params: srcParams } = orgScopeExpr(scope, 'cor.source_org_id');
const { expr: tgtExpr, params: tgtParams } = orgScopeExpr(scope, 'cor.target_org_id', srcParams.length);
const where = srcExpr && tgtExpr ? `WHERE (${srcExpr} OR ${tgtExpr})`
: srcExpr ? `WHERE ${srcExpr}`
: tgtExpr ? `WHERE ${tgtExpr}`
: '';
const { params } = await listScope(db, req, scope, 'read', 'cross_org_rules', 'cor.source_org_id');
const where = 'WHERE cor.source_org_id = ANY($1::UUID[]) OR cor.target_org_id = ANY($1::UUID[])';
const { rows } = await db.query(
`SELECT cor.id,
@@ -46,7 +42,7 @@ export function createCrossOrgRuleRoutes(db: Pool) {
JOIN morbac.orgs "to" ON "to".id = cor.target_org_id
${where}
ORDER BY so.name, sr.name, "to".name, cor.activity, cor.view`,
[...srcParams, ...tgtParams],
params,
);
return rows;
});
+2 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, permittedScope, canGrantRule, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, canGrantRule, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
import { ruleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createRoleRuleRoutes(db: Pool) {
@@ -26,7 +26,7 @@ export function createRoleRuleRoutes(db: Pool) {
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
if (!scope) return reply;
const { expr, params } = orgScopeExpr(scope, 'r.org_id');
const { expr, params } = await listScope(db, req, scope, 'read', 'rules', 'r.org_id');
const { rows } = await db.query(
`SELECT r.id, r.role_id, r.org_id, o.name AS org_name, ro.name AS role_name,
r.activity, r.view, r.modality, r.priority, r.scope,
+2 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, permittedScope, isActivityViewBound } from '../permissions.js';
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, isActivityViewBound } from '../permissions.js';
import { userRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createUserRuleRoutes(db: Pool) {
@@ -26,7 +26,7 @@ export function createUserRuleRoutes(db: Pool) {
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'user_rules');
if (!scope) return reply;
const { expr, params } = orgScopeExpr(scope, 'ur.org_id');
const { expr, params } = await listScope(db, req, scope, 'read', 'user_rules', 'ur.org_id');
const { rows } = await db.query(
`SELECT ur.id, ur.user_id, ur.org_id,
COALESCE(u.name, ur.user_id::TEXT) AS user_name,
+5 -8
View File
@@ -3,8 +3,8 @@ import type { Pool } from 'pg';
import bcrypt from 'bcryptjs';
import type { CheckLimitFn } from '../plugin.js';
import {
getUser, getOrgScope, orgScopeExpr, hasPermission, permittedScope, canActOnUser, checkApiKeyScope,
getAccountType, viewForType, isAllowed, SYSTEM_ORG_ID,
getUser, getOrgScope, listScope, hasPermission, permittedScope, canActOnUser, checkApiKeyScope,
getAccountType, viewForType, isAllowed,
} from '../permissions.js';
import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { generatePassword } from '../password.js';
@@ -44,12 +44,9 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
isAllowed(db, user.id, null, 'read', 'users', req),
]);
const { expr, params } = orgScopeExpr(scope, 'ur2.org_id');
const isSystemScope = scope.includes(SYSTEM_ORG_ID);
const { params } = await listScope(db, req, scope, 'read', 'users', 'ur2.org_id');
const placement = canReadUnattached ? ' OR u.needs_placement' : '';
const whereClause = expr && !isSystemScope
? `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})${placement} OR u.account_type IN ('system', 'service'))`
: '';
const whereClause = `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ur2.org_id = ANY($1::UUID[]))${placement} OR u.account_type IN ('system', 'service'))`;
const { rows } = await db.query(
`SELECT
u.id, u.email, u.name, u.account_type, u.is_active, u.needs_placement, u.created_at,
@@ -68,7 +65,7 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
LEFT JOIN morbac.orgs o ON o.id = ur.org_id
${whereClause}
GROUP BY u.id ORDER BY u.name`,
whereClause ? params : [],
params,
);
return (rows as { account_type: string }[]).filter((u) => {
if (u.account_type === 'system' && !canSeeSystem) return false;
+40
View File
@@ -0,0 +1,40 @@
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { addPermissionHook, listScope } from '../dist/index.js';
const ROOT = '00000000-0000-0000-0000-000000000000';
const A = '00000000-0000-4000-8000-00000000000a';
const B = '00000000-0000-4000-8000-00000000000b';
const TREE = { [ROOT]: [ROOT, A, B], [A]: [A], [B]: [B] };
function fakeDb(allowed) {
return {
async query(sql, params) {
if (sql.includes('get_org_scope')) return { rows: [...new Set(params[0].flatMap((o) => TREE[o]))].map((id) => ({ id })) };
if (sql.includes('FROM morbac.orgs')) return { rows: Object.keys(TREE).map((id) => ({ id })) };
if (sql.includes('morbac.is_allowed')) return { rows: params[1].filter((o) => allowed.has(o)).map((o) => ({ o })) };
throw new Error(`unexpected query ${sql}`);
},
};
}
const req = { user: { id: 'u' } };
let removers = [];
beforeEach(() => { removers.forEach((r) => r()); removers = []; });
test('the root org stands for its subtree, decided org by org', async () => {
const { expr, params } = await listScope(fakeDb(new Set([ROOT, A])), req, [ROOT], 'read', 'users', 'x.org_id');
assert.equal(expr, 'x.org_id = ANY($1::UUID[])');
assert.deepEqual(params[0].sort(), [ROOT, A].sort());
});
test('an org the hooks refuse is left out of every list', async () => {
removers.push(addPermissionHook('pre', (c) => c.orgId !== B));
const { params } = await listScope(fakeDb(new Set([ROOT, A, B])), req, [], 'read', 'users', 'x.org_id');
assert.deepEqual(params[0].sort(), [ROOT, A].sort());
});
test('an unpinned list also shows rows with no org', async () => {
const { expr } = await listScope(fakeDb(new Set([A])), req, [], 'read', 'roles', 'r.org_id', 2);
assert.equal(expr, '(r.org_id = ANY($3::UUID[]) OR r.org_id IS NULL)');
});