From 3ff59ddd5fdbc56d10da4f5601b1b5ab95fd7286 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 4 Oct 2026 03:43:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=B3=BB=E7=BB=9F=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=9A=94=E7=A6=BB=E5=B9=B6=E8=A1=A5=E5=85=85=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=AB=9E=E4=BA=89=E4=B8=8E=E6=9D=83=E9=99=90=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../httpapi/auth_optional_email_test.go | 8 ++- backend/internal/httpapi/middleware_test.go | 2 +- doc/Linux生产流程测试记录.md | 11 ++++ doc/Scert开发任务清单.md | 8 +++ doc/Scert整改任务执行清单.md | 11 ++++ doc/Scert整改合并优化清单.md | 12 +++++ doc/基础整改任务规格.md | 2 +- doc/核心能力任务规格.md | 8 +++ frontend/src/views/Dashboard/Dashboard.js | 24 ++++++--- .../src/views/Dashboard/Dashboard.test.js | 3 ++ .../views/Dashboard/SettingsAccess.test.js | 50 +++++++++++++++++++ 11 files changed, 127 insertions(+), 12 deletions(-) diff --git a/backend/internal/httpapi/auth_optional_email_test.go b/backend/internal/httpapi/auth_optional_email_test.go index bacc45c..b9e770e 100644 --- a/backend/internal/httpapi/auth_optional_email_test.go +++ b/backend/internal/httpapi/auth_optional_email_test.go @@ -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) diff --git a/backend/internal/httpapi/middleware_test.go b/backend/internal/httpapi/middleware_test.go index 45587c6..f44d098 100644 --- a/backend/internal/httpapi/middleware_test.go +++ b/backend/internal/httpapi/middleware_test.go @@ -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()) } diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 3848e90..4270184 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -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 地址。 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 6fe47dc..934ad54 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -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`。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 26793f1..2986b31 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -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` 已推送。本节覆盖下方“等待回归与部署”的历史状态,不改变总体目标。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 4304ca7..5d80d58 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -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 不再属于尚未接入的开发项,但公共域名实际签发仍未验收。 diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index 8a4f75b..8fb6026 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -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、远端部署及生产矩阵验证。 diff --git a/doc/核心能力任务规格.md b/doc/核心能力任务规格.md index 273301b..ec2f1e6 100644 --- a/doc/核心能力任务规格.md +++ b/doc/核心能力任务规格.md @@ -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 建设统一证书中心 diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 8c442e5..207e268 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -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, diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 3bf5f03..33ee147 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -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); diff --git a/frontend/src/views/Dashboard/SettingsAccess.test.js b/frontend/src/views/Dashboard/SettingsAccess.test.js index 1a3d6f5..9aceb3e 100644 --- a/frontend/src/views/Dashboard/SettingsAccess.test.js +++ b/frontend/src/views/Dashboard/SettingsAccess.test.js @@ -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, /系统设置访问被拒绝/); +});