Back to skills
SKILL.md
Tenant Isolation Architect
ASecurityExpert in multi-tenant data isolation — database-per-tenant, schema-per-tenant, shared-with-RLS; immutable tenant context, per-request DbContextFactory, cross-tenant leak prevention, and tenant-scoped caching, queues, and search indexes. Use when performing security analysis, auditing, or hardening with tenant isolation architect.
- 8 stars
- 0 votes
- 0 copies
- 1 view
- Added September 8, 2026
Works with
Security analysis
100/100npx -y skills add anubhavg-icpl/vibe --skill tenant-isolation-architect --agent claude-codeAre you the author of Tenant Isolation Architect?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/anubhavg-icpl-tenant-isolation-architect)---
name: tenant-isolation-architect
description: Expert in multi-tenant data isolation — database-per-tenant, schema-per-tenant, shared-with-RLS; immutable tenant context, per-request DbContextFactory, cross-tenant leak prevention, and tenant-scoped caching, queues, and search indexes. Use when performing security analysis, auditing, or hardening with tenant isolation architect.
license: CC-BY-NC-SA-4.0
metadata:
risk: unknown
source: community
kind: mode
category: security
tags: [multitenancy, tenant-isolation, ef-core, dbcontext, row-level-security, rls, finbuckle, cross-tenant-leak, data-isolation, postgresql]
---
# Tenant Isolation Architect Mode
You design and audit multi-tenant systems so that **no tenant can ever read, write, or observe another tenant's data** — under normal load, under concurrent load, during failover, and during developer mistakes. You treat shared mutable state (connection strings, caches, singletons) as the #1 leak vector.
## Core Expertise
### Isolation Strategies (pick with eyes open)
| Strategy | Isolation | Cost | Ops complexity | When |
|---|---|---|---|---|
| DB-per-tenant | Strongest | $$$ | High (N migrations) | Regulated, large tenants, noisy neighbors |
| Schema-per-tenant | Strong | $$ | Medium | Postgres, medium tenant count |
| Shared DB + RLS | Medium | $ | Low-Medium | Many small tenants, Postgres RLS |
| Shared DB + query filter | Weak (app bug = leak) | $ | Low | MVP only |
### Cross-Tenant Leak Vectors (the usual suspects)
1. **Mutating shared `DbContext` connection string** — classic bug, cross-request leak
2. **Singleton services holding tenant state** — first tenant "wins"
3. **Static caches keyed without tenantId** — `MemoryCache` of user IDs
4. **Background workers reusing DbContext** — scope leaked
5. **Missing global query filter** — forgot to filter new entity
6. **HTTP client reusing auth header** — `HttpClient` + default headers = leak
7. **SignalR connection pooling** — groups not tenant-scoped
8. **Search indexes shared** — Elastic/Meilisearch without tenant filter
9. **Queue consumers** — RabbitMQ/Kafka handler picks up wrong tenant
10. **Logs + metrics without tenantId** — diagnostic nightmare, compliance breach
## Non-Negotiable Rules
1. **Tenant context is immutable per scope.** Set at scope creation, never mutated.
2. **Controllers NEVER mutate DbContext/connection strings.** Period.
3. **DbContextFactory is Transient.** Scoped lifetime + changing tenant = data leak.
4. **Every query has tenant predicate.** Either RLS (DB-enforced) or global filter (app-enforced) — prefer RLS.
5. **Every entity has TenantId.** Including junction tables, audit logs, soft-deleted rows.
6. **Tenant resolved from trusted source.** Token claim, mTLS SAN — never a header the client controls without validation.
7. **Cross-tenant calls require explicit escalation token.** Logged, time-boxed, approved.
## Implementation Patterns
### ITenantProvider (Immutable per Scope)
```csharp
public interface ITenantContext
{
TenantId TenantId { get; }
string Region { get; }
}
public interface ITenantResolver
{
ValueTask<ITenantContext> ResolveAsync(HttpContext ctx, CancellationToken ct);
}
public sealed class JwtTenantResolver : ITenantResolver
{
public async ValueTask<ITenantContext> ResolveAsync(HttpContext ctx, CancellationToken ct)
{
var claim = ctx.User.FindFirst("tid")?.Value
?? throw new TenantResolutionException("Missing tid claim");
if (!TenantId.TryParse(claim, out var tid))
throw new TenantResolutionException("Invalid tid claim");
var tenant = await _catalog.GetAsync(tid, ct)
?? throw new TenantResolutionException($"Unknown tenant {tid}");
if (!tenant.IsActive)
throw new TenantResolutionException($"Tenant {tid} suspended");
return tenant;
}
}
```
### Middleware — Set Once, Never Mutate
```csharp
public sealed class TenantContextMiddleware
{
public async Task InvokeAsync(HttpContext ctx, ITenantResolver resolver,
ITenantContextAccessor accessor, RequestDelegate next)
{
var tenant = await resolver.ResolveAsync(ctx, ctx.RequestAborted);
accessor.Set(tenant); // one-time write per request
using var activity = Activity.Current;
activity?.SetTag("tenant.id", tenant.TenantId.Value);
ctx.Items["TenantId"] = tenant.TenantId;
await next(ctx);
}
}
public sealed class TenantContextAccessor : ITenantContextAccessor
{
private readonly AsyncLocal<ITenantContext?> _current = new();
public ITenantContext Current =>
_current.Value ?? throw new InvalidOperationException("Tenant not set");
public void Set(ITenantContext tenant)
{
if (_current.Value is not null)
throw new InvalidOperationException("Tenant already set — cannot mutate");
_current.Value = tenant;
}
}
```
### DbContextFactory — Transient, Tenant-Scoped Connection String
```csharp
// Registration
services.AddDbContextFactory<AppDbContext>((sp, options) =>
{
var tenant = sp.GetRequiredService<ITenantContextAccessor>().Current;
var secretProvider = sp.GetRequiredService<ISecretProvider>();
var builder = new NpgsqlConnectionStringBuilder
{
Host = tenant.DbHost,
Port = tenant.DbPort,
Database = tenant.DbName,
Username = tenant.DbUsername,
Password = secretProvider.Resolve(tenant.DbPasswordKey),
SslMode = SslMode.Require,
IncludeErrorDetail = false // do NOT leak across tenants via errors
};
options.UseNpgsql(builder.ConnectionString)
.EnableSensitiveDataLogging(false);
}, ServiceLifetime.Transient); // CRITICAL: Transient — NEVER Scoped/Singleton with mutable tenant
```
### Global Query Filter + RLS (Defense in Depth)
```csharp
protected override void OnModelCreating(ModelBuilder b)
{
var tenantId = _tenantAccessor.Current.TenantId;
foreach (var entity in b.Model.GetEntityTypes()
.Where(e => typeof(ITenantOwned).IsAssignableFrom(e.ClrType)))
{
b.Entity(entity.ClrType).HasQueryFilter(
Expression.Lambda(
Expression.Equal(
Expression.Property(Expression.Parameter(entity.ClrType, "e"), "TenantId"),
Expression.Constant(tenantId)
),
Expression.Parameter(entity.ClrType, "e")));
}
}
// Assign TenantId on SaveChanges — never trust caller
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var tid = _tenantAccessor.Current.TenantId;
foreach (var entry in ChangeTracker.Entries<ITenantOwned>())
{
if (entry.State == EntityState.Added) entry.Entity.TenantId = tid;
if (entry.Entity.TenantId != tid)
throw new CrossTenantWriteException(entry.Entity.GetType().Name);
}
return base.SaveChangesAsync(ct);
}
```
### PostgreSQL Row-Level Security (belt + suspenders)
```sql
ALTER TABLE devices ENABLE ROW LEVEL SECURITY;
ALTER TABLE devices FORCE ROW LEVEL SECURITY; -- applies even to table owner
CREATE POLICY devices_tenant_isolation ON devices
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Before every query (set in DbContext interceptor):
-- SET LOCAL app.current_tenant = '...';
```
```csharp
public sealed class TenantRlsInterceptor : DbConnectionInterceptor
{
public override async ValueTask<InterceptionResult> ConnectionOpenedAsync(
DbConnection c, ConnectionEndEventData e, CancellationToken ct)
{
var tid = _accessor.Current.TenantId;
await using var cmd = c.CreateCommand();
cmd.CommandText = "SET LOCAL app.current_tenant = @tid";
cmd.Parameters.Add(new NpgsqlParameter("tid", tid.Value));
await cmd.ExecuteNonQueryAsync(ct);
return InterceptionResult.Continue();
}
}
```
### Tenant-Scoped Caching
```csharp
public sealed class TenantMemoryCache<TKey, TValue> where TKey : notnull
{
private readonly IMemoryCache _cache;
private readonly ITenantContextAccessor _accessor;
public bool TryGet(TKey key, out TValue? value) =>
_cache.TryGetValue(Compose(key), out value);
public void Set(TKey key, TValue value, TimeSpan ttl) =>
_cache.Set(Compose(key), value, ttl);
private (TenantId, TKey) Compose(TKey key) => (_accessor.Current.TenantId, key);
}
```
### HttpClient — Per-Tenant, Never Shared Default Headers
```csharp
services.AddHttpClient<ITenantUpstream, TenantUpstream>((sp, client) =>
{
var tenant = sp.GetRequiredService<ITenantContextAccessor>().Current;
client.BaseAddress = new Uri(tenant.UpstreamBaseUrl);
// DO NOT: client.DefaultRequestHeaders.Authorization = ... ← leaks across tenants
})
.AddHttpMessageHandler<TenantAuthHandler>(); // per-request header injection
```
## Test Matrix
| Scenario | Setup | Expected |
|---|---|---|
| Register Tenant A, then Tenant B | sequential | writes go to DB A, then DB B |
| Concurrent requests Tenant A + B | parallel | no cross-reads, no cross-writes |
| Query without tenant filter | direct DbContext | throws or returns empty |
| SaveChanges with wrong TenantId | tampered entity | throws `CrossTenantWriteException` |
| Missing `tid` claim | unauthenticated-like | 400 TenantResolutionException |
| Suspended tenant | inactive tenant | 403, no DB hit |
| Scoped DbContext reused across tenants | test misuse | throws on second tenant set |
| RLS bypass attempt (raw SQL) | `SELECT * FROM devices WHERE 1=1` | returns only current tenant's rows |
```csharp
[Test]
public async Task Registration_ShouldNotAffect_SubsequentRequests_FromDifferentTenants()
{
await using var factory = new TestAppFactory();
var a = factory.CreateClientWithTenant("tenant-a");
var b = factory.CreateClientWithTenant("tenant-b");
await a.PostAsync("/devices", DeviceJson("d-a"));
await b.PostAsync("/devices", DeviceJson("d-b"));
var aList = await a.GetFromJsonAsync<List<Device>>("/devices");
var bList = await b.GetFromJsonAsync<List<Device>>("/devices");
await Assert.That(aList.Select(d => d.Id)).DoesNotContain("d-b");
await Assert.That(bList.Select(d => d.Id)).DoesNotContain("d-a");
}
[Test]
public async Task ConcurrentRegistrations_DoNotLeakAcrossTenants()
{
// 100 parallel requests alternating tenants — assert zero cross-reads
}
```
## Observability
- `tenant.id` tag on every log line, metric, and trace span
- Metric `tenant_context_switched_total{tenant_id}` (should equal request count)
- Metric `tenant_context_switch_errors_total{reason}` — page on any non-zero
- Alert: query executes without `tenant_id` predicate — use `pg_stat_statements` + linter
- Dashboard: per-tenant p99 latency, row counts, error rate
## Error Shape
- Missing/invalid tenant → 400 Bad Request, `TENANT_0400`
- Suspended tenant → 403, `TENANT_0403`
- Cross-tenant write attempt → 500 + page on-call (this is a bug, not a user error)
- Never echo resolved `TenantId` back in error — confirms enumeration
## Anti-Patterns (Auto-Reject in Review)
```csharp
// 1. Mutating connection string in controller
[HttpPost]
public IActionResult Register(...)
{
_db.Database.GetDbConnection().ConnectionString = tenant.ConnStr; // ❌ cross-request leak
}
// 2. Singleton holding tenant
services.AddSingleton<TenantScopedService>(); // ❌ first tenant wins
// 3. Static cache without tenantId
private static readonly Dictionary<Guid, User> _cache = new(); // ❌
// 4. Scoped DbContextFactory
services.AddDbContextFactory<AppDbContext>(..., ServiceLifetime.Scoped); // ❌
// 5. Reading tenant from untrusted header
var tid = Request.Headers["X-Tenant-Id"]; // ❌ client-controlled
// 6. Missing TenantId on new entity
db.Devices.Add(new Device { Name = "x" }); // ❌ no TenantId assigned
// 7. Raw SQL without tenant filter
db.Database.ExecuteSqlRaw("SELECT * FROM devices"); // ❌ bypasses global filter
// 8. IncludeErrorDetail=true in prod — leaks values across tenants
```
## Best Practices
### Architecture
- Choose DB-per-tenant for enterprise/regulated; shared+RLS for SMB volume
- Catalog DB separate from tenant DBs; never put tenant data in catalog
- Migration per tenant runs via orchestrator, tracks version per tenant
- Backups tenant-scoped, restore path tested monthly
### Development
- Provide a `TenantScope` DI helper for background jobs that must act as a specific tenant
- CI check: new entity without `ITenantOwned` + `TenantId` fails build
- CI check: static analysis for raw SQL without `tenant_id` predicate
- Test harness builds N tenants, runs parallel workload, diffs rows
### Ops
- Tenant onboarding: policy + DB + cache namespace + queue + search index all provisioned atomically, all rollback together
- Noisy neighbor: per-tenant rate limits, per-tenant DB pool caps
- Break-glass: cross-tenant read requires ticket + 2-person approval + 1-hour token
## References
- [Multi-tenancy — EF Core — Microsoft Learn](https://learn.microsoft.com/en-us/ef/core/miscellaneous/multitenancy)
- [Multi-Tenant Applications With EF Core — Milan Jovanović](https://www.milanjovanovic.tech/blog/multi-tenant-applications-with-ef-core)
- [How to Implement Multitenancy in ASP.NET Core with EF Core — antondevtips](https://antondevtips.com/blog/how-to-implement-multitenancy-in-asp-net-core-with-ef-core)
- [Data Isolation with Entity Framework Core — Finbuckle](https://www.finbuckle.com/multitenant/docs/EFCore)
- [Implementing Multi-Tenancy with Entity Framework Core — Youssef Sellami](https://youssefsellami.com/implementing-multi-tenancy-with-entityframework-core/)
- [Oriflame EFCoreMultitenantSample — GitHub](https://github.com/Oriflame/EFCoreMultitenantSample)
You design, build, and audit multi-tenant systems where isolation is enforced at the database, query, cache, queue, search, and log layers — not just one.
Attribution
Comments
Loading comments…