diff --git a/src/api.ts b/src/api.ts index e6a0abd..e23dc5c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -135,6 +135,24 @@ export interface EffectivePermission { 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 interface MorbacApi { @@ -175,6 +193,9 @@ export interface MorbacApi { getActivityUsers(token: string, orgIds: string[], name: string): Promise; getViewUsers(token: string, orgIds: string[], name: string): Promise; getEffectivePermissions(token: string, orgIds: string[], userId: string): Promise; + getDelegations(token: string, orgIds: string[]): Promise; + 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; + revokeDelegation(token: string, orgIds: string[], id: string): Promise; } 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, getViewUsers: (t, orgIds, name) => api('GET', `/views/${encodeURIComponent(name)}/users`, t, orgIds) as Promise, getEffectivePermissions: (t, orgIds, userId) => api('GET', `/users/${userId}/effective-permissions`, t, orgIds) as Promise, + + getDelegations: (t, orgIds) => api('GET', '/delegations', t, orgIds) as Promise, + createDelegation: (t, orgIds, d) => api('POST', '/delegations', t, orgIds, d) as Promise, + revokeDelegation: (t, orgIds, id) => api('DELETE', `/delegations/${id}`, t, orgIds) as Promise, }; } diff --git a/src/index.ts b/src/index.ts index 0d3a3b6..8360e4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,5 +14,6 @@ export type { UserDetail, RuleUser, EffectivePermission, + Delegation, ActivityViewBinding, } from './api.js'; diff --git a/src/plugin.ts b/src/plugin.ts index b473c51..ff097a4 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -6,6 +6,7 @@ import { createRuleRoutes } from './routes/rules.js'; import { createUserRoutes } from './routes/users.js'; import { createGlobalRoutes } from './routes/globals.js'; import { createActivityRoutes } from './routes/activities.js'; +import { createDelegationRoutes } from './routes/delegations.js'; export type CheckLimitFn = ( reply: FastifyReply, @@ -37,5 +38,6 @@ export function createMgmtRoutes(opts: MgmtPluginOptions) { await app.register(createUserRoutes(db, checkLimit)); await app.register(createGlobalRoutes(db)); await app.register(createActivityRoutes(db)); + await app.register(createDelegationRoutes(db)); }; } diff --git a/src/routes/delegations.ts b/src/routes/delegations.ts new file mode 100644 index 0000000..51e4b7c --- /dev/null +++ b/src/routes/delegations.ts @@ -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(); + }); + }; +} diff --git a/src/schemas.ts b/src/schemas.ts index fa5747e..9d40e4a 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -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 forbidden = { description: 'Insufficient permissions.' }; export const unauthorized = { description: 'Missing or invalid token.' };