feat: audit log of admin writes, sign-ins and key creation, with an audit tab [CI]
This commit is contained in:
1 parent
359313a57b
commit
1379b8c7cb
21 files changed
+579
-7
No files matched your search
@@ -36,6 +36,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<WindowComputer>();
|
||||
services.AddSingleton<AuthService>();
|
||||
services.AddSingleton<PasskeyService>();
|
||||
services.AddSingleton<AuditService>();
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<ReleaseService>();
|
||||
services.AddSingleton<ScheduleService>();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 审计日志里的一行:谁(<see cref="Actor"/>,用户名或 <c>key:令牌名</c>)、怎么登录的(<see cref="Method"/>)、做了什么(<see cref="Action"/>,
|
||||
/// 管理接口的 operationId,或 <c>auth.login</c> 这类登录事件)、对谁(<see cref="Target"/>)、结果如何。
|
||||
/// </summary>
|
||||
public sealed record AuditRecord(long Id, long AtMs, string? UserId, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success);
|
||||
|
||||
public sealed record AuditQuery(long? BeforeId, int Limit, string? Actor, string? Action);
|
||||
|
||||
/// <summary>审计日志只追加:没有修改和单条删除,只按保留期整体清理。</summary>
|
||||
public sealed partial class AuditService
|
||||
{
|
||||
/// <summary>保留一年。审计记录的是谁动过信任相关的东西,比监测数据留得久。</summary>
|
||||
public const int RetentionDays = 365;
|
||||
|
||||
private readonly IAuditStore _store;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<AuditService> _logger;
|
||||
|
||||
public AuditService(IAuditStore store, TimeProvider time, ILogger<AuditService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>记一条。写不进去只记日志,不能因为审计失败而让已经完成的操作报错。</summary>
|
||||
public async ValueTask RecordAsync(Principal principal, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default) =>
|
||||
await RecordAsync(principal.UserId, principal.Label, MethodOf(principal), action, target, detail, remote, success, ct);
|
||||
|
||||
public async ValueTask RecordAsync(string? userId, string actor, string method, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _store.AddAsync(new AuditRecord(0, _time.GetUtcNow().ToUnixTimeMilliseconds(), userId, actor, method, action, Clip(target, 200), Clip(detail, 500), remote, success), ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
LogFailed(ex, action);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuditRecord>> ListAsync(AuditQuery query, CancellationToken ct) => _store.ListAsync(query, ct);
|
||||
|
||||
public ValueTask<int> PruneAsync(long nowMs, CancellationToken ct) => _store.DeleteOlderThanAsync(nowMs - (RetentionDays * 86_400_000L), ct);
|
||||
|
||||
/// <summary>登录方式:密码、passkey,或 API key。</summary>
|
||||
public static string MethodOf(Principal principal) =>
|
||||
principal.Kind == PrincipalKind.Anonymous ? "anonymous"
|
||||
: principal.Kind == PrincipalKind.Token && principal.UserId is null ? "key"
|
||||
: principal.AuthMethod.Length > 0 ? principal.AuthMethod : AuthMethods.Password;
|
||||
|
||||
private static string? Clip(string? text, int max) => text is { } t && t.Length > max ? t[..max] : text;
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "could not write the audit record for {Action}")]
|
||||
private partial void LogFailed(Exception exception, string action);
|
||||
}
|
||||
@@ -144,6 +144,15 @@ public interface IEventStore
|
||||
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IAuditStore
|
||||
{
|
||||
ValueTask AddAsync(AuditRecord record, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<AuditRecord>> ListAsync(AuditQuery query, CancellationToken ct);
|
||||
|
||||
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>主机资源占用的历史:每个 Agent 每分钟一个样本。</summary>
|
||||
public interface IHostStore
|
||||
{
|
||||
|
||||
@@ -12,19 +12,21 @@ public sealed partial class RetentionService : BackgroundService
|
||||
private readonly IMetricStore _metrics;
|
||||
private readonly IEventStore _events;
|
||||
private readonly IAuthStore _auth;
|
||||
private readonly AuditService _audit;
|
||||
private readonly IHostStore _hosts;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly LoginRateLimiter _limiter;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<RetentionService> _logger;
|
||||
|
||||
public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger<RetentionService> logger)
|
||||
public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, AuditService audit, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger<RetentionService> logger)
|
||||
{
|
||||
_raw = raw;
|
||||
_metrics = metrics;
|
||||
_events = events;
|
||||
_hosts = hosts;
|
||||
_auth = auth;
|
||||
_audit = audit;
|
||||
_settings = settings;
|
||||
_limiter = limiter;
|
||||
_time = time;
|
||||
@@ -69,6 +71,7 @@ public sealed partial class RetentionService : BackgroundService
|
||||
await _events.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.EventDays, ct) * Steps.Day), ct);
|
||||
await _hosts.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.HostDays, ct) * Steps.Day), ct);
|
||||
await _auth.DeleteExpiredSessionsAsync(nowMs, ct);
|
||||
await _audit.PruneAsync(nowMs, ct);
|
||||
_limiter.Prune();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user