feat(packages): add pgmorbac package

This commit is contained in:
2026-04-29 07:49:11 +02:00
commit 9c87cff4a2
14 changed files with 2365 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@crudy/pgmorbac",
"version": "0.1.0",
"type": "module",
"description": "Crudy pgmorbac — Multi-OrBAC permission helpers for Fastify and PostgreSQL",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./permissions": { "types": "./dist/permissions.d.ts", "default": "./dist/permissions.js" },
"./proxy": { "types": "./dist/proxy.d.ts", "default": "./dist/proxy.js" },
"./plugin": { "types": "./dist/plugin.d.ts", "default": "./dist/plugin.js" },
"./schemas": { "types": "./dist/schemas.d.ts", "default": "./dist/schemas.js" },
"./api": { "types": "./dist/api.d.ts", "default": "./dist/api.js" }
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "package.json"],
"scripts": {
"build": "tsc",
"prepack": "npm run build"
},
"publishConfig": {
"registry": "http://localhost:4873",
"access": "public"
},
"dependencies": {
"bcryptjs": "^2"
},
"peerDependencies": {
"fastify": ">=5",
"pg": ">=8"
},
"peerDependenciesMeta": {
"fastify": { "optional": true },
"pg": { "optional": true }
},
"devDependencies": {
"@types/bcryptjs": "^2",
"@types/pg": "^8",
"fastify": "^5",
"pg": "^8",
"typescript": "^5"
}
}
+266
View File
@@ -0,0 +1,266 @@
export interface Org {
id: string;
name: string;
parent_id: string | null;
metadata: Record<string, unknown>;
}
export interface Role {
id: string | null;
name: string;
description: string | null;
source: string;
}
export interface RoleDetail {
id: string;
org_id: string;
org_name: string;
name: string;
description: string | null;
}
export interface Activity {
name: string;
description: string | null;
}
export interface View {
name: string;
description: string | null;
}
export interface Rule {
id: string;
role_id: string;
org_id: string;
org_name: string;
role_name: string;
activity: string;
view: string;
modality: 'permission' | 'prohibition';
priority: number | null;
scope: string;
}
export interface CrossOrgRule {
id: string;
source_org_id: string;
source_role_id: string;
target_org_id: string;
source_org_name: string;
source_role_name: string;
target_org_name: string;
activity: string;
view: string;
modality: 'permission' | 'prohibition';
priority: number | null;
}
export interface UserRule {
id: string;
user_id: string;
org_id: string;
user_name: string;
user_email: string | null;
org_name: string;
activity: string;
view: string;
modality: 'permission' | 'prohibition';
priority: number | null;
}
export interface GlobalRule {
id: string;
user_id: string | null;
user_name: string | null;
user_email: string | null;
activity: string | null;
view: string | null;
modality: 'permission' | 'prohibition';
priority: number | null;
is_system_principal: boolean;
}
export interface RoleHierarchy {
senior_role_id: string;
senior_name: string;
junior_role_id: string;
junior_name: string;
org_id: string;
org_name: string;
}
export interface SystemPrincipal {
user_id: string;
user_name: string;
user_email: string | null;
description: string | null;
created_at: string;
global_rules: { id: string; activity: string | null; view: string | null; modality: 'permission' | 'prohibition' }[];
}
export interface UserDetail {
id: string;
email: string;
name: string;
account_type: 'user' | 'system' | 'service';
is_active: boolean;
created_at: string;
roles: { role_id: string; role_name: string; org_id: string; org_name: string }[];
}
export interface RuleUser {
id: string;
user_name: string;
email: string | null;
account_type: 'user' | 'system' | 'service';
org_id: string;
org_name: string;
role_id: string | null;
role_name: string | null;
source: 'role' | 'direct';
}
export interface EffectivePermission {
activity: string | null;
view: string | null;
modality: 'permission' | 'prohibition';
scope: string | null;
org_id: string | null;
org_name: string | null;
role_id: string | null;
role_name: string | null;
source: 'role' | 'direct' | 'global';
}
export type ActivityViewBinding = { activity: string; view: string };
export interface MorbacApi {
checkPermission(token: string, orgId: string, activity: string, view: string): Promise<boolean>;
getOrgs(token: string, orgIds: string[]): Promise<Org[]>;
createOrg(token: string, orgIds: string[], data: { name: string; parent_id?: string }): Promise<Org>;
updateOrg(token: string, orgIds: string[], id: string, data: { name?: string; parent_id?: string | null }): Promise<Org>;
deleteOrg(token: string, orgIds: string[], id: string): Promise<null>;
getRoles(token: string, orgIds: string[]): Promise<RoleDetail[]>;
createRole(token: string, orgIds: string[], data: { org_id: string; name: string; description?: string }): Promise<RoleDetail>;
updateRole(token: string, orgIds: string[], id: string, data: { name?: string; description?: string }): Promise<RoleDetail>;
deleteRole(token: string, orgIds: string[], id: string): Promise<null>;
getRoleHierarchy(token: string, orgIds: string[]): Promise<RoleHierarchy[]>;
addRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise<unknown>;
removeRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise<unknown>;
getActivities(token: string, orgIds: string[]): Promise<Activity[]>;
getViews(token: string, orgIds: string[]): Promise<View[]>;
getActivityViewBindings(token: string, orgIds: string[]): Promise<ActivityViewBinding[]>;
getRules(token: string, orgIds: string[]): Promise<Rule[]>;
createRule(token: string, orgIds: string[], data: { org_id: string; role_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; scope?: string; priority?: number }): Promise<Rule>;
deleteRule(token: string, orgIds: string[], id: string): Promise<null>;
getCrossOrgRules(token: string, orgIds: string[]): Promise<CrossOrgRule[]>;
createCrossOrgRule(token: string, orgIds: string[], data: { source_org_id: string; source_role_id: string; target_org_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise<CrossOrgRule>;
deleteCrossOrgRule(token: string, orgIds: string[], id: string): Promise<null>;
getUserRules(token: string, orgIds: string[]): Promise<UserRule[]>;
createUserRule(token: string, orgIds: string[], data: { user_id: string; org_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise<UserRule>;
deleteUserRule(token: string, orgIds: string[], id: string): Promise<null>;
getGlobalRules(token: string, orgIds: string[]): Promise<GlobalRule[]>;
createGlobalRule(token: string, orgIds: string[], data: { user_id?: string | null; activity?: string | null; view?: string | null; modality: 'permission' | 'prohibition'; priority?: number }): Promise<GlobalRule>;
deleteGlobalRule(token: string, orgIds: string[], id: string): Promise<null>;
getSystemPrincipals(token: string, orgIds: string[]): Promise<SystemPrincipal[]>;
getUsers(token: string, orgIds: string[]): Promise<UserDetail[]>;
createUser(token: string, orgIds: string[], data: { email: string; name: string; password: string }): Promise<UserDetail>;
updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; password?: string }): Promise<UserDetail>;
deleteUser(token: string, orgIds: string[], id: string): Promise<null>;
assignRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise<unknown>;
removeRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise<unknown>;
getActivityUsers(token: string, orgIds: string[], name: string): Promise<RuleUser[]>;
getViewUsers(token: string, orgIds: string[], name: string): Promise<RuleUser[]>;
getEffectivePermissions(token: string, orgIds: string[], userId: string): Promise<EffectivePermission[]>;
}
export function createMorbacApi(apiUrl: string): MorbacApi {
async function api(method: string, path: string, token: string, orgIds: string[], body?: unknown): Promise<unknown> {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
if (orgIds.length === 1) {
headers['X-Org-Id'] = orgIds[0];
} else if (orgIds.length > 1) {
headers['X-Org-Ids'] = orgIds.join(',');
}
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${apiUrl}${path}`, {
method,
headers,
credentials: 'include',
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error((data as { error?: string }).error || 'Request failed');
return data;
}
return {
checkPermission: async (token, orgId, activity, view) => {
const res = await fetch(`${apiUrl}/me/permissions/check`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Org-Id': orgId,
},
credentials: 'include',
body: JSON.stringify({ p_activity: activity, p_view: view }),
});
if (!res.ok) return false;
return res.json() as Promise<boolean>;
},
getOrgs: (t, orgIds) => api('GET', '/orgs', t, orgIds) as Promise<Org[]>,
createOrg: (t, orgIds, d) => api('POST', '/orgs', t, orgIds, d) as Promise<Org>,
updateOrg: (t, orgIds, id, d) => api('PATCH', `/orgs/${id}`, t, orgIds, d) as Promise<Org>,
deleteOrg: (t, orgIds, id) => api('DELETE', `/orgs/${id}`, t, orgIds) as Promise<null>,
getRoles: (t, orgIds) => api('GET', '/roles', t, orgIds) as Promise<RoleDetail[]>,
createRole: (t, orgIds, d) => api('POST', '/roles', t, orgIds, d) as Promise<RoleDetail>,
updateRole: (t, orgIds, id, d) => api('PATCH', `/roles/${id}`, t, orgIds, d) as Promise<RoleDetail>,
deleteRole: (t, orgIds, id) => api('DELETE', `/roles/${id}`, t, orgIds) as Promise<null>,
getRoleHierarchy: (t, orgIds) => api('GET', '/role-hierarchy', t, orgIds) as Promise<RoleHierarchy[]>,
addRoleHierarchy: (t, orgIds, d) => api('POST', '/role-hierarchy', t, orgIds, d),
removeRoleHierarchy: (t, orgIds, d) => api('DELETE', '/role-hierarchy', t, orgIds, d),
getActivities: (t, orgIds) => api('GET', '/activities', t, orgIds) as Promise<Activity[]>,
getViews: (t, orgIds) => api('GET', '/views', t, orgIds) as Promise<View[]>,
getActivityViewBindings: (t, orgIds) => api('GET', '/activity-view-bindings', t, orgIds) as Promise<ActivityViewBinding[]>,
getRules: (t, orgIds) => api('GET', '/rules', t, orgIds) as Promise<Rule[]>,
createRule: (t, orgIds, d) => api('POST', '/rules', t, orgIds, d) as Promise<Rule>,
deleteRule: (t, orgIds, id) => api('DELETE', `/rules/${id}`, t, orgIds) as Promise<null>,
getCrossOrgRules: (t, orgIds) => api('GET', '/cross-org-rules', t, orgIds) as Promise<CrossOrgRule[]>,
createCrossOrgRule: (t, orgIds, d) => api('POST', '/cross-org-rules', t, orgIds, d) as Promise<CrossOrgRule>,
deleteCrossOrgRule: (t, orgIds, id) => api('DELETE', `/cross-org-rules/${id}`, t, orgIds) as Promise<null>,
getUserRules: (t, orgIds) => api('GET', '/user-rules', t, orgIds) as Promise<UserRule[]>,
createUserRule: (t, orgIds, d) => api('POST', '/user-rules', t, orgIds, d) as Promise<UserRule>,
deleteUserRule: (t, orgIds, id) => api('DELETE', `/user-rules/${id}`, t, orgIds) as Promise<null>,
getGlobalRules: (t, orgIds) => api('GET', '/global-rules', t, orgIds) as Promise<GlobalRule[]>,
createGlobalRule: (t, orgIds, d) => api('POST', '/global-rules', t, orgIds, d) as Promise<GlobalRule>,
deleteGlobalRule: (t, orgIds, id) => api('DELETE', `/global-rules/${id}`, t, orgIds) as Promise<null>,
getSystemPrincipals: (t, orgIds) => api('GET', '/system-principals', t, orgIds) as Promise<SystemPrincipal[]>,
getUsers: (t, orgIds) => api('GET', '/users', t, orgIds) as Promise<UserDetail[]>,
createUser: (t, orgIds, d) => api('POST', '/users', t, orgIds, d) as Promise<UserDetail>,
updateUser: (t, orgIds, id, d) => api('PATCH', `/users/${id}`, t, orgIds, d) as Promise<UserDetail>,
deleteUser: (t, orgIds, id) => api('DELETE', `/users/${id}`, t, orgIds) as Promise<null>,
assignRole: (t, orgIds, d) => api('POST', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }),
removeRole: (t, orgIds, d) => api('DELETE', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }),
getActivityUsers: (t, orgIds, name) => api('GET', `/activities/${encodeURIComponent(name)}/users`, t, orgIds) as Promise<RuleUser[]>,
getViewUsers: (t, orgIds, name) => api('GET', `/views/${encodeURIComponent(name)}/users`, t, orgIds) as Promise<RuleUser[]>,
getEffectivePermissions: (t, orgIds, userId) => api('GET', `/users/${userId}/effective-permissions`, t, orgIds) as Promise<EffectivePermission[]>,
};
}
+18
View File
@@ -0,0 +1,18 @@
export type { DataPermission } from './proxy.js';
export type {
Org,
Role,
RoleDetail,
Activity,
View,
Rule,
CrossOrgRule,
UserRule,
GlobalRule,
RoleHierarchy,
SystemPrincipal,
UserDetail,
RuleUser,
EffectivePermission,
ActivityViewBinding,
} from './api.js';
+74
View File
@@ -0,0 +1,74 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { Pool } from 'pg';
export const SYSTEM_ORG_ID = '00000000-0000-0000-0000-000000000000';
export function getUser(req: FastifyRequest): { id: string } | null {
return ((req as unknown) as Record<string, unknown>).user as { id: string } | null ?? null;
}
export function getOrgScope(req: FastifyRequest, reply: FastifyReply, required = true): string[] | null {
const single = req.headers['x-org-id'];
const singleVal = Array.isArray(single) ? single[0] : (single ?? null);
if (singleVal) return [singleVal];
const multi = req.headers['x-org-ids'];
const multiVal = Array.isArray(multi) ? multi[0] : (multi ?? null);
if (multiVal) {
const ids = multiVal.split(',').map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) return ids;
}
if (required) {
reply.code(400).send({ error: 'X-Org-Id header is required' });
return null;
}
return [];
}
export function orgScopeExpr(orgIds: string[], column: string, paramOffset = 0): { expr: string; params: string[] } {
if (orgIds.length === 0) return { expr: '', params: [] };
const parts = orgIds.map((_, i) =>
`SELECT org_id FROM morbac.get_org_scope($${paramOffset + i + 1}::UUID, 'subtree')`,
);
return { expr: `${column} IN (${parts.join(' UNION ')})`, params: orgIds };
}
export function viewForType(type: 'user' | 'service'): string {
return type === 'service' ? 'service_users' : 'users';
}
export async function getAccountType(
db: Pool,
id: string,
): Promise<'user' | 'system' | 'service' | null> {
const { rows } = await db.query<{ account_type: 'user' | 'system' | 'service' }>(
'SELECT account_type FROM app.users WHERE id = $1',
[id],
);
return rows[0]?.account_type ?? null;
}
export async function hasPermission(
db: Pool,
req: FastifyRequest,
reply: FastifyReply,
orgId: string | null,
activity: string,
target: string,
): Promise<boolean> {
const user = getUser(req);
if (!user) {
reply.code(401).send({ error: 'Authentication required' });
return false;
}
const { rows } = await db.query<{ allowed: boolean }>(
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, orgId, activity, target],
);
if (!rows[0]?.allowed) {
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
return true;
}
+32
View File
@@ -0,0 +1,32 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { Pool } from 'pg';
import { createOrgRoutes } from './routes/orgs.js';
import { createRoleRoutes } from './routes/roles.js';
import { createRuleRoutes } from './routes/rules.js';
import { createUserRoutes } from './routes/users.js';
import { createGlobalRoutes } from './routes/globals.js';
import { createActivityRoutes } from './routes/activities.js';
export type CheckLimitFn = (
reply: FastifyReply,
limitName: string,
countSql: string,
params?: unknown[],
) => Promise<boolean>;
export interface MgmtPluginOptions {
db: Pool;
checkLimit?: CheckLimitFn;
}
export function createMgmtRoutes(opts: MgmtPluginOptions) {
const { db, checkLimit } = opts;
return async function mgmtRoutes(app: FastifyInstance) {
await app.register(createOrgRoutes(db, checkLimit));
await app.register(createRoleRoutes(db, checkLimit));
await app.register(createRuleRoutes(db));
await app.register(createUserRoutes(db, checkLimit));
await app.register(createGlobalRoutes(db));
await app.register(createActivityRoutes(db));
};
}
+127
View File
@@ -0,0 +1,127 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { Pool } from 'pg';
export interface DataPermission {
activity: string;
view: string;
}
export type ProxyDataRouteFn = (
app: import('fastify').FastifyInstance,
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
publicPath: string,
upstreamResource: string,
permission: DataPermission | null,
) => void;
type RegistryEntry = { upstreamResource: string; publicPath: string; permission: DataPermission | null };
export interface ProxyRegistry {
proxy: (
app: import('fastify').FastifyInstance,
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
publicPath: string,
upstreamResource: string,
permission: DataPermission | null,
) => void;
allDataPermissions: () => DataPermission[];
getPermissionByUpstream: (resource: string, method: string) => DataPermission | null | undefined;
getPublicPath: (resource: string, method: string) => string | undefined;
}
export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegistry {
const registry = new Map<string, RegistryEntry>();
function proxy(
app: import('fastify').FastifyInstance,
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
publicPath: string,
upstreamResource: string,
permission: DataPermission | null,
): void {
registry.set(`${method}:${upstreamResource}`, { upstreamResource, publicPath, permission });
app.route({
method,
url: publicPath,
schema: { hide: true } as Record<string, unknown>,
handler: makeHandler(upstreamResource, permission),
});
}
function makeHandler(upstreamResource: string, permission: DataPermission | null) {
return async function handler(req: FastifyRequest, reply: FastifyReply) {
const user = ((req as unknown) as Record<string, unknown>).user as { id: string } | null;
if (!user) return reply.code(401).send({ error: 'Authentication required' });
if (permission !== null) {
const orgId = req.headers['x-org-id'];
const orgIdVal = Array.isArray(orgId) ? orgId[0] : orgId;
if (!orgIdVal) return reply.code(400).send({ error: 'X-Org-Id header is required' });
const { rows } = await db.query<{ allowed: boolean }>(
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, orgIdVal, permission.activity, permission.view],
);
if (!rows[0]?.allowed) return reply.code(403).send({ error: 'Forbidden' });
}
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
const url = `${postgrestUrl}/${upstreamResource}${qs}`;
const headers: Record<string, string> = {};
const pick = (src: string, dst: string = src) => {
const v = req.headers[src.toLowerCase()];
if (v) headers[dst] = Array.isArray(v) ? v[0] : v;
};
pick('authorization', 'Authorization');
pick('x-org-id', 'X-Org-Id');
pick('x-org-ids', 'X-Org-Ids');
pick('content-type', 'Content-Type');
pick('prefer', 'Prefer');
pick('accept', 'Accept');
const hasBody = req.method !== 'GET' && req.method !== 'HEAD' && req.body != null;
const body = hasBody ? JSON.stringify(req.body) : undefined;
let upstream: Response;
try {
upstream = await fetch(url, { method: req.method, headers, body });
} catch {
return reply.code(503).send({ error: 'Data service unavailable' });
}
reply.code(upstream.status);
const fwd = (name: string) => {
const v = upstream.headers.get(name);
if (v !== null) reply.header(name, v);
};
fwd('content-type');
fwd('content-range');
fwd('x-total-count');
return reply.send(await upstream.text());
};
}
function allDataPermissions(): DataPermission[] {
const seen = new Set<string>();
const result: DataPermission[] = [];
for (const { permission } of registry.values()) {
if (permission != null) {
const key = `${permission.activity}:${permission.view}`;
if (!seen.has(key)) { seen.add(key); result.push(permission); }
}
}
return result;
}
function getPermissionByUpstream(resource: string, method: string): DataPermission | null | undefined {
return registry.get(`${method.toUpperCase()}:${resource}`)?.permission;
}
function getPublicPath(resource: string, method: string): string | undefined {
return registry.get(`${method.toUpperCase()}:${resource}`)?.publicPath;
}
return { proxy, allDataPermissions, getPermissionByUpstream, getPublicPath };
}
+229
View File
@@ -0,0 +1,229 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, hasPermission } from '../permissions.js';
import { unauthorized, badRequest, forbidden } from '../schemas.js';
export function createActivityRoutes(db: Pool) {
return async function activityRoutes(app: FastifyInstance) {
app.get('/activities', {
schema: {
tags: ['Management'],
summary: 'List activities',
description: 'Read-only list of permission verbs (read, create, update, delete). Managed at the database level.',
security: [{ oauth2: [] }],
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string', nullable: true },
},
},
},
401: unauthorized,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { rows } = await db.query('SELECT name, description FROM morbac.activities ORDER BY name');
return rows;
});
app.get('/activities/:name/users', {
schema: {
tags: ['Management'],
summary: 'List users with a given activity',
description: 'Returns users who have permissions via roles or direct user rules for the given activity.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { name: { type: 'string' } } },
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
user_name: { type: 'string' },
email: { type: 'string', nullable: true },
account_type: { type: 'string' },
org_id: { type: 'string', format: 'uuid' },
org_name: { type: 'string' },
role_id: { type: 'string', format: 'uuid', nullable: true },
role_name: { type: 'string', nullable: true },
source: { type: 'string' },
},
},
},
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const caller = getUser(req);
if (!caller) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
const { name } = req.params as { name: string };
const { rows } = await db.query(
`SELECT DISTINCT
u.id, u.name AS user_name, u.email, u.account_type,
o.id AS org_id, o.name AS org_name,
ur.role_id, ro.name AS role_name,
'role' AS source
FROM morbac.user_roles ur
JOIN morbac.mv_role_closure rc ON rc.senior_role_id = ur.role_id
JOIN morbac.rules r ON r.role_id = rc.junior_role_id AND r.activity = $1
JOIN app.users u ON u.id = ur.user_id
JOIN morbac.orgs o ON o.id = ur.org_id
JOIN morbac.roles ro ON ro.id = ur.role_id
WHERE morbac.org_in_scope(ur.org_id, r.org_id, r.scope)
UNION
SELECT DISTINCT
u.id, u.name AS user_name, u.email, u.account_type,
o.id AS org_id, o.name AS org_name,
NULL::UUID AS role_id, NULL::TEXT AS role_name,
'direct' AS source
FROM morbac.user_rules urr
JOIN app.users u ON u.id = urr.user_id
JOIN morbac.orgs o ON o.id = urr.org_id
WHERE urr.activity = $1
ORDER BY user_name, org_name`,
[name],
);
return rows;
});
app.get('/views', {
schema: {
tags: ['Management'],
summary: 'List views',
description: 'Read-only list of permission subjects (the resources activities apply to). Managed at the database level.',
security: [{ oauth2: [] }],
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string', nullable: true },
},
},
},
401: unauthorized,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { rows } = await db.query('SELECT name, description FROM morbac.views ORDER BY name');
return rows;
});
app.get('/views/:name/users', {
schema: {
tags: ['Management'],
summary: 'List users with a given view',
description: 'Returns users who have permissions via roles or direct user rules for the given view.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { name: { type: 'string' } } },
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
user_name: { type: 'string' },
email: { type: 'string', nullable: true },
account_type: { type: 'string' },
org_id: { type: 'string', format: 'uuid' },
org_name: { type: 'string' },
role_id: { type: 'string', format: 'uuid', nullable: true },
role_name: { type: 'string', nullable: true },
source: { type: 'string' },
},
},
},
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const caller = getUser(req);
if (!caller) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
const { name } = req.params as { name: string };
const { rows } = await db.query(
`SELECT DISTINCT
u.id, u.name AS user_name, u.email, u.account_type,
o.id AS org_id, o.name AS org_name,
ur.role_id, ro.name AS role_name,
'role' AS source
FROM morbac.user_roles ur
JOIN morbac.mv_role_closure rc ON rc.senior_role_id = ur.role_id
JOIN morbac.rules r ON r.role_id = rc.junior_role_id AND r.view = $1
JOIN app.users u ON u.id = ur.user_id
JOIN morbac.orgs o ON o.id = ur.org_id
JOIN morbac.roles ro ON ro.id = ur.role_id
WHERE morbac.org_in_scope(ur.org_id, r.org_id, r.scope)
UNION
SELECT DISTINCT
u.id, u.name AS user_name, u.email, u.account_type,
o.id AS org_id, o.name AS org_name,
NULL::UUID AS role_id, NULL::TEXT AS role_name,
'direct' AS source
FROM morbac.user_rules urr
JOIN app.users u ON u.id = urr.user_id
JOIN morbac.orgs o ON o.id = urr.org_id
WHERE urr.view = $1
ORDER BY user_name, org_name`,
[name],
);
return rows;
});
app.get('/activity-view-bindings', {
schema: {
tags: ['Management'],
summary: 'List activity-view bindings',
description: 'Valid (activity, view) pairs that can be used when creating rules. Activities with no bindings accept any view.',
security: [{ oauth2: [] }],
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
activity: { type: 'string' },
view: { type: 'string' },
},
},
},
401: unauthorized,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { rows } = await db.query(
'SELECT activity, view FROM morbac.activity_view_bindings ORDER BY activity, view',
);
return rows;
});
};
}
+197
View File
@@ -0,0 +1,197 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
import { globalRuleResponse, notFound, forbidden, unauthorized } from '../schemas.js';
export function createGlobalRoutes(db: Pool) {
return async function globalRoutes(app: FastifyInstance) {
app.get('/global-rules', {
schema: {
tags: ['Management'],
summary: 'List global rules',
description: 'System-wide permission rules not bound to any organisation or role. Requires `read`/`rules` in the system org.',
security: [{ oauth2: [] }],
response: {
200: { type: 'array', items: globalRuleResponse },
401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
if (!await hasPermission(db, req, reply, SYSTEM_ORG_ID, 'read', 'rules')) return reply;
const { rows } = await db.query(
`SELECT gr.id,
gr.user_id,
COALESCE(u.name, gr.user_id::TEXT) AS user_name,
u.email AS user_email,
gr.activity, gr.view, gr.modality, gr.priority,
(sp.user_id IS NOT NULL) AS is_system_principal
FROM morbac.global_rules gr
LEFT JOIN app.users u ON u.id = gr.user_id
LEFT JOIN morbac.system_principals sp ON sp.user_id = gr.user_id
ORDER BY u.name NULLS LAST, gr.activity, gr.view`,
);
return rows;
});
app.post('/global-rules', {
schema: {
tags: ['Management'],
summary: 'Create a global rule',
description: 'Grants or prohibits access system-wide for a user (or all users when `user_id` is null). `activity`/`view` null = any activity/view. Requires `create`/`rules` in the system org.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['modality'],
properties: {
user_id: { type: 'string', format: 'uuid', nullable: true },
activity: { type: 'string', nullable: true },
view: { type: 'string', nullable: true },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
priority: { type: 'integer' },
},
},
response: {
201: globalRuleResponse,
401: unauthorized, 403: forbidden,
409: { description: 'Rule already exists.' },
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { user_id, activity, view, modality, priority } = req.body as {
user_id?: string | null; activity?: string | null; view?: string | null;
modality: 'permission' | 'prohibition'; priority?: number;
};
if (user_id) {
const { rows: [sp] } = await db.query(
'SELECT 1 FROM morbac.system_principals WHERE user_id = $1', [user_id],
);
if (sp) return reply.code(403).send({ error: 'System principal rules are immutable' });
}
if (!await hasPermission(db, req, reply, SYSTEM_ORG_ID, 'create', 'rules')) return reply;
try {
const { rows } = await db.query(
`INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES ($1, $2, $3, (SELECT id FROM morbac.contexts WHERE name = 'always'), $4::morbac.modality, $5)
RETURNING id, user_id,
(SELECT name FROM app.users WHERE id = $1) AS user_name,
(SELECT email FROM app.users WHERE id = $1) AS user_email,
activity, view, modality, priority,
FALSE AS is_system_principal`,
[user_id ?? null, activity ?? null, view ?? null, modality, priority ?? null],
);
return reply.code(201).send(rows[0]);
} catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
throw e;
}
});
app.delete('/global-rules/:id', {
schema: {
tags: ['Management'],
summary: 'Delete a global rule',
description: 'Removes a global rule. Requires `delete`/`rules` in the system org. System principal rules cannot be deleted.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'Rule deleted.' },
401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const { rows: [gr] } = await db.query<{ user_id: string | null }>(
'SELECT user_id FROM morbac.global_rules WHERE id = $1', [id],
);
if (!gr) return reply.code(404).send({ error: 'Not found' });
if (gr.user_id) {
const { rows: [sp] } = await db.query(
'SELECT 1 FROM morbac.system_principals WHERE user_id = $1', [gr.user_id],
);
if (sp) return reply.code(403).send({ error: 'System principal rules are immutable' });
}
if (!await hasPermission(db, req, reply, SYSTEM_ORG_ID, 'delete', 'rules')) return reply;
await db.query('DELETE FROM morbac.global_rules WHERE id = $1', [id]);
return reply.code(204).send();
});
app.get('/system-principals', {
schema: {
tags: ['Management'],
summary: 'List system principals',
description: 'Read-only registry of system-level accounts with immutable global rules. Requires `read`/`rules` in the system org.',
security: [{ oauth2: [] }],
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: { type: 'string', format: 'uuid' },
user_name: { type: 'string' },
user_email: { type: 'string', nullable: true },
description: { type: 'string', nullable: true },
global_rules: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
activity: { type: 'string', nullable: true },
view: { type: 'string', nullable: true },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
},
},
},
},
},
},
401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
if (!await hasPermission(db, req, reply, SYSTEM_ORG_ID, 'read', 'rules')) return reply;
const { rows } = await db.query(
`SELECT sp.user_id,
COALESCE(u.name, sp.user_id::TEXT) AS user_name,
u.email AS user_email,
sp.description,
COALESCE(
json_agg(json_build_object(
'id', gr.id,
'activity', gr.activity,
'view', gr.view,
'modality', gr.modality
)) FILTER (WHERE gr.id IS NOT NULL),
'[]'::json
) AS global_rules
FROM morbac.system_principals sp
LEFT JOIN app.users u ON u.id = sp.user_id
LEFT JOIN morbac.global_rules gr ON gr.user_id = sp.user_id
GROUP BY sp.user_id, u.name, u.email, sp.description
ORDER BY u.name`,
);
return rows;
});
};
}
+176
View File
@@ -0,0 +1,176 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import type { CheckLimitFn } from '../plugin.js';
import {
getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID,
} from '../permissions.js';
import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn) {
return async function orgRoutes(app: FastifyInstance) {
app.get('/orgs', {
schema: {
tags: ['Management'],
summary: 'List organisations',
description: 'Returns all accessible organisations, or only those within the scoped org subtree when X-Org-Id is provided.',
security: [{ oauth2: [] }],
response: {
200: { type: 'array', items: orgResponse },
401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (orgIds.length === 0) {
const { rows: adminCheck } = await db.query<{ allowed: boolean }>(
'SELECT morbac.is_allowed($1::UUID, NULL::UUID, $2, $3) AS allowed',
[user.id, 'read', 'orgs'],
);
if (adminCheck[0]?.allowed) {
const { rows } = await db.query(
'SELECT id, name, parent_id, metadata FROM morbac.orgs ORDER BY name',
);
return rows;
}
const { rows } = await db.query(
`SELECT DISTINCT o.id, o.name, o.parent_id, o.metadata
FROM morbac.orgs o
INNER JOIN morbac.user_roles ur ON ur.org_id = o.id AND ur.user_id = $1
ORDER BY o.name`,
[user.id],
);
return rows;
}
if (!await hasPermission(db, req, reply, orgIds[0], 'read', 'orgs')) return reply;
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(orgIds, 'id');
const { rows } = await db.query(
`SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''} ORDER BY name`,
params,
);
return rows;
});
app.post('/orgs', {
schema: {
tags: ['Management'],
summary: 'Create an organisation',
description: [
'Creates a sub-organisation under `parent_id`. Requires `create`/`orgs` permission in the parent org.',
'Creating a root organisation (no `parent_id`) requires `create`/`root_orgs` permission in the scoped org.',
].join('\n'),
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string', minLength: 1, maxLength: 255 },
parent_id: { type: 'string', format: 'uuid', nullable: true },
},
},
response: {
201: orgResponse,
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { name, parent_id } = req.body as { name: string; parent_id?: string | null };
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const orgId = orgIds[0] ?? null;
const permOrgId = parent_id ?? orgId;
const permTarget = parent_id ? 'orgs' : 'root_orgs';
if (!await hasPermission(db, req, reply, permOrgId ?? null, 'create', permTarget)) return reply;
if (checkLimit) {
const ok = await checkLimit(
reply, 'max_orgs',
'SELECT count(*)::TEXT AS c FROM morbac.orgs WHERE id <> $1::UUID',
[SYSTEM_ORG_ID],
);
if (!ok) return reply;
}
const { rows } = await db.query(
'INSERT INTO morbac.orgs (name, parent_id) VALUES ($1, $2) RETURNING id, name, parent_id, metadata',
[name, parent_id ?? null],
);
return reply.code(201).send(rows[0]);
});
app.patch('/orgs/:id', {
schema: {
tags: ['Management'],
summary: 'Update an organisation',
description: 'Updates an organisation name or parent. Requires `update`/`orgs` permission in the target org.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 255 },
parent_id: { type: 'string', format: 'uuid', nullable: true },
},
},
response: {
200: orgResponse,
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
if (id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, id, 'update', 'orgs')) return reply;
const body = req.body as { name?: string; parent_id?: string | null };
const { rows } = await db.query(
`UPDATE morbac.orgs
SET name = CASE WHEN $1::text IS NOT NULL THEN $1 ELSE name END,
parent_id = CASE WHEN $2 THEN NULL WHEN $3::text IS NOT NULL THEN $3::uuid ELSE parent_id END
WHERE id = $4
RETURNING id, name, parent_id, metadata`,
[body.name ?? null, body.parent_id === null, body.parent_id ?? null, id],
);
if (!rows[0]) return reply.code(404).send({ error: 'Not found' });
return rows[0];
});
app.delete('/orgs/:id', {
schema: {
tags: ['Management'],
summary: 'Delete an organisation',
description: 'Deletes an organisation and all its children (cascade). Requires `delete`/`orgs` permission.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'Organisation deleted.' },
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
if (id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, id, 'delete', 'orgs')) return reply;
await db.query('DELETE FROM morbac.orgs WHERE id = $1', [id]);
return reply.code(204).send();
});
};
}
+293
View File
@@ -0,0 +1,293 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import type { CheckLimitFn } from '../plugin.js';
import {
getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID,
} from '../permissions.js';
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn) {
return async function roleRoutes(app: FastifyInstance) {
app.get('/roles', {
schema: {
tags: ['Management'],
summary: 'List roles',
description: 'Returns roles within the scoped org subtree.',
security: [{ oauth2: [] }],
response: {
200: { type: 'array', items: roleResponse },
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
const { expr, params } = orgScopeExpr(orgIds, 'r.org_id');
const { rows } = await db.query(
`SELECT r.id, r.org_id, r.name, r.description, o.name AS org_name
FROM morbac.roles r
JOIN morbac.orgs o ON o.id = r.org_id
${expr ? `WHERE ${expr}` : ''}
ORDER BY o.name, r.name`,
params,
);
return rows;
});
app.post('/roles', {
schema: {
tags: ['Management'],
summary: 'Create a role',
description: 'Creates a role scoped to the given org. Requires `create`/`roles` permission.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['org_id', 'name'],
properties: {
org_id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
description: { type: 'string' },
},
},
response: {
201: roleResponse,
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { org_id, name, description } = req.body as {
org_id: string; name: string; description?: string;
};
if (!await hasPermission(db, req, reply, org_id, 'create', 'roles')) return reply;
if (checkLimit) {
const ok = await checkLimit(reply, 'max_roles', 'SELECT count(*)::TEXT AS c FROM morbac.roles');
if (!ok) return reply;
}
const { rows } = await db.query(
`INSERT INTO morbac.roles (org_id, name, description) VALUES ($1, $2, $3)
RETURNING id, org_id, name, description`,
[org_id, name, description ?? null],
);
const org = await db.query('SELECT name FROM morbac.orgs WHERE id = $1', [org_id]);
return reply.code(201).send({ ...rows[0], org_name: org.rows[0]?.name });
});
app.patch('/roles/:id', {
schema: {
tags: ['Management'],
summary: 'Update a role',
description: "Updates a role name or description. Requires `update`/`roles` permission in the role's org.",
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
description: { type: 'string', nullable: true },
},
},
response: {
200: roleResponse,
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const roleOrg = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [id],
);
if (!roleOrg.rows[0]) return reply.code(404).send({ error: 'Not found' });
if (roleOrg.rows[0].org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, roleOrg.rows[0].org_id, 'update', 'roles')) return reply;
const { name, description } = req.body as { name?: string; description?: string };
const { rows } = await db.query(
`UPDATE morbac.roles
SET name = COALESCE($1, name),
description = COALESCE($2, description)
WHERE id = $3
RETURNING id, org_id, name, description,
(SELECT name FROM morbac.orgs WHERE id = org_id) AS org_name`,
[name ?? null, description ?? null, id],
);
if (!rows[0]) return reply.code(404).send({ error: 'Not found' });
return rows[0];
});
app.delete('/roles/:id', {
schema: {
tags: ['Management'],
summary: 'Delete a role',
description: "Deletes a role. Requires `delete`/`roles` permission in the role's org.",
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'Role deleted.' },
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const roleOrg = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [id],
);
if (!roleOrg.rows[0]) return reply.code(404).send({ error: 'Not found' });
if (roleOrg.rows[0].org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, roleOrg.rows[0].org_id, 'delete', 'roles')) return reply;
await db.query('DELETE FROM morbac.roles WHERE id = $1', [id]);
return reply.code(204).send();
});
app.get('/role-hierarchy', {
schema: {
tags: ['Management'],
summary: 'List role hierarchy edges',
description: 'Returns senior->junior role relationships within the scoped org subtree.',
security: [{ oauth2: [] }],
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
senior_role_id: { type: 'string', format: 'uuid' },
senior_name: { type: 'string' },
junior_role_id: { type: 'string', format: 'uuid' },
junior_name: { type: 'string' },
org_id: { type: 'string', format: 'uuid' },
org_name: { type: 'string' },
},
},
},
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
const { expr, params } = orgScopeExpr(orgIds, 'rs.org_id');
const { rows } = await db.query(
`SELECT rh.senior_role_id, rs.name AS senior_name,
rh.junior_role_id, rj.name AS junior_name,
rs.org_id, o.name AS org_name
FROM morbac.role_hierarchy rh
JOIN morbac.roles rs ON rs.id = rh.senior_role_id
JOIN morbac.roles rj ON rj.id = rh.junior_role_id
JOIN morbac.orgs o ON o.id = rs.org_id
${expr ? `WHERE ${expr}` : ''}`,
params,
);
return rows;
});
app.post('/role-hierarchy', {
schema: {
tags: ['Management'],
summary: 'Add a hierarchy edge',
description: '`senior_role` inherits all permissions of `junior_role`. Requires `update`/`roles` in the senior role\'s org.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['senior_role_id', 'junior_role_id'],
properties: {
senior_role_id: { type: 'string', format: 'uuid' },
junior_role_id: { type: 'string', format: 'uuid' },
},
},
response: {
201: {
type: 'object',
properties: {
senior_role_id: { type: 'string', format: 'uuid' },
junior_role_id: { type: 'string', format: 'uuid' },
},
},
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { senior_role_id, junior_role_id } = req.body as {
senior_role_id: string; junior_role_id: string;
};
const roleOrg = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [senior_role_id],
);
if (!roleOrg.rows[0]) return reply.code(404).send({ error: 'Senior role not found' });
if (roleOrg.rows[0].org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, roleOrg.rows[0].org_id, 'update', 'roles')) return reply;
await db.query(
'INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[senior_role_id, junior_role_id],
);
return reply.code(201).send({ senior_role_id, junior_role_id });
});
app.delete('/role-hierarchy', {
schema: {
tags: ['Management'],
summary: 'Remove a hierarchy edge',
description: "Removes the senior->junior inheritance link. Requires `update`/`roles` in the senior role's org.",
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['senior_role_id', 'junior_role_id'],
properties: {
senior_role_id: { type: 'string', format: 'uuid' },
junior_role_id: { type: 'string', format: 'uuid' },
},
},
response: {
204: { type: 'null', description: 'Edge removed.' },
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { senior_role_id, junior_role_id } = req.body as {
senior_role_id: string; junior_role_id: string;
};
const roleOrg = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [senior_role_id],
);
if (!roleOrg.rows[0]) return reply.code(404).send({ error: 'Senior role not found' });
if (roleOrg.rows[0].org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, roleOrg.rows[0].org_id, 'update', 'roles')) return reply;
await db.query(
'DELETE FROM morbac.role_hierarchy WHERE senior_role_id = $1 AND junior_role_id = $2',
[senior_role_id, junior_role_id],
);
return reply.code(204).send();
});
};
}
+388
View File
@@ -0,0 +1,388 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
import { ruleResponse, crossOrgRuleResponse, userRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createRuleRoutes(db: Pool) {
return async function ruleRoutes(app: FastifyInstance) {
app.get('/rules', {
schema: {
tags: ['Management'],
summary: 'List role rules',
description: 'Returns role-based permission rules within the scoped org subtree.',
security: [{ oauth2: [] }],
response: {
200: { type: 'array', items: ruleResponse },
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
const { expr, params } = orgScopeExpr(orgIds, 'r.org_id');
const { rows } = await db.query(
`SELECT r.id, r.role_id, r.org_id, o.name AS org_name, ro.name AS role_name,
r.activity, r.view, r.modality, r.priority, r.scope
FROM morbac.rules r
JOIN morbac.orgs o ON o.id = r.org_id
JOIN morbac.roles ro ON ro.id = r.role_id
${expr ? `WHERE ${expr}` : ''}
ORDER BY o.name, ro.name, r.activity, r.view`,
params,
);
return rows;
});
app.post('/rules', {
schema: {
tags: ['Management'],
summary: 'Create a role rule',
description: 'Adds a role-based permission rule. Scope is stored natively and evaluated at query time.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['org_id', 'role_id', 'activity', 'view', 'modality'],
properties: {
org_id: { type: 'string', format: 'uuid' },
role_id: { type: 'string', format: 'uuid' },
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
priority: { type: 'integer' },
scope: {
type: 'string',
enum: ['self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root'],
default: 'self',
},
},
},
response: {
201: ruleResponse,
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
409: { description: 'Rule already exists.' },
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { org_id, role_id, activity, view, modality, priority, scope = 'self' } = req.body as {
org_id: string; role_id: string; activity: string;
view: string; modality: 'permission' | 'prohibition'; priority?: number; scope?: string;
};
if (!await hasPermission(db, req, reply, org_id, 'create', 'rules')) return reply;
try {
const { rows } = await db.query(
`INSERT INTO morbac.rules
(org_id, role_id, activity, view, context_id, modality, scope, priority, is_active)
VALUES (
$1, $2, $3, $4,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
$5::morbac.modality, $6, $7, TRUE
)
RETURNING id, $2::UUID AS role_id, $1::UUID AS org_id,
(SELECT name FROM morbac.orgs WHERE id = $1) AS org_name,
(SELECT name FROM morbac.roles WHERE id = $2) AS role_name,
activity, view, modality, priority, scope`,
[org_id, role_id, activity, view, modality, scope, priority ?? null],
);
return reply.code(201).send(rows[0]);
} catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
throw e;
}
});
app.delete('/rules/:id', {
schema: {
tags: ['Management'],
summary: 'Delete a role rule',
description: "Removes a role-based permission rule. Requires `delete`/`rules` in the rule's org.",
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'Rule deleted.' },
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const { rows: [rule] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.rules WHERE id = $1', [id],
);
if (!rule) return reply.code(404).send({ error: 'Not found' });
if (rule.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, rule.org_id, 'delete', 'rules')) return reply;
await db.query('DELETE FROM morbac.rules WHERE id = $1', [id]);
return reply.code(204).send();
});
app.get('/cross-org-rules', {
schema: {
tags: ['Management'],
summary: 'List cross-org rules',
description: "Rules that grant a role from a source org access to a target org's resources.",
security: [{ oauth2: [] }],
response: {
200: { type: 'array', items: crossOrgRuleResponse },
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'cross_org_rules')) return reply;
const { expr: srcExpr, params: srcParams } = orgScopeExpr(orgIds, 'cor.source_org_id');
const { expr: tgtExpr, params: tgtParams } = orgScopeExpr(orgIds, 'cor.target_org_id', srcParams.length);
const where = srcExpr && tgtExpr ? `WHERE (${srcExpr} OR ${tgtExpr})`
: srcExpr ? `WHERE ${srcExpr}`
: tgtExpr ? `WHERE ${tgtExpr}`
: '';
const { rows } = await db.query(
`SELECT cor.id,
cor.source_org_id, cor.role_id AS source_role_id, cor.target_org_id,
so.name AS source_org_name,
sr.name AS source_role_name,
"to".name AS target_org_name,
cor.activity, cor.view, cor.modality, cor.priority
FROM morbac.cross_org_rules cor
JOIN morbac.orgs so ON so.id = cor.source_org_id
JOIN morbac.roles sr ON sr.id = cor.role_id
JOIN morbac.orgs "to" ON "to".id = cor.target_org_id
${where}
ORDER BY so.name, sr.name, "to".name, cor.activity, cor.view`,
[...srcParams, ...tgtParams],
);
return rows;
});
app.post('/cross-org-rules', {
schema: {
tags: ['Management'],
summary: 'Create a cross-org rule',
description: 'Grants a role from source_org access to target_org resources. Requires `create`/`cross_org_rules` in the source org.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['source_org_id', 'source_role_id', 'target_org_id', 'activity', 'view', 'modality'],
properties: {
source_org_id: { type: 'string', format: 'uuid' },
source_role_id: { type: 'string', format: 'uuid' },
target_org_id: { type: 'string', format: 'uuid' },
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
priority: { type: 'integer' },
},
},
response: {
201: crossOrgRuleResponse,
400: badRequest, 401: unauthorized, 403: forbidden,
409: { description: 'Rule already exists.' },
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { source_org_id, source_role_id, target_org_id, activity, view, modality, priority } = req.body as {
source_org_id: string; source_role_id: string; target_org_id: string;
activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number;
};
if (source_org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, source_org_id, 'create', 'cross_org_rules')) return reply;
try {
const { rows } = await db.query(
`INSERT INTO morbac.cross_org_rules
(source_org_id, target_org_id, role_id, activity, view, context_id, modality, priority)
VALUES (
$1, $2, $3, $4, $5,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
$6::morbac.modality, $7
)
RETURNING id,
$1::UUID AS source_org_id, $3::UUID AS source_role_id, $2::UUID AS target_org_id,
(SELECT name FROM morbac.orgs WHERE id = $1) AS source_org_name,
(SELECT name FROM morbac.roles WHERE id = $3) AS source_role_name,
(SELECT name FROM morbac.orgs WHERE id = $2) AS target_org_name,
activity, view, modality, priority`,
[source_org_id, target_org_id, source_role_id, activity, view, modality, priority ?? null],
);
return reply.code(201).send(rows[0]);
} catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
throw e;
}
});
app.delete('/cross-org-rules/:id', {
schema: {
tags: ['Management'],
summary: 'Delete a cross-org rule',
description: 'Removes a cross-org rule. Requires `delete`/`cross_org_rules` in the source org.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'Rule deleted.' },
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const { rows: [cor] } = await db.query<{ source_org_id: string }>(
'SELECT source_org_id FROM morbac.cross_org_rules WHERE id = $1', [id],
);
if (!cor) return reply.code(404).send({ error: 'Not found' });
if (cor.source_org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, cor.source_org_id, 'delete', 'cross_org_rules')) return reply;
await db.query('DELETE FROM morbac.cross_org_rules WHERE id = $1', [id]);
return reply.code(204).send();
});
app.get('/user-rules', {
schema: {
tags: ['Management'],
summary: 'List user rules',
description: 'Direct per-user authorization rules that bypass the role system.',
security: [{ oauth2: [] }],
response: {
200: { type: 'array', items: userRuleResponse },
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'user_rules')) return reply;
const { expr, params } = orgScopeExpr(orgIds, 'ur.org_id');
const { rows } = await db.query(
`SELECT ur.id, ur.user_id, ur.org_id,
COALESCE(u.name, ur.user_id::TEXT) AS user_name,
u.email AS user_email,
o.name AS org_name,
ur.activity, ur.view, ur.modality, ur.priority
FROM morbac.user_rules ur
JOIN morbac.orgs o ON o.id = ur.org_id
LEFT JOIN app.users u ON u.id = ur.user_id
${expr ? `WHERE ${expr}` : ''}
ORDER BY o.name, ur.activity, ur.view`,
params,
);
return rows;
});
app.post('/user-rules', {
schema: {
tags: ['Management'],
summary: 'Create a user rule',
description: 'Grants or prohibits access for a specific user, bypassing the role system. Requires `create`/`user_rules`.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['user_id', 'org_id', 'activity', 'view', 'modality'],
properties: {
user_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
priority: { type: 'integer' },
},
},
response: {
201: userRuleResponse,
400: badRequest, 401: unauthorized, 403: forbidden,
409: { description: 'Rule already exists.' },
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { user_id, org_id, activity, view, modality, priority } = req.body as {
user_id: string; org_id: string; activity: string;
view: string; modality: 'permission' | 'prohibition'; priority?: number;
};
if (!await hasPermission(db, req, reply, org_id, 'create', 'user_rules')) return reply;
try {
const { rows } = await db.query(
`INSERT INTO morbac.user_rules
(user_id, org_id, activity, view, context_id, modality, priority)
VALUES (
$1, $2, $3, $4,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
$5::morbac.modality, $6
)
RETURNING id, user_id, $2::UUID AS org_id,
COALESCE((SELECT name FROM app.users WHERE id = $1), $1::TEXT) AS user_name,
(SELECT email FROM app.users WHERE id = $1) AS user_email,
(SELECT name FROM morbac.orgs WHERE id = $2) AS org_name,
activity, view, modality, priority`,
[user_id, org_id, activity, view, modality, priority ?? null],
);
return reply.code(201).send(rows[0]);
} catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
throw e;
}
});
app.delete('/user-rules/:id', {
schema: {
tags: ['Management'],
summary: 'Delete a user rule',
description: "Removes a per-user rule. Requires `delete`/`user_rules` in the rule's org.",
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'Rule deleted.' },
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const { rows: [ur] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.user_rules WHERE id = $1', [id],
);
if (!ur) return reply.code(404).send({ error: 'Not found' });
if (!await hasPermission(db, req, reply, ur.org_id, 'delete', 'user_rules')) return reply;
await db.query('DELETE FROM morbac.user_rules WHERE id = $1', [id]);
return reply.code(204).send();
});
};
}
+395
View File
@@ -0,0 +1,395 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import bcrypt from 'bcryptjs';
import type { CheckLimitFn } from '../plugin.js';
import {
getUser, getOrgScope, orgScopeExpr, hasPermission, getAccountType, viewForType, SYSTEM_ORG_ID,
} from '../permissions.js';
import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) {
return async function userRoutes(app: FastifyInstance) {
app.get('/users', {
schema: {
tags: ['Management'],
summary: 'List users',
description: 'Returns users who have at least one role within the scoped org subtree. System and service accounts are filtered based on `read`/`system_users` and `read`/`service_users` permissions.',
security: [{ oauth2: [] }],
response: {
200: { type: 'array', items: userResponse },
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
const checkOrg = orgIds[0] ?? null;
const [sysRes, svcRes] = await Promise.all([
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, checkOrg, 'read', 'system_users']),
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, checkOrg, 'read', 'service_users']),
]);
const canSeeSystem = sysRes.rows[0]?.allowed ?? false;
const canSeeService = svcRes.rows[0]?.allowed ?? false;
const { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id');
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
const whereClause = expr && !isSystemScope
? `WHERE u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})`
: '';
const { rows } = await db.query(
`SELECT
u.id, u.email, u.name, u.account_type, u.is_active, u.created_at,
COALESCE(
json_agg(json_build_object(
'role_id', ur.role_id,
'role_name', r.name,
'org_id', ur.org_id,
'org_name', o.name
)) FILTER (WHERE ur.role_id IS NOT NULL),
'[]'::json
) AS roles
FROM app.users u
LEFT JOIN morbac.user_roles ur ON ur.user_id = u.id
LEFT JOIN morbac.roles r ON r.id = ur.role_id
LEFT JOIN morbac.orgs o ON o.id = ur.org_id
${whereClause}
GROUP BY u.id ORDER BY u.name`,
whereClause ? params : [],
);
return (rows as { account_type: string }[]).filter((u) => {
if (u.account_type === 'system' && !canSeeSystem) return false;
if (u.account_type === 'service' && !canSeeService) return false;
return true;
});
});
app.post('/users', {
schema: {
tags: ['Management'],
summary: 'Create a user',
description: 'Creates a user account. Requires `create`/`users` (or `create`/`service_users`) permission in the scoped org.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['email', 'name', 'password'],
properties: {
email: { type: 'string', format: 'email', maxLength: 320 },
name: { type: 'string', minLength: 1, maxLength: 255 },
password: { type: 'string', minLength: 8, maxLength: 1024 },
account_type: { type: 'string', enum: ['user', 'service'], default: 'user' },
is_active: { type: 'boolean', default: true },
},
},
response: {
201: userResponse,
400: badRequest, 401: unauthorized, 403: forbidden,
409: { description: 'Email already in use.' },
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const orgId = orgIds[0] ?? null;
const { email: rawEmail, name, password, account_type = 'user', is_active = true } = req.body as {
email: string; name: string; password: string;
account_type?: 'user' | 'service'; is_active?: boolean;
};
const email = rawEmail.trim().toLowerCase();
const view = viewForType(account_type);
if (!await hasPermission(db, req, reply, orgId, 'create', view)) return reply;
if (checkLimit) {
const limitName = account_type === 'service' ? 'max_service_users' : 'max_users';
const ok = await checkLimit(
reply, limitName,
`SELECT count(*)::TEXT AS c FROM app.users WHERE account_type = $1`,
[account_type],
);
if (!ok) return reply;
}
const hash = await bcrypt.hash(password, 12);
try {
const { rows } = await db.query(
`INSERT INTO app.users (email, name, password_hash, account_type, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, email, name, account_type, is_active, created_at`,
[email, name, hash, account_type, is_active],
);
return reply.code(201).send({ ...rows[0], roles: [] });
} catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Email already in use' });
throw e;
}
});
app.patch('/users/:id', {
schema: {
tags: ['Management'],
summary: 'Update a user',
description: "Updates a user's name, email, password, or active status. Requires `update`/`users` (or `update`/`service_users`) permission.",
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 255 },
email: { type: 'string', format: 'email', maxLength: 320 },
password: { type: 'string', minLength: 8, maxLength: 1024 },
is_active: { type: 'boolean' },
},
},
response: {
200: userResponse,
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const orgId = orgIds[0] ?? null;
const { id } = req.params as { id: string };
const type = await getAccountType(db, id);
if (!type) return reply.code(404).send({ error: 'Not found' });
if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, orgId, 'update', viewForType(type))) return reply;
const { name, email, password, is_active } = req.body as {
name?: string; email?: string; password?: string; is_active?: boolean;
};
const hash = password ? await bcrypt.hash(password, 12) : null;
const { rows } = await db.query(
`UPDATE app.users
SET name = COALESCE($1, name),
email = COALESCE($2, email),
is_active = COALESCE($3, is_active),
password_hash = COALESCE($4, password_hash)
WHERE id = $5
RETURNING id, email, name, account_type, is_active, created_at`,
[name ?? null, email ?? null, is_active ?? null, hash, id],
);
if (!rows[0]) return reply.code(404).send({ error: 'Not found' });
return rows[0];
});
app.delete('/users/:id', {
schema: {
tags: ['Management'],
summary: 'Delete a user',
description: 'Permanently deletes a user account. Requires `delete`/`users` (or `delete`/`service_users`) permission.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'User deleted.' },
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const orgId = orgIds[0] ?? null;
const { id } = req.params as { id: string };
const type = await getAccountType(db, id);
if (!type) return reply.code(404).send({ error: 'Not found' });
if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, orgId, 'delete', viewForType(type))) return reply;
await db.query('DELETE FROM app.users WHERE id = $1', [id]);
return reply.code(204).send();
});
app.post('/users/:id/roles', {
schema: {
tags: ['Management'],
summary: 'Assign a role to a user',
description: 'Assigns a role to a user in the given org. Requires `create`/`user_roles` permission (or `create`/`service_users` for service accounts).',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['role_id', 'org_id'],
properties: {
role_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
},
},
response: {
201: {
type: 'object',
properties: {
user_id: { type: 'string', format: 'uuid' },
role_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
},
},
400: badRequest, 401: unauthorized, 403: forbidden,
409: { description: 'Would violate a Separation of Duty constraint.' },
},
},
}, async (req, reply) => {
const caller = getUser(req);
if (!caller) return reply.code(401).send({ error: 'Authentication required' });
const { id: user_id } = req.params as { id: string };
const { role_id, org_id } = req.body as { role_id: string; org_id: string };
const assignType = await getAccountType(db, user_id);
if (assignType === 'system') return reply.code(403).send({ error: 'System account roles are immutable' });
const assignView = assignType === 'service' ? 'service_users' : 'user_roles';
if (!await hasPermission(db, req, reply, org_id, 'create', assignView)) return reply;
try {
await db.query(
'SELECT morbac.assign_role($1::UUID, $2::UUID, $3::UUID)',
[user_id, role_id, org_id],
);
} catch (e: unknown) {
if ((e as { message?: string }).message?.includes('Separation of Duty')) {
return reply.code(409).send({ error: 'Role assignment would violate Separation of Duty constraints' });
}
throw e;
}
return reply.code(201).send({ user_id, role_id, org_id });
});
app.get('/users/:id/effective-permissions', {
schema: {
tags: ['Management'],
summary: 'Get effective permissions for a user',
description: 'Returns the flattened list of all rules applying to a user: role-based rules (including role hierarchy), direct user rules, and global rules.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
activity: { type: 'string', nullable: true },
view: { type: 'string', nullable: true },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
scope: { type: 'string', nullable: true },
org_id: { type: 'string', format: 'uuid', nullable: true },
org_name: { type: 'string', nullable: true },
role_id: { type: 'string', format: 'uuid', nullable: true },
role_name: { type: 'string', nullable: true },
source: { type: 'string' },
},
},
},
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const caller = getUser(req);
if (!caller) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
const { id } = req.params as { id: string };
const exists = await db.query('SELECT 1 FROM app.users WHERE id = $1', [id]);
if (!exists.rows[0]) return reply.code(404).send({ error: 'Not found' });
const { rows } = await db.query(
`SELECT DISTINCT
r.activity, r.view, r.modality, r.scope,
o.id AS org_id, o.name AS org_name,
ro.id AS role_id, ro.name AS role_name,
'role' AS source
FROM morbac.user_roles ur
JOIN morbac.mv_role_closure rc ON rc.senior_role_id = ur.role_id
JOIN morbac.rules r ON r.role_id = rc.junior_role_id
JOIN morbac.orgs o ON o.id = r.org_id
JOIN morbac.roles ro ON ro.id = r.role_id
WHERE ur.user_id = $1
AND morbac.org_in_scope(ur.org_id, r.org_id, r.scope)
UNION ALL
SELECT DISTINCT
urr.activity, urr.view, urr.modality, NULL AS scope,
o.id AS org_id, o.name AS org_name,
NULL::UUID AS role_id, NULL::TEXT AS role_name,
'direct' AS source
FROM morbac.user_rules urr
JOIN morbac.orgs o ON o.id = urr.org_id
WHERE urr.user_id = $1
UNION ALL
SELECT DISTINCT
gr.activity, gr.view, gr.modality, NULL AS scope,
NULL::UUID AS org_id, NULL::TEXT AS org_name,
NULL::UUID AS role_id, NULL::TEXT AS role_name,
'global' AS source
FROM morbac.global_rules gr
WHERE gr.user_id = $1
ORDER BY source, org_name NULLS LAST, activity, view`,
[id],
);
return rows;
});
app.delete('/users/:id/roles', {
schema: {
tags: ['Management'],
summary: 'Remove a role from a user',
description: 'Revokes a role from a user in the given org. Requires `delete`/`user_roles` permission (or `delete`/`service_users` for service accounts).',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['role_id', 'org_id'],
properties: {
role_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
},
},
response: {
204: { type: 'null', description: 'Role assignment removed.' },
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const caller = getUser(req);
if (!caller) return reply.code(401).send({ error: 'Authentication required' });
const { id: user_id } = req.params as { id: string };
const { role_id, org_id } = req.body as { role_id: string; org_id: string };
const revokeType = await getAccountType(db, user_id);
if (revokeType === 'system') return reply.code(403).send({ error: 'System account roles are immutable' });
const revokeView = revokeType === 'service' ? 'service_users' : 'user_roles';
if (!await hasPermission(db, req, reply, org_id, 'delete', revokeView)) return reply;
await db.query(
'SELECT morbac.revoke_role($1::UUID, $2::UUID, $3::UUID)',
[user_id, role_id, org_id],
);
return reply.code(204).send();
});
};
}
+113
View File
@@ -0,0 +1,113 @@
export const orgResponse = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
parent_id: { type: 'string', format: 'uuid', nullable: true },
metadata: { type: 'object' },
},
};
export const roleResponse = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
org_name: { type: 'string' },
name: { type: 'string' },
description: { type: 'string', nullable: true },
},
};
export const ruleResponse = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
role_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
org_name: { type: 'string' },
role_name: { type: 'string' },
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
priority: { type: 'integer', nullable: true },
scope: { type: 'string' },
},
};
export const userResponse = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
name: { type: 'string' },
account_type: { type: 'string', enum: ['user', 'system', 'service'] },
is_active: { type: 'boolean' },
created_at: { type: 'string', format: 'date-time' },
roles: {
type: 'array',
items: {
type: 'object',
properties: {
role_id: { type: 'string', format: 'uuid' },
role_name: { type: 'string' },
org_id: { type: 'string', format: 'uuid' },
org_name: { type: 'string' },
},
},
},
},
};
export const crossOrgRuleResponse = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
source_org_id: { type: 'string', format: 'uuid' },
source_role_id: { type: 'string', format: 'uuid' },
target_org_id: { type: 'string', format: 'uuid' },
source_org_name: { type: 'string' },
source_role_name: { type: 'string' },
target_org_name: { type: 'string' },
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
priority: { type: 'integer', nullable: true },
},
};
export const userRuleResponse = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
user_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
user_name: { type: 'string' },
user_email: { type: 'string', nullable: true },
org_name: { type: 'string' },
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
priority: { type: 'integer', nullable: true },
},
};
export const globalRuleResponse = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
user_id: { type: 'string', format: 'uuid', nullable: true },
user_name: { type: 'string', nullable: true },
user_email: { type: 'string', nullable: true },
activity: { type: 'string', nullable: true },
view: { type: 'string', nullable: true },
modality: { type: 'string', enum: ['permission', 'prohibition'] },
priority: { type: 'integer', nullable: true },
is_system_principal: { type: 'boolean' },
},
};
export const notFound = { description: 'Resource not found.' };
export const forbidden = { description: 'Insufficient permissions.' };
export const unauthorized = { description: 'Missing or invalid token.' };
export const badRequest = { description: 'X-Org-Id header is required.' };
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}