Compare commits
4
Commits
v1.0.0
...
bc05d312a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc05d312a6 | ||
|
|
ee15ee0adf | ||
|
|
9b7003a691 | ||
|
|
10fb0858f6 |
@@ -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
|
||||
+11
-1
@@ -5,6 +5,16 @@ 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.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 +32,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
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/pgmorbac-fastify",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user