diff --git a/backend/internal/httpapi/certificate_api.go b/backend/internal/httpapi/certificate_api.go index 40757c1..622a45f 100644 --- a/backend/internal/httpapi/certificate_api.go +++ b/backend/internal/httpapi/certificate_api.go @@ -142,7 +142,7 @@ func (s *Server) deployCertificateCurrent(c *gin.Context) { } func (s *Server) importCertificateFiles(c *gin.Context) { - if !s.authorizeVisibleDomainPermission(c, domain.PermissionCertificateManage) { + if !s.authorizeVisibleDomainPermissions(c, domain.PermissionCertificateManage, domain.PermissionDeploymentExecute) { return } if s.importCertificate == nil { diff --git a/backend/internal/httpapi/certificate_api_test.go b/backend/internal/httpapi/certificate_api_test.go index db023ed..8a8e4f7 100644 --- a/backend/internal/httpapi/certificate_api_test.go +++ b/backend/internal/httpapi/certificate_api_test.go @@ -405,3 +405,42 @@ func TestCertificateMutationAPIsEnforceProjectPermissions(t *testing.T) { t.Fatalf("certificate workflow was called %d times after authorization failure", calls) } } + +func TestCertificateImportRequiresDeploymentPermission(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, + authorize: func(_ context.Context, _ string, _ string, permission domain.ProjectPermission) error { + if permission == domain.PermissionDeploymentExecute { + return repository.ErrProjectAccessDenied + } + return nil + }, + }, + ImportCertificate: func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) { + calls++ + return certificate.IssueResult{}, deployment.Report{}, nil + }, + }) + + request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/import", strings.NewReader(`{"certificate_path":"/ssl/cert.pem","private_key_path":"/ssl/key.pem"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + serveAdminHTTP(server, response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("缺少部署权限时证书导入未拒绝:%d %s", response.Code, response.Body.String()) + } + if calls != 0 { + t.Fatalf("权限拒绝后仍调用证书导入工作流:%d", calls) + } +} diff --git a/backend/internal/httpapi/project_resources_api.go b/backend/internal/httpapi/project_resources_api.go index ae655fc..bae968a 100644 --- a/backend/internal/httpapi/project_resources_api.go +++ b/backend/internal/httpapi/project_resources_api.go @@ -59,6 +59,22 @@ func (s *Server) authorizeVisibleDomainPermission(c *gin.Context, permission dom s.writeProjectResourceError(c, err) return false } + return s.authorizeProjectPermissions(c, item.ProjectID, permission) +} + +func (s *Server) authorizeVisibleDomainPermissions(c *gin.Context, permissions ...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 + } + return s.authorizeProjectPermissions(c, item.ProjectID, permissions...) +} + +func (s *Server) authorizeProjectPermissions(c *gin.Context, projectID string, permissions ...domain.ProjectPermission) bool { authorizer, ok := s.projectDomains.(repository.ProjectAuthorizer) if !ok { if fallback, fallbackOK := s.projectMembers.(repository.ProjectAuthorizer); fallbackOK { @@ -70,9 +86,11 @@ func (s *Server) authorizeVisibleDomainPermission(c *gin.Context, permission dom 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 + for _, permission := range permissions { + if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), projectID, permission); err != nil { + s.writeProjectResourceError(c, err) + return false + } } return true } diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 3e1ec60..b2e7b20 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -52,6 +52,14 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { s.writeError(c, http.StatusInternalServerError, "检查证书任务权限失败", err) return } + if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ResourceScope.Normalize().ProjectID, domain.PermissionDeploymentExecute); err != nil { + if errors.Is(err, repository.ErrProjectAccessDenied) { + s.writeError(c, http.StatusForbidden, "无权执行此项目的证书部署任务", nil) + return + } + s.writeError(c, http.StatusInternalServerError, "检查证书部署权限失败", err) + return + } if !s.authorizeDNSCredentialUse(c, resource.ResourceScope.Normalize().ProjectID) { return } diff --git a/backend/internal/httpapi/task_api_test.go b/backend/internal/httpapi/task_api_test.go index 71c6399..0c9389d 100644 --- a/backend/internal/httpapi/task_api_test.go +++ b/backend/internal/httpapi/task_api_test.go @@ -120,6 +120,43 @@ func TestTaskAPIRecordsSuccessfulIssueAndAudit(t *testing.T) { } } +func TestIssueTaskRequiresDeploymentPermission(t *testing.T) { + calls := 0 + server, _, _, closeDB := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + calls++ + return certificate.IssueResult{}, deployment.Report{}, nil + }) + defer closeDB() + + server.projectMembers = permissionProjectMemberRepository{ + ProjectMemberRepository: server.projectMembers, + allow: func(permission domain.ProjectPermission) error { + if permission == domain.PermissionDeploymentExecute { + return repository.ErrProjectAccessDenied + } + return nil + }, + } + + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + if response.Code != http.StatusForbidden { + t.Fatalf("缺少部署权限时证书任务未拒绝:%d %s", response.Code, response.Body.String()) + } + if calls != 0 { + t.Fatalf("权限拒绝后仍调用证书申请工作流:%d", calls) + } +} + +type permissionProjectMemberRepository struct { + repository.ProjectMemberRepository + allow func(domain.ProjectPermission) error +} + +func (p permissionProjectMemberRepository) AuthorizeProject(_ context.Context, _ string, _ string, permission domain.ProjectPermission) error { + return p.allow(permission) +} + type failingTaskStartRepository struct { repository.TaskRepository } diff --git a/backend/internal/httpapi/test_helpers_test.go b/backend/internal/httpapi/test_helpers_test.go index 4a740f5..30a3bdb 100644 --- a/backend/internal/httpapi/test_helpers_test.go +++ b/backend/internal/httpapi/test_helpers_test.go @@ -16,9 +16,13 @@ import ( type projectDomainRepositoryStub struct { repository.DomainRepository authorizeErr error + authorize func(context.Context, string, string, domain.ProjectPermission) error } -func (r projectDomainRepositoryStub) AuthorizeProject(context.Context, string, string, domain.ProjectPermission) error { +func (r projectDomainRepositoryStub) AuthorizeProject(ctx context.Context, actorID, projectID string, permission domain.ProjectPermission) error { + if r.authorize != nil { + return r.authorize(ctx, actorID, projectID, permission) + } return r.authorizeErr }