refacto: cleanup
This commit is contained in:
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
+6
-381
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
+4
-392
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user