权限整改:完成 DNS 账户项目归属与凭据使用隔离
为 DNS 配置和账户补充项目归属,接入 DNS 凭据使用与项目管理权限校验,并补充配置和接口回归测试。
This commit is contained in:
parent
92c2d19ce3
commit
b8fd536dbd
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user