权限整改:完成 DNS 账户项目归属与凭据使用隔离

为 DNS 配置和账户补充项目归属,接入 DNS 凭据使用与项目管理权限校验,并补充配置和接口回归测试。
This commit is contained in:
xuans 2026-09-29 19:21:38 +08:00
parent 92c2d19ce3
commit b8fd536dbd
8 changed files with 131 additions and 2 deletions

View File

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

View File

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

View File

@ -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 == "" {

View File

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

View File

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

View File

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

View File

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

View File

@ -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 != "" {