fix: list routes answer for permitted orgs only; org lists go through the hooks
This commit is contained in:
@@ -5,6 +5,15 @@ 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.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
|
## [1.1.0] - 2026-10-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@crudy/pgmorbac-fastify",
|
"name": "@crudy/pgmorbac-fastify",
|
||||||
"version": "1.1.0",
|
"version": "1.2.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": [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export {
|
|||||||
hasAnyPermission,
|
hasAnyPermission,
|
||||||
isAllowed,
|
isAllowed,
|
||||||
permittedOrgs,
|
permittedOrgs,
|
||||||
|
permittedScope,
|
||||||
type OrgTarget,
|
type OrgTarget,
|
||||||
type OrgSelection,
|
type OrgSelection,
|
||||||
} from './permissions.js';
|
} from './permissions.js';
|
||||||
|
|||||||
+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]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,6 +304,38 @@ export async function hasPermissionAny(
|
|||||||
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 one permission decision every helper makes: morbac's answer, narrowed by
|
||||||
* the application's permission hooks (`addPermissionHook`). Use it instead of
|
* the application's permission hooks (`addPermissionHook`). Use it instead of
|
||||||
|
|||||||
@@ -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, orgScopeExpr, 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 } = orgScopeExpr(scope, '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
|
||||||
|
|||||||
+25
-12
@@ -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, orgScopeExpr, 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,25 +41,37 @@ 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 isSystemScope = scope.includes(SYSTEM_ORG_ID);
|
||||||
const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(orgIds, 'id');
|
const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(scope, 'id');
|
||||||
const { rows } = await withMorbacContext(db, { userId: user.id, orgId: orgIds[0] }, (client) =>
|
const { rows } = await withMorbacContext(db, { userId: user.id, orgId: scope[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 ${expr ? `WHERE ${expr}` : ''}`),
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+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, orgScopeExpr, 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 } = orgScopeExpr(scope, '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 } = orgScopeExpr(scope, '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, orgScopeExpr, 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,10 +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 { expr: srcExpr, params: srcParams } = orgScopeExpr(scope, 'cor.source_org_id');
|
||||||
const { expr: tgtExpr, params: tgtParams } = orgScopeExpr(orgIds, 'cor.target_org_id', srcParams.length);
|
const { expr: tgtExpr, params: tgtParams } = orgScopeExpr(scope, 'cor.target_org_id', srcParams.length);
|
||||||
const where = srcExpr && tgtExpr ? `WHERE (${srcExpr} OR ${tgtExpr})`
|
const where = srcExpr && tgtExpr ? `WHERE (${srcExpr} OR ${tgtExpr})`
|
||||||
: srcExpr ? `WHERE ${srcExpr}`
|
: srcExpr ? `WHERE ${srcExpr}`
|
||||||
: tgtExpr ? `WHERE ${tgtExpr}`
|
: tgtExpr ? `WHERE ${tgtExpr}`
|
||||||
|
|||||||
@@ -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, orgScopeExpr, 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 } = orgScopeExpr(scope, '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, orgScopeExpr, 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 } = orgScopeExpr(scope, '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,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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, orgScopeExpr, hasPermission, permittedScope, canActOnUser, checkApiKeyScope,
|
||||||
getAccountType, viewForType, isAllowed, SYSTEM_ORG_ID,
|
getAccountType, viewForType, isAllowed, SYSTEM_ORG_ID,
|
||||||
} from '../permissions.js';
|
} from '../permissions.js';
|
||||||
import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||||
@@ -30,9 +30,10 @@ 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
|
||||||
@@ -43,8 +44,8 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
|
|||||||
isAllowed(db, user.id, null, 'read', 'users', req),
|
isAllowed(db, user.id, null, 'read', 'users', req),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id');
|
const { expr, params } = orgScopeExpr(scope, 'ur2.org_id');
|
||||||
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
|
const isSystemScope = scope.includes(SYSTEM_ORG_ID);
|
||||||
const placement = canReadUnattached ? ' OR u.needs_placement' : '';
|
const placement = canReadUnattached ? ' OR u.needs_placement' : '';
|
||||||
const whereClause = expr && !isSystemScope
|
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'))`
|
? `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})${placement} OR u.account_type IN ('system', 'service'))`
|
||||||
|
|||||||
@@ -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,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