refacto: cleanup

This commit is contained in:
2026-06-06 10:35:40 +02:00
parent dfe8c8c322
commit 8ff6bf2d0e
5 changed files with 205 additions and 0 deletions
+25
View File
@@ -135,6 +135,24 @@ export interface EffectivePermission {
source: 'role' | 'direct' | 'global'; source: 'role' | 'direct' | 'global';
} }
export interface Delegation {
id: string;
delegator_id: string;
delegatee_id: string;
delegator_name: string | null;
delegator_email: string | null;
delegatee_name: string | null;
delegatee_email: string | null;
role_id: string;
role_name: string;
org_id: string;
org_name: string;
valid_from: string;
valid_until: string;
revoked: boolean;
created_at: string;
}
export type ActivityViewBinding = { activity: string; view: string }; export type ActivityViewBinding = { activity: string; view: string };
export interface MorbacApi { export interface MorbacApi {
@@ -175,6 +193,9 @@ export interface MorbacApi {
getActivityUsers(token: string, orgIds: string[], name: string): Promise<RuleUser[]>; getActivityUsers(token: string, orgIds: string[], name: string): Promise<RuleUser[]>;
getViewUsers(token: string, orgIds: string[], name: string): Promise<RuleUser[]>; getViewUsers(token: string, orgIds: string[], name: string): Promise<RuleUser[]>;
getEffectivePermissions(token: string, orgIds: string[], userId: string): Promise<EffectivePermission[]>; getEffectivePermissions(token: string, orgIds: string[], userId: string): Promise<EffectivePermission[]>;
getDelegations(token: string, orgIds: string[]): Promise<Delegation[]>;
createDelegation(token: string, orgIds: string[], data: { delegator_id: string; delegatee_id: string; role_id: string; org_id: string; valid_from?: string; valid_until: string }): Promise<Delegation>;
revokeDelegation(token: string, orgIds: string[], id: string): Promise<null>;
} }
export function createMorbacApi(apiUrl: string): MorbacApi { export function createMorbacApi(apiUrl: string): MorbacApi {
@@ -263,5 +284,9 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
getActivityUsers: (t, orgIds, name) => api('GET', `/activities/${encodeURIComponent(name)}/users`, t, orgIds) as Promise<RuleUser[]>, getActivityUsers: (t, orgIds, name) => api('GET', `/activities/${encodeURIComponent(name)}/users`, t, orgIds) as Promise<RuleUser[]>,
getViewUsers: (t, orgIds, name) => api('GET', `/views/${encodeURIComponent(name)}/users`, t, orgIds) as Promise<RuleUser[]>, getViewUsers: (t, orgIds, name) => api('GET', `/views/${encodeURIComponent(name)}/users`, t, orgIds) as Promise<RuleUser[]>,
getEffectivePermissions: (t, orgIds, userId) => api('GET', `/users/${userId}/effective-permissions`, t, orgIds) as Promise<EffectivePermission[]>, getEffectivePermissions: (t, orgIds, userId) => api('GET', `/users/${userId}/effective-permissions`, t, orgIds) as Promise<EffectivePermission[]>,
getDelegations: (t, orgIds) => api('GET', '/delegations', t, orgIds) as Promise<Delegation[]>,
createDelegation: (t, orgIds, d) => api('POST', '/delegations', t, orgIds, d) as Promise<Delegation>,
revokeDelegation: (t, orgIds, id) => api('DELETE', `/delegations/${id}`, t, orgIds) as Promise<null>,
}; };
} }
+1
View File
@@ -14,5 +14,6 @@ export type {
UserDetail, UserDetail,
RuleUser, RuleUser,
EffectivePermission, EffectivePermission,
Delegation,
ActivityViewBinding, ActivityViewBinding,
} from './api.js'; } from './api.js';
+2
View File
@@ -6,6 +6,7 @@ import { createRuleRoutes } from './routes/rules.js';
import { createUserRoutes } from './routes/users.js'; import { createUserRoutes } from './routes/users.js';
import { createGlobalRoutes } from './routes/globals.js'; import { createGlobalRoutes } from './routes/globals.js';
import { createActivityRoutes } from './routes/activities.js'; import { createActivityRoutes } from './routes/activities.js';
import { createDelegationRoutes } from './routes/delegations.js';
export type CheckLimitFn = ( export type CheckLimitFn = (
reply: FastifyReply, reply: FastifyReply,
@@ -37,5 +38,6 @@ export function createMgmtRoutes(opts: MgmtPluginOptions) {
await app.register(createUserRoutes(db, checkLimit)); await app.register(createUserRoutes(db, checkLimit));
await app.register(createGlobalRoutes(db)); await app.register(createGlobalRoutes(db));
await app.register(createActivityRoutes(db)); await app.register(createActivityRoutes(db));
await app.register(createDelegationRoutes(db));
}; };
} }
+156
View File
@@ -0,0 +1,156 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
import { delegationResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
const SELECT_COLUMNS = `
d.id, d.delegator_id, d.delegatee_id, d.role_id, d.org_id,
dor.name AS delegator_name, dor.email AS delegator_email,
dee.name AS delegatee_name, dee.email AS delegatee_email,
ro.name AS role_name, o.name AS org_name,
d.valid_from, d.valid_until, d.revoked, d.created_at`;
export function createDelegationRoutes(db: Pool) {
return async function delegationRoutes(app: FastifyInstance) {
app.get('/delegations', {
schema: {
tags: ['Management'],
summary: 'List role delegations',
description: 'Time-bounded role delegations within the scoped org subtree. A delegatee gains the delegated role only while the delegator still holds it, within the validity window, and until revoked.',
security: [{ oauth2: [] }],
response: {
200: { type: 'array', items: delegationResponse },
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', 'delegations')) return reply;
const { expr, params } = orgScopeExpr(orgIds, 'd.org_id');
const { rows } = await db.query(
`SELECT ${SELECT_COLUMNS}
FROM morbac.delegations d
JOIN morbac.orgs o ON o.id = d.org_id
JOIN morbac.roles ro ON ro.id = d.role_id
LEFT JOIN app.users dor ON dor.id = d.delegator_id
LEFT JOIN app.users dee ON dee.id = d.delegatee_id
${expr ? `WHERE ${expr}` : ''}
ORDER BY d.created_at DESC`,
params,
);
return rows;
});
app.post('/delegations', {
schema: {
tags: ['Management'],
summary: 'Create a role delegation',
description: 'Delegates a role the delegator holds to another user, time-bounded. Requires `create`/`delegations` in the org. The delegator must currently hold the role in that org.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['delegator_id', 'delegatee_id', 'role_id', 'org_id', 'valid_until'],
properties: {
delegator_id: { type: 'string', format: 'uuid' },
delegatee_id: { type: 'string', format: 'uuid' },
role_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
valid_from: { type: 'string', format: 'date-time' },
valid_until: { type: 'string', format: 'date-time' },
},
},
response: {
201: delegationResponse,
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 { delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until } = req.body as {
delegator_id: string; delegatee_id: string; role_id: string;
org_id: string; valid_from?: string; valid_until: string;
};
if (org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (delegator_id === delegatee_id) return reply.code(400).send({ error: 'delegator_and_delegatee_must_differ' });
if (!await hasPermission(db, req, reply, org_id, 'create', 'delegations')) return reply;
const from = valid_from ? new Date(valid_from) : new Date();
const until = new Date(valid_until);
if (Number.isNaN(until.getTime()) || until <= from) {
return reply.code(400).send({ error: 'invalid_validity_window' });
}
const { rows: [delegatee] } = await db.query<{ ok: number }>(
'SELECT 1 AS ok FROM app.users WHERE id = $1', [delegatee_id],
);
if (!delegatee) return reply.code(400).send({ error: 'invalid_delegatee' });
const { rows: [held] } = await db.query<{ ok: number }>(
'SELECT 1 AS ok FROM morbac.user_roles WHERE user_id = $1 AND role_id = $2 AND org_id = $3',
[delegator_id, role_id, org_id],
);
if (!held) return reply.code(400).send({ error: 'delegator_lacks_role' });
try {
const { rows } = await db.query(
`INSERT INTO morbac.delegations (delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until, revoked, created_at,
(SELECT name FROM app.users WHERE id = delegator_id) AS delegator_name,
(SELECT email FROM app.users WHERE id = delegator_id) AS delegator_email,
(SELECT name FROM app.users WHERE id = delegatee_id) AS delegatee_name,
(SELECT email FROM app.users WHERE id = delegatee_id) AS delegatee_email,
(SELECT name FROM morbac.roles WHERE id = role_id) AS role_name,
(SELECT name FROM morbac.orgs WHERE id = org_id) AS org_name`,
[delegator_id, delegatee_id, role_id, org_id, from.toISOString(), until.toISOString()],
);
return reply.code(201).send(rows[0]);
} catch (e: unknown) {
const code = (e as { code?: string }).code;
if (code === '23514') return reply.code(400).send({ error: 'invalid_validity_window' });
if (code === '23503') return reply.code(400).send({ error: 'invalid_role_or_org' });
throw e;
}
});
app.delete('/delegations/:id', {
schema: {
tags: ['Management'],
summary: 'Revoke a role delegation',
description: 'Revokes a delegation (sets revoked = true), immediately removing the delegated role. The delegator may revoke their own delegation; otherwise `delete`/`delegations` is required in the org.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'Delegation revoked.' },
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: [del] } = await db.query<{ org_id: string; delegator_id: string }>(
'SELECT org_id, delegator_id FROM morbac.delegations WHERE id = $1', [id],
);
if (!del) return reply.code(404).send({ error: 'Not found' });
if (del.delegator_id !== user.id) {
if (!await hasPermission(db, req, reply, del.org_id, 'delete', 'delegations')) return reply;
}
await db.query('UPDATE morbac.delegations SET revoked = TRUE WHERE id = $1', [id]);
return reply.code(204).send();
});
};
}
+21
View File
@@ -108,6 +108,27 @@ export const globalRuleResponse = {
}, },
}; };
export const delegationResponse = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
delegator_id: { type: 'string', format: 'uuid' },
delegatee_id: { type: 'string', format: 'uuid' },
delegator_name: { type: 'string', nullable: true },
delegator_email: { type: 'string', nullable: true },
delegatee_name: { type: 'string', nullable: true },
delegatee_email: { type: 'string', nullable: true },
role_id: { type: 'string', format: 'uuid' },
role_name: { type: 'string' },
org_id: { type: 'string', format: 'uuid' },
org_name: { type: 'string' },
valid_from: { type: 'string', format: 'date-time' },
valid_until: { type: 'string', format: 'date-time' },
revoked: { type: 'boolean' },
created_at: { type: 'string', format: 'date-time' },
},
};
export const notFound = { description: 'Resource not found.' }; export const notFound = { description: 'Resource not found.' };
export const forbidden = { description: 'Insufficient permissions.' }; export const forbidden = { description: 'Insufficient permissions.' };
export const unauthorized = { description: 'Missing or invalid token.' }; export const unauthorized = { description: 'Missing or invalid token.' };