diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 878072d..5823ca4 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -3598,6 +3598,93 @@ "x-moshiro-capability": "data.write" } }, + "/api/admin/audit": { + "get": { + "tags": [ + "admin-audit" + ], + "summary": "Audit log of admin actions, newest first", + "operationId": "audit_list", + "parameters": [ + { + "name": "actor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "action", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfAuditDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "audit.read" + } + }, "/api/admin/api-tokens": { "get": { "tags": [ @@ -4938,6 +5025,63 @@ } } }, + "AuditDto": { + "required": [ + "id", + "at", + "actor", + "method", + "action", + "target", + "detail", + "remote", + "success" + ], + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "at": { + "format": "date-time" + }, + "actor": { + "type": "string" + }, + "method": { + "type": "string" + }, + "action": { + "type": "string" + }, + "target": { + "type": [ + "null", + "string" + ] + }, + "detail": { + "type": [ + "null", + "string" + ] + }, + "remote": { + "type": [ + "null", + "string" + ] + }, + "success": { + "type": "boolean" + } + } + }, "BackendUpdateDto": { "required": [ "channel", @@ -6982,6 +7126,27 @@ } } }, + "PageDtoOfAuditDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, "PageDtoOfEventDto": { "required": [ "items", @@ -8623,6 +8788,9 @@ { "name": "admin-data" }, + { + "name": "admin-audit" + }, { "name": "admin-api-tokens" }, diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index b0c22b0..f80e267 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -132,6 +132,7 @@ public static class AgentEndpoints private static async Task CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options) { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; + http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}"); Result result = await tokens.CreateAsync( new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, http.RequestAborted); if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error)) diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs new file mode 100644 index 0000000..fc4180d --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Api; + +/// 审计日志:管理接口里每个写操作、登录和提权都会记一行,这里只读。 +public static class AuditEndpoints +{ + private const string Tag = "admin-audit"; + + /// handler 往这里放一句「这次具体做了什么」(例如新建令牌的名字),守卫记审计时一起写进去。 + public const string DetailItem = "moshiro.audit.detail"; + + public static void Map(RouteGroupBuilder guarded) => + guarded.MapGet("/audit", ListAsync) + .Api("audit_list", "Audit log of admin actions, newest first", Tag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.AuditRead); + + public static void SetDetail(this HttpContext http, string detail) => http.Items[DetailItem] = detail; + + private static async Task ListAsync(HttpContext http, AuditService audit, string? actor, string? action, int? limit, string? cursor) + { + long? before = null; + if (cursor is not null) + { + if (!long.TryParse(cursor, out long parsed)) + { + return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor")); + } + + before = parsed; + } + + int size = Math.Clamp(limit ?? 50, 1, 500); + IReadOnlyList rows = await audit.ListAsync(new AuditQuery(before, size + 1, string.IsNullOrEmpty(actor) ? null : actor, string.IsNullOrEmpty(action) ? null : action), http.RequestAborted); + bool more = rows.Count > size; + IReadOnlyList page = [.. rows.Take(size)]; + string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null; + return TypedResults.Json(new PageDto([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoAuditDto); + } +} diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 85cc869..39e6f8a 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -57,7 +57,7 @@ public static class AuthEndpoints .Requires(Capability.Authenticated); } - private static async Task LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options) + private static async Task LoginAsync(HttpContext http, LoginRequest request, AuthService auth, AuditService audit, LoginRateLimiter limiter, BackendOptions options) { string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; if (!limiter.TryAcquire(source, out int retryAfter)) @@ -66,11 +66,14 @@ public static class AuthEndpoints } Result result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted); + string remote = source; if (!result.TryGet(out LoginResult? login, out FleetError? error)) { + await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "auth.login", null, null, remote, false, http.RequestAborted); return Problems.From(http, error); } + await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "auth.login", null, null, remote, true, http.RequestAborted); return IssueSession(http, login, request.ReturnSession == true, options); } @@ -132,7 +135,7 @@ public static class AuthEndpoints return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs), ApiJsonContext.Default.PasskeyRequestOptionsDto); } - private static async Task PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options) + private static async Task PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options) { IResult limit = Limited(http, limiter, out bool ok); if (!ok) @@ -142,7 +145,15 @@ public static class AuthEndpoints Result result = await passkeys.LoginAsync( new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted); - return result.TryGet(out LoginResult? login, out FleetError? error) ? IssueSession(http, login, request.ReturnSession == true, options) : Problems.From(http, error); + string? remote = http.Connection.RemoteIpAddress?.ToString(); + if (!result.TryGet(out LoginResult? login, out FleetError? error)) + { + await audit.RecordAsync(null, "?", AuthMethods.Passkey, "auth.login", null, null, remote, false, http.RequestAborted); + return Problems.From(http, error); + } + + await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Passkey, "auth.login", null, null, remote, true, http.RequestAborted); + return IssueSession(http, login, request.ReturnSession == true, options); } private static async Task ListPasskeysAsync(HttpContext http, PasskeyService passkeys) diff --git a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs index f52f95a..3c3b1dc 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs @@ -64,6 +64,7 @@ public static class SystemEndpoints private static async Task CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request) { + http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}"); Result result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted); if (!result.TryGet(out CreatedApiToken? created, out FleetError? error)) { diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 74300f3..95d72f0 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -110,6 +110,7 @@ public static class ApiModule AgentEndpoints.Map(guarded); TaskEndpoints.Map(guarded); DataEndpoints.Map(guarded); + AuditEndpoints.Map(guarded); SystemEndpoints.Map(guarded); BuildEndpoints.Map(guarded); LinkEndpoints.Map(guarded); diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 431c4d3..92ec7dc 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using MoShiro.Backend.Application; using MoShiro.Protocol; @@ -88,6 +89,19 @@ public static class CapabilityGuard } } + if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method)) + { + // 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里 + string action = http.GetEndpoint()?.Metadata.GetMetadata()?.EndpointName ?? http.Request.Path.Value ?? ""; + string? target = http.Request.RouteValues.Count == 0 ? null : string.Join(' ', http.Request.RouteValues.Where(v => v.Value is not null).Select(v => v.Key + "=" + v.Value)); + http.Response.OnCompleted(() => + { + http.Items.TryGetValue(AuditEndpoints.DetailItem, out object? detail); + return http.RequestServices.GetRequiredService() + .RecordAsync(principal, action, target, detail as string, http.Connection.RemoteIpAddress?.ToString(), http.Response.StatusCode < 400).AsTask(); + }); + } + return await next(context); } } diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index 131affe..cf56e6c 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -156,6 +156,8 @@ public static class Mappers public static JoinTokenDto ToDto(JoinTokenInfo t) => new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy); + public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success); + public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports); public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs)); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index cbb44f7..ce5b95d 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -70,6 +70,7 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(RawChunkMetaDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(ApiTokenDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreateApiTokenRequest))] diff --git a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs index 7698bb5..5033e40 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs @@ -10,6 +10,8 @@ public sealed record EdgePartialDto(bool First, bool Last); public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason); +public sealed record AuditDto(long Id, DateTimeOffset At, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success); + public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail); public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt); diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index d8a1c19..cfb3ad2 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -36,6 +36,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Audit/AuditService.cs b/backend/src/MoShiro.Backend.Application/Audit/AuditService.cs new file mode 100644 index 0000000..36c9b47 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Audit/AuditService.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Logging; + +namespace MoShiro.Backend.Application; + +/// +/// 审计日志里的一行:谁(,用户名或 key:令牌名)、怎么登录的()、做了什么(, +/// 管理接口的 operationId,或 auth.login 这类登录事件)、对谁()、结果如何。 +/// +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); + +/// 审计日志只追加:没有修改和单条删除,只按保留期整体清理。 +public sealed partial class AuditService +{ + /// 保留一年。审计记录的是谁动过信任相关的东西,比监测数据留得久。 + public const int RetentionDays = 365; + + private readonly IAuditStore _store; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public AuditService(IAuditStore store, TimeProvider time, ILogger logger) + { + _store = store; + _time = time; + _logger = logger; + } + + /// 记一条。写不进去只记日志,不能因为审计失败而让已经完成的操作报错。 + 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> ListAsync(AuditQuery query, CancellationToken ct) => _store.ListAsync(query, ct); + + public ValueTask PruneAsync(long nowMs, CancellationToken ct) => _store.DeleteOlderThanAsync(nowMs - (RetentionDays * 86_400_000L), ct); + + /// 登录方式:密码、passkey,或 API key。 + 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); +} diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index ba1c7ef..15d72ee 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -144,6 +144,15 @@ public interface IEventStore ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); } +public interface IAuditStore +{ + ValueTask AddAsync(AuditRecord record, CancellationToken ct); + + ValueTask> ListAsync(AuditQuery query, CancellationToken ct); + + ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); +} + /// 主机资源占用的历史:每个 Agent 每分钟一个样本。 public interface IHostStore { diff --git a/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs b/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs index 85448fb..81966b3 100644 --- a/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs +++ b/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs @@ -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 _logger; - public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger logger) + public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, AuditService audit, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger 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(); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 2745ce7..1f03d07 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -191,6 +191,21 @@ internal static class Schema connection.Exec("ALTER TABLE join_tokens ADD COLUMN created_by TEXT"); connection.Exec("ALTER TABLE agents ADD COLUMN added_by TEXT"); connection.Exec("ALTER TABLE probes ADD COLUMN added_by TEXT"); + connection.Exec(""" + CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + at INTEGER NOT NULL, + user_id TEXT, + actor TEXT NOT NULL, + method TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + detail TEXT, + remote TEXT, + success INTEGER NOT NULL + ) + """); + connection.Exec("CREATE INDEX ix_audit_time ON audit_log (at)"); connection.Exec("PRAGMA user_version = 13"); transaction.Commit(); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuditStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuditStore.cs new file mode 100644 index 0000000..cbeb7d7 --- /dev/null +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuditStore.cs @@ -0,0 +1,33 @@ +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Storage.Sqlite; + +public sealed class SqliteAuditStore : IAuditStore +{ + private readonly SqliteDatabase _db; + + public SqliteAuditStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask AddAsync(AuditRecord record, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT INTO audit_log (at, user_id, actor, method, action, target, detail, remote, success) VALUES (@at, @uid, @actor, @method, @action, @target, @detail, @remote, @ok)", + ("@at", record.AtMs), ("@uid", record.UserId), ("@actor", record.Actor), ("@method", record.Method), ("@action", record.Action), + ("@target", record.Target), ("@detail", record.Detail), ("@remote", record.Remote), ("@ok", record.Success ? 1 : 0)), + ct); + + public ValueTask> ListAsync(AuditQuery query, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT id, at, user_id, actor, method, action, target, detail, remote, success FROM audit_log " + + "WHERE (@before IS NULL OR id < @before) AND (@actor IS NULL OR actor = @actor) AND (@action IS NULL OR action = @action) ORDER BY id DESC LIMIT @limit", + r => new AuditRecord(r.GetInt64(0), r.GetInt64(1), r.NullableString(2), r.GetString(3), r.GetString(4), r.GetString(5), r.NullableString(6), r.NullableString(7), r.NullableString(8), r.GetInt64(9) != 0), + ("@before", query.BeforeId), ("@actor", query.Actor), ("@action", query.Action), ("@limit", query.Limit)), + ct); + + public ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM audit_log WHERE at < @cutoff", ("@cutoff", cutoffMs)), ct); +} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs index bf05c7f..63b4f1a 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/tests/MoShiro.Backend.Tests/AuditTests.cs b/backend/tests/MoShiro.Backend.Tests/AuditTests.cs new file mode 100644 index 0000000..9d1f5ec --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/AuditTests.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Text.Json.Nodes; + +namespace MoShiro.Backend.Tests; + +/// 审计日志:写操作、登录成败都会记一行;谁做的(用户名或 key:令牌名)、结果、细节。 +[Collection("probe-e2e")] +public sealed class AuditTests +{ + private readonly BackendFixture _backend; + + public AuditTests(BackendFixture backend) + { + _backend = backend; + } + + /// 审计在响应发完之后才写,轮询一下。 + private async Task FindAsync(string query, Func match) + { + for (int i = 0; i < 50; i++) + { + JsonNode page = JsonNode.Parse(await _backend.Admin.GetStringAsync("/api/admin/audit?" + query))!; + if (page["items"]!.AsArray().FirstOrDefault(x => x is not null && match(x)) is { } found) + { + return found; + } + + await Task.Delay(100); + } + + return null; + } + + [Fact] + public async Task WriteActions_AreRecorded_WithWhoAndTheDetail() + { + string note = "audit-" + Guid.NewGuid().ToString("N")[..8]; + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["note"] = note })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + + JsonNode? row = await FindAsync("limit=100", x => x["detail"]?.GetValue().Contains(note, StringComparison.Ordinal) == true); + Assert.NotNull(row); + Assert.StartsWith("key:", row["actor"]!.GetValue(), StringComparison.Ordinal); + Assert.Equal("key", row["method"]!.GetValue()); + Assert.True(row["success"]!.GetValue()); + Assert.False(string.IsNullOrEmpty(row["action"]!.GetValue())); + } + + [Fact] + public async Task FailedSignIn_IsRecorded_WithTheAttemptedName_AndWithoutThePassword() + { + string name = "nobody-" + Guid.NewGuid().ToString("N")[..8]; + using HttpResponseMessage denied = await _backend.Anonymous.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "secret-pw-123" })); + Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode); + + JsonNode? row = await FindAsync("action=auth.login&limit=100", x => x["actor"]!.GetValue() == name); + Assert.NotNull(row); + Assert.False(row["success"]!.GetValue()); + Assert.DoesNotContain("secret-pw-123", row.ToJsonString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Audit_NeedsItsOwnCapability() + { + using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/admin/audit"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index ef7651f..1cc6d72 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,7 +1,7 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' import { useEffect, useMemo, useState, type ReactNode } from 'react' import { api } from '@shared/api/client' -import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' import { fmtNum, fmtTime } from '@shared/format' import { translateSetting } from '@shared/i18n' @@ -10,7 +10,7 @@ import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented import { CapabilityPicker, capabilityName } from './CapabilityPicker' import { PasskeyAddForm } from './PasskeyAdd' -type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis' +type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'audit' | 'events' | 'raw' | 'analysis' const RESTART_KEYS = ['fleet.restart_enabled', 'fleet.restart_hour', 'fleet.restart_minute', 'fleet.restart_utc_offset_minutes'] @@ -636,6 +636,55 @@ function Users() { ) } +/** 审计:谁在什么时候做了什么。写操作、登录成败都在里面,保留一年。 */ +function Audit() { + const [actor, setActor] = useState('') + const [action, setAction] = useState('') + const audit = useQuery(() => api.get>('/api/admin/audit', { actor: actor || undefined, action: action || undefined, limit: 100 }), [actor, action], 10_000) + return ( +
+
+ setActor(e.target.value)} /> + setAction(e.target.value)} /> + {audit.loading && !audit.data ? : null} +
+ + {(audit.data?.items ?? []).length === 0 ? ( + 没有记录 + ) : ( + + + + + + + + + + + + + + {audit.data?.items.map((a) => ( + + + + + + + + + + ))} + +
时间操作者方式操作对象 / 细节来源结果
{fmtTime(a.at)}{a.actor}{a.method === 'key' ? 'API 令牌' : a.method === 'passkey' ? 'Passkey' : '密码'}{a.action}{[a.target, a.detail].filter(Boolean).join(' · ')}{a.remote ?? ''} + {a.success ? '成功' : '失败'} +
+ )} +
+ ) +} + function Events() { const [kind, setKind] = useState('') const events = useQuery(() => api.get>('/api/admin/events', { kind: kind || undefined, limit: 100 }), [kind], 10_000) @@ -783,6 +832,7 @@ export function System() { { value: 'versions' as const, label: '版本' }, { value: 'tokens' as const, label: 'API 令牌' }, { value: 'users' as const, label: '用户' }, + { value: 'audit' as const, label: '审计' }, { value: 'events' as const, label: '事件' }, { value: 'raw' as const, label: '原始数据' }, { value: 'analysis' as const, label: '分析' }, @@ -790,7 +840,7 @@ export function System() { value={tab} onChange={setTab} /> - {tab === 'settings' ? : tab === 'versions' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'events' ? : tab === 'raw' ? : } + {tab === 'settings' ? : tab === 'versions' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'audit' ? : tab === 'events' ? : tab === 'raw' ? : } ) diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index dec44f8..f02fce8 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -814,6 +814,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Audit log of admin actions, newest first */ + get: operations["audit_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/api-tokens": { parameters: { query?: never; @@ -1183,6 +1200,19 @@ export interface components { /** Format: date-time */ revoked_at: unknown; }; + AuditDto: { + /** Format: int64 */ + id: number | string; + /** Format: date-time */ + at: unknown; + actor: string; + method: string; + action: string; + target: null | string; + detail: null | string; + remote: null | string; + success: boolean; + }; BackendUpdateDto: { channel: string; enabled: boolean; @@ -1634,6 +1664,10 @@ export interface components { items: components["schemas"]["ApiTokenDto"][]; next_cursor: null | string; }; + PageDtoOfAuditDto: { + items: components["schemas"]["AuditDto"][]; + next_cursor: null | string; + }; PageDtoOfEventDto: { items: components["schemas"]["EventDto"][]; next_cursor: null | string; @@ -4783,6 +4817,58 @@ export interface operations { }; }; }; + audit_list: { + parameters: { + query?: { + actor?: string; + action?: string; + limit?: number | string; + cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfAuditDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; api_tokens_list: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 0833115..8d1cf71 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -47,6 +47,7 @@ export type MeDto = Narrow export type SettingDto = Narrow export type UserDto = Narrow export type EventDto = Narrow +export type AuditDto = Narrow export type GapDto = Narrow export type RecomputeDto = Narrow export type RawChunkMetaDto = Narrow