2110 lines
68 KiB
Markdown
2110 lines
68 KiB
Markdown
# Multi-Tenancy Architecture
|
|
|
|
## Table of Contents
|
|
|
|
1. [Architecture Overview](#architecture-overview)
|
|
2. [Tenant Aggregate Root Design](#tenant-aggregate-root-design)
|
|
3. [User Aggregate Root Adjustment](#user-aggregate-root-adjustment)
|
|
4. [TenantContext Service](#tenantcontext-service)
|
|
5. [EF Core Global Query Filter](#ef-core-global-query-filter)
|
|
6. [Tenant Resolution Middleware](#tenant-resolution-middleware)
|
|
7. [Database Schema](#database-schema)
|
|
8. [Application Layer Commands/Queries](#application-layer-commandsqueries)
|
|
9. [Security Protection](#security-protection)
|
|
10. [Testing Strategy](#testing-strategy)
|
|
|
|
---
|
|
|
|
## Architecture Overview
|
|
|
|
### Multi-Tenancy Pattern
|
|
|
|
ColaFlow uses the **Shared Database with Discriminator** pattern:
|
|
|
|
- **Single Database**: All tenants share the same PostgreSQL database
|
|
- **Tenant Isolation**: Every table has a `tenant_id` column for data segregation
|
|
- **Automatic Filtering**: EF Core Global Query Filters ensure tenant isolation
|
|
- **Performance**: Optimized with composite indexes on (tenant_id + business_key)
|
|
|
|
```mermaid
|
|
graph TB
|
|
A[User Request] --> B{Subdomain Detection}
|
|
B --> C[acme.colaflow.com]
|
|
B --> D[beta.colaflow.com]
|
|
C --> E[Extract tenant_slug='acme']
|
|
D --> F[Extract tenant_slug='beta']
|
|
E --> G[Query tenants table]
|
|
F --> G
|
|
G --> H[Inject tenant_id into JWT]
|
|
H --> I[EF Core Global Filter]
|
|
I --> J[WHERE tenant_id = current_tenant]
|
|
```
|
|
|
|
### System Components
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ HTTP Request Layer │
|
|
│ (Subdomain: acme.colaflow.com / Header: X-Tenant-Id) │
|
|
└────────────────────────┬────────────────────────────────────┘
|
|
│
|
|
┌────────────────────────▼────────────────────────────────────┐
|
|
│ TenantResolutionMiddleware │
|
|
│ 1. Parse subdomain or custom header │
|
|
│ 2. Query tenants table by slug │
|
|
│ 3. Validate tenant status (Active/Suspended) │
|
|
│ 4. Inject TenantContext (Scoped) │
|
|
└────────────────────────┬────────────────────────────────────┘
|
|
│
|
|
┌────────────────────────▼────────────────────────────────────┐
|
|
│ TenantContext Service │
|
|
│ - CurrentTenantId: Guid │
|
|
│ - CurrentTenantSlug: string │
|
|
│ - CurrentTenantPlan: SubscriptionPlan │
|
|
└────────────────────────┬────────────────────────────────────┘
|
|
│
|
|
┌────────────────────────▼────────────────────────────────────┐
|
|
│ EF Core Global Query Filter │
|
|
│ - Intercepts ALL queries │
|
|
│ - Automatically appends: WHERE tenant_id = {current} │
|
|
│ - Applied to: Users, Projects, Issues, Documents, etc. │
|
|
└────────────────────────┬────────────────────────────────────┘
|
|
│
|
|
┌────────────────────────▼────────────────────────────────────┐
|
|
│ Database Layer │
|
|
│ - All tables have tenant_id column (NOT NULL) │
|
|
│ - Composite indexes: (tenant_id, created_at), etc. │
|
|
│ - Foreign keys include tenant validation │
|
|
└──────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Tenant Aggregate Root Design
|
|
|
|
### Tenant Entity (Domain Layer)
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/Tenant.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Common;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.Events;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
|
|
/// <summary>
|
|
/// Tenant aggregate root - represents a single organization/company in the system
|
|
/// </summary>
|
|
public sealed class Tenant : AggregateRoot<TenantId>
|
|
{
|
|
// Properties
|
|
public TenantName Name { get; private set; }
|
|
public TenantSlug Slug { get; private set; }
|
|
public TenantStatus Status { get; private set; }
|
|
public SubscriptionPlan Plan { get; private set; }
|
|
public SsoConfiguration? SsoConfig { get; private set; }
|
|
public DateTime CreatedAt { get; private set; }
|
|
public DateTime? SuspendedAt { get; private set; }
|
|
public string? SuspensionReason { get; private set; }
|
|
|
|
// Settings
|
|
public int MaxUsers { get; private set; }
|
|
public int MaxProjects { get; private set; }
|
|
public int MaxStorageGB { get; private set; }
|
|
|
|
// Private constructor for EF Core
|
|
private Tenant() { }
|
|
|
|
// Factory method for creating new tenant
|
|
public static Tenant Create(
|
|
TenantName name,
|
|
TenantSlug slug,
|
|
SubscriptionPlan plan = SubscriptionPlan.Free)
|
|
{
|
|
var tenant = new Tenant
|
|
{
|
|
Id = TenantId.CreateUnique(),
|
|
Name = name,
|
|
Slug = slug,
|
|
Status = TenantStatus.Active,
|
|
Plan = plan,
|
|
CreatedAt = DateTime.UtcNow,
|
|
MaxUsers = GetMaxUsersByPlan(plan),
|
|
MaxProjects = GetMaxProjectsByPlan(plan),
|
|
MaxStorageGB = GetMaxStorageByPlan(plan)
|
|
};
|
|
|
|
tenant.AddDomainEvent(new TenantCreatedEvent(tenant.Id, tenant.Slug));
|
|
|
|
return tenant;
|
|
}
|
|
|
|
// Business methods
|
|
public void UpdateName(TenantName newName)
|
|
{
|
|
if (Status == TenantStatus.Cancelled)
|
|
throw new InvalidOperationException("Cannot update cancelled tenant");
|
|
|
|
Name = newName;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
public void UpgradePlan(SubscriptionPlan newPlan)
|
|
{
|
|
if (newPlan <= Plan)
|
|
throw new InvalidOperationException("New plan must be higher than current plan");
|
|
|
|
if (Status != TenantStatus.Active)
|
|
throw new InvalidOperationException("Only active tenants can upgrade");
|
|
|
|
Plan = newPlan;
|
|
MaxUsers = GetMaxUsersByPlan(newPlan);
|
|
MaxProjects = GetMaxProjectsByPlan(newPlan);
|
|
MaxStorageGB = GetMaxStorageByPlan(newPlan);
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new TenantPlanUpgradedEvent(Id, newPlan));
|
|
}
|
|
|
|
public void ConfigureSso(SsoConfiguration ssoConfig)
|
|
{
|
|
if (Plan == SubscriptionPlan.Free)
|
|
throw new InvalidOperationException("SSO is only available for Pro and Enterprise plans");
|
|
|
|
SsoConfig = ssoConfig;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new TenantSsoConfiguredEvent(Id, ssoConfig.Provider));
|
|
}
|
|
|
|
public void Suspend(string reason)
|
|
{
|
|
if (Status == TenantStatus.Cancelled)
|
|
throw new InvalidOperationException("Cannot suspend cancelled tenant");
|
|
|
|
Status = TenantStatus.Suspended;
|
|
SuspendedAt = DateTime.UtcNow;
|
|
SuspensionReason = reason;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new TenantSuspendedEvent(Id, reason));
|
|
}
|
|
|
|
public void Reactivate()
|
|
{
|
|
if (Status != TenantStatus.Suspended)
|
|
throw new InvalidOperationException("Only suspended tenants can be reactivated");
|
|
|
|
Status = TenantStatus.Active;
|
|
SuspendedAt = null;
|
|
SuspensionReason = null;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new TenantReactivatedEvent(Id));
|
|
}
|
|
|
|
public void Cancel()
|
|
{
|
|
Status = TenantStatus.Cancelled;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new TenantCancelledEvent(Id));
|
|
}
|
|
|
|
// Plan limits
|
|
private static int GetMaxUsersByPlan(SubscriptionPlan plan) => plan switch
|
|
{
|
|
SubscriptionPlan.Free => 5,
|
|
SubscriptionPlan.Pro => 50,
|
|
SubscriptionPlan.Enterprise => int.MaxValue,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(plan))
|
|
};
|
|
|
|
private static int GetMaxProjectsByPlan(SubscriptionPlan plan) => plan switch
|
|
{
|
|
SubscriptionPlan.Free => 3,
|
|
SubscriptionPlan.Pro => 100,
|
|
SubscriptionPlan.Enterprise => int.MaxValue,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(plan))
|
|
};
|
|
|
|
private static int GetMaxStorageByPlan(SubscriptionPlan plan) => plan switch
|
|
{
|
|
SubscriptionPlan.Free => 2,
|
|
SubscriptionPlan.Pro => 100,
|
|
SubscriptionPlan.Enterprise => 1000,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(plan))
|
|
};
|
|
}
|
|
```
|
|
|
|
### Value Objects
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/ValueObjects/TenantId.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Common;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
public sealed class TenantId : ValueObject
|
|
{
|
|
public Guid Value { get; }
|
|
|
|
private TenantId(Guid value)
|
|
{
|
|
Value = value;
|
|
}
|
|
|
|
public static TenantId CreateUnique() => new(Guid.NewGuid());
|
|
|
|
public static TenantId Create(Guid value)
|
|
{
|
|
if (value == Guid.Empty)
|
|
throw new ArgumentException("Tenant ID cannot be empty", nameof(value));
|
|
|
|
return new TenantId(value);
|
|
}
|
|
|
|
protected override IEnumerable<object> GetEqualityComponents()
|
|
{
|
|
yield return Value;
|
|
}
|
|
|
|
public override string ToString() => Value.ToString();
|
|
|
|
// Implicit conversion
|
|
public static implicit operator Guid(TenantId tenantId) => tenantId.Value;
|
|
}
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/ValueObjects/TenantName.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Common;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
public sealed class TenantName : ValueObject
|
|
{
|
|
public string Value { get; }
|
|
|
|
private TenantName(string value)
|
|
{
|
|
Value = value;
|
|
}
|
|
|
|
public static TenantName Create(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
throw new ArgumentException("Tenant name cannot be empty", nameof(value));
|
|
|
|
if (value.Length < 2)
|
|
throw new ArgumentException("Tenant name must be at least 2 characters", nameof(value));
|
|
|
|
if (value.Length > 100)
|
|
throw new ArgumentException("Tenant name cannot exceed 100 characters", nameof(value));
|
|
|
|
return new TenantName(value.Trim());
|
|
}
|
|
|
|
protected override IEnumerable<object> GetEqualityComponents()
|
|
{
|
|
yield return Value;
|
|
}
|
|
|
|
public override string ToString() => Value;
|
|
|
|
// Implicit conversion
|
|
public static implicit operator string(TenantName name) => name.Value;
|
|
}
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/ValueObjects/TenantSlug.cs`
|
|
|
|
```csharp
|
|
using System.Text.RegularExpressions;
|
|
using ColaFlow.Domain.Common;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
public sealed class TenantSlug : ValueObject
|
|
{
|
|
private static readonly Regex SlugRegex = new(@"^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.Compiled);
|
|
|
|
public string Value { get; }
|
|
|
|
private TenantSlug(string value)
|
|
{
|
|
Value = value;
|
|
}
|
|
|
|
public static TenantSlug Create(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
throw new ArgumentException("Tenant slug cannot be empty", nameof(value));
|
|
|
|
value = value.ToLowerInvariant().Trim();
|
|
|
|
if (value.Length < 3)
|
|
throw new ArgumentException("Tenant slug must be at least 3 characters", nameof(value));
|
|
|
|
if (value.Length > 50)
|
|
throw new ArgumentException("Tenant slug cannot exceed 50 characters", nameof(value));
|
|
|
|
if (!SlugRegex.IsMatch(value))
|
|
throw new ArgumentException("Tenant slug can only contain lowercase letters, numbers, and hyphens", nameof(value));
|
|
|
|
// Reserved slugs
|
|
var reservedSlugs = new[] { "www", "api", "admin", "app", "dashboard", "docs", "blog", "support" };
|
|
if (reservedSlugs.Contains(value))
|
|
throw new ArgumentException($"Tenant slug '{value}' is reserved", nameof(value));
|
|
|
|
return new TenantSlug(value);
|
|
}
|
|
|
|
protected override IEnumerable<object> GetEqualityComponents()
|
|
{
|
|
yield return Value;
|
|
}
|
|
|
|
public override string ToString() => Value;
|
|
|
|
// Implicit conversion
|
|
public static implicit operator string(TenantSlug slug) => slug.Value;
|
|
}
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/ValueObjects/SsoConfiguration.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Common;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
public sealed class SsoConfiguration : ValueObject
|
|
{
|
|
public SsoProvider Provider { get; }
|
|
public string Authority { get; }
|
|
public string ClientId { get; }
|
|
public string ClientSecret { get; } // Encrypted in database
|
|
public string? MetadataUrl { get; }
|
|
|
|
// SAML-specific
|
|
public string? EntityId { get; }
|
|
public string? SignOnUrl { get; }
|
|
public string? Certificate { get; }
|
|
|
|
private SsoConfiguration(
|
|
SsoProvider provider,
|
|
string authority,
|
|
string clientId,
|
|
string clientSecret,
|
|
string? metadataUrl = null,
|
|
string? entityId = null,
|
|
string? signOnUrl = null,
|
|
string? certificate = null)
|
|
{
|
|
Provider = provider;
|
|
Authority = authority;
|
|
ClientId = clientId;
|
|
ClientSecret = clientSecret;
|
|
MetadataUrl = metadataUrl;
|
|
EntityId = entityId;
|
|
SignOnUrl = signOnUrl;
|
|
Certificate = certificate;
|
|
}
|
|
|
|
public static SsoConfiguration CreateOidc(
|
|
SsoProvider provider,
|
|
string authority,
|
|
string clientId,
|
|
string clientSecret,
|
|
string? metadataUrl = null)
|
|
{
|
|
if (provider == SsoProvider.GenericSaml)
|
|
throw new ArgumentException("Use CreateSaml for SAML configuration");
|
|
|
|
if (string.IsNullOrWhiteSpace(authority))
|
|
throw new ArgumentException("Authority is required", nameof(authority));
|
|
|
|
if (string.IsNullOrWhiteSpace(clientId))
|
|
throw new ArgumentException("Client ID is required", nameof(clientId));
|
|
|
|
if (string.IsNullOrWhiteSpace(clientSecret))
|
|
throw new ArgumentException("Client secret is required", nameof(clientSecret));
|
|
|
|
return new SsoConfiguration(provider, authority, clientId, clientSecret, metadataUrl);
|
|
}
|
|
|
|
public static SsoConfiguration CreateSaml(
|
|
string entityId,
|
|
string signOnUrl,
|
|
string certificate,
|
|
string? metadataUrl = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(entityId))
|
|
throw new ArgumentException("Entity ID is required", nameof(entityId));
|
|
|
|
if (string.IsNullOrWhiteSpace(signOnUrl))
|
|
throw new ArgumentException("Sign-on URL is required", nameof(signOnUrl));
|
|
|
|
if (string.IsNullOrWhiteSpace(certificate))
|
|
throw new ArgumentException("Certificate is required", nameof(certificate));
|
|
|
|
return new SsoConfiguration(
|
|
SsoProvider.GenericSaml,
|
|
signOnUrl,
|
|
entityId,
|
|
string.Empty, // No client secret for SAML
|
|
metadataUrl,
|
|
entityId,
|
|
signOnUrl,
|
|
certificate);
|
|
}
|
|
|
|
protected override IEnumerable<object> GetEqualityComponents()
|
|
{
|
|
yield return Provider;
|
|
yield return Authority;
|
|
yield return ClientId;
|
|
yield return EntityId ?? string.Empty;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Enumerations
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/Enums.cs`
|
|
|
|
```csharp
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
|
|
public enum TenantStatus
|
|
{
|
|
Active = 1,
|
|
Suspended = 2,
|
|
Cancelled = 3
|
|
}
|
|
|
|
public enum SubscriptionPlan
|
|
{
|
|
Free = 1,
|
|
Pro = 2,
|
|
Enterprise = 3
|
|
}
|
|
|
|
public enum SsoProvider
|
|
{
|
|
AzureAD = 1,
|
|
Google = 2,
|
|
Okta = 3,
|
|
GenericSaml = 4
|
|
}
|
|
```
|
|
|
|
### Domain Events
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/Events/TenantCreatedEvent.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Common;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate.Events;
|
|
|
|
public sealed record TenantCreatedEvent(TenantId TenantId, TenantSlug Slug) : IDomainEvent;
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/Events/TenantSuspendedEvent.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Common;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate.Events;
|
|
|
|
public sealed record TenantSuspendedEvent(TenantId TenantId, string Reason) : IDomainEvent;
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/Events/TenantPlanUpgradedEvent.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Common;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate.Events;
|
|
|
|
public sealed record TenantPlanUpgradedEvent(TenantId TenantId, SubscriptionPlan NewPlan) : IDomainEvent;
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/Events/TenantSsoConfiguredEvent.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Common;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate.Events;
|
|
|
|
public sealed record TenantSsoConfiguredEvent(TenantId TenantId, SsoProvider Provider) : IDomainEvent;
|
|
```
|
|
|
|
---
|
|
|
|
## User Aggregate Root Adjustment
|
|
|
|
### Updated User Entity
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/UserAggregate/User.cs` (Updated)
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Common;
|
|
using ColaFlow.Domain.Aggregates.UserAggregate.Events;
|
|
using ColaFlow.Domain.Aggregates.UserAggregate.ValueObjects;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.UserAggregate;
|
|
|
|
/// <summary>
|
|
/// User aggregate root - now multi-tenant aware
|
|
/// </summary>
|
|
public sealed class User : AggregateRoot<UserId>
|
|
{
|
|
// Tenant association (NEW)
|
|
public TenantId TenantId { get; private set; }
|
|
|
|
// User identity
|
|
public Email Email { get; private set; }
|
|
public string PasswordHash { get; private set; }
|
|
public FullName FullName { get; private set; }
|
|
public UserStatus Status { get; private set; }
|
|
|
|
// SSO properties (NEW)
|
|
public AuthenticationProvider AuthProvider { get; private set; }
|
|
public string? ExternalUserId { get; private set; } // IdP user ID
|
|
public string? ExternalEmail { get; private set; } // Email from IdP
|
|
|
|
// Profile
|
|
public string? AvatarUrl { get; private set; }
|
|
public string? JobTitle { get; private set; }
|
|
public string? PhoneNumber { get; private set; }
|
|
|
|
// Timestamps
|
|
public DateTime CreatedAt { get; private set; }
|
|
public DateTime? LastLoginAt { get; private set; }
|
|
public DateTime? EmailVerifiedAt { get; private set; }
|
|
|
|
// Security
|
|
public string? EmailVerificationToken { get; private set; }
|
|
public string? PasswordResetToken { get; private set; }
|
|
public DateTime? PasswordResetTokenExpiresAt { get; private set; }
|
|
|
|
// Private constructor for EF Core
|
|
private User() { }
|
|
|
|
// Factory method for local authentication
|
|
public static User CreateLocal(
|
|
TenantId tenantId,
|
|
Email email,
|
|
string passwordHash,
|
|
FullName fullName)
|
|
{
|
|
var user = new User
|
|
{
|
|
Id = UserId.CreateUnique(),
|
|
TenantId = tenantId,
|
|
Email = email,
|
|
PasswordHash = passwordHash,
|
|
FullName = fullName,
|
|
Status = UserStatus.Active,
|
|
AuthProvider = AuthenticationProvider.Local,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
user.AddDomainEvent(new UserCreatedEvent(user.Id, user.Email, tenantId));
|
|
|
|
return user;
|
|
}
|
|
|
|
// Factory method for SSO authentication (NEW)
|
|
public static User CreateFromSso(
|
|
TenantId tenantId,
|
|
AuthenticationProvider provider,
|
|
string externalUserId,
|
|
Email email,
|
|
FullName fullName,
|
|
string? avatarUrl = null)
|
|
{
|
|
if (provider == AuthenticationProvider.Local)
|
|
throw new ArgumentException("Use CreateLocal for local authentication");
|
|
|
|
var user = new User
|
|
{
|
|
Id = UserId.CreateUnique(),
|
|
TenantId = tenantId,
|
|
Email = email,
|
|
PasswordHash = string.Empty, // No password for SSO users
|
|
FullName = fullName,
|
|
Status = UserStatus.Active,
|
|
AuthProvider = provider,
|
|
ExternalUserId = externalUserId,
|
|
ExternalEmail = email,
|
|
AvatarUrl = avatarUrl,
|
|
CreatedAt = DateTime.UtcNow,
|
|
EmailVerifiedAt = DateTime.UtcNow // Trust IdP verification
|
|
};
|
|
|
|
user.AddDomainEvent(new UserCreatedViaSsoEvent(user.Id, user.Email, tenantId, provider));
|
|
|
|
return user;
|
|
}
|
|
|
|
// Business methods
|
|
public void UpdatePassword(string newPasswordHash)
|
|
{
|
|
if (AuthProvider != AuthenticationProvider.Local)
|
|
throw new InvalidOperationException("Cannot change password for SSO users");
|
|
|
|
PasswordHash = newPasswordHash;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
public void UpdateProfile(FullName? fullName = null, string? avatarUrl = null, string? jobTitle = null, string? phoneNumber = null)
|
|
{
|
|
if (fullName is not null)
|
|
FullName = fullName;
|
|
|
|
if (avatarUrl is not null)
|
|
AvatarUrl = avatarUrl;
|
|
|
|
if (jobTitle is not null)
|
|
JobTitle = jobTitle;
|
|
|
|
if (phoneNumber is not null)
|
|
PhoneNumber = phoneNumber;
|
|
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
public void RecordLogin()
|
|
{
|
|
LastLoginAt = DateTime.UtcNow;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
public void VerifyEmail()
|
|
{
|
|
EmailVerifiedAt = DateTime.UtcNow;
|
|
EmailVerificationToken = null;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
public void Suspend(string reason)
|
|
{
|
|
Status = UserStatus.Suspended;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new UserSuspendedEvent(Id, reason));
|
|
}
|
|
|
|
public void Reactivate()
|
|
{
|
|
Status = UserStatus.Active;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
// SSO-specific methods (NEW)
|
|
public void UpdateSsoProfile(string externalUserId, Email email, FullName fullName, string? avatarUrl = null)
|
|
{
|
|
if (AuthProvider == AuthenticationProvider.Local)
|
|
throw new InvalidOperationException("Cannot update SSO profile for local users");
|
|
|
|
ExternalUserId = externalUserId;
|
|
ExternalEmail = email;
|
|
FullName = fullName;
|
|
|
|
if (avatarUrl is not null)
|
|
AvatarUrl = avatarUrl;
|
|
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
}
|
|
```
|
|
|
|
### New Enumeration
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/UserAggregate/Enums.cs` (Updated)
|
|
|
|
```csharp
|
|
namespace ColaFlow.Domain.Aggregates.UserAggregate;
|
|
|
|
public enum UserStatus
|
|
{
|
|
Active = 1,
|
|
Suspended = 2,
|
|
Deleted = 3
|
|
}
|
|
|
|
// NEW
|
|
public enum AuthenticationProvider
|
|
{
|
|
Local = 1, // Username/password
|
|
AzureAD = 2, // Microsoft Azure AD
|
|
Google = 3, // Google Workspace
|
|
Okta = 4, // Okta
|
|
GenericSaml = 5 // Generic SAML 2.0
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## TenantContext Service
|
|
|
|
### Interface
|
|
|
|
**File**: `src/ColaFlow.Application/Common/Interfaces/ITenantContext.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Application.Common.Interfaces;
|
|
|
|
/// <summary>
|
|
/// Provides access to the current tenant context
|
|
/// </summary>
|
|
public interface ITenantContext
|
|
{
|
|
TenantId CurrentTenantId { get; }
|
|
string CurrentTenantSlug { get; }
|
|
SubscriptionPlan CurrentTenantPlan { get; }
|
|
bool IsMultiTenantContext { get; }
|
|
}
|
|
```
|
|
|
|
### Implementation
|
|
|
|
**File**: `src/ColaFlow.Infrastructure/Services/TenantContext.cs`
|
|
|
|
```csharp
|
|
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http;
|
|
using ColaFlow.Application.Common.Interfaces;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Infrastructure.Services;
|
|
|
|
/// <summary>
|
|
/// Extracts tenant context from JWT claims or HTTP context
|
|
/// Registered as Scoped service (one instance per HTTP request)
|
|
/// </summary>
|
|
public sealed class TenantContext : ITenantContext
|
|
{
|
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
private TenantId? _cachedTenantId;
|
|
private string? _cachedTenantSlug;
|
|
private SubscriptionPlan? _cachedTenantPlan;
|
|
|
|
public TenantContext(IHttpContextAccessor httpContextAccessor)
|
|
{
|
|
_httpContextAccessor = httpContextAccessor;
|
|
}
|
|
|
|
public TenantId CurrentTenantId
|
|
{
|
|
get
|
|
{
|
|
if (_cachedTenantId is not null)
|
|
return _cachedTenantId;
|
|
|
|
var httpContext = _httpContextAccessor.HttpContext
|
|
?? throw new InvalidOperationException("No HTTP context available");
|
|
|
|
// Try to get from JWT claims first
|
|
var tenantIdClaim = httpContext.User.FindFirst("tenant_id")?.Value;
|
|
if (!string.IsNullOrEmpty(tenantIdClaim) && Guid.TryParse(tenantIdClaim, out var tenantIdGuid))
|
|
{
|
|
_cachedTenantId = TenantId.Create(tenantIdGuid);
|
|
return _cachedTenantId;
|
|
}
|
|
|
|
// Try to get from HTTP context items (set by middleware)
|
|
if (httpContext.Items.TryGetValue("TenantId", out var tenantIdObj) && tenantIdObj is TenantId tenantId)
|
|
{
|
|
_cachedTenantId = tenantId;
|
|
return _cachedTenantId;
|
|
}
|
|
|
|
throw new InvalidOperationException("Tenant context not found. Ensure TenantResolutionMiddleware is configured.");
|
|
}
|
|
}
|
|
|
|
public string CurrentTenantSlug
|
|
{
|
|
get
|
|
{
|
|
if (_cachedTenantSlug is not null)
|
|
return _cachedTenantSlug;
|
|
|
|
var httpContext = _httpContextAccessor.HttpContext
|
|
?? throw new InvalidOperationException("No HTTP context available");
|
|
|
|
var tenantSlugClaim = httpContext.User.FindFirst("tenant_slug")?.Value;
|
|
if (!string.IsNullOrEmpty(tenantSlugClaim))
|
|
{
|
|
_cachedTenantSlug = tenantSlugClaim;
|
|
return _cachedTenantSlug;
|
|
}
|
|
|
|
if (httpContext.Items.TryGetValue("TenantSlug", out var tenantSlugObj) && tenantSlugObj is string tenantSlug)
|
|
{
|
|
_cachedTenantSlug = tenantSlug;
|
|
return _cachedTenantSlug;
|
|
}
|
|
|
|
throw new InvalidOperationException("Tenant slug not found");
|
|
}
|
|
}
|
|
|
|
public SubscriptionPlan CurrentTenantPlan
|
|
{
|
|
get
|
|
{
|
|
if (_cachedTenantPlan is not null)
|
|
return _cachedTenantPlan.Value;
|
|
|
|
var httpContext = _httpContextAccessor.HttpContext
|
|
?? throw new InvalidOperationException("No HTTP context available");
|
|
|
|
var tenantPlanClaim = httpContext.User.FindFirst("tenant_plan")?.Value;
|
|
if (!string.IsNullOrEmpty(tenantPlanClaim) && Enum.TryParse<SubscriptionPlan>(tenantPlanClaim, out var plan))
|
|
{
|
|
_cachedTenantPlan = plan;
|
|
return _cachedTenantPlan.Value;
|
|
}
|
|
|
|
if (httpContext.Items.TryGetValue("TenantPlan", out var tenantPlanObj) && tenantPlanObj is SubscriptionPlan tenantPlan)
|
|
{
|
|
_cachedTenantPlan = tenantPlan;
|
|
return _cachedTenantPlan.Value;
|
|
}
|
|
|
|
// Default to Free if not found
|
|
_cachedTenantPlan = SubscriptionPlan.Free;
|
|
return _cachedTenantPlan.Value;
|
|
}
|
|
}
|
|
|
|
public bool IsMultiTenantContext =>
|
|
_httpContextAccessor.HttpContext?.User.FindFirst("tenant_id") is not null;
|
|
}
|
|
```
|
|
|
|
### DI Registration
|
|
|
|
**File**: `src/ColaFlow.Infrastructure/DependencyInjection.cs` (Add this)
|
|
|
|
```csharp
|
|
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
|
{
|
|
// ... other services
|
|
|
|
// Tenant Context (Scoped - one per request)
|
|
services.AddScoped<ITenantContext, TenantContext>();
|
|
|
|
return services;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## EF Core Global Query Filter
|
|
|
|
### ApplicationDbContext Configuration
|
|
|
|
**File**: `src/ColaFlow.Infrastructure/Persistence/ApplicationDbContext.cs` (Updated)
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Application.Common.Interfaces;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
using ColaFlow.Domain.Aggregates.UserAggregate;
|
|
using ColaFlow.Domain.Aggregates.ProjectAggregate;
|
|
using ColaFlow.Domain.Aggregates.IssueAggregate;
|
|
|
|
namespace ColaFlow.Infrastructure.Persistence;
|
|
|
|
public sealed class ApplicationDbContext : DbContext
|
|
{
|
|
private readonly ITenantContext _tenantContext;
|
|
|
|
public ApplicationDbContext(
|
|
DbContextOptions<ApplicationDbContext> options,
|
|
ITenantContext tenantContext) : base(options)
|
|
{
|
|
_tenantContext = tenantContext;
|
|
}
|
|
|
|
public DbSet<Tenant> Tenants => Set<Tenant>();
|
|
public DbSet<User> Users => Set<User>();
|
|
public DbSet<Project> Projects => Set<Project>();
|
|
public DbSet<Issue> Issues => Set<Issue>();
|
|
// ... other entities
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
base.OnModelCreating(modelBuilder);
|
|
|
|
// Apply all entity configurations
|
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
|
|
|
|
// Configure Global Query Filters for ALL multi-tenant entities
|
|
ConfigureGlobalQueryFilters(modelBuilder);
|
|
}
|
|
|
|
private void ConfigureGlobalQueryFilters(ModelBuilder modelBuilder)
|
|
{
|
|
// IMPORTANT: Tenant entity itself should NOT have a filter
|
|
// (admin operations need to query all tenants)
|
|
|
|
// User entity filter
|
|
modelBuilder.Entity<User>().HasQueryFilter(u =>
|
|
u.TenantId == _tenantContext.CurrentTenantId);
|
|
|
|
// Project entity filter
|
|
modelBuilder.Entity<Project>().HasQueryFilter(p =>
|
|
p.TenantId == _tenantContext.CurrentTenantId);
|
|
|
|
// Issue entity filter
|
|
modelBuilder.Entity<Issue>().HasQueryFilter(i =>
|
|
i.TenantId == _tenantContext.CurrentTenantId);
|
|
|
|
// Add filters for ALL other multi-tenant entities
|
|
// modelBuilder.Entity<Sprint>().HasQueryFilter(s => s.TenantId == _tenantContext.CurrentTenantId);
|
|
// modelBuilder.Entity<Document>().HasQueryFilter(d => d.TenantId == _tenantContext.CurrentTenantId);
|
|
// modelBuilder.Entity<Comment>().HasQueryFilter(c => c.TenantId == _tenantContext.CurrentTenantId);
|
|
// ... etc.
|
|
}
|
|
|
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
// Automatically set TenantId for new entities
|
|
foreach (var entry in ChangeTracker.Entries())
|
|
{
|
|
if (entry.State == EntityState.Added && entry.Entity is IHasTenant tenantEntity)
|
|
{
|
|
// Set tenant ID if not already set
|
|
if (tenantEntity.TenantId == default)
|
|
{
|
|
tenantEntity.TenantId = _tenantContext.CurrentTenantId;
|
|
}
|
|
}
|
|
}
|
|
|
|
return await base.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Temporarily disable query filters (for admin operations)
|
|
/// Usage: context.DisableFilters().Users.ToListAsync()
|
|
/// </summary>
|
|
public ApplicationDbContext DisableFilters()
|
|
{
|
|
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
|
return this;
|
|
}
|
|
}
|
|
```
|
|
|
|
### IHasTenant Interface
|
|
|
|
**File**: `src/ColaFlow.Domain/Common/IHasTenant.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Domain.Common;
|
|
|
|
/// <summary>
|
|
/// Marker interface for entities that belong to a tenant
|
|
/// </summary>
|
|
public interface IHasTenant
|
|
{
|
|
TenantId TenantId { get; set; }
|
|
}
|
|
```
|
|
|
|
### Update All Entities
|
|
|
|
All domain entities must implement `IHasTenant`:
|
|
|
|
```csharp
|
|
public sealed class Project : AggregateRoot<ProjectId>, IHasTenant
|
|
{
|
|
public TenantId TenantId { get; set; }
|
|
// ... rest of properties
|
|
}
|
|
|
|
public sealed class Issue : AggregateRoot<IssueId>, IHasTenant
|
|
{
|
|
public TenantId TenantId { get; set; }
|
|
// ... rest of properties
|
|
}
|
|
|
|
// ... etc for ALL entities
|
|
```
|
|
|
|
---
|
|
|
|
## Tenant Resolution Middleware
|
|
|
|
**File**: `src/ColaFlow.API/Middleware/TenantResolutionMiddleware.cs`
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.API.Middleware;
|
|
|
|
/// <summary>
|
|
/// Resolves tenant from subdomain or custom header and validates tenant status
|
|
/// </summary>
|
|
public sealed class TenantResolutionMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
private readonly ILogger<TenantResolutionMiddleware> _logger;
|
|
|
|
public TenantResolutionMiddleware(RequestDelegate next, ILogger<TenantResolutionMiddleware> logger)
|
|
{
|
|
_next = next;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task InvokeAsync(HttpContext context, ApplicationDbContext dbContext)
|
|
{
|
|
try
|
|
{
|
|
// 1. Try to resolve tenant from multiple sources
|
|
var tenantSlug = ResolveTenantSlug(context);
|
|
|
|
if (string.IsNullOrEmpty(tenantSlug))
|
|
{
|
|
// Allow requests without tenant for public endpoints
|
|
if (IsPublicEndpoint(context.Request.Path))
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
await context.Response.WriteAsJsonAsync(new { error = "Tenant not specified" });
|
|
return;
|
|
}
|
|
|
|
// 2. Query tenant from database (bypass query filter)
|
|
var tenant = await dbContext.Tenants
|
|
.IgnoreQueryFilters()
|
|
.FirstOrDefaultAsync(t => t.Slug.Value == tenantSlug);
|
|
|
|
if (tenant is null)
|
|
{
|
|
_logger.LogWarning("Tenant not found: {TenantSlug}", tenantSlug);
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
await context.Response.WriteAsJsonAsync(new { error = "Tenant not found" });
|
|
return;
|
|
}
|
|
|
|
// 3. Validate tenant status
|
|
if (tenant.Status == TenantStatus.Suspended)
|
|
{
|
|
_logger.LogWarning("Tenant suspended: {TenantId}, Reason: {Reason}",
|
|
tenant.Id, tenant.SuspensionReason);
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
await context.Response.WriteAsJsonAsync(new
|
|
{
|
|
error = "Tenant suspended",
|
|
reason = tenant.SuspensionReason
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (tenant.Status == TenantStatus.Cancelled)
|
|
{
|
|
_logger.LogWarning("Tenant cancelled: {TenantId}", tenant.Id);
|
|
context.Response.StatusCode = StatusCodes.Status410Gone;
|
|
await context.Response.WriteAsJsonAsync(new { error = "Tenant cancelled" });
|
|
return;
|
|
}
|
|
|
|
// 4. Store tenant context in HTTP context items
|
|
context.Items["TenantId"] = tenant.Id;
|
|
context.Items["TenantSlug"] = tenant.Slug.Value;
|
|
context.Items["TenantPlan"] = tenant.Plan;
|
|
|
|
_logger.LogInformation("Tenant resolved: {TenantSlug} (ID: {TenantId})",
|
|
tenant.Slug, tenant.Id);
|
|
|
|
// 5. Continue to next middleware
|
|
await _next(context);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error in tenant resolution");
|
|
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
|
await context.Response.WriteAsJsonAsync(new { error = "Internal server error" });
|
|
}
|
|
}
|
|
|
|
private string? ResolveTenantSlug(HttpContext context)
|
|
{
|
|
// Strategy 1: Custom header (for MCP clients)
|
|
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader))
|
|
{
|
|
var tenantSlug = tenantHeader.ToString();
|
|
if (!string.IsNullOrEmpty(tenantSlug))
|
|
{
|
|
_logger.LogDebug("Tenant resolved from X-Tenant-Id header: {TenantSlug}", tenantSlug);
|
|
return tenantSlug;
|
|
}
|
|
}
|
|
|
|
// Strategy 2: JWT claims (already authenticated)
|
|
var tenantClaim = context.User.FindFirst("tenant_slug")?.Value;
|
|
if (!string.IsNullOrEmpty(tenantClaim))
|
|
{
|
|
_logger.LogDebug("Tenant resolved from JWT claims: {TenantSlug}", tenantClaim);
|
|
return tenantClaim;
|
|
}
|
|
|
|
// Strategy 3: Subdomain (e.g., acme.colaflow.com)
|
|
var host = context.Request.Host.Host;
|
|
var parts = host.Split('.');
|
|
|
|
if (parts.Length >= 3) // e.g., acme.colaflow.com
|
|
{
|
|
var subdomain = parts[0];
|
|
|
|
// Ignore common subdomains
|
|
if (subdomain is not ("www" or "api" or "admin" or "app"))
|
|
{
|
|
_logger.LogDebug("Tenant resolved from subdomain: {TenantSlug}", subdomain);
|
|
return subdomain;
|
|
}
|
|
}
|
|
|
|
// Strategy 4: Query parameter (fallback for development)
|
|
if (context.Request.Query.TryGetValue("tenant", out var tenantQuery))
|
|
{
|
|
var tenantSlug = tenantQuery.ToString();
|
|
if (!string.IsNullOrEmpty(tenantSlug))
|
|
{
|
|
_logger.LogDebug("Tenant resolved from query parameter: {TenantSlug}", tenantSlug);
|
|
return tenantSlug;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool IsPublicEndpoint(PathString path)
|
|
{
|
|
var publicPaths = new[]
|
|
{
|
|
"/health",
|
|
"/api/tenants/register",
|
|
"/api/auth/login",
|
|
"/api/auth/sso/callback"
|
|
};
|
|
|
|
return publicPaths.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
}
|
|
|
|
// Extension method for easy registration
|
|
public static class TenantResolutionMiddlewareExtensions
|
|
{
|
|
public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app)
|
|
{
|
|
return app.UseMiddleware<TenantResolutionMiddleware>();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Middleware Registration
|
|
|
|
**File**: `src/ColaFlow.API/Program.cs` (Add this)
|
|
|
|
```csharp
|
|
var app = builder.Build();
|
|
|
|
// Middleware pipeline order is CRITICAL
|
|
app.UseHttpsRedirection();
|
|
app.UseCors();
|
|
|
|
// 1. Tenant resolution MUST come before authentication
|
|
app.UseTenantResolution();
|
|
|
|
// 2. Authentication (reads JWT, but JWT already contains tenant_id)
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapControllers();
|
|
|
|
app.Run();
|
|
```
|
|
|
|
---
|
|
|
|
## Database Schema
|
|
|
|
### Tenants Table
|
|
|
|
```sql
|
|
-- Table: tenants
|
|
CREATE TABLE tenants (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name VARCHAR(100) NOT NULL,
|
|
slug VARCHAR(50) NOT NULL UNIQUE,
|
|
status INT NOT NULL DEFAULT 1, -- 1=Active, 2=Suspended, 3=Cancelled
|
|
plan INT NOT NULL DEFAULT 1, -- 1=Free, 2=Pro, 3=Enterprise
|
|
|
|
-- SSO Configuration (stored as JSONB)
|
|
sso_config JSONB NULL,
|
|
|
|
-- Limits
|
|
max_users INT NOT NULL DEFAULT 5,
|
|
max_projects INT NOT NULL DEFAULT 3,
|
|
max_storage_gb INT NOT NULL DEFAULT 2,
|
|
|
|
-- Status tracking
|
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMP NULL,
|
|
suspended_at TIMESTAMP NULL,
|
|
suspension_reason TEXT NULL,
|
|
|
|
CONSTRAINT chk_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$')
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_tenants_slug ON tenants(slug);
|
|
CREATE INDEX idx_tenants_status ON tenants(status);
|
|
CREATE INDEX idx_tenants_plan ON tenants(plan);
|
|
|
|
-- Comments
|
|
COMMENT ON TABLE tenants IS 'Multi-tenant organizations';
|
|
COMMENT ON COLUMN tenants.slug IS 'URL-safe identifier (used in subdomains)';
|
|
COMMENT ON COLUMN tenants.sso_config IS 'SSO configuration (OIDC/SAML) in JSON format';
|
|
```
|
|
|
|
### Users Table (Updated)
|
|
|
|
```sql
|
|
-- Table: users (updated for multi-tenancy)
|
|
CREATE TABLE users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Identity
|
|
email VARCHAR(255) NOT NULL,
|
|
password_hash VARCHAR(255) NOT NULL, -- Empty for SSO users
|
|
full_name VARCHAR(100) NOT NULL,
|
|
status INT NOT NULL DEFAULT 1, -- 1=Active, 2=Suspended, 3=Deleted
|
|
|
|
-- SSO fields (NEW)
|
|
auth_provider INT NOT NULL DEFAULT 1, -- 1=Local, 2=AzureAD, 3=Google, 4=Okta, 5=GenericSaml
|
|
external_user_id VARCHAR(255) NULL, -- IdP user ID
|
|
external_email VARCHAR(255) NULL, -- Email from IdP
|
|
|
|
-- Profile
|
|
avatar_url VARCHAR(500) NULL,
|
|
job_title VARCHAR(100) NULL,
|
|
phone_number VARCHAR(50) NULL,
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMP NULL,
|
|
last_login_at TIMESTAMP NULL,
|
|
|
|
-- Email verification
|
|
email_verified_at TIMESTAMP NULL,
|
|
email_verification_token VARCHAR(255) NULL,
|
|
|
|
-- Password reset
|
|
password_reset_token VARCHAR(255) NULL,
|
|
password_reset_token_expires_at TIMESTAMP NULL,
|
|
|
|
-- Unique constraint: email must be unique within a tenant
|
|
CONSTRAINT uq_users_tenant_email UNIQUE (tenant_id, email),
|
|
|
|
-- Unique constraint: external user ID must be unique within tenant + provider
|
|
CONSTRAINT uq_users_tenant_provider_external UNIQUE (tenant_id, auth_provider, external_user_id)
|
|
);
|
|
|
|
-- Indexes (CRITICAL for performance)
|
|
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
|
|
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);
|
|
CREATE INDEX idx_users_tenant_status ON users(tenant_id, status);
|
|
CREATE INDEX idx_users_external_user_id ON users(external_user_id) WHERE external_user_id IS NOT NULL;
|
|
|
|
-- Comments
|
|
COMMENT ON COLUMN users.tenant_id IS 'Tenant this user belongs to';
|
|
COMMENT ON COLUMN users.auth_provider IS 'Authentication provider (Local/AzureAD/Google/Okta/SAML)';
|
|
COMMENT ON COLUMN users.external_user_id IS 'User ID from external IdP (for SSO)';
|
|
```
|
|
|
|
### Projects Table (Example)
|
|
|
|
```sql
|
|
-- Table: projects (updated for multi-tenancy)
|
|
CREATE TABLE projects (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Project details
|
|
name VARCHAR(200) NOT NULL,
|
|
key VARCHAR(10) NOT NULL, -- e.g., "COLA"
|
|
description TEXT NULL,
|
|
status INT NOT NULL DEFAULT 1,
|
|
|
|
-- Ownership
|
|
owner_id UUID NOT NULL REFERENCES users(id),
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMP NULL,
|
|
|
|
-- Unique constraint: key must be unique within tenant
|
|
CONSTRAINT uq_projects_tenant_key UNIQUE (tenant_id, key)
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);
|
|
CREATE INDEX idx_projects_tenant_key ON projects(tenant_id, key);
|
|
CREATE INDEX idx_projects_tenant_owner ON projects(tenant_id, owner_id);
|
|
CREATE INDEX idx_projects_tenant_status ON projects(tenant_id, status);
|
|
```
|
|
|
|
### EF Core Entity Configuration
|
|
|
|
**File**: `src/ColaFlow.Infrastructure/Persistence/Configurations/TenantConfiguration.cs`
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Infrastructure.Persistence.Configurations;
|
|
|
|
public sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
|
|
{
|
|
public void Configure(EntityTypeBuilder<Tenant> builder)
|
|
{
|
|
builder.ToTable("tenants");
|
|
|
|
// Primary key
|
|
builder.HasKey(t => t.Id);
|
|
builder.Property(t => t.Id)
|
|
.HasConversion(
|
|
id => id.Value,
|
|
value => TenantId.Create(value))
|
|
.HasColumnName("id");
|
|
|
|
// Value objects
|
|
builder.Property(t => t.Name)
|
|
.HasConversion(
|
|
name => name.Value,
|
|
value => TenantName.Create(value))
|
|
.HasColumnName("name")
|
|
.HasMaxLength(100)
|
|
.IsRequired();
|
|
|
|
builder.Property(t => t.Slug)
|
|
.HasConversion(
|
|
slug => slug.Value,
|
|
value => TenantSlug.Create(value))
|
|
.HasColumnName("slug")
|
|
.HasMaxLength(50)
|
|
.IsRequired();
|
|
|
|
builder.HasIndex(t => t.Slug).IsUnique();
|
|
|
|
// Enums
|
|
builder.Property(t => t.Status)
|
|
.HasConversion<int>()
|
|
.HasColumnName("status")
|
|
.IsRequired();
|
|
|
|
builder.Property(t => t.Plan)
|
|
.HasConversion<int>()
|
|
.HasColumnName("plan")
|
|
.IsRequired();
|
|
|
|
// SSO Configuration (stored as JSONB)
|
|
builder.OwnsOne(t => t.SsoConfig, sso =>
|
|
{
|
|
sso.ToJson("sso_config");
|
|
sso.Property(s => s.Provider).HasConversion<int>();
|
|
});
|
|
|
|
// Settings
|
|
builder.Property(t => t.MaxUsers).HasColumnName("max_users").IsRequired();
|
|
builder.Property(t => t.MaxProjects).HasColumnName("max_projects").IsRequired();
|
|
builder.Property(t => t.MaxStorageGB).HasColumnName("max_storage_gb").IsRequired();
|
|
|
|
// Timestamps
|
|
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
|
|
builder.Property(t => t.UpdatedAt).HasColumnName("updated_at");
|
|
builder.Property(t => t.SuspendedAt).HasColumnName("suspended_at");
|
|
builder.Property(t => t.SuspensionReason).HasColumnName("suspension_reason").HasMaxLength(500);
|
|
|
|
// Indexes
|
|
builder.HasIndex(t => t.Status);
|
|
builder.HasIndex(t => t.Plan);
|
|
|
|
// Ignore domain events (not persisted)
|
|
builder.Ignore(t => t.DomainEvents);
|
|
}
|
|
}
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Infrastructure/Persistence/Configurations/UserConfiguration.cs` (Updated)
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
using ColaFlow.Domain.Aggregates.UserAggregate;
|
|
using ColaFlow.Domain.Aggregates.UserAggregate.ValueObjects;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Infrastructure.Persistence.Configurations;
|
|
|
|
public sealed class UserConfiguration : IEntityTypeConfiguration<User>
|
|
{
|
|
public void Configure(EntityTypeBuilder<User> builder)
|
|
{
|
|
builder.ToTable("users");
|
|
|
|
// Primary key
|
|
builder.HasKey(u => u.Id);
|
|
builder.Property(u => u.Id)
|
|
.HasConversion(
|
|
id => id.Value,
|
|
value => UserId.Create(value))
|
|
.HasColumnName("id");
|
|
|
|
// Tenant association (NEW)
|
|
builder.Property(u => u.TenantId)
|
|
.HasConversion(
|
|
id => id.Value,
|
|
value => TenantId.Create(value))
|
|
.HasColumnName("tenant_id")
|
|
.IsRequired();
|
|
|
|
builder.HasOne<Tenant>()
|
|
.WithMany()
|
|
.HasForeignKey(u => u.TenantId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
// Value objects
|
|
builder.Property(u => u.Email)
|
|
.HasConversion(
|
|
email => email.Value,
|
|
value => Email.Create(value))
|
|
.HasColumnName("email")
|
|
.HasMaxLength(255)
|
|
.IsRequired();
|
|
|
|
builder.Property(u => u.FullName)
|
|
.HasConversion(
|
|
name => name.Value,
|
|
value => FullName.Create(value))
|
|
.HasColumnName("full_name")
|
|
.HasMaxLength(100)
|
|
.IsRequired();
|
|
|
|
// Authentication
|
|
builder.Property(u => u.PasswordHash).HasColumnName("password_hash").HasMaxLength(255).IsRequired();
|
|
builder.Property(u => u.Status).HasConversion<int>().HasColumnName("status").IsRequired();
|
|
|
|
// SSO fields (NEW)
|
|
builder.Property(u => u.AuthProvider).HasConversion<int>().HasColumnName("auth_provider").IsRequired();
|
|
builder.Property(u => u.ExternalUserId).HasColumnName("external_user_id").HasMaxLength(255);
|
|
builder.Property(u => u.ExternalEmail).HasColumnName("external_email").HasMaxLength(255);
|
|
|
|
// Profile
|
|
builder.Property(u => u.AvatarUrl).HasColumnName("avatar_url").HasMaxLength(500);
|
|
builder.Property(u => u.JobTitle).HasColumnName("job_title").HasMaxLength(100);
|
|
builder.Property(u => u.PhoneNumber).HasColumnName("phone_number").HasMaxLength(50);
|
|
|
|
// Timestamps
|
|
builder.Property(u => u.CreatedAt).HasColumnName("created_at").IsRequired();
|
|
builder.Property(u => u.UpdatedAt).HasColumnName("updated_at");
|
|
builder.Property(u => u.LastLoginAt).HasColumnName("last_login_at");
|
|
|
|
// Email verification
|
|
builder.Property(u => u.EmailVerifiedAt).HasColumnName("email_verified_at");
|
|
builder.Property(u => u.EmailVerificationToken).HasColumnName("email_verification_token").HasMaxLength(255);
|
|
|
|
// Password reset
|
|
builder.Property(u => u.PasswordResetToken).HasColumnName("password_reset_token").HasMaxLength(255);
|
|
builder.Property(u => u.PasswordResetTokenExpiresAt).HasColumnName("password_reset_token_expires_at");
|
|
|
|
// Unique constraints
|
|
builder.HasIndex(u => new { u.TenantId, u.Email }).IsUnique();
|
|
builder.HasIndex(u => new { u.TenantId, u.AuthProvider, u.ExternalUserId })
|
|
.IsUnique()
|
|
.HasFilter("external_user_id IS NOT NULL");
|
|
|
|
// Indexes for performance
|
|
builder.HasIndex(u => u.TenantId);
|
|
builder.HasIndex(u => new { u.TenantId, u.Status });
|
|
builder.HasIndex(u => u.ExternalUserId).HasFilter("external_user_id IS NOT NULL");
|
|
|
|
// Ignore domain events
|
|
builder.Ignore(u => u.DomainEvents);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Application Layer Commands/Queries
|
|
|
|
### Register Tenant Command
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Commands/RegisterTenant/RegisterTenantCommand.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Application.Common.Interfaces;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
|
|
namespace ColaFlow.Application.Tenants.Commands.RegisterTenant;
|
|
|
|
public sealed record RegisterTenantCommand(
|
|
string Name,
|
|
string Slug,
|
|
SubscriptionPlan Plan = SubscriptionPlan.Free) : IRequest<RegisterTenantResult>;
|
|
|
|
public sealed record RegisterTenantResult(Guid TenantId, string Slug);
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Commands/RegisterTenant/RegisterTenantCommandHandler.cs`
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Application.Common.Interfaces;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
|
|
namespace ColaFlow.Application.Tenants.Commands.RegisterTenant;
|
|
|
|
public sealed class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantCommand, RegisterTenantResult>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly ILogger<RegisterTenantCommandHandler> _logger;
|
|
|
|
public RegisterTenantCommandHandler(
|
|
ApplicationDbContext context,
|
|
ILogger<RegisterTenantCommandHandler> logger)
|
|
{
|
|
_context = context;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<RegisterTenantResult> Handle(RegisterTenantCommand request, CancellationToken cancellationToken)
|
|
{
|
|
// 1. Validate slug uniqueness (bypass tenant filter)
|
|
var slugExists = await _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.AnyAsync(t => t.Slug.Value == request.Slug, cancellationToken);
|
|
|
|
if (slugExists)
|
|
throw new InvalidOperationException($"Tenant slug '{request.Slug}' is already taken");
|
|
|
|
// 2. Create tenant aggregate
|
|
var name = TenantName.Create(request.Name);
|
|
var slug = TenantSlug.Create(request.Slug);
|
|
var tenant = Tenant.Create(name, slug, request.Plan);
|
|
|
|
// 3. Persist
|
|
await _context.Tenants.AddAsync(tenant, cancellationToken);
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
|
|
_logger.LogInformation("Tenant registered: {TenantId}, Slug: {Slug}", tenant.Id, tenant.Slug);
|
|
|
|
return new RegisterTenantResult(tenant.Id, tenant.Slug);
|
|
}
|
|
}
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Commands/RegisterTenant/RegisterTenantCommandValidator.cs`
|
|
|
|
```csharp
|
|
using FluentValidation;
|
|
|
|
namespace ColaFlow.Application.Tenants.Commands.RegisterTenant;
|
|
|
|
public sealed class RegisterTenantCommandValidator : AbstractValidator<RegisterTenantCommand>
|
|
{
|
|
public RegisterTenantCommandValidator()
|
|
{
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty().WithMessage("Tenant name is required")
|
|
.MinimumLength(2).WithMessage("Tenant name must be at least 2 characters")
|
|
.MaximumLength(100).WithMessage("Tenant name cannot exceed 100 characters");
|
|
|
|
RuleFor(x => x.Slug)
|
|
.NotEmpty().WithMessage("Tenant slug is required")
|
|
.MinimumLength(3).WithMessage("Tenant slug must be at least 3 characters")
|
|
.MaximumLength(50).WithMessage("Tenant slug cannot exceed 50 characters")
|
|
.Matches(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
.WithMessage("Tenant slug can only contain lowercase letters, numbers, and hyphens");
|
|
|
|
RuleFor(x => x.Plan)
|
|
.IsInEnum().WithMessage("Invalid subscription plan");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Get Tenant By Slug Query
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Queries/GetTenantBySlug/GetTenantBySlugQuery.cs`
|
|
|
|
```csharp
|
|
namespace ColaFlow.Application.Tenants.Queries.GetTenantBySlug;
|
|
|
|
public sealed record GetTenantBySlugQuery(string Slug) : IRequest<TenantDto?>;
|
|
|
|
public sealed record TenantDto(
|
|
Guid Id,
|
|
string Name,
|
|
string Slug,
|
|
string Status,
|
|
string Plan,
|
|
int MaxUsers,
|
|
int MaxProjects,
|
|
int MaxStorageGB,
|
|
DateTime CreatedAt);
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Queries/GetTenantBySlug/GetTenantBySlugQueryHandler.cs`
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
|
|
namespace ColaFlow.Application.Tenants.Queries.GetTenantBySlug;
|
|
|
|
public sealed class GetTenantBySlugQueryHandler : IRequestHandler<GetTenantBySlugQuery, TenantDto?>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public GetTenantBySlugQueryHandler(ApplicationDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public async Task<TenantDto?> Handle(GetTenantBySlugQuery request, CancellationToken cancellationToken)
|
|
{
|
|
// Bypass query filter to allow lookup by slug
|
|
var tenant = await _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.Where(t => t.Slug.Value == request.Slug)
|
|
.Select(t => new TenantDto(
|
|
t.Id,
|
|
t.Name,
|
|
t.Slug,
|
|
t.Status.ToString(),
|
|
t.Plan.ToString(),
|
|
t.MaxUsers,
|
|
t.MaxProjects,
|
|
t.MaxStorageGB,
|
|
t.CreatedAt))
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
return tenant;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Update Tenant Command
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Commands/UpdateTenant/UpdateTenantCommand.cs`
|
|
|
|
```csharp
|
|
namespace ColaFlow.Application.Tenants.Commands.UpdateTenant;
|
|
|
|
public sealed record UpdateTenantCommand(
|
|
Guid TenantId,
|
|
string? Name = null) : IRequest<Unit>;
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Commands/UpdateTenant/UpdateTenantCommandHandler.cs`
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Application.Tenants.Commands.UpdateTenant;
|
|
|
|
public sealed class UpdateTenantCommandHandler : IRequestHandler<UpdateTenantCommand, Unit>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly ITenantContext _tenantContext;
|
|
|
|
public UpdateTenantCommandHandler(ApplicationDbContext context, ITenantContext tenantContext)
|
|
{
|
|
_context = context;
|
|
_tenantContext = tenantContext;
|
|
}
|
|
|
|
public async Task<Unit> Handle(UpdateTenantCommand request, CancellationToken cancellationToken)
|
|
{
|
|
// Security: Only allow updating current tenant
|
|
if (request.TenantId != _tenantContext.CurrentTenantId.Value)
|
|
throw new UnauthorizedAccessException("Cannot update other tenants");
|
|
|
|
var tenant = await _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.FirstOrDefaultAsync(t => t.Id == TenantId.Create(request.TenantId), cancellationToken);
|
|
|
|
if (tenant is null)
|
|
throw new InvalidOperationException("Tenant not found");
|
|
|
|
if (!string.IsNullOrEmpty(request.Name))
|
|
{
|
|
var name = TenantName.Create(request.Name);
|
|
tenant.UpdateName(name);
|
|
}
|
|
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
|
|
return Unit.Value;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Security Protection
|
|
|
|
### Cross-Tenant Data Leakage Prevention
|
|
|
|
**File**: `tests/ColaFlow.Application.Tests/Security/TenantIsolationTests.cs`
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
using ColaFlow.Domain.Aggregates.UserAggregate;
|
|
using Xunit;
|
|
|
|
namespace ColaFlow.Application.Tests.Security;
|
|
|
|
public sealed class TenantIsolationTests
|
|
{
|
|
[Fact]
|
|
public async Task Users_ShouldOnlyAccessTheirTenantData()
|
|
{
|
|
// Arrange
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
// Create two tenants
|
|
var tenant1 = Tenant.Create(TenantName.Create("Acme Corp"), TenantSlug.Create("acme"));
|
|
var tenant2 = Tenant.Create(TenantName.Create("Beta Inc"), TenantSlug.Create("beta"));
|
|
|
|
var user1 = User.CreateLocal(tenant1.Id, Email.Create("user1@acme.com"), "hash", FullName.Create("User 1"));
|
|
var user2 = User.CreateLocal(tenant2.Id, Email.Create("user2@beta.com"), "hash", FullName.Create("User 2"));
|
|
|
|
using (var context = new ApplicationDbContext(options, new MockTenantContext(tenant1.Id)))
|
|
{
|
|
context.Tenants.AddRange(tenant1, tenant2);
|
|
context.Users.AddRange(user1, user2);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
// Act - Query as Tenant1
|
|
using (var context = new ApplicationDbContext(options, new MockTenantContext(tenant1.Id)))
|
|
{
|
|
var users = await context.Users.ToListAsync();
|
|
|
|
// Assert - Should only see Tenant1's user
|
|
Assert.Single(users);
|
|
Assert.Equal(user1.Id, users[0].Id);
|
|
}
|
|
|
|
// Act - Query as Tenant2
|
|
using (var context = new ApplicationDbContext(options, new MockTenantContext(tenant2.Id)))
|
|
{
|
|
var users = await context.Users.ToListAsync();
|
|
|
|
// Assert - Should only see Tenant2's user
|
|
Assert.Single(users);
|
|
Assert.Equal(user2.Id, users[0].Id);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdminOperations_ShouldBypassFilters()
|
|
{
|
|
// Arrange
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
var tenant1 = Tenant.Create(TenantName.Create("Acme Corp"), TenantSlug.Create("acme"));
|
|
var tenant2 = Tenant.Create(TenantName.Create("Beta Inc"), TenantSlug.Create("beta"));
|
|
|
|
var user1 = User.CreateLocal(tenant1.Id, Email.Create("user1@acme.com"), "hash", FullName.Create("User 1"));
|
|
var user2 = User.CreateLocal(tenant2.Id, Email.Create("user2@beta.com"), "hash", FullName.Create("User 2"));
|
|
|
|
using (var context = new ApplicationDbContext(options, new MockTenantContext(tenant1.Id)))
|
|
{
|
|
context.Tenants.AddRange(tenant1, tenant2);
|
|
context.Users.AddRange(user1, user2);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
// Act - Admin query with filter disabled
|
|
using (var context = new ApplicationDbContext(options, new MockTenantContext(tenant1.Id)))
|
|
{
|
|
var allUsers = await context.Users.IgnoreQueryFilters().ToListAsync();
|
|
|
|
// Assert - Should see ALL users
|
|
Assert.Equal(2, allUsers.Count);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mock TenantContext for testing
|
|
public sealed class MockTenantContext : ITenantContext
|
|
{
|
|
public MockTenantContext(TenantId tenantId)
|
|
{
|
|
CurrentTenantId = tenantId;
|
|
CurrentTenantSlug = "test";
|
|
CurrentTenantPlan = SubscriptionPlan.Enterprise;
|
|
}
|
|
|
|
public TenantId CurrentTenantId { get; }
|
|
public string CurrentTenantSlug { get; }
|
|
public SubscriptionPlan CurrentTenantPlan { get; }
|
|
public bool IsMultiTenantContext => true;
|
|
}
|
|
```
|
|
|
|
### SQL Injection Protection
|
|
|
|
All queries use **parameterized queries** via EF Core, preventing SQL injection:
|
|
|
|
```csharp
|
|
// SAFE: EF Core uses parameterized queries
|
|
var tenant = await _context.Tenants
|
|
.FirstOrDefaultAsync(t => t.Slug.Value == userInputSlug);
|
|
|
|
// SAFE: LINQ queries are compiled to parameterized SQL
|
|
var users = await _context.Users
|
|
.Where(u => u.Email.Value.Contains(searchTerm))
|
|
.ToListAsync();
|
|
|
|
// UNSAFE (NEVER DO THIS):
|
|
// var sql = $"SELECT * FROM users WHERE email = '{userInput}'";
|
|
// await _context.Database.ExecuteSqlRawAsync(sql); // SQL INJECTION RISK!
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests - Tenant Aggregate
|
|
|
|
**File**: `tests/ColaFlow.Domain.Tests/Aggregates/TenantAggregateTests.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.Events;
|
|
using Xunit;
|
|
|
|
namespace ColaFlow.Domain.Tests.Aggregates;
|
|
|
|
public sealed class TenantAggregateTests
|
|
{
|
|
[Fact]
|
|
public void Create_ShouldCreateActiveTenantWithDefaultPlan()
|
|
{
|
|
// Arrange
|
|
var name = TenantName.Create("Acme Corp");
|
|
var slug = TenantSlug.Create("acme");
|
|
|
|
// Act
|
|
var tenant = Tenant.Create(name, slug);
|
|
|
|
// Assert
|
|
Assert.NotNull(tenant);
|
|
Assert.Equal(TenantStatus.Active, tenant.Status);
|
|
Assert.Equal(SubscriptionPlan.Free, tenant.Plan);
|
|
Assert.Equal(5, tenant.MaxUsers);
|
|
Assert.Single(tenant.DomainEvents);
|
|
Assert.IsType<TenantCreatedEvent>(tenant.DomainEvents.First());
|
|
}
|
|
|
|
[Fact]
|
|
public void UpgradePlan_ShouldIncreaseResourceLimits()
|
|
{
|
|
// Arrange
|
|
var tenant = Tenant.Create(TenantName.Create("Acme"), TenantSlug.Create("acme"));
|
|
|
|
// Act
|
|
tenant.UpgradePlan(SubscriptionPlan.Pro);
|
|
|
|
// Assert
|
|
Assert.Equal(SubscriptionPlan.Pro, tenant.Plan);
|
|
Assert.Equal(50, tenant.MaxUsers);
|
|
Assert.Equal(100, tenant.MaxProjects);
|
|
Assert.Contains(tenant.DomainEvents, e => e is TenantPlanUpgradedEvent);
|
|
}
|
|
|
|
[Fact]
|
|
public void Suspend_ShouldSetStatusToSuspended()
|
|
{
|
|
// Arrange
|
|
var tenant = Tenant.Create(TenantName.Create("Acme"), TenantSlug.Create("acme"));
|
|
var reason = "Payment failed";
|
|
|
|
// Act
|
|
tenant.Suspend(reason);
|
|
|
|
// Assert
|
|
Assert.Equal(TenantStatus.Suspended, tenant.Status);
|
|
Assert.NotNull(tenant.SuspendedAt);
|
|
Assert.Equal(reason, tenant.SuspensionReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfigureSso_ShouldThrowForFreePlan()
|
|
{
|
|
// Arrange
|
|
var tenant = Tenant.Create(TenantName.Create("Acme"), TenantSlug.Create("acme"));
|
|
var ssoConfig = SsoConfiguration.CreateOidc(
|
|
SsoProvider.AzureAD,
|
|
"https://login.microsoft.com/tenant-id",
|
|
"client-id",
|
|
"client-secret");
|
|
|
|
// Act & Assert
|
|
Assert.Throws<InvalidOperationException>(() => tenant.ConfigureSso(ssoConfig));
|
|
}
|
|
}
|
|
```
|
|
|
|
### Integration Tests - Global Query Filter
|
|
|
|
**File**: `tests/ColaFlow.Infrastructure.Tests/Persistence/GlobalQueryFilterTests.cs`
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
using ColaFlow.Domain.Aggregates.UserAggregate;
|
|
using Xunit;
|
|
|
|
namespace ColaFlow.Infrastructure.Tests.Persistence;
|
|
|
|
public sealed class GlobalQueryFilterTests
|
|
{
|
|
[Fact]
|
|
public async Task QueryFilter_ShouldAutomaticallyFilterByTenant()
|
|
{
|
|
// Arrange
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
var tenant1 = Tenant.Create(TenantName.Create("Tenant1"), TenantSlug.Create("tenant1"));
|
|
var tenant2 = Tenant.Create(TenantName.Create("Tenant2"), TenantSlug.Create("tenant2"));
|
|
|
|
var user1 = User.CreateLocal(tenant1.Id, Email.Create("user1@test.com"), "hash", FullName.Create("User 1"));
|
|
var user2 = User.CreateLocal(tenant2.Id, Email.Create("user2@test.com"), "hash", FullName.Create("User 2"));
|
|
|
|
using (var context = new ApplicationDbContext(options, new MockTenantContext(tenant1.Id)))
|
|
{
|
|
context.Tenants.AddRange(tenant1, tenant2);
|
|
context.Users.AddRange(user1, user2);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
// Act
|
|
using (var context = new ApplicationDbContext(options, new MockTenantContext(tenant1.Id)))
|
|
{
|
|
var users = await context.Users.ToListAsync();
|
|
|
|
// Assert
|
|
Assert.Single(users);
|
|
Assert.Equal(user1.Id, users[0].Id);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveChanges_ShouldAutoSetTenantIdForNewEntities()
|
|
{
|
|
// Arrange
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
var tenant = Tenant.Create(TenantName.Create("Test"), TenantSlug.Create("test"));
|
|
|
|
using (var context = new ApplicationDbContext(options, new MockTenantContext(tenant.Id)))
|
|
{
|
|
context.Tenants.Add(tenant);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
// Act
|
|
using (var context = new ApplicationDbContext(options, new MockTenantContext(tenant.Id)))
|
|
{
|
|
var user = User.CreateLocal(
|
|
default, // TenantId not set
|
|
Email.Create("test@test.com"),
|
|
"hash",
|
|
FullName.Create("Test User"));
|
|
|
|
context.Users.Add(user);
|
|
await context.SaveChangesAsync();
|
|
|
|
// Assert - TenantId should be auto-set
|
|
Assert.Equal(tenant.Id, user.TenantId);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
This multi-tenancy architecture provides:
|
|
|
|
✅ **Complete Tenant Isolation**: EF Core Global Query Filters automatically scope ALL queries
|
|
✅ **Secure by Design**: Cross-tenant access is impossible without explicitly bypassing filters
|
|
✅ **DDD Best Practices**: Proper aggregates, value objects, and domain events
|
|
✅ **Performance**: Optimized with composite indexes (tenant_id + business_key)
|
|
✅ **Flexible Tenant Resolution**: Subdomain, JWT claims, custom headers
|
|
✅ **Production-Ready**: Complete error handling, logging, and validation
|
|
✅ **Testable**: All components have comprehensive unit and integration tests
|
|
|
|
**Next Steps**:
|
|
1. Implement SSO Integration (see `sso-integration-architecture.md`)
|
|
2. Implement MCP Authentication (see `mcp-authentication-architecture.md`)
|
|
3. Execute database migration (see `migration-strategy.md`)
|