feat: strengthen P0 security and operations

This commit is contained in:
2026-08-01 11:20:02 +08:00
parent f776056834
commit 84c2b0b21d
77 changed files with 24185 additions and 355 deletions

View File

@@ -0,0 +1,30 @@
namespace Tiku.Application.Assets;
public enum AssetSecurityScanVerdict
{
Clean,
Infected
}
public sealed record AssetSecurityScanResult(
AssetSecurityScanVerdict Verdict,
string Provider,
string? Signature,
long BytesScanned,
string RawResponse);
public interface IAssetSecurityScanner
{
Task<AssetSecurityScanResult> ScanAsync(
Stream content,
long? declaredLength,
CancellationToken cancellationToken = default);
Task<bool> CheckHealthAsync(CancellationToken cancellationToken = default);
}
public sealed class AssetSecurityScannerException(string code, string message, Exception? innerException = null)
: Exception(message, innerException)
{
public string Code { get; } = code;
}

View File

@@ -99,6 +99,41 @@ public sealed record PasswordChangeChallengeRequest(
string? IpAddress,
string? UserAgent);
public sealed record PasswordResetCodeRequest(
Guid TenantId,
string Phone,
string? IpAddress,
string? UserAgent,
string? DeviceId = null);
public sealed record PasswordResetRequest(
Guid TenantId,
string Phone,
string Code,
string NewPassword,
string? IpAddress,
string? UserAgent);
public sealed record AuthenticatedPasswordChangeRequest(
Guid UserId,
Guid SessionId,
string CurrentPassword,
string NewPassword,
string? IpAddress,
string? UserAgent);
public sealed record AuthSessionSummary(
Guid SessionFamilyId,
AuthRealm Realm,
Guid? TenantId,
string Provider,
DateTimeOffset CreatedAt,
DateTimeOffset LastRotatedAt,
DateTimeOffset ExpiresAt,
string? IpAddressMasked,
string? UserAgent,
bool IsCurrent);
public sealed record SmsSendResult(
Guid VerificationId,
DateTimeOffset ExpiresAt);

View File

@@ -25,3 +25,9 @@ public sealed class AuthSecurityUnavailableException()
public sealed class InvalidAuthChallengeException(string code = "invalid_auth_challenge")
: AuthException(code, "The authentication challenge is invalid, consumed, or expired.");
public sealed class AuthSessionNotFoundException()
: AuthException("auth_session_not_found", "The requested authentication session was not found.");
public sealed class CurrentAuthSessionCannotBeRevokedException()
: AuthException("current_auth_session_cannot_be_revoked", "Use logout to revoke the current authentication session.");

View File

@@ -0,0 +1,15 @@
namespace Tiku.Application.Auth;
public sealed record AdministrativePasswordResetRequest(
Guid ActorUserId,
Guid TargetUserId,
Guid? TenantId,
string TemporaryPassword,
string Reason);
public interface IAuthAdministrationService
{
Task ResetPasswordAsync(
AdministrativePasswordResetRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -31,4 +31,16 @@ public interface IAuthService
Task<AuthenticationResult> ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default);
Task<SmsSendResult> RequestPasswordResetAsync(
PasswordResetCodeRequest request,
CancellationToken cancellationToken = default);
Task ResetPasswordAsync(
PasswordResetRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticationResult> ChangePasswordAsync(
AuthenticatedPasswordChangeRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -25,9 +25,25 @@ public interface IAuthSessionStore
Guid? tenantId,
CancellationToken cancellationToken = default);
Task<AuthSessionValidationResult?> ResolveActiveSessionAsync(
Guid sessionId,
Guid userId,
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);
Task<IReadOnlyCollection<AuthSessionSummary>> ListActiveAsync(
Guid userId,
Guid currentSessionId,
CancellationToken cancellationToken = default);
Task RevokeOwnedFamilyAsync(
Guid userId,
Guid currentSessionId,
Guid sessionFamilyId,
CancellationToken cancellationToken = default);
}
public sealed record AuthSessionIssueRequest(

View File

@@ -3,23 +3,34 @@ using Tiku.Domain.Operations;
namespace Tiku.Application.Jobs;
public sealed class BackgroundJobException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}
public sealed record CreateBackgroundJobCommand(
Guid TenantId,
string JobType,
JsonElement Payload,
DateTimeOffset? RunAfter = null,
int MaxRetries = 3);
int MaxRetries = 3,
string? IdempotencyKey = null,
bool IsSystemJob = false);
public sealed record BackgroundJobItem(
Guid Id,
Guid TenantId,
string JobType,
string? IdempotencyKey,
BackgroundJobStatus Status,
int RetryCount,
int MaxRetries,
DateTimeOffset? RunAfter,
DateTimeOffset? StartedAt,
DateTimeOffset? CompletedAt,
DateTimeOffset? CancellationRequestedAt,
Guid? CancellationRequestedBy,
string? CancellationReason,
string? LastError,
Guid? OutputAssetId,
JsonElement Result);
@@ -48,4 +59,29 @@ public interface IBackgroundJobService
string? jobType = null,
int limit = 50,
CancellationToken cancellationToken = default);
Task<BackgroundJobItem?> GetAsync(
Guid jobId,
Guid? tenantId,
CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<BackgroundJobItem>> ListPlatformAsync(
Guid? tenantId = null,
string? jobType = null,
BackgroundJobStatus? status = null,
int limit = 100,
CancellationToken cancellationToken = default);
Task<BackgroundJobItem> RequestCancellationAsync(
Guid jobId,
Guid? tenantId,
Guid actorUserId,
string reason,
CancellationToken cancellationToken = default);
Task<BackgroundJobItem> RetryAsync(
Guid jobId,
Guid? tenantId,
Guid actorUserId,
CancellationToken cancellationToken = default);
}

View File

@@ -35,6 +35,8 @@ public static class BackendPermissions
public const string PlatformSmsWrite = "platform:sms:write";
public const string PlatformPaymentRead = "platform:payment:read";
public const string PlatformPaymentWrite = "platform:payment:write";
public const string PlatformOperationsView = "platform:operations:view";
public const string PlatformOperationsManage = "platform:operations:manage";
public static readonly IReadOnlySet<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
{
@@ -73,7 +75,9 @@ public static class BackendPermissions
PlatformSmsRead,
PlatformSmsWrite,
PlatformPaymentRead,
PlatformPaymentWrite
PlatformPaymentWrite,
PlatformOperationsView,
PlatformOperationsManage
};
public static void EnsureTenant(string permissionCode)

View File

@@ -83,6 +83,7 @@ public static class PermissionModuleCatalog
["platform_crm"] = null,
["platform_sms"] = null,
["platform_payment"] = null,
["platform_operations"] = null,
["commerce"] = SaasFeatureCatalog.StudentStore
};
@@ -114,6 +115,7 @@ public static class PermissionModuleCatalog
BackendPermissions.PlatformCrmRead or BackendPermissions.PlatformCrmWrite => "platform_crm",
BackendPermissions.PlatformSmsRead or BackendPermissions.PlatformSmsWrite => "platform_sms",
BackendPermissions.PlatformPaymentRead or BackendPermissions.PlatformPaymentWrite => "platform_payment",
BackendPermissions.PlatformOperationsView or BackendPermissions.PlatformOperationsManage => "platform_operations",
_ when permissionCode.StartsWith("commerce:", StringComparison.Ordinal) => "commerce",
_ => throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Permission module mapping is missing.")
};

View File

@@ -26,4 +26,9 @@ public interface IObjectStorageService
Task<ObjectStorageMetadata> HeadObjectAsync(
ObjectStorageHeadRequest request,
CancellationToken cancellationToken = default);
Task<Stream> OpenReadAsync(
ObjectStorageReadRequest request,
CancellationToken cancellationToken = default) =>
Task.FromException<Stream>(new NotSupportedException("Object storage read streaming is not configured."));
}

View File

@@ -45,6 +45,12 @@ public sealed record ObjectStorageHeadRequest(
long? DeclaredFileSizeBytes = null,
string? DeclaredChecksumSha256 = null);
public sealed record ObjectStorageReadRequest(
Guid TenantId,
string Provider,
string Bucket,
string ObjectKey);
public sealed record ObjectStorageWriteRequest(
Guid TenantId,
string Provider,

View File

@@ -0,0 +1,39 @@
using Tiku.Application.Storage;
using Tiku.Domain.Operations;
namespace Tiku.Application.Tenancy;
public sealed record TenantArchivePreview(
Guid TenantId,
bool CanArchive,
bool HasRecentSuccessfulExport,
IReadOnlyCollection<string> Blockers);
public sealed record TenantLifecycleOperationItem(
Guid Id,
Guid TenantId,
TenantLifecycleOperationType OperationType,
TenantLifecycleOperationStatus Status,
Guid RequestedBy,
Guid? TargetUserId,
Guid? ExportAssetId,
string? Reason,
string? LastError,
DateTimeOffset CreatedAt,
DateTimeOffset? CompletedAt);
public interface ITenantLifecycleService
{
Task<TenantArchivePreview> PreviewArchiveAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task<TenantLifecycleOperationItem> CreateExportAsync(Guid tenantId, Guid actorUserId, CancellationToken cancellationToken = default);
Task<TenantLifecycleOperationItem?> GetOperationAsync(Guid tenantId, Guid operationId, CancellationToken cancellationToken = default);
Task<ObjectStorageSignedUrl> SignExportDownloadAsync(Guid tenantId, Guid operationId, CancellationToken cancellationToken = default);
Task<TenantLifecycleOperationItem> ArchiveAsync(Guid tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default);
Task<TenantLifecycleOperationItem> RestoreAsync(Guid tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default);
Task<TenantLifecycleOperationItem> TransferOwnerAsync(Guid tenantId, Guid actorUserId, Guid targetUserId, string reason, CancellationToken cancellationToken = default);
}
public sealed class TenantLifecycleException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}