保存系统设置权限隔离修复检查点
This commit is contained in:
parent
6d425259f6
commit
8b26cbf5a1
@ -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)
|
||||
|
||||
64
backend/internal/httpapi/settings_scope_test.go
Normal file
64
backend/internal/httpapi/settings_scope_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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" } });
|
||||
|
||||
71
frontend/src/views/Dashboard/SettingsAccess.test.js
Normal file
71
frontend/src/views/Dashboard/SettingsAccess.test.js
Normal 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, /会话服务异常/);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user