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:
@@ -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:%';
|
||||
@@ -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"},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 前端页面与静态资产托管
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user