fix(log): 补齐系统配置、发信及运维操作审计日志拦截与分类
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-13 03:08:16 +08:00
parent c1f5f4289b
commit f285b5c904
2 changed files with 258 additions and 18 deletions

View File

@ -13,32 +13,146 @@ import (
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
// 仅记录明确的变更接口,不将 POST 列表查询误认为操作,也不复制请求或响应载荷。
// loggedOperations 注册显式的静态状态变更路由,仅记录状态变更操作,不将列表查询误认为操作,也不复制请求或响应载荷。
var loggedOperations = map[string]string{
"/api/user/profile": "修改个人资料",
"/api/login": "处理账号登录", "/api/logout": "退出登录",
"/api/security/login/verify": "完成登录二次验证",
"/api/security/webauthn/login/finish": "完成通行密钥登录验证",
"/api/user/create": "创建用户", "/api/user/edit": "编辑用户",
"/api/group/add": "创建分组", "/api/group/del": "删除分组",
"/api/email/del": "删除邮件", "/api/email/move": "移动邮件", "/api/email/read": "修改已读状态",
"/api/rule/add": "创建邮件规则", "/api/rule/update": "修改邮件规则", "/api/rule/del": "删除邮件规则",
"/api/settings/modify_password": "修改账号密码",
"/api/login": "处理账号登录",
"/api/logout": "退出登录",
"/api/security/reauth": "完成身份重新认证",
"/api/security/verification/request": "申请安全验证码",
"/api/security/verification/verify": "完成安全验证",
"/api/security/login/verify": "完成登录二次验证",
"/api/security/login/resend": "重新发送登录验证码",
"/api/security/webauthn/registration/begin": "发起通行密钥注册",
"/api/security/webauthn/registration/finish": "完成通行密钥注册",
"/api/security/webauthn/credentials/rename": "重命名通行密钥",
"/api/security/webauthn/credentials/revoke": "撤销通行密钥",
"/api/security/webauthn/login/begin": "发起通行密钥登录",
"/api/security/webauthn/login/finish": "完成通行密钥登录验证",
"/api/security/recovery/webauthn/begin": "发起通行密钥恢复账号",
"/api/security/recovery/webauthn/verify": "通过通行密钥恢复账号",
"/api/security/recovery/begin": "发起账号恢复",
"/api/security/recovery/verify": "验证账号恢复凭据",
"/api/security/recovery/complete": "完成账号恢复",
"/api/security/setup/acknowledge": "确认安全初始化引导",
"/api/security/credentials/question": "设置密保问题",
"/api/security/credentials/recovery-codes/generate": "生成安全码",
"/api/security/credentials/recovery-codes/revoke": "撤销安全码",
"/api/security/credentials/protocols/enabled": "设置邮件客户端授权",
"/api/security/credentials/protocols/generate": "生成邮件客户端安全码",
"/api/security/credentials/protocols/revoke": "撤销邮件客户端安全码",
"/api/security/credentials/protocols/reveal": "查看邮件客户端安全码",
"/api/security/external-email/bind": "申请绑定外部邮箱",
"/api/security/external-email/verify": "验证外部邮箱",
"/api/security/external-email/change": "申请修改外部邮箱",
"/api/security/external-email/change/verify": "验证外部邮箱修改",
"/api/security/external-email/disable": "解绑外部邮箱",
"/api/security/credentials/protocols/enabled": "设置邮件客户端授权",
"/api/security/credentials/protocols/generate": "生成邮件客户端安全码",
"/api/security/credentials/protocols/revoke": "撤销邮件客户端安全码",
"/api/security/recovery/complete": "完成账号恢复",
"/api/settings/modify_password": "修改账号密码",
"/api/user/create": "创建用户",
"/api/user/edit": "编辑用户",
"/api/user/profile": "修改个人资料",
"/api/user/security/recovery-codes/generate": "管理用户安全码",
"/api/user/security/recovery-codes/revoke": "撤销用户安全码",
"/api/user/security/external-email/change": "申请修改用户外部邮箱",
"/api/user/security/external-email/verify": "验证用户外部邮箱修改",
"/api/group/add": "创建分组",
"/api/group/del": "删除分组",
"/api/email/del": "删除邮件",
"/api/email/read": "修改已读状态",
"/api/email/move": "移动邮件",
"/api/email/send": "发送邮件",
"/api/email/contacts/delete": "删除常用联系人",
"/api/email/trust-sender": "设置信任发件人",
"/api/rule/add": "创建邮件规则",
"/api/rule/update": "修改邮件规则",
"/api/rule/del": "删除邮件规则",
"/api/system/restart": "请求重启系统服务",
"/api/system/restart/preview": "预览系统服务重启",
"/api/database/migrations": "执行数据库迁移",
"/api/database/migrations/preview": "预览数据库迁移",
"/api/setup": "执行系统初始化配置",
"/api/plugin/settings/telegram_push/user": "更新电报通知设置",
"/api/plugin/settings/telegram_push/test": "测试电报消息推送",
}
// readonlyPostEndpoints 明确排除属于查询性质的 POST 接口,防止误记操作记录。
var readonlyPostEndpoints = map[string]struct{}{
"/api/user/info": {},
"/api/user/list": {},
"/api/email/list": {},
"/api/email/detail": {},
"/api/rule/get": {},
}
var configSectionNames = map[string]string{
"base": "基础",
"mail": "邮件服务与策略",
"smtp": "SMTP服务",
"email": "邮件策略",
"ssl": "证书",
"plugins": "插件",
}
func parseConfigAction(path, method string) string {
cleanPath := strings.TrimSuffix(path, "/")
if !strings.HasPrefix(cleanPath, "/api/config") {
return ""
}
switch cleanPath {
case "/api/config/pending":
if method == http.MethodPost {
return "管理候选配置"
}
return ""
case "/api/config/rollback/preview":
if method == http.MethodPost {
return "预览配置回滚"
}
return ""
case "/api/config/rollback/ticket":
if method == http.MethodPost {
return "签发配置回滚票据"
}
return ""
case "/api/config/rollback":
if method == http.MethodPost {
return "回滚系统配置"
}
return ""
}
parts := strings.Split(strings.Trim(cleanPath, "/"), "/")
if len(parts) == 3 && method == http.MethodPut {
sectionKey := parts[2]
sectionName := configSectionNames[sectionKey]
if sectionName == "" {
sectionName = sectionKey
}
return "保存" + sectionName + "配置"
}
if len(parts) == 4 && method == http.MethodPost {
sectionKey := parts[2]
operation := parts[3]
sectionName := configSectionNames[sectionKey]
if sectionName == "" {
sectionName = sectionKey
}
switch operation {
case "validate":
return "校验" + sectionName + "配置"
case "apply":
return "应用" + sectionName + "配置"
}
}
return ""
}
func parseOperationAction(path, method string) string {
if _, readonly := readonlyPostEndpoints[path]; readonly {
return ""
}
if action := loggedOperations[path]; action != "" {
return action
}
return parseConfigAction(path, method)
}
type operationResponse struct {
@ -70,8 +184,17 @@ func (w *operationResponse) Write(data []byte) (int, error) {
// 只从白名单路由识别对象类别,不解析或复制含密码、验证码的请求正文。
func operationObject(path string) string {
for _, item := range []struct{ prefix, object string }{
{"/api/email/", "邮件"}, {"/api/group/", "邮件分组"}, {"/api/rule/", "邮件规则"},
{"/api/user/", "用户账号"}, {"/api/security/", "账号安全"},
{"/api/config", "系统配置"},
{"/api/system/", "系统服务"},
{"/api/database/", "数据库运维"},
{"/api/plugin/", "插件配置"},
{"/api/setup", "系统服务"},
{"/api/email/", "邮件"},
{"/api/group/", "邮件分组"},
{"/api/rule/", "邮件规则"},
{"/api/user/security/", "账号安全"},
{"/api/user/", "用户账号"},
{"/api/security/", "账号安全"},
{"/api/settings/", "账号设置"},
} {
if strings.HasPrefix(path, item.prefix) {
@ -106,8 +229,11 @@ func operationOutcome(code int, result string) string {
func operationLog(ctx *appcontext.Context, req *http.Request, w http.ResponseWriter) (http.ResponseWriter, func()) {
path := normalizedAPIPath(req.URL.Path)
action := loggedOperations[path]
if action == "" || !stateChangingMethod(req.Method) {
if !stateChangingMethod(req.Method) {
return w, func() {}
}
action := parseOperationAction(path, req.Method)
if action == "" {
return w, func() {}
}
observed := &operationResponse{ResponseWriter: w}

View File

@ -128,3 +128,117 @@ func TestOperationResponseDistinguishesPendingHTTPFailureAndUnknown(t *testing.T
t.Fatal("只读查询不能新增操作记录")
}
}
func TestOperationLogCoversFullOperationsAndRejectsReadonly(t *testing.T) {
database, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "full_operations.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
database.SetMaxOpenConns(1)
migrations := migration.DefaultMigrations()
runner := migration.Runner{DB: database, Dialect: migration.DialectSQLite, Migrations: migrations}
if _, err := runner.Run(context.Background()); err != nil {
t.Fatal(err)
}
store, err := logstore.New(database, migration.DialectSQLite)
if err != nil {
t.Fatal(err)
}
runtime := logstore.Start(store)
defer runtime.Stop()
// 验证只读 POST 查询不会被误记为操作日志
readonlyCases := []string{
"/api/v1/user/info",
"/api/v1/user/list",
"/api/v1/email/list",
"/api/v1/email/detail",
"/api/rule/get",
}
for _, path := range readonlyCases {
ctx := &appcontext.Context{UserID: 1, UserAccount: "admin", Role: access.RoleSuperAdmin}
writer, finish := operationLog(ctx, httptest.NewRequest(http.MethodPost, path, nil), httptest.NewRecorder())
response.NewSuccessResponse(map[string]any{"data": "ok"}).FPrint(writer)
finish()
}
entries, err := store.Query(context.Background(), logstore.Query{Kind: "operation"})
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("只读接口不应记录操作日志,实际记录条数:%d", len(entries))
}
// 验证全量代表性操作正常记入日志且元数据分类正确
testCases := []struct {
method string
path string
wantAction string
wantObject string
}{
{http.MethodPost, "/api/v1/email/send", "发送邮件", "邮件"},
{http.MethodPost, "/api/v1/email/contacts/delete", "删除常用联系人", "邮件"},
{http.MethodPost, "/api/v1/email/trust-sender", "设置信任发件人", "邮件"},
{http.MethodPost, "/api/v1/security/credentials/protocols/reveal", "查看邮件客户端安全码", "账号安全"},
{http.MethodPut, "/api/v1/security/credentials/protocols/enabled", "设置邮件客户端授权", "账号安全"},
{http.MethodPost, "/api/v1/security/credentials/protocols/generate", "生成邮件客户端安全码", "账号安全"},
{http.MethodPost, "/api/v1/security/credentials/protocols/revoke", "撤销邮件客户端安全码", "账号安全"},
{http.MethodPost, "/api/v1/security/credentials/question", "设置密保问题", "账号安全"},
{http.MethodPost, "/api/v1/security/setup/acknowledge", "确认安全初始化引导", "账号安全"},
{http.MethodPost, "/api/v1/security/reauth", "完成身份重新认证", "账号安全"},
{http.MethodPost, "/api/v1/security/verification/request", "申请安全验证码", "账号安全"},
{http.MethodPost, "/api/v1/security/verification/verify", "完成安全验证", "账号安全"},
{http.MethodPost, "/api/v1/security/login/resend", "重新发送登录验证码", "账号安全"},
{http.MethodPost, "/api/v1/security/webauthn/registration/begin", "发起通行密钥注册", "账号安全"},
{http.MethodPost, "/api/v1/security/webauthn/registration/finish", "完成通行密钥注册", "账号安全"},
{http.MethodPost, "/api/v1/security/webauthn/credentials/rename", "重命名通行密钥", "账号安全"},
{http.MethodPost, "/api/v1/security/webauthn/credentials/revoke", "撤销通行密钥", "账号安全"},
{http.MethodPost, "/api/v1/security/recovery/begin", "发起账号恢复", "账号安全"},
{http.MethodPost, "/api/v1/security/recovery/verify", "验证账号恢复凭据", "账号安全"},
{http.MethodPost, "/api/v1/security/recovery/complete", "完成账号恢复", "账号安全"},
{http.MethodPost, "/api/v1/user/security/external-email/change", "申请修改用户外部邮箱", "账号安全"},
{http.MethodPost, "/api/v1/user/security/external-email/verify", "验证用户外部邮箱修改", "账号安全"},
{http.MethodPost, "/api/v1/system/restart", "请求重启系统服务", "系统服务"},
{http.MethodPost, "/api/v1/system/restart/preview", "预览系统服务重启", "系统服务"},
{http.MethodPost, "/api/v1/database/migrations", "执行数据库迁移", "数据库运维"},
{http.MethodPost, "/api/v1/database/migrations/preview", "预览数据库迁移", "数据库运维"},
{http.MethodPost, "/api/v1/setup", "执行系统初始化配置", "系统服务"},
{http.MethodPut, "/api/v1/plugin/settings/telegram_push/user", "更新电报通知设置", "插件配置"},
{http.MethodPost, "/api/v1/plugin/settings/telegram_push/test", "测试电报消息推送", "插件配置"},
// 动态系统配置接口测试
{http.MethodPut, "/api/v1/config/base", "保存基础配置", "系统配置"},
{http.MethodPost, "/api/v1/config/mail/apply", "应用邮件服务与策略配置", "系统配置"},
{http.MethodPost, "/api/v1/config/mail/validate", "校验邮件服务与策略配置", "系统配置"},
{http.MethodPut, "/api/v1/config/smtp", "保存SMTP服务配置", "系统配置"},
{http.MethodPut, "/api/v1/config/ssl", "保存证书配置", "系统配置"},
{http.MethodPut, "/api/v1/config/plugins", "保存插件配置", "系统配置"},
{http.MethodPost, "/api/v1/config/pending", "管理候选配置", "系统配置"},
{http.MethodPost, "/api/v1/config/rollback/preview", "预览配置回滚", "系统配置"},
{http.MethodPost, "/api/v1/config/rollback/ticket", "签发配置回滚票据", "系统配置"},
{http.MethodPost, "/api/v1/config/rollback", "回滚系统配置", "系统配置"},
}
for _, tc := range testCases {
ctx := &appcontext.Context{UserID: 88, UserAccount: "admin", Role: access.RoleSuperAdmin}
writer, finish := operationLog(ctx, httptest.NewRequest(tc.method, tc.path, nil), httptest.NewRecorder())
response.NewSuccessResponse(map[string]any{"status": "ok"}).FPrint(writer)
finish()
queryEntries, err := store.Query(context.Background(), logstore.Query{Kind: "operation", Limit: 1})
if err != nil || len(queryEntries) == 0 {
t.Fatalf("路径 %s %s 期望记录操作日志,但未查询到记录:%v", tc.method, tc.path, err)
}
entry := queryEntries[0]
if entry.Action != tc.wantAction {
t.Fatalf("路径 %s 动作不匹配,期望 %s,实际 %s", tc.path, tc.wantAction, entry.Action)
}
var payload map[string]any
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload["object"] != tc.wantObject {
t.Fatalf("路径 %s 类别不匹配,期望 %s,实际 %v", tc.path, tc.wantObject, payload["object"])
}
}
}