Compare commits
7
Commits
73d3bc36c8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d713b25431 | ||
|
|
0f7db7e99e | ||
|
|
12964793cf | ||
|
|
bc05d312a6 | ||
|
|
ee15ee0adf | ||
|
|
9b7003a691 | ||
|
|
10fb0858f6 |
@@ -0,0 +1,53 @@
|
|||||||
|
# Stages @crudy/pgmorbac-fastify to npmjs on version tags (v<major>.<minor>.<patch>)
|
||||||
|
# through npm trusted publishing: GitHub's OIDC token stands in for an npm
|
||||||
|
# token, and npm attaches a provenance statement naming this repository and
|
||||||
|
# commit. The trusted publisher may only stage: a staged version goes live once
|
||||||
|
# a maintainer approves it with 2FA (npm stage approve, or npmjs.com). The
|
||||||
|
# organisation only allows its own actions, so checkout and Node setup are
|
||||||
|
# plain shell. prepack builds dist from src.
|
||||||
|
name: publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
NODE_VERSION: v24.22.2
|
||||||
|
steps:
|
||||||
|
- name: Check out the tag
|
||||||
|
run: |
|
||||||
|
case "$GITHUB_REF_NAME" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "not a version tag: $GITHUB_REF_NAME" >&2; exit 1 ;; esac
|
||||||
|
git init -q .
|
||||||
|
git fetch -q --depth 1 "https://github.com/$GITHUB_REPOSITORY" "refs/tags/$GITHUB_REF_NAME:refs/tags/$GITHUB_REF_NAME"
|
||||||
|
git checkout -q "refs/tags/$GITHUB_REF_NAME"
|
||||||
|
- name: Install Node
|
||||||
|
run: |
|
||||||
|
base="https://nodejs.org/dist/$NODE_VERSION"
|
||||||
|
file="node-$NODE_VERSION-linux-x64.tar.xz"
|
||||||
|
curl -fsSLO "$base/$file"
|
||||||
|
curl -fsSL "$base/SHASUMS256.txt" | grep " $file\$" | sha256sum -c -
|
||||||
|
mkdir -p "$RUNNER_TEMP/node"
|
||||||
|
tar -xJf "$file" -C "$RUNNER_TEMP/node" --strip-components=1
|
||||||
|
rm "$file"
|
||||||
|
echo "$RUNNER_TEMP/node/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Install npm with staged publishing
|
||||||
|
run: npm install -g npm@11.21.1
|
||||||
|
- run: npm install --no-audit --no-fund
|
||||||
|
- run: npm run check
|
||||||
|
- name: Publish
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
[ "v$VERSION" = "$GITHUB_REF_NAME" ] || { echo "tag $GITHUB_REF_NAME does not match package version $VERSION" >&2; exit 1; }
|
||||||
|
NAME=$(node -p "require('./package.json').name")
|
||||||
|
if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then
|
||||||
|
echo "$NAME@$VERSION is already published"
|
||||||
|
else
|
||||||
|
npm stage publish
|
||||||
|
fi
|
||||||
+28
-1
@@ -5,6 +5,33 @@ All notable changes to this package will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-11-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `listScope(db, req, scope, activity, view, column)`: the filter a list applies, over the orgs under the requested ones (every org when none is named), each kept only where the caller holds the permission once the hooks have run.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The org, user, role, role hierarchy, rule, user rule, cross-org rule and delegation lists answered for the whole subtree of each permitted org, and a request naming the system org skipped the org filter entirely. Every org they answer for is now decided by the permission system with the hooks applied, so an org the hooks refuse never shows in a list, pinned or not, and the system org is an org like any other.
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-11-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `permittedScope(db, req, reply, orgIds, activity, view)`: the orgs of a list request the caller may act in, through the hooks; 403 only when none passes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The list routes for users, roles, role settings, role, user and cross-org rules, delegations, permission profiles, user roles and activities checked only the first org of `X-Org-Ids`, then answered for every listed org. A caller allowed in the first org read the others' rows, and a caller refused in the first org got 403 for the whole list. They now answer for the permitted orgs only.
|
||||||
|
- The unpinned org list, the stored org filter and `readableOrgPaths` decided with `morbac.is_allowed` in SQL, which skips the permission hooks. They now go through them, so an org the hooks refuse is never offered.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `addPermissionHook('pre' | 'post', hook)`: application rules every permission decision runs. Hooks can only refuse; a throwing hook fails the decision.
|
||||||
|
- `isAllowed` and `permittedOrgs`: the hooked decision without a reply, for one org or several.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `hasPermission`, `hasPermissionAny`, `canPerform`, `canActOnUser`, `canGrantRule`, the user listing and role assignment decide through `isAllowed`, so hooks apply to each.
|
||||||
|
- The data proxy forwards only the `X-Org-Ids` the caller is allowed in, instead of every listed org once one passed. Rows are unchanged without hooks: RLS already hid the others.
|
||||||
|
|
||||||
## [1.0.0] - 2026-07-25
|
## [1.0.0] - 2026-07-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -22,4 +49,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `withMorbacContext` accepts an org selection, serialised to `morbac.org_ids`
|
- `withMorbacContext` accepts an org selection, serialised to `morbac.org_ids`
|
||||||
where a JSON null element names the unattributed bucket.
|
where a JSON null element names the unattributed bucket.
|
||||||
|
|
||||||
[1.0.0]: https://git.villains.fr/crudy/pgmorbac-node/releases/tag/v1.0.0
|
[1.0.0]: https://github.com/crudylabs/pgmorbac-fastify/releases/tag/v1.0.0
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ The package is split into focused entry points so you only pull in what you need
|
|||||||
| Import | Purpose |
|
| Import | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `@crudy/pgmorbac-fastify` | RLS session context and shared domain types |
|
| `@crudy/pgmorbac-fastify` | RLS session context and shared domain types |
|
||||||
| `@crudy/pgmorbac-fastify/permissions` | `hasPermission`, org-scope resolution, admin checks |
|
| `@crudy/pgmorbac-fastify/permissions` | `hasPermission`, `isAllowed`, `permittedOrgs`, permission hooks, org-scope resolution, admin checks |
|
||||||
| `@crudy/pgmorbac-fastify/proxy` | PostgREST data proxy with per-route permissions |
|
| `@crudy/pgmorbac-fastify/proxy` | PostgREST data proxy with per-route permissions |
|
||||||
| `@crudy/pgmorbac-fastify/plugin` | Fastify management routes (orgs, roles, rules, users, delegations) |
|
| `@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/list-query` | PostgREST-style sort/filter/pagination parsing |
|
||||||
@@ -48,6 +48,21 @@ app.get('/documents', async (req, reply) => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a decision without a reply, use `isAllowed(pool, userId, orgId, activity, view)`; for several orgs at once, `permittedOrgs`. Never query `morbac.is_allowed` directly: it skips the hooks below.
|
||||||
|
|
||||||
|
### Permission hooks
|
||||||
|
|
||||||
|
An application adds its own rules to every decision with `addPermissionHook`. A `pre` hook runs before morbac is asked and a `post` hook receives morbac's answer. Either can only refuse: a decision is allowed when morbac allows it and every hook returns `true`. A hook that throws fails the decision with its error.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { addPermissionHook } from '@crudy/pgmorbac-fastify/permissions';
|
||||||
|
|
||||||
|
// No action in an org whose subscription lapsed.
|
||||||
|
addPermissionHook('pre', async ({ orgId }) => orgId === null || await subscriptionActive(orgId));
|
||||||
|
```
|
||||||
|
|
||||||
|
The hooks run in `hasPermission`, `hasPermissionAny`, `canPerform`, `canActOnUser`, `canGrantRule`, `isAllowed`, `permittedOrgs` and the data proxy, which forwards only the `X-Org-Ids` the caller is allowed in. `hasAnyPermission` names no org and runs none. Row-level security evaluates morbac alone, so a rule that must also hide rows belongs in the database.
|
||||||
|
|
||||||
### RLS session context
|
### 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.
|
Run queries under the acting user so `morbac` row-level security policies apply as a second gate behind `hasPermission`. Required for non-`BYPASSRLS` runtime roles.
|
||||||
@@ -107,6 +122,7 @@ createMgmtRoutes({
|
|||||||
- **Deny by default.** Unregistered routes do not exist; unauthorized reads return `404` over `403` to avoid enumeration.
|
- **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.
|
- **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.
|
- **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.
|
- **Allowlisted queries.** `list-query` parameterizes values and allowlists sortable/filterable columns; user input is never interpolated.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@crudy/pgmorbac-fastify",
|
"name": "@crudy/pgmorbac-fastify",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Fastify layer over pgmorbac: permission checks, RLS session context, management routes, PostgREST data proxy, migration loader",
|
"description": "Fastify layer over pgmorbac: permission checks, RLS session context, management routes, PostgREST data proxy, migration loader",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -76,7 +76,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"prepack": "npm run build",
|
"prepack": "npm run build",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit",
|
||||||
|
"test": "tsc && node --test 'test/*.test.mjs'"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ export {
|
|||||||
orgSelectionToSetting,
|
orgSelectionToSetting,
|
||||||
orgTargetsToHeader,
|
orgTargetsToHeader,
|
||||||
hasAnyPermission,
|
hasAnyPermission,
|
||||||
|
isAllowed,
|
||||||
|
permittedOrgs,
|
||||||
|
permittedScope,
|
||||||
|
listScope,
|
||||||
type OrgTarget,
|
type OrgTarget,
|
||||||
type OrgSelection,
|
type OrgSelection,
|
||||||
} from './permissions.js';
|
} from './permissions.js';
|
||||||
|
export { addPermissionHook, type PermissionCheck, type PrePermissionHook, type PostPermissionHook } from './permission-hooks.js';
|
||||||
export type { DataPermission } from './proxy.js';
|
export type { DataPermission } from './proxy.js';
|
||||||
export type {
|
export type {
|
||||||
Org,
|
Org,
|
||||||
|
|||||||
+6
-4
@@ -1,4 +1,5 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
import { permittedOrgs } from './permissions.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps a query selecting the visible orgs (id, name, parent_id, metadata) and
|
* Wraps a query selecting the visible orgs (id, name, parent_id, metadata) and
|
||||||
@@ -26,10 +27,11 @@ export function withOrgPaths(visSql: string): string {
|
|||||||
* the path start at the topmost org they can read, never above it.
|
* 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[]>> {
|
export async function readableOrgPaths(db: Pool, userId: string, view: string): Promise<Map<string, string[]>> {
|
||||||
|
const { rows: candidates } = await db.query<{ id: string }>(
|
||||||
|
`SELECT id FROM morbac.orgs WHERE morbac.is_allowed($1::UUID, id, 'read', $2)`, [userId, view]);
|
||||||
|
const readable = await permittedOrgs(db, userId, candidates.map((c) => c.id), 'read', view);
|
||||||
const { rows } = await db.query<{ id: string; path: string[] }>(
|
const { rows } = await db.query<{ id: string; path: string[] }>(
|
||||||
withOrgPaths(
|
withOrgPaths('SELECT id, name, parent_id, NULL::jsonb AS metadata FROM morbac.orgs WHERE id = ANY($1::UUID[])'),
|
||||||
`SELECT id, name, parent_id, NULL::jsonb AS metadata
|
[readable]);
|
||||||
FROM morbac.orgs WHERE morbac.is_allowed($1::UUID, id, 'read', $2)`),
|
|
||||||
[userId, view]);
|
|
||||||
return new Map(rows.map((r) => [r.id, r.path]));
|
return new Map(rows.map((r) => [r.id, r.path]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
|
/** One permission decision: may this user perform (activity, view) in this org. */
|
||||||
|
export interface PermissionCheck {
|
||||||
|
userId: string;
|
||||||
|
/** The org acted in, or null for the no-org perimeter. */
|
||||||
|
orgId: string | null;
|
||||||
|
activity: string;
|
||||||
|
view: string;
|
||||||
|
/** The request behind the decision, when there is one. */
|
||||||
|
req?: FastifyRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs before morbac is asked. Returning false refuses without asking it. */
|
||||||
|
export type PrePermissionHook = (check: PermissionCheck, db: Pool) => boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
/** Runs after morbac answered, with that answer. Returning false refuses. */
|
||||||
|
export type PostPermissionHook = (check: PermissionCheck, allowed: boolean, db: Pool) => boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
const pre: PrePermissionHook[] = [];
|
||||||
|
const post: PostPermissionHook[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an application rule to every permission decision the helpers make. A
|
||||||
|
* hook can only narrow: a decision is allowed when morbac allows it and every
|
||||||
|
* hook agrees, so no hook can grant what morbac refused. A hook that throws
|
||||||
|
* fails the decision with its error; nothing is swallowed into an allow.
|
||||||
|
* Returns a function that removes the hook.
|
||||||
|
*/
|
||||||
|
export function addPermissionHook(phase: 'pre', hook: PrePermissionHook): () => void;
|
||||||
|
export function addPermissionHook(phase: 'post', hook: PostPermissionHook): () => void;
|
||||||
|
export function addPermissionHook(phase: 'pre' | 'post', hook: PrePermissionHook | PostPermissionHook): () => void {
|
||||||
|
const list = (phase === 'pre' ? pre : post) as unknown[];
|
||||||
|
list.push(hook);
|
||||||
|
return () => {
|
||||||
|
const i = list.indexOf(hook);
|
||||||
|
if (i !== -1) list.splice(i, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPreHooks(check: PermissionCheck, db: Pool): Promise<boolean> {
|
||||||
|
for (const hook of pre) {
|
||||||
|
if (await hook(check, db) !== true) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPostHooks(check: PermissionCheck, allowed: boolean, db: Pool): Promise<boolean> {
|
||||||
|
let result = allowed;
|
||||||
|
for (const hook of post) {
|
||||||
|
if (await hook(check, allowed, db) !== true) result = false;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
+127
-42
@@ -1,5 +1,8 @@
|
|||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
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';
|
export const SYSTEM_ORG_ID = '00000000-0000-0000-0000-000000000000';
|
||||||
|
|
||||||
@@ -72,6 +75,32 @@ export function orgScopeExpr(orgIds: string[], column: string, paramOffset = 0):
|
|||||||
return { expr: `${column} IN (${parts.join(' UNION ')})`, params: orgIds };
|
return { expr: `${column} IN (${parts.join(' UNION ')})`, params: orgIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filter a list applies to `column`: the orgs it answers for are those under
|
||||||
|
* the orgs of `scope` (every org when the request named none), kept only where
|
||||||
|
* the caller holds (activity, target) once the permission hooks have run. A row
|
||||||
|
* with no org shows only on an unpinned list, which `permittedScope` let through
|
||||||
|
* on the no-org perimeter.
|
||||||
|
*/
|
||||||
|
export async function listScope(
|
||||||
|
db: Pool,
|
||||||
|
req: FastifyRequest,
|
||||||
|
scope: string[],
|
||||||
|
activity: string,
|
||||||
|
target: string,
|
||||||
|
column: string,
|
||||||
|
paramOffset = 0,
|
||||||
|
): Promise<{ expr: string; params: [string[]] }> {
|
||||||
|
const user = getUser(req);
|
||||||
|
const { rows } = scope.length
|
||||||
|
? await db.query<{ id: string }>(
|
||||||
|
"SELECT DISTINCT s.org_id AS id FROM unnest($1::UUID[]) o, morbac.get_org_scope(o, 'subtree') s", [scope])
|
||||||
|
: await db.query<{ id: string }>('SELECT id FROM morbac.orgs');
|
||||||
|
const orgs = user ? await permittedOrgs(db, user.id, rows.map((r) => r.id), activity, target, req) : [];
|
||||||
|
const own = `${column} = ANY($${paramOffset + 1}::UUID[])`;
|
||||||
|
return { expr: scope.length ? own : `(${own} OR ${column} IS NULL)`, params: [orgs] };
|
||||||
|
}
|
||||||
|
|
||||||
export function viewForType(type: 'user' | 'service'): string {
|
export function viewForType(type: 'user' | 'service'): string {
|
||||||
return type === 'service' ? 'service_users' : 'users';
|
return type === 'service' ? 'service_users' : 'users';
|
||||||
}
|
}
|
||||||
@@ -122,18 +151,12 @@ export async function canActOnUser(
|
|||||||
activity: string,
|
activity: string,
|
||||||
view: string,
|
view: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { rows } = await db.query<{ ok: boolean }>(
|
if (await isAllowed(db, callerId, null, activity, view)) return true;
|
||||||
`SELECT (
|
const { rows } = await db.query<{ org_id: string }>(
|
||||||
morbac.is_allowed($1::UUID, NULL::UUID, $3, $4)
|
'SELECT DISTINCT org_id FROM morbac.user_roles WHERE user_id = $1::UUID',
|
||||||
OR EXISTS (
|
[targetUserId],
|
||||||
SELECT 1 FROM morbac.user_roles ur
|
|
||||||
WHERE ur.user_id = $2::UUID
|
|
||||||
AND morbac.is_allowed($1::UUID, ur.org_id, $3, $4)
|
|
||||||
)
|
|
||||||
) AS ok`,
|
|
||||||
[callerId, targetUserId, activity, view],
|
|
||||||
);
|
);
|
||||||
return rows[0]?.ok ?? false;
|
return (await permittedOrgs(db, callerId, rows.map((r) => r.org_id), activity, view)).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if the caller may reach org (activity, view) at every org that a rule
|
/** True if the caller may reach org (activity, view) at every org that a rule
|
||||||
@@ -153,20 +176,14 @@ export async function canGrantRule(
|
|||||||
activity: string,
|
activity: string,
|
||||||
view: string,
|
view: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { rows } = await db.query<{ ok: boolean }>(
|
const { rows } = await db.query<{ org_id: string }>(
|
||||||
`SELECT (
|
'SELECT org_id FROM morbac.get_org_scope($1::UUID, $2) AS s(org_id)',
|
||||||
NOT EXISTS (
|
[ruleOrgId, scope],
|
||||||
SELECT 1 FROM morbac.get_org_scope($2::UUID, $3) AS s(org_id)
|
|
||||||
WHERE NOT morbac.is_allowed($1::UUID, s.org_id, $4, $5)
|
|
||||||
)
|
|
||||||
AND (
|
|
||||||
$3 NOT IN ('unattributed', 'all')
|
|
||||||
OR morbac.is_allowed($1::UUID, NULL::UUID, $4, $5)
|
|
||||||
)
|
|
||||||
) AS ok`,
|
|
||||||
[callerId, ruleOrgId, scope, activity, view],
|
|
||||||
);
|
);
|
||||||
return rows[0]?.ok ?? false;
|
const covered = rows.map((r) => r.org_id);
|
||||||
|
if ((await permittedOrgs(db, callerId, covered, activity, view)).length !== covered.length) return false;
|
||||||
|
if (scope !== 'unattributed' && scope !== 'all') return true;
|
||||||
|
return isAllowed(db, callerId, null, activity, view);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True iff (activity, view) has an explicit row in morbac.activity_view_bindings.
|
/** True iff (activity, view) has an explicit row in morbac.activity_view_bindings.
|
||||||
@@ -211,7 +228,8 @@ export async function requireGlobalAdmin(db: Pool, req: FastifyRequest, reply: F
|
|||||||
|
|
||||||
/** True if the user may perform (activity, view) in ANY context: any org they
|
/** 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
|
* belong to, unattributed objects, or via a global rule. For deciding whether
|
||||||
* to expose a feature at all -- never a substitute for the per-object check. */
|
* to expose a feature at all -- never a substitute for the per-object check.
|
||||||
|
* Names no org, so permission hooks do not run. */
|
||||||
export async function hasAnyPermission(
|
export async function hasAnyPermission(
|
||||||
db: Pool,
|
db: Pool,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -245,11 +263,7 @@ export async function canPerform(
|
|||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
|
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
|
||||||
if (!scopeAllows(apiKeyScope, activity, target)) return false;
|
if (!scopeAllows(apiKeyScope, activity, target)) return false;
|
||||||
const { rows } = await db.query<{ allowed: boolean }>(
|
return isAllowed(db, user.id, orgId, activity, target, req);
|
||||||
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
|
|
||||||
[user.id, orgId, activity, target],
|
|
||||||
);
|
|
||||||
return rows[0]?.allowed === true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads an API key's scope off a request, for callers that must reason about it. */
|
/** Reads an API key's scope off a request, for callers that must reason about it. */
|
||||||
@@ -277,11 +291,7 @@ export async function hasPermission(
|
|||||||
reply.code(403).send({ error: 'Insufficient permissions' });
|
reply.code(403).send({ error: 'Insufficient permissions' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { rows } = await db.query<{ allowed: boolean }>(
|
if (!await isAllowed(db, user.id, orgId, activity, target, req)) {
|
||||||
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
|
|
||||||
[user.id, orgId, activity, target],
|
|
||||||
);
|
|
||||||
if (!rows[0]?.allowed) {
|
|
||||||
reply.code(403).send({ error: 'Insufficient permissions' });
|
reply.code(403).send({ error: 'Insufficient permissions' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -312,15 +322,90 @@ export async function hasPermissionAny(
|
|||||||
reply.code(403).send({ error: 'Insufficient permissions' });
|
reply.code(403).send({ error: 'Insufficient permissions' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const real = orgIds.filter((v) => v && v.toLowerCase() !== 'unattributed');
|
const real = orgIds.filter((v) => v && v.toLowerCase() !== UNATTRIBUTED_ORG_TOKEN);
|
||||||
const { rows } = await db.query<{ allowed: boolean }>(
|
if ((await permittedOrgs(db, user.id, real, activity, target, req)).length === 0) {
|
||||||
`SELECT bool_or(morbac.is_allowed($1::UUID, o::UUID, $3, $4)) AS allowed
|
|
||||||
FROM unnest($2::UUID[]) o`,
|
|
||||||
[user.id, real, activity, target],
|
|
||||||
);
|
|
||||||
if (!rows[0]?.allowed) {
|
|
||||||
reply.code(403).send({ error: 'Insufficient permissions' });
|
reply.code(403).send({ error: 'Insufficient permissions' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The orgs of a list request the caller may read in, or null once a 401 or 403
|
||||||
|
* is sent. An unpinned request (no org header) is decided on the no-org
|
||||||
|
* perimeter and answers []. A pinned one keeps only the orgs that pass, hooks
|
||||||
|
* included, and is refused only when none does: one refused org neither fails
|
||||||
|
* the list nor widens it to orgs the caller cannot read.
|
||||||
|
*/
|
||||||
|
export async function permittedScope(
|
||||||
|
db: Pool,
|
||||||
|
req: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
orgIds: string[],
|
||||||
|
activity: string,
|
||||||
|
target: string,
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
if (orgIds.length === 0) return await hasPermission(db, req, reply, null, activity, target) ? [] : null;
|
||||||
|
const user = getUser(req);
|
||||||
|
if (!user) {
|
||||||
|
reply.code(401).send({ error: 'Authentication required' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
|
||||||
|
const allowed = scopeAllows(apiKeyScope, activity, target)
|
||||||
|
? await permittedOrgs(db, user.id, orgIds, activity, target, req)
|
||||||
|
: [];
|
||||||
|
if (allowed.length === 0) {
|
||||||
|
reply.code(403).send({ error: 'Insufficient permissions' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one permission decision every helper makes: morbac's answer, narrowed by
|
||||||
|
* the application's permission hooks (`addPermissionHook`). Use it instead of
|
||||||
|
* querying `morbac.is_allowed` directly, which would skip those hooks.
|
||||||
|
*/
|
||||||
|
export async function isAllowed(
|
||||||
|
db: Pool,
|
||||||
|
userId: string,
|
||||||
|
orgId: string | null,
|
||||||
|
activity: string,
|
||||||
|
view: string,
|
||||||
|
req?: FastifyRequest,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const check = { userId, orgId, activity, view, req };
|
||||||
|
if (!await runPreHooks(check, db)) return false;
|
||||||
|
const { rows } = await db.query<{ allowed: boolean }>(
|
||||||
|
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
|
||||||
|
[userId, orgId, activity, view],
|
||||||
|
);
|
||||||
|
return runPostHooks(check, rows[0]?.allowed === true, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The orgs among `orgIds` where `isAllowed` would answer yes, in their given order, asked of morbac in one query. */
|
||||||
|
export async function permittedOrgs(
|
||||||
|
db: Pool,
|
||||||
|
userId: string,
|
||||||
|
orgIds: string[],
|
||||||
|
activity: string,
|
||||||
|
view: string,
|
||||||
|
req?: FastifyRequest,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
for (const orgId of new Set(orgIds)) {
|
||||||
|
if (await runPreHooks({ userId, orgId, activity, view, req }, db)) candidates.push(orgId);
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) return [];
|
||||||
|
const { rows } = await db.query<{ o: string }>(
|
||||||
|
'SELECT o FROM unnest($2::UUID[]) o WHERE morbac.is_allowed($1::UUID, o, $3, $4)',
|
||||||
|
[userId, candidates, activity, view],
|
||||||
|
);
|
||||||
|
const allowed = new Set(rows.map((r) => r.o.toLowerCase()));
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const orgId of candidates) {
|
||||||
|
if (await runPostHooks({ userId, orgId, activity, view, req }, allowed.has(orgId.toLowerCase()), db)) out.push(orgId);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
+11
-7
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { hasPermission, hasPermissionAny } from './permissions.js';
|
import { checkApiKeyScope, hasPermission, permittedOrgs, UNATTRIBUTED_ORG_TOKEN } from './permissions.js';
|
||||||
|
|
||||||
export interface DataPermission {
|
export interface DataPermission {
|
||||||
activity: string;
|
activity: string;
|
||||||
@@ -95,13 +95,17 @@ export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegist
|
|||||||
return reply;
|
return reply;
|
||||||
}
|
}
|
||||||
} else if (orgIdsVal && orgIdsVal.trim()) {
|
} else if (orgIdsVal && orgIdsVal.trim()) {
|
||||||
// A multi-org list read (e.g. "all organizations"): scoped to the
|
// A multi-org list read (e.g. "all organizations"). Only the orgs
|
||||||
// set via X-Org-Ids. RLS filters each row, so the gate only needs
|
// where the caller is allowed go upstream: RLS filters rows by
|
||||||
// permission in one of them.
|
// 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 ids = orgIdsVal.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
if (!await hasPermissionAny(db, req, reply, ids, permission.activity, permission.view)) {
|
const unattributed = ids.some((id) => id.toLowerCase() === UNATTRIBUTED_ORG_TOKEN);
|
||||||
return reply;
|
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 {
|
} else {
|
||||||
return reply.code(400).send({ error: 'X-Org-Id header is required' });
|
return reply.code(400).send({ error: 'X-Org-Id header is required' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { getUser, getOrgScope, hasPermission } from '../permissions.js';
|
import { getUser, getOrgScope, permittedScope } from '../permissions.js';
|
||||||
import { unauthorized, badRequest, forbidden } from '../schemas.js';
|
import { unauthorized, badRequest, forbidden } from '../schemas.js';
|
||||||
|
|
||||||
export function createActivityRoutes(db: Pool) {
|
export function createActivityRoutes(db: Pool) {
|
||||||
@@ -31,7 +31,8 @@ export function createActivityRoutes(db: Pool) {
|
|||||||
if (!user) return reply.code(401).send({ error: 'Authentication required' });
|
if (!user) return reply.code(401).send({ error: 'Authentication required' });
|
||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
|
||||||
|
if (!scope) return reply;
|
||||||
const { rows } = await db.query('SELECT name, description FROM morbac.activities ORDER BY name');
|
const { rows } = await db.query('SELECT name, description FROM morbac.activities ORDER BY name');
|
||||||
return rows;
|
return rows;
|
||||||
});
|
});
|
||||||
@@ -70,7 +71,8 @@ export function createActivityRoutes(db: Pool) {
|
|||||||
|
|
||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const { name } = req.params as { name: string };
|
const { name } = req.params as { name: string };
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
@@ -132,7 +134,8 @@ export function createActivityRoutes(db: Pool) {
|
|||||||
if (!user) return reply.code(401).send({ error: 'Authentication required' });
|
if (!user) return reply.code(401).send({ error: 'Authentication required' });
|
||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
|
||||||
|
if (!scope) return reply;
|
||||||
// no_org marks a view whose rows carry no org, so callers can offer the
|
// 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.
|
// unattributed grant instead of a tree-scoped rule that could never match.
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
@@ -178,7 +181,8 @@ export function createActivityRoutes(db: Pool) {
|
|||||||
|
|
||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const { name } = req.params as { name: string };
|
const { name } = req.params as { name: string };
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
@@ -239,7 +243,8 @@ export function createActivityRoutes(db: Pool) {
|
|||||||
if (!user) return reply.code(401).send({ error: 'Authentication required' });
|
if (!user) return reply.code(401).send({ error: 'Authentication required' });
|
||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
|
||||||
|
if (!scope) return reply;
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
'SELECT activity, view FROM morbac.activity_view_bindings ORDER BY activity, view',
|
'SELECT activity, view FROM morbac.activity_view_bindings ORDER BY activity, view',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, SYSTEM_ORG_ID } from '../permissions.js';
|
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, canActOnUser, SYSTEM_ORG_ID } from '../permissions.js';
|
||||||
import { delegationResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { delegationResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
|
|
||||||
const SELECT_COLUMNS = `
|
const SELECT_COLUMNS = `
|
||||||
@@ -30,9 +30,10 @@ export function createDelegationRoutes(db: Pool) {
|
|||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
|
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'delegations')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'delegations');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const { expr, params } = orgScopeExpr(orgIds, 'd.org_id');
|
const { expr, params } = await listScope(db, req, scope, 'read', 'delegations', 'd.org_id');
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT ${SELECT_COLUMNS}
|
`SELECT ${SELECT_COLUMNS}
|
||||||
FROM morbac.delegations d
|
FROM morbac.delegations d
|
||||||
|
|||||||
+26
-14
@@ -3,11 +3,12 @@ import { withMorbacContext } from '../rls-context.js';
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import type { CheckLimitFn, OrgEventSink } from '../plugin.js';
|
import type { CheckLimitFn, OrgEventSink } from '../plugin.js';
|
||||||
import {
|
import {
|
||||||
getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID,
|
getUser, getOrgScope, listScope, hasPermission, permittedScope, permittedOrgs, SYSTEM_ORG_ID,
|
||||||
} from '../permissions.js';
|
} from '../permissions.js';
|
||||||
import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
|
|
||||||
import { withOrgPaths } from '../org-paths.js';
|
import { withOrgPaths } from '../org-paths.js';
|
||||||
|
import { runPreHooks, runPostHooks } from '../permission-hooks.js';
|
||||||
|
|
||||||
interface OrgRow { id: string; name: string; parent_id: string | null; metadata?: Record<string, unknown> }
|
interface OrgRow { id: string; name: string; parent_id: string | null; metadata?: Record<string, unknown> }
|
||||||
|
|
||||||
@@ -40,28 +41,39 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven
|
|||||||
// caller in it even with no read/orgs rule, and the org filter is
|
// 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
|
// what supplies X-Org-Id, so dropping those orgs leaves such a
|
||||||
// user with an empty perimeter and an app that does nothing.
|
// user with an empty perimeter and an app that does nothing.
|
||||||
|
// Both ways in still go through the permission hooks, so an org
|
||||||
|
// the hooks refuse never reaches the filter and every request
|
||||||
|
// pinned to it fails.
|
||||||
|
const { rows: candidates } = await db.query<{ id: string; member: boolean }>(
|
||||||
|
`SELECT o.id, EXISTS (SELECT 1 FROM morbac.user_roles ur WHERE ur.org_id = o.id AND ur.user_id = $1) AS member
|
||||||
|
FROM morbac.orgs o
|
||||||
|
WHERE morbac.is_allowed($1::UUID, o.id, 'read', 'orgs')
|
||||||
|
OR EXISTS (SELECT 1 FROM morbac.user_roles ur WHERE ur.org_id = o.id AND ur.user_id = $1)`,
|
||||||
|
[user.id],
|
||||||
|
);
|
||||||
|
const readable = new Set(await permittedOrgs(db, user.id, candidates.map((c) => c.id), 'read', 'orgs', req));
|
||||||
|
const visible: string[] = [];
|
||||||
|
for (const c of candidates) {
|
||||||
|
const check = { userId: user.id, orgId: c.id, activity: 'read', view: 'orgs', req };
|
||||||
|
if (readable.has(c.id) || (c.member && await runPreHooks(check, db) && await runPostHooks(check, true, db))) visible.push(c.id);
|
||||||
|
}
|
||||||
const { rows } = await withMorbacContext(db, { userId: user.id }, (client) =>
|
const { rows } = await withMorbacContext(db, { userId: user.id }, (client) =>
|
||||||
client.query(
|
client.query(
|
||||||
withOrgPaths(
|
withOrgPaths('SELECT id, name, parent_id, metadata FROM morbac.orgs WHERE id = ANY($1::UUID[])'),
|
||||||
`SELECT id, name, parent_id, metadata FROM morbac.orgs o
|
[visible],
|
||||||
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;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0], 'read', 'orgs')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'orgs');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
|
const { params: [visible] } = await listScope(db, req, scope, 'read', 'orgs', 'id');
|
||||||
const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(orgIds, 'id');
|
const { rows } = await withMorbacContext(db, { userId: user.id, orgId: scope[0] }, (client) =>
|
||||||
const { rows } = await withMorbacContext(db, { userId: user.id, orgId: orgIds[0] }, (client) =>
|
|
||||||
client.query(
|
client.query(
|
||||||
withOrgPaths(`SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''}`),
|
withOrgPaths('SELECT id, name, parent_id, metadata FROM morbac.orgs WHERE id = ANY($1::UUID[])'),
|
||||||
params,
|
[visible],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
|
import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
|
||||||
import { getUser, getOrgScope, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
|
import { getUser, getOrgScope, hasPermission, permittedScope, SYSTEM_ORG_ID } from '../permissions.js';
|
||||||
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
import { loadProfile, validateGrantable, applyProfileRules } from './profile-apply.js';
|
import { loadProfile, validateGrantable, applyProfileRules } from './profile-apply.js';
|
||||||
|
|
||||||
@@ -58,7 +58,8 @@ export function createPermissionProfileRoutes(db: Pool, checkLimit?: CheckLimitF
|
|||||||
|
|
||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const { module_id } = req.query as { module_id?: string };
|
const { module_id } = req.query as { module_id?: string };
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
import { isAllowed } from '../permissions.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the caller may hand out this role at all: assigning it is exercising
|
* Whether the caller may hand out this role at all: assigning it is exercising
|
||||||
@@ -7,12 +8,6 @@ import type { Pool } from 'pg';
|
|||||||
export async function callerControlsRoleOrg(
|
export async function callerControlsRoleOrg(
|
||||||
db: Pool, callerId: string, roleId: string, activity: string,
|
db: Pool, callerId: string, roleId: string, activity: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { rows } = await db.query<{ ok: boolean }>(
|
const { rows } = await db.query<{ org_id: string | null }>('SELECT org_id FROM morbac.roles WHERE id = $1::UUID', [roleId]);
|
||||||
`SELECT EXISTS (
|
return rows.length > 0 && isAllowed(db, callerId, rows[0].org_id, activity, 'user_roles');
|
||||||
SELECT 1 FROM morbac.roles r
|
|
||||||
WHERE r.id = $2::UUID AND morbac.is_allowed($1::UUID, r.org_id, $3, 'user_roles')
|
|
||||||
) AS ok`,
|
|
||||||
[callerId, roleId, activity],
|
|
||||||
);
|
|
||||||
return rows[0]?.ok ?? false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
|
import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
|
||||||
import {
|
import {
|
||||||
getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID,
|
getUser, getOrgScope, listScope, hasPermission, permittedScope, SYSTEM_ORG_ID,
|
||||||
} from '../permissions.js';
|
} from '../permissions.js';
|
||||||
import { parseListParams, composeWhere, setContentRange, ListQueryError } from '../list-query.js';
|
import { parseListParams, composeWhere, setContentRange, ListQueryError } from '../list-query.js';
|
||||||
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
@@ -30,9 +30,10 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv
|
|||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
|
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const { expr, params } = orgScopeExpr(orgIds, 'r.org_id');
|
const { expr, params } = await listScope(db, req, scope, 'read', 'roles', 'r.org_id');
|
||||||
let list;
|
let list;
|
||||||
try {
|
try {
|
||||||
list = parseListParams(req.query as Record<string, unknown>, {
|
list = parseListParams(req.query as Record<string, unknown>, {
|
||||||
@@ -214,9 +215,10 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv
|
|||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
|
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const { expr, params } = orgScopeExpr(orgIds, 'rs.org_id');
|
const { expr, params } = await listScope(db, req, scope, 'read', 'roles', 'rs.org_id');
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT rh.senior_role_id, rs.name AS senior_name,
|
`SELECT rh.senior_role_id, rs.name AS senior_name,
|
||||||
rh.junior_role_id, rj.name AS junior_name,
|
rh.junior_role_id, rj.name AS junior_name,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { getUser, getOrgScope, orgScopeExpr, hasPermission, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
|
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
|
||||||
import { crossOrgRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { crossOrgRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
|
|
||||||
export function createCrossOrgRuleRoutes(db: Pool) {
|
export function createCrossOrgRuleRoutes(db: Pool) {
|
||||||
@@ -23,14 +23,11 @@ export function createCrossOrgRuleRoutes(db: Pool) {
|
|||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
|
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'cross_org_rules')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'cross_org_rules');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const { expr: srcExpr, params: srcParams } = orgScopeExpr(orgIds, 'cor.source_org_id');
|
const { params } = await listScope(db, req, scope, 'read', 'cross_org_rules', 'cor.source_org_id');
|
||||||
const { expr: tgtExpr, params: tgtParams } = orgScopeExpr(orgIds, 'cor.target_org_id', srcParams.length);
|
const where = 'WHERE cor.source_org_id = ANY($1::UUID[]) OR cor.target_org_id = ANY($1::UUID[])';
|
||||||
const where = srcExpr && tgtExpr ? `WHERE (${srcExpr} OR ${tgtExpr})`
|
|
||||||
: srcExpr ? `WHERE ${srcExpr}`
|
|
||||||
: tgtExpr ? `WHERE ${tgtExpr}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT cor.id,
|
`SELECT cor.id,
|
||||||
@@ -45,7 +42,7 @@ export function createCrossOrgRuleRoutes(db: Pool) {
|
|||||||
JOIN morbac.orgs "to" ON "to".id = cor.target_org_id
|
JOIN morbac.orgs "to" ON "to".id = cor.target_org_id
|
||||||
${where}
|
${where}
|
||||||
ORDER BY so.name, sr.name, "to".name, cor.activity, cor.view`,
|
ORDER BY so.name, sr.name, "to".name, cor.activity, cor.view`,
|
||||||
[...srcParams, ...tgtParams],
|
params,
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { getUser, getOrgScope, orgScopeExpr, hasPermission, canGrantRule, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
|
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, canGrantRule, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
|
||||||
import { ruleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { ruleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
|
|
||||||
export function createRoleRuleRoutes(db: Pool) {
|
export function createRoleRuleRoutes(db: Pool) {
|
||||||
@@ -23,9 +23,10 @@ export function createRoleRuleRoutes(db: Pool) {
|
|||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
|
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const { expr, params } = orgScopeExpr(orgIds, 'r.org_id');
|
const { expr, params } = await listScope(db, req, scope, 'read', 'rules', 'r.org_id');
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT r.id, r.role_id, r.org_id, o.name AS org_name, ro.name AS role_name,
|
`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,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { getUser, getOrgScope, orgScopeExpr, hasPermission, isActivityViewBound } from '../permissions.js';
|
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, isActivityViewBound } from '../permissions.js';
|
||||||
import { userRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { userRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
|
|
||||||
export function createUserRuleRoutes(db: Pool) {
|
export function createUserRuleRoutes(db: Pool) {
|
||||||
@@ -23,9 +23,10 @@ export function createUserRuleRoutes(db: Pool) {
|
|||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
|
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'user_rules')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'user_rules');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const { expr, params } = orgScopeExpr(orgIds, 'ur.org_id');
|
const { expr, params } = await listScope(db, req, scope, 'read', 'user_rules', 'ur.org_id');
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT ur.id, ur.user_id, ur.org_id,
|
`SELECT ur.id, ur.user_id, ur.org_id,
|
||||||
COALESCE(u.name, ur.user_id::TEXT) AS user_name,
|
COALESCE(u.name, ur.user_id::TEXT) AS user_name,
|
||||||
|
|||||||
+12
-20
@@ -3,8 +3,8 @@ import type { Pool } from 'pg';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import type { CheckLimitFn } from '../plugin.js';
|
import type { CheckLimitFn } from '../plugin.js';
|
||||||
import {
|
import {
|
||||||
getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, checkApiKeyScope,
|
getUser, getOrgScope, listScope, hasPermission, permittedScope, canActOnUser, checkApiKeyScope,
|
||||||
getAccountType, viewForType, SYSTEM_ORG_ID,
|
getAccountType, viewForType, isAllowed,
|
||||||
} from '../permissions.js';
|
} from '../permissions.js';
|
||||||
import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
import { generatePassword } from '../password.js';
|
import { generatePassword } from '../password.js';
|
||||||
@@ -30,31 +30,23 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
|
|||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
|
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const checkOrg = orgIds[0] ?? null;
|
const checkOrg = scope[0] ?? null;
|
||||||
// A user awaiting placement belongs to no org, so seeing one is the
|
// 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
|
// no-org perimeter (a NULL org), not a claim about every org. Any
|
||||||
// grant that reaches it answers: a role rule scoped unattributed or
|
// grant that reaches it answers: a role rule scoped unattributed or
|
||||||
// all, a user rule with no org, or a global rule.
|
// all, a user rule with no org, or a global rule.
|
||||||
const [sysRes, svcRes, noOrgRes] = await Promise.all([
|
const [canSeeSystem, canSeeService, canReadUnattached] = await Promise.all([
|
||||||
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
|
isAllowed(db, user.id, checkOrg, 'read', 'system_users', req),
|
||||||
[user.id, checkOrg, 'read', 'system_users']),
|
isAllowed(db, user.id, checkOrg, 'read', 'service_users', req),
|
||||||
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
|
isAllowed(db, user.id, null, 'read', 'users', req),
|
||||||
[user.id, checkOrg, 'read', 'service_users']),
|
|
||||||
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, NULL, $2, $3) AS allowed',
|
|
||||||
[user.id, 'read', 'users']),
|
|
||||||
]);
|
]);
|
||||||
const canSeeSystem = sysRes.rows[0]?.allowed ?? false;
|
|
||||||
const canSeeService = svcRes.rows[0]?.allowed ?? false;
|
|
||||||
const canReadUnattached = noOrgRes.rows[0]?.allowed ?? false;
|
|
||||||
|
|
||||||
const { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id');
|
const { params } = await listScope(db, req, scope, 'read', 'users', 'ur2.org_id');
|
||||||
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
|
|
||||||
const placement = canReadUnattached ? ' OR u.needs_placement' : '';
|
const placement = canReadUnattached ? ' OR u.needs_placement' : '';
|
||||||
const whereClause = expr && !isSystemScope
|
const whereClause = `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ur2.org_id = ANY($1::UUID[]))${placement} OR u.account_type IN ('system', 'service'))`;
|
||||||
? `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})${placement} OR u.account_type IN ('system', 'service'))`
|
|
||||||
: '';
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
u.id, u.email, u.name, u.account_type, u.is_active, u.needs_placement, u.created_at,
|
u.id, u.email, u.name, u.account_type, u.is_active, u.needs_placement, u.created_at,
|
||||||
@@ -73,7 +65,7 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
|
|||||||
LEFT JOIN morbac.orgs o ON o.id = ur.org_id
|
LEFT JOIN morbac.orgs o ON o.id = ur.org_id
|
||||||
${whereClause}
|
${whereClause}
|
||||||
GROUP BY u.id ORDER BY u.name`,
|
GROUP BY u.id ORDER BY u.name`,
|
||||||
whereClause ? params : [],
|
params,
|
||||||
);
|
);
|
||||||
return (rows as { account_type: string }[]).filter((u) => {
|
return (rows as { account_type: string }[]).filter((u) => {
|
||||||
if (u.account_type === 'system' && !canSeeSystem) return false;
|
if (u.account_type === 'system' && !canSeeSystem) return false;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { getUser, canActOnUser, checkApiKeyScope, getAccountType, viewForType } from '../permissions.js';
|
import { getUser, canActOnUser, permittedOrgs, checkApiKeyScope, getAccountType, viewForType } from '../permissions.js';
|
||||||
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
|
|
||||||
const MAX_ORG_FILTER = 100;
|
const MAX_ORG_FILTER = 100;
|
||||||
@@ -21,12 +21,8 @@ const orgFilterResponse = {
|
|||||||
* direction can be used to probe orgs outside the caller's reach. */
|
* direction can be used to probe orgs outside the caller's reach. */
|
||||||
async function visibleOrgIds(db: Pool, userId: string, ids: string[]): Promise<string[]> {
|
async function visibleOrgIds(db: Pool, userId: string, ids: string[]): Promise<string[]> {
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) return [];
|
||||||
const { rows } = await db.query<{ id: string }>(
|
const { rows } = await db.query<{ id: string }>('SELECT id FROM morbac.orgs WHERE id = ANY($1::UUID[])', [ids]);
|
||||||
`SELECT o.id FROM morbac.orgs o
|
return permittedOrgs(db, userId, rows.map((r) => r.id), 'read', 'orgs');
|
||||||
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[]> {
|
async function storedOrgFilter(db: Pool, userId: string): Promise<string[]> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js';
|
import { getUser, getOrgScope, hasPermission, permittedScope, getAccountType } from '../permissions.js';
|
||||||
|
|
||||||
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
import { callerControlsRoleOrg } from './role-authority.js';
|
import { callerControlsRoleOrg } from './role-authority.js';
|
||||||
@@ -99,7 +99,8 @@ export function createUserRoleRoutes(db: Pool) {
|
|||||||
|
|
||||||
const orgIds = getOrgScope(req, reply, false);
|
const orgIds = getOrgScope(req, reply, false);
|
||||||
if (orgIds === null) return reply;
|
if (orgIds === null) return reply;
|
||||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
|
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
|
||||||
|
if (!scope) return reply;
|
||||||
|
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const exists = await db.query('SELECT 1 FROM app.users WHERE id = $1', [id]);
|
const exists = await db.query('SELECT 1 FROM app.users WHERE id = $1', [id]);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { test, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { addPermissionHook, listScope } from '../dist/index.js';
|
||||||
|
|
||||||
|
const ROOT = '00000000-0000-0000-0000-000000000000';
|
||||||
|
const A = '00000000-0000-4000-8000-00000000000a';
|
||||||
|
const B = '00000000-0000-4000-8000-00000000000b';
|
||||||
|
const TREE = { [ROOT]: [ROOT, A, B], [A]: [A], [B]: [B] };
|
||||||
|
|
||||||
|
function fakeDb(allowed) {
|
||||||
|
return {
|
||||||
|
async query(sql, params) {
|
||||||
|
if (sql.includes('get_org_scope')) return { rows: [...new Set(params[0].flatMap((o) => TREE[o]))].map((id) => ({ id })) };
|
||||||
|
if (sql.includes('FROM morbac.orgs')) return { rows: Object.keys(TREE).map((id) => ({ id })) };
|
||||||
|
if (sql.includes('morbac.is_allowed')) return { rows: params[1].filter((o) => allowed.has(o)).map((o) => ({ o })) };
|
||||||
|
throw new Error(`unexpected query ${sql}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = { user: { id: 'u' } };
|
||||||
|
let removers = [];
|
||||||
|
beforeEach(() => { removers.forEach((r) => r()); removers = []; });
|
||||||
|
|
||||||
|
test('the root org stands for its subtree, decided org by org', async () => {
|
||||||
|
const { expr, params } = await listScope(fakeDb(new Set([ROOT, A])), req, [ROOT], 'read', 'users', 'x.org_id');
|
||||||
|
assert.equal(expr, 'x.org_id = ANY($1::UUID[])');
|
||||||
|
assert.deepEqual(params[0].sort(), [ROOT, A].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an org the hooks refuse is left out of every list', async () => {
|
||||||
|
removers.push(addPermissionHook('pre', (c) => c.orgId !== B));
|
||||||
|
const { params } = await listScope(fakeDb(new Set([ROOT, A, B])), req, [], 'read', 'users', 'x.org_id');
|
||||||
|
assert.deepEqual(params[0].sort(), [ROOT, A].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unpinned list also shows rows with no org', async () => {
|
||||||
|
const { expr } = await listScope(fakeDb(new Set([A])), req, [], 'read', 'roles', 'r.org_id', 2);
|
||||||
|
assert.equal(expr, '(r.org_id = ANY($3::UUID[]) OR r.org_id IS NULL)');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { test, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { addPermissionHook, permittedScope } from '../dist/index.js';
|
||||||
|
|
||||||
|
const A = '00000000-0000-4000-8000-00000000000a';
|
||||||
|
const B = '00000000-0000-4000-8000-00000000000b';
|
||||||
|
|
||||||
|
function fakeDb(allowed) {
|
||||||
|
return {
|
||||||
|
async query(sql, params) {
|
||||||
|
if (sql.includes('unnest')) return { rows: params[1].filter((o) => allowed.has(o)).map((o) => ({ o })) };
|
||||||
|
if (sql.includes('morbac.is_allowed')) return { rows: [{ allowed: allowed.has(params[1]) }] };
|
||||||
|
throw new Error(`unexpected query ${sql}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeReply() {
|
||||||
|
return { status: 200, code(c) { this.status = c; return this; }, send() { return this; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = { user: { id: 'u' } };
|
||||||
|
let removers = [];
|
||||||
|
beforeEach(() => { removers.forEach((r) => r()); removers = []; });
|
||||||
|
|
||||||
|
test('a pinned list keeps only the orgs the caller may read', async () => {
|
||||||
|
const reply = fakeReply();
|
||||||
|
assert.deepEqual(await permittedScope(fakeDb(new Set([B])), req, reply, [A, B], 'read', 'users'), [B]);
|
||||||
|
assert.equal(reply.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refused first org no longer fails the list', async () => {
|
||||||
|
removers.push(addPermissionHook('pre', (c) => c.orgId !== A));
|
||||||
|
assert.deepEqual(await permittedScope(fakeDb(new Set([A, B])), req, fakeReply(), [A, B], 'read', 'users'), [B]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a list with no permitted org is refused', async () => {
|
||||||
|
const reply = fakeReply();
|
||||||
|
assert.equal(await permittedScope(fakeDb(new Set()), req, reply, [A, B], 'read', 'users'), null);
|
||||||
|
assert.equal(reply.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unpinned list is decided on the no-org perimeter', async () => {
|
||||||
|
assert.deepEqual(await permittedScope(fakeDb(new Set([null])), req, fakeReply(), [], 'read', 'users'), []);
|
||||||
|
const reply = fakeReply();
|
||||||
|
assert.equal(await permittedScope(fakeDb(new Set()), req, reply, [], 'read', 'users'), null);
|
||||||
|
assert.equal(reply.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an API key outside its scope gets nothing', async () => {
|
||||||
|
const reply = fakeReply();
|
||||||
|
const keyed = { ...req, apiKeyScope: [{ activity: 'read', view: 'roles' }] };
|
||||||
|
assert.equal(await permittedScope(fakeDb(new Set([A])), keyed, reply, [A], 'read', 'users'), null);
|
||||||
|
assert.equal(reply.status, 403);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user