8 Commits
Author SHA1 Message Date
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
marc 73d3bc36c8 fix: exports resolve dist only
The source condition pointed consumers at TypeScript sources; it only made sense
inside the crudy monorepo.
2026-10-03 10:00:58 +02:00
marc 94f1f13302 feat: @crudy/pgmorbac-fastify, MIT, its own repository
The Fastify layer over pgmorbac: permission checks, RLS session context,
management routes, PostgREST data proxy, and the migration loader for the SQL
from @crudy/pgmorbac. The SQL is no longer bundled here.
2026-10-03 09:58:18 +02:00
marc a1d6f471e2 release: 1.0.0 2026-10-02 22:48:16 +02:00
marc e470b670c2 docs: add LICENSE, NOTICE, README, CHANGELOG, and npm metadata
The public repo shipped with no license, readme, or changelog. Add them
plus standard package metadata (license field, keywords, author,
homepage, repository, bugs) so the Gitea/npm pages present the package
professionally. README and CHANGELOG describe the exports this repo
actually contains.
2026-07-23 13:34:21 +02:00
35 changed files with 1958 additions and 192 deletions
-56
View File
@@ -1,56 +0,0 @@
# Per-repo Drone pipeline template for an extracted @crudy/* package or module (RFC 0006).
# Copy this to .drone.yml in the standalone repo. It consumes dependencies from Verdaccio and
# publishes the package on a tag. Set the `verdaccio_url` and `npm_token` Drone secrets.
---
kind: pipeline
type: docker
name: default
trigger:
event:
- push
- pull_request
- tag
steps:
- name: install
image: node:24
environment:
VERDACCIO:
from_secret: verdaccio_url
commands:
- npm ci --registry "$VERDACCIO"
- name: lint
image: node:24
commands:
- npm run --if-present lint -- --max-warnings=0
- name: typecheck
image: node:24
commands:
- npx tsc --noEmit
- name: test
image: node:24
commands:
- npm test --if-present
- name: build
image: node:24
commands:
- npm run --if-present build
- name: publish
image: node:24
environment:
VERDACCIO:
from_secret: verdaccio_url
NPM_TOKEN:
from_secret: npm_token
commands:
- echo "//${VERDACCIO#*://}:_authToken=$NPM_TOKEN" > .npmrc
- npm publish --registry "$VERDACCIO"
when:
event:
- tag
+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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
package-lock.json
+35
View File
@@ -0,0 +1,35 @@
# Changelog
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
- First stable release of the Node.js companion to the `pgmorbac` PostgreSQL extension.
- `hasPermission` and org-scope helpers backed by `morbac.is_allowed`.
- `withMorbacContext` for running queries under the acting user so RLS applies.
- PostgREST data proxy with per-route permissions and a hard page-size cap.
- `morbacMigrations()` loader that ships the bundled extension SQL.
- Fastify management routes for orgs, roles, rules, users, global rules, activities, and delegations.
- PostgREST-style list-query parsing with allowlisted sort/filter columns.
- JSON schema response fragments and shared domain types.
- Unattributed org target: `UNATTRIBUTED`, `OrgTarget` and `OrgSelection` express
a specific organisation, records with no org, or every org at once.
- `hasAnyPermission` wraps the `morbac.has_permission` capability probe for UI gating.
- `withMorbacContext` accepts an org selection, serialised to `morbac.org_ids`
where a JSON null element names the unattributed bucket.
[1.0.0]: https://github.com/crudylabs/pgmorbac-fastify/releases/tag/v1.0.0
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-2026 Marc Villain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+135
View File
@@ -0,0 +1,135 @@
# @crudy/pgmorbac-fastify
**Multi-OrBAC permission helpers for Fastify and PostgreSQL.**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Node](https://img.shields.io/badge/Node-%3E%3D18-brightgreen.svg)](https://nodejs.org/)
This is the Node.js companion to the [`pgmorbac` PostgreSQL extension](https://github.com/crudylabs/pgmorbac). The extension implements the Multi-OrBAC access control model in the database (`morbac` schema); this package wires it into a Fastify backend: permission checks, RLS session context, management routes, a PostgREST data proxy, and the migration loader for the extension SQL from [`@crudy/pgmorbac`](https://www.npmjs.com/package/@crudy/pgmorbac).
> MIT, like the pgmorbac SQL extension it builds on. See [LICENSE](LICENSE).
## Install
```bash
npm install @crudy/pgmorbac-fastify
```
Peer dependencies (both optional, install the ones you use): `fastify >=5`, `pg >=8`.
## Exports
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`, `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 |
| `@crudy/pgmorbac-fastify/schemas` | JSON schema response fragments |
| `@crudy/pgmorbac-fastify/migrations` | Loads the bundled `morbac` extension SQL as migrations |
| `@crudy/pgmorbac-fastify/api` | Domain type definitions (`Org`, `Role`, `Rule`, ...) |
## Usage
### Permission checks
Every org-scoped route authorizes with `hasPermission`. Authentication is necessary but not sufficient; the check calls `morbac.is_allowed` and requires an `X-Org-Id` header.
```ts
import { hasPermission } from '@crudy/pgmorbac-fastify/permissions';
app.get('/documents', async (req, reply) => {
const orgId = req.headers['x-org-id'] as string;
if (!(await hasPermission(pool, req, reply, orgId, 'read', 'documents'))) return;
// ... authorized: read and return rows
});
```
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.
```ts
import { withMorbacContext } from '@crudy/pgmorbac-fastify';
const rows = await withMorbacContext(pool, { userId, orgId }, async (client) => {
const { rows } = await client.query('SELECT * FROM app.orgs');
return rows;
});
```
### Data proxy
Expose an `app`-schema table through the backend with an explicit permission. A path that is never registered does not exist (fail closed).
```ts
import { createProxyRegistry } from '@crudy/pgmorbac-fastify/proxy';
const proxy = createProxyRegistry(pool, process.env.POSTGREST_URL!);
proxy.proxyDataRoute(app, 'GET', '/data/documents', 'documents', { activity: 'read', view: 'documents' });
```
Pass `null` as the permission for any-authenticated-user access with no org check. Page size is capped at `MAX_PAGE_LIMIT` (1000); over-large `limit` values are rejected, never silently clamped.
### Migrations
`morbacMigrations()` returns the extension SQL from `@crudy/pgmorbac` (install script plus upgrade scripts) ready for your migration runner. Feed it before your own DDL.
```ts
import { morbacMigrations } from '@crudy/pgmorbac-fastify/migrations';
for (const { filename, sql } of morbacMigrations()) {
await runMigration(filename, sql);
}
```
The base script keeps the historical filename `0001_morbac.sql` so `schema_migrations` rows on existing databases stay valid; upgrade scripts follow in version order.
### Management routes
Register CRUD routes for orgs, roles, rules, users, global rules, activities, and delegations. Supply optional hooks for license-limit enforcement and event emission.
```ts
import { createMgmtRoutes } from '@crudy/pgmorbac-fastify/plugin';
createMgmtRoutes({
db: pool,
checkLimit: async (reply, name, sql, params) => { /* enforce quotas */ return true; },
emitOrgEvent: (event) => { /* audit / notify */ },
});
```
## Security model
- **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
- [pgmorbac](https://github.com/crudylabs/pgmorbac) — the PostgreSQL extension (MIT)
- [Documentation](https://pgmorbac.crudy.fr) — model, architecture, integration guides
## License
MIT (c) 2025-2026 Marc Villain. See [LICENSE](LICENSE).
+79 -14
View File
@@ -1,28 +1,89 @@
{
"name": "@crudy/pgmorbac",
"version": "0.1.0",
"name": "@crudy/pgmorbac-fastify",
"version": "1.1.0",
"type": "module",
"description": "Crudy pgmorbac — Multi-OrBAC permission helpers for Fastify and PostgreSQL",
"description": "Fastify layer over pgmorbac: permission checks, RLS session context, management routes, PostgREST data proxy, migration loader",
"keywords": [
"access-control",
"authorization",
"fastify",
"morbac",
"multi-tenant",
"orbac",
"pgmorbac",
"postgresql",
"rbac",
"rls"
],
"author": "Marc Villain",
"homepage": "https://pgmorbac.crudy.fr",
"repository": {
"type": "git",
"url": "git+https://github.com/crudylabs/pgmorbac-fastify.git"
},
"bugs": {
"url": "https://github.com/crudylabs/pgmorbac-fastify/issues"
},
"license": "MIT",
"exports": {
".": { "types": "./dist/index.d.ts", "source": "./src/index.ts", "default": "./dist/index.js" },
"./permissions": { "types": "./dist/permissions.d.ts", "source": "./src/permissions.ts", "default": "./dist/permissions.js" },
"./proxy": { "types": "./dist/proxy.d.ts", "source": "./src/proxy.ts", "default": "./dist/proxy.js" },
"./plugin": { "types": "./dist/plugin.d.ts", "source": "./src/plugin.ts", "default": "./dist/plugin.js" },
"./schemas": { "types": "./dist/schemas.d.ts", "source": "./src/schemas.ts", "default": "./dist/schemas.js" },
"./api": { "types": "./dist/api.d.ts", "source": "./src/api.ts", "default": "./dist/api.js" }
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./permissions": {
"types": "./dist/permissions.d.ts",
"default": "./dist/permissions.js"
},
"./proxy": {
"types": "./dist/proxy.d.ts",
"default": "./dist/proxy.js"
},
"./list-query": {
"types": "./dist/list-query.d.ts",
"default": "./dist/list-query.js"
},
"./rls-context": {
"types": "./dist/rls-context.d.ts",
"default": "./dist/rls-context.js"
},
"./plugin": {
"types": "./dist/plugin.d.ts",
"default": "./dist/plugin.js"
},
"./schemas": {
"types": "./dist/schemas.d.ts",
"default": "./dist/schemas.js"
},
"./api": {
"types": "./dist/api.d.ts",
"default": "./dist/api.js"
},
"./migrations": {
"types": "./dist/migrations.d.ts",
"default": "./dist/migrations.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "package.json"],
"files": [
"dist",
"package.json",
"src",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
"build": "tsc",
"prepack": "npm run build"
"prepack": "npm run build",
"check": "tsc --noEmit",
"test": "tsc && node --test 'test/*.test.mjs'"
},
"publishConfig": {
"registry": "http://localhost:4873",
"access": "public"
},
"dependencies": {
"@crudy/pgmorbac": "^1.0.0",
"bcryptjs": "^2"
},
"peerDependencies": {
@@ -30,8 +91,12 @@
"pg": ">=8"
},
"peerDependenciesMeta": {
"fastify": { "optional": true },
"pg": { "optional": true }
"fastify": {
"optional": true
},
"pg": {
"optional": true
}
},
"devDependencies": {
"@types/bcryptjs": "^2",
+106 -19
View File
@@ -3,6 +3,8 @@ export interface Org {
name: string;
parent_id: string | null;
metadata: Record<string, unknown>;
/** Names from the nearest visible ancestor down to this org. Server-computed. */
path?: string[];
}
export interface Role {
@@ -18,6 +20,23 @@ export interface RoleDetail {
org_name: string;
name: string;
description: string | null;
profile_module_id?: string | null;
profile_key?: string | null;
profile_synced?: boolean | null;
}
export interface PermissionProfileRule {
activity: string;
view: string;
modality: 'permission' | 'prohibition';
scope: string;
}
export interface PermissionProfile {
module_id: string;
key: string;
content_hash: string;
rules: PermissionProfileRule[];
}
export interface Activity {
@@ -28,6 +47,10 @@ export interface Activity {
export interface View {
name: string;
description: string | null;
/** Rows in this view carry no org, so grant it with an unattributed (or all)
* scope rule, a user rule with no org, or a global rule. A tree-scoped rule
* could never match. */
no_org?: boolean;
}
export interface Rule {
@@ -41,6 +64,9 @@ export interface Rule {
modality: 'permission' | 'prohibition';
priority: number | null;
scope: string;
valid_from: string | null;
valid_until: string | null;
is_active: boolean;
}
export interface CrossOrgRule {
@@ -60,10 +86,11 @@ export interface CrossOrgRule {
export interface UserRule {
id: string;
user_id: string;
org_id: string;
/** null = unattributed: the rule targets objects with no org. */
org_id: string | null;
user_name: string;
user_email: string | null;
org_name: string;
org_name: string | null;
activity: string;
view: string;
modality: 'permission' | 'prohibition';
@@ -165,6 +192,7 @@ export type ActivityViewBinding = { activity: string; view: string };
export interface MorbacApi {
checkPermission(token: string, orgId: string, activity: string, view: string): Promise<boolean>;
clearPermissionCache(): void;
getOrgs(token: string, orgIds: string[]): Promise<Org[]>;
createOrg(token: string, orgIds: string[], data: { name: string; parent_id?: string }): Promise<Org>;
updateOrg(token: string, orgIds: string[], id: string, data: { name?: string; parent_id?: string | null }): Promise<Org>;
@@ -173,6 +201,10 @@ export interface MorbacApi {
createRole(token: string, orgIds: string[], data: { org_id: string; name: string; description?: string }): Promise<RoleDetail>;
updateRole(token: string, orgIds: string[], id: string, data: { name?: string; description?: string }): Promise<RoleDetail>;
deleteRole(token: string, orgIds: string[], id: string): Promise<null>;
getPermissionProfiles(token: string, orgIds: string[], moduleId?: string): Promise<PermissionProfile[]>;
createRoleFromProfile(token: string, orgIds: string[], data: { org_id: string; name: string; description?: string; sync?: boolean; profile: { module_id: string; key: string } }): Promise<RoleDetail>;
attachRoleProfile(token: string, orgIds: string[], roleId: string, data: { profile: { module_id: string; key: string }; sync?: boolean }): Promise<{ role_id: string; module_id: string; profile_key: string; synced: boolean; applied_hash: string }>;
detachRoleProfile(token: string, orgIds: string[], roleId: string): Promise<null>;
getRoleHierarchy(token: string, orgIds: string[]): Promise<RoleHierarchy[]>;
addRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise<unknown>;
removeRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise<unknown>;
@@ -186,19 +218,21 @@ export interface MorbacApi {
createCrossOrgRule(token: string, orgIds: string[], data: { source_org_id: string; source_role_id: string; target_org_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise<CrossOrgRule>;
deleteCrossOrgRule(token: string, orgIds: string[], id: string): Promise<null>;
getUserRules(token: string, orgIds: string[]): Promise<UserRule[]>;
createUserRule(token: string, orgIds: string[], data: { user_id: string; org_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise<UserRule>;
createUserRule(token: string, orgIds: string[], data: { user_id: string; org_id: string | null; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise<UserRule>;
deleteUserRule(token: string, orgIds: string[], id: string): Promise<null>;
getGlobalRules(token: string, orgIds: string[]): Promise<GlobalRule[]>;
createGlobalRule(token: string, orgIds: string[], data: { user_id?: string | null; activity?: string | null; view?: string | null; modality: 'permission' | 'prohibition'; priority?: number }): Promise<GlobalRule>;
deleteGlobalRule(token: string, orgIds: string[], id: string): Promise<null>;
getUsers(token: string, orgIds: string[]): Promise<UserDetail[]>;
createUser(token: string, orgIds: string[], data: { email: string; name: string; password?: string; account_type?: 'user' | 'service' }): Promise<UserDetail>;
createUser(token: string, orgIds: string[], data: { email: string; name: string; account_type?: 'user' | 'service'; placement?: { role_id: string; org_id: string } }): Promise<UserDetail>;
updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; reset_password?: boolean }): Promise<UserDetail>;
deleteUser(token: string, orgIds: string[], id: string): Promise<null>;
getUserKeys(token: string, orgIds: string[], userId: string): Promise<ServiceAccountKey[]>;
getUserPermissionOptions(token: string, orgIds: string[], userId: string): Promise<Array<{ activity: string; view: string }>>;
createUserKey(token: string, orgIds: string[], userId: string, data: { name: string; description?: string; readonly?: boolean; expires_at?: string | null; permission_scope?: 'inherit' | Array<{ activity: string; view: string }> }): Promise<ServiceAccountKey>;
deleteUserKey(token: string, orgIds: string[], userId: string, keyId: string): Promise<null>;
getUserOrgFilter(token: string, orgIds: string[], userId: string): Promise<{ org_filter: string[] }>;
updateUserOrgFilter(token: string, orgIds: string[], userId: string, orgFilter: string[]): Promise<{ org_filter: string[] }>;
assignRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise<unknown>;
removeRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise<unknown>;
getActivityUsers(token: string, orgIds: string[], name: string): Promise<RuleUser[]>;
@@ -209,7 +243,10 @@ export interface MorbacApi {
revokeDelegation(token: string, orgIds: string[], id: string): Promise<null>;
}
export function createMorbacApi(apiUrl: string): MorbacApi {
// apiUrl may be a function so a browser caller resolves the origin per request
// rather than when its module loads.
export function createMorbacApi(apiUrl: string | (() => string)): MorbacApi {
const base = typeof apiUrl === 'function' ? apiUrl : () => apiUrl;
async function api(method: string, path: string, token: string, orgIds: string[], body?: unknown): Promise<unknown> {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
@@ -220,7 +257,7 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
headers['X-Org-Ids'] = orgIds.join(',');
}
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${apiUrl}${path}`, {
const res = await fetch(`${base()}${path}`, {
method,
headers,
credentials: 'include',
@@ -228,24 +265,64 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error((data as { error?: string }).error || 'Request failed');
if (!res.ok) {
const d = data as { error?: string; message?: string; errorId?: string };
const err = new Error(d.message || d.error || `Request failed (HTTP ${res.status})`) as
Error & { status: number; code?: string; errorId?: string };
err.status = res.status;
err.code = d.error;
err.errorId = d.errorId;
throw err;
}
return data;
}
const permCache = new Map<string, { value: boolean; expires: number }>();
const permInflight = new Map<string, Promise<boolean>>();
const PERM_TTL_MS = 30_000;
async function fetchPermission(token: string, orgId: string, activity: string, view: string): Promise<boolean> {
const res = await fetch(`${base()}/me/permissions/check`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Org-Id': orgId,
},
credentials: 'include',
body: JSON.stringify({ p_activity: activity, p_view: view }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({})) as { error?: string; message?: string; errorId?: string };
const err = new Error(d.message || d.error || `Permission check failed (HTTP ${res.status})`) as
Error & { status: number; code?: string; errorId?: string };
err.status = res.status;
err.code = d.error;
err.errorId = d.errorId;
throw err;
}
return res.json() as Promise<boolean>;
}
return {
checkPermission: async (token, orgId, activity, view) => {
const res = await fetch(`${apiUrl}/me/permissions/check`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Org-Id': orgId,
},
credentials: 'include',
body: JSON.stringify({ p_activity: activity, p_view: view }),
checkPermission: (token, orgId, activity, view) => {
const key = `${token}\x00${orgId}\x00${activity}\x00${view}`;
const cached = permCache.get(key);
if (cached && cached.expires > Date.now()) return Promise.resolve(cached.value);
const existing = permInflight.get(key);
if (existing) return existing;
const p = fetchPermission(token, orgId, activity, view).then((value) => {
permCache.set(key, { value, expires: Date.now() + PERM_TTL_MS });
return value;
});
if (!res.ok) return false;
return res.json() as Promise<boolean>;
permInflight.set(key, p);
void p.finally(() => permInflight.delete(key)).catch(() => {});
return p;
},
clearPermissionCache: () => {
permCache.clear();
permInflight.clear();
},
getOrgs: (t, orgIds) => api('GET', '/orgs', t, orgIds) as Promise<Org[]>,
@@ -258,6 +335,13 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
updateRole: (t, orgIds, id, d) => api('PATCH', `/roles/${id}`, t, orgIds, d) as Promise<RoleDetail>,
deleteRole: (t, orgIds, id) => api('DELETE', `/roles/${id}`, t, orgIds) as Promise<null>,
getPermissionProfiles: (t, orgIds, moduleId) =>
api('GET', moduleId ? `/permission-profiles?module_id=${encodeURIComponent(moduleId)}` : '/permission-profiles', t, orgIds) as Promise<PermissionProfile[]>,
createRoleFromProfile: (t, orgIds, d) => api('POST', '/roles/from-profile', t, orgIds, d) as Promise<RoleDetail>,
attachRoleProfile: (t, orgIds, roleId, d) =>
api('POST', `/roles/${roleId}/profile-sync`, t, orgIds, d) as Promise<{ role_id: string; module_id: string; profile_key: string; synced: boolean; applied_hash: string }>,
detachRoleProfile: (t, orgIds, roleId) => api('DELETE', `/roles/${roleId}/profile-sync`, t, orgIds) as Promise<null>,
getRoleHierarchy: (t, orgIds) => api('GET', '/role-hierarchy', t, orgIds) as Promise<RoleHierarchy[]>,
addRoleHierarchy: (t, orgIds, d) => api('POST', '/role-hierarchy', t, orgIds, d),
removeRoleHierarchy: (t, orgIds, d) => api('DELETE', '/role-hierarchy', t, orgIds, d),
@@ -292,6 +376,9 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
createUserKey: (t, orgIds, userId, d) => api('POST', `/users/${userId}/keys`, t, orgIds, d) as Promise<ServiceAccountKey>,
deleteUserKey: (t, orgIds, userId, keyId) => api('DELETE', `/users/${userId}/keys/${keyId}`, t, orgIds) as Promise<null>,
getUserOrgFilter: (t, orgIds, userId) => api('GET', `/users/${encodeURIComponent(userId)}/org-filter`, t, orgIds) as Promise<{ org_filter: string[] }>,
updateUserOrgFilter: (t, orgIds, userId, orgFilter) => api('PATCH', `/users/${encodeURIComponent(userId)}/org-filter`, t, orgIds, { org_filter: orgFilter }) as Promise<{ org_filter: string[] }>,
assignRole: (t, orgIds, d) => api('POST', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }),
removeRole: (t, orgIds, d) => api('DELETE', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }),
+14
View File
@@ -1,3 +1,17 @@
export { withOrgPaths, readableOrgPaths } from './org-paths.js';
export { withMorbacContext, type MorbacContext } from './rls-context.js';
export {
UNATTRIBUTED,
UNATTRIBUTED_ORG_TOKEN,
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,
+164
View File
@@ -0,0 +1,164 @@
import type { FastifyReply } from 'fastify';
/**
* Shared list-query handling for management routes, so every endpoint that
* lists things offers the same sort, filter and pagination the data tables
* expect. The frontend speaks a PostgREST-style querystring
* (`?name=ilike.*ada*&order=created_at.desc&limit=25&offset=0`); this parses
* that into SQL fragments the route composes into its own, already
* permission-scoped, query.
*
* Columns are allowlisted, values are parameterised, and the caller supplies
* the parameter start index so the filter params slot after its own.
*/
/**
* Maps an API column name (what the client sends) to its SQL expression (what
* the query uses, e.g. 'r.name' in a joined query). Values are developer
* authored and trusted; keys are matched against the request and never
* interpolated.
*/
export type ColumnMap = Record<string, string>;
export interface ListParamsConfig {
/** Columns allowed in ORDER BY, mapped to their SQL expression. */
sortable: ColumnMap;
/** Columns allowed to filter on, mapped to their SQL expression. */
filterable: ColumnMap;
/** Applied when the request sets no order, e.g. 'name.asc'. */
defaultOrder?: string;
/** Largest page a caller may request. Default 200. */
maxLimit?: number;
}
export interface ParsedListParams {
/** True when the request asked to paginate (sent limit or offset). */
paginated: boolean;
/** The page size, or the returned-row count when unpaginated. */
limit: number;
offset: number;
/** 'ORDER BY "col" ASC' or ''. */
orderSql: string;
/** Conditions joined by AND, without a WHERE or leading AND, or ''. The route
* composes these with its own scope conditions. */
filterSql: string;
/** Filter values, numbered from startIndex. */
params: unknown[];
/** ' LIMIT $n OFFSET $m', or '' when the request did not ask to paginate. */
pageSql: string;
/** All params in order: filters, then limit and offset when paginating. */
allParams: unknown[];
}
export class ListQueryError extends Error {
constructor(message: string) { super(message); this.name = 'ListQueryError'; }
}
const RESERVED = new Set(['order', 'limit', 'offset', 'select', 'include_archived']);
const OPERATORS: Record<string, (col: string, i: number) => { sql: string; wildcard?: boolean }> = {
eq: (c, i) => ({ sql: `${c} = $${i}` }),
neq: (c, i) => ({ sql: `${c} <> $${i}` }),
gt: (c, i) => ({ sql: `${c} > $${i}` }),
gte: (c, i) => ({ sql: `${c} >= $${i}` }),
lt: (c, i) => ({ sql: `${c} < $${i}` }),
lte: (c, i) => ({ sql: `${c} <= $${i}` }),
like: (c, i) => ({ sql: `${c} LIKE $${i}`, wildcard: true }),
ilike: (c, i) => ({ sql: `${c} ILIKE $${i}`, wildcard: true }),
};
function parseInteger(value: string | undefined, fallback: number): number {
if (value === undefined) return fallback;
const n = Number(value);
if (!Number.isInteger(n) || n < 0) throw new ListQueryError(`invalid_integer:${value}`);
return n;
}
function buildOrder(raw: string | undefined, sortable: ColumnMap, fallback: string | undefined): string {
const spec = raw ?? fallback;
if (!spec) return '';
const parts: string[] = [];
for (const segment of spec.split(',')) {
const [col, dir = 'asc'] = segment.trim().split('.');
const expr = sortable[col];
if (!expr) throw new ListQueryError(`unsortable_column:${col}`);
const direction = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
parts.push(`${expr} ${direction}`);
}
return parts.length ? `ORDER BY ${parts.join(', ')}` : '';
}
/**
* Parses the querystring into SQL fragments. `qs` is req.query. `startIndex` is
* one past the count of params the route already uses, so filter params do not
* collide with the route's own.
*/
export function parseListParams(
qs: Record<string, unknown>,
config: ListParamsConfig,
startIndex = 1,
): ParsedListParams {
const maxLimit = config.maxLimit ?? 200;
const first = (v: unknown): string | undefined =>
Array.isArray(v) ? (v[0] as string | undefined) : (v as string | undefined);
// Pagination is opt-in: a request that sends neither limit nor offset gets
// every matching row, so existing clients that expect the full list are
// unchanged. A request that paginates is clamped to the max page.
const paginated = first(qs.limit) !== undefined || first(qs.offset) !== undefined;
const requestedLimit = parseInteger(first(qs.limit), maxLimit);
const limit = Math.min(requestedLimit, maxLimit);
const offset = parseInteger(first(qs.offset), 0);
const orderSql = buildOrder(first(qs.order), config.sortable, config.defaultOrder);
const conditions: string[] = [];
const params: unknown[] = [];
let index = startIndex;
for (const [key, rawValue] of Object.entries(qs)) {
if (RESERVED.has(key)) continue;
const value = first(rawValue);
if (value === undefined || value === '') continue;
const expr = config.filterable[key];
if (!expr) throw new ListQueryError(`unfilterable_column:${key}`);
const dot = value.indexOf('.');
const op = dot === -1 ? 'eq' : value.slice(0, dot).toLowerCase();
const raw = dot === -1 ? value : value.slice(dot + 1);
const build = OPERATORS[op];
if (!build) throw new ListQueryError(`unsupported_operator:${op}`);
const { sql, wildcard } = build(expr, index);
conditions.push(sql);
params.push(wildcard ? raw.replace(/\*/g, '%') : raw);
index += 1;
}
const filterSql = conditions.join(' AND ');
const pageSql = paginated ? ` LIMIT $${index} OFFSET $${index + 1}` : '';
return {
paginated,
limit,
offset,
orderSql,
filterSql,
params,
pageSql,
allParams: paginated ? [...params, limit, offset] : [...params],
};
}
/** Joins scope conditions with the parsed filter conditions into a WHERE, or ''. */
export function composeWhere(...conditions: Array<string | undefined | null>): string {
const kept = conditions.filter((c): c is string => Boolean(c && c.trim()));
return kept.length ? `WHERE ${kept.join(' AND ')}` : '';
}
/** Sets the Content-Range header the frontend reads for the total row count. */
export function setContentRange(reply: FastifyReply, offset: number, returned: number, total: number): void {
const end = returned > 0 ? offset + returned - 1 : offset;
reply.header('Content-Range', `${offset}-${end}/${total}`);
}
+28
View File
@@ -0,0 +1,28 @@
import { extensionSql, upgradeScripts, version } from '@crudy/pgmorbac';
export interface MorbacMigration {
filename: string;
sql: string;
}
// The pgmorbac install and upgrade scripts (from @crudy/pgmorbac) as plain
// migrations for a migration runner. Without CREATE EXTENSION the morbac schema is
// created up front, and the installed version is recorded. The base keeps the
// historical filename 0001_morbac.sql so schema_migrations rows on existing
// databases stay valid; its bytes must not change for an unchanged extension
// version, or boot re-runs it. Upgrades follow as 0001_morbac--<old>--<new>.sql.
export function morbacMigrations(): MorbacMigration[] {
const lines = extensionSql().split('\n');
lines.splice(lines.indexOf('') + 1, 0, 'CREATE SCHEMA IF NOT EXISTS morbac;');
const sql = lines.join('\n') + `
-- Record the installed schema version (readable via
-- morbac.get_config('schema_version'); upgrade scripts bump it).
INSERT INTO morbac.config (key, value, description)
VALUES ('schema_version', '${version}', 'Installed pgmorbac schema version')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP;
`;
return [
{ filename: '0001_morbac.sql', sql },
...upgradeScripts().map(u => ({ filename: `0001_morbac--${u.from}--${u.to}.sql`, sql: u.sql })),
];
}
+35
View File
@@ -0,0 +1,35 @@
import type { Pool } from 'pg';
/**
* Wraps a query selecting the visible orgs (id, name, parent_id, metadata) and
* computes each org's path as the names from the nearest visible ancestor down
* to it. Recursion is seeded from orgs whose parent is absent from the visible
* set, so a path can never name an ancestor the caller cannot read. Rows come
* back parent-first (ORDER BY path).
*/
export function withOrgPaths(visSql: string): string {
return `WITH RECURSIVE vis AS (${visSql}),
tree AS (
SELECT v.id, v.name, v.parent_id, v.metadata, ARRAY[v.name]::text[] AS path
FROM vis v
WHERE v.parent_id IS NULL OR NOT EXISTS (SELECT 1 FROM vis p WHERE p.id = v.parent_id)
UNION ALL
SELECT v.id, v.name, v.parent_id, v.metadata, t.path || v.name
FROM vis v JOIN tree t ON v.parent_id = t.id
)
SELECT id, name, parent_id, metadata, path FROM tree ORDER BY path`;
}
/**
* Ancestor-path names, keyed by org id, for every org the user may read under
* `view`. The truncation is the point: a caller who cannot read a parent sees
* 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 } = 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]);
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;
}
+272 -5
View File
@@ -1,8 +1,43 @@
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';
/** The no-org bucket. An object whose org is UNATTRIBUTED belongs to no
* organisation (typically awaiting attribution). Mirrors the SQL model where a
* NULL org means unattributed -- it never means "any org" or "all orgs". */
export const UNATTRIBUTED = null;
/** A single org target: an organisation id, or UNATTRIBUTED. */
export type OrgTarget = string | typeof UNATTRIBUTED;
/** Which records a listing should return. Either 'all' -- every organisation,
* unattributed included -- or an explicit list of targets, where an
* UNATTRIBUTED entry selects objects that have no org. */
export type OrgSelection = 'all' | OrgTarget[];
/** Serialises an OrgSelection for the morbac.org_ids session variable.
* 'all' returns null: no restriction, which the extension reads as every
* authorized record including unattributed ones. */
export function orgSelectionToSetting(selection: OrgSelection): string | null {
return selection === 'all' ? null : JSON.stringify(selection);
}
/** The X-Org-Ids token naming the no-org bucket on a data request. An org id is
* always a UUID, so the token cannot collide with one. The pre-request hook
* turns it into the JSON null element of morbac.org_ids. */
export const UNATTRIBUTED_ORG_TOKEN = 'unattributed';
/** Builds an X-Org-Ids value from org targets, naming the no-org bucket with
* UNATTRIBUTED_ORG_TOKEN. Data requests only: the management routes below are
* org-scoped listings and reject the token. */
export function orgTargetsToHeader(targets: OrgTarget[]): string {
return targets.map((t) => t ?? UNATTRIBUTED_ORG_TOKEN).join(',');
}
export function getUser(req: FastifyRequest): { id: string } | null {
return ((req as unknown) as Record<string, unknown>).user as { id: string } | null ?? null;
}
@@ -16,6 +51,12 @@ export function getOrgScope(req: FastifyRequest, reply: FastifyReply, required =
const multiVal = Array.isArray(multi) ? multi[0] : (multi ?? null);
if (multiVal) {
const ids = multiVal.split(',').map((s) => s.trim()).filter(Boolean);
// These routes enumerate orgs via get_org_scope, which the no-org bucket
// is not part of. Rejecting beats letting the token reach a UUID cast.
if (ids.some((id) => id.toLowerCase() === UNATTRIBUTED_ORG_TOKEN)) {
reply.code(400).send({ error: `${UNATTRIBUTED_ORG_TOKEN} is only valid on data requests, not management routes` });
return null;
}
if (ids.length > 0) return ids;
}
@@ -56,6 +97,156 @@ function scopeAllows(scope: ApiKeyScope | undefined, activity: string, target: s
return scope.some((p) => p.activity === activity && p.view === target);
}
/** True if the caller may exercise (activity, view) over the target user.
* Authorizes against the target's ACTUAL membership orgs (or a global rule),
* never a caller-supplied header. A target with no membership can only be
* managed by a global admin. Prevents cross-org account takeover. */
/** Enforces an API key's permission_scope for management routes that no longer
* route through hasPermission (they authorize against target ownership instead).
* Sends 403 and returns false when the key's scope excludes (activity, view). */
export function checkApiKeyScope(
req: FastifyRequest,
reply: FastifyReply,
activity: string,
view: string,
): boolean {
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
if (!scopeAllows(apiKeyScope, activity, view)) {
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
return true;
}
export async function canActOnUser(
db: Pool,
callerId: string,
targetUserId: string,
activity: string,
view: string,
): Promise<boolean> {
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 (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
* with (ruleOrgId, scope) would cover. Enforces that a rule can never grant a
* permission the caller does not already hold on the affected orgs, killing
* scope-based vertical privilege escalation.
*
* The 'unattributed' and 'all' scopes reach objects with no org, which
* get_org_scope does not enumerate (unattributed is not an organisation).
* Those scopes additionally require the caller to hold the permission on
* unattributed objects, otherwise 'unattributed' would pass vacuously. */
export async function canGrantRule(
db: Pool,
callerId: string,
ruleOrgId: string,
scope: string,
activity: string,
view: string,
): Promise<boolean> {
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],
);
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.
* The DB trigger only restricts an activity once it has at least one binding
* (empty = unconstrained); this closes that gap at the application layer so an
* activity/view pair with no binding is rejected rather than silently allowed. */
export async function isActivityViewBound(db: Pool, activity: string, view: string): Promise<boolean> {
const { rows } = await db.query<{ ok: boolean }>(
`SELECT EXISTS(SELECT 1 FROM morbac.activity_view_bindings WHERE activity = $1 AND view = $2) AS ok`,
[activity, view],
);
return rows[0]?.ok ?? false;
}
/** True iff the caller holds the global wildcard rule (activity IS NULL, view IS NULL).
* Used by framework-level surfaces (audit, monitoring) that are not org-scoped. */
export async function isGlobalAdmin(db: Pool, userId: string): Promise<boolean> {
const { rows } = await db.query<{ is_admin: boolean }>(
`SELECT EXISTS(
SELECT 1 FROM morbac.global_rules
WHERE user_id = $1::UUID AND activity IS NULL AND view IS NULL AND modality = 'permission'
) AS is_admin`,
[userId],
);
return rows[0]?.is_admin === true;
}
/** Sends 401/403 and returns false unless the caller is authenticated and holds
* the global wildcard rule. Use for routes with no org concept (e.g. monitoring). */
export async function requireGlobalAdmin(db: Pool, req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
const user = getUser(req);
if (!user) {
reply.code(401).send({ error: 'Authentication required' });
return false;
}
if (!(await isGlobalAdmin(db, user.id))) {
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
return true;
}
/** 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.
* Names no org, so permission hooks do not run. */
export async function hasAnyPermission(
db: Pool,
userId: string,
activity: string,
view: string,
): Promise<boolean> {
const { rows } = await db.query<{ allowed: boolean }>(
'SELECT morbac.has_permission($1::UUID, $2, $3) AS allowed',
[userId, activity, view],
);
return rows[0]?.allowed === true;
}
/**
* The same decision `hasPermission` makes, without touching the reply.
*
* For callers that must ask about many permissions and decide what to do with
* the answers: a report fan-out drops the metrics the runner cannot read
* rather than failing, so it needs the boolean, not a 403 on the wire. Honours
* an API key's scope exactly as `hasPermission` does, so a restricted key never
* reads more through this door than through that one.
*/
export async function canPerform(
db: Pool,
req: FastifyRequest,
orgId: string | null,
activity: string,
target: string,
): Promise<boolean> {
const user = getUser(req);
if (!user) return false;
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
if (!scopeAllows(apiKeyScope, activity, target)) return false;
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. */
export function apiKeyScopeOf(req: FastifyRequest): ApiKeyScope | undefined {
return ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
}
/** Authorization for a single object. Pass the object's org, or UNATTRIBUTED
* when the object has no org. */
export async function hasPermission(
db: Pool,
req: FastifyRequest,
@@ -74,13 +265,89 @@ 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;
}
return true;
}
/**
* A coarse gate for a multi-org list read (e.g. the "all organizations" filter):
* the caller may proceed if allowed in at least one of the requested orgs. RLS
* still filters each row by its own org, so a row the caller cannot see in one
* of the listed orgs is dropped regardless.
*/
export async function hasPermissionAny(
db: Pool,
req: FastifyRequest,
reply: FastifyReply,
orgIds: string[],
activity: string,
target: string,
): Promise<boolean> {
const user = getUser(req);
if (!user) {
reply.code(401).send({ error: 'Authentication required' });
return false;
}
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
if (!scopeAllows(apiKeyScope, activity, target)) {
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
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;
}
+2
View File
@@ -7,6 +7,7 @@ import { createUserRoutes } from './routes/users.js';
import { createGlobalRoutes } from './routes/globals.js';
import { createActivityRoutes } from './routes/activities.js';
import { createDelegationRoutes } from './routes/delegations.js';
import { createPermissionProfileRoutes } from './routes/permission-profiles.js';
export type CheckLimitFn = (
reply: FastifyReply,
@@ -43,6 +44,7 @@ export function createMgmtRoutes(opts: MgmtPluginOptions) {
return async function mgmtRoutes(app: FastifyInstance) {
await app.register(createOrgRoutes(db, checkLimit, emitOrgEvent));
await app.register(createRoleRoutes(db, checkLimit, emitRoleEvent));
await app.register(createPermissionProfileRoutes(db, checkLimit, emitRoleEvent));
await app.register(createRuleRoutes(db));
await app.register(createUserRoutes(db, checkLimit));
await app.register(createGlobalRoutes(db));
+65 -7
View File
@@ -1,11 +1,38 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { Pool } from 'pg';
import { checkApiKeyScope, hasPermission, permittedOrgs, UNATTRIBUTED_ORG_TOKEN } from './permissions.js';
export interface DataPermission {
activity: string;
view: string;
}
/** Rows returned by a GET that does not ask for a page size. */
export const DEFAULT_PAGE_LIMIT = 100;
/**
* Largest page a caller may ask for. Deliberately well above the table's
* page-size options; bulk reads belong on the export routes, which stream
* from a cursor instead of buffering a PostgREST response.
*
* Not configurable: an env override would be an escape hatch around the cap.
*/
export const MAX_PAGE_LIMIT = 1000;
/**
* Rejects rather than clamps. A clamp answers 200 with a short Content-Range
* that the caller reads as authoritative, so a truncated read looks like a
* complete one and surfaces later as wrong numbers. A 400 surfaces at the
* call site immediately.
*/
function checkLimit(rawQs: string): { ok: true } | { ok: false } {
for (const value of new URLSearchParams(rawQs).getAll('limit')) {
const n = Number(value);
if (!Number.isInteger(n) || n < 0 || n > MAX_PAGE_LIMIT) return { ok: false };
}
return { ok: true };
}
export type ProxyDataRouteFn = (
app: import('fastify').FastifyInstance,
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
@@ -44,6 +71,7 @@ export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegist
method,
url: publicPath,
schema: { hide: true } as Record<string, unknown>,
config: { permission },
handler: makeHandler(upstreamResource, permission),
});
}
@@ -56,16 +84,46 @@ export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegist
if (permission !== null) {
const orgId = req.headers['x-org-id'];
const orgIdVal = Array.isArray(orgId) ? orgId[0] : orgId;
if (!orgIdVal) return reply.code(400).send({ error: 'X-Org-Id header is required' });
const orgIds = req.headers['x-org-ids'];
const orgIdsVal = Array.isArray(orgIds) ? orgIds[0] : orgIds;
const { rows } = await db.query<{ allowed: boolean }>(
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, orgIdVal, permission.activity, permission.view],
);
if (!rows[0]?.allowed) return reply.code(403).send({ error: 'Forbidden' });
// Goes through hasPermission rather than is_allowed directly: it is
// the only place an API key's permission_scope is applied, so an
// inline check would let a narrowly scoped key act as its full user.
if (orgIdVal) {
if (!await hasPermission(db, req, reply, orgIdVal, permission.activity, permission.view)) {
return reply;
}
} else if (orgIdsVal && orgIdsVal.trim()) {
// 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);
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' });
}
}
const qsIndex = req.url.indexOf('?');
const rawQs = qsIndex === -1 ? '' : req.url.slice(qsIndex + 1);
if (!checkLimit(rawQs).ok) return reply.code(400).send({ error: 'limit_exceeded' });
// Only reads get a default page. On PATCH and DELETE, PostgREST reads
// limit as a bound on the affected rows, so injecting one would
// silently truncate a bulk mutation.
let qs = rawQs ? `?${rawQs}` : '';
if (req.method === 'GET' && !new URLSearchParams(rawQs).has('limit')) {
qs = rawQs ? `?${rawQs}&limit=${DEFAULT_PAGE_LIMIT}` : `?limit=${DEFAULT_PAGE_LIMIT}`;
}
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
const url = `${postgrestUrl}/${upstreamResource}${qs}`;
const headers: Record<string, string> = {};
+55
View File
@@ -0,0 +1,55 @@
import type { Pool, PoolClient } from 'pg';
import { orgSelectionToSetting, type OrgSelection } from './permissions.js';
export interface MorbacContext {
userId: string;
/** Pin to a single organisation. Unattributed records are excluded by a
* pin; use `orgs` to select them. */
orgId?: string | null;
/** Which records to return: 'all' (every org, unattributed included), or a
* list of targets where an UNATTRIBUTED (null) entry selects records that
* have no org. Ignored when `orgId` is set. */
orgs?: OrgSelection;
}
/**
* Runs fn on a dedicated client inside a transaction with the morbac session
* GUCs set, so morbac RLS policies see the acting user.
*
* Whether that is load bearing depends on which pool it is given. Under
* app_runtime, the role without BYPASSRLS that the metrics read path uses, it
* turns RLS into a second authorization gate behind hasPermission. Under
* app_backend, which every other caller still uses, the role attribute exempts
* it and the GUCs only feed the audit triggers.
*
* Routes not yet converted fail closed under app_runtime: with no GUC set,
* morbac.current_user_id() is NULL and the policies return no rows.
*/
export async function withMorbacContext<T>(
pool: Pool,
ctx: MorbacContext,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`SELECT set_config('morbac.user_id', $1, true),
set_config('morbac.org_id', $2, true),
set_config('morbac.org_ids', $3, true)`,
[
ctx.userId,
ctx.orgId ?? '',
(ctx.orgs === undefined ? null : orgSelectionToSetting(ctx.orgs)) ?? '',
],
);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK').catch(() => undefined);
throw err;
} finally {
client.release();
}
}
+22 -2
View File
@@ -9,7 +9,7 @@ export function createActivityRoutes(db: Pool) {
schema: {
tags: ['Management'],
summary: 'List activities',
description: 'Read-only list of permission verbs (read, create, update, delete). Managed at the database level.',
description: 'Read-only list of permission verbs (read, create, update, delete). Managed at the database level. Requires `read`/`rules`.',
security: [{ oauth2: [] }],
response: {
200: {
@@ -23,11 +23,15 @@ export function createActivityRoutes(db: Pool) {
},
},
401: unauthorized,
403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
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 { rows } = await db.query('SELECT name, description FROM morbac.activities ORDER BY name');
return rows;
});
@@ -115,16 +119,28 @@ export function createActivityRoutes(db: Pool) {
properties: {
name: { type: 'string' },
description: { type: 'string', nullable: true },
no_org: { type: 'boolean' },
},
},
},
401: unauthorized,
403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { rows } = await db.query('SELECT name, description FROM morbac.views ORDER BY name');
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) 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(
`SELECT v.name, v.description, COALESCE(f.no_org, FALSE) AS no_org
FROM morbac.views v
LEFT JOIN app.view_flags f ON f.view = v.name
ORDER BY v.name`,
);
return rows;
});
@@ -215,11 +231,15 @@ export function createActivityRoutes(db: Pool) {
},
},
401: unauthorized,
403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
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 { rows } = await db.query(
'SELECT activity, view FROM morbac.activity_view_bindings ORDER BY activity, view',
);
+9 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, SYSTEM_ORG_ID } from '../permissions.js';
import { delegationResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
const SELECT_COLUMNS = `
@@ -84,6 +84,14 @@ export function createDelegationRoutes(db: Pool) {
if (!await hasPermission(db, req, reply, org_id, 'create', 'delegations')) return reply;
// The caller must be the delegator, or hold authority over them. Otherwise
// any create/delegations holder could self-grant a powerful role held by
// another principal by naming them as delegator.
if (delegator_id !== user.id
&& !await canActOnUser(db, user.id, delegator_id, 'create', 'user_roles')) {
return reply.code(403).send({ error: 'Cannot delegate on behalf of this user' });
}
const from = valid_from ? new Date(valid_from) : new Date();
const until = new Date(valid_until);
if (Number.isNaN(until.getTime()) || until <= from) {
+12 -4
View File
@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
import { globalRuleResponse, notFound, forbidden, unauthorized } from '../schemas.js';
import { getUser, hasPermission, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
import { globalRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createGlobalRoutes(db: Pool) {
return async function globalRoutes(app: FastifyInstance) {
@@ -57,7 +57,7 @@ export function createGlobalRoutes(db: Pool) {
},
response: {
201: globalRuleResponse,
401: unauthorized, 403: forbidden,
400: badRequest, 401: unauthorized, 403: forbidden,
409: { description: 'Rule already exists.' },
},
},
@@ -79,6 +79,12 @@ export function createGlobalRoutes(db: Pool) {
if (!await hasPermission(db, req, reply, SYSTEM_ORG_ID, 'create', 'rules')) return reply;
// activity/view null = wildcard (e.g. the global admin rule) - not a concrete
// pair to validate against the bindings whitelist.
if (activity && view && !await isActivityViewBound(db, activity, view)) {
return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
}
try {
const { rows } = await db.query(
`INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
@@ -93,7 +99,9 @@ export function createGlobalRoutes(db: Pool) {
);
return reply.code(201).send(rows[0]);
} catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
const code = (e as { code?: string }).code;
if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' });
throw e;
}
});
+45 -19
View File
@@ -1,4 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { withMorbacContext } from '../rls-context.js';
import type { Pool } from 'pg';
import type { CheckLimitFn, OrgEventSink } from '../plugin.js';
import {
@@ -6,8 +7,11 @@ import {
} from '../permissions.js';
import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { withOrgPaths } from '../org-paths.js';
interface OrgRow { id: string; name: string; parent_id: string | null; metadata?: Record<string, unknown> }
export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEvent?: OrgEventSink) {
return async function orgRoutes(app: FastifyInstance) {
app.get('/orgs', {
@@ -29,22 +33,23 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven
if (orgIds === null) return reply;
if (orgIds.length === 0) {
const { rows: adminCheck } = await db.query<{ allowed: boolean }>(
'SELECT morbac.is_allowed($1::UUID, NULL::UUID, $2, $3) AS allowed',
[user.id, 'read', 'orgs'],
);
if (adminCheck[0]?.allowed) {
const { rows } = await db.query(
'SELECT id, name, parent_id, metadata FROM morbac.orgs ORDER BY name',
);
return rows;
}
const { rows } = await db.query(
`SELECT DISTINCT o.id, o.name, o.parent_id, o.metadata
FROM morbac.orgs o
INNER JOIN morbac.user_roles ur ON ur.org_id = o.id AND ur.user_id = $1
ORDER BY o.name`,
[user.id],
// Unpinned: the caller's whole perimeter. Two ways in, and both
// are needed. A rule scoped to a subtree, a user rule or a
// cross-org rule reaches orgs the caller holds no role in, and
// those belong in the org filter. A role in an org puts the
// 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.
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],
),
);
return rows;
}
@@ -53,9 +58,11 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(orgIds, 'id');
const { rows } = await db.query(
`SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''} ORDER BY name`,
params,
const { rows } = await withMorbacContext(db, { userId: user.id, orgId: orgIds[0] }, (client) =>
client.query(
withOrgPaths(`SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''}`),
params,
),
);
return rows;
});
@@ -140,6 +147,25 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven
if (!await hasPermission(db, req, reply, id, 'update', 'orgs')) return reply;
const body = req.body as { name?: string; parent_id?: string | null };
// Reparenting must be authorized against the destination, not just the
// moved org, and must not create a cycle (new parent within own subtree).
if ('parent_id' in body) {
if (body.parent_id === null) {
if (!await hasPermission(db, req, reply, id, 'create', 'root_orgs')) return reply;
} else if (body.parent_id) {
if (body.parent_id === id) return reply.code(400).send({ error: 'invalid_parent' });
const { rows: [cycle] } = await db.query<{ inside: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM morbac.get_org_scope($1::UUID, 'subtree') AS s(org_id)
WHERE s.org_id = $2::UUID
) AS inside`,
[id, body.parent_id],
);
if (cycle?.inside) return reply.code(400).send({ error: 'invalid_parent' });
if (!await hasPermission(db, req, reply, body.parent_id, 'create', 'orgs')) return reply;
}
}
const { rows } = await db.query<OrgRow & { reparented: boolean }>(
`WITH prev AS (SELECT parent_id AS old_parent FROM morbac.orgs WHERE id = $4)
UPDATE morbac.orgs o
+254
View File
@@ -0,0 +1,254 @@
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 { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { loadProfile, validateGrantable, applyProfileRules } from './profile-apply.js';
const profileResponse = {
type: 'object',
properties: {
module_id: { type: 'string' },
key: { type: 'string' },
content_hash: { type: 'string' },
rules: {
type: 'array',
items: {
type: 'object',
properties: {
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string' },
scope: { type: 'string' },
},
},
},
},
};
const profileRef = {
type: 'object',
required: ['module_id', 'key'],
properties: {
module_id: { type: 'string', maxLength: 128 },
key: { type: 'string', maxLength: 128 },
},
};
export function createPermissionProfileRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEvent?: RoleEventSink) {
return async function permissionProfileRoutes(app: FastifyInstance) {
app.get('/permission-profiles', {
schema: {
tags: ['Management'],
summary: 'List permission profiles',
description: 'Returns role presets declared by modules. Requires `read`/`roles`.',
security: [{ oauth2: [] }],
querystring: {
type: 'object',
properties: { module_id: { type: 'string', maxLength: 128 } },
},
response: {
200: { type: 'array', items: profileResponse },
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
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', 'roles')) return reply;
const { module_id } = req.query as { module_id?: string };
const { rows } = await db.query(
`SELECT module_id, key, content_hash, rules
FROM app.permission_profiles
${module_id ? 'WHERE module_id = $1' : ''}
ORDER BY module_id, key`,
module_id ? [module_id] : [],
);
return rows;
});
app.post('/roles/from-profile', {
schema: {
tags: ['Management'],
summary: 'Create a role from a profile',
description:
'Creates a role in the target org and seeds its rules from a module profile. '
+ 'Requires `create`/`roles`, and the caller must already hold every granted permission. '
+ 'When `sync` is true the role tracks future profile changes.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['org_id', 'name', 'profile'],
properties: {
org_id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1, maxLength: 200 },
description: { type: 'string', maxLength: 500 },
sync: { type: 'boolean', default: false },
profile: profileRef,
},
},
response: {
201: roleResponse,
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { org_id, name, description, sync, profile } = req.body as {
org_id: string; name: string; description?: string; sync?: boolean;
profile: { module_id: string; key: string };
};
if (org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, org_id, 'create', 'roles')) return reply;
const prof = await loadProfile(db, profile.module_id, profile.key);
if (!prof) return reply.code(404).send({ error: 'Not found' });
if (checkLimit) {
const ok = await checkLimit(reply, 'max_roles', 'SELECT count(*)::TEXT AS c FROM morbac.roles');
if (!ok) return reply;
}
if (!await validateGrantable(db, reply, user.id, org_id, prof.rules)) return reply;
const client = await db.connect();
try {
await client.query('BEGIN');
const { rows: [role] } = await client.query(
`INSERT INTO morbac.roles (org_id, name, description) VALUES ($1, $2, $3)
RETURNING id, org_id, name, description`,
[org_id, name, description ?? null],
);
const nonSelf = await applyProfileRules(client, org_id, role.id, profile.module_id, profile.key, prof.rules);
if (sync) {
await client.query(
`INSERT INTO app.role_profile_links (role_id, module_id, profile_key, synced, applied_hash)
VALUES ($1, $2, $3, TRUE, $4)`,
[role.id, profile.module_id, profile.key, prof.content_hash],
);
}
await client.query('COMMIT');
if (nonSelf) await db.query('SELECT morbac.invalidate_cache()');
const org = await db.query('SELECT name FROM morbac.orgs WHERE id = $1', [org_id]);
emitRoleEvent?.({ action: 'created', role, actorId: user.id });
return reply.code(201).send({ ...role, org_name: org.rows[0]?.name });
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
});
app.post('/roles/:id/profile-sync', {
schema: {
tags: ['Management'],
summary: 'Attach a profile to an existing role',
description:
'Applies a module profile to an existing role and records the link. Requires '
+ '`update`/`roles`, and the caller must already hold every granted permission. '
+ 'When `sync` is true the role tracks future profile changes.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['profile'],
properties: {
profile: profileRef,
sync: { type: 'boolean', default: true },
},
},
response: {
200: {
type: 'object',
properties: {
role_id: { type: 'string', format: 'uuid' },
module_id: { type: 'string' },
profile_key: { type: 'string' },
synced: { type: 'boolean' },
applied_hash: { type: 'string' },
},
},
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const { profile, sync } = req.body as { profile: { module_id: string; key: string }; sync?: boolean };
const synced = sync !== false;
const { rows: [roleRow] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [id],
);
if (!roleRow) return reply.code(404).send({ error: 'Not found' });
if (roleRow.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, roleRow.org_id, 'update', 'roles')) return reply;
const prof = await loadProfile(db, profile.module_id, profile.key);
if (!prof) return reply.code(404).send({ error: 'Not found' });
if (!await validateGrantable(db, reply, user.id, roleRow.org_id, prof.rules)) return reply;
const client = await db.connect();
try {
await client.query('BEGIN');
const nonSelf = await applyProfileRules(client, roleRow.org_id, id, profile.module_id, profile.key, prof.rules);
await client.query(
`INSERT INTO app.role_profile_links (role_id, module_id, profile_key, synced, applied_hash, applied_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (role_id)
DO UPDATE SET module_id = EXCLUDED.module_id, profile_key = EXCLUDED.profile_key,
synced = EXCLUDED.synced, applied_hash = EXCLUDED.applied_hash, applied_at = now()`,
[id, profile.module_id, profile.key, synced, prof.content_hash],
);
await client.query('COMMIT');
if (nonSelf) await db.query('SELECT morbac.invalidate_cache()');
return reply.send({ role_id: id, module_id: profile.module_id, profile_key: profile.key, synced, applied_hash: prof.content_hash });
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
});
app.delete('/roles/:id/profile-sync', {
schema: {
tags: ['Management'],
summary: 'Detach a role from its profile',
description:
"Removes the profile link so the role stops tracking the profile. Requires "
+ "`update`/`roles`. Already-applied rules are left in place.",
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'Link removed.' },
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const { rows: [roleRow] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [id],
);
if (!roleRow) return reply.code(404).send({ error: 'Not found' });
if (roleRow.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, roleRow.org_id, 'update', 'roles')) return reply;
await db.query('DELETE FROM app.role_profile_links WHERE role_id = $1', [id]);
return reply.code(204).send();
});
};
}
+75
View File
@@ -0,0 +1,75 @@
import type { Pool, PoolClient } from 'pg';
import type { FastifyReply } from 'fastify';
import { canGrantRule, isActivityViewBound } from '../permissions.js';
export interface ProfileRule {
activity: string;
view: string;
modality: 'permission' | 'prohibition';
scope: string;
}
export async function loadProfile(
db: Pool, moduleId: string, key: string,
): Promise<{ rules: ProfileRule[]; content_hash: string } | null> {
const { rows: [row] } = await db.query<{ rules: ProfileRule[]; content_hash: string }>(
'SELECT rules, content_hash FROM app.permission_profiles WHERE module_id = $1 AND key = $2',
[moduleId, key],
);
return row ?? null;
}
/** Anti-escalation gate for the caller-driven apply: every rule must be a valid
* binding, and the caller must already hold every permission it would grant.
* Sends the reply and returns false on the first failure. */
export async function validateGrantable(
db: Pool, reply: FastifyReply, userId: string, orgId: string, rules: ProfileRule[],
): Promise<boolean> {
for (const r of rules) {
if (!await isActivityViewBound(db, r.activity, r.view)) {
reply.code(400).send({ error: `Activity "${r.activity}" is not valid for view "${r.view}"` });
return false;
}
if (r.modality === 'permission'
&& !await canGrantRule(db, userId, orgId, r.scope, r.activity, r.view)) {
reply.code(403).send({ error: 'Cannot grant a permission you do not hold on the target orgs' });
return false;
}
}
return true;
}
/** Insert/adopt every profile rule (tagged) and revoke this profile's tagged rules
* no longer in the set. Manual (untagged) rules are never touched. Returns whether
* any touched rule was non-self, so the caller can flush the morbac cache. */
export async function applyProfileRules(
client: PoolClient, orgId: string, roleId: string, moduleId: string, key: string, rules: ProfileRule[],
): Promise<boolean> {
const tag = JSON.stringify({ profile: { module_id: moduleId, key } });
let touchedNonSelf = false;
for (const r of rules) {
await client.query(
`INSERT INTO morbac.rules
(org_id, role_id, activity, view, context_id, modality, scope, is_active, metadata)
VALUES ($1, $2, $3, $4,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
$5::morbac.modality, $6, TRUE, $7::jsonb)
ON CONFLICT (org_id, role_id, activity, view, context_id, modality, scope)
DO UPDATE SET metadata = EXCLUDED.metadata, is_active = TRUE`,
[orgId, roleId, r.activity, r.view, r.modality, r.scope, tag],
);
if (r.scope !== 'self') touchedNonSelf = true;
}
const keep = rules.map(r => `${r.activity}|${r.view}|${r.modality}|${r.scope}`);
const { rows: removed } = await client.query<{ scope: string }>(
`DELETE FROM morbac.rules
WHERE role_id = $1
AND metadata->'profile'->>'module_id' = $2
AND metadata->'profile'->>'key' = $3
AND (activity || '|' || view || '|' || modality::text || '|' || scope) <> ALL($4::text[])
RETURNING scope`,
[roleId, moduleId, key, keep],
);
if (removed.some(row => row.scope !== 'self')) touchedNonSelf = true;
return touchedNonSelf;
}
+13
View File
@@ -0,0 +1,13 @@
import type { Pool } from 'pg';
import { isAllowed } from '../permissions.js';
/**
* Whether the caller may hand out this role at all: assigning it is exercising
* authority over the organization the role lives in, not over the user.
*/
export async function callerControlsRoleOrg(
db: Pool, callerId: string, roleId: string, activity: string,
): Promise<boolean> {
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');
}
+29 -5
View File
@@ -4,8 +4,12 @@ import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
import {
getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID,
} from '../permissions.js';
import { parseListParams, composeWhere, setContentRange, ListQueryError } from '../list-query.js';
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
const ROLE_SORTABLE = { name: 'r.name', org_name: 'o.name' };
const ROLE_FILTERABLE = { name: 'r.name', description: 'r.description', org_id: 'r.org_id' };
export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEvent?: RoleEventSink) {
return async function roleRoutes(app: FastifyInstance) {
app.get('/roles', {
@@ -29,15 +33,35 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
const { expr, params } = orgScopeExpr(orgIds, 'r.org_id');
let list;
try {
list = parseListParams(req.query as Record<string, unknown>, {
sortable: ROLE_SORTABLE,
filterable: ROLE_FILTERABLE,
defaultOrder: 'org_name.asc,name.asc',
}, params.length + 1);
} catch (e) {
if (e instanceof ListQueryError) return reply.code(400).send({ error: 'invalid_query' });
throw e;
}
const where = composeWhere(expr, list.filterSql);
const { rows } = await db.query(
`SELECT r.id, r.org_id, r.name, r.description, o.name AS org_name
`SELECT r.id, r.org_id, r.name, r.description, o.name AS org_name,
rpl.module_id AS profile_module_id, rpl.profile_key, rpl.synced AS profile_synced,
count(*) OVER() AS _total
FROM morbac.roles r
JOIN morbac.orgs o ON o.id = r.org_id
${expr ? `WHERE ${expr}` : ''}
ORDER BY o.name, r.name`,
params,
LEFT JOIN app.role_profile_links rpl ON rpl.role_id = r.id
${where}
${list.orderSql || 'ORDER BY o.name, r.name'}
${list.pageSql}`,
[...params, ...list.allParams],
);
return rows;
const total = rows.length > 0 ? Number((rows[0] as { _total: string })._total) : 0;
setContentRange(reply, list.offset, rows.length, total);
return (rows as Array<Record<string, unknown>>).map(({ _total, ...rest }) => rest);
});
app.post('/roles', {
+9 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
import { crossOrgRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createCrossOrgRuleRoutes(db: Pool) {
@@ -87,6 +87,10 @@ export function createCrossOrgRuleRoutes(db: Pool) {
if (source_org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, source_org_id, 'create', 'cross_org_rules')) return reply;
if (!await isActivityViewBound(db, activity, view)) {
return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
}
try {
const { rows } = await db.query(
`INSERT INTO morbac.cross_org_rules
@@ -106,7 +110,10 @@ export function createCrossOrgRuleRoutes(db: Pool) {
);
return reply.code(201).send(rows[0]);
} catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
const code = (e as { code?: string }).code;
if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' });
if (code === 'P0001') return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
throw e;
}
});
+41 -7
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, canGrantRule, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
import { ruleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createRoleRuleRoutes(db: Pool) {
@@ -28,7 +28,10 @@ export function createRoleRuleRoutes(db: Pool) {
const { expr, params } = orgScopeExpr(orgIds, '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
r.activity, r.view, r.modality, r.priority, r.scope,
r.valid_from, r.valid_until,
(r.valid_from IS NULL OR r.valid_from <= now())
AND (r.valid_until IS NULL OR r.valid_until > now()) AS is_active
FROM morbac.rules r
JOIN morbac.orgs o ON o.id = r.org_id
JOIN morbac.roles ro ON ro.id = r.role_id
@@ -57,8 +60,11 @@ export function createRoleRuleRoutes(db: Pool) {
priority: { type: 'integer' },
scope: {
type: 'string',
enum: ['self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root'],
enum: ['self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root', 'unattributed', 'all'],
default: 'self',
description:
'Which objects the rule reaches: a specific org via the tree scopes, '
+ 'unattributed for objects with no org, or all for every org including unattributed.',
},
},
},
@@ -79,6 +85,23 @@ export function createRoleRuleRoutes(db: Pool) {
if (!await hasPermission(db, req, reply, org_id, 'create', 'rules')) return reply;
if (!await isActivityViewBound(db, activity, view)) {
return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
}
const { rows: [roleRow] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [role_id],
);
if (!roleRow) return reply.code(404).send({ error: 'Not found' });
// Anti-escalation: only permission rules are constrained (prohibitions
// only ever reduce access). A caller may not grant, via any scope, a
// permission they do not themselves hold on every org that scope covers.
if (modality === 'permission'
&& !await canGrantRule(db, user.id, org_id, scope, activity, view)) {
return reply.code(403).send({ error: 'Cannot grant a permission you do not hold on the target orgs' });
}
try {
const { rows } = await db.query(
`INSERT INTO morbac.rules
@@ -91,12 +114,20 @@ export function createRoleRuleRoutes(db: Pool) {
RETURNING id, $2::UUID AS role_id, $1::UUID AS org_id,
(SELECT name FROM morbac.orgs WHERE id = $1) AS org_name,
(SELECT name FROM morbac.roles WHERE id = $2) AS role_name,
activity, view, modality, priority, scope`,
activity, view, modality, priority, scope,
valid_from, valid_until, is_active`,
[org_id, role_id, activity, view, modality, scope, priority ?? null],
);
// The morbac AFTER trigger only clears cache for the rule's own org.
// A non-self scope also affects other orgs (ancestors/descendants), so
// flush the whole cache to avoid stale grant/revocation decisions.
if (scope !== 'self') await db.query('SELECT morbac.invalidate_cache()');
return reply.code(201).send(rows[0]);
} catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
const code = (e as { code?: string }).code;
if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' });
if (code === 'P0001') return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
throw e;
}
});
@@ -118,14 +149,17 @@ export function createRoleRuleRoutes(db: Pool) {
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const { rows: [rule] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.rules WHERE id = $1', [id],
const { rows: [rule] } = await db.query<{ org_id: string; scope: string }>(
'SELECT org_id, scope FROM morbac.rules WHERE id = $1', [id],
);
if (!rule) return reply.code(404).send({ error: 'Not found' });
if (rule.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, rule.org_id, 'delete', 'rules')) return reply;
await db.query('DELETE FROM morbac.rules WHERE id = $1', [id]);
// See POST: a non-self scope revocation must flush cache for the
// affected orgs, which the morbac trigger does not cover.
if (rule.scope !== 'self') await db.query('SELECT morbac.invalidate_cache()');
return reply.code(204).send();
});
};
+14 -5
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission } from '../permissions.js';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, isActivityViewBound } from '../permissions.js';
import { userRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createUserRuleRoutes(db: Pool) {
@@ -33,7 +33,7 @@ export function createUserRuleRoutes(db: Pool) {
o.name AS org_name,
ur.activity, ur.view, ur.modality, ur.priority
FROM morbac.user_rules ur
JOIN morbac.orgs o ON o.id = ur.org_id
LEFT JOIN morbac.orgs o ON o.id = ur.org_id
LEFT JOIN app.users u ON u.id = ur.user_id
${expr ? `WHERE ${expr}` : ''}
ORDER BY o.name, ur.activity, ur.view`,
@@ -53,7 +53,10 @@ export function createUserRuleRoutes(db: Pool) {
required: ['user_id', 'org_id', 'activity', 'view', 'modality'],
properties: {
user_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
org_id: {
type: 'string', format: 'uuid', nullable: true,
description: 'Organisation the rule targets, or null for unattributed objects (those with no org).',
},
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
@@ -71,12 +74,16 @@ export function createUserRuleRoutes(db: Pool) {
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { user_id, org_id, activity, view, modality, priority } = req.body as {
user_id: string; org_id: string; activity: string;
user_id: string; org_id: string | null; activity: string;
view: string; modality: 'permission' | 'prohibition'; priority?: number;
};
if (!await hasPermission(db, req, reply, org_id, 'create', 'user_rules')) return reply;
if (!await isActivityViewBound(db, activity, view)) {
return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
}
try {
const { rows } = await db.query(
`INSERT INTO morbac.user_rules
@@ -95,7 +102,9 @@ export function createUserRuleRoutes(db: Pool) {
);
return reply.code(201).send(rows[0]);
} catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
const code = (e as { code?: string }).code;
if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' });
throw e;
}
});
+64 -26
View File
@@ -3,10 +3,12 @@ import type { Pool } from 'pg';
import bcrypt from 'bcryptjs';
import type { CheckLimitFn } from '../plugin.js';
import {
getUser, getOrgScope, orgScopeExpr, hasPermission, getAccountType, viewForType, SYSTEM_ORG_ID,
getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, checkApiKeyScope,
getAccountType, viewForType, isAllowed, SYSTEM_ORG_ID,
} from '../permissions.js';
import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { generatePassword } from '../password.js';
import { callerControlsRoleOrg } from './role-authority.js';
export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
return async function userCrudRoutes(app: FastifyInstance) {
@@ -31,21 +33,19 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
const checkOrg = orgIds[0] ?? null;
const [sysRes, svcRes, globalRes] = 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']),
// 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 [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 canReadGlobally = globalRes.rows[0]?.allowed ?? false;
const { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id');
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
const placement = canReadGlobally ? ' OR u.needs_placement' : '';
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'))`
: '';
@@ -80,7 +80,7 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
schema: {
tags: ['Management'],
summary: 'Create a user',
description: 'Creates a user account. For `user` accounts a strong password is auto-generated and returned once as `generated_password`; the account must change it on first login. `service` accounts have no password (API-only). Requires `create`/`users` (or `create`/`service_users`) permission.',
description: 'Creates a user account. For `user` accounts a strong password is auto-generated and returned once as `generated_password`; the account must change it on first login. `service` accounts have no password (API-only). Requires `create`/`users` (or `create`/`service_users`) permission. Pass `placement` to give the account a role in one go: an account created without one is unplaced, and only a holder of unattributed `read`/`users` can see it afterwards, so a manager who creates a user without placing it loses sight of it.',
security: [{ oauth2: [] }],
body: {
type: 'object',
@@ -90,6 +90,15 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
name: { type: 'string', minLength: 1, maxLength: 255 },
account_type: { type: 'string', enum: ['user', 'service'], default: 'user' },
is_active: { type: 'boolean', default: true },
placement: {
type: 'object',
required: ['role_id', 'org_id'],
additionalProperties: false,
properties: {
role_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
},
},
},
},
response: {
@@ -106,14 +115,25 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
if (orgIds === null) return reply;
const orgId = orgIds[0] ?? null;
const { email: rawEmail, name, account_type = 'user', is_active = true } = req.body as {
const { email: rawEmail, name, account_type = 'user', is_active = true, placement } = req.body as {
email: string; name: string;
account_type?: 'user' | 'service'; is_active?: boolean;
placement?: { role_id: string; org_id: string };
};
const email = rawEmail.trim().toLowerCase();
const view = viewForType(account_type);
if (!await hasPermission(db, req, reply, orgId, 'create', view)) return reply;
// Placing the account is a second act of authority, checked before
// anything is written so a refused placement creates no account.
if (placement) {
const assignView = account_type === 'service' ? 'service_users' : 'user_roles';
if (!await hasPermission(db, req, reply, placement.org_id, 'create', assignView)) return reply;
if (!await callerControlsRoleOrg(db, user.id, placement.role_id, 'create')) {
return reply.code(403).send({ error: 'Cannot assign a role outside your authority' });
}
}
if (checkLimit) {
const limitName = account_type === 'service' ? 'max_service_users' : 'max_users';
const ok = await checkLimit(
@@ -129,17 +149,35 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
// accounts are API-only and have no password.
const generated = account_type === 'user' ? generatePassword() : null;
const hash = generated ? await bcrypt.hash(generated, 12) : null;
const tx = await db.connect();
try {
const { rows } = await db.query(
await tx.query('BEGIN');
const { rows } = await tx.query(
`INSERT INTO app.users (email, name, password_hash, account_type, is_active, must_change_password)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, email, name, account_type, is_active, needs_placement, created_at`,
[email, name, hash, account_type, is_active, generated !== null],
);
return reply.code(201).send({ ...rows[0], roles: [], ...(generated && { generated_password: generated }) });
const created = rows[0] as { id: string; needs_placement: boolean };
const roles: { role_id: string; org_id: string }[] = [];
if (placement) {
await tx.query('SELECT morbac.assign_role($1::UUID, $2::UUID, $3::UUID)',
[created.id, placement.role_id, placement.org_id]);
await tx.query('UPDATE app.users SET needs_placement = FALSE WHERE id = $1', [created.id]);
created.needs_placement = false;
roles.push(placement);
}
await tx.query('COMMIT');
return reply.code(201).send({ ...created, roles, ...(generated && { generated_password: generated }) });
} catch (e: unknown) {
await tx.query('ROLLBACK').catch(() => undefined);
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Email already in use' });
if ((e as { message?: string }).message?.includes('Separation of Duty')) {
return reply.code(409).send({ error: 'Role assignment would violate Separation of Duty constraints' });
}
throw e;
} finally {
tx.release();
}
});
@@ -168,15 +206,15 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const orgId = orgIds[0] ?? null;
const { id } = req.params as { id: string };
const type = await getAccountType(db, id);
if (!type) return reply.code(404).send({ error: 'Not found' });
if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, orgId, 'update', viewForType(type))) return reply;
const view = viewForType(type);
if (!checkApiKeyScope(req, reply, 'update', view)) return reply;
if (!await canActOnUser(db, user.id, id, 'update', view)) {
return reply.code(404).send({ error: 'Not found' });
}
const { name, email, is_active, reset_password } = req.body as {
name?: string; email?: string; is_active?: boolean; reset_password?: boolean;
@@ -215,15 +253,15 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const orgId = orgIds[0] ?? null;
const { id } = req.params as { id: string };
const type = await getAccountType(db, id);
if (!type) return reply.code(404).send({ error: 'Not found' });
if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, orgId, 'delete', viewForType(type))) return reply;
const view = viewForType(type);
if (!checkApiKeyScope(req, reply, 'delete', view)) return reply;
if (!await canActOnUser(db, user.id, id, 'delete', view)) {
return reply.code(404).send({ error: 'Not found' });
}
await db.query('DELETE FROM app.users WHERE id = $1', [id]);
return reply.code(204).send();
+9 -13
View File
@@ -1,7 +1,7 @@
import { randomBytes, createHash } from 'node:crypto';
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js';
import { getUser, canActOnUser, checkApiKeyScope, getAccountType } from '../permissions.js';
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
function generateKey(): { raw: string; hash: string; prefix: string } {
@@ -78,11 +78,10 @@ export function createUserKeyRoutes(db: Pool) {
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const { id } = req.params as { id: string };
if (!await ensureService(id, reply)) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'service_users')) return reply;
if (!checkApiKeyScope(req, reply, 'read', 'service_users')) return reply;
if (!await canActOnUser(db, user.id, id, 'read', 'service_users')) return reply.code(404).send({ error: 'Not found' });
const { rows } = await db.query(
`SELECT id, name, description, key_prefix, readonly, permission_scope, expires_at, last_used_at, created_at
@@ -107,11 +106,10 @@ export function createUserKeyRoutes(db: Pool) {
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const { id } = req.params as { id: string };
if (!await ensureService(id, reply)) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'service_users')) return reply;
if (!checkApiKeyScope(req, reply, 'read', 'service_users')) return reply;
if (!await canActOnUser(db, user.id, id, 'read', 'service_users')) return reply.code(404).send({ error: 'Not found' });
return heldPerms(db, id);
});
@@ -142,11 +140,10 @@ export function createUserKeyRoutes(db: Pool) {
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const { id } = req.params as { id: string };
if (!await ensureService(id, reply)) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'create', 'service_users')) return reply;
if (!checkApiKeyScope(req, reply, 'create', 'service_users')) return reply;
if (!await canActOnUser(db, user.id, id, 'create', 'service_users')) return reply.code(404).send({ error: 'Not found' });
const { name, description, readonly = false, expires_at, permission_scope } = req.body as {
name: string; description?: string; readonly?: boolean; expires_at?: string | null; permission_scope?: Scope;
@@ -201,11 +198,10 @@ export function createUserKeyRoutes(db: Pool) {
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const { id, keyId } = req.params as { id: string; keyId: string };
if (!await ensureService(id, reply)) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'delete', 'service_users')) return reply;
if (!checkApiKeyScope(req, reply, 'delete', 'service_users')) return reply;
if (!await canActOnUser(db, user.id, id, 'delete', 'service_users')) return reply.code(404).send({ error: 'Not found' });
await db.query('DELETE FROM app.api_keys WHERE id = $1 AND user_id = $2', [keyId, id]);
return reply.code(204).send();
+132
View File
@@ -0,0 +1,132 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { Pool } from 'pg';
import { getUser, canActOnUser, checkApiKeyScope, getAccountType, viewForType } from '../permissions.js';
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
const MAX_ORG_FILTER = 100;
const orgFilterArray = {
type: 'array',
maxItems: MAX_ORG_FILTER,
items: { type: 'string', format: 'uuid' },
} as const;
const orgFilterResponse = {
type: 'object',
properties: { org_filter: orgFilterArray },
} as const;
/** Subset of `ids` that `userId` is allowed to read as orgs. Used both to filter
* what a caller may see and to validate what a caller may write, so neither
* 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);
}
async function storedOrgFilter(db: Pool, userId: string): Promise<string[]> {
const { rows } = await db.query<{ org_filter: unknown }>(
`SELECT ui_preferences->'orgFilter' AS org_filter FROM app.users WHERE id = $1`,
[userId],
);
const value = rows[0]?.org_filter;
if (!Array.isArray(value)) return [];
return value.filter((id): id is string => typeof id === 'string');
}
/** The org filter is a display preference, never an authorization input: every
* route still authorizes the org ids it receives on the wire. */
export function createUserOrgFilterRoutes(db: Pool) {
return async function userOrgFilterRoutes(app: FastifyInstance) {
async function resolveView(id: string, reply: FastifyReply): Promise<string | null> {
const type = await getAccountType(db, id);
if (type === null || type === 'system') {
await reply.code(404).send({ error: 'Not found' });
return null;
}
return viewForType(type);
}
app.get('/users/:id/org-filter', {
schema: {
tags: ['Management'],
summary: 'Read a user organization filter',
description: 'Organization filter stored in the user preferences, restricted to the orgs the caller can read. Requires `read`/`users` (or `service_users`).',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: { 200: orgFilterResponse, 401: unauthorized, 403: forbidden, 404: notFound },
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const view = await resolveView(id, reply);
if (!view) return reply;
if (!checkApiKeyScope(req, reply, 'read', view)) return reply;
if (!await canActOnUser(db, user.id, id, 'read', view)) return reply.code(404).send({ error: 'Not found' });
const stored = await storedOrgFilter(db, id);
return { org_filter: await visibleOrgIds(db, user.id, stored) };
});
app.patch('/users/:id/org-filter', {
schema: {
tags: ['Management'],
summary: 'Replace a user organization filter',
description: 'Replaces the slice of the organization filter the caller can see. Ids outside the caller reach are preserved untouched. Requires `update`/`users` (or `service_users`).',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['org_filter'],
additionalProperties: false,
properties: { org_filter: orgFilterArray },
},
response: { 200: orgFilterResponse, 400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound },
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const type = await getAccountType(db, id);
if (type === null) return reply.code(404).send({ error: 'Not found' });
if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' });
const view = viewForType(type);
if (!checkApiKeyScope(req, reply, 'update', view)) return reply;
if (!await canActOnUser(db, user.id, id, 'update', view)) return reply.code(404).send({ error: 'Not found' });
const submitted = [...new Set((req.body as { org_filter: string[] }).org_filter)];
// Only the caller's own reach is validated. The target's is deliberately
// not: the filter is intersected with what the target can see at render
// time, so an id they cannot see shows nothing and grants nothing, and
// forbidding it would forbid preserving the very ids below.
const visibleToCaller = await visibleOrgIds(db, user.id, submitted);
if (visibleToCaller.length !== submitted.length) {
return reply.code(400).send({ error: 'Invalid organization' });
}
const stored = await storedOrgFilter(db, id);
const callerVisibleStored = new Set(await visibleOrgIds(db, user.id, stored));
const preserved = stored.filter((orgId) => !callerVisibleStored.has(orgId));
const next = [...new Set([...submitted, ...preserved])];
await db.query(
`UPDATE app.users
SET ui_preferences = COALESCE(ui_preferences, '{}'::jsonb) || jsonb_build_object('orgFilter', $1::jsonb)
WHERE id = $2`,
[JSON.stringify(next), id],
);
return { org_filter: submitted };
});
};
}
+8
View File
@@ -1,7 +1,9 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js';
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { callerControlsRoleOrg } from './role-authority.js';
export function createUserRoleRoutes(db: Pool) {
return async function userRoleRoutes(app: FastifyInstance) {
@@ -44,6 +46,9 @@ export function createUserRoleRoutes(db: Pool) {
if (assignType === 'system') return reply.code(403).send({ error: 'System account roles are immutable' });
const assignView = assignType === 'service' ? 'service_users' : 'user_roles';
if (!await hasPermission(db, req, reply, org_id, 'create', assignView)) return reply;
if (!await callerControlsRoleOrg(db, caller.id, role_id, 'create')) {
return reply.code(403).send({ error: 'Cannot assign a role outside your authority' });
}
try {
await db.query(
@@ -172,6 +177,9 @@ export function createUserRoleRoutes(db: Pool) {
if (revokeType === 'system') return reply.code(403).send({ error: 'System account roles are immutable' });
const revokeView = revokeType === 'service' ? 'service_users' : 'user_roles';
if (!await hasPermission(db, req, reply, org_id, 'delete', revokeView)) return reply;
if (!await callerControlsRoleOrg(db, caller.id, role_id, 'delete')) {
return reply.code(403).send({ error: 'Cannot revoke a role outside your authority' });
}
await db.query(
'SELECT morbac.revoke_role($1::UUID, $2::UUID, $3::UUID)',
+2
View File
@@ -4,11 +4,13 @@ import type { CheckLimitFn } from '../plugin.js';
import { createUserCrudRoutes } from './user-crud.js';
import { createUserRoleRoutes } from './user-roles.js';
import { createUserKeyRoutes } from './user-keys.js';
import { createUserOrgFilterRoutes } from './user-org-filter.js';
export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) {
return async function userRoutes(app: FastifyInstance) {
await app.register(createUserCrudRoutes(db, checkLimit));
await app.register(createUserRoleRoutes(db));
await app.register(createUserKeyRoutes(db));
await app.register(createUserOrgFilterRoutes(db));
};
}
+15 -7
View File
@@ -5,17 +5,21 @@ export const orgResponse = {
name: { type: 'string' },
parent_id: { type: 'string', format: 'uuid', nullable: true },
metadata: { type: 'object' },
path: { type: 'array', items: { type: 'string' } },
},
};
export const roleResponse = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
org_name: { type: 'string' },
name: { type: 'string' },
description: { type: 'string', nullable: true },
id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
org_name: { type: 'string' },
name: { type: 'string' },
description: { type: 'string', nullable: true },
profile_module_id: { type: 'string', nullable: true },
profile_key: { type: 'string', nullable: true },
profile_synced: { type: 'boolean', nullable: true },
},
};
@@ -32,6 +36,9 @@ export const ruleResponse = {
modality: { type: 'string', enum: ['permission', 'prohibition'] },
priority: { type: 'integer', nullable: true },
scope: { type: 'string' },
valid_from: { type: 'string', format: 'date-time', nullable: true },
valid_until: { type: 'string', format: 'date-time', nullable: true },
is_active: { type: 'boolean' },
},
};
@@ -82,10 +89,11 @@ export const userRuleResponse = {
properties: {
id: { type: 'string', format: 'uuid' },
user_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
// null org = unattributed (the rule targets objects with no org)
org_id: { type: 'string', format: 'uuid', nullable: true },
user_name: { type: 'string' },
user_email: { type: 'string', nullable: true },
org_name: { type: 'string' },
org_name: { type: 'string', nullable: true },
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
+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);
});