Smail/backend/config/security.go
smail 3ab3c21a09
Some checks are pending
Security Checks / security (push) Waiting to run
检查点:保存安全方式二选一授权调整以补充回归覆盖
2026-10-02 18:33:48 +08:00

241 lines
7.5 KiB
Go

package config
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/services/access"
)
var (
ErrAuthorizationRequired = errors.New("configuration authorization required")
ErrStepUpRequired = errors.New("configuration step-up confirmation required")
ErrAuditWrite = errors.New("configuration audit write failed")
)
type ConfigOperation string
const (
ConfigOperationUpdate ConfigOperation = "update"
ConfigOperationRollback ConfigOperation = "rollback"
ConfigOperationMigration ConfigOperation = "migration"
)
type ConfigAuditEvent struct {
ID string `json:"id"`
Operation ConfigOperation `json:"operation"`
Section Section `json:"section,omitempty"`
ActorID string `json:"actorId"`
ActorRole string `json:"actorRole"`
ActorName string `json:"actorName,omitempty"`
Changes []ConfigAuditChange `json:"changes,omitempty"`
FromGeneration uint64 `json:"fromGeneration"`
ToGeneration uint64 `json:"toGeneration"`
TargetGeneration uint64 `json:"targetGeneration,omitempty"`
ChangedFields []string `json:"changedFields,omitempty"`
ConfirmationMethod string `json:"confirmationMethod,omitempty"`
Result string `json:"result"`
CreatedAt time.Time `json:"createdAt"`
}
type ConfigAuditWriter interface {
WriteConfigAudit(context.Context, ConfigAuditEvent) error
}
type ConfigSecurity struct {
Authorize func(context.Context, AuditActor, ConfigOperation, Section) error
Confirm func(context.Context, AuditActor, StepUpConfirmation, ConfigOperation, Section) error
Audit ConfigAuditWriter
}
type pendingConfig struct {
ID string
Candidate RuntimeConfig
Section Section
Actor AuditActor
Confirmation StepUpConfirmation
Committed bool
PreviousRestart []ServiceName
}
type MemoryAuditWriter struct {
mu sync.Mutex
events []ConfigAuditEvent
}
func (w *MemoryAuditWriter) WriteConfigAudit(_ context.Context, event ConfigAuditEvent) error {
if w == nil {
return ErrAuditWrite
}
var err error
event, err = normalizeAuditEvent(event)
if err != nil {
return err
}
event.ChangedFields = append([]string(nil), event.ChangedFields...)
w.mu.Lock()
w.events = append(w.events, event)
w.mu.Unlock()
return nil
}
func (w *MemoryAuditWriter) Query(ctx context.Context, query ConfigAuditQuery) ([]ConfigAuditEvent, error) {
if err := auditContextError(ctx); err != nil {
return nil, err
}
if err := ValidateConfigAuditQuery(query); err != nil {
return nil, err
}
if w == nil {
return nil, ErrAuditQueryUnavailable
}
limit := query.Limit
if limit <= 0 {
limit = 1000
}
w.mu.Lock()
defer w.mu.Unlock()
result := make([]ConfigAuditEvent, 0, minAuditLimit(limit, len(w.events)))
for _, event := range w.events {
if err := auditContextError(ctx); err != nil {
return nil, err
}
if !auditEventMatches(event, query) {
continue
}
copyEvent := event
copyEvent.ChangedFields = append([]string(nil), event.ChangedFields...)
copyEvent.Changes = append([]ConfigAuditChange(nil), event.Changes...)
result = append(result, copyEvent)
if len(result) >= limit {
break
}
}
return result, nil
}
func (w *MemoryAuditWriter) ExportJSON(ctx context.Context, query ConfigAuditQuery) ([]byte, error) {
events, err := w.Query(ctx, query)
if err != nil {
return nil, err
}
return json.Marshal(events)
}
func (w *MemoryAuditWriter) ExportCSV(ctx context.Context, query ConfigAuditQuery) ([]byte, error) {
events, err := w.Query(ctx, query)
if err != nil {
return nil, err
}
return exportAuditCSV(events)
}
func (w *MemoryAuditWriter) Events() []ConfigAuditEvent {
if w == nil {
return nil
}
w.mu.Lock()
defer w.mu.Unlock()
result := make([]ConfigAuditEvent, len(w.events))
copy(result, w.events)
for index := range result {
result[index].ChangedFields = append([]string(nil), result[index].ChangedFields...)
result[index].Changes = append([]ConfigAuditChange(nil), result[index].Changes...)
}
return result
}
func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation ConfigOperation, section Section) error {
if actor.UserID == "" || !access.Role(actor.Role).Valid() {
return ErrAuthorizationRequired
}
capability := access.CapabilityConfigWriteGlobal
if operation == ConfigOperationUpdate && section == SectionPlugins {
capability = access.CapabilityPluginWriteGlobal
}
if operation == ConfigOperationRollback {
capability = access.CapabilityConfigRollback
} else if operation == ConfigOperationMigration {
capability = access.CapabilityDatabaseMigrate
}
request := access.Request{
ActorRole: access.Role(actor.Role),
Capability: capability,
RecentAuth: actor.RecentAuth,
RecoveryCodeConfigured: actor.RecoveryCodeConfigured,
ExternalEmailConfigured: actor.ExternalEmailConfigured,
}
if operation == ConfigOperationRollback && request.ActorRole == access.RoleSuperAdmin {
if !request.RecentAuth {
return &access.Denial{Code: access.CodeRecentAuthRequired}
}
return nil
}
if err := access.Authorize(request); err != nil {
return err
}
if operation == ConfigOperationUpdate && section == SectionRoot && request.ActorRole != access.RoleSuperAdmin {
return ErrAuthorizationRequired
}
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
}
if id == "wechat_push" || id == "telegram_push" {
// 允许普通插件保存时原样携带共享配置,但不允许借整个分区绕过权限。
if !sameNotificationPlugin(previous, next) {
return ErrAuthorizationRequired
}
}
}
return nil
}
func sameNotificationPlugin(previous, next PluginInstance) bool {
var settings [2]any
for index, data := range []json.RawMessage{previous.Settings, next.Settings} {
if len(data) == 0 {
continue
}
if !json.Valid(data) {
return false
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.UseNumber()
if err := decoder.Decode(&settings[index]); err != nil {
return false
}
}
previous.Settings, next.Settings = nil, nil
return reflect.DeepEqual(previous, next) && reflect.DeepEqual(settings[0], settings[1])
}
func DefaultConfigConfirmation(_ context.Context, _ AuditActor, confirmation StepUpConfirmation, operation ConfigOperation, _ Section) error {
if operation != ConfigOperationRollback && !strings.EqualFold(confirmation.Method, "webauthn") && !strings.EqualFold(confirmation.Method, "recent_auth") {
return nil
}
if !confirmation.Confirmed {
return ErrStepUpRequired
}
method := strings.ToLower(strings.TrimSpace(confirmation.Method))
if method != "recent_auth" && method != "webauthn" {
return ErrStepUpRequired
}
return nil
}