🔥

CultivaLeads — Firestore Enterprise

Configuración completa de Cloud Firestore para plataforma SaaS B2B multi-tenant
● Enterprise Edition ● europe-west1 ● 5 colecciones ● Reglas hardened v1.0 ● Web SDK + Python
🗂
Modelo de datos
Firestore Enterprise · europe-west1
ℹ️
La base de datos cultivaleads-prod fue creada en edición Enterprise para acceder a colección group queries, mayor throughput y SLAs garantizados. Multi-tenant por agencyId en cada documento.
Estado del lead — flujo de transición
New
Contacted
Qualified
Closed Won
Closed Lost
agencies Enterprise
  • name string Nombre de la agencia (<100 chars) required
  • ownerUid string UID Firebase Auth del owner required
  • plan string starter | growth | enterprise required
  • createdAt timestamp Inmutable tras creación required
  • leadsQuota number Límite de leads según plan optional
users PII privado
  • email string Email corporativo (solo owner lee) required
  • displayName string <80 chars required
  • role string admin | agent (solo admin modifica) required
  • agencyId string Ref a agencies (inmutable) required
  • photoURL string https:// URL <500 chars optional
leads Multi-tenant
  • agencyId string Tenant ID (inmutable) required
  • fullName string <120 chars required
  • email string Email del lead (validado) required
  • status string new→contacted→qualified→closed_* required
  • assignedAgentId string UID del agente asignado optional
  • campaignId string Ref a campaigns optional
  • score number 0–100 lead scoring optional
  • createdAt timestamp Inmutable, validado con isRecent() required
campaigns Multi-tenant
  • agencyId string Tenant ID (inmutable) required
  • name string <200 chars required
  • channel string google_ads | meta | linkedin | email required
  • budget number > 0 (EUR) optional
  • leadsCount number Contador atómico (getAfter) required
  • createdAt timestamp Inmutable required
leads/{leadId}/notes Subcollection
  • authorUid string Agente que escribe (inmutable) required
  • body string <2000 chars required
  • agencyId string Desnormalizado para reglas required
  • createdAt timestamp isRecent() en create required
🔒
Reglas de Seguridad (Prototype v1.0)
firestore.rules
⚠️
He configurado reglas prototipo para mantener los datos de Firestore seguros. Están diseñadas para ser seguras gracias al aislamiento por agencyId, validadores de datos estrictos, protección RBAC contra escalada de privilegios y checks de campos inmutables. Sin embargo, debes revisarlas y verificarlas antes de lanzar la app a producción.
Firebase Rules firestore.rules
rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // ============================================================ // HELPER FUNCTIONS // ============================================================ function isAuthenticated() { return request.auth != null; } function isOwner(userId) { return isAuthenticated() && request.auth.uid == userId; } function isDocOwner() { return isAuthenticated() && request.auth.uid == resource.data.authorUid; } // Verifica que el usuario pertenece a la agencia del documento function isSameAgency(agencyId) { return isAuthenticated() && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.agencyId == agencyId; } function isAgencyAdmin(agencyId) { let u = get(/databases/$(database)/documents/users/$(request.auth.uid)).data; return isAuthenticated() && u.agencyId == agencyId && u.role == 'admin'; } function hasOnlyAllowedFields(fields) { return request.resource.data.keys().hasOnly(fields); } function isRecent(time) { return time is timestamp && time > request.time - duration.value(5, 'm') && time <= request.time; } function isValidEmail(email) { return email is string && email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); } function isValidUrl(url) { return url is string && url.matches("^https://.*") && url.size() < 500; } // ============================================================ // DOMAIN VALIDATORS // ============================================================ function isValidLead(data) { return hasOnlyAllowedFields(['agencyId', 'fullName', 'email', 'status', 'assignedAgentId', 'campaignId', 'score', 'createdAt', 'updatedAt']) && data.agencyId is string && data.agencyId.size() > 0 && data.fullName is string && data.fullName.size() > 0 && data.fullName.size() < 120 && isValidEmail(data.email) && data.status in ['new', 'contacted', 'qualified', 'closed_won', 'closed_lost']; } function validLeadStatusTransition() { let prev = resource.data.status; let next = request.resource.data.status; return (prev == 'new' && next == 'contacted') || (prev == 'contacted' && next == 'qualified') || (prev == 'qualified' && next in ['closed_won', 'closed_lost']) || prev == next; } function isValidCampaign(data) { return hasOnlyAllowedFields(['agencyId', 'name', 'channel', 'budget', 'leadsCount', 'createdAt']) && data.agencyId is string && data.name is string && data.name.size() > 0 && data.name.size() < 200 && data.channel in ['google_ads', 'meta', 'linkedin', 'email'] && data.leadsCount is number && data.leadsCount >= 0; } function isValidUser(data) { return hasOnlyAllowedFields(['email', 'displayName', 'role', 'agencyId', 'photoURL', 'createdAt']) && isValidEmail(data.email) && data.displayName is string && data.displayName.size() > 0 && data.displayName.size() < 80 && data.role in ['admin', 'agent'] && data.agencyId is string && data.agencyId.size() > 0; } // ============================================================ // COLLECTION RULES // ============================================================ // agencies — solo owner de la agencia puede leer/editar match /agencies/{agencyId} { allow read: if isAuthenticated() && isSameAgency(agencyId); allow create: if isAuthenticated() && request.resource.data.ownerUid == request.auth.uid; allow update: if isAgencyAdmin(agencyId); allow delete: if false; // nunca borrar agencias desde cliente } // users — PII: solo el propio usuario puede leer su doc // Solo admin de la agencia puede crear/editar roles match /users/{userId} { allow read: if isOwner(userId); allow create: if isAuthenticated() && isValidUser(request.resource.data) && // Usuario solo puede crearse a sí mismo con rol 'agent' ((isOwner(userId) && request.resource.data.role == 'agent') || isAgencyAdmin(request.resource.data.agencyId)); allow update: if isAuthenticated() && isValidUser(request.resource.data) && // El agente no puede cambiar su propio rol ((isOwner(userId) && request.resource.data.role == resource.data.role) || isAgencyAdmin(resource.data.agencyId)) && // agencyId inmutable request.resource.data.agencyId == resource.data.agencyId; allow delete: if isAgencyAdmin(resource.data.agencyId); } // leads — aislamiento por agencyId + transición de estado validada match /leads/{leadId} { allow read: if isSameAgency(resource.data.agencyId); allow create: if isAuthenticated() && isValidLead(request.resource.data) && isSameAgency(request.resource.data.agencyId) && request.resource.data.status == 'new' && isRecent(request.resource.data.createdAt); allow update: if isAuthenticated() && isValidLead(request.resource.data) && isSameAgency(resource.data.agencyId) && validLeadStatusTransition() && // campos inmutables request.resource.data.agencyId == resource.data.agencyId && request.resource.data.createdAt == resource.data.createdAt; allow delete: if isAgencyAdmin(resource.data.agencyId); // Notas: cualquier miembro de la agencia puede crear/leer; solo el autor edita match /notes/{noteId} { allow read: if isSameAgency(resource.data.agencyId); allow create: if isAuthenticated() && isSameAgency(request.resource.data.agencyId) && request.resource.data.authorUid == request.auth.uid && request.resource.data.body is string && request.resource.data.body.size() > 0 && request.resource.data.body.size() < 2000 && isRecent(request.resource.data.createdAt); allow update: if isDocOwner() && request.resource.data.body is string && request.resource.data.body.size() < 2000 && request.resource.data.authorUid == resource.data.authorUid && request.resource.data.createdAt == resource.data.createdAt; allow delete: if isDocOwner() || isAgencyAdmin(resource.data.agencyId); } } // campaigns — CRUD para admins; agentes solo leen match /campaigns/{campaignId} { allow read: if isSameAgency(resource.data.agencyId); allow create: if isAgencyAdmin(request.resource.data.agencyId) && isValidCampaign(request.resource.data) && isRecent(request.resource.data.createdAt) && request.resource.data.leadsCount == 0; allow update: if isSameAgency(resource.data.agencyId) && isValidCampaign(request.resource.data) && request.resource.data.agencyId == resource.data.agencyId && request.resource.data.createdAt == resource.data.createdAt; allow delete: if isAgencyAdmin(resource.data.agencyId); } } }
Índices Compuestos
firestore.indexes.json
1
leads
agencyId ▲ ASC status ▲ ASC createdAt ▼ DESC
Query: leads por agencia + status
2
leads
agencyId ▲ ASC assignedAgentId ▲ ASC createdAt ▼ DESC
Query: mis leads asignados
3
leads
agencyId ▲ ASC campaignId ▲ ASC score ▼ DESC
Query: leads por campaña + scoring
4
campaigns
agencyId ▲ ASC channel ▲ ASC createdAt ▼ DESC
Query: campañas por canal
5
leads/{leadId}/notes
agencyId ▲ ASC createdAt ▼ DESC
Timeline de notas por lead
TypeScript src/lib/firestore/leads.ts — SDK queries
import { db } from './firebase'; import { collection, query, where, orderBy, limit, getDocs, Timestamp } from 'firebase/firestore'; // Query 1: leads de mi agencia filtrados por status export async function getLeadsByStatus(agencyId: string, status: LeadStatus) { const q = query( collection(db, 'leads'), where('agencyId', '==', agencyId), where('status', '==', status), orderBy('createdAt', 'desc'), limit(50) ); const snap = await getDocs(q); return snap.docs.map(d => ({ id: d.id, ...d.data() })); } // Query 2: leads asignados al agente actual export async function getMyLeads(agencyId: string, agentUid: string) { const q = query( collection(db, 'leads'), where('agencyId', '==', agencyId), where('assignedAgentId', '==', agentUid), orderBy('createdAt', 'desc') ); const snap = await getDocs(q); return snap.docs.map(d => ({ id: d.id, ...d.data() })); } // Crear lead con validación client-side + server-side (reglas) export async function createLead(agencyId: string, lead: NewLeadInput) { const ref = doc(collection(db, 'leads')); await setDoc(ref, { ...lead, agencyId, status: 'new', leadsCount: 0, createdAt: Timestamp.now(), }); return ref.id; }
🛡
Análisis Devil's Advocate — Vectores bloqueados
Phase-3 completada
Cross-tenant leak
Agente de agencia A no puede leer leads de agencia B. isSameAgency() hace get del perfil del usuario y compara agencyId.
Escalada de rol
Un agente no puede escribir role: 'admin' en su propio documento. La regla exige que el rol sea igual al actual si no eres admin.
Bypass de transición
No se puede pasar un lead de new a closed_won directamente. validLeadStatusTransition() valida cada salto.
Contaminación de schema
hasOnlyAllowedFields() en create y update rechaza cualquier campo no declarado en el modelo.
Ataque DoS (string 1MB)
Todos los campos de texto tienen límite .size(): fullName <120, body <2000, name <200. No hay strings sin acotar.
Manipulación de timestamp
isRecent() valida que createdAt esté dentro de los últimos 5 minutos. No se puede poner fecha futura ni pasada.
Campo inmutable modificado
createdAt y agencyId en leads/campaigns no pueden cambiar en update. Validado explícitamente en cada regla.
PII expuesto entre usuarios
La colección users solo permite leer al propio usuario (isOwner(userId)). Email y datos privados nunca accesibles por terceros.