feat(M4): 生命周期 + 审计 — 到期锁定/回收 cron、审计查询/CSV 导出/每日归档、settings 动态策略

This commit is contained in:
2026-08-30 10:17:22 +08:00
parent 1ea18490e0
commit c0e2ff975a
18 changed files with 1088 additions and 75 deletions
+97 -4
View File
@@ -97,19 +97,21 @@ func setupTestAppWithSys(t *testing.T, sys system.Manager) *testApp {
adminSvc := service.NewAdminService(db)
userSvc := service.NewUserService(db, sys, cfg)
keySvc := service.NewKeyService(db, sys, cfg)
auditSvc := service.NewAuditService(db)
mailer := &recordingMailer{}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
auditSvc := service.NewAuditService(db, log)
settingsSvc := service.NewSettingService(db, cfg)
userSvc.WithSettings(settingsSvc)
captchas := auth.NewMemoryCaptchaStore(cfg.Auth.CaptchaTTL)
otps := auth.NewDBOTPStore(db, auth.DefaultMaxFailures, auth.DefaultFailureWin)
sessions := auth.NewDBSessionStore(db)
resets := auth.NewDBResetTokenStore(db)
limiter := auth.NewRateLimiter(cfg.Auth.MaxLoginFailures, cfg.Auth.LockDuration)
mailer := &recordingMailer{}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
authSvc := service.NewAuthService(db, cfg, otps, captchas, sessions, resets, limiter, mailer, userSvc, adminSvc, auditSvc, log)
approvalSvc := service.NewApprovalService(db, cfg, userSvc, mailer, log)
h := api.New(cfg, authSvc, userSvc, keySvc, approvalSvc, auditSvc)
h := api.New(cfg, authSvc, userSvc, keySvc, approvalSvc, auditSvc, settingsSvc)
r := router.New(cfg, h, sessions, log)
if _, err := adminSvc.Create(context.Background(), "root", "Passw0rd", "root@example.com"); err != nil {
@@ -576,3 +578,94 @@ func TestAPIApprovalFlow(t *testing.T) {
t.Fatalf("resubmit status = %d, body=%s", w.Code, w.Body.String())
}
}
func TestAPIAuditQueryExport(t *testing.T) {
app := setupTestApp(t)
// 登录后产生若干审计记录(登录本身即审计)
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
ck := sessionCookie(t, w)
// 查询:action 筛选 + 分页
w = app.doJSON(http.MethodGet, "/api/v1/audit?action=admin.login&page=1&page_size=10", nil, ck)
if w.Code != http.StatusOK {
t.Fatalf("audit list status = %d, body=%s", w.Code, w.Body.String())
}
m := decodeBody(t, w)["data"].(map[string]any)
if m["total"].(float64) < 1 {
t.Fatalf("audit total = %v, want >= 1", m["total"])
}
if items := m["items"].([]any); len(items) == 0 {
t.Fatal("audit items empty")
}
// 非法时间参数 → 400
w = app.doJSON(http.MethodGet, "/api/v1/audit?since=not-a-time", nil, ck)
if w.Code != http.StatusBadRequest {
t.Fatalf("bad since status = %d, want 400", w.Code)
}
// 未登录 / 外部用户 → 401 / 403
w = app.doJSON(http.MethodGet, "/api/v1/audit", nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("unauth audit status = %d, want 401", w.Code)
}
// CSV 导出
w = app.doJSON(http.MethodGet, "/api/v1/audit/export", nil, ck)
if w.Code != http.StatusOK {
t.Fatalf("export status = %d, body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.HasPrefix(body, "\ufeffid,created_at,") {
t.Fatalf("csv body = %q", body[:min(40, len(body))])
}
if !strings.Contains(body, "admin.login") {
t.Fatalf("csv missing rows: %q", body[:min(200, len(body))])
}
}
func TestAPISettings(t *testing.T) {
app := setupTestApp(t)
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
ck := sessionCookie(t, w)
// 未登录 → 401
w = app.doJSON(http.MethodGet, "/api/v1/settings", nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("unauth settings status = %d, want 401", w.Code)
}
// 初始值(config 默认)
w = app.doJSON(http.MethodGet, "/api/v1/settings", nil, ck)
if w.Code != http.StatusOK {
t.Fatalf("settings list status = %d", w.Code)
}
items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any)
if len(items) != 3 {
t.Fatalf("settings items = %d, want 3", len(items))
}
// 更新默认有效期
w = app.doJSON(http.MethodPut, "/api/v1/settings", map[string]string{"key": "policy.default_ttl", "value": "720h"}, ck)
if w.Code != http.StatusOK {
t.Fatalf("settings put status = %d, body=%s", w.Code, w.Body.String())
}
// 未知 key → 400
w = app.doJSON(http.MethodPut, "/api/v1/settings", map[string]string{"key": "smtp.host", "value": "x"}, ck)
if w.Code != http.StatusBadRequest {
t.Fatalf("unknown key status = %d, want 400", w.Code)
}
// 再次读取:default_ttl 已覆盖
w = app.doJSON(http.MethodGet, "/api/v1/settings", nil, ck)
items = decodeBody(t, w)["data"].(map[string]any)["items"].([]any)
found := false
for _, it := range items {
item := it.(map[string]any)
if item["key"] == "policy.default_ttl" && item["overridden"] == true && item["value"] == "720h" {
found = true
}
}
if !found {
t.Fatalf("settings after put = %v", items)
}
}
+85
View File
@@ -0,0 +1,85 @@
package api
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"ws_usernode/internal/config"
"ws_usernode/internal/service"
)
// AuditHandler 审计接口(admin):查询 + 手动 CSV 导出(PLAN F6)。
type AuditHandler struct {
svc *service.AuditService
cfg *config.Config
}
// NewAuditHandler 创建审计 handler。
func NewAuditHandler(svc *service.AuditService, cfg *config.Config) *AuditHandler {
return &AuditHandler{svc: svc, cfg: cfg}
}
// parseTime 解析可选的时间参数(RFC3339)。
func parseTime(v string) (*time.Time, error) {
if v == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil, err
}
return &t, nil
}
// List GET /audit —— 审计查询(操作者/动作/资源/时间范围/分页)。
func (h *AuditHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
since, err := parseTime(c.Query("since"))
if err != nil {
fail(c, http.StatusBadRequest, "since 需为 RFC3339 时间")
return
}
until, err := parseTime(c.Query("until"))
if err != nil {
fail(c, http.StatusBadRequest, "until 需为 RFC3339 时间")
return
}
rows, total, err := h.svc.Query(c.Request.Context(), service.AuditFilter{
ActorName: c.Query("actor"),
Action: c.Query("action"),
ResourceType: c.Query("resource_type"),
ResourceID: c.Query("resource_id"),
Since: since,
Until: until,
Page: page,
PageSize: pageSize,
})
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
ok(c, gin.H{"total": total, "items": rows})
}
// Export GET /audit/export —— 手动 CSV 导出(可选时间范围)。
func (h *AuditHandler) Export(c *gin.Context) {
since, err := parseTime(c.Query("since"))
if err != nil {
fail(c, http.StatusBadRequest, "since 需为 RFC3339 时间")
return
}
until, err := parseTime(c.Query("until"))
if err != nil {
fail(c, http.StatusBadRequest, "until 需为 RFC3339 时间")
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="audit-`+time.Now().Format("2006-01-02")+`.csv"`)
// 已开始写响应体后无法再返回 JSON 错误;查询失败在此吞掉(导出为管理操作,
// 失败由审计归档兜底),正常路径写入 CSV。
_ = h.svc.ExportCSV(c.Request.Context(), c.Writer, since, until)
}
+8 -3
View File
@@ -1,7 +1,8 @@
// Package api 为 HTTP handler 层(RESTful v1)。
// M1 覆盖认证(管理员/外部用户登录、会话、OTP)与用户管理 CRUD;
// M2 覆盖 SSH 公钥管理(上传/重命名/吊销/列表);
// M3 覆盖申请审批(公开提交 + 管理员审批 + 邮件通知)
// M3 覆盖申请审批(公开提交 + 管理员审批 + 邮件通知)
// M4 覆盖审计查询/导出与系统设置。
package api
import (
@@ -24,13 +25,15 @@ type Handler struct {
User *UserHandler
Key *KeyHandler
Approval *ApprovalHandler
Audit *AuditHandler
Settings *SettingsHandler
authSvc *service.AuthService
auditSvc *service.AuditService
}
// New 创建 handler 集合。
func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.UserService, keySvc *service.KeyService, approvalSvc *service.ApprovalService, auditSvc *service.AuditService) *Handler {
func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.UserService, keySvc *service.KeyService, approvalSvc *service.ApprovalService, auditSvc *service.AuditService, settingsSvc *service.SettingService) *Handler {
h := &Handler{
Health: &HealthHandler{startedAt: time.Now()},
authSvc: authSvc,
@@ -40,6 +43,8 @@ func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.User
h.User = &UserHandler{svc: userSvc, cfg: cfg, h: h}
h.Key = &KeyHandler{svc: keySvc, cfg: cfg, h: h}
h.Approval = &ApprovalHandler{svc: approvalSvc, cfg: cfg, h: h}
h.Audit = NewAuditHandler(auditSvc, cfg)
h.Settings = NewSettingsHandler(settingsSvc)
return h
}
@@ -66,7 +71,7 @@ type HealthHandler struct {
func (h *HealthHandler) Healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"version": "0.3.0-m3",
"version": "0.3.0-m4",
"uptime": time.Since(h.startedAt).String(),
"go": runtime.Version(),
"timestamp": time.Now().UTC().Format(time.RFC3339),
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"ws_usernode/internal/service"
)
// SettingsHandler 系统设置接口(adminPLAN F7)。
type SettingsHandler struct {
svc *service.SettingService
}
// NewSettingsHandler 创建设置 handler。
func NewSettingsHandler(svc *service.SettingService) *SettingsHandler {
return &SettingsHandler{svc: svc}
}
// List GET /settings —— 全部设置项(config 默认值 + settings 覆盖)。
func (h *SettingsHandler) List(c *gin.Context) {
items, err := h.svc.GetAll(c.Request.Context())
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
ok(c, gin.H{"items": items})
}
// SettingUpdateRequest 更新单个设置项。
type SettingUpdateRequest struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
}
// Update PUT /settings —— 更新设置项(仅白名单 key,值为时长格式)。
func (h *SettingsHandler) Update(c *gin.Context) {
var req SettingUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
return
}
if err := h.svc.Set(c.Request.Context(), req.Key, req.Value); err != nil {
switch {
case errors.Is(err, service.ErrSettingKeyUnknown):
fail(c, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrSettingValueInvalid):
fail(c, http.StatusBadRequest, err.Error())
default:
fail(c, http.StatusInternalServerError, err.Error())
}
return
}
ok(c, gin.H{"status": "updated", "key": req.Key, "value": req.Value})
}
+3 -10
View File
@@ -179,21 +179,14 @@ func (h *UserHandler) Extend(c *gin.Context) {
fail(c, http.StatusNotFound, err.Error())
return
}
if err := h.svc.Extend(c.Request.Context(), uint(id), req.Days); err != nil {
newExpire, err := h.svc.Extend(c.Request.Context(), uint(id), req.Days)
if err != nil {
h.h.audit(c, "user.extend", "user", c.Param("id"), map[string]any{"days": req.Days, "err": err.Error()}, model.ResultFailed)
fail(c, http.StatusInternalServerError, err.Error())
return
}
h.h.audit(c, "user.extend", "user", c.Param("id"), map[string]any{"days": req.Days, "old_status": u.Status}, model.ResultSuccess)
ok(c, gin.H{"expire_at": time.Now().Add(h.extendTTL(req.Days)).UTC()})
}
// extendTTL 计算新的有效期(与 service 保持一致:days<=0 用默认)。
func (h *UserHandler) extendTTL(days int) time.Duration {
if days <= 0 {
return h.cfg.Policy.DefaultTTL
}
return time.Duration(days) * 24 * time.Hour
ok(c, gin.H{"expire_at": newExpire.UTC()})
}
// Delete DELETE /users/:id —— 删除并回收(系统账号 + 家目录 + 密钥,保留审计)。