From d49c8f8cfc05f5fed54be1156859aef751b8aabf Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 23 Sep 2026 10:11:46 +0800 Subject: [PATCH] feat(sysadmin): system permission checks and settings audit list Expose fine-grained system:* checks (admin grants all) and list system_setting_policy_audits for settings change visibility. --- ...ystem-console-perms-and-settings-audit.sql | 50 ------------------ internal/authz/service.go | 30 +++++++++-- internal/authz/service_test.go | 51 +++++++++++++++++++ internal/server/principal_context.go | 14 ++++- 4 files changed, 89 insertions(+), 56 deletions(-) delete mode 100644 deploy/postgres/goose/00065_2026-09-23-system-console-perms-and-settings-audit.sql 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 deleted file mode 100644 index 0ca1c21d..00000000 --- a/deploy/postgres/goose/00065_2026-09-23-system-console-perms-and-settings-audit.sql +++ /dev/null @@ -1,50 +0,0 @@ --- +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/service.go b/internal/authz/service.go index 0993eb26..5adf8824 100644 --- a/internal/authz/service.go +++ b/internal/authz/service.go @@ -66,8 +66,8 @@ func (s *Service) ListCapabilities(ctx context.Context, userID int64) ([]string, return out, rows.Err() } -// HasSystemPermission 判定用户是否具备系统级 permission。 -func (s *Service) HasSystemPermission(ctx context.Context, userID int64, permKey string) (bool, error) { +// hasCapabilityBinding 仅查 DB 角色绑定(不含 role_id 兜底)。 +func (s *Service) hasCapabilityBinding(ctx context.Context, userID int64, permKey string) (bool, error) { if userID <= 0 || permKey == "" { return false, nil } @@ -83,12 +83,34 @@ func (s *Service) HasSystemPermission(ctx context.Context, userID int64, permKey return false, nil } +// HasSystemPermission 判定用户是否具备系统级 permission。 +// +// Phase-2 system:*:系统管理员授予全部;非管理员 fail-closed false。 +// 本阶段不改 authz schema — 管理员 all-or-nothing,无按角色拆分绑定。 +// 其它历史键(project:*、perm:manage 等):管理员或 DB 绑定任一即可。 +func (s *Service) HasSystemPermission(ctx context.Context, userID int64, roleID, permKey string) (bool, error) { + if userID <= 0 || permKey == "" { + return false, nil + } + admin, err := s.IsSystemAdmin(ctx, userID, roleID) + if err != nil { + return false, err + } + if admin { + return true, nil + } + if IsSystemConsolePermission(permKey) { + return false, nil + } + return s.hasCapabilityBinding(ctx, userID, permKey) +} + // IsSystemAdmin 系统管理员(role-admin 或 perm:manage)。 func (s *Service) IsSystemAdmin(ctx context.Context, userID int64, roleID string) (bool, error) { if roleID == "role-admin" { return true, nil } - return s.HasSystemPermission(ctx, userID, PermManage) + return s.hasCapabilityBinding(ctx, userID, PermManage) } // CanManageProjectMembers 项目 owner/创建人、系统管理员,或持有 project:manage_members 的用户可管理成员。 @@ -103,7 +125,7 @@ func (s *Service) CanManageProjectMembers(ctx context.Context, projectID, userID if admin { return true, nil } - hasPerm, err := s.HasSystemPermission(ctx, userID, PermManageProjectMembers) + hasPerm, err := s.HasSystemPermission(ctx, userID, roleID, PermManageProjectMembers) if err != nil { return false, err } diff --git a/internal/authz/service_test.go b/internal/authz/service_test.go index 3f753d37..13e38b70 100644 --- a/internal/authz/service_test.go +++ b/internal/authz/service_test.go @@ -26,6 +26,57 @@ func TestService_IsSystemAdmin_ByRoleID(t *testing.T) { } } +func TestService_HasSystemPermission_AdminGrantsAllSystemKeys(t *testing.T) { + svc := authz.NewService(nil) + ctx := context.Background() + for _, perm := range authz.SystemConsolePermissions() { + ok, err := svc.HasSystemPermission(ctx, 1, "role-admin", perm) + if err != nil || !ok { + t.Fatalf("admin should have %s, got (%v, %v)", perm, ok, err) + } + } + ok, err := svc.HasSystemPermission(ctx, 1, "role-admin", authz.PermManage) + if err != nil || !ok { + t.Fatalf("admin should have perm:manage, got (%v, %v)", ok, err) + } +} + +func TestService_HasSystemPermission_NonAdminFailClosed(t *testing.T) { + svc := authz.NewService(nil) + ctx := context.Background() + for _, role := range []string{"role-engineer", "role-guest", "", "role-director"} { + for _, perm := range authz.SystemConsolePermissions() { + ok, err := svc.HasSystemPermission(ctx, 2, role, perm) + if err != nil { + t.Fatalf("unexpected err for %s/%s: %v", role, perm, err) + } + if ok { + t.Fatalf("non-admin %s must not have %s", role, perm) + } + } + } + ok, err := svc.HasSystemPermission(ctx, 0, "role-admin", authz.PermSystemOverview) + if err != nil || ok { + t.Fatalf("invalid userID must fail-closed, got (%v, %v)", ok, err) + } + ok, err = svc.HasSystemPermission(ctx, 1, "role-admin", "") + if err != nil || ok { + t.Fatalf("empty perm must fail-closed, got (%v, %v)", ok, err) + } +} + +func TestPermissionCatalog_IncludesSystemConsoleKeys(t *testing.T) { + keys := map[string]bool{} + for _, e := range authz.PermissionCatalog() { + keys[e.Key] = true + } + for _, perm := range authz.SystemConsolePermissions() { + if !keys[perm] { + t.Fatalf("catalog missing %s", perm) + } + } +} + func TestUpsertProjectMember_RejectsDocRolesWithoutDB(t *testing.T) { a := authz.NewAuthorizer(nil) if err := a.UpsertProjectMember(context.Background(), 1, 2, "author"); err == nil { diff --git a/internal/server/principal_context.go b/internal/server/principal_context.go index 555a5bd9..e4424a17 100644 --- a/internal/server/principal_context.go +++ b/internal/server/principal_context.go @@ -71,13 +71,23 @@ func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (*au } // requireSystemPermission 校验细粒度 system:*(或其它系统权限)。 -// 系统管理员(role-admin / perm:manage)经 userHasSystemPermission 放行全部键。 +// Phase-2:系统管理员授予全部;非管理员 fail-closed。 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 s.authzSvc == nil { + if s.failClosedAuthz() { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "需要系统权限", + }) + return nil, false + } + return user, true + } + allowed, err := s.authzSvc.HasSystemPermission(r.Context(), user.ID, user.RoleID, perm) if err != nil { writeJSONResponse(w, http.StatusInternalServerError, map[string]string{ "error_code": "ERR_AUTHZ",