feat(M4): 生命周期 + 审计 — 到期锁定/回收 cron、审计查询/CSV 导出/每日归档、settings 动态策略
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// SettingsHandler 系统设置接口(admin,PLAN 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
@@ -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 —— 删除并回收(系统账号 + 家目录 + 密钥,保留审计)。
|
||||
|
||||
Reference in New Issue
Block a user