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

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