feat: audit log of admin writes, sign-ins and key creation, with an audit tab [CI]

This commit is contained in:
justme committed 2026-10-07 22:17:52 +08:00
1 parent 359313a57b
commit 1379b8c7cb
21 files changed
+579 -7

No files matched your search

@@ -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);
}