From 262de97c5ad61f897415d439c9df00ef6e86f633 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 14:18:05 +0100 Subject: [PATCH 1/2] feat: Add scoped peer deployment management Connected servers can expose full deployment management without granting access to the host. Health checks now support separate protocols for each service. --- CHANGELOG.md | 7 ++ internal/api/ai_handlers.go | 6 +- internal/api/authz.go | 23 ++++ internal/api/authz_test.go | 31 +++++ internal/api/backup_handlers.go | 30 +++++ internal/api/cert_renewal_test.go | 26 +++++ internal/api/cluster_handlers.go | 152 +++++++++++++++++++++++- internal/api/cluster_handlers_test.go | 56 ++++++++- internal/api/deployment_diagnostics.go | 39 ++++--- internal/api/openapi.json | 153 ++++++++++++++++++++++++- internal/api/require_plan_test.go | 20 ++++ internal/api/server.go | 116 +++++++++++++++++-- internal/auth/models.go | 7 ++ internal/auth/models_test.go | 10 ++ internal/auth/permissions.go | 2 +- internal/auth/permissions_test.go | 6 +- internal/cluster/capabilities.go | 17 +-- internal/cluster/client.go | 30 ++++- internal/cluster/client_test.go | 43 +++++++ pkg/models/deployment.go | 11 ++ 20 files changed, 735 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68341c5..1b265e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Sixth beta of the Albacore release, making connected servers manageable as one F - Docker Swarm and k3s orchestration adapters with nginx and Traefik routing adapters - Grouped incidents and configurable notification targets and delivery rules - HTTP, TCP, and container command health checks for web services and databases +- Deployment management grants for administering allowed deployments through a peer +- Service-specific health checks for mixed web and database deployments ### Fixed - Existing Fleet peers gain default access policies during startup repair without reconnecting @@ -21,6 +23,11 @@ Sixth beta of the Albacore release, making connected servers manageable as one F - Settings, notifications, and API keys require explicit access for non-admin roles - Repeated metric alerts share one incident until every affected series recovers - Email headers keep the white logo visible in clients that ignore inline CSS +- Peer requests preserve uploads, downloads, query parameters, and response types +- Peer deployment actions use deployment permissions instead of Fleet configuration access +- Fleet access intersects module permissions, server-qualified user grants, and peer policy +- Operators no longer receive host shell or process-control access by default +- Certificate lists and actions are restricted to assigned deployments for non-admin users ## [0.4.0-beta.4] - 2026-08-21 diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index ec43160..15ab2a9 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -113,8 +113,10 @@ func (s *Server) platformSection(deploymentName string) ai.Section { } else { fmt.Fprintf(&b, "This deployment is not exposed through the reverse proxy\n") } - if healthCheckConfigured(meta.HealthCheck) { - fmt.Fprintf(&b, "Configured health check type: %s\n", healthCheckType(meta.HealthCheck)) + if checks := meta.EffectiveHealthChecks(); len(checks) > 0 { + for _, check := range checks { + fmt.Fprintf(&b, "Configured health check for %s: %s\n", check.Service, healthCheckType(check)) + } } if len(meta.Databases) > 0 { aliases := make([]string, 0, len(meta.Databases)) diff --git a/internal/api/authz.go b/internal/api/authz.go index c4414f8..812ac36 100644 --- a/internal/api/authz.go +++ b/internal/api/authz.go @@ -35,6 +35,29 @@ func (s *Server) requireDeploymentAccess(c *gin.Context, deploymentName, level s return true } +func restrictClusterServiceResources(c *gin.Context) { + actor := auth.GetActorFromContext(c) + if actor == nil || actor.User == nil || actor.User.Role != auth.RoleService || actor.User.Username != "__flatrun_cluster" { + c.Next() + return + } + + path := c.Request.URL.Path + if strings.HasPrefix(path, "/api/deployments/") || strings.HasPrefix(path, "/api/containers/") || + strings.HasPrefix(path, "/api/proxy/") { + c.Next() + return + } + for _, prefix := range []string{"/api/backups", "/api/certificates", "/api/credentials", "/api/images", "/api/security"} { + if strings.HasPrefix(path, prefix) { + c.JSON(http.StatusForbidden, gin.H{"error": "Fleet credentials require a deployment-scoped endpoint"}) + c.Abort() + return + } + } + c.Next() +} + func (s *Server) requireContainerAccess(c *gin.Context, containerID, level string) bool { if containerID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Container ID required"}) diff --git a/internal/api/authz_test.go b/internal/api/authz_test.go index f4fe7e0..da12291 100644 --- a/internal/api/authz_test.go +++ b/internal/api/authz_test.go @@ -39,6 +39,37 @@ func actorMiddleware(actor *auth.ActorContext) gin.HandlerFunc { } } +func TestClusterServiceCredentialsRejectUnscopedSensitiveResources(t *testing.T) { + gin.SetMode(gin.TestMode) + actor := &auth.ActorContext{ + Type: "api_key", + Role: auth.RoleService, + User: &auth.User{Role: auth.RoleService, Username: "__flatrun_cluster"}, + } + + for _, path := range []string{"/api/backups/other", "/api/credentials", "/api/security/events"} { + router := gin.New() + router.Use(actorMiddleware(actor), restrictClusterServiceResources) + router.GET(path, func(c *gin.Context) { c.Status(http.StatusNoContent) }) + request := httptest.NewRequest(http.MethodGet, path, nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("%s status = %d", path, response.Code) + } + } + + router := gin.New() + router.Use(actorMiddleware(actor), restrictClusterServiceResources) + router.GET("/api/deployments/:name/security", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + request := httptest.NewRequest(http.MethodGet, "/api/deployments/app/security", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("deployment-scoped status = %d", response.Code) + } +} + func TestListVirtualHostsFiltersByDeploymentAccess(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/internal/api/backup_handlers.go b/internal/api/backup_handlers.go index d35949d..b59fbe6 100644 --- a/internal/api/backup_handlers.go +++ b/internal/api/backup_handlers.go @@ -70,6 +70,36 @@ func (s *Server) getBackup(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"backup": b}) } +func (s *Server) requireBackupDeployment(c *gin.Context) { + if s.backupManager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"}) + c.Abort() + return + } + b, err := s.backupManager.GetBackup(c.Param("id")) + if err != nil || b.DeploymentName != c.Param("name") { + c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"}) + c.Abort() + return + } + c.Next() +} + +func (s *Server) requireBackupJobDeployment(c *gin.Context) { + if s.backupManager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"}) + c.Abort() + return + } + job := s.backupManager.GetJob(c.Param("id")) + if job == nil || job.DeploymentName != c.Param("name") { + c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) + c.Abort() + return + } + c.Next() +} + func (s *Server) createBackup(c *gin.Context) { if s.backupManager == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"}) diff --git a/internal/api/cert_renewal_test.go b/internal/api/cert_renewal_test.go index 1336cdc..4a6992d 100644 --- a/internal/api/cert_renewal_test.go +++ b/internal/api/cert_renewal_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "github.com/flatrun/agent/internal/auth" "github.com/flatrun/agent/internal/docker" "github.com/flatrun/agent/internal/nginx" "github.com/flatrun/agent/internal/proxy" @@ -173,6 +174,31 @@ func TestListCertificates_AnnotatesDeploymentID(t *testing.T) { } } +func TestListCertificates_FiltersByDeploymentAccess(t *testing.T) { + server, deploymentsPath, certsPath := setupRenewalTestServer(t) + writeSelfSignedCert(t, certsPath, "mine.example.com") + writeSelfSignedCert(t, certsPath, "other.example.com") + writeSelfSignedCert(t, certsPath, "orphan.example.com") + writeDeploymentWithDomains(t, deploymentsPath, "mine", []models.DomainConfig{{Domain: "mine.example.com"}}) + writeDeploymentWithDomains(t, deploymentsPath, "other", []models.DomainConfig{{Domain: "other.example.com"}}) + + router := gin.New() + router.Use(actorMiddleware(testActor(auth.RoleOperator, map[string]string{"mine": auth.AccessLevelRead}))) + router.GET("/certificates", server.listCertificates) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/certificates", nil)) + + var payload struct { + Certificates []models.Certificate `json:"certificates"` + } + if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &payload) != nil { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if len(payload.Certificates) != 1 || payload.Certificates[0].Domain != "mine.example.com" { + t.Fatalf("unexpected certificates: %+v", payload.Certificates) + } +} + func TestRenewDeploymentCertificates_CollectsAllDomainsAndAliases(t *testing.T) { server, deploymentsPath, certsPath := setupRenewalTestServer(t) diff --git a/internal/api/cluster_handlers.go b/internal/api/cluster_handlers.go index 82eacae..1f38eb4 100644 --- a/internal/api/cluster_handlers.go +++ b/internal/api/cluster_handlers.go @@ -23,6 +23,7 @@ import ( "github.com/flatrun/agent/internal/routing" "github.com/flatrun/agent/internal/system" "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" "github.com/flatrun/agent/pkg/version" "github.com/gin-gonic/gin" ) @@ -709,6 +710,19 @@ func clusterPolicyAccess(policy cluster.PeerPolicy) ([]string, auth.DeploymentAc permissions[auth.PermContainersRead.String()] = true permissions[auth.PermContainersWrite.String()] = true unrestrictedDeployments = mergeClusterDeploymentAccess(deployments, grant.Deployments, auth.AccessLevelWrite, unrestrictedDeployments) + case cluster.CapabilityDeploymentsManage: + for _, permission := range []auth.Permission{ + auth.PermDeploymentsRead, auth.PermDeploymentsWrite, auth.PermDeploymentsDelete, + auth.PermContainersRead, auth.PermContainersWrite, auth.PermContainersDelete, + auth.PermCertificatesRead, auth.PermCertificatesWrite, auth.PermCertificatesDelete, + auth.PermSecurityRead, auth.PermSecurityWrite, auth.PermImagesRead, + auth.PermImagesWrite, auth.PermImagesDelete, auth.PermBackupsRead, + auth.PermBackupsWrite, auth.PermBackupsDelete, + auth.PermSchedulerRead, auth.PermSchedulerWrite, auth.PermSchedulerDelete, + } { + permissions[permission.String()] = true + } + unrestrictedDeployments = mergeClusterDeploymentAccess(deployments, grant.Deployments, auth.AccessLevelAdmin, unrestrictedDeployments) case cluster.CapabilityCapacityRead: permissions[auth.PermSystemRead.String()] = true case cluster.CapabilityCapacityOffer: @@ -833,6 +847,9 @@ func (s *Server) deleteClusterAPIKey(peerName string) error { } func (s *Server) clusterProxy(c *gin.Context) { + if !authorizePeerProxy(c) { + return + } mgr := s.getClusterManager() if mgr == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Cluster is not enabled"}) @@ -853,18 +870,143 @@ func (s *Server) clusterProxy(c *gin.Context) { body = c.Request.Body } - data, status, headers, err := client.Forward(c.Request.Context(), c.Request.Method, "/api"+path, body) + forwardPath := "/api" + path + if c.Request.URL.RawQuery != "" { + forwardPath += "?" + c.Request.URL.RawQuery + } + resp, err := client.DoWithHeaders(c.Request.Context(), c.Request.Method, forwardPath, c.Request.Header, body) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Failed to proxy request: %v", err)}) return } - for k, v := range headers { - if k != "Content-Length" && k != "Transfer-Encoding" { - c.Header(k, v) + defer resp.Body.Close() + for k, values := range resp.Header { + if k != "Content-Length" && k != "Transfer-Encoding" && k != "Connection" { + for _, value := range values { + c.Writer.Header().Add(k, value) + } + } + } + if c.Request.Method == http.MethodGet && path == "/deployments" { + s.writeScopedPeerDeployments(c, name, resp) + return + } + c.Status(resp.StatusCode) + _, _ = io.Copy(c.Writer, resp.Body) +} + +func (s *Server) writeScopedPeerDeployments(c *gin.Context, peer string, resp *http.Response) { + actor := auth.GetActorFromContext(c) + if actor == nil || actor.Role == auth.RoleAdmin || resp.StatusCode != http.StatusOK { + c.Status(resp.StatusCode) + _, _ = io.Copy(c.Writer, resp.Body) + return + } + var payload struct { + Deployments []models.Deployment `json:"deployments"` + Path string `json:"path,omitempty"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "Peer returned an invalid deployment list"}) + return + } + visible := payload.Deployments[:0] + for _, deployment := range payload.Deployments { + if actor.CanAccessPeerDeployment(peer, deployment.Name, auth.AccessLevelRead) { + visible = append(visible, deployment) + } + } + payload.Deployments = visible + c.JSON(http.StatusOK, payload) +} + +func authorizePeerProxy(c *gin.Context) bool { + actor := auth.GetActorFromContext(c) + if actor == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"}) + return false + } + + path := c.Param("path") + peer := c.Param("name") + deployment := peerProxyDeployment(path) + if deployment == "" { + deployment = strings.TrimSpace(c.GetHeader("X-FlatRun-Deployment")) + } + if actor.Role != auth.RoleAdmin && path != "/deployments" && deployment == "" { + c.JSON(http.StatusForbidden, gin.H{"error": "A deployment scope is required"}) + return false + } + requiredLevel := auth.AccessLevelRead + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + requiredLevel = auth.AccessLevelWrite + } + if c.Request.Method == http.MethodDelete { + requiredLevel = auth.AccessLevelAdmin + } + if deployment != "" && !actor.CanAccessPeerDeployment(peer, deployment, requiredLevel) { + c.JSON(http.StatusForbidden, gin.H{"error": "No access to this peer deployment"}) + return false + } + if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead { + return true + } + permission := auth.PermDeploymentsWrite + if c.Request.Method == http.MethodDelete { + permission = auth.PermDeploymentsDelete + } + + switch { + case strings.HasPrefix(path, "/containers/"): + permission = auth.PermContainersRead + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + permission = auth.PermContainersWrite + } + if c.Request.Method == http.MethodDelete { + permission = auth.PermContainersDelete + } + case strings.HasPrefix(path, "/certificates"), strings.HasPrefix(path, "/proxy/"): + permission = auth.PermCertificatesRead + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + permission = auth.PermCertificatesWrite + } + if c.Request.Method == http.MethodDelete { + permission = auth.PermCertificatesDelete + } + case strings.Contains(path, "/security"): + permission = auth.PermSecurityRead + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + permission = auth.PermSecurityWrite + } + case strings.HasPrefix(path, "/backups"), strings.Contains(path, "/backups"): + permission = auth.PermBackupsRead + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + permission = auth.PermBackupsWrite + } + if c.Request.Method == http.MethodDelete { + permission = auth.PermBackupsDelete + } + case strings.HasPrefix(path, "/credentials"): + permission = auth.PermRegistriesRead + } + + if !actor.HasPermission(permission) { + c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied", "required": permission}) + return false + } + return true +} + +func peerProxyDeployment(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) >= 2 && parts[0] == "deployments" { + name, err := url.PathUnescape(parts[1]) + if err == nil { + return name } } - c.Data(status, "application/json", data) + return "" } func (s *Server) clusterAggregateDeployments(c *gin.Context) { diff --git a/internal/api/cluster_handlers_test.go b/internal/api/cluster_handlers_test.go index c88e6ad..7f8c3d6 100644 --- a/internal/api/cluster_handlers_test.go +++ b/internal/api/cluster_handlers_test.go @@ -15,6 +15,7 @@ import ( "github.com/flatrun/agent/internal/auth" "github.com/flatrun/agent/internal/capacity" "github.com/flatrun/agent/internal/cluster" + "github.com/flatrun/agent/internal/contextkeys" "github.com/flatrun/agent/internal/docker" "github.com/flatrun/agent/internal/orchestrator" "github.com/flatrun/agent/internal/routing" @@ -39,6 +40,28 @@ func TestCapacityClaimUsesPeerSpecificGrant(t *testing.T) { } } +func TestAuthorizePeerProxyRequiresServerQualifiedDeploymentGrant(t *testing.T) { + gin.SetMode(gin.TestMode) + request := httptest.NewRequest(http.MethodGet, "/cluster/peers/prod3/proxy/deployments/database", nil) + response := httptest.NewRecorder() + c, _ := gin.CreateTestContext(response) + c.Request = request + c.Params = gin.Params{{Key: "name", Value: "prod3"}, {Key: "path", Value: "/deployments/database"}} + c.Set(contextkeys.Actor, &auth.ActorContext{Role: auth.RoleOperator, Deployments: map[string]string{"prod3/database": auth.AccessLevelRead}}) + if !authorizePeerProxy(c) { + t.Fatalf("qualified grant rejected: %d %s", response.Code, response.Body.String()) + } + + response = httptest.NewRecorder() + c, _ = gin.CreateTestContext(response) + c.Request = request + c.Params = gin.Params{{Key: "name", Value: "prod1"}, {Key: "path", Value: "/deployments/database"}} + c.Set(contextkeys.Actor, &auth.ActorContext{Role: auth.RoleOperator, Deployments: map[string]string{"prod3/database": auth.AccessLevelRead}}) + if authorizePeerProxy(c) || response.Code != http.StatusForbidden { + t.Fatalf("grant crossed peer boundary: %d %s", response.Code, response.Body.String()) + } +} + type testClusterEnv struct { server *Server router *gin.Engine @@ -140,10 +163,10 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool clusterGroup.POST("/accept", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterAccept) clusterGroup.DELETE("/peers/:name", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterRemovePeer) clusterGroup.GET("/peers/:name/proxy/*path", server.clusterProxy) - clusterGroup.POST("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy) - clusterGroup.PUT("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy) - clusterGroup.PATCH("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy) - clusterGroup.DELETE("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy) + clusterGroup.POST("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermDeploymentsWrite), server.clusterProxy) + clusterGroup.PUT("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermDeploymentsWrite), server.clusterProxy) + clusterGroup.PATCH("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermDeploymentsWrite), server.clusterProxy) + clusterGroup.DELETE("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermDeploymentsWrite), server.clusterProxy) clusterGroup.GET("/deployments", server.clusterAggregateDeployments) clusterGroup.GET("/stats", server.clusterAggregateStats) clusterGroup.GET("/capacity", server.clusterAggregateCapacity) @@ -422,6 +445,28 @@ func TestClusterPolicyAccessScopesDeployments(t *testing.T) { } } +func TestClusterPolicyAccessGrantsDeploymentManagement(t *testing.T) { + permissions, deployments := clusterPolicyAccess(cluster.PeerPolicy{Grants: []cluster.Grant{ + {Capability: cluster.CapabilityDeploymentsManage, Deployments: []string{"public-site"}}, + }}) + + if deployments["public-site"] != auth.AccessLevelAdmin { + t.Fatalf("deployment access = %#v", deployments) + } + wanted := []string{ + auth.PermDeploymentsDelete.String(), + auth.PermSecurityWrite.String(), + auth.PermBackupsWrite.String(), + auth.PermCertificatesWrite.String(), + auth.PermSchedulerWrite.String(), + } + for _, permission := range wanted { + if !slices.Contains(permissions, permission) { + t.Fatalf("missing permission %q in %#v", permission, permissions) + } + } +} + func TestClusterSetupEnablesClusterWithoutRestart(t *testing.T) { env := setupClusterTestServer(t, "", false) defer env.cleanup() @@ -937,6 +982,9 @@ func TestClusterProxyAllowsReadWithoutWrite(t *testing.T) { if err != nil { t.Fatal(err) } + if err := env.server.authManager.AssignDeployment(user.ID, "remote/shop", auth.AccessLevelRead, 0); err != nil { + t.Fatal(err) + } _, err = env.server.authManager.CreateAPIKeyFromRaw( "fleet-reader-key", user.ID, "fleet-reader", "Fleet reader", auth.Role(""), []string{auth.PermClusterRead.String()}, nil, time.Time{}, diff --git a/internal/api/deployment_diagnostics.go b/internal/api/deployment_diagnostics.go index 67ab904..19e05f5 100644 --- a/internal/api/deployment_diagnostics.go +++ b/internal/api/deployment_diagnostics.go @@ -262,11 +262,23 @@ func (s *Server) addApplicationHealthDiagnostic( addWithOutput func(string, string, DiagnosticStatus, string, string, string, string), ) { metadata := deployment.Metadata - if metadata == nil || !healthCheckConfigured(metadata.HealthCheck) { + if metadata == nil || len(metadata.EffectiveHealthChecks()) == 0 { add("application", "Application health", diagnosticSkipped, "No application health check is configured in service.yml.", "edit_healthcheck", "") return } - config := metadata.HealthCheck + for _, config := range metadata.EffectiveHealthChecks() { + s.addServiceHealthDiagnostic(ctx, deployment, config, add, addWithOutput) + } +} + +func (s *Server) addServiceHealthDiagnostic( + ctx context.Context, + deployment *models.Deployment, + config models.HealthCheckConfig, + add func(string, string, DiagnosticStatus, string, string, string), + addWithOutput func(string, string, DiagnosticStatus, string, string, string, string), +) { + metadata := deployment.Metadata checkType := healthCheckType(config) service := config.Service if service == "" { @@ -277,7 +289,7 @@ func (s *Server) addApplicationHealthDiagnostic( port = metadata.Networking.ContainerPort } if service == "" || checkType != "exec" && (port < 1 || port > 65535) || checkType == "http" && !validHealthPath(config.Path) { - add("application", "Application health", diagnosticWarning, "The application health configuration is incomplete.", "edit_healthcheck", "") + add("application", "Application health: "+service, diagnosticWarning, "The service health configuration is incomplete.", "edit_healthcheck", service) return } probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second) @@ -287,41 +299,42 @@ func (s *Server) addApplicationHealthDiagnostic( case "tcp": ip, err := s.manager.ContainerServiceIP(deployment.Name, service, "") if err != nil { - addWithOutput("application", "Application health", diagnosticFailed, "The service container address could not be resolved.", err.Error(), "edit_healthcheck", strconv.Itoa(port)) + addWithOutput("application", "Application health: "+service, diagnosticFailed, "The service container address could not be resolved.", err.Error(), "edit_healthcheck", service) return } connection, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(probeCtx, "tcp", net.JoinHostPort(ip, strconv.Itoa(port))) if err != nil { - addWithOutput("application", "Application health", diagnosticFailed, fmt.Sprintf("TCP port %d did not accept a connection.", port), err.Error(), "edit_healthcheck", strconv.Itoa(port)) + addWithOutput("application", "Application health: "+service, diagnosticFailed, fmt.Sprintf("TCP port %d did not accept a connection.", port), err.Error(), "edit_healthcheck", service) return } _ = connection.Close() - add("application", "Application health", diagnosticPassed, fmt.Sprintf("TCP port %d accepted a connection.", port), "", strconv.Itoa(port)) + add("application", "Application health: "+service, diagnosticPassed, fmt.Sprintf("TCP port %d accepted a connection.", port), "", service) case "exec": output, err := s.manager.ComposeExec(probeCtx, deployment.Name, service, config.Command) if err != nil { - addWithOutput("application", "Application health", diagnosticFailed, "The health command returned an error.", output, "edit_healthcheck", "exec") + addWithOutput("application", "Application health: "+service, diagnosticFailed, "The health command returned an error.", output, "edit_healthcheck", service) return } - add("application", "Application health", diagnosticPassed, "The health command completed successfully.", "", "exec") + add("application", "Application health: "+service, diagnosticPassed, "The health command completed successfully.", "", service) default: + title := "Application health: " + service command := fmt.Sprintf("curl -sS -w '\\n%%{http_code}' --max-time 5 %s", shellLiteral("http://127.0.0.1:"+strconv.Itoa(port)+config.Path)) output, err := s.manager.ComposeExec(probeCtx, deployment.Name, service, command) body, statusCode, parseErr := parseHealthResponse(output) if err != nil || parseErr != nil { - addWithOutput("application", "Application health", diagnosticFailed, "The configured endpoint could not be reached from its service container.", output, "edit_healthcheck", config.Path) + addWithOutput("application", title, diagnosticFailed, "The configured endpoint could not be reached from its service container.", output, "edit_healthcheck", service) } else if healthStatusAccepted(statusCode, config.SuccessStatuses) && healthBodyAccepted(body, config.ResponseContains) { detail := fmt.Sprintf("GET %s returned HTTP %d.", config.Path, statusCode) if config.ResponseContains != "" { detail = fmt.Sprintf("GET %s returned HTTP %d and matched the expected response.", config.Path, statusCode) } - add("application", "Application health", diagnosticPassed, detail, "", config.Path) + add("application", title, diagnosticPassed, detail, "", service) } else if healthStatusAccepted(statusCode, config.SuccessStatuses) { - addWithOutput("application", "Application health", diagnosticFailed, fmt.Sprintf("GET %s returned HTTP %d but did not match the expected response.", config.Path, statusCode), body, "edit_healthcheck", config.Path) + addWithOutput("application", title, diagnosticFailed, fmt.Sprintf("GET %s returned HTTP %d but did not match the expected response.", config.Path, statusCode), body, "edit_healthcheck", service) } else if statusCode == http.StatusNotFound { - addWithOutput("application", "Application health", diagnosticWarning, fmt.Sprintf("GET %s returned HTTP 404. Configure a health endpoint to enable this check.", config.Path), body, "edit_healthcheck", config.Path) + addWithOutput("application", title, diagnosticWarning, fmt.Sprintf("GET %s returned HTTP 404. Configure a health endpoint to enable this check.", config.Path), body, "edit_healthcheck", service) } else { - addWithOutput("application", "Application health", diagnosticFailed, fmt.Sprintf("GET %s returned HTTP %d.", config.Path, statusCode), body, "edit_healthcheck", config.Path) + addWithOutput("application", title, diagnosticFailed, fmt.Sprintf("GET %s returned HTTP %d.", config.Path, statusCode), body, "edit_healthcheck", service) } } } diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 45c08f7..c48b487 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -1780,7 +1780,7 @@ "tags": [ "cluster" ], - "x-permission": "cluster:write" + "x-permission": "deployments:write" }, "get": { "operationId": "get-cluster-peers-by-name-proxy-by-path", @@ -1841,7 +1841,7 @@ "tags": [ "cluster" ], - "x-permission": "cluster:write" + "x-permission": "deployments:write" }, "post": { "operationId": "post-cluster-peers-by-name-proxy-by-path", @@ -1872,7 +1872,7 @@ "tags": [ "cluster" ], - "x-permission": "cluster:write" + "x-permission": "deployments:write" }, "put": { "operationId": "put-cluster-peers-by-name-proxy-by-path", @@ -1903,7 +1903,7 @@ "tags": [ "cluster" ], - "x-permission": "cluster:write" + "x-permission": "deployments:write" } }, "/api/cluster/providers": { @@ -4301,6 +4301,144 @@ "x-permission": "backups:write" } }, + "/api/deployments/{name}/backups/jobs/{id}": { + "get": { + "operationId": "get-deployments-by-name-backups-jobs-by-id", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "backups:read" + } + }, + "/api/deployments/{name}/backups/{id}": { + "delete": { + "operationId": "delete-deployments-by-name-backups-by-id", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "backups:delete" + } + }, + "/api/deployments/{name}/backups/{id}/download": { + "get": { + "operationId": "get-deployments-by-name-backups-by-id-download", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "backups:read" + } + }, + "/api/deployments/{name}/backups/{id}/restore": { + "post": { + "operationId": "post-deployments-by-name-backups-by-id-restore", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.RestoreBackupRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "backups:write" + } + }, "/api/deployments/{name}/certificates/renew": { "post": { "operationId": "post-deployments-by-name-certificates-renew", @@ -14196,6 +14334,12 @@ "healthcheck": { "$ref": "#/components/schemas/models.HealthCheckConfig" }, + "healthchecks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.HealthCheckConfig" + } + }, "kind": { "type": "string" }, @@ -14254,6 +14398,7 @@ "networking", "ssl", "healthcheck", + "healthchecks", "quick_actions", "security", "backup", diff --git a/internal/api/require_plan_test.go b/internal/api/require_plan_test.go index a5ab365..c40b553 100644 --- a/internal/api/require_plan_test.go +++ b/internal/api/require_plan_test.go @@ -107,6 +107,26 @@ func TestUpdateDeploymentMetadataAcceptsTCPHealthCheck(t *testing.T) { } } +func TestUpdateDeploymentMetadataAcceptsChecksForMultipleServices(t *testing.T) { + s, tmpDir, ts := setupPlanTestServer(t) + createTestDeployment(t, tmpDir, "stack", &models.ServiceMetadata{Name: "stack", Type: "compose"}) + + resp, parsed := doJSON(t, http.MethodPut, ts.URL+"/api/deployments/stack/metadata", map[string]interface{}{ + "healthchecks": []map[string]interface{}{ + {"type": "http", "service": "web", "port": 8080, "path": "/ready", "interval": "30s"}, + {"type": "tcp", "service": "postgres", "port": 5432, "interval": "30s"}, + }, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("metadata update status = %d, body %v", resp.StatusCode, parsed) + } + + deployment, err := s.manager.GetDeployment("stack") + if err != nil || len(deployment.Metadata.HealthChecks) != 2 { + t.Fatalf("health checks not persisted: %+v, error %v", deployment.Metadata.HealthChecks, err) + } +} + func TestServiceActionPlan(t *testing.T) { _, tmpDir, ts := setupPlanTestServer(t) createTestDeployment(t, tmpDir, "myapp", nil) diff --git a/internal/api/server.go b/internal/api/server.go index b8b58a7..89d49fa 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -17,6 +17,7 @@ import ( "path" "path/filepath" "regexp" + "slices" "sort" "strconv" "strings" @@ -480,6 +481,7 @@ func (s *Server) setupRoutes() { protected := api.Group("") protected.Use(s.authMiddleware.RequireAuth()) + protected.Use(restrictClusterServiceResources) if s.auditMiddleware != nil { protected.Use(s.auditMiddleware.Capture()) } @@ -810,6 +812,10 @@ func (s *Server) setupRoutes() { protected.GET("/backups/:id/download", s.authMiddleware.RequirePermission(auth.PermBackupsRead), s.downloadBackup) protected.GET("/deployments/:name/backups", s.authMiddleware.RequirePermission(auth.PermBackupsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.listDeploymentBackups) protected.POST("/deployments/:name/backups", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.createDeploymentBackup) + protected.DELETE("/deployments/:name/backups/:id", s.authMiddleware.RequirePermission(auth.PermBackupsDelete), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelAdmin), s.requireBackupDeployment, s.deleteBackup) + protected.GET("/deployments/:name/backups/:id/download", s.authMiddleware.RequirePermission(auth.PermBackupsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.requireBackupDeployment, s.downloadBackup) + protected.POST("/deployments/:name/backups/:id/restore", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.requireBackupDeployment, s.restoreBackup) + protected.GET("/deployments/:name/backups/jobs/:id", s.authMiddleware.RequirePermission(auth.PermBackupsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.requireBackupJobDeployment, s.getBackupJob) protected.GET("/deployments/:name/backup-config", s.authMiddleware.RequirePermission(auth.PermBackupsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentBackupConfig) protected.PUT("/deployments/:name/backup-config", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.updateDeploymentBackupConfig) protected.POST("/backups/:id/restore", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.restoreBackup) @@ -927,10 +933,10 @@ func (s *Server) setupRoutes() { clusterGroup.POST("/accept", s.authMiddleware.RequirePermission(auth.PermClusterWrite), s.clusterAccept) clusterGroup.DELETE("/peers/:name", s.authMiddleware.RequirePermission(auth.PermClusterWrite), s.clusterRemovePeer) clusterGroup.GET("/peers/:name/proxy/*path", s.clusterProxy) - clusterGroup.POST("/peers/:name/proxy/*path", s.authMiddleware.RequirePermission(auth.PermClusterWrite), s.clusterProxy) - clusterGroup.PUT("/peers/:name/proxy/*path", s.authMiddleware.RequirePermission(auth.PermClusterWrite), s.clusterProxy) - clusterGroup.PATCH("/peers/:name/proxy/*path", s.authMiddleware.RequirePermission(auth.PermClusterWrite), s.clusterProxy) - clusterGroup.DELETE("/peers/:name/proxy/*path", s.authMiddleware.RequirePermission(auth.PermClusterWrite), s.clusterProxy) + clusterGroup.POST("/peers/:name/proxy/*path", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.clusterProxy) + clusterGroup.PUT("/peers/:name/proxy/*path", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.clusterProxy) + clusterGroup.PATCH("/peers/:name/proxy/*path", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.clusterProxy) + clusterGroup.DELETE("/peers/:name/proxy/*path", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.clusterProxy) clusterGroup.GET("/deployments", s.clusterAggregateDeployments) clusterGroup.GET("/stats", s.clusterAggregateStats) clusterGroup.GET("/capacity", s.clusterAggregateCapacity) @@ -1970,6 +1976,24 @@ func (s *Server) updateDeploymentMetadata(c *gin.Context) { return } } + if _, sentHealthChecks := sentFields["healthchecks"]; sentHealthChecks { + seenServices := make(map[string]struct{}, len(incoming.HealthChecks)) + for _, healthCheck := range incoming.HealthChecks { + if err := validateHealthCheckConfig(healthCheck); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if healthCheck.Service == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "health check service is required"}) + return + } + if _, exists := seenServices[healthCheck.Service]; exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "only one health check may be configured for each service"}) + return + } + seenServices[healthCheck.Service] = struct{}{} + } + } metadata := mergeMetadata(deployment.Metadata, &incoming, sentFields) @@ -1997,6 +2021,9 @@ func (s *Server) updateDeploymentMetadata(c *gin.Context) { } func validateHealthCheckConfig(config models.HealthCheckConfig) error { + if !healthCheckConfigured(config) && config.Port == 0 { + return nil + } checkType := healthCheckType(config) if checkType != "http" && checkType != "tcp" && checkType != "exec" { return fmt.Errorf("health check type must be http, tcp, or exec") @@ -2072,6 +2099,9 @@ func mergeMetadata(existing, incoming *models.ServiceMetadata, sentFields map[st if _, ok := sentFields["healthcheck"]; ok { merged.HealthCheck = incoming.HealthCheck } + if _, ok := sentFields["healthchecks"]; ok { + merged.HealthChecks = incoming.HealthChecks + } if _, ok := sentFields["quick_actions"]; ok { merged.QuickActions = incoming.QuickActions } @@ -5257,6 +5287,16 @@ func (s *Server) listCertificates(c *gin.Context) { } s.annotateCertificatesWithDeployment(certificates) + actor := auth.GetActorFromContext(c) + if actor != nil && actor.Role != auth.RoleAdmin { + visible := certificates[:0] + for _, certificate := range certificates { + if certificate.DeploymentID != "" && actor.CanAccessDeployment(certificate.DeploymentID, auth.AccessLevelRead) { + visible = append(visible, certificate) + } + } + certificates = visible + } c.JSON(http.StatusOK, NewList(certificates, "certificates")) } @@ -5312,6 +5352,18 @@ func (s *Server) requestCertificate(c *gin.Context) { }) return } + actor := auth.GetActorFromContext(c) + if actor != nil && actor.Role != auth.RoleAdmin { + if req.Deployment == "" || !actor.CanAccessDeployment(req.Deployment, auth.AccessLevelWrite) { + c.JSON(http.StatusForbidden, gin.H{"error": "Write access to the certificate deployment is required"}) + return + } + deployment, err := s.manager.GetDeployment(req.Deployment) + if err != nil || !deploymentHasDomain(deployment, req.Domain) { + c.JSON(http.StatusForbidden, gin.H{"error": "The domain is not assigned to this deployment"}) + return + } + } result, err := s.proxyOrchestrator.RequestCertificate(req.Domain) if err != nil { @@ -5369,6 +5421,11 @@ func (s *Server) enableSSLForDeployment(name, domain string) { } func (s *Server) renewCertificates(c *gin.Context) { + actor := auth.GetActorFromContext(c) + if actor != nil && actor.Role != auth.RoleAdmin { + c.JSON(http.StatusForbidden, gin.H{"error": "Renewing every certificate requires administrator access"}) + return + } result, err := s.proxyOrchestrator.RenewCertificates() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -5385,18 +5442,18 @@ func (s *Server) renewCertificates(c *gin.Context) { func (s *Server) getCertificate(c *gin.Context) { domain := c.Param("domain") - cert, err := s.proxyOrchestrator.SSLManager().GetCertificate(domain) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + cert, ok := s.requireCertificateAccess(c, domain, auth.AccessLevelRead) + if !ok { return } - annotated := []models.Certificate{*cert} - s.annotateCertificatesWithDeployment(annotated) - c.JSON(http.StatusOK, gin.H{"certificate": annotated[0]}) + c.JSON(http.StatusOK, gin.H{"certificate": cert}) } func (s *Server) renewCertificate(c *gin.Context) { domain := c.Param("domain") + if _, ok := s.requireCertificateAccess(c, domain, auth.AccessLevelWrite); !ok { + return + } force := c.Query("force") == "true" result, err := s.proxyOrchestrator.RenewCertificate(domain, force) @@ -5419,6 +5476,9 @@ func (s *Server) renewCertificate(c *gin.Context) { func (s *Server) setCertificateAutoRenew(c *gin.Context) { domain := c.Param("domain") + if _, ok := s.requireCertificateAccess(c, domain, auth.AccessLevelWrite); !ok { + return + } var req struct { AutoRenew bool `json:"auto_renew"` @@ -5489,6 +5549,12 @@ func (s *Server) renewDeploymentCertificates(c *gin.Context) { func (s *Server) deleteCertificate(c *gin.Context) { domain := c.Param("domain") + actor := auth.GetActorFromContext(c) + if actor != nil && actor.Role != auth.RoleAdmin { + if _, ok := s.requireCertificateAccess(c, domain, auth.AccessLevelAdmin); !ok { + return + } + } force := c.DefaultQuery("force", "false") == "true" vhosts := s.proxyOrchestrator.NginxManager().GetVhostsUsingSSLDomain(domain) @@ -5514,6 +5580,36 @@ func (s *Server) deleteCertificate(c *gin.Context) { }) } +func (s *Server) requireCertificateAccess(c *gin.Context, domain, level string) (*models.Certificate, bool) { + certificate, err := s.proxyOrchestrator.SSLManager().GetCertificate(domain) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return nil, false + } + annotated := []models.Certificate{*certificate} + s.annotateCertificatesWithDeployment(annotated) + actor := auth.GetActorFromContext(c) + if actor != nil && actor.Role != auth.RoleAdmin { + if annotated[0].DeploymentID == "" || !actor.CanAccessDeployment(annotated[0].DeploymentID, level) { + c.JSON(http.StatusForbidden, gin.H{"error": "No access to this certificate"}) + return nil, false + } + } + return &annotated[0], true +} + +func deploymentHasDomain(deployment *models.Deployment, domain string) bool { + if deployment == nil || deployment.Metadata == nil { + return false + } + for _, configured := range deployment.Metadata.GetDomains() { + if configured.Domain == domain || slices.Contains(configured.Aliases, domain) || slices.Contains(configured.RouteOnlyAliases, domain) { + return true + } + } + return false +} + func (s *Server) getProxyStatus(c *gin.Context) { name := c.Param("name") diff --git a/internal/auth/models.go b/internal/auth/models.go index cd5a2e9..1bd853e 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -171,6 +171,13 @@ func (a *ActorContext) CanAccessDeployment(name string, requiredLevel string) bo return accessLevelSufficient(minAccessLevel(userLevel, keyLevel), requiredLevel) } +func (a *ActorContext) CanAccessPeerDeployment(server, name, requiredLevel string) bool { + if a.Role == RoleAdmin { + return true + } + return a.CanAccessDeployment(server+"/"+name, requiredLevel) +} + func actorUserDeploymentLevel(a *ActorContext, name string) string { if a.User != nil && a.User.Role == RoleAdmin { return AccessLevelAdmin diff --git a/internal/auth/models_test.go b/internal/auth/models_test.go index 8ed7e4d..dcd953c 100644 --- a/internal/auth/models_test.go +++ b/internal/auth/models_test.go @@ -267,6 +267,16 @@ func TestActorContextCanAccessDeployment(t *testing.T) { } } +func TestActorContextCanAccessPeerDeployment(t *testing.T) { + actor := &ActorContext{Role: RoleOperator, Deployments: map[string]string{"prod3/database": AccessLevelWrite}} + if !actor.CanAccessPeerDeployment("prod3", "database", AccessLevelWrite) { + t.Fatal("server-qualified deployment grant was not accepted") + } + if actor.CanAccessPeerDeployment("prod1", "database", AccessLevelRead) { + t.Fatal("deployment grant crossed the server boundary") + } +} + func TestAccessLevelSufficient(t *testing.T) { tests := []struct { has string diff --git a/internal/auth/permissions.go b/internal/auth/permissions.go index 97ca599..583e849 100644 --- a/internal/auth/permissions.go +++ b/internal/auth/permissions.go @@ -135,7 +135,7 @@ var operatorPermissions = []Permission{ PermDatabasesRead, PermDatabasesWrite, PermInfrastructureRead, PermInfrastructureWrite, PermSchedulerRead, PermSchedulerWrite, - PermSystemRead, PermSystemWrite, + PermSystemRead, PermDNSRead, PermDNSWrite, PermRegistriesRead, PermRegistriesWrite, PermTemplatesRead, diff --git a/internal/auth/permissions_test.go b/internal/auth/permissions_test.go index 9172ed9..48e3776 100644 --- a/internal/auth/permissions_test.go +++ b/internal/auth/permissions_test.go @@ -33,6 +33,11 @@ func TestGetRolePermissions(t *testing.T) { if len(operatorPerms) == 0 { t.Error("Operator should have permissions") } + for _, permission := range operatorPerms { + if permission == PermSystemWrite || permission == PermSystemFiles { + t.Fatalf("operator role includes host control permission %s", permission) + } + } viewerPerms := GetRolePermissions(RoleViewer) if len(viewerPerms) == 0 { @@ -169,7 +174,6 @@ func TestOperatorPermissions(t *testing.T) { PermDatabasesWrite, PermInfrastructureWrite, PermSchedulerWrite, - PermSystemWrite, PermDNSWrite, PermRegistriesWrite, PermStorageWrite, diff --git a/internal/cluster/capabilities.go b/internal/cluster/capabilities.go index 87ff1fc..28d0eb1 100644 --- a/internal/cluster/capabilities.go +++ b/internal/cluster/capabilities.go @@ -3,13 +3,14 @@ package cluster type Capability string const ( - CapabilityFleetRead Capability = "fleet.read" - CapabilityDeploymentsRead Capability = "deployments.read" - CapabilityDeploymentsRun Capability = "deployments.run" - CapabilityCapacityRead Capability = "capacity.read" - CapabilityCapacityOffer Capability = "capacity.offer" - CapabilityEventsPublish Capability = "events.publish" - CapabilityRoutingManage Capability = "routing.manage" + CapabilityFleetRead Capability = "fleet.read" + CapabilityDeploymentsRead Capability = "deployments.read" + CapabilityDeploymentsRun Capability = "deployments.run" + CapabilityDeploymentsManage Capability = "deployments.manage" + CapabilityCapacityRead Capability = "capacity.read" + CapabilityCapacityOffer Capability = "capacity.offer" + CapabilityEventsPublish Capability = "events.publish" + CapabilityRoutingManage Capability = "routing.manage" ) type Grant struct { @@ -36,7 +37,7 @@ func DefaultPeerGrants() []Grant { func ValidCapability(value Capability) bool { switch value { - case CapabilityFleetRead, CapabilityDeploymentsRead, CapabilityDeploymentsRun, + case CapabilityFleetRead, CapabilityDeploymentsRead, CapabilityDeploymentsRun, CapabilityDeploymentsManage, CapabilityCapacityRead, CapabilityCapacityOffer, CapabilityEventsPublish, CapabilityRoutingManage: return true default: diff --git a/internal/cluster/client.go b/internal/cluster/client.go index 82ac4f8..912bea4 100644 --- a/internal/cluster/client.go +++ b/internal/cluster/client.go @@ -27,10 +27,21 @@ func NewClient(baseURL, apiKey string, timeout time.Duration) *Client { } func (c *Client) Do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { - fullURL, err := url.JoinPath(c.baseURL, path) + return c.DoWithHeaders(ctx, method, path, nil, body) +} + +func (c *Client) DoWithHeaders(ctx context.Context, method, path string, headers http.Header, body io.Reader) (*http.Response, error) { + reference, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("invalid URL path %q: %w", path, err) + } + fullURL, err := url.JoinPath(c.baseURL, reference.Path) if err != nil { return nil, fmt.Errorf("invalid URL path %q: %w", path, err) } + if reference.RawQuery != "" { + fullURL += "?" + reference.RawQuery + } req, err := http.NewRequestWithContext(ctx, method, fullURL, body) if err != nil { @@ -38,11 +49,26 @@ func (c *Client) Do(ctx context.Context, method, path string, body io.Reader) (* } req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Content-Type", "application/json") + copyForwardHeaders(req.Header, headers) + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } return c.httpClient.Do(req) } +func copyForwardHeaders(destination, source http.Header) { + for key, values := range source { + switch http.CanonicalHeaderKey(key) { + case "Authorization", "Connection", "Content-Length", "Host", "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade": + continue + } + for _, value := range values { + destination.Add(key, value) + } + } +} + func (c *Client) Health(ctx context.Context) error { resp, err := c.Do(ctx, "GET", "/api/health", nil) if err != nil { diff --git a/internal/cluster/client_test.go b/internal/cluster/client_test.go index 21fad7f..8470458 100644 --- a/internal/cluster/client_test.go +++ b/internal/cluster/client_test.go @@ -171,3 +171,46 @@ func TestClientForward(t *testing.T) { t.Error("Expected forwarded=true") } } + +func TestClientForwardsQueryAndRepresentationHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/agent/api/deployments/app/files" { + t.Fatalf("path = %q", r.URL.Path) + } + if r.URL.RawQuery != "path=%2Fconfig" { + t.Fatalf("query = %q", r.URL.RawQuery) + } + if got := r.Header.Get("Content-Type"); got != "multipart/form-data; boundary=test" { + t.Fatalf("content type = %q", got) + } + if got := r.Header.Get("Accept"); got != "application/octet-stream" { + t.Fatalf("accept = %q", got) + } + if got := r.Header.Get("Authorization"); got != "Bearer peer-key" { + t.Fatalf("authorization = %q", got) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := NewClient(server.URL+"/agent", "peer-key", 5*time.Second) + headers := http.Header{ + "Authorization": []string{"Bearer local-user-key"}, + "Content-Type": []string{"multipart/form-data; boundary=test"}, + "Accept": []string{"application/octet-stream"}, + } + resp, err := client.DoWithHeaders( + context.Background(), + http.MethodPost, + "/api/deployments/app/files?path=%2Fconfig", + headers, + strings.NewReader("payload"), + ) + if err != nil { + t.Fatalf("forward request: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d", resp.StatusCode) + } +} diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index fa22b75..0f16a3e 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -36,6 +36,7 @@ type ServiceMetadata struct { Networking NetworkingConfig `yaml:"networking" json:"networking"` SSL SSLConfig `yaml:"ssl" json:"ssl"` HealthCheck HealthCheckConfig `yaml:"healthcheck" json:"healthcheck"` + HealthChecks []HealthCheckConfig `yaml:"healthchecks,omitempty" json:"healthchecks,omitempty"` QuickActions []QuickAction `yaml:"quick_actions,omitempty" json:"quick_actions,omitempty"` Security *DeploymentSecurityConfig `yaml:"security,omitempty" json:"security,omitempty"` Backup *BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"` @@ -342,6 +343,16 @@ type HealthCheckConfig struct { Command string `yaml:"command,omitempty" json:"command,omitempty"` } +func (m *ServiceMetadata) EffectiveHealthChecks() []HealthCheckConfig { + if m.HealthChecks != nil { + return m.HealthChecks + } + if m.HealthCheck.Type != "" || m.HealthCheck.Path != "" || m.HealthCheck.Command != "" || m.HealthCheck.Port != 0 { + return []HealthCheckConfig{m.HealthCheck} + } + return nil +} + type DeploymentStatus string const ( From 57908244531e4f84df3e9aafb1937ab48b3589c9 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 14:53:14 +0100 Subject: [PATCH 2/2] fix: Enforce scoped peer management Peer reads now require their module permission. Combined grants retain the highest deployment access. --- internal/api/cluster_handlers.go | 21 ++++++++-- internal/api/cluster_handlers_test.go | 55 ++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/internal/api/cluster_handlers.go b/internal/api/cluster_handlers.go index 1f38eb4..17f6030 100644 --- a/internal/api/cluster_handlers.go +++ b/internal/api/cluster_handlers.go @@ -748,13 +748,26 @@ func mergeClusterDeploymentAccess(access auth.DeploymentAccess, names []string, return true } for _, name := range names { - if current, ok := access[name]; !ok || current == auth.AccessLevelRead && level == auth.AccessLevelWrite { + if current, ok := access[name]; !ok || clusterAccessLevelRank(level) > clusterAccessLevelRank(current) { access[name] = level } } return false } +func clusterAccessLevelRank(level string) int { + switch level { + case auth.AccessLevelRead: + return 1 + case auth.AccessLevelWrite: + return 2 + case auth.AccessLevelAdmin: + return 3 + default: + return 0 + } +} + func (s *Server) applyClusterPeerPolicy(policy cluster.PeerPolicy) error { if s.authManager == nil { return fmt.Errorf("Authentication manager is not available") @@ -949,10 +962,10 @@ func authorizePeerProxy(c *gin.Context) bool { c.JSON(http.StatusForbidden, gin.H{"error": "No access to this peer deployment"}) return false } - if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead { - return true + permission := auth.PermDeploymentsRead + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + permission = auth.PermDeploymentsWrite } - permission := auth.PermDeploymentsWrite if c.Request.Method == http.MethodDelete { permission = auth.PermDeploymentsDelete } diff --git a/internal/api/cluster_handlers_test.go b/internal/api/cluster_handlers_test.go index 7f8c3d6..43ac65e 100644 --- a/internal/api/cluster_handlers_test.go +++ b/internal/api/cluster_handlers_test.go @@ -62,6 +62,47 @@ func TestAuthorizePeerProxyRequiresServerQualifiedDeploymentGrant(t *testing.T) } } +func TestAuthorizePeerProxyRequiresModulePermissionForReads(t *testing.T) { + gin.SetMode(gin.TestMode) + request := func(actor *auth.ActorContext) *httptest.ResponseRecorder { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(contextkeys.Actor, actor) + c.Next() + }) + router.GET("/cluster/peers/:name/proxy/*path", func(c *gin.Context) { + if authorizePeerProxy(c) { + c.Status(http.StatusNoContent) + } + }) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest( + http.MethodGet, + "/cluster/peers/prod3/proxy/deployments/database/backups", + nil, + )) + return response + } + + response := request(&auth.ActorContext{ + Role: auth.RoleService, + Permissions: []string{auth.PermClusterRead.String(), auth.PermDeploymentsRead.String()}, + Deployments: map[string]string{"prod3/database": auth.AccessLevelRead}, + }) + if response.Code != http.StatusForbidden { + t.Fatalf("backup read without permission accepted: %d %s", response.Code, response.Body.String()) + } + + response = request(&auth.ActorContext{ + Role: auth.RoleService, + Permissions: []string{auth.PermClusterRead.String(), auth.PermDeploymentsRead.String(), auth.PermBackupsRead.String()}, + Deployments: map[string]string{"prod3/database": auth.AccessLevelRead}, + }) + if response.Code != http.StatusNoContent { + t.Fatalf("backup read with permission rejected: %d %s", response.Code, response.Body.String()) + } +} + type testClusterEnv struct { server *Server router *gin.Engine @@ -467,6 +508,18 @@ func TestClusterPolicyAccessGrantsDeploymentManagement(t *testing.T) { } } +func TestClusterPolicyAccessKeepsHighestDeploymentAccess(t *testing.T) { + _, deployments := clusterPolicyAccess(cluster.PeerPolicy{Grants: []cluster.Grant{ + {Capability: cluster.CapabilityDeploymentsRead, Deployments: []string{"public-site"}}, + {Capability: cluster.CapabilityDeploymentsManage, Deployments: []string{"public-site"}}, + {Capability: cluster.CapabilityDeploymentsRun, Deployments: []string{"public-site"}}, + }}) + + if deployments["public-site"] != auth.AccessLevelAdmin { + t.Fatalf("deployment access = %#v", deployments) + } +} + func TestClusterSetupEnablesClusterWithoutRestart(t *testing.T) { env := setupClusterTestServer(t, "", false) defer env.cleanup() @@ -987,7 +1040,7 @@ func TestClusterProxyAllowsReadWithoutWrite(t *testing.T) { } _, err = env.server.authManager.CreateAPIKeyFromRaw( "fleet-reader-key", user.ID, "fleet-reader", "Fleet reader", auth.Role(""), - []string{auth.PermClusterRead.String()}, nil, time.Time{}, + []string{auth.PermClusterRead.String(), auth.PermDeploymentsRead.String()}, nil, time.Time{}, ) if err != nil { t.Fatal(err)