From fd475cd88da277c07cfc1f79b68d31ba60fa48b9 Mon Sep 17 00:00:00 2001 From: smail Date: Wed, 9 Sep 2026 20:27:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E8=A7=92=E8=89=B2=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=88=86=E5=B1=82=E6=94=B9=E9=80=A0=E7=BB=AD=E5=AE=A1?= =?UTF-8?q?=E7=8E=B0=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/manager_v2.go | 31 ++++ backend/config/operation_log.go | 11 +- backend/config/pending.go | 3 + backend/config/runtime_event.go | 9 ++ backend/config/security.go | 21 +++ backend/controllers/config.go | 18 ++- .../controllers/config_backup_access_test.go | 6 +- backend/controllers/config_pending.go | 5 +- backend/controllers/config_permissions.go | 29 ++++ backend/controllers/email/send.go | 8 +- backend/controllers/log_permissions.go | 42 ++++++ backend/controllers/logs.go | 17 ++- backend/controllers/plugin.go | 5 +- backend/controllers/profile.go | 55 +++++++ backend/controllers/user.go | 9 +- backend/listen/http_server/api_v1_compat.go | 1 + backend/listen/http_server/http_server.go | 1 + backend/listen/http_server/operation_log.go | 3 +- backend/listen/smtp_server/read_content.go | 38 +++-- backend/models/user_scope.go | 4 + backend/services/access/access_test.go | 10 +- backend/services/access/authorize.go | 4 +- backend/services/access/capability.go | 10 +- backend/services/access/catalog.go | 6 +- backend/services/access/matrix_test.go | 7 +- backend/services/logstore/store.go | 48 ++++-- backend/services/runtimelog/range.go | 20 ++- backend/services/runtimelog/scope.go | 60 ++++++++ backend/utils/validation/sender.go | 4 +- .../ConfigSettings/ConfigSettings.js | 2 +- .../views/components/HomeHeader/HomeHeader.js | 5 +- .../components/LogSettings/LogSettings.js | 6 +- .../components/LogSettings/LogSettings.vue | 3 +- .../PluginSettings/PluginSettings.js | 4 +- .../SpamBlockSettings/SpamBlockSettings.js | 2 +- .../ProfileSettings/ProfileSettings.css | 4 + .../ProfileSettings/ProfileSettings.js | 28 ++++ .../ProfileSettings/ProfileSettings.vue | 28 ++++ .../SecuritySettings/SecuritySettings.js | 1 + .../SecuritySettings/SecuritySettings.vue | 2 + .../UserManagement/UserManagement.js | 6 +- .../UserManagement/UserManagement.vue | 3 +- test/backend/log_scope_test.go | 50 +++++++ test/backend/log_store_integration_test.go | 6 +- test/backend/logs_api_test.go | 11 +- test/backend/plugin_role_scope_test.go | 107 +++++++++++++ test/backend/role_scope_test.go | 140 ++++++++++++++++++ test/backend/runtime_scope_test.go | 73 +++++++++ test/backend/smtp_mail_log_test.go | 45 ++++++ test/frontend/logs.test.mjs | 20 ++- test/frontend/plugin-navigation.test.mjs | 8 +- test/frontend/profile.test.mjs | 31 ++++ test/frontend/settings-menu.test.mjs | 4 +- test/run.mjs | 4 + 54 files changed, 987 insertions(+), 91 deletions(-) create mode 100644 backend/controllers/config_permissions.go create mode 100644 backend/controllers/log_permissions.go create mode 100644 backend/controllers/profile.go create mode 100644 backend/models/user_scope.go create mode 100644 backend/services/runtimelog/scope.go create mode 100644 frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.css create mode 100644 frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.js create mode 100644 frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.vue create mode 100644 test/backend/log_scope_test.go create mode 100644 test/backend/plugin_role_scope_test.go create mode 100644 test/backend/role_scope_test.go create mode 100644 test/backend/runtime_scope_test.go create mode 100644 test/frontend/profile.test.mjs diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index e5d719b..e669049 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -254,6 +254,21 @@ func (m *ConfigManager) Status() ConfigStatus { } func (m *ConfigManager) Validate(ctx context.Context, req ValidateRequest) (*ValidationResult, error) { + return m.validate(ctx, req, nil) +} + +// ValidateAuthorized 对外部请求使用与保存一致的授权;内部校验不暴露为 HTTP 入口。 +func (m *ConfigManager) ValidateAuthorized(ctx context.Context, req ValidateRequest, actor AuditActor) (*ValidationResult, error) { + if m == nil { + return nil, ErrManagerStorage + } + if err := m.authorize(ctx, actor, ConfigOperationUpdate, req.Section); err != nil { + return nil, err + } + return m.validate(ctx, req, &actor) +} + +func (m *ConfigManager) validate(ctx context.Context, req ValidateRequest, actor *AuditActor) (*ValidationResult, error) { if err := contextError(ctx); err != nil { return nil, err } @@ -273,6 +288,11 @@ func (m *ConfigManager) Validate(ctx context.Context, req ValidateRequest) (*Val if err != nil { return nil, err } + if actor != nil && req.Section == SectionPlugins { + if err := AuthorizePluginChanges(*actor, current, candidate); err != nil { + return nil, err + } + } if req.Section == SectionSSL { if _, err := prepareCustomSSLPaths(m.loader.root, &candidate); err != nil { issue, ok := SSLValidationIssue(err) @@ -317,6 +337,11 @@ func (m *ConfigManager) Update(ctx context.Context, req UpdateRequest) (*UpdateR return nil, err } var sslMaterial *customSSLMaterial + if req.Section == SectionPlugins { + if err := AuthorizePluginChanges(req.Actor, current, candidate); err != nil { + return nil, err + } + } if req.Section == SectionSSL { sslMaterial, err = prepareCustomSSLPaths(m.loader.root, &candidate) if err != nil { @@ -406,6 +431,12 @@ func (m *ConfigManager) apply(ctx context.Context, generation uint64, actor Audi if err := m.authorize(ctx, actor, ConfigOperationUpdate, pending.Section); err != nil { return nil, err } + // 暂存后角色可能变化,应用时必须重新检查程序与权限边界。 + if pending.Section == SectionPlugins { + if err := AuthorizePluginChanges(actor, current, candidate); err != nil { + return nil, err + } + } } if generation != candidate.Generation || candidate.Generation != current.Generation+1 { return nil, ErrGenerationConflict diff --git a/backend/config/operation_log.go b/backend/config/operation_log.go index 27ad49d..d0c6e24 100644 --- a/backend/config/operation_log.go +++ b/backend/config/operation_log.go @@ -2,6 +2,10 @@ package config import ( "context" + "strconv" + + "git.pcux.cn/xuans/Smail/services/access" + appcontext "git.pcux.cn/xuans/Smail/utils/context" log "github.com/sirupsen/logrus" ) @@ -10,7 +14,7 @@ import ( // 只保留定位操作需要的元数据,不输出配置值、凭据、用户名称或完整变更载荷。 type RuntimeConfigLogWriter struct{} -func (RuntimeConfigLogWriter) WriteConfigAudit(_ context.Context, event ConfigAuditEvent) error { +func (RuntimeConfigLogWriter) WriteConfigAudit(ctx context.Context, event ConfigAuditEvent) error { event, err := normalizeAuditEvent(event) if err != nil { return err @@ -20,7 +24,10 @@ func (RuntimeConfigLogWriter) WriteConfigAudit(_ context.Context, event ConfigAu ConfigOperationRollback: "回滚配置", ConfigOperationMigration: "迁移配置", }[event.Operation] - entry := log.WithFields(log.Fields{ + // 操作者来自配置管理器已授权的事件,不从客户端载荷或任意日志字段恢复归属。 + actorID, _ := strconv.Atoi(event.ActorID) + actorContext := &appcontext.Context{Context: ctx, UserID: actorID, Role: access.Role(event.ActorRole)} + entry := log.WithContext(actorContext).WithFields(log.Fields{ "action": action, "object": event.Section, "operation": event.Operation, diff --git a/backend/config/pending.go b/backend/config/pending.go index 8b6c853..270a0e9 100644 --- a/backend/config/pending.go +++ b/backend/config/pending.go @@ -24,6 +24,9 @@ func (m *ConfigManager) Pending(actor AuditActor) *PendingView { if m.pending == nil { return nil } + if actor.Role != "super_admin" && (actor.Role != "admin" || m.pending.Section != SectionPlugins || actor.UserID == "" || actor.UserID != m.pending.Actor.UserID) { + return nil + } return &PendingView{ ID: m.pending.ID, Generation: m.pending.Candidate.Generation, Section: m.pending.Section, diff --git a/backend/config/runtime_event.go b/backend/config/runtime_event.go index 3d64355..311ab98 100644 --- a/backend/config/runtime_event.go +++ b/backend/config/runtime_event.go @@ -1,8 +1,10 @@ package config import ( + "strconv" "strings" + appcontext "git.pcux.cn/xuans/Smail/utils/context" log "github.com/sirupsen/logrus" ) @@ -14,6 +16,13 @@ func runtimeEventFields(entry *log.Entry) map[string]any { fields[key] = value } fields["logSchema"] = 1 + // 归属只来自服务端认证上下文,调用点不能用任意字段冒充其他账号。 + delete(fields, "actorId") + delete(fields, "actorRole") + if ctx, ok := entry.Context.(*appcontext.Context); ok && ctx != nil && ctx.UserID > 0 { + fields["actorId"] = strconv.Itoa(ctx.UserID) + fields["actorRole"] = string(ctx.EffectiveRole()) + } if action, ok := fields["action"].(string); !ok || strings.TrimSpace(action) == "" { action = "运行事件" if entry.Caller != nil && entry.Caller.Function != "" { diff --git a/backend/config/security.go b/backend/config/security.go index d87354a..069dd5d 100644 --- a/backend/config/security.go +++ b/backend/config/security.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "reflect" "strings" "sync" "time" @@ -152,6 +153,9 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C return ErrAuthorizationRequired } capability := access.CapabilityConfigWriteGlobal + if operation == ConfigOperationUpdate && section == SectionPlugins { + capability = access.CapabilityPluginWriteGlobal + } if operation == ConfigOperationRollback { capability = access.CapabilityConfigRollback } else if operation == ConfigOperationMigration { @@ -178,6 +182,23 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C return nil } +// AuthorizePluginChanges 限制管理员只能修改已有插件参数,不允许安装、替换程序或提升权限。 +func AuthorizePluginChanges(actor AuditActor, current, candidate RuntimeConfig) error { + if actor.Role == string(access.RoleSuperAdmin) { + return nil + } + if actor.Role != string(access.RoleAdmin) || len(current.Plugins.Plugins) != len(candidate.Plugins.Plugins) { + return ErrAuthorizationRequired + } + for id, next := range candidate.Plugins.Plugins { + previous, ok := current.Plugins.Plugins[id] + if !ok || next.Executable != previous.Executable || !reflect.DeepEqual(next.Permissions, previous.Permissions) { + return ErrAuthorizationRequired + } + } + return nil +} + func DefaultConfigConfirmation(_ context.Context, _ AuditActor, confirmation StepUpConfirmation, operation ConfigOperation, _ Section) error { if operation != ConfigOperationRollback && !strings.EqualFold(confirmation.Method, "webauthn") && !strings.EqualFold(confirmation.Method, "recent_auth") { return nil diff --git a/backend/controllers/config.go b/backend/controllers/config.go index 02f190f..48c9f65 100644 --- a/backend/controllers/config.go +++ b/backend/controllers/config.go @@ -17,7 +17,7 @@ import ( // ConfigStatus 返回配置管理状态,不返回任何密文、路径或 DSN。 func ConfigStatus(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { - if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { + if err := authorizeConfigOverview(ctx, false); err != nil { writeConfigAccessError(w, err) return } @@ -30,6 +30,10 @@ func ConfigStatus(ctx *appcontext.Context, w http.ResponseWriter, req *http.Requ return } status := manager.Status() + if ctx.EffectiveRole() != access.RoleSuperAdmin { + response.NewSuccessResponse(map[string]any{"available": true, "activeGeneration": status.ActiveGeneration}).FPrint(w) + return + } response.NewSuccessResponse(map[string]any{ "available": true, "setupState": status.SetupState, @@ -52,7 +56,7 @@ func GetConfigSection(ctx *appcontext.Context, w http.ResponseWriter, req *http. if !ok { return } - if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { + if err := authorizeConfigSection(ctx, section, false); err != nil { writeConfigAccessError(w, err) return } @@ -77,7 +81,7 @@ func ValidateConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Re if !ok { return } - if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil { + if err := authorizeConfigSection(ctx, section, true); err != nil { writeConfigAccessError(w, err) return } @@ -86,10 +90,10 @@ func ValidateConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Re writeConfigError(w, errors.New("invalid configuration request")) return } - result, err := manager.Validate(req.Context(), config.ValidateRequest{ + result, err := manager.ValidateAuthorized(req.Context(), config.ValidateRequest{ Section: section, ExpectedGeneration: input.ExpectedGeneration, Payload: input.Payload, SecretChanges: input.SecretChanges, - }) + }, configActor(ctx, req)) if err != nil { writeConfigError(w, err) return @@ -103,7 +107,7 @@ func UpdateConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Requ if !ok { return } - if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil { + if err := authorizeConfigSection(ctx, section, true); err != nil { writeConfigAccessError(w, err) return } @@ -135,7 +139,7 @@ func ApplyConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Reque if !ok { return } - if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil { + if err := authorizeConfigSection(ctx, section, true); err != nil { writeConfigAccessError(w, err) return } diff --git a/backend/controllers/config_backup_access_test.go b/backend/controllers/config_backup_access_test.go index 66bc901..6766ae1 100644 --- a/backend/controllers/config_backup_access_test.go +++ b/backend/controllers/config_backup_access_test.go @@ -40,9 +40,9 @@ func TestConfigBackupReadDoesNotRequireRecentAuth(t *testing.T) { ctx := &appcontext.Context{UserID: 1, Role: role, RecentAuth: false} recorder := httptest.NewRecorder() ListConfigBackups(ctx, recorder, httptest.NewRequest(http.MethodGet, "/api/v1/config/backups", nil)) - if role == access.RoleUser { + if role != access.RoleSuperAdmin { if recorder.Code != http.StatusForbidden { - t.Fatalf("普通用户不应读取配置备份:%d", recorder.Code) + t.Fatalf("非超级管理员不应读取配置备份:%d", recorder.Code) } return } @@ -53,7 +53,7 @@ func TestConfigBackupReadDoesNotRequireRecentAuth(t *testing.T) { t.Fatal(err) } if recorder.Code != http.StatusOK || payload.ErrorNo != 0 { - t.Fatalf("管理员读取脱敏备份不应要求近期认证:%d", recorder.Code) + t.Fatalf("超级管理员读取脱敏备份不应要求近期认证:%d", recorder.Code) } }) } diff --git a/backend/controllers/config_pending.go b/backend/controllers/config_pending.go index 324b496..0d24ade 100644 --- a/backend/controllers/config_pending.go +++ b/backend/controllers/config_pending.go @@ -6,7 +6,6 @@ import ( "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/dto/response" - "git.pcux.cn/xuans/Smail/services/access" appcontext "git.pcux.cn/xuans/Smail/utils/context" ) @@ -16,7 +15,7 @@ func GetPendingConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http. if !ok { return } - if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { + if err := authorizeConfigOverview(ctx, false); err != nil { writeConfigAccessError(w, err) return } @@ -38,7 +37,7 @@ func ManagePendingConfig(ctx *appcontext.Context, w http.ResponseWriter, req *ht if !ok { return } - if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil { + if err := authorizeConfigOverview(ctx, true); err != nil { writeConfigAccessError(w, err) return } diff --git a/backend/controllers/config_permissions.go b/backend/controllers/config_permissions.go new file mode 100644 index 0000000..edc1e41 --- /dev/null +++ b/backend/controllers/config_permissions.go @@ -0,0 +1,29 @@ +package controllers + +import ( + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/services/access" + appcontext "git.pcux.cn/xuans/Smail/utils/context" +) + +// 分区权限必须在接口和配置管理器两层检查,不能信任前端菜单。 +func authorizeConfigSection(ctx *appcontext.Context, section config.Section, write bool) error { + capability := access.CapabilityConfigRead + if write { + capability = access.CapabilityConfigWriteGlobal + } + if section == config.SectionPlugins { + capability = access.CapabilityPluginReadGlobal + if write { + capability = access.CapabilityPluginWriteGlobal + } + } + return authorizeCapability(ctx, capability, "", false) +} + +func authorizeConfigOverview(ctx *appcontext.Context, write bool) error { + if ctx != nil && ctx.EffectiveRole() == access.RoleAdmin { + return authorizeConfigSection(ctx, config.SectionPlugins, write) + } + return authorizeConfigSection(ctx, config.SectionRoot, write) +} diff --git a/backend/controllers/email/send.go b/backend/controllers/email/send.go index 2cc05e8..025395f 100644 --- a/backend/controllers/email/send.go +++ b/backend/controllers/email/send.go @@ -13,6 +13,7 @@ import ( "git.pcux.cn/xuans/Smail/hooks/framework" "git.pcux.cn/xuans/Smail/i18n" "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/utils/async" "git.pcux.cn/xuans/Smail/utils/context" @@ -75,11 +76,12 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } log.WithContext(ctx).Infof("发送邮件") - if reqData.From.Email != "" && !validation.IsAuthorizedSender(reqData.From.Email, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) { + allowLocalAlias := ctx.EffectiveRole() == access.RoleSuperAdmin + if reqData.From.Email != "" && !validation.IsAuthorizedSender(reqData.From.Email, ctx.UserAccount, allowLocalAlias, domains.Accepted) { response.NewErrorResponse(response.ParamsError, "\u53d1\u4ef6\u4eba\u5730\u5740\u4e0d\u5141\u8bb8", "").FPrint(w) return } - if reqData.Sender.Email != "" && !validation.IsAuthorizedSender(reqData.Sender.Email, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) { + if reqData.Sender.Email != "" && !validation.IsAuthorizedSender(reqData.Sender.Email, ctx.UserAccount, allowLocalAlias, domains.Accepted) { response.NewErrorResponse(response.ParamsError, "\u53d1\u9001\u8005\u5730\u5740\u4e0d\u5141\u8bb8", "").FPrint(w) return } @@ -194,7 +196,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } log.WithContext(ctx).Debugf("插件执行--SendBefore End") - if e.From == nil || e.Sender == nil || !validation.IsAuthorizedSender(e.From.EmailAddress, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) || !validation.IsAuthorizedSender(e.Sender.EmailAddress, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) { + if e.From == nil || e.Sender == nil || !validation.IsAuthorizedSender(e.From.EmailAddress, ctx.UserAccount, allowLocalAlias, domains.Accepted) || !validation.IsAuthorizedSender(e.Sender.EmailAddress, ctx.UserAccount, allowLocalAlias, domains.Accepted) { response.NewErrorResponse(response.ParamsError, "\u63d2\u4ef6\u4fee\u6539\u4e86\u672a\u6388\u6743\u7684\u53d1\u4ef6\u4eba", "").FPrint(w) return } diff --git a/backend/controllers/log_permissions.go b/backend/controllers/log_permissions.go new file mode 100644 index 0000000..2821cb7 --- /dev/null +++ b/backend/controllers/log_permissions.go @@ -0,0 +1,42 @@ +package controllers + +import ( + "context" + "strconv" + + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/services/logstore" + appcontext "git.pcux.cn/xuans/Smail/utils/context" +) + +func authorizeLogs(ctx *appcontext.Context) error { + if ctx == nil || ctx.UserID <= 0 { + return &access.Denial{Code: access.CodeAccessDenied} + } + // 此授权只允许进入已实施数据范围过滤的日志接口,不影响旧的全局审计接口。 + return access.Authorize(access.Request{ActorRole: ctx.EffectiveRole(), Capability: access.CapabilityAuditRead, OwnResource: true}) +} + +func runtimeLogActors(requestContext context.Context, ctx *appcontext.Context) (map[string]bool, error) { + if ctx.EffectiveRole() == access.RoleSuperAdmin { + return nil, nil + } + actors := map[string]bool{strconv.Itoa(ctx.UserID): true} + if ctx.EffectiveRole() == access.RoleAdmin { + if db.Instance == nil { + return nil, logstore.ErrUnavailable + } + var users []models.User + if err := db.Instance.Context(requestContext).Cols("id").Where(models.OrdinaryUserPredicate).Find(&users); err != nil { + return nil, logstore.ErrUnavailable + } + for _, user := range users { + if user.ID != ctx.UserID { + actors[strconv.Itoa(user.ID)] = false + } + } + } + return actors, nil +} diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index da0a583..f8a20fe 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -28,7 +28,7 @@ func logQueryError(w http.ResponseWriter, err error) { // GetLogs 只读日志入口,授权在服务端检查,不依赖导航隐藏。 func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { w.Header().Set("Cache-Control", "no-store") - if err := authorizeCapability(ctx, access.CapabilityAuditRead, "", false); err != nil { + if err := authorizeLogs(ctx); err != nil { writeConfigAccessError(w, err) return } @@ -48,6 +48,10 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) bounded, cancel := context.WithTimeout(req.Context(), 10*time.Second) defer cancel() query := logstore.Query{Kind: values.Get("kind"), ActorID: values.Get("actorId"), Limit: limit + 1, Offset: offset} + if ctx.EffectiveRole() != access.RoleSuperAdmin { + query.ScopeActorID = strconv.Itoa(ctx.UserID) + query.IncludeOrdinaryUsers = ctx.EffectiveRole() == access.RoleAdmin + } for _, item := range []struct { name string target *time.Time @@ -78,6 +82,10 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) } // 显式保留旧文件列表入口,新页面默认跨文件查询。 if values.Get("listFiles") == "true" { + if ctx.EffectiveRole() != access.RoleSuperAdmin { + writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied}) + return + } files, err := writer.Files() if err != nil { logQueryError(w, err) @@ -86,7 +94,12 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) response.NewSuccessResponse(map[string]any{"files": files}).FPrint(w) return } - page, err := writer.Query(bounded, runtimelog.Query{File: values.Get("file"), From: query.From, To: query.To, Offset: offset, Limit: limit}) + actors, err := runtimeLogActors(bounded, ctx) + if err != nil { + logQueryError(w, err) + return + } + page, err := writer.Query(bounded, runtimelog.Query{File: values.Get("file"), From: query.From, To: query.To, Offset: offset, Limit: limit, AllowedActors: actors}) if err != nil { logQueryError(w, err) return diff --git a/backend/controllers/plugin.go b/backend/controllers/plugin.go index e8a15f5..2018cbf 100644 --- a/backend/controllers/plugin.go +++ b/backend/controllers/plugin.go @@ -3,6 +3,7 @@ package controllers import ( "git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/hooks" + "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/utils/context" log "github.com/sirupsen/logrus" "io" @@ -18,7 +19,7 @@ const ( ) func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { - if !ctx.IsAdmin { + if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w) return } @@ -31,7 +32,7 @@ func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Reques } func SettingsHtml(ctx *context.Context, w http.ResponseWriter, req *http.Request) { - if !ctx.IsAdmin { + if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w) return } diff --git a/backend/controllers/profile.go b/backend/controllers/profile.go new file mode 100644 index 0000000..8af9e97 --- /dev/null +++ b/backend/controllers/profile.go @@ -0,0 +1,55 @@ +package controllers + +import ( + "net/http" + + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + appcontext "git.pcux.cn/xuans/Smail/utils/context" + "git.pcux.cn/xuans/Smail/utils/validation" +) + +// UpdateProfile 只接受本人显示名称,不允许传入目标账号、角色、禁用状态或密码。 +func UpdateProfile(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { + if ctx == nil || ctx.UserID <= 0 { + response.NewErrorResponse(response.NoAccessPrivileges, "无权修改个人资料", "").FPrint(w) + return + } + if err := authorizeCapability(ctx, access.CapabilityProfileWriteOwn, "", true); err != nil { + writeUserAuthorizationError(w, err, "") + return + } + var input struct { + Name string `json:"name"` + } + if err := decodeUserRequest(w, req, &input); err != nil { + response.NewErrorResponse(response.ParamsError, "个人资料参数错误", "").FPrint(w) + return + } + name, err := validation.NormalizeDisplayName(input.Name) + if err != nil { + response.NewErrorResponse(response.ParamsError, "请输入有效的显示名称", "").FPrint(w) + return + } + if db.Instance == nil { + response.NewErrorResponse(response.ServerError, "个人资料保存失败", "").FPrint(w) + return + } + affected, err := db.Instance.Context(req.Context()).ID(ctx.UserID).Cols("name").Update(&models.User{Name: name}) + if err != nil { + response.NewErrorResponse(response.ServerError, "个人资料保存失败", "").FPrint(w) + return + } + // MySQL 重复保存相同名称时可能返回零行,必须区分无变化和账号已不存在。 + if affected == 0 { + found, err := db.Instance.Context(req.Context()).ID(ctx.UserID).Exist(&models.User{}) + if err != nil || !found { + response.NewErrorResponse(response.ServerError, "账号已不可用,请重新登录", "").FPrint(w) + return + } + } + ctx.UserName = name + response.NewSuccessResponse(map[string]any{"name": name}).FPrint(w) +} diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 64daa5e..8fa34a3 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -60,7 +60,7 @@ func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) if reqData.Role != "" { targetRole = access.Role(reqData.Role) } - if err := authorizeCapability(ctx, access.CapabilityUserManage, targetRole, false); err != nil { + if err := authorizeCapability(ctx, access.CapabilityUserCreate, targetRole, false); err != nil { response.NewErrorResponse(response.NoAccessPrivileges, "无权访问", "").FPrint(w) return } @@ -170,7 +170,12 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } var users []models.User - totalNum, err := db.Instance.Table(&models.User{}).Limit(reqData.PageSize, offset).FindAndCount(&users) + query := db.Instance.Table(&models.User{}) + defer query.Close() + if ctx.EffectiveRole() == access.RoleAdmin { + query.Where(models.OrdinaryUserPredicate) + } + totalNum, err := query.Asc("id").Limit(reqData.PageSize, offset).FindAndCount(&users) if err != nil { log.WithContext(ctx).Errorf("SQL Error: %+v", err) response.NewErrorResponse(response.ServerError, "查询失败", "").FPrint(w) diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index c7bdd2a..0d4626f 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -65,6 +65,7 @@ func registerV1Routes(mux *http.ServeMux) { registerV1MethodRoute(mux, "/api/v1/rule/del", "/api/rule/del", http.MethodPost, 64<<10, controllers.DelRule) registerV1MethodRoute(mux, "/api/v1/user/create", "/api/user/create", http.MethodPost, 64<<10, controllers.CreateUser) registerV1MethodRoute(mux, "/api/v1/user/edit", "/api/user/edit", http.MethodPost, 64<<10, controllers.EditUser) + registerV1MethodRoute(mux, "/api/v1/user/profile", "/api/user/profile", http.MethodPost, 64<<10, controllers.UpdateProfile) registerV1MethodRoute(mux, "/api/v1/user/info", "/api/user/info", http.MethodPost, 64<<10, controllers.Info) registerV1MethodRoute(mux, "/api/v1/user/list", "/api/user/list", http.MethodPost, 64<<10, controllers.UserList) registerV1MethodRoute(mux, "/api/v1/user/security/recovery-codes", "/api/user/security/recovery-codes", http.MethodGet, 0, controllers.GetManagedRecoveryCodeStatus) diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 7e8e1f6..4f485b5 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -97,6 +97,7 @@ func router(mux *http.ServeMux) error { mux.HandleFunc("/attachments/download/", onlyMethod(http.MethodGet, contextIterceptor(controllers.Download))) mux.HandleFunc("/api/user/create", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.CreateUser)))) mux.HandleFunc("/api/user/edit", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.EditUser)))) + mux.HandleFunc("/api/user/profile", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UpdateProfile)))) mux.HandleFunc("/api/user/info", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.Info)))) mux.HandleFunc("/api/user/list", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UserList)))) mux.HandleFunc("/api/user/security/recovery-codes", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetManagedRecoveryCodeStatus))) diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index 9cc277b..a3d9ce8 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -15,7 +15,8 @@ import ( // 仅记录明确的变更接口,不将 POST 列表查询误认为操作,也不复制请求或响应载荷。 var loggedOperations = map[string]string{ - "/api/login": "处理账号登录", "/api/logout": "退出登录", + "/api/user/profile": "修改个人资料", + "/api/login": "处理账号登录", "/api/logout": "退出登录", "/api/security/login/verify": "完成登录二次验证", "/api/security/webauthn/login/finish": "完成通行密钥登录验证", "/api/user/create": "创建用户", "/api/user/edit": "编辑用户", diff --git a/backend/listen/smtp_server/read_content.go b/backend/listen/smtp_server/read_content.go index ee7fa15..9d7c5c9 100644 --- a/backend/listen/smtp_server/read_content.go +++ b/backend/listen/smtp_server/read_content.go @@ -20,6 +20,7 @@ import ( "git.pcux.cn/xuans/Smail/hooks/framework" "git.pcux.cn/xuans/Smail/listen/imap_server" "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/logstore" "git.pcux.cn/xuans/Smail/services/rule" "git.pcux.cn/xuans/Smail/utils/array" @@ -41,6 +42,7 @@ const DropUnknownRecipientEmails = true func (s *Session) Data(r io.Reader) (resultErr error) { messageBytes := 0 + var recipientIDs []int result := "unknown" details := logstore.MailDetails{Trigger: "SMTP 收信", Stage: "读取邮件内容"} defer func() { @@ -59,7 +61,18 @@ func (s *Session) Data(r io.Reader) (resultErr error) { details.Outcome += fmt.Sprintf(",SMTP 状态码 %d", protocol.Code) } } - logstore.MailResult(s.Ctx.UserID, direction, s.From, len(s.To), messageBytes, result, details) + if direction == "inbound" && len(recipientIDs) > 0 { + // 每个真实收件人只看到自己的投递记录,不暴露其他收件人数或地址。 + seen := make(map[int]bool) + for _, id := range recipientIDs { + if id > 0 && !seen[id] { + seen[id] = true + logstore.MailResult(id, direction, s.From, 1, messageBytes, result, details) + } + } + } else { + logstore.MailResult(s.Ctx.UserID, direction, s.From, len(s.To), messageBytes, result, details) + } }() ctx := s.Ctx @@ -188,6 +201,9 @@ func (s *Session) Data(r io.Reader) (resultErr error) { result = "discarded" return nil } + for _, user := range users { + recipientIDs = append(recipientIDs, user.ID) + } details.Stage = "执行收信规则与通知" if email.MessageId > 0 { @@ -200,20 +216,21 @@ func (s *Session) Data(r io.Reader) (resultErr error) { ruleContext.Context = ruleExecutionContext log.WithContext(ctx).Debugf("开始执行邮件规则!") for _, user := range users { + ruleContext.UserID, ruleContext.Role = user.ID, user.EffectiveRole() if ruleExecutionContext.Err() != nil { - log.WithContext(ctx).WithFields(log.Fields{"action": "执行收信规则", "result": "failure"}).Warn("规则处理超时,已停止后续规则") + log.WithContext(&ruleContext).WithFields(log.Fields{"action": "执行收信规则", "result": "failure"}).Warn("规则处理超时,已停止后续规则") break } // 执行邮件规则 rs, ruleErr := rule.GetAllRules(&ruleContext, user.ID) if ruleErr != nil { - log.WithContext(ctx).WithFields(log.Fields{"action": "读取收信规则", "result": "failure", "user_id": user.ID}).WithError(ruleErr).Error("读取收信规则失败") + log.WithContext(&ruleContext).WithFields(log.Fields{"action": "读取收信规则", "result": "failure", "user_id": user.ID}).WithError(ruleErr).Error("读取收信规则失败") continue } for _, r := range rs { if rule.MatchRule(&ruleContext, r, email) { if err := rule.DoRule(&ruleContext, r, email, user, emailData); err != nil { - log.WithContext(ctx).WithFields(log.Fields{"action": "执行收信规则", "result": "failure", "user_id": user.ID}).WithError(err).Error("执行收信规则失败") + log.WithContext(&ruleContext).WithFields(log.Fields{"action": "执行收信规则", "result": "failure", "user_id": user.ID}).WithError(err).Error("执行收信规则失败") } } } @@ -241,8 +258,10 @@ func (s *Session) Data(r io.Reader) (resultErr error) { // IDLE命令通知 for _, user := range users { - if err := imap_server.IdleNotice(ctx, user.ID, dbEmail); err != nil { - log.WithContext(ctx).WithFields(log.Fields{"action": "通知 IMAP 客户端新邮件", "result": "failure"}).WithError(err).Error("邮件已保存,但新邮件通知失败") + noticeContext := *ctx + noticeContext.UserID, noticeContext.Role = user.ID, user.EffectiveRole() + if err := imap_server.IdleNotice(¬iceContext, user.ID, dbEmail); err != nil { + log.WithContext(¬iceContext).WithFields(log.Fields{"action": "通知 IMAP 客户端新邮件", "result": "failure"}).WithError(err).Error("邮件已保存,但新邮件通知失败") } } @@ -273,13 +292,14 @@ func authorizedSubmissionEmail(email *parsemail.Email, envelopeFrom string, ctx if email == nil || email.From == nil || email.Sender == nil || ctx == nil || len(domains.Accepted) == 0 { return false } - if !validation.IsAuthorizedSender(email.From.EmailAddress, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) { + allowLocalAlias := ctx.EffectiveRole() == access.RoleSuperAdmin + if !validation.IsAuthorizedSender(email.From.EmailAddress, ctx.UserAccount, allowLocalAlias, domains.Accepted) { return false } - if !validation.IsAuthorizedSender(email.Sender.EmailAddress, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) { + if !validation.IsAuthorizedSender(email.Sender.EmailAddress, ctx.UserAccount, allowLocalAlias, domains.Accepted) { return false } - return envelopeFrom == "" || validation.IsAuthorizedSender(envelopeFrom, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) + return envelopeFrom == "" || validation.IsAuthorizedSender(envelopeFrom, ctx.UserAccount, allowLocalAlias, domains.Accepted) } func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserID int, emailType int, reallyTo []string, SPFStatus, dkimStatus bool) ([]*models.User, *models.Email, error) { diff --git a/backend/models/user_scope.go b/backend/models/user_scope.go new file mode 100644 index 0000000..dd103af --- /dev/null +++ b/backend/models/user_scope.go @@ -0,0 +1,4 @@ +package models + +// OrdinaryUserPredicate 与 EffectiveRole 的兼容规则一致,旧管理员不能混入普通用户范围。 +const OrdinaryUserPredicate = "(role = 'user' OR ((role IS NULL OR role NOT IN ('user', 'admin', 'super_admin')) AND is_admin <> 1))" diff --git a/backend/services/access/access_test.go b/backend/services/access/access_test.go index 69dc15e..1cb3d1b 100644 --- a/backend/services/access/access_test.go +++ b/backend/services/access/access_test.go @@ -51,7 +51,7 @@ func TestRoleBoundaries(t *testing.T) { } func TestRecoveryAndRecentAuthentication(t *testing.T) { - admin := Request{ActorRole: RoleAdmin, Capability: CapabilityPluginRestart, RecentAuth: true, PluginInstanceAuthorized: true} + admin := Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteGlobal, RecentAuth: true} if err := Authorize(admin); !IsDenied(err, CodeRecoveryCodeRequired) { t.Fatalf("expected recovery code requirement, got %v", err) } @@ -83,18 +83,18 @@ func TestRoleChangeRequiresTargetRecovery(t *testing.T) { func TestAdminPluginRestartScope(t *testing.T) { req := Request{ActorRole: RoleAdmin, Capability: CapabilityPluginRestart, RecentAuth: true, RecoveryCodeConfigured: true} - if err := Authorize(req); !IsDenied(err, CodePluginScopeDenied) { + if err := Authorize(req); !IsDenied(err, CodeAccessDenied) { t.Fatalf("expected plugin scope denial, got %v", err) } req.PluginInstanceAuthorized = true - if err := Authorize(req); err != nil { - t.Fatalf("expected plugin restart to be allowed, got %v", err) + if err := Authorize(req); !IsDenied(err, CodeAccessDenied) { + t.Fatalf("expected plugin restart to remain denied, got %v", err) } } func TestManagementEndpointCatalog(t *testing.T) { endpoints := ManagementEndpoints() - if len(endpoints) != 35 { + if len(endpoints) != 37 { t.Fatalf("unexpected endpoint count: %d", len(endpoints)) } seen := map[string]struct{}{} diff --git a/backend/services/access/authorize.go b/backend/services/access/authorize.go index 1f5ccfa..e4e846a 100644 --- a/backend/services/access/authorize.go +++ b/backend/services/access/authorize.go @@ -63,7 +63,7 @@ func Authorize(request Request) error { func authorizeResource(request Request) error { switch request.Capability { - case CapabilityPasswordChangeOwn, CapabilityPluginWriteOwn: + case CapabilityPasswordChangeOwn, CapabilityPluginWriteOwn, CapabilityProfileWriteOwn: if !request.OwnResource { return deny(CodeAccessDenied) } @@ -78,7 +78,7 @@ func authorizeResource(request Request) error { if request.ActorRole == RoleAdmin && !request.OwnResource && !request.AuditScopeAuthorized { return deny(CodeAccessDenied) } - case CapabilityUserManage, CapabilityUserSecurityManage, CapabilityPasswordReset: + case CapabilityUserCreate, CapabilityUserManage, CapabilityUserSecurityManage, CapabilityPasswordReset: if !canManageAccountTarget(request.ActorRole, request.TargetRole) { return deny(CodeTargetRoleDenied) } diff --git a/backend/services/access/capability.go b/backend/services/access/capability.go index 61092a7..8631c45 100644 --- a/backend/services/access/capability.go +++ b/backend/services/access/capability.go @@ -8,6 +8,7 @@ const ( CapabilityConfigRollback Capability = "config.rollback" CapabilityDatabaseMigrate Capability = "database.migrate" CapabilityPluginRead Capability = "plugin.read" + CapabilityPluginReadGlobal Capability = "plugin.read_global" CapabilityPluginControl Capability = "plugin.control" CapabilityPluginRestart Capability = "plugin.restart" CapabilityServiceRestart Capability = "service.restart" @@ -15,6 +16,8 @@ const ( CapabilityPluginWriteAnyUser Capability = "plugin.write_any_user" CapabilityPluginWriteOwn Capability = "plugin.write_own" CapabilityUserRead Capability = "user.read" + CapabilityUserCreate Capability = "user.create" + CapabilityProfileWriteOwn Capability = "profile.write_own" CapabilityUserManage Capability = "user.manage" CapabilityUserSecurityManage Capability = "user.security_manage" CapabilityRoleManage Capability = "role.manage" @@ -38,6 +41,7 @@ var policies = map[Capability]Policy{ CapabilityConfigRollback: {Capability: CapabilityConfigRollback, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, CapabilityDatabaseMigrate: {Capability: CapabilityDatabaseMigrate, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, CapabilityPluginRead: {Capability: CapabilityPluginRead}, + CapabilityPluginReadGlobal: {Capability: CapabilityPluginReadGlobal}, CapabilityPluginControl: {Capability: CapabilityPluginControl, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, CapabilityPluginRestart: {Capability: CapabilityPluginRestart, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, CapabilityServiceRestart: {Capability: CapabilityServiceRestart, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, @@ -45,6 +49,8 @@ var policies = map[Capability]Policy{ CapabilityPluginWriteAnyUser: {Capability: CapabilityPluginWriteAnyUser, RequiresTarget: true}, CapabilityPluginWriteOwn: {Capability: CapabilityPluginWriteOwn}, CapabilityUserRead: {Capability: CapabilityUserRead}, + CapabilityUserCreate: {Capability: CapabilityUserCreate, RequiresTarget: true}, + CapabilityProfileWriteOwn: {Capability: CapabilityProfileWriteOwn}, CapabilityUserManage: {Capability: CapabilityUserManage, RequiresTarget: true}, CapabilityUserSecurityManage: {Capability: CapabilityUserSecurityManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true}, CapabilityRoleManage: {Capability: CapabilityRoleManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true}, @@ -56,6 +62,7 @@ var policies = map[Capability]Policy{ var grants = map[Role]map[Capability]struct{}{ RoleSuperAdmin: grant( + CapabilityPluginReadGlobal, CapabilityUserCreate, CapabilityProfileWriteOwn, CapabilityConfigRead, CapabilityConfigWriteGlobal, CapabilityConfigRollback, CapabilityDatabaseMigrate, CapabilityPluginRead, CapabilityPluginControl, CapabilityPluginRestart, CapabilityServiceRestart, CapabilityPluginWriteGlobal, CapabilityPluginWriteAnyUser, @@ -64,12 +71,13 @@ var grants = map[Role]map[Capability]struct{}{ CapabilityAuditRead, CapabilityAuditExport, ), RoleAdmin: grant( - CapabilityConfigRead, CapabilityPluginRead, CapabilityPluginRestart, + CapabilityPluginReadGlobal, CapabilityPluginRead, CapabilityPluginWriteGlobal, CapabilityProfileWriteOwn, CapabilityPluginWriteAnyUser, CapabilityPluginWriteOwn, CapabilityUserRead, CapabilityUserManage, CapabilityUserSecurityManage, CapabilityPasswordChangeOwn, CapabilityPasswordReset, CapabilityAuditRead, ), RoleUser: grant( + CapabilityProfileWriteOwn, CapabilityPluginRead, CapabilityPluginWriteOwn, CapabilityUserRead, CapabilityPasswordChangeOwn, CapabilityAuditRead, ), diff --git a/backend/services/access/catalog.go b/backend/services/access/catalog.go index f5c69ee..0c3520f 100644 --- a/backend/services/access/catalog.go +++ b/backend/services/access/catalog.go @@ -10,7 +10,8 @@ type Endpoint struct { var managementEndpoints = []Endpoint{ {Method: "POST", Path: "/api/settings/modify_password", Capability: CapabilityPasswordChangeOwn}, - {Method: "POST", Path: "/api/user/create", Capability: CapabilityUserManage}, + {Method: "POST", Path: "/api/user/create", Capability: CapabilityUserCreate}, + {Method: "POST", Path: "/api/user/profile", Capability: CapabilityProfileWriteOwn}, {Method: "POST", Path: "/api/user/edit", Capability: CapabilityUserManage}, {Method: "*", Path: "/api/user/security/", Capability: CapabilityUserSecurityManage}, {Method: "POST", Path: "/api/user/info", Capability: CapabilityUserRead}, @@ -37,7 +38,8 @@ var managementEndpoints = []Endpoint{ {Method: "POST", Path: "/api/v1/database/migrations/preview", Capability: CapabilityDatabaseMigrate}, {Method: "POST", Path: "/api/v1/database/migrations", Capability: CapabilityDatabaseMigrate}, {Method: "GET", Path: "/api/v1/database/migrations/:id", Capability: CapabilityDatabaseMigrate}, - {Method: "POST", Path: "/api/v1/user/create", Capability: CapabilityUserManage}, + {Method: "POST", Path: "/api/v1/user/create", Capability: CapabilityUserCreate}, + {Method: "POST", Path: "/api/v1/user/profile", Capability: CapabilityProfileWriteOwn}, {Method: "POST", Path: "/api/v1/user/edit", Capability: CapabilityUserManage}, {Method: "*", Path: "/api/v1/user/security/", Capability: CapabilityUserSecurityManage}, {Method: "POST", Path: "/api/v1/user/info", Capability: CapabilityUserRead}, diff --git a/backend/services/access/matrix_test.go b/backend/services/access/matrix_test.go index 8baf5e6..6d8fe7c 100644 --- a/backend/services/access/matrix_test.go +++ b/backend/services/access/matrix_test.go @@ -9,13 +9,16 @@ func TestCapabilityMatrix(t *testing.T) { allowed bool }{ {name: "super admin reads config", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityConfigRead}, allowed: true}, - {name: "admin reads config", req: Request{ActorRole: RoleAdmin, Capability: CapabilityConfigRead}, allowed: true}, + {name: "admin cannot read config", req: Request{ActorRole: RoleAdmin, Capability: CapabilityConfigRead}}, {name: "user cannot read config", req: Request{ActorRole: RoleUser, Capability: CapabilityConfigRead}}, {name: "super admin controls plugin", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginControl, RecentAuth: true, RecoveryCodeConfigured: true}, allowed: true}, {name: "super admin controls plugin without recovery code", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginControl, RecentAuth: true}, allowed: true}, {name: "admin cannot control plugin", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginControl, RecentAuth: true, RecoveryCodeConfigured: true}}, {name: "super admin writes global plugin settings", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginWriteGlobal, RecentAuth: true, RecoveryCodeConfigured: true}, allowed: true}, - {name: "admin cannot write global plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteGlobal, RecentAuth: true, RecoveryCodeConfigured: true}}, + {name: "admin writes global plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteGlobal, RecentAuth: true, RecoveryCodeConfigured: true}, allowed: true}, + {name: "admin cannot create user", req: Request{ActorRole: RoleAdmin, Capability: CapabilityUserCreate, TargetRole: RoleUser}}, + {name: "user edits own profile", req: Request{ActorRole: RoleUser, Capability: CapabilityProfileWriteOwn, OwnResource: true}, allowed: true}, + {name: "user cannot edit another profile", req: Request{ActorRole: RoleUser, Capability: CapabilityProfileWriteOwn}}, {name: "admin writes user plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleUser}, allowed: true}, {name: "admin cannot write admin plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleAdmin}}, {name: "super admin writes all user plugin settings", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleSuperAdmin}, allowed: true}, diff --git a/backend/services/logstore/store.go b/backend/services/logstore/store.go index 0b5a558..1d9c2ce 100644 --- a/backend/services/logstore/store.go +++ b/backend/services/logstore/store.go @@ -13,6 +13,7 @@ import ( "time" "git.pcux.cn/xuans/Smail/db/migration" + "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/utils/logpolicy" ) @@ -31,15 +32,18 @@ type Entry struct { } type Query struct { - ID string - Kind string - ActorID string - Action string - Section string - From time.Time - To time.Time - Limit int - Offset int + // ScopeActorID 不接受客户端直接赋值;非空时由服务端强制限制可见账号。 + ScopeActorID string + IncludeOrdinaryUsers bool + ID string + Kind string + ActorID string + Action string + Section string + From time.Time + To time.Time + Limit int + Offset int } type Store struct { @@ -147,6 +151,32 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) { } statement := "SELECT id, created_at, kind, actor_id, action, section, result, payload FROM " + table + " WHERE created_at >= ? AND kind = ?" args := []any{from.UnixNano(), query.Kind} + if query.ScopeActorID != "" { + statement += " AND (actor_id = ?" + args = append(args, query.ScopeActorID) + if query.IncludeOrdinaryUsers { + statement += " OR (" + userTable, idType := `"user"`, "TEXT" + if s.dialect == migration.DialectMySQL { + userTable, idType = "`user`", "CHAR" + } + statement += "actor_id IN (SELECT CAST(id AS " + idType + ") FROM " + userTable + " WHERE " + models.OrdinaryUserPredicate + ")" + // 代管视图还要核对操作发生时的角色,不能因账号降级暴露旧管理操作。 + // 缺少历史角色的操作仅本人和超级管理员可见,收发信元数据不包含管理操作。 + if query.Kind == "operation" { + switch s.dialect { + case migration.DialectMySQL: + statement += " AND CASE WHEN JSON_VALID(payload) THEN JSON_UNQUOTE(JSON_EXTRACT(payload, '$.actorRole')) ELSE NULL END = 'user'" + case migration.DialectPostgres: + statement += " AND (payload::jsonb ->> 'actorRole') = 'user'" + default: + statement += " AND CASE WHEN json_valid(payload) THEN json_extract(payload, '$.actorRole') ELSE NULL END = 'user'" + } + } + statement += ")" + } + statement += ")" + } if !query.To.IsZero() { statement += " AND created_at <= ?" args = append(args, query.To.UnixNano()) diff --git a/backend/services/runtimelog/range.go b/backend/services/runtimelog/range.go index 86268f3..b43d4ee 100644 --- a/backend/services/runtimelog/range.go +++ b/backend/services/runtimelog/range.go @@ -11,11 +11,14 @@ import ( ) type Query struct { - File string - From time.Time - To time.Time - Offset int - Limit int + // nil 表示完整管理视图;非 nil 时先按可信归属过滤并脱敏,再进行分页。 + // 映射值为 true 表示本人;false 表示代管普通用户,事件发生时也必须是普通用户。 + AllowedActors map[string]bool + File string + From time.Time + To time.Time + Offset int + Limit int } type lineRef struct { @@ -110,7 +113,7 @@ func (w *Writer) Query(ctx context.Context, query Query) (Page, error) { if at.Before(query.From) || at.After(query.To) { return nil } - visible, keep := presentLine(string(line)) + visible, keep := presentScopedLine(string(line), query.AllowedActors) if !keep { return nil } @@ -161,10 +164,11 @@ func (w *Writer) Query(ctx context.Context, query Query) (Page, error) { if !ok { return nil } - if !at.Equal(refs[index].at) || len(line) != refs[index].bytes { + visible, keep := presentScopedLine(string(line), query.AllowedActors) + if !keep || !at.Equal(refs[index].at) || len(visible) != refs[index].bytes { return ErrInvalid } - page.Lines[index] = string(line) + page.Lines[index] = visible remaining-- if remaining == 0 { return finished diff --git a/backend/services/runtimelog/scope.go b/backend/services/runtimelog/scope.go new file mode 100644 index 0000000..bda6cae --- /dev/null +++ b/backend/services/runtimelog/scope.go @@ -0,0 +1,60 @@ +package runtimelog + +import ( + "encoding/json" + "regexp" + "strings" +) + +var scopedHeader = regexp.MustCompile(`^(\[[^\]\r\n]+\]\s+\[(?:trace|debug|info|warn|warning|error|fatal|panic)\]\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\])`) + +// 低权限视图不返回任意消息、源码路径、请求标识或异常原文。 +// 公共事件仅开放固定生命周期文案,无法确认归属的历史日志默认隐藏。 +func presentScopedLine(line string, actors map[string]bool) (string, bool) { + if actors == nil { + return presentLine(line) + } + header := scopedHeader.FindStringSubmatch(line) + boundary := strings.LastIndex(line, " @event ") + if header == nil || boundary < len(header[0]) { + return "", false + } + var event struct { + Schema int `json:"logSchema"` + ActorID string `json:"actorId"` + ActorRole string `json:"actorRole"` + Action string `json:"action"` + Result string `json:"result"` + Object string `json:"object"` + } + if json.Unmarshal([]byte(line[boundary+8:]), &event) != nil || event.Schema != 1 { + return "", false + } + action := "账号相关运行事件" + if own, allowed := actors[event.ActorID]; allowed { + if !own && event.ActorRole != "user" { + return "", false + } + // 已知动作保留业务含义,未知动作不能携带任意错误原文或账号信息。 + switch event.Action { + case "执行收信规则", "读取收信规则", "通知 IMAP 客户端新邮件", "接收邮件", "投递邮件", "客户端提交邮件", "更新个人资料", "登录验证", "退出登录", "保存配置", "回滚配置", "迁移配置": + action = event.Action + } + } else { + if event.ActorID != "" || event.Object != "Smail" { + return "", false + } + switch event.Action { + case "启动主服务", "停止主服务", "运行主服务": + action = event.Action + default: + return "", false + } + } + outcome := map[string]string{"success": "处理已完成", "failure": "处理失败", "started": "已开始处理", "recorded": "已记录运行状态", "accepted": "已接收", "partial": "部分完成", "unknown": "结果待确认"}[event.Result] + if outcome == "" { + event.Result, outcome = "recorded", "已记录运行状态" + } + payload, _ := json.Marshal(map[string]any{"logSchema": 1, "action": action, "result": event.Result, "outcome": outcome}) + return header[1] + action + " @event " + string(payload), true +} diff --git a/backend/utils/validation/sender.go b/backend/utils/validation/sender.go index 7b6f1ad..505cc7c 100644 --- a/backend/utils/validation/sender.go +++ b/backend/utils/validation/sender.go @@ -5,7 +5,7 @@ import ( "strings" ) -func IsAuthorizedSender(address, account string, isAdmin bool, domains []string) bool { +func IsAuthorizedSender(address, account string, allowLocalAlias bool, domains []string) bool { address = strings.TrimSpace(address) parsed, err := mail.ParseAddress(address) if err != nil || parsed.Address != address { @@ -33,5 +33,5 @@ func IsAuthorizedSender(address, account string, isAdmin bool, domains []string) return false } - return isAdmin || strings.EqualFold(address[:at], strings.TrimSpace(account)) + return allowLocalAlias || strings.EqualFold(address[:at], strings.TrimSpace(account)) } diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index 99922c0..c451de2 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -148,7 +148,7 @@ export function usePage(pageKey = 'base') { const restartPreview = ref(null) const hasChanges = computed(() => editorText.value !== loadedText.value || !!secretValue.value || !!savedGeneration.value) - const canWrite = computed(() => globalStatus.hasCapability('config.write_global')) + const canWrite = computed(() => globalStatus.hasCapability(apiSection() === 'plugins' ? 'plugin.write_global' : 'config.write_global')) const canRollback = computed(() => globalStatus.hasCapability('config.rollback')) const canMigrate = computed(() => globalStatus.hasCapability('database.migrate')) const canRestart = computed(() => globalStatus.hasCapability('service.restart')) diff --git a/frontend/src/views/components/HomeHeader/HomeHeader.js b/frontend/src/views/components/HomeHeader/HomeHeader.js index df97ff1..87e6523 100644 --- a/frontend/src/views/components/HomeHeader/HomeHeader.js +++ b/frontend/src/views/components/HomeHeader/HomeHeader.js @@ -56,10 +56,9 @@ export function usePage() { { key: 'plugins', label: lang.plugin_settings, hint: '扩展功能管理', icon: Grid, component: PluginSettings, capability: 'plugin.read' }, { key: 'logs', label: '日志', hint: '操作、收发信与运行记录', icon: Document, component: LogSettings, capability: 'audit.read' }, ].filter(item => { - // 全局日志包含其他账号与运行信息,不能把本人审计能力当作全局权限。 - if (item.key === 'logs' && globalStatus.role !== 'super_admin') return false + // 日志数据范围由服务端强制过滤;菜单只决定入口是否可见。 return globalStatus.hasCapability(item.capability) || - (item.key === 'plugins' && globalStatus.hasCapability('config.read')) + (item.key === 'plugins' && globalStatus.hasCapability('plugin.read_global')) })) const activeSettings = computed(() => settingsMenu.value.find(item => item.key === globalStatus.settingsActiveTab)) diff --git a/frontend/src/views/components/LogSettings/LogSettings.js b/frontend/src/views/components/LogSettings/LogSettings.js index bdba8d8..fda900b 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.js +++ b/frontend/src/views/components/LogSettings/LogSettings.js @@ -1,8 +1,11 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { http } from '@/utils/axios' +import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' import { parseRuntimeLine, summarizeRuntime, timeParts } from './RuntimeLog.js' export function useLogs() { + const store = useGlobalStatusStore() + const canFilterActor = computed(() => ['super_admin', 'admin'].includes(store.role)) const category = ref('operation') const loading = ref(false) const error = ref('') @@ -90,7 +93,7 @@ export function useLogs() { return } appliedFilters = { kind: category.value, from: from.toISOString(), to: to.toISOString() } - if (!isRuntime.value && actorId.value.trim()) appliedFilters.actorId = actorId.value.trim() + if (canFilterActor.value && !isRuntime.value && actorId.value.trim()) appliedFilters.actorId = actorId.value.trim() offset.value = 0 previousOffsets.length = 0 detail.value = null @@ -125,6 +128,7 @@ export function useLogs() { return { category, categories, loading, error, items, lines, offset, + canFilterActor, hasMore, actorId, dateRange, detail, details, isRuntime, runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange, currentCategory, resultType, runtimeRowClass, timeParts, diff --git a/frontend/src/views/components/LogSettings/LogSettings.vue b/frontend/src/views/components/LogSettings/LogSettings.vue index 9895c0c..3cc4d9d 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.vue +++ b/frontend/src/views/components/LogSettings/LogSettings.vue @@ -18,7 +18,7 @@ format="YYYY-MM-DD HH:mm" aria-label="日志时间范围" /> - +
最近 24 小时 查询 @@ -171,6 +171,7 @@ import { useLogs } from './LogSettings.js' const { category, categories, loading, error, items, offset, + canFilterActor, hasMore, actorId, dateRange, detail, details, isRuntime, runtimeDetail, runtimeRows, runtimeSummary, pageRange, currentCategory, resultType, runtimeRowClass, timeParts, diff --git a/frontend/src/views/components/PluginSettings/PluginSettings.js b/frontend/src/views/components/PluginSettings/PluginSettings.js index d87b545..43b8b28 100644 --- a/frontend/src/views/components/PluginSettings/PluginSettings.js +++ b/frontend/src/views/components/PluginSettings/PluginSettings.js @@ -4,7 +4,7 @@ import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' export function usePage() { const store = useGlobalStatusStore() - const selectedSection = ref(store.hasCapability('config.read') ? 'spam_block' : 'telegram-user') + const selectedSection = ref(store.hasCapability('plugin.read_global') ? 'spam_block' : 'telegram-user') const currentPage = ref(null) const pageHost = ref(null) const switching = ref(false) @@ -14,7 +14,7 @@ export function usePage() { let disposed = false // 全局配置不依赖正在运行的插件列表,未启用的插件也必须可以配置。 - const sectionOptions = computed(() => store.hasCapability('config.read') ? [ + const sectionOptions = computed(() => store.hasCapability('plugin.read_global') ? [ { value: 'spam_block', label: '垃圾邮件拦截', kind: 'config' }, { value: 'telegram_push', label: 'Telegram 推送', kind: 'config' }, { value: 'wechat_push', label: '微信推送', kind: 'config' } diff --git a/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js b/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js index b315fb5..5c4bb73 100644 --- a/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js +++ b/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js @@ -26,7 +26,7 @@ export function useSpamBlockSettings() { const settings = reactive({ ...defaultSettings }) const hasChanges = computed(() => JSON.stringify(settings) !== savedInput) - const canWrite = () => globalStatus.hasCapability('config.write_global') + const canWrite = () => globalStatus.hasCapability('plugin.write_global') const notifyResponseError = (response, fallback) => { if (!response || response.errorNo !== 0) { diff --git a/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.css b/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.css new file mode 100644 index 0000000..f114096 --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.css @@ -0,0 +1,4 @@ +.profile-settings { + max-width: 480px; + width: 100%; +} diff --git a/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.js b/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.js new file mode 100644 index 0000000..c1110ee --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.js @@ -0,0 +1,28 @@ +import { ref } from 'vue' +import { ElMessage } from 'element-plus' +import { http } from '@/utils/axios' +import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' +import { roleMeta } from '@/utils/userRoles.mjs' + +export function useProfile() { + const store = useGlobalStatusStore() + const name = ref(store.userInfos.name || '') + const saving = ref(false) + async function save() { + if (saving.value) return + if (!name.value.trim()) return ElMessage.error('请输入显示名称') + saving.value = true + try { + const result = await http.post('/api/v1/user/profile', { name: name.value.trim() }) + if (result?.errorNo !== 0) throw new Error(result?.errorMsg || '个人资料保存失败') + store.userInfos.name = result.data.name + name.value = result.data.name + ElMessage.success('个人资料已保存') + } catch (error) { + ElMessage.error(error.message || '个人资料保存失败') + } finally { + saving.value = false + } + } + return { store, name, saving, save, roleMeta } +} diff --git a/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.vue b/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.vue new file mode 100644 index 0000000..cea0912 --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.js b/frontend/src/views/components/SecuritySettings/SecuritySettings.js index 51ff4a6..0827559 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.js +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.js @@ -15,6 +15,7 @@ export function usePage() { const canReadGlobalPolicy = computed(() => globalStatus.hasCapability('config.read')) const sectionOptions = computed(() => { const options = [] + options.push({ value: 'profile', label: '个人资料' }) if (canReadGlobalPolicy.value) { options.push({ value: 'security-policy', label: '安全策略' }) } diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index 9c606fa..cd9c780 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -9,6 +9,7 @@ />
+ import { usePage } from './SecuritySettings.js' +import ProfileSettings from './ProfileSettings/ProfileSettings.vue' import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue' import GlobalSecurityPolicy from './GlobalSecurityPolicy/GlobalSecurityPolicy.vue' import ExternalEmailBindingDialog from './ExternalEmailBindingDialog/ExternalEmailBindingDialog.vue' diff --git a/frontend/src/views/components/UserManagement/UserManagement.js b/frontend/src/views/components/UserManagement/UserManagement.js index 9b7662d..75071a6 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.js +++ b/frontend/src/views/components/UserManagement/UserManagement.js @@ -72,8 +72,10 @@ export function usePage() { const availableRoles = computed(() => assignableRoles(globalStatus.role)) + const canCreateUser = computed(() => globalStatus.hasCapability('user.create')) + const canEditSelectedUser = computed(() => editModel.value === 'create' - ? availableRoles.value.length > 0 + ? canCreateUser.value && availableRoles.value.length > 0 : canManageUser(globalStatus.role, selectedUser.value?.Role)) const canSelectRole = computed(() => canEditSelectedUser.value @@ -122,6 +124,7 @@ export function usePage() { } const createUser = function () { + if (!canCreateUser.value) return selectedUser.value = null editUserInfo.account = "" editUserInfo.name = "" @@ -220,6 +223,7 @@ export function usePage() { editModel, editUserInfo, availableRoles, + canCreateUser, canEditSelectedUser, canSelectRole, roleChanged, diff --git a/frontend/src/views/components/UserManagement/UserManagement.vue b/frontend/src/views/components/UserManagement/UserManagement.vue index 97d6b9f..4ba7397 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.vue +++ b/frontend/src/views/components/UserManagement/UserManagement.vue @@ -60,7 +60,7 @@ @@ -239,6 +239,7 @@ const { editModel, editUserInfo, availableRoles, + canCreateUser, canEditSelectedUser, canSelectRole, roleChanged, diff --git a/test/backend/log_scope_test.go b/test/backend/log_scope_test.go new file mode 100644 index 0000000..7b23c75 --- /dev/null +++ b/test/backend/log_scope_test.go @@ -0,0 +1,50 @@ +package logstore + +import ( + "context" + "fmt" + "testing" + "time" +) + +func TestLogScopeFiltersBeforePagination(t *testing.T) { + s := testStore(t) + if _, err := s.db.Exec(`CREATE TABLE "user" (id INTEGER PRIMARY KEY, role TEXT, is_admin INTEGER)`); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`INSERT INTO "user" VALUES (1,'super_admin',1),(2,'admin',1),(3,'admin',1),(4,'user',0),(5,NULL,0),(6,'legacy',1),(7,'user',1)`); err != nil { + t.Fatal(err) + } + ctx := context.Background() + for _, kind := range []string{"operation", "mail"} { + for id := 0; id <= 8; id++ { + if err := s.Write(ctx, Entry{ID: fmt.Sprintf("%s-%d", kind, id), Kind: kind, ActorID: fmt.Sprint(id), CreatedAt: time.Now().Add(time.Duration(-id) * time.Second), Action: "权限验证", Result: "success", Payload: []byte(`{}`)}); err != nil { + t.Fatal(err) + } + } + for page, actor := range []string{"2", "4", "5", "7"} { + items, err := s.Query(ctx, Query{Kind: kind, ScopeActorID: "2", IncludeOrdinaryUsers: true, Limit: 1, Offset: page}) + if err != nil || len(items) != 1 || items[0].ActorID != actor { + t.Fatalf("管理员分页越界:%s %d %+v %v", kind, page, items, err) + } + } + for _, actor := range []string{"0", "1", "3", "6", "8"} { + items, err := s.Query(ctx, Query{Kind: kind, ScopeActorID: "2", IncludeOrdinaryUsers: true, ActorID: actor}) + if err != nil || len(items) != 0 { + t.Fatalf("伪造筛选扩大管理员范围:%s %v", actor, err) + } + } + items, err := s.Query(ctx, Query{Kind: kind, ScopeActorID: "4"}) + if err != nil || len(items) != 1 || items[0].ActorID != "4" { + t.Fatal("普通用户范围不正确") + } + items, err = s.Query(ctx, Query{Kind: kind, ScopeActorID: "4", ActorID: "2"}) + if err != nil || len(items) != 0 { + t.Fatal("普通用户可以伪造操作者") + } + items, err = s.Query(ctx, Query{Kind: kind}) + if err != nil || len(items) != 9 { + t.Fatal("超级管理员丢失系统或历史日志") + } + } +} diff --git a/test/backend/log_store_integration_test.go b/test/backend/log_store_integration_test.go index 14be987..6db1c1e 100644 --- a/test/backend/log_store_integration_test.go +++ b/test/backend/log_store_integration_test.go @@ -88,7 +88,7 @@ func TestLogStoreServerDatabaseIntegration(t *testing.T) { now := time.Now().UTC() cutoff := logpolicy.Cutoff(now) store.now = func() time.Time { return now.Add(-logpolicy.Retention) } - for _, kind := range []string{"operation", "config", "mail"} { + for _, kind := range []string{"operation", "mail"} { for index := 0; index < 1003; index++ { at := cutoff.Add(-time.Nanosecond) if index >= 1001 { @@ -106,14 +106,14 @@ func TestLogStoreServerDatabaseIntegration(t *testing.T) { } } store.now = func() time.Time { return now } - for _, kind := range []string{"operation", "config", "mail"} { + for _, kind := range []string{"operation", "mail"} { items, err := store.Query(ctx, Query{Kind: kind, ActorID: "42"}) if err != nil || len(items) != 2 || items[0].Action != "兼容验证" { t.Fatalf("query or retention failed: kind=%s count=%d err=%v", kind, len(items), err) } } deleted, err := store.Cleanup(ctx, now) - if err != nil || deleted != 3003 { + if err != nil || deleted != 2002 { t.Fatalf("batch cleanup failed: deleted=%d err=%v", deleted, err) } if deleted, err := store.Cleanup(ctx, now); err != nil || deleted != 0 { diff --git a/test/backend/logs_api_test.go b/test/backend/logs_api_test.go index c5fc4c3..9a7e1b7 100644 --- a/test/backend/logs_api_test.go +++ b/test/backend/logs_api_test.go @@ -62,11 +62,14 @@ func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) { } return result } + for _, kind := range []string{"operation", "mail", "runtime"} { + if got := call(access.RoleUser, "kind="+kind+"&actorId=42"); got.ErrorNo != 0 { + t.Fatalf("普通用户无法查询本人日志:%s => %d", kind, got.ErrorNo) + } + } for _, role := range []access.Role{access.RoleUser, access.RoleAdmin} { - for _, kind := range []string{"operation", "config", "mail", "runtime"} { - if got := call(role, "kind="+kind+"&actorId=42"); got.ErrorNo != response.NoAccessPrivileges { - t.Fatalf("unscoped role %s accessed %s: %d", role, kind, got.ErrorNo) - } + if got := call(role, "kind=runtime&listFiles=true"); got.ErrorNo != response.NoAccessPrivileges { + t.Fatalf("低权限用户能读取全局文件列表:%s", role) } } for _, query := range []string{"kind=config", "kind=unknown", "kind=mail&limit=501", "kind=mail&offset=-1", "kind=mail&from=invalid", "kind=mail&from=2026-09-08T00:00:00Z&to=2026-09-01T00:00:00Z"} { diff --git a/test/backend/plugin_role_scope_test.go b/test/backend/plugin_role_scope_test.go new file mode 100644 index 0000000..50fff39 --- /dev/null +++ b/test/backend/plugin_role_scope_test.go @@ -0,0 +1,107 @@ +package config + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "git.pcux.cn/xuans/Smail/services/access" + appcontext "git.pcux.cn/xuans/Smail/utils/context" + log "github.com/sirupsen/logrus" +) + +func TestPluginRolesValidateUpdateAndPending(t *testing.T) { + root := t.TempDir() + writeRuntimeFixture(t, root, validRuntimeConfig()) + manager, err := NewConfigManagerWithSecurity(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}, ConfigSecurity{Audit: &MemoryAuditWriter{}}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + admin := AuditActor{UserID: "2", Role: "admin", SessionID: "admin-session", RecentAuth: true, RecoveryCodeConfigured: true} + current := manager.Snapshot() + for _, change := range []string{"executable", "permissions", "install", "remove"} { + candidate := cloneRuntimeConfig(current) + plugin := candidate.Plugins.Plugins["telegram_push"] + switch change { + case "executable": + plugin.Executable = "other-program.exe" + case "permissions": + plugin.Permissions.Network[0].Host = "other.example.com" + case "install": + candidate.Plugins.Plugins["other"] = plugin + } + candidate.Plugins.Plugins["telegram_push"] = plugin + if change == "remove" { + delete(candidate.Plugins.Plugins, "telegram_push") + } + payload, _ := json.Marshal(candidate.Plugins) + if _, err := manager.ValidateAuthorized(ctx, ValidateRequest{Section: SectionPlugins, ExpectedGeneration: current.Generation, Payload: payload}, admin); !errors.Is(err, ErrAuthorizationRequired) { + t.Fatalf("校验允许越权 %s:%v", change, err) + } + if _, err := manager.Update(ctx, UpdateRequest{Section: SectionPlugins, ExpectedGeneration: current.Generation, Payload: payload, Actor: admin}); !errors.Is(err, ErrAuthorizationRequired) { + t.Fatalf("保存允许越权 %s:%v", change, err) + } + } + plugins := current.Plugins + plugin := plugins.Plugins["telegram_push"] + plugin.Settings = json.RawMessage(`{"displayName":"修改通知名称"}`) + plugins.Plugins["telegram_push"] = plugin + payload, _ := json.Marshal(plugins) + validation, err := manager.ValidateAuthorized(ctx, ValidateRequest{Section: SectionPlugins, ExpectedGeneration: current.Generation, Payload: payload}, admin) + if err != nil || !validation.Valid { + t.Fatalf("管理员参数校验失败:%+v %v", validation, err) + } + updated, err := manager.Update(ctx, UpdateRequest{Section: SectionPlugins, ExpectedGeneration: current.Generation, Payload: payload, Actor: admin}) + if err != nil { + t.Fatal(err) + } + other := admin + other.UserID, other.SessionID = "3", "other-session" + user := AuditActor{UserID: "4", Role: "user", SessionID: "user-session", RecentAuth: true} + for _, actor := range []AuditActor{other, user} { + if manager.Pending(actor) != nil { + t.Fatal("读取了其他操作者候选") + } + if _, err := manager.ApplyPendingAuthorized(ctx, updated.Generation, updated.CandidateID, SectionPlugins, actor); err == nil { + t.Fatal("应用了其他操作者候选") + } + if err := manager.ManagePending(ctx, updated.Generation, updated.CandidateID, actor, StepUpConfirmation{Confirmed: true, Method: "recent_auth"}, true); err == nil { + t.Fatal("取消了其他操作者候选") + } + } + if view := manager.Pending(admin); view == nil || !view.SameSession { + t.Fatal("本人候选不可见") + } + if _, err := manager.ApplyPendingAuthorized(ctx, updated.Generation, updated.CandidateID, SectionRoot, admin); err == nil { + t.Fatal("候选可跨分区应用") + } + if _, err := manager.ApplyPendingAuthorized(ctx, updated.Generation, updated.CandidateID, SectionPlugins, admin); err != nil { + t.Fatal(err) + } + super := authorizedConfigActor() + mail, _ := json.Marshal(manager.Snapshot().Email) + if _, err := manager.ValidateAuthorized(ctx, ValidateRequest{Section: SectionEmail, Payload: mail}, admin); err == nil { + t.Fatal("管理员可校验系统配置") + } + if _, err := manager.Update(ctx, UpdateRequest{Section: SectionEmail, ExpectedGeneration: manager.Snapshot().Generation, Payload: mail, Actor: super}); err != nil { + t.Fatal(err) + } + if manager.Pending(admin) != nil { + t.Fatal("管理员读到了系统配置候选") + } +} + +func TestRuntimeActorCannotBeSuppliedByLogFields(t *testing.T) { + entry := &log.Entry{Data: log.Fields{"actorId": "1", "actorRole": "super_admin"}} + fields := runtimeEventFields(entry) + if fields["actorId"] != nil || fields["actorRole"] != nil { + t.Fatal("任意日志字段可伪造归属") + } + entry.Context = &appcontext.Context{UserID: 4, Role: access.RoleUser} + fields = runtimeEventFields(entry) + if fields["actorId"] != "4" || fields["actorRole"] != "user" || entry.Data["actorId"] != "1" { + t.Fatal("可信认证归属未生效或修改了调用方字段") + } +} diff --git a/test/backend/role_scope_test.go b/test/backend/role_scope_test.go new file mode 100644 index 0000000..a1a3666 --- /dev/null +++ b/test/backend/role_scope_test.go @@ -0,0 +1,140 @@ +package controllers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/db/migration" + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/services/logstore" + appcontext "git.pcux.cn/xuans/Smail/utils/context" + "xorm.io/xorm" +) + +func TestAccountRoleScopeAndOwnProfile(t *testing.T) { + engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "roles.db")) + if err != nil { + t.Fatal(err) + } + previous := db.Instance + db.Instance = engine + t.Cleanup(func() { db.Instance = previous; _ = engine.Close() }) + if err := engine.Sync2(&models.User{}, &models.RecoveryCode{}, &models.ExternalEmail{}); err != nil { + t.Fatal(err) + } + users := []models.User{ + {Account: "root", Name: "Root", Role: "super_admin", IsAdmin: 1}, + {Account: "manager", Name: "Manager", Role: "admin", IsAdmin: 1}, + {Account: "other-manager", Name: "Other", Role: "admin", IsAdmin: 1}, + {Account: "alice", Name: "Alice", Role: "user"}, + {Account: "legacy", Name: "Legacy", Role: "old", IsAdmin: 0}, + {Account: "legacy-manager", Name: "Legacy admin", Role: "old", IsAdmin: 1}, + } + for i := range users { + if _, err := engine.Insert(&users[i]); err != nil { + t.Fatal(err) + } + } + admin := &appcontext.Context{UserID: users[1].ID, Role: access.RoleAdmin} + ordinary := &appcontext.Context{UserID: users[3].ID, Role: access.RoleUser} + call := func(ctx *appcontext.Context, handler func(*appcontext.Context, http.ResponseWriter, *http.Request), body string) response.Response { + t.Helper() + recorder := httptest.NewRecorder() + handler(ctx, recorder, httptest.NewRequest("POST", "/", strings.NewReader(body))) + var result response.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result + } + for _, ctx := range []*appcontext.Context{admin, ordinary} { + if got := call(ctx, CreateUser, `{"account":"blocked","username":"Blocked","password":"TestPass12","role":"user"}`); got.ErrorNo != response.NoAccessPrivileges { + t.Fatalf("低权限创建账号:%+v", got) + } + } + for page, expected := range []string{"alice", "legacy"} { + body, _ := json.Marshal(map[string]int{"current_page": page + 1, "page_size": 1}) + got := call(admin, UserList, string(body)) + if got.ErrorNo != 0 { + t.Fatalf("用户列表失败:%+v", got) + } + data := got.Data.(map[string]any) + items := data["list"].([]any) + if data["total_page"] != float64(2) || len(items) != 1 || items[0].(map[string]any)["Account"] != expected { + t.Fatalf("分页前权限过滤错误:%+v", data) + } + } + if got := call(ordinary, UserList, `{}`); got.ErrorNo != response.NoAccessPrivileges { + t.Fatal("普通用户可以枚举账号") + } + for _, body := range []string{`{"name":"Bad","id":1}`, `{"name":"Bad","role":"super_admin"}`, `{"name":"Bad","password":"Changed12"}`, `{"name":" "}`, `{"name":"Good"} {}`} { + if got := call(ordinary, UpdateProfile, body); got.ErrorNo == 0 { + t.Fatalf("越权字段未拒绝:%s", body) + } + } + for i := 0; i < 2; i++ { + if got := call(ordinary, UpdateProfile, `{"name":" 新名称 "}`); got.ErrorNo != 0 { + t.Fatalf("本人资料保存失败:%+v", got) + } + } + var updated, untouched models.User + _, _ = engine.ID(users[3].ID).Get(&updated) + _, _ = engine.ID(users[0].ID).Get(&untouched) + if updated.Name != "新名称" || updated.Role != "user" || untouched.Name != "Root" { + t.Fatal("资料更新改变了角色或他人资料") + } + if got := call(&appcontext.Context{UserID: 9999, Role: access.RoleUser}, UpdateProfile, `{"name":"Missing"}`); got.ErrorNo == 0 { + t.Fatal("不存在账号虚报保存成功") + } + for _, ctx := range []*appcontext.Context{admin, ordinary} { + ctx.IsAdmin = true + if got := call(ctx, GetPluginList, `{}`); got.ErrorNo != response.NoAccessPrivileges { + t.Fatal("旧管理员标志绕过插件页面授权") + } + ctx.IsAdmin = false + } + migrations := migration.DefaultMigrations() + for _, statement := range migrations[len(migrations)-1].StatementsFor(migration.DialectSQLite) { + if _, err := engine.Exec(statement); err != nil { + t.Fatal(err) + } + } + store, err := logstore.New(engine.DB().DB, migration.DialectSQLite) + if err != nil { + t.Fatal(err) + } + runtime := logstore.Start(store) + defer runtime.Stop() + for _, user := range users { + if err := store.Write(context.Background(), logstore.Entry{Kind: "operation", ActorID: itoa(user.ID), CreatedAt: time.Now(), Action: "权限验证", Result: "success", Payload: []byte(`{}`)}); err != nil { + t.Fatal(err) + } + } + for _, item := range []struct { + ctx *appcontext.Context + query string + count int + }{ + {admin, "kind=operation", 3}, {ordinary, "kind=operation", 1}, + {admin, "kind=operation&actorId=" + itoa(users[0].ID), 0}, {ordinary, "kind=operation&actorId=" + itoa(admin.UserID), 0}, + } { + recorder := httptest.NewRecorder() + GetLogs(item.ctx, recorder, httptest.NewRequest("GET", "/api/v1/logs?"+item.query, nil)) + var result response.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.ErrorNo != 0 || len(result.Data.(map[string]any)["items"].([]any)) != item.count { + t.Fatalf("日志接口范围失效:%+v", result) + } + } +} diff --git a/test/backend/runtime_scope_test.go b/test/backend/runtime_scope_test.go new file mode 100644 index 0000000..59eeaa3 --- /dev/null +++ b/test/backend/runtime_scope_test.go @@ -0,0 +1,73 @@ +package runtimelog + +import ( + "context" + "fmt" + "strings" + "testing" + "time" +) + +func TestScopedRuntimePaginationAndArchive(t *testing.T) { + w, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer w.Close() + now := time.Now().UTC() + at := now.Add(-9 * 24 * time.Hour).Truncate(time.Second) + for i, actor := range []string{"4", "1", "4", "3", "4"} { + message := fmt.Sprintf(`[error][%s][request-secret][/private/source.go:10]secret raw error @event {"logSchema":1,"actorId":"%s","action":"执行收信规则","result":"failure","error":"private-error"}`, at.Format("2006-01-02 15:04:05"), actor) + if err := w.WriteAt(at.Add(time.Duration(i)*time.Second), message); err != nil { + t.Fatal(err) + } + } + if err := w.WriteAt(at.Add(10*time.Second), "unknown secret legacy record"); err != nil { + t.Fatal(err) + } + check := func() { + t.Helper() + query := Query{From: at.Add(-time.Second), To: now, Limit: 2, AllowedActors: map[string]bool{"4": true}} + page, err := w.Query(context.Background(), query) + if err != nil || len(page.Lines) != 2 || !page.HasMore { + t.Fatalf("权限过滤后分页错误:%+v %v", page, err) + } + for _, line := range page.Lines { + if strings.Contains(line, "secret") || strings.Contains(line, "private") || strings.Contains(line, "actorId") || !strings.Contains(line, "执行收信规则") { + t.Fatalf("日志脱敏丢失动作或泄露信息:%s", line) + } + } + query.Offset = 2 + page, err = w.Query(context.Background(), query) + if err != nil || len(page.Lines) != 1 || page.HasMore { + t.Fatalf("第二页错误:%+v %v", page, err) + } + } + check() + if err := w.Maintain(context.Background(), now); err != nil { + t.Fatal(err) + } + files, err := w.Files() + if err != nil || len(files) != 1 || !strings.HasSuffix(files[0].Name, ".gz") { + t.Fatalf("归档未生效:%+v %v", files, err) + } + check() +} + +func TestRuntimePublicEventsAreExplicitlyRedacted(t *testing.T) { + header := "[2026-09-09T01:00:00Z] [info][2026-09-09 09:00:00]" + for _, test := range []struct { + event string + keep bool + }{ + {`{"logSchema":1,"action":"启动主服务","result":"success","object":"Smail","error":"hidden"}`, true}, + {`{"logSchema":1,"action":"任意全局事件","result":"success","object":"Smail"}`, false}, + {`{"logSchema":1,"actorId":"1","action":"启动主服务","object":"Smail"}`, false}, + {`{"logSchema":2,"action":"启动主服务","object":"Smail"}`, false}, + } { + line, keep := presentScopedLine(header+"hidden @event "+test.event, map[string]bool{"4": true}) + if keep != test.keep || strings.Contains(line, "hidden") { + t.Fatalf("公共事件边界错误:%s %v", line, keep) + } + } +} diff --git a/test/backend/smtp_mail_log_test.go b/test/backend/smtp_mail_log_test.go index 0e54cbb..1fb37fb 100644 --- a/test/backend/smtp_mail_log_test.go +++ b/test/backend/smtp_mail_log_test.go @@ -6,15 +6,60 @@ import ( "encoding/json" "errors" "net" + "strconv" "strings" "testing" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/db/migration" + "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/logstore" appcontext "git.pcux.cn/xuans/Smail/utils/context" ) +func TestInboundMailLogBelongsToEachActualRecipient(t *testing.T) { + engine, _ := newAuditTestEngine(t) + config.Instance.SpamFilterLevel = 0 + users := []models.User{{Account: "alice", Name: "Alice", Role: "user"}, {Account: "bob", Name: "Bob", Role: "user"}} + for i := range users { + if _, err := engine.Insert(&users[i]); err != nil { + t.Fatal(err) + } + } + migrations := migration.DefaultMigrations() + for _, statement := range migrations[len(migrations)-1].StatementsFor(migration.DialectSQLite) { + if _, err := engine.Exec(statement); err != nil { + t.Fatal(err) + } + } + store, err := logstore.New(engine.DB().DB, migration.DialectSQLite) + if err != nil { + t.Fatal(err) + } + runtime := logstore.Start(store) + defer runtime.Stop() + session := &Session{Ctx: &appcontext.Context{Context: context.Background()}, From: "sender@example.net", To: []string{"alice@example.com", "bob@example.com", "alice@example.com"}, RemoteAddress: &net.TCPAddr{IP: net.IPv4(192, 168, 1, 2), Port: 25}} + message := "From: sender@example.net\r\nTo: alice@example.com\r\nSubject: private-subject\r\nMessage-ID: \r\n\r\nprivate-body" + if err := session.Data(strings.NewReader(message)); err != nil { + t.Fatal(err) + } + for _, user := range users { + entries, err := store.Query(context.Background(), logstore.Query{Kind: "mail", ScopeActorID: strconv.Itoa(user.ID)}) + if err != nil || len(entries) != 1 { + t.Fatalf("收件人日志重复或缺失:%d %d %v", user.ID, len(entries), err) + } + var payload struct { + RecipientCount int `json:"recipientCount"` + } + if err := json.Unmarshal(entries[0].Payload, &payload); err != nil { + t.Fatal(err) + } + if payload.RecipientCount != 1 || entries[0].Result != "success" || strings.Contains(string(entries[0].Payload), "private-") || strings.Contains(string(entries[0].Payload), "bob@") { + t.Fatalf("收信范围或结果错误:%+v", entries[0]) + } + } +} + type failedMailReader struct{} func (failedMailReader) Read([]byte) (int, error) { return 0, errors.New("private-reader-error") } diff --git a/test/frontend/logs.test.mjs b/test/frontend/logs.test.mjs index 033b682..631ec2c 100644 --- a/test/frontend/logs.test.mjs +++ b/test/frontend/logs.test.mjs @@ -10,15 +10,29 @@ const root = new URL('../../frontend/src/views/components/LogSettings/', import. const read = name => readFileSync(new URL(name, root), 'utf8') const create = compileFunction( read('LogSettings.js').replace(/^import .*$/gm, '').replace('export function', 'function') + '\nreturn useLogs()', - ['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'watch', 'http', 'formatFileSize', 'parseRuntimeLine', 'summarizeRuntime', 'timeParts'] + ['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'watch', 'http', 'formatFileSize', 'parseRuntimeLine', 'summarizeRuntime', 'timeParts', 'useGlobalStatusStore'] ) -function fixture(get) { +function fixture(get, role = 'super_admin') { let dispose - const page = create(computed, callback => { dispose = callback }, () => {}, ref, () => {}, { get }, formatFileSize, parseRuntimeLine, summarizeRuntime, timeParts) + const page = create(computed, callback => { dispose = callback }, () => {}, ref, () => {}, { get }, formatFileSize, parseRuntimeLine, summarizeRuntime, timeParts, () => ({ role })) return { page, dispose: () => dispose() } } +test('普通用户不发送操作者筛选,管理员保留范围内筛选', async () => { + for (const role of ['user', 'admin']) { + const requests = [] + const { page } = fixture(async (_, { params }) => { + requests.push(params) + return { errorNo: 0, data: { items: [] } } + }, role) + page.actorId.value = '1' + await page.search() + assert.equal(page.canFilterActor.value, role === 'admin') + assert.equal(requests[0].actorId, role === 'admin' ? '1' : undefined) + } +}) + test('日志页面使用共享 HTTP 客户端的命名导出', () => { const transport = readFileSync(new URL('../../frontend/src/utils/axios.js', import.meta.url), 'utf8') assert.match(transport, /export\s*\{\s*http\s*\}/) diff --git a/test/frontend/plugin-navigation.test.mjs b/test/frontend/plugin-navigation.test.mjs index 9bce3a6..920caae 100644 --- a/test/frontend/plugin-navigation.test.mjs +++ b/test/frontend/plugin-navigation.test.mjs @@ -11,7 +11,7 @@ const createPage = compileFunction( ['computed', 'nextTick', 'onMounted', 'onBeforeUnmount', 'ref', 'http', 'useGlobalStatusStore'] ) -function fixture(capabilities = ['config.read', 'plugin.read', 'plugin.write_own'], get = async () => ({ errorNo: 0, data: [] })) { +function fixture(capabilities = ['config.read', 'plugin.read_global', 'plugin.read', 'plugin.write_own'], get = async () => ({ errorNo: 0, data: [] })) { const allowed = reactive(new Set(capabilities)) const requests = [] let mount, dispose @@ -46,8 +46,8 @@ test('普通账号只保留本人通知设置,不读取或进入全局配置 assert.equal(page.activePage.value, undefined) }) -test('只有配置读取权限的账号仍可进入插件配置,但不能读取无权限的插件列表', async () => { - const { page, mount, requests } = fixture(['config.read']) +test('只有插件读取权限的账号仍可进入插件配置,但不能读取无权限的插件列表', async () => { + const { page, mount, requests } = fixture(['plugin.read_global']) await mount() assert.equal(requests.length, 0) assert.equal(page.sectionOptions.value.length, 3) @@ -69,7 +69,7 @@ test('插件切换等待离开确认,取消、并发点击及权限收回不 resolve(true) await second assert.equal(page.selectedSection.value, 'wechat_push') - page.currentPage.value = { beforeLeave: async () => { allowed.delete('config.read'); return true } } + page.currentPage.value = { beforeLeave: async () => { allowed.delete('plugin.read_global'); return true } } await page.selectSection('telegram_push') assert.equal(page.selectedSection.value, 'wechat_push') assert.equal(page.activePage.value, undefined) diff --git a/test/frontend/profile.test.mjs b/test/frontend/profile.test.mjs new file mode 100644 index 0000000..7df6342 --- /dev/null +++ b/test/frontend/profile.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { compileFunction } from 'node:vm' +import { ref } from './vue.mjs' + +const source = readFileSync(new URL('../../frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.js', import.meta.url), 'utf8') +const create = compileFunction(source.replace(/^import .*$/gm, '').replace('export function', 'function') + '\nreturn useProfile()', ['ref', 'ElMessage', 'http', 'useGlobalStatusStore', 'roleMeta']) + +test('本人资料仅提交显示名称,成功更新当前账号,失败不虚报成功', async () => { + const requests = [], messages = [] + const store = { userInfos: { name: '原名称', id: 4, role: 'user' } } + let fail = false + const page = create(ref, { success: text => messages.push(text), error: text => messages.push(text) }, { + post: async (url, body) => { + requests.push({ url, body }) + return fail ? { errorNo: 1, errorMsg: '保存失败' } : { errorNo: 0, data: { name: body.name } } + } + }, () => store, () => ({})) + page.name.value = ' 新名称 ' + await page.save() + assert.deepEqual(requests, [{ url: '/api/v1/user/profile', body: { name: '新名称' } }]) + assert.equal(store.userInfos.name, '新名称') + assert.equal(store.userInfos.role, 'user') + fail = true + page.name.value = '未保存名称' + await page.save() + assert.equal(store.userInfos.name, '新名称') + assert.equal(messages.at(-1), '保存失败') + assert.equal(page.saving.value, false) +}) diff --git a/test/frontend/settings-menu.test.mjs b/test/frontend/settings-menu.test.mjs index c9e2e68..c9a5653 100644 --- a/test/frontend/settings-menu.test.mjs +++ b/test/frontend/settings-menu.test.mjs @@ -54,12 +54,14 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => { assert.equal(page.settingsMenu.value.find(item => item.key === 'third-party').label, '邮件客户端') assert.equal(page.activeSettings.value.label, '安全') capabilities.add('config.read') + capabilities.add('plugin.read_global') store.settingsActiveTab = 'config' assert.equal(page.activeSettings.value.label, '配置管理') assert.equal(page.settingsMenu.value.length, 6) assert.equal(page.settingsMenu.value.find(item => item.key === 'plugins').label, '插件配置') assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint)) capabilities.delete('config.read') + capabilities.delete('plugin.read_global') assert.equal(page.activeSettings.value, undefined) assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), false) capabilities.add('plugin.read') @@ -67,7 +69,7 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => { capabilities.add('audit.read') for (const role of ['user', 'admin']) { store.role = role - assert.equal(page.settingsMenu.value.some(item => item.key === 'logs'), false) + assert.equal(page.settingsMenu.value.some(item => item.key === 'logs'), true) } store.role = 'super_admin' assert.equal(page.settingsMenu.value.at(-1).key, 'logs') diff --git a/test/run.mjs b/test/run.mjs index 1039dda..8e60e85 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -49,6 +49,10 @@ if ((suite === 'all' || suite === 'backend') && result === 0) { const args = extra[0] === '--' ? extra.slice(1) : extra // 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。 const replacements = { + 'backend/config/plugin_role_scope_test.go': 'test/backend/plugin_role_scope_test.go', + 'backend/controllers/role_scope_test.go': 'test/backend/role_scope_test.go', + 'backend/services/logstore/log_scope_test.go': 'test/backend/log_scope_test.go', + 'backend/services/runtimelog/runtime_scope_test.go': 'test/backend/runtime_scope_test.go', 'backend/internal/app/runtime_log_test.go': 'test/backend/app_runtime_log_test.go', 'backend/listen/http_server/operation_log_test.go': 'test/backend/operation_log_test.go', 'backend/controllers/login_audit_test.go': 'test/backend/login_audit_test.go',