In progress
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
|
||||
|
||||
public sealed record SsoConfiguredEvent(Guid TenantId, SsoProvider Provider) : DomainEvent;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
|
||||
|
||||
public sealed record SsoDisabledEvent(Guid TenantId) : DomainEvent;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
|
||||
|
||||
public sealed record TenantActivatedEvent(Guid TenantId) : DomainEvent;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
|
||||
|
||||
public sealed record TenantCancelledEvent(Guid TenantId) : DomainEvent;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
|
||||
|
||||
public sealed record TenantCreatedEvent(Guid TenantId, string Slug) : DomainEvent;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
|
||||
|
||||
public sealed record TenantPlanUpgradedEvent(Guid TenantId, SubscriptionPlan NewPlan) : DomainEvent;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
|
||||
|
||||
public sealed record TenantSuspendedEvent(Guid TenantId, string Reason) : DomainEvent;
|
||||
@@ -0,0 +1,93 @@
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
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> GetAtomicValues()
|
||||
{
|
||||
yield return Provider;
|
||||
yield return Authority;
|
||||
yield return ClientId;
|
||||
yield return EntityId ?? string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
public enum SsoProvider
|
||||
{
|
||||
AzureAD = 1,
|
||||
Google = 2,
|
||||
Okta = 3,
|
||||
GenericSaml = 4
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
public enum SubscriptionPlan
|
||||
{
|
||||
Free = 1,
|
||||
Starter = 2,
|
||||
Professional = 3,
|
||||
Enterprise = 4
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant aggregate root - represents a single organization/company in the system
|
||||
/// </summary>
|
||||
public sealed class Tenant : AggregateRoot
|
||||
{
|
||||
// Properties
|
||||
public TenantName Name { get; private set; } = null!;
|
||||
public TenantSlug Slug { get; private set; } = null!;
|
||||
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? UpdatedAt { 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() : base()
|
||||
{
|
||||
}
|
||||
|
||||
// Factory method for creating new tenant
|
||||
public static Tenant Create(
|
||||
TenantName name,
|
||||
TenantSlug slug,
|
||||
SubscriptionPlan plan = SubscriptionPlan.Free)
|
||||
{
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
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 && Status != TenantStatus.Trial)
|
||||
throw new InvalidOperationException("Only active or trial 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 || Plan == SubscriptionPlan.Starter)
|
||||
throw new InvalidOperationException("SSO is only available for Professional and Enterprise plans");
|
||||
|
||||
SsoConfig = ssoConfig;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SsoConfiguredEvent(Id, ssoConfig.Provider));
|
||||
}
|
||||
|
||||
public void DisableSso()
|
||||
{
|
||||
if (SsoConfig == null)
|
||||
throw new InvalidOperationException("SSO is not configured");
|
||||
|
||||
SsoConfig = null;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SsoDisabledEvent(Id));
|
||||
}
|
||||
|
||||
public void Activate()
|
||||
{
|
||||
if (Status == TenantStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot activate cancelled tenant");
|
||||
|
||||
if (Status == TenantStatus.Active)
|
||||
return; // Already active
|
||||
|
||||
Status = TenantStatus.Active;
|
||||
SuspendedAt = null;
|
||||
SuspensionReason = null;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new TenantActivatedEvent(Id));
|
||||
}
|
||||
|
||||
public void Suspend(string reason)
|
||||
{
|
||||
if (Status == TenantStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot suspend cancelled tenant");
|
||||
|
||||
if (Status == TenantStatus.Suspended)
|
||||
return; // Already suspended
|
||||
|
||||
Status = TenantStatus.Suspended;
|
||||
SuspendedAt = DateTime.UtcNow;
|
||||
SuspensionReason = reason;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new TenantSuspendedEvent(Id, reason));
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
if (Status == TenantStatus.Cancelled)
|
||||
return; // Already cancelled
|
||||
|
||||
Status = TenantStatus.Cancelled;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new TenantCancelledEvent(Id));
|
||||
}
|
||||
|
||||
// Plan limits
|
||||
private static int GetMaxUsersByPlan(SubscriptionPlan plan) => plan switch
|
||||
{
|
||||
SubscriptionPlan.Free => 5,
|
||||
SubscriptionPlan.Starter => 20,
|
||||
SubscriptionPlan.Professional => 100,
|
||||
SubscriptionPlan.Enterprise => int.MaxValue,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(plan))
|
||||
};
|
||||
|
||||
private static int GetMaxProjectsByPlan(SubscriptionPlan plan) => plan switch
|
||||
{
|
||||
SubscriptionPlan.Free => 3,
|
||||
SubscriptionPlan.Starter => 20,
|
||||
SubscriptionPlan.Professional => 100,
|
||||
SubscriptionPlan.Enterprise => int.MaxValue,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(plan))
|
||||
};
|
||||
|
||||
private static int GetMaxStorageByPlan(SubscriptionPlan plan) => plan switch
|
||||
{
|
||||
SubscriptionPlan.Free => 2,
|
||||
SubscriptionPlan.Starter => 20,
|
||||
SubscriptionPlan.Professional => 100,
|
||||
SubscriptionPlan.Enterprise => 1000,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(plan))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
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> GetAtomicValues()
|
||||
{
|
||||
yield return Value;
|
||||
}
|
||||
|
||||
public override string ToString() => Value.ToString();
|
||||
|
||||
// Implicit conversion
|
||||
public static implicit operator Guid(TenantId tenantId) => tenantId.Value;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
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> GetAtomicValues()
|
||||
{
|
||||
yield return Value;
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
// Implicit conversion
|
||||
public static implicit operator string(TenantName name) => name.Value;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
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> GetAtomicValues()
|
||||
{
|
||||
yield return Value;
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
// Implicit conversion
|
||||
public static implicit operator string(TenantSlug slug) => slug.Value;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
public enum TenantStatus
|
||||
{
|
||||
Active = 1,
|
||||
Trial = 2,
|
||||
Suspended = 3,
|
||||
Cancelled = 4
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
public sealed class Email : ValueObject
|
||||
{
|
||||
private static readonly Regex EmailRegex = new(
|
||||
@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
private Email(string value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public static Email Create(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException("Email cannot be empty", nameof(value));
|
||||
|
||||
value = value.ToLowerInvariant().Trim();
|
||||
|
||||
if (value.Length > 255)
|
||||
throw new ArgumentException("Email cannot exceed 255 characters", nameof(value));
|
||||
|
||||
if (!EmailRegex.IsMatch(value))
|
||||
throw new ArgumentException("Invalid email format", nameof(value));
|
||||
|
||||
return new Email(value);
|
||||
}
|
||||
|
||||
protected override IEnumerable<object> GetAtomicValues()
|
||||
{
|
||||
yield return Value;
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
// Implicit conversion
|
||||
public static implicit operator string(Email email) => email.Value;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||
|
||||
public sealed record UserCreatedEvent(Guid UserId, string Email, TenantId TenantId) : DomainEvent;
|
||||
@@ -0,0 +1,10 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||
|
||||
public sealed record UserCreatedFromSsoEvent(
|
||||
Guid UserId,
|
||||
string Email,
|
||||
TenantId TenantId,
|
||||
AuthenticationProvider Provider) : DomainEvent;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||
|
||||
public sealed record UserPasswordChangedEvent(Guid UserId) : DomainEvent;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ColaFlow.Shared.Kernel.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||
|
||||
public sealed record UserSuspendedEvent(Guid UserId, string Reason) : DomainEvent;
|
||||
@@ -0,0 +1,37 @@
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
public sealed class FullName : ValueObject
|
||||
{
|
||||
public string Value { get; }
|
||||
|
||||
private FullName(string value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public static FullName Create(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException("Full name cannot be empty", nameof(value));
|
||||
|
||||
if (value.Length < 2)
|
||||
throw new ArgumentException("Full name must be at least 2 characters", nameof(value));
|
||||
|
||||
if (value.Length > 100)
|
||||
throw new ArgumentException("Full name cannot exceed 100 characters", nameof(value));
|
||||
|
||||
return new FullName(value.Trim());
|
||||
}
|
||||
|
||||
protected override IEnumerable<object> GetAtomicValues()
|
||||
{
|
||||
yield return Value;
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
// Implicit conversion
|
||||
public static implicit operator string(FullName name) => name.Value;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
/// <summary>
|
||||
/// User aggregate root - multi-tenant aware with SSO support
|
||||
/// </summary>
|
||||
public sealed class User : AggregateRoot
|
||||
{
|
||||
// Tenant association
|
||||
public TenantId TenantId { get; private set; } = null!;
|
||||
|
||||
// User identity
|
||||
public Email Email { get; private set; } = null!;
|
||||
public string PasswordHash { get; private set; } = string.Empty;
|
||||
public FullName FullName { get; private set; } = null!;
|
||||
public UserStatus Status { get; private set; }
|
||||
|
||||
// SSO properties
|
||||
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? UpdatedAt { 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() : base()
|
||||
{
|
||||
}
|
||||
|
||||
// Factory method for local authentication
|
||||
public static User CreateLocal(
|
||||
TenantId tenantId,
|
||||
Email email,
|
||||
string passwordHash,
|
||||
FullName fullName)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
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
|
||||
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 = Guid.NewGuid(),
|
||||
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 UserCreatedFromSsoEvent(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");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newPasswordHash))
|
||||
throw new ArgumentException("Password hash cannot be empty", nameof(newPasswordHash));
|
||||
|
||||
PasswordHash = newPasswordHash;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new UserPasswordChangedEvent(Id));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (Status == UserStatus.Deleted)
|
||||
throw new InvalidOperationException("Cannot suspend deleted user");
|
||||
|
||||
Status = UserStatus.Suspended;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new UserSuspendedEvent(Id, reason));
|
||||
}
|
||||
|
||||
public void Reactivate()
|
||||
{
|
||||
if (Status == UserStatus.Deleted)
|
||||
throw new InvalidOperationException("Cannot reactivate deleted user");
|
||||
|
||||
Status = UserStatus.Active;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
Status = UserStatus.Deleted;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// SSO-specific methods
|
||||
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;
|
||||
}
|
||||
|
||||
public void SetPasswordResetToken(string token, DateTime expiresAt)
|
||||
{
|
||||
if (AuthProvider != AuthenticationProvider.Local)
|
||||
throw new InvalidOperationException("Cannot set password reset token for SSO users");
|
||||
|
||||
PasswordResetToken = token;
|
||||
PasswordResetTokenExpiresAt = expiresAt;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void ClearPasswordResetToken()
|
||||
{
|
||||
PasswordResetToken = null;
|
||||
PasswordResetTokenExpiresAt = null;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void SetEmailVerificationToken(string token)
|
||||
{
|
||||
EmailVerificationToken = token;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
public sealed class UserId : ValueObject
|
||||
{
|
||||
public Guid Value { get; }
|
||||
|
||||
private UserId(Guid value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public static UserId CreateUnique() => new(Guid.NewGuid());
|
||||
|
||||
public static UserId Create(Guid value)
|
||||
{
|
||||
if (value == Guid.Empty)
|
||||
throw new ArgumentException("User ID cannot be empty", nameof(value));
|
||||
|
||||
return new UserId(value);
|
||||
}
|
||||
|
||||
protected override IEnumerable<object> GetAtomicValues()
|
||||
{
|
||||
yield return Value;
|
||||
}
|
||||
|
||||
public override string ToString() => Value.ToString();
|
||||
|
||||
// Implicit conversion
|
||||
public static implicit operator Guid(UserId userId) => userId.Value;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
public enum UserStatus
|
||||
{
|
||||
Active = 1,
|
||||
Suspended = 2,
|
||||
Deleted = 3
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Shared\ColaFlow.Shared.Kernel\ColaFlow.Shared.Kernel.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,44 @@
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for Tenant aggregate
|
||||
/// </summary>
|
||||
public interface ITenantRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Get tenant by ID
|
||||
/// </summary>
|
||||
Task<Tenant?> GetByIdAsync(TenantId tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get tenant by slug (unique subdomain identifier)
|
||||
/// </summary>
|
||||
Task<Tenant?> GetBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a slug already exists
|
||||
/// </summary>
|
||||
Task<bool> ExistsBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all tenants (admin operation)
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Add a new tenant
|
||||
/// </summary>
|
||||
Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update an existing tenant
|
||||
/// </summary>
|
||||
Task UpdateAsync(Tenant tenant, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a tenant (hard delete)
|
||||
/// </summary>
|
||||
Task DeleteAsync(Tenant tenant, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for User aggregate
|
||||
/// </summary>
|
||||
public interface IUserRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Get user by ID
|
||||
/// </summary>
|
||||
Task<User?> GetByIdAsync(UserId userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get user by email within a tenant
|
||||
/// </summary>
|
||||
Task<User?> GetByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get user by external SSO ID within a tenant
|
||||
/// </summary>
|
||||
Task<User?> GetByExternalIdAsync(
|
||||
TenantId tenantId,
|
||||
AuthenticationProvider provider,
|
||||
string externalUserId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if an email already exists within a tenant
|
||||
/// </summary>
|
||||
Task<bool> ExistsByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all users for a tenant
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<User>> GetAllByTenantAsync(TenantId tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get active users count for a tenant
|
||||
/// </summary>
|
||||
Task<int> GetActiveUsersCountAsync(TenantId tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Add a new user
|
||||
/// </summary>
|
||||
Task AddAsync(User user, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update an existing user
|
||||
/// </summary>
|
||||
Task UpdateAsync(User user, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a user (hard delete)
|
||||
/// </summary>
|
||||
Task DeleteAsync(User user, CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user