完善系统设置隔离并补充会话竞争与权限回归

This commit is contained in:
xuans 2026-10-04 03:43:42 +08:00
parent 8b26cbf5a1
commit 3ff59ddd5f
11 changed files with 127 additions and 12 deletions

View File

@ -57,8 +57,12 @@ func TestAuthSessionsSupportOptionalEmail(t *testing.T) {
if !payload.Authenticated || payload.Email != "" || payload.Role != role || payload.IsAdmin != (role == repository.UserRoleAdmin) {
t.Fatalf("unexpected session identity: %+v", payload)
}
// 全局管理角色边界使用系统设置;域名读取现按项目成员身份判定。
request := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
// 普通管理员可读服务商目录,但不能读取超级管理员专属系统设置。
settings := serveUserRequest(server, cookies[0], http.MethodGet, "/api/v1/settings", "")
if settings.Code != http.StatusForbidden {
t.Fatalf("非超级管理员读取系统设置应返回 403,实际为 %d", settings.Code)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/dns/providers", nil)
request.AddCookie(cookies[0])
access := httptest.NewRecorder()
server.Handler().ServeHTTP(access, request)

View File

@ -108,7 +108,7 @@ func TestAdminAPIRejectsAuthenticatedRegularUser(t *testing.T) {
cfg.Admin.PasswordHash = string(hash)
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}})
cookie := httpUserSessionCookie(server, "user-1", repository.UserRoleUser)
response := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/settings", "")
response := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/dns/providers", "")
if response.Code != http.StatusForbidden {
t.Fatalf("普通用户访问管理接口应返回 403,实际为 %d:%s", response.Code, response.Body.String())
}

View File

@ -1,5 +1,16 @@
# Linux 生产流程测试记录
## 2026年10月4日03:40 系统设置越权读取修复
- 旧 `9c26db0` 工作树加入 `settings_scope_test.go`,执行 `scert-settings-scope-red-20261004-0333.service`,真实失败,退出码一。普通管理员 GET 原返回二百而非四百零三,响应未隔离缓存;角色降权后的旧会话原已返回四百零一,不误报为原认证失效。
- 修复:GET/PUT 均使用超级管理员实时会话中间件并先设置 `private, no-store` 与 `Vary: Cookie`;读取不访问明文凭据。前端先确认会话,仅超级管理员请求设置,响应解码完成后再次核验加载版本,避免旧请求覆盖新会话。
- 首次前端全量 284 项中一项失败:旧保存契约夹具未模拟超级管理员会话;补充真实会话响应,保留原数据提交断言。新增设置拒绝和旧响应竞争后,全量 286 项通过,Vite 构建通过。
- [✓] 设置专项修复后在第一台通过,耗时 0.364 秒。
- [!] `scert-settings-scope-green-20261004-0340.service` 已失败,退出码一。设置专项通过,HTTP API 全量 49.447 秒后两项失败:旧可选邮箱测试仍要求普通管理员读取全局设置、旧中间件测试仍要求通用管理错误文案。未运行后续 vet 和竞态,不以专项替代全量。
- [x] 调整旧权限契约测试:可选邮箱会话继续验证普通管理员可读服务商目录,同时新增普通用户与普通管理员均不可读系统设置的断言;通用管理拒绝文案使用真正的普通管理路由,系统设置专属拒绝由新范围测试覆盖。没有放宽实际系统设置权限。
- [~] 复跑单元 `scert-settings-scope-fixed-20261004-0343.service` 已启动;依次执行 HTTP API 全量、vet 和设置、DNS 范围、可选邮箱、管理中间件三轮竞态。日志同目录 `settings-scope-fixed.log`,最终状态待确认。
- [ ] 全量 Linux 回归和正式新包部署待完成;线上第一台仍为 `9c26db0`。没有清理生产数据库、操作第二台、修改公共 DNS、增加辅助脚本或备份。
## 2026年10月4日 真实 lego 客户端隔离协议回归
- [✓] 新增 `lego_http_protocol_test.go`、`lego_protocol_fixture_test.go` 正式 Go 用例,调用产品真实 LegoIssuer 和真实 HTTP 挑战服务。仅通过已有 HTTPClient 注入把 ACME 目录限制在回环 TLS CA;信任测试 CA,不跳过 TLS,禁止访问外部 ACME 地址。

View File

@ -1,5 +1,13 @@
# Scert 开发任务清单
## 2026年10月4日03:40 系统设置隔离开发进度
- [x] 全局系统设置 GET/PUT 超级管理员限制、私有无缓存响应、前端会话先行和并发旧响应拒绝已实现。
- [✓] 前端 286 项全量及构建通过,包含旧系统配置清理及普通角色正常工作台;修复后第一台后端设置专项通过。
- [~] 第一台 HTTP API 全量、vet、三轮专项竞态仍运行;尚不能标记本批已上线。
- [ ] 干净 Linux 全量、发行构建、第一台升级与实际入口复核。
- [!] DNS 多账户、CNAME、三库首次 Web、正常公共入口、系统邮件和密码重置仍有未完成任务;总体不宣称开发或生产验收全部完成。
## 2026年10月4日03:14 HTTP 验证开发与部署更新
本节优先于历史记录:HTTP-01 已接入,不再是完全未开发;第一台正式运行 `9c26db0`。

View File

@ -1,5 +1,16 @@
# Scert 整改任务执行清单
## 2026年10月4日03:40 系统设置隔离任务
- [✓] 需求确认:全局系统设置仅超级管理员读写;普通管理员仍可使用项目工作台,不能因设置接口被拒绝而整页失败。
- [x] 后端读取与写入统一实时权限和无缓存边界;前端按会话加载、清除旧配置并隔离并发响应。
- [✓] 前端全量 286 项和生产构建通过;旧源码后端红测已确认漏洞,修复后设置专项通过。
- [~] 第一台首批全量两项旧权限契约失败已修正;HTTP API 全量、vet、三轮专项竞态复跑中,单元为 `scert-settings-scope-fixed-20261004-0343.service`。首批失败记录保留。
- [ ] 干净源码 Linux 全量回归、正式发行构建及第一台部署复审。
- [!] 正常公共域名、系统发信授权、三库首次 Web 与剩余 DNS/ACME 门禁仍待完成;第二台只做推送,不重复主程序测试。
本节沿用已确认需求,检查点 `8b26cbf` 已推送;详细失败和复跑证据见《Linux生产流程测试记录》。
## 2026年10月4日03:14 本批复审及后续任务
沿用已确认需求,检查点 `7b670a0` 已推送。本节覆盖下方“等待回归与部署”的历史状态,不改变总体目标。

View File

@ -1,5 +1,17 @@
# Scert 整改、合并与优化清单
## 2026年10月4日03:40 系统设置读取隔离
本节按运行环境 UTC+8 记录,优先于历史状态。本批检查点 `8b26cbf` 已提交推送;只在第一台隔离工作树运行后端测试,尚未替换线上程序。
- [x] 系统设置 GET 与 PUT 均限制为实时超级管理员会话;成功、未登录和越权响应均禁止缓存,不改变项目 DNS 账户的独立权限。
- [x] 工作台先获取实时会话,仅超级管理员请求系统设置;重载先清除旧系统配置和未保存凭据,并拒绝旧并发响应覆盖新会话。
- [✓] 前端全量 286 项及 Vite 构建通过;包括普通管理员、普通用户、超级管理员、会话失败、降权、设置拒绝和旧响应竞争。旧保存契约夹具补充真实超级管理员会话,原数据断言未放宽。
- [✓] 第一台旧源码红测确认普通管理员原可读全局配置;修复后设置专项已通过。
- [~] 第一台首批 HTTP API 全量发现两项旧权限契约测试不适配收紧后的权限;已补充系统设置拒绝断言并保留普通管理访问边界,正在复跑全量、静态检查和三轮专项竞态,以执行单元最终状态为准。
- [ ] 干净提交全量 Linux 回归、正式发行包构建及第一台升级后复核。
- [!] 第一台正常公共域名入口、系统发信及密码重置、三库首次 Web、DNS 多账户与 CNAME 完整生命周期、公共 CA 签发和总体生产验收仍未完成。不以本批权限修复替代整体门禁。
## 2026年10月4日03:14 HTTP 验证回归与第一台部署
本节按运行环境 UTC+8 记录,优先于下方历史状态;仅 Linux 与 Web。第一台运行版本已从 `2513f93` 升级至 `9c26db0`,HTTP-01 不再属于尚未接入的开发项,但公共域名实际签发仍未验收。

View File

@ -486,7 +486,7 @@ P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收
下一步完成开发阶段剩余非权限项后,再统一执行生产构建、部署和实机验收。
## 本轮开发进度(2026-09-29 22:34)
- [x] 全局系统设置修改已收紧为超级管理员权限;普通管理员保留读取系统设置、DNS 服务商目录和 DNS 账户归属信息的能力。
- [x] 全局系统设置读取与修改均收紧为超级管理员权限;普通管理员保留 DNS 服务商目录及授权范围内 DNS 账户归属信息的读取能力,不再读取全局系统配置。2026年10月4日补读取隔离,部署状态见《Linux生产流程测试记录》。
- [x] DNS 在线测试、阿里云 DNS 检查和全局批量续期已收紧为超级管理员操作;前端只读、按钮隐藏和后端拒绝路径均已补充。
- [x] 本批次已补充对应 HTTP 与前端回归测试,代码和进度记录已提交并推送 `ba6bd80`。
- [~] 后续仍需完成回归执行、正式构建、三数据库、浏览器清缓存、密码 SSH、远端部署及生产矩阵验证。

View File

@ -1,5 +1,13 @@
# 核心能力任务规格
## 2026年10月4日03:40 元数据读取边界补强
- [x] 全局系统设置仅允许超级管理员读取和修改;普通管理员不再保留全局 SMTP、ACME、DNS 项目及路径元数据读取权限。项目 DNS 账户独立授权不变。
- [✓] 前端角色加载和并发隔离已补回归,全量 286 项及构建通过;第一台后端设置专项通过。
- [~] 第一台 HTTP API 全量、静态检查及专项竞态执行中;部署复审、其余元数据通道、完整多账户和 CNAME 生命周期仍未完成。
以上不整项关闭 P1 或总体生产门禁,证据以《Linux生产流程测试记录》为准。
覆盖 P1 全部 12 项,遵循《整改任务规格与验收约定.md》。当前不少业务集中于 Dashboard,不得把建议目录当作已经存在的独立页面;新页面均按 Vue、JS、CSS 就近拆分。
## P1-01 建设统一证书中心

View File

@ -1075,16 +1075,24 @@ export function useDashboard() {
if (failedResponse) {
throw new Error(await responseError(failedResponse, "读取管理数据失败"));
}
status.value = await statusResponse.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 || [];
const nextSettings = settingsResponse ? await settingsResponse.json() : null;
const [nextStatus, nextDomains, nextServers, nextCertificates, nextTasks, nextAudit, nextSettings, providerResult] = await Promise.all([
statusResponse.json(),
domainResponse.json(),
serverResponse.json(),
certificateResponse.json(),
taskResponse.json(),
auditResponse.json(),
settingsResponse ? settingsResponse.json() : Promise.resolve(null),
dnsProviderResponse.json(),
]);
if (version !== loadDataVersion) return;
status.value = nextStatus;
domains.value = nextDomains.items || [];
servers.value = nextServers.items || [];
certificates.value = nextCertificates.items || [];
tasks.value = nextTasks.items || [];
auditLogs.value = nextAudit.items || [];
settings.value = nextSettings;
const providerResult = await dnsProviderResponse.json();
dnsProviderCatalog.value = (providerResult.items || []).map((item) => ({
...item,
value: item.id,

View File

@ -719,6 +719,9 @@ test("settingsReadEditSaveContract", async (t) => {
};
let submitted;
const { state } = await mountComposable(t, useDashboard, async (path, options = {}) => {
if (path.endsWith("/auth/session")) {
return Response.json({ account: "test-admin", role: "super_admin" });
}
if (path.endsWith("/settings")) {
if (options.method === "PUT") {
submitted = JSON.parse(options.body);

View File

@ -69,3 +69,53 @@ test("会话查询失败不能继续获取系统设置或使用旧角色", async
assert.equal(state.currentUser.value, null);
assert.match(state.error.value, /会话服务异常/);
});
test("旧超级管理员响应不能覆盖新普通管理员工作台", async (t) => {
let role = "super_admin";
const pending = deferred();
const decoding = deferred();
const { state } = await mountComposable(t, useDashboard, async (path) => {
if (path.endsWith("/auth/session")) return Response.json({ role });
if (path.endsWith("/settings")) {
return {
ok: true,
json() {
decoding.resolve();
return pending.promise;
},
};
}
if (path.endsWith("/status")) return Response.json({ owner: role });
return Response.json({ items: [] });
});
await decoding.promise;
role = "admin";
await state.loadData();
pending.resolve({ notification: { smtp_username: "old-system-account" } });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(state.currentUser.value.role, "admin");
assert.equal(state.settings.value, null);
assert.equal(state.settingsForm.value.notification.smtp_username, "");
assert.equal(state.status.value.owner, "admin");
assert.equal(state.loading.value, false);
assert.equal(state.error.value, "");
});
test("系统设置拒绝访问时清除已加载的角色和配置", async (t) => {
let denied = false;
const { state } = await mountComposable(t, useDashboard, async (path) => {
if (path.endsWith("/auth/session")) return Response.json({ role: "super_admin" });
if (path.endsWith("/settings")) {
return denied
? Response.json({ error: "系统设置访问被拒绝" }, { status: 403 })
: Response.json({ notification: { smtp_username: "system-account" } });
}
return Response.json({ items: [] });
});
denied = true;
await state.loadData();
assert.equal(state.currentUser.value, null);
assert.equal(state.settings.value, null);
assert.equal(state.settingsForm.value.notification.smtp_username, "");
assert.match(state.error.value, /系统设置访问被拒绝/);
});