保存角色权限分层改造续审现场
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) {
return m.validate(ctx, req, nil)
}
// ValidateAuthorized 对外部请求使用与保存一致的授权;内部校验不暴露为 HTTP 入口。
func (m *ConfigManager) ValidateAuthorized(ctx context.Context, req ValidateRequest, actor AuditActor) (*ValidationResult, error) {
if m == nil {
return nil, ErrManagerStorage
}
if err := m.authorize(ctx, actor, ConfigOperationUpdate, req.Section); err != nil {
return nil, err
}
return m.validate(ctx, req, &actor)
}
func (m *ConfigManager) validate(ctx context.Context, req ValidateRequest, actor *AuditActor) (*ValidationResult, error) {
if err := contextError(ctx); err != nil {
return nil, err
}
@ -273,6 +288,11 @@ func (m *ConfigManager) Validate(ctx context.Context, req ValidateRequest) (*Val
if err != nil {
return nil, err
}
if actor != nil && req.Section == SectionPlugins {
if err := AuthorizePluginChanges(*actor, current, candidate); err != nil {
return nil, err
}
}
if req.Section == SectionSSL {
if _, err := prepareCustomSSLPaths(m.loader.root, &candidate); err != nil {
issue, ok := SSLValidationIssue(err)
@ -317,6 +337,11 @@ func (m *ConfigManager) Update(ctx context.Context, req UpdateRequest) (*UpdateR
return nil, err
}
var sslMaterial *customSSLMaterial
if req.Section == SectionPlugins {
if err := AuthorizePluginChanges(req.Actor, current, candidate); err != nil {
return nil, err
}
}
if req.Section == SectionSSL {
sslMaterial, err = prepareCustomSSLPaths(m.loader.root, &candidate)
if err != nil {
@ -406,6 +431,12 @@ func (m *ConfigManager) apply(ctx context.Context, generation uint64, actor Audi
if err := m.authorize(ctx, actor, ConfigOperationUpdate, pending.Section); err != nil {
return nil, err
}
// 暂存后角色可能变化,应用时必须重新检查程序与权限边界。
if pending.Section == SectionPlugins {
if err := AuthorizePluginChanges(actor, current, candidate); err != nil {
return nil, err
}
}
}
if generation != candidate.Generation || candidate.Generation != current.Generation+1 {
return nil, ErrGenerationConflict

View File

@ -2,6 +2,10 @@ package config
import (
"context"
"strconv"
"git.pcux.cn/xuans/Smail/services/access"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus"
)
@ -10,7 +14,7 @@ import (
// 只保留定位操作需要的元数据,不输出配置值、凭据、用户名称或完整变更载荷。
type RuntimeConfigLogWriter struct{}
func (RuntimeConfigLogWriter) WriteConfigAudit(_ context.Context, event ConfigAuditEvent) error {
func (RuntimeConfigLogWriter) WriteConfigAudit(ctx context.Context, event ConfigAuditEvent) error {
event, err := normalizeAuditEvent(event)
if err != nil {
return err
@ -20,7 +24,10 @@ func (RuntimeConfigLogWriter) WriteConfigAudit(_ context.Context, event ConfigAu
ConfigOperationRollback: "回滚配置",
ConfigOperationMigration: "迁移配置",
}[event.Operation]
entry := log.WithFields(log.Fields{
// 操作者来自配置管理器已授权的事件,不从客户端载荷或任意日志字段恢复归属。
actorID, _ := strconv.Atoi(event.ActorID)
actorContext := &appcontext.Context{Context: ctx, UserID: actorID, Role: access.Role(event.ActorRole)}
entry := log.WithContext(actorContext).WithFields(log.Fields{
"action": action,
"object": event.Section,
"operation": event.Operation,

View File

@ -24,6 +24,9 @@ func (m *ConfigManager) Pending(actor AuditActor) *PendingView {
if m.pending == nil {
return nil
}
if actor.Role != "super_admin" && (actor.Role != "admin" || m.pending.Section != SectionPlugins || actor.UserID == "" || actor.UserID != m.pending.Actor.UserID) {
return nil
}
return &PendingView{
ID: m.pending.ID,
Generation: m.pending.Candidate.Generation, Section: m.pending.Section,

View File

@ -1,8 +1,10 @@
package config
import (
"strconv"
"strings"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus"
)
@ -14,6 +16,13 @@ func runtimeEventFields(entry *log.Entry) map[string]any {
fields[key] = value
}
fields["logSchema"] = 1
// 归属只来自服务端认证上下文,调用点不能用任意字段冒充其他账号。
delete(fields, "actorId")
delete(fields, "actorRole")
if ctx, ok := entry.Context.(*appcontext.Context); ok && ctx != nil && ctx.UserID > 0 {
fields["actorId"] = strconv.Itoa(ctx.UserID)
fields["actorRole"] = string(ctx.EffectiveRole())
}
if action, ok := fields["action"].(string); !ok || strings.TrimSpace(action) == "" {
action = "运行事件"
if entry.Caller != nil && entry.Caller.Function != "" {

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
"time"
@ -152,6 +153,9 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C
return ErrAuthorizationRequired
}
capability := access.CapabilityConfigWriteGlobal
if operation == ConfigOperationUpdate && section == SectionPlugins {
capability = access.CapabilityPluginWriteGlobal
}
if operation == ConfigOperationRollback {
capability = access.CapabilityConfigRollback
} else if operation == ConfigOperationMigration {
@ -178,6 +182,23 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C
return nil
}
// AuthorizePluginChanges 限制管理员只能修改已有插件参数,不允许安装、替换程序或提升权限。
func AuthorizePluginChanges(actor AuditActor, current, candidate RuntimeConfig) error {
if actor.Role == string(access.RoleSuperAdmin) {
return nil
}
if actor.Role != string(access.RoleAdmin) || len(current.Plugins.Plugins) != len(candidate.Plugins.Plugins) {
return ErrAuthorizationRequired
}
for id, next := range candidate.Plugins.Plugins {
previous, ok := current.Plugins.Plugins[id]
if !ok || next.Executable != previous.Executable || !reflect.DeepEqual(next.Permissions, previous.Permissions) {
return ErrAuthorizationRequired
}
}
return nil
}
func DefaultConfigConfirmation(_ context.Context, _ AuditActor, confirmation StepUpConfirmation, operation ConfigOperation, _ Section) error {
if operation != ConfigOperationRollback && !strings.EqualFold(confirmation.Method, "webauthn") && !strings.EqualFold(confirmation.Method, "recent_auth") {
return nil

View File

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

View File

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

View File

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

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/i18n"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/utils/async"
"git.pcux.cn/xuans/Smail/utils/context"
@ -75,11 +76,12 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
}
log.WithContext(ctx).Infof("发送邮件")
if reqData.From.Email != "" && !validation.IsAuthorizedSender(reqData.From.Email, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) {
allowLocalAlias := ctx.EffectiveRole() == access.RoleSuperAdmin
if reqData.From.Email != "" && !validation.IsAuthorizedSender(reqData.From.Email, ctx.UserAccount, allowLocalAlias, domains.Accepted) {
response.NewErrorResponse(response.ParamsError, "\u53d1\u4ef6\u4eba\u5730\u5740\u4e0d\u5141\u8bb8", "").FPrint(w)
return
}
if reqData.Sender.Email != "" && !validation.IsAuthorizedSender(reqData.Sender.Email, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) {
if reqData.Sender.Email != "" && !validation.IsAuthorizedSender(reqData.Sender.Email, ctx.UserAccount, allowLocalAlias, domains.Accepted) {
response.NewErrorResponse(response.ParamsError, "\u53d1\u9001\u8005\u5730\u5740\u4e0d\u5141\u8bb8", "").FPrint(w)
return
}
@ -194,7 +196,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
}
log.WithContext(ctx).Debugf("插件执行--SendBefore End")
if e.From == nil || e.Sender == nil || !validation.IsAuthorizedSender(e.From.EmailAddress, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) || !validation.IsAuthorizedSender(e.Sender.EmailAddress, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) {
if e.From == nil || e.Sender == nil || !validation.IsAuthorizedSender(e.From.EmailAddress, ctx.UserAccount, allowLocalAlias, domains.Accepted) || !validation.IsAuthorizedSender(e.Sender.EmailAddress, ctx.UserAccount, allowLocalAlias, domains.Accepted) {
response.NewErrorResponse(response.ParamsError, "\u63d2\u4ef6\u4fee\u6539\u4e86\u672a\u6388\u6743\u7684\u53d1\u4ef6\u4eba", "").FPrint(w)
return
}

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

View File

@ -3,6 +3,7 @@ package controllers
import (
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/hooks"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus"
"io"
@ -18,7 +19,7 @@ const (
)
func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !ctx.IsAdmin {
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil {
response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w)
return
}
@ -31,7 +32,7 @@ func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Reques
}
func SettingsHtml(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !ctx.IsAdmin {
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil {
response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w)
return
}

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 != "" {
targetRole = access.Role(reqData.Role)
}
if err := authorizeCapability(ctx, access.CapabilityUserManage, targetRole, false); err != nil {
if err := authorizeCapability(ctx, access.CapabilityUserCreate, targetRole, false); err != nil {
response.NewErrorResponse(response.NoAccessPrivileges, "无权访问", "").FPrint(w)
return
}
@ -170,7 +170,12 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
}
var users []models.User
totalNum, err := db.Instance.Table(&models.User{}).Limit(reqData.PageSize, offset).FindAndCount(&users)
query := db.Instance.Table(&models.User{})
defer query.Close()
if ctx.EffectiveRole() == access.RoleAdmin {
query.Where(models.OrdinaryUserPredicate)
}
totalNum, err := query.Asc("id").Limit(reqData.PageSize, offset).FindAndCount(&users)
if err != nil {
log.WithContext(ctx).Errorf("SQL Error: %+v", err)
response.NewErrorResponse(response.ServerError, "查询失败", "").FPrint(w)

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/user/create", "/api/user/create", http.MethodPost, 64<<10, controllers.CreateUser)
registerV1MethodRoute(mux, "/api/v1/user/edit", "/api/user/edit", http.MethodPost, 64<<10, controllers.EditUser)
registerV1MethodRoute(mux, "/api/v1/user/profile", "/api/user/profile", http.MethodPost, 64<<10, controllers.UpdateProfile)
registerV1MethodRoute(mux, "/api/v1/user/info", "/api/user/info", http.MethodPost, 64<<10, controllers.Info)
registerV1MethodRoute(mux, "/api/v1/user/list", "/api/user/list", http.MethodPost, 64<<10, controllers.UserList)
registerV1MethodRoute(mux, "/api/v1/user/security/recovery-codes", "/api/user/security/recovery-codes", http.MethodGet, 0, controllers.GetManagedRecoveryCodeStatus)

View File

@ -97,6 +97,7 @@ func router(mux *http.ServeMux) error {
mux.HandleFunc("/attachments/download/", onlyMethod(http.MethodGet, contextIterceptor(controllers.Download)))
mux.HandleFunc("/api/user/create", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.CreateUser))))
mux.HandleFunc("/api/user/edit", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.EditUser))))
mux.HandleFunc("/api/user/profile", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UpdateProfile))))
mux.HandleFunc("/api/user/info", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.Info))))
mux.HandleFunc("/api/user/list", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UserList))))
mux.HandleFunc("/api/user/security/recovery-codes", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetManagedRecoveryCodeStatus)))

View File

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

View File

@ -20,6 +20,7 @@ import (
"git.pcux.cn/xuans/Smail/hooks/framework"
"git.pcux.cn/xuans/Smail/listen/imap_server"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/logstore"
"git.pcux.cn/xuans/Smail/services/rule"
"git.pcux.cn/xuans/Smail/utils/array"
@ -41,6 +42,7 @@ const DropUnknownRecipientEmails = true
func (s *Session) Data(r io.Reader) (resultErr error) {
messageBytes := 0
var recipientIDs []int
result := "unknown"
details := logstore.MailDetails{Trigger: "SMTP 收信", Stage: "读取邮件内容"}
defer func() {
@ -59,7 +61,18 @@ func (s *Session) Data(r io.Reader) (resultErr error) {
details.Outcome += fmt.Sprintf(",SMTP 状态码 %d", protocol.Code)
}
}
logstore.MailResult(s.Ctx.UserID, direction, s.From, len(s.To), messageBytes, result, details)
if direction == "inbound" && len(recipientIDs) > 0 {
// 每个真实收件人只看到自己的投递记录,不暴露其他收件人数或地址。
seen := make(map[int]bool)
for _, id := range recipientIDs {
if id > 0 && !seen[id] {
seen[id] = true
logstore.MailResult(id, direction, s.From, 1, messageBytes, result, details)
}
}
} else {
logstore.MailResult(s.Ctx.UserID, direction, s.From, len(s.To), messageBytes, result, details)
}
}()
ctx := s.Ctx
@ -188,6 +201,9 @@ func (s *Session) Data(r io.Reader) (resultErr error) {
result = "discarded"
return nil
}
for _, user := range users {
recipientIDs = append(recipientIDs, user.ID)
}
details.Stage = "执行收信规则与通知"
if email.MessageId > 0 {
@ -200,20 +216,21 @@ func (s *Session) Data(r io.Reader) (resultErr error) {
ruleContext.Context = ruleExecutionContext
log.WithContext(ctx).Debugf("开始执行邮件规则!")
for _, user := range users {
ruleContext.UserID, ruleContext.Role = user.ID, user.EffectiveRole()
if ruleExecutionContext.Err() != nil {
log.WithContext(ctx).WithFields(log.Fields{"action": "执行收信规则", "result": "failure"}).Warn("规则处理超时,已停止后续规则")
log.WithContext(&ruleContext).WithFields(log.Fields{"action": "执行收信规则", "result": "failure"}).Warn("规则处理超时,已停止后续规则")
break
}
// 执行邮件规则
rs, ruleErr := rule.GetAllRules(&ruleContext, user.ID)
if ruleErr != nil {
log.WithContext(ctx).WithFields(log.Fields{"action": "读取收信规则", "result": "failure", "user_id": user.ID}).WithError(ruleErr).Error("读取收信规则失败")
log.WithContext(&ruleContext).WithFields(log.Fields{"action": "读取收信规则", "result": "failure", "user_id": user.ID}).WithError(ruleErr).Error("读取收信规则失败")
continue
}
for _, r := range rs {
if rule.MatchRule(&ruleContext, r, email) {
if err := rule.DoRule(&ruleContext, r, email, user, emailData); err != nil {
log.WithContext(ctx).WithFields(log.Fields{"action": "执行收信规则", "result": "failure", "user_id": user.ID}).WithError(err).Error("执行收信规则失败")
log.WithContext(&ruleContext).WithFields(log.Fields{"action": "执行收信规则", "result": "failure", "user_id": user.ID}).WithError(err).Error("执行收信规则失败")
}
}
}
@ -241,8 +258,10 @@ func (s *Session) Data(r io.Reader) (resultErr error) {
// IDLE命令通知
for _, user := range users {
if err := imap_server.IdleNotice(ctx, user.ID, dbEmail); err != nil {
log.WithContext(ctx).WithFields(log.Fields{"action": "通知 IMAP 客户端新邮件", "result": "failure"}).WithError(err).Error("邮件已保存,但新邮件通知失败")
noticeContext := *ctx
noticeContext.UserID, noticeContext.Role = user.ID, user.EffectiveRole()
if err := imap_server.IdleNotice(&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 {
return false
}
if !validation.IsAuthorizedSender(email.From.EmailAddress, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) {
allowLocalAlias := ctx.EffectiveRole() == access.RoleSuperAdmin
if !validation.IsAuthorizedSender(email.From.EmailAddress, ctx.UserAccount, allowLocalAlias, domains.Accepted) {
return false
}
if !validation.IsAuthorizedSender(email.Sender.EmailAddress, ctx.UserAccount, ctx.IsAdmin, domains.Accepted) {
if !validation.IsAuthorizedSender(email.Sender.EmailAddress, ctx.UserAccount, allowLocalAlias, domains.Accepted) {
return false
}
return envelopeFrom == "" || validation.IsAuthorizedSender(envelopeFrom, ctx.UserAccount, ctx.IsAdmin, domains.Accepted)
return envelopeFrom == "" || validation.IsAuthorizedSender(envelopeFrom, ctx.UserAccount, allowLocalAlias, domains.Accepted)
}
func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserID int, emailType int, reallyTo []string, SPFStatus, dkimStatus bool) ([]*models.User, *models.Email, error) {

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

View File

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

View File

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

View File

@ -10,7 +10,8 @@ type Endpoint struct {
var managementEndpoints = []Endpoint{
{Method: "POST", Path: "/api/settings/modify_password", Capability: CapabilityPasswordChangeOwn},
{Method: "POST", Path: "/api/user/create", Capability: CapabilityUserManage},
{Method: "POST", Path: "/api/user/create", Capability: CapabilityUserCreate},
{Method: "POST", Path: "/api/user/profile", Capability: CapabilityProfileWriteOwn},
{Method: "POST", Path: "/api/user/edit", Capability: CapabilityUserManage},
{Method: "*", Path: "/api/user/security/", Capability: CapabilityUserSecurityManage},
{Method: "POST", Path: "/api/user/info", Capability: CapabilityUserRead},
@ -37,7 +38,8 @@ var managementEndpoints = []Endpoint{
{Method: "POST", Path: "/api/v1/database/migrations/preview", Capability: CapabilityDatabaseMigrate},
{Method: "POST", Path: "/api/v1/database/migrations", Capability: CapabilityDatabaseMigrate},
{Method: "GET", Path: "/api/v1/database/migrations/:id", Capability: CapabilityDatabaseMigrate},
{Method: "POST", Path: "/api/v1/user/create", Capability: CapabilityUserManage},
{Method: "POST", Path: "/api/v1/user/create", Capability: CapabilityUserCreate},
{Method: "POST", Path: "/api/v1/user/profile", Capability: CapabilityProfileWriteOwn},
{Method: "POST", Path: "/api/v1/user/edit", Capability: CapabilityUserManage},
{Method: "*", Path: "/api/v1/user/security/", Capability: CapabilityUserSecurityManage},
{Method: "POST", Path: "/api/v1/user/info", Capability: CapabilityUserRead},

View File

@ -9,13 +9,16 @@ func TestCapabilityMatrix(t *testing.T) {
allowed bool
}{
{name: "super admin reads config", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityConfigRead}, allowed: true},
{name: "admin reads config", req: Request{ActorRole: RoleAdmin, Capability: CapabilityConfigRead}, allowed: true},
{name: "admin cannot read config", req: Request{ActorRole: RoleAdmin, Capability: CapabilityConfigRead}},
{name: "user cannot read config", req: Request{ActorRole: RoleUser, Capability: CapabilityConfigRead}},
{name: "super admin controls plugin", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginControl, RecentAuth: true, RecoveryCodeConfigured: true}, allowed: true},
{name: "super admin controls plugin without recovery code", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginControl, RecentAuth: true}, allowed: true},
{name: "admin cannot control plugin", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginControl, RecentAuth: true, RecoveryCodeConfigured: true}},
{name: "super admin writes global plugin settings", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginWriteGlobal, RecentAuth: true, RecoveryCodeConfigured: true}, allowed: true},
{name: "admin cannot write global plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteGlobal, RecentAuth: true, RecoveryCodeConfigured: true}},
{name: "admin writes global plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteGlobal, RecentAuth: true, RecoveryCodeConfigured: true}, allowed: true},
{name: "admin cannot create user", req: Request{ActorRole: RoleAdmin, Capability: CapabilityUserCreate, TargetRole: RoleUser}},
{name: "user edits own profile", req: Request{ActorRole: RoleUser, Capability: CapabilityProfileWriteOwn, OwnResource: true}, allowed: true},
{name: "user cannot edit another profile", req: Request{ActorRole: RoleUser, Capability: CapabilityProfileWriteOwn}},
{name: "admin writes user plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleUser}, allowed: true},
{name: "admin cannot write admin plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleAdmin}},
{name: "super admin writes all user plugin settings", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleSuperAdmin}, allowed: true},

View File

@ -13,6 +13,7 @@ import (
"time"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/logpolicy"
)
@ -31,15 +32,18 @@ type Entry struct {
}
type Query struct {
ID string
Kind string
ActorID string
Action string
Section string
From time.Time
To time.Time
Limit int
Offset int
// ScopeActorID 不接受客户端直接赋值;非空时由服务端强制限制可见账号。
ScopeActorID string
IncludeOrdinaryUsers bool
ID string
Kind string
ActorID string
Action string
Section string
From time.Time
To time.Time
Limit int
Offset int
}
type Store struct {
@ -147,6 +151,32 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) {
}
statement := "SELECT id, created_at, kind, actor_id, action, section, result, payload FROM " + table + " WHERE created_at >= ? AND kind = ?"
args := []any{from.UnixNano(), query.Kind}
if query.ScopeActorID != "" {
statement += " AND (actor_id = ?"
args = append(args, query.ScopeActorID)
if query.IncludeOrdinaryUsers {
statement += " OR ("
userTable, idType := `"user"`, "TEXT"
if s.dialect == migration.DialectMySQL {
userTable, idType = "`user`", "CHAR"
}
statement += "actor_id IN (SELECT CAST(id AS " + idType + ") FROM " + userTable + " WHERE " + models.OrdinaryUserPredicate + ")"
// 代管视图还要核对操作发生时的角色,不能因账号降级暴露旧管理操作。
// 缺少历史角色的操作仅本人和超级管理员可见,收发信元数据不包含管理操作。
if query.Kind == "operation" {
switch s.dialect {
case migration.DialectMySQL:
statement += " AND CASE WHEN JSON_VALID(payload) THEN JSON_UNQUOTE(JSON_EXTRACT(payload, '$.actorRole')) ELSE NULL END = 'user'"
case migration.DialectPostgres:
statement += " AND (payload::jsonb ->> 'actorRole') = 'user'"
default:
statement += " AND CASE WHEN json_valid(payload) THEN json_extract(payload, '$.actorRole') ELSE NULL END = 'user'"
}
}
statement += ")"
}
statement += ")"
}
if !query.To.IsZero() {
statement += " AND created_at <= ?"
args = append(args, query.To.UnixNano())

View File

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

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

View File

@ -148,7 +148,7 @@ export function usePage(pageKey = 'base') {
const restartPreview = ref(null)
const hasChanges = computed(() => editorText.value !== loadedText.value || !!secretValue.value || !!savedGeneration.value)
const canWrite = computed(() => globalStatus.hasCapability('config.write_global'))
const canWrite = computed(() => globalStatus.hasCapability(apiSection() === 'plugins' ? 'plugin.write_global' : 'config.write_global'))
const canRollback = computed(() => globalStatus.hasCapability('config.rollback'))
const canMigrate = computed(() => globalStatus.hasCapability('database.migrate'))
const canRestart = computed(() => globalStatus.hasCapability('service.restart'))

View File

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

View File

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

View File

@ -18,7 +18,7 @@
format="YYYY-MM-DD HH:mm"
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">
<el-button :disabled="loading" @click="recent">最近 24 小时</el-button>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
@ -171,6 +171,7 @@ import { useLogs } from './LogSettings.js'
const {
category, categories, loading, error, items, offset,
canFilterActor,
hasMore, actorId, dateRange, detail, details, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts,

View File

@ -4,7 +4,7 @@ import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
export function usePage() {
const store = useGlobalStatusStore()
const selectedSection = ref(store.hasCapability('config.read') ? 'spam_block' : 'telegram-user')
const selectedSection = ref(store.hasCapability('plugin.read_global') ? 'spam_block' : 'telegram-user')
const currentPage = ref(null)
const pageHost = ref(null)
const switching = ref(false)
@ -14,7 +14,7 @@ export function usePage() {
let disposed = false
// 全局配置不依赖正在运行的插件列表,未启用的插件也必须可以配置。
const sectionOptions = computed(() => store.hasCapability('config.read') ? [
const sectionOptions = computed(() => store.hasCapability('plugin.read_global') ? [
{ value: 'spam_block', label: '垃圾邮件拦截', kind: 'config' },
{ value: 'telegram_push', label: 'Telegram 推送', kind: 'config' },
{ value: 'wechat_push', label: '微信推送', kind: 'config' }

View File

@ -26,7 +26,7 @@ export function useSpamBlockSettings() {
const settings = reactive({ ...defaultSettings })
const hasChanges = computed(() => JSON.stringify(settings) !== savedInput)
const canWrite = () => globalStatus.hasCapability('config.write_global')
const canWrite = () => globalStatus.hasCapability('plugin.write_global')
const notifyResponseError = (response, fallback) => {
if (!response || response.errorNo !== 0) {

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 sectionOptions = computed(() => {
const options = []
options.push({ value: 'profile', label: '个人资料' })
if (canReadGlobalPolicy.value) {
options.push({ value: 'security-policy', label: '安全策略' })
}

View File

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

View File

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

View File

@ -60,7 +60,7 @@
</el-table-column>
<el-table-column align="right" width="68">
<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-button>
</template>
@ -239,6 +239,7 @@ const {
editModel,
editUserInfo,
availableRoles,
canCreateUser,
canEditSelectedUser,
canSelectRole,
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()
cutoff := logpolicy.Cutoff(now)
store.now = func() time.Time { return now.Add(-logpolicy.Retention) }
for _, kind := range []string{"operation", "config", "mail"} {
for _, kind := range []string{"operation", "mail"} {
for index := 0; index < 1003; index++ {
at := cutoff.Add(-time.Nanosecond)
if index >= 1001 {
@ -106,14 +106,14 @@ func TestLogStoreServerDatabaseIntegration(t *testing.T) {
}
}
store.now = func() time.Time { return now }
for _, kind := range []string{"operation", "config", "mail"} {
for _, kind := range []string{"operation", "mail"} {
items, err := store.Query(ctx, Query{Kind: kind, ActorID: "42"})
if err != nil || len(items) != 2 || items[0].Action != "兼容验证" {
t.Fatalf("query or retention failed: kind=%s count=%d err=%v", kind, len(items), err)
}
}
deleted, err := store.Cleanup(ctx, now)
if err != nil || deleted != 3003 {
if err != nil || deleted != 2002 {
t.Fatalf("batch cleanup failed: deleted=%d err=%v", deleted, err)
}
if deleted, err := store.Cleanup(ctx, now); err != nil || deleted != 0 {

View File

@ -62,11 +62,14 @@ func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) {
}
return result
}
for _, kind := range []string{"operation", "mail", "runtime"} {
if got := call(access.RoleUser, "kind="+kind+"&actorId=42"); got.ErrorNo != 0 {
t.Fatalf("普通用户无法查询本人日志:%s => %d", kind, got.ErrorNo)
}
}
for _, role := range []access.Role{access.RoleUser, access.RoleAdmin} {
for _, kind := range []string{"operation", "config", "mail", "runtime"} {
if got := call(role, "kind="+kind+"&actorId=42"); got.ErrorNo != response.NoAccessPrivileges {
t.Fatalf("unscoped role %s accessed %s: %d", role, kind, got.ErrorNo)
}
if got := call(role, "kind=runtime&listFiles=true"); got.ErrorNo != response.NoAccessPrivileges {
t.Fatalf("低权限用户能读取全局文件列表:%s", role)
}
}
for _, query := range []string{"kind=config", "kind=unknown", "kind=mail&limit=501", "kind=mail&offset=-1", "kind=mail&from=invalid", "kind=mail&from=2026-09-08T00:00:00Z&to=2026-09-01T00:00:00Z"} {

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"
"errors"
"net"
"strconv"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/logstore"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
func TestInboundMailLogBelongsToEachActualRecipient(t *testing.T) {
engine, _ := newAuditTestEngine(t)
config.Instance.SpamFilterLevel = 0
users := []models.User{{Account: "alice", Name: "Alice", Role: "user"}, {Account: "bob", Name: "Bob", Role: "user"}}
for i := range users {
if _, err := engine.Insert(&users[i]); err != nil {
t.Fatal(err)
}
}
migrations := migration.DefaultMigrations()
for _, statement := range migrations[len(migrations)-1].StatementsFor(migration.DialectSQLite) {
if _, err := engine.Exec(statement); err != nil {
t.Fatal(err)
}
}
store, err := logstore.New(engine.DB().DB, migration.DialectSQLite)
if err != nil {
t.Fatal(err)
}
runtime := logstore.Start(store)
defer runtime.Stop()
session := &Session{Ctx: &appcontext.Context{Context: context.Background()}, From: "sender@example.net", To: []string{"alice@example.com", "bob@example.com", "alice@example.com"}, RemoteAddress: &net.TCPAddr{IP: net.IPv4(192, 168, 1, 2), Port: 25}}
message := "From: sender@example.net\r\nTo: alice@example.com\r\nSubject: private-subject\r\nMessage-ID: <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{}
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 create = compileFunction(
read('LogSettings.js').replace(/^import .*$/gm, '').replace('export function', 'function') + '\nreturn useLogs()',
['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'watch', 'http', 'formatFileSize', 'parseRuntimeLine', 'summarizeRuntime', 'timeParts']
['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'watch', 'http', 'formatFileSize', 'parseRuntimeLine', 'summarizeRuntime', 'timeParts', 'useGlobalStatusStore']
)
function fixture(get) {
function fixture(get, role = 'super_admin') {
let dispose
const page = create(computed, callback => { dispose = callback }, () => {}, ref, () => {}, { get }, formatFileSize, parseRuntimeLine, summarizeRuntime, timeParts)
const page = create(computed, callback => { dispose = callback }, () => {}, ref, () => {}, { get }, formatFileSize, parseRuntimeLine, summarizeRuntime, timeParts, () => ({ role }))
return { page, dispose: () => dispose() }
}
test('普通用户不发送操作者筛选,管理员保留范围内筛选', async () => {
for (const role of ['user', 'admin']) {
const requests = []
const { page } = fixture(async (_, { params }) => {
requests.push(params)
return { errorNo: 0, data: { items: [] } }
}, role)
page.actorId.value = '1'
await page.search()
assert.equal(page.canFilterActor.value, role === 'admin')
assert.equal(requests[0].actorId, role === 'admin' ? '1' : undefined)
}
})
test('日志页面使用共享 HTTP 客户端的命名导出', () => {
const transport = readFileSync(new URL('../../frontend/src/utils/axios.js', import.meta.url), 'utf8')
assert.match(transport, /export\s*\{\s*http\s*\}/)

View File

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

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

View File

@ -49,6 +49,10 @@ if ((suite === 'all' || suite === 'backend') && result === 0) {
const args = extra[0] === '--' ? extra.slice(1) : extra
// 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。
const replacements = {
'backend/config/plugin_role_scope_test.go': 'test/backend/plugin_role_scope_test.go',
'backend/controllers/role_scope_test.go': 'test/backend/role_scope_test.go',
'backend/services/logstore/log_scope_test.go': 'test/backend/log_scope_test.go',
'backend/services/runtimelog/runtime_scope_test.go': 'test/backend/runtime_scope_test.go',
'backend/internal/app/runtime_log_test.go': 'test/backend/app_runtime_log_test.go',
'backend/listen/http_server/operation_log_test.go': 'test/backend/operation_log_test.go',
'backend/controllers/login_audit_test.go': 'test/backend/login_audit_test.go',