修复DNS账户跨项目读取并补充撤权与失败隔离回归
This commit is contained in:
parent
e060205a4d
commit
2513f935bc
@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
|
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -28,7 +29,6 @@ type dnsAccountView struct {
|
|||||||
|
|
||||||
// listDNSAccounts 仅列出当前绑定的配置,不枚举凭据仓库中的临时或历史密钥。
|
// listDNSAccounts 仅列出当前绑定的配置,不枚举凭据仓库中的临时或历史密钥。
|
||||||
func (s *Server) listDNSAccounts(c *gin.Context) {
|
func (s *Server) listDNSAccounts(c *gin.Context) {
|
||||||
c.Header("Cache-Control", "no-store")
|
|
||||||
s.configMu.RLock()
|
s.configMu.RLock()
|
||||||
cfg := s.config
|
cfg := s.config
|
||||||
s.configMu.RUnlock()
|
s.configMu.RUnlock()
|
||||||
@ -37,16 +37,36 @@ func (s *Server) listDNSAccounts(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
return
|
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{
|
item := dnsAccountView{
|
||||||
ID: "system-dns", Name: "系统 DNS 账户", Provider: cfg.DNS.Provider,
|
ID: "system-dns", Name: "系统 DNS 账户", Provider: cfg.DNS.Provider,
|
||||||
ProjectID: strings.TrimSpace(cfg.DNS.ProjectID),
|
ProjectID: projectID,
|
||||||
ProviderName: "未知服务商", AccountIdentifier: "未提供账号标识",
|
ProviderName: "未知服务商", AccountIdentifier: "未提供账号标识",
|
||||||
OwnerUserID: cfg.DNS.OwnerUserID, OwnerState: "unassigned", OwnerUsername: "未分配",
|
OwnerUserID: cfg.DNS.OwnerUserID, OwnerState: "unassigned", OwnerUsername: "未分配",
|
||||||
VerificationState: cfg.DNS.VerificationState, CredentialState: "available",
|
VerificationState: cfg.DNS.VerificationState, CredentialState: "available",
|
||||||
}
|
}
|
||||||
if item.ProjectID == "" {
|
|
||||||
item.ProjectID = "default"
|
|
||||||
}
|
|
||||||
var owner repository.User
|
var owner repository.User
|
||||||
ownerID := strings.TrimSpace(cfg.DNS.OwnerUserID)
|
ownerID := strings.TrimSpace(cfg.DNS.OwnerUserID)
|
||||||
if ownerID == "" {
|
if ownerID == "" {
|
||||||
|
|||||||
136
backend/internal/httpapi/dns_accounts_scope_test.go
Normal file
136
backend/internal/httpapi/dns_accounts_scope_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -91,7 +91,7 @@ func TestAdminDNSAccountsExposeProjectScope(t *testing.T) {
|
|||||||
secrets := &fakeSecretReaderStore{data: map[string]map[string]string{
|
secrets := &fakeSecretReaderStore{data: map[string]map[string]string{
|
||||||
cfg.DNS.CredentialRef: {"api_token": "test-token"},
|
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()
|
response := httptest.NewRecorder()
|
||||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/dns/accounts", nil)
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/dns/accounts", nil)
|
||||||
serveAdminHTTP(server, response, request)
|
serveAdminHTTP(server, response, request)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
func (s *Server) registerProjectResourceRoutes() {
|
func (s *Server) registerProjectResourceRoutes() {
|
||||||
api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
|
api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
|
||||||
api.GET("/domains", s.listDomains)
|
api.GET("/domains", s.listDomains)
|
||||||
|
api.GET("/dns/accounts", s.listDNSAccounts)
|
||||||
api.GET("/audit-logs", s.listAuditLogs)
|
api.GET("/audit-logs", s.listAuditLogs)
|
||||||
api.GET("/servers", s.listServers)
|
api.GET("/servers", s.listServers)
|
||||||
api.GET("/servers/:id", s.getServer)
|
api.GET("/servers/:id", s.getServer)
|
||||||
|
|||||||
@ -65,10 +65,10 @@ type Dependencies struct {
|
|||||||
ImportCertificateTask func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
|
ImportCertificateTask func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
|
||||||
DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
||||||
// DeployCertificateTask 执行已有任务,不得重复创建任务或尝试记录。
|
// DeployCertificateTask 执行已有任务,不得重复创建任务或尝试记录。
|
||||||
DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
||||||
RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error)
|
RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error)
|
||||||
RestoreCertificateVersionTask 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 直接校验当前证书,不重复创建任务。
|
||||||
ValidateCertificateTask func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
|
ValidateCertificateTask func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
|
||||||
|
|
||||||
@ -246,7 +246,6 @@ func (s *Server) registerRoutes() {
|
|||||||
adminAPI.GET("/settings", s.getSettings)
|
adminAPI.GET("/settings", s.getSettings)
|
||||||
adminAPI.PUT("/settings", s.requireSuperAdminSystemConfig, s.updateSettings)
|
adminAPI.PUT("/settings", s.requireSuperAdminSystemConfig, s.updateSettings)
|
||||||
adminAPI.GET("/dns/providers", s.listAdminDNSProviders)
|
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/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS)
|
||||||
adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain)
|
adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain)
|
||||||
adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.runRenewalNow)
|
adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.runRenewalNow)
|
||||||
|
|||||||
@ -1,5 +1,18 @@
|
|||||||
# Linux 生产流程测试记录
|
# 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 前提与系统通知授权核查
|
## 2026年10月4日01:10 首次 Web 前提与系统通知授权核查
|
||||||
|
|
||||||
- [✓] 第一台 Scert、Nginx 正常运行;正确名称的回环严格 TLS 前端首页返回 200,后端就绪已通过。本轮未启动新的隔离 Web 实例、未清空现有业务库。
|
- [✓] 第一台 Scert、Nginx 正常运行;正确名称的回环严格 TLS 前端首页返回 200,后端就绪已通过。本轮未启动新的隔离 Web 实例、未清空现有业务库。
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
# Scert 开发任务清单
|
# Scert 开发任务清单
|
||||||
|
|
||||||
|
## 2026年10月4日01:35 DNS 账户隔离补漏
|
||||||
|
|
||||||
|
- [x] 已修复 DNS 列表仅按全局管理员校验的问题,接入实时项目权限及先授权后读凭据;前端项目角色入口同步。
|
||||||
|
- [x] 第一台 Linux 旧源码红测确认越权及撤权缺陷,新源码专项通过;前端 276 项通过。
|
||||||
|
- [~] 后端全量、竞态、正式 Linux 包及第一台部署待完成,不将隔离测试当生产验收。
|
||||||
|
- [!] HTTP-01 尚未接入,完整 DNS 账户生命周期与委派管理仍需开发和验证;域名切至第一台、系统通知授权与三数据库首次 Web 仍未关闭。
|
||||||
|
|
||||||
## 2026年10月4日01:04 不同版本实机验收进度
|
## 2026年10月4日01:04 不同版本实机验收进度
|
||||||
|
|
||||||
- [✓] 未改产品源码,沿用第一台正式包 `e00a757` 验证历史版本恢复及双目标推送;第二台仅检查证书和 HTTPS 生效,不运行重复业务测试。
|
- [✓] 未改产品源码,沿用第一台正式包 `e00a757` 验证历史版本恢复及双目标推送;第二台仅检查证书和 HTTPS 生效,不运行重复业务测试。
|
||||||
|
|||||||
@ -5,9 +5,9 @@
|
|||||||
沿用已确认的 P0-07、P1-03 要求:第一台进行主程序与隔离回归,第二台仅验证证书推送;不重复磁盘检查、签发和已通过的恢复测试。
|
沿用已确认的 P0-07、P1-03 要求:第一台进行主程序与隔离回归,第二台仅验证证书推送;不重复磁盘检查、签发和已通过的恢复测试。
|
||||||
|
|
||||||
- [x] 核对源码:DNS 账户列表目前只校验全局管理员身份,返回的项目归属没有用于读取授权;需修复跨项目元数据暴露。
|
- [x] 核对源码:DNS 账户列表目前只校验全局管理员身份,返回的项目归属没有用于读取授权;需修复跨项目元数据暴露。
|
||||||
- [~] 先补正式回归用例,验证没有项目权限的管理员不获知账户、归属或凭据状态,且拒绝路径不读取凭据仓库。
|
- [x] 正式回归已在旧源码复现越权及撤权失效:无项目角色的管理员收到完整账户元数据;失败记录保留,不与修复后的通过混用。
|
||||||
- [ ] 将列表接入实时项目 DNS 读取权限;项目内授权角色可读取脱敏列表,撤权后原会话立即失效,依赖异常拒绝返回数据。
|
- [x] 列表接入实时项目 DNS 读取权限;项目内授权角色可读取脱敏列表,撤权后原会话立即失效,依赖异常拒绝返回数据;导航开放项目角色入口,不放开系统设置写权限。
|
||||||
- [ ] 覆盖匿名缓存隔离、默认项目、跨项目、缺依赖及数据库失败;只在第一台隔离 Linux 源码目录运行正式 Go 测试。
|
- [x] 第一台隔离 Linux 专项通过,覆盖匿名缓存隔离、默认项目、跨项目、缺依赖及数据库失败,拒绝路径没有读取凭据;前端 276 项通过。后端全量与竞态仍待完成。
|
||||||
- [ ] 回归通过后构建、部署第一台正式包并复核;未部署前不声称线上修复。
|
- [ ] 回归通过后构建、部署第一台正式包并复核;未部署前不声称线上修复。
|
||||||
- [!] 源码仅接入 DNS-01,HTTP-01 尚未接入,属于开发缺项而非仅缺实机证据。账户独立增删、禁用轮换和委派管理的完整矩阵继续保留,不能由已有单系统账户或 Provider 适配器代替。
|
- [!] 源码仅接入 DNS-01,HTTP-01 尚未接入,属于开发缺项而非仅缺实机证据。账户独立增删、禁用轮换和委派管理的完整矩阵继续保留,不能由已有单系统账户或 Provider 适配器代替。
|
||||||
- [!] 管理域名仍指第二台,用户要求真实域名入口;不使用本地映射或隧道冒充公共验收,不在第二台配置发信。
|
- [!] 管理域名仍指第二台,用户要求真实域名入口;不使用本地映射或隧道冒充公共验收,不在第二台配置发信。
|
||||||
|
|||||||
@ -1,5 +1,15 @@
|
|||||||
# Scert 整改、合并与优化清单
|
# 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 外部前提补充
|
## 2026年10月4日01:10 外部前提补充
|
||||||
|
|
||||||
不同版本回滚与重试的通过结论见下节,不重复执行第二台业务测试。第一台内部前后端正常,但公共入口定向诊断仍为 HTTP 403、HTTPS 握手失败,服务器侧管理域名 DNS 仍指第二台。Smail 网站可达,但 Scert 系统 SMTP 未启用、未配置授权;匿名状态查询的权限拒绝不是发信故障。完整首次 Web 和邮件闭环需要可用且指向第一台的管理域名,以及 Smail 系统通知专用授权;未满足前不清空现有业务数据、不猜凭据、不改用普通邮箱。整体目标及其余门禁保持不变。
|
不同版本回滚与重试的通过结论见下节,不重复执行第二台业务测试。第一台内部前后端正常,但公共入口定向诊断仍为 HTTP 403、HTTPS 握手失败,服务器侧管理域名 DNS 仍指第二台。Smail 网站可达,但 Scert 系统 SMTP 未启用、未配置授权;匿名状态查询的权限拒绝不是发信故障。完整首次 Web 和邮件闭环需要可用且指向第一台的管理域名,以及 Smail 系统通知专用授权;未满足前不清空现有业务数据、不猜凭据、不改用普通邮箱。整体目标及其余门禁保持不变。
|
||||||
|
|||||||
@ -32,6 +32,8 @@
|
|||||||
|
|
||||||
## P1-03 完善 ACME 和 DNS 管理
|
## P1-03 完善 ACME 和 DNS 管理
|
||||||
|
|
||||||
|
2026年10月4日源码复核:HTTP-01 尚未接入;system-dns 单账户列表不能代表独立多账户生命周期完成。已补 DNS 列表实时项目读取权限及第一台 Linux 隔离红绿测试,正式新包部署仍待完成。原六阶段未完成状态保留,不把开发缺项改写成仅待验收。
|
||||||
|
|
||||||
- 任务编号:P1-03;任务名称:完善 ACME 和 DNS 管理;优先级:P1。
|
- 任务编号:P1-03;任务名称:完善 ACME 和 DNS 管理;优先级:P1。
|
||||||
- 当前状态:`[~] 进行中`。
|
- 当前状态:`[~] 进行中`。
|
||||||
- 六阶段:需求确认 `[x]`;代码实现 `[~]`;自动化测试 `[~]`;隔离环境测试 `[~]`;生产部署测试 `[~]`;最终验收 `[ ]`。
|
- 六阶段:需求确认 `[x]`;代码实现 `[~]`;自动化测试 `[~]`;隔离环境测试 `[~]`;生产部署测试 `[~]`;最终验收 `[ ]`。
|
||||||
|
|||||||
@ -65,6 +65,18 @@ test("用户管理导航按权限显示,权限变化后回到基本信息", ()
|
|||||||
assert.equal(navigation.sections.value.length, 5);
|
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 () => {
|
test("单板块布局使用互斥条件,切换重置滚动且保留弹窗入口", async () => {
|
||||||
const { readFile } = await import("node:fs/promises");
|
const { readFile } = await import("node:fs/promises");
|
||||||
const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
|
const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
|
||||||
|
|||||||
@ -12,9 +12,11 @@ export const dashboardSections = [
|
|||||||
|
|
||||||
export function useDashboardNavigation(currentUser) {
|
export function useDashboardNavigation(currentUser) {
|
||||||
const selectedSection = ref("overview");
|
const selectedSection = ref("overview");
|
||||||
const sections = computed(() => dashboardSections.filter(
|
const sections = computed(() => dashboardSections.filter((section) => {
|
||||||
(section) => !["users", "dns-accounts"].includes(section.id) || ["super_admin", "admin"].includes(currentUser.value?.role),
|
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(
|
const activeSection = computed(() => sections.value.some(
|
||||||
(section) => section.id === selectedSection.value,
|
(section) => section.id === selectedSection.value,
|
||||||
) ? selectedSection.value : "overview");
|
) ? selectedSection.value : "overview");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user