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
+3
View File
@@ -6,9 +6,12 @@ export {
orgSelectionToSetting,
orgTargetsToHeader,
hasAnyPermission,
isAllowed,
permittedOrgs,
type OrgTarget,
type OrgSelection,
} from './permissions.js';
export { addPermissionHook, type PermissionCheck, type PrePermissionHook, type PostPermissionHook } from './permission-hooks.js';
export type { DataPermission } from './proxy.js';
export type {
Org,
+55
View File
@@ -0,0 +1,55 @@
import type { FastifyRequest } from 'fastify';
import type { Pool } from 'pg';
/** One permission decision: may this user perform (activity, view) in this org. */
export interface PermissionCheck {
userId: string;
/** The org acted in, or null for the no-org perimeter. */
orgId: string | null;
activity: string;
view: string;
/** The request behind the decision, when there is one. */
req?: FastifyRequest;
}
/** Runs before morbac is asked. Returning false refuses without asking it. */
export type PrePermissionHook = (check: PermissionCheck, db: Pool) => boolean | Promise<boolean>;
/** Runs after morbac answered, with that answer. Returning false refuses. */
export type PostPermissionHook = (check: PermissionCheck, allowed: boolean, db: Pool) => boolean | Promise<boolean>;
const pre: PrePermissionHook[] = [];
const post: PostPermissionHook[] = [];
/**
* Adds an application rule to every permission decision the helpers make. A
* hook can only narrow: a decision is allowed when morbac allows it and every
* hook agrees, so no hook can grant what morbac refused. A hook that throws
* fails the decision with its error; nothing is swallowed into an allow.
* Returns a function that removes the hook.
*/
export function addPermissionHook(phase: 'pre', hook: PrePermissionHook): () => void;
export function addPermissionHook(phase: 'post', hook: PostPermissionHook): () => void;
export function addPermissionHook(phase: 'pre' | 'post', hook: PrePermissionHook | PostPermissionHook): () => void {
const list = (phase === 'pre' ? pre : post) as unknown[];
list.push(hook);
return () => {
const i = list.indexOf(hook);
if (i !== -1) list.splice(i, 1);
};
}
export async function runPreHooks(check: PermissionCheck, db: Pool): Promise<boolean> {
for (const hook of pre) {
if (await hook(check, db) !== true) return false;
}
return true;
}
export async function runPostHooks(check: PermissionCheck, allowed: boolean, db: Pool): Promise<boolean> {
let result = allowed;
for (const hook of post) {
if (await hook(check, allowed, db) !== true) result = false;
}
return result;
}
+69 -42
View File
@@ -1,5 +1,8 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { Pool } from 'pg';
import { runPostHooks, runPreHooks } from './permission-hooks.js';
export { addPermissionHook, type PermissionCheck, type PrePermissionHook, type PostPermissionHook } from './permission-hooks.js';
export const SYSTEM_ORG_ID = '00000000-0000-0000-0000-000000000000';
@@ -122,18 +125,12 @@ export async function canActOnUser(
activity: string,
view: string,
): Promise<boolean> {
const { rows } = await db.query<{ ok: boolean }>(
`SELECT (
morbac.is_allowed($1::UUID, NULL::UUID, $3, $4)
OR EXISTS (
SELECT 1 FROM morbac.user_roles ur
WHERE ur.user_id = $2::UUID
AND morbac.is_allowed($1::UUID, ur.org_id, $3, $4)
)
) AS ok`,
[callerId, targetUserId, activity, view],
if (await isAllowed(db, callerId, null, activity, view)) return true;
const { rows } = await db.query<{ org_id: string }>(
'SELECT DISTINCT org_id FROM morbac.user_roles WHERE user_id = $1::UUID',
[targetUserId],
);
return rows[0]?.ok ?? false;
return (await permittedOrgs(db, callerId, rows.map((r) => r.org_id), activity, view)).length > 0;
}
/** True if the caller may reach org (activity, view) at every org that a rule
@@ -153,20 +150,14 @@ export async function canGrantRule(
activity: string,
view: string,
): Promise<boolean> {
const { rows } = await db.query<{ ok: boolean }>(
`SELECT (
NOT EXISTS (
SELECT 1 FROM morbac.get_org_scope($2::UUID, $3) AS s(org_id)
WHERE NOT morbac.is_allowed($1::UUID, s.org_id, $4, $5)
)
AND (
$3 NOT IN ('unattributed', 'all')
OR morbac.is_allowed($1::UUID, NULL::UUID, $4, $5)
)
) AS ok`,
[callerId, ruleOrgId, scope, activity, view],
const { rows } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.get_org_scope($1::UUID, $2) AS s(org_id)',
[ruleOrgId, scope],
);
return rows[0]?.ok ?? false;
const covered = rows.map((r) => r.org_id);
if ((await permittedOrgs(db, callerId, covered, activity, view)).length !== covered.length) return false;
if (scope !== 'unattributed' && scope !== 'all') return true;
return isAllowed(db, callerId, null, activity, view);
}
/** True iff (activity, view) has an explicit row in morbac.activity_view_bindings.
@@ -211,7 +202,8 @@ export async function requireGlobalAdmin(db: Pool, req: FastifyRequest, reply: F
/** True if the user may perform (activity, view) in ANY context: any org they
* belong to, unattributed objects, or via a global rule. For deciding whether
* to expose a feature at all -- never a substitute for the per-object check. */
* to expose a feature at all -- never a substitute for the per-object check.
* Names no org, so permission hooks do not run. */
export async function hasAnyPermission(
db: Pool,
userId: string,
@@ -245,11 +237,7 @@ export async function canPerform(
if (!user) return false;
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
if (!scopeAllows(apiKeyScope, activity, target)) return false;
const { rows } = await db.query<{ allowed: boolean }>(
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, orgId, activity, target],
);
return rows[0]?.allowed === true;
return isAllowed(db, user.id, orgId, activity, target, req);
}
/** Reads an API key's scope off a request, for callers that must reason about it. */
@@ -277,11 +265,7 @@ export async function hasPermission(
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
const { rows } = await db.query<{ allowed: boolean }>(
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, orgId, activity, target],
);
if (!rows[0]?.allowed) {
if (!await isAllowed(db, user.id, orgId, activity, target, req)) {
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
@@ -312,15 +296,58 @@ export async function hasPermissionAny(
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
const real = orgIds.filter((v) => v && v.toLowerCase() !== 'unattributed');
const { rows } = await db.query<{ allowed: boolean }>(
`SELECT bool_or(morbac.is_allowed($1::UUID, o::UUID, $3, $4)) AS allowed
FROM unnest($2::UUID[]) o`,
[user.id, real, activity, target],
);
if (!rows[0]?.allowed) {
const real = orgIds.filter((v) => v && v.toLowerCase() !== UNATTRIBUTED_ORG_TOKEN);
if ((await permittedOrgs(db, user.id, real, activity, target, req)).length === 0) {
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
return true;
}
/**
* The one permission decision every helper makes: morbac's answer, narrowed by
* the application's permission hooks (`addPermissionHook`). Use it instead of
* querying `morbac.is_allowed` directly, which would skip those hooks.
*/
export async function isAllowed(
db: Pool,
userId: string,
orgId: string | null,
activity: string,
view: string,
req?: FastifyRequest,
): Promise<boolean> {
const check = { userId, orgId, activity, view, req };
if (!await runPreHooks(check, db)) return false;
const { rows } = await db.query<{ allowed: boolean }>(
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[userId, orgId, activity, view],
);
return runPostHooks(check, rows[0]?.allowed === true, db);
}
/** The orgs among `orgIds` where `isAllowed` would answer yes, in their given order, asked of morbac in one query. */
export async function permittedOrgs(
db: Pool,
userId: string,
orgIds: string[],
activity: string,
view: string,
req?: FastifyRequest,
): Promise<string[]> {
const candidates: string[] = [];
for (const orgId of new Set(orgIds)) {
if (await runPreHooks({ userId, orgId, activity, view, req }, db)) candidates.push(orgId);
}
if (candidates.length === 0) return [];
const { rows } = await db.query<{ o: string }>(
'SELECT o FROM unnest($2::UUID[]) o WHERE morbac.is_allowed($1::UUID, o, $3, $4)',
[userId, candidates, activity, view],
);
const allowed = new Set(rows.map((r) => r.o.toLowerCase()));
const out: string[] = [];
for (const orgId of candidates) {
if (await runPostHooks({ userId, orgId, activity, view, req }, allowed.has(orgId.toLowerCase()), db)) out.push(orgId);
}
return out;
}
+11 -7
View File
@@ -1,6 +1,6 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { Pool } from 'pg';
import { hasPermission, hasPermissionAny } from './permissions.js';
import { checkApiKeyScope, hasPermission, permittedOrgs, UNATTRIBUTED_ORG_TOKEN } from './permissions.js';
export interface DataPermission {
activity: string;
@@ -95,13 +95,17 @@ export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegist
return reply;
}
} else if (orgIdsVal && orgIdsVal.trim()) {
// A multi-org list read (e.g. "all organizations"): scoped to the
// set via X-Org-Ids. RLS filters each row, so the gate only needs
// permission in one of them.
// A multi-org list read (e.g. "all organizations"). Only the orgs
// where the caller is allowed go upstream: RLS filters rows by
// morbac alone, so an org a permission hook refused would
// otherwise still show its rows.
if (!checkApiKeyScope(req, reply, permission.activity, permission.view)) return reply;
const ids = orgIdsVal.split(',').map(s => s.trim()).filter(Boolean);
if (!await hasPermissionAny(db, req, reply, ids, permission.activity, permission.view)) {
return reply;
}
const unattributed = ids.some((id) => id.toLowerCase() === UNATTRIBUTED_ORG_TOKEN);
const real = ids.filter((id) => id.toLowerCase() !== UNATTRIBUTED_ORG_TOKEN);
const allowed = await permittedOrgs(db, user.id, real, permission.activity, permission.view, req);
if (allowed.length === 0) return reply.code(403).send({ error: 'Insufficient permissions' });
req.headers['x-org-ids'] = (unattributed ? [...allowed, UNATTRIBUTED_ORG_TOKEN] : allowed).join(',');
} else {
return reply.code(400).send({ error: 'X-Org-Id header is required' });
}
+3 -8
View File
@@ -1,4 +1,5 @@
import type { Pool } from 'pg';
import { isAllowed } from '../permissions.js';
/**
* Whether the caller may hand out this role at all: assigning it is exercising
@@ -7,12 +8,6 @@ import type { Pool } from 'pg';
export async function callerControlsRoleOrg(
db: Pool, callerId: string, roleId: string, activity: string,
): Promise<boolean> {
const { rows } = await db.query<{ ok: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM morbac.roles r
WHERE r.id = $2::UUID AND morbac.is_allowed($1::UUID, r.org_id, $3, 'user_roles')
) AS ok`,
[callerId, roleId, activity],
);
return rows[0]?.ok ?? false;
const { rows } = await db.query<{ org_id: string | null }>('SELECT org_id FROM morbac.roles WHERE id = $1::UUID', [roleId]);
return rows.length > 0 && isAllowed(db, callerId, rows[0].org_id, activity, 'user_roles');
}
+5 -11
View File
@@ -4,7 +4,7 @@ import bcrypt from 'bcryptjs';
import type { CheckLimitFn } from '../plugin.js';
import {
getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, checkApiKeyScope,
getAccountType, viewForType, SYSTEM_ORG_ID,
getAccountType, viewForType, isAllowed, SYSTEM_ORG_ID,
} from '../permissions.js';
import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { generatePassword } from '../password.js';
@@ -37,17 +37,11 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
// no-org perimeter (a NULL org), not a claim about every org. Any
// grant that reaches it answers: a role rule scoped unattributed or
// all, a user rule with no org, or a global rule.
const [sysRes, svcRes, noOrgRes] = await Promise.all([
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, checkOrg, 'read', 'system_users']),
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, checkOrg, 'read', 'service_users']),
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, NULL, $2, $3) AS allowed',
[user.id, 'read', 'users']),
const [canSeeSystem, canSeeService, canReadUnattached] = await Promise.all([
isAllowed(db, user.id, checkOrg, 'read', 'system_users', req),
isAllowed(db, user.id, checkOrg, 'read', 'service_users', req),
isAllowed(db, user.id, null, 'read', 'users', req),
]);
const canSeeSystem = sysRes.rows[0]?.allowed ?? false;
const canSeeService = svcRes.rows[0]?.allowed ?? false;
const canReadUnattached = noOrgRes.rows[0]?.allowed ?? false;
const { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id');
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);