π RecSys.AI - Authentication & Authorization Guide β
Version: 1.0.0
Last Updated: January 3, 2026
Status: Implementation Guide
Table of Contents β
- Overview
- Multi-Tenancy Architecture
- Authentication System
- Authorization & Access Control
- Subscription Management
- API Key Management
- Implementation Guide
- 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
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
| Plan | Price/mo | Requests/mo | Max Items | Max Users | Support |
|---|---|---|---|---|---|
| Free | $0 | 50,000 | 10,000 | 1,000 | Community |
| Starter | $49 | 200,000 | 50,000 | 10,000 | Email (48h) |
| Growth | $199 | 1,000,000 | 200,000 | 50,000 | Priority (24h) |
| Business | $499 | 5,000,000 | 1,000,000 | 200,000 | Dedicated (4h) |
| Enterprise | Custom | Unlimited | Unlimited | Unlimited | 24/7 |
Implementation:
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 prefixlive- Environment (live/test)_- Separator<random>- 24 cryptographically secure random characters
Generation:
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.
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 reportsData Isolation Strategy β
Row-Level Security (RLS):
Every entity that belongs to a tenant has a tenant_id foreign key:
-- 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:
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:
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) β
// 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 = true2. Trial Conversion β
// 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 β
// Renew subscription (typically triggered by successful payment)
tenant.RenewSubscription();
// Result:
// - SubscriptionEndsAt += 1 month
// - TotalRequestsThisMonth = 0 (reset counter)4. Suspension (Payment Failed) β
// Suspend due to payment failure
tenant.SuspendSubscription();
// Result:
// - Status = SubscriptionStatus.Suspended
// - IsActive = false
// - API requests blocked5. Reactivation β
// Reactivate after payment resolved
tenant.ReactivateSubscription();
// Result:
// - Status = SubscriptionStatus.Active
// - IsActive = true6. Cancellation β
// Cancel subscription
tenant.CancelSubscription();
// Result:
// - Status = SubscriptionStatus.Cancelled
// - IsActive = false
// - SubscriptionEndsAt = DateTime.UtcNowQuota Enforcement β
Request Quota β
// 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 β
// 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 β
// 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 β
// 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 β
// 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 β
// 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 β
// Revoke a specific key
tenant.RevokeApiKey(apiKeyId);
await _unitOfWork.SaveChangesAsync();
// Result:
// - apiKey.IsRevoked = true
// - Future authentication attempts will failRotating API Keys β
// 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 β
# 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.ApiExpected Tables:
recsys.tenantsrecsys.api_keys- Updated:
recsys.users,recsys.items,recsys.interactions(addtenant_idFK)
Step 2: EF Core Configurations β
TenantConfiguration.cs:
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:
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:
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:
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:
app.UseMiddleware<ApiKeyAuthenticationMiddleware>();Step 5: Update Existing Entities β
User entity - add TenantId:
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