Files
pgmorbac-fastify/src/routes/permission-profiles.ts
T

256 lines
12 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
import { getUser, getOrgScope, hasPermission, permittedScope, SYSTEM_ORG_ID } from '../permissions.js';
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { loadProfile, validateGrantable, applyProfileRules } from './profile-apply.js';
const profileResponse = {
type: 'object',
properties: {
module_id: { type: 'string' },
key: { type: 'string' },
content_hash: { type: 'string' },
rules: {
type: 'array',
items: {
type: 'object',
properties: {
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string' },
scope: { type: 'string' },
},
},
},
},
};
const profileRef = {
type: 'object',
required: ['module_id', 'key'],
properties: {
module_id: { type: 'string', maxLength: 128 },
key: { type: 'string', maxLength: 128 },
},
};
export function createPermissionProfileRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEvent?: RoleEventSink) {
return async function permissionProfileRoutes(app: FastifyInstance) {
app.get('/permission-profiles', {
schema: {
tags: ['Management'],
summary: 'List permission profiles',
description: 'Returns role presets declared by modules. Requires `read`/`roles`.',
security: [{ oauth2: [] }],
querystring: {
type: 'object',
properties: { module_id: { type: 'string', maxLength: 128 } },
},
response: {
200: { type: 'array', items: profileResponse },
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;
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
if (!scope) return reply;
const { module_id } = req.query as { module_id?: string };
const { rows } = await db.query(
`SELECT module_id, key, content_hash, rules
FROM app.permission_profiles
${module_id ? 'WHERE module_id = $1' : ''}
ORDER BY module_id, key`,
module_id ? [module_id] : [],
);
return rows;
});
app.post('/roles/from-profile', {
schema: {
tags: ['Management'],
summary: 'Create a role from a profile',
description:
'Creates a role in the target org and seeds its rules from a module profile. '
+ 'Requires `create`/`roles`, and the caller must already hold every granted permission. '
+ 'When `sync` is true the role tracks future profile changes.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['org_id', 'name', 'profile'],
properties: {
org_id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1, maxLength: 200 },
description: { type: 'string', maxLength: 500 },
sync: { type: 'boolean', default: false },
profile: profileRef,
},
},
response: {
201: roleResponse,
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 { org_id, name, description, sync, profile } = req.body as {
org_id: string; name: string; description?: string; sync?: boolean;
profile: { module_id: string; key: string };
};
if (org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, org_id, 'create', 'roles')) return reply;
const prof = await loadProfile(db, profile.module_id, profile.key);
if (!prof) return reply.code(404).send({ error: 'Not found' });
if (checkLimit) {
const ok = await checkLimit(reply, 'max_roles', 'SELECT count(*)::TEXT AS c FROM morbac.roles');
if (!ok) return reply;
}
if (!await validateGrantable(db, reply, user.id, org_id, prof.rules)) return reply;
const client = await db.connect();
try {
await client.query('BEGIN');
const { rows: [role] } = await client.query(
`INSERT INTO morbac.roles (org_id, name, description) VALUES ($1, $2, $3)
RETURNING id, org_id, name, description`,
[org_id, name, description ?? null],
);
const nonSelf = await applyProfileRules(client, org_id, role.id, profile.module_id, profile.key, prof.rules);
if (sync) {
await client.query(
`INSERT INTO app.role_profile_links (role_id, module_id, profile_key, synced, applied_hash)
VALUES ($1, $2, $3, TRUE, $4)`,
[role.id, profile.module_id, profile.key, prof.content_hash],
);
}
await client.query('COMMIT');
if (nonSelf) await db.query('SELECT morbac.invalidate_cache()');
const org = await db.query('SELECT name FROM morbac.orgs WHERE id = $1', [org_id]);
emitRoleEvent?.({ action: 'created', role, actorId: user.id });
return reply.code(201).send({ ...role, org_name: org.rows[0]?.name });
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
});
app.post('/roles/:id/profile-sync', {
schema: {
tags: ['Management'],
summary: 'Attach a profile to an existing role',
description:
'Applies a module profile to an existing role and records the link. Requires '
+ '`update`/`roles`, and the caller must already hold every granted permission. '
+ 'When `sync` is true the role tracks future profile changes.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['profile'],
properties: {
profile: profileRef,
sync: { type: 'boolean', default: true },
},
},
response: {
200: {
type: 'object',
properties: {
role_id: { type: 'string', format: 'uuid' },
module_id: { type: 'string' },
profile_key: { type: 'string' },
synced: { type: 'boolean' },
applied_hash: { type: 'string' },
},
},
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 { profile, sync } = req.body as { profile: { module_id: string; key: string }; sync?: boolean };
const synced = sync !== false;
const { rows: [roleRow] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [id],
);
if (!roleRow) return reply.code(404).send({ error: 'Not found' });
if (roleRow.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, roleRow.org_id, 'update', 'roles')) return reply;
const prof = await loadProfile(db, profile.module_id, profile.key);
if (!prof) return reply.code(404).send({ error: 'Not found' });
if (!await validateGrantable(db, reply, user.id, roleRow.org_id, prof.rules)) return reply;
const client = await db.connect();
try {
await client.query('BEGIN');
const nonSelf = await applyProfileRules(client, roleRow.org_id, id, profile.module_id, profile.key, prof.rules);
await client.query(
`INSERT INTO app.role_profile_links (role_id, module_id, profile_key, synced, applied_hash, applied_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (role_id)
DO UPDATE SET module_id = EXCLUDED.module_id, profile_key = EXCLUDED.profile_key,
synced = EXCLUDED.synced, applied_hash = EXCLUDED.applied_hash, applied_at = now()`,
[id, profile.module_id, profile.key, synced, prof.content_hash],
);
await client.query('COMMIT');
if (nonSelf) await db.query('SELECT morbac.invalidate_cache()');
return reply.send({ role_id: id, module_id: profile.module_id, profile_key: profile.key, synced, applied_hash: prof.content_hash });
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
});
app.delete('/roles/:id/profile-sync', {
schema: {
tags: ['Management'],
summary: 'Detach a role from its profile',
description:
"Removes the profile link so the role stops tracking the profile. Requires "
+ "`update`/`roles`. Already-applied rules are left in place.",
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'Link removed.' },
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: [roleRow] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [id],
);
if (!roleRow) return reply.code(404).send({ error: 'Not found' });
if (roleRow.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, roleRow.org_id, 'update', 'roles')) return reply;
await db.query('DELETE FROM app.role_profile_links WHERE role_id = $1', [id]);
return reply.code(204).send();
});
};
}