refacto: cleanup
This commit is contained in:
+25
@@ -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>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ export type {
|
|||||||
UserDetail,
|
UserDetail,
|
||||||
RuleUser,
|
RuleUser,
|
||||||
EffectivePermission,
|
EffectivePermission,
|
||||||
|
Delegation,
|
||||||
ActivityViewBinding,
|
ActivityViewBinding,
|
||||||
} from './api.js';
|
} from './api.js';
|
||||||
|
|||||||
@@ -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));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.' };
|
||||||
|
|||||||
Reference in New Issue
Block a user