From 8200c4650f9ec22e94e3939966970dbefb09ac89 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Sat, 6 Jun 2026 16:00:42 +0200 Subject: [PATCH] refacto: cleanup --- src/routes/rule-cross-org.ts | 142 +++++++++++++ src/routes/rule-role.ts | 132 ++++++++++++ src/routes/rule-user.ts | 130 ++++++++++++ src/routes/rules.ts | 387 +--------------------------------- src/routes/user-crud.ts | 225 ++++++++++++++++++++ src/routes/user-roles.ts | 183 ++++++++++++++++ src/routes/users.ts | 396 +---------------------------------- 7 files changed, 822 insertions(+), 773 deletions(-) create mode 100644 src/routes/rule-cross-org.ts create mode 100644 src/routes/rule-role.ts create mode 100644 src/routes/rule-user.ts create mode 100644 src/routes/user-crud.ts create mode 100644 src/routes/user-roles.ts diff --git a/src/routes/rule-cross-org.ts b/src/routes/rule-cross-org.ts new file mode 100644 index 0000000..7a25255 --- /dev/null +++ b/src/routes/rule-cross-org.ts @@ -0,0 +1,142 @@ +import type { FastifyInstance } from 'fastify'; +import type { Pool } from 'pg'; +import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; +import { crossOrgRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; + +export function createCrossOrgRuleRoutes(db: Pool) { + return async function crossOrgRuleRoutes(app: FastifyInstance) { + 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(); + }); + }; +} diff --git a/src/routes/rule-role.ts b/src/routes/rule-role.ts new file mode 100644 index 0000000..b5abe3e --- /dev/null +++ b/src/routes/rule-role.ts @@ -0,0 +1,132 @@ +import type { FastifyInstance } from 'fastify'; +import type { Pool } from 'pg'; +import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; +import { ruleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; + +export function createRoleRuleRoutes(db: Pool) { + return async function roleRuleRoutes(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(); + }); + }; +} diff --git a/src/routes/rule-user.ts b/src/routes/rule-user.ts new file mode 100644 index 0000000..c26af20 --- /dev/null +++ b/src/routes/rule-user.ts @@ -0,0 +1,130 @@ +import type { FastifyInstance } from 'fastify'; +import type { Pool } from 'pg'; +import { getUser, getOrgScope, orgScopeExpr, hasPermission } from '../permissions.js'; +import { userRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; + +export function createUserRuleRoutes(db: Pool) { + return async function userRuleRoutes(app: FastifyInstance) { + 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/rules.ts b/src/routes/rules.ts index 27f80f4..66f397f 100644 --- a/src/routes/rules.ts +++ b/src/routes/rules.ts @@ -1,388 +1,13 @@ 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'; +import { createRoleRuleRoutes } from './rule-role.js'; +import { createCrossOrgRuleRoutes } from './rule-cross-org.js'; +import { createUserRuleRoutes } from './rule-user.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(); - }); + await app.register(createRoleRuleRoutes(db)); + await app.register(createCrossOrgRuleRoutes(db)); + await app.register(createUserRuleRoutes(db)); }; } diff --git a/src/routes/user-crud.ts b/src/routes/user-crud.ts new file mode 100644 index 0000000..78bed7f --- /dev/null +++ b/src/routes/user-crud.ts @@ -0,0 +1,225 @@ +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 createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { + return async function userCrudRoutes(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, globalRes] = 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']), + db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, NULL, $2, $3) AS allowed', + [user.id, 'read', 'users']), + ]); + const canSeeSystem = sysRes.rows[0]?.allowed ?? false; + const canSeeService = svcRes.rows[0]?.allowed ?? false; + const canReadGlobally = globalRes.rows[0]?.allowed ?? false; + + const { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id'); + const isSystemScope = orgIds.includes(SYSTEM_ORG_ID); + const placement = canReadGlobally ? ' OR u.needs_placement' : ''; + const whereClause = expr && !isSystemScope + ? `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})${placement})` + : ''; + const { rows } = await db.query( + `SELECT + u.id, u.email, u.name, u.account_type, u.is_active, u.needs_placement, 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'], + 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 = password ? await bcrypt.hash(password, 12) : null; + 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, needs_placement, 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, needs_placement, 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(); + }); + }; +} diff --git a/src/routes/user-roles.ts b/src/routes/user-roles.ts new file mode 100644 index 0000000..cbe1ebd --- /dev/null +++ b/src/routes/user-roles.ts @@ -0,0 +1,183 @@ +import type { FastifyInstance } from 'fastify'; +import type { Pool } from 'pg'; +import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js'; +import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; + +export function createUserRoleRoutes(db: Pool) { + return async function userRoleRoutes(app: FastifyInstance) { + 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; + } + await db.query('UPDATE app.users SET needs_placement = FALSE WHERE id = $1 AND needs_placement', [user_id]); + 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/routes/users.ts b/src/routes/users.ts index aff38c8..66cfeb4 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -1,400 +1,12 @@ 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'; +import { createUserCrudRoutes } from './user-crud.js'; +import { createUserRoleRoutes } from './user-roles.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, globalRes] = 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']), - db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, NULL, $2, $3) AS allowed', - [user.id, 'read', 'users']), - ]); - const canSeeSystem = sysRes.rows[0]?.allowed ?? false; - const canSeeService = svcRes.rows[0]?.allowed ?? false; - const canReadGlobally = globalRes.rows[0]?.allowed ?? false; - - const { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id'); - const isSystemScope = orgIds.includes(SYSTEM_ORG_ID); - const placement = canReadGlobally ? ' OR u.needs_placement' : ''; - const whereClause = expr && !isSystemScope - ? `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})${placement})` - : ''; - const { rows } = await db.query( - `SELECT - u.id, u.email, u.name, u.account_type, u.is_active, u.needs_placement, 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'], - 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 = password ? await bcrypt.hash(password, 12) : null; - 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, needs_placement, 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, needs_placement, 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; - } - await db.query('UPDATE app.users SET needs_placement = FALSE WHERE id = $1 AND needs_placement', [user_id]); - 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(); - }); + await app.register(createUserCrudRoutes(db, checkLimit)); + await app.register(createUserRoleRoutes(db)); }; }