Skip to content

Latest commit

 

History

History
331 lines (228 loc) · 10.6 KB

File metadata and controls

331 lines (228 loc) · 10.6 KB

Multi-Tenancy Guide

This guide details the Enterprise-grade Multi-tenancy implementation in the BookStore application with comprehensive performance optimizations, security features, and user experience enhancements.

Overview

The application uses Conjoined Tenancy where all tenants share the same database and schema, but data is logically isolated using a tenant_id column.

Key Features

Data Isolation: Marten-based conjoined multi-tenancy
Event Sourcing: Full multi-tenant event store support
Performance: Redis caching reduces DB queries by ~99%
Security: Audit logging and per-tenant rate limiting
UX: Tenant switcher UI with localStorage persistence


Marten Compliance

Our implementation follows Marten's official event store multi-tenancy recommendations:

Event Store: TenancyStyle.Conjoined configured
Documents: AllDocumentsAreMultiTenanted() policy applied
Global Documents: [DoNotPartition] for Tenant model (Marten 8.5+)
Sessions: Properly scoped per-tenant
Projections: Automatically tenant-aware
Async Daemon: Processes events per-tenant


Architecture

1. Event Store Multi-Tenancy (Marten)

// MartenConfigurationExtensions.cs
options.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined;

What this means:

  • All events are automatically captured with tenant_id
  • Event streams are isolated per tenant
  • Projections respect tenant boundaries
  • Async daemon processes events per-tenant

2. Document Multi-Tenancy

options.Policies.AllDocumentsAreMultiTenanted();
  • Every document includes a tenant_id column
  • Sessions are tenant-scoped when created with store.LightweightSession(tenantId)
  • Queries automatically filter by tenant ID

Exception: Tenant documents use [DoNotPartition] attribute to be globally accessible:

[Marten.Schema.DoNotPartition]
public class Tenant
{
    public string Id { get; set; }
    public string Name { get; set; }
    public bool IsEnabled { get; set; }
}

3. API Isolation (Middleware)

TenantResolutionMiddleware intercepts every HTTP request:

  1. Extracts X-Tenant-ID header
  2. Validates tenant (with Redis caching)
  3. Sets ITenantContext for request scope
  4. Logs tenant access for audit trail
  5. Returns 400 Bad Request if invalid

If the header is omitted, the request uses the shared default tenant (*DEFAULT*). The lowercase alias default is also accepted in tests and helper flows.

4. Cache Isolation

All caching operations automatically include tenant context. Cache keys are tenant-scoped to prevent data leakage:

// Tenant ID is automatically appended by GetOrCreateLocalizedAsync
var cacheKey = $"book:{id}";  // Becomes: book:123|en-US|acme

Note

For complete caching implementation details including tenant-aware patterns, see the Caching Guide.


Blob Storage Multi-Tenancy

Isolation Strategy: Folder Prefixes

Unlike Marten (database) which has built-in support, Azure Blob Storage (and Azurite) uses a flat namespace. We implement logical isolation by prefixing all blobs with the tenant ID.

  • Structure: {tenantId}/{bookId}.{extension}
  • Example: acme/book-123.png, contoso/book-456.png

1. Upload Isolation

The BlobStorageService accepts a tenantId (resolved from ITenantContext) and prefixes the blob path during upload. This ensures acme data never ends up in default folders.

2. Download Isolation & The "Browser Context" Problem

The Challenge: When a browser loads an image via an <img> tag, it makes a standard GET request. It does not send custom headers (like X-Tenant-ID) and, in a development environment (localhost), the domain is shared. The API cannot automatically distinguish which tenant the request belongs to.

The Solution: Proxy Endpoint with Explicit Context We serve images via a proxy endpoint that carries the tenant context in the URL:

  • URL: /api/books/{id}/cover?tenantId=acme
  • Mechanism:
    1. BookCoverHandlers saves this parameterized URL in the database.
    2. The browser requests this URL.
    3. The API reads tenantId from the query string.
    4. The API reconstructs the path (acme/{bookId}.png) and streams the blob.

This guarantees robust isolation and correct image loading regardless of the domain or network environment (Docker/Host).


Performance Optimizations

Tenant Validation Caching

CachedTenantStore wraps MartenTenantStore with Redis:

  • Cache Duration: 5 minutes
  • Impact: ~99% reduction in DB queries
  • Invalidation: InvalidateCacheAsync() on tenant updates

Per-Tenant Rate Limiting

1000 requests/minute per tenant prevents noisy neighbor problem:

options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
    var tenantId = context.Items["TenantId"]?.ToString() ?? "default";
    return RateLimitPartition.GetFixedWindowLimiter(tenantId, 
        new FixedWindowRateLimiterOptions
        {
            PermitLimit = 1000,
            Window = TimeSpan.FromMinutes(1)
        });
});

Database Indexes

For optimal multi-tenancy performance in production, proper database indexing is critical.

Quick Reference

Indexes are declared in Marten configuration (ConfigureIndexes) and managed through Marten schema workflows, not hand-written SQL in feature code.

Expected Performance

With proper indexing:

  • Tenant-filtered queries: < 10ms for up to 1M records per tenant
  • Event queries: < 5ms for recent events

For comprehensive indexing strategies, monitoring queries, and maintenance schedules, see Database Indexes Guide.


Cross-Tenant Maintenance

To ensure system-wide hygiene, background maintenance tasks are configured to be tenant-aware or global as needed:

  • Unverified Account Cleanup: The background cleanup job for unverified accounts uses .AnyTenant() to ensure stale accounts are removed across all tenants in a single pass.
  • Index Management: Indexes are monitored and rebuilt system-wide to maintain performance for all tenants.

Security Features

Audit Logging

All tenant access is logged:

_logger.LogInformation(
    "Tenant {TenantId} accessing {Method} {Path} from {RemoteIp}",
    tenantContext.TenantId,
    context.Request.Method,
    context.Request.Path,
    context.Connection.RemoteIpAddress);

Path-Based Security (Middleware)

The TenantSecurityMiddleware enforces strict isolation:

  • Authenticated Users: Validates that the tenant_id claim in their JWT matches the X-Tenant-ID header. Prevents cross-tenant token theft.
  • Anonymous Users: Only allowed to access the Default tenant (*DEFAULT*) unless the endpoint is explicitly whitelisted.

The [AllowAnonymousTenant] Attribute

For public endpoints that should be accessible from any tenant context without authentication (e.g., login, tenant info, health checks), use the AllowAnonymousTenantAttribute.

// Example: Whitelisting a group of endpoints
group.WithMetadata(new AllowAnonymousTenantAttribute());

Administrative Restrictions

  • System Admin: Only the Default tenant administrator (admin@bookstore.com) can access global management endpoints under /api/admin/tenants.
  • Tenant Admin: Authenticated administrators for specific tenants (e.g., acme) are restricted to their own data and cannot list or manage other tenants.
  • Default Tenant Constants: In code, always use shared constants (e.g., MultiTenancyConstants.DefaultTenantId) for default-tenant checks instead of hardcoded *DEFAULT*/default literals.

Rate Limiting

  • Global: 1000 req/min per tenant
  • Auth policy: per tenantId:ip partition using configured AuthPermitLimit and AuthWindowSeconds
    • default config: 20 req / 60s
    • development config: 200 req / 60s
  • Response: 429 Too Many Requests with retryAfter seconds

Frontend Integration

Tenant Service

Three-tier priority:

  1. URL parameter (?tenant=xxx)
  2. LocalStorage
  3. Default tenant

Tenant Switcher UI

<MudMenu Icon="@Icons.Material.Filled.Business">
    <MudMenuItem OnClick="@(() => SwitchTenant("acme"))">Acme Corp</MudMenuItem>
</MudMenu>

LocalStorage Persistence

await _localStorage.SetItemAsStringAsync("selected-tenant", tenantId);

Developer Guidelines

1. Always Inject ITenantContext

public class MyService(ITenantContext tenantContext)
{
    var tenantId = tenantContext.TenantId;
}

2. Verify Cache Keys

// ✅ Correct
var key = $"book:{id}:tenant={tenantContext.TenantId}";

// ❌ Wrong - data leak!
var key = $"book:{id}";

3. Use [AllowAnonymousTenant] for Public Endpoints

If you create a new public endpoint that must be accessible from any tenant (like a shared resource or authentication flow), remember to apply the AllowAnonymousTenantAttribute to the route mapping.

4. Test Multi-Tenancy

[Test]
public async Task EntitiesAreIsolatedByTenant()
{
    var acmeClient = CreateClient("acme");
    var contosoClient = CreateClient("contoso");
    
    var book = await acmeClient.PostAsync("/api/admin/books", data);
    var response = await contosoClient.GetAsync($"/api/books/{book.Id}");
    Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}

Production Deployment

Checklist

  • Verify all tenant_id indexes exist
  • Configure Redis for distributed caching
  • Set up audit log aggregation
  • Configure rate limits per tenant tier
  • Schedule monthly index rebuilds

Environment Variables

ConnectionStrings__redis=localhost:6379
RateLimit__PermitLimit=1000
RateLimit__WindowInMinutes=1

Maintenance

For index rebuild schedules and vacuum strategies, see Database Indexes Guide - Maintenance.


Troubleshooting

Problem Solution
Tenant not displaying Restart Docker
404 on /api/tenants/{id} Verify seeding, check [DoNotPartition]
Slow queries Check index usage with EXPLAIN ANALYZE

References