const { all, audit, json, now, one, parseJson, run } = require('../db'); const { CAPABILITY_KEYS, WILDCARD, capabilitySetIncludes, sanitizeCapabilities } = require('./accessControl'); function slugify(value) { return String(value || '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 40); } function fromRow(row) { if (!row) return null; return { key: row.key, name: row.name, description: row.description || '', capabilities: parseJson(row.capabilities_json, []), is_system: Boolean(row.is_system), locked: Boolean(row.locked), user_count: one('SELECT COUNT(*) AS c FROM users WHERE role = ?', [row.key]).c }; } function listRoles() { return all('SELECT * FROM roles ORDER BY sort_order, name').map(fromRow); } function getRole(key) { return fromRow(one('SELECT * FROM roles WHERE key = ?', [key])); } function roleExists(key) { return Boolean(one('SELECT key FROM roles WHERE key = ?', [key])); } // Capabilities granted to a role key (empty when the role is unknown). function capabilitiesForRole(key) { const row = one('SELECT capabilities_json FROM roles WHERE key = ?', [key]); return row ? parseJson(row.capabilities_json, []) : []; } function roleHasCapability(key, capability) { return capabilitySetIncludes(capabilitiesForRole(key), capability); } function createRole(body, userId) { const name = String(body.name || '').trim(); if (!name) throw Object.assign(new Error('A role name is required'), { status: 400 }); const key = slugify(body.key || name); if (!key) throw Object.assign(new Error('A valid role key is required'), { status: 400 }); if (roleExists(key)) throw Object.assign(new Error('A role with that key already exists'), { status: 400 }); const capabilities = sanitizeCapabilities(body.capabilities); const ts = now(); const sort = (one('SELECT MAX(sort_order) AS m FROM roles').m || 0) + 1; run( 'INSERT INTO roles (key, name, description, capabilities_json, is_system, locked, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?)', [key, name, body.description || null, json(capabilities), sort, ts, ts] ); audit(userId, 'create', 'role', key, null, { name, capabilities }); return getRole(key); } function updateRole(key, body, userId) { const before = one('SELECT * FROM roles WHERE key = ?', [key]); if (!before) return null; const name = body.name !== undefined ? String(body.name).trim() || before.name : before.name; // Locked roles (admin) always keep the wildcard capability set. const capabilities = before.locked ? parseJson(before.capabilities_json, [WILDCARD]) : (body.capabilities !== undefined ? sanitizeCapabilities(body.capabilities) : parseJson(before.capabilities_json, [])); run( 'UPDATE roles SET name = ?, description = ?, capabilities_json = ?, updated_at = ? WHERE key = ?', [name, body.description ?? before.description, json(capabilities), now(), key] ); audit(userId, 'update', 'role', key, fromRow(before), { name, capabilities }); return getRole(key); } function deleteRole(key, userId) { const before = one('SELECT * FROM roles WHERE key = ?', [key]); if (!before) return { ok: false, status: 404, error: 'Role not found' }; if (before.is_system) return { ok: false, status: 400, error: 'Built-in roles cannot be deleted' }; const inUse = one('SELECT COUNT(*) AS c FROM users WHERE role = ?', [key]).c; if (inUse) return { ok: false, status: 400, error: `Reassign the ${inUse} user(s) with this role before deleting it` }; run('DELETE FROM roles WHERE key = ?', [key]); audit(userId, 'delete', 'role', key, fromRow(before), null); return { ok: true }; } module.exports = { CAPABILITY_KEYS, capabilitiesForRole, createRole, deleteRole, getRole, listRoles, roleExists, roleHasCapability, updateRole };