Skip to content

Commit 73ce437

Browse files
authored
Merge pull request #218 from flatrun/fix/peer-deployment-management
feat: Add scoped peer deployment management
2 parents 257924d + 5790824 commit 73ce437

20 files changed

Lines changed: 803 additions & 52 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Sixth beta of the Albacore release, making connected servers manageable as one F
1010
- Docker Swarm and k3s orchestration adapters with nginx and Traefik routing adapters
1111
- Grouped incidents and configurable notification targets and delivery rules
1212
- HTTP, TCP, and container command health checks for web services and databases
13+
- Deployment management grants for administering allowed deployments through a peer
14+
- Service-specific health checks for mixed web and database deployments
1315

1416
### Fixed
1517
- Existing Fleet peers gain default access policies during startup repair without reconnecting
@@ -21,6 +23,11 @@ Sixth beta of the Albacore release, making connected servers manageable as one F
2123
- Settings, notifications, and API keys require explicit access for non-admin roles
2224
- Repeated metric alerts share one incident until every affected series recovers
2325
- Email headers keep the white logo visible in clients that ignore inline CSS
26+
- Peer requests preserve uploads, downloads, query parameters, and response types
27+
- Peer deployment actions use deployment permissions instead of Fleet configuration access
28+
- Fleet access intersects module permissions, server-qualified user grants, and peer policy
29+
- Operators no longer receive host shell or process-control access by default
30+
- Certificate lists and actions are restricted to assigned deployments for non-admin users
2431

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

internal/api/ai_handlers.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,10 @@ 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 healthCheckConfigured(meta.HealthCheck) {
117-
fmt.Fprintf(&b, "Configured health check type: %s\n", healthCheckType(meta.HealthCheck))
116+
if checks := meta.EffectiveHealthChecks(); len(checks) > 0 {
117+
for _, check := range checks {
118+
fmt.Fprintf(&b, "Configured health check for %s: %s\n", check.Service, healthCheckType(check))
119+
}
118120
}
119121
if len(meta.Databases) > 0 {
120122
aliases := make([]string, 0, len(meta.Databases))

internal/api/authz.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@ func (s *Server) requireDeploymentAccess(c *gin.Context, deploymentName, level s
3535
return true
3636
}
3737

38+
func restrictClusterServiceResources(c *gin.Context) {
39+
actor := auth.GetActorFromContext(c)
40+
if actor == nil || actor.User == nil || actor.User.Role != auth.RoleService || actor.User.Username != "__flatrun_cluster" {
41+
c.Next()
42+
return
43+
}
44+
45+
path := c.Request.URL.Path
46+
if strings.HasPrefix(path, "/api/deployments/") || strings.HasPrefix(path, "/api/containers/") ||
47+
strings.HasPrefix(path, "/api/proxy/") {
48+
c.Next()
49+
return
50+
}
51+
for _, prefix := range []string{"/api/backups", "/api/certificates", "/api/credentials", "/api/images", "/api/security"} {
52+
if strings.HasPrefix(path, prefix) {
53+
c.JSON(http.StatusForbidden, gin.H{"error": "Fleet credentials require a deployment-scoped endpoint"})
54+
c.Abort()
55+
return
56+
}
57+
}
58+
c.Next()
59+
}
60+
3861
func (s *Server) requireContainerAccess(c *gin.Context, containerID, level string) bool {
3962
if containerID == "" {
4063
c.JSON(http.StatusBadRequest, gin.H{"error": "Container ID required"})

internal/api/authz_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,37 @@ func actorMiddleware(actor *auth.ActorContext) gin.HandlerFunc {
3939
}
4040
}
4141

42+
func TestClusterServiceCredentialsRejectUnscopedSensitiveResources(t *testing.T) {
43+
gin.SetMode(gin.TestMode)
44+
actor := &auth.ActorContext{
45+
Type: "api_key",
46+
Role: auth.RoleService,
47+
User: &auth.User{Role: auth.RoleService, Username: "__flatrun_cluster"},
48+
}
49+
50+
for _, path := range []string{"/api/backups/other", "/api/credentials", "/api/security/events"} {
51+
router := gin.New()
52+
router.Use(actorMiddleware(actor), restrictClusterServiceResources)
53+
router.GET(path, func(c *gin.Context) { c.Status(http.StatusNoContent) })
54+
request := httptest.NewRequest(http.MethodGet, path, nil)
55+
response := httptest.NewRecorder()
56+
router.ServeHTTP(response, request)
57+
if response.Code != http.StatusForbidden {
58+
t.Fatalf("%s status = %d", path, response.Code)
59+
}
60+
}
61+
62+
router := gin.New()
63+
router.Use(actorMiddleware(actor), restrictClusterServiceResources)
64+
router.GET("/api/deployments/:name/security", func(c *gin.Context) { c.Status(http.StatusNoContent) })
65+
request := httptest.NewRequest(http.MethodGet, "/api/deployments/app/security", nil)
66+
response := httptest.NewRecorder()
67+
router.ServeHTTP(response, request)
68+
if response.Code != http.StatusNoContent {
69+
t.Fatalf("deployment-scoped status = %d", response.Code)
70+
}
71+
}
72+
4273
func TestListVirtualHostsFiltersByDeploymentAccess(t *testing.T) {
4374
gin.SetMode(gin.TestMode)
4475

internal/api/backup_handlers.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,36 @@ func (s *Server) getBackup(c *gin.Context) {
7070
c.JSON(http.StatusOK, gin.H{"backup": b})
7171
}
7272

73+
func (s *Server) requireBackupDeployment(c *gin.Context) {
74+
if s.backupManager == nil {
75+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
76+
c.Abort()
77+
return
78+
}
79+
b, err := s.backupManager.GetBackup(c.Param("id"))
80+
if err != nil || b.DeploymentName != c.Param("name") {
81+
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
82+
c.Abort()
83+
return
84+
}
85+
c.Next()
86+
}
87+
88+
func (s *Server) requireBackupJobDeployment(c *gin.Context) {
89+
if s.backupManager == nil {
90+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
91+
c.Abort()
92+
return
93+
}
94+
job := s.backupManager.GetJob(c.Param("id"))
95+
if job == nil || job.DeploymentName != c.Param("name") {
96+
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
97+
c.Abort()
98+
return
99+
}
100+
c.Next()
101+
}
102+
73103
func (s *Server) createBackup(c *gin.Context) {
74104
if s.backupManager == nil {
75105
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})

internal/api/cert_renewal_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"testing"
1717
"time"
1818

19+
"github.com/flatrun/agent/internal/auth"
1920
"github.com/flatrun/agent/internal/docker"
2021
"github.com/flatrun/agent/internal/nginx"
2122
"github.com/flatrun/agent/internal/proxy"
@@ -173,6 +174,31 @@ func TestListCertificates_AnnotatesDeploymentID(t *testing.T) {
173174
}
174175
}
175176

177+
func TestListCertificates_FiltersByDeploymentAccess(t *testing.T) {
178+
server, deploymentsPath, certsPath := setupRenewalTestServer(t)
179+
writeSelfSignedCert(t, certsPath, "mine.example.com")
180+
writeSelfSignedCert(t, certsPath, "other.example.com")
181+
writeSelfSignedCert(t, certsPath, "orphan.example.com")
182+
writeDeploymentWithDomains(t, deploymentsPath, "mine", []models.DomainConfig{{Domain: "mine.example.com"}})
183+
writeDeploymentWithDomains(t, deploymentsPath, "other", []models.DomainConfig{{Domain: "other.example.com"}})
184+
185+
router := gin.New()
186+
router.Use(actorMiddleware(testActor(auth.RoleOperator, map[string]string{"mine": auth.AccessLevelRead})))
187+
router.GET("/certificates", server.listCertificates)
188+
response := httptest.NewRecorder()
189+
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/certificates", nil))
190+
191+
var payload struct {
192+
Certificates []models.Certificate `json:"certificates"`
193+
}
194+
if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &payload) != nil {
195+
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
196+
}
197+
if len(payload.Certificates) != 1 || payload.Certificates[0].Domain != "mine.example.com" {
198+
t.Fatalf("unexpected certificates: %+v", payload.Certificates)
199+
}
200+
}
201+
176202
func TestRenewDeploymentCertificates_CollectsAllDomainsAndAliases(t *testing.T) {
177203
server, deploymentsPath, certsPath := setupRenewalTestServer(t)
178204

internal/api/cluster_handlers.go

Lines changed: 161 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/flatrun/agent/internal/routing"
2424
"github.com/flatrun/agent/internal/system"
2525
"github.com/flatrun/agent/pkg/config"
26+
"github.com/flatrun/agent/pkg/models"
2627
"github.com/flatrun/agent/pkg/version"
2728
"github.com/gin-gonic/gin"
2829
)
@@ -709,6 +710,19 @@ func clusterPolicyAccess(policy cluster.PeerPolicy) ([]string, auth.DeploymentAc
709710
permissions[auth.PermContainersRead.String()] = true
710711
permissions[auth.PermContainersWrite.String()] = true
711712
unrestrictedDeployments = mergeClusterDeploymentAccess(deployments, grant.Deployments, auth.AccessLevelWrite, unrestrictedDeployments)
713+
case cluster.CapabilityDeploymentsManage:
714+
for _, permission := range []auth.Permission{
715+
auth.PermDeploymentsRead, auth.PermDeploymentsWrite, auth.PermDeploymentsDelete,
716+
auth.PermContainersRead, auth.PermContainersWrite, auth.PermContainersDelete,
717+
auth.PermCertificatesRead, auth.PermCertificatesWrite, auth.PermCertificatesDelete,
718+
auth.PermSecurityRead, auth.PermSecurityWrite, auth.PermImagesRead,
719+
auth.PermImagesWrite, auth.PermImagesDelete, auth.PermBackupsRead,
720+
auth.PermBackupsWrite, auth.PermBackupsDelete,
721+
auth.PermSchedulerRead, auth.PermSchedulerWrite, auth.PermSchedulerDelete,
722+
} {
723+
permissions[permission.String()] = true
724+
}
725+
unrestrictedDeployments = mergeClusterDeploymentAccess(deployments, grant.Deployments, auth.AccessLevelAdmin, unrestrictedDeployments)
712726
case cluster.CapabilityCapacityRead:
713727
permissions[auth.PermSystemRead.String()] = true
714728
case cluster.CapabilityCapacityOffer:
@@ -734,13 +748,26 @@ func mergeClusterDeploymentAccess(access auth.DeploymentAccess, names []string,
734748
return true
735749
}
736750
for _, name := range names {
737-
if current, ok := access[name]; !ok || current == auth.AccessLevelRead && level == auth.AccessLevelWrite {
751+
if current, ok := access[name]; !ok || clusterAccessLevelRank(level) > clusterAccessLevelRank(current) {
738752
access[name] = level
739753
}
740754
}
741755
return false
742756
}
743757

758+
func clusterAccessLevelRank(level string) int {
759+
switch level {
760+
case auth.AccessLevelRead:
761+
return 1
762+
case auth.AccessLevelWrite:
763+
return 2
764+
case auth.AccessLevelAdmin:
765+
return 3
766+
default:
767+
return 0
768+
}
769+
}
770+
744771
func (s *Server) applyClusterPeerPolicy(policy cluster.PeerPolicy) error {
745772
if s.authManager == nil {
746773
return fmt.Errorf("Authentication manager is not available")
@@ -833,6 +860,9 @@ func (s *Server) deleteClusterAPIKey(peerName string) error {
833860
}
834861

835862
func (s *Server) clusterProxy(c *gin.Context) {
863+
if !authorizePeerProxy(c) {
864+
return
865+
}
836866
mgr := s.getClusterManager()
837867
if mgr == nil {
838868
c.JSON(http.StatusBadRequest, gin.H{"error": "Cluster is not enabled"})
@@ -853,18 +883,143 @@ func (s *Server) clusterProxy(c *gin.Context) {
853883
body = c.Request.Body
854884
}
855885

856-
data, status, headers, err := client.Forward(c.Request.Context(), c.Request.Method, "/api"+path, body)
886+
forwardPath := "/api" + path
887+
if c.Request.URL.RawQuery != "" {
888+
forwardPath += "?" + c.Request.URL.RawQuery
889+
}
890+
resp, err := client.DoWithHeaders(c.Request.Context(), c.Request.Method, forwardPath, c.Request.Header, body)
857891
if err != nil {
858892
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Failed to proxy request: %v", err)})
859893
return
860894
}
861895

862-
for k, v := range headers {
863-
if k != "Content-Length" && k != "Transfer-Encoding" {
864-
c.Header(k, v)
896+
defer resp.Body.Close()
897+
for k, values := range resp.Header {
898+
if k != "Content-Length" && k != "Transfer-Encoding" && k != "Connection" {
899+
for _, value := range values {
900+
c.Writer.Header().Add(k, value)
901+
}
902+
}
903+
}
904+
if c.Request.Method == http.MethodGet && path == "/deployments" {
905+
s.writeScopedPeerDeployments(c, name, resp)
906+
return
907+
}
908+
c.Status(resp.StatusCode)
909+
_, _ = io.Copy(c.Writer, resp.Body)
910+
}
911+
912+
func (s *Server) writeScopedPeerDeployments(c *gin.Context, peer string, resp *http.Response) {
913+
actor := auth.GetActorFromContext(c)
914+
if actor == nil || actor.Role == auth.RoleAdmin || resp.StatusCode != http.StatusOK {
915+
c.Status(resp.StatusCode)
916+
_, _ = io.Copy(c.Writer, resp.Body)
917+
return
918+
}
919+
var payload struct {
920+
Deployments []models.Deployment `json:"deployments"`
921+
Path string `json:"path,omitempty"`
922+
}
923+
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
924+
c.JSON(http.StatusBadGateway, gin.H{"error": "Peer returned an invalid deployment list"})
925+
return
926+
}
927+
visible := payload.Deployments[:0]
928+
for _, deployment := range payload.Deployments {
929+
if actor.CanAccessPeerDeployment(peer, deployment.Name, auth.AccessLevelRead) {
930+
visible = append(visible, deployment)
931+
}
932+
}
933+
payload.Deployments = visible
934+
c.JSON(http.StatusOK, payload)
935+
}
936+
937+
func authorizePeerProxy(c *gin.Context) bool {
938+
actor := auth.GetActorFromContext(c)
939+
if actor == nil {
940+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
941+
return false
942+
}
943+
944+
path := c.Param("path")
945+
peer := c.Param("name")
946+
deployment := peerProxyDeployment(path)
947+
if deployment == "" {
948+
deployment = strings.TrimSpace(c.GetHeader("X-FlatRun-Deployment"))
949+
}
950+
if actor.Role != auth.RoleAdmin && path != "/deployments" && deployment == "" {
951+
c.JSON(http.StatusForbidden, gin.H{"error": "A deployment scope is required"})
952+
return false
953+
}
954+
requiredLevel := auth.AccessLevelRead
955+
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
956+
requiredLevel = auth.AccessLevelWrite
957+
}
958+
if c.Request.Method == http.MethodDelete {
959+
requiredLevel = auth.AccessLevelAdmin
960+
}
961+
if deployment != "" && !actor.CanAccessPeerDeployment(peer, deployment, requiredLevel) {
962+
c.JSON(http.StatusForbidden, gin.H{"error": "No access to this peer deployment"})
963+
return false
964+
}
965+
permission := auth.PermDeploymentsRead
966+
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
967+
permission = auth.PermDeploymentsWrite
968+
}
969+
if c.Request.Method == http.MethodDelete {
970+
permission = auth.PermDeploymentsDelete
971+
}
972+
973+
switch {
974+
case strings.HasPrefix(path, "/containers/"):
975+
permission = auth.PermContainersRead
976+
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
977+
permission = auth.PermContainersWrite
978+
}
979+
if c.Request.Method == http.MethodDelete {
980+
permission = auth.PermContainersDelete
981+
}
982+
case strings.HasPrefix(path, "/certificates"), strings.HasPrefix(path, "/proxy/"):
983+
permission = auth.PermCertificatesRead
984+
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
985+
permission = auth.PermCertificatesWrite
986+
}
987+
if c.Request.Method == http.MethodDelete {
988+
permission = auth.PermCertificatesDelete
989+
}
990+
case strings.Contains(path, "/security"):
991+
permission = auth.PermSecurityRead
992+
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
993+
permission = auth.PermSecurityWrite
994+
}
995+
case strings.HasPrefix(path, "/backups"), strings.Contains(path, "/backups"):
996+
permission = auth.PermBackupsRead
997+
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
998+
permission = auth.PermBackupsWrite
999+
}
1000+
if c.Request.Method == http.MethodDelete {
1001+
permission = auth.PermBackupsDelete
1002+
}
1003+
case strings.HasPrefix(path, "/credentials"):
1004+
permission = auth.PermRegistriesRead
1005+
}
1006+
1007+
if !actor.HasPermission(permission) {
1008+
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied", "required": permission})
1009+
return false
1010+
}
1011+
return true
1012+
}
1013+
1014+
func peerProxyDeployment(path string) string {
1015+
parts := strings.Split(strings.Trim(path, "/"), "/")
1016+
if len(parts) >= 2 && parts[0] == "deployments" {
1017+
name, err := url.PathUnescape(parts[1])
1018+
if err == nil {
1019+
return name
8651020
}
8661021
}
867-
c.Data(status, "application/json", data)
1022+
return ""
8681023
}
8691024

8701025
func (s *Server) clusterAggregateDeployments(c *gin.Context) {

0 commit comments

Comments
 (0)