Ir al contenido

Roles y permisos

Estado: ✅ Confirmada — definidos por Aquazaku.

El sistema tiene exactamente cuatro roles. No hay más, y agregar uno nuevo es una decisión de negocio, no una comodidad de implementación.

Rol Quién es Dónde opera Qué hace
admin Dueño / administración Web (remoto, visita la planta cuando viaja) Configuración, auditoría, ajustes sensibles
seller Vendedor Web responsiva Contacta clientes y registra ventas a distancia
pos Planta + mostrador Web / terminal en la planta Venta de mostrador, preparación de pedidos, despacho, cierre de producción
contador Contador externo o interno Web, solo lectura Genera reportes PDF para temas impositivos y legales

La diferencia entre seller y pos no es de jerarquía: es de contexto de operación. Uno contacta clientes por canal remoto. El otro opera la planta y atiende mostrador.

El admin es el dueño, vive en otra ciudad y concentra los permisos sensibles del sistema (anular ventas, ajustar stock, cambiar precios). Para temas tributarios en Colombia — DIAN, retenciones, ICA, etc. — hace falta entregarle reportes a un contador externo que no tiene por qué poder modificar nada.

Un admin “de solo lectura” como flag sobre el mismo rol no alcanza: el contador necesita generar y descargar sus propios reportes sin heredar los permisos de modificación. Es un rol aparte, no un subconjunto.

Estado: ✅ Confirmada — cerrá la pregunta #13 de Qué falta preguntar.

Un mismo usuario puede tener N roles asignados a la vez. Esto cubre el caso futuro en que una persona ejerza varias funciones (ej. operador de planta que también venda en la calle).

usuario = {
id: uuid,
email: string,
roles: ["pos", "seller"], // cualquiera de los cuatro; asignable por admin
...
}

Todos los roles asignados están activos simultáneamente. NO existe switch-role ni active_role. Un usuario con ["pos", "seller"] ve y opera ambos módulos sin elegir uno. La decisión de diseño completa está en ADR-0003.

Todo se audita bajo el mismo user_id. La auditoría registra rol_ejercido como array (los roles bajo los que se ejecutó la acción específica), así que se puede responder “¿qué hizo este usuario como seller?” o “¿qué hizo como pos?” filtrando el log.

Que un usuario tenga permisos de pos y seller no significa que la app muestre los módulos según un rol activo. Regla de UI:

  • Web (desktop): muestra los módulos para los que el usuario tiene al menos un rol con permiso. Sin selector de rol, sin switch.
  • App móvil (post-MVP): la lógica de UI mobile se define aparte, pero la regla sigue siendo “permisos del backend, UI mobile decide qué mostrar”.

El backend no conoce el device — autoriza por capacidad del rol, sin mirar si la petición viene desde mobile o web. Ocultar un botón no es seguridad (RN-ACC-02).


Un permiso tiene dos ejes, no uno. La mayoría de los sistemas modela solo el primero y después parchea el segundo a mano por toda la aplicación.

Eje Pregunta Ejemplo
Acción ¿Qué puede hacer? ventas:ver
Alcance ¿Sobre qué datos? Solo las suyas

Un seller “puede ver ventas” — pero solo las suyas. Eso no es un flag booleano: es un filtro de datos. Si tratás el alcance como un if suelto en cada endpoint, tarde o temprano hay un endpoint donde te olvidaste, y un vendedor ve la facturación completa de la empresa.

Alcance Significa Filtra datos
todo Todos los registros del sistema sin filtro
propio Solo los registros que él mismo creó sí
ruta Solo los de la ruta que tiene abierta sí
BODEGA Solo lo que está físicamente en bodega sí
prep Solo los reportes de preparación no — categoría
operativos Solo los reportes operativos no — categoría
— Sin acceso —

Los cuatro primeros filtran filas de una tabla y se aplican con una cláusula WHERE. prep y operativos no: acotan qué categoría de reporte se puede ver o descargar, y eso lo resuelve el módulo de reportes (M13), no la capa de datos.

La distinción importa: si un alcance categórico se colara en una consulta de tabla, no habría columna por la cual filtrar. El código (scopes.ts) falla en ese caso en vez de devolver todo sin filtrar.


ventas:anular
│ └── acción
└───────── recurso

Minúsculas, sin acentos, recurso:accion. El mismo string se usa en el backend, en el frontend y en esta documentación. Una sola fuente de verdad.


Leyenda: ✅ alcance todo · 🟡 alcance limitado (se indica) · ❌ sin acceso

Permiso admin seller pos contador
ventas:ver ✅ 🟡 propio 🟡 propio 🟡 todo
ventas:crear ✅ ✅ ✅ ❌
ventas:anular ✅ todo 🟡 propio + status=pendiente 🟡 propio + status=pendiente ❌
ventas:anular_verificada ✅ todo (motivo obligatorio) ❌ ❌ ❌
ventas:corregir ✅ todo (motivo obligatorio) ❌ ❌ ❌
ventas:verificar_pago ✅ todo 🟡 propio 🟡 propio ❌
ventas:gestionar_cuentas_pendientes ✅ todo ❌ ❌ ❌
cobros:ver ✅ 🟡 propio 🟡 propio 🟡 todo
cobros:registrar ✅ ✅ ✅ ❌
Permiso admin seller pos contador
clientes:ver ✅ ✅ ✅ 🟡 todo
clientes:crear ✅ ✅ ✅ ❌
clientes:verificar_documento ✅ ✅ ✅ ❌
clientes:editar ✅ ❌ ❌ ❌
clientes:habilitar_credito ✅ ❌ ❌ ❌
Permiso admin seller pos contador
stock:ver ✅ 🟡 todo 🟡 todo 🟡 todo
stock:cargar_ruta ✅ todo ❌ ✅ todo ❌
stock:ajustar ✅ ❌ 🟡 cantidades (con motivo) ❌
stock:descartar ✅ ❌ ✅ ❌
insumos:ver ✅ ❌ ✅ 🟡 todo
insumos:ajustar ✅ ❌ 🟡 cantidades (con motivo) ❌
Permiso admin seller pos contador
botellones:ver ✅ ✅ ✅ 🟡 todo
botellones:entregar ✅ ❌ ✅ ❌
botellones:recibir_retorno ✅ ❌ ✅ ❌
botellones:registrar ✅ ❌ ✅ ❌
botellones:descartar ✅ ❌ ✅ (con motivo) ❌
Permiso admin seller pos contador
bases:ver ✅ ✅ ✅ 🟡 todo
bases:prestar ✅ todo ❌ ✅ con cliente verificado ❌
bases:retirar ✅ ❌ ✅ ❌
bases:registrar ✅ ❌ ✅ ❌
bases:descartar ✅ ❌ ✅ (con motivo) ❌
Permiso admin seller pos contador
produccion:ver ✅ ❌ ✅ 🟡 todo
produccion:registrar_cierre ✅ ❌ ✅ ❌
tanques:ver ✅ ❌ ✅ ❌
tanques:registrar_reposicion ✅ ❌ ✅ ❌
tanques:ajustar ✅ ❌ ❌ ❌
configuracion:equivalencias ✅ ❌ ✅ ❌
Permiso admin seller pos contador
proveedores:ver ✅ ❌ 🟡 todo 🟡 todo
proveedores:crear ✅ ❌ ❌ ❌
proveedores:editar ✅ ❌ ❌ ❌
compras:crear ✅ ❌ ✅ ❌
compras:recibir ✅ todo ❌ ✅ solo si compra=pendiente y proveedor=activo ❌
Permiso admin seller pos contador
rutas:ver ✅ 🟡 propio 🟡 todo 🟡 todo
rutas:abrir ✅ ✅ ❌ ❌
rutas:rendir ✅ 🟡 propio ❌ ❌
rutas:cerrar_con_faltante ✅ ❌ ❌ ❌
Permiso admin seller pos contador
productos:ver ✅ ✅ ✅ 🟡 todo
productos:crear ✅ ❌ ❌ ❌
productos:editar ✅ ❌ ❌ ❌
productos:editar_precios ✅ ❌ ❌ ❌
productos:desactivar ✅ ❌ ❌ ❌
usuarios:* ✅ ❌ ❌ ❌
auditoria:ver ✅ todo ❌ ❌ ✅ todo (read-only)
reportes:operativos ✅ ❌ 🟡 prep ✅
reportes:financieros ✅ ❌ ❌ ✅
reportes:descargar_pdf ✅ ❌ 🟡 operativos ✅
configuracion:* ✅ ❌ ❌ ❌

La consecuencia de tener admin con super-poderes

Sección titulada «La consecuencia de tener admin con super-poderes»

Con este modelo, admin concentra las funciones de control sensibles: ajusta stock, anula ventas, cambia precios, descarta botellones y bases, y administra usuarios. Ningún otro rol puede tocar nada de esto.

Eso es perfectamente razonable en una operación chica, sobre todo porque el dueño es el único admin y vive en otra ciudad (viaja a Tasajera cada cierto tiempo). Pero hay que decirlo en voz alta:


RN-ACC-01 — Un usuario puede tener varios roles a la vez

Sección titulada «RN-ACC-01 — Un usuario puede tener varios roles a la vez»

Estado: ✅ Confirmada — cerrá la pregunta #13 de Qué falta preguntar.

Un usuario puede tener N roles asignados a la vez, configurado por admin. Hoy el operario de planta tiene solo pos; una futura persona híbrida podría tener ["pos", "seller"].

Por qué: la operación futura de Aquazaku puede incluir personas que atiendan mostrador y salgan a ruta según el día. Prohibirlo en el modelo implica crear dos usuarios para la misma persona — feo y propenso a errores.


Estado: ✅ Confirmada (decisión técnica)

El permiso se valida siempre en el servidor. Que el frontend no muestre el botón es comodidad para el usuario, no seguridad.

Por qué: ocultar un botón no impide llamar al endpoint. Toda validación que viva únicamente en el cliente ya está rota, solo que todavía no lo sabés.


RN-ACC-03 — El alcance se aplica en la capa de datos

Sección titulada «RN-ACC-03 — El alcance se aplica en la capa de datos»

Estado: ✅ Confirmada (decisión técnica)

El filtro por alcance (propio, ruta, todo) se aplica en un único lugar del acceso a datos, no repetido endpoint por endpoint.

Por qué: repetir el filtro garantiza que algún día falte en uno. Y ese uno va a ser el de reportes.


RN-ACC-04 — Toda acción sensible queda auditada

Sección titulada «RN-ACC-04 — Toda acción sensible queda auditada»

Estado: ✅ Confirmada (sesión M0 del 19-ago-2026)

Anulaciones, ajustes de stock, bajas de botellones y bases, préstamo y retiro de bases, cambios de precio, habilitación de crédito y cierres con faltante registran quién, cuándo, bajo qué rol y por qué. La consulta de la auditoría es accesible desde la UI por admin (vista completa) y contador (read-only para temas impositivos/DIAN).

Por qué: con admin concentrando todo el poder de corrección, la auditoría es el único mecanismo de control que queda. Todas las reglas de inmutabilidad del dominio (RN-VEN-02, RN-RUT-04) dependen de que exista. La consulta UI cierra el ciclo: sin forma de ver la auditoría, no es control, es solo un log oculto.

Implementación: audit_log append-only (REVOKE + trigger en Postgres), escritura automática desde authz/middleware.ts (allow/deny) y desde la capa de servicio de cada módulo (acciones sensibles). La UI vive en /admin/auditoria y /contador/auditoria con filtros por usuario, módulo, acción y rango de fechas. Detalles en ADR-0001 y spec de M0.

Desde el 10-oct-2026, cada una de estas acciones escribe su fila dentro de la transacción que hace el cambio, como decidió ADR-0007: si la bitácora no se puede escribir, la acción no ocurre. Lo vigila un guardián estructural (audit/__tests__/auditoria-transaccional.test.ts) que falla cuando alguien escribe la próxima acción sensible del lado equivocado.


RN-ACC-05 — Un usuario no se borra, se desactiva

Sección titulada «RN-ACC-05 — Un usuario no se borra, se desactiva»

Estado: 🟡 Supuesto

Un usuario con historial se desactiva y pierde el acceso, pero sus registros mantienen la referencia a él.

Por qué: borrar al usuario deja huérfanas todas las ventas que registró.


RN-ACC-06 — Siempre tiene que quedar un administrador activo

Sección titulada «RN-ACC-06 — Siempre tiene que quedar un administrador activo»

Estado: ✅ Confirmada (implementación de M0, 20-ago-2026)

El sistema rechaza cualquier operación que dejaría cero administradores activos:

  • quitarle el rol admin al único que lo tiene,
  • desactivar al único administrador activo.

Un administrador desactivado no cuenta como respaldo: si contara, el sistema quedaría en manos de una cuenta que no puede entrar.

Por qué: hoy el dueño es el único admin (ver arriba). Sin esta regla, un click de más en la pantalla de usuarios —la que existe justamente para editar usuarios— lo deja sin el rol, y nadie queda con permiso para devolvérselo. El sistema pasa a ser inadministrable y solo se recupera metiendo mano directamente en la base de datos.

No es un escenario rebuscado. Es el error más fácil de cometer en esa pantalla.

Cómo se comporta: la API responde 409 con el código ULTIMO_ADMIN y un mensaje que dice qué hacer (“asignale el rol admin a otra persona antes”). El intento fallido queda auditado: alguien tratando de quitarse el rol admin es exactamente el tipo de cosa que hay que poder ver después.

Qué NO hace: no impide que un admin se quite el rol a sí mismo si hay otro administrador activo. Delegar y salir es una operación legítima.


RN-ACC-07 — Un cambio de roles hace efecto en el acto

Sección titulada «RN-ACC-07 — Un cambio de roles hace efecto en el acto»

Estado: ✅ Confirmada (implementación de M0, 20-ago-2026)

Cuando un admin le cambia los roles a alguien, el cambio rige desde el siguiente request, sin necesidad de que la persona vuelva a iniciar sesión. Y sin cerrarle la sesión: sigue trabajando, con los permisos nuevos.

Por qué: los roles se congelan dentro de la sesión al iniciar sesión (RN-ACC-01), que es lo que evita consultar la base en cada request. Sin actualizarlos al cambiarlos, quitarle el rol admin a alguien lo dejaría administrando siete días más, hasta que le venza la sesión.

Por qué no se cierra la sesión: cerrarla también resolvería el problema, pero echaría a la persona del sistema cada vez que un admin le toca un rol. Un trámite administrativo no debería interrumpirle el trabajo a nadie.

Excepción: desactivar a un usuario sí le cierra las sesiones. Ahí no hay trabajo que preservar.


RN-ACC-08 — Nadie puede ver la contraseña de nadie

Sección titulada «RN-ACC-08 — Nadie puede ver la contraseña de nadie»

Estado: ✅ Confirmada (26-ago-2026)

Ni el administrador. Ni el rootadmin. No es una restricción de permisos: el sistema no tiene la contraseña de nadie.

accounts.password guarda un hash argon2id, que es una función de una sola vía: sirve para verificar un intento, no para reconstruir el original. Mostrar «la contraseña registrada» exigiría dejar de hashear y guardarlas de forma recuperable.

Por qué no se haría aunque se pudiera, y es una razón de Aquazaku: si existe alguien que puede leer esas claves, el audit_log deja de probar nada. «pos@aquazaku.com anuló esta venta» pasaría a significar «alguien que sabía esa clave lo hizo», y esa lista incluye a quien puede leerlas. Cualquiera podría decir «yo no fui» y tendría razón.

El módulo de auditoría —la pieza más cara del sistema— quedaría decorativo.

Y hay algo práctico: la gente reusa contraseñas. Una filtración no expondría el acceso a Aquazaku, expondría el correo y el banco de ocho personas.

El problema que motivó el pedido es legítimo: que nadie dependa del correo para volver a entrar. Son ocho personas en Campo de la Cruz y no todas tienen correo a mano.

Se resuelve con restablecimiento en persona: el admin genera una temporal, la ve una sola vez en pantalla, se la dice de viva voz, y mustChangePassword obliga a cambiarla al entrar. El admin nunca conoce la contraseña final, así que la auditoría sigue valiendo.

La auditoría registra el hecho, no el valor: quién restableció, sobre quién y cuándo. Guardar la temporal en la bitácora la volvería permanente y legible por más gente que la que la necesita.

  • ¿pos vende contra el stock de bodega directamente, o el punto de venta tiene su propia ubicación de stock?
  • ¿Hay más de un punto de venta?