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

+168
View File
@@ -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"
},
@@ -132,6 +132,7 @@ public static class AgentEndpoints
private static async Task<IResult> 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<CreatedJoinToken> 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))
@@ -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;
/// <summary>审计日志:管理接口里每个写操作、登录和提权都会记一行,这里只读。</summary>
public static class AuditEndpoints
{
private const string Tag = "admin-audit";
/// <summary>handler 往这里放一句「这次具体做了什么」(例如新建令牌的名字),守卫记审计时一起写进去。</summary>
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<PageDto<AuditDto>>().ProducesProblems(400, 401, 403)
.Requires(Capability.AuditRead);
public static void SetDetail(this HttpContext http, string detail) => http.Items[DetailItem] = detail;
private static async Task<IResult> 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<AuditRecord> 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<AuditRecord> page = [.. rows.Take(size)];
string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
return TypedResults.Json(new PageDto<AuditDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoAuditDto);
}
}
@@ -57,7 +57,7 @@ public static class AuthEndpoints
.Requires(Capability.Authenticated);
}
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options)
private static async Task<IResult> 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<LoginResult> 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<IResult> PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
private static async Task<IResult> 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<LoginResult> 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<IResult> ListPasskeysAsync(HttpContext http, PasskeyService passkeys)
@@ -64,6 +64,7 @@ public static class SystemEndpoints
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
{
http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted);
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
{
@@ -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);
@@ -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<IEndpointNameMetadata>()?.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<AuditService>()
.RecordAsync(principal, action, target, detail as string, http.Connection.RemoteIpAddress?.ToString(), http.Response.StatusCode < 400).AsTask();
});
}
return await next(context);
}
}
@@ -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));
@@ -70,6 +70,7 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(RawChunkMetaDto))]
[JsonSerializable(typeof(PageDto<RawChunkMetaDto>))]
[JsonSerializable(typeof(PageDto<EventDto>))]
[JsonSerializable(typeof(PageDto<AuditDto>))]
[JsonSerializable(typeof(ApiTokenDto))]
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
[JsonSerializable(typeof(CreateApiTokenRequest))]
@@ -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);
@@ -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();
}
@@ -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();
}
@@ -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<IReadOnlyList<AuditRecord>> ListAsync(AuditQuery query, CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<AuditRecord>>(
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<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct) =>
_db.WriteAsync(c => c.Exec("DELETE FROM audit_log WHERE at < @cutoff", ("@cutoff", cutoffMs)), ct);
}
@@ -22,6 +22,7 @@ public static class StorageServiceCollectionExtensions
services.AddSingleton<IHostStore, SqliteHostStore>();
services.AddSingleton<INodeMetaStore, SqliteNodeMetaStore>();
services.AddSingleton<IAuthStore, SqliteAuthStore>();
services.AddSingleton<IAuditStore, SqliteAuditStore>();
services.AddSingleton<IRawStore, SqliteRawStore>();
services.AddSingleton<IMetricStore, SqliteMetricStore>();
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();
@@ -0,0 +1,68 @@
using System.Net;
using System.Text.Json.Nodes;
namespace MoShiro.Backend.Tests;
/// <summary>审计日志:写操作、登录成败都会记一行;谁做的(用户名或 key:令牌名)、结果、细节。</summary>
[Collection("probe-e2e")]
public sealed class AuditTests
{
private readonly BackendFixture _backend;
public AuditTests(BackendFixture backend)
{
_backend = backend;
}
/// <summary>审计在响应发完之后才写,轮询一下。</summary>
private async Task<JsonNode?> FindAsync(string query, Func<JsonNode, bool> 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<string>().Contains(note, StringComparison.Ordinal) == true);
Assert.NotNull(row);
Assert.StartsWith("key:", row["actor"]!.GetValue<string>(), StringComparison.Ordinal);
Assert.Equal("key", row["method"]!.GetValue<string>());
Assert.True(row["success"]!.GetValue<bool>());
Assert.False(string.IsNullOrEmpty(row["action"]!.GetValue<string>()));
}
[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<string>() == name);
Assert.NotNull(row);
Assert.False(row["success"]!.GetValue<bool>());
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);
}
}
+53 -3
View File
@@ -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<Page<AuditDto>>('/api/admin/audit', { actor: actor || undefined, action: action || undefined, limit: 100 }), [actor, action], 10_000)
return (
<div className="card">
<div className="row wrap" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 200 }} placeholder="按操作者筛选,例如 admin" value={actor} onChange={(e) => setActor(e.target.value)} />
<input className="input" style={{ width: 240 }} placeholder="按操作筛选,例如 auth.login" value={action} onChange={(e) => setAction(e.target.value)} />
<span className="right">{audit.loading && !audit.data ? <Spinner /> : null}</span>
</div>
<ErrorNotice error={audit.error} />
{(audit.data?.items ?? []).length === 0 ? (
<Empty>没有记录</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>时间</th>
<th>操作者</th>
<th>方式</th>
<th>操作</th>
<th>对象 / 细节</th>
<th>来源</th>
<th>结果</th>
</tr>
</thead>
<tbody>
{audit.data?.items.map((a) => (
<tr key={a.id}>
<td className="muted num">{fmtTime(a.at)}</td>
<td>{a.actor}</td>
<td className="muted">{a.method === 'key' ? 'API 令牌' : a.method === 'passkey' ? 'Passkey' : '密码'}</td>
<td className="mono">{a.action}</td>
<td className="mono muted">{[a.target, a.detail].filter(Boolean).join(' · ')}</td>
<td className="mono faint">{a.remote ?? ''}</td>
<td>
<Chip tone={a.success ? 'ok' : 'bad'}>{a.success ? '成功' : '失败'}</Chip>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function Events() {
const [kind, setKind] = useState('')
const events = useQuery(() => api.get<Page<EventDto>>('/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' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
</div>
</>
)
+86
View File
@@ -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;
+1
View File
@@ -47,6 +47,7 @@ export type MeDto = Narrow<S['MeDto']>
export type SettingDto = Narrow<S['SettingDto']>
export type UserDto = Narrow<S['UserDto']>
export type EventDto = Narrow<S['EventDto']>
export type AuditDto = Narrow<S['AuditDto']>
export type GapDto = Narrow<S['GapDto']>
export type RecomputeDto = Narrow<S['RecomputeDto']>
export type RawChunkMetaDto = Narrow<S['RawChunkMetaDto']>