feat(packages): add pgmorbac package
This commit is contained in:
@@ -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
@@ -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[]>,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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.' };
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user