diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6f7a7ae --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# FlatRun Agent Guide + +## Authorization + +Permissions and resource grants answer different questions. A permission allows an operation. A resource grant limits where that operation may run. Endpoints that operate on deployments or another owned resource must enforce both. + +Rules: + +- Define dedicated read and write permissions for each module. Do not reuse an unrelated permission because two features share a page, plugin, or transport. +- Enforce authorization in the HTTP API. UI guards are not security boundaries. +- Filter collection responses to resources the actor may read. +- Validate every resource referenced by create, update, delete, bulk, and action requests. +- Preserve records outside the actor's scope when processing bulk updates. A scoped request must never replace a global collection. +- Require explicit global access for host-wide, fleet-wide, and all-resource operations. An empty resource identifier must not grant global access. +- Apply the intersection of user and API key grants. An API key may narrow its user's access but must never widen it. +- Keep secret-bearing administration resources separate from safe selectors. A scoped feature may receive target identifiers and display names without receiving target credentials. +- Test authorization through HTTP with actors whose resource grants differ. Prove that each actor sees only allowed records and cannot change the other actor's records. + +## Tests + +Drive regression tests through the boundary used in production. HTTP features must create requests through their router and middleware instead of calling handlers' collaborators directly. diff --git a/CHANGELOG.md b/CHANGELOG.md index b871b5a..68341c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,26 @@ # Changelog -## [0.4.0-beta.5] - 2026-08-22 +## [0.4.0-beta.6] - 2026-08-23 -Fifth beta of the Albacore release, making connected servers manageable as one Fleet. +Sixth beta of the Albacore release, making connected servers manageable as one Fleet. ### Added - Guided Fleet setup, peer access policies, remote deployment inventories, and runtime provider selection - Host and deployment capacity decisions with managed horizontal and vertical scaling - 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 ### Fixed - Existing Fleet peers gain default access policies during startup repair without reconnecting - Existing peer credentials are restricted to their configured Fleet policy during startup +- Fleet peer credentials can read deployments allowed by their peer policy +- Fleet readers can open deployment details without gaining write access +- Object storage and notifications have independent permission boundaries +- Updates require dedicated access and remain admin-only by default +- 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 ## [0.4.0-beta.4] - 2026-08-21 diff --git a/VERSION b/VERSION index c7cc572..cc930a0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0-beta.5 +0.4.0-beta.6 diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index ecbbd31..ec43160 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -113,8 +113,8 @@ func (s *Server) platformSection(deploymentName string) ai.Section { } else { fmt.Fprintf(&b, "This deployment is not exposed through the reverse proxy\n") } - if meta.HealthCheck.Path != "" { - fmt.Fprintf(&b, "Configured health check path: %s\n", meta.HealthCheck.Path) + if healthCheckConfigured(meta.HealthCheck) { + fmt.Fprintf(&b, "Configured health check type: %s\n", healthCheckType(meta.HealthCheck)) } if len(meta.Databases) > 0 { aliases := make([]string, 0, len(meta.Databases)) diff --git a/internal/api/apikeys_test.go b/internal/api/apikeys_test.go index b3edca7..6171a6d 100644 --- a/internal/api/apikeys_test.go +++ b/internal/api/apikeys_test.go @@ -337,13 +337,11 @@ func TestRevokeAPIKey(t *testing.T) { } } -func TestOperatorCanAccessOwnAPIKeys(t *testing.T) { +func TestOperatorCannotAccessAPIKeysWithoutExplicitPermission(t *testing.T) { server, router, cleanup := setupAPIKeyTestServer(t) defer cleanup() - operator, _ := server.authManager.CreateUser("operator", "", "operatorpass", auth.RoleOperator, nil) - - _, _, _ = server.authManager.CreateAPIKey(operator.ID, "Operator's Key", "", "", nil, nil, time.Time{}) + _, _ = server.authManager.CreateUser("operator", "", "operatorpass", auth.RoleOperator, nil) token := apiKeyLogin(t, router, "operator", "operatorpass") @@ -353,16 +351,8 @@ func TestOperatorCanAccessOwnAPIKeys(t *testing.T) { w := httptest.NewRecorder() router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp map[string]interface{} - _ = json.Unmarshal(w.Body.Bytes(), &resp) - - keys := resp["api_keys"].([]interface{}) - if len(keys) != 1 { - t.Errorf("Operator should see their own 1 key, got %d", len(keys)) + if w.Code != http.StatusForbidden { + t.Errorf("Expected status 403, got %d: %s", w.Code, w.Body.String()) } } diff --git a/internal/api/cluster_handlers.go b/internal/api/cluster_handlers.go index 1b52eb8..82eacae 100644 --- a/internal/api/cluster_handlers.go +++ b/internal/api/cluster_handlers.go @@ -797,29 +797,39 @@ func (s *Server) clusterRemovePeer(c *gin.Context) { } name := c.Param("name") + if err := s.deleteClusterAPIKey(name); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete peer credential"}) + return + } if err := mgr.RemovePeer(name); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - s.revokeClusterAPIKey(name) c.JSON(http.StatusOK, gin.H{"status": "removed", "peer": name}) } -func (s *Server) revokeClusterAPIKey(peerName string) { +func (s *Server) deleteClusterAPIKey(peerName string) error { if s.authManager == nil { - return + return nil + } + userID, err := s.clusterServiceUserID() + if err != nil { + return err } keys, err := s.authManager.GetAllAPIKeys() if err != nil { - return + return err } name := fmt.Sprintf("cluster-peer-%s", peerName) for _, key := range keys { - if key.Name == name { - _ = s.authManager.DeactivateAPIKey(key.ID) + if key.UserID == userID && key.Name == name { + if err := s.authManager.DeleteAPIKey(key.ID); err != nil { + return err + } } } + return nil } func (s *Server) clusterProxy(c *gin.Context) { diff --git a/internal/api/cluster_handlers_test.go b/internal/api/cluster_handlers_test.go index c9f9ef1..c88e6ad 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/docker" "github.com/flatrun/agent/internal/orchestrator" "github.com/flatrun/agent/internal/routing" "github.com/flatrun/agent/pkg/config" @@ -102,6 +103,7 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool configPath: tmpDir + "/config.yml", authManager: authManager, clusterManager: clusterManager, + manager: docker.NewManager(tmpDir), } router := gin.New() @@ -116,6 +118,7 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool protected.Use(authMiddleware.RequireAuth()) { protected.GET("/capacity", authMiddleware.RequirePermission(auth.PermSystemRead), server.getCapacityStatus) + protected.GET("/deployments", authMiddleware.RequirePermission(auth.PermDeploymentsRead), server.listDeployments) protected.GET("/test/deployments", authMiddleware.RequirePermission(auth.PermDeploymentsRead), func(c *gin.Context) { c.Status(http.StatusNoContent) }) @@ -136,7 +139,11 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool clusterGroup.POST("/invite", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterInvite) clusterGroup.POST("/accept", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterAccept) clusterGroup.DELETE("/peers/:name", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterRemovePeer) - clusterGroup.Any("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy) + 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.GET("/deployments", server.clusterAggregateDeployments) clusterGroup.GET("/stats", server.clusterAggregateStats) clusterGroup.GET("/capacity", server.clusterAggregateCapacity) @@ -159,6 +166,57 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool } } +func TestClusterDeploymentsIncludesPeerWhenLocalServerIsEmpty(t *testing.T) { + local := setupClusterTestServer(t, "local", true) + defer local.cleanup() + remote := setupClusterTestServer(t, "remote", true) + defer remote.cleanup() + + if err := remote.server.manager.CreateDeployment("remote-app", `services: + app: + image: nginx:alpine +`, nil); err != nil { + t.Fatal(err) + } + const peerKey = "local-to-remote-key" + if err := remote.server.createClusterAPIKey(peerKey, "local"); err != nil { + t.Fatal(err) + } + remoteHTTP := httptest.NewServer(remote.router) + defer remoteHTTP.Close() + if err := local.server.clusterManager.AddPeer("remote", remoteHTTP.URL, peerKey); err != nil { + t.Fatal(err) + } + + token := clusterLogin(t, local.router) + req := httptest.NewRequest(http.MethodGet, "/api/cluster/deployments", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + local.router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d: %s", w.Code, w.Body.String()) + } + var response struct { + Servers map[string]struct { + Data struct { + Deployments []struct { + Name string `json:"name"` + } `json:"deployments"` + } `json:"data"` + } `json:"servers"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if len(response.Servers["local"].Data.Deployments) != 0 { + t.Fatalf("local deployments = %#v", response.Servers["local"].Data.Deployments) + } + remoteDeployments := response.Servers["remote"].Data.Deployments + if len(remoteDeployments) != 1 || remoteDeployments[0].Name != "remote-app" { + t.Fatalf("remote deployments = %#v", remoteDeployments) + } +} + func TestClusterCapacityIncludesLocalOfferPolicy(t *testing.T) { env := setupClusterTestServer(t, "server-a", true) defer env.cleanup() @@ -192,6 +250,52 @@ func TestClusterCapacityIncludesLocalOfferPolicy(t *testing.T) { } } +func TestClusterRemovePeerDeletesOnlyItsServiceCredential(t *testing.T) { + env := setupClusterTestServer(t, "server-a", true) + defer env.cleanup() + + if err := env.server.clusterManager.AddPeer("server-b", "https://server-b.example.com", "peer-key"); err != nil { + t.Fatal(err) + } + if err := env.server.createClusterAPIKey("credential-for-server-b", "server-b"); err != nil { + t.Fatal(err) + } + admin, err := env.server.authManager.GetUserByUsername("admin") + if err != nil { + t.Fatal(err) + } + if _, _, err := env.server.authManager.CreateAPIKey( + admin.ID, "cluster-peer-server-b", "User-managed key", auth.RoleAdmin, nil, nil, time.Time{}, + ); err != nil { + t.Fatal(err) + } + + token := clusterLogin(t, env.router) + req := httptest.NewRequest(http.MethodDelete, "/api/cluster/peers/server-b", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + env.router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d: %s", w.Code, w.Body.String()) + } + if _, err := env.server.clusterManager.GetPeer("server-b"); err == nil { + t.Fatal("peer still exists") + } + keys, err := env.server.authManager.GetAllAPIKeys() + if err != nil { + t.Fatal(err) + } + var matching []auth.APIKey + for _, key := range keys { + if key.Name == "cluster-peer-server-b" { + matching = append(matching, key) + } + } + if len(matching) != 1 || matching[0].UserID != admin.ID { + t.Fatalf("remaining matching keys = %+v", matching) + } +} + func TestUpdateClusterPeerPolicyThroughHTTP(t *testing.T) { env := setupClusterTestServer(t, "server-a", true) defer env.cleanup() @@ -817,6 +921,47 @@ func TestClusterProxyForwardsToPeer(t *testing.T) { } } +func TestClusterProxyAllowsReadWithoutWrite(t *testing.T) { + peerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"deployment":{"name":"shop"}}`)) + })) + defer peerServer.Close() + + env := setupClusterTestServer(t, "primary", true) + defer env.cleanup() + if err := env.server.clusterManager.AddPeer("remote", peerServer.URL, "key"); err != nil { + t.Fatal(err) + } + user, err := env.server.authManager.CreateUser("fleet-reader", "", "password", auth.RoleService, nil) + if 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{}, + ) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/cluster/peers/remote/proxy/deployments/shop", nil) + req.Header.Set("Authorization", "Bearer fleet-reader-key") + w := httptest.NewRecorder() + env.router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("read status = %d, body = %s", w.Code, w.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/api/cluster/peers/remote/proxy/deployments/shop/restart", nil) + req.Header.Set("Authorization", "Bearer fleet-reader-key") + w = httptest.NewRecorder() + env.router.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("write status = %d, body = %s", w.Code, w.Body.String()) + } +} + func TestClusterProxyUnknownPeer(t *testing.T) { env := setupClusterTestServer(t, "primary", true) defer env.cleanup() diff --git a/internal/api/deployment_diagnostics.go b/internal/api/deployment_diagnostics.go index fe0875d..67ab904 100644 --- a/internal/api/deployment_diagnostics.go +++ b/internal/api/deployment_diagnostics.go @@ -3,6 +3,7 @@ package api import ( "context" "fmt" + "net" "net/http" "net/url" "regexp" @@ -112,39 +113,7 @@ func (s *Server) runDeploymentDiagnostics(ctx context.Context, deployment *model } add("docker_health", "Docker health", dockerStatus, dockerDetail, "edit_compose", "") - metadata := deployment.Metadata - if metadata == nil || metadata.HealthCheck.Path == "" { - add("application", "Application endpoint", diagnosticSkipped, "No application health path is configured in service.yml.", "edit_healthcheck", "") - } else { - service := metadata.EffectivePrimaryService() - port := metadata.Networking.ContainerPort - path := metadata.HealthCheck.Path - if service == "" || port <= 0 || !validHealthPath(path) { - add("application", "Application endpoint", diagnosticWarning, "The application health configuration is incomplete.", "edit_healthcheck", "") - } else { - probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second) - defer cancel() - command := fmt.Sprintf("curl -sS -w '\\n%%{http_code}' --max-time 5 %s", shellLiteral("http://127.0.0.1:"+strconv.Itoa(port)+path)) - output, err := s.manager.ComposeExec(probeCtx, deployment.Name, service, command) - body, statusCode, parseErr := parseHealthResponse(output) - if err != nil || parseErr != nil { - detail := "The configured endpoint could not be reached from its service container." - addWithOutput("application", "Application endpoint", diagnosticFailed, detail, output, "edit_healthcheck", path) - } else if healthStatusAccepted(statusCode, metadata.HealthCheck.SuccessStatuses) && healthBodyAccepted(body, metadata.HealthCheck.ResponseContains) { - detail := fmt.Sprintf("GET %s returned HTTP %d.", path, statusCode) - if metadata.HealthCheck.ResponseContains != "" { - detail = fmt.Sprintf("GET %s returned HTTP %d and matched the expected response.", path, statusCode) - } - add("application", "Application endpoint", diagnosticPassed, detail, "", path) - } else if healthStatusAccepted(statusCode, metadata.HealthCheck.SuccessStatuses) { - addWithOutput("application", "Application endpoint", diagnosticFailed, fmt.Sprintf("GET %s returned HTTP %d but did not match the expected response.", path, statusCode), body, "edit_healthcheck", path) - } else if statusCode == http.StatusNotFound { - addWithOutput("application", "Application endpoint", diagnosticWarning, fmt.Sprintf("GET %s returned HTTP 404. Configure a health endpoint to enable this check.", path), body, "edit_healthcheck", path) - } else { - addWithOutput("application", "Application endpoint", diagnosticFailed, fmt.Sprintf("GET %s returned HTTP %d.", path, statusCode), body, "edit_healthcheck", path) - } - } - } + s.addApplicationHealthDiagnostic(ctx, deployment, add, addWithOutput) proxyStatus := s.proxyOrchestrator.GetDeploymentProxyStatus(deployment) if !proxyStatus.Exposed { @@ -286,6 +255,89 @@ func (s *Server) addSecurityDiagnostic(result *DeploymentDiagnostics, deployment result.Steps = append(result.Steps, step) } +func (s *Server) addApplicationHealthDiagnostic( + ctx context.Context, + deployment *models.Deployment, + add func(string, string, DiagnosticStatus, string, string, string), + addWithOutput func(string, string, DiagnosticStatus, string, string, string, string), +) { + metadata := deployment.Metadata + if metadata == nil || !healthCheckConfigured(metadata.HealthCheck) { + add("application", "Application health", diagnosticSkipped, "No application health check is configured in service.yml.", "edit_healthcheck", "") + return + } + config := metadata.HealthCheck + checkType := healthCheckType(config) + service := config.Service + if service == "" { + service = metadata.EffectivePrimaryService() + } + port := config.Port + if port == 0 { + 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", "") + return + } + probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + + switch checkType { + 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)) + 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)) + return + } + _ = connection.Close() + add("application", "Application health", diagnosticPassed, fmt.Sprintf("TCP port %d accepted a connection.", port), "", strconv.Itoa(port)) + 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") + return + } + add("application", "Application health", diagnosticPassed, "The health command completed successfully.", "", "exec") + default: + 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) + } 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) + } 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) + } 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) + } else { + addWithOutput("application", "Application health", diagnosticFailed, fmt.Sprintf("GET %s returned HTTP %d.", config.Path, statusCode), body, "edit_healthcheck", config.Path) + } + } +} + +func healthCheckType(config models.HealthCheckConfig) string { + checkType := strings.ToLower(strings.TrimSpace(config.Type)) + if checkType == "" { + return "http" + } + return checkType +} + +func healthCheckConfigured(config models.HealthCheckConfig) bool { + return strings.TrimSpace(config.Type) != "" || config.Path != "" || strings.TrimSpace(config.Command) != "" +} + var incidentIDPattern = regexp.MustCompile(`^FR-[A-F0-9]{12}$`) func validIncidentID(value string) bool { diff --git a/internal/api/deployment_diagnostics_test.go b/internal/api/deployment_diagnostics_test.go index 471f011..257104b 100644 --- a/internal/api/deployment_diagnostics_test.go +++ b/internal/api/deployment_diagnostics_test.go @@ -82,6 +82,30 @@ func TestValidateHealthCheckConfig(t *testing.T) { if err := validateHealthCheckConfig(models.HealthCheckConfig{Path: "/", SuccessStatuses: []int{700}}); err == nil { t.Fatal("invalid health check status accepted") } + if err := validateHealthCheckConfig(models.HealthCheckConfig{Type: "tcp", Service: "postgres", Port: 5432}); err != nil { + t.Fatalf("valid TCP health check rejected: %v", err) + } + if err := validateHealthCheckConfig(models.HealthCheckConfig{Type: "exec", Service: "postgres", Command: "pg_isready -U postgres"}); err != nil { + t.Fatalf("valid exec health check rejected: %v", err) + } + if err := validateHealthCheckConfig(models.HealthCheckConfig{Type: "tcp", Port: 5432, Path: "/"}); err == nil { + t.Fatal("HTTP fields accepted for TCP health check") + } + if err := validateHealthCheckConfig(models.HealthCheckConfig{Type: "exec"}); err == nil { + t.Fatal("exec health check without a command accepted") + } +} + +func TestHealthCheckTypeCompatibility(t *testing.T) { + if got := healthCheckType(models.HealthCheckConfig{Path: "/ready"}); got != "http" { + t.Fatalf("legacy check type = %q", got) + } + if healthCheckConfigured(models.HealthCheckConfig{}) { + t.Fatal("empty health check treated as configured") + } + if !healthCheckConfigured(models.HealthCheckConfig{Type: "tcp", Port: 5432}) { + t.Fatal("TCP health check treated as empty") + } } func TestValidIncidentID(t *testing.T) { diff --git a/internal/api/notifications_test.go b/internal/api/notifications_test.go index 9f45026..62b4fd5 100644 --- a/internal/api/notifications_test.go +++ b/internal/api/notifications_test.go @@ -23,6 +23,7 @@ func setupNotifyTest(t *testing.T) (*Server, *gin.Engine) { } r := gin.New() r.GET("/notifications/targets", s.getNotificationTargets) + r.GET("/alerts/target-options", s.getAlertTargetOptions) r.GET("/notifications/incidents", s.listNotificationIncidents) r.GET("/notifications/rules", s.listNotificationRules) r.PUT("/notifications/rules", s.updateNotificationRules) @@ -33,6 +34,24 @@ func setupNotifyTest(t *testing.T) (*Server, *gin.Engine) { return s, r } +func TestAlertTargetOptionsExposeOnlyEnabledNames(t *testing.T) { + s, r := setupNotifyTest(t) + if err := s.notify.Save(notify.Config{Targets: []notify.Target{ + {ID: "ops", Name: "Ops", URL: "generic+https://example.com/secret", Enabled: true}, + {ID: "off", Name: "Disabled", URL: "smtp://user:pass@example.com", Enabled: false}, + }}); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/alerts/target-options", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d: %s", w.Code, w.Body.String()) + } + if body := w.Body.String(); body != `{"targets":[{"id":"ops","name":"Ops"}]}` { + t.Fatalf("body = %s", body) + } +} + func TestNotificationRulesRoundTripThroughHTTP(t *testing.T) { _, r := setupNotifyTest(t) payload := `{"rules":[{"id":"critical-fleet","name":"Critical fleet incidents","enabled":true,"topics":["fleet"],"severities":["critical"],"target_ids":["email"]}]}` diff --git a/internal/api/object_consume.go b/internal/api/object_consume.go index 7c542bc..bbba8b7 100644 --- a/internal/api/object_consume.go +++ b/internal/api/object_consume.go @@ -9,6 +9,7 @@ import ( "sort" "strconv" + "github.com/flatrun/agent/internal/auth" "github.com/flatrun/agent/internal/docker" "github.com/gin-gonic/gin" ) @@ -33,6 +34,13 @@ func (s *Server) attachStoreToDeployment(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if filepath.IsAbs(req.Deployment) || filepath.Base(req.Deployment) != req.Deployment || req.Deployment == "." || req.Deployment == ".." { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deployment name"}) + return + } + if !s.requireDeploymentAccess(c, req.Deployment, auth.AccessLevelWrite) { + return + } prefix := req.Prefix if prefix == "" { prefix = "S3_" diff --git a/internal/api/object_stores_test.go b/internal/api/object_stores_test.go index 8f729b7..fa5b20f 100644 --- a/internal/api/object_stores_test.go +++ b/internal/api/object_stores_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/flatrun/agent/internal/auth" "github.com/flatrun/agent/internal/backup" @@ -57,22 +58,34 @@ func setupObjectStoreTestServer(t *testing.T) (*Server, *gin.Engine, func()) { router := gin.New() mw := auth.NewMiddlewareWithManager(&cfg.Auth, authManager) + server.authMiddleware = mw api := router.Group("/api") api.POST("/auth/login", mw.Login) protected := api.Group("") protected.Use(mw.RequireAuth()) - protected.GET("/storage-credentials", mw.RequirePermission(auth.PermBackupsRead), server.listStorageCredentials) - protected.POST("/storage-credentials", mw.RequirePermission(auth.PermBackupsWrite), server.createStorageCredential) - protected.PUT("/storage-credentials/:id", mw.RequirePermission(auth.PermBackupsWrite), server.updateStorageCredential) - protected.DELETE("/storage-credentials/:id", mw.RequirePermission(auth.PermBackupsDelete), server.deleteStorageCredential) + protected.GET("/storage-credentials", mw.RequirePermission(auth.PermStorageRead), server.listStorageCredentials) + protected.POST("/storage-credentials", mw.RequirePermission(auth.PermStorageWrite), server.createStorageCredential) + protected.PUT("/storage-credentials/:id", mw.RequirePermission(auth.PermStorageWrite), server.updateStorageCredential) + protected.DELETE("/storage-credentials/:id", mw.RequirePermission(auth.PermStorageDelete), server.deleteStorageCredential) protected.GET("/backup-destinations", mw.RequirePermission(auth.PermBackupsRead), server.listBackupDestinations) - protected.POST("/object-stores/provision-managed", mw.RequirePermission(auth.PermBackupsWrite), server.provisionManagedObjectStore) - protected.GET("/object-stores/:name/objects", mw.RequirePermission(auth.PermBackupsRead), server.listStoreObjects) - protected.POST("/object-stores/:name/objects", mw.RequirePermission(auth.PermBackupsWrite), server.uploadStoreObject) - protected.GET("/object-stores/:name/objects/download", mw.RequirePermission(auth.PermBackupsRead), server.downloadStoreObject) - protected.DELETE("/object-stores/:name/objects", mw.RequirePermission(auth.PermBackupsWrite), server.deleteStoreObject) - protected.POST("/object-stores/:name/attach", mw.RequirePermission(auth.PermDeploymentsWrite), server.attachStoreToDeployment) + protected.GET("/object-stores", mw.RequirePermission(auth.PermStorageRead), server.listBackupDestinations) + protected.POST("/object-stores/provision-managed", mw.RequirePermission(auth.PermStorageWrite), server.provisionManagedObjectStore) + protected.GET("/object-stores/:name/objects", mw.RequirePermission(auth.PermStorageRead), server.listStoreObjects) + protected.POST("/object-stores/:name/objects", mw.RequirePermission(auth.PermStorageWrite), server.uploadStoreObject) + protected.GET("/object-stores/:name/objects/download", mw.RequirePermission(auth.PermStorageRead), server.downloadStoreObject) + protected.DELETE("/object-stores/:name/objects", mw.RequirePermission(auth.PermStorageDelete), server.deleteStoreObject) + protected.POST("/object-stores/:name/attach", mw.RequirePermission(auth.PermStorageWrite, auth.PermDeploymentsWrite), server.attachStoreToDeployment) + protected.GET("/agent/update", mw.RequirePermission(auth.PermUpdatesRead), func(c *gin.Context) { c.Status(http.StatusOK) }) + protected.POST("/agent/update", mw.RequirePermission(auth.PermUpdatesWrite), func(c *gin.Context) { c.Status(http.StatusOK) }) + dnsGroup := protected.Group("/dns") + dnsGroup.Use(mw.RequirePermission(auth.PermDNSRead), server.requireDNSWriteForMutations()) + dnsGroup.POST("/provider/zones", func(c *gin.Context) { c.Status(http.StatusOK) }) + dnsGroup.POST("/provider/zones/:zone/records/create", func(c *gin.Context) { c.Status(http.StatusOK) }) + firewallGroup := protected.Group("") + firewallGroup.Use(mw.RequirePermission(auth.PermSecurityRead), server.requireWriteForMethods(auth.PermSecurityWrite, http.MethodPut)) + firewallGroup.GET("/firewall", func(c *gin.Context) { c.Status(http.StatusOK) }) + firewallGroup.PUT("/firewall", func(c *gin.Context) { c.Status(http.StatusOK) }) cleanup := func() { authManager.Close() @@ -82,6 +95,112 @@ func setupObjectStoreTestServer(t *testing.T) (*Server, *gin.Engine, func()) { return server, router, cleanup } +func objectStoreKey(t *testing.T, server *Server, raw string, permissions []string, deployments auth.DeploymentAccess) string { + t.Helper() + user, err := server.authManager.CreateUser(raw, "", "password", auth.RoleService, nil) + if err != nil { + t.Fatal(err) + } + for deployment, level := range deployments { + if err := server.authManager.AssignDeployment(user.ID, deployment, level, user.ID); err != nil { + t.Fatal(err) + } + } + if _, err := server.authManager.CreateAPIKeyFromRaw(raw, user.ID, raw, "", auth.Role(""), permissions, deployments, time.Time{}); err != nil { + t.Fatal(err) + } + return raw +} + +func roleKey(t *testing.T, server *Server, raw string, role auth.Role) string { + t.Helper() + user, err := server.authManager.CreateUser(raw, "", "password", auth.RoleService, nil) + if err != nil { + t.Fatal(err) + } + if _, err := server.authManager.CreateAPIKeyFromRaw(raw, user.ID, raw, "", role, nil, nil, time.Time{}); err != nil { + t.Fatal(err) + } + return raw +} + +func TestObjectStorePermissionsAreIndependentFromBackups(t *testing.T) { + server, router, cleanup := setupObjectStoreTestServer(t) + defer cleanup() + + backupKey := objectStoreKey(t, server, "backup-reader-key", []string{auth.PermBackupsRead.String()}, nil) + if res := osReq(t, router, http.MethodGet, "/api/object-stores", backupKey, nil); res.Code != http.StatusForbidden { + t.Fatalf("backup read status = %d, body = %s", res.Code, res.Body.String()) + } + + storageKey := objectStoreKey(t, server, "storage-reader-key", []string{auth.PermStorageRead.String()}, nil) + if res := osReq(t, router, http.MethodGet, "/api/object-stores", storageKey, nil); res.Code != http.StatusOK { + t.Fatalf("storage read status = %d, body = %s", res.Code, res.Body.String()) + } + if res := osReq(t, router, http.MethodPost, "/api/object-stores/provision-managed", storageKey, map[string]string{}); res.Code != http.StatusForbidden { + t.Fatalf("storage write status = %d, body = %s", res.Code, res.Body.String()) + } +} + +func TestServiceReadersCannotMutateDNSOrFirewall(t *testing.T) { + server, router, cleanup := setupObjectStoreTestServer(t) + defer cleanup() + + dnsKey := objectStoreKey(t, server, "dns-reader-key", []string{auth.PermDNSRead.String()}, nil) + if res := osReq(t, router, http.MethodPost, "/api/dns/provider/zones", dnsKey, nil); res.Code != http.StatusOK { + t.Fatalf("DNS list status = %d, body = %s", res.Code, res.Body.String()) + } + if res := osReq(t, router, http.MethodPost, "/api/dns/provider/zones/example/records/create", dnsKey, nil); res.Code != http.StatusForbidden { + t.Fatalf("DNS create status = %d, body = %s", res.Code, res.Body.String()) + } + + firewallKey := objectStoreKey(t, server, "firewall-reader-key", []string{auth.PermSecurityRead.String()}, nil) + if res := osReq(t, router, http.MethodGet, "/api/firewall", firewallKey, nil); res.Code != http.StatusOK { + t.Fatalf("firewall read status = %d, body = %s", res.Code, res.Body.String()) + } + if res := osReq(t, router, http.MethodPut, "/api/firewall", firewallKey, nil); res.Code != http.StatusForbidden { + t.Fatalf("firewall write status = %d, body = %s", res.Code, res.Body.String()) + } +} + +func TestOperatorCannotAccessUpdatesByDefault(t *testing.T) { + server, router, cleanup := setupObjectStoreTestServer(t) + defer cleanup() + + key := roleKey(t, server, "operator-update-key", auth.RoleOperator) + if res := osReq(t, router, http.MethodGet, "/api/agent/update", key, nil); res.Code != http.StatusForbidden { + t.Fatalf("update read status = %d, body = %s", res.Code, res.Body.String()) + } + if res := osReq(t, router, http.MethodPost, "/api/agent/update", key, nil); res.Code != http.StatusForbidden { + t.Fatalf("update write status = %d, body = %s", res.Code, res.Body.String()) + } +} + +func TestObjectStoreAttachRequiresDeploymentAccess(t *testing.T) { + server, router, cleanup := setupObjectStoreTestServer(t) + defer cleanup() + server.config.Backup.Destinations = []config.BackupDestination{{Name: "store", Type: "s3"}} + key := objectStoreKey(t, server, "storage-attacher-key", []string{ + auth.PermStorageWrite.String(), auth.PermDeploymentsWrite.String(), + }, auth.DeploymentAccess{"allowed": auth.AccessLevelWrite}) + + res := osReq(t, router, http.MethodPost, "/api/object-stores/store/attach", key, map[string]string{"deployment": "blocked"}) + if res.Code != http.StatusForbidden { + t.Fatalf("attach status = %d, body = %s", res.Code, res.Body.String()) + } +} + +func TestObjectStoreAttachRejectsDeploymentPath(t *testing.T) { + server, router, cleanup := setupObjectStoreTestServer(t) + defer cleanup() + server.config.Backup.Destinations = []config.BackupDestination{{Name: "store", Type: "s3"}} + + res := osReq(t, router, http.MethodPost, "/api/object-stores/store/attach", objStoreLogin(t, router), map[string]string{"deployment": "../outside"}) + if res.Code != http.StatusBadRequest { + t.Fatalf("attach status = %d, body = %s", res.Code, res.Body.String()) + } +} + func objStoreLogin(t *testing.T, router *gin.Engine) string { body, _ := json.Marshal(map[string]string{"username": "admin", "password": "testadminpass"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(body)) diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 508edd4..45c08f7 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "FlatRun Agent API", "description": "Generated from the agent's routes and the types its handlers bind and return.", - "version": "0.4.0-beta.5" + "version": "0.4.0-beta.6" }, "paths": { "/api/agent/update": { @@ -33,7 +33,7 @@ "tags": [ "agent" ], - "x-permission": "settings:read" + "x-permission": "updates:read" }, "post": { "operationId": "post-agent-update", @@ -55,7 +55,7 @@ "tags": [ "agent" ], - "x-permission": "settings:write" + "x-permission": "updates:write" } }, "/api/ai/agents": { @@ -461,6 +461,20 @@ } } }, + "/api/alerts/target-options": { + "get": { + "operationId": "get-alerts-target-options", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "alerts" + ], + "x-permission": "alerts:read" + } + }, "/api/apikeys": { "get": { "operationId": "get-apikeys", @@ -1736,6 +1750,162 @@ "x-permission": "cluster:write" } }, + "/api/cluster/peers/{name}/proxy/{path}": { + "delete": { + "operationId": "delete-cluster-peers-by-name-proxy-by-path", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + }, + "x-rest-of-path": true + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "cluster" + ], + "x-permission": "cluster:write" + }, + "get": { + "operationId": "get-cluster-peers-by-name-proxy-by-path", + "tags": [ + "cluster" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "x-rest-of-path": true + } + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "patch": { + "operationId": "patch-cluster-peers-by-name-proxy-by-path", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + }, + "x-rest-of-path": true + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "cluster" + ], + "x-permission": "cluster:write" + }, + "post": { + "operationId": "post-cluster-peers-by-name-proxy-by-path", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + }, + "x-rest-of-path": true + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "cluster" + ], + "x-permission": "cluster:write" + }, + "put": { + "operationId": "put-cluster-peers-by-name-proxy-by-path", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + }, + "x-rest-of-path": true + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "cluster" + ], + "x-permission": "cluster:write" + } + }, "/api/cluster/providers": { "get": { "operationId": "get-cluster-providers", @@ -6869,7 +7039,7 @@ "tags": [ "notifications" ], - "x-permission": "settings:read" + "x-permission": "notifications:read" } }, "/api/notifications/rules": { @@ -6883,7 +7053,7 @@ "tags": [ "notifications" ], - "x-permission": "settings:read" + "x-permission": "notifications:read" }, "put": { "operationId": "put-notifications-rules", @@ -6916,7 +7086,7 @@ "tags": [ "notifications" ], - "x-permission": "settings:write" + "x-permission": "notifications:write" } }, "/api/notifications/targets": { @@ -6937,7 +7107,7 @@ "tags": [ "notifications" ], - "x-permission": "settings:read" + "x-permission": "notifications:read" }, "put": { "operationId": "put-notifications-targets", @@ -6966,7 +7136,7 @@ "tags": [ "notifications" ], - "x-permission": "settings:write" + "x-permission": "notifications:write" } }, "/api/notifications/test": { @@ -7006,7 +7176,21 @@ "tags": [ "notifications" ], - "x-permission": "settings:write" + "x-permission": "notifications:write" + } + }, + "/api/object-stores": { + "get": { + "operationId": "get-object-stores", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "storage:read" } }, "/api/object-stores/provision-managed": { @@ -7030,7 +7214,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/object-stores/{name}/attach": { @@ -7107,7 +7291,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:read" + "x-permission": "storage:read" }, "post": { "operationId": "post-object-stores-by-name-buckets", @@ -7153,7 +7337,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/object-stores/{name}/buckets/{bucket}": { @@ -7185,7 +7369,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:delete" + "x-permission": "storage:delete" } }, "/api/object-stores/{name}/objects": { @@ -7223,7 +7407,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:write" + "x-permission": "storage:delete" }, "get": { "operationId": "get-object-stores-by-name-objects", @@ -7266,7 +7450,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:read" + "x-permission": "storage:read" }, "post": { "operationId": "post-object-stores-by-name-objects", @@ -7288,7 +7472,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/object-stores/{name}/objects/download": { @@ -7326,7 +7510,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:read" + "x-permission": "storage:read" } }, "/api/object-stores/{name}/replicate": { @@ -7374,7 +7558,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/openapi.json": { @@ -9288,7 +9472,7 @@ "tags": [ "storage-credentials" ], - "x-permission": "backups:read" + "x-permission": "storage:read" }, "post": { "operationId": "post-storage-credentials", @@ -9337,7 +9521,7 @@ "tags": [ "storage-credentials" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/storage-credentials/{id}": { @@ -9361,7 +9545,7 @@ "tags": [ "storage-credentials" ], - "x-permission": "backups:delete" + "x-permission": "storage:delete" }, "put": { "operationId": "put-storage-credentials-by-id", @@ -9411,7 +9595,7 @@ "tags": [ "storage-credentials" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/subdomain/generate": { @@ -13607,32 +13791,52 @@ "models.HealthCheckConfig": { "type": "object", "properties": { + "command": { + "type": "string" + }, "interval": { "type": "string" }, "path": { "type": "string" }, + "port": { + "type": "integer" + }, "response_contains": { "type": "string" }, + "service": { + "type": "string" + }, "success_statuses": { "type": "array", "items": { "type": "integer" } + }, + "type": { + "type": "string" } }, "x-property-order": [ + "type", + "service", + "port", "path", "interval", "success_statuses", - "response_contains" + "response_contains", + "command" ], "x-columns": [ + "type", + "service", + "port", "path", "interval", - "response_contains" + "response_contains", + "command" ] }, "models.LogSource": { diff --git a/internal/api/plugin_access_test.go b/internal/api/plugin_access_test.go new file mode 100644 index 0000000..a11ac1c --- /dev/null +++ b/internal/api/plugin_access_test.go @@ -0,0 +1,59 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/flatrun/agent/internal/auth" +) + +func TestPluginPermissionSeparatesReadsAndWrites(t *testing.T) { + tests := []struct { + name string + path string + method string + want auth.Permission + }{ + {name: "observability", path: "/alerts/rules", method: http.MethodGet, want: auth.PermAlertsRead}, + {name: "observability", path: "/alerts/rules", method: http.MethodPut, want: auth.PermAlertsWrite}, + {name: "example", path: "/config", method: http.MethodGet, want: auth.PermTemplatesRead}, + {name: "example", path: "/config", method: http.MethodPut, want: auth.PermTemplatesWrite}, + } + for _, test := range tests { + if got := pluginPermission(test.name, test.path, test.method); got != test.want { + t.Errorf("%s %s %s: got %s, want %s", test.method, test.name, test.path, got, test.want) + } + } +} + +func TestActorResourceAccessCarriesDeploymentLevels(t *testing.T) { + access := actorResourceAccess(&auth.ActorContext{ + Role: auth.RoleOperator, + Deployments: map[string]string{ + "shop": auth.AccessLevelWrite, + "billing": auth.AccessLevelRead, + }, + }) + if !access.Allows("deployment", "shop", "write") { + t.Fatal("shop write access missing") + } + if !access.Allows("deployment", "billing", "read") || access.Allows("deployment", "billing", "write") { + t.Fatal("billing read access widened") + } +} + +func TestActorResourceAccessHonoursAPIKeyScope(t *testing.T) { + access := actorResourceAccess(&auth.ActorContext{ + Role: auth.RoleAdmin, + User: &auth.User{Role: auth.RoleAdmin}, + APIKey: &auth.APIKey{Deployments: auth.DeploymentAccess{ + "shop": auth.AccessLevelRead, + }}, + }) + if access.Global || !access.Allows("deployment", "shop", "read") { + t.Fatal("scoped admin key did not retain its deployment read grant") + } + if access.Allows("deployment", "shop", "write") || access.Allows("deployment", "billing", "read") { + t.Fatal("scoped admin key was widened") + } +} diff --git a/internal/api/powerdns_handlers.go b/internal/api/powerdns_handlers.go index 07e0d4a..70162aa 100644 --- a/internal/api/powerdns_handlers.go +++ b/internal/api/powerdns_handlers.go @@ -16,7 +16,7 @@ func NewPowerDNSHandlers(manager *dns.PowerDNSManager) *PowerDNSHandlers { } func (h *PowerDNSHandlers) RegisterRoutes(rg *gin.RouterGroup) { - pdns := rg.Group("/dns/powerdns") + pdns := rg.Group("/powerdns") { pdns.GET("/status", h.GetStatus) pdns.POST("/enable", h.EnableService) diff --git a/internal/api/require_plan_test.go b/internal/api/require_plan_test.go index b893554..a5ab365 100644 --- a/internal/api/require_plan_test.go +++ b/internal/api/require_plan_test.go @@ -81,6 +81,32 @@ func TestRequirePlanToggleViaMetadata(t *testing.T) { } } +func TestUpdateDeploymentMetadataAcceptsTCPHealthCheck(t *testing.T) { + s, tmpDir, ts := setupPlanTestServer(t) + createTestDeployment(t, tmpDir, "database", &models.ServiceMetadata{Name: "database", Type: "postgres"}) + + resp, parsed := doJSON(t, http.MethodPut, ts.URL+"/api/deployments/database/metadata", + map[string]interface{}{ + "healthcheck": map[string]interface{}{ + "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("database") + if err != nil { + t.Fatalf("get deployment: %v", err) + } + if deployment.Metadata.HealthCheck.Type != "tcp" || deployment.Metadata.HealthCheck.Service != "postgres" || deployment.Metadata.HealthCheck.Port != 5432 { + t.Fatalf("health check not persisted: %+v", deployment.Metadata.HealthCheck) + } +} + 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 e226165..b8b58a7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -56,6 +56,7 @@ import ( "github.com/flatrun/agent/internal/traffic" "github.com/flatrun/agent/pkg/config" "github.com/flatrun/agent/pkg/models" + "github.com/flatrun/agent/pkg/pluginapi" "github.com/flatrun/agent/pkg/plugins" dnsPlugins "github.com/flatrun/agent/pkg/plugins/dns" "github.com/flatrun/agent/pkg/plugins/firewall" @@ -549,14 +550,15 @@ func (s *Server) setupRoutes() { protected.PUT("/settings", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.updateSettings) protected.PUT("/settings/security", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.updateSecuritySettings) - protected.GET("/agent/update", s.authMiddleware.RequirePermission(auth.PermSettingsRead), s.getAgentUpdate) - protected.POST("/agent/update", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.triggerAgentUpdate) - protected.GET("/notifications/targets", s.authMiddleware.RequirePermission(auth.PermSettingsRead), s.getNotificationTargets) - protected.GET("/notifications/incidents", s.authMiddleware.RequirePermission(auth.PermSettingsRead), s.listNotificationIncidents) - protected.GET("/notifications/rules", s.authMiddleware.RequirePermission(auth.PermSettingsRead), s.listNotificationRules) - protected.PUT("/notifications/rules", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.updateNotificationRules) - protected.PUT("/notifications/targets", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.updateNotificationTargets) - protected.POST("/notifications/test", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.testNotification) + protected.GET("/agent/update", s.authMiddleware.RequirePermission(auth.PermUpdatesRead), s.getAgentUpdate) + protected.POST("/agent/update", s.authMiddleware.RequirePermission(auth.PermUpdatesWrite), s.triggerAgentUpdate) + protected.GET("/notifications/targets", s.authMiddleware.RequirePermission(auth.PermNotificationsRead), s.getNotificationTargets) + protected.GET("/alerts/target-options", s.authMiddleware.RequirePermission(auth.PermAlertsRead), s.getAlertTargetOptions) + protected.GET("/notifications/incidents", s.authMiddleware.RequirePermission(auth.PermNotificationsRead), s.listNotificationIncidents) + protected.GET("/notifications/rules", s.authMiddleware.RequirePermission(auth.PermNotificationsRead), s.listNotificationRules) + protected.PUT("/notifications/rules", s.authMiddleware.RequirePermission(auth.PermNotificationsWrite), s.updateNotificationRules) + protected.PUT("/notifications/targets", s.authMiddleware.RequirePermission(auth.PermNotificationsWrite), s.updateNotificationTargets) + protected.POST("/notifications/test", s.authMiddleware.RequirePermission(auth.PermNotificationsWrite), s.testNotification) protected.GET("/config", s.authMiddleware.RequirePermission(auth.PermConfigRead), s.listConfig) protected.GET("/config/*key", s.authMiddleware.RequirePermission(auth.PermConfigRead), s.getConfigKey) protected.PUT("/config/*key", s.authMiddleware.RequirePermission(auth.PermConfigWrite), s.updateConfigKey) @@ -616,7 +618,7 @@ func (s *Server) setupRoutes() { protected.GET("/plugins/:name", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.getPlugin) protected.POST("/plugins/:name/deployments", s.authMiddleware.RequirePermission(auth.PermTemplatesWrite), s.createPluginDeployment) - protected.Any("/plugin/:name/*proxyPath", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.proxyToPlugin) + protected.Any("/plugin/:name/*proxyPath", s.proxyToPlugin) protected.Any("/marketplace/*path", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.proxyMarketplace) protected.GET("/templates", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.listTemplates) protected.GET("/templates/categories", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.getTemplateCategories) @@ -757,10 +759,10 @@ func (s *Server) setupRoutes() { protected.DELETE("/source-credentials/:id", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.deleteSourceCredential) // Storage credential endpoints (S3 and other object-storage secrets) - protected.GET("/storage-credentials", s.authMiddleware.RequirePermission(auth.PermBackupsRead), s.listStorageCredentials) - protected.POST("/storage-credentials", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.createStorageCredential) - protected.PUT("/storage-credentials/:id", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.updateStorageCredential) - protected.DELETE("/storage-credentials/:id", s.authMiddleware.RequirePermission(auth.PermBackupsDelete), s.deleteStorageCredential) + protected.GET("/storage-credentials", s.authMiddleware.RequirePermission(auth.PermStorageRead), s.listStorageCredentials) + protected.POST("/storage-credentials", s.authMiddleware.RequirePermission(auth.PermStorageWrite), s.createStorageCredential) + protected.PUT("/storage-credentials/:id", s.authMiddleware.RequirePermission(auth.PermStorageWrite), s.updateStorageCredential) + protected.DELETE("/storage-credentials/:id", s.authMiddleware.RequirePermission(auth.PermStorageDelete), s.deleteStorageCredential) // Security endpoints protected.GET("/security/stats", s.authMiddleware.RequirePermission(auth.PermSecurityRead), s.getSecurityStats) @@ -819,16 +821,17 @@ func (s *Server) setupRoutes() { protected.POST("/backup-destinations/test", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.testBackupDestination) // Object stores - protected.POST("/object-stores/provision-managed", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.provisionManagedObjectStore) - protected.GET("/object-stores/:name/buckets", s.authMiddleware.RequirePermission(auth.PermBackupsRead), s.listStoreBuckets) - protected.POST("/object-stores/:name/buckets", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.createStoreBucket) - protected.DELETE("/object-stores/:name/buckets/:bucket", s.authMiddleware.RequirePermission(auth.PermBackupsDelete), s.deleteStoreBucket) - protected.GET("/object-stores/:name/objects", s.authMiddleware.RequirePermission(auth.PermBackupsRead), s.listStoreObjects) - protected.POST("/object-stores/:name/objects", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.uploadStoreObject) - protected.GET("/object-stores/:name/objects/download", s.authMiddleware.RequirePermission(auth.PermBackupsRead), s.downloadStoreObject) - protected.DELETE("/object-stores/:name/objects", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.deleteStoreObject) - protected.POST("/object-stores/:name/attach", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.attachStoreToDeployment) - protected.POST("/object-stores/:name/replicate", s.authMiddleware.RequirePermission(auth.PermBackupsWrite), s.replicateStore) + protected.GET("/object-stores", s.authMiddleware.RequirePermission(auth.PermStorageRead), s.listBackupDestinations) + protected.POST("/object-stores/provision-managed", s.authMiddleware.RequirePermission(auth.PermStorageWrite), s.provisionManagedObjectStore) + protected.GET("/object-stores/:name/buckets", s.authMiddleware.RequirePermission(auth.PermStorageRead), s.listStoreBuckets) + protected.POST("/object-stores/:name/buckets", s.authMiddleware.RequirePermission(auth.PermStorageWrite), s.createStoreBucket) + protected.DELETE("/object-stores/:name/buckets/:bucket", s.authMiddleware.RequirePermission(auth.PermStorageDelete), s.deleteStoreBucket) + protected.GET("/object-stores/:name/objects", s.authMiddleware.RequirePermission(auth.PermStorageRead), s.listStoreObjects) + protected.POST("/object-stores/:name/objects", s.authMiddleware.RequirePermission(auth.PermStorageWrite), s.uploadStoreObject) + protected.GET("/object-stores/:name/objects/download", s.authMiddleware.RequirePermission(auth.PermStorageRead), s.downloadStoreObject) + protected.DELETE("/object-stores/:name/objects", s.authMiddleware.RequirePermission(auth.PermStorageDelete), s.deleteStoreObject) + protected.POST("/object-stores/:name/attach", s.authMiddleware.RequirePermission(auth.PermStorageWrite, auth.PermDeploymentsWrite), s.attachStoreToDeployment) + protected.POST("/object-stores/:name/replicate", s.authMiddleware.RequirePermission(auth.PermStorageWrite), s.replicateStore) // Scheduler endpoints protected.GET("/scheduler/tasks", s.authMiddleware.RequirePermission(auth.PermSchedulerRead), s.listScheduledTasks) @@ -890,6 +893,7 @@ func (s *Server) setupRoutes() { // DNS plugin routes dnsGroup := protected.Group("/dns") dnsGroup.Use(s.authMiddleware.RequirePermission(auth.PermDNSRead)) + dnsGroup.Use(s.requireDNSWriteForMutations()) { dnsGroup.GET("/providers", s.listDNSProviders) @@ -900,11 +904,14 @@ func (s *Server) setupRoutes() { } // PowerDNS routes - NewPowerDNSHandlers(s.powerDNSManager).RegisterRoutes(protected) + NewPowerDNSHandlers(s.powerDNSManager).RegisterRoutes(dnsGroup) } // Firewall built-in app routes (config + plan; enforcement not wired yet) - _ = s.firewall.RegisterRoutes(protected) + firewallGroup := protected.Group("") + firewallGroup.Use(s.authMiddleware.RequirePermission(auth.PermSecurityRead)) + firewallGroup.Use(s.requireWriteForMethods(auth.PermSecurityWrite, http.MethodPut)) + _ = s.firewall.RegisterRoutes(firewallGroup) // Cluster endpoints protected.POST("/cluster/capacity/claim", s.authMiddleware.RequirePermission(auth.PermClusterCapacityClaim), s.clusterCapacityClaim) @@ -919,7 +926,11 @@ func (s *Server) setupRoutes() { clusterGroup.POST("/invite", s.authMiddleware.RequirePermission(auth.PermClusterWrite), s.clusterInvite) clusterGroup.POST("/accept", s.authMiddleware.RequirePermission(auth.PermClusterWrite), s.clusterAccept) clusterGroup.DELETE("/peers/:name", s.authMiddleware.RequirePermission(auth.PermClusterWrite), s.clusterRemovePeer) - clusterGroup.Any("/peers/:name/proxy/*path", s.authMiddleware.RequirePermission(auth.PermClusterWrite), s.clusterProxy) + 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.GET("/deployments", s.clusterAggregateDeployments) clusterGroup.GET("/stats", s.clusterAggregateStats) clusterGroup.GET("/capacity", s.clusterAggregateCapacity) @@ -1986,9 +1997,22 @@ func (s *Server) updateDeploymentMetadata(c *gin.Context) { } func validateHealthCheckConfig(config models.HealthCheckConfig) error { - if config.Path != "" && !validHealthPath(config.Path) { + checkType := healthCheckType(config) + if checkType != "http" && checkType != "tcp" && checkType != "exec" { + return fmt.Errorf("health check type must be http, tcp, or exec") + } + if len(config.Service) > 128 { + return fmt.Errorf("health check service cannot exceed 128 characters") + } + if config.Port < 0 || config.Port > 65535 { + return fmt.Errorf("health check port must be from 1 through 65535") + } + if checkType == "http" && config.Path != "" && !validHealthPath(config.Path) { return fmt.Errorf("health check path must start with / and contain a valid request path") } + if checkType != "http" && (config.Path != "" || len(config.SuccessStatuses) > 0 || config.ResponseContains != "") { + return fmt.Errorf("only HTTP health checks accept a path, status codes, or response text") + } if len(config.SuccessStatuses) > 20 { return fmt.Errorf("health check accepts at most 20 status codes") } @@ -2000,6 +2024,15 @@ func validateHealthCheckConfig(config models.HealthCheckConfig) error { if len(config.ResponseContains) > 512 { return fmt.Errorf("health check response text cannot exceed 512 characters") } + if checkType == "exec" && strings.TrimSpace(config.Command) == "" { + return fmt.Errorf("exec health checks require a command") + } + if checkType != "exec" && config.Command != "" { + return fmt.Errorf("only exec health checks accept a command") + } + if len(config.Command) > 2048 || strings.ContainsRune(config.Command, 0) { + return fmt.Errorf("health check command is invalid or exceeds 2048 characters") + } return nil } @@ -3451,6 +3484,17 @@ func (s *Server) getNotificationTargets(c *gin.Context) { c.JSON(http.StatusOK, s.notify.Load()) } +func (s *Server) getAlertTargetOptions(c *gin.Context) { + targets := s.notify.Load().Targets + options := make([]gin.H, 0, len(targets)) + for _, target := range targets { + if target.Enabled { + options = append(options, gin.H{"id": target.ID, "name": target.Name}) + } + } + c.JSON(http.StatusOK, gin.H{"targets": options}) +} + func (s *Server) updateNotificationTargets(c *gin.Context) { var cfg notify.Config if err := c.ShouldBindJSON(&cfg); err != nil { @@ -3604,18 +3648,84 @@ func (s *Server) proxyMarketplace(c *gin.Context) { func (s *Server) proxyToPlugin(c *gin.Context) { name := c.Param("name") + pluginPath := c.Param("proxyPath") + required := pluginPermission(name, pluginPath, c.Request.Method) + actor := auth.GetActorFromContext(c) + if actor == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"}) + return + } + if !actor.HasPermission(required) { + c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("Permission denied: %s required", required)}) + return + } + access := actorResourceAccess(actor) + encodedAccess, err := pluginapi.EncodeResourceAccess(access) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare plugin access"}) + return + } proxy, ok := s.pluginHost.Proxy(name) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "plugin not running"}) return } - c.Request.URL.Path = c.Param("proxyPath") + c.Request.Header.Del(pluginapi.ResourceAccessHeader) + c.Request.Header.Set(pluginapi.ResourceAccessHeader, encodedAccess) + c.Request.URL.Path = pluginPath if c.Request.URL.Path == "" { c.Request.URL.Path = "/" } proxy.ServeHTTP(c.Writer, c.Request) } +func pluginPermission(name, pluginPath, method string) auth.Permission { + write := method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions + if name == "observability" && strings.HasPrefix(pluginPath, "/alerts/") { + if write { + return auth.PermAlertsWrite + } + return auth.PermAlertsRead + } + if write { + return auth.PermTemplatesWrite + } + return auth.PermTemplatesRead +} + +func actorResourceAccess(actor *auth.ActorContext) pluginapi.ResourceAccess { + userIsAdmin := actor.User == nil && actor.Role == auth.RoleAdmin + if actor.User != nil { + userIsAdmin = actor.User.Role == auth.RoleAdmin + } + access := pluginapi.ResourceAccess{Global: userIsAdmin && (actor.APIKey == nil || len(actor.APIKey.Deployments) == 0)} + if access.Global { + return access + } + candidates := make(map[string]struct{}, len(actor.Deployments)) + for id := range actor.Deployments { + candidates[id] = struct{}{} + } + if actor.APIKey != nil { + for id := range actor.APIKey.Deployments { + candidates[id] = struct{}{} + } + } + for id := range candidates { + level := "" + for _, candidate := range []string{auth.AccessLevelAdmin, auth.AccessLevelWrite, auth.AccessLevelRead} { + if actor.CanAccessDeployment(id, candidate) { + level = candidate + break + } + } + if level != "" { + access.Grants = append(access.Grants, pluginapi.ResourceGrant{Resource: "deployment", ID: id, Level: level}) + } + } + return access +} + func (s *Server) getPlugin(c *gin.Context) { name := c.Param("name") @@ -7486,3 +7596,33 @@ func (s *Server) deploymentAuthOptions(name string) (credentials.AuthConfig, []d } return cfg, []docker.RunOption{docker.WithDockerConfig(cfg.Dir())} } + +func (s *Server) requireDNSWriteForMutations() gin.HandlerFunc { + write := s.authMiddleware.RequirePermission(auth.PermDNSWrite) + return func(c *gin.Context) { + method := c.Request.Method + requestPath := c.Request.URL.Path + mutation := method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete + mutation = mutation || method == http.MethodPost && (strings.Contains(requestPath, "/dns/powerdns/") || strings.HasSuffix(requestPath, "/records/create")) + if mutation { + write(c) + return + } + c.Next() + } +} + +func (s *Server) requireWriteForMethods(permission auth.Permission, methods ...string) gin.HandlerFunc { + write := s.authMiddleware.RequirePermission(permission) + allowed := make(map[string]struct{}, len(methods)) + for _, method := range methods { + allowed[method] = struct{}{} + } + return func(c *gin.Context) { + if _, ok := allowed[c.Request.Method]; ok { + write(c) + return + } + c.Next() + } +} diff --git a/internal/auth/models.go b/internal/auth/models.go index ee83119..cd5a2e9 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -175,6 +175,9 @@ func actorUserDeploymentLevel(a *ActorContext, name string) string { if a.User != nil && a.User.Role == RoleAdmin { return AccessLevelAdmin } + if a.User != nil && a.User.Role == RoleService && a.User.Username == "__flatrun_cluster" && a.APIKey != nil { + return AccessLevelAdmin + } if a.User == nil && a.Role == RoleAdmin { return AccessLevelAdmin } diff --git a/internal/auth/models_test.go b/internal/auth/models_test.go index e4d80f1..8ed7e4d 100644 --- a/internal/auth/models_test.go +++ b/internal/auth/models_test.go @@ -202,6 +202,39 @@ func TestActorContextCanAccessDeployment(t *testing.T) { requiredLevel: "read", want: false, }, + { + name: "service user access is defined by its key", + actor: &ActorContext{ + User: &User{Username: "__flatrun_cluster", Role: RoleService}, + Role: RoleService, + APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelRead}}, + }, + deploymentName: "my-app", + requiredLevel: "read", + want: true, + }, + { + name: "service user remains limited by its key", + actor: &ActorContext{ + User: &User{Username: "__flatrun_cluster", Role: RoleService}, + Role: RoleService, + APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelRead}}, + }, + deploymentName: "other-app", + requiredLevel: "read", + want: false, + }, + { + name: "non fleet service user keeps intersected access", + actor: &ActorContext{ + User: &User{Username: "integration", Role: RoleService}, + Role: RoleService, + APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelRead}}, + }, + deploymentName: "my-app", + requiredLevel: "read", + want: false, + }, { name: "operator user with both grants takes the lower level", actor: &ActorContext{ diff --git a/internal/auth/permissions.go b/internal/auth/permissions.go index 1a3c31b..97ca599 100644 --- a/internal/auth/permissions.go +++ b/internal/auth/permissions.go @@ -21,6 +21,14 @@ const ( PermBackupsRead Permission = "backups:read" PermBackupsWrite Permission = "backups:write" PermBackupsDelete Permission = "backups:delete" + PermStorageRead Permission = "storage:read" + PermStorageWrite Permission = "storage:write" + PermStorageDelete Permission = "storage:delete" + + PermNotificationsRead Permission = "notifications:read" + PermNotificationsWrite Permission = "notifications:write" + PermAlertsRead Permission = "alerts:read" + PermAlertsWrite Permission = "alerts:write" PermUsersRead Permission = "users:read" PermUsersWrite Permission = "users:write" @@ -32,6 +40,8 @@ const ( PermSettingsRead Permission = "settings:read" PermSettingsWrite Permission = "settings:write" + PermUpdatesRead Permission = "updates:read" + PermUpdatesWrite Permission = "updates:write" PermConfigRead Permission = "config:read" PermConfigWrite Permission = "config:write" @@ -89,9 +99,13 @@ var adminPermissions = []Permission{ PermNetworksRead, PermNetworksWrite, PermNetworksDelete, PermSecurityRead, PermSecurityWrite, PermBackupsRead, PermBackupsWrite, PermBackupsDelete, + PermStorageRead, PermStorageWrite, PermStorageDelete, + PermNotificationsRead, PermNotificationsWrite, + PermAlertsRead, PermAlertsWrite, PermUsersRead, PermUsersWrite, PermUsersDelete, PermAPIKeysRead, PermAPIKeysWrite, PermAPIKeysDelete, PermSettingsRead, PermSettingsWrite, + PermUpdatesRead, PermUpdatesWrite, PermConfigRead, PermConfigWrite, PermAuditRead, PermContainersRead, PermContainersWrite, PermContainersDelete, @@ -114,8 +128,7 @@ var operatorPermissions = []Permission{ PermNetworksRead, PermSecurityRead, PermBackupsRead, PermBackupsWrite, - PermAPIKeysRead, PermAPIKeysWrite, PermAPIKeysDelete, - PermSettingsRead, + PermStorageRead, PermStorageWrite, PermContainersRead, PermContainersWrite, PermImagesRead, PermImagesWrite, PermVolumesRead, PermVolumesWrite, @@ -127,6 +140,7 @@ var operatorPermissions = []Permission{ PermRegistriesRead, PermRegistriesWrite, PermTemplatesRead, PermTrafficRead, + PermAlertsRead, PermAlertsWrite, } var viewerPermissions = []Permission{ @@ -135,8 +149,7 @@ var viewerPermissions = []Permission{ PermNetworksRead, PermSecurityRead, PermBackupsRead, - PermAPIKeysRead, - PermSettingsRead, + PermStorageRead, PermContainersRead, PermImagesRead, PermVolumesRead, @@ -148,6 +161,7 @@ var viewerPermissions = []Permission{ PermRegistriesRead, PermTemplatesRead, PermTrafficRead, + PermAlertsRead, } func GetRolePermissions(role Role) []Permission { diff --git a/internal/auth/permissions_test.go b/internal/auth/permissions_test.go index 0c16ffb..9172ed9 100644 --- a/internal/auth/permissions_test.go +++ b/internal/auth/permissions_test.go @@ -86,6 +86,11 @@ func TestViewerCannotWrite(t *testing.T) { PermRegistriesDelete, PermTemplatesWrite, PermTrafficWrite, + PermStorageWrite, + PermStorageDelete, + PermNotificationsWrite, + PermUpdatesRead, + PermUpdatesWrite, } for _, perm := range writePerms { @@ -111,6 +116,7 @@ func TestViewerCanRead(t *testing.T) { PermRegistriesRead, PermTemplatesRead, PermTrafficRead, + PermStorageRead, } for _, perm := range readPerms { @@ -133,6 +139,28 @@ func TestOperatorPermissions(t *testing.T) { t.Error("Operator should not be able to delete deployments") } + if HasPermission(RoleOperator, nil, PermUpdatesRead) || HasPermission(RoleOperator, nil, PermUpdatesWrite) { + t.Error("Operator should not have update permissions by default") + } + + adminOnlyPerms := []Permission{ + PermSettingsRead, + PermSettingsWrite, + PermNotificationsRead, + PermNotificationsWrite, + PermAPIKeysRead, + PermAPIKeysWrite, + PermAPIKeysDelete, + } + for _, perm := range adminOnlyPerms { + if HasPermission(RoleOperator, nil, perm) { + t.Errorf("Operator should not have permission %s without an explicit grant", perm) + } + if HasPermission(RoleViewer, nil, perm) { + t.Errorf("Viewer should not have permission %s without an explicit grant", perm) + } + } + // Operator can write new resource groups operatorWritePerms := []Permission{ PermContainersWrite, @@ -144,6 +172,7 @@ func TestOperatorPermissions(t *testing.T) { PermSystemWrite, PermDNSWrite, PermRegistriesWrite, + PermStorageWrite, } for _, perm := range operatorWritePerms { if !HasPermission(RoleOperator, nil, perm) { @@ -159,6 +188,7 @@ func TestOperatorPermissions(t *testing.T) { PermDatabasesDelete, PermSchedulerDelete, PermRegistriesDelete, + PermStorageDelete, } for _, perm := range operatorNoDeletePerms { if HasPermission(RoleOperator, nil, perm) { diff --git a/internal/autoscale/compatibility.go b/internal/autoscale/compatibility.go index 4357d8e..3f13ffa 100644 --- a/internal/autoscale/compatibility.go +++ b/internal/autoscale/compatibility.go @@ -157,7 +157,9 @@ func BuildWorkload(deployment *models.Deployment, composeContent string, replica break } } - workload.Health.Path = deployment.Metadata.HealthCheck.Path + if checkType := strings.ToLower(strings.TrimSpace(deployment.Metadata.HealthCheck.Type)); checkType == "" || checkType == "http" { + workload.Health.Path = deployment.Metadata.HealthCheck.Path + } return workload, nil } diff --git a/internal/docker/api.go b/internal/docker/api.go index a26f571..a29da6b 100644 --- a/internal/docker/api.go +++ b/internal/docker/api.go @@ -104,6 +104,31 @@ func (a *APIClient) ContainerPrimaryIP(ctx context.Context, project, network str return "", fmt.Errorf("container for project %q has no network address yet", project) } +func (a *APIClient) ContainerServiceIP(ctx context.Context, project, service, network string) (string, error) { + containerID, err := a.FindContainer(ctx, project, service) + if err != nil { + return "", err + } + info, err := a.cli.ContainerInspect(ctx, containerID) + if err != nil { + return "", fmt.Errorf("failed to inspect service container: %w", err) + } + if info.NetworkSettings == nil { + return "", fmt.Errorf("service %q in project %q has no network settings", service, project) + } + if network != "" { + if attached, ok := info.NetworkSettings.Networks[network]; ok && attached.IPAddress != "" { + return attached.IPAddress, nil + } + } + for _, attached := range info.NetworkSettings.Networks { + if attached.IPAddress != "" { + return attached.IPAddress, nil + } + } + return "", fmt.Errorf("service %q in project %q has no network address", service, project) +} + func (a *APIClient) ExecInContainer(ctx context.Context, containerID string, command string) (string, error) { execConfig := container.ExecOptions{ Cmd: []string{"sh", "-c", command}, diff --git a/internal/docker/manager.go b/internal/docker/manager.go index 1077f1f..bbb6148 100644 --- a/internal/docker/manager.go +++ b/internal/docker/manager.go @@ -76,6 +76,21 @@ func (m *Manager) ContainerPrimaryIP(project, network string) (string, error) { return m.apiClient.ContainerPrimaryIP(ctx, project, network) } +func (m *Manager) ContainerServiceIP(deploymentName, service, network string) (string, error) { + if m.apiClient == nil { + return "", fmt.Errorf("docker api client unavailable") + } + m.mu.RLock() + deployment, err := m.discovery.GetDeployment(deploymentName) + m.mu.RUnlock() + if err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(context.Background(), statusReadTimeout) + defer cancel() + return m.apiClient.ContainerServiceIP(ctx, m.executor.getProjectName(deployment.Path), service, network) +} + // projectFor resolves a deployment's compose project name without shelling out. // It mirrors ComposeExecutor.getProjectName, except that the fallback probe for // an existing project reads the already-fetched index instead of running diff --git a/internal/events/events.go b/internal/events/events.go index c908224..a3d3306 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -31,6 +31,7 @@ type Event struct { CorrelationKey string `json:"correlation_key,omitempty"` OccurredAt time.Time `json:"occurred_at"` Attributes map[string]any `json:"attributes,omitempty"` + TargetIDs []string `json:"target_ids,omitempty"` Resolved bool `json:"resolved,omitempty"` } @@ -69,6 +70,9 @@ func (c *Correlator) Ingest(event Event) IngestResult { key := CorrelationKey(event) incident, exists := c.incidents[key] + if exists && incident.Status == IncidentResolved && event.Resolved { + return IngestResult{Incident: incident, Notification: NotificationNone} + } action := NotificationNone if !exists || incident.Status == IncidentResolved { incident = Incident{ diff --git a/internal/events/events_test.go b/internal/events/events_test.go index 4edecf0..011bd0f 100644 --- a/internal/events/events_test.go +++ b/internal/events/events_test.go @@ -42,4 +42,9 @@ func TestCorrelatorSendsOneRecoveryNotification(t *testing.T) { if resolved.Notification != NotificationResolved || resolved.Incident.Status != IncidentResolved { t.Fatalf("resolved = %#v", resolved) } + + duplicate := correlator.Ingest(Event{Source: "fleet", Type: "node.available", Severity: SeverityInfo, Title: "prod2 recovered", Scope: Scope{Node: "prod2"}, OccurredAt: started.Add(11 * time.Minute), Resolved: true}) + if duplicate.Notification != NotificationNone || duplicate.Incident.ID != resolved.Incident.ID { + t.Fatalf("duplicate recovery = %#v", duplicate) + } } diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go index 6fad5b8..d84a5b7 100644 --- a/internal/nginx/manager.go +++ b/internal/nginx/manager.go @@ -557,6 +557,9 @@ func (m *Manager) generateConfig(deployment *models.Deployment) (string, error) ssl := deployment.Metadata.SSL healthPath := deployment.Metadata.HealthCheck.Path + if checkType := strings.ToLower(strings.TrimSpace(deployment.Metadata.HealthCheck.Type)); checkType != "" && checkType != "http" { + healthPath = "" + } if healthPath == "/" { healthPath = "" } diff --git a/internal/notify/events_test.go b/internal/notify/events_test.go index f38667e..60c8eca 100644 --- a/internal/notify/events_test.go +++ b/internal/notify/events_test.go @@ -126,3 +126,30 @@ func TestPublishUsesMatchingRules(t *testing.T) { t.Fatalf("targets = %#v", targets) } } + +func TestPublishUsesExplicitEventTargets(t *testing.T) { + service := NewService(t.TempDir()) + defer service.Close() + if err := service.Save(Config{Targets: []Target{ + {ID: "selected", URL: "generic+https://selected.example.test", Enabled: true}, + {ID: "other", URL: "generic+https://other.example.test", Enabled: true}, + }}); err != nil { + t.Fatal(err) + } + var targets []string + service.send = func(target, _ string) error { + targets = append(targets, target) + return nil + } + + _, err := service.Publish(events.Event{ + Source: "observability", Type: "metric.alert", Severity: events.SeverityWarning, + Title: "High memory", TargetIDs: []string{"selected"}, OccurredAt: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + if len(targets) != 1 || !strings.Contains(targets[0], "selected.example.test") { + t.Fatalf("targets = %#v", targets) + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index f8af11d..6ae90f3 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -168,9 +168,17 @@ func (s *Service) Close() error { func (s *Service) deliverEvent(event events.Event, action events.NotificationAction, notification Notification) error { cfg := s.Load() selected := matchingRuleTargets(cfg.Rules, event, action) + explicitTargets := stringSet(event.TargetIDs) var firstErr error for _, target := range cfg.Targets { - if !target.Enabled || target.URL == "" || !targetMatches(target, event) || (len(cfg.Rules) > 0 && !selected[target.ID]) { + if !target.Enabled || target.URL == "" { + continue + } + if len(explicitTargets) > 0 { + if !explicitTargets[target.ID] { + continue + } + } else if !targetMatches(target, event) || (len(cfg.Rules) > 0 && !selected[target.ID]) { continue } if err := s.deliver(target.URL, notification); err != nil && firstErr == nil { @@ -180,6 +188,14 @@ func (s *Service) deliverEvent(event events.Event, action events.NotificationAct return firstErr } +func stringSet(values []string) map[string]bool { + result := make(map[string]bool, len(values)) + for _, value := range values { + result[value] = true + } + return result +} + func matchingRuleTargets(rules []Rule, event events.Event, action events.NotificationAction) map[string]bool { selected := make(map[string]bool) for _, rule := range rules { diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index f1ea04b..2f1064b 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -237,6 +237,9 @@ func TestEmailVariantsAndPanels(t *testing.T) { if !strings.Contains(body, "alt=\"FlatRun\"") || !strings.Contains(body, "data:image/png;base64,") { t.Errorf("%s template missing shared header", tc.kind) } + if !strings.Contains(body, "bgcolor=\"#111827\"") { + t.Errorf("%s template missing email-client-safe header background", tc.kind) + } if !strings.Contains(body, ">FlatRun") { t.Errorf("%s template missing shared footer", tc.kind) } diff --git a/internal/notify/templates/default/header.html b/internal/notify/templates/default/header.html index 7947768..83aa9dd 100644 --- a/internal/notify/templates/default/header.html +++ b/internal/notify/templates/default/header.html @@ -1,3 +1,3 @@ -{{define "header"}}