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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 09:15:15 +00:00
co-authored by Cursor
parent b4b300bb8a
commit a2cbaf1e33
4 changed files with 261 additions and 26 deletions
+13 -2
View File
@@ -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:
+96
View File
@@ -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)
+53 -10
View File
@@ -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:
+99 -14
View File
@@ -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
}