diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go
index 68b17ce..089b677 100644
--- a/backend/internal/app/app.go
+++ b/backend/internal/app/app.go
@@ -158,6 +158,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
ProjectMembers: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectDirectory: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectLifecycle: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
+ ProjectResources: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker,
IssueCertificate: workflow.IssueForHTTP,
IssueSetupCertificate: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
diff --git a/backend/internal/app/project_lifecycle_api_test.go b/backend/internal/app/project_lifecycle_api_test.go
index f5f7bbd..c7decfc 100644
--- a/backend/internal/app/project_lifecycle_api_test.go
+++ b/backend/internal/app/project_lifecycle_api_test.go
@@ -37,6 +37,14 @@ func TestApplicationWiresProjectLifecycleAPI(t *testing.T) {
}
login := request(http.MethodPost, "/api/v1/auth/login", `{"identifier":"admin","password":"Test1234!"}`, 200)
cookies = login.Result().Cookies()
+ for _, path := range []string{"/api/v1/domains", "/api/v1/servers", "/api/v1/certificates"} {
+ resources := request(http.MethodGet, path, "", http.StatusOK)
+ if !strings.Contains(resources.Body.String(), `"items":[]`) || resources.Header().Get("Cache-Control") != "private, no-store" {
+ t.Fatalf("实际应用未接入空资源列表与缓存隔离:%s,%s", path, resources.Body.String())
+ }
+ }
+ request(http.MethodGet, "/api/v1/servers/missing", "", http.StatusForbidden)
+ request(http.MethodGet, "/api/v1/certificates/missing", "", http.StatusForbidden)
created := request(http.MethodPost, "/api/v1/projects", `{"name":"实际应用项目","description":"接线验证"}`, 201)
var item domain.Project
if err := json.Unmarshal(created.Body.Bytes(), &item); err != nil || item.ID == "" {
diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go
index caced17..99227d8 100644
--- a/backend/internal/domain/project.go
+++ b/backend/internal/domain/project.go
@@ -13,6 +13,10 @@ const DefaultProjectID = "default"
var projectIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,35}$`)
+func ValidProjectID(id string) bool {
+ return projectIDPattern.MatchString(id)
+}
+
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -41,7 +45,7 @@ func (p Project) Normalize() Project {
}
func (p Project) Validate() error {
- if !projectIDPattern.MatchString(p.ID) {
+ if !ValidProjectID(p.ID) {
return errors.New("项目 ID 必须为 1~36 位小写字母、数字、连字符或下划线,并以字母或数字开头")
}
if !utf8.ValidString(p.Name) || strings.TrimSpace(p.Name) == "" || utf8.RuneCountInString(p.Name) > 128 {
diff --git a/backend/internal/domain/project_test.go b/backend/internal/domain/project_test.go
index b5dd7cd..c25f124 100644
--- a/backend/internal/domain/project_test.go
+++ b/backend/internal/domain/project_test.go
@@ -22,6 +22,19 @@ func TestNewProject(t *testing.T) {
}
}
+func TestValidProjectID(t *testing.T) {
+ for _, id := range []string{DefaultProjectID, "a", "1", "project_a-1", strings.Repeat("a", 36)} {
+ if !ValidProjectID(id) {
+ t.Fatalf("拒绝合法项目标识:%q", id)
+ }
+ }
+ for _, id := range []string{"", "UPPERCASE", strings.Repeat("a", 37), " project", "project ", "_project", "../project", "项目"} {
+ if ValidProjectID(id) {
+ t.Fatalf("接受无效项目标识:%q", id)
+ }
+ }
+}
+
func TestProjectValidation(t *testing.T) {
cases := []struct {
name string
diff --git a/backend/internal/httpapi/auth_optional_email_test.go b/backend/internal/httpapi/auth_optional_email_test.go
index a0ddd07..bacc45c 100644
--- a/backend/internal/httpapi/auth_optional_email_test.go
+++ b/backend/internal/httpapi/auth_optional_email_test.go
@@ -57,7 +57,8 @@ 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/domains", nil)
+ // 全局管理角色边界使用系统设置;域名读取现按项目成员身份判定。
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
request.AddCookie(cookies[0])
access := httptest.NewRecorder()
server.Handler().ServeHTTP(access, request)
diff --git a/backend/internal/httpapi/certificate_api.go b/backend/internal/httpapi/certificate_api.go
index def42ad..c63ce3e 100644
--- a/backend/internal/httpapi/certificate_api.go
+++ b/backend/internal/httpapi/certificate_api.go
@@ -30,13 +30,16 @@ type certificatePathRequest struct {
}
func (s *Server) listCertificates(c *gin.Context) {
- if s.repository == nil || s.currentCertificate == nil {
+ if !s.requireProjectResourceReader(c) {
+ return
+ }
+ if s.currentCertificate == nil {
s.writeError(c, http.StatusNotImplemented, "证书管理接口尚未配置", errors.New("证书服务未配置"))
return
}
- items, err := s.repository.List(c.Request.Context())
+ items, err := s.projectResources.ListVisibleDomains(c.Request.Context(), c.GetString(authenticatedActorKey))
if err != nil {
- s.writeError(c, http.StatusInternalServerError, "查询域名证书失败", err)
+ s.writeProjectResourceError(c, err)
return
}
result := make([]gin.H, 0, len(items))
@@ -57,6 +60,9 @@ func (s *Server) listCertificates(c *gin.Context) {
}
func (s *Server) getCertificate(c *gin.Context) {
+ if !s.requireVisibleCertificateDomain(c) {
+ return
+ }
if s.currentCertificate == nil {
s.writeError(c, http.StatusNotImplemented, "证书管理接口尚未配置", errors.New("证书服务未配置"))
return
@@ -70,6 +76,9 @@ func (s *Server) getCertificate(c *gin.Context) {
}
func (s *Server) listCertificateVersions(c *gin.Context) {
+ if !s.requireVisibleCertificateDomain(c) {
+ return
+ }
if s.currentCertificate == nil {
s.writeError(c, http.StatusNotImplemented, "证书管理接口尚未配置", errors.New("证书服务未配置"))
return
diff --git a/backend/internal/httpapi/certificate_api_test.go b/backend/internal/httpapi/certificate_api_test.go
index 747909c..09854f1 100644
--- a/backend/internal/httpapi/certificate_api_test.go
+++ b/backend/internal/httpapi/certificate_api_test.go
@@ -49,7 +49,11 @@ func TestCertificateReadAPIs(t *testing.T) {
versions: []certificate.SavedResource{{Version: "20260827-120000.000000000"}},
}
repo := &stubDomainRepository{items: []domain.Domain{{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSAN}}}
- server := New(Dependencies{Config: readyTestConfig(t.TempDir()), Repository: repo, CurrentCertificate: reader})
+ repo.loaded = repo.items[0]
+ server := New(Dependencies{
+ Config: readyTestConfig(t.TempDir()), Repository: repo, CurrentCertificate: reader,
+ ProjectResources: projectResourceReaderStub{domains: repo},
+ })
list := httptest.NewRecorder()
serveAdminHTTP(server, list, httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil))
@@ -79,6 +83,7 @@ func TestCertificateListHidesReaderErrors(t *testing.T) {
Config: readyTestConfig(t.TempDir()),
Repository: repo,
CurrentCertificate: reader,
+ ProjectResources: projectResourceReaderStub{domains: repo},
})
response := httptest.NewRecorder()
diff --git a/backend/internal/httpapi/middleware_test.go b/backend/internal/httpapi/middleware_test.go
index 4e95002..d9ca4a3 100644
--- a/backend/internal/httpapi/middleware_test.go
+++ b/backend/internal/httpapi/middleware_test.go
@@ -77,7 +77,7 @@ func TestAdminAPIAcceptsAuthenticatedSessionAndRejectsCrossOrigin(t *testing.T)
t.Fatal("登录响应未返回会话 Cookie")
}
- authenticatedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
+ authenticatedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
authenticatedRequest.AddCookie(cookie[0])
authenticatedResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(authenticatedResponse, authenticatedRequest)
@@ -85,7 +85,7 @@ func TestAdminAPIAcceptsAuthenticatedSessionAndRejectsCrossOrigin(t *testing.T)
t.Fatalf("登录后访问管理接口失败:%d %s", authenticatedResponse.Code, authenticatedResponse.Body.String())
}
- crossOriginRequest := httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
+ crossOriginRequest := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
crossOriginRequest.AddCookie(cookie[0])
crossOriginRequest.Host = "127.0.0.1:5200"
crossOriginRequest.Header.Set("Origin", "https://malicious.example")
@@ -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/domains", "")
+ response := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/settings", "")
if response.Code != http.StatusForbidden {
t.Fatalf("普通用户访问管理接口应返回 403,实际为 %d:%s", response.Code, response.Body.String())
}
diff --git a/backend/internal/httpapi/project_lifecycle_api.go b/backend/internal/httpapi/project_lifecycle_api.go
index ee0917e..e90f1e0 100644
--- a/backend/internal/httpapi/project_lifecycle_api.go
+++ b/backend/internal/httpapi/project_lifecycle_api.go
@@ -24,7 +24,7 @@ func (s *Server) projectLifecycleReady(c *gin.Context, creating bool) bool {
s.writeError(c, http.StatusServiceUnavailable, "项目管理服务尚未就绪", nil)
return false
}
- if c.Request.URL.RawQuery != "" || (!creating && !validMemberIdentifier(c.Param("projectID"))) {
+ if c.Request.URL.RawQuery != "" || (!creating && !domain.ValidProjectID(c.Param("projectID"))) {
s.writeError(c, http.StatusBadRequest, "项目请求参数无效", nil)
return false
}
@@ -119,7 +119,7 @@ func (s *Server) projectFailureAudit(action string) gin.HandlerFunc {
return
}
projectID := c.Param("projectID")
- if !validMemberIdentifier(projectID) {
+ if !domain.ValidProjectID(projectID) {
projectID = ""
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 3*time.Second)
diff --git a/backend/internal/httpapi/project_members_api.go b/backend/internal/httpapi/project_members_api.go
index f1b4ab6..e744c9a 100644
--- a/backend/internal/httpapi/project_members_api.go
+++ b/backend/internal/httpapi/project_members_api.go
@@ -27,7 +27,7 @@ func (s *Server) projectMemberRequestReady(c *gin.Context) bool {
s.writeError(c, http.StatusServiceUnavailable, "项目成员服务尚未就绪", nil)
return false
}
- if !validMemberIdentifier(c.Param("projectID")) {
+ if !domain.ValidProjectID(c.Param("projectID")) {
s.writeError(c, http.StatusBadRequest, "项目标识无效", nil)
return false
}
diff --git a/backend/internal/httpapi/project_members_audit.go b/backend/internal/httpapi/project_members_audit.go
index 4cfad97..2321305 100644
--- a/backend/internal/httpapi/project_members_audit.go
+++ b/backend/internal/httpapi/project_members_audit.go
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
+ "git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
@@ -17,7 +18,7 @@ func (s *Server) projectMemberFailureAudit(action string) gin.HandlerFunc {
return
}
projectID := c.Param("projectID")
- if !validMemberIdentifier(projectID) {
+ if !domain.ValidProjectID(projectID) {
projectID = ""
}
target := c.GetString(projectMemberTargetKey)
diff --git a/backend/internal/httpapi/project_members_test_helpers_test.go b/backend/internal/httpapi/project_members_test_helpers_test.go
index 573b45e..c2b2719 100644
--- a/backend/internal/httpapi/project_members_test_helpers_test.go
+++ b/backend/internal/httpapi/project_members_test_helpers_test.go
@@ -48,7 +48,10 @@ func newProjectMemberAPIFixture(t *testing.T) projectMemberAPIFixture {
db: db.DB, members: repository.NewSQLProjectMemberRepository(db.DB, cfg.Database.Driver),
audit: repository.NewSQLAuditRepository(db.DB, cfg.Database.Driver), cookies: make(map[string]*http.Cookie),
}
- f.server = New(Dependencies{Config: cfg, Users: users, ProjectMembers: f.members, ProjectDirectory: f.members, ProjectLifecycle: f.members, Audit: f.audit})
+ f.server = New(Dependencies{
+ Config: cfg, Users: users, ProjectMembers: f.members, ProjectDirectory: f.members,
+ ProjectLifecycle: f.members, ProjectResources: f.members, Audit: f.audit,
+ })
for _, account := range []struct{ id, role string }{
{"primary-super-admin", repository.UserRoleSuperAdmin}, {"manager", repository.UserRoleUser},
{"global-admin", repository.UserRoleAdmin}, {"viewer", repository.UserRoleUser}, {"target", repository.UserRoleUser},
diff --git a/backend/internal/httpapi/project_resources_api.go b/backend/internal/httpapi/project_resources_api.go
new file mode 100644
index 0000000..96f0fa6
--- /dev/null
+++ b/backend/internal/httpapi/project_resources_api.go
@@ -0,0 +1,49 @@
+package httpapi
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/repository"
+)
+
+func (s *Server) registerProjectResourceRoutes() {
+ api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
+ api.GET("/domains", s.listDomains)
+ api.GET("/servers", s.listServers)
+ api.GET("/servers/:id", s.getServer)
+ api.GET("/domains/:id/servers", s.listDomainServers)
+ api.GET("/certificates", s.listCertificates)
+ api.GET("/certificates/:id", s.getCertificate)
+ api.GET("/domains/:id/certificate/versions", s.listCertificateVersions)
+}
+
+func (s *Server) requireProjectResourceReader(c *gin.Context) bool {
+ if s.projectResources == nil {
+ s.writeError(c, http.StatusServiceUnavailable, "项目资源服务尚未就绪", nil)
+ return false
+ }
+ return true
+}
+
+func (s *Server) writeProjectResourceError(c *gin.Context, err error) {
+ if errors.Is(err, repository.ErrProjectAccessDenied) {
+ // 无权限和不存在使用相同响应,不泄露其他项目的资源是否存在。
+ s.writeError(c, http.StatusForbidden, "无权读取此项目资源", nil)
+ return
+ }
+ s.writeError(c, http.StatusInternalServerError, "读取项目资源失败,请稍后重试", nil)
+}
+
+func (s *Server) requireVisibleCertificateDomain(c *gin.Context) bool {
+ if !s.requireProjectResourceReader(c) {
+ return false
+ }
+ if _, err := s.projectResources.GetVisibleDomain(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id")); err != nil {
+ s.writeProjectResourceError(c, err)
+ return false
+ }
+ return true
+}
diff --git a/backend/internal/httpapi/project_resources_api_test.go b/backend/internal/httpapi/project_resources_api_test.go
new file mode 100644
index 0000000..6cb037c
--- /dev/null
+++ b/backend/internal/httpapi/project_resources_api_test.go
@@ -0,0 +1,190 @@
+package httpapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/certificate"
+ "git.pcux.cn/xuans/Scert/backend/internal/deployment"
+ "git.pcux.cn/xuans/Scert/backend/internal/domain"
+ "git.pcux.cn/xuans/Scert/backend/internal/repository"
+)
+
+type resourceCertificateReader struct {
+ reads []string
+}
+
+func (r *resourceCertificateReader) Current(_ context.Context, id string) (certificate.IssueResult, error) {
+ r.reads = append(r.reads, id)
+ return certificate.IssueResult{DomainID: id}, nil
+}
+
+func (r *resourceCertificateReader) ListVersions(_ context.Context, id string) ([]certificate.SavedResource, error) {
+ r.reads = append(r.reads, id)
+ return []certificate.SavedResource{}, nil
+}
+
+func newProjectResourceAPIFixture(t *testing.T) (projectMemberAPIFixture, *resourceCertificateReader) {
+ t.Helper()
+ f := newProjectMemberAPIFixture(t)
+ domains := repository.NewSQLDomainRepository(f.db)
+ servers := repository.NewSQLServerRepository(f.db)
+ for _, suffix := range []string{"a", "b"} {
+ item, err := domain.NewDomain(suffix+".example.test", domain.CertificateSingle, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ item.ID, item.ProjectID = "domain-"+suffix, "project-"+suffix
+ if err := domains.Create(t.Context(), item); err != nil {
+ t.Fatal(err)
+ }
+ server := deployment.Server{
+ ResourceScope: domain.ResourceScope{ProjectID: item.ProjectID},
+ ID: "server-" + suffix, Name: suffix + " 服务器", Address: "192.0.2.10", Port: 22, Username: "deploy",
+ PrivateKeyRef: "secret://ssh/fixture", HostKeyFingerprint: "SHA256:test",
+ CertificatePath: "/ssl/cert.pem", PrivateKeyPath: "/ssl/key.pem", WebServer: deployment.WebServerNginx,
+ }
+ if err := servers.CreateServer(t.Context(), server); err != nil {
+ t.Fatal(err)
+ }
+ if err := servers.ReplaceDomainServers(t.Context(), item.ID, []string{server.ID}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ reader := &resourceCertificateReader{}
+ f.server.repository, f.server.servers, f.server.currentCertificate = domains, servers, reader
+ return f, reader
+}
+
+func TestProjectResourceAPIListsUseProjectPermissions(t *testing.T) {
+ f, reader := newProjectResourceAPIFixture(t)
+ for _, tc := range []struct {
+ actor string
+ count int
+ }{
+ {"primary-super-admin", 2}, {"manager", 1}, {"viewer", 1}, {"global-admin", 0}, {"target", 0},
+ } {
+ for _, path := range []string{"/api/v1/domains", "/api/v1/servers", "/api/v1/certificates"} {
+ reader.reads = nil
+ response := serveUserRequest(f.server, f.cookies[tc.actor], http.MethodGet, path, "")
+ var page struct {
+ Items []json.RawMessage `json:"items"`
+ }
+ if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &page) != nil || page.Items == nil || len(page.Items) != tc.count {
+ t.Fatalf("可见资源范围错误:%s,%s,%d,%s", tc.actor, path, response.Code, response.Body.String())
+ }
+ if response.Header().Get("Cache-Control") != "private, no-store" || !strings.Contains(response.Header().Get("Vary"), "Cookie") {
+ t.Fatalf("资源响应缺少身份缓存隔离:%v", response.Header())
+ }
+ if tc.actor != "primary-super-admin" && strings.Contains(response.Body.String(), "project-b") {
+ t.Fatalf("泄露其他项目:%s", response.Body.String())
+ }
+ if path == "/api/v1/certificates" && len(reader.reads) != tc.count {
+ t.Fatalf("证书读取器访问了无权限域名:%+v", reader.reads)
+ }
+ }
+ }
+}
+
+func TestProjectResourceAPIDetailsHideExistenceAndGateFiles(t *testing.T) {
+ f, reader := newProjectResourceAPIFixture(t)
+ for _, suffix := range []string{"a", "b", "missing"} {
+ for _, path := range []string{
+ "/api/v1/servers/server-" + suffix,
+ "/api/v1/certificates/domain-" + suffix,
+ "/api/v1/domains/domain-" + suffix + "/certificate/versions",
+ "/api/v1/domains/domain-" + suffix + "/servers",
+ } {
+ reader.reads = nil
+ response := serveUserRequest(f.server, f.cookies["viewer"], http.MethodGet, path, "")
+ want := http.StatusForbidden
+ if suffix == "a" {
+ want = http.StatusOK
+ }
+ if response.Code != want {
+ t.Fatalf("资源详情权限错误:%s,%d,%s", path, response.Code, response.Body.String())
+ }
+ if suffix != "a" && (len(reader.reads) != 0 || !strings.Contains(response.Body.String(), "无权读取此项目资源")) {
+ t.Fatalf("拒绝前访问了证书文件或泄露资源:%s,%+v", response.Body.String(), reader.reads)
+ }
+ }
+ }
+}
+
+type resourceReadBoundary struct {
+ repository.ProjectResourceReader
+ before func()
+}
+
+func (r resourceReadBoundary) ListVisibleDomains(ctx context.Context, actor string) ([]domain.Domain, error) {
+ r.before()
+ return r.ProjectResourceReader.ListVisibleDomains(ctx, actor)
+}
+
+func TestProjectResourceAPIRechecksAfterSessionValidation(t *testing.T) {
+ for _, mode := range []string{"revoke", "disable"} {
+ t.Run(mode, func(t *testing.T) {
+ f, reader := newProjectResourceAPIFixture(t)
+ f.server.projectResources = resourceReadBoundary{ProjectResourceReader: f.members, before: func() {
+ if mode == "revoke" {
+ if err := f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "viewer", domain.ProjectRoleReadOnly); err != nil {
+ t.Fatal(err)
+ }
+ } else if _, err := f.db.ExecContext(t.Context(), `UPDATE users SET status = 'disabled' WHERE id = 'viewer'`); err != nil {
+ t.Fatal(err)
+ }
+ }}
+ response := serveUserRequest(f.server, f.cookies["viewer"], http.MethodGet, "/api/v1/certificates", "")
+ if response.Code != http.StatusOK || len(reader.reads) != 0 || !strings.Contains(response.Body.String(), `"items":[]`) {
+ t.Fatalf("会话通过后撤权仍读取资源:%d,%s,%+v", response.Code, response.Body.String(), reader.reads)
+ }
+ })
+ }
+}
+
+func TestProjectResourceAPIMissingDependencyFailsClosed(t *testing.T) {
+ f, reader := newProjectResourceAPIFixture(t)
+ f.server.projectResources = nil
+ for _, path := range []string{
+ "/api/v1/domains", "/api/v1/servers", "/api/v1/servers/server-a", "/api/v1/certificates",
+ "/api/v1/certificates/domain-a", "/api/v1/domains/domain-a/certificate/versions", "/api/v1/domains/domain-a/servers",
+ } {
+ response := serveUserRequest(f.server, f.cookies["primary-super-admin"], http.MethodGet, path, "")
+ if response.Code != http.StatusServiceUnavailable || len(reader.reads) != 0 {
+ t.Fatalf("缺失授权依赖时不应回退全量仓储:%s,%d,%s", path, response.Code, response.Body.String())
+ }
+ }
+}
+
+func TestProjectResourceAPIAuthenticationAndScopeForgery(t *testing.T) {
+ f, reader := newProjectResourceAPIFixture(t)
+ for _, path := range []string{"/api/v1/domains", "/api/v1/servers", "/api/v1/certificates"} {
+ request := httptest.NewRequest(http.MethodGet, path, nil)
+ response := httptest.NewRecorder()
+ f.server.Handler().ServeHTTP(response, request)
+ if response.Code != http.StatusUnauthorized || response.Header().Get("Cache-Control") != "private, no-store" {
+ t.Fatalf("未认证读取未拒绝或缺少缓存保护:%s,%d", path, response.Code)
+ }
+ request = httptest.NewRequest(http.MethodGet, path, nil)
+ request.AddCookie(f.cookies["viewer"])
+ request.Header.Set("Origin", "https://foreign.example.test")
+ response = httptest.NewRecorder()
+ f.server.Handler().ServeHTTP(response, request)
+ if response.Code != http.StatusForbidden {
+ t.Fatalf("跨来源读取未拒绝:%s,%d", path, response.Code)
+ }
+ }
+ for _, path := range []string{
+ "/api/v1/servers/server-b?project_id=project-a&actor_id=primary-super-admin",
+ "/api/v1/certificates/domain-b?project_id=project-a&actor_id=primary-super-admin",
+ } {
+ response := serveUserRequest(f.server, f.cookies["viewer"], http.MethodGet, path, "")
+ if response.Code != http.StatusForbidden || len(reader.reads) != 0 {
+ t.Fatalf("客户端伪造身份或项目生效:%s,%d", path, response.Code)
+ }
+ }
+}
diff --git a/backend/internal/httpapi/review_test.go b/backend/internal/httpapi/review_test.go
index 93be764..4793b4f 100644
--- a/backend/internal/httpapi/review_test.go
+++ b/backend/internal/httpapi/review_test.go
@@ -118,7 +118,7 @@ func TestHTTPServerUsesProductionTimeoutsAndTLSMinimum(t *testing.T) {
func TestDomainAPIValidatesSANAndHidesInternalErrors(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
repo := &stubDomainRepository{}
- server := New(Dependencies{Config: cfg, Repository: repo})
+ server := New(Dependencies{Config: cfg, Repository: repo, ProjectResources: projectResourceReaderStub{domains: repo}})
invalid := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["bad_domain"]}`))
invalid.Header.Set("Content-Type", "application/json")
diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go
index 8348156..7a03b37 100644
--- a/backend/internal/httpapi/server.go
+++ b/backend/internal/httpapi/server.go
@@ -51,6 +51,7 @@ type Dependencies struct {
ProjectMembers repository.ProjectMemberRepository
ProjectDirectory repository.ProjectDirectoryRepository
ProjectLifecycle repository.ProjectLifecycleRepository
+ ProjectResources repository.ProjectResourceReader
SSHKeys *sshkey.Service
DeploymentFactory deployment.ClientFactory
SetupDNS setupDNSVerifier
@@ -85,6 +86,7 @@ type Server struct {
projectMembers repository.ProjectMemberRepository
projectDirectory repository.ProjectDirectoryRepository
projectLifecycle repository.ProjectLifecycleRepository
+ projectResources repository.ProjectResourceReader
sshKeys *sshkey.Service
deploymentFactory deployment.ClientFactory
setupDNS setupDNSVerifier
@@ -148,6 +150,7 @@ func New(deps Dependencies) *Server {
projectMembers: deps.ProjectMembers,
projectDirectory: deps.ProjectDirectory,
projectLifecycle: deps.ProjectLifecycle,
+ projectResources: deps.ProjectResources,
sshKeys: deps.SSHKeys,
deploymentFactory: deps.DeploymentFactory,
issueCertificate: deps.IssueCertificate,
@@ -197,8 +200,8 @@ func (s *Server) registerRoutes() {
userAPI.DELETE("/:id", s.requireSuperAdmin, s.deleteUser)
s.registerProjectMemberRoutes()
s.registerProjectLifecycleRoutes()
+ s.registerProjectResourceRoutes()
s.router.GET("/api/v1/projects", privateProjectResponse, s.requireAuthenticated, s.listVisibleProjects)
- adminAPI.GET("/domains", s.listDomains)
adminAPI.POST("/domains", s.dnsOperation, s.createDomain)
adminAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
adminAPI.DELETE("/domains/:id", s.deleteDomain)
@@ -215,18 +218,12 @@ func (s *Server) registerRoutes() {
adminAPI.POST("/dns/test", s.dnsOperation, s.testAdminDNS)
adminAPI.POST("/dns/aliyun/check", s.dnsOperation, s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.longOperation(certificateTaskTimeout), s.runRenewalNow)
- adminAPI.GET("/certificates", s.listCertificates)
- adminAPI.GET("/certificates/:id", s.getCertificate)
adminAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
adminAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate)
adminAPI.POST("/domains/:id/deploy", s.longOperation(certificateTaskTimeout), s.deployCertificateCurrent)
- adminAPI.GET("/domains/:id/certificate/versions", s.listCertificateVersions)
adminAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion)
- adminAPI.GET("/domains/:id/servers", s.listDomainServers)
adminAPI.PUT("/domains/:id/servers", s.replaceDomainServers)
- adminAPI.GET("/servers", s.listServers)
adminAPI.POST("/servers", s.createServer)
- adminAPI.GET("/servers/:id", s.getServer)
adminAPI.PUT("/servers/:id", s.updateServer)
adminAPI.DELETE("/servers/:id", s.deleteServer)
adminAPI.GET("/ssh-keys", s.listSSHKeys)
@@ -529,12 +526,12 @@ func (s *Server) requireDomainRepository(c *gin.Context) bool {
}
func (s *Server) listDomains(c *gin.Context) {
- if !s.requireDomainRepository(c) {
+ if !s.requireProjectResourceReader(c) {
return
}
- items, err := s.repository.List(c.Request.Context())
+ items, err := s.projectResources.ListVisibleDomains(c.Request.Context(), c.GetString(authenticatedActorKey))
if err != nil {
- s.writeError(c, http.StatusInternalServerError, "查询域名失败", err)
+ s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
@@ -676,30 +673,24 @@ func (s *Server) issueDomain(c *gin.Context) {
}
func (s *Server) listServers(c *gin.Context) {
- if s.servers == nil {
- s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
+ if !s.requireProjectResourceReader(c) {
return
}
- items, err := s.servers.ListServers(c.Request.Context())
+ items, err := s.projectResources.ListVisibleServers(c.Request.Context(), c.GetString(authenticatedActorKey))
if err != nil {
- s.writeError(c, http.StatusInternalServerError, "查询服务器失败", err)
+ s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) getServer(c *gin.Context) {
- if s.servers == nil {
- s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
+ if !s.requireProjectResourceReader(c) {
return
}
- item, err := s.servers.GetServerByID(c.Request.Context(), c.Param("id"))
+ item, err := s.projectResources.GetVisibleServer(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
- if repository.IsNotFound(err) {
- s.writeError(c, http.StatusNotFound, "服务器不存在", err)
- return
- }
- s.writeError(c, http.StatusInternalServerError, "读取服务器失败", err)
+ s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, item)
@@ -962,24 +953,12 @@ func (s *Server) deleteServer(c *gin.Context) {
}
func (s *Server) listDomainServers(c *gin.Context) {
- if !s.requireDomainRepository(c) {
+ if !s.requireProjectResourceReader(c) {
return
}
- if s.servers == nil {
- s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
- return
- }
- if _, err := s.repository.GetByID(c.Request.Context(), c.Param("id")); err != nil {
- if repository.IsNotFound(err) {
- s.writeError(c, http.StatusNotFound, "域名不存在", err)
- return
- }
- s.writeError(c, http.StatusInternalServerError, "读取域名失败", err)
- return
- }
- items, err := s.servers.ListServersByDomainID(c.Request.Context(), c.Param("id"))
+ items, err := s.projectResources.ListVisibleDomainServers(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
- s.writeError(c, http.StatusInternalServerError, "查询域名部署服务器失败", err)
+ s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
diff --git a/backend/internal/httpapi/server_test.go b/backend/internal/httpapi/server_test.go
index 094cadb..54585d9 100644
--- a/backend/internal/httpapi/server_test.go
+++ b/backend/internal/httpapi/server_test.go
@@ -43,7 +43,11 @@ func TestHealthAndDomainAPI(t *testing.T) {
t.Fatalf("初始化日志失败:%v", err)
}
defer logger.Close()
- server := New(Dependencies{Config: cfg, Logger: logger, Repository: repository.NewSQLDomainRepository(db.DB)})
+ repo := repository.NewSQLDomainRepository(db.DB)
+ server := New(Dependencies{
+ Config: cfg, Logger: logger, Repository: repo,
+ ProjectResources: projectResourceReaderStub{domains: repo},
+ })
health := httptest.NewRecorder()
serveAdminHTTP(server, health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
@@ -308,6 +312,9 @@ func TestServerAndDomainAssociationAPI(t *testing.T) {
server := New(Dependencies{
Config: cfg, Repository: repository.NewSQLDomainRepository(db.DB),
Servers: repository.NewSQLServerRepository(db.DB), SSHKeys: sshKeys,
+ ProjectResources: projectResourceReaderStub{
+ domains: repository.NewSQLDomainRepository(db.DB), servers: repository.NewSQLServerRepository(db.DB),
+ },
})
domainRequest := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"single"}`))
@@ -668,10 +675,10 @@ func TestDomainAPIReportsMissingRepository(t *testing.T) {
response := httptest.NewRecorder()
serveAdminHTTP(server, response, request)
- if response.Code != http.StatusNotImplemented {
- t.Fatalf("域名仓储未配置时应返回 501:%d %s", response.Code, response.Body.String())
+ if response.Code != http.StatusServiceUnavailable {
+ t.Fatalf("项目资源授权仓储未配置时应返回 503:%d %s", response.Code, response.Body.String())
}
- if !strings.Contains(response.Body.String(), "域名管理接口尚未配置") {
+ if !strings.Contains(response.Body.String(), "项目资源服务尚未就绪") {
t.Fatalf("缺少域名仓储错误提示:%s", response.Body.String())
}
}
diff --git a/backend/internal/httpapi/test_helpers_test.go b/backend/internal/httpapi/test_helpers_test.go
index 863d1f7..bbbb6da 100644
--- a/backend/internal/httpapi/test_helpers_test.go
+++ b/backend/internal/httpapi/test_helpers_test.go
@@ -1,14 +1,44 @@
package httpapi
import (
+ "context"
"net/http"
"net/http/httptest"
"path/filepath"
"git.pcux.cn/xuans/Scert/backend/internal/config"
+ "git.pcux.cn/xuans/Scert/backend/internal/deployment"
+ "git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
+// 仅供旧的响应格式与错误脱敏单元测试显式注入;权限验证使用真实 SQL 资源夹具。
+// 不在产品构造器或通用请求帮助函数中自动补齐,缺失依赖测试必须保持拒绝。
+type projectResourceReaderStub struct {
+ domains repository.DomainRepository
+ servers repository.ServerRepository
+}
+
+func (r projectResourceReaderStub) ListVisibleDomains(ctx context.Context, _ string) ([]domain.Domain, error) {
+ return r.domains.List(ctx)
+}
+
+func (r projectResourceReaderStub) GetVisibleDomain(ctx context.Context, _ string, id string) (domain.Domain, error) {
+ return r.domains.GetByID(ctx, id)
+}
+
+func (r projectResourceReaderStub) ListVisibleServers(ctx context.Context, _ string) ([]deployment.Server, error) {
+ return r.servers.ListServers(ctx)
+}
+
+func (r projectResourceReaderStub) GetVisibleServer(ctx context.Context, _ string, id string) (deployment.Server, error) {
+ return r.servers.GetServerByID(ctx, id)
+}
+
+func (r projectResourceReaderStub) ListVisibleDomainServers(ctx context.Context, _ string, id string) ([]deployment.Server, error) {
+ return r.servers.ListServersByDomainID(ctx, id)
+}
+
func readyTestConfig(directory string) config.Config {
cfg := config.Default(directory)
// Linux 默认目录属于正式安装,测试必须显式隔离所有可写路径。
diff --git a/backend/internal/repository/project_directory_test.go b/backend/internal/repository/project_directory_test.go
index 674b833..e147c41 100644
--- a/backend/internal/repository/project_directory_test.go
+++ b/backend/internal/repository/project_directory_test.go
@@ -66,8 +66,11 @@ func checkProjectDirectory(t *testing.T, cfg config.DatabaseConfig) {
if err != nil || page.Items == nil || len(page.Items) != tc.count || page.HasMore || page.NextCursor != "" {
t.Fatalf("项目可见性错误:%s,%+v,%v", tc.actor, page, err)
}
+ if page.CanCreateProject != (tc.actor == "root") {
+ t.Fatalf("空目录也必须返回正确创建能力:%s,%+v", tc.actor, page)
+ }
for _, item := range page.Items {
- if page.CanCreateProject != (tc.actor == "root") || item.CanManageProject != tc.manage || item.CanDeleteProject != (tc.manage && item.ID != domain.DefaultProjectID) {
+ if item.CanManageProject != tc.manage || item.CanDeleteProject != (tc.manage && item.ID != domain.DefaultProjectID) {
t.Fatalf("生命周期能力错误:%s,%+v,%+v", tc.actor, page, item)
}
if item.CanManageMembers != tc.manage || (tc.actor != "root" && item.ID == domain.DefaultProjectID) {
diff --git a/backend/internal/repository/project_resources.go b/backend/internal/repository/project_resources.go
new file mode 100644
index 0000000..a4a6e3b
--- /dev/null
+++ b/backend/internal/repository/project_resources.go
@@ -0,0 +1,163 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/deployment"
+ "git.pcux.cn/xuans/Scert/backend/internal/domain"
+)
+
+// ProjectResourceReader 将实时权限和资源读取合并,不向外部入口暴露全量基础仓储。
+type ProjectResourceReader interface {
+ ListVisibleDomains(context.Context, string) ([]domain.Domain, error)
+ GetVisibleDomain(context.Context, string, string) (domain.Domain, error)
+ ListVisibleServers(context.Context, string) ([]deployment.Server, error)
+ GetVisibleServer(context.Context, string, string) (deployment.Server, error)
+ ListVisibleDomainServers(context.Context, string, string) ([]deployment.Server, error)
+}
+
+var _ ProjectResourceReader = (*SQLProjectMemberRepository)(nil)
+
+const visibleDomainSelect = `SELECT domains.id, domains.name, domains.certificate_type,
+ domains.sans_json, domains.auto_renew, domains.acme_environment, domains.dns_provider,
+ domains.status, domains.expires_at, domains.created_at, domains.updated_at,
+ domains.project_id, domains.owner_id, domains.created_by, domains.updated_by FROM domains`
+
+// 列名只接受内部固定值,角色能力复用领域模型;所有身份和角色值均使用绑定参数。
+// EXISTS 避免多角色账户重复返回资源,不按用户分组或全局普通管理员身份放行。
+func resourceReadPredicate(projectColumn, actorID string, permission domain.ProjectPermission) (string, []any) {
+ if projectColumn != "domains.project_id" && projectColumn != "servers.project_id" {
+ return "1 = 0", nil
+ }
+ if permission != domain.PermissionCertificateRead && permission != domain.PermissionServerRead {
+ return "1 = 0", nil
+ }
+ roles := []domain.ProjectRole{
+ domain.ProjectRoleAdmin, domain.ProjectRoleCertificateAdmin, domain.ProjectRoleDeploymentAdmin,
+ domain.ProjectRoleDNSAdmin, domain.ProjectRoleAuditViewer, domain.ProjectRoleReadOnly,
+ }
+ args := []any{actorID}
+ placeholders := make([]string, 0, len(roles))
+ for _, role := range roles {
+ if role.Allows(permission) {
+ placeholders = append(placeholders, "?")
+ args = append(args, string(role))
+ }
+ }
+ return `EXISTS (SELECT 1 FROM projects resource_project
+ JOIN users resource_actor ON resource_actor.id = ?
+ WHERE resource_project.id = ` + projectColumn + ` AND resource_actor.status = 'active'
+ AND (resource_actor.role = 'super_admin' OR (resource_actor.role IN ('admin', 'user')
+ AND EXISTS (SELECT 1 FROM project_members resource_member
+ WHERE resource_member.project_id = resource_project.id AND resource_member.user_id = resource_actor.id
+ AND resource_member.role IN (` + strings.Join(placeholders, ", ") + `)))))`, args
+}
+
+func (r *SQLProjectMemberRepository) resourceReaderReady(actorID string) error {
+ if err := r.ensureDB(); err != nil {
+ return err
+ }
+ if strings.TrimSpace(actorID) == "" || len(actorID) > 128 {
+ return ErrProjectAccessDenied
+ }
+ return nil
+}
+
+func (r *SQLProjectMemberRepository) ListVisibleDomains(ctx context.Context, actorID string) ([]domain.Domain, error) {
+ if err := r.resourceReaderReady(actorID); err != nil {
+ return nil, err
+ }
+ predicate, args := resourceReadPredicate("domains.project_id", actorID, domain.PermissionCertificateRead)
+ rows, err := r.db.QueryContext(ctx, Rebind(r.driver, visibleDomainSelect+` WHERE `+predicate+` ORDER BY domains.name, domains.id`), args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询可见域名失败:%w", err)
+ }
+ defer rows.Close()
+ items := make([]domain.Domain, 0)
+ for rows.Next() {
+ item, err := scanDomain(rows)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("读取可见域名失败:%w", err)
+ }
+ return items, nil
+}
+
+func (r *SQLProjectMemberRepository) GetVisibleDomain(ctx context.Context, actorID, id string) (domain.Domain, error) {
+ if err := r.resourceReaderReady(actorID); err != nil {
+ return domain.Domain{}, err
+ }
+ predicate, args := resourceReadPredicate("domains.project_id", actorID, domain.PermissionCertificateRead)
+ args = append(args, id)
+ item, err := scanDomain(r.db.QueryRowContext(ctx, Rebind(r.driver,
+ visibleDomainSelect+` WHERE `+predicate+` AND domains.id = ?`), args...))
+ if errors.Is(err, sql.ErrNoRows) {
+ return domain.Domain{}, ErrProjectAccessDenied
+ }
+ return item, err
+}
+
+func (r *SQLProjectMemberRepository) ListVisibleServers(ctx context.Context, actorID string) ([]deployment.Server, error) {
+ if err := r.resourceReaderReady(actorID); err != nil {
+ return nil, err
+ }
+ predicate, args := resourceReadPredicate("servers.project_id", actorID, domain.PermissionServerRead)
+ return r.readVisibleServers(ctx, serverSelectQualified+` WHERE `+predicate+` ORDER BY servers.name, servers.id`, args)
+}
+
+func (r *SQLProjectMemberRepository) GetVisibleServer(ctx context.Context, actorID, id string) (deployment.Server, error) {
+ if err := r.resourceReaderReady(actorID); err != nil {
+ return deployment.Server{}, err
+ }
+ predicate, args := resourceReadPredicate("servers.project_id", actorID, domain.PermissionServerRead)
+ args = append(args, id)
+ item, err := scanServer(r.db.QueryRowContext(ctx, Rebind(r.driver,
+ serverSelectQualified+` WHERE `+predicate+` AND servers.id = ?`), args...))
+ if errors.Is(err, sql.ErrNoRows) {
+ return deployment.Server{}, ErrProjectAccessDenied
+ }
+ return item, err
+}
+
+func (r *SQLProjectMemberRepository) ListVisibleDomainServers(ctx context.Context, actorID, domainID string) ([]deployment.Server, error) {
+ // 对未知和无权限域名统一拒绝;随后的查询仍重判双方权限,撤权后不会返回旧结果。
+ if _, err := r.GetVisibleDomain(ctx, actorID, domainID); err != nil {
+ return nil, err
+ }
+ domainPredicate, args := resourceReadPredicate("domains.project_id", actorID, domain.PermissionCertificateRead)
+ serverPredicate, serverArgs := resourceReadPredicate("servers.project_id", actorID, domain.PermissionServerRead)
+ args = append(args, serverArgs...)
+ args = append(args, domainID)
+ return r.readVisibleServers(ctx, serverSelectQualified+`
+ JOIN domain_servers visible_binding ON visible_binding.server_id = servers.id
+ JOIN domains ON domains.id = visible_binding.domain_id AND domains.project_id = servers.project_id
+ WHERE `+domainPredicate+` AND `+serverPredicate+` AND domains.id = ? ORDER BY servers.name, servers.id`, args)
+}
+
+func (r *SQLProjectMemberRepository) readVisibleServers(ctx context.Context, query string, args []any) ([]deployment.Server, error) {
+ rows, err := r.db.QueryContext(ctx, Rebind(r.driver, query), args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询可见服务器失败:%w", err)
+ }
+ defer rows.Close()
+ items := make([]deployment.Server, 0)
+ for rows.Next() {
+ item, err := scanServer(rows)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("读取可见服务器失败:%w", err)
+ }
+ return items, nil
+}
diff --git a/backend/internal/repository/project_resources_test.go b/backend/internal/repository/project_resources_test.go
new file mode 100644
index 0000000..45e9769
--- /dev/null
+++ b/backend/internal/repository/project_resources_test.go
@@ -0,0 +1,165 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/config"
+ "git.pcux.cn/xuans/Scert/backend/internal/database"
+ "git.pcux.cn/xuans/Scert/backend/internal/deployment"
+ "git.pcux.cn/xuans/Scert/backend/internal/domain"
+ "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
+)
+
+func TestProjectResourcesSQLite(t *testing.T) {
+ checkProjectResources(t, config.Default(t.TempDir()).Database)
+}
+
+func TestProjectResourcesMySQLReal(t *testing.T) {
+ server := mysqltest.New(t)
+ checkProjectResources(t, server.Database(t))
+}
+
+func checkProjectResources(t *testing.T, cfg config.DatabaseConfig) {
+ t.Helper()
+ db, err := database.Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil {
+ t.Fatal(err)
+ }
+ repo := NewSQLProjectMemberRepository(db.DB, cfg.Driver)
+ projects := NewSQLProjectRepository(db.DB, cfg.Driver)
+ domains := NewSQLDomainRepository(db.DB, cfg.Driver)
+ servers := NewSQLServerRepository(db.DB, cfg.Driver)
+ for _, user := range []struct{ id, role string }{
+ {"root", "super_admin"}, {"manager", "user"}, {"cert", "user"}, {"deploy", "admin"},
+ {"dns", "user"}, {"audit", "user"}, {"reader", "user"}, {"outsider", "admin"},
+ } {
+ if _, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users
+ (id, username, password_hash, role, status) VALUES (?, ?, 'test-only-no-login', ?, 'active')`),
+ user.id, user.id, user.role); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, id := range []string{"a", "b"} {
+ if err := projects.CreateProject(t.Context(), domain.Project{ID: "project-" + id, Name: "资源项目"}); err != nil {
+ t.Fatal(err)
+ }
+ item, err := domain.NewDomain(id+".example.test", domain.CertificateSingle, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ item.ID, item.ProjectID = "domain-"+id, "project-"+id
+ if err := domains.Create(t.Context(), item); err != nil {
+ t.Fatal(err)
+ }
+ server := deployment.Server{
+ ResourceScope: domain.ResourceScope{ProjectID: item.ProjectID},
+ ID: "server-" + id, Name: "资源服务器", Address: "192.0.2.10", Port: 22, Username: "deploy",
+ PrivateKeyRef: "secret://ssh/fixture", HostKeyFingerprint: "SHA256:test",
+ CertificatePath: "/ssl/cert.pem", PrivateKeyPath: "/ssl/key.pem", WebServer: deployment.WebServerNginx,
+ }
+ if err := servers.CreateServer(t.Context(), server); err != nil {
+ t.Fatal(err)
+ }
+ if err := servers.ReplaceDomainServers(t.Context(), item.ID, []string{server.ID}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for actor, role := range map[string]domain.ProjectRole{
+ "manager": domain.ProjectRoleAdmin, "cert": domain.ProjectRoleCertificateAdmin,
+ "deploy": domain.ProjectRoleDeploymentAdmin, "dns": domain.ProjectRoleDNSAdmin,
+ "audit": domain.ProjectRoleAuditViewer, "reader": domain.ProjectRoleReadOnly,
+ } {
+ if err := repo.GrantProjectRole(t.Context(), "root", "project-a", actor, role); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := repo.GrantProjectRole(t.Context(), "root", "project-a", "manager", domain.ProjectRoleReadOnly); err != nil {
+ t.Fatal(err)
+ }
+ t.Run("lists_filter_and_deduplicate", func(t *testing.T) {
+ for _, tc := range []struct {
+ actor string
+ domains, servers int
+ }{
+ {"root", 2, 2}, {"manager", 1, 1}, {"cert", 1, 0}, {"deploy", 1, 1},
+ {"dns", 0, 0}, {"audit", 0, 0}, {"reader", 1, 1}, {"outsider", 0, 0}, {"missing", 0, 0},
+ } {
+ domainItems, err := repo.ListVisibleDomains(t.Context(), tc.actor)
+ if err != nil || domainItems == nil || len(domainItems) != tc.domains {
+ t.Fatalf("域名范围错误:%s,%+v,%v", tc.actor, domainItems, err)
+ }
+ serverItems, err := repo.ListVisibleServers(t.Context(), tc.actor)
+ if err != nil || serverItems == nil || len(serverItems) != tc.servers {
+ t.Fatalf("服务器范围错误:%s,%+v,%v", tc.actor, serverItems, err)
+ }
+ if tc.actor != "root" {
+ for _, item := range domainItems {
+ if item.ProjectID != "project-a" {
+ t.Fatalf("跨项目域名泄露:%+v", item)
+ }
+ }
+ for _, item := range serverItems {
+ if item.ProjectID != "project-a" {
+ t.Fatalf("跨项目服务器泄露:%+v", item)
+ }
+ }
+ }
+ }
+ })
+ t.Run("details_and_bindings_use_real_scope", func(t *testing.T) {
+ for _, id := range []string{"b", "missing", "a' OR 1=1 --"} {
+ if _, err := repo.GetVisibleDomain(t.Context(), "reader", "domain-"+id); !errors.Is(err, ErrProjectAccessDenied) {
+ t.Fatalf("越权域名应统一拒绝:%s,%v", id, err)
+ }
+ if _, err := repo.GetVisibleServer(t.Context(), "reader", "server-"+id); !errors.Is(err, ErrProjectAccessDenied) {
+ t.Fatalf("越权服务器应统一拒绝:%s,%v", id, err)
+ }
+ }
+ for _, actor := range []string{"root", "manager", "reader", "cert"} {
+ items, err := repo.ListVisibleDomainServers(t.Context(), actor, "domain-a")
+ want := 1
+ if actor == "cert" {
+ want = 0
+ }
+ if err != nil || len(items) != want || (want == 1 && items[0].ID != "server-a") {
+ t.Fatalf("绑定未核对双方读取权限:%s,%+v,%v", actor, items, err)
+ }
+ }
+ if _, err := repo.ListVisibleDomainServers(t.Context(), "reader", "domain-b"); !errors.Is(err, ErrProjectAccessDenied) {
+ t.Fatalf("越权绑定查询未拒绝:%v", err)
+ }
+ })
+ t.Run("revocation_and_disabled_account_are_not_cached", func(t *testing.T) {
+ if err := repo.RevokeProjectRole(t.Context(), "root", "project-a", "reader", domain.ProjectRoleReadOnly); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := repo.GetVisibleDomain(t.Context(), "reader", "domain-a"); !errors.Is(err, ErrProjectAccessDenied) {
+ t.Fatalf("撤权后仍能读取:%v", err)
+ }
+ if _, err := db.DB.ExecContext(t.Context(), `UPDATE users SET status = 'disabled' WHERE id = 'manager'`); err != nil {
+ t.Fatal(err)
+ }
+ for _, actor := range []string{"reader", "manager"} {
+ items, err := repo.ListVisibleServers(t.Context(), actor)
+ if err != nil || len(items) != 0 {
+ t.Fatalf("撤权或停用后仍有资源:%s,%+v,%v", actor, items, err)
+ }
+ }
+ })
+ t.Run("canceled_and_missing_identity", func(t *testing.T) {
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ if _, err := repo.ListVisibleDomains(ctx, "root"); err == nil {
+ t.Fatal("取消请求不应成功")
+ }
+ if _, err := repo.ListVisibleServers(t.Context(), ""); !errors.Is(err, ErrProjectAccessDenied) {
+ t.Fatalf("空身份未拒绝:%v", err)
+ }
+ })
+}
diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md
index 192dcb3..82e7962 100644
--- a/doc/Scert整改任务执行清单.md
+++ b/doc/Scert整改任务执行清单.md
@@ -1,5 +1,19 @@
# Scert 整改任务执行清单
+## 2026 年 9 月 28 日:先开发、后统一测试(20:57 接续)
+
+需求确认:按照用户最新指令先完成开发,再集中运行测试。沿用 P0-06/P0-07 的既定需求,不缩减原始 44 项,不将未测试的源码记为复审通过。本阶段不清库、不部署、不创建辅助验收脚本。
+
+- [x] 核对当前分支和交接差异;生命周期候选已在 `dcca17e`,两处测试修正另以检查点 `fd767b2` 提交推送。
+- [x] 生命周期收尾源码已完成,待统一测试:领域、接口及失败审计统一项目标识校验;项目与成员操作共享忙状态,补充控制字符和表单渲染用例。
+- [x] 资源读取隔离源码已完成,待统一测试:域名、服务器、绑定列表及证书元数据入口根据真实资源归属判权,列表在 SQL 内过滤。
+- [x] 正式应用依赖已接入;缺少授权仓储拒绝读取,不退回旧全量列表;无权限与不存在统一拒绝,内部错误不向客户端透传。
+- [~] 补齐模型、接口交接及实际进度文档;SQLite/MySQL、HTTP 和前端回归用例仅编写,不执行。
+- [~] 下一子项:将域名绑定服务器的写入改为真实项目授权与事务审计;权限须覆盖双方资源,失败保留旧绑定,不触发 SSH 或部署。
+- [!] 统一测试推迟至开发阶段完成之后;构建、浏览器和生产验收均未执行,旧证据不能证明新代码。
+
+历史补记:上一批完整前端 210 项通过且生产构建成功;MySQL 5.7 组合通过,MySQL 8.0 的三条审计故障注入用例因业务账户无建触发器权限失败。夹具改由隔离实例管理员建触发器,未提高业务权限,修正后尚未运行。新增非法项目 ID 回归已复现失败,详见 `artifacts/project-lifecycle-20260928/invalid-id-before.jsonl`。以下 20:47 及更早记录仅为历史快照。
+
## 2026 年 9 月 28 日:项目生命周期与审计(20:21 开始,20:47 同步)
需求确认:继续 P0-06/P0-07,补齐项目创建、资料修改和空项目删除的正式入口;项目不是用户分组。创建仅允许当前有效超级管理员,修改沿用项目管理员实时权限;删除仍遵守默认项目与域名、服务器、成员引用保护,不自动删除业务资源或成员,不清理现有业务数据。名称允许重名,标识由服务端生成且不可修改。所有成功变更与审计同事务,失败记录不泄露请求正文。
diff --git a/frontend/src/views/ProjectManagement/ProjectLifecycle.js b/frontend/src/views/ProjectManagement/ProjectLifecycle.js
index b04f2f7..ca01a33 100644
--- a/frontend/src/views/ProjectManagement/ProjectLifecycle.js
+++ b/frontend/src/views/ProjectManagement/ProjectLifecycle.js
@@ -1,10 +1,9 @@
import { computed, onUnmounted, ref, watch } from "vue";
import { handleProjectSession, requestProjectAPI } from "./ProjectRequest.js";
-export function useProjectLifecycle(project, canCreateProject, busy, refresh) {
+export function useProjectLifecycle(project, canCreateProject, busy, refresh, savingProject = ref(false)) {
const projectEditor = ref(null);
const pendingDeleteProject = ref(null);
- const savingProject = ref(false);
const lifecycleError = ref("");
const lifecycleNotice = ref("");
const canEditProject = computed(() => Boolean(project.value?.can_manage_project));
@@ -87,7 +86,9 @@ export function useProjectLifecycle(project, canCreateProject, busy, refresh) {
if (!form || !(form.create ? canCreateProject.value : canEditProject.value && form.id === project.value?.id)) return;
const name = form.name.trim();
const description = form.description.trim();
- if (!name || [...name].length > 128 || [...description].length > 4096 || /[\u0000-\u001f\u007f]/.test(name)) {
+ if (!name || [...name].length > 128 || [...description].length > 4096
+ || /[\u0000-\u001f\u007f-\u009f]/.test(name)
+ || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/.test(description)) {
lifecycleError.value = "请填写有效项目名称(最多 128 字),说明最多 4096 字";
return;
}
diff --git a/frontend/src/views/ProjectManagement/ProjectLifecycle.test.js b/frontend/src/views/ProjectManagement/ProjectLifecycle.test.js
index a01a6a3..259fa44 100644
--- a/frontend/src/views/ProjectManagement/ProjectLifecycle.test.js
+++ b/frontend/src/views/ProjectManagement/ProjectLifecycle.test.js
@@ -4,10 +4,50 @@ import { ref } from "vue";
import { mountComposable, deferred } from "../../test-support/mount.js";
import { useProjectLifecycle } from "./ProjectLifecycle.js";
import { useProjectManagement } from "./ProjectManagement.js";
+import { useProjectMembers } from "./ProjectMembers.js";
const projectA = { id: "project-a", name: "项目甲", description: "说明", can_manage_project: true, can_delete_project: true, can_manage_members: false };
const projectB = { ...projectA, id: "project-b", name: "项目乙" };
+test("项目保存期间,成员操作函数也拒绝变更和翻页", async (t) => {
+ const project = ref({ ...projectA, can_manage_members: true });
+ const busy = ref(false);
+ const calls = [];
+ const { state } = await mountComposable(t, () => useProjectMembers(project, busy), async (path, options) => {
+ calls.push([path, options.method]);
+ return Response.json({ items: [], has_more: true, next_cursor: "member-next" });
+ });
+ const before = calls.length;
+ busy.value = true;
+ state.memberForm.value = { userID: "target", role: "read_only" };
+ await state.grantMember();
+ state.requestRevoke({ user_id: "target", username: "目标", roles: ["read_only"] }, "read_only");
+ await state.nextMemberPage();
+ await state.changeMemberPageSize(30);
+ assert.equal(calls.length, before);
+ assert.equal(state.pendingRevoke.value, null);
+ assert.equal(state.memberPage.value, 0);
+ assert.equal(state.memberPageSize.value, 10);
+});
+
+test("项目资料拒绝控制字符但说明允许换行和制表符", async (t) => {
+ const calls = [];
+ const { state } = await setup(t, async (_path, options) => {
+ calls.push(JSON.parse(options.body));
+ return Response.json({ id: "created" }, { status: 201 });
+ });
+ state.openProjectEditor(true);
+ for (const [name, description] of [["项目\u0085名称", "说明"], ["项目", "说明\u000b内容"], ["项目", "说明\u009f内容"]]) {
+ Object.assign(state.projectEditor.value, { name, description });
+ await state.saveProject();
+ assert.equal(calls.length, 0);
+ assert.ok(state.lifecycleError.value);
+ }
+ Object.assign(state.projectEditor.value, { name: "项目", description: "第一行\n第二行\t内容" });
+ await state.saveProject();
+ assert.deepEqual(calls, [{ name: "项目", description: "第一行\n第二行\t内容" }]);
+});
+
async function setup(t, fetcher, refresh = async () => {}) {
const project = ref({ ...projectA });
const canCreate = ref(true);
diff --git a/frontend/src/views/ProjectManagement/ProjectManagement.js b/frontend/src/views/ProjectManagement/ProjectManagement.js
index 12e8dd5..1e8f61c 100644
--- a/frontend/src/views/ProjectManagement/ProjectManagement.js
+++ b/frontend/src/views/ProjectManagement/ProjectManagement.js
@@ -14,9 +14,10 @@ export function useProjectManagement() {
const projectHasMore = ref(false);
const projectNextCursor = ref("");
const canCreateProject = ref(false);
- const members = useProjectMembers(selectedProject);
+ const savingProject = ref(false);
+ const members = useProjectMembers(selectedProject, savingProject);
const lifecycle = useProjectLifecycle(selectedProject, canCreateProject,
- computed(() => loadingProjects.value || members.savingMember.value), refreshAfterMutation);
+ computed(() => loadingProjects.value || members.savingMember.value), refreshAfterMutation, savingProject);
let generation = 0;
let controller;
diff --git a/frontend/src/views/ProjectManagement/ProjectManagement.test.js b/frontend/src/views/ProjectManagement/ProjectManagement.test.js
index 007b674..3c14e10 100644
--- a/frontend/src/views/ProjectManagement/ProjectManagement.test.js
+++ b/frontend/src/views/ProjectManagement/ProjectManagement.test.js
@@ -42,6 +42,36 @@ test("完整成员页面可渲染中文、多个角色、权限拒绝和确认
assert.match(html, /当前没有成员管理权限/);
assert.doesNotMatch(html, /project-grant-form/);
assert.doesNotMatch(html, /unexpected/);
+
+ state.canCreateProject.value = true;
+ state.openProjectEditor(true);
+ state.projectEditor.value.name = '
';
+ state.projectEditor.value.description = "第一行\n第二行";
+ html = await renderPage();
+ assert.match(html, /aria-label="项目资料"/);
+ assert.match(html, /maxlength="128"/);
+ assert.match(html, /maxlength="4096"/);
+ assert.match(html, /第一行\n第二行/);
+ assert.match(html, /<img/);
+ assert.doesNotMatch(html, /
]*disabled/);
+ assert.match(html, /