From a2cbaf1e339d37e90d3c81d8a74455f44ce12375 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 09:15:15 +0000 Subject: [PATCH] feat(sysadmin): CAS author-only policy with transactional audit Register the workflow total switch, require expected_revision for updates, and expose AuthorOnlyPolicySource for activation locking. Co-authored-by: Cursor --- internal/server/admin_system_handlers.go | 15 ++- internal/sysadmin/policy.go | 96 +++++++++++++++++++ internal/sysadmin/settings.go | 63 +++++++++++-- internal/sysadmin/store.go | 113 ++++++++++++++++++++--- 4 files changed, 261 insertions(+), 26 deletions(-) create mode 100644 internal/sysadmin/policy.go diff --git a/internal/server/admin_system_handlers.go b/internal/server/admin_system_handlers.go index 2d7327f3..73bfa6c1 100644 --- a/internal/server/admin_system_handlers.go +++ b/internal/server/admin_system_handlers.go @@ -70,13 +70,20 @@ func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Re } key := r.PathValue("key") var req struct { - Value json.RawMessage `json:"value"` + Value json.RawMessage `json:"value"` + ExpectedRevision *int64 `json:"expected_revision"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeSafeError(w, r, http.StatusBadRequest, "无效的请求体") return } - setting, err := s.sysadmin.UpdateSetting(r.Context(), key, req.Value, user.ID) + setting, err := s.sysadmin.UpdateSettingEx(r.Context(), sysadmin.SettingUpdate{ + Key: key, + Value: req.Value, + UpdatedBy: user.ID, + ExpectedRevision: req.ExpectedRevision, + RequestID: r.Header.Get("X-Request-ID"), + }) switch { case errors.Is(err, sysadmin.ErrUnknownSetting): writeSafeError(w, r, http.StatusBadRequest, "未知的设置键") @@ -84,6 +91,10 @@ func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Re writeSafeError(w, r, http.StatusForbidden, "该设置为只读") case errors.Is(err, sysadmin.ErrInvalidSettingValue): writeSafeError(w, r, http.StatusBadRequest, "设置值类型不匹配") + case errors.Is(err, sysadmin.ErrExpectedRevisionRequired): + writeSafeError(w, r, http.StatusBadRequest, "该设置需要 expected_revision") + case errors.Is(err, sysadmin.ErrSettingRevisionConflict): + writeSafeError(w, r, http.StatusConflict, `{"error":"SETTING_REVISION_CONFLICT","message":"设置版本冲突,请刷新后重试"}`) case errors.Is(err, sysadmin.ErrStoreUnavailable): writeSafeError(w, r, http.StatusServiceUnavailable, "设置存储不可用") case err != nil: diff --git a/internal/sysadmin/policy.go b/internal/sysadmin/policy.go new file mode 100644 index 00000000..9a541e4a --- /dev/null +++ b/internal/sysadmin/policy.go @@ -0,0 +1,96 @@ +package sysadmin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// SettingKeyAllowAuthorOnlyApproval 系统级「允许编制人兼任审核、批准」总授权。 +// 默认关闭;无环境变量绕过;仅系统管理员可写。 +const SettingKeyAllowAuthorOnlyApproval = "workflow.allow_author_only_approval" + +// AuthorOnlyApprovalPolicy 供 projectinstance 消费的窄策略快照。 +type AuthorOnlyApprovalPolicy struct { + Key string `json:"key"` + Allowed bool `json:"allowed"` + Revision int64 `json:"revision"` +} + +// AuthorOnlyPolicySource 组合根注入给 projectinstance 的只读/事务锁接口。 +// sysadmin 是唯一写入 owner;调用方不得自行写 system_settings。 +type AuthorOnlyPolicySource interface { + GetAuthorOnlyApprovalPolicy(ctx context.Context) (AuthorOnlyApprovalPolicy, error) + LockAuthorOnlyApprovalPolicy(ctx context.Context, tx pgx.Tx) (AuthorOnlyApprovalPolicy, error) +} + +// GetAuthorOnlyApprovalPolicy 读取当前总授权。缺行按默认关闭(revision=0); +// 存储不可用或查询失败返回错误,不得伪装成默认关闭。 +func (s *Service) GetAuthorOnlyApprovalPolicy(ctx context.Context) (AuthorOnlyApprovalPolicy, error) { + out := AuthorOnlyApprovalPolicy{ + Key: SettingKeyAllowAuthorOnlyApproval, + Allowed: false, + Revision: 0, + } + if s.store == nil { + return out, ErrStoreUnavailable + } + raw, _, _, revision, err := s.store.Get(ctx, SettingKeyAllowAuthorOnlyApproval) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return out, nil + } + return out, err + } + allowed, err := decodeStrictBool(raw) + if err != nil { + return out, fmt.Errorf("sysadmin: decode %s: %w", SettingKeyAllowAuthorOnlyApproval, err) + } + out.Allowed = allowed + out.Revision = revision + return out, nil +} + +// LockAuthorOnlyApprovalPolicy 在调用方事务内锁定策略行,供激活与关闭串行化。 +func (s *Service) LockAuthorOnlyApprovalPolicy(ctx context.Context, tx pgx.Tx) (AuthorOnlyApprovalPolicy, error) { + out := AuthorOnlyApprovalPolicy{ + Key: SettingKeyAllowAuthorOnlyApproval, + Allowed: false, + Revision: 0, + } + if s.store == nil { + return out, ErrStoreUnavailable + } + raw, _, _, revision, err := s.store.GetForUpdate(ctx, tx, SettingKeyAllowAuthorOnlyApproval) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + // 迁移应已种子化;缺行按关闭处理但不伪装 DB 故障。 + return out, nil + } + return out, err + } + allowed, err := decodeStrictBool(raw) + if err != nil { + return out, fmt.Errorf("sysadmin: decode %s: %w", SettingKeyAllowAuthorOnlyApproval, err) + } + out.Allowed = allowed + out.Revision = revision + return out, nil +} + +func decodeStrictBool(raw json.RawMessage) (bool, error) { + if len(raw) == 0 { + return false, ErrInvalidSettingValue + } + var v bool + if err := json.Unmarshal(raw, &v); err != nil { + return false, ErrInvalidSettingValue + } + return v, nil +} + +// Ensure AuthorOnlyPolicySource 由 Service 实现。 +var _ AuthorOnlyPolicySource = (*Service)(nil) diff --git a/internal/sysadmin/settings.go b/internal/sysadmin/settings.go index cd22e093..f7223023 100644 --- a/internal/sysadmin/settings.go +++ b/internal/sysadmin/settings.go @@ -1,6 +1,7 @@ package sysadmin import ( + "bytes" "context" "encoding/json" "errors" @@ -12,10 +13,11 @@ import ( // 设置读写错误;HTTP 适配层据此映射状态码。 var ( - ErrUnknownSetting = errors.New("sysadmin: unknown setting key") - ErrSettingReadOnly = errors.New("sysadmin: setting is read-only") - ErrInvalidSettingValue = errors.New("sysadmin: invalid setting value") - ErrStoreUnavailable = errors.New("sysadmin: settings store unavailable") + ErrUnknownSetting = errors.New("sysadmin: unknown setting key") + ErrSettingReadOnly = errors.New("sysadmin: setting is read-only") + ErrInvalidSettingValue = errors.New("sysadmin: invalid setting value") + ErrStoreUnavailable = errors.New("sysadmin: settings store unavailable") + ErrExpectedRevisionRequired = errors.New("sysadmin: expected_revision required") ) // 设置值类型。 @@ -49,10 +51,20 @@ type Setting struct { Writable bool `json:"writable"` RequiresRestart bool `json:"requiresRestart"` Description string `json:"description"` + Revision int64 `json:"revision"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` UpdatedBy *int64 `json:"updatedBy,omitempty"` } +// SettingUpdate 写入入参;仅版本化策略 key 要求 ExpectedRevision。 +type SettingUpdate struct { + Key string + Value json.RawMessage + UpdatedBy int64 + ExpectedRevision *int64 + RequestID string +} + // defaultSettingSpecs 一期白名单:先做机制,键空间可后续扩展。 func defaultSettingSpecs() []SettingSpec { return []SettingSpec{ @@ -62,6 +74,11 @@ func defaultSettingSpecs() []SettingSpec { {Key: "tasks.default_auto_repair", Label: "任务默认自动修复", Group: "tasks", Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false, Description: "独立任务运行未传 auto_repair 时的默认值"}, {Key: "kb.default_search_limit", Label: "KB 检索默认条数", Group: "kb", Type: SettingTypeNumber, Default: 8.0, Writable: true, RequiresRestart: false, Description: "知识库检索未指定 limit 时的默认返回条数"}, {Key: "notifications.stage_advance_reminder", Label: "阶段推进提醒总开关", Group: "notification", Type: SettingTypeBool, Default: true, Writable: true, RequiresRestart: false, Description: "阶段就绪 24h 提醒的总开关(一期先落机制)"}, + { + Key: SettingKeyAllowAuthorOnlyApproval, Label: "允许编制人兼任审核批准", Group: "workflow", + Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false, + Description: "授权新配置:开启后项目创建者可在激活前为文档启用编制人兼任。关闭后禁止新启用/激活兼任规划;已激活实例按冻结职责继续执行,不作为紧急熔断。", + }, } } @@ -88,22 +105,37 @@ func (s *Service) ListSettings(ctx context.Context) ([]Setting, error) { } // UpdateSetting 校验并写入白名单设置,返回更新后的视图。 +// 兼容旧调用:非版本化 key 可走此入口;版本化策略 key 必须带 ExpectedRevision,请用 UpdateSettingEx。 func (s *Service) UpdateSetting(ctx context.Context, key string, raw json.RawMessage, updatedBy int64) (Setting, error) { - spec, ok := s.findSpec(key) + return s.UpdateSettingEx(ctx, SettingUpdate{Key: key, Value: raw, UpdatedBy: updatedBy}) +} + +// UpdateSettingEx 校验并写入白名单设置;对 SettingKeyAllowAuthorOnlyApproval 强制 CAS + 同事务审计。 +func (s *Service) UpdateSettingEx(ctx context.Context, in SettingUpdate) (Setting, error) { + spec, ok := s.findSpec(in.Key) if !ok { return Setting{}, ErrUnknownSetting } if !spec.Writable { return Setting{}, ErrSettingReadOnly } - if err := validateSettingValue(spec.Type, raw); err != nil { + if err := validateSettingValue(spec.Type, in.Value); err != nil { return Setting{}, err } if s.store == nil { return Setting{}, ErrStoreUnavailable } - if _, err := s.store.Upsert(ctx, key, raw, updatedBy); err != nil { - return Setting{}, err + if in.Key == SettingKeyAllowAuthorOnlyApproval { + if in.ExpectedRevision == nil { + return Setting{}, ErrExpectedRevisionRequired + } + if _, _, err := s.store.UpsertCAS(ctx, in.Key, in.Value, in.UpdatedBy, *in.ExpectedRevision, in.RequestID); err != nil { + return Setting{}, err + } + } else { + if _, _, err := s.store.Upsert(ctx, in.Key, in.Value, in.UpdatedBy); err != nil { + return Setting{}, err + } } return s.resolve(ctx, spec) } @@ -131,7 +163,7 @@ func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error if s.store == nil { return setting, nil } - raw, updatedBy, updatedAt, err := s.store.Get(ctx, spec.Key) + raw, updatedBy, updatedAt, revision, err := s.store.Get(ctx, spec.Key) if err != nil { if errors.Is(err, ErrSettingNotFound) { return setting, nil @@ -143,6 +175,7 @@ func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error return Setting{}, fmt.Errorf("sysadmin: decode stored value for %s: %w", spec.Key, err) } setting.Value = value + setting.Revision = revision setting.UpdatedBy = updatedBy setting.UpdatedAt = updatedAt return setting, nil @@ -153,6 +186,10 @@ func validateSettingValue(valueType string, raw json.RawMessage) error { if len(raw) == 0 { return ErrInvalidSettingValue } + trimmed := bytes.TrimSpace(raw) + if string(trimmed) == "null" { + return ErrInvalidSettingValue + } switch valueType { case SettingTypeString: var v string @@ -161,7 +198,13 @@ func validateSettingValue(valueType string, raw json.RawMessage) error { } case SettingTypeBool: var v bool - if err := json.Unmarshal(raw, &v); err != nil { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return ErrInvalidSettingValue + } + // 拒绝尾随垃圾(如 truefalse) + if dec.More() { return ErrInvalidSettingValue } case SettingTypeNumber: diff --git a/internal/sysadmin/store.go b/internal/sysadmin/store.go index 9d30f6ce..475a0e33 100644 --- a/internal/sysadmin/store.go +++ b/internal/sysadmin/store.go @@ -13,10 +13,17 @@ import ( // ErrSettingNotFound DB 中无该 key 的行(回退 env/default)。 var ErrSettingNotFound = errors.New("sysadmin: setting not found") +// ErrSettingRevisionConflict CAS 期望 revision 与当前不一致。 +var ErrSettingRevisionConflict = errors.New("sysadmin: setting revision conflict") + // SettingsStore system_settings 表读写(表 owner 为 sysadmin)。 type SettingsStore interface { - Get(ctx context.Context, key string) (value json.RawMessage, updatedBy *int64, updatedAt *time.Time, err error) - Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, error) + Get(ctx context.Context, key string) (value json.RawMessage, updatedBy *int64, updatedAt *time.Time, revision int64, err error) + Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (updatedAt time.Time, revision int64, err error) + // UpsertCAS 在同一事务内校验 expectedRevision、写入新值并追加策略审计;冲突返回 ErrSettingRevisionConflict。 + UpsertCAS(ctx context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (updatedAt time.Time, revision int64, err error) + // GetForUpdate 在调用方事务内锁定目标行(FOR UPDATE),供激活路径与关闭串行化。 + GetForUpdate(ctx context.Context, tx pgx.Tx, key string) (value json.RawMessage, updatedBy *int64, updatedAt *time.Time, revision int64, err error) } type pgSettingsStore struct { @@ -27,30 +34,108 @@ func newPGSettingsStore(pool *pgxpool.Pool) SettingsStore { return &pgSettingsStore{pool: pool} } -func (s *pgSettingsStore) Get(ctx context.Context, key string) (json.RawMessage, *int64, *time.Time, error) { +func (s *pgSettingsStore) Get(ctx context.Context, key string) (json.RawMessage, *int64, *time.Time, int64, error) { var raw []byte var updatedBy *int64 var updatedAt time.Time + var revision int64 err := s.pool.QueryRow(ctx, ` - SELECT value, updated_by, updated_at FROM system_settings WHERE key = $1 - `, key).Scan(&raw, &updatedBy, &updatedAt) + SELECT value, updated_by, updated_at, revision FROM system_settings WHERE key = $1 + `, key).Scan(&raw, &updatedBy, &updatedAt, &revision) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return nil, nil, nil, ErrSettingNotFound + return nil, nil, nil, 0, ErrSettingNotFound } - return nil, nil, nil, err + return nil, nil, nil, 0, err } - return json.RawMessage(raw), updatedBy, &updatedAt, nil + return json.RawMessage(raw), updatedBy, &updatedAt, revision, nil } -func (s *pgSettingsStore) Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, error) { +func (s *pgSettingsStore) Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) { var updatedAt time.Time + var revision int64 err := s.pool.QueryRow(ctx, ` - INSERT INTO system_settings (key, value, updated_by, updated_at) - VALUES ($1, $2, $3, now()) + INSERT INTO system_settings (key, value, updated_by, updated_at, revision) + VALUES ($1, $2, $3, now(), 0) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_by = EXCLUDED.updated_by, updated_at = now() - RETURNING updated_at - `, key, []byte(value), updatedBy).Scan(&updatedAt) - return updatedAt, err + RETURNING updated_at, revision + `, key, []byte(value), updatedBy).Scan(&updatedAt, &revision) + return updatedAt, revision, err +} + +func (s *pgSettingsStore) UpsertCAS(ctx context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (time.Time, int64, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return time.Time{}, 0, err + } + defer tx.Rollback(ctx) + + var beforeRaw []byte + var beforeRev int64 + err = tx.QueryRow(ctx, ` + SELECT value, revision FROM system_settings WHERE key = $1 FOR UPDATE + `, key).Scan(&beforeRaw, &beforeRev) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return time.Time{}, 0, ErrSettingNotFound + } + return time.Time{}, 0, err + } + if beforeRev != expectedRevision { + return time.Time{}, 0, ErrSettingRevisionConflict + } + + var updatedAt time.Time + var newRev int64 + err = tx.QueryRow(ctx, ` + UPDATE system_settings + SET value = $2, updated_by = $3, updated_at = now(), revision = revision + 1 + WHERE key = $1 AND revision = $4 + RETURNING updated_at, revision + `, key, []byte(value), updatedBy, expectedRevision).Scan(&updatedAt, &newRev) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return time.Time{}, 0, ErrSettingRevisionConflict + } + return time.Time{}, 0, err + } + + var reqID any + if requestID != "" { + reqID = requestID + } + _, err = tx.Exec(ctx, ` + INSERT INTO system_setting_policy_audits ( + setting_key, before_value, after_value, before_revision, after_revision, actor_user_id, request_id + ) VALUES ($1, $2::jsonb, $3::jsonb, $4, $5, $6, $7) + `, key, beforeRaw, []byte(value), beforeRev, newRev, updatedBy, reqID) + if err != nil { + return time.Time{}, 0, err + } + + if err := tx.Commit(ctx); err != nil { + return time.Time{}, 0, err + } + return updatedAt, newRev, nil +} + +func (s *pgSettingsStore) GetForUpdate(ctx context.Context, tx pgx.Tx, key string) (json.RawMessage, *int64, *time.Time, int64, error) { + if tx == nil { + return nil, nil, nil, 0, errors.New("sysadmin: GetForUpdate requires transaction") + } + var raw []byte + var updatedBy *int64 + var updatedAt time.Time + var revision int64 + err := tx.QueryRow(ctx, ` + SELECT value, updated_by, updated_at, revision FROM system_settings WHERE key = $1 FOR UPDATE + `, key).Scan(&raw, &updatedBy, &updatedAt, &revision) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, nil, 0, ErrSettingNotFound + } + return nil, nil, nil, 0, err + } + return json.RawMessage(raw), updatedBy, &updatedAt, revision, nil }