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