修复DNS账户跨项目读取并补充撤权与失败隔离回归

This commit is contained in:
xuans 2026-10-04 01:35:13 +08:00
parent e060205a4d
commit 2513f935bc
12 changed files with 218 additions and 16 deletions

View File

@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
@ -28,7 +29,6 @@ type dnsAccountView struct {
// listDNSAccounts 仅列出当前绑定的配置,不枚举凭据仓库中的临时或历史密钥。
func (s *Server) listDNSAccounts(c *gin.Context) {
c.Header("Cache-Control", "no-store")
s.configMu.RLock()
cfg := s.config
s.configMu.RUnlock()
@ -37,16 +37,36 @@ func (s *Server) listDNSAccounts(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": items})
return
}
projectID := strings.TrimSpace(cfg.DNS.ProjectID)
if projectID == "" {
projectID = domain.DefaultProjectID
}
// 在查询账户归属或解密凭据之前,按配置中的真实项目实时判权。
// 全局管理员身份不能代替项目授权,撤权后的旧会话也不能继续读取。
authorizer, ok := s.projectMembers.(repository.ProjectAuthorizer)
if !ok {
authorizer, ok = s.projectDomains.(repository.ProjectAuthorizer)
}
if !ok {
s.writeError(c, http.StatusServiceUnavailable, "项目 DNS 权限服务尚未就绪", nil)
return
}
if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), projectID, domain.PermissionDNSRead); err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) {
// 列表只返回可见账户,不泄露其他项目是否配置了凭据。
c.JSON(http.StatusOK, gin.H{"items": items})
return
}
s.writeError(c, http.StatusServiceUnavailable, "读取项目 DNS 权限失败,请稍后重试", nil)
return
}
item := dnsAccountView{
ID: "system-dns", Name: "系统 DNS 账户", Provider: cfg.DNS.Provider,
ProjectID: strings.TrimSpace(cfg.DNS.ProjectID),
ProjectID: projectID,
ProviderName: "未知服务商", AccountIdentifier: "未提供账号标识",
OwnerUserID: cfg.DNS.OwnerUserID, OwnerState: "unassigned", OwnerUsername: "未分配",
VerificationState: cfg.DNS.VerificationState, CredentialState: "available",
}
if item.ProjectID == "" {
item.ProjectID = "default"
}
var owner repository.User
ownerID := strings.TrimSpace(cfg.DNS.OwnerUserID)
if ownerID == "" {

View File

@ -0,0 +1,136 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
func newDNSAccountScopeFixture(t *testing.T) (projectMemberAPIFixture, *fakeSecretReaderStore) {
t.Helper()
f := newProjectMemberAPIFixture(t)
f.server.config.DNS.Provider = "aliyun"
f.server.config.DNS.ProjectID = "project-a"
f.server.config.DNS.CredentialRef = "secret://dns/scope-test"
secrets := &fakeSecretReaderStore{data: map[string]map[string]string{
f.server.config.DNS.CredentialRef: {
"access_key_id": "test-access-key-1234", "access_key_secret": "test-secret-never-return",
},
}}
f.server.secrets = secrets
return f, secrets
}
func TestDNSAccountScopePermissions(t *testing.T) {
f, secrets := newDNSAccountScopeFixture(t)
for _, tc := range []struct {
actor string
count int
}{
{"primary-super-admin", 1}, {"manager", 1}, {"viewer", 1}, {"global-admin", 0}, {"target", 0},
} {
t.Run(tc.actor, func(t *testing.T) {
secrets.getCalls = 0
response := serveUserRequest(f.server, f.cookies[tc.actor], http.MethodGet, "/api/v1/dns/accounts", "")
var page struct {
Items []dnsAccountView `json:"items"`
}
if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &page) != nil || page.Items == nil || len(page.Items) != tc.count {
t.Fatalf("DNS 账户项目可见范围错误:%s,%d,%s", tc.actor, response.Code, response.Body.String())
}
if secrets.getCalls != tc.count {
t.Fatalf("无权访问的请求读取了凭据仓库:%s,%d", tc.actor, secrets.getCalls)
}
if response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" {
t.Fatal("DNS 账户响应缺少身份缓存隔离")
}
for _, forbidden := range []string{"test-access-key-1234", "test-secret-never-return", "secret://dns/scope-test"} {
if strings.Contains(response.Body.String(), forbidden) {
t.Fatal("DNS 账户列表泄露完整凭据或内部引用")
}
}
if tc.count == 1 && (page.Items[0].ProjectID != "project-a" || page.Items[0].AccountIdentifier != "test****1234") {
t.Fatalf("授权响应丢失真实归属或脱敏标识:%s", response.Body.String())
}
})
}
// 请求参数不能冒充其他身份或改变配置中的真实项目归属。
f.server.config.DNS.ProjectID = "project-b"
secrets.getCalls = 0
response := serveUserRequest(f.server, f.cookies["manager"], http.MethodGet, "/api/v1/dns/accounts?project_id=project-a&actor_id=primary-super-admin", "")
if response.Code != http.StatusOK || strings.TrimSpace(response.Body.String()) != `{"items":[]}` || secrets.getCalls != 0 {
t.Fatalf("跨项目请求没有在读取凭据之前拒绝:%d,%s", response.Code, response.Body.String())
}
}
func TestDNSAccountScopeRevocation(t *testing.T) {
f, secrets := newDNSAccountScopeFixture(t)
if err := f.members.GrantProjectRole(t.Context(), "primary-super-admin", "project-a", "global-admin", domain.ProjectRoleDNSAdmin); err != nil {
t.Fatal(err)
}
response := serveUserRequest(f.server, f.cookies["global-admin"], http.MethodGet, "/api/v1/dns/accounts", "")
if response.Code != http.StatusOK || secrets.getCalls != 1 {
t.Fatalf("显式授予 DNS 角色后不可读取:%d,%s", response.Code, response.Body.String())
}
if err := f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "global-admin", domain.ProjectRoleDNSAdmin); err != nil {
t.Fatal(err)
}
response = serveUserRequest(f.server, f.cookies["global-admin"], http.MethodGet, "/api/v1/dns/accounts", "")
if response.Code != http.StatusOK || strings.TrimSpace(response.Body.String()) != `{"items":[]}` || secrets.getCalls != 1 {
t.Fatalf("撤权后原会话仍可读取 DNS 账户:%d,%s", response.Code, response.Body.String())
}
}
func TestDNSAccountScopeDefaultsAndDependencies(t *testing.T) {
for _, scenario := range []string{"default", "missing_project", "missing_authorizer", "authorizer_error", "database_error", "anonymous"} {
t.Run(scenario, func(t *testing.T) {
f, secrets := newDNSAccountScopeFixture(t)
cookie := f.cookies["primary-super-admin"]
want := http.StatusOK
switch scenario {
case "default":
f.server.config.DNS.ProjectID = " "
case "missing_project":
f.server.config.DNS.ProjectID = "missing"
case "missing_authorizer":
f.server.projectMembers, f.server.projectDomains = nil, nil
want = http.StatusServiceUnavailable
case "authorizer_error":
f.server.projectMembers = nil
f.server.projectDomains = projectDomainRepositoryStub{authorizeErr: errors.New("internal-sensitive-diagnostic")}
want = http.StatusServiceUnavailable
case "database_error":
// 认证仍使用正常账户仓储,只让项目授权的数据库读取失败。
f.server.projectMembers = nil
f.server.projectDomains = projectDomainRepositoryStub{authorize: func(ctx context.Context, actor, project string, permission domain.ProjectPermission) error {
if err := f.db.Close(); err != nil {
return err
}
return f.members.AuthorizeProject(ctx, actor, project, permission)
}}
want = http.StatusServiceUnavailable
case "anonymous":
cookie = nil
want = http.StatusUnauthorized
}
response := serveUserRequest(f.server, cookie, http.MethodGet, "/api/v1/dns/accounts", "")
if response.Code != want || response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" {
t.Fatalf("DNS 列表依赖或缓存隔离错误:%d,%s", response.Code, response.Body.String())
}
if scenario == "default" {
if secrets.getCalls != 1 || !strings.Contains(response.Body.String(), `"project_id":"default"`) {
t.Fatal("旧配置没有按默认项目授权")
}
return
}
if secrets.getCalls != 0 || strings.Contains(response.Body.String(), "project-a") || strings.Contains(response.Body.String(), "internal-sensitive-diagnostic") || strings.Contains(response.Body.String(), "account_identifier") {
t.Fatalf("未获可靠授权仍读取凭据或泄露元数据:%s", response.Body.String())
}
})
}
}

View File

@ -91,7 +91,7 @@ func TestAdminDNSAccountsExposeProjectScope(t *testing.T) {
secrets := &fakeSecretReaderStore{data: map[string]map[string]string{
cfg.DNS.CredentialRef: {"api_token": "test-token"},
}}
server := New(Dependencies{Config: cfg, Secrets: secrets})
server := New(Dependencies{Config: cfg, Secrets: secrets, ProjectDomains: projectDomainRepositoryStub{}})
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/dns/accounts", nil)
serveAdminHTTP(server, response, request)

View File

@ -13,6 +13,7 @@ import (
func (s *Server) registerProjectResourceRoutes() {
api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
api.GET("/domains", s.listDomains)
api.GET("/dns/accounts", s.listDNSAccounts)
api.GET("/audit-logs", s.listAuditLogs)
api.GET("/servers", s.listServers)
api.GET("/servers/:id", s.getServer)

View File

@ -65,10 +65,10 @@ type Dependencies struct {
ImportCertificateTask func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
// DeployCertificateTask 执行已有任务,不得重复创建任务或尝试记录。
DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error)
DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error)
RestoreCertificateVersionTask func(context.Context, string, string) (certificate.IssueResult, error)
ValidateCertificate func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
ValidateCertificate func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
// ValidateCertificateTask 直接校验当前证书,不重复创建任务。
ValidateCertificateTask func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
@ -246,7 +246,6 @@ func (s *Server) registerRoutes() {
adminAPI.GET("/settings", s.getSettings)
adminAPI.PUT("/settings", s.requireSuperAdminSystemConfig, s.updateSettings)
adminAPI.GET("/dns/providers", s.listAdminDNSProviders)
adminAPI.GET("/dns/accounts", s.listDNSAccounts)
adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS)
adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.runRenewalNow)

View File

@ -1,5 +1,18 @@
# Linux 生产流程测试记录
## 2026年10月4日01:35 DNS 账户项目隔离红绿测试
修改前中文检查点 `e060205` 已推送。旧源码基线的 DNS 账户实现摘要为 `9bcb1dbe1ca60dc0dfcbd8dfecbb1ceead1dfe92d54bc501f5c2432e0bcdf585`,本机与第一台既有隔离源码一致。新测试使用正式 Go 用例,不创建辅助验收脚本、不修改生产配置或数据库,不使用第二台。
- [x] 隔离目录为第一台 `/var/tmp/scert-dns-scope-20261004-0130/backend`,使用原生 Linux Go 工具链、nobody 账户、独立临时 SQLite;CPU、内存及运行时间有限制。
- [x] 单元 `scert-dns-scope-red-20261004-0130.service` 在旧源码运行新用例,终态 failed、退出一:无项目角色的全局管理员仍返回 system-dns,撤销项目 DNS 角色后仍可读取。项目角色反被全局管理员门槛拒绝;缺授权依赖未拒绝读取。该失败为缺陷复现,不隐去。
- [x] 修复后单元 `scert-dns-scope-green-20261004-0134.service` 完成,终态 inactive、Result=success、退出零;DNS 账户、原有 DNS API、项目资源与项目目录专项共用正式 HTTP 路由及隔离 SQL 权限仓储,包耗时 2.910 秒。
- [x] 用例覆盖真实归属、管理员无隐式权限、项目管理员及只读角色、跨项目伪造参数、撤权后旧会话、默认项目、不存在项目、缺依赖、数据库读失败、匿名缓存隔离和凭据不回显;拒绝路径断言凭据 Get 次数为零。
- [x] 前端正式测试共 276 项通过,无失败或跳过;新增项目角色 DNS 入口及退出后导航复位用例。
- [~] 后端全量、竞态及最新正式包升级尚未完成;当前结果不是线上或浏览器验收,也不代表 P0-07 全部完成。
本次读取源码还确认:当前 ACME 入口仅调用 DNS-01 Provider 设置,没有 HTTP-01 接入;DNS 列表仅包含当前 system-dns,没有独立多账户增删管理。完整矩阵必须区分开发未完成与已有能力缺实机证据,不再无限复跑已通过的 DNS-01 或证书回滚来替代这些缺项。
## 2026年10月4日01:10 首次 Web 前提与系统通知授权核查
- [✓] 第一台 Scert、Nginx 正常运行;正确名称的回环严格 TLS 前端首页返回 200,后端就绪已通过。本轮未启动新的隔离 Web 实例、未清空现有业务库。

View File

@ -1,5 +1,12 @@
# Scert 开发任务清单
## 2026年10月4日01:35 DNS 账户隔离补漏
- [x] 已修复 DNS 列表仅按全局管理员校验的问题,接入实时项目权限及先授权后读凭据;前端项目角色入口同步。
- [x] 第一台 Linux 旧源码红测确认越权及撤权缺陷,新源码专项通过;前端 276 项通过。
- [~] 后端全量、竞态、正式 Linux 包及第一台部署待完成,不将隔离测试当生产验收。
- [!] HTTP-01 尚未接入,完整 DNS 账户生命周期与委派管理仍需开发和验证;域名切至第一台、系统通知授权与三数据库首次 Web 仍未关闭。
## 2026年10月4日01:04 不同版本实机验收进度
- [✓] 未改产品源码,沿用第一台正式包 `e00a757` 验证历史版本恢复及双目标推送;第二台仅检查证书和 HTTPS 生效,不运行重复业务测试。

View File

@ -5,9 +5,9 @@
沿用已确认的 P0-07、P1-03 要求:第一台进行主程序与隔离回归,第二台仅验证证书推送;不重复磁盘检查、签发和已通过的恢复测试。
- [x] 核对源码:DNS 账户列表目前只校验全局管理员身份,返回的项目归属没有用于读取授权;需修复跨项目元数据暴露。
- [~] 先补正式回归用例,验证没有项目权限的管理员不获知账户、归属或凭据状态,且拒绝路径不读取凭据仓库。
- [ ] 将列表接入实时项目 DNS 读取权限;项目内授权角色可读取脱敏列表,撤权后原会话立即失效,依赖异常拒绝返回数据。
- [ ] 覆盖匿名缓存隔离、默认项目、跨项目、缺依赖及数据库失败;只在第一台隔离 Linux 源码目录运行正式 Go 测试。
- [x] 正式回归已在旧源码复现越权及撤权失效:无项目角色的管理员收到完整账户元数据;失败记录保留,不与修复后的通过混用。
- [x] 列表接入实时项目 DNS 读取权限;项目内授权角色可读取脱敏列表,撤权后原会话立即失效,依赖异常拒绝返回数据;导航开放项目角色入口,不放开系统设置写权限。
- [x] 第一台隔离 Linux 专项通过,覆盖匿名缓存隔离、默认项目、跨项目、缺依赖及数据库失败,拒绝路径没有读取凭据;前端 276 项通过。后端全量与竞态仍待完成。
- [ ] 回归通过后构建、部署第一台正式包并复核;未部署前不声称线上修复。
- [!] 源码仅接入 DNS-01,HTTP-01 尚未接入,属于开发缺项而非仅缺实机证据。账户独立增删、禁用轮换和委派管理的完整矩阵继续保留,不能由已有单系统账户或 Provider 适配器代替。
- [!] 管理域名仍指第二台,用户要求真实域名入口;不使用本地映射或隧道冒充公共验收,不在第二台配置发信。

View File

@ -1,5 +1,15 @@
# Scert 整改、合并与优化清单
## 2026年10月4日01:35 DNS 账户读取隔离补漏
- [x] 第一台隔离 Linux 正式用例在旧源码复现全局管理员跨项目读取 DNS 账户、撤权后旧会话继续读取的问题;这不是仅有待验收项,已据实补修。
- [x] DNS 列表改为真实项目的实时 DNS 读取授权,先授权再查询归属及凭据;无权返回空列表、依赖异常拒绝返回数据,匿名及授权响应均禁止共享缓存。项目角色导航同步开放,系统写权限不变。
- [x] 第一台 Linux 专项通过,前端 276 项通过;后端全量、竞态、正式新包与第一台升级待完成。当前线上仍为旧发布包,不提前宣称线上缺口已关闭。
- [!] DNS/ACME 源码核对发现 HTTP-01 尚未接入;完整账户生命周期与委派管理仍不能由单系统账户和 Provider 适配器替代。这些属于开发及验收缺项,不再笼统写作只差实机矩阵。
- [!] 域名页面已经按用户要求通过正常域名打开,但当前仍指第二台;未在那里配置发信,未修改 DNS。公网入口、系统通知授权和三数据库首次 Web 前提仍保留。
仅处理 Linux;第二台不重复主程序测试,无新增辅助脚本、备份或磁盘检查。详细用例及执行记录见《Linux生产流程测试记录》。
## 2026年10月4日01:10 外部前提补充
不同版本回滚与重试的通过结论见下节,不重复执行第二台业务测试。第一台内部前后端正常,但公共入口定向诊断仍为 HTTP 403、HTTPS 握手失败,服务器侧管理域名 DNS 仍指第二台。Smail 网站可达,但 Scert 系统 SMTP 未启用、未配置授权;匿名状态查询的权限拒绝不是发信故障。完整首次 Web 和邮件闭环需要可用且指向第一台的管理域名,以及 Smail 系统通知专用授权;未满足前不清空现有业务数据、不猜凭据、不改用普通邮箱。整体目标及其余门禁保持不变。

View File

@ -32,6 +32,8 @@
## P1-03 完善 ACME 和 DNS 管理
2026年10月4日源码复核:HTTP-01 尚未接入;system-dns 单账户列表不能代表独立多账户生命周期完成。已补 DNS 列表实时项目读取权限及第一台 Linux 隔离红绿测试,正式新包部署仍待完成。原六阶段未完成状态保留,不把开发缺项改写成仅待验收。
- 任务编号:P1-03;任务名称:完善 ACME 和 DNS 管理;优先级:P1。
- 当前状态:`[~] 进行中`。
- 六阶段:需求确认 `[x]`;代码实现 `[~]`;自动化测试 `[~]`;隔离环境测试 `[~]`;生产部署测试 `[~]`;最终验收 `[ ]`。

View File

@ -65,6 +65,18 @@ test("用户管理导航按权限显示,权限变化后回到基本信息", ()
assert.equal(navigation.sections.value.length, 5);
});
test("DNS 账户入口允许项目角色访问,数据范围由后端实时授权", () => {
const user = ref({ role: "user" });
const navigation = useDashboardNavigation(user);
assert.equal(navigation.sections.value.some((section) => section.id === "dns-accounts"), true);
assert.equal(navigation.sections.value.some((section) => section.id === "users"), false);
navigation.selectSection("dns-accounts");
assert.equal(navigation.activeSection.value, "dns-accounts");
user.value = null;
assert.equal(navigation.activeSection.value, "overview");
assert.equal(navigation.sections.value.some((section) => section.id === "dns-accounts"), false);
});
test("单板块布局使用互斥条件,切换重置滚动且保留弹窗入口", async () => {
const { readFile } = await import("node:fs/promises");
const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");

View File

@ -12,9 +12,11 @@ export const dashboardSections = [
export function useDashboardNavigation(currentUser) {
const selectedSection = ref("overview");
const sections = computed(() => dashboardSections.filter(
(section) => !["users", "dns-accounts"].includes(section.id) || ["super_admin", "admin"].includes(currentUser.value?.role),
));
const sections = computed(() => dashboardSections.filter((section) => {
if (section.id === "users") return ["super_admin", "admin"].includes(currentUser.value?.role);
if (section.id === "dns-accounts") return Boolean(currentUser.value);
return true;
}));
const activeSection = computed(() => sections.value.some(
(section) => section.id === selectedSection.value,
) ? selectedSection.value : "overview");