Skip to content

Commit 257924d

Browse files
authored
Merge pull request #216 from flatrun/fix/notification-alert-incidents
fix(notifications): Group alerts and enforce service access
2 parents fc60871 + d7ea678 commit 257924d

39 files changed

Lines changed: 1488 additions & 151 deletions

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# FlatRun Agent Guide
2+
3+
## Authorization
4+
5+
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.
6+
7+
Rules:
8+
9+
- Define dedicated read and write permissions for each module. Do not reuse an unrelated permission because two features share a page, plugin, or transport.
10+
- Enforce authorization in the HTTP API. UI guards are not security boundaries.
11+
- Filter collection responses to resources the actor may read.
12+
- Validate every resource referenced by create, update, delete, bulk, and action requests.
13+
- Preserve records outside the actor's scope when processing bulk updates. A scoped request must never replace a global collection.
14+
- Require explicit global access for host-wide, fleet-wide, and all-resource operations. An empty resource identifier must not grant global access.
15+
- Apply the intersection of user and API key grants. An API key may narrow its user's access but must never widen it.
16+
- Keep secret-bearing administration resources separate from safe selectors. A scoped feature may receive target identifiers and display names without receiving target credentials.
17+
- 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.
18+
19+
## Tests
20+
21+
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.

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
# Changelog
22

3-
## [0.4.0-beta.5] - 2026-08-22
3+
## [0.4.0-beta.6] - 2026-08-23
44

5-
Fifth beta of the Albacore release, making connected servers manageable as one Fleet.
5+
Sixth beta of the Albacore release, making connected servers manageable as one Fleet.
66

77
### Added
88
- Guided Fleet setup, peer access policies, remote deployment inventories, and runtime provider selection
99
- Host and deployment capacity decisions with managed horizontal and vertical scaling
1010
- Docker Swarm and k3s orchestration adapters with nginx and Traefik routing adapters
1111
- Grouped incidents and configurable notification targets and delivery rules
12+
- HTTP, TCP, and container command health checks for web services and databases
1213

1314
### Fixed
1415
- Existing Fleet peers gain default access policies during startup repair without reconnecting
1516
- Existing peer credentials are restricted to their configured Fleet policy during startup
17+
- Fleet peer credentials can read deployments allowed by their peer policy
18+
- Fleet readers can open deployment details without gaining write access
19+
- Object storage and notifications have independent permission boundaries
20+
- Updates require dedicated access and remain admin-only by default
21+
- Settings, notifications, and API keys require explicit access for non-admin roles
22+
- Repeated metric alerts share one incident until every affected series recovers
23+
- Email headers keep the white logo visible in clients that ignore inline CSS
1624

1725
## [0.4.0-beta.4] - 2026-08-21
1826

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.4.0-beta.5
1+
0.4.0-beta.6

internal/api/ai_handlers.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ func (s *Server) platformSection(deploymentName string) ai.Section {
113113
} else {
114114
fmt.Fprintf(&b, "This deployment is not exposed through the reverse proxy\n")
115115
}
116-
if meta.HealthCheck.Path != "" {
117-
fmt.Fprintf(&b, "Configured health check path: %s\n", meta.HealthCheck.Path)
116+
if healthCheckConfigured(meta.HealthCheck) {
117+
fmt.Fprintf(&b, "Configured health check type: %s\n", healthCheckType(meta.HealthCheck))
118118
}
119119
if len(meta.Databases) > 0 {
120120
aliases := make([]string, 0, len(meta.Databases))

internal/api/apikeys_test.go

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -337,13 +337,11 @@ func TestRevokeAPIKey(t *testing.T) {
337337
}
338338
}
339339

340-
func TestOperatorCanAccessOwnAPIKeys(t *testing.T) {
340+
func TestOperatorCannotAccessAPIKeysWithoutExplicitPermission(t *testing.T) {
341341
server, router, cleanup := setupAPIKeyTestServer(t)
342342
defer cleanup()
343343

344-
operator, _ := server.authManager.CreateUser("operator", "", "operatorpass", auth.RoleOperator, nil)
345-
346-
_, _, _ = server.authManager.CreateAPIKey(operator.ID, "Operator's Key", "", "", nil, nil, time.Time{})
344+
_, _ = server.authManager.CreateUser("operator", "", "operatorpass", auth.RoleOperator, nil)
347345

348346
token := apiKeyLogin(t, router, "operator", "operatorpass")
349347

@@ -353,16 +351,8 @@ func TestOperatorCanAccessOwnAPIKeys(t *testing.T) {
353351
w := httptest.NewRecorder()
354352
router.ServeHTTP(w, req)
355353

356-
if w.Code != http.StatusOK {
357-
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
358-
}
359-
360-
var resp map[string]interface{}
361-
_ = json.Unmarshal(w.Body.Bytes(), &resp)
362-
363-
keys := resp["api_keys"].([]interface{})
364-
if len(keys) != 1 {
365-
t.Errorf("Operator should see their own 1 key, got %d", len(keys))
354+
if w.Code != http.StatusForbidden {
355+
t.Errorf("Expected status 403, got %d: %s", w.Code, w.Body.String())
366356
}
367357
}
368358

internal/api/cluster_handlers.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -797,29 +797,39 @@ func (s *Server) clusterRemovePeer(c *gin.Context) {
797797
}
798798

799799
name := c.Param("name")
800+
if err := s.deleteClusterAPIKey(name); err != nil {
801+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete peer credential"})
802+
return
803+
}
800804
if err := mgr.RemovePeer(name); err != nil {
801805
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
802806
return
803807
}
804-
s.revokeClusterAPIKey(name)
805808

806809
c.JSON(http.StatusOK, gin.H{"status": "removed", "peer": name})
807810
}
808811

809-
func (s *Server) revokeClusterAPIKey(peerName string) {
812+
func (s *Server) deleteClusterAPIKey(peerName string) error {
810813
if s.authManager == nil {
811-
return
814+
return nil
815+
}
816+
userID, err := s.clusterServiceUserID()
817+
if err != nil {
818+
return err
812819
}
813820
keys, err := s.authManager.GetAllAPIKeys()
814821
if err != nil {
815-
return
822+
return err
816823
}
817824
name := fmt.Sprintf("cluster-peer-%s", peerName)
818825
for _, key := range keys {
819-
if key.Name == name {
820-
_ = s.authManager.DeactivateAPIKey(key.ID)
826+
if key.UserID == userID && key.Name == name {
827+
if err := s.authManager.DeleteAPIKey(key.ID); err != nil {
828+
return err
829+
}
821830
}
822831
}
832+
return nil
823833
}
824834

825835
func (s *Server) clusterProxy(c *gin.Context) {

internal/api/cluster_handlers_test.go

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/flatrun/agent/internal/auth"
1616
"github.com/flatrun/agent/internal/capacity"
1717
"github.com/flatrun/agent/internal/cluster"
18+
"github.com/flatrun/agent/internal/docker"
1819
"github.com/flatrun/agent/internal/orchestrator"
1920
"github.com/flatrun/agent/internal/routing"
2021
"github.com/flatrun/agent/pkg/config"
@@ -102,6 +103,7 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool
102103
configPath: tmpDir + "/config.yml",
103104
authManager: authManager,
104105
clusterManager: clusterManager,
106+
manager: docker.NewManager(tmpDir),
105107
}
106108

107109
router := gin.New()
@@ -116,6 +118,7 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool
116118
protected.Use(authMiddleware.RequireAuth())
117119
{
118120
protected.GET("/capacity", authMiddleware.RequirePermission(auth.PermSystemRead), server.getCapacityStatus)
121+
protected.GET("/deployments", authMiddleware.RequirePermission(auth.PermDeploymentsRead), server.listDeployments)
119122
protected.GET("/test/deployments", authMiddleware.RequirePermission(auth.PermDeploymentsRead), func(c *gin.Context) {
120123
c.Status(http.StatusNoContent)
121124
})
@@ -136,7 +139,11 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool
136139
clusterGroup.POST("/invite", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterInvite)
137140
clusterGroup.POST("/accept", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterAccept)
138141
clusterGroup.DELETE("/peers/:name", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterRemovePeer)
139-
clusterGroup.Any("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy)
142+
clusterGroup.GET("/peers/:name/proxy/*path", server.clusterProxy)
143+
clusterGroup.POST("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy)
144+
clusterGroup.PUT("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy)
145+
clusterGroup.PATCH("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy)
146+
clusterGroup.DELETE("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy)
140147
clusterGroup.GET("/deployments", server.clusterAggregateDeployments)
141148
clusterGroup.GET("/stats", server.clusterAggregateStats)
142149
clusterGroup.GET("/capacity", server.clusterAggregateCapacity)
@@ -159,6 +166,57 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool
159166
}
160167
}
161168

169+
func TestClusterDeploymentsIncludesPeerWhenLocalServerIsEmpty(t *testing.T) {
170+
local := setupClusterTestServer(t, "local", true)
171+
defer local.cleanup()
172+
remote := setupClusterTestServer(t, "remote", true)
173+
defer remote.cleanup()
174+
175+
if err := remote.server.manager.CreateDeployment("remote-app", `services:
176+
app:
177+
image: nginx:alpine
178+
`, nil); err != nil {
179+
t.Fatal(err)
180+
}
181+
const peerKey = "local-to-remote-key"
182+
if err := remote.server.createClusterAPIKey(peerKey, "local"); err != nil {
183+
t.Fatal(err)
184+
}
185+
remoteHTTP := httptest.NewServer(remote.router)
186+
defer remoteHTTP.Close()
187+
if err := local.server.clusterManager.AddPeer("remote", remoteHTTP.URL, peerKey); err != nil {
188+
t.Fatal(err)
189+
}
190+
191+
token := clusterLogin(t, local.router)
192+
req := httptest.NewRequest(http.MethodGet, "/api/cluster/deployments", nil)
193+
req.Header.Set("Authorization", "Bearer "+token)
194+
w := httptest.NewRecorder()
195+
local.router.ServeHTTP(w, req)
196+
if w.Code != http.StatusOK {
197+
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
198+
}
199+
var response struct {
200+
Servers map[string]struct {
201+
Data struct {
202+
Deployments []struct {
203+
Name string `json:"name"`
204+
} `json:"deployments"`
205+
} `json:"data"`
206+
} `json:"servers"`
207+
}
208+
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
209+
t.Fatal(err)
210+
}
211+
if len(response.Servers["local"].Data.Deployments) != 0 {
212+
t.Fatalf("local deployments = %#v", response.Servers["local"].Data.Deployments)
213+
}
214+
remoteDeployments := response.Servers["remote"].Data.Deployments
215+
if len(remoteDeployments) != 1 || remoteDeployments[0].Name != "remote-app" {
216+
t.Fatalf("remote deployments = %#v", remoteDeployments)
217+
}
218+
}
219+
162220
func TestClusterCapacityIncludesLocalOfferPolicy(t *testing.T) {
163221
env := setupClusterTestServer(t, "server-a", true)
164222
defer env.cleanup()
@@ -192,6 +250,52 @@ func TestClusterCapacityIncludesLocalOfferPolicy(t *testing.T) {
192250
}
193251
}
194252

253+
func TestClusterRemovePeerDeletesOnlyItsServiceCredential(t *testing.T) {
254+
env := setupClusterTestServer(t, "server-a", true)
255+
defer env.cleanup()
256+
257+
if err := env.server.clusterManager.AddPeer("server-b", "https://server-b.example.com", "peer-key"); err != nil {
258+
t.Fatal(err)
259+
}
260+
if err := env.server.createClusterAPIKey("credential-for-server-b", "server-b"); err != nil {
261+
t.Fatal(err)
262+
}
263+
admin, err := env.server.authManager.GetUserByUsername("admin")
264+
if err != nil {
265+
t.Fatal(err)
266+
}
267+
if _, _, err := env.server.authManager.CreateAPIKey(
268+
admin.ID, "cluster-peer-server-b", "User-managed key", auth.RoleAdmin, nil, nil, time.Time{},
269+
); err != nil {
270+
t.Fatal(err)
271+
}
272+
273+
token := clusterLogin(t, env.router)
274+
req := httptest.NewRequest(http.MethodDelete, "/api/cluster/peers/server-b", nil)
275+
req.Header.Set("Authorization", "Bearer "+token)
276+
w := httptest.NewRecorder()
277+
env.router.ServeHTTP(w, req)
278+
if w.Code != http.StatusOK {
279+
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
280+
}
281+
if _, err := env.server.clusterManager.GetPeer("server-b"); err == nil {
282+
t.Fatal("peer still exists")
283+
}
284+
keys, err := env.server.authManager.GetAllAPIKeys()
285+
if err != nil {
286+
t.Fatal(err)
287+
}
288+
var matching []auth.APIKey
289+
for _, key := range keys {
290+
if key.Name == "cluster-peer-server-b" {
291+
matching = append(matching, key)
292+
}
293+
}
294+
if len(matching) != 1 || matching[0].UserID != admin.ID {
295+
t.Fatalf("remaining matching keys = %+v", matching)
296+
}
297+
}
298+
195299
func TestUpdateClusterPeerPolicyThroughHTTP(t *testing.T) {
196300
env := setupClusterTestServer(t, "server-a", true)
197301
defer env.cleanup()
@@ -817,6 +921,47 @@ func TestClusterProxyForwardsToPeer(t *testing.T) {
817921
}
818922
}
819923

924+
func TestClusterProxyAllowsReadWithoutWrite(t *testing.T) {
925+
peerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
926+
w.Header().Set("Content-Type", "application/json")
927+
_, _ = w.Write([]byte(`{"deployment":{"name":"shop"}}`))
928+
}))
929+
defer peerServer.Close()
930+
931+
env := setupClusterTestServer(t, "primary", true)
932+
defer env.cleanup()
933+
if err := env.server.clusterManager.AddPeer("remote", peerServer.URL, "key"); err != nil {
934+
t.Fatal(err)
935+
}
936+
user, err := env.server.authManager.CreateUser("fleet-reader", "", "password", auth.RoleService, nil)
937+
if err != nil {
938+
t.Fatal(err)
939+
}
940+
_, err = env.server.authManager.CreateAPIKeyFromRaw(
941+
"fleet-reader-key", user.ID, "fleet-reader", "Fleet reader", auth.Role(""),
942+
[]string{auth.PermClusterRead.String()}, nil, time.Time{},
943+
)
944+
if err != nil {
945+
t.Fatal(err)
946+
}
947+
948+
req := httptest.NewRequest(http.MethodGet, "/api/cluster/peers/remote/proxy/deployments/shop", nil)
949+
req.Header.Set("Authorization", "Bearer fleet-reader-key")
950+
w := httptest.NewRecorder()
951+
env.router.ServeHTTP(w, req)
952+
if w.Code != http.StatusOK {
953+
t.Fatalf("read status = %d, body = %s", w.Code, w.Body.String())
954+
}
955+
956+
req = httptest.NewRequest(http.MethodPost, "/api/cluster/peers/remote/proxy/deployments/shop/restart", nil)
957+
req.Header.Set("Authorization", "Bearer fleet-reader-key")
958+
w = httptest.NewRecorder()
959+
env.router.ServeHTTP(w, req)
960+
if w.Code != http.StatusForbidden {
961+
t.Fatalf("write status = %d, body = %s", w.Code, w.Body.String())
962+
}
963+
}
964+
820965
func TestClusterProxyUnknownPeer(t *testing.T) {
821966
env := setupClusterTestServer(t, "primary", true)
822967
defer env.cleanup()

0 commit comments

Comments
 (0)