This commit is contained in:
parent
9dc861d560
commit
fd475cd88d
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 != "" {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
29
backend/controllers/config_permissions.go
Normal file
29
backend/controllers/config_permissions.go
Normal 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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
42
backend/controllers/log_permissions.go
Normal file
42
backend/controllers/log_permissions.go
Normal 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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
55
backend/controllers/profile.go
Normal file
55
backend/controllers/profile.go
Normal 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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)))
|
||||||
|
|||||||
@ -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": "编辑用户",
|
||||||
|
|||||||
@ -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(¬iceContext, user.ID, dbEmail); err != nil {
|
||||||
|
log.WithContext(¬iceContext).WithFields(log.Fields{"action": "通知 IMAP 客户端新邮件", "result": "failure"}).WithError(err).Error("邮件已保存,但新邮件通知失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,13 +292,14 @@ func authorizedSubmissionEmail(email *parsemail.Email, envelopeFrom string, ctx
|
|||||||
if email == nil || email.From == nil || email.Sender == nil || ctx == nil || len(domains.Accepted) == 0 {
|
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) {
|
||||||
|
|||||||
4
backend/models/user_scope.go
Normal file
4
backend/models/user_scope.go
Normal 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))"
|
||||||
@ -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{}{}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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},
|
||||||
|
|||||||
@ -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},
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
60
backend/services/runtimelog/scope.go
Normal file
60
backend/services/runtimelog/scope.go
Normal 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
|
||||||
|
}
|
||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'))
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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' }
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -0,0 +1,4 @@
|
|||||||
|
.profile-settings {
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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: '安全策略' })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
50
test/backend/log_scope_test.go
Normal file
50
test/backend/log_scope_test.go
Normal 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("超级管理员丢失系统或历史日志")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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"} {
|
||||||
|
|||||||
107
test/backend/plugin_role_scope_test.go
Normal file
107
test/backend/plugin_role_scope_test.go
Normal 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("可信认证归属未生效或修改了调用方字段")
|
||||||
|
}
|
||||||
|
}
|
||||||
140
test/backend/role_scope_test.go
Normal file
140
test/backend/role_scope_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
test/backend/runtime_scope_test.go
Normal file
73
test/backend/runtime_scope_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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") }
|
||||||
|
|||||||
@ -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*\}/)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
31
test/frontend/profile.test.mjs
Normal file
31
test/frontend/profile.test.mjs
Normal 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)
|
||||||
|
})
|
||||||
@ -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')
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user