← Volver al blog

Multi-Tenant Data Isolation en ASP.NET Core: cómo garantizar que un tenant nunca vea datos de otro

  • .NET
  • C#
  • Backend
  • Architecture
  • Security
  • Entity Framework
  • Multi-tenant

Descubre cómo implementar una capa de seguridad estructural en sistemas Multi-Tenant usando Global Query Filters en EF Core para evitar brechas de datos accidentales.

TL;DR: En sistemas Multi-Tenant, filtrar por TenantId manualmente en cada consulta es inviable a largo plazo y propenso a errores humanos. La solución definitiva es usar Global Query Filters en EF Core, una restricción estructural a nivel de DbContext que garantiza el aislamiento de datos por defecto, eliminando el riesgo de fugas de información accidentales. Aquí explico cómo lo implementé en FlowHub.

El error más común en sistemas multi-tenant no es técnico —es de diseño. La mayoría de implementaciones añaden el filtro por tenant como una capa de presentación, no como una restricción de datos. Aquí explico cómo lo resuelvo en FlowHub.

En el post anterior resolví varios casos de IDOR en una aplicación mediante un servicio IAuthTeacherService que centraliza la validación de ownership de los endpoints. Funcionó, pero dejé anotada una deuda técnica: valorar los Global Query Filters para eliminar el riesgo humano de olvidar el filtro y volver al principio con los dichosos IDORs. En FlowHub, que desarrollo y mantengo en solitario, el riesgo no está tanto en la coordinación entre personas sino de la consistencia a lo largo del tiempo y la solución pasa por eliminar puntos de fricción en los que puede aparecer el error por despiste u olvido.

El problema del filtrado manual

El patrón más común en sistemas multi-tenant es añadir el tenantId como condición en cada query:

public async Task<List<Contact>> GetContactsAsync(int tenantId)
{
    return await _context.CONTACTS
        .Where(i => i.TenantId == tenantId)
        .ToListAsync();
}

Este patrón parece razonable pero tiene varios problemas - como que estamos confiando en el tenantId que nos manda el cliente - pero nos centraremos en que no escala nada bien.

Nos encontramos con un problema de disciplina. En un proyecto en desarrollo con una base de código que crece continuamente o simplemente a lo largo del tiempo, basta un endpoint que olvide el .Where(i => i.TenantId == tenantId) para tener una brecha de aislamiento. Y ese tipo de bug no lanza una excepción — devuelve datos silenciosamente.

Global Query Filters: Seguridad estructural con EF Core

Un Global Query Filter es un predicado que Entity Framework Core aplica automáticamente a todas las queries de una entidad, que se define al construir el modelo. Es decir, no vamos servicio por servicio, repositorio a repositorio o controlador a controlador. Se aplica en el modelBuilder<Entity>.HasQueryFilter(...) del DbContext. Lo importante para lo que nos ocupa: no puedes olvidarlo. Está en la infraestructura.

Implementación técnica: El TenantID en el DbContext

El primer paso es disponer del tenantId actual en el DbContext.

  • En FlowHub lo resuelvo con un servicio ITenantContext que extrae el tenant del JWT.
public interface ITenantContext
{
	long TenantId { get; }
}

public class HttpTenantContext : ITenantContext
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public HttpTenantContext(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

	public long TenantId
	{
		get
		{
			var httpContext = _httpContextAccessor.HttpContext;
			// Si es un background job o non-HTTP devolvemos un 0
			if (httpContext == null) return 0;
			
		
			var user = httpContext.User;
			// Si no está autenticado devolvemos un 0
			if (user?.Identity?.IsAuthenticated != true) return 0;
		
			var claim = user.FindFirst("tenant_id")?.Value;
		  
		
			if (long.TryParse(claim, out var tenantId) && tenantId > 0)
			{
				return tenantId;
			}
			return 0;
		}
	
	}
}
ℹ️Info

Devolver 0 en lugar de lanzar una excepción es una decisión de diseño: todas las entidades tienen datos reales con TenantId > 0, así que el filtro TenantId == 0 devuelve vacío sin romper el flujo — lo que resulta en un 401 o 404 según el endpoint.

Con el contexto disponible, el DbContext aplica el filtro en OnModelCreating:

public class CrmDbContext : DbContext
{
    private readonly ITenantContext _tenantContext;

    public CrmDbContext(DbContextOptions<CrmDbContext> options,         ITenantContext tenantContext) : base(options)
    {
        _tenantContext = tenantContext;
    }

    public DbSet<Contact> CONTACTS => Set<Contact>();
    //Incluye DbSet para cada entidad

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("crm");
        
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Contact>().HasQueryFilter(e => e.TenantId == _tenantContext.TenantId);
		// Aplica el Global Query Filter a cada entidad que lo necesite.
    }
}

A partir de aquí, cualquier query sobre Contacts o cualquier otra entidad a la que apliquemos el Global Query Filter incluye automáticamente el filtro por TenantId. Sin depender de que nadie recuerde incluir el filtro en su query.

// Esta query filtra por tenantId automáticamente
var contacts = await _context.CONTACTS.ToListAsync();
// EF Core genera: WHERE TenantId = @tenantId

Cuándo (y cómo) usar IgnoreQueryFilters()

Los Global Query Filters tienen una vía de escape: IgnoreQueryFilters(). Esta vía de escape es necesaria para operaciones de administración cross-tenant, pero hay que utilizarla con criterio.

La regla que sigo en FlowHub: IgnoreQueryFilters() solo está permitido en servicios del módulo de administración y en endpoints que están doblemente protegidos para el rol superAdmin.

Navegación y relaciones: filtrado en entidades hijo

El filtro se aplica a la entidad directa. Si una entidad hija puede consultarse de forma independiente, necesita su propio filtro. En FlowHub, entidades como ContactNote solo se acceden a través de Contact, que ya tiene el filtro — así que el aislamiento está garantizado por la cadena de navegación. Pero si una entidad hija puede consultarse directamente, aplico el filtro también ahí:

modelBuilder.Entity<ContactNote>().HasQueryFilter(n => n.Contact.TenantId == _tenantContext.TenantId);

La regla: si puede consultarse directamente, necesita su propio filtro.

GQF vs validación de ownership: no son excluyentes

Los Global Query Filters garantizan que nunca devuelves datos de otro tenant. Pero no sustituyen la validación de ownership en endpoints que reciben IDs del cliente. Un usuario puede estar en un tenant y no tener autorización de acceso a un recurso, por su rol o configuración.

Si un tenant hace GET /invoices/42 y esa factura existe pero pertenece a otro tenant, el GQF devuelve null — lo que se traduce en un 404. Correcto; pero si la factura pertenece al tenant correcto y existe, el GQF no verifica de forma automática que el usuario concreto tenga permiso para verla dentro de su propio tenant.

La estrategia completa

Desde que implementé los Global Query Filters en Flowhub, ningún endpoint de negocio necesita preocuparse por el tenantId en las queries. El aislamiento está garantizado por la infraestructura y un despiste no puede romperlo.

El aislamiento real en multi-tenant requiere tres capas:

  1. Global Query Filter → ningún tenant ve datos de otro (infraestructura)
  2. Validación de ownership / autorización → control de acceso dentro del tenant (aplicación)
  3. Tests con datos de múltiples tenants → verificar que el filtro funciona con tenants distintos en los tests de integración

Esta es la arquitectura que uso en producción en FlowHub, donde múltiples organizaciones comparten la misma instancia del backend.