release: 1.0.0
This commit is contained in:
+41
-7
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
|
||||
import { getUser, getOrgScope, orgScopeExpr, hasPermission, canGrantRule, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
|
||||
import { ruleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
|
||||
export function createRoleRuleRoutes(db: Pool) {
|
||||
@@ -28,7 +28,10 @@ export function createRoleRuleRoutes(db: Pool) {
|
||||
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
|
||||
r.activity, r.view, r.modality, r.priority, r.scope,
|
||||
r.valid_from, r.valid_until,
|
||||
(r.valid_from IS NULL OR r.valid_from <= now())
|
||||
AND (r.valid_until IS NULL OR r.valid_until > now()) AS is_active
|
||||
FROM morbac.rules r
|
||||
JOIN morbac.orgs o ON o.id = r.org_id
|
||||
JOIN morbac.roles ro ON ro.id = r.role_id
|
||||
@@ -57,8 +60,11 @@ export function createRoleRuleRoutes(db: Pool) {
|
||||
priority: { type: 'integer' },
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root'],
|
||||
enum: ['self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root', 'unattributed', 'all'],
|
||||
default: 'self',
|
||||
description:
|
||||
'Which objects the rule reaches: a specific org via the tree scopes, '
|
||||
+ 'unattributed for objects with no org, or all for every org including unattributed.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -79,6 +85,23 @@ export function createRoleRuleRoutes(db: Pool) {
|
||||
|
||||
if (!await hasPermission(db, req, reply, org_id, 'create', 'rules')) return reply;
|
||||
|
||||
if (!await isActivityViewBound(db, activity, view)) {
|
||||
return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
|
||||
}
|
||||
|
||||
const { rows: [roleRow] } = await db.query<{ org_id: string }>(
|
||||
'SELECT org_id FROM morbac.roles WHERE id = $1', [role_id],
|
||||
);
|
||||
if (!roleRow) return reply.code(404).send({ error: 'Not found' });
|
||||
|
||||
// Anti-escalation: only permission rules are constrained (prohibitions
|
||||
// only ever reduce access). A caller may not grant, via any scope, a
|
||||
// permission they do not themselves hold on every org that scope covers.
|
||||
if (modality === 'permission'
|
||||
&& !await canGrantRule(db, user.id, org_id, scope, activity, view)) {
|
||||
return reply.code(403).send({ error: 'Cannot grant a permission you do not hold on the target orgs' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO morbac.rules
|
||||
@@ -91,12 +114,20 @@ export function createRoleRuleRoutes(db: Pool) {
|
||||
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`,
|
||||
activity, view, modality, priority, scope,
|
||||
valid_from, valid_until, is_active`,
|
||||
[org_id, role_id, activity, view, modality, scope, priority ?? null],
|
||||
);
|
||||
// The morbac AFTER trigger only clears cache for the rule's own org.
|
||||
// A non-self scope also affects other orgs (ancestors/descendants), so
|
||||
// flush the whole cache to avoid stale grant/revocation decisions.
|
||||
if (scope !== 'self') await db.query('SELECT morbac.invalidate_cache()');
|
||||
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' });
|
||||
const code = (e as { code?: string }).code;
|
||||
if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
|
||||
if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' });
|
||||
if (code === 'P0001') return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
@@ -118,14 +149,17 @@ export function createRoleRuleRoutes(db: Pool) {
|
||||
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],
|
||||
const { rows: [rule] } = await db.query<{ org_id: string; scope: string }>(
|
||||
'SELECT org_id, scope 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]);
|
||||
// See POST: a non-self scope revocation must flush cache for the
|
||||
// affected orgs, which the morbac trigger does not cover.
|
||||
if (rule.scope !== 'self') await db.query('SELECT morbac.invalidate_cache()');
|
||||
return reply.code(204).send();
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user