Skip to content

πŸ” RecSys.AI - Authentication & Authorization Guide ​

Version: 1.0.0
Last Updated: January 3, 2026
Status: Implementation Guide


Table of Contents ​

  1. Overview
  2. Multi-Tenancy Architecture
  3. Authentication System
  4. Authorization & Access Control
  5. Subscription Management
  6. API Key Management
  7. Implementation Guide
  8. Security Best Practices

Overview ​

RecSys.AI implements a multi-tenant SaaS architecture with API key-based authentication for service-to-service communication and JWT-based authentication for the admin dashboard.

Key Features ​

  • βœ… Multi-Tenancy - Complete data isolation per tenant
  • βœ… Subscription Plans - 5 tiers (Free, Starter, Growth, Business, Enterprise)
  • βœ… API Key Authentication - Secure, revocable keys with expiration
  • βœ… Quota Management - Per-tenant rate limiting and usage tracking
  • βœ… Domain Whitelisting - Restrict API usage to approved domains
  • βœ… Usage Analytics - Real-time tracking of requests, items, and users

Multi-Tenancy Architecture ​

Data Model ​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   Tenant                          β”‚
β”‚  (Aggregate Root)                                β”‚
β”‚                                                   β”‚
β”‚  - TenantId (PK)                                 β”‚
β”‚  - Name, ContactEmail, CompanyName               β”‚
β”‚  - SubscriptionPlan (Free/Starter/Growth/...)    β”‚
β”‚  - SubscriptionStatus (Trial/Active/Suspended)   β”‚
β”‚  - Usage Metrics (Requests, Items, Users)        β”‚
β”‚  - Allowed Domains []                            β”‚
β”‚  - Created/Updated/Expiry Dates                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                β”‚ 1:N
                β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚                          β”‚
                β–Ό                          β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚      ApiKey           β”‚   β”‚   User/Item/etc     β”‚
β”‚  - Id (PK)            β”‚   β”‚   - tenant_id (FK)  β”‚
β”‚  - TenantId (FK)      β”‚   β”‚   - ... (data)      β”‚
β”‚  - Name               β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚  - KeyHash (SHA256)   β”‚
β”‚  - IsRevoked          β”‚
β”‚  - ExpiresAt          β”‚
β”‚  - TotalRequests      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Tenant Entity ​

File: RecSysAI.Domain/Entities/Tenant.cs

csharp
public sealed class Tenant : Entity<TenantId>, IAggregateRoot
{
    // Identity
    public string Name { get; }
    public string ContactEmail { get; }
    public string? CompanyName { get; }
    
    // Subscription
    public SubscriptionPlan SubscriptionPlan { get; }
    public SubscriptionStatus Status { get; }
    
    // Usage Tracking
    public long TotalRequestsThisMonth { get; }
    public long TotalItemsCount { get; }
    public long TotalUsersCount { get; }
    
    // Limits (from subscription plan)
    public long MonthlyRequestLimit => SubscriptionPlan.MonthlyRequestLimit;
    public long MaxItemsLimit => SubscriptionPlan.MaxItemsLimit;
    public long MaxUsersLimit => SubscriptionPlan.MaxUsersLimit;
    
    // Collections
    public IReadOnlyCollection<string> AllowedDomains { get; }
    public IReadOnlyCollection<ApiKey> ApiKeys { get; }
    
    // Business Methods
    public bool CanMakeRequest();
    public void IncrementRequestCount();
    public ApiKey CreateApiKey(string name, DateTime? expiresAt = null);
    public void UpgradePlan(SubscriptionPlan newPlan);
    public void SuspendSubscription();
    // ... more methods
}

Subscription Plans ​

File: RecSysAI.Domain/ValueObjects/SubscriptionPlan.cs

PlanPrice/moRequests/moMax ItemsMax UsersSupport
Free$050,00010,0001,000Community
Starter$49200,00050,00010,000Email (48h)
Growth$1991,000,000200,00050,000Priority (24h)
Business$4995,000,0001,000,000200,000Dedicated (4h)
EnterpriseCustomUnlimitedUnlimitedUnlimited24/7

Implementation:

csharp
public sealed class SubscriptionPlan : ValueObject
{
    public static readonly SubscriptionPlan Free = new(
        name: "Free",
        level: 0,
        monthlyPrice: 0,
        monthlyRequestLimit: 50_000,
        maxItemsLimit: 10_000,
        maxUsersLimit: 1_000
    );
    
    public static readonly SubscriptionPlan Starter = new(
        name: "Starter",
        level: 1,
        monthlyPrice: 49,
        monthlyRequestLimit: 200_000,
        maxItemsLimit: 50_000,
        maxUsersLimit: 10_000
    );
    
    // ... other plans
}

Authentication System ​

API Key Authentication Flow ​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Client    β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
       β”‚ 1. HTTP Request
       β”‚    Header: X-Api-Key: rsk_live_abc123...
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     Authentication Middleware                 β”‚
β”‚                                               β”‚
β”‚  1. Extract API key from X-Api-Key header    β”‚
β”‚  2. Hash the key (SHA256)                    β”‚
β”‚  3. Query ApiKey by hash                     β”‚
β”‚  4. Validate:                                β”‚
β”‚     - Key exists?                            β”‚
β”‚     - Not revoked?                           β”‚
β”‚     - Not expired?                           β”‚
β”‚  5. Load Tenant                              β”‚
β”‚  6. Validate:                                β”‚
β”‚     - Tenant IsActive?                       β”‚
β”‚     - Subscription Active?                   β”‚
β”‚     - Within quota limits?                   β”‚
β”‚  7. Set HttpContext.Items["TenantId"]        β”‚
β”‚  8. Set HttpContext.Items["Tenant"]          β”‚
β”‚  9. Record API key usage                     β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚ 2. Authenticated request
       β”‚    with TenantId in context
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         Recommendation Controller             β”‚
β”‚                                               β”‚
β”‚  - Access Tenant from HttpContext            β”‚
β”‚  - Filter queries by TenantId                β”‚
β”‚  - Enforce data isolation                    β”‚
β”‚  - Increment request counter                 β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚ 3. Query with tenant_id filter
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Database                         β”‚
β”‚  SELECT * FROM users                          β”‚
β”‚  WHERE tenant_id = ?                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

API Key Format ​

Format: rsk_live_<24 random characters>

Example: rsk_live_xJ8Kp2mNq5RtYz3WvBc9

Breakdown:

  • rsk - RecSys Key prefix
  • live - Environment (live/test)
  • _ - Separator
  • <random> - 24 cryptographically secure random characters

Generation:

csharp
private static string GenerateApiKey()
{
    const string prefix = "rsk_live_";
    const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    var random = new byte[24];
    
    using (var rng = RandomNumberGenerator.Create())
    {
        rng.GetBytes(random);
    }

    var sb = new StringBuilder(prefix);
    foreach (var b in random)
    {
        sb.Append(chars[b % chars.Length]);
    }

    return sb.ToString();
}

Storage: Keys are hashed with SHA256 before storing in database. Plain keys are shown ONCE to the user during creation.

csharp
private static string HashApiKey(string apiKey)
{
    using var sha256 = SHA256.Create();
    var bytes = Encoding.UTF8.GetBytes(apiKey);
    var hash = sha256.ComputeHash(bytes);
    return Convert.ToBase64String(hash);
}

Authorization & Access Control ​

Access Control Model ​

RecSys.AI uses a Role-Based Access Control (RBAC) model within each tenant:

Tenant
β”œβ”€β”€ Owner (Full access)
β”‚   └── Can manage billing, invite users, delete tenant
β”œβ”€β”€ Admin (Manage access)
β”‚   └── Can manage API keys, view analytics, configure settings
β”œβ”€β”€ Developer (API access)
β”‚   └── Can view API keys, make API requests, view documentation
└── Viewer (Read-only)
    └── Can view dashboards and reports

Data Isolation Strategy ​

Row-Level Security (RLS):

Every entity that belongs to a tenant has a tenant_id foreign key:

sql
-- Users table
CREATE TABLE recsys.users (
    id VARCHAR(100) PRIMARY KEY,
    tenant_id VARCHAR(100) NOT NULL REFERENCES recsys.tenants(id) ON DELETE CASCADE,
    metadata JSONB,
    features JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Create index for tenant-scoped queries
CREATE INDEX idx_users_tenant_id ON recsys.users(tenant_id);

-- PostgreSQL Row-Level Security (optional additional layer)
ALTER TABLE recsys.users ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_policy ON recsys.users
    USING (tenant_id = current_setting('app.current_tenant_id')::VARCHAR);

Application-Level Filtering:

All repository queries automatically filter by TenantId:

csharp
public async Task<IReadOnlyList<User>> GetAllAsync(
    TenantId tenantId, 
    CancellationToken cancellationToken = default)
{
    return await _context.Users
        .Where(u => u.TenantId == tenantId) // Automatic filtering
        .ToListAsync(cancellationToken);
}

Query Filters (Global Filter) ​

EF Core Global Query Filters ensure tenant isolation:

csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Automatically filter all User queries by TenantId
    modelBuilder.Entity<User>().HasQueryFilter(u => 
        u.TenantId == _currentTenantId);
    
    modelBuilder.Entity<Item>().HasQueryFilter(i => 
        i.TenantId == _currentTenantId);
    
    modelBuilder.Entity<Interaction>().HasQueryFilter(i => 
        i.TenantId == _currentTenantId);
}

Subscription Management ​

Lifecycle Management ​

1. Sign-Up (Trial Period) ​

csharp
// Create new tenant with 30-day trial
var tenant = Tenant.Create(
    tenantId: "acme_corp",
    name: "Acme Corporation",
    contactEmail: "admin@acme.com",
    plan: SubscriptionPlan.Free
);

// Auto-generated properties:
// - Status = SubscriptionStatus.Trial
// - TrialEndsAt = DateTime.UtcNow.AddDays(30)
// - IsActive = true

2. Trial Conversion ​

csharp
// Upgrade to paid plan
tenant.UpgradePlan(SubscriptionPlan.Starter);

// Result:
// - Status = SubscriptionStatus.Active
// - SubscriptionStartedAt = DateTime.UtcNow
// - SubscriptionEndsAt = DateTime.UtcNow.AddMonths(1)
// - TrialEndsAt = null (trial ended)

3. Monthly Renewal ​

csharp
// Renew subscription (typically triggered by successful payment)
tenant.RenewSubscription();

// Result:
// - SubscriptionEndsAt += 1 month
// - TotalRequestsThisMonth = 0 (reset counter)

4. Suspension (Payment Failed) ​

csharp
// Suspend due to payment failure
tenant.SuspendSubscription();

// Result:
// - Status = SubscriptionStatus.Suspended
// - IsActive = false
// - API requests blocked

5. Reactivation ​

csharp
// Reactivate after payment resolved
tenant.ReactivateSubscription();

// Result:
// - Status = SubscriptionStatus.Active
// - IsActive = true

6. Cancellation ​

csharp
// Cancel subscription
tenant.CancelSubscription();

// Result:
// - Status = SubscriptionStatus.Cancelled
// - IsActive = false
// - SubscriptionEndsAt = DateTime.UtcNow

Quota Enforcement ​

Request Quota ​

csharp
// Before processing API request
if (!tenant.CanMakeRequest())
{
    // Returns 429 Too Many Requests
    return Results.Problem(
        statusCode: 429,
        title: "Quota Exceeded",
        detail: $"Monthly limit of {tenant.MonthlyRequestLimit:N0} requests exceeded. " +
                $"Upgrade your plan at https://recsys.ai/pricing"
    );
}

// Process request
tenant.IncrementRequestCount(); // Thread-safe increment
await _unitOfWork.SaveChangesAsync();

Items Quota ​

csharp
// Before adding new item
if (tenant.TotalItemsCount >= tenant.MaxItemsLimit)
{
    return Results.Problem(
        statusCode: 403,
        title: "Item Limit Reached",
        detail: $"Your plan allows up to {tenant.MaxItemsLimit:N0} items. " +
                $"Current count: {tenant.TotalItemsCount:N0}. " +
                $"Upgrade to add more items."
    );
}

// Add item
var item = Item.Create(...);
tenant.UpdateUsageMetrics(tenant.TotalItemsCount + 1, tenant.TotalUsersCount);

Background Jobs ​

Daily Quota Reset ​

csharp
// Run daily at midnight UTC
public async Task ResetMonthlyQuotasAsync()
{
    var tenantsToReset = await _tenantRepository.GetTenantsForQuotaResetAsync();
    
    foreach (var tenant in tenantsToReset)
    {
        if (tenant.SubscriptionEndsAt <= DateTime.UtcNow.AddDays(1))
        {
            // Subscription ending soon, renew or suspend
            if (await _billingService.AttemptRenewalAsync(tenant.Id))
            {
                tenant.RenewSubscription();
            }
            else
            {
                tenant.SuspendSubscription();
            }
        }
    }
    
    await _unitOfWork.SaveChangesAsync();
}

Trial Expiration Warnings ​

csharp
// Run daily
public async Task SendTrialExpirationWarningsAsync()
{
    var expiringTrials = await _tenantRepository.GetExpiringTrialsAsync(daysUntilExpiry: 7);
    
    foreach (var tenant in expiringTrials)
    {
        await _emailService.SendAsync(new TrialExpirationEmail
        {
            To = tenant.ContactEmail,
            TenantName = tenant.Name,
            DaysRemaining = (tenant.TrialEndsAt!.Value - DateTime.UtcNow).Days
        });
    }
}

API Key Management ​

Creating API Keys ​

csharp
// In controller or service
var apiKey = tenant.CreateApiKey(
    name: "Production API Key",
    expiresAt: DateTime.UtcNow.AddYears(1) // Optional
);

// Store the plain key temporarily (only shown once!)
var plainKey = apiKey.PlainKeyOneTime; // "rsk_live_abc123..."

// Return to user (NEVER store plain key in database)
return Results.Ok(new
{
    id = apiKey.Id,
    key = plainKey, // ⚠️ SHOWN ONLY ONCE
    name = apiKey.Name,
    expiresAt = apiKey.ExpiresAt
});

// The database stores only the hash
// apiKey.KeyHash = "7X9K2mP5..." (SHA256 hash)

Validating API Keys ​

csharp
// In authentication middleware
public async Task<Tenant?> AuthenticateAsync(string plainApiKey)
{
    // 1. Hash the incoming key
    var keyHash = ApiKey.HashApiKey(plainApiKey);
    
    // 2. Find the API key by hash
    var apiKey = await _apiKeyRepository.GetByKeyHashAsync(keyHash);
    if (apiKey == null)
        return null;
    
    // 3. Validate the key
    if (!apiKey.IsValid()) // Checks IsRevoked and ExpiresAt
        return null;
    
    // 4. Load the tenant
    var tenant = await _tenantRepository.GetByIdAsync(apiKey.TenantId);
    if (tenant == null || !tenant.IsActive)
        return null;
    
    // 5. Record usage
    apiKey.RecordUsage();
    await _unitOfWork.SaveChangesAsync();
    
    return tenant;
}

Revoking API Keys ​

csharp
// Revoke a specific key
tenant.RevokeApiKey(apiKeyId);
await _unitOfWork.SaveChangesAsync();

// Result:
// - apiKey.IsRevoked = true
// - Future authentication attempts will fail

Rotating API Keys ​

csharp
// Best practice: Rotate keys every 90 days
public async Task RotateApiKeyAsync(Guid oldKeyId)
{
    var tenant = await _tenantRepository.GetByApiKeyIdAsync(oldKeyId);
    
    // 1. Create new key
    var newApiKey = tenant.CreateApiKey(
        name: "Rotated Production Key",
        expiresAt: DateTime.UtcNow.AddYears(1)
    );
    
    // 2. Grace period (both keys work for 7 days)
    await Task.Delay(TimeSpan.FromDays(7));
    
    // 3. Revoke old key
    tenant.RevokeApiKey(oldKeyId);
    
    await _unitOfWork.SaveChangesAsync();
    
    return newApiKey;
}

Implementation Guide ​

Step 1: Database Migrations ​

bash
# Add migrations for new entities
cd recommendai-api/RecSysAI.Infrastructure
dotnet ef migrations add AddMultiTenancy --startup-project ../RecommendAI.Api

# Review generated migration, then apply
dotnet ef database update --startup-project ../RecommendAI.Api

Expected Tables:

  • recsys.tenants
  • recsys.api_keys
  • Updated: recsys.users, recsys.items, recsys.interactions (add tenant_id FK)

Step 2: EF Core Configurations ​

TenantConfiguration.cs:

csharp
public class TenantConfiguration : IEntityTypeConfiguration<Tenant>
{
    public void Configure(EntityTypeBuilder<Tenant> builder)
    {
        builder.ToTable("tenants", "recsys");
        
        builder.HasKey(t => t.Id);
        builder.Property(t => t.Id)
            .HasConversion(
                id => id.Value,
                value => TenantId.Create(value))
            .HasMaxLength(100);
        
        builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
        builder.Property(t => t.ContactEmail).IsRequired().HasMaxLength(255);
        
        builder.OwnsOne(t => t.SubscriptionPlan, plan =>
        {
            plan.Property(p => p.Name).HasColumnName("plan_name").HasMaxLength(50);
            plan.Property(p => p.Level).HasColumnName("plan_level");
            plan.Property(p => p.MonthlyPrice).HasColumnName("monthly_price").HasColumnType("decimal(10,2)");
        });
        
        builder.Property(t => t.Status)
            .HasConversion<string>()
            .HasMaxLength(20);
        
        builder.Property(t => t.AllowedDomains)
            .HasConversion(
                v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null!),
                v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions)null!) ?? new List<string>())
            .HasColumnType("jsonb");
        
        builder.HasMany(t => t.ApiKeys)
            .WithOne(k => k.Tenant)
            .HasForeignKey(k => k.TenantId);
        
        builder.HasIndex(t => t.ContactEmail);
        builder.HasIndex(t => t.IsActive);
        builder.HasIndex(t => t.Status);
    }
}

ApiKeyConfiguration.cs:

csharp
public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
{
    public void Configure(EntityTypeBuilder<ApiKey> builder)
    {
        builder.ToTable("api_keys", "recsys");
        
        builder.HasKey(k => k.Id);
        
        builder.Property(k => k.TenantId)
            .HasConversion(
                id => id.Value,
                value => TenantId.Create(value))
            .IsRequired();
        
        builder.Property(k => k.Name).IsRequired().HasMaxLength(200);
        builder.Property(k => k.KeyHash).IsRequired().HasMaxLength(64);
        builder.Property(k => k.IsRevoked).IsRequired();
        
        builder.HasIndex(k => k.KeyHash).IsUnique();
        builder.HasIndex(k => k.TenantId);
        builder.HasIndex(k => k.ExpiresAt);
    }
}

Step 3: Repository Implementations ​

TenantRepository.cs:

csharp
internal sealed class TenantRepository : ITenantRepository
{
    private readonly RecSysAIDbContext _context;
    
    public TenantRepository(RecSysAIDbContext context)
    {
        _context = context;
    }
    
    public async Task<Tenant?> GetByIdAsync(TenantId id, CancellationToken cancellationToken = default)
    {
        return await _context.Tenants
            .Include(t => t.ApiKeys)
            .FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
    }
    
    public async Task<Tenant?> GetByApiKeyAsync(string apiKeyHash, CancellationToken cancellationToken = default)
    {
        return await _context.Tenants
            .Include(t => t.ApiKeys)
            .FirstOrDefaultAsync(t => t.ApiKeys.Any(k => k.KeyHash == apiKeyHash), cancellationToken);
    }
    
    public async Task<IReadOnlyList<Tenant>> GetActiveTenantsAsync(CancellationToken cancellationToken = default)
    {
        return await _context.Tenants
            .Where(t => t.IsActive && t.Status == SubscriptionStatus.Active)
            .ToListAsync(cancellationToken);
    }
    
    // ... more methods
}

Step 4: Authentication Middleware ​

ApiKeyAuthenticationMiddleware.cs:

csharp
public class ApiKeyAuthenticationMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ApiKeyAuthenticationMiddleware> _logger;
    
    public ApiKeyAuthenticationMiddleware(
        RequestDelegate next,
        ILogger<ApiKeyAuthenticationMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
    
    public async Task InvokeAsync(HttpContext context, ITenantRepository tenantRepository)
    {
        // 1. Extract API key from header
        if (!context.Request.Headers.TryGetValue("X-Api-Key", out var apiKeyValue))
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsJsonAsync(new 
            { 
                error = "Missing API key", 
                message = "Provide X-Api-Key header" 
            });
            return;
        }
        
        var plainKey = apiKeyValue.ToString();
        
        // 2. Hash and validate
        var keyHash = ApiKey.HashApiKey(plainKey);
        var apiKey = await apiKeyRepository.GetByKeyHashAsync(keyHash);
        
        if (apiKey == null || !apiKey.IsValid())
        {
            _logger.LogWarning("Invalid API key attempt");
            context.Response.StatusCode = 401;
            await context.Response.WriteAsJsonAsync(new 
            { 
                error = "Invalid API key", 
                message = "Key not found, revoked, or expired" 
            });
            return;
        }
        
        // 3. Load tenant
        var tenant = await tenantRepository.GetByIdAsync(apiKey.TenantId);
        
        if (tenant == null || !tenant.CanMakeRequest())
        {
            _logger.LogWarning("Tenant {TenantId} cannot make request", apiKey.TenantId);
            context.Response.StatusCode = 429;
            await context.Response.WriteAsJsonAsync(new 
            { 
                error = "Quota exceeded", 
                message = $"Monthly limit of {tenant?.MonthlyRequestLimit:N0} reached" 
            });
            return;
        }
        
        // 4. Set tenant context
        context.Items["TenantId"] = tenant.Id;
        context.Items["Tenant"] = tenant;
        
        // 5. Record usage
        apiKey.RecordUsage();
        tenant.IncrementRequestCount();
        
        // 6. Continue pipeline
        await _next(context);
        
        // 7. Save metrics (async, don't block response)
        _ = Task.Run(async () =>
        {
            using var scope = context.RequestServices.CreateScope();
            var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
            await unitOfWork.SaveChangesAsync();
        });
    }
}

Register in Program.cs:

csharp
app.UseMiddleware<ApiKeyAuthenticationMiddleware>();

Step 5: Update Existing Entities ​

User entity - add TenantId:

csharp
public sealed class User : Entity<UserId>, IAggregateRoot
{
    public TenantId TenantId { get; private init; } // ADD THIS
    
    // ... existing properties
    
    public static User Create(TenantId tenantId, string userId, string? metadata = null)
    {
        var id = UserId.Create(userId);
        return new User(tenantId, id, metadata);
    }
}

Similar updates for Item, Interaction entities.


Security Best Practices ​

1. API Key Security ​

βœ… Do:

  • Hash keys with SHA256 before storing
  • Show plain keys only once during creation
  • Implement key rotation (90-day policy)
  • Support key expiration
  • Allow immediate revocation
  • Use cryptographically secure random generation

❌ Don't:

  • Store plain keys in database
  • Log API keys in plain text
  • Transmit keys in URLs (use headers)
  • Use weak hashing algorithms (MD5, SHA1)
  • Allow unlimited key lifetime

2. Tenant Isolation ​

βœ… Do:

  • Always filter queries by tenant_id
  • Use EF Core global query filters
  • Implement PostgreSQL RLS as defense-in-depth
  • Validate tenant ownership in business logic
  • Test cross-tenant access attempts

❌ Don't:

  • Rely solely on application-level filtering
  • Use user-provided tenant IDs directly
  • Skip tenant validation in async operations
  • Allow tenant ID changes after creation

3. Rate Limiting ​

βœ… Do:

  • Enforce per-tenant quotas
  • Track usage in real-time
  • Provide clear error messages
  • Offer quota upgrade paths
  • Reset counters on billing cycle

❌ Don't:

  • Use global rate limits only
  • Ignore burst traffic patterns
  • Block legitimate traffic aggressively
  • Forget to handle clock skew

4. Secrets Management ​

βœ… Do:

  • Use HashiCorp Vault or AWS Secrets Manager
  • Rotate secrets regularly
  • Encrypt secrets at rest
  • Use environment variables for config
  • Audit secret access

❌ Don't:

  • Hardcode secrets in code
  • Commit secrets to Git
  • Share secrets via email/Slack
  • Use weak encryption

Summary ​

We've Implemented:

  • βœ… Multi-tenant domain model (Tenant, ApiKey entities)
  • βœ… Subscription management system (5 plans)
  • βœ… API key generation and hashing
  • βœ… Quota tracking and enforcement logic
  • βœ… Repository interfaces

Still Need to Implement:

  • ⚠️ Authentication middleware
  • ⚠️ Database migrations
  • ⚠️ Repository implementations
  • ⚠️ Tenant context injection
  • ⚠️ Query filters for existing entities

Estimated Time to Complete: 1-2 weeks for full implementation and testing.


Documentation Version: 1.0.0
Last Updated: January 3, 2026
Next Review: After middleware implementation

Plug-and-play AI recommendations for B2B SaaS.