diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1fdf8d0..453c166 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -90,6 +90,7 @@ type ACMEConfig struct { } type DNSConfig struct { + ProjectID string `json:"project_id,omitempty"` OwnerUserID string `json:"owner_user_id,omitempty"` Provider string `json:"provider"` CredentialRef string `json:"credential_ref"` @@ -149,7 +150,7 @@ func Default(baseDir string) Config { PropagationTimeoutSecs: 300, PropagationPollingIntervalSecs: 5, }, - DNS: DNSConfig{VerificationState: "not_configured"}, + DNS: DNSConfig{ProjectID: "default", VerificationState: "not_configured"}, Notification: NotificationConfig{ HTTPHeaders: map[string]string{}, HTTPPath: "/api/email/send", @@ -343,6 +344,9 @@ func (c *Config) Normalize() { if c.DNS.VerificationState == "" { c.DNS.VerificationState = "not_configured" } + if strings.TrimSpace(c.DNS.ProjectID) == "" { + c.DNS.ProjectID = "default" + } if c.ACME.PropagationTimeoutSecs == 0 { c.ACME.PropagationTimeoutSecs = 300 } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index ab83501..c741e4c 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -223,6 +223,15 @@ func TestNormalizeRestoresMissingRenewalPolicy(t *testing.T) { } } +func TestNormalizeRestoresDNSProjectScope(t *testing.T) { + cfg := Default(t.TempDir()) + cfg.DNS.ProjectID = "" + cfg.Normalize() + if cfg.DNS.ProjectID != "default" { + t.Fatalf("旧配置的 DNS 项目归属未补齐:%q", cfg.DNS.ProjectID) + } +} + func TestValidateRejectsInvalidRenewalPolicy(t *testing.T) { cases := []struct { name string diff --git a/backend/internal/httpapi/dns_accounts_api.go b/backend/internal/httpapi/dns_accounts_api.go index 88f56e0..91a170b 100644 --- a/backend/internal/httpapi/dns_accounts_api.go +++ b/backend/internal/httpapi/dns_accounts_api.go @@ -18,6 +18,7 @@ type dnsAccountView struct { Provider string `json:"provider"` ProviderName string `json:"provider_name"` AccountIdentifier string `json:"account_identifier"` + ProjectID string `json:"project_id"` OwnerUserID string `json:"owner_user_id,omitempty"` OwnerUsername string `json:"owner_username"` OwnerState string `json:"owner_state"` @@ -38,10 +39,14 @@ func (s *Server) listDNSAccounts(c *gin.Context) { } item := dnsAccountView{ ID: "system-dns", Name: "系统 DNS 账户", Provider: cfg.DNS.Provider, + ProjectID: strings.TrimSpace(cfg.DNS.ProjectID), ProviderName: "未知服务商", AccountIdentifier: "未提供账号标识", OwnerUserID: cfg.DNS.OwnerUserID, OwnerState: "unassigned", OwnerUsername: "未分配", VerificationState: cfg.DNS.VerificationState, CredentialState: "available", } + if item.ProjectID == "" { + item.ProjectID = "default" + } var owner repository.User ownerID := strings.TrimSpace(cfg.DNS.OwnerUserID) if ownerID == "" { diff --git a/backend/internal/httpapi/dns_api_test.go b/backend/internal/httpapi/dns_api_test.go index b9c4119..9f0b93f 100644 --- a/backend/internal/httpapi/dns_api_test.go +++ b/backend/internal/httpapi/dns_api_test.go @@ -7,7 +7,10 @@ import ( "strings" "testing" + "github.com/gin-gonic/gin" + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/repository" "git.pcux.cn/xuans/Scert/backend/internal/setup" ) @@ -38,6 +41,43 @@ func TestAdminDNSProvidersReturnsCatalog(t *testing.T) { } } +func TestAdminDNSAccountsExposeProjectScope(t *testing.T) { + cfg := config.Default(t.TempDir()) + cfg.SetupReady = true + cfg.DNS.ProjectID = "project-a" + cfg.DNS.CredentialRef = "secret://dns/cloudflare/project-a" + secrets := &fakeSecretReaderStore{data: map[string]map[string]string{ + cfg.DNS.CredentialRef: {"api_token": "test-token"}, + }} + server := New(Dependencies{Config: cfg, Secrets: secrets}) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/v1/dns/accounts", nil) + serveAdminHTTP(server, response, request) + if response.Code != http.StatusOK { + t.Fatalf("读取 DNS 账户失败:%d %s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), `"project_id":"project-a"`) { + t.Fatalf("DNS 账户响应未包含项目归属:%s", response.Body.String()) + } +} + +func TestDNSCredentialUseRejectsUnauthorizedProject(t *testing.T) { + server := New(Dependencies{ + ProjectDomains: projectDomainRepositoryStub{authorizeErr: repository.ErrProjectAccessDenied}, + }) + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest(http.MethodGet, "/", nil) + context.Set(authenticatedActorKey, "user-a") + if server.authorizeDNSCredentialUse(context, "project-a") { + t.Fatal("无 DNS 凭据使用权限的项目不应通过授权") + } + if recorder.Code != http.StatusForbidden { + t.Fatalf("无 DNS 凭据使用权限应返回 403,实际为 %d", recorder.Code) + } +} + func TestAdminDNSTestReturnsVerificationResult(t *testing.T) { cfg := config.Default(t.TempDir()) cfg.SetupReady = true diff --git a/backend/internal/httpapi/project_resources_api.go b/backend/internal/httpapi/project_resources_api.go index cb8eb1c..ae655fc 100644 --- a/backend/internal/httpapi/project_resources_api.go +++ b/backend/internal/httpapi/project_resources_api.go @@ -76,3 +76,23 @@ func (s *Server) authorizeVisibleDomainPermission(c *gin.Context, permission dom } return true } + +func (s *Server) authorizeDNSCredentialUse(c *gin.Context, projectID string) bool { + authorizer, ok := s.projectMembers.(repository.ProjectAuthorizer) + if !ok { + authorizer, ok = s.projectDomains.(repository.ProjectAuthorizer) + } + if !ok { + s.writeError(c, http.StatusServiceUnavailable, "项目 DNS 权限服务尚未就绪", nil) + return false + } + if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), projectID, domain.PermissionDNSCredentialUse); err != nil { + if errors.Is(err, repository.ErrProjectAccessDenied) { + s.writeError(c, http.StatusForbidden, "无权使用此项目的 DNS 凭据", nil) + return false + } + s.writeError(c, http.StatusInternalServerError, "检查项目 DNS 凭据权限失败", err) + return false + } + return true +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 656582a..ebfbefd 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -576,6 +576,14 @@ func (s *Server) authorizeServerMutation(c *gin.Context, projectID string) bool } return true } + +func (s *Server) currentDNSProvider() string { + s.configMu.RLock() + provider := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider)) + s.configMu.RUnlock() + return provider +} + func (s *Server) listDomains(c *gin.Context) { if !s.requireProjectResourceReader(c) { return @@ -606,6 +614,9 @@ func (s *Server) createDomain(c *gin.Context) { if !s.authorizeDomainMutation(c, item.ProjectID) { return } + if s.currentDNSProvider() == dnsprovider.Aliyun && !s.authorizeDNSCredentialUse(c, item.ProjectID) { + return + } if err := s.validateDomainAliyunAuthorization(c.Request.Context(), append([]string{item.Name}, item.SANs...)...); err != nil { s.writeError(c, http.StatusBadRequest, err.Error(), err) return @@ -642,6 +653,9 @@ func (s *Server) updateDomain(c *gin.Context) { if !s.authorizeDomainMutation(c, existing.ProjectID) { return } + if s.currentDNSProvider() == dnsprovider.Aliyun && !s.authorizeDNSCredentialUse(c, existing.ProjectID) { + return + } item, err := newDomainFromRequest(request) if err != nil { s.writeError(c, http.StatusBadRequest, "域名配置无效", err) diff --git a/backend/internal/httpapi/settings_api.go b/backend/internal/httpapi/settings_api.go index 5b8177b..4687129 100644 --- a/backend/internal/httpapi/settings_api.go +++ b/backend/internal/httpapi/settings_api.go @@ -12,6 +12,8 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" ) type settingsView struct { @@ -55,6 +57,7 @@ type settingsACMEView struct { } type settingsDNSView struct { + ProjectID string `json:"project_id"` Provider string `json:"provider"` VerificationState string `json:"verification_state"` CredentialConfigured bool `json:"credential_configured"` @@ -128,6 +131,7 @@ type settingsACMERequest struct { } type settingsDNSRequest struct { + ProjectID *string `json:"project_id"` Provider *string `json:"provider"` CredentialRef *string `json:"credential_ref"` VerificationToken *string `json:"verification_token"` @@ -195,6 +199,12 @@ func (s *Server) updateSettings(c *gin.Context) { updated := s.config applySettingsRequest(&updated, request) updated.Normalize() + if request.DNS != nil && request.DNS.ProjectID != nil && updated.DNS.ProjectID != previous.DNS.ProjectID { + if !s.authorizeDNSProjectAssignment(c, updated.DNS.ProjectID) { + s.configMu.Unlock() + return + } + } createdCredentialRefs, err := s.saveSettingsNotificationCredentials(c.Request.Context(), &updated, request) if err != nil { s.rollbackSetupCredentialsWithLog(createdCredentialRefs) @@ -291,6 +301,26 @@ func (s *Server) updateSettings(c *gin.Context) { }) } +func (s *Server) authorizeDNSProjectAssignment(c *gin.Context, projectID string) bool { + authorizer, ok := s.projectMembers.(repository.ProjectAuthorizer) + if !ok { + authorizer, ok = s.projectDomains.(repository.ProjectAuthorizer) + } + if !ok { + s.writeError(c, http.StatusServiceUnavailable, "项目 DNS 权限服务尚未就绪", nil) + return false + } + if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), projectID, domain.PermissionDNSManage); err != nil { + if errors.Is(err, repository.ErrProjectAccessDenied) { + s.writeError(c, http.StatusForbidden, "无权管理此项目的 DNS 账户", nil) + return false + } + s.writeError(c, http.StatusInternalServerError, "检查项目 DNS 管理权限失败", err) + return false + } + return true +} + func (s *Server) applySettingsDNS(ctx context.Context, cfg *config.Config, previous config.Config, request *settingsDNSRequest) (string, string, string, error) { if cfg == nil || request == nil { return "", "", "", nil @@ -358,7 +388,8 @@ func settingsViewFromConfig(cfg config.Config) settingsView { PropagationTimeoutSecs: cfg.ACME.PropagationTimeoutSecs, PropagationPollingIntervalSecs: cfg.ACME.PropagationPollingIntervalSecs, }, DNS: settingsDNSView{ - Provider: cfg.DNS.Provider, VerificationState: cfg.DNS.VerificationState, + ProjectID: cfg.DNS.ProjectID, + Provider: cfg.DNS.Provider, VerificationState: cfg.DNS.VerificationState, CredentialConfigured: strings.TrimSpace(cfg.DNS.CredentialRef) != "", }, Notification: settingsNotificationView{ @@ -447,6 +478,9 @@ func applySettingsRequest(cfg *config.Config, request settingsRequest) { if request.DNS != nil && request.DNS.Provider != nil { cfg.DNS.Provider = strings.TrimSpace(*request.DNS.Provider) } + if request.DNS != nil && request.DNS.ProjectID != nil { + cfg.DNS.ProjectID = strings.TrimSpace(*request.DNS.ProjectID) + } if request.Notification != nil { item := request.Notification if item.HTTPEnabled != nil { diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index c5bde25..3e1ec60 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -52,6 +52,9 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { s.writeError(c, http.StatusInternalServerError, "检查证书任务权限失败", err) return } + if !s.authorizeDNSCredentialUse(c, resource.ResourceScope.Normalize().ProjectID) { + return + } s.issueTaskMu.Lock() idempotencyKey := strings.TrimSpace(c.GetHeader("Idempotency-Key")) if idempotencyKey != "" {