From 9c87cff4a2ae0f9789195b5d704edb3b332bba2a Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Wed, 29 Apr 2026 07:49:11 +0200 Subject: [PATCH] feat(packages): add pgmorbac package --- package.json | 43 +++++ src/api.ts | 266 ++++++++++++++++++++++++++ src/index.ts | 18 ++ src/permissions.ts | 74 ++++++++ src/plugin.ts | 32 ++++ src/proxy.ts | 127 +++++++++++++ src/routes/activities.ts | 229 +++++++++++++++++++++++ src/routes/globals.ts | 197 +++++++++++++++++++ src/routes/orgs.ts | 176 +++++++++++++++++ src/routes/roles.ts | 293 +++++++++++++++++++++++++++++ src/routes/rules.ts | 388 ++++++++++++++++++++++++++++++++++++++ src/routes/users.ts | 395 +++++++++++++++++++++++++++++++++++++++ src/schemas.ts | 113 +++++++++++ tsconfig.json | 14 ++ 14 files changed, 2365 insertions(+) create mode 100644 package.json create mode 100644 src/api.ts create mode 100644 src/index.ts create mode 100644 src/permissions.ts create mode 100644 src/plugin.ts create mode 100644 src/proxy.ts create mode 100644 src/routes/activities.ts create mode 100644 src/routes/globals.ts create mode 100644 src/routes/orgs.ts create mode 100644 src/routes/roles.ts create mode 100644 src/routes/rules.ts create mode 100644 src/routes/users.ts create mode 100644 src/schemas.ts create mode 100644 tsconfig.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..89b3c70 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..95318e8 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,266 @@ +export interface Org { + id: string; + name: string; + parent_id: string | null; + metadata: Record; +} + +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; + getOrgs(token: string, orgIds: string[]): Promise; + createOrg(token: string, orgIds: string[], data: { name: string; parent_id?: string }): Promise; + updateOrg(token: string, orgIds: string[], id: string, data: { name?: string; parent_id?: string | null }): Promise; + deleteOrg(token: string, orgIds: string[], id: string): Promise; + getRoles(token: string, orgIds: string[]): Promise; + createRole(token: string, orgIds: string[], data: { org_id: string; name: string; description?: string }): Promise; + updateRole(token: string, orgIds: string[], id: string, data: { name?: string; description?: string }): Promise; + deleteRole(token: string, orgIds: string[], id: string): Promise; + getRoleHierarchy(token: string, orgIds: string[]): Promise; + addRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise; + removeRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise; + getActivities(token: string, orgIds: string[]): Promise; + getViews(token: string, orgIds: string[]): Promise; + getActivityViewBindings(token: string, orgIds: string[]): Promise; + getRules(token: string, orgIds: string[]): Promise; + createRule(token: string, orgIds: string[], data: { org_id: string; role_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; scope?: string; priority?: number }): Promise; + deleteRule(token: string, orgIds: string[], id: string): Promise; + getCrossOrgRules(token: string, orgIds: string[]): Promise; + 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; + deleteCrossOrgRule(token: string, orgIds: string[], id: string): Promise; + getUserRules(token: string, orgIds: string[]): Promise; + createUserRule(token: string, orgIds: string[], data: { user_id: string; org_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise; + deleteUserRule(token: string, orgIds: string[], id: string): Promise; + getGlobalRules(token: string, orgIds: string[]): Promise; + createGlobalRule(token: string, orgIds: string[], data: { user_id?: string | null; activity?: string | null; view?: string | null; modality: 'permission' | 'prohibition'; priority?: number }): Promise; + deleteGlobalRule(token: string, orgIds: string[], id: string): Promise; + getSystemPrincipals(token: string, orgIds: string[]): Promise; + getUsers(token: string, orgIds: string[]): Promise; + createUser(token: string, orgIds: string[], data: { email: string; name: string; password: string }): Promise; + updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; password?: string }): Promise; + deleteUser(token: string, orgIds: string[], id: string): Promise; + assignRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise; + removeRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise; + getActivityUsers(token: string, orgIds: string[], name: string): Promise; + getViewUsers(token: string, orgIds: string[], name: string): Promise; + getEffectivePermissions(token: string, orgIds: string[], userId: string): Promise; +} + +export function createMorbacApi(apiUrl: string): MorbacApi { + async function api(method: string, path: string, token: string, orgIds: string[], body?: unknown): Promise { + const headers: Record = { + 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; + }, + + getOrgs: (t, orgIds) => api('GET', '/orgs', t, orgIds) as Promise, + createOrg: (t, orgIds, d) => api('POST', '/orgs', t, orgIds, d) as Promise, + updateOrg: (t, orgIds, id, d) => api('PATCH', `/orgs/${id}`, t, orgIds, d) as Promise, + deleteOrg: (t, orgIds, id) => api('DELETE', `/orgs/${id}`, t, orgIds) as Promise, + + getRoles: (t, orgIds) => api('GET', '/roles', t, orgIds) as Promise, + createRole: (t, orgIds, d) => api('POST', '/roles', t, orgIds, d) as Promise, + updateRole: (t, orgIds, id, d) => api('PATCH', `/roles/${id}`, t, orgIds, d) as Promise, + deleteRole: (t, orgIds, id) => api('DELETE', `/roles/${id}`, t, orgIds) as Promise, + + getRoleHierarchy: (t, orgIds) => api('GET', '/role-hierarchy', t, orgIds) as Promise, + 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, + getViews: (t, orgIds) => api('GET', '/views', t, orgIds) as Promise, + getActivityViewBindings: (t, orgIds) => api('GET', '/activity-view-bindings', t, orgIds) as Promise, + + getRules: (t, orgIds) => api('GET', '/rules', t, orgIds) as Promise, + createRule: (t, orgIds, d) => api('POST', '/rules', t, orgIds, d) as Promise, + deleteRule: (t, orgIds, id) => api('DELETE', `/rules/${id}`, t, orgIds) as Promise, + + getCrossOrgRules: (t, orgIds) => api('GET', '/cross-org-rules', t, orgIds) as Promise, + createCrossOrgRule: (t, orgIds, d) => api('POST', '/cross-org-rules', t, orgIds, d) as Promise, + deleteCrossOrgRule: (t, orgIds, id) => api('DELETE', `/cross-org-rules/${id}`, t, orgIds) as Promise, + + getUserRules: (t, orgIds) => api('GET', '/user-rules', t, orgIds) as Promise, + createUserRule: (t, orgIds, d) => api('POST', '/user-rules', t, orgIds, d) as Promise, + deleteUserRule: (t, orgIds, id) => api('DELETE', `/user-rules/${id}`, t, orgIds) as Promise, + + getGlobalRules: (t, orgIds) => api('GET', '/global-rules', t, orgIds) as Promise, + createGlobalRule: (t, orgIds, d) => api('POST', '/global-rules', t, orgIds, d) as Promise, + deleteGlobalRule: (t, orgIds, id) => api('DELETE', `/global-rules/${id}`, t, orgIds) as Promise, + + getSystemPrincipals: (t, orgIds) => api('GET', '/system-principals', t, orgIds) as Promise, + + getUsers: (t, orgIds) => api('GET', '/users', t, orgIds) as Promise, + createUser: (t, orgIds, d) => api('POST', '/users', t, orgIds, d) as Promise, + updateUser: (t, orgIds, id, d) => api('PATCH', `/users/${id}`, t, orgIds, d) as Promise, + deleteUser: (t, orgIds, id) => api('DELETE', `/users/${id}`, t, orgIds) as Promise, + + 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, + getViewUsers: (t, orgIds, name) => api('GET', `/views/${encodeURIComponent(name)}/users`, t, orgIds) as Promise, + getEffectivePermissions: (t, orgIds, userId) => api('GET', `/users/${userId}/effective-permissions`, t, orgIds) as Promise, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0d3a3b6 --- /dev/null +++ b/src/index.ts @@ -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'; diff --git a/src/permissions.ts b/src/permissions.ts new file mode 100644 index 0000000..417b768 --- /dev/null +++ b/src/permissions.ts @@ -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).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 { + 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; +} diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..807935a --- /dev/null +++ b/src/plugin.ts @@ -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; + +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)); + }; +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..4bb704f --- /dev/null +++ b/src/proxy.ts @@ -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(); + + 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, + 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).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 = {}; + 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(); + 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 }; +} diff --git a/src/routes/activities.ts b/src/routes/activities.ts new file mode 100644 index 0000000..66b72e9 --- /dev/null +++ b/src/routes/activities.ts @@ -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; + }); + }; +} diff --git a/src/routes/globals.ts b/src/routes/globals.ts new file mode 100644 index 0000000..394a0c4 --- /dev/null +++ b/src/routes/globals.ts @@ -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; + }); + }; +} diff --git a/src/routes/orgs.ts b/src/routes/orgs.ts new file mode 100644 index 0000000..82763c9 --- /dev/null +++ b/src/routes/orgs.ts @@ -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(); + }); + }; +} diff --git a/src/routes/roles.ts b/src/routes/roles.ts new file mode 100644 index 0000000..0972402 --- /dev/null +++ b/src/routes/roles.ts @@ -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(); + }); + }; +} diff --git a/src/routes/rules.ts b/src/routes/rules.ts new file mode 100644 index 0000000..27f80f4 --- /dev/null +++ b/src/routes/rules.ts @@ -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(); + }); + }; +} diff --git a/src/routes/users.ts b/src/routes/users.ts new file mode 100644 index 0000000..87fa330 --- /dev/null +++ b/src/routes/users.ts @@ -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(); + }); + }; +} diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..63a9448 --- /dev/null +++ b/src/schemas.ts @@ -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.' }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f0423d6 --- /dev/null +++ b/tsconfig.json @@ -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"] +}