diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index 6539728..3a5a5f3 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -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} diff --git a/test/backend/operation_log_test.go b/test/backend/operation_log_test.go index 41985fe..d3755b1 100644 --- a/test/backend/operation_log_test.go +++ b/test/backend/operation_log_test.go @@ -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"]) + } + } +}