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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Sixth beta of the Albacore release, making connected servers manageable as one F
- 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
- Deployment management grants for administering allowed deployments through a peer
- Service-specific health checks for mixed web and database deployments

### Fixed
- Existing Fleet peers gain default access policies during startup repair without reconnecting
Expand All @@ -21,6 +23,11 @@ Sixth beta of the Albacore release, making connected servers manageable as one F
- 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
- Peer requests preserve uploads, downloads, query parameters, and response types
- Peer deployment actions use deployment permissions instead of Fleet configuration access
- Fleet access intersects module permissions, server-qualified user grants, and peer policy
- Operators no longer receive host shell or process-control access by default
- Certificate lists and actions are restricted to assigned deployments for non-admin users

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

Expand Down
6 changes: 4 additions & 2 deletions internal/api/ai_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ func (s *Server) platformSection(deploymentName string) ai.Section {
} else {
fmt.Fprintf(&b, "This deployment is not exposed through the reverse proxy\n")
}
if healthCheckConfigured(meta.HealthCheck) {
fmt.Fprintf(&b, "Configured health check type: %s\n", healthCheckType(meta.HealthCheck))
if checks := meta.EffectiveHealthChecks(); len(checks) > 0 {
for _, check := range checks {
fmt.Fprintf(&b, "Configured health check for %s: %s\n", check.Service, healthCheckType(check))
}
}
if len(meta.Databases) > 0 {
aliases := make([]string, 0, len(meta.Databases))
Expand Down
23 changes: 23 additions & 0 deletions internal/api/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@ func (s *Server) requireDeploymentAccess(c *gin.Context, deploymentName, level s
return true
}

func restrictClusterServiceResources(c *gin.Context) {
actor := auth.GetActorFromContext(c)
if actor == nil || actor.User == nil || actor.User.Role != auth.RoleService || actor.User.Username != "__flatrun_cluster" {
c.Next()
return
}

path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/deployments/") || strings.HasPrefix(path, "/api/containers/") ||
strings.HasPrefix(path, "/api/proxy/") {
c.Next()
return
}
for _, prefix := range []string{"/api/backups", "/api/certificates", "/api/credentials", "/api/images", "/api/security"} {
if strings.HasPrefix(path, prefix) {
c.JSON(http.StatusForbidden, gin.H{"error": "Fleet credentials require a deployment-scoped endpoint"})
c.Abort()
return
}
}
c.Next()
}

func (s *Server) requireContainerAccess(c *gin.Context, containerID, level string) bool {
if containerID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Container ID required"})
Expand Down
31 changes: 31 additions & 0 deletions internal/api/authz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,37 @@ func actorMiddleware(actor *auth.ActorContext) gin.HandlerFunc {
}
}

func TestClusterServiceCredentialsRejectUnscopedSensitiveResources(t *testing.T) {
gin.SetMode(gin.TestMode)
actor := &auth.ActorContext{
Type: "api_key",
Role: auth.RoleService,
User: &auth.User{Role: auth.RoleService, Username: "__flatrun_cluster"},
}

for _, path := range []string{"/api/backups/other", "/api/credentials", "/api/security/events"} {
router := gin.New()
router.Use(actorMiddleware(actor), restrictClusterServiceResources)
router.GET(path, func(c *gin.Context) { c.Status(http.StatusNoContent) })
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("%s status = %d", path, response.Code)
}
}

router := gin.New()
router.Use(actorMiddleware(actor), restrictClusterServiceResources)
router.GET("/api/deployments/:name/security", func(c *gin.Context) { c.Status(http.StatusNoContent) })
request := httptest.NewRequest(http.MethodGet, "/api/deployments/app/security", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("deployment-scoped status = %d", response.Code)
}
}

func TestListVirtualHostsFiltersByDeploymentAccess(t *testing.T) {
gin.SetMode(gin.TestMode)

Expand Down
30 changes: 30 additions & 0 deletions internal/api/backup_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,36 @@ func (s *Server) getBackup(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"backup": b})
}

func (s *Server) requireBackupDeployment(c *gin.Context) {
if s.backupManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
c.Abort()
return
}
b, err := s.backupManager.GetBackup(c.Param("id"))
if err != nil || b.DeploymentName != c.Param("name") {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
c.Abort()
return
}
c.Next()
}

func (s *Server) requireBackupJobDeployment(c *gin.Context) {
if s.backupManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
c.Abort()
return
}
job := s.backupManager.GetJob(c.Param("id"))
if job == nil || job.DeploymentName != c.Param("name") {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
c.Abort()
return
}
c.Next()
}

func (s *Server) createBackup(c *gin.Context) {
if s.backupManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
Expand Down
26 changes: 26 additions & 0 deletions internal/api/cert_renewal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"testing"
"time"

"github.com/flatrun/agent/internal/auth"
"github.com/flatrun/agent/internal/docker"
"github.com/flatrun/agent/internal/nginx"
"github.com/flatrun/agent/internal/proxy"
Expand Down Expand Up @@ -173,6 +174,31 @@ func TestListCertificates_AnnotatesDeploymentID(t *testing.T) {
}
}

func TestListCertificates_FiltersByDeploymentAccess(t *testing.T) {
server, deploymentsPath, certsPath := setupRenewalTestServer(t)
writeSelfSignedCert(t, certsPath, "mine.example.com")
writeSelfSignedCert(t, certsPath, "other.example.com")
writeSelfSignedCert(t, certsPath, "orphan.example.com")
writeDeploymentWithDomains(t, deploymentsPath, "mine", []models.DomainConfig{{Domain: "mine.example.com"}})
writeDeploymentWithDomains(t, deploymentsPath, "other", []models.DomainConfig{{Domain: "other.example.com"}})

router := gin.New()
router.Use(actorMiddleware(testActor(auth.RoleOperator, map[string]string{"mine": auth.AccessLevelRead})))
router.GET("/certificates", server.listCertificates)
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/certificates", nil))

var payload struct {
Certificates []models.Certificate `json:"certificates"`
}
if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &payload) != nil {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if len(payload.Certificates) != 1 || payload.Certificates[0].Domain != "mine.example.com" {
t.Fatalf("unexpected certificates: %+v", payload.Certificates)
}
}

func TestRenewDeploymentCertificates_CollectsAllDomainsAndAliases(t *testing.T) {
server, deploymentsPath, certsPath := setupRenewalTestServer(t)

Expand Down
167 changes: 161 additions & 6 deletions internal/api/cluster_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/flatrun/agent/internal/routing"
"github.com/flatrun/agent/internal/system"
"github.com/flatrun/agent/pkg/config"
"github.com/flatrun/agent/pkg/models"
"github.com/flatrun/agent/pkg/version"
"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -709,6 +710,19 @@ func clusterPolicyAccess(policy cluster.PeerPolicy) ([]string, auth.DeploymentAc
permissions[auth.PermContainersRead.String()] = true
permissions[auth.PermContainersWrite.String()] = true
unrestrictedDeployments = mergeClusterDeploymentAccess(deployments, grant.Deployments, auth.AccessLevelWrite, unrestrictedDeployments)
case cluster.CapabilityDeploymentsManage:
for _, permission := range []auth.Permission{
auth.PermDeploymentsRead, auth.PermDeploymentsWrite, auth.PermDeploymentsDelete,
auth.PermContainersRead, auth.PermContainersWrite, auth.PermContainersDelete,
auth.PermCertificatesRead, auth.PermCertificatesWrite, auth.PermCertificatesDelete,
auth.PermSecurityRead, auth.PermSecurityWrite, auth.PermImagesRead,
auth.PermImagesWrite, auth.PermImagesDelete, auth.PermBackupsRead,
auth.PermBackupsWrite, auth.PermBackupsDelete,
auth.PermSchedulerRead, auth.PermSchedulerWrite, auth.PermSchedulerDelete,
} {
permissions[permission.String()] = true
}
unrestrictedDeployments = mergeClusterDeploymentAccess(deployments, grant.Deployments, auth.AccessLevelAdmin, unrestrictedDeployments)
case cluster.CapabilityCapacityRead:
permissions[auth.PermSystemRead.String()] = true
case cluster.CapabilityCapacityOffer:
Expand All @@ -734,13 +748,26 @@ func mergeClusterDeploymentAccess(access auth.DeploymentAccess, names []string,
return true
}
for _, name := range names {
if current, ok := access[name]; !ok || current == auth.AccessLevelRead && level == auth.AccessLevelWrite {
if current, ok := access[name]; !ok || clusterAccessLevelRank(level) > clusterAccessLevelRank(current) {
access[name] = level
}
}
return false
}

func clusterAccessLevelRank(level string) int {
switch level {
case auth.AccessLevelRead:
return 1
case auth.AccessLevelWrite:
return 2
case auth.AccessLevelAdmin:
return 3
default:
return 0
}
}

func (s *Server) applyClusterPeerPolicy(policy cluster.PeerPolicy) error {
if s.authManager == nil {
return fmt.Errorf("Authentication manager is not available")
Expand Down Expand Up @@ -833,6 +860,9 @@ func (s *Server) deleteClusterAPIKey(peerName string) error {
}

func (s *Server) clusterProxy(c *gin.Context) {
if !authorizePeerProxy(c) {
return
}
mgr := s.getClusterManager()
if mgr == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cluster is not enabled"})
Expand All @@ -853,18 +883,143 @@ func (s *Server) clusterProxy(c *gin.Context) {
body = c.Request.Body
}

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

for k, v := range headers {
if k != "Content-Length" && k != "Transfer-Encoding" {
c.Header(k, v)
defer resp.Body.Close()
for k, values := range resp.Header {
if k != "Content-Length" && k != "Transfer-Encoding" && k != "Connection" {
for _, value := range values {
c.Writer.Header().Add(k, value)
}
}
}
if c.Request.Method == http.MethodGet && path == "/deployments" {
s.writeScopedPeerDeployments(c, name, resp)
return
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
}

func (s *Server) writeScopedPeerDeployments(c *gin.Context, peer string, resp *http.Response) {
actor := auth.GetActorFromContext(c)
if actor == nil || actor.Role == auth.RoleAdmin || resp.StatusCode != http.StatusOK {
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
return
}
var payload struct {
Deployments []models.Deployment `json:"deployments"`
Path string `json:"path,omitempty"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "Peer returned an invalid deployment list"})
return
}
visible := payload.Deployments[:0]
for _, deployment := range payload.Deployments {
if actor.CanAccessPeerDeployment(peer, deployment.Name, auth.AccessLevelRead) {
visible = append(visible, deployment)
}
}
payload.Deployments = visible
c.JSON(http.StatusOK, payload)
}

func authorizePeerProxy(c *gin.Context) bool {
actor := auth.GetActorFromContext(c)
if actor == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return false
}

path := c.Param("path")
peer := c.Param("name")
deployment := peerProxyDeployment(path)
if deployment == "" {
deployment = strings.TrimSpace(c.GetHeader("X-FlatRun-Deployment"))
}
if actor.Role != auth.RoleAdmin && path != "/deployments" && deployment == "" {
c.JSON(http.StatusForbidden, gin.H{"error": "A deployment scope is required"})
return false
}
requiredLevel := auth.AccessLevelRead
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
requiredLevel = auth.AccessLevelWrite
}
if c.Request.Method == http.MethodDelete {
requiredLevel = auth.AccessLevelAdmin
}
if deployment != "" && !actor.CanAccessPeerDeployment(peer, deployment, requiredLevel) {
c.JSON(http.StatusForbidden, gin.H{"error": "No access to this peer deployment"})
return false
}
permission := auth.PermDeploymentsRead
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
permission = auth.PermDeploymentsWrite
}
if c.Request.Method == http.MethodDelete {
permission = auth.PermDeploymentsDelete
}

switch {
case strings.HasPrefix(path, "/containers/"):
permission = auth.PermContainersRead
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
permission = auth.PermContainersWrite
}
if c.Request.Method == http.MethodDelete {
permission = auth.PermContainersDelete
}
case strings.HasPrefix(path, "/certificates"), strings.HasPrefix(path, "/proxy/"):
permission = auth.PermCertificatesRead
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
permission = auth.PermCertificatesWrite
}
if c.Request.Method == http.MethodDelete {
permission = auth.PermCertificatesDelete
}
case strings.Contains(path, "/security"):
permission = auth.PermSecurityRead
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
permission = auth.PermSecurityWrite
}
case strings.HasPrefix(path, "/backups"), strings.Contains(path, "/backups"):
permission = auth.PermBackupsRead
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
permission = auth.PermBackupsWrite
}
if c.Request.Method == http.MethodDelete {
permission = auth.PermBackupsDelete
}
case strings.HasPrefix(path, "/credentials"):
permission = auth.PermRegistriesRead
}

if !actor.HasPermission(permission) {
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied", "required": permission})
return false
}
return true
}

func peerProxyDeployment(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) >= 2 && parts[0] == "deployments" {
name, err := url.PathUnescape(parts[1])
if err == nil {
return name
}
}
c.Data(status, "application/json", data)
return ""
}

func (s *Server) clusterAggregateDeployments(c *gin.Context) {
Expand Down
Loading
Loading