feat: enforce tenant isolation and shared question bank

This commit is contained in:
2026-07-27 16:59:12 +08:00
parent 28e9a9fa41
commit db4c7b4496
137 changed files with 6402 additions and 112274 deletions

View File

@@ -1,16 +0,0 @@
using System.Security.Claims;
namespace Tiku.Application.Security;
public sealed class CurrentTenant : ICurrentTenant
{
public Guid? TenantId { get; private set; }
public string? Role { get; private set; }
public bool IsResolved => TenantId.HasValue;
public void Load(ClaimsPrincipal principal)
{
TenantId = principal.FindGuid(TikuClaimTypes.TenantId);
Role = principal.FindValue(TikuClaimTypes.TenantRole);
}
}

View File

@@ -8,6 +8,7 @@ public sealed class CurrentUser : ICurrentUser
public Guid? SessionId { get; private set; }
public string? Phone { get; private set; }
public string? Email { get; private set; }
public string? TenantRole { get; private set; }
public bool IsAuthenticated { get; private set; }
public void Load(ClaimsPrincipal principal)
@@ -17,5 +18,6 @@ public sealed class CurrentUser : ICurrentUser
SessionId = principal.FindGuid(TikuClaimTypes.SessionId);
Phone = principal.FindValue(TikuClaimTypes.Phone);
Email = principal.FindValue(TikuClaimTypes.Email);
TenantRole = principal.FindValue(TikuClaimTypes.TenantRole);
}
}

View File

@@ -1,11 +0,0 @@
using System.Security.Claims;
namespace Tiku.Application.Security;
public interface ICurrentTenant
{
Guid? TenantId { get; }
string? Role { get; }
bool IsResolved { get; }
void Load(ClaimsPrincipal principal);
}

View File

@@ -8,6 +8,7 @@ public interface ICurrentUser
Guid? SessionId { get; }
string? Phone { get; }
string? Email { get; }
string? TenantRole { get; }
bool IsAuthenticated { get; }
void Load(ClaimsPrincipal principal);
}

View File

@@ -0,0 +1,87 @@
namespace Tiku.Application.Security;
public enum TenantResolutionSource
{
None,
Host,
TenantCode,
Jwt,
RefreshToken,
System
}
public interface ITenantContext
{
Guid? TenantId { get; }
string? TenantCode { get; }
TenantResolutionSource ResolutionSource { get; }
bool IsResolved { get; }
bool IsSystem { get; }
}
public interface ITenantContextInitializer
{
void Initialize(Guid tenantId, string? tenantCode, TenantResolutionSource source);
void InitializeSystem(Guid? targetTenantId, string reason);
}
public sealed class TenantContext : ITenantContext, ITenantContextInitializer
{
public Guid? TenantId { get; private set; }
public string? TenantCode { get; private set; }
public TenantResolutionSource ResolutionSource { get; private set; }
public bool IsResolved => TenantId.HasValue;
public bool IsSystem { get; private set; }
internal string? SystemReason { get; private set; }
public void Initialize(Guid tenantId, string? tenantCode, TenantResolutionSource source)
{
if (tenantId == Guid.Empty)
{
throw new ArgumentException("Tenant ID cannot be empty.", nameof(tenantId));
}
if (IsSystem)
{
throw new InvalidOperationException("A system tenant context cannot be replaced.");
}
if (TenantId.HasValue && TenantId.Value != tenantId)
{
throw new TenantContextConflictException(TenantId.Value, tenantId);
}
var wasResolved = TenantId.HasValue;
TenantId = tenantId;
TenantCode = string.IsNullOrWhiteSpace(tenantCode) ? TenantCode : tenantCode.Trim();
if (!wasResolved)
{
ResolutionSource = source;
}
}
public void InitializeSystem(Guid? targetTenantId, string reason)
{
if (IsResolved || IsSystem)
{
throw new InvalidOperationException("Tenant context has already been initialized.");
}
if (string.IsNullOrWhiteSpace(reason))
{
throw new ArgumentException("A system scope requires an audit reason.", nameof(reason));
}
TenantId = targetTenantId;
ResolutionSource = TenantResolutionSource.System;
IsSystem = true;
SystemReason = reason.Trim();
}
}
public sealed class TenantContextConflictException(Guid expectedTenantId, Guid actualTenantId)
: Exception($"Resolved tenant '{expectedTenantId}' conflicts with authenticated tenant '{actualTenantId}'.")
{
public Guid ExpectedTenantId { get; } = expectedTenantId;
public Guid ActualTenantId { get; } = actualTenantId;
}

View File

@@ -0,0 +1,16 @@
namespace Tiku.Application.Security;
public interface ITenantExecutionScope
{
Task ExecuteAsync(
Guid? targetTenantId,
string reason,
Func<IServiceProvider, CancellationToken, Task> operation,
CancellationToken cancellationToken = default);
Task<TResult> ExecuteAsync<TResult>(
Guid? targetTenantId,
string reason,
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken = default);
}