Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
# Changelog

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

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

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

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

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

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.0-beta.5
0.4.0-beta.6
4 changes: 2 additions & 2 deletions internal/api/ai_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
18 changes: 4 additions & 14 deletions internal/api/apikeys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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())
}
}

Expand Down
22 changes: 16 additions & 6 deletions internal/api/cluster_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
147 changes: 146 additions & 1 deletion internal/api/cluster_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand All @@ -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)
})
Expand All @@ -136,7 +139,11 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool
clusterGroup.POST("/invite", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterInvite)
clusterGroup.POST("/accept", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterAccept)
clusterGroup.DELETE("/peers/:name", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterRemovePeer)
clusterGroup.Any("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy)
clusterGroup.GET("/peers/:name/proxy/*path", server.clusterProxy)
clusterGroup.POST("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy)
clusterGroup.PUT("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy)
clusterGroup.PATCH("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy)
clusterGroup.DELETE("/peers/:name/proxy/*path", authMiddleware.RequirePermission(auth.PermClusterWrite), server.clusterProxy)
clusterGroup.GET("/deployments", server.clusterAggregateDeployments)
clusterGroup.GET("/stats", server.clusterAggregateStats)
clusterGroup.GET("/capacity", server.clusterAggregateCapacity)
Expand All @@ -159,6 +166,57 @@ func setupClusterTestServer(t *testing.T, serverName string, clusterEnabled bool
}
}

func TestClusterDeploymentsIncludesPeerWhenLocalServerIsEmpty(t *testing.T) {
local := setupClusterTestServer(t, "local", true)
defer local.cleanup()
remote := setupClusterTestServer(t, "remote", true)
defer remote.cleanup()

if err := remote.server.manager.CreateDeployment("remote-app", `services:
app:
image: nginx:alpine
`, nil); err != nil {
t.Fatal(err)
}
const peerKey = "local-to-remote-key"
if err := remote.server.createClusterAPIKey(peerKey, "local"); err != nil {
t.Fatal(err)
}
remoteHTTP := httptest.NewServer(remote.router)
defer remoteHTTP.Close()
if err := local.server.clusterManager.AddPeer("remote", remoteHTTP.URL, peerKey); err != nil {
t.Fatal(err)
}

token := clusterLogin(t, local.router)
req := httptest.NewRequest(http.MethodGet, "/api/cluster/deployments", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
local.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
}
var response struct {
Servers map[string]struct {
Data struct {
Deployments []struct {
Name string `json:"name"`
} `json:"deployments"`
} `json:"data"`
} `json:"servers"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Servers["local"].Data.Deployments) != 0 {
t.Fatalf("local deployments = %#v", response.Servers["local"].Data.Deployments)
}
remoteDeployments := response.Servers["remote"].Data.Deployments
if len(remoteDeployments) != 1 || remoteDeployments[0].Name != "remote-app" {
t.Fatalf("remote deployments = %#v", remoteDeployments)
}
}

func TestClusterCapacityIncludesLocalOfferPolicy(t *testing.T) {
env := setupClusterTestServer(t, "server-a", true)
defer env.cleanup()
Expand Down Expand Up @@ -192,6 +250,52 @@ func TestClusterCapacityIncludesLocalOfferPolicy(t *testing.T) {
}
}

func TestClusterRemovePeerDeletesOnlyItsServiceCredential(t *testing.T) {
env := setupClusterTestServer(t, "server-a", true)
defer env.cleanup()

if err := env.server.clusterManager.AddPeer("server-b", "https://server-b.example.com", "peer-key"); err != nil {
t.Fatal(err)
}
if err := env.server.createClusterAPIKey("credential-for-server-b", "server-b"); err != nil {
t.Fatal(err)
}
admin, err := env.server.authManager.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if _, _, err := env.server.authManager.CreateAPIKey(
admin.ID, "cluster-peer-server-b", "User-managed key", auth.RoleAdmin, nil, nil, time.Time{},
); err != nil {
t.Fatal(err)
}

token := clusterLogin(t, env.router)
req := httptest.NewRequest(http.MethodDelete, "/api/cluster/peers/server-b", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
}
if _, err := env.server.clusterManager.GetPeer("server-b"); err == nil {
t.Fatal("peer still exists")
}
keys, err := env.server.authManager.GetAllAPIKeys()
if err != nil {
t.Fatal(err)
}
var matching []auth.APIKey
for _, key := range keys {
if key.Name == "cluster-peer-server-b" {
matching = append(matching, key)
}
}
if len(matching) != 1 || matching[0].UserID != admin.ID {
t.Fatalf("remaining matching keys = %+v", matching)
}
}

func TestUpdateClusterPeerPolicyThroughHTTP(t *testing.T) {
env := setupClusterTestServer(t, "server-a", true)
defer env.cleanup()
Expand Down Expand Up @@ -817,6 +921,47 @@ func TestClusterProxyForwardsToPeer(t *testing.T) {
}
}

func TestClusterProxyAllowsReadWithoutWrite(t *testing.T) {
peerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"deployment":{"name":"shop"}}`))
}))
defer peerServer.Close()

env := setupClusterTestServer(t, "primary", true)
defer env.cleanup()
if err := env.server.clusterManager.AddPeer("remote", peerServer.URL, "key"); err != nil {
t.Fatal(err)
}
user, err := env.server.authManager.CreateUser("fleet-reader", "", "password", auth.RoleService, nil)
if err != nil {
t.Fatal(err)
}
_, err = env.server.authManager.CreateAPIKeyFromRaw(
"fleet-reader-key", user.ID, "fleet-reader", "Fleet reader", auth.Role(""),
[]string{auth.PermClusterRead.String()}, nil, time.Time{},
)
if err != nil {
t.Fatal(err)
}

req := httptest.NewRequest(http.MethodGet, "/api/cluster/peers/remote/proxy/deployments/shop", nil)
req.Header.Set("Authorization", "Bearer fleet-reader-key")
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("read status = %d, body = %s", w.Code, w.Body.String())
}

req = httptest.NewRequest(http.MethodPost, "/api/cluster/peers/remote/proxy/deployments/shop/restart", nil)
req.Header.Set("Authorization", "Bearer fleet-reader-key")
w = httptest.NewRecorder()
env.router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("write status = %d, body = %s", w.Code, w.Body.String())
}
}

func TestClusterProxyUnknownPeer(t *testing.T) {
env := setupClusterTestServer(t, "primary", true)
defer env.cleanup()
Expand Down
Loading
Loading