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
+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;