fix(log): 补齐系统配置、发信及运维操作审计日志拦截与分类
Some checks are pending
Security Checks / security (push) Waiting to run
Some checks are pending
Security Checks / security (push) Waiting to run
This commit is contained in:
parent
c1f5f4289b
commit
f285b5c904
@ -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}
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user