开发检查点:收尾项目生命周期并接入资源读取隔离,待统一测试
This commit is contained in:
parent
fd767b23c3
commit
709c1ef063
@ -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) {
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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},
|
||||
|
||||
49
backend/internal/httpapi/project_resources_api.go
Normal file
49
backend/internal/httpapi/project_resources_api.go
Normal file
@ -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
|
||||
}
|
||||
190
backend/internal/httpapi/project_resources_api_test.go
Normal file
190
backend/internal/httpapi/project_resources_api_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 默认目录属于正式安装,测试必须显式隔离所有可写路径。
|
||||
|
||||
@ -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) {
|
||||
|
||||
163
backend/internal/repository/project_resources.go
Normal file
163
backend/internal/repository/project_resources.go
Normal file
@ -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
|
||||
}
|
||||
165
backend/internal/repository/project_resources_test.go
Normal file
165
backend/internal/repository/project_resources_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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,补齐项目创建、资料修改和空项目删除的正式入口;项目不是用户分组。创建仅允许当前有效超级管理员,修改沿用项目管理员实时权限;删除仍遵守默认项目与域名、服务器、成员引用保护,不自动删除业务资源或成员,不清理现有业务数据。名称允许重名,标识由服务端生成且不可修改。所有成功变更与审计同事务,失败记录不泄露请求正文。
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 = '<img src=x onerror="unexpected">';
|
||||
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, /<img src=x/);
|
||||
state.savingProject.value = true;
|
||||
html = await renderPage();
|
||||
assert.match(html, /正在保存……/);
|
||||
assert.match(html, /<input[^>]*disabled/);
|
||||
assert.match(html, /<textarea[^>]*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 () => {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user