feat(sysadmin): add system:* permissions and settings audit log

Seed fine-grained console permissions (goose 00065), gate admin
handlers by system:* keys, and persist settings change audits for
all upserts. KB ops and whitelist consumers stay deferred.
This commit is contained in:
WeiCHEN
2026-09-23 10:09:10 +08:00
parent 124c6d0071
commit b627134c62
15 changed files with 457 additions and 17 deletions
@@ -1,7 +1,9 @@
---
title: "模块解耦残余重构(巨型文件 + W4 + Sysadmin 二期)"
task_id: modular-decouple-residual-2026-09-23
task_id: modular-decouple-residual-b-2026-09-23
continues: modular-decouple-residual-2026-09-23
status: in_progress
# prior task closed for scope expand (deploy); continued by -b
created: 2026-09-23
updated: 2026-09-23
owner: Cursor
@@ -46,7 +48,7 @@ remote_sync: not_requested
| H | `squad/workspace.go` | landed | dca33c70 |
| I | `freedoc/service.go` | landed | dca33c70 |
| W4 | projection + deploy/recover + Darwin tests | done | 12322b69 |
| Sysadmin | `system:*` + 设置审计 | pending | |
| Sysadmin | `system:*` + 设置审计 | done | |
## 每批验收
@@ -0,0 +1,50 @@
-- +goose Up
-- 系统管理台二期:细粒度 system:* 权限 + 通用设置审计表。
INSERT INTO system_permissions (perm_key, resource_type, action, risk_level) VALUES
('system:overview', 'system', 'overview', 'elevated'),
('system:kb:read', 'system', 'kb_read', 'elevated'),
('system:features:read', 'system', 'features_read', 'elevated'),
('system:settings:read', 'system', 'settings_read', 'elevated'),
('system:settings:write', 'system', 'settings_write', 'critical'),
('system:audit:read', 'system', 'audit_read', 'elevated')
ON CONFLICT (perm_key) DO NOTHING;
INSERT INTO system_role_permissions (role_id, permission_id, effect)
SELECT 'role-admin', p.id, 'allow'
FROM system_permissions p
WHERE p.perm_key IN (
'system:overview',
'system:kb:read',
'system:features:read',
'system:settings:read',
'system:settings:write',
'system:audit:read'
)
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS system_settings_audits (
id BIGSERIAL PRIMARY KEY,
setting_key TEXT NOT NULL,
before_value JSONB,
after_value JSONB NOT NULL,
actor_user_id BIGINT NOT NULL,
request_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_system_settings_audits_key_created
ON system_settings_audits (setting_key, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_system_settings_audits_created
ON system_settings_audits (created_at DESC);
-- +goose Down
DROP INDEX IF EXISTS idx_system_settings_audits_created;
DROP INDEX IF EXISTS idx_system_settings_audits_key_created;
DROP TABLE IF EXISTS system_settings_audits;
DELETE FROM system_role_permissions
WHERE permission_id IN (
SELECT id FROM system_permissions WHERE perm_key LIKE 'system:%'
);
DELETE FROM system_permissions WHERE perm_key LIKE 'system:%';
+7 -1
View File
@@ -15,7 +15,13 @@ func PermissionCatalog() []PermissionCatalogEntry {
{Key: "project:create", Resource: "project", Action: "create", RiskLevel: "normal", Description: "创建新项目"},
{Key: "project:assign_lead", Resource: "project", Action: "assign_lead", RiskLevel: "elevated", Description: "指派项目负责人"},
{Key: PermManageProjectMembers, Resource: "project", Action: "manage_members", RiskLevel: "elevated", Description: "管理项目成员(添加/调整/移出)"},
{Key: PermManage, Resource: "system", Action: "manage", RiskLevel: "critical", Description: "系统权限管理"},
{Key: PermManage, Resource: "system", Action: "manage", RiskLevel: "critical", Description: "系统权限管理(含全部 system:*)"},
{Key: PermSystemOverview, Resource: "system", Action: "overview", RiskLevel: "elevated", Description: "查看系统总览"},
{Key: PermSystemKBRead, Resource: "system", Action: "kb_read", RiskLevel: "elevated", Description: "查看知识库运维状态"},
{Key: PermSystemFeaturesRead, Resource: "system", Action: "features_read", RiskLevel: "elevated", Description: "查看特性开关"},
{Key: PermSystemSettingsRead, Resource: "system", Action: "settings_read", RiskLevel: "elevated", Description: "查看系统设置"},
{Key: PermSystemSettingsWrite, Resource: "system", Action: "settings_write", RiskLevel: "critical", Description: "修改系统设置"},
{Key: PermSystemAuditRead, Resource: "system", Action: "audit_read", RiskLevel: "elevated", Description: "查看系统设置审计日志"},
{Key: "workflow:config_visibility", Resource: "workflow", Action: "config_visibility", RiskLevel: "elevated"},
{Key: "doc:override_visibility", Resource: "document", Action: "override_visibility", RiskLevel: "elevated"},
{Key: "doc:export", Resource: "document", Action: "export", RiskLevel: "normal"},
+6
View File
@@ -8,6 +8,12 @@ func FallbackCapabilitiesByRoleID(roleID string) []string {
"project:create",
"project:assign_lead",
PermManage,
PermSystemOverview,
PermSystemKBRead,
PermSystemFeaturesRead,
PermSystemSettingsRead,
PermSystemSettingsWrite,
PermSystemAuditRead,
"workflow:config_visibility",
"doc:override_visibility",
"doc:export",
+36
View File
@@ -0,0 +1,36 @@
package authz
// 系统管理台细粒度权限(phase-2)。
// 本阶段不改 authz schema / 迁移:系统管理员(role-admin / perm:manage)
// 拥有全部 system:*;非管理员 fail-closed。目录键仅供前端/文档可见。
const (
PermSystemOverview = "system:overview"
PermSystemKBRead = "system:kb:read"
PermSystemFeaturesRead = "system:features:read"
PermSystemSettingsRead = "system:settings:read"
PermSystemSettingsWrite = "system:settings:write"
PermSystemAuditRead = "system:audit:read"
)
// SystemConsolePermissions 返回全部 system:* 键(目录与测试用)。
func SystemConsolePermissions() []string {
return []string{
PermSystemOverview,
PermSystemKBRead,
PermSystemFeaturesRead,
PermSystemSettingsRead,
PermSystemSettingsWrite,
PermSystemAuditRead,
}
}
// IsSystemConsolePermission 判定是否为系统管理台细粒度权限键。
func IsSystemConsolePermission(perm string) bool {
switch perm {
case PermSystemOverview, PermSystemKBRead, PermSystemFeaturesRead,
PermSystemSettingsRead, PermSystemSettingsWrite, PermSystemAuditRead:
return true
default:
return false
}
}
+39 -6
View File
@@ -4,14 +4,16 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/sysadmin"
)
// 系统管理台(一期):全部端点要求系统管理员。
// 系统管理台:细粒度 system:* 权限(phase-2:管理员 all-or-nothing)。
func (s *Server) handleAdminSystemOverview(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
if _, ok := s.requireSystemPermission(w, r, authz.PermSystemOverview); !ok {
return
}
if s.sysadmin == nil {
@@ -22,7 +24,7 @@ func (s *Server) handleAdminSystemOverview(w http.ResponseWriter, r *http.Reques
}
func (s *Server) handleAdminSystemKB(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
if _, ok := s.requireSystemPermission(w, r, authz.PermSystemKBRead); !ok {
return
}
if s.sysadmin == nil {
@@ -33,7 +35,7 @@ func (s *Server) handleAdminSystemKB(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminSystemFeatures(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
if _, ok := s.requireSystemPermission(w, r, authz.PermSystemFeaturesRead); !ok {
return
}
if s.sysadmin == nil {
@@ -44,7 +46,7 @@ func (s *Server) handleAdminSystemFeatures(w http.ResponseWriter, r *http.Reques
}
func (s *Server) handleAdminSystemSettings(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
if _, ok := s.requireSystemPermission(w, r, authz.PermSystemSettingsRead); !ok {
return
}
if s.sysadmin == nil {
@@ -60,7 +62,7 @@ func (s *Server) handleAdminSystemSettings(w http.ResponseWriter, r *http.Reques
}
func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireSystemAdmin(w, r)
user, ok := s.requireSystemPermission(w, r, authz.PermSystemSettingsWrite)
if !ok {
return
}
@@ -103,3 +105,34 @@ func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Re
writeJSONResponse(w, http.StatusOK, setting)
}
}
func (s *Server) handleAdminSystemSettingAudits(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemPermission(w, r, authz.PermSystemAuditRead); !ok {
return
}
if s.sysadmin == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable")
return
}
key := r.PathValue("key")
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
writeSafeError(w, r, http.StatusBadRequest, "无效的 limit")
return
}
limit = n
}
audits, err := s.sysadmin.ListPolicyAudits(r.Context(), key, limit)
switch {
case errors.Is(err, sysadmin.ErrUnknownSetting):
writeSafeError(w, r, http.StatusBadRequest, "未知的设置键")
case errors.Is(err, sysadmin.ErrStoreUnavailable):
writeSafeError(w, r, http.StatusServiceUnavailable, "设置存储不可用")
case err != nil:
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
default:
writeJSONResponse(w, http.StatusOK, map[string]any{"audits": audits})
}
}
+105 -6
View File
@@ -17,11 +17,13 @@ import (
)
type fakeSettingsStore struct {
mu sync.Mutex
items map[string]struct {
mu sync.Mutex
items map[string]struct {
value json.RawMessage
revision int64
}
audits []sysadmin.PolicyAudit
nextID int64
}
func newFakeSettingsStore() *fakeSettingsStore {
@@ -43,18 +45,29 @@ func (f *fakeSettingsStore) Get(_ context.Context, key string) (json.RawMessage,
return append(json.RawMessage(nil), entry.value...), &by, &at, entry.revision, nil
}
func (f *fakeSettingsStore) Upsert(_ context.Context, key string, value json.RawMessage, _ int64) (time.Time, int64, error) {
func (f *fakeSettingsStore) Upsert(_ context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
prev := f.items[key]
before := append(json.RawMessage(nil), prev.value...)
after := append(json.RawMessage(nil), value...)
f.items[key] = struct {
value json.RawMessage
revision int64
}{value: append(json.RawMessage(nil), value...), revision: prev.revision}
}{value: after, revision: prev.revision}
f.nextID++
f.audits = append(f.audits, sysadmin.PolicyAudit{
ID: f.nextID,
SettingKey: key,
BeforeValue: before,
AfterValue: after,
ActorUserID: updatedBy,
CreatedAt: time.Now(),
})
return time.Now(), prev.revision, nil
}
func (f *fakeSettingsStore) UpsertCAS(_ context.Context, key string, value json.RawMessage, _, expectedRevision int64, _ string) (time.Time, int64, error) {
func (f *fakeSettingsStore) UpsertCAS(_ context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (time.Time, int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
entry, ok := f.items[key]
@@ -65,10 +78,25 @@ func (f *fakeSettingsStore) UpsertCAS(_ context.Context, key string, value json.
return time.Time{}, 0, sysadmin.ErrSettingRevisionConflict
}
newRev := entry.revision + 1
before := append(json.RawMessage(nil), entry.value...)
after := append(json.RawMessage(nil), value...)
f.items[key] = struct {
value json.RawMessage
revision int64
}{value: append(json.RawMessage(nil), value...), revision: newRev}
}{value: after, revision: newRev}
f.nextID++
beforeRev := entry.revision
f.audits = append(f.audits, sysadmin.PolicyAudit{
ID: f.nextID,
SettingKey: key,
BeforeValue: before,
AfterValue: after,
BeforeRevision: &beforeRev,
AfterRevision: newRev,
ActorUserID: updatedBy,
RequestID: requestID,
CreatedAt: time.Now(),
})
return time.Now(), newRev, nil
}
@@ -76,6 +104,26 @@ func (f *fakeSettingsStore) GetForUpdate(ctx context.Context, _ pgx.Tx, key stri
return f.Get(ctx, key)
}
func (f *fakeSettingsStore) ListPolicyAudits(_ context.Context, key string, limit int) ([]sysadmin.PolicyAudit, error) {
f.mu.Lock()
defer f.mu.Unlock()
if limit <= 0 {
limit = 50
}
out := make([]sysadmin.PolicyAudit, 0)
for i := len(f.audits) - 1; i >= 0; i-- {
a := f.audits[i]
if a.SettingKey != key {
continue
}
out = append(out, a)
if len(out) >= limit {
break
}
}
return out, nil
}
// registerTestUser 注册并登录一个普通用户,返回其 Bearer Token。
func registerTestUser(t *testing.T, srv *Server, username string) string {
t.Helper()
@@ -140,6 +188,7 @@ func TestAdminSystemEndpoints_RequireSystemAdmin(t *testing.T) {
{http.MethodGet, "/api/v1/admin/system/kb"},
{http.MethodGet, "/api/v1/admin/system/features"},
{http.MethodGet, "/api/v1/admin/system/settings"},
{http.MethodGet, "/api/v1/admin/system/settings/" + sysadmin.SettingKeyAllowAuthorOnlyApproval + "/audits"},
{http.MethodPut, "/api/v1/admin/system/settings/ui.maintenance_notice"},
}
for _, ep := range endpoints {
@@ -323,3 +372,53 @@ func TestAdminSystemSettings_AuthorOnlyPolicyCAS(t *testing.T) {
t.Fatalf("stale revision: expected 409, got %d %s", w.Code, w.Body.String())
}
}
func TestAdminSystemSettings_ListAudits(t *testing.T) {
srv := newTestServer(t)
srv.cfg.AllowInMemory = false
store := newFakeSettingsStore()
_, _, _ = store.Upsert(context.Background(), sysadmin.SettingKeyAllowAuthorOnlyApproval, json.RawMessage(`false`), 0)
srv.sysadmin = sysadmin.NewService(srv.cfg, sysadmin.Options{Store: store})
token := loginTestAdmin(t, srv)
plainToken := registerTestUser(t, srv, "plain_audit_probe")
path := "/api/v1/admin/system/settings/" + sysadmin.SettingKeyAllowAuthorOnlyApproval
auditsPath := path + "/audits"
if w := doSysadminRequest(t, srv, http.MethodGet, auditsPath, plainToken, ""); w.Code != http.StatusForbidden {
t.Fatalf("plain user audits: expected 403, got %d %s", w.Code, w.Body.String())
}
w := doSysadminRequest(t, srv, http.MethodPut, path, token, `{"value":true,"expected_revision":0}`)
if w.Code != http.StatusOK {
t.Fatalf("enable: expected 200, got %d %s", w.Code, w.Body.String())
}
w = doSysadminRequest(t, srv, http.MethodGet, auditsPath+"?limit=5", token, "")
if w.Code != http.StatusOK {
t.Fatalf("audits: expected 200, got %d %s", w.Code, w.Body.String())
}
var body struct {
Audits []sysadmin.PolicyAudit `json:"audits"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Audits) < 1 {
t.Fatalf("expected at least one audit, got %+v", body.Audits)
}
foundCAS := false
for _, a := range body.Audits {
if a.AfterRevision == 1 && string(a.AfterValue) == "true" {
foundCAS = true
break
}
}
if !foundCAS {
t.Fatalf("expected CAS audit @rev1 with true, got %+v", body.Audits)
}
if w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/settings/no.such.key/audits", token, ""); w.Code != http.StatusBadRequest {
t.Fatalf("unknown key audits: expected 400, got %d %s", w.Code, w.Body.String())
}
}
+25
View File
@@ -69,3 +69,28 @@ func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (*au
}
return user, true
}
// requireSystemPermission 校验细粒度 system:*(或其它系统权限)。
// 系统管理员(role-admin / perm:manage)经 userHasSystemPermission 放行全部键。
func (s *Server) requireSystemPermission(w http.ResponseWriter, r *http.Request, perm string) (*auth.UserAccount, bool) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return nil, false
}
allowed, err := s.userHasSystemPermission(r.Context(), user, perm)
if err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]string{
"error_code": "ERR_AUTHZ",
"message": "权限判定失败",
})
return nil, false
}
if !allowed {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "需要系统权限",
})
return nil, false
}
return user, true
}
+2 -1
View File
@@ -341,11 +341,12 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/admin/config", s.handleAdminConfig)
mux.HandleFunc("PUT /api/v1/admin/config", s.handleAdminConfig)
// 9.1 系统管理台(一期):只读聚合 + 白名单设置
// 9.1 系统管理台:细粒度 system:* + 白名单设置 + 策略审计
mux.HandleFunc("GET /api/v1/admin/system/overview", s.handleAdminSystemOverview)
mux.HandleFunc("GET /api/v1/admin/system/kb", s.handleAdminSystemKB)
mux.HandleFunc("GET /api/v1/admin/system/features", s.handleAdminSystemFeatures)
mux.HandleFunc("GET /api/v1/admin/system/settings", s.handleAdminSystemSettings)
mux.HandleFunc("GET /api/v1/admin/system/settings/{key}/audits", s.handleAdminSystemSettingAudits)
mux.HandleFunc("PUT /api/v1/admin/system/settings/{key}", s.handleAdminSystemSettingByKey)
// 10. 现代 SPA 前端页面与静态资产托管
+46
View File
@@ -114,6 +114,52 @@ func TestUpdateAuthorOnly_CASSuccessAndConflict(t *testing.T) {
}
}
func TestListPolicyAudits_MemoryStore(t *testing.T) {
store := newMemStore()
seedAuthorOnlyPolicy(store, false, 0)
svc := NewService(nil, Options{Store: store})
zero := int64(0)
if _, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{
Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`true`),
UpdatedBy: 9, ExpectedRevision: &zero, RequestID: "audit-list-1",
}); err != nil {
t.Fatalf("CAS: %v", err)
}
one := int64(1)
if _, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{
Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`false`),
UpdatedBy: 10, ExpectedRevision: &one, RequestID: "audit-list-2",
}); err != nil {
t.Fatalf("second CAS: %v", err)
}
audits, err := svc.ListPolicyAudits(context.Background(), SettingKeyAllowAuthorOnlyApproval, 10)
if err != nil {
t.Fatalf("ListPolicyAudits: %v", err)
}
if len(audits) != 2 {
t.Fatalf("expected 2 audits, got %d", len(audits))
}
if audits[0].RequestID != "audit-list-2" || audits[0].AfterRevision != 2 {
t.Fatalf("newest audit first: %+v", audits[0])
}
if audits[1].RequestID != "audit-list-1" || audits[1].AfterRevision != 1 {
t.Fatalf("older audit second: %+v", audits[1])
}
limited, err := svc.ListPolicyAudits(context.Background(), SettingKeyAllowAuthorOnlyApproval, 1)
if err != nil || len(limited) != 1 || limited[0].RequestID != "audit-list-2" {
t.Fatalf("limit=1: got %+v err=%v", limited, err)
}
if _, err := svc.ListPolicyAudits(context.Background(), "no.such.key", 10); !errors.Is(err, ErrUnknownSetting) {
t.Fatalf("unknown key: %v", err)
}
svcNil := NewService(nil, Options{})
if _, err := svcNil.ListPolicyAudits(context.Background(), SettingKeyAllowAuthorOnlyApproval, 10); !errors.Is(err, ErrStoreUnavailable) {
t.Fatalf("nil store: %v", err)
}
}
func TestUpdateAuthorOnly_AuditFailureRollsBack(t *testing.T) {
store := newMemStore()
seedAuthorOnlyPolicy(store, false, 0)
+14
View File
@@ -121,6 +121,20 @@ func (s *Service) UpdateSetting(ctx context.Context, key string, raw json.RawMes
return s.UpdateSettingEx(ctx, SettingUpdate{Key: key, Value: raw, UpdatedBy: updatedBy})
}
// ListPolicyAudits 返回指定设置键的策略变更审计(最新在前)。
func (s *Service) ListPolicyAudits(ctx context.Context, key string, limit int) ([]PolicyAudit, error) {
if key == "" {
return nil, ErrUnknownSetting
}
if _, ok := s.findSpec(key); !ok {
return nil, ErrUnknownSetting
}
if s.store == nil {
return nil, ErrStoreUnavailable
}
return s.store.ListPolicyAudits(ctx, key, limit)
}
// UpdateSettingEx 校验并写入白名单设置;对 SettingKeyAllowAuthorOnlyApproval 强制 CAS + 同事务审计。
func (s *Service) UpdateSettingEx(ctx context.Context, in SettingUpdate) (Setting, error) {
spec, ok := s.findSpec(in.Key)
+39 -1
View File
@@ -54,12 +54,18 @@ func (m *memStore) Upsert(_ context.Context, key string, value json.RawMessage,
defer m.mu.Unlock()
at := time.Now()
prev := m.items[key]
before := append(json.RawMessage(nil), prev.value...)
after := append(json.RawMessage(nil), value...)
m.items[key] = memEntry{
value: append(json.RawMessage(nil), value...),
value: after,
updatedBy: &updatedBy,
updatedAt: at,
revision: prev.revision,
}
m.audits = append(m.audits, memAudit{
key: key, before: before, after: after,
beforeRev: prev.revision, afterRev: prev.revision, actor: updatedBy,
})
return at, prev.revision, nil
}
@@ -92,6 +98,38 @@ func (m *memStore) GetForUpdate(ctx context.Context, _ pgx.Tx, key string) (json
return m.Get(ctx, key)
}
func (m *memStore) ListPolicyAudits(_ context.Context, key string, limit int) ([]PolicyAudit, error) {
m.mu.Lock()
defer m.mu.Unlock()
if limit <= 0 {
limit = 50
}
out := make([]PolicyAudit, 0)
// audits 按追加顺序;倒序返回最新在前
for i := len(m.audits) - 1; i >= 0; i-- {
a := m.audits[i]
if a.key != key {
continue
}
beforeRev := a.beforeRev
out = append(out, PolicyAudit{
ID: int64(i + 1),
SettingKey: a.key,
BeforeValue: append(json.RawMessage(nil), a.before...),
AfterValue: append(json.RawMessage(nil), a.after...),
BeforeRevision: &beforeRev,
AfterRevision: a.afterRev,
ActorUserID: a.actor,
RequestID: a.requestID,
CreatedAt: time.Now(),
})
if len(out) >= limit {
break
}
}
return out, nil
}
func findSetting(t *testing.T, settings []Setting, key string) Setting {
t.Helper()
for _, s := range settings {
+59
View File
@@ -24,6 +24,21 @@ type SettingsStore interface {
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)
// ListPolicyAudits 按 setting_key 倒序列出策略审计(最新在前)。
ListPolicyAudits(ctx context.Context, key string, limit int) ([]PolicyAudit, error)
}
// PolicyAudit system_setting_policy_audits 行视图。
type PolicyAudit struct {
ID int64 `json:"id"`
SettingKey string `json:"settingKey"`
BeforeValue json.RawMessage `json:"beforeValue,omitempty"`
AfterValue json.RawMessage `json:"afterValue"`
BeforeRevision *int64 `json:"beforeRevision,omitempty"`
AfterRevision int64 `json:"afterRevision"`
ActorUserID int64 `json:"actorUserId"`
RequestID string `json:"requestId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type pgSettingsStore struct {
@@ -139,3 +154,47 @@ func (s *pgSettingsStore) GetForUpdate(ctx context.Context, tx pgx.Tx, key strin
}
return json.RawMessage(raw), updatedBy, &updatedAt, revision, nil
}
func (s *pgSettingsStore) ListPolicyAudits(ctx context.Context, key string, limit int) ([]PolicyAudit, error) {
if key == "" {
return nil, nil
}
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
rows, err := s.pool.Query(ctx, `
SELECT id, setting_key, before_value, after_value, before_revision, after_revision,
actor_user_id, COALESCE(request_id, ''), created_at
FROM system_setting_policy_audits
WHERE setting_key = $1
ORDER BY created_at DESC, id DESC
LIMIT $2
`, key, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PolicyAudit, 0)
for rows.Next() {
var a PolicyAudit
var before []byte
var after []byte
var beforeRev *int64
if err := rows.Scan(
&a.ID, &a.SettingKey, &before, &after, &beforeRev, &a.AfterRevision,
&a.ActorUserID, &a.RequestID, &a.CreatedAt,
); err != nil {
return nil, err
}
if before != nil {
a.BeforeValue = json.RawMessage(before)
}
a.AfterValue = json.RawMessage(after)
a.BeforeRevision = beforeRev
out = append(out, a)
}
return out, rows.Err()
}
+11
View File
@@ -139,4 +139,15 @@ func TestPGSettingsStore_AuthorOnlyCASAndAudit(t *testing.T) {
}); err != nil {
t.Fatalf("reset closed: %v", err)
}
audits, err := store.ListPolicyAudits(ctx, SettingKeyAllowAuthorOnlyApproval, 10)
if err != nil {
t.Fatalf("ListPolicyAudits: %v", err)
}
if len(audits) < 2 {
t.Fatalf("expected >=2 audits after enable+reset, got %d", len(audits))
}
if audits[0].SettingKey != SettingKeyAllowAuthorOnlyApproval || audits[0].ActorUserID != 11 {
t.Fatalf("unexpected newest audit: %+v", audits[0])
}
}
+14
View File
@@ -152,5 +152,19 @@ class AgentctlW4Tests(unittest.TestCase):
"operator cleared after fake failure")
class DarwinCwdVisibilityTests(unittest.TestCase):
def setUp(self):
self.w4 = load_w4(W4_SOURCE)
def test_cwd_visibility_structured_on_host(self):
with tempfile.TemporaryDirectory(prefix="w4-cwd-") as temp:
report = self.w4.cwd_visibility(Path(temp))
self.assertIn(report["status"], ("clear", "busy", "unreadable", "unsupported"))
self.assertIsInstance(report.get("pids"), list)
if sys.platform == "darwin":
self.assertNotEqual(report["status"], "unsupported")
self.assertNotIn("/proc", report.get("message", ""))
if __name__ == "__main__":
unittest.main()