保存系统设置权限隔离修复检查点

This commit is contained in:
xuans 2026-10-04 03:36:32 +08:00
parent 6d425259f6
commit 8b26cbf5a1
5 changed files with 166 additions and 10 deletions

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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;
}
}

View File

@ -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" } });

View File

@ -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, /会话服务异常/);
});