fix: version 2.5.0 -- compartir proyectos con permisos reales (Lectura/Escritura)

Bug de seguridad real corregido: cualquier usuario autenticado veia
TODOS los proyectos (listado, detalle, actividades, tareas,
comentarios, adjuntos, KPIs del panel), sin importar si era miembro.
Ahora nadie ve un proyecto ajeno sin que su dueno se lo comparta --
los roles de administracion (superadmin/admin/it_manager) mantienen
visibilidad total para soporte.

Nuevo modelo de permisos por proyecto, simple y no granular:
- Solo el dueno original puede compartir un proyecto (agregar/quitar
  miembros, cambiar su nivel) -- ni un admin del proyecto ni un rol
  global pueden hacerlo por el.
- Dos niveles: Lectura (solo ver) y Escritura (crear/editar/eliminar
  actividades y tareas -- nunca el proyecto en si: no lo borra, no
  edita sus datos, no crea proyectos nuevos).

Actividades y tareas pasan a borrado logico (antes se borraban
fisico sin poder recuperar) -- nueva papelera por proyecto con
restaurar.

Incluye tambien el filtro "Usuario" en Proyectos (probado en dev en
un paso anterior), que va de la mano con este cambio de visibilidad.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-25 18:19:41 -05:00
parent 9b20f85ae0
commit fda83e9dff
30 changed files with 469 additions and 140 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ export const getProjects = asyncHandler(async (req, res) => {
});
export const getProject = asyncHandler(async (req, res) => {
ok(res, await service.getProject(Number(req.params.id)));
ok(res, await service.getProject(Number(req.params.id), req.currentUser));
});
export const postProject = asyncHandler(async (req, res) => {
@@ -45,7 +45,7 @@ export const postMember = asyncHandler(async (req, res) => {
});
export const putMemberRole = asyncHandler(async (req, res) => {
const members = await service.setMemberAdmin(Number(req.params.id), Number(req.params.userId), !!req.body.admin, req.currentUser);
const members = await service.setMemberRole(Number(req.params.id), Number(req.params.userId), req.body.role, req.currentUser);
ok(res, members);
});
+7 -1
View File
@@ -18,12 +18,18 @@ const LIST_SELECT = `
LEFT JOIN departments sd ON sd.id = p.responsible_department_id
`;
export async function findAll({ statusId, priorityId, ownerUserId, trash } = {}) {
export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash } = {}) {
const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'];
const params = [];
if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); }
if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); }
if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); }
// Restringe a proyectos donde el usuario es miembro (compartidos con el, o el propio dueño ya
// que quien crea un proyecto queda como miembro 'owner' automaticamente -- ver create()).
if (memberUserId) {
clauses.push('EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)');
params.push(memberUserId);
}
const where = `WHERE ${clauses.join(' AND ')}`;
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params);
return rows;
+2 -1
View File
@@ -5,7 +5,7 @@ import {
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
} from './controller.js';
import { getGantt } from '../dashboard/controller.js';
import { getActivitiesByProject, postActivity, putReorderActivities } from '../activities/controller.js';
import { getActivitiesByProject, getActivitiesTrash, postActivity, putReorderActivities } from '../activities/controller.js';
const router = Router();
@@ -22,6 +22,7 @@ router.post('/:id/members', postMember);
router.put('/:id/members/:userId/role', putMemberRole);
router.delete('/:id/members/:userId', deleteMember);
router.get('/:projectId/activities', getActivitiesByProject);
router.get('/:projectId/activities/trash', getActivitiesTrash);
router.post('/:projectId/activities', postActivity);
router.put('/:projectId/activities/reorder', putReorderActivities);
+80 -21
View File
@@ -1,35 +1,83 @@
import * as repo from './repository.js';
import * as activitiesRepo from '../activities/repository.js';
import * as usersService from '../users/service.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js';
// Alcance de escritura: admin/superadmin/it_manager siempre; un proyecto puede tener uno o mas
// "administradores del proyecto" (role_in_project = 'owner'/'admin' en project_members) con acceso
// total sin importar su rol global — un collaborator puede ser admin de UN proyecto puntual;
// ademas, por compatibilidad, cualquier project_lead que sea miembro (con o sin ese tag) tambien
// puede escribir, igual que antes de introducir el rol de administrador por proyecto.
export async function canWriteProject(user, project) {
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true;
const FULL_ACCESS_ROLES = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
// Nadie ve ni toca el proyecto de otro dueño sin que se lo compartan -- salvo los roles
// globales de administracion, que siguen viendo todo para poder dar soporte (acordado con el
// usuario). "Compartido" = tiene una fila en project_members, sin importar el nivel.
export async function canReadProject(user, project) {
if (FULL_ACCESS_ROLES.includes(user.roleCode)) return true;
const membership = await repo.getMembership(project.id, user.id);
if (membership === 'owner' || membership === 'admin') return true;
return membership !== null;
}
// Niveles simples, no granulares (acordado): Lectura (role_in_project NULL) o Escritura
// (role_in_project = 'write') -- crear/editar/eliminar(logico) actividades y tareas, nunca el
// proyecto en si. 'owner' es quien lo creo. 'admin' es un valor legado (de cuando existia un
// tercer nivel "administrador de proyecto") que se sigue respetando como escritura en filas
// viejas, pero ya no se asigna desde la UI.
export async function canWriteProject(user, project) {
if (FULL_ACCESS_ROLES.includes(user.roleCode)) return true;
const membership = await repo.getMembership(project.id, user.id);
if (['owner', 'admin', 'write'].includes(membership)) return true;
if (user.roleCode === ROLES.PROJECT_LEAD && membership !== null) return true;
return false;
}
export function isProjectOwner(user, project) {
return project.owner_user_id === user.id;
}
// Compartir (agregar/quitar miembros, cambiar su nivel) es exclusivo del dueño original -- ni
// un admin del proyecto ni un rol global de administracion pueden compartir el proyecto de otra
// persona sin que el dueño lo autorice (acordado con el usuario).
export function canManageMembers(user, project) {
return isProjectOwner(user, project);
}
// Borrar el PROYECTO (no su contenido) es exclusivo del dueño o de un rol global de
// administracion -- un colaborador con permiso de Escritura puede borrar actividades/tareas
// dentro del proyecto, pero nunca el proyecto en si (acordado con el usuario).
export function canDeleteProject(user, project) {
return FULL_ACCESS_ROLES.includes(user.roleCode) || isProjectOwner(user, project);
}
// Solo el superadmin puede ver la papelera (proyectos eliminados logicamente).
export async function listProjects(filters, currentUser) {
if (filters.trash && currentUser?.roleCode !== ROLES.SUPERADMIN) {
throw new HttpError(403, 'Solo el superadministrador puede ver la papelera');
}
return repo.findAll(filters);
if (filters.trash || FULL_ACCESS_ROLES.includes(currentUser?.roleCode)) {
return repo.findAll(filters);
}
// El resto de roles solo ve proyectos donde es miembro (compartidos con el, o propios).
return repo.findAll({ ...filters, memberUserId: currentUser.id });
}
export async function getProject(id) {
// Usado por comments/attachments, que cuelgan de 'project' o 'activity' (entity_type/entity_id
// generico, ver BASE_DE_DATOS.md) -- resuelve a que proyecto pertenece la entidad para poder
// aplicar el mismo chequeo de lectura/escritura por membresia en los dos modulos.
export async function resolveProjectForEntity(entityType, entityId) {
if (entityType === 'project') return repo.findById(entityId);
if (entityType === 'activity') {
const activity = await activitiesRepo.findById(entityId, { includeDeleted: true });
if (!activity) return null;
return repo.findById(activity.project_id);
}
return null;
}
export async function getProject(id, currentUser) {
const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canReadProject(currentUser, project))) throw new HttpError(404, 'Proyecto no encontrado');
const members = await repo.listMembers(id);
return { ...project, members };
return { ...project, members, isOwner: isProjectOwner(currentUser, project) };
}
export async function createProject(payload, currentUser) {
@@ -48,10 +96,13 @@ export async function createProject(payload, currentUser) {
return project;
}
// Editar los datos del PROYECTO en si (nombre, fechas, prioridad, etc.) es exclusivo del dueño o
// de un rol global de administracion -- un colaborador compartido con Escritura solo puede
// crear/editar actividades y tareas, nunca el proyecto (acordado con el usuario).
export async function updateProject(id, payload, currentUser) {
const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!canDeleteProject(currentUser, project)) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!payload.name || !payload.priorityId || !payload.statusId) {
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
}
@@ -60,12 +111,12 @@ export async function updateProject(id, payload, currentUser) {
return updated;
}
// Eliminacion logica: cualquiera que pueda editar el proyecto puede "eliminarlo" (deja de verse
// en el listado, pero sigue en la BD). Solo el superadmin puede restaurarlo o borrarlo definitivo.
// Eliminacion logica: solo el dueño o un rol global de administracion. Solo el superadmin puede
// restaurarlo o borrarlo definitivo (sin cambios).
export async function deleteProject(id, currentUser) {
const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!canDeleteProject(currentUser, project)) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
await repo.softDelete(id, currentUser.id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'project', entityId: id });
}
@@ -99,10 +150,17 @@ export async function recalcProjectProgress(projectId) {
// crea una cuenta 'invited' y le manda un correo para que complete su propio registro (nombre y
// clave) — la fila de project_members ya queda creada de una vez, asi que en cuanto acepte la
// invitacion ya aparece como miembro sin pasos adicionales.
// role_in_project que llega del cliente: solo 'write' o null/undefined (Lectura) son validos
// para asignar de ahora en mas -- 'owner'/'admin' son valores que el propio backend controla.
function normalizeRoleInProject(role) {
return role === 'write' ? 'write' : null;
}
export async function addMember(projectId, { userId, email }, roleInProject, currentUser) {
const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!canManageMembers(currentUser, project)) throw new HttpError(403, 'Solo el dueño del proyecto puede compartirlo');
roleInProject = normalizeRoleInProject(roleInProject);
let targetUserId = userId;
let invited = false;
@@ -121,13 +179,14 @@ export async function addMember(projectId, { userId, email }, roleInProject, cur
return { members, invited };
}
export async function setMemberAdmin(projectId, userId, isAdmin, currentUser) {
export async function setMemberRole(projectId, userId, roleInProject, currentUser) {
const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
const members = await repo.updateMemberRole(projectId, userId, isAdmin ? 'admin' : null);
if (!canManageMembers(currentUser, project)) throw new HttpError(403, 'Solo el dueño del proyecto puede cambiar permisos');
roleInProject = normalizeRoleInProject(roleInProject);
const members = await repo.updateMemberRole(projectId, userId, roleInProject);
await logAudit({
userId: currentUser.id, action: isAdmin ? 'grant_project_admin' : 'revoke_project_admin',
userId: currentUser.id, action: roleInProject === 'write' ? 'grant_project_write' : 'revoke_project_write',
entityType: 'project', entityId: projectId, detail: { userId },
});
return members;
@@ -136,7 +195,7 @@ export async function setMemberAdmin(projectId, userId, isAdmin, currentUser) {
export async function removeMember(projectId, userId, currentUser) {
const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!canManageMembers(currentUser, project)) throw new HttpError(403, 'Solo el dueño del proyecto puede quitar miembros');
const members = await repo.removeMember(projectId, userId);
await logAudit({ userId: currentUser.id, action: 'remove_member', entityType: 'project', entityId: projectId, detail: { userId } });
return members;