6 Commits
Author SHA1 Message Date
marc 0f7db7e99e fix: lists decide every org through the permission system; no system-org shortcut 2026-11-02 10:43:29 +01:00
marc 12964793cf fix: list routes answer for permitted orgs only; org lists go through the hooks 2026-11-02 00:22:35 +01:00
marc bc05d312a6 feat: permission hooks, isAllowed and permittedOrgs 2026-10-21 14:38:15 +02:00
marc ee15ee0adf ci: stage releases for 2FA approval instead of publishing them
The npm trusted publisher may only stage, so a version goes live once a
maintainer approves it with 2FA. Staging needs npm 11.15 or later; Node 24.12
ships 11.6, so the job installs a pinned npm first.
2026-10-20 13:16:37 +02:00
marc 9b7003a691 ci: publish to npmjs on version tags with trusted publishing
No npm token: the workflow's OIDC identity is the npm trusted publisher, and
npm records provenance for the tag's commit.
2026-10-20 12:39:34 +02:00
marc 10fb0858f6 docs: 1.0.0 release link points at crudylabs/pgmorbac-fastify on GitHub 2026-10-03 15:32:06 +02:00
24 changed files with 569 additions and 139 deletions
+53
View File
@@ -0,0 +1,53 @@
# Stages @crudy/pgmorbac-fastify to npmjs on version tags (v<major>.<minor>.<patch>)
# through npm trusted publishing: GitHub's OIDC token stands in for an npm
# token, and npm attaches a provenance statement naming this repository and
# commit. The trusted publisher may only stage: a staged version goes live once
# a maintainer approves it with 2FA (npm stage approve, or npmjs.com). The
# organisation only allows its own actions, so checkout and Node setup are
# plain shell. prepack builds dist from src.
name: publish
on:
push:
tags: ['v*']
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
env:
NODE_VERSION: v24.12.0
steps:
- name: Check out the tag
run: |
case "$GITHUB_REF_NAME" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "not a version tag: $GITHUB_REF_NAME" >&2; exit 1 ;; esac
git init -q .
git fetch -q --depth 1 "https://github.com/$GITHUB_REPOSITORY" "refs/tags/$GITHUB_REF_NAME:refs/tags/$GITHUB_REF_NAME"
git checkout -q "refs/tags/$GITHUB_REF_NAME"
- name: Install Node
run: |
base="https://nodejs.org/dist/$NODE_VERSION"
file="node-$NODE_VERSION-linux-x64.tar.xz"
curl -fsSLO "$base/$file"
curl -fsSL "$base/SHASUMS256.txt" | grep " $file\$" | sha256sum -c -
mkdir -p "$RUNNER_TEMP/node"
tar -xJf "$file" -C "$RUNNER_TEMP/node" --strip-components=1
rm "$file"
echo "$RUNNER_TEMP/node/bin" >> "$GITHUB_PATH"
- name: Install npm with staged publishing
run: npm install -g npm@11.21.1
- run: npm install --no-audit --no-fund
- run: npm run check
- name: Publish
run: |
VERSION=$(node -p "require('./package.json').version")
[ "v$VERSION" = "$GITHUB_REF_NAME" ] || { echo "tag $GITHUB_REF_NAME does not match package version $VERSION" >&2; exit 1; }
NAME=$(node -p "require('./package.json').name")
if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then
echo "$NAME@$VERSION is already published"
else
npm stage publish
fi
+28 -1
View File
@@ -5,6 +5,33 @@ 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
- `permittedScope(db, req, reply, orgIds, activity, view)`: the orgs of a list request the caller may act in, through the hooks; 403 only when none passes.
### Fixed
- The list routes for users, roles, role settings, role, user and cross-org rules, delegations, permission profiles, user roles and activities checked only the first org of `X-Org-Ids`, then answered for every listed org. A caller allowed in the first org read the others' rows, and a caller refused in the first org got 403 for the whole list. They now answer for the permitted orgs only.
- The unpinned org list, the stored org filter and `readableOrgPaths` decided with `morbac.is_allowed` in SQL, which skips the permission hooks. They now go through them, so an org the hooks refuse is never offered.
## [1.1.0] - 2026-10-21
### Added
- `addPermissionHook('pre' | 'post', hook)`: application rules every permission decision runs. Hooks can only refuse; a throwing hook fails the decision.
- `isAllowed` and `permittedOrgs`: the hooked decision without a reply, for one org or several.
### Changed
- `hasPermission`, `hasPermissionAny`, `canPerform`, `canActOnUser`, `canGrantRule`, the user listing and role assignment decide through `isAllowed`, so hooks apply to each.
- The data proxy forwards only the `X-Org-Ids` the caller is allowed in, instead of every listed org once one passed. Rows are unchanged without hooks: RLS already hid the others.
## [1.0.0] - 2026-07-25
### Added
@@ -22,4 +49,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `withMorbacContext` accepts an org selection, serialised to `morbac.org_ids`
where a JSON null element names the unattributed bucket.
[1.0.0]: https://git.villains.fr/crudy/pgmorbac-node/releases/tag/v1.0.0
[1.0.0]: https://github.com/crudylabs/pgmorbac-fastify/releases/tag/v1.0.0
+17 -1
View File
@@ -24,7 +24,7 @@ The package is split into focused entry points so you only pull in what you need
| Import | Purpose |
|--------|---------|
| `@crudy/pgmorbac-fastify` | RLS session context and shared domain types |
| `@crudy/pgmorbac-fastify/permissions` | `hasPermission`, org-scope resolution, admin checks |
| `@crudy/pgmorbac-fastify/permissions` | `hasPermission`, `isAllowed`, `permittedOrgs`, permission hooks, org-scope resolution, admin checks |
| `@crudy/pgmorbac-fastify/proxy` | PostgREST data proxy with per-route permissions |
| `@crudy/pgmorbac-fastify/plugin` | Fastify management routes (orgs, roles, rules, users, delegations) |
| `@crudy/pgmorbac-fastify/list-query` | PostgREST-style sort/filter/pagination parsing |
@@ -48,6 +48,21 @@ app.get('/documents', async (req, reply) => {
});
```
For a decision without a reply, use `isAllowed(pool, userId, orgId, activity, view)`; for several orgs at once, `permittedOrgs`. Never query `morbac.is_allowed` directly: it skips the hooks below.
### Permission hooks
An application adds its own rules to every decision with `addPermissionHook`. A `pre` hook runs before morbac is asked and a `post` hook receives morbac's answer. Either can only refuse: a decision is allowed when morbac allows it and every hook returns `true`. A hook that throws fails the decision with its error.
```ts
import { addPermissionHook } from '@crudy/pgmorbac-fastify/permissions';
// No action in an org whose subscription lapsed.
addPermissionHook('pre', async ({ orgId }) => orgId === null || await subscriptionActive(orgId));
```
The hooks run in `hasPermission`, `hasPermissionAny`, `canPerform`, `canActOnUser`, `canGrantRule`, `isAllowed`, `permittedOrgs` and the data proxy, which forwards only the `X-Org-Ids` the caller is allowed in. `hasAnyPermission` names no org and runs none. Row-level security evaluates morbac alone, so a rule that must also hide rows belongs in the database.
### RLS session context
Run queries under the acting user so `morbac` row-level security policies apply as a second gate behind `hasPermission`. Required for non-`BYPASSRLS` runtime roles.
@@ -107,6 +122,7 @@ createMgmtRoutes({
- **Deny by default.** Unregistered routes do not exist; unauthorized reads return `404` over `403` to avoid enumeration.
- **Reads are checked too.** List and detail routes require the same permission checks as writes.
- **Two gates stacked.** `hasPermission` (application) plus `morbac` RLS (database) when running under a non-`BYPASSRLS` role.
- **Hooks only narrow.** An application rule added with `addPermissionHook` can refuse a decision morbac allowed, never allow one it refused.
- **Allowlisted queries.** `list-query` parameterizes values and allowlists sortable/filterable columns; user input is never interpolated.
## Related
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@crudy/pgmorbac-fastify",
"version": "1.0.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": [
@@ -76,7 +76,8 @@
"scripts": {
"build": "tsc",
"prepack": "npm run build",
"check": "tsc --noEmit"
"check": "tsc --noEmit",
"test": "tsc && node --test 'test/*.test.mjs'"
},
"publishConfig": {
"access": "public"
+5
View File
@@ -6,9 +6,14 @@ export {
orgSelectionToSetting,
orgTargetsToHeader,
hasAnyPermission,
isAllowed,
permittedOrgs,
permittedScope,
listScope,
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,
+6 -4
View File
@@ -1,4 +1,5 @@
import type { Pool } from 'pg';
import { permittedOrgs } from './permissions.js';
/**
* Wraps a query selecting the visible orgs (id, name, parent_id, metadata) and
@@ -26,10 +27,11 @@ export function withOrgPaths(visSql: string): string {
* the path start at the topmost org they can read, never above it.
*/
export async function readableOrgPaths(db: Pool, userId: string, view: string): Promise<Map<string, string[]>> {
const { rows: candidates } = await db.query<{ id: string }>(
`SELECT id FROM morbac.orgs WHERE morbac.is_allowed($1::UUID, id, 'read', $2)`, [userId, view]);
const readable = await permittedOrgs(db, userId, candidates.map((c) => c.id), 'read', view);
const { rows } = await db.query<{ id: string; path: string[] }>(
withOrgPaths(
`SELECT id, name, parent_id, NULL::jsonb AS metadata
FROM morbac.orgs WHERE morbac.is_allowed($1::UUID, id, 'read', $2)`),
[userId, view]);
withOrgPaths('SELECT id, name, parent_id, NULL::jsonb AS metadata FROM morbac.orgs WHERE id = ANY($1::UUID[])'),
[readable]);
return new Map(rows.map((r) => [r.id, r.path]));
}
+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;
}
+127 -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';
@@ -72,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';
}
@@ -122,18 +151,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 +176,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 +228,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 +263,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 +291,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 +322,90 @@ 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 orgs of a list request the caller may read in, or null once a 401 or 403
* is sent. An unpinned request (no org header) is decided on the no-org
* perimeter and answers []. A pinned one keeps only the orgs that pass, hooks
* included, and is refused only when none does: one refused org neither fails
* the list nor widens it to orgs the caller cannot read.
*/
export async function permittedScope(
db: Pool,
req: FastifyRequest,
reply: FastifyReply,
orgIds: string[],
activity: string,
target: string,
): Promise<string[] | null> {
if (orgIds.length === 0) return await hasPermission(db, req, reply, null, activity, target) ? [] : null;
const user = getUser(req);
if (!user) {
reply.code(401).send({ error: 'Authentication required' });
return null;
}
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
const allowed = scopeAllows(apiKeyScope, activity, target)
? await permittedOrgs(db, user.id, orgIds, activity, target, req)
: [];
if (allowed.length === 0) {
reply.code(403).send({ error: 'Insufficient permissions' });
return null;
}
return allowed;
}
/**
* 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' });
}
+11 -6
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, hasPermission } from '../permissions.js';
import { getUser, getOrgScope, permittedScope } from '../permissions.js';
import { unauthorized, badRequest, forbidden } from '../schemas.js';
export function createActivityRoutes(db: Pool) {
@@ -31,7 +31,8 @@ export function createActivityRoutes(db: Pool) {
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
if (!scope) return reply;
const { rows } = await db.query('SELECT name, description FROM morbac.activities ORDER BY name');
return rows;
});
@@ -70,7 +71,8 @@ export function createActivityRoutes(db: Pool) {
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
if (!scope) return reply;
const { name } = req.params as { name: string };
const { rows } = await db.query(
@@ -132,7 +134,8 @@ export function createActivityRoutes(db: Pool) {
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
if (!scope) return reply;
// no_org marks a view whose rows carry no org, so callers can offer the
// unattributed grant instead of a tree-scoped rule that could never match.
const { rows } = await db.query(
@@ -178,7 +181,8 @@ export function createActivityRoutes(db: Pool) {
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
if (!scope) return reply;
const { name } = req.params as { name: string };
const { rows } = await db.query(
@@ -239,7 +243,8 @@ export function createActivityRoutes(db: Pool) {
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
if (!scope) return reply;
const { rows } = await db.query(
'SELECT activity, view FROM morbac.activity_view_bindings ORDER BY activity, view',
);
+4 -3
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, 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 = `
@@ -30,9 +30,10 @@ export function createDelegationRoutes(db: Pool) {
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'delegations')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'delegations');
if (!scope) return reply;
const { expr, params } = orgScopeExpr(orgIds, '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
+26 -14
View File
@@ -3,11 +3,12 @@ import { withMorbacContext } from '../rls-context.js';
import type { Pool } from 'pg';
import type { CheckLimitFn, OrgEventSink } from '../plugin.js';
import {
getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID,
getUser, getOrgScope, listScope, hasPermission, permittedScope, permittedOrgs, SYSTEM_ORG_ID,
} from '../permissions.js';
import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { withOrgPaths } from '../org-paths.js';
import { runPreHooks, runPostHooks } from '../permission-hooks.js';
interface OrgRow { id: string; name: string; parent_id: string | null; metadata?: Record<string, unknown> }
@@ -40,28 +41,39 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven
// caller in it even with no read/orgs rule, and the org filter is
// what supplies X-Org-Id, so dropping those orgs leaves such a
// user with an empty perimeter and an app that does nothing.
// Both ways in still go through the permission hooks, so an org
// the hooks refuse never reaches the filter and every request
// pinned to it fails.
const { rows: candidates } = await db.query<{ id: string; member: boolean }>(
`SELECT o.id, EXISTS (SELECT 1 FROM morbac.user_roles ur WHERE ur.org_id = o.id AND ur.user_id = $1) AS member
FROM morbac.orgs o
WHERE morbac.is_allowed($1::UUID, o.id, 'read', 'orgs')
OR EXISTS (SELECT 1 FROM morbac.user_roles ur WHERE ur.org_id = o.id AND ur.user_id = $1)`,
[user.id],
);
const readable = new Set(await permittedOrgs(db, user.id, candidates.map((c) => c.id), 'read', 'orgs', req));
const visible: string[] = [];
for (const c of candidates) {
const check = { userId: user.id, orgId: c.id, activity: 'read', view: 'orgs', req };
if (readable.has(c.id) || (c.member && await runPreHooks(check, db) && await runPostHooks(check, true, db))) visible.push(c.id);
}
const { rows } = await withMorbacContext(db, { userId: user.id }, (client) =>
client.query(
withOrgPaths(
`SELECT id, name, parent_id, metadata FROM morbac.orgs o
WHERE morbac.is_allowed($1::UUID, o.id, 'read', 'orgs')
OR EXISTS (SELECT 1 FROM morbac.user_roles ur
WHERE ur.org_id = o.id AND ur.user_id = $1)`,
),
[user.id],
withOrgPaths('SELECT id, name, parent_id, metadata FROM morbac.orgs WHERE id = ANY($1::UUID[])'),
[visible],
),
);
return rows;
}
if (!await hasPermission(db, req, reply, orgIds[0], 'read', 'orgs')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'orgs');
if (!scope) return reply;
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(orgIds, 'id');
const { rows } = await withMorbacContext(db, { userId: user.id, orgId: orgIds[0] }, (client) =>
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 -2
View File
@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
import { getUser, getOrgScope, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
import { getUser, getOrgScope, hasPermission, permittedScope, SYSTEM_ORG_ID } from '../permissions.js';
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { loadProfile, validateGrantable, applyProfileRules } from './profile-apply.js';
@@ -58,7 +58,8 @@ export function createPermissionProfileRoutes(db: Pool, checkLimit?: CheckLimitF
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
if (!scope) return reply;
const { module_id } = req.query as { module_id?: string };
const { rows } = await db.query(
+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');
}
+7 -5
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, 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';
@@ -30,9 +30,10 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
if (!scope) return reply;
const { expr, params } = orgScopeExpr(orgIds, '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>, {
@@ -214,9 +215,10 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
if (!scope) return reply;
const { expr, params } = orgScopeExpr(orgIds, '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,
+6 -9
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, 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) {
@@ -23,14 +23,11 @@ export function createCrossOrgRuleRoutes(db: Pool) {
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'cross_org_rules')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'cross_org_rules');
if (!scope) return reply;
const { expr: srcExpr, params: srcParams } = orgScopeExpr(orgIds, 'cor.source_org_id');
const { expr: tgtExpr, params: tgtParams } = orgScopeExpr(orgIds, '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,
@@ -45,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;
});
+4 -3
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, 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) {
@@ -23,9 +23,10 @@ export function createRoleRuleRoutes(db: Pool) {
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
if (!scope) return reply;
const { expr, params } = orgScopeExpr(orgIds, '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,
+4 -3
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, 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) {
@@ -23,9 +23,10 @@ export function createUserRuleRoutes(db: Pool) {
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'user_rules')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'user_rules');
if (!scope) return reply;
const { expr, params } = orgScopeExpr(orgIds, '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,
+12 -20
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, canActOnUser, checkApiKeyScope,
getAccountType, viewForType, 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';
@@ -30,31 +30,23 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
if (!scope) return reply;
const checkOrg = orgIds[0] ?? null;
const checkOrg = scope[0] ?? null;
// A user awaiting placement belongs to no org, so seeing one is the
// 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);
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,
@@ -73,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;
+3 -7
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { Pool } from 'pg';
import { getUser, canActOnUser, checkApiKeyScope, getAccountType, viewForType } from '../permissions.js';
import { getUser, canActOnUser, permittedOrgs, checkApiKeyScope, getAccountType, viewForType } from '../permissions.js';
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
const MAX_ORG_FILTER = 100;
@@ -21,12 +21,8 @@ const orgFilterResponse = {
* direction can be used to probe orgs outside the caller's reach. */
async function visibleOrgIds(db: Pool, userId: string, ids: string[]): Promise<string[]> {
if (ids.length === 0) return [];
const { rows } = await db.query<{ id: string }>(
`SELECT o.id FROM morbac.orgs o
WHERE o.id = ANY($2::UUID[]) AND morbac.is_allowed($1::UUID, o.id, 'read', 'orgs')`,
[userId, ids],
);
return rows.map((r) => r.id);
const { rows } = await db.query<{ id: string }>('SELECT id FROM morbac.orgs WHERE id = ANY($1::UUID[])', [ids]);
return permittedOrgs(db, userId, rows.map((r) => r.id), 'read', 'orgs');
}
async function storedOrgFilter(db: Pool, userId: string): Promise<string[]> {
+3 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js';
import { getUser, getOrgScope, hasPermission, permittedScope, getAccountType } from '../permissions.js';
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { callerControlsRoleOrg } from './role-authority.js';
@@ -99,7 +99,8 @@ export function createUserRoleRoutes(db: Pool) {
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
if (!scope) return reply;
const { id } = req.params as { id: string };
const exists = await db.query('SELECT 1 FROM app.users WHERE id = $1', [id]);
+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)');
});
+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);
});
+55
View File
@@ -0,0 +1,55 @@
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { addPermissionHook, permittedScope } from '../dist/index.js';
const A = '00000000-0000-4000-8000-00000000000a';
const B = '00000000-0000-4000-8000-00000000000b';
function fakeDb(allowed) {
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]) }] };
throw new Error(`unexpected query ${sql}`);
},
};
}
function fakeReply() {
return { status: 200, code(c) { this.status = c; return this; }, send() { return this; } };
}
const req = { user: { id: 'u' } };
let removers = [];
beforeEach(() => { removers.forEach((r) => r()); removers = []; });
test('a pinned list keeps only the orgs the caller may read', async () => {
const reply = fakeReply();
assert.deepEqual(await permittedScope(fakeDb(new Set([B])), req, reply, [A, B], 'read', 'users'), [B]);
assert.equal(reply.status, 200);
});
test('a refused first org no longer fails the list', async () => {
removers.push(addPermissionHook('pre', (c) => c.orgId !== A));
assert.deepEqual(await permittedScope(fakeDb(new Set([A, B])), req, fakeReply(), [A, B], 'read', 'users'), [B]);
});
test('a list with no permitted org is refused', async () => {
const reply = fakeReply();
assert.equal(await permittedScope(fakeDb(new Set()), req, reply, [A, B], 'read', 'users'), null);
assert.equal(reply.status, 403);
});
test('an unpinned list is decided on the no-org perimeter', async () => {
assert.deepEqual(await permittedScope(fakeDb(new Set([null])), req, fakeReply(), [], 'read', 'users'), []);
const reply = fakeReply();
assert.equal(await permittedScope(fakeDb(new Set()), req, reply, [], 'read', 'users'), null);
assert.equal(reply.status, 403);
});
test('an API key outside its scope gets nothing', async () => {
const reply = fakeReply();
const keyed = { ...req, apiKeyScope: [{ activity: 'read', view: 'roles' }] };
assert.equal(await permittedScope(fakeDb(new Set([A])), keyed, reply, [A], 'read', 'users'), null);
assert.equal(reply.status, 403);
});