Rol Staff (equipo comercial interno de Marko)
Acceso transversal a todas las empresas, limitado a datos comerciales. Es el rol con el que el equipo de Marko hace prospección y seguimiento de clientes sin ver la operación de ninguno.
Descripción
Marko es multi-empresa: un usuario normal tiene roles por empresa (roles[companyId]) y solo ve la empresa en la que está. Eso no sirve para el equipo comercial de Marko, que necesita mirar a todos los clientes y a los leads que todavía no son clientes.
staff es ese rol transversal. No es un rol dentro de una empresa ni una variante de admin: es un flag propio del documento de cuenta, deliberadamente separado de los otros dos.
Por qué no se usa admin
admin es el administrador de plataforma y las reglas de Firestore le conceden read + write sobre companies/{id}/{document=**}: facturas, banco, inventario y documentos tributarios de todos los clientes. Eso es exactamente lo que un comercial interno no debe poder.
roles[companyId] | staff | admin | |
|---|---|---|---|
| Alcance | Una empresa | Todas las empresas | Todas las empresas |
| Datos comerciales de otras empresas | ❌ | ✅ | ✅ |
| Datos operativos (facturas, banco, inventario) | ✅ (su empresa) | ❌ | ✅ |
| Credenciales de integración de clientes | ❌ | ❌ | ✅ |
Pipeline de leads (leads) | ❌ | ✅ | ✅ |
Un
admintambién entra al CRM: el superusuario ya puede todo lo que puede staff. Lo que nunca debe hacerse es fusionar los dos flags ni concederstaffdentro del wildcard decompanies/, porque las reglas de Firestore se combinan con OR y no tienen precedencia por especificidad.
Cómo se asigna
El flag se escribe a mano en el documento de la cuenta: no hay casilla en la pantalla de Usuarios y Roles, y es a propósito — son cuentas del equipo interno, no de clientes.
accounts/{uid}
staff: true // habilita el acceso transversal comercial
admin: false // NO es administrador de plataforma
active: true // una cuenta desactivada no opera, aunque sea staff
roles: {} // sin roles: no pertenece a ninguna empresa cliente
Una cuenta staff no debe tener roles en ninguna empresa. Si se le asigna uno, deja de ser "staff puro" y vuelve a navegar como usuario de esa empresa (ver Alcance de navegación).
Qué ve y qué no
Ve:
- La colección
leadscompleta (pipeline comercial) y su historial de eventos. - De cada empresa, solo los campos comerciales: nombre, razón social, RUT, giro, comuna, dirección, contacto, estado de pago, plan, precio de licencia, fechas de trial, código de organismo y fecha de creación.
- La actividad pública en ChileCompra de cada cuenta (volumen de OC de los últimos 12 meses, canal dominante).
- El estado del Acuerdo de Servicio de cualquier empresa.
Puede cambiar:
- El plan de una empresa: plan, estado de pago, precio de licencia y fechas de trial.
- El Acuerdo de Servicio: generarlo, descargar el contrato firmado con su acta, rotar el enlace de firma y anularlo mientras esté pendiente.
- Del pipeline: el seguimiento comercial del lead (estado, próxima acción, dueño, notas, etiquetas).
No ve:
- Facturas, guías, banco, gastos, inventario, OC ni ningún dato operativo de los clientes.
- Las credenciales de integración que viven en
companies.config(maimagToken,mercadoApiKey, tokens de Fintoc, credenciales del SII).
El recorte de la empresa lo hace una whitelist explícita en el backend (COMPANY_COMMERCIAL_FIELDS en functions/src/crm/lib/company-fields.ts), no una lista negra: si mañana se agrega otro secreto al documento de empresa, no se filtra por olvido.
Por eso ninguna pantalla del rol lee
companiescon el SDK del navegador: piden la lista por Cloud Function (crmListCompanies) y reciben solo los campos comerciales. El costo es perder el tiempo real sobrecompanies—45 documentos que cambian poco: un plan, un trial—, mientrasleads, que es el pipeline vivo y no guarda secretos, se sigue leyendo cononSnapshot.
Desde MRKO-327 eso además lo imponen las reglas: el scope staff ya no tiene get ni list sobre companies. Los tuvo durante MRKO-320 sin que ninguna pantalla los usara, y un permiso sin dueño es una consola de navegador de distancia: bastaba firebase.firestore().collection('companies').get() para bajar los 45 documentos completos. Que el recorte viva en las reglas y no en la disciplina del código es lo que hace que una pantalla nueva no pueda filtrarlo por descuido.
Reglas de Firestore
function isStaff() {
return isAuthenticated() &&
get(/databases/$(database)/documents/accounts/$(request.auth.uid))
.data.get('staff', false) == true;
}
| Colección | Lectura | Escritura |
|---|---|---|
leads/{leadId} | isAdmin() || isStaff() | Solo Admin SDK (Cloud Function) |
leads/{leadId}/events/{eventId} | isAdmin() || isStaff() | Solo Admin SDK — append-only |
leads/{leadId}/campaigns/{campaignId} | isAdmin() || isStaff() | Solo Admin SDK |
serviceAgreements/{id} | isAdmin() || isStaff() || hasRoleInCompany(...) | Solo Admin SDK |
staffAccessLog/{logId} | ❌ cerrada al cliente | ❌ cerrada al cliente |
companies/{id} y subcolecciones | Sin acceso staff | Sin acceso staff |
configuration/{doc} | Cualquier autenticado (catálogo de planes) | Solo admin |
Detalles que no son casuales:
leadsvive en la raíz, hermana decompaniesyaccounts. Un lead es justamente una empresa que todavía no existe como cliente; y anidar la colección bajocompanies/la haría escribible por cualquier miembro de esa empresa, porque las reglas se combinan con OR (la misma razón por la que la bitácora de firmas de MRKO-308 está en raíz).- Toda escritura pasa por Cloud Function. Las reglas no pueden garantizar los tres invariantes que el rol necesita: dejar el registro en
staffAccessLog, crear la ficha del lead antes del evento (si no, la subcolección queda colgando y el lead se vuelve invisible en todo listado) y traducir el toque al enum cerrado deevents.
Las reglas se verifican ejecutándolas, no leyéndolas:
firebase emulators:exec --only firestore --project marko-devenv \
--config firebase.emulator-crm.json "python3 scripts/test-staff-rules.py"
El config aparte existe porque el emulador del repo usa el puerto 8080, ocupado en las máquinas del equipo. Cubre 28 casos, y los que más valen son los negativos: que el staff no lea una empresa, sus facturas, su banco, su inventario ni la propia auditoría.
Auditoría (staffAccessLog)
Un rol que ve a todos los clientes necesita traza desde el día uno. Cada operación del CRM escribe un registro:
staffAccessLog/{autoId}
at // serverTimestamp
uid // quién
email // quién, legible
via // "staff" | "admin"
action // crm-list | crm-view-account | crm-register-touch |
// crm-schedule-action | crm-create-account |
// company-update-plan | agreement-create | agreement-download |
// agreement-revoke | agreement-rotate-link
leadId // cuenta comercial tocada, o null
companyId // empresa cuyos datos se vieron, o null
detail // contexto extra de la operación
- La colección está cerrada al cliente por completo: si el propio staff pudiera leerla o escribirla, no sería una auditoría. Se consulta desde la consola de Firebase o desde un job.
- El registro es parte del camino crítico: si la auditoría no se puede escribir, la operación falla y los datos no se entregan. Un acceso sin registrar es justo lo que el criterio de aceptación prohíbe.
Gestión de planes y acuerdos (MRKO-327)
El rol nació sin poder cerrar el ciclo comercial: cambiar el plan de un cliente que compró o generar su Acuerdo de Servicio dependía de un administrador de plataforma. Desde MRKO-327 el staff entra a Configuración → Empresas para las dos cosas.
Qué se le abre y qué no
En esa pestaña, compartida con el administrador de plataforma, el staff ve el listado con datos comerciales y dos acciones por empresa:
- Configurar Plan — plan, estado de pago, precio de licencia y fechas de trial.
- Acuerdo de Servicio — generar, ver estado, descargar el contrato firmado con su acta, rotar el enlace de firma y anular mientras esté pendiente.
No se le renderiza ninguna de las demás: crear y editar empresa, eliminar, cargar créditos, configuración de compras ágiles, indicadores operativos y migración a BigQuery siguen siendo del administrador. Y del hub /company-settings no ve ninguna otra pestaña: se le recorta la lista, en vez de mostrarle botones deshabilitados, porque el resto configura una empresa activa que un staff no tiene.
Cómo se escribe
| Operación | Camino | Por qué |
|---|---|---|
| Ver el listado | crmListCompanies | Whitelist de campos comerciales; el config con las credenciales nunca sale del backend |
| Cambiar el plan | crmUpdateCompanyPlan | Escribe solo los cinco campos del plan y registra el valor anterior y el nuevo |
| Acuerdo de Servicio | createServiceAgreement, getAgreementDocumentUrls, revokeServiceAgreement, rotateSignatureLink | Ya existían (MRKO-308); ahora aceptan el scope staff y registran quién actuó |
Al staff no se le concede write sobre companies, y no es una formalidad: las reglas de Firestore no filtran campos, así que poder escribir el documento para cambiar un plan permitiría reescribir su config — o sea, las credenciales de integración del cliente. El administrador de plataforma, cuyo alcance ya cubre el documento completo, sigue escribiendo directo con el SDK.
crmUpdateCompanyPlan valida además que el plan exista y esté activo en el catálogo (configuration/plans): un id mal escrito deja a la empresa con un plan que ninguna pantalla resuelve y que se muestra como "No asignado" sin que nada falle.
Alcance de navegación
Un usuario staff no pertenece a ninguna empresa, y la app estaba construida asumiendo que todos pertenecen a una. Estos son los puntos donde el rol se comporta distinto:
| Momento | Comportamiento |
|---|---|
| Login | Sin empresas y con staff (o admin) → va a /crm, no a /dashboard ni a /onboarding. |
Onboarding (OnboardingGuard) | No aplica: sin esta salida, un staff queda atrapado en /onboarding porque nunca tiene roles. |
| Menú lateral | CRM comercial —fuera del grupo "Comercial", que son las herramientas de la empresa activa— y Configuración. |
| Selector de empresa | Oculto: un staff no cambia de empresa. |
| Rol mostrado en el header | "Comercial Marko". |
Empresa suspendida (PaymentStatusGuard) | La suspensión es del cliente y solo bloquea a sus miembros. A un staff sin rol en esa empresa no lo bloquea. |
Configuración (/company-settings) | Entra, pero el hub le muestra solo la pestaña Empresas: las otras 14 configuran una empresa activa que no tiene. ManagerAdminGuard lo deja pasar sin empresa. |
| Rutas operativas escritas a mano | StaffScopeGuard lo devuelve al CRM. Solo se permiten /crm, /profile, /help-center y /company-settings. |
StaffGuard y StaffScopeGuard
StaffGuardprotege la entrada a/crm: exigestaff === trueoadmin === true, y que la cuenta no esté desactivada (active !== false).StaffScopeGuardescanActivateChilddel layout: restringe a quien no tiene ningún rol en ninguna empresa y no es admin. La condición es estrecha a propósito — todo usuario con al menos un rol navega como siempre, así que el guard no puede dejar afuera a nadie que hoy entre.
Ambos esperan el primer evento de Firebase Auth (waitForAuthUser) en vez de leer currentUser directo. Al entrar por URL —una recarga completa, no una navegación del router— el SDK todavía no restauró la sesión: leyendo currentUser el StaffGuard expulsaba al usuario al dashboard (el CRM no aguantaba un refresco ni un enlace compartido) y el StaffScopeGuard concedía el paso, dejando la restricción sin efecto.
Los guards son solo la puerta de la UI. Lo que de verdad protege los datos son las reglas de Firestore y la validación de cada Cloud Function (
assertStaff), que además re-chequeaactive === false: el login del frontend ya frena una cuenta desactivada, pero ese chequeo es de UI —la sesión de Auth se crea igual y las reglas no miranactiveen ninguna parte.
Enlaces Relacionados
- CRM Comercial — la pantalla del rol
- Usuarios y Roles — roles por empresa
- Administración de Plataforma — el otro acceso transversal (
admin) y el resto de la pestaña Empresas - Mi Empresa — dónde viven las credenciales de integración que este rol no ve