Octopus
Multi-Tenant Configuration
Every Octopus SQL table has a TenantId column. The OctopusDbContext enforces tenant isolation via EF Core global query filters, making it impossible for one tenant's queries to accidentally see another tenant's data.
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
| Layer | Mechanism | Protects Against |
|---|---|---|
| JWT validation | Bearer token required for all API calls | Unauthenticated access |
ITenantContext | Tenant ID extracted from verified JWT claim | Forged or missing tenant claims |
| Global query filters | EF Core appends WHERE TenantId = @tid automatically | Cross-tenant data reads in application code |
| Repository write stamp | All Create operations stamp TenantId from context | Cross-tenant writes |
| Database index | TenantId is the leading column on all indexes | Cross-tenant data leaking via table scans |