Auth y RBAC
SipSop usa Better Auth con el plugin de organizaciones para autenticación y multi-tenancy. Esta página documenta el modelo de datos, los cuatro roles principales, sub-roles MSP, permisos, y cómo crear o gestionar usuarios.
Visión general de la arquitectura
Organización de Better Auth (1 org = 1 tenant)
└── filas member (userId + organizationId + role)
└── filas invitation (invitaciones org pendientes)
user.role (rol global — almacenado en la fila del usuario)
→ 'admin_msp' — Equipo interno de Sopinf Tech LLC
→ 'client_admin' — Dueño del tenant / contacto de billing
→ 'client_user' — Empleado del tenant
→ 'seller' — Sales rep externo (solo portal de vendedores)
user.msp_roles (array — solo relevante cuando user.role = 'admin_msp')
→ ['super_admin', 'billing_clerk', 'technician', 'sales_rep']Roles principales
admin_msp
Equipo interno de Sopinf Tech LLC. Puede acceder a la sección /admin/* del portal. Requerido para llegar a cualquier endpoint adminProcedure en la API.
- Seteado por: manualmente en la DB (
UPDATE "user" SET role = 'admin_msp' WHERE email = 'x') o via el flujo de invitación del equipo MSP (tablamsp_invitations). - No está scoped a una org — los usuarios
admin_msppueden ver todos los tenants cross-org. - Los sub-roles (
msp_rolesarray) restringen adicionalmente qué acciones admin pueden realizar.
client_admin
El contacto principal/dueño de un tenant. Acceso completo al portal de su tenant: CDR, billing, gestión de equipo, settings. Puede invitar miembros client_user via el flujo de invitación org de Better Auth.
- Creado durante el onboarding del tenant.
member.role = 'owner'o'admin'en la tablamemberde Better Auth.
client_user
Un empleado del tenant. Acceso de solo lectura a dashboard y CDR. No puede gestionar billing ni equipo.
- Invitado por
client_adminvia la página de equipo del portal. member.role = 'member'en la tablamemberde Better Auth.
seller
Sales rep externo con acceso solo al portal de vendedores (/seller/* routes). Sin acceso al portal principal ni a la sección admin.
- Otorgado via invitación por magic-link (ver Invitaciones de vendedores más abajo).
- Tiene una fila correspondiente en
commission_repsvinculada viacommission_reps.user_id. - Auth verificado por
sellerProcedure: requiereuser.role = 'seller'Y una filacommission_repsactiva.
Sub-roles MSP (solo admin_msp)
Los sub-roles MSP se almacenan como array de texto en user.msp_roles. Un super_admin puede tener todos los roles. Los sub-roles se almacenan en msp_role_definitions y se mapean a permisos via msp_role_permissions.
| Sub-rol MSP | Permisos típicos |
|---|---|
super_admin | Todos los permisos. Puede cambiar carrier mode, gestionar todas las funciones admin. |
billing_clerk | CRUD en fees, compliance dashboard, reportes trimestrales, invoice line items. |
sales_rep | Leer tenants, leer leads, gestionar comisiones. |
technician | Leer tenants, gestionar PBX/inventario (Fase 11). |
Los sub-roles se verifican en trpc.ts via makeMspRoleProcedure(allowed):
export const billingClerkProcedure = makeMspRoleProcedure(['super_admin', 'billing_clerk'])
export const salesProcedure = makeMspRoleProcedure(['super_admin', 'sales_rep'])
export const technicianProcedure = makeMspRoleProcedure(['super_admin', 'technician'])
export const superAdminProcedure = adminProcedure.use(async ({ ctx, next }) => {
if (!ctx.mspRoles.includes('super_admin')) throw new TRPCError(...)
...
})Sistema de permisos
Los permisos se cargan en createContext como un Set<string> (ctx.mspPermissions). Se calculan como la unión de todos los permisos otorgados a los sub-roles MSP del usuario.
Permisos conocidos (desde la config de navegación):
| Permission key | Usado por |
|---|---|
tenants.read | Ver lista y detalle de tenants |
leads.read | Ver leads |
tenant_requests.read | Ver y gestionar tenant requests |
impersonation.read_log | Ver log de impersonation |
team.read | Ver y gestionar equipo MSP |
roles.read | Ver y gestionar roles MSP |
commissions.manage | Acceso completo al admin de comisiones |
Los permisos se verifican en la API via requirePermission(permissionKey):
export function requirePermission(permissionKey: string) {
return adminProcedure.use(async ({ ctx, next }) => {
if (!ctx.mspPermissions.has(permissionKey)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'MISSING_PERMISSION' })
}
return next({ ctx })
})
}Tipos de procedure tRPC
| Procedure | Requisito de auth | Tenant gate |
|---|---|---|
publicProcedure | Ninguno | Ninguno |
protectedProcedure | Sesión válida | Sí — bloquea suspended/cancelled |
billingProcedure | Sesión válida | No — permite tenants suspended para pagar |
adminProcedure | Sesión + role = 'admin_msp' | No (admin es cross-tenant) |
superAdminProcedure | adminProcedure + 'super_admin' en msp_roles | No |
billingClerkProcedure | adminProcedure + billing_clerk o super_admin | No |
salesProcedure | adminProcedure + sales_rep o super_admin | No |
technicianProcedure | adminProcedure + technician o super_admin | No |
sellerProcedure | protectedProcedure + role=seller + commission_rep activo | Específico de seller |
tenantAdminProcedure | protectedProcedure + activeMembershipRole=client_admin | Sí |
impersonationAwareProcedure | protectedProcedure + NO en sesión de impersonation | Sí |
Tablas de base de datos
user (gestionado por Better Auth)
id — text PK (Better Auth lo genera)
name — nombre de display
email — único
email_verified — boolean
role — 'admin_msp' | 'client_admin' | 'client_user' | 'seller'
msp_roles — text[] (sub-roles para usuarios admin_msp)
is_active — boolean (soft delete)
deactivated_at — timestamp (cuándo fue desactivado)organization (gestionado por Better Auth)
id — text PK
name — nombre de display del tenant
slug — slug único URL-safemember (gestionado por Better Auth)
organization_id — FK → organization
user_id — FK → user
role — 'owner' | 'admin' | 'member' (roles de Better Auth dentro del org)msp_role_definitions
key — PK: 'super_admin' | 'billing_clerk' | 'sales_rep' | 'technician'
label — label de display
description — descripción
color — color del badge en UI
is_system — boolean (los roles de sistema no pueden borrarse)msp_permissions
key — PK: 'tenants.read', 'commissions.manage', etc.
category — agrupa permisos en el editor de roles en la UI
label — label de displaymsp_role_permissions
role_key — FK → msp_role_definitions.key
permission_key — FK → msp_permissions.key(PK compuesta en ambas columnas)
Tenant gate
checkTenantGate(tenantStatus) en apps/api/src/middleware/tenant-gate.ts:
| Estado del tenant | Efecto |
|---|---|
active | Acceso completo |
past_due | Acceso concedido, isPastDue = true. El portal muestra un banner rojo. CDR y billing son accesibles. |
suspended | TenantGateError — protectedProcedure lanza FORBIDDEN. Solo funciona billingProcedure (para pagar). El portal muestra un bloqueo de pantalla completa. |
cancelled | Igual que suspended |
Crear un nuevo usuario admin_msp
- Registrar al usuario a través del login del portal (email + contraseña).
- Conectarse a la base de datos y ejecutar:
UPDATE "user"
SET role = 'admin_msp',
msp_roles = ARRAY['billing_clerk']::text[]
WHERE email = 'nuevo@sopinf.com';O dar acceso completo de super_admin:
UPDATE "user"
SET role = 'admin_msp',
msp_roles = ARRAY['super_admin']::text[]
WHERE email = 'nuevo@sopinf.com';- El usuario puede ver ahora la sección admin en el portal.
Alternativamente, usar el flujo /admin/team → Invite del portal, que crea una fila en msp_invitations y envía un email. El invitado hace click en el link y completa el registro.
Crear un client_admin para un nuevo tenant
- Crear el tenant en
/admin/tenants/new. - El nuevo tenant crea una
organizationde Better Auth. Anotar elorganization.id. - Registrar el usuario cliente a través del portal.
- Setear su rol:
UPDATE "user"
SET role = 'client_admin'
WHERE email = 'owner@clienteempresa.com';- Crear la membresía org:
-- Tabla member de Better Auth
INSERT INTO member (id, organization_id, user_id, role, created_at)
VALUES (gen_random_uuid()::text, '<org_id>', '<user_id>', 'owner', NOW());O usar la API de Better Auth (POST a /api/auth/organization/add-member) — este es el enfoque preferido para mantenerse dentro de la gestión de sesiones de Better Auth.
Invitaciones de vendedores
Los vendedores (commission reps) acceden al portal via invitación por magic-link. El flujo:
- Un usuario
admin_mspconcommissions.managecrea un commission rep en/admin/commissions/vendedores. - Desde la vista de detalle del rep, click en "Invite to portal".
- La API llama a
inviteRepToPortal()enseller-invitations.service.ts:- Invalida cualquier invitación pendiente existente para este rep.
- Genera un token aleatorio de 64 caracteres (32 bytes → hex).
- Crea una fila en
seller_invitationsconexpires_at = ahora + 7 días. - Retorna el token.
- El email de invitación se envía con un link a
/seller/setup?token=<token>. - El vendedor hace click en el link, el token se valida y consume atómicamente en
consumeInvitationToken(). - Se crea un nuevo usuario con
role = 'seller'via Better Auth. linkUserToRep()seteacommission_reps.user_id = newUser.id.
Re-invitar a un vendedor: llamar a "Invite to portal" nuevamente invalida los tokens pendientes existentes antes de crear uno nuevo (seguro re-invitar si el link anterior expiró).
Revocar acceso de vendedor: revokeAccess() setea commission_reps.user_id = null e invalida invitaciones pendientes. La cuenta de usuario queda pero sellerProcedure los bloqueará ya que no tienen más una fila de rep activa.
Reset de contraseña
Better Auth maneja el reset de contraseña via email token. Si la entrega de email está rota o se necesita un reset manual:
-- Opción 1: encontrar la fila de account y actualizar la contraseña hasheada
-- (Better Auth usa argon2 por defecto)
SELECT id FROM "user" WHERE email = 'usuario@ejemplo.com';
-- Luego usar el admin endpoint de Better Auth para disparar email de reset,
-- o actualizar el campo password de la tabla 'account' con un nuevo hash argon2.
-- Más fácil: usar el admin endpoint de Better Auth (si está configurado):
-- POST /api/auth/admin/set-password
-- { userId: '...', newPassword: '...' }Ver Runbooks para el procedimiento paso a paso.
Gestión de sesiones
- Las sesiones se almacenan en la tabla
sessioncon un timestampexpires_at. active_organization_iden la sesión trackea qué org está viendo el usuario actualmente (para usuariosclient_adminmulti-tenant).- Los session tokens se almacenan en Redis con un TTL coincidente para verificación de auth rápida.
-- Listar sesiones activas para un usuario
SELECT id, expires_at, ip_address, user_agent
FROM session
WHERE user_id = '<user_id>'
AND expires_at > NOW()
ORDER BY created_at DESC;
-- Invalidar todas las sesiones de un usuario (forzar logout)
DELETE FROM session WHERE user_id = '<user_id>';