权限整改:完成证书资源项目权限隔离

将证书导入、校验、部署和历史版本恢复统一接入项目资源权限校验,并补充无权限阻断测试及清单进度记录。
This commit is contained in:
xuans 2026-09-29 18:41:46 +08:00
parent 85593bcde7
commit f5ab9c1610
11 changed files with 140 additions and 5 deletions

View File

@ -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

View File

@ -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)
}
}

View File

@ -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

View File

@ -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,

View File

@ -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
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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????????????????????????????????????????????
- [!] ????????????????????????????

View File

@ -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 ????????????

View File

@ -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 ???
- ????????????????????????????????

View File

@ -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??????????????????????????????????