Portal Community

Tenant ID Source

The tenant ID for the current request is provided by ITenantContext, which is resolved from the JWT claim on each HTTP request:

// JwtTenantContext — registered as Scoped
public class JwtTenantContext : ITenantContext
{
    public string TenantId { get; }

    public JwtTenantContext(IHttpContextAccessor httpContext)
    {
        TenantId = httpContext.HttpContext?
            .User.FindFirstValue("tid")   // Azure AD tenant ID claim
            ?? httpContext.HttpContext?
            .User.FindFirstValue("tenant_id")   // Custom claim fallback
            ?? throw new UnauthorizedAccessException("Request has no tenant claim.");
    }
}

Global Query Filters in OctopusDbContext

public class OctopusDbContext : DbContext
{
    private readonly ITenantContext _tenant;

    public OctopusDbContext(DbContextOptions<OctopusDbContext> options,
                             ITenantContext tenant) : base(options)
    {
        _tenant = tenant;
    }

    public DbSet<Agent>           Agents           { get; set; } = null!;
    public DbSet<Episode>         Episodes         { get; set; } = null!;
    public DbSet<EpisodeMessage>  EpisodeMessages  { get; set; } = null!;
    public DbSet<Procedure>       Procedures       { get; set; } = null!;
    public DbSet<AIFunction>      AIFunctions      { get; set; } = null!;
    public DbSet<Area>            Areas            { get; set; } = null!;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Apply TenantId filter to every entity — automatically adds WHERE TenantId = @tid
        modelBuilder.Entity<Agent>()
            .HasQueryFilter(a => a.TenantId == _tenant.TenantId);
        modelBuilder.Entity<Episode>()
            .HasQueryFilter(e => e.TenantId == _tenant.TenantId);
        modelBuilder.Entity<EpisodeMessage>()
            .HasQueryFilter(m => m.TenantId == _tenant.TenantId);
        modelBuilder.Entity<Procedure>()
            .HasQueryFilter(p => p.TenantId == _tenant.TenantId);
        modelBuilder.Entity<AIFunction>()
            .HasQueryFilter(f => f.TenantId == _tenant.TenantId);
        modelBuilder.Entity<Area>()
            .HasQueryFilter(a => a.TenantId == _tenant.TenantId);
    }
}

Bypassing the Query Filter (Admin Use Only)

In exceptional cases — such as cross-tenant data purges or system health reports — the filter can be bypassed using IgnoreQueryFilters(). This must never be used in tenant-facing code paths:

// ADMIN ONLY — purge all data for a specific tenant
public async Task PurgeTenantAsync(string tenantId, CancellationToken ct)
{
    // Must use IgnoreQueryFilters because the current context may be a different tenant
    await _db.Episodes
        .IgnoreQueryFilters()
        .Where(e => e.TenantId == tenantId)
        .ExecuteDeleteAsync(ct);

    await _db.EpisodeMessages
        .IgnoreQueryFilters()
        .Where(m => m.TenantId == tenantId)
        .ExecuteDeleteAsync(ct);
}
IgnoreQueryFilters is dangerous. Any code using IgnoreQueryFilters() must always specify an explicit .Where(x => x.TenantId == targetTenantId) filter. Without it, the query reads all tenants' data.

Setting TenantId on Write

The repositories always stamp the current TenantId on new entities before saving:

public class SqlAgentStore : IAgentStore
{
    private readonly OctopusDbContext _db;
    private readonly ITenantContext   _tenant;

    public SqlAgentStore(OctopusDbContext db, ITenantContext tenant)
    {
        _db     = db;
        _tenant = tenant;
    }

    public async Task<Agent> CreateAgentAsync(CreateAgentRequest request, CancellationToken ct)
    {
        var agent = new Agent
        {
            TenantId    = _tenant.TenantId,  // Always stamped from ITenantContext
            Name        = request.Name,
            SystemPrompt = request.SystemPrompt,
            // ...
        };

        _db.Agents.Add(agent);
        await _db.SaveChangesAsync(ct);
        return agent;
    }
}

Tenant Isolation Model Summary

LayerMechanismProtects Against
JWT validationBearer token required for all API callsUnauthenticated access
ITenantContextTenant ID extracted from verified JWT claimForged or missing tenant claims
Global query filtersEF Core appends WHERE TenantId = @tid automaticallyCross-tenant data reads in application code
Repository write stampAll Create operations stamp TenantId from contextCross-tenant writes
Database indexTenantId is the leading column on all indexesCross-tenant data leaking via table scans