From b627134c6280d3a3931a44ceed0df3b3fd8e04d0 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 23 Sep 2026 10:09:10 +0800 Subject: [PATCH] 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. --- .../2026-09-23-modular-decouple-residual.md | 6 +- ...ystem-console-perms-and-settings-audit.sql | 50 ++++++++ internal/authz/catalog.go | 8 +- internal/authz/fallback.go | 6 + internal/authz/system_perms.go | 36 ++++++ internal/server/admin_system_handlers.go | 45 ++++++- internal/server/admin_system_handlers_test.go | 111 +++++++++++++++++- internal/server/principal_context.go | 25 ++++ internal/server/server.go | 3 +- internal/sysadmin/policy_test.go | 46 ++++++++ internal/sysadmin/settings.go | 14 +++ internal/sysadmin/settings_test.go | 40 ++++++- internal/sysadmin/store.go | 59 ++++++++++ internal/sysadmin/store_pg_test.go | 11 ++ scripts/tests/test_agentctl_w4.py | 14 +++ 15 files changed, 457 insertions(+), 17 deletions(-) create mode 100644 deploy/postgres/goose/00065_2026-09-23-system-console-perms-and-settings-audit.sql create mode 100644 internal/authz/system_perms.go diff --git a/.doc/ops/short-term/2026-09-23-modular-decouple-residual.md b/.doc/ops/short-term/2026-09-23-modular-decouple-residual.md index f9c63252..15847c14 100644 --- a/.doc/ops/short-term/2026-09-23-modular-decouple-residual.md +++ b/.doc/ops/short-term/2026-09-23-modular-decouple-residual.md @@ -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 | | ## 每批验收 diff --git a/deploy/postgres/goose/00065_2026-09-23-system-console-perms-and-settings-audit.sql b/deploy/postgres/goose/00065_2026-09-23-system-console-perms-and-settings-audit.sql new file mode 100644 index 00000000..0ca1c21d --- /dev/null +++ b/deploy/postgres/goose/00065_2026-09-23-system-console-perms-and-settings-audit.sql @@ -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:%'; diff --git a/internal/authz/catalog.go b/internal/authz/catalog.go index f9a0cdc3..b3cb1b1d 100644 --- a/internal/authz/catalog.go +++ b/internal/authz/catalog.go @@ -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"}, diff --git a/internal/authz/fallback.go b/internal/authz/fallback.go index 79087edc..aa49e237 100644 --- a/internal/authz/fallback.go +++ b/internal/authz/fallback.go @@ -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", diff --git a/internal/authz/system_perms.go b/internal/authz/system_perms.go new file mode 100644 index 00000000..9fb062fd --- /dev/null +++ b/internal/authz/system_perms.go @@ -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 + } +} diff --git a/internal/server/admin_system_handlers.go b/internal/server/admin_system_handlers.go index 73bfa6c1..c26e87cb 100644 --- a/internal/server/admin_system_handlers.go +++ b/internal/server/admin_system_handlers.go @@ -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}) + } +} diff --git a/internal/server/admin_system_handlers_test.go b/internal/server/admin_system_handlers_test.go index fb2e0647..0804cd63 100644 --- a/internal/server/admin_system_handlers_test.go +++ b/internal/server/admin_system_handlers_test.go @@ -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()) + } +} diff --git a/internal/server/principal_context.go b/internal/server/principal_context.go index 79930850..555a5bd9 100644 --- a/internal/server/principal_context.go +++ b/internal/server/principal_context.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 1ea6a2f8..e4b42f54 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 前端页面与静态资产托管 diff --git a/internal/sysadmin/policy_test.go b/internal/sysadmin/policy_test.go index bd6debff..8e87cbe7 100644 --- a/internal/sysadmin/policy_test.go +++ b/internal/sysadmin/policy_test.go @@ -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) diff --git a/internal/sysadmin/settings.go b/internal/sysadmin/settings.go index 89f6a2c5..e1720862 100644 --- a/internal/sysadmin/settings.go +++ b/internal/sysadmin/settings.go @@ -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) diff --git a/internal/sysadmin/settings_test.go b/internal/sysadmin/settings_test.go index aa0265ee..09cc8d2c 100644 --- a/internal/sysadmin/settings_test.go +++ b/internal/sysadmin/settings_test.go @@ -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 { diff --git a/internal/sysadmin/store.go b/internal/sysadmin/store.go index 475a0e33..8f685007 100644 --- a/internal/sysadmin/store.go +++ b/internal/sysadmin/store.go @@ -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() +} diff --git a/internal/sysadmin/store_pg_test.go b/internal/sysadmin/store_pg_test.go index c37b4ae7..b143f788 100644 --- a/internal/sysadmin/store_pg_test.go +++ b/internal/sysadmin/store_pg_test.go @@ -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]) + } } diff --git a/scripts/tests/test_agentctl_w4.py b/scripts/tests/test_agentctl_w4.py index 5e180a1f..4c493c19 100644 --- a/scripts/tests/test_agentctl_w4.py +++ b/scripts/tests/test_agentctl_w4.py @@ -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()