保存角色权限分层改造续审现场
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-09 20:27:50 +08:00
parent 9dc861d560
commit fd475cd88d
54 changed files with 987 additions and 91 deletions

View File

@ -254,6 +254,21 @@ func (m *ConfigManager) Status() ConfigStatus {
} }
func (m *ConfigManager) Validate(ctx context.Context, req ValidateRequest) (*ValidationResult, error) { 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 { if err := contextError(ctx); err != nil {
return nil, err return nil, err
} }
@ -273,6 +288,11 @@ func (m *ConfigManager) Validate(ctx context.Context, req ValidateRequest) (*Val
if err != nil { if err != nil {
return nil, err 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 req.Section == SectionSSL {
if _, err := prepareCustomSSLPaths(m.loader.root, &candidate); err != nil { if _, err := prepareCustomSSLPaths(m.loader.root, &candidate); err != nil {
issue, ok := SSLValidationIssue(err) issue, ok := SSLValidationIssue(err)
@ -317,6 +337,11 @@ func (m *ConfigManager) Update(ctx context.Context, req UpdateRequest) (*UpdateR
return nil, err return nil, err
} }
var sslMaterial *customSSLMaterial var sslMaterial *customSSLMaterial
if req.Section == SectionPlugins {
if err := AuthorizePluginChanges(req.Actor, current, candidate); err != nil {
return nil, err
}
}
if req.Section == SectionSSL { if req.Section == SectionSSL {
sslMaterial, err = prepareCustomSSLPaths(m.loader.root, &candidate) sslMaterial, err = prepareCustomSSLPaths(m.loader.root, &candidate)
if err != nil { 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 { if err := m.authorize(ctx, actor, ConfigOperationUpdate, pending.Section); err != nil {
return nil, err 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 { if generation != candidate.Generation || candidate.Generation != current.Generation+1 {
return nil, ErrGenerationConflict return nil, ErrGenerationConflict

View File

@ -2,6 +2,10 @@ package config
import ( import (
"context" "context"
"strconv"
"git.pcux.cn/xuans/Smail/services/access"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -10,7 +14,7 @@ import (
// 只保留定位操作需要的元数据,不输出配置值、凭据、用户名称或完整变更载荷。 // 只保留定位操作需要的元数据,不输出配置值、凭据、用户名称或完整变更载荷。
type RuntimeConfigLogWriter struct{} type RuntimeConfigLogWriter struct{}
func (RuntimeConfigLogWriter) WriteConfigAudit(_ context.Context, event ConfigAuditEvent) error { func (RuntimeConfigLogWriter) WriteConfigAudit(ctx context.Context, event ConfigAuditEvent) error {
event, err := normalizeAuditEvent(event) event, err := normalizeAuditEvent(event)
if err != nil { if err != nil {
return err return err
@ -20,7 +24,10 @@ func (RuntimeConfigLogWriter) WriteConfigAudit(_ context.Context, event ConfigAu
ConfigOperationRollback: "回滚配置", ConfigOperationRollback: "回滚配置",
ConfigOperationMigration: "迁移配置", ConfigOperationMigration: "迁移配置",
}[event.Operation] }[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, "action": action,
"object": event.Section, "object": event.Section,
"operation": event.Operation, "operation": event.Operation,

View File

@ -24,6 +24,9 @@ func (m *ConfigManager) Pending(actor AuditActor) *PendingView {
if m.pending == nil { if m.pending == nil {
return 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{ return &PendingView{
ID: m.pending.ID, ID: m.pending.ID,
Generation: m.pending.Candidate.Generation, Section: m.pending.Section, Generation: m.pending.Candidate.Generation, Section: m.pending.Section,

View File

@ -1,8 +1,10 @@
package config package config
import ( import (
"strconv"
"strings" "strings"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -14,6 +16,13 @@ func runtimeEventFields(entry *log.Entry) map[string]any {
fields[key] = value fields[key] = value
} }
fields["logSchema"] = 1 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) == "" { if action, ok := fields["action"].(string); !ok || strings.TrimSpace(action) == "" {
action = "运行事件" action = "运行事件"
if entry.Caller != nil && entry.Caller.Function != "" { if entry.Caller != nil && entry.Caller.Function != "" {

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"reflect"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -152,6 +153,9 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C
return ErrAuthorizationRequired return ErrAuthorizationRequired
} }
capability := access.CapabilityConfigWriteGlobal capability := access.CapabilityConfigWriteGlobal
if operation == ConfigOperationUpdate && section == SectionPlugins {
capability = access.CapabilityPluginWriteGlobal
}
if operation == ConfigOperationRollback { if operation == ConfigOperationRollback {
capability = access.CapabilityConfigRollback capability = access.CapabilityConfigRollback
} else if operation == ConfigOperationMigration { } else if operation == ConfigOperationMigration {
@ -178,6 +182,23 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C
return nil 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 { 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") { if operation != ConfigOperationRollback && !strings.EqualFold(confirmation.Method, "webauthn") && !strings.EqualFold(confirmation.Method, "recent_auth") {
return nil return nil

View File

@ -17,7 +17,7 @@ import (
// ConfigStatus 返回配置管理状态,不返回任何密文、路径或 DSN。 // ConfigStatus 返回配置管理状态,不返回任何密文、路径或 DSN。
func ConfigStatus(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { 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) writeConfigAccessError(w, err)
return return
} }
@ -30,6 +30,10 @@ func ConfigStatus(ctx *appcontext.Context, w http.ResponseWriter, req *http.Requ
return return
} }
status := manager.Status() 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{ response.NewSuccessResponse(map[string]any{
"available": true, "available": true,
"setupState": status.SetupState, "setupState": status.SetupState,
@ -52,7 +56,7 @@ func GetConfigSection(ctx *appcontext.Context, w http.ResponseWriter, req *http.
if !ok { if !ok {
return return
} }
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { if err := authorizeConfigSection(ctx, section, false); err != nil {
writeConfigAccessError(w, err) writeConfigAccessError(w, err)
return return
} }
@ -77,7 +81,7 @@ func ValidateConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Re
if !ok { if !ok {
return return
} }
if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil { if err := authorizeConfigSection(ctx, section, true); err != nil {
writeConfigAccessError(w, err) writeConfigAccessError(w, err)
return return
} }
@ -86,10 +90,10 @@ func ValidateConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Re
writeConfigError(w, errors.New("invalid configuration request")) writeConfigError(w, errors.New("invalid configuration request"))
return return
} }
result, err := manager.Validate(req.Context(), config.ValidateRequest{ result, err := manager.ValidateAuthorized(req.Context(), config.ValidateRequest{
Section: section, ExpectedGeneration: input.ExpectedGeneration, Section: section, ExpectedGeneration: input.ExpectedGeneration,
Payload: input.Payload, SecretChanges: input.SecretChanges, Payload: input.Payload, SecretChanges: input.SecretChanges,
}) }, configActor(ctx, req))
if err != nil { if err != nil {
writeConfigError(w, err) writeConfigError(w, err)
return return
@ -103,7 +107,7 @@ func UpdateConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Requ
if !ok { if !ok {
return return
} }
if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil { if err := authorizeConfigSection(ctx, section, true); err != nil {
writeConfigAccessError(w, err) writeConfigAccessError(w, err)
return return
} }
@ -135,7 +139,7 @@ func ApplyConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Reque
if !ok { if !ok {
return return
} }
if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil { if err := authorizeConfigSection(ctx, section, true); err != nil {
writeConfigAccessError(w, err) writeConfigAccessError(w, err)
return return
} }

View File

@ -40,9 +40,9 @@ func TestConfigBackupReadDoesNotRequireRecentAuth(t *testing.T) {
ctx := &appcontext.Context{UserID: 1, Role: role, RecentAuth: false} ctx := &appcontext.Context{UserID: 1, Role: role, RecentAuth: false}
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ListConfigBackups(ctx, recorder, httptest.NewRequest(http.MethodGet, "/api/v1/config/backups", nil)) 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 { if recorder.Code != http.StatusForbidden {
t.Fatalf("普通用户不应读取配置备份:%d", recorder.Code) t.Fatalf("非超级管理员不应读取配置备份:%d", recorder.Code)
} }
return return
} }
@ -53,7 +53,7 @@ func TestConfigBackupReadDoesNotRequireRecentAuth(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if recorder.Code != http.StatusOK || payload.ErrorNo != 0 { if recorder.Code != http.StatusOK || payload.ErrorNo != 0 {
t.Fatalf("管理员读取脱敏备份不应要求近期认证:%d", recorder.Code) t.Fatalf("超级管理员读取脱敏备份不应要求近期认证:%d", recorder.Code)
} }
}) })
} }

View File

@ -6,7 +6,6 @@ import (
"git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/access"
appcontext "git.pcux.cn/xuans/Smail/utils/context" appcontext "git.pcux.cn/xuans/Smail/utils/context"
) )
@ -16,7 +15,7 @@ func GetPendingConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.
if !ok { if !ok {
return return
} }
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { if err := authorizeConfigOverview(ctx, false); err != nil {
writeConfigAccessError(w, err) writeConfigAccessError(w, err)
return return
} }
@ -38,7 +37,7 @@ func ManagePendingConfig(ctx *appcontext.Context, w http.ResponseWriter, req *ht
if !ok { if !ok {
return return
} }
if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil { if err := authorizeConfigOverview(ctx, true); err != nil {
writeConfigAccessError(w, err) writeConfigAccessError(w, err)
return return
} }

View File

@ -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)
}

View File

@ -13,6 +13,7 @@ import (
"git.pcux.cn/xuans/Smail/hooks/framework" "git.pcux.cn/xuans/Smail/hooks/framework"
"git.pcux.cn/xuans/Smail/i18n" "git.pcux.cn/xuans/Smail/i18n"
"git.pcux.cn/xuans/Smail/models" "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/async"
"git.pcux.cn/xuans/Smail/utils/context" "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("发送邮件") 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) response.NewErrorResponse(response.ParamsError, "\u53d1\u4ef6\u4eba\u5730\u5740\u4e0d\u5141\u8bb8", "").FPrint(w)
return 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) response.NewErrorResponse(response.ParamsError, "\u53d1\u9001\u8005\u5730\u5740\u4e0d\u5141\u8bb8", "").FPrint(w)
return return
} }
@ -194,7 +196,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
} }
log.WithContext(ctx).Debugf("插件执行--SendBefore End") 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) response.NewErrorResponse(response.ParamsError, "\u63d2\u4ef6\u4fee\u6539\u4e86\u672a\u6388\u6743\u7684\u53d1\u4ef6\u4eba", "").FPrint(w)
return return
} }

View File

@ -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
}

View File

@ -28,7 +28,7 @@ func logQueryError(w http.ResponseWriter, err error) {
// GetLogs 只读日志入口,授权在服务端检查,不依赖导航隐藏。 // GetLogs 只读日志入口,授权在服务端检查,不依赖导航隐藏。
func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
w.Header().Set("Cache-Control", "no-store") 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) writeConfigAccessError(w, err)
return 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) bounded, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel() defer cancel()
query := logstore.Query{Kind: values.Get("kind"), ActorID: values.Get("actorId"), Limit: limit + 1, Offset: offset} 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 { for _, item := range []struct {
name string name string
target *time.Time target *time.Time
@ -78,6 +82,10 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request)
} }
// 显式保留旧文件列表入口,新页面默认跨文件查询。 // 显式保留旧文件列表入口,新页面默认跨文件查询。
if values.Get("listFiles") == "true" { if values.Get("listFiles") == "true" {
if ctx.EffectiveRole() != access.RoleSuperAdmin {
writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied})
return
}
files, err := writer.Files() files, err := writer.Files()
if err != nil { if err != nil {
logQueryError(w, err) 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) response.NewSuccessResponse(map[string]any{"files": files}).FPrint(w)
return 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 { if err != nil {
logQueryError(w, err) logQueryError(w, err)
return return

View File

@ -3,6 +3,7 @@ package controllers
import ( import (
"git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/hooks" "git.pcux.cn/xuans/Smail/hooks"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"io" "io"
@ -18,7 +19,7 @@ const (
) )
func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 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) response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w)
return 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) { 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) response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w)
return return
} }

View File

@ -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)
}

View File

@ -60,7 +60,7 @@ func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request)
if reqData.Role != "" { if reqData.Role != "" {
targetRole = access.Role(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) response.NewErrorResponse(response.NoAccessPrivileges, "无权访问", "").FPrint(w)
return return
} }
@ -170,7 +170,12 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
} }
var users []models.User 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 { if err != nil {
log.WithContext(ctx).Errorf("SQL Error: %+v", err) log.WithContext(ctx).Errorf("SQL Error: %+v", err)
response.NewErrorResponse(response.ServerError, "查询失败", "").FPrint(w) response.NewErrorResponse(response.ServerError, "查询失败", "").FPrint(w)

View File

@ -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/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/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/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/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/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) registerV1MethodRoute(mux, "/api/v1/user/security/recovery-codes", "/api/user/security/recovery-codes", http.MethodGet, 0, controllers.GetManagedRecoveryCodeStatus)

View File

@ -97,6 +97,7 @@ func router(mux *http.ServeMux) error {
mux.HandleFunc("/attachments/download/", onlyMethod(http.MethodGet, contextIterceptor(controllers.Download))) 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/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/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/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/list", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UserList))))
mux.HandleFunc("/api/user/security/recovery-codes", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetManagedRecoveryCodeStatus))) mux.HandleFunc("/api/user/security/recovery-codes", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetManagedRecoveryCodeStatus)))

View File

@ -15,7 +15,8 @@ import (
// 仅记录明确的变更接口,不将 POST 列表查询误认为操作,也不复制请求或响应载荷。 // 仅记录明确的变更接口,不将 POST 列表查询误认为操作,也不复制请求或响应载荷。
var loggedOperations = map[string]string{ var loggedOperations = map[string]string{
"/api/login": "处理账号登录", "/api/logout": "退出登录", "/api/user/profile": "修改个人资料",
"/api/login": "处理账号登录", "/api/logout": "退出登录",
"/api/security/login/verify": "完成登录二次验证", "/api/security/login/verify": "完成登录二次验证",
"/api/security/webauthn/login/finish": "完成通行密钥登录验证", "/api/security/webauthn/login/finish": "完成通行密钥登录验证",
"/api/user/create": "创建用户", "/api/user/edit": "编辑用户", "/api/user/create": "创建用户", "/api/user/edit": "编辑用户",

View File

@ -20,6 +20,7 @@ import (
"git.pcux.cn/xuans/Smail/hooks/framework" "git.pcux.cn/xuans/Smail/hooks/framework"
"git.pcux.cn/xuans/Smail/listen/imap_server" "git.pcux.cn/xuans/Smail/listen/imap_server"
"git.pcux.cn/xuans/Smail/models" "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/logstore"
"git.pcux.cn/xuans/Smail/services/rule" "git.pcux.cn/xuans/Smail/services/rule"
"git.pcux.cn/xuans/Smail/utils/array" "git.pcux.cn/xuans/Smail/utils/array"
@ -41,6 +42,7 @@ const DropUnknownRecipientEmails = true
func (s *Session) Data(r io.Reader) (resultErr error) { func (s *Session) Data(r io.Reader) (resultErr error) {
messageBytes := 0 messageBytes := 0
var recipientIDs []int
result := "unknown" result := "unknown"
details := logstore.MailDetails{Trigger: "SMTP 收信", Stage: "读取邮件内容"} details := logstore.MailDetails{Trigger: "SMTP 收信", Stage: "读取邮件内容"}
defer func() { defer func() {
@ -59,7 +61,18 @@ func (s *Session) Data(r io.Reader) (resultErr error) {
details.Outcome += fmt.Sprintf(",SMTP 状态码 %d", protocol.Code) 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 ctx := s.Ctx
@ -188,6 +201,9 @@ func (s *Session) Data(r io.Reader) (resultErr error) {
result = "discarded" result = "discarded"
return nil return nil
} }
for _, user := range users {
recipientIDs = append(recipientIDs, user.ID)
}
details.Stage = "执行收信规则与通知" details.Stage = "执行收信规则与通知"
if email.MessageId > 0 { if email.MessageId > 0 {
@ -200,20 +216,21 @@ func (s *Session) Data(r io.Reader) (resultErr error) {
ruleContext.Context = ruleExecutionContext ruleContext.Context = ruleExecutionContext
log.WithContext(ctx).Debugf("开始执行邮件规则!") log.WithContext(ctx).Debugf("开始执行邮件规则!")
for _, user := range users { for _, user := range users {
ruleContext.UserID, ruleContext.Role = user.ID, user.EffectiveRole()
if ruleExecutionContext.Err() != nil { 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 break
} }
// 执行邮件规则 // 执行邮件规则
rs, ruleErr := rule.GetAllRules(&ruleContext, user.ID) rs, ruleErr := rule.GetAllRules(&ruleContext, user.ID)
if ruleErr != nil { 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 continue
} }
for _, r := range rs { for _, r := range rs {
if rule.MatchRule(&ruleContext, r, email) { if rule.MatchRule(&ruleContext, r, email) {
if err := rule.DoRule(&ruleContext, r, email, user, emailData); err != nil { 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命令通知 // IDLE命令通知
for _, user := range users { for _, user := range users {
if err := imap_server.IdleNotice(ctx, user.ID, dbEmail); err != nil { noticeContext := *ctx
log.WithContext(ctx).WithFields(log.Fields{"action": "通知 IMAP 客户端新邮件", "result": "failure"}).WithError(err).Error("邮件已保存,但新邮件通知失败") noticeContext.UserID, noticeContext.Role = user.ID, user.EffectiveRole()
if err := imap_server.IdleNotice(&noticeContext, user.ID, dbEmail); err != nil {
log.WithContext(&noticeContext).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 { if email == nil || email.From == nil || email.Sender == nil || ctx == nil || len(domains.Accepted) == 0 {
return false 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 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 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) { func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserID int, emailType int, reallyTo []string, SPFStatus, dkimStatus bool) ([]*models.User, *models.Email, error) {

View File

@ -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))"

View File

@ -51,7 +51,7 @@ func TestRoleBoundaries(t *testing.T) {
} }
func TestRecoveryAndRecentAuthentication(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) { if err := Authorize(admin); !IsDenied(err, CodeRecoveryCodeRequired) {
t.Fatalf("expected recovery code requirement, got %v", err) t.Fatalf("expected recovery code requirement, got %v", err)
} }
@ -83,18 +83,18 @@ func TestRoleChangeRequiresTargetRecovery(t *testing.T) {
func TestAdminPluginRestartScope(t *testing.T) { func TestAdminPluginRestartScope(t *testing.T) {
req := Request{ActorRole: RoleAdmin, Capability: CapabilityPluginRestart, RecentAuth: true, RecoveryCodeConfigured: true} 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) t.Fatalf("expected plugin scope denial, got %v", err)
} }
req.PluginInstanceAuthorized = true req.PluginInstanceAuthorized = true
if err := Authorize(req); err != nil { if err := Authorize(req); !IsDenied(err, CodeAccessDenied) {
t.Fatalf("expected plugin restart to be allowed, got %v", err) t.Fatalf("expected plugin restart to remain denied, got %v", err)
} }
} }
func TestManagementEndpointCatalog(t *testing.T) { func TestManagementEndpointCatalog(t *testing.T) {
endpoints := ManagementEndpoints() endpoints := ManagementEndpoints()
if len(endpoints) != 35 { if len(endpoints) != 37 {
t.Fatalf("unexpected endpoint count: %d", len(endpoints)) t.Fatalf("unexpected endpoint count: %d", len(endpoints))
} }
seen := map[string]struct{}{} seen := map[string]struct{}{}

View File

@ -63,7 +63,7 @@ func Authorize(request Request) error {
func authorizeResource(request Request) error { func authorizeResource(request Request) error {
switch request.Capability { switch request.Capability {
case CapabilityPasswordChangeOwn, CapabilityPluginWriteOwn: case CapabilityPasswordChangeOwn, CapabilityPluginWriteOwn, CapabilityProfileWriteOwn:
if !request.OwnResource { if !request.OwnResource {
return deny(CodeAccessDenied) return deny(CodeAccessDenied)
} }
@ -78,7 +78,7 @@ func authorizeResource(request Request) error {
if request.ActorRole == RoleAdmin && !request.OwnResource && !request.AuditScopeAuthorized { if request.ActorRole == RoleAdmin && !request.OwnResource && !request.AuditScopeAuthorized {
return deny(CodeAccessDenied) return deny(CodeAccessDenied)
} }
case CapabilityUserManage, CapabilityUserSecurityManage, CapabilityPasswordReset: case CapabilityUserCreate, CapabilityUserManage, CapabilityUserSecurityManage, CapabilityPasswordReset:
if !canManageAccountTarget(request.ActorRole, request.TargetRole) { if !canManageAccountTarget(request.ActorRole, request.TargetRole) {
return deny(CodeTargetRoleDenied) return deny(CodeTargetRoleDenied)
} }

View File

@ -8,6 +8,7 @@ const (
CapabilityConfigRollback Capability = "config.rollback" CapabilityConfigRollback Capability = "config.rollback"
CapabilityDatabaseMigrate Capability = "database.migrate" CapabilityDatabaseMigrate Capability = "database.migrate"
CapabilityPluginRead Capability = "plugin.read" CapabilityPluginRead Capability = "plugin.read"
CapabilityPluginReadGlobal Capability = "plugin.read_global"
CapabilityPluginControl Capability = "plugin.control" CapabilityPluginControl Capability = "plugin.control"
CapabilityPluginRestart Capability = "plugin.restart" CapabilityPluginRestart Capability = "plugin.restart"
CapabilityServiceRestart Capability = "service.restart" CapabilityServiceRestart Capability = "service.restart"
@ -15,6 +16,8 @@ const (
CapabilityPluginWriteAnyUser Capability = "plugin.write_any_user" CapabilityPluginWriteAnyUser Capability = "plugin.write_any_user"
CapabilityPluginWriteOwn Capability = "plugin.write_own" CapabilityPluginWriteOwn Capability = "plugin.write_own"
CapabilityUserRead Capability = "user.read" CapabilityUserRead Capability = "user.read"
CapabilityUserCreate Capability = "user.create"
CapabilityProfileWriteOwn Capability = "profile.write_own"
CapabilityUserManage Capability = "user.manage" CapabilityUserManage Capability = "user.manage"
CapabilityUserSecurityManage Capability = "user.security_manage" CapabilityUserSecurityManage Capability = "user.security_manage"
CapabilityRoleManage Capability = "role.manage" CapabilityRoleManage Capability = "role.manage"
@ -38,6 +41,7 @@ var policies = map[Capability]Policy{
CapabilityConfigRollback: {Capability: CapabilityConfigRollback, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, CapabilityConfigRollback: {Capability: CapabilityConfigRollback, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true},
CapabilityDatabaseMigrate: {Capability: CapabilityDatabaseMigrate, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, CapabilityDatabaseMigrate: {Capability: CapabilityDatabaseMigrate, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true},
CapabilityPluginRead: {Capability: CapabilityPluginRead}, CapabilityPluginRead: {Capability: CapabilityPluginRead},
CapabilityPluginReadGlobal: {Capability: CapabilityPluginReadGlobal},
CapabilityPluginControl: {Capability: CapabilityPluginControl, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, CapabilityPluginControl: {Capability: CapabilityPluginControl, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true},
CapabilityPluginRestart: {Capability: CapabilityPluginRestart, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, CapabilityPluginRestart: {Capability: CapabilityPluginRestart, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true},
CapabilityServiceRestart: {Capability: CapabilityServiceRestart, 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}, CapabilityPluginWriteAnyUser: {Capability: CapabilityPluginWriteAnyUser, RequiresTarget: true},
CapabilityPluginWriteOwn: {Capability: CapabilityPluginWriteOwn}, CapabilityPluginWriteOwn: {Capability: CapabilityPluginWriteOwn},
CapabilityUserRead: {Capability: CapabilityUserRead}, CapabilityUserRead: {Capability: CapabilityUserRead},
CapabilityUserCreate: {Capability: CapabilityUserCreate, RequiresTarget: true},
CapabilityProfileWriteOwn: {Capability: CapabilityProfileWriteOwn},
CapabilityUserManage: {Capability: CapabilityUserManage, RequiresTarget: true}, CapabilityUserManage: {Capability: CapabilityUserManage, RequiresTarget: true},
CapabilityUserSecurityManage: {Capability: CapabilityUserSecurityManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true}, CapabilityUserSecurityManage: {Capability: CapabilityUserSecurityManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true},
CapabilityRoleManage: {Capability: CapabilityRoleManage, 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{}{ var grants = map[Role]map[Capability]struct{}{
RoleSuperAdmin: grant( RoleSuperAdmin: grant(
CapabilityPluginReadGlobal, CapabilityUserCreate, CapabilityProfileWriteOwn,
CapabilityConfigRead, CapabilityConfigWriteGlobal, CapabilityConfigRollback, CapabilityConfigRead, CapabilityConfigWriteGlobal, CapabilityConfigRollback,
CapabilityDatabaseMigrate, CapabilityPluginRead, CapabilityPluginControl, CapabilityDatabaseMigrate, CapabilityPluginRead, CapabilityPluginControl,
CapabilityPluginRestart, CapabilityServiceRestart, CapabilityPluginWriteGlobal, CapabilityPluginWriteAnyUser, CapabilityPluginRestart, CapabilityServiceRestart, CapabilityPluginWriteGlobal, CapabilityPluginWriteAnyUser,
@ -64,12 +71,13 @@ var grants = map[Role]map[Capability]struct{}{
CapabilityAuditRead, CapabilityAuditExport, CapabilityAuditRead, CapabilityAuditExport,
), ),
RoleAdmin: grant( RoleAdmin: grant(
CapabilityConfigRead, CapabilityPluginRead, CapabilityPluginRestart, CapabilityPluginReadGlobal, CapabilityPluginRead, CapabilityPluginWriteGlobal, CapabilityProfileWriteOwn,
CapabilityPluginWriteAnyUser, CapabilityPluginWriteOwn, CapabilityUserRead, CapabilityPluginWriteAnyUser, CapabilityPluginWriteOwn, CapabilityUserRead,
CapabilityUserManage, CapabilityUserSecurityManage, CapabilityPasswordChangeOwn, CapabilityPasswordReset, CapabilityUserManage, CapabilityUserSecurityManage, CapabilityPasswordChangeOwn, CapabilityPasswordReset,
CapabilityAuditRead, CapabilityAuditRead,
), ),
RoleUser: grant( RoleUser: grant(
CapabilityProfileWriteOwn,
CapabilityPluginRead, CapabilityPluginWriteOwn, CapabilityUserRead, CapabilityPluginRead, CapabilityPluginWriteOwn, CapabilityUserRead,
CapabilityPasswordChangeOwn, CapabilityAuditRead, CapabilityPasswordChangeOwn, CapabilityAuditRead,
), ),

View File

@ -10,7 +10,8 @@ type Endpoint struct {
var managementEndpoints = []Endpoint{ var managementEndpoints = []Endpoint{
{Method: "POST", Path: "/api/settings/modify_password", Capability: CapabilityPasswordChangeOwn}, {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: "POST", Path: "/api/user/edit", Capability: CapabilityUserManage},
{Method: "*", Path: "/api/user/security/", Capability: CapabilityUserSecurityManage}, {Method: "*", Path: "/api/user/security/", Capability: CapabilityUserSecurityManage},
{Method: "POST", Path: "/api/user/info", Capability: CapabilityUserRead}, {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/preview", Capability: CapabilityDatabaseMigrate},
{Method: "POST", Path: "/api/v1/database/migrations", Capability: CapabilityDatabaseMigrate}, {Method: "POST", Path: "/api/v1/database/migrations", Capability: CapabilityDatabaseMigrate},
{Method: "GET", Path: "/api/v1/database/migrations/:id", 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: "POST", Path: "/api/v1/user/edit", Capability: CapabilityUserManage},
{Method: "*", Path: "/api/v1/user/security/", Capability: CapabilityUserSecurityManage}, {Method: "*", Path: "/api/v1/user/security/", Capability: CapabilityUserSecurityManage},
{Method: "POST", Path: "/api/v1/user/info", Capability: CapabilityUserRead}, {Method: "POST", Path: "/api/v1/user/info", Capability: CapabilityUserRead},

View File

@ -9,13 +9,16 @@ func TestCapabilityMatrix(t *testing.T) {
allowed bool allowed bool
}{ }{
{name: "super admin reads config", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityConfigRead}, allowed: true}, {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: "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", 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: "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: "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: "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 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: "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}, {name: "super admin writes all user plugin settings", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleSuperAdmin}, allowed: true},

View File

@ -13,6 +13,7 @@ import (
"time" "time"
"git.pcux.cn/xuans/Smail/db/migration" "git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/logpolicy" "git.pcux.cn/xuans/Smail/utils/logpolicy"
) )
@ -31,15 +32,18 @@ type Entry struct {
} }
type Query struct { type Query struct {
ID string // ScopeActorID 不接受客户端直接赋值;非空时由服务端强制限制可见账号。
Kind string ScopeActorID string
ActorID string IncludeOrdinaryUsers bool
Action string ID string
Section string Kind string
From time.Time ActorID string
To time.Time Action string
Limit int Section string
Offset int From time.Time
To time.Time
Limit int
Offset int
} }
type Store struct { 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 = ?" 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} 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() { if !query.To.IsZero() {
statement += " AND created_at <= ?" statement += " AND created_at <= ?"
args = append(args, query.To.UnixNano()) args = append(args, query.To.UnixNano())

View File

@ -11,11 +11,14 @@ import (
) )
type Query struct { type Query struct {
File string // nil 表示完整管理视图;非 nil 时先按可信归属过滤并脱敏,再进行分页。
From time.Time // 映射值为 true 表示本人;false 表示代管普通用户,事件发生时也必须是普通用户。
To time.Time AllowedActors map[string]bool
Offset int File string
Limit int From time.Time
To time.Time
Offset int
Limit int
} }
type lineRef struct { 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) { if at.Before(query.From) || at.After(query.To) {
return nil return nil
} }
visible, keep := presentLine(string(line)) visible, keep := presentScopedLine(string(line), query.AllowedActors)
if !keep { if !keep {
return nil return nil
} }
@ -161,10 +164,11 @@ func (w *Writer) Query(ctx context.Context, query Query) (Page, error) {
if !ok { if !ok {
return nil 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 return ErrInvalid
} }
page.Lines[index] = string(line) page.Lines[index] = visible
remaining-- remaining--
if remaining == 0 { if remaining == 0 {
return finished return finished

View File

@ -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
}

View File

@ -5,7 +5,7 @@ import (
"strings" "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) address = strings.TrimSpace(address)
parsed, err := mail.ParseAddress(address) parsed, err := mail.ParseAddress(address)
if err != nil || parsed.Address != address { if err != nil || parsed.Address != address {
@ -33,5 +33,5 @@ func IsAuthorizedSender(address, account string, isAdmin bool, domains []string)
return false return false
} }
return isAdmin || strings.EqualFold(address[:at], strings.TrimSpace(account)) return allowLocalAlias || strings.EqualFold(address[:at], strings.TrimSpace(account))
} }

View File

@ -148,7 +148,7 @@ export function usePage(pageKey = 'base') {
const restartPreview = ref(null) const restartPreview = ref(null)
const hasChanges = computed(() => editorText.value !== loadedText.value || !!secretValue.value || !!savedGeneration.value) 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 canRollback = computed(() => globalStatus.hasCapability('config.rollback'))
const canMigrate = computed(() => globalStatus.hasCapability('database.migrate')) const canMigrate = computed(() => globalStatus.hasCapability('database.migrate'))
const canRestart = computed(() => globalStatus.hasCapability('service.restart')) const canRestart = computed(() => globalStatus.hasCapability('service.restart'))

View File

@ -56,10 +56,9 @@ export function usePage() {
{ key: 'plugins', label: lang.plugin_settings, hint: '扩展功能管理', icon: Grid, component: PluginSettings, capability: 'plugin.read' }, { 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' }, { key: 'logs', label: '日志', hint: '操作、收发信与运行记录', icon: Document, component: LogSettings, capability: 'audit.read' },
].filter(item => { ].filter(item => {
// 全局日志包含其他账号与运行信息,不能把本人审计能力当作全局权限。 // 日志数据范围由服务端强制过滤;菜单只决定入口是否可见。
if (item.key === 'logs' && globalStatus.role !== 'super_admin') return false
return globalStatus.hasCapability(item.capability) || 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)) const activeSettings = computed(() => settingsMenu.value.find(item => item.key === globalStatus.settingsActiveTab))

View File

@ -1,8 +1,11 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { http } from '@/utils/axios' import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import { parseRuntimeLine, summarizeRuntime, timeParts } from './RuntimeLog.js' import { parseRuntimeLine, summarizeRuntime, timeParts } from './RuntimeLog.js'
export function useLogs() { export function useLogs() {
const store = useGlobalStatusStore()
const canFilterActor = computed(() => ['super_admin', 'admin'].includes(store.role))
const category = ref('operation') const category = ref('operation')
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
@ -90,7 +93,7 @@ export function useLogs() {
return return
} }
appliedFilters = { kind: category.value, from: from.toISOString(), to: to.toISOString() } 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 offset.value = 0
previousOffsets.length = 0 previousOffsets.length = 0
detail.value = null detail.value = null
@ -125,6 +128,7 @@ export function useLogs() {
return { return {
category, categories, loading, error, items, lines, offset, category, categories, loading, error, items, lines, offset,
canFilterActor,
hasMore, actorId, dateRange, detail, details, isRuntime, hasMore, actorId, dateRange, detail, details, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange, runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts, currentCategory, resultType, runtimeRowClass, timeParts,

View File

@ -18,7 +18,7 @@
format="YYYY-MM-DD HH:mm" format="YYYY-MM-DD HH:mm"
aria-label="日志时间范围" aria-label="日志时间范围"
/> />
<el-input v-if="!isRuntime" v-model="actorId" clearable placeholder="操作者编号" aria-label="操作者编号" @keyup.enter="search" /> <el-input v-if="!isRuntime && canFilterActor" v-model="actorId" clearable placeholder="操作者编号" aria-label="操作者编号" @keyup.enter="search" />
<div class="log-filter-actions"> <div class="log-filter-actions">
<el-button :disabled="loading" @click="recent">最近 24 小时</el-button> <el-button :disabled="loading" @click="recent">最近 24 小时</el-button>
<el-button type="primary" :loading="loading" @click="search">查询</el-button> <el-button type="primary" :loading="loading" @click="search">查询</el-button>
@ -171,6 +171,7 @@ import { useLogs } from './LogSettings.js'
const { const {
category, categories, loading, error, items, offset, category, categories, loading, error, items, offset,
canFilterActor,
hasMore, actorId, dateRange, detail, details, isRuntime, hasMore, actorId, dateRange, detail, details, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, pageRange, runtimeDetail, runtimeRows, runtimeSummary, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts, currentCategory, resultType, runtimeRowClass, timeParts,

View File

@ -4,7 +4,7 @@ import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
export function usePage() { export function usePage() {
const store = useGlobalStatusStore() 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 currentPage = ref(null)
const pageHost = ref(null) const pageHost = ref(null)
const switching = ref(false) const switching = ref(false)
@ -14,7 +14,7 @@ export function usePage() {
let disposed = false 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: 'spam_block', label: '垃圾邮件拦截', kind: 'config' },
{ value: 'telegram_push', label: 'Telegram 推送', kind: 'config' }, { value: 'telegram_push', label: 'Telegram 推送', kind: 'config' },
{ value: 'wechat_push', label: '微信推送', kind: 'config' } { value: 'wechat_push', label: '微信推送', kind: 'config' }

View File

@ -26,7 +26,7 @@ export function useSpamBlockSettings() {
const settings = reactive({ ...defaultSettings }) const settings = reactive({ ...defaultSettings })
const hasChanges = computed(() => JSON.stringify(settings) !== savedInput) 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) => { const notifyResponseError = (response, fallback) => {
if (!response || response.errorNo !== 0) { if (!response || response.errorNo !== 0) {

View File

@ -0,0 +1,4 @@
.profile-settings {
max-width: 480px;
width: 100%;
}

View File

@ -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 }
}

View File

@ -0,0 +1,28 @@
<template>
<section class="security-section profile-settings">
<div class="settings-header">
<h3>个人资料</h3>
</div>
<el-form label-position="top" @submit.prevent="save">
<el-form-item label="当前账号">
<el-input :model-value="store.userInfos.account" disabled />
</el-form-item>
<el-form-item label="权限级别">
<el-tag :type="roleMeta(store.role).type">{{ roleMeta(store.role).label }}</el-tag>
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="name" maxlength="10" placeholder="请输入显示名称" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="saving">
保存资料
</el-button>
</el-form>
</section>
</template>
<script setup>
import { useProfile } from './ProfileSettings.js'
const { store, name, saving, save, roleMeta } = useProfile()
</script>
<style scoped src="./ProfileSettings.css"></style>

View File

@ -15,6 +15,7 @@ export function usePage() {
const canReadGlobalPolicy = computed(() => globalStatus.hasCapability('config.read')) const canReadGlobalPolicy = computed(() => globalStatus.hasCapability('config.read'))
const sectionOptions = computed(() => { const sectionOptions = computed(() => {
const options = [] const options = []
options.push({ value: 'profile', label: '个人资料' })
if (canReadGlobalPolicy.value) { if (canReadGlobalPolicy.value) {
options.push({ value: 'security-policy', label: '安全策略' }) options.push({ value: 'security-policy', label: '安全策略' })
} }

View File

@ -9,6 +9,7 @@
/> />
<div class="security-main"> <div class="security-main">
<ProfileSettings v-if="selectedSection === 'profile'" />
<GlobalSecurityPolicy <GlobalSecurityPolicy
v-if="selectedSection === 'security-policy'" v-if="selectedSection === 'security-policy'"
ref="globalSecurityPolicyPage" ref="globalSecurityPolicyPage"
@ -102,6 +103,7 @@
<script setup> <script setup>
import { usePage } from './SecuritySettings.js' import { usePage } from './SecuritySettings.js'
import ProfileSettings from './ProfileSettings/ProfileSettings.vue'
import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue' import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue'
import GlobalSecurityPolicy from './GlobalSecurityPolicy/GlobalSecurityPolicy.vue' import GlobalSecurityPolicy from './GlobalSecurityPolicy/GlobalSecurityPolicy.vue'
import ExternalEmailBindingDialog from './ExternalEmailBindingDialog/ExternalEmailBindingDialog.vue' import ExternalEmailBindingDialog from './ExternalEmailBindingDialog/ExternalEmailBindingDialog.vue'

View File

@ -72,8 +72,10 @@ export function usePage() {
const availableRoles = computed(() => assignableRoles(globalStatus.role)) const availableRoles = computed(() => assignableRoles(globalStatus.role))
const canCreateUser = computed(() => globalStatus.hasCapability('user.create'))
const canEditSelectedUser = computed(() => editModel.value === 'create' const canEditSelectedUser = computed(() => editModel.value === 'create'
? availableRoles.value.length > 0 ? canCreateUser.value && availableRoles.value.length > 0
: canManageUser(globalStatus.role, selectedUser.value?.Role)) : canManageUser(globalStatus.role, selectedUser.value?.Role))
const canSelectRole = computed(() => canEditSelectedUser.value const canSelectRole = computed(() => canEditSelectedUser.value
@ -122,6 +124,7 @@ export function usePage() {
} }
const createUser = function () { const createUser = function () {
if (!canCreateUser.value) return
selectedUser.value = null selectedUser.value = null
editUserInfo.account = "" editUserInfo.account = ""
editUserInfo.name = "" editUserInfo.name = ""
@ -220,6 +223,7 @@ export function usePage() {
editModel, editModel,
editUserInfo, editUserInfo,
availableRoles, availableRoles,
canCreateUser,
canEditSelectedUser, canEditSelectedUser,
canSelectRole, canSelectRole,
roleChanged, roleChanged,

View File

@ -60,7 +60,7 @@
</el-table-column> </el-table-column>
<el-table-column align="right" width="68"> <el-table-column align="right" width="68">
<template #header> <template #header>
<el-button type="primary" size="small" @click="createUser" class="new-btn" plain> <el-button v-if="canCreateUser" type="primary" size="small" @click="createUser" class="new-btn" plain>
<el-icon><Plus /></el-icon> 新建 <el-icon><Plus /></el-icon> 新建
</el-button> </el-button>
</template> </template>
@ -239,6 +239,7 @@ const {
editModel, editModel,
editUserInfo, editUserInfo,
availableRoles, availableRoles,
canCreateUser,
canEditSelectedUser, canEditSelectedUser,
canSelectRole, canSelectRole,
roleChanged, roleChanged,

View File

@ -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("超级管理员丢失系统或历史日志")
}
}
}

View File

@ -88,7 +88,7 @@ func TestLogStoreServerDatabaseIntegration(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
cutoff := logpolicy.Cutoff(now) cutoff := logpolicy.Cutoff(now)
store.now = func() time.Time { return now.Add(-logpolicy.Retention) } 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++ { for index := 0; index < 1003; index++ {
at := cutoff.Add(-time.Nanosecond) at := cutoff.Add(-time.Nanosecond)
if index >= 1001 { if index >= 1001 {
@ -106,14 +106,14 @@ func TestLogStoreServerDatabaseIntegration(t *testing.T) {
} }
} }
store.now = func() time.Time { return now } 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"}) items, err := store.Query(ctx, Query{Kind: kind, ActorID: "42"})
if err != nil || len(items) != 2 || items[0].Action != "兼容验证" { 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) t.Fatalf("query or retention failed: kind=%s count=%d err=%v", kind, len(items), err)
} }
} }
deleted, err := store.Cleanup(ctx, now) 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) t.Fatalf("batch cleanup failed: deleted=%d err=%v", deleted, err)
} }
if deleted, err := store.Cleanup(ctx, now); err != nil || deleted != 0 { if deleted, err := store.Cleanup(ctx, now); err != nil || deleted != 0 {

View File

@ -62,11 +62,14 @@ func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) {
} }
return result 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 _, role := range []access.Role{access.RoleUser, access.RoleAdmin} {
for _, kind := range []string{"operation", "config", "mail", "runtime"} { if got := call(role, "kind=runtime&listFiles=true"); got.ErrorNo != response.NoAccessPrivileges {
if got := call(role, "kind="+kind+"&actorId=42"); got.ErrorNo != response.NoAccessPrivileges { t.Fatalf("低权限用户能读取全局文件列表:%s", role)
t.Fatalf("unscoped role %s accessed %s: %d", role, kind, got.ErrorNo)
}
} }
} }
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"} { 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"} {

View File

@ -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("可信认证归属未生效或修改了调用方字段")
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -6,15 +6,60 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net" "net"
"strconv"
"strings" "strings"
"testing" "testing"
"git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db/migration" "git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/logstore" "git.pcux.cn/xuans/Smail/services/logstore"
appcontext "git.pcux.cn/xuans/Smail/utils/context" 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: <scope@example.net>\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{} type failedMailReader struct{}
func (failedMailReader) Read([]byte) (int, error) { return 0, errors.New("private-reader-error") } func (failedMailReader) Read([]byte) (int, error) { return 0, errors.New("private-reader-error") }

View File

@ -10,15 +10,29 @@ const root = new URL('../../frontend/src/views/components/LogSettings/', import.
const read = name => readFileSync(new URL(name, root), 'utf8') const read = name => readFileSync(new URL(name, root), 'utf8')
const create = compileFunction( const create = compileFunction(
read('LogSettings.js').replace(/^import .*$/gm, '').replace('export function', 'function') + '\nreturn useLogs()', 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 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() } 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 客户端的命名导出', () => { test('日志页面使用共享 HTTP 客户端的命名导出', () => {
const transport = readFileSync(new URL('../../frontend/src/utils/axios.js', import.meta.url), 'utf8') const transport = readFileSync(new URL('../../frontend/src/utils/axios.js', import.meta.url), 'utf8')
assert.match(transport, /export\s*\{\s*http\s*\}/) assert.match(transport, /export\s*\{\s*http\s*\}/)

View File

@ -11,7 +11,7 @@ const createPage = compileFunction(
['computed', 'nextTick', 'onMounted', 'onBeforeUnmount', 'ref', 'http', 'useGlobalStatusStore'] ['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 allowed = reactive(new Set(capabilities))
const requests = [] const requests = []
let mount, dispose let mount, dispose
@ -46,8 +46,8 @@ test('普通账号只保留本人通知设置,不读取或进入全局配置
assert.equal(page.activePage.value, undefined) assert.equal(page.activePage.value, undefined)
}) })
test('只有配置读取权限的账号仍可进入插件配置,但不能读取无权限的插件列表', async () => { test('只有插件读取权限的账号仍可进入插件配置,但不能读取无权限的插件列表', async () => {
const { page, mount, requests } = fixture(['config.read']) const { page, mount, requests } = fixture(['plugin.read_global'])
await mount() await mount()
assert.equal(requests.length, 0) assert.equal(requests.length, 0)
assert.equal(page.sectionOptions.value.length, 3) assert.equal(page.sectionOptions.value.length, 3)
@ -69,7 +69,7 @@ test('插件切换等待离开确认,取消、并发点击及权限收回不
resolve(true) resolve(true)
await second await second
assert.equal(page.selectedSection.value, 'wechat_push') 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') await page.selectSection('telegram_push')
assert.equal(page.selectedSection.value, 'wechat_push') assert.equal(page.selectedSection.value, 'wechat_push')
assert.equal(page.activePage.value, undefined) assert.equal(page.activePage.value, undefined)

View File

@ -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)
})

View File

@ -54,12 +54,14 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
assert.equal(page.settingsMenu.value.find(item => item.key === 'third-party').label, '邮件客户端') assert.equal(page.settingsMenu.value.find(item => item.key === 'third-party').label, '邮件客户端')
assert.equal(page.activeSettings.value.label, '安全') assert.equal(page.activeSettings.value.label, '安全')
capabilities.add('config.read') capabilities.add('config.read')
capabilities.add('plugin.read_global')
store.settingsActiveTab = 'config' store.settingsActiveTab = 'config'
assert.equal(page.activeSettings.value.label, '配置管理') assert.equal(page.activeSettings.value.label, '配置管理')
assert.equal(page.settingsMenu.value.length, 6) assert.equal(page.settingsMenu.value.length, 6)
assert.equal(page.settingsMenu.value.find(item => item.key === 'plugins').label, '插件配置') assert.equal(page.settingsMenu.value.find(item => item.key === 'plugins').label, '插件配置')
assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint)) assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint))
capabilities.delete('config.read') capabilities.delete('config.read')
capabilities.delete('plugin.read_global')
assert.equal(page.activeSettings.value, undefined) assert.equal(page.activeSettings.value, undefined)
assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), false) assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), false)
capabilities.add('plugin.read') capabilities.add('plugin.read')
@ -67,7 +69,7 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
capabilities.add('audit.read') capabilities.add('audit.read')
for (const role of ['user', 'admin']) { for (const role of ['user', 'admin']) {
store.role = role 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' store.role = 'super_admin'
assert.equal(page.settingsMenu.value.at(-1).key, 'logs') assert.equal(page.settingsMenu.value.at(-1).key, 'logs')

View File

@ -49,6 +49,10 @@ if ((suite === 'all' || suite === 'backend') && result === 0) {
const args = extra[0] === '--' ? extra.slice(1) : extra const args = extra[0] === '--' ? extra.slice(1) : extra
// 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。 // 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。
const replacements = { 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/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/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', 'backend/controllers/login_audit_test.go': 'test/backend/login_audit_test.go',