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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user