diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index e1be0b2..d4f4c42 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -247,8 +247,8 @@ func (s *Server) registerRoutes() { api.GET("/tasks/:id/deployment-results", s.listTaskDeploymentResults) api.GET("/tasks/:id/validation-results", s.listTaskValidationResults) - adminAPI.GET("/settings", s.getSettings) - adminAPI.PUT("/settings", s.requireSuperAdminSystemConfig, s.updateSettings) + s.router.GET("/api/v1/settings", privateProjectResponse, s.requireSuperAdminSystemConfig, s.getSettings) + s.router.PUT("/api/v1/settings", privateProjectResponse, s.requireSuperAdminSystemConfig, s.updateSettings) adminAPI.GET("/dns/providers", s.listAdminDNSProviders) adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS) adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain) diff --git a/backend/internal/httpapi/settings_scope_test.go b/backend/internal/httpapi/settings_scope_test.go new file mode 100644 index 0000000..86eea31 --- /dev/null +++ b/backend/internal/httpapi/settings_scope_test.go @@ -0,0 +1,64 @@ +package httpapi + +import ( + "net/http" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestSettingsReadScope(t *testing.T) { + f, secrets := newDNSAccountScopeFixture(t) + f.server.config.Notification.SMTPUsername = "system-only-notification-account" + for _, item := range []struct { + actor string + want int + }{ + {"", http.StatusUnauthorized}, + {"global-admin", http.StatusForbidden}, + {"manager", http.StatusForbidden}, + {"viewer", http.StatusForbidden}, + {"target", http.StatusForbidden}, + {"primary-super-admin", http.StatusOK}, + } { + t.Run(item.actor, func(t *testing.T) { + response := serveUserRequest(f.server, f.cookies[item.actor], http.MethodGet, "/api/v1/settings", "") + if response.Code != item.want { + t.Fatalf("系统设置读取范围错误:%s,状态=%d", item.actor, response.Code) + } + if response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" { + t.Error("系统设置响应缺少身份缓存隔离") + } + if item.want != http.StatusOK { + for _, forbidden := range []string{"project-a", "system-only-notification-account", "account_email", "credential_configured"} { + if strings.Contains(response.Body.String(), forbidden) { + t.Fatal("被拒绝的请求泄露系统元数据") + } + } + } else if !strings.Contains(response.Body.String(), "system-only-notification-account") { + t.Fatal("超级管理员读取丢失设置内容") + } + if secrets.getCalls != 0 { + t.Fatal("读取设置不应读取密钥明文") + } + }) + } +} + +func TestSettingsReadRejectsChangedAccountSession(t *testing.T) { + f := newProjectMemberAPIFixture(t) + cookie := httpUserSessionCookie(f.server, "temporary-super-admin", repository.UserRoleSuperAdmin) + response := serveUserRequest(f.server, cookie, http.MethodGet, "/api/v1/settings", "") + if response.Code != http.StatusOK { + t.Fatal("初始超级管理员不可读") + } + // 修改隔离数据库的角色,原 cookie 不得继续继承曾经的系统读取权限。 + if _, err := f.db.ExecContext(t.Context(), "UPDATE users SET role = ? WHERE id = ?", repository.UserRoleAdmin, "temporary-super-admin"); err != nil { + t.Fatal(err) + } + response = serveUserRequest(f.server, cookie, http.MethodGet, "/api/v1/settings", "") + if response.Code != http.StatusUnauthorized || response.Header().Get("Cache-Control") != "private, no-store" { + t.Fatalf("降权后的旧会话未失效或可被缓存:%d", response.Code) + } +} diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index a88f073..8c442e5 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -1023,13 +1023,27 @@ export function useDashboard() { } } + let loadDataVersion = 0; + async function loadData() { + const version = ++loadDataVersion; loading.value = true; error.value = ""; + currentUser.value = null; + settings.value = null; + settingsForm.value = normalizeSettings(null); + showSettings.value = false; try { + const authResponse = await fetchAdmin("/api/v1/auth/session"); + if (version !== loadDataVersion) return; + if (!authResponse.ok) { + throw new Error(await responseError(authResponse, "读取登录会话失败")); + } + const session = await authResponse.json(); + if (version !== loadDataVersion) return; + currentUser.value = session; const [ statusResponse, - authResponse, domainResponse, serverResponse, certificateResponse, @@ -1039,15 +1053,15 @@ export function useDashboard() { dnsProviderResponse, ] = await Promise.all([ fetchAdmin("/api/v1/status"), - fetchAdmin("/api/v1/auth/session"), fetchAdmin("/api/v1/domains"), fetchAdmin("/api/v1/servers"), fetchAdmin("/api/v1/certificates"), fetchAdmin("/api/v1/tasks?limit=20"), fetchAdmin("/api/v1/audit-logs?limit=20"), - fetchAdmin("/api/v1/settings"), + session.role === "super_admin" ? fetchAdmin("/api/v1/settings") : Promise.resolve(null), fetchAdmin("/api/v1/dns/providers"), ]); + if (version !== loadDataVersion) return; const failedResponse = [ statusResponse, domainResponse, @@ -1057,18 +1071,19 @@ export function useDashboard() { auditResponse, settingsResponse, dnsProviderResponse, - ].find((response) => !response.ok); + ].find((response) => response && !response.ok); if (failedResponse) { throw new Error(await responseError(failedResponse, "读取管理数据失败")); } status.value = await statusResponse.json(); - currentUser.value = await authResponse.json(); domains.value = (await domainResponse.json()).items || []; servers.value = (await serverResponse.json()).items || []; certificates.value = (await certificateResponse.json()).items || []; tasks.value = (await taskResponse.json()).items || []; auditLogs.value = (await auditResponse.json()).items || []; - settings.value = await settingsResponse.json(); + const nextSettings = settingsResponse ? await settingsResponse.json() : null; + if (version !== loadDataVersion) return; + settings.value = nextSettings; const providerResult = await dnsProviderResponse.json(); dnsProviderCatalog.value = (providerResult.items || []).map((item) => ({ ...item, @@ -1087,11 +1102,15 @@ export function useDashboard() { ]; }), ); - domainServers.value = Object.fromEntries(associations); + if (version === loadDataVersion) domainServers.value = Object.fromEntries(associations); } catch (err) { + if (version !== loadDataVersion) return; + currentUser.value = null; + settings.value = null; + settingsForm.value = normalizeSettings(null); error.value = err.message || "无法连接核心服务,请先启动 scert serve"; } finally { - loading.value = false; + if (version === loadDataVersion) loading.value = false; } } diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 4d69db6..3bf5f03 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -3,6 +3,7 @@ import "./DeploymentResult.test.js"; import "./TaskMonitor.test.js"; import "./RenewalAction.test.js"; import "./DashboardLogout.test.js"; +import "./SettingsAccess.test.js"; import "./DomainModal.test.js"; import "./DomainForm.test.js"; import test from "node:test"; @@ -435,6 +436,7 @@ test("导入执行中阻止重复提交,成功后展示实际部署结果", as async function setup(t, request) { const mounted = await mountComposable(t, useDashboard, async (path) => { + if (path.endsWith("/auth/session")) return Response.json({ account: "test-admin", role: "super_admin" }); if (path === "/api/v1/dns/test") return request(); if (path.endsWith("/dns/providers")) return Response.json({ items: [aliyunProvider] }); if (path.endsWith("/settings")) return Response.json({ dns: { provider: "aliyun", credential_configured: true, verification_state: "verified" } }); diff --git a/frontend/src/views/Dashboard/SettingsAccess.test.js b/frontend/src/views/Dashboard/SettingsAccess.test.js new file mode 100644 index 0000000..1a3d6f5 --- /dev/null +++ b/frontend/src/views/Dashboard/SettingsAccess.test.js @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { useDashboard } from "./Dashboard.js"; +import { deferred, mountComposable } from "../../test-support/mount.js"; + +for (const role of ["admin", "user", "super_admin"]) { + test(`工作台按实时会话加载系统设置:${role}`, async (t) => { + const requests = []; + const { state } = await mountComposable(t, useDashboard, async (path) => { + requests.push(path); + if (path.endsWith("/auth/session")) return Response.json({ role }); + if (path.endsWith("/settings")) { + if (role !== "super_admin") return Response.json({ error: "无系统权限" }, { status: 403 }); + return Response.json({ notification: { smtp_username: "system-account" } }); + } + return Response.json({ items: [] }); + }); + assert.equal(state.error.value, ""); + assert.equal(requests.includes("/api/v1/settings"), role === "super_admin"); + assert.equal(state.settings.value?.notification?.smtp_username || "", role === "super_admin" ? "system-account" : ""); + }); +} + +test("工作台重载在确认角色之前清除旧系统配置", async (t) => { + let session = { role: "super_admin" }; + let pending; + let reads = 0; + const { state } = await mountComposable(t, useDashboard, async (path) => { + if (path.endsWith("/auth/session")) return pending ? pending.promise : Response.json(session); + if (path.endsWith("/settings")) { + reads += 1; + return Response.json({ notification: { smtp_username: "system-account" } }); + } + return Response.json({ items: [] }); + }); + state.showSettings.value = true; + state.settingsForm.value.notification.smtp_password = "unsaved-secret"; + pending = deferred(); + const reload = state.loadData(); + assert.equal(state.currentUser.value, null); + assert.equal(state.settings.value, null); + assert.equal(state.showSettings.value, false); + assert.equal(state.settingsForm.value.notification.smtp_password, ""); + session = { role: "admin" }; + pending.resolve(Response.json(session)); + await reload; + assert.equal(reads, 1); + assert.equal(state.error.value, ""); + assert.equal(state.canManageSystemSettings.value, false); +}); + +test("会话查询失败不能继续获取系统设置或使用旧角色", async (t) => { + let fail = false; + let reads = 0; + const { state } = await mountComposable(t, useDashboard, async (path) => { + if (path.endsWith("/auth/session")) { + return fail ? Response.json({ error: "会话服务异常" }, { status: 503 }) : Response.json({ role: "super_admin" }); + } + if (path.endsWith("/settings")) { + reads += 1; + return Response.json({ dns: { project_id: "system-project" } }); + } + return Response.json({ items: [] }); + }); + fail = true; + await state.loadData(); + assert.equal(reads, 1); + assert.equal(state.settings.value, null); + assert.equal(state.currentUser.value, null); + assert.match(state.error.value, /会话服务异常/); +});