From cb49c42c6f7d1325024e2f5b9268dcb3effcb319 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 23:34:55 +0100 Subject: [PATCH 01/14] fix(notifications): Group metric alerts into incidents Metric alerts now share one incident per rule and resolve only after every affected series recovers. Explicit alert targets remain scoped to their intended recipients. --- internal/events/events.go | 4 +++ internal/events/events_test.go | 5 ++++ internal/notify/events_test.go | 27 ++++++++++++++++++++ internal/notify/notify.go | 18 ++++++++++++- internal/observ/alerts.go | 25 ++++++++++++++++-- internal/observ/plugin.go | 38 +++++++++++++++++++++++----- internal/observ/plugin_alert_test.go | 33 ++++++++++++++++++++++++ 7 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 internal/observ/plugin_alert_test.go 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/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/observ/alerts.go b/internal/observ/alerts.go index 6e8ef27..e20df1b 100644 --- a/internal/observ/alerts.go +++ b/internal/observ/alerts.go @@ -98,8 +98,9 @@ type AlertEvent struct { At time.Time `json:"at"` // Targets and Action are copied from the rule so the event is self-contained // for the notification and action sinks. - Targets []string `json:"targets,omitempty"` - Action string `json:"action,omitempty"` + Targets []string `json:"targets,omitempty"` + Action string `json:"action,omitempty"` + incidentResolved bool // Snapshot is the top consuming containers at the moment a rule fired, so a // notification and the dashboard can show what was using the resource. Snapshot []Consumer `json:"snapshot,omitempty"` @@ -389,11 +390,21 @@ func (e *AlertEngine) evaluate() { e.events = e.events[len(e.events)-maxAlertEvents:] } action := e.onAction + for i := range fired { + fired[i].incidentResolved = fired[i].State == AlertOK && !ruleHasActiveSeries(e.states, fired[i].RuleID) + } e.mu.Unlock() // Outside the lock: a notification goes over the network and evaluation should not // hold readers while it does. + resolvedRules := map[string]bool{} for _, ev := range fired { + if ev.State == AlertOK && (!ev.incidentResolved || resolvedRules[ev.RuleID]) { + continue + } + if ev.incidentResolved { + resolvedRules[ev.RuleID] = true + } if notify != nil { notify(ev) } @@ -405,6 +416,16 @@ func (e *AlertEngine) evaluate() { } } +func ruleHasActiveSeries(states map[string]seriesState, ruleID string) bool { + prefix := ruleID + "\x00" + for key := range states { + if strings.HasPrefix(key, prefix) { + return true + } + } + return false +} + // Run evaluates on each tick until stopped. func (e *AlertEngine) Run(stop <-chan struct{}, interval time.Duration) { if interval <= 0 { diff --git a/internal/observ/plugin.go b/internal/observ/plugin.go index 64f9fdd..ab4b265 100644 --- a/internal/observ/plugin.go +++ b/internal/observ/plugin.go @@ -11,6 +11,7 @@ import ( "path/filepath" "time" + "github.com/flatrun/agent/internal/events" "github.com/flatrun/agent/pkg/pluginapi" "github.com/flatrun/agent/pkg/pluginsdk" ) @@ -112,18 +113,14 @@ func RunPlugin() error { engine := NewAlertEngine(store) engine.SetRules(alertStore.Load()) engine.OnAlert(func(ev AlertEvent) { - kind := "negative" - if ev.State == AlertOK { - kind = "positive" - } - emitTypedNotificationTo(kind, ev.RuleName, ev.Message(), ev.Targets) + emitAlertEvent(ev, ev.Message()) }) // An opt-in rule action restarts the offending deployment when it fires, // scoped to FlatRun-managed deployments and rate-limited so it cannot flap. actioner := NewActionRunner(DockerComposeRestart, isManaged, cfg.restartCooldown(), dataDir) engine.OnAction(func(ev AlertEvent) { if msg := actioner.Run(ev); msg != "" { - emitNotificationTo(ev.RuleName, msg, ev.Targets) + emitAlertEvent(ev, msg) } }) alertStop := make(chan struct{}) @@ -202,3 +199,32 @@ func emitTypedNotificationTo(kind, title, message string, targets []string) { _ = resp.Body.Close() } } + +func emitAlertEvent(event AlertEvent, message string) { + base, token := pluginsdk.AgentCallback() + if base == "" || token == "" { + return + } + payload := alertCoreEvent(event, message) + body, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, base+"/internal/events", bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Plugin-Token", token) + client := &http.Client{Timeout: 5 * time.Second} + if resp, err := client.Do(req); err == nil { + _ = resp.Body.Close() + } +} + +func alertCoreEvent(event AlertEvent, message string) events.Event { + return events.Event{ + Source: "observability", Type: "metric.alert", Severity: events.SeverityWarning, + Title: event.RuleName, Message: message, + Scope: events.Scope{Deployment: event.Deployment, Container: event.Container}, + CorrelationKey: "alert:" + event.RuleID, + OccurredAt: event.At, TargetIDs: event.Targets, Resolved: event.incidentResolved, + } +} diff --git a/internal/observ/plugin_alert_test.go b/internal/observ/plugin_alert_test.go new file mode 100644 index 0000000..f94c7d0 --- /dev/null +++ b/internal/observ/plugin_alert_test.go @@ -0,0 +1,33 @@ +package observ + +import ( + "reflect" + "testing" + "time" +) + +func TestAlertCoreEventKeepsOneIncidentAcrossRecovery(t *testing.T) { + at := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) + firing := alertCoreEvent(AlertEvent{ + RuleID: "memory", RuleName: "High memory", Deployment: "shop", Container: "web", + State: AlertFiring, At: at, Targets: []string{"ops"}, + }, "Memory is high") + recovered := alertCoreEvent(AlertEvent{ + RuleID: "memory", RuleName: "High memory", Deployment: "shop", Container: "web", + State: AlertOK, incidentResolved: true, At: at.Add(time.Minute), Targets: []string{"ops"}, + }, "Memory is normal") + + if firing.CorrelationKey != recovered.CorrelationKey || !recovered.Resolved { + t.Fatalf("firing = %#v, recovered = %#v", firing, recovered) + } + if firing.Scope.Deployment != "shop" || firing.Scope.Container != "web" || !reflect.DeepEqual(firing.TargetIDs, []string{"ops"}) { + t.Fatalf("event = %#v", firing) + } + secondContainer := alertCoreEvent(AlertEvent{ + RuleID: "memory", RuleName: "High memory", Deployment: "shop", Container: "worker", + State: AlertFiring, At: at, Targets: []string{"ops"}, + }, "Memory is high") + if secondContainer.CorrelationKey != firing.CorrelationKey { + t.Fatalf("events from one rule must share an incident: %q != %q", secondContainer.CorrelationKey, firing.CorrelationKey) + } +} From e2d12b3c4c36447653cd2614441752e1901a2689 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 23:35:05 +0100 Subject: [PATCH 02/14] fix(fleet): Allow read-only peer detail requests Fleet readers can retrieve deployment details from peers without receiving permission to mutate those deployments. --- internal/api/cluster_handlers_test.go | 47 +++++++- internal/api/openapi.json | 156 ++++++++++++++++++++++++++ internal/api/server.go | 6 +- 3 files changed, 207 insertions(+), 2 deletions(-) diff --git a/internal/api/cluster_handlers_test.go b/internal/api/cluster_handlers_test.go index c9f9ef1..2afb6bf 100644 --- a/internal/api/cluster_handlers_test.go +++ b/internal/api/cluster_handlers_test.go @@ -136,7 +136,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) @@ -817,6 +821,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/openapi.json b/internal/api/openapi.json index 508edd4..a45918f 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -1736,6 +1736,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", diff --git a/internal/api/server.go b/internal/api/server.go index e226165..7530df5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -919,7 +919,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) From c9f2b1a9be57f03553f28d7c4917d2632f17cd04 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 23:58:06 +0100 Subject: [PATCH 03/14] fix(auth): Isolate service permission boundaries Service readers can no longer change DNS records or firewall policy. Object storage and notifications now have independent access controls, and storage attachments require access to the selected deployment. --- internal/api/object_consume.go | 8 +++ internal/api/object_stores_test.go | 112 ++++++++++++++++++++++++++--- internal/api/openapi.json | 52 +++++++++----- internal/api/powerdns_handlers.go | 2 +- internal/api/server.go | 79 ++++++++++++++------ internal/auth/permissions.go | 12 ++++ internal/auth/permissions_test.go | 7 ++ 7 files changed, 220 insertions(+), 52 deletions(-) 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..8e3257a 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,32 @@ 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) + 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 +93,87 @@ 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 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 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 a45918f..893e9ff 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -7025,7 +7025,7 @@ "tags": [ "notifications" ], - "x-permission": "settings:read" + "x-permission": "notifications:read" } }, "/api/notifications/rules": { @@ -7039,7 +7039,7 @@ "tags": [ "notifications" ], - "x-permission": "settings:read" + "x-permission": "notifications:read" }, "put": { "operationId": "put-notifications-rules", @@ -7072,7 +7072,7 @@ "tags": [ "notifications" ], - "x-permission": "settings:write" + "x-permission": "notifications:write" } }, "/api/notifications/targets": { @@ -7093,7 +7093,7 @@ "tags": [ "notifications" ], - "x-permission": "settings:read" + "x-permission": "notifications:read" }, "put": { "operationId": "put-notifications-targets", @@ -7122,7 +7122,7 @@ "tags": [ "notifications" ], - "x-permission": "settings:write" + "x-permission": "notifications:write" } }, "/api/notifications/test": { @@ -7162,7 +7162,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": { @@ -7186,7 +7200,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/object-stores/{name}/attach": { @@ -7263,7 +7277,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:read" + "x-permission": "storage:read" }, "post": { "operationId": "post-object-stores-by-name-buckets", @@ -7309,7 +7323,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/object-stores/{name}/buckets/{bucket}": { @@ -7341,7 +7355,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:delete" + "x-permission": "storage:delete" } }, "/api/object-stores/{name}/objects": { @@ -7379,7 +7393,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:write" + "x-permission": "storage:delete" }, "get": { "operationId": "get-object-stores-by-name-objects", @@ -7422,7 +7436,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:read" + "x-permission": "storage:read" }, "post": { "operationId": "post-object-stores-by-name-objects", @@ -7444,7 +7458,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/object-stores/{name}/objects/download": { @@ -7482,7 +7496,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:read" + "x-permission": "storage:read" } }, "/api/object-stores/{name}/replicate": { @@ -7530,7 +7544,7 @@ "tags": [ "object-stores" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/openapi.json": { @@ -9444,7 +9458,7 @@ "tags": [ "storage-credentials" ], - "x-permission": "backups:read" + "x-permission": "storage:read" }, "post": { "operationId": "post-storage-credentials", @@ -9493,7 +9507,7 @@ "tags": [ "storage-credentials" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/storage-credentials/{id}": { @@ -9517,7 +9531,7 @@ "tags": [ "storage-credentials" ], - "x-permission": "backups:delete" + "x-permission": "storage:delete" }, "put": { "operationId": "put-storage-credentials-by-id", @@ -9567,7 +9581,7 @@ "tags": [ "storage-credentials" ], - "x-permission": "backups:write" + "x-permission": "storage:write" } }, "/api/subdomain/generate": { 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/server.go b/internal/api/server.go index 7530df5..557378c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -551,12 +551,12 @@ func (s *Server) setupRoutes() { 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("/notifications/targets", s.authMiddleware.RequirePermission(auth.PermNotificationsRead), s.getNotificationTargets) + 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) @@ -757,10 +757,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 +819,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 +891,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 +902,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) @@ -7490,3 +7495,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/permissions.go b/internal/auth/permissions.go index 1a3c31b..37de619 100644 --- a/internal/auth/permissions.go +++ b/internal/auth/permissions.go @@ -21,6 +21,12 @@ 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" PermUsersRead Permission = "users:read" PermUsersWrite Permission = "users:write" @@ -89,6 +95,8 @@ var adminPermissions = []Permission{ PermNetworksRead, PermNetworksWrite, PermNetworksDelete, PermSecurityRead, PermSecurityWrite, PermBackupsRead, PermBackupsWrite, PermBackupsDelete, + PermStorageRead, PermStorageWrite, PermStorageDelete, + PermNotificationsRead, PermNotificationsWrite, PermUsersRead, PermUsersWrite, PermUsersDelete, PermAPIKeysRead, PermAPIKeysWrite, PermAPIKeysDelete, PermSettingsRead, PermSettingsWrite, @@ -114,6 +122,8 @@ var operatorPermissions = []Permission{ PermNetworksRead, PermSecurityRead, PermBackupsRead, PermBackupsWrite, + PermStorageRead, PermStorageWrite, + PermNotificationsRead, PermAPIKeysRead, PermAPIKeysWrite, PermAPIKeysDelete, PermSettingsRead, PermContainersRead, PermContainersWrite, @@ -135,6 +145,8 @@ var viewerPermissions = []Permission{ PermNetworksRead, PermSecurityRead, PermBackupsRead, + PermStorageRead, + PermNotificationsRead, PermAPIKeysRead, PermSettingsRead, PermContainersRead, diff --git a/internal/auth/permissions_test.go b/internal/auth/permissions_test.go index 0c16ffb..529e5af 100644 --- a/internal/auth/permissions_test.go +++ b/internal/auth/permissions_test.go @@ -86,6 +86,9 @@ func TestViewerCannotWrite(t *testing.T) { PermRegistriesDelete, PermTemplatesWrite, PermTrafficWrite, + PermStorageWrite, + PermStorageDelete, + PermNotificationsWrite, } for _, perm := range writePerms { @@ -111,6 +114,8 @@ func TestViewerCanRead(t *testing.T) { PermRegistriesRead, PermTemplatesRead, PermTrafficRead, + PermStorageRead, + PermNotificationsRead, } for _, perm := range readPerms { @@ -144,6 +149,7 @@ func TestOperatorPermissions(t *testing.T) { PermSystemWrite, PermDNSWrite, PermRegistriesWrite, + PermStorageWrite, } for _, perm := range operatorWritePerms { if !HasPermission(RoleOperator, nil, perm) { @@ -159,6 +165,7 @@ func TestOperatorPermissions(t *testing.T) { PermDatabasesDelete, PermSchedulerDelete, PermRegistriesDelete, + PermStorageDelete, } for _, perm := range operatorNoDeletePerms { if HasPermission(RoleOperator, nil, perm) { From b3fbd4bfd74e25b7df9ff6f0d2d5f14e8654f66e Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 23:58:20 +0100 Subject: [PATCH 04/14] fix(email): Preserve logo contrast across mail clients The branded header retains its dark background in clients that ignore inline CSS, keeping the white logo visible. --- internal/notify/notify_test.go | 3 +++ internal/notify/templates/default/header.html | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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"}} +{{define "header"}} FlatRun {{end}} From 4502558fddc4af9195e2475582e3f642fafac8e8 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 00:05:16 +0100 Subject: [PATCH 05/14] fix(notifications): Remove obsolete delivery helper Notification delivery now uses the typed path exclusively, keeping the branch clean under CI lint rules. --- internal/observ/plugin.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/observ/plugin.go b/internal/observ/plugin.go index ab4b265..20d105b 100644 --- a/internal/observ/plugin.go +++ b/internal/observ/plugin.go @@ -178,10 +178,6 @@ func emitTypedNotification(kind, title, message string) { emitTypedNotificationTo(kind, title, message, nil) } -func emitNotificationTo(title, message string, targets []string) { - emitTypedNotificationTo("generic", title, message, targets) -} - func emitTypedNotificationTo(kind, title, message string, targets []string) { base, token := pluginsdk.AgentCallback() if base == "" || token == "" { From 964872f9409b9320e48bc968fc39f6b6599b1fff Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 04:33:07 +0100 Subject: [PATCH 06/14] fix(release): Move Albacore to beta 6 --- CHANGELOG.md | 8 ++++++-- VERSION | 2 +- internal/api/openapi.json | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b871b5a..a3cac9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # 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 @@ -13,6 +13,10 @@ Fifth beta of the Albacore release, making connected servers manageable as one F ### 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 readers can open deployment details without gaining write access +- Object storage and notifications have independent permission boundaries +- 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/openapi.json b/internal/api/openapi.json index 893e9ff..bf5a1db 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": { From 093a1700a1a7da8603c89a00a34e2a46218d1f33 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 04:51:57 +0100 Subject: [PATCH 07/14] fix(auth): Require explicit administration access Operators and viewers no longer inherit administration access. Explicit grants remain available for custom roles. --- CHANGELOG.md | 2 ++ internal/api/apikeys_test.go | 18 ++++-------------- internal/api/object_stores_test.go | 27 +++++++++++++++++++++++++++ internal/api/openapi.json | 4 ++-- internal/api/server.go | 4 ++-- internal/auth/permissions.go | 9 +++------ internal/auth/permissions_test.go | 25 ++++++++++++++++++++++++- 7 files changed, 64 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3cac9b..c70b85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Sixth beta of the Albacore release, making connected servers manageable as one F - Existing peer credentials are restricted to their configured Fleet policy during startup - 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 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/object_stores_test.go b/internal/api/object_stores_test.go index 8e3257a..fa5b20f 100644 --- a/internal/api/object_stores_test.go +++ b/internal/api/object_stores_test.go @@ -76,6 +76,8 @@ func setupObjectStoreTestServer(t *testing.T) (*Server, *gin.Engine, func()) { 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) }) @@ -110,6 +112,18 @@ func objectStoreKey(t *testing.T, server *Server, raw string, permissions []stri 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() @@ -149,6 +163,19 @@ func TestServiceReadersCannotMutateDNSOrFirewall(t *testing.T) { } } +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() diff --git a/internal/api/openapi.json b/internal/api/openapi.json index bf5a1db..428ae42 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -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": { diff --git a/internal/api/server.go b/internal/api/server.go index 557378c..7728e11 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -549,8 +549,8 @@ 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("/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("/notifications/incidents", s.authMiddleware.RequirePermission(auth.PermNotificationsRead), s.listNotificationIncidents) protected.GET("/notifications/rules", s.authMiddleware.RequirePermission(auth.PermNotificationsRead), s.listNotificationRules) diff --git a/internal/auth/permissions.go b/internal/auth/permissions.go index 37de619..0062f65 100644 --- a/internal/auth/permissions.go +++ b/internal/auth/permissions.go @@ -38,6 +38,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" @@ -100,6 +102,7 @@ var adminPermissions = []Permission{ PermUsersRead, PermUsersWrite, PermUsersDelete, PermAPIKeysRead, PermAPIKeysWrite, PermAPIKeysDelete, PermSettingsRead, PermSettingsWrite, + PermUpdatesRead, PermUpdatesWrite, PermConfigRead, PermConfigWrite, PermAuditRead, PermContainersRead, PermContainersWrite, PermContainersDelete, @@ -123,9 +126,6 @@ var operatorPermissions = []Permission{ PermSecurityRead, PermBackupsRead, PermBackupsWrite, PermStorageRead, PermStorageWrite, - PermNotificationsRead, - PermAPIKeysRead, PermAPIKeysWrite, PermAPIKeysDelete, - PermSettingsRead, PermContainersRead, PermContainersWrite, PermImagesRead, PermImagesWrite, PermVolumesRead, PermVolumesWrite, @@ -146,9 +146,6 @@ var viewerPermissions = []Permission{ PermSecurityRead, PermBackupsRead, PermStorageRead, - PermNotificationsRead, - PermAPIKeysRead, - PermSettingsRead, PermContainersRead, PermImagesRead, PermVolumesRead, diff --git a/internal/auth/permissions_test.go b/internal/auth/permissions_test.go index 529e5af..9172ed9 100644 --- a/internal/auth/permissions_test.go +++ b/internal/auth/permissions_test.go @@ -89,6 +89,8 @@ func TestViewerCannotWrite(t *testing.T) { PermStorageWrite, PermStorageDelete, PermNotificationsWrite, + PermUpdatesRead, + PermUpdatesWrite, } for _, perm := range writePerms { @@ -115,7 +117,6 @@ func TestViewerCanRead(t *testing.T) { PermTemplatesRead, PermTrafficRead, PermStorageRead, - PermNotificationsRead, } for _, perm := range readPerms { @@ -138,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, From 3bce78f79ed740f0dcde188f215448aee7b4afb9 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 05:40:39 +0100 Subject: [PATCH 08/14] fix(alerts): Scope rules to deployment access Operators can manage alerts for assigned deployments. Other deployments, host rules, and notification credentials remain private. --- internal/api/notifications_test.go | 19 +++++ internal/api/openapi.json | 14 ++++ internal/api/plugin_access_test.go | 59 ++++++++++++++ internal/api/server.go | 83 +++++++++++++++++++- internal/auth/permissions.go | 5 ++ internal/observ/alert_api_test.go | 69 +++++++++++++++++ internal/observ/api.go | 120 ++++++++++++++++++++++++++--- pkg/pluginapi/access.go | 62 +++++++++++++++ pkg/pluginapi/access_test.go | 21 +++++ 9 files changed, 440 insertions(+), 12 deletions(-) create mode 100644 internal/api/plugin_access_test.go create mode 100644 pkg/pluginapi/access.go create mode 100644 pkg/pluginapi/access_test.go 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/openapi.json b/internal/api/openapi.json index 428ae42..534024a 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -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", 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/server.go b/internal/api/server.go index 7728e11..5405625 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" @@ -552,6 +553,7 @@ func (s *Server) setupRoutes() { 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) @@ -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) @@ -3460,6 +3462,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 { @@ -3613,18 +3626,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") diff --git a/internal/auth/permissions.go b/internal/auth/permissions.go index 0062f65..97ca599 100644 --- a/internal/auth/permissions.go +++ b/internal/auth/permissions.go @@ -27,6 +27,8 @@ const ( PermNotificationsRead Permission = "notifications:read" PermNotificationsWrite Permission = "notifications:write" + PermAlertsRead Permission = "alerts:read" + PermAlertsWrite Permission = "alerts:write" PermUsersRead Permission = "users:read" PermUsersWrite Permission = "users:write" @@ -99,6 +101,7 @@ var adminPermissions = []Permission{ PermBackupsRead, PermBackupsWrite, PermBackupsDelete, PermStorageRead, PermStorageWrite, PermStorageDelete, PermNotificationsRead, PermNotificationsWrite, + PermAlertsRead, PermAlertsWrite, PermUsersRead, PermUsersWrite, PermUsersDelete, PermAPIKeysRead, PermAPIKeysWrite, PermAPIKeysDelete, PermSettingsRead, PermSettingsWrite, @@ -137,6 +140,7 @@ var operatorPermissions = []Permission{ PermRegistriesRead, PermRegistriesWrite, PermTemplatesRead, PermTrafficRead, + PermAlertsRead, PermAlertsWrite, } var viewerPermissions = []Permission{ @@ -157,6 +161,7 @@ var viewerPermissions = []Permission{ PermRegistriesRead, PermTemplatesRead, PermTrafficRead, + PermAlertsRead, } func GetRolePermissions(role Role) []Permission { diff --git a/internal/observ/alert_api_test.go b/internal/observ/alert_api_test.go index 14b0f6c..20eadd6 100644 --- a/internal/observ/alert_api_test.go +++ b/internal/observ/alert_api_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/flatrun/agent/pkg/pluginapi" ) func alertHandler(t *testing.T) (http.Handler, *AlertEngine, *AlertStore) { @@ -17,6 +19,73 @@ func alertHandler(t *testing.T) (http.Handler, *AlertEngine, *AlertStore) { return h, engine, rules } +func scopedAlertRequest(t *testing.T, method, path, body string, grants ...pluginapi.ResourceGrant) *http.Request { + t.Helper() + encoded, err := pluginapi.EncodeResourceAccess(pluginapi.ResourceAccess{Grants: grants}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set(pluginapi.ResourceAccessHeader, encoded) + return req +} + +func TestAlertRulesAreScopedThroughTheHTTPAPI(t *testing.T) { + metrics := NewStore(10) + engine := NewAlertEngine(metrics) + store := NewAlertStore(t.TempDir()) + if err := store.Save([]AlertRule{ + {ID: "shop", Name: "Shop CPU", Deployment: "shop", Metric: MetricCPUUsage, Comparison: ComparisonAbove, Threshold: 80, Enabled: true}, + {ID: "billing", Name: "Billing CPU", Deployment: "billing", Metric: MetricCPUUsage, Comparison: ComparisonAbove, Threshold: 80, Enabled: true}, + {ID: "host", Name: "Host CPU", Metric: MetricHostCPU, Comparison: ComparisonAbove, Threshold: 80, Enabled: true}, + }); err != nil { + t.Fatal(err) + } + engine.SetRules(store.Load()) + h := HandlerWithAlerts(metrics, nil, nil, nil, nil, alerts{engine: engine, store: store}) + + grant := pluginapi.ResourceGrant{Resource: "deployment", ID: "shop", Level: "write"} + rec := httptest.NewRecorder() + h.ServeHTTP(rec, scopedAlertRequest(t, http.MethodGet, "/alerts/rules", "", grant)) + var visible []AlertRule + if err := json.Unmarshal(rec.Body.Bytes(), &visible); err != nil { + t.Fatal(err) + } + if len(visible) != 1 || visible[0].ID != "shop" { + t.Fatalf("visible rules = %+v", visible) + } + + body, _ := json.Marshal([]AlertRule{{ID: "shop", Name: "Shop memory", Deployment: "shop", Metric: MetricMemoryUsage, Comparison: ComparisonAbove, Threshold: 90, Enabled: true}}) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, scopedAlertRequest(t, http.MethodPut, "/alerts/rules", string(body), grant)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + all := store.Load() + if len(all) != 3 { + t.Fatalf("stored rules = %+v", all) + } + for _, rule := range all { + if rule.ID == "billing" && rule.Name != "Billing CPU" { + t.Fatal("scoped update changed another deployment") + } + if rule.ID == "host" && rule.Name != "Host CPU" { + t.Fatal("scoped update changed the host rule") + } + } +} + +func TestAlertRulesRejectAnotherDeploymentThroughTheHTTPAPI(t *testing.T) { + h, _, _ := alertHandler(t) + grant := pluginapi.ResourceGrant{Resource: "deployment", ID: "shop", Level: "write"} + body := `[{"name":"Billing CPU","deployment":"billing","metric":"container.cpu.usage","comparison":"above","threshold":80,"enabled":true}]` + rec := httptest.NewRecorder() + h.ServeHTTP(rec, scopedAlertRequest(t, http.MethodPut, "/alerts/rules", body, grant)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403: %s", rec.Code, rec.Body.String()) + } +} + func TestAlertRulesRoundTripThroughTheAPI(t *testing.T) { h, engine, _ := alertHandler(t) diff --git a/internal/observ/api.go b/internal/observ/api.go index 6e5f2b4..e4135f8 100644 --- a/internal/observ/api.go +++ b/internal/observ/api.go @@ -2,9 +2,13 @@ package observ import ( "encoding/json" + "fmt" "net/http" + "reflect" "strings" "time" + + "github.com/flatrun/agent/pkg/pluginapi" ) // healthReporter is the slice of the health watcher the API needs; nil-safe so the handler @@ -155,15 +159,22 @@ func HandlerWithAlerts(store *Store, history *MetricsDB, health healthReporter, http.Error(w, "invalid rules", http.StatusBadRequest) return } - // Saving assigns ids and rejects a rule that cannot be evaluated, so a bad - // rule never reaches the engine. + var err error + incoming, err = mergeScoped( + al.engine.Rules(), incoming, resourceAccess(r), alertRuleDeployment, + func(rule AlertRule) string { return rule.ID }, + ) + if err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } if err := al.store.Save(incoming); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } al.engine.SetRules(al.store.Load()) } - writeJSON(w, al.engine.Rules()) + writeJSON(w, filterScoped(al.engine.Rules(), resourceAccess(r), alertRuleDeployment)) }) mux.HandleFunc("/alerts/log-rules", func(w http.ResponseWriter, r *http.Request) { if al.logEngine == nil || al.logStore == nil { @@ -176,21 +187,34 @@ func HandlerWithAlerts(store *Store, history *MetricsDB, health healthReporter, http.Error(w, "invalid rules", http.StatusBadRequest) return } - // Saving assigns ids and rejects a rule that would match everything. + var err error + incoming, err = mergeScoped( + al.logEngine.Rules(), incoming, resourceAccess(r), + func(rule LogRule) string { return rule.Deployment }, + func(rule LogRule) string { return rule.ID }, + ) + if err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } if err := al.logStore.Save(incoming); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } al.logEngine.SetRules(al.logStore.Load()) } - writeJSON(w, al.logEngine.Rules()) + writeJSON(w, filterScoped( + al.logEngine.Rules(), resourceAccess(r), func(rule LogRule) string { return rule.Deployment }, + )) }) mux.HandleFunc("/alerts/incidents", func(w http.ResponseWriter, r *http.Request) { if al.logEngine == nil { writeJSON(w, []Incident{}) return } - incidents := al.logEngine.Incidents() + incidents := filterScoped( + al.logEngine.Incidents(), resourceAccess(r), func(incident Incident) string { return incident.Deployment }, + ) if deployment := r.URL.Query().Get("deployment"); deployment != "" { filtered := make([]Incident, 0, len(incidents)) for _, in := range incidents { @@ -205,19 +229,23 @@ func HandlerWithAlerts(store *Store, history *MetricsDB, health healthReporter, mux.HandleFunc("/alerts/responders", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, KnownResponders()) }) - mux.HandleFunc("/alerts/firing", func(w http.ResponseWriter, _ *http.Request) { + mux.HandleFunc("/alerts/firing", func(w http.ResponseWriter, r *http.Request) { if al.engine == nil { writeJSON(w, []AlertEvent{}) return } - writeJSON(w, al.engine.Firing()) + writeJSON(w, filterScoped( + al.engine.Firing(), resourceAccess(r), func(event AlertEvent) string { return event.Deployment }, + )) }) - mux.HandleFunc("/alerts/events", func(w http.ResponseWriter, _ *http.Request) { + mux.HandleFunc("/alerts/events", func(w http.ResponseWriter, r *http.Request) { if al.engine == nil { writeJSON(w, []AlertEvent{}) return } - writeJSON(w, al.engine.Events()) + writeJSON(w, filterScoped( + al.engine.Events(), resourceAccess(r), func(event AlertEvent) string { return event.Deployment }, + )) }) mux.HandleFunc("/health/events", func(w http.ResponseWriter, _ *http.Request) { if health == nil { @@ -249,6 +277,78 @@ func HandlerWithAlerts(store *Store, history *MetricsDB, health healthReporter, return mux } +func resourceAccess(r *http.Request) pluginapi.ResourceAccess { + value := r.Header.Get(pluginapi.ResourceAccessHeader) + if value == "" { + return pluginapi.ResourceAccess{Global: true} + } + access, err := pluginapi.DecodeResourceAccess(value) + if err != nil { + return pluginapi.ResourceAccess{} + } + return access +} + +func alertRuleDeployment(rule AlertRule) string { + switch rule.Metric { + case MetricHostCPU, MetricHostMemUtil, MetricHostMemUsage, MetricHostMemLimit, MetricHostDisk: + return "" + default: + return rule.Deployment + } +} + +func filterScoped[T any](items []T, access pluginapi.ResourceAccess, deployment func(T) string) []T { + if access.Global { + return items + } + filtered := make([]T, 0, len(items)) + for _, item := range items { + if name := deployment(item); name != "" && access.Allows("deployment", name, "read") { + filtered = append(filtered, item) + } + } + return filtered +} + +func mergeScoped[T any](stored, incoming []T, access pluginapi.ResourceAccess, deployment func(T) string, id func(T) string) ([]T, error) { + if access.Global { + return incoming, nil + } + merged := make([]T, 0, len(stored)+len(incoming)) + protectedIDs := make(map[string]struct{}) + for _, item := range stored { + name := deployment(item) + if name == "" || !access.Allows("deployment", name, "write") { + merged = append(merged, item) + if itemID := id(item); itemID != "" { + protectedIDs[itemID] = struct{}{} + } + } + } + for _, item := range incoming { + name := deployment(item) + if name == "" || !access.Allows("deployment", name, "write") { + unchanged := false + for _, existing := range stored { + if id(existing) == id(item) && reflect.DeepEqual(existing, item) { + unchanged = true + break + } + } + if unchanged { + continue + } + return nil, fmt.Errorf("no write access to alert scope") + } + if _, exists := protectedIDs[id(item)]; exists { + return nil, fmt.Errorf("no write access to alert rule") + } + merged = append(merged, item) + } + return merged, nil +} + type containerMetrics struct { Container string `json:"container"` Metrics map[string]float64 `json:"metrics"` diff --git a/pkg/pluginapi/access.go b/pkg/pluginapi/access.go new file mode 100644 index 0000000..ca8640c --- /dev/null +++ b/pkg/pluginapi/access.go @@ -0,0 +1,62 @@ +package pluginapi + +import ( + "encoding/base64" + "encoding/json" +) + +const ResourceAccessHeader = "X-Flatrun-Resource-Access" + +type ResourceGrant struct { + Resource string `json:"resource"` + ID string `json:"id"` + Level string `json:"level"` +} + +type ResourceAccess struct { + Global bool `json:"global,omitempty"` + Grants []ResourceGrant `json:"grants,omitempty"` +} + +func EncodeResourceAccess(access ResourceAccess) (string, error) { + payload, err := json.Marshal(access) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(payload), nil +} + +func DecodeResourceAccess(value string) (ResourceAccess, error) { + var access ResourceAccess + payload, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return access, err + } + err = json.Unmarshal(payload, &access) + return access, err +} + +func (a ResourceAccess) Allows(resource, id, level string) bool { + if a.Global { + return true + } + for _, grant := range a.Grants { + if grant.Resource == resource && grant.ID == id && accessRank(grant.Level) >= accessRank(level) { + return true + } + } + return false +} + +func accessRank(level string) int { + switch level { + case "read": + return 1 + case "write": + return 2 + case "admin": + return 3 + default: + return 0 + } +} diff --git a/pkg/pluginapi/access_test.go b/pkg/pluginapi/access_test.go new file mode 100644 index 0000000..17d7fc0 --- /dev/null +++ b/pkg/pluginapi/access_test.go @@ -0,0 +1,21 @@ +package pluginapi + +import "testing" + +func TestResourceAccessRoundTrip(t *testing.T) { + want := ResourceAccess{Grants: []ResourceGrant{{Resource: "deployment", ID: "shop", Level: "write"}}} + encoded, err := EncodeResourceAccess(want) + if err != nil { + t.Fatal(err) + } + got, err := DecodeResourceAccess(encoded) + if err != nil { + t.Fatal(err) + } + if !got.Allows("deployment", "shop", "read") || !got.Allows("deployment", "shop", "write") { + t.Fatal("write grant should allow deployment reads and writes") + } + if got.Allows("deployment", "other", "read") || got.Allows("deployment", "shop", "admin") { + t.Fatal("grant must not widen its resource or level") + } +} From c082c676165b34dcfaa8e3577390eefdc5703bf0 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 05:41:27 +0100 Subject: [PATCH 09/14] fix(fleet): Delete removed peer credentials Removing a peer now deletes its generated service credential while preserving user-managed keys with the same display name. --- internal/api/cluster_handlers.go | 22 +++++++++---- internal/api/cluster_handlers_test.go | 46 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) 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 2afb6bf..46f74af 100644 --- a/internal/api/cluster_handlers_test.go +++ b/internal/api/cluster_handlers_test.go @@ -196,6 +196,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() From 52c688a9719edb2493e75c68df539b614fc53e56 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 05:41:44 +0100 Subject: [PATCH 10/14] docs(auth): Define resource authorization rules Module permissions and resource grants now have shared implementation and test requirements. --- AGENTS.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 AGENTS.md 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. From b3b65fbbf5e09721c2c865e22dbd5bace1ed499d Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 06:22:30 +0100 Subject: [PATCH 11/14] fix(fleet): Restore peer deployment access Fleet service credentials follow their own deployment policy. Peer lists remain available when the current server has no deployments. --- internal/api/cluster_handlers_test.go | 54 +++++++++++++++++++++++++++ internal/auth/models.go | 3 ++ internal/auth/models_test.go | 22 +++++++++++ 3 files changed, 79 insertions(+) diff --git a/internal/api/cluster_handlers_test.go b/internal/api/cluster_handlers_test.go index 46f74af..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) }) @@ -163,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() diff --git a/internal/auth/models.go b/internal/auth/models.go index ee83119..fd6f374 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.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..d091d6d 100644 --- a/internal/auth/models_test.go +++ b/internal/auth/models_test.go @@ -202,6 +202,28 @@ func TestActorContextCanAccessDeployment(t *testing.T) { requiredLevel: "read", want: false, }, + { + name: "service user access is defined by its key", + actor: &ActorContext{ + User: &User{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{Role: RoleService}, + Role: RoleService, + APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelRead}}, + }, + deploymentName: "other-app", + requiredLevel: "read", + want: false, + }, { name: "operator user with both grants takes the lower level", actor: &ActorContext{ From 12e30da13cc872403abe204d0bd35b4bb2644fbf Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 06:22:39 +0100 Subject: [PATCH 12/14] feat(health): Support TCP and command checks Deployments can verify non-HTTP services without unsafe HTTP probes. Existing HTTP health configuration remains compatible. --- internal/api/ai_handlers.go | 4 +- internal/api/deployment_diagnostics.go | 118 ++++++++++++++------ internal/api/deployment_diagnostics_test.go | 24 ++++ internal/api/openapi.json | 24 +++- internal/api/require_plan_test.go | 26 +++++ internal/api/server.go | 24 +++- internal/autoscale/compatibility.go | 4 +- internal/docker/api.go | 25 +++++ internal/docker/manager.go | 15 +++ internal/nginx/manager.go | 3 + pkg/models/deployment.go | 4 + 11 files changed, 232 insertions(+), 39 deletions(-) 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/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/openapi.json b/internal/api/openapi.json index 534024a..45c08f7 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -13791,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/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 5405625..b8b58a7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1997,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") } @@ -2011,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 } 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/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/pkg/models/deployment.go b/pkg/models/deployment.go index 99b80b0..fa22b75 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -332,10 +332,14 @@ type SSLConfig struct { } type HealthCheckConfig struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Service string `yaml:"service,omitempty" json:"service,omitempty"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` Path string `yaml:"path" json:"path"` Interval string `yaml:"interval" json:"interval"` SuccessStatuses []int `yaml:"success_statuses,omitempty" json:"success_statuses,omitempty"` ResponseContains string `yaml:"response_contains,omitempty" json:"response_contains,omitempty"` + Command string `yaml:"command,omitempty" json:"command,omitempty"` } type DeploymentStatus string From 31838d91b1e9b3ed288bc571917d711b908c026e Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 06:32:53 +0100 Subject: [PATCH 13/14] fix(fleet): Limit credential policy exception Only the reserved Fleet identity uses its API key as the deployment grant. Other service identities keep the existing permission intersection. --- internal/auth/models.go | 2 +- internal/auth/models_test.go | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/internal/auth/models.go b/internal/auth/models.go index fd6f374..cd5a2e9 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -175,7 +175,7 @@ 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.APIKey != nil { + 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 { diff --git a/internal/auth/models_test.go b/internal/auth/models_test.go index d091d6d..8ed7e4d 100644 --- a/internal/auth/models_test.go +++ b/internal/auth/models_test.go @@ -205,7 +205,7 @@ func TestActorContextCanAccessDeployment(t *testing.T) { { name: "service user access is defined by its key", actor: &ActorContext{ - User: &User{Role: RoleService}, + User: &User{Username: "__flatrun_cluster", Role: RoleService}, Role: RoleService, APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelRead}}, }, @@ -216,7 +216,7 @@ func TestActorContextCanAccessDeployment(t *testing.T) { { name: "service user remains limited by its key", actor: &ActorContext{ - User: &User{Role: RoleService}, + User: &User{Username: "__flatrun_cluster", Role: RoleService}, Role: RoleService, APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelRead}}, }, @@ -224,6 +224,17 @@ func TestActorContextCanAccessDeployment(t *testing.T) { 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{ From d7ea678bc80767e842373e5045bc7b09c05e4744 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 06:32:53 +0100 Subject: [PATCH 14/14] docs: Update beta 6 release notes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c70b85d..68341c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,12 @@ Sixth beta of the Albacore release, making connected servers manageable as one F - 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