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, /]*disabled/); + state.savingProject.value = false; + state.cancelProjectAction(); + state.selectedProject.value = { ...projectA, can_manage_project: true, can_delete_project: true }; + state.requestDeleteProject(); + html = await renderPage(); + assert.match(html, /确认删除项目/); + assert.match(html, /默认项目不能删除/); + assert.doesNotMatch(html, /project-editor/); + state.selectedProject.value = { ...projectA, can_manage_project: false, can_delete_project: false }; + state.openProjectEditor(); + state.requestDeleteProject(); + html = await renderPage(); + assert.doesNotMatch(html, /确认删除项目|aria-label="项目资料"/); }); test("账户页提供本人可分享的账户 ID,不要求用户查看数据库", async () => { diff --git a/frontend/src/views/ProjectManagement/ProjectMembers.js b/frontend/src/views/ProjectManagement/ProjectMembers.js index da61f1c..fa2e9f1 100644 --- a/frontend/src/views/ProjectManagement/ProjectMembers.js +++ b/frontend/src/views/ProjectManagement/ProjectMembers.js @@ -10,7 +10,7 @@ export const projectRoles = [ { value: "read_only", label: "只读用户" }, ]; -export function useProjectMembers(project) { +export function useProjectMembers(project, projectBusy = ref(false)) { const members = ref([]); const accounts = ref([]); const loadingMembers = ref(false); @@ -95,7 +95,7 @@ export function useProjectMembers(project) { } async function changeMemberPageSize(value) { - if (savingMember.value || !pageSizes.includes(Number(value))) return; + if (projectBusy.value || savingMember.value || !pageSizes.includes(Number(value))) return; memberPageSize.value = Number(value); memberPage.value = 0; cursors.value = [""]; @@ -104,7 +104,7 @@ export function useProjectMembers(project) { } async function nextMemberPage() { - if (loadingMembers.value || savingMember.value || !memberHasMore.value) return; + if (projectBusy.value || loadingMembers.value || savingMember.value || !memberHasMore.value) return; cursors.value = [...cursors.value.slice(0, memberPage.value + 1), nextCursor.value]; memberPage.value += 1; pendingRevoke.value = null; @@ -112,14 +112,14 @@ export function useProjectMembers(project) { } async function previousMemberPage() { - if (loadingMembers.value || savingMember.value || memberPage.value === 0) return; + if (projectBusy.value || loadingMembers.value || savingMember.value || memberPage.value === 0) return; memberPage.value -= 1; pendingRevoke.value = null; await loadMembers(); } async function mutateMember(userID, role, revoke) { - if (!canManageMembers.value || savingMember.value || loadingMembers.value) return; + if (projectBusy.value || !canManageMembers.value || savingMember.value || loadingMembers.value) return; if (!/^[a-zA-Z0-9_-]{1,128}$/.test(userID) || !projectRoles.some((item) => item.value === role)) { memberError.value = "请选择有效的账户 ID 和项目角色"; return; @@ -159,7 +159,7 @@ export function useProjectMembers(project) { } function requestRevoke(member, role) { - if (!canManageMembers.value || savingMember.value || loadingMembers.value || !member.roles.includes(role)) return; + if (projectBusy.value || !canManageMembers.value || savingMember.value || loadingMembers.value || !member.roles.includes(role)) return; pendingRevoke.value = { userID: member.user_id, username: member.username, role }; }