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
137 changes: 125 additions & 12 deletions internal/api/apikeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,27 +79,50 @@ func (s *Server) getAPIKey(c *gin.Context) {

func (s *Server) createAPIKey(c *gin.Context) {
actor := auth.GetActorFromContext(c)
if actor == nil || actor.User == nil {
if actor == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}

ownerUser := actor.User
if ownerUser == nil {
if actor.Role != auth.RoleAdmin {
c.JSON(http.StatusForbidden, gin.H{"error": "Current authentication has no user identity; API keys cannot be created"})
return
}
users, err := s.authManager.GetUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot list users to attribute API key: " + err.Error()})
return
}
for i := range users {
if users[i].Role == auth.RoleAdmin {
ownerUser = &users[i]
break
}
}
if ownerUser == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "No admin user available to own the new API key"})
return
}
}

var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Role auth.Role `json:"role"`
Permissions []string `json:"permissions"`
Deployments []string `json:"deployments"`
ExpiresIn int `json:"expires_in"`
UserID int64 `json:"user_id"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Role auth.Role `json:"role"`
Permissions []string `json:"permissions"`
Deployments auth.DeploymentAccess `json:"deployments"`
ExpiresIn int `json:"expires_in"`
UserID int64 `json:"user_id"`
}

if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}

userID := actor.User.ID
userID := ownerUser.ID
if req.UserID > 0 && actor.Role == auth.RoleAdmin {
userID = req.UserID
}
Expand Down Expand Up @@ -179,7 +202,7 @@ func (s *Server) revokeAPIKey(c *gin.Context) {
}

func apiKeyToResponse(k *auth.APIKey) gin.H {
return gin.H{
response := gin.H{
"id": k.ID,
"key_id": k.KeyID,
"user_id": k.UserID,
Expand All @@ -189,10 +212,100 @@ func apiKeyToResponse(k *auth.APIKey) gin.H {
"role": k.Role,
"permissions": k.Permissions,
"deployments": k.Deployments,
"expires_at": k.ExpiresAt,
"last_used_at": k.LastUsedAt,
"last_used_ip": k.LastUsedIP,
"is_active": k.IsActive,
"created_at": k.CreatedAt,
}
if k.ExpiresAt.IsZero() {
response["expires_at"] = nil
} else {
response["expires_at"] = k.ExpiresAt
}
if k.LastUsedAt.IsZero() {
response["last_used_at"] = nil
} else {
response["last_used_at"] = k.LastUsedAt
}
return response
}

func (s *Server) updateAPIKey(c *gin.Context) {
key, ok := s.getAPIKeyWithAuth(c)
if !ok {
return
}
actor := auth.GetActorFromContext(c)
if actor == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}

var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
Role *auth.Role `json:"role"`
Permissions *[]string `json:"permissions"`
Deployments *auth.DeploymentAccess `json:"deployments"`
ExpiresIn *int `json:"expires_in"`
}

if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}

if req.Name != nil {
key.Name = *req.Name
}
if req.Description != nil {
key.Description = *req.Description
}
if req.Role != nil {
if *req.Role != "" && !req.Role.IsValid() {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
return
}
if actor.Role != auth.RoleAdmin && *req.Role == auth.RoleAdmin {
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot grant admin role"})
return
}
key.Role = *req.Role
}
if req.Permissions != nil {
if actor.Role != auth.RoleAdmin {
for _, p := range *req.Permissions {
if !actor.HasPermission(auth.Permission(p)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot grant permission you don't have: " + p})
return
}
}
}
key.Permissions = *req.Permissions
}
if req.Deployments != nil {
key.Deployments = *req.Deployments
}
if req.ExpiresIn != nil {
if *req.ExpiresIn > 0 {
key.ExpiresAt = time.Now().Add(time.Duration(*req.ExpiresIn) * time.Second)
} else {
key.ExpiresAt = time.Time{}
}
}

updated, err := s.authManager.UpdateAPIKey(
key.ID,
key.Name,
key.Description,
key.Role,
key.Permissions,
key.Deployments,
key.ExpiresAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"})
return
}

c.JSON(http.StatusOK, gin.H{"api_key": apiKeyToResponse(updated)})
}
5 changes: 4 additions & 1 deletion internal/api/apikeys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,8 +416,11 @@ func TestCreateAPIKeyWithDeploymentScope(t *testing.T) {
_ = json.Unmarshal(w.Body.Bytes(), &resp)

apiKey := resp["api_key"].(map[string]interface{})
deployments := apiKey["deployments"].([]interface{})
deployments := apiKey["deployments"].(map[string]interface{})
if len(deployments) != 2 {
t.Errorf("Expected 2 deployments, got %d", len(deployments))
}
if deployments["app-a"] != "admin" || deployments["app-b"] != "admin" {
t.Errorf("Legacy array deployments should default to admin level, got %v", deployments)
}
}
120 changes: 119 additions & 1 deletion internal/api/container_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import (
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"

"github.com/creack/pty"
"github.com/flatrun/agent/internal/auth"
"github.com/flatrun/agent/internal/contextkeys"
"github.com/flatrun/agent/pkg/models"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
Expand Down Expand Up @@ -86,6 +88,15 @@ func (s *Server) containerExec(c *gin.Context) {
sendError(conn, "No access to this container")
return
}
if deploymentName, err := containerDeploymentName(containerID); err == nil && deploymentName != "" {
if blocked, reason, err := s.protectedDeploymentActionBlocked(deploymentName, protectedActionTerminal); err != nil {
sendError(conn, "Failed to check protected mode: "+err.Error())
return
} else if blocked {
sendTerminalError(conn, "protected_mode", reason)
return
}
}
if s.authMiddleware.IsAuthEnabled() {
if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"auth_success"}`)); err != nil {
return
Expand Down Expand Up @@ -146,6 +157,7 @@ func (s *Server) containerExec(c *gin.Context) {
wg.Add(1)
go func() {
defer wg.Done()
var commandBuffer strings.Builder
for {
select {
case <-done:
Expand All @@ -169,7 +181,13 @@ func (s *Server) containerExec(c *gin.Context) {
}
}

// Write input to PTY
if blocked, err := s.handleTerminalInput(containerID, ptmx, conn, message, &commandBuffer); err != nil {
log.Printf("PTY write error: %v", err)
return
} else if blocked {
continue
}

if _, err := ptmx.Write(message); err != nil {
log.Printf("PTY write error: %v", err)
return
Expand All @@ -187,6 +205,43 @@ func (s *Server) containerExec(c *gin.Context) {
wg.Wait()
}

func (s *Server) handleTerminalInput(containerID string, ptmx *os.File, conn *websocket.Conn, message []byte, commandBuffer *strings.Builder) (bool, error) {
for _, b := range message {
switch b {
case '\r', '\n':
command := strings.TrimSpace(commandBuffer.String())
commandBuffer.Reset()
if command == "" {
continue
}
blocked, rule, err := s.protectedContainerCommandBlocked(containerID, command)
if err != nil {
return false, err
}
if blocked {
if _, err := ptmx.Write([]byte{0x15}); err != nil {
return false, err
}
sendTerminalCommandBlocked(conn, command, rule)
return true, nil
}
case 0x03:
commandBuffer.Reset()
case 0x7f, 0x08:
current := commandBuffer.String()
if len(current) > 0 {
commandBuffer.Reset()
commandBuffer.WriteString(current[:len(current)-1])
}
default:
if b >= 0x20 {
commandBuffer.WriteByte(b)
}
}
}
return false, nil
}

func detectShell(containerID string) string {
shells := []string{"/bin/bash", "/bin/sh", "sh"}

Expand All @@ -206,6 +261,35 @@ func sendError(conn *websocket.Conn, msg string) {
_ = conn.WriteMessage(websocket.TextMessage, []byte("\r\n\x1b[31mError: "+msg+"\x1b[0m\r\n"))
}

func sendTerminalError(conn *websocket.Conn, code, msg string) {
payload, err := json.Marshal(gin.H{
"type": "error",
"code": code,
"message": msg,
})
if err != nil {
sendError(conn, msg)
return
}
_ = conn.WriteMessage(websocket.TextMessage, payload)
}

func sendTerminalCommandBlocked(conn *websocket.Conn, command string, rule *models.ProtectedCommandRule) {
ruleLabel := ""
if rule != nil {
ruleLabel = rule.Name
if ruleLabel == "" {
ruleLabel = rule.ID
}
}
msg := "\r\n\x1b[31mCommand blocked: " + command + "\x1b[0m"
if ruleLabel != "" {
msg += "\r\n\x1b[90mRule: " + ruleLabel + "\x1b[0m"
}
msg += "\r\n"
_ = conn.WriteMessage(websocket.TextMessage, []byte(msg))
}

func (s *Server) containerExecHTTP(c *gin.Context) {
containerID := c.Param("id")
if !s.requireContainerAccess(c, containerID, auth.AccessLevelWrite) {
Expand All @@ -227,6 +311,40 @@ func (s *Server) containerExecHTTP(c *gin.Context) {
req.Args = []string{"-c", "echo 'Shell ready'"}
}

commandLine := strings.Join(append([]string{req.Command}, req.Args...), " ")
if deploymentName, err := containerDeploymentName(containerID); err == nil && deploymentName != "" {
if blocked, reason, err := s.protectedDeploymentActionBlocked(deploymentName, protectedActionExec); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check protected mode: " + err.Error()})
return
} else if blocked {
c.JSON(http.StatusLocked, gin.H{
"error": reason,
"action": protectedActionExec,
"reason": reason,
})
return
}
}
blocked, rule, err := s.protectedContainerCommandBlocked(containerID, commandLine)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check protected command rules: " + err.Error()})
return
}
if blocked {
ruleName := rule.Name
if ruleName == "" {
ruleName = rule.ID
}
c.JSON(http.StatusLocked, gin.H{
"error": "Command blocked by deployment protected mode",
"command": commandLine,
"rule": ruleName,
"match": rule.Match,
"pattern": rule.Pattern,
})
return
}

args := append([]string{"exec", containerID, req.Command}, req.Args...)
cmd := exec.Command("docker", args...)
cmd.Env = os.Environ()
Expand Down
Loading
Loading