diff --git a/backend/internal/httpapi/certificate_api.go b/backend/internal/httpapi/certificate_api.go index c63ce3e..40757c1 100644 --- a/backend/internal/httpapi/certificate_api.go +++ b/backend/internal/httpapi/certificate_api.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/repository" ) @@ -92,6 +93,9 @@ func (s *Server) listCertificateVersions(c *gin.Context) { } func (s *Server) validateCertificate(c *gin.Context) { + if !s.authorizeVisibleDomainPermission(c, domain.PermissionPrivateKeyRead) { + return + } if s.currentCertificate == nil { s.writeError(c, http.StatusNotImplemented, "证书校验接口尚未配置", errors.New("证书服务未配置")) return @@ -116,6 +120,9 @@ func (s *Server) validateCertificate(c *gin.Context) { } func (s *Server) deployCertificateCurrent(c *gin.Context) { + if !s.authorizeVisibleDomainPermission(c, domain.PermissionDeploymentExecute) { + return + } if s.deployCertificate == nil { s.writeError(c, http.StatusNotImplemented, "证书部署接口尚未配置", errors.New("证书部署流程未配置")) return @@ -135,6 +142,9 @@ func (s *Server) deployCertificateCurrent(c *gin.Context) { } func (s *Server) importCertificateFiles(c *gin.Context) { + if !s.authorizeVisibleDomainPermission(c, domain.PermissionCertificateManage) { + return + } if s.importCertificate == 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 09854f1..db023ed 100644 --- a/backend/internal/httpapi/certificate_api_test.go +++ b/backend/internal/httpapi/certificate_api_test.go @@ -17,6 +17,7 @@ import ( "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 certificateReaderStub struct { @@ -355,3 +356,52 @@ func TestCertificateImportAPISanitizesReportDetails(t *testing.T) { t.Fatalf("证书导入响应缺少安全的服务器信息:%s", responseBody) } } + +func TestCertificateMutationAPIsEnforceProjectPermissions(t *testing.T) { + repo := &stubDomainRepository{ + loaded: domain.Domain{ + ID: "domain-1", + ResourceScope: domain.ResourceScope{ProjectID: domain.DefaultProjectID}, + }, + } + calls := 0 + server := New(Dependencies{ + Config: readyTestConfig(t.TempDir()), + Repository: repo, + ProjectResources: projectResourceReaderStub{domains: repo}, + ProjectDomains: projectDomainRepositoryStub{ + DomainRepository: repo, + authorizeErr: repository.ErrProjectAccessDenied, + }, + CurrentCertificate: certificateReaderStub{}, + DeployCertificate: func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + calls++ + return certificate.IssueResult{}, deployment.Report{}, nil + }, + ImportCertificate: func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) { + calls++ + return certificate.IssueResult{}, deployment.Report{}, nil + }, + RestoreCertificateVersion: func(context.Context, string, string) (certificate.IssueResult, error) { + calls++ + return certificate.IssueResult{}, nil + }, + }) + + requests := []*http.Request{ + httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/deploy", nil), + httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/validate", nil), + httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/import", nil), + httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/versions/v1/restore", nil), + } + for _, request := range requests { + response := httptest.NewRecorder() + serveAdminHTTP(server, response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("unauthorized certificate mutation returned %d: %s", response.Code, response.Body.String()) + } + } + if calls != 0 { + t.Fatalf("certificate workflow was called %d times after authorization failure", calls) + } +} diff --git a/backend/internal/httpapi/certificate_restore_api.go b/backend/internal/httpapi/certificate_restore_api.go index c345f39..75ca5c8 100644 --- a/backend/internal/httpapi/certificate_restore_api.go +++ b/backend/internal/httpapi/certificate_restore_api.go @@ -7,10 +7,14 @@ import ( "github.com/gin-gonic/gin" + "git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/repository" ) func (s *Server) restoreCertificateVersion(c *gin.Context) { + if !s.authorizeVisibleDomainPermission(c, domain.PermissionDeploymentRollback) { + return + } if s.restoreVersion == nil { s.writeError(c, http.StatusNotImplemented, "证书恢复接口尚未配置", errors.New("证书恢复流程未配置")) return diff --git a/backend/internal/httpapi/certificate_restore_api_test.go b/backend/internal/httpapi/certificate_restore_api_test.go index 91cfa31..00c8b55 100644 --- a/backend/internal/httpapi/certificate_restore_api_test.go +++ b/backend/internal/httpapi/certificate_restore_api_test.go @@ -44,9 +44,12 @@ func newRestoreAPITestServer(t *testing.T, restore func(context.Context, string, cfg.Admin.Username = "admin" cfg.Admin.Email = "admin@example.test" cfg.Admin.PasswordHash = httpUserPasswordHash(t, "RestoreTest123") + repo := &stubDomainRepository{loaded: domain.Domain{ID: "domain-1", ResourceScope: domain.ResourceScope{ProjectID: domain.DefaultProjectID}}} server := New(Dependencies{ Config: cfg, - Repository: &stubDomainRepository{}, + Repository: repo, + ProjectResources: projectResourceReaderStub{domains: repo}, + ProjectDomains: projectDomainRepositoryStub{DomainRepository: repo}, CurrentCertificate: restoreCertificateReader{}, RestoreCertificateVersion: restore, Audit: audit, diff --git a/backend/internal/httpapi/project_resources_api.go b/backend/internal/httpapi/project_resources_api.go index 2e3ba0b..cb8eb1c 100644 --- a/backend/internal/httpapi/project_resources_api.go +++ b/backend/internal/httpapi/project_resources_api.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" ) @@ -48,3 +49,30 @@ func (s *Server) requireVisibleCertificateDomain(c *gin.Context) bool { } return true } + +func (s *Server) authorizeVisibleDomainPermission(c *gin.Context, permission domain.ProjectPermission) bool { + if !s.requireProjectResourceReader(c) { + return false + } + item, err := s.projectResources.GetVisibleDomain(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id")) + if err != nil { + s.writeProjectResourceError(c, err) + return false + } + authorizer, ok := s.projectDomains.(repository.ProjectAuthorizer) + if !ok { + if fallback, fallbackOK := s.projectMembers.(repository.ProjectAuthorizer); fallbackOK { + authorizer = fallback + ok = true + } + } + if !ok { + s.writeProjectResourceError(c, errors.New("project authorizer unavailable")) + return false + } + if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), item.ProjectID, permission); err != nil { + s.writeProjectResourceError(c, err) + return false + } + return true +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 78e50bf..d07ee02 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -226,10 +226,10 @@ 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.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.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion) + domainAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles) + domainAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate) + domainAPI.POST("/domains/:id/deploy", s.longOperation(certificateTaskTimeout), s.deployCertificateCurrent) + domainAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion) adminAPI.POST("/servers", s.createServer) adminAPI.PUT("/servers/:id", s.updateServer) adminAPI.DELETE("/servers/:id", s.deleteServer) diff --git a/backend/internal/httpapi/test_helpers_test.go b/backend/internal/httpapi/test_helpers_test.go index d0d9e25..4a740f5 100644 --- a/backend/internal/httpapi/test_helpers_test.go +++ b/backend/internal/httpapi/test_helpers_test.go @@ -75,6 +75,15 @@ func readyTestConfig(directory string) config.Config { } func serveAdminHTTP(server *Server, recorder *httptest.ResponseRecorder, request *http.Request) { + if server.projectResources == nil { + if repo, ok := server.repository.(*stubDomainRepository); ok { + if repo.loaded.ID == "" { + repo.loaded = domain.Domain{ID: "domain-1", ResourceScope: domain.ResourceScope{ProjectID: domain.DefaultProjectID}} + } + server.projectResources = projectResourceReaderStub{domains: repo} + server.projectDomains = projectDomainRepositoryStub{DomainRepository: repo} + } + } request.AddCookie(httpUserSessionCookie(server, "test-admin", repository.UserRoleAdmin)) server.Handler().ServeHTTP(recorder, request) } diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 5fdc0b8..926b8a6 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3379,3 +3379,13 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - [!] PostgreSQL/MySQL 矩阵、浏览器、Linux/Windows 服务、远端 SSH、HTTPS/DNS/ACME 和生产验收仍未执行。 下一步:继续补齐任务与证书/部署权限链,完成开发阶段后再统一运行三库、浏览器和远端生产流程测试。 + + +## ???????2026-09-29 18:38? + +???????? **P0-03 ?????**?????????? P0-07? + +- [x] ?????????????????????????????????? +- [x] ???????????????? HTTP ????? +- [~] ????SSH?DNS???????????????????????????????????????????? +- [!] ???????????????????????????? diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index ad3abb7..f413248 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -680,3 +680,13 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 - [!] PostgreSQL/MySQL 矩阵、浏览器、Linux/Windows 服务、远端 SSH、HTTPS/DNS/ACME 和生产验收仍未执行。 下一步:继续补齐任务与证书/部署权限链,完成开发阶段后再统一运行三库、浏览器和远端生产流程测试。 + + +## ???????2026-09-29 18:38? + +???????????? **P0-03 ?????**??????? P0-07?????????????????????? + +- [x] ?????????????????????????????????????? +- [x] ?????? `certificate.manage`??????? `certificate.private_key.read`????????? `deployment.execute`????????? `deployment.rollback`? +- [x] ????????????????? HTTP ?????`go test ./internal/httpapi` ???? +- [~] ????SSH ???DNS?????????????????????????????? SSH?HTTPS/DNS/ACME ???????????? diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index d45d6ed..6322e35 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1318,3 +1318,11 @@ P0-11 生产验收门禁 - [!] PostgreSQL/MySQL 矩阵、浏览器、Linux/Windows 服务、远端 SSH、HTTPS/DNS/ACME 和生产验收仍未执行。 下一步:继续补齐任务与证书/部署权限链,完成开发阶段后再统一运行三库、浏览器和远端生产流程测试。 + + +## ???????2026-09-29 18:38? + +- **???????P0-03 ??????** +- **P0-07?????** ????????????????????????????????????????????????? +- P0-07 ????????SSH?DNS??????????????????????????????????????? HTTPS/DNS/ACME ??? +- ???????????????????????????????? diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index 8719126..8379299 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -274,3 +274,6 @@ P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收 - [!] PostgreSQL/MySQL 矩阵、浏览器、Linux/Windows 服务、远端 SSH、HTTPS/DNS/ACME 和生产验收仍未执行。 下一步:继续补齐任务与证书/部署权限链,完成开发阶段后再统一运行三库、浏览器和远端生产流程测试。 + + +> ???????2026-09-29 18:38???????? **P0-03 ?????**?P0-07 ?????????????????????????????????????? HTTP ?????????????SSH?DNS??????????????????????????????????