diff --git a/API.md b/API.md index 953fc60..9f3f6de 100644 --- a/API.md +++ b/API.md @@ -124,6 +124,9 @@ red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes }`. - `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` · `GET /api/devices/software/devices?name=` — software transversal. +- `GET /api/devices/access` — `{canView, canEdit, canManage}` del usuario (lo usa el menú). Lectura (`GET` de + dashboard/listado/detalle/software) también para usuarios con acceso compartido. +- `GET/POST /api/devices/viewers` · `DELETE /api/devices/viewers/:userId` — compartir Equipos (admin/superadmin). - `GET /api/devices/:id/raw` — último inventario crudo del agente. - `GET /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`. - `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 05d789e..ea89b42 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -45,6 +45,7 @@ | `devices` | inventario de equipos (menú "Equipos"), upsert por `deviceid` del agente GLPI; baja lógica con `status` | | `device_cpus` / `device_memories` / `device_disks` / `device_volumes` / `device_networks` / `device_monitors` / `device_software` | componentes de cada equipo, reemplazados completos en cada reporte | | `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) | +| `device_viewers` | usuarios con acceso de solo lectura compartido al menú Equipos (`user_id`, `granted_by`) | | `inventory_tokens` | tokens de ingesta del agente (solo hash sha256) | ## Relaciones clave diff --git a/CHANGELOG.md b/CHANGELOG.md index f54cc3c..5c9cd98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ - Tokens de ingesta recuperables: se guardan también cifrados (AES-256-GCM, migración `025`) y cada token de la lista tiene un botón para descargar el instalador sin volver a pegarlo (queda en auditoría; los tokens creados antes de esta versión no son recuperables). +- Equipos → Compartir (solo admin/superadmin): da acceso de **solo lectura** (Resumen, Equipos y + Software) a usuarios que por su rol no ven el menú. Migración `026` (`device_viewers`). Sin + editar, sin Agentes, sin inventario crudo. El menú Equipos aparece solo a quien tenga acceso. - Dependencia backend: `fast-xml-parser`. Diseño traído de linkedtrack (que sigue aparte) en `docs/ARQUITECTURA-INVENTARIO.md`. diff --git a/SPEC.md b/SPEC.md index 667e71f..6056776 100644 --- a/SPEC.md +++ b/SPEC.md @@ -83,7 +83,7 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa servidor GLPI). Diseño en `docs/ARQUITECTURA-INVENTARIO.md`. - Pestañas: Equipos (KPIs + listado + detalle General/Hardware/Red/Software/Historial), Software (transversal: en cuántos equipos está instalado) y Agentes (tokens de ingesta, - solo `superadmin`/`admin`). + solo `superadmin`/`admin`) y Compartir (da acceso de solo lectura a otros usuarios, `superadmin`/`admin`). - Edición manual solo de: estado (activo/de baja), usuario asignado, departamento y notas; el resto lo manda el agente. Historial de cambios entre reportes. - Fuera de este módulo (fase 2): tickets/helpdesk, despliegue remoto de software, panel de diff --git a/backend/database/migrations/026_device_viewers.sql b/backend/database/migrations/026_device_viewers.sql new file mode 100644 index 0000000..e1ce113 --- /dev/null +++ b/backend/database/migrations/026_device_viewers.sql @@ -0,0 +1,9 @@ +-- Acceso de solo lectura al menu Equipos para usuarios que por su rol no lo ven (compartir el modulo). +-- Lo gestionan admin/superadmin. Idempotente. +CREATE TABLE IF NOT EXISTS device_viewers ( + user_id INT NOT NULL PRIMARY KEY, + granted_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_device_viewers_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_device_viewers_granter FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/src/modules/devices/controller.js b/backend/src/modules/devices/controller.js index 24e3705..4149b83 100644 --- a/backend/src/modules/devices/controller.js +++ b/backend/src/modules/devices/controller.js @@ -18,3 +18,8 @@ export const postToken = asyncHandler(async (req, res) => ok(res, await service. export const getTokenSecret = asyncHandler(async (req, res) => ok(res, await service.revealToken(Number(req.params.id), req.currentUser))); export const patchToken = asyncHandler(async (req, res) => ok(res, await service.setTokenActive(Number(req.params.id), !!req.body.isActive, req.currentUser))); export const deleteToken = asyncHandler(async (req, res) => ok(res, await service.removeToken(Number(req.params.id), req.currentUser))); + +export const getAccess = asyncHandler(async (req, res) => ok(res, await service.getAccess(req.currentUser))); +export const getViewers = asyncHandler(async (req, res) => ok(res, await service.listViewers())); +export const postViewer = asyncHandler(async (req, res) => ok(res, await service.addViewer(Number(req.body.userId), req.currentUser), 201)); +export const deleteViewer = asyncHandler(async (req, res) => ok(res, await service.removeViewer(Number(req.params.userId), req.currentUser))); diff --git a/backend/src/modules/devices/repository.js b/backend/src/modules/devices/repository.js index c10df8d..422dec8 100644 --- a/backend/src/modules/devices/repository.js +++ b/backend/src/modules/devices/repository.js @@ -46,6 +46,30 @@ export async function findTokenSecret(id) { export const setTokenActive = (id, active) => pool.query('UPDATE inventory_tokens SET is_active = ? WHERE id = ?', [active ? 1 : 0, id]); export const deleteToken = (id) => pool.query('DELETE FROM inventory_tokens WHERE id = ?', [id]); +// ---------- Acceso compartido (solo lectura) ---------- + +export async function isViewer(userId) { + const [rows] = await pool.query('SELECT 1 FROM device_viewers WHERE user_id = ?', [userId]); + return rows.length > 0; +} + +export async function listViewers() { + const [rows] = await pool.query( + `SELECT v.user_id, u.name, u.email, r.name AS role_name, v.created_at, g.name AS granted_by_name + FROM device_viewers v JOIN users u ON u.id = v.user_id JOIN roles r ON r.id = u.role_id + LEFT JOIN users g ON g.id = v.granted_by ORDER BY u.name` + ); + return rows; +} + +export const addViewer = (userId, grantedBy) => pool.query('INSERT IGNORE INTO device_viewers (user_id, granted_by) VALUES (?, ?)', [userId, grantedBy]); +export const removeViewer = (userId) => pool.query('DELETE FROM device_viewers WHERE user_id = ?', [userId]); + +export async function findUserBrief(userId) { + const [[row]] = await pool.query('SELECT u.id, u.name, u.is_active, r.code AS role_code FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?', [userId]); + return row || null; +} + // ---------- Ingesta ---------- // Foto de lo que hay hoy en BD (para calcular el diff contra el reporte nuevo). diff --git a/backend/src/modules/devices/routes.js b/backend/src/modules/devices/routes.js index 6d954ff..6d01437 100644 --- a/backend/src/modules/devices/routes.js +++ b/backend/src/modules/devices/routes.js @@ -1,28 +1,43 @@ import { Router } from 'express'; import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js'; +import { fail } from '../../utils/http.js'; +import { getAccess as getAccessFor } from './service.js'; import { getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw, - getTokens, postToken, patchToken, deleteToken, getTokenSecret, + getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer, } from './controller.js'; const router = Router(); -// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente, solo admin/superadmin. -const CAN_VIEW = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER]; -const CAN_MANAGE_TOKENS = [ROLES.SUPERADMIN, ROLES.ADMIN]; +// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo +// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers). +const CAN_EDIT = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER]; +const CAN_MANAGE = [ROLES.SUPERADMIN, ROLES.ADMIN]; -router.use(requireAuth, requireRole(...CAN_VIEW)); -router.get('/dashboard', getDashboard); -router.get('/software', getSoftware); -router.get('/software/devices', getSoftwareDevices); -router.get('/tokens', requireRole(...CAN_MANAGE_TOKENS), getTokens); -router.post('/tokens', requireRole(...CAN_MANAGE_TOKENS), postToken); -router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE_TOKENS), getTokenSecret); -router.patch('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), patchToken); -router.delete('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), deleteToken); -router.get('/', getDevices); -router.get('/:id', getDevice); -router.get('/:id/raw', getRaw); -router.put('/:id', putDevice); +async function canView(req, res, next) { + try { + const access = await getAccessFor(req.currentUser); + if (!access.canView) return fail(res, 403, 'No tienes permisos para esta accion'); + next(); + } catch (err) { next(err); } +} + +router.use(requireAuth); +router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para decidir si se muestra +router.get('/dashboard', canView, getDashboard); +router.get('/software', canView, getSoftware); +router.get('/software/devices', canView, getSoftwareDevices); +router.get('/viewers', requireRole(...CAN_MANAGE), getViewers); +router.post('/viewers', requireRole(...CAN_MANAGE), postViewer); +router.delete('/viewers/:userId', requireRole(...CAN_MANAGE), deleteViewer); +router.get('/tokens', requireRole(...CAN_MANAGE), getTokens); +router.post('/tokens', requireRole(...CAN_MANAGE), postToken); +router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE), getTokenSecret); +router.patch('/tokens/:id', requireRole(...CAN_MANAGE), patchToken); +router.delete('/tokens/:id', requireRole(...CAN_MANAGE), deleteToken); +router.get('/', canView, getDevices); +router.get('/:id', canView, getDevice); +router.get('/:id/raw', requireRole(...CAN_EDIT), getRaw); +router.put('/:id', requireRole(...CAN_EDIT), putDevice); export default router; diff --git a/backend/src/modules/devices/service.js b/backend/src/modules/devices/service.js index 329ad70..e85212a 100644 --- a/backend/src/modules/devices/service.js +++ b/backend/src/modules/devices/service.js @@ -51,6 +51,33 @@ export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() || export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name); export const getDashboard = () => repo.dashboard(STALE_DAYS); +const FULL_ACCESS_ROLES = ['superadmin', 'admin', 'it_manager']; +const MANAGE_ROLES = ['superadmin', 'admin']; + +// Que puede hacer este usuario en Equipos: ver (rol de TI o compartido), editar, gestionar (agentes/compartir). +export async function getAccess(user) { + const full = FULL_ACCESS_ROLES.includes(user.roleCode); + const canView = full || (await repo.isViewer(user.id)); + return { canView, canEdit: full, canManage: MANAGE_ROLES.includes(user.roleCode) }; +} + +export const listViewers = () => repo.listViewers(); + +export async function addViewer(userId, actor) { + const target = await repo.findUserBrief(userId); + if (!target || !target.is_active) throw new HttpError(404, 'Usuario no encontrado'); + if (FULL_ACCESS_ROLES.includes(target.role_code)) throw new HttpError(400, 'Ese usuario ya tiene acceso completo por su rol'); + await repo.addViewer(userId, actor.id); + await logAudit({ userId: actor.id, action: 'share', entityType: 'devices', entityId: userId, detail: { name: target.name } }); + return repo.listViewers(); +} + +export async function removeViewer(userId, actor) { + await repo.removeViewer(userId); + await logAudit({ userId: actor.id, action: 'unshare', entityType: 'devices', entityId: userId }); + return repo.listViewers(); +} + export const listTokens = () => repo.listTokens(); // El token en claro se devuelve UNA sola vez (aqui); en BD queda solo el hash. diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index 4ac42b6..df0f86d 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -1,9 +1,11 @@ import { NavLink } from 'react-router-dom'; import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { useUi } from '../context/ui-context.jsx'; import { useAuth } from '../context/auth-context.jsx'; import { useBranding } from '../context/branding-context.jsx'; import logoIcon from '../assets/logoLinkedTrackIcon.png'; +import { getDevicesAccess } from '../services/devices.js'; import pkg from '../../package.json'; const ITEMS = [ @@ -12,7 +14,7 @@ const ITEMS = [ { to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' }, { to: '/notas', icon: StickyNote, labelKey: 'nav_notes' }, { to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' }, - { to: '/equipos', icon: Monitor, labelKey: 'nav_devices', roles: ['superadmin', 'admin', 'it_manager'] }, + { to: '/equipos', icon: Monitor, labelKey: 'nav_devices', needsDevicesAccess: true }, { to: '/organizacion', icon: Building2, labelKey: 'nav_organization' }, { to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true }, ]; @@ -24,7 +26,13 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) { // En celular el drawer siempre se ve completo (con etiquetas), sin importar el modo // compacto/expandido del riel de escritorio -- son dos cosas independientes. const compact = mode === 'compact' && !mobileOpen; - const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.roles || item.roles.includes(user?.roleCode))); + // Equipos: lo ven roles de TI o quien tenga acceso compartido (lo decide el backend). + const [canSeeDevices, setCanSeeDevices] = useState(false); + useEffect(() => { + if (!user) return; + getDevicesAccess().then((a) => setCanSeeDevices(!!a?.canView)).catch(() => setCanSeeDevices(false)); + }, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps + const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.needsDevicesAccess || canSeeDevices)); const brandName = branding.name || t('appName'); return (