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
@@ -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>();
|
||||
|
||||
Reference in new issue
Block a user