diff --git a/backend/internal/httpapi/dns_api_test.go b/backend/internal/httpapi/dns_api_test.go index 548158b..deb8588 100644 --- a/backend/internal/httpapi/dns_api_test.go +++ b/backend/internal/httpapi/dns_api_test.go @@ -42,6 +42,47 @@ func TestAdminDNSProvidersReturnsCatalog(t *testing.T) { } } +func TestAdminDNSMutationRequiresSuperAdmin(t *testing.T) { + cfg := config.Default(t.TempDir()) + cfg.SetupReady = true + server := New(Dependencies{Config: cfg, SetupDNS: &fakeSetupDNSVerifier{}}) + cookie := httpUserSessionCookie(server, "dns-admin", repository.UserRoleAdmin) + cases := []struct { + name string + method string + path string + body string + }{ + { + name: "在线测试", + method: http.MethodPost, + path: "/api/v1/dns/test", + body: `{"provider":"cloudflare","test_domain":"example.com","credentials":{"api_token":"test-token"}}`, + }, + { + name: "阿里云权限检查", + method: http.MethodPost, + path: "/api/v1/dns/aliyun/check", + body: `{"domain":"example.com"}`, + }, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + request := httptest.NewRequest(item.method, item.path, strings.NewReader(item.body)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(cookie) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("普通管理员执行 DNS 变更操作应返回 403,实际为 %d:%s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), "只有超级管理员可以管理系统设置") { + t.Fatalf("DNS 权限错误提示不明确:%s", response.Body.String()) + } + }) + } +} + func TestAdminDNSAccountsExposeProjectScope(t *testing.T) { cfg := config.Default(t.TempDir()) cfg.SetupReady = true @@ -122,7 +163,7 @@ func TestAdminDNSTestReturnsVerificationResult(t *testing.T) { "credentials":{"api_token":"test-token"} }`)) request.Header.Set("Content-Type", "application/json") - serveAdminHTTP(server, response, request) + serveSuperAdminHTTP(server, response, request) if response.Code != http.StatusOK { t.Fatalf("DNS 在线测试失败:%d %s", response.Code, response.Body.String()) } @@ -152,7 +193,7 @@ func TestAdminAliyunDNSTestReportsFastVerificationTimeout(t *testing.T) { "credentials":{"access_key_id":"test-id","access_key_secret":"test-secret","managed_zones":"example.com","allowed_domains":"example.com"} }`)) request.Header.Set("Content-Type", "application/json") - serveAdminHTTP(server, response, request) + serveSuperAdminHTTP(server, response, request) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"verification_timeout_secs":6`) { t.Fatalf("管理端阿里云 DNS 在线测试未返回 6 秒等待上限:%d %s", response.Code, response.Body.String()) } @@ -165,7 +206,7 @@ func TestAdminDNSTestRejectsUnavailableService(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/dns/test", strings.NewReader(`{"provider":"cloudflare","test_domain":"example.com","credentials":{"api_token":"test-token"}}`)) request.Header.Set("Content-Type", "application/json") - serveAdminHTTP(server, response, request) + serveSuperAdminHTTP(server, response, request) if response.Code != http.StatusServiceUnavailable { t.Fatalf("DNS 服务未初始化时应返回 503,实际为 %d:%s", response.Code, response.Body.String()) } diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index cca78c6..31661ec 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -221,11 +221,11 @@ func (s *Server) registerRoutes() { api.GET("/tasks/:id/attempts", s.listTaskAttempts) adminAPI.GET("/settings", s.getSettings) - adminAPI.PUT("/settings", s.updateSettings) + adminAPI.PUT("/settings", s.requireSuperAdminSystemConfig, s.updateSettings) adminAPI.GET("/dns/providers", s.listAdminDNSProviders) adminAPI.GET("/dns/accounts", s.listDNSAccounts) - adminAPI.POST("/dns/test", s.dnsOperation, s.testAdminDNS) - adminAPI.POST("/dns/aliyun/check", s.dnsOperation, s.checkAliyunDNSDomain) + adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS) + adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain) adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.longOperation(certificateTaskTimeout), s.runRenewalNow) domainAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles) domainAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate) diff --git a/backend/internal/httpapi/settings_api_test.go b/backend/internal/httpapi/settings_api_test.go index 506dcd9..7fdc174 100644 --- a/backend/internal/httpapi/settings_api_test.go +++ b/backend/internal/httpapi/settings_api_test.go @@ -31,6 +31,23 @@ func TestSettingsAPIRequiresAuthentication(t *testing.T) { } } +func TestSettingsAPIUpdateRequiresSuperAdmin(t *testing.T) { + cfg := config.Default(t.TempDir()) + cfg.SetupReady = true + server := New(Dependencies{Config: cfg}) + request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{"server":{"port":5443}}`)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(httpUserSessionCookie(server, "settings-admin", repository.UserRoleAdmin)) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("普通管理员修改系统设置应返回 403,实际为 %d:%s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), "只有超级管理员可以管理系统设置") { + t.Fatalf("系统设置权限错误提示不明确:%s", response.Body.String()) + } +} + func TestSettingsAPIUpdatesSafeFieldsWithoutReturningSecrets(t *testing.T) { dir := t.TempDir() cfg := config.Default(dir) diff --git a/backend/internal/httpapi/test_helpers_test.go b/backend/internal/httpapi/test_helpers_test.go index 66ebf3c..cc33f10 100644 --- a/backend/internal/httpapi/test_helpers_test.go +++ b/backend/internal/httpapi/test_helpers_test.go @@ -104,3 +104,8 @@ func serveAdminHTTP(server *Server, recorder *httptest.ResponseRecorder, request request.AddCookie(httpUserSessionCookie(server, "test-admin", repository.UserRoleAdmin)) server.Handler().ServeHTTP(recorder, request) } + +func serveSuperAdminHTTP(server *Server, recorder *httptest.ResponseRecorder, request *http.Request) { + request.AddCookie(httpUserSessionCookie(server, "test-super-admin", repository.UserRoleSuperAdmin)) + server.Handler().ServeHTTP(recorder, request) +} diff --git a/backend/internal/httpapi/users_api.go b/backend/internal/httpapi/users_api.go index fade8bf..0985180 100644 --- a/backend/internal/httpapi/users_api.go +++ b/backend/internal/httpapi/users_api.go @@ -23,6 +23,14 @@ type userRequest struct { } func (s *Server) requireSuperAdmin(c *gin.Context) { + s.requireSuperAdminAction(c, "管理用户") +} + +func (s *Server) requireSuperAdminSystemConfig(c *gin.Context) { + s.requireSuperAdminAction(c, "管理系统设置") +} + +func (s *Server) requireSuperAdminAction(c *gin.Context, action string) { s.configMu.RLock() setupReady := s.config.SetupReady s.configMu.RUnlock() @@ -39,7 +47,7 @@ func (s *Server) requireSuperAdmin(c *gin.Context) { return } if session.Role != repository.UserRoleSuperAdmin { - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "只有超级管理员可以管理用户", "request_id": requestIDFromContext(c)}) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "只有超级管理员可以" + action, "request_id": requestIDFromContext(c)}) return } if !s.sameOriginForAPI(c.Request) { diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index a0ba305..14ad66e 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -781,4 +781,17 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 - [~] P0-07 仍需继续完成三数据库生产矩阵、浏览器清缓存验收、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务及最终生产门禁。 - [!] 总体验收不得升级,P0-03 仍为“完成,待复审”。 -下一步完成开发阶段剩余非权限项后,再统一执行生产构建、部署和实机验收。 \ No newline at end of file +下一步完成开发阶段剩余非权限项后,再统一执行生产构建、部署和实机验收。 + +## 本轮开发进度(2026-09-29,续) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮完成全局系统设置与 DNS 管理权限收紧,不将局部自动化测试结果视为生产部署验收通过。 + +- [x] 普通管理员保留系统设置、DNS 服务商目录和 DNS 账户归属信息的读取能力。 +- [x] 系统设置修改、DNS API 在线测试和阿里云 DNS 权限检查统一限制为超级管理员。 +- [x] 前端普通管理员进入系统设置时改为只读,并隐藏全局续期执行按钮;后端继续保留权限拒绝保护。 +- [x] 新增后端全局配置权限回归测试和前端普通管理员操作保护测试;前端 230 项定向测试通过,DNS、设置和续期定向后端测试通过。 +- [~] P0-07 仍需继续完成三数据库生产矩阵、浏览器清缓存验收、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务及最终生产门禁。 +- [!] 总体验收不得升级,P0-03 仍为“完成,待复审”。 + +下一步继续审查剩余资源权限联动,开发阶段完成后统一执行生产构建、部署和实机验收。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 8eff628..9353f24 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1421,4 +1421,17 @@ P0-11 生产验收门禁 - [~] P0-07 仍需继续完成三数据库生产矩阵、浏览器清缓存验收、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务及最终生产门禁。 - [!] 总体验收不得升级,P0-03 仍为“完成,待复审”。 -下一步完成开发阶段剩余非权限项后,再统一执行生产构建、部署和实机验收。 \ No newline at end of file +下一步完成开发阶段剩余非权限项后,再统一执行生产构建、部署和实机验收。 + +## 本轮开发进度(2026-09-29,续) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮完成全局系统设置与 DNS 管理权限收紧,不将局部自动化测试结果视为生产部署验收通过。 + +- [x] 普通管理员保留系统设置、DNS 服务商目录和 DNS 账户归属信息的读取能力。 +- [x] 系统设置修改、DNS API 在线测试和阿里云 DNS 权限检查统一限制为超级管理员。 +- [x] 前端普通管理员进入系统设置时改为只读,并隐藏全局续期执行按钮;后端继续保留权限拒绝保护。 +- [x] 新增后端全局配置权限回归测试和前端普通管理员操作保护测试;前端 230 项定向测试通过,DNS、设置和续期定向后端测试通过。 +- [~] P0-07 仍需继续完成三数据库生产矩阵、浏览器清缓存验收、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务及最终生产门禁。 +- [!] 总体验收不得升级,P0-03 仍为“完成,待复审”。 + +下一步继续审查剩余资源权限联动,开发阶段完成后统一执行生产构建、部署和实机验收。 diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 2ea9422..dc310be 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -15,6 +15,8 @@ function dnsVerificationTimeoutFor(providerID) { export function useDashboard() { const status = ref(null); const currentUser = ref(null); + const canManageSystemSettings = computed(() => currentUser.value?.role === "super_admin"); + const canRunRenewal = computed(() => currentUser.value?.role === "super_admin"); const domains = ref([]); const servers = ref([]); const sshKeys = ref([]); @@ -864,6 +866,10 @@ export function useDashboard() { } async function runRenewalNow() { + if (!canRunRenewal.value) { + error.value = "只有超级管理员可以执行续期检查"; + return; + } error.value = ""; notice.value = ""; try { @@ -904,6 +910,10 @@ export function useDashboard() { } async function testDNS() { + if (!canManageSystemSettings.value) { + dnsTestError.value = "只有超级管理员可以进行 DNS 在线测试"; + return; + } handleDNSProviderChange(); const dns = settingsForm.value.dns; const provider = selectedDNSProvider.value; @@ -986,6 +996,10 @@ export function useDashboard() { async function saveSettings() { settingsError.value = ""; + if (!canManageSystemSettings.value) { + settingsError.value = "只有超级管理员可以保存系统设置"; + return; + } verificationClock.value = Date.now(); if (!dnsReadyToSave.value) { settingsError.value = "DNS 服务商配置尚未通过在线测试,请先完成测试"; @@ -1216,6 +1230,8 @@ export function useDashboard() { ...domainForm, status, currentUser, + canManageSystemSettings, + canRunRenewal, domains, servers, sshKeys, diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 25e54d7..5b4886f 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -276,11 +276,25 @@ async function setup(t, request) { return Response.json({ items: [] }); }); const { state } = mounted; + state.currentUser.value = { account: "test-admin", role: "super_admin" }; state.openSettings(); await nextTick(); return mounted; } +test("普通管理员不能在前端执行全局续期、DNS验证或保存系统设置", async (t) => { + const { state } = await setup(t, () => verifiedResponse()); + state.currentUser.value = { account: "operator", role: "admin" }; + state.showSettings.value = true; + + await state.testDNS(); + assert.equal(state.dnsTestError.value, "只有超级管理员可以进行 DNS 在线测试"); + await state.saveSettings(); + assert.equal(state.settingsError.value, "只有超级管理员可以保存系统设置"); + await state.runRenewalNow(); + assert.equal(state.error.value, "只有超级管理员可以执行续期检查"); +}); + test("编辑服务器正确保留已有密码状态,新建和未配置密码仍要求输入", async (t) => { const { state } = await setup(t, () => verifiedResponse()); state.openServerEdit({ id: "configured", auth_method: "password", password_configured: true }); @@ -540,6 +554,7 @@ test("settingsReadEditSaveContract", async (t) => { } return Response.json({ items: [] }); }); + state.currentUser.value = { account: "test-admin", role: "super_admin" }; state.openSettings(); state.settingsForm.value.acme.renewal_threshold_days = 91; assert.equal(state.dnsReadyToSave.value, true); diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index d8f3086..ff7a018 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -129,6 +129,7 @@ const { validateDomain, deployDomain, runRenewalNow, + canRunRenewal, cancelTask, openTaskDetails, closeTaskDetails, @@ -158,7 +159,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi - +

+ 当前账号仅可查看系统设置,修改配置和 DNS 在线验证需要超级管理员权限。 +

+
+