diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go
index e5d719be..e6690493 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 27ad49d8..d0c6e248 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 8b6c853c..270a0e95 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 3d64355c..311ab985 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 d87354a7..069dd5d1 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 02f190f4..48c9f656 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 66bc901e..6766ae15 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 324b496e..0d24adef 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 00000000..edc1e414
--- /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 2cc05e8a..025395f1 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 00000000..2821cb76
--- /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 da0a583a..f8a20fef 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 e8a15f56..2018cbf3 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 00000000..8af9e97e
--- /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 64daa5e4..8fa34a37 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 c7bdd2a8..0d4626fe 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 7e8e1f6c..4f485b5f 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 9cc277b3..a3d9ce8c 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 ee7fa15a..9d7c5c97 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 00000000..dd103aff
--- /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 69dc15e3..1cb3d1b5 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 1f5ccfac..e4e846a1 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 61092a77..8631c45a 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 f5c69ee4..0c3520f5 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 8baf5e6f..6d8fe7c2 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 0b5a5587..1d9c2ce8 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 86268f38..b43d4eeb 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 00000000..bda6caea
--- /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 7b6f1ad3..505cc7cb 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 99922c03..c451de22 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 df97ff14..87e6523e 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 bdba8d87..fda900b8 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 9895c0c3..3cc4d9d7 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="日志时间范围"
/>
-