Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d713b25431 | ||
|
|
0f7db7e99e | ||
|
|
12964793cf |
@@ -19,7 +19,7 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: v24.12.0
|
NODE_VERSION: v24.22.2
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the tag
|
- name: Check out the tag
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ 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
|
## [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.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": [
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export {
|
|||||||
hasAnyPermission,
|
hasAnyPermission,
|
||||||
isAllowed,
|
isAllowed,
|
||||||
permittedOrgs,
|
permittedOrgs,
|
||||||
|
permittedScope,
|
||||||
|
listScope,
|
||||||
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]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,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';
|
||||||
}
|
}
|
||||||
@@ -304,6 +330,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, 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(
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
+8
-10
@@ -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, isAllowed, 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,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,12 +44,9 @@ 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 { 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,
|
||||||
@@ -67,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,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