feat: harden SaaS authentication and authorization

This commit is contained in:
2026-07-28 12:15:51 +08:00
parent f22f329d33
commit 5d2248efee
123 changed files with 9090 additions and 2822 deletions

View File

@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Tiku.Domain.Tenancy;
namespace Tiku.Application.Auth;
@@ -35,6 +36,7 @@ public sealed record TenantMembershipSummary(
/// <param name="Phone">手机号。</param>
/// <param name="Email">邮箱。</param>
/// <param name="Name">用户显示名称。</param>
/// <param name="Realm">当前令牌的 tenant 或 platform 授权域。</param>
/// <param name="Tenant">当前登录租户成员摘要。</param>
/// <param name="Tokens">认证令牌对。</param>
public sealed record AuthenticatedUser(
@@ -42,25 +44,47 @@ public sealed record AuthenticatedUser(
string? Phone,
string? Email,
string? Name,
TenantMembershipSummary Tenant,
AuthRealm Realm,
TenantMembershipSummary? Tenant,
AuthTokenPair Tokens);
public enum AuthenticationStatus
{
[JsonStringEnumMemberName("authenticated")]
Authenticated,
[JsonStringEnumMemberName("mfa_required")]
MfaRequired,
[JsonStringEnumMemberName("mfa_enrollment_required")]
MfaEnrollmentRequired,
[JsonStringEnumMemberName("password_change_required")]
PasswordChangeRequired
}
public sealed record AuthenticationResult(
AuthenticationStatus Status,
AuthenticatedUser? User = null,
string? ChallengeToken = null,
DateTimeOffset? ChallengeExpiresAt = null);
public sealed record PasswordLoginRequest(
Guid TenantId,
AuthRealm Realm,
Guid? TenantId,
string Phone,
string Password,
string? IpAddress,
string? UserAgent);
public sealed record SmsLoginRequest(
Guid TenantId,
AuthRealm Realm,
Guid? TenantId,
string Phone,
string Code,
string? IpAddress,
string? UserAgent);
public sealed record WechatLoginRequest(
Guid TenantId,
AuthRealm Realm,
Guid? TenantId,
string Code,
string? IpAddress,
string? UserAgent);
@@ -73,6 +97,24 @@ public sealed record RefreshSessionRequest(
public sealed record LogoutSessionRequest(
string RefreshToken);
public sealed record MfaChallengeRequest(
string ChallengeToken,
string? Code,
string? IpAddress,
string? UserAgent);
public sealed record PasswordChangeChallengeRequest(
string ChallengeToken,
string NewPassword,
string? IpAddress,
string? UserAgent);
public sealed record MfaSetupResult(string SharedKey, string AuthenticatorUri);
public sealed record MfaConfirmResult(
AuthenticationResult Authentication,
IReadOnlyList<string> RecoveryCodes);
public sealed record SmsSendResult(
Guid VerificationId,
DateTimeOffset ExpiresAt);
@@ -82,4 +124,5 @@ public sealed record SendSmsCodeRequest(
string Phone,
SmsPurpose Purpose,
string? IpAddress,
string? UserAgent);
string? UserAgent,
string? DeviceId = null);

View File

@@ -19,3 +19,6 @@ public sealed class SmsRateLimitedException()
public sealed class AuthProviderNotConfiguredException(string provider)
: AuthException("auth_provider_not_configured", $"The {provider} auth provider is not configured.");
public sealed class InvalidAuthChallengeException(string code = "invalid_auth_challenge")
: AuthException(code, "The authentication challenge is invalid, consumed, or expired.");

View File

@@ -2,19 +2,19 @@ namespace Tiku.Application.Auth;
public interface IAuthService
{
Task<AuthenticatedUser> LoginWithPasswordAsync(
Task<AuthenticationResult> LoginWithPasswordAsync(
PasswordLoginRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticatedUser> LoginWithSmsAsync(
Task<AuthenticationResult> LoginWithSmsAsync(
SmsLoginRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticatedUser> LoginWithWechatWebAsync(
Task<AuthenticationResult> LoginWithWechatWebAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
Task<AuthenticationResult> LoginWithWechatMiniAppAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default);
@@ -25,4 +25,22 @@ public interface IAuthService
Task LogoutAsync(
LogoutSessionRequest request,
CancellationToken cancellationToken = default);
Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default);
Task<MfaSetupResult> SetupTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default);
Task<MfaConfirmResult> ConfirmTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticationResult> VerifyTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticationResult> ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,49 @@
using Tiku.Domain.Tenancy;
namespace Tiku.Application.Auth;
public interface IAuthSessionStore
{
string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId);
bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator);
string HashRefreshToken(string refreshToken);
Task<AuthTokenPair> IssueAsync(
AuthSessionIssueRequest request,
CancellationToken cancellationToken = default);
Task<AuthTokenPair> RotateAsync(
string refreshToken,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken = default);
Task<AuthSessionValidationResult?> ValidateAccessSessionAsync(
Guid sessionId,
Guid userId,
AuthRealm realm,
Guid? tenantId,
CancellationToken cancellationToken = default);
Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default);
Task RevokeRealmAsync(Guid userId, AuthRealm realm, Guid? tenantId, string reason, CancellationToken cancellationToken = default);
Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default);
}
public sealed record AuthSessionIssueRequest(
Guid UserId,
string? Phone,
string? Email,
string SecurityStamp,
AuthRealm Realm,
Guid? TenantId,
string Provider,
bool MfaSatisfied,
string? IpAddress,
string? UserAgent,
Guid? TokenFamilyId = null,
Guid? ParentSessionId = null);
public sealed record AuthSessionValidationResult(Guid UserId, AuthRealm Realm, Guid? TenantId, bool MfaSatisfied);
public readonly record struct RefreshTokenLocator(AuthRealm Realm, Guid? TenantId, Guid SessionId);

View File

@@ -1,7 +0,0 @@
namespace Tiku.Application.Auth;
public interface IPasswordHasher
{
string Hash(string password);
bool Verify(string password, string passwordHash);
}

View File

@@ -1,22 +0,0 @@
using Tiku.Domain.Tenancy;
namespace Tiku.Application.Auth;
public interface ISessionService
{
string GenerateRefreshToken(Guid tenantId, Guid sessionId);
bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator);
string HashRefreshToken(string refreshToken);
Task<AuthTokenPair> IssueAsync(
Guid userId,
string? phone,
string? email,
TenantMembership membership,
string provider,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken = default);
}
public readonly record struct RefreshTokenLocator(Guid TenantId, Guid SessionId);

View File

@@ -9,5 +9,7 @@ public interface ITokenService
Guid sessionId,
string? phone,
string? email,
TenantMembership membership);
AuthRealm realm,
Guid? tenantId,
bool mfaSatisfied);
}

View File

@@ -0,0 +1,23 @@
namespace Tiku.Application.Auth;
public sealed class SmsSecurityOptions
{
public const string SectionName = "Authentication:Sms";
public string CodePepper { get; set; } = string.Empty;
public int MaxVerificationAttempts { get; set; } = 5;
public int TenantRequestsPerHour { get; set; } = 100;
public int PhoneRequestsPerHour { get; set; } = 5;
public int IpRequestsPerHour { get; set; } = 20;
public int DeviceRequestsPerHour { get; set; } = 10;
public static bool BeValid(SmsSecurityOptions options)
{
return options.CodePepper.Length >= 32 &&
options.MaxVerificationAttempts == 5 &&
options.TenantRequestsPerHour > 0 &&
options.PhoneRequestsPerHour > 0 &&
options.IpRequestsPerHour > 0 &&
options.DeviceRequestsPerHour > 0;
}
}

View File

@@ -1,15 +1,43 @@
using System.Text.Json;
using Tiku.Application.Security;
using Tiku.Domain.Operations;
namespace Tiku.Application.Backoffice;
public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatform);
public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatform)
{
public static BackofficeActor FromTenantAccess(CurrentAccessSnapshot access)
{
if (access.UserId is not { } userId ||
access.TenantId is not { } tenantId ||
!access.IsCurrentTenantMember)
{
throw new InvalidOperationException("Tenant backoffice actor was not resolved.");
}
return new BackofficeActor(userId, tenantId, false);
}
public static BackofficeActor FromPlatformAccess(CurrentAccessSnapshot access)
{
if (access.UserId is not { } userId || !access.IsUserActive)
{
throw new InvalidOperationException("Platform backoffice actor was not resolved.");
}
return new BackofficeActor(userId, null, true);
}
}
public sealed record BackofficeBootstrap(
IReadOnlyCollection<BackofficePermissionItem> Permissions,
IReadOnlyCollection<BackofficeMenuItem> Menus,
IReadOnlyCollection<BackofficeRoleItem> Roles);
public sealed record BackofficeUiBootstrap(
IReadOnlyCollection<string> PermissionCodes,
IReadOnlyCollection<BackofficeMenuItem> Menus);
public sealed record BackofficePermissionItem(
Guid Id,
string Code,

View File

@@ -1,7 +1,17 @@
using Tiku.Application.Security;
namespace Tiku.Application.Backoffice;
public interface IBackofficeService
{
Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
CurrentAccessSnapshot access,
CancellationToken cancellationToken = default);
Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
CurrentAccessSnapshot access,
CancellationToken cancellationToken = default);
Task<BackofficeBootstrap> GetTenantBootstrapAsync(
BackofficeActor actor,
CancellationToken cancellationToken = default);

View File

@@ -0,0 +1,64 @@
namespace Tiku.Application.Security;
public static class BackendPermissions
{
public const string TenantDashboardView = "tenant:dashboard:view";
public const string TenantStaffManage = "tenant:staff:manage";
public const string TenantRoleManage = "tenant:role:manage";
public const string TenantStudentManage = "tenant:student:manage";
public const string TenantContentManage = "tenant:content:manage";
public const string TenantSettingsManage = "tenant:settings:manage";
public const string TenantProviderManage = "tenant:provider:manage";
public const string TenantCommerceOperate = "tenant:commerce:operate";
public const string TenantCrmManage = "tenant:crm:manage";
public const string TenantCommissionManage = "tenant:commission:manage";
public const string TenantJobManage = "tenant:job:manage";
public const string PlatformDashboardView = "platform:dashboard:view";
public const string PlatformTenantManage = "platform:tenant:manage";
public const string PlatformStaffManage = "platform:staff:manage";
public const string PlatformRoleManage = "platform:role:manage";
public const string PlatformQuestionBankManage = "platform:question-bank:manage";
public const string PlatformAuditView = "platform:audit:view";
public static readonly IReadOnlySet<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
{
TenantDashboardView,
TenantStaffManage,
TenantRoleManage,
TenantStudentManage,
TenantContentManage,
TenantSettingsManage,
TenantProviderManage,
TenantCommerceOperate,
TenantCrmManage,
TenantCommissionManage,
TenantJobManage
};
public static readonly IReadOnlySet<string> Platform = new HashSet<string>(StringComparer.Ordinal)
{
PlatformDashboardView,
PlatformTenantManage,
PlatformStaffManage,
PlatformRoleManage,
PlatformQuestionBankManage,
PlatformAuditView
};
public static void EnsureTenant(string permissionCode)
{
if (!Tenant.Contains(permissionCode))
{
throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Unknown tenant permission.");
}
}
public static void EnsurePlatform(string permissionCode)
{
if (!Platform.Contains(permissionCode))
{
throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Unknown platform permission.");
}
}
}

View File

@@ -8,7 +8,6 @@ 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)
@@ -18,6 +17,5 @@ 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

@@ -0,0 +1,149 @@
using System.Text.Json;
namespace Tiku.Application.Security;
public enum DataScopeMode
{
Self,
Restricted,
All
}
public sealed record CurrentDataScope(
DataScopeMode Mode,
IReadOnlySet<Guid> RegionIds,
IReadOnlySet<Guid> ClassIds,
bool IncludesSelf)
{
public static CurrentDataScope Self { get; } = new(
DataScopeMode.Self,
new HashSet<Guid>(),
new HashSet<Guid>(),
true);
public bool AllowsResource(Guid currentUserId, Guid? ownerUserId = null, Guid? regionId = null, Guid? classId = null)
{
if (Mode == DataScopeMode.All)
{
return true;
}
if (IncludesSelf && ownerUserId == currentUserId)
{
return true;
}
return Mode == DataScopeMode.Restricted &&
((regionId.HasValue && RegionIds.Contains(regionId.Value)) ||
(classId.HasValue && ClassIds.Contains(classId.Value)));
}
public static CurrentDataScope Merge(IEnumerable<JsonElement> roleScopes)
{
var regionIds = new HashSet<Guid>();
var classIds = new HashSet<Guid>();
var includesSelf = false;
var hasRestrictedScope = false;
foreach (var roleScope in roleScopes)
{
var parsed = Parse(roleScope);
if (parsed.Mode == DataScopeMode.All)
{
return new CurrentDataScope(DataScopeMode.All, new HashSet<Guid>(), new HashSet<Guid>(), true);
}
includesSelf |= parsed.IncludesSelf;
hasRestrictedScope |= parsed.Mode == DataScopeMode.Restricted;
regionIds.UnionWith(parsed.RegionIds);
classIds.UnionWith(parsed.ClassIds);
}
return hasRestrictedScope || regionIds.Count > 0 || classIds.Count > 0
? new CurrentDataScope(DataScopeMode.Restricted, regionIds, classIds, includesSelf)
: Self;
}
private static CurrentDataScope Parse(JsonElement value)
{
if (value.ValueKind != JsonValueKind.Object)
{
return Self;
}
var mode = ReadString(value, "mode") ?? ReadString(value, "type");
if (string.Equals(mode, nameof(DataScopeMode.All), StringComparison.OrdinalIgnoreCase))
{
return new CurrentDataScope(DataScopeMode.All, new HashSet<Guid>(), new HashSet<Guid>(), true);
}
if (string.Equals(mode, nameof(DataScopeMode.Self), StringComparison.OrdinalIgnoreCase))
{
return Self;
}
var regions = ReadGuids(value, "regionIds");
var classes = ReadGuids(value, "classIds");
var restricted = string.Equals(mode, nameof(DataScopeMode.Restricted), StringComparison.OrdinalIgnoreCase) ||
regions.Count > 0 ||
classes.Count > 0;
return restricted
? new CurrentDataScope(DataScopeMode.Restricted, regions, classes, ReadBoolean(value, "includesSelf") || ReadBoolean(value, "ownLeadsOnly"))
: Self;
}
private static string? ReadString(JsonElement value, string propertyName)
{
return value.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static bool ReadBoolean(JsonElement value, string propertyName)
{
return value.TryGetProperty(propertyName, out var property) &&
property.ValueKind is JsonValueKind.True or JsonValueKind.False &&
property.GetBoolean();
}
private static HashSet<Guid> ReadGuids(JsonElement value, string propertyName)
{
var result = new HashSet<Guid>();
if (!value.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array)
{
return result;
}
foreach (var item in property.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id))
{
result.Add(id);
}
}
return result;
}
}
public sealed record CurrentAccessSnapshot(
Guid? UserId,
Guid? TenantId,
bool IsUserActive,
bool IsCurrentTenantMember,
IReadOnlySet<string> TenantPermissions,
IReadOnlySet<string> PlatformPermissions,
CurrentDataScope DataScope)
{
public bool HasTenantPermission(string permissionCode) =>
IsCurrentTenantMember && TenantPermissions.Contains(permissionCode);
public bool HasPlatformPermission(string permissionCode) =>
IsUserActive && PlatformPermissions.Contains(permissionCode);
}
public interface ICurrentAccessContext
{
Task<CurrentAccessSnapshot> GetAsync(CancellationToken cancellationToken = default);
}

View File

@@ -8,7 +8,6 @@ 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,9 @@
using Microsoft.IdentityModel.Tokens;
namespace Tiku.Application.Security;
public interface IJwtKeyRing
{
SigningCredentials SigningCredentials { get; }
IReadOnlyCollection<SecurityKey> ValidationKeys { get; }
}

View File

@@ -9,14 +9,73 @@ public sealed class JwtOptions
public string Audience { get; set; } = "tiku-api";
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.MinLength(32)]
public string SigningKey { get; set; } = string.Empty;
public string KeyId { get; set; } = "development-ephemeral";
public string PrivateKeyPem { get; set; } = string.Empty;
public Dictionary<string, string> PublicKeys { get; set; } = new(StringComparer.Ordinal);
[System.ComponentModel.DataAnnotations.Range(1, 1440)]
public int AccessTokenMinutes { get; set; } = 30;
public int AccessTokenMinutes { get; set; } = 15;
[System.ComponentModel.DataAnnotations.Range(1, 365)]
public int RefreshTokenDays { get; set; } = 30;
public bool ValidateSessions { get; set; } = true;
public static bool BeValid(JwtOptions options, bool isProduction)
{
if (string.IsNullOrWhiteSpace(options.Issuer) ||
string.IsNullOrWhiteSpace(options.Audience) ||
string.IsNullOrWhiteSpace(options.KeyId) ||
options.AccessTokenMinutes != 15 ||
(isProduction && string.Equals(
options.KeyId,
"development-ephemeral",
StringComparison.Ordinal)))
{
return false;
}
if (string.IsNullOrWhiteSpace(options.PrivateKeyPem))
{
if (isProduction)
{
return false;
}
}
else if (!IsValidRsaPem(options.PrivateKeyPem, requirePrivateKey: true))
{
return false;
}
return options.PublicKeys.All(pair =>
!string.IsNullOrWhiteSpace(pair.Key) &&
!string.Equals(pair.Key, options.KeyId, StringComparison.Ordinal) &&
IsValidRsaPem(pair.Value, requirePrivateKey: false));
}
private static bool IsValidRsaPem(string pem, bool requirePrivateKey)
{
try
{
using var rsa = System.Security.Cryptography.RSA.Create();
rsa.ImportFromPem(pem);
if (rsa.KeySize < 2048)
{
return false;
}
if (requirePrivateKey)
{
_ = rsa.ExportParameters(includePrivateParameters: true);
}
return true;
}
catch (Exception exception) when (
exception is ArgumentException or
System.Security.Cryptography.CryptographicException)
{
return false;
}
}
}

View File

@@ -4,10 +4,11 @@ namespace Tiku.Application.Security;
public static class TikuClaimTypes
{
public const string UserId = "tiku:user_id";
public const string TenantId = "tiku:tenant_id";
public const string SessionId = "tiku:session_id";
public const string TenantRole = "tiku:tenant_role";
public const string UserId = "sub";
public const string TenantId = "tid";
public const string SessionId = "sid";
public const string Realm = "scope";
public const string Mfa = "amr";
public const string Phone = ClaimTypes.MobilePhone;
public const string Email = ClaimTypes.Email;
}

View File

@@ -4,5 +4,23 @@ public static class TikuPolicies
{
public const string AuthenticatedUser = "authenticated_user";
public const string CurrentTenantMember = "current_tenant_member";
public const string TenantBackofficeBootstrap = "tenant_backoffice_bootstrap";
public const string PlatformBackofficeBootstrap = "platform_backoffice_bootstrap";
public const string Mfa = "mfa";
public const string TenantContentManageAllScope = "tenant:content:manage:all_scope";
public const string TenantCommerceOperateAllScope = "tenant:commerce:operate:all_scope";
public const string TenantAdmin = "tenant_admin";
public static string TenantPermission(string permissionCode)
{
BackendPermissions.EnsureTenant(permissionCode);
return permissionCode;
}
public static string PlatformPermission(string permissionCode)
{
BackendPermissions.EnsurePlatform(permissionCode);
return permissionCode;
}
}

View File

@@ -90,25 +90,6 @@ public interface ITenantAdminDirectService
TenantAdminAuditLogFilter filter,
CancellationToken cancellationToken = default);
Task<TenantAdminPermissionMatrix> GetPermissionMatrixAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminRoleTemplateItem>> GetRoleTemplatesAsync(
TenantAdminActor actor,
TenantAdminRoleTemplateFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminRoleTemplateItem>> UpsertRoleTemplateAsync(
TenantAdminActor actor,
UpsertTenantAdminRoleTemplateCommand command,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminRoleTemplateItem>> DisableRoleTemplateAsync(
TenantAdminActor actor,
Guid roleTemplateId,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantBrandingItem>> UpsertBrandingAsync(
TenantAdminActor actor,
UpsertTenantBrandingCommand command,

View File

@@ -6,7 +6,18 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Application.TenantAdmin;
public sealed record TenantAdminActor(Guid TenantId, Guid UserId, TenantRole Role = TenantRole.TenantAdmin);
public sealed record TenantAdminActor(Guid TenantId, Guid UserId)
{
public static TenantAdminActor FromResolvedIdentity(Guid? tenantId, Guid? userId)
{
if (tenantId is null || userId is null)
{
throw new InvalidOperationException("Tenant admin actor was not resolved.");
}
return new(tenantId.Value, userId.Value);
}
}
public sealed record TenantAdminClassFilter(
Guid? RegionId = null,
@@ -44,10 +55,6 @@ public sealed record TenantAdminAuditLogFilter(
Guid? ActorUserId = null,
int? Limit = null);
public sealed record TenantAdminRoleTemplateFilter(
string? Status = null,
int? Limit = null);
public sealed record TenantAdminBadgeFilter(
string? Category = null,
bool IncludeInactive = false,
@@ -140,24 +147,8 @@ public sealed record UpsertTenantAdminMemberCommand(
UserLookupCommand User,
string? Role,
string? Status,
Guid? RoleTemplateId,
JsonElement Permissions,
string? PrimaryRole);
public sealed record UpsertTenantAdminRoleTemplateCommand(
Guid? Id,
string? Code,
string Name,
string? Description,
string? BaseRole,
string? Status,
JsonElement Permissions,
JsonElement MenuPermissions,
JsonElement ModulePermissions,
JsonElement FieldPermissions,
JsonElement DataScope,
int? Order);
public sealed record UpsertTenantBrandingCommand(
string BrandName,
string? ShortName,
@@ -357,10 +348,6 @@ public sealed record TenantAdminMemberItem(
Guid UserId,
TenantRole Role,
MembershipStatus Status,
JsonElement Permissions,
Guid? RoleTemplateId,
string? RoleTemplateCode,
string? RoleTemplateName,
string? LegacyRole,
TenantAdminUserSummary User,
DateTimeOffset CreatedAt,
@@ -379,37 +366,6 @@ public sealed record TenantAdminAuditLogItem(
string? ActorPhone,
DateTimeOffset CreatedAt);
public sealed record TenantAdminPermissionMatrix(
TenantAdminCurrentPermission Current,
IReadOnlyCollection<TenantAdminPermissionCatalogItem> Permissions,
IReadOnlyDictionary<string, IReadOnlyCollection<string>> RoleDefaults);
public sealed record TenantAdminCurrentPermission(
Guid UserId,
Guid TenantId,
TenantRole Role);
public sealed record TenantAdminPermissionCatalogItem(string Key, string Label);
public sealed record TenantAdminRoleTemplateItem(
Guid Id,
string Code,
string Name,
string? Description,
TenantRole BaseRole,
TenantRoleTemplateStatus Status,
JsonElement Permissions,
JsonElement MenuPermissions,
JsonElement ModulePermissions,
JsonElement FieldPermissions,
JsonElement DataScope,
bool IsSystem,
int Order,
Guid? CreatedBy,
Guid? UpdatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record TenantBrandingItem(
Guid TenantId,
string BrandName,

View File

@@ -6,6 +6,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>
<PropertyGroup>