Wire action-group variables, sudo, and command path resolution through the API and UI.
Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
@@ -50,14 +51,20 @@ type createActionGroupItemRequest struct {
|
||||
Kind settings.ActionKind `json:"kind"`
|
||||
Body string `json:"body"`
|
||||
Env []settings.ActionEnvVar `json:"env"`
|
||||
RequiresSudo bool `json:"requires_sudo"`
|
||||
SetVariable string `json:"set_variable"`
|
||||
RunIf string `json:"run_if"`
|
||||
}
|
||||
|
||||
type patchActionGroupItemRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Kind *settings.ActionKind `json:"kind"`
|
||||
Body *string `json:"body"`
|
||||
Env *[]settings.ActionEnvVar `json:"env"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Kind *settings.ActionKind `json:"kind"`
|
||||
Body *string `json:"body"`
|
||||
Env *[]settings.ActionEnvVar `json:"env"`
|
||||
RequiresSudo *bool `json:"requires_sudo"`
|
||||
SetVariable *string `json:"set_variable"`
|
||||
RunIf *string `json:"run_if"`
|
||||
}
|
||||
|
||||
type reorderActionGroupItemsRequest struct {
|
||||
@@ -325,32 +332,55 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
|
||||
return
|
||||
}
|
||||
item := store.Groups[groupIndex].Items[itemIndex]
|
||||
if item.Source != settings.ActionItemSourceLocal {
|
||||
editingBodyFields := payload.Name != nil || payload.Description != nil || payload.Kind != nil || payload.Body != nil || payload.Env != nil || payload.RequiresSudo != nil
|
||||
if item.Source != settings.ActionItemSourceLocal && editingBodyFields {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library actions must be cloned before editing"})
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Name != nil {
|
||||
item.Name = strings.TrimSpace(*payload.Name)
|
||||
if item.Source == settings.ActionItemSourceLocal {
|
||||
if payload.Name != nil {
|
||||
item.Name = strings.TrimSpace(*payload.Name)
|
||||
}
|
||||
if payload.Description != nil {
|
||||
item.Description = strings.TrimSpace(*payload.Description)
|
||||
}
|
||||
if payload.Kind != nil {
|
||||
item.Kind = *payload.Kind
|
||||
}
|
||||
if payload.Body != nil {
|
||||
item.Body = strings.TrimSpace(*payload.Body)
|
||||
}
|
||||
if payload.Env != nil {
|
||||
item.Env = *payload.Env
|
||||
}
|
||||
if payload.RequiresSudo != nil {
|
||||
item.RequiresSudo = *payload.RequiresSudo
|
||||
}
|
||||
if item.Env == nil {
|
||||
item.Env = []settings.ActionEnvVar{}
|
||||
}
|
||||
if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if payload.Description != nil {
|
||||
item.Description = strings.TrimSpace(*payload.Description)
|
||||
|
||||
if payload.SetVariable != nil {
|
||||
setVariable, err := normalizeSetVariable(*payload.SetVariable)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
item.SetVariable = setVariable
|
||||
}
|
||||
if payload.Kind != nil {
|
||||
item.Kind = *payload.Kind
|
||||
}
|
||||
if payload.Body != nil {
|
||||
item.Body = strings.TrimSpace(*payload.Body)
|
||||
}
|
||||
if payload.Env != nil {
|
||||
item.Env = *payload.Env
|
||||
}
|
||||
if item.Env == nil {
|
||||
item.Env = []settings.ActionEnvVar{}
|
||||
}
|
||||
if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
if payload.RunIf != nil {
|
||||
runIf, err := normalizeRunIf(*payload.RunIf)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
item.RunIf = runIf
|
||||
}
|
||||
|
||||
store.Groups[groupIndex].Items[itemIndex] = item
|
||||
@@ -425,6 +455,7 @@ func (app *App) actionGroupItemsCloneHandler(writer http.ResponseWriter, request
|
||||
item.Kind = libraryAction.Kind
|
||||
item.Body = libraryAction.Body
|
||||
item.Env = env
|
||||
item.RequiresSudo = libraryAction.RequiresSudo
|
||||
|
||||
store.Groups[groupIndex].Items[itemIndex] = item
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
@@ -534,7 +565,7 @@ func (app *App) actionGroupItemsOrderHandler(writer http.ResponseWriter, request
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeJobsRun(request); err != nil {
|
||||
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -565,7 +596,7 @@ func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsRunHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeJobsRun(request); err != nil {
|
||||
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -700,6 +731,14 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
||||
if err != nil {
|
||||
return settings.NodeActionItem{}, err
|
||||
}
|
||||
setVariable, err := normalizeSetVariable(payload.SetVariable)
|
||||
if err != nil {
|
||||
return settings.NodeActionItem{}, err
|
||||
}
|
||||
runIf, err := normalizeRunIf(payload.RunIf)
|
||||
if err != nil {
|
||||
return settings.NodeActionItem{}, err
|
||||
}
|
||||
switch payload.Source {
|
||||
case settings.ActionItemSourceLibrary:
|
||||
libraryID := strings.TrimSpace(payload.LibraryActionID)
|
||||
@@ -711,6 +750,8 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: libraryID,
|
||||
Env: []settings.ActionEnvVar{},
|
||||
SetVariable: setVariable,
|
||||
RunIf: runIf,
|
||||
}, nil
|
||||
case settings.ActionItemSourceLocal:
|
||||
env := payload.Env
|
||||
@@ -723,19 +764,48 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
||||
return settings.NodeActionItem{}, err
|
||||
}
|
||||
return settings.NodeActionItem{
|
||||
ID: itemID,
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(payload.Description),
|
||||
Kind: payload.Kind,
|
||||
Body: body,
|
||||
Env: env,
|
||||
ID: itemID,
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(payload.Description),
|
||||
Kind: payload.Kind,
|
||||
Body: body,
|
||||
Env: env,
|
||||
RequiresSudo: payload.RequiresSudo,
|
||||
SetVariable: setVariable,
|
||||
RunIf: runIf,
|
||||
}, nil
|
||||
default:
|
||||
return settings.NodeActionItem{}, errors.New("source must be library or local")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSetVariable(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "$") {
|
||||
trimmed = strings.TrimPrefix(trimmed, "$")
|
||||
trimmed = strings.TrimSpace(trimmed)
|
||||
}
|
||||
if err := runner.ValidateVariableName(trimmed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func normalizeRunIf(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := runner.ValidateCondition(trimmed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func validateSchedule(schedule settings.ActionGroupSchedule) error {
|
||||
switch schedule.Kind {
|
||||
case settings.ActionGroupScheduleInterval:
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -168,6 +169,9 @@ func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
|
||||
if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" {
|
||||
t.Fatalf("cloned item = %#v", clonedItem)
|
||||
}
|
||||
if clonedItem.RequiresSudo {
|
||||
t.Fatalf("builtin uptime clone should not require sudo: %#v", clonedItem)
|
||||
}
|
||||
|
||||
// Forbidden without actions.create for a limited user session is covered below via groups.
|
||||
listReq := withSession(
|
||||
@@ -191,6 +195,218 @@ func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGroupItemVariablesCreateAndPatch(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||
}
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": "Vars",
|
||||
"schedule": map[string]any{
|
||||
"enabled": false,
|
||||
"kind": "interval",
|
||||
"times": []any{},
|
||||
},
|
||||
})
|
||||
createReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||
bytes.NewReader(createBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var created actionGroupResponse
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create: %v", err)
|
||||
}
|
||||
groupID := created.Group.ID
|
||||
|
||||
addLocalBody, _ := json.Marshal(map[string]any{
|
||||
"source": "local",
|
||||
"name": "Count",
|
||||
"kind": "shell",
|
||||
"body": "echo 3",
|
||||
"env": []any{},
|
||||
"set_variable": "UpdateCount",
|
||||
"run_if": "",
|
||||
})
|
||||
addLocalReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addLocalBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addLocalRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addLocalRec, addLocalReq)
|
||||
if addLocalRec.Code != http.StatusOK {
|
||||
t.Fatalf("add local status=%d body=%s", addLocalRec.Code, addLocalRec.Body.String())
|
||||
}
|
||||
var withLocal actionGroupResponse
|
||||
if err := json.Unmarshal(addLocalRec.Body.Bytes(), &withLocal); err != nil {
|
||||
t.Fatalf("decode local: %v", err)
|
||||
}
|
||||
if withLocal.Group.Items[0].SetVariable != "UpdateCount" {
|
||||
t.Fatalf("set_variable = %#v", withLocal.Group.Items[0])
|
||||
}
|
||||
localID := withLocal.Group.Items[0].ID
|
||||
|
||||
addLibBody, _ := json.Marshal(map[string]any{
|
||||
"source": "library",
|
||||
"library_action_id": settings.BuiltinActionUptimeID,
|
||||
"set_variable": "$Bad-Name",
|
||||
})
|
||||
badLibReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addLibBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
badLibRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(badLibRec, badLibReq)
|
||||
if badLibRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad variable name, got %d body=%s", badLibRec.Code, badLibRec.Body.String())
|
||||
}
|
||||
|
||||
addLibOKBody, _ := json.Marshal(map[string]any{
|
||||
"source": "library",
|
||||
"library_action_id": settings.BuiltinActionUptimeID,
|
||||
})
|
||||
addLibReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addLibOKBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addLibRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addLibRec, addLibReq)
|
||||
if addLibRec.Code != http.StatusOK {
|
||||
t.Fatalf("add library status=%d body=%s", addLibRec.Code, addLibRec.Body.String())
|
||||
}
|
||||
var withLib actionGroupResponse
|
||||
_ = json.Unmarshal(addLibRec.Body.Bytes(), &withLib)
|
||||
var libID string
|
||||
for _, item := range withLib.Group.Items {
|
||||
if item.Source == settings.ActionItemSourceLibrary {
|
||||
libID = item.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if libID == "" {
|
||||
t.Fatal("library item missing")
|
||||
}
|
||||
|
||||
patchLibBody, _ := json.Marshal(map[string]any{
|
||||
"run_if": "if $UpdateCount >= 1",
|
||||
"set_variable": "UptimeOut",
|
||||
})
|
||||
patchLibReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID,
|
||||
bytes.NewReader(patchLibBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
patchLibRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchLibRec, patchLibReq)
|
||||
if patchLibRec.Code != http.StatusOK {
|
||||
t.Fatalf("patch library vars status=%d body=%s", patchLibRec.Code, patchLibRec.Body.String())
|
||||
}
|
||||
var patchedLib actionGroupResponse
|
||||
_ = json.Unmarshal(patchLibRec.Body.Bytes(), &patchedLib)
|
||||
var libItem settings.NodeActionItem
|
||||
for _, item := range patchedLib.Group.Items {
|
||||
if item.ID == libID {
|
||||
libItem = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if libItem.RunIf != "if $UpdateCount >= 1" || libItem.SetVariable != "UptimeOut" {
|
||||
t.Fatalf("library vars = %#v", libItem)
|
||||
}
|
||||
|
||||
badBodyPatch, _ := json.Marshal(map[string]any{
|
||||
"body": "echo no",
|
||||
})
|
||||
badBodyReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID,
|
||||
bytes.NewReader(badBodyPatch),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
badBodyRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(badBodyRec, badBodyReq)
|
||||
if badBodyRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected clone-required for body edit, got %d", badBodyRec.Code)
|
||||
}
|
||||
|
||||
badCondBody, _ := json.Marshal(map[string]any{
|
||||
"run_if": "not valid",
|
||||
})
|
||||
badCondReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID,
|
||||
bytes.NewReader(badCondBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
badCondRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(badCondRec, badCondReq)
|
||||
if badCondRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad run_if, got %d body=%s", badCondRec.Code, badCondRec.Body.String())
|
||||
}
|
||||
|
||||
sudoLocalBody, _ := json.Marshal(map[string]any{
|
||||
"requires_sudo": true,
|
||||
})
|
||||
sudoLocalReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID,
|
||||
bytes.NewReader(sudoLocalBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
sudoLocalRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(sudoLocalRec, sudoLocalReq)
|
||||
if sudoLocalRec.Code != http.StatusOK {
|
||||
t.Fatalf("patch requires_sudo status=%d body=%s", sudoLocalRec.Code, sudoLocalRec.Body.String())
|
||||
}
|
||||
var withSudo actionGroupResponse
|
||||
_ = json.Unmarshal(sudoLocalRec.Body.Bytes(), &withSudo)
|
||||
foundSudo := false
|
||||
for _, item := range withSudo.Group.Items {
|
||||
if item.ID == localID {
|
||||
foundSudo = true
|
||||
if !item.RequiresSudo {
|
||||
t.Fatalf("expected requires_sudo on local item, got %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundSudo {
|
||||
t.Fatal("local item missing after sudo patch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGroupsForbiddenWithoutPermission(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
@@ -222,3 +438,178 @@ func TestActionGroupsForbiddenWithoutPermission(t *testing.T) {
|
||||
t.Fatalf("expected forbidden, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGroupRunRequiresActionsExecute(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||
}
|
||||
|
||||
// Create a group while still admin, then strip execute (+ jobs.run so
|
||||
// migration does not re-grant actions.execute).
|
||||
router := NewRouter(configDir)
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": "RunAuth",
|
||||
"schedule": map[string]any{
|
||||
"enabled": false,
|
||||
"kind": "interval",
|
||||
"times": []any{},
|
||||
},
|
||||
})
|
||||
createReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||
bytes.NewReader(createBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var created actionGroupResponse
|
||||
_ = json.Unmarshal(createRec.Body.Bytes(), &created)
|
||||
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
payload.Groups[0].Permissions = []string{"nodes.read", "actions.create", "actions.update"}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
runReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+created.Group.ID+"/run",
|
||||
nil,
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
runRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(runRec, runReq)
|
||||
if runRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected run forbidden without actions.execute, got %d body=%s", runRec.Code, runRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(runRec.Body.String(), "actions.execute") {
|
||||
t.Fatalf("expected actions.execute in error, got %s", runRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGroupCloneCopiesRequiresSudo(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||
}
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createActionBody, _ := json.Marshal(map[string]any{
|
||||
"name": "Restart svc",
|
||||
"description": "",
|
||||
"kind": "shell",
|
||||
"body": "/usr/bin/systemctl restart myservice",
|
||||
"env": []any{},
|
||||
"requires_sudo": true,
|
||||
})
|
||||
createActionReq := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createActionBody)),
|
||||
cookie,
|
||||
)
|
||||
createActionRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createActionRec, createActionReq)
|
||||
if createActionRec.Code != http.StatusOK {
|
||||
t.Fatalf("create action status=%d body=%s", createActionRec.Code, createActionRec.Body.String())
|
||||
}
|
||||
var actionsPayload actionsResponseTest
|
||||
if err := json.Unmarshal(createActionRec.Body.Bytes(), &actionsPayload); err != nil {
|
||||
t.Fatalf("decode actions: %v", err)
|
||||
}
|
||||
var libraryID string
|
||||
for _, action := range actionsPayload.Actions {
|
||||
if action.Name == "Restart svc" {
|
||||
libraryID = action.ID
|
||||
if !action.RequiresSudo {
|
||||
t.Fatalf("library action missing requires_sudo: %#v", action)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if libraryID == "" {
|
||||
t.Fatal("library action not found")
|
||||
}
|
||||
|
||||
createGroupBody, _ := json.Marshal(map[string]any{
|
||||
"name": "SudoGrp",
|
||||
"schedule": map[string]any{
|
||||
"enabled": false,
|
||||
"kind": "interval",
|
||||
"times": []any{},
|
||||
},
|
||||
})
|
||||
createGroupReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||
bytes.NewReader(createGroupBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
createGroupRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createGroupRec, createGroupReq)
|
||||
if createGroupRec.Code != http.StatusOK {
|
||||
t.Fatalf("create group status=%d", createGroupRec.Code)
|
||||
}
|
||||
var createdGroup actionGroupResponse
|
||||
_ = json.Unmarshal(createGroupRec.Body.Bytes(), &createdGroup)
|
||||
|
||||
addItemBody, _ := json.Marshal(map[string]any{
|
||||
"source": "library",
|
||||
"library_action_id": libraryID,
|
||||
})
|
||||
addItemReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items",
|
||||
bytes.NewReader(addItemBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addItemRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addItemRec, addItemReq)
|
||||
if addItemRec.Code != http.StatusOK {
|
||||
t.Fatalf("add item status=%d body=%s", addItemRec.Code, addItemRec.Body.String())
|
||||
}
|
||||
var withItem actionGroupResponse
|
||||
_ = json.Unmarshal(addItemRec.Body.Bytes(), &withItem)
|
||||
itemID := withItem.Group.Items[0].ID
|
||||
|
||||
cloneReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items/"+itemID+"/clone",
|
||||
nil,
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
cloneRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(cloneRec, cloneReq)
|
||||
if cloneRec.Code != http.StatusOK {
|
||||
t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
|
||||
}
|
||||
var cloned actionGroupResponse
|
||||
_ = json.Unmarshal(cloneRec.Body.Bytes(), &cloned)
|
||||
clonedItem := cloned.Group.Items[0]
|
||||
if clonedItem.Source != settings.ActionItemSourceLocal || !clonedItem.RequiresSudo {
|
||||
t.Fatalf("cloned item should be local with requires_sudo: %#v", clonedItem)
|
||||
}
|
||||
if clonedItem.Body != "/usr/bin/systemctl restart myservice" {
|
||||
t.Fatalf("cloned body = %q", clonedItem.Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,20 @@ type actionsResponse struct {
|
||||
}
|
||||
|
||||
type createActionRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Kind settings.ActionKind `json:"kind"`
|
||||
Body string `json:"body"`
|
||||
Env []settings.ActionEnvVar `json:"env"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Kind settings.ActionKind `json:"kind"`
|
||||
Body string `json:"body"`
|
||||
Env []settings.ActionEnvVar `json:"env"`
|
||||
RequiresSudo bool `json:"requires_sudo"`
|
||||
}
|
||||
|
||||
type patchActionRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Body *string `json:"body"`
|
||||
Env *[]settings.ActionEnvVar `json:"env"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Body *string `json:"body"`
|
||||
Env *[]settings.ActionEnvVar `json:"env"`
|
||||
RequiresSudo *bool `json:"requires_sudo"`
|
||||
}
|
||||
|
||||
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
@@ -136,6 +138,9 @@ func actionsPatchHandler(configDir string) http.HandlerFunc {
|
||||
if payload.Env != nil {
|
||||
updated.Env = *payload.Env
|
||||
}
|
||||
if payload.RequiresSudo != nil {
|
||||
updated.RequiresSudo = *payload.RequiresSudo
|
||||
}
|
||||
updated.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil {
|
||||
@@ -215,15 +220,16 @@ func buildCustomAction(payload createActionRequest) (settings.Action, error) {
|
||||
|
||||
now := time.Now().UTC()
|
||||
return settings.Action{
|
||||
ID: actionID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Kind: payload.Kind,
|
||||
Body: body,
|
||||
Env: env,
|
||||
Builtin: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: actionID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Kind: payload.Kind,
|
||||
Body: body,
|
||||
Env: env,
|
||||
RequiresSudo: payload.RequiresSudo,
|
||||
Builtin: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
||||
"description": "Print the target IP",
|
||||
"kind": "shell",
|
||||
"body": "echo {{node.ip}}",
|
||||
"env": [{"name": "APP_HOME", "value": "/opt/app"}]
|
||||
"env": [{"name": "APP_HOME", "value": "/opt/app"}],
|
||||
"requires_sudo": true
|
||||
}`)
|
||||
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
|
||||
createRec := httptest.NewRecorder()
|
||||
@@ -71,8 +72,11 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
||||
if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell {
|
||||
t.Fatalf("custom = %#v", custom)
|
||||
}
|
||||
if !custom.RequiresSudo {
|
||||
t.Fatalf("expected requires_sudo true, got %#v", custom)
|
||||
}
|
||||
|
||||
patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}"}`)
|
||||
patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}","requires_sudo":false}`)
|
||||
patchReq := withSession(
|
||||
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)),
|
||||
cookie,
|
||||
@@ -94,6 +98,9 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
||||
if action.Name != "Echo target" || action.Body != "echo {{node.host}}" {
|
||||
t.Fatalf("patched = %#v", action)
|
||||
}
|
||||
if action.RequiresSudo {
|
||||
t.Fatalf("expected requires_sudo false after patch, got %#v", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
|
||||
@@ -201,6 +201,31 @@ func TestMigrateLogsReadPermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateActionsExecutePermission(t *testing.T) {
|
||||
payload := settings.Settings{
|
||||
Groups: []settings.Group{
|
||||
{Name: "runners", Permissions: []string{"jobs.run", "nodes.read"}},
|
||||
{Name: "already", Permissions: []string{"jobs.run", "actions.execute"}},
|
||||
{Name: "viewers", Permissions: []string{"jobs.read"}},
|
||||
},
|
||||
}
|
||||
if !migrateActionsExecutePermission(&payload) {
|
||||
t.Fatal("expected migration to change settings")
|
||||
}
|
||||
if !containsPermission(payload.Groups[0].Permissions, "actions.execute") {
|
||||
t.Fatal("runners should gain actions.execute from jobs.run")
|
||||
}
|
||||
if len(payload.Groups[1].Permissions) != 2 {
|
||||
t.Fatal("already should be unchanged")
|
||||
}
|
||||
if containsPermission(payload.Groups[2].Permissions, "actions.execute") {
|
||||
t.Fatal("viewers should not gain actions.execute")
|
||||
}
|
||||
if migrateActionsExecutePermission(&payload) {
|
||||
t.Fatal("second migration should be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func containsPermission(permissions []string, wanted string) bool {
|
||||
for _, permission := range permissions {
|
||||
if permission == wanted {
|
||||
|
||||
@@ -14,6 +14,7 @@ var errNodesDeleteRequired = errors.New("permission nodes.delete required")
|
||||
var errActionsCreateRequired = errors.New("permission actions.create required")
|
||||
var errActionsUpdateRequired = errors.New("permission actions.update required")
|
||||
var errActionsDeleteRequired = errors.New("permission actions.delete required")
|
||||
var errActionsExecuteRequired = errors.New("permission actions.execute required")
|
||||
var errJobsRunRequired = errors.New("permission jobs.run required")
|
||||
var errLogsReadRequired = errors.New("permission logs.read required")
|
||||
|
||||
@@ -162,6 +163,10 @@ func authorizeActionsDelete(request *http.Request, configDir string) error {
|
||||
return authorizeNamedPermission(request, configDir, "actions.delete", errActionsDeleteRequired)
|
||||
}
|
||||
|
||||
func authorizeActionsExecute(request *http.Request, configDir string) error {
|
||||
return authorizeNamedPermission(request, configDir, "actions.execute", errActionsExecuteRequired)
|
||||
}
|
||||
|
||||
func (app *App) authorizeJobsRun(request *http.Request) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
|
||||
@@ -22,19 +22,20 @@ type upsertGroupRequest struct {
|
||||
}
|
||||
|
||||
var allowedPermissions = map[string]struct{}{
|
||||
"nodes.read": {},
|
||||
"nodes.exec": {},
|
||||
"nodes.update": {},
|
||||
"nodes.delete": {},
|
||||
"jobs.read": {},
|
||||
"jobs.run": {},
|
||||
"actions.create": {},
|
||||
"actions.update": {},
|
||||
"actions.delete": {},
|
||||
"users.manage": {},
|
||||
"secrets.manage": {},
|
||||
"roles.manage": {},
|
||||
"logs.read": {},
|
||||
"nodes.read": {},
|
||||
"nodes.exec": {},
|
||||
"nodes.update": {},
|
||||
"nodes.delete": {},
|
||||
"jobs.read": {},
|
||||
"jobs.run": {},
|
||||
"actions.create": {},
|
||||
"actions.update": {},
|
||||
"actions.delete": {},
|
||||
"actions.execute": {},
|
||||
"users.manage": {},
|
||||
"secrets.manage": {},
|
||||
"roles.manage": {},
|
||||
"logs.read": {},
|
||||
}
|
||||
|
||||
func groupsGetHandler(configDir string) http.HandlerFunc {
|
||||
|
||||
@@ -357,6 +357,63 @@ func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesResolveCommandsRejectsInvalidNames(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody := []byte(`{
|
||||
"id":"dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
"kind":"container",
|
||||
"name":"resolve-test",
|
||||
"host_ip":"127.0.0.1",
|
||||
"group_name":"Administrators",
|
||||
"generate":{"algorithm":"ed25519"}
|
||||
}`)
|
||||
createRequest := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
|
||||
cookie,
|
||||
)
|
||||
createRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRecorder, createRequest)
|
||||
if createRecorder.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
|
||||
}
|
||||
|
||||
badBody := []byte(`{"names":["/usr/bin/apt"]}`)
|
||||
badRequest := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee/resolve-commands",
|
||||
bytes.NewReader(badBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
badRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(badRecorder, badRequest)
|
||||
if badRecorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s", badRecorder.Code, badRecorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(badRecorder.Body.String(), "basename") {
|
||||
t.Fatalf("expected basename error, got %s", badRecorder.Body.String())
|
||||
}
|
||||
|
||||
emptyBody := []byte(`{"names":[]}`)
|
||||
emptyRequest := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee/resolve-commands",
|
||||
bytes.NewReader(emptyBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
emptyRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(emptyRecorder, emptyRequest)
|
||||
if emptyRecorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty status = %d body=%s", emptyRecorder.Code, emptyRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesDeleteRequiresPermissionAndInlineReauth(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type resolveCommandsRequest struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
|
||||
type resolveCommandsResponse struct {
|
||||
Paths map[string]string `json:"paths"`
|
||||
Missing []string `json:"missing"`
|
||||
}
|
||||
|
||||
const resolveCommandsTimeout = 30 * time.Second
|
||||
|
||||
func (app *App) nodesResolveCommandsHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeNodesExec(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
nodeID := strings.TrimSpace(request.PathValue("id"))
|
||||
if nodeID == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload resolveCommandsRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
names, err := auth.ValidateCommandNames(payload.Names)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var node settings.Node
|
||||
found := false
|
||||
for _, candidate := range store.Nodes {
|
||||
if candidate.ID != nodeID {
|
||||
continue
|
||||
}
|
||||
node = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
|
||||
return
|
||||
}
|
||||
if !userCanAccessNode(user, settingsPayload.Groups, node) {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var keyEntry settings.NodeKeyEntry
|
||||
keyFound := false
|
||||
for _, entry := range keyStore.Keys {
|
||||
if entry.NodeID == node.ID {
|
||||
keyEntry = entry
|
||||
keyFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !keyFound {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{
|
||||
Error: "private key not found for node",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
remoteCommand := auth.BuildResolveCommandsRemote(names)
|
||||
result, runErr := auth.RunSSHShell(
|
||||
node.HostIP,
|
||||
node.Username,
|
||||
keyEntry.PrivateKey,
|
||||
keyEntry.Passphrase,
|
||||
remoteCommand,
|
||||
nil,
|
||||
resolveCommandsTimeout,
|
||||
)
|
||||
if runErr != nil {
|
||||
writeJSON(writer, http.StatusBadGateway, apiErrorResponse{Error: runErr.Error()})
|
||||
return
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
message := strings.TrimSpace(result.Stderr)
|
||||
if message == "" {
|
||||
message = "resolve-commands failed on node"
|
||||
}
|
||||
writeJSON(writer, http.StatusBadGateway, apiErrorResponse{Error: message})
|
||||
return
|
||||
}
|
||||
|
||||
paths, missing := auth.ParseResolveCommandsOutput(names, result.Stdout)
|
||||
if missing == nil {
|
||||
missing = []string{}
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, resolveCommandsResponse{
|
||||
Paths: paths,
|
||||
Missing: missing,
|
||||
})
|
||||
}
|
||||
@@ -64,6 +64,7 @@ func NewRouterWithApp(configDir string) (http.Handler, *App) {
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
|
||||
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/resolve-commands", app.nodesResolveCommandsHandler)
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler)
|
||||
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsPatchHandler)
|
||||
|
||||
@@ -511,6 +511,7 @@ func allPermissionList() []string {
|
||||
"actions.create",
|
||||
"actions.update",
|
||||
"actions.delete",
|
||||
"actions.execute",
|
||||
"users.manage",
|
||||
"secrets.manage",
|
||||
"roles.manage",
|
||||
@@ -540,7 +541,14 @@ func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
|
||||
payload.Groups = []settings.Group{}
|
||||
}
|
||||
payload.Network = settings.EffectiveNetwork(payload.Network)
|
||||
changed := false
|
||||
if migrateLogsReadPermission(&payload) {
|
||||
changed = true
|
||||
}
|
||||
if migrateActionsExecutePermission(&payload) {
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
return settings.Settings{}, err
|
||||
}
|
||||
@@ -572,3 +580,28 @@ func migrateLogsReadPermission(payload *settings.Settings) bool {
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// migrateActionsExecutePermission grants actions.execute to groups that
|
||||
// previously could run actions via jobs.run. Returns true when settings changed.
|
||||
func migrateActionsExecutePermission(payload *settings.Settings) bool {
|
||||
changed := false
|
||||
for index := range payload.Groups {
|
||||
group := &payload.Groups[index]
|
||||
hasActionsExecute := false
|
||||
hasJobsRun := false
|
||||
for _, permission := range group.Permissions {
|
||||
switch permission {
|
||||
case "actions.execute":
|
||||
hasActionsExecute = true
|
||||
case "jobs.run":
|
||||
hasJobsRun = true
|
||||
}
|
||||
}
|
||||
if hasActionsExecute || !hasJobsRun {
|
||||
continue
|
||||
}
|
||||
group.Permissions = append(group.Permissions, "actions.execute")
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const MaxResolveCommandNames = 32
|
||||
|
||||
// ValidateCommandNames checks relative command basenames for resolve-commands.
|
||||
func ValidateCommandNames(names []string) ([]string, error) {
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("names is required")
|
||||
}
|
||||
if len(names) > MaxResolveCommandNames {
|
||||
return nil, fmt.Errorf("at most %d names allowed", MaxResolveCommandNames)
|
||||
}
|
||||
|
||||
cleaned := make([]string, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
for _, raw := range names {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("command name must not be empty")
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return nil, fmt.Errorf("command name must be a basename without /")
|
||||
}
|
||||
if len(name) > 64 {
|
||||
return nil, fmt.Errorf("command name too long")
|
||||
}
|
||||
for _, runeValue := range name {
|
||||
if (runeValue >= 'a' && runeValue <= 'z') ||
|
||||
(runeValue >= 'A' && runeValue <= 'Z') ||
|
||||
(runeValue >= '0' && runeValue <= '9') ||
|
||||
runeValue == '.' || runeValue == '_' || runeValue == '-' || runeValue == '+' {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("command name contains invalid characters")
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
cleaned = append(cleaned, name)
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return nil, fmt.Errorf("names is required")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// BuildResolveCommandsRemote builds a shell snippet that prints name=path lines.
|
||||
func BuildResolveCommandsRemote(names []string) string {
|
||||
var builder strings.Builder
|
||||
for _, name := range names {
|
||||
quoted := shellQuote(name)
|
||||
builder.WriteString("path=$(command -v -- ")
|
||||
builder.WriteString(quoted)
|
||||
builder.WriteString(" 2>/dev/null || true); printf '%s=%s\\n' ")
|
||||
builder.WriteString(quoted)
|
||||
builder.WriteString(" \"$path\"; ")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// ParseResolveCommandsOutput parses name=path lines into found paths and missing names.
|
||||
func ParseResolveCommandsOutput(names []string, stdout string) (paths map[string]string, missing []string) {
|
||||
paths = make(map[string]string, len(names))
|
||||
wanted := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
wanted[name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(stdout, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
name, path, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
path = strings.TrimSpace(path)
|
||||
if _, ok := wanted[name]; !ok {
|
||||
continue
|
||||
}
|
||||
if path == "" || !strings.HasPrefix(path, "/") {
|
||||
continue
|
||||
}
|
||||
paths[name] = path
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if _, ok := paths[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return paths, missing
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCommandNames(t *testing.T) {
|
||||
cleaned, err := ValidateCommandNames([]string{" apt ", "systemctl", "apt"})
|
||||
if err != nil {
|
||||
t.Fatalf("valid names: %v", err)
|
||||
}
|
||||
if len(cleaned) != 2 || cleaned[0] != "apt" || cleaned[1] != "systemctl" {
|
||||
t.Fatalf("cleaned = %#v", cleaned)
|
||||
}
|
||||
|
||||
if _, err := ValidateCommandNames(nil); err == nil {
|
||||
t.Fatal("expected error for empty names")
|
||||
}
|
||||
if _, err := ValidateCommandNames([]string{"/usr/bin/apt"}); err == nil {
|
||||
t.Fatal("expected error for path")
|
||||
}
|
||||
if _, err := ValidateCommandNames([]string{"apt;rm"}); err == nil {
|
||||
t.Fatal("expected error for metacharacters")
|
||||
}
|
||||
|
||||
tooMany := make([]string, MaxResolveCommandNames+1)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = fmt.Sprintf("cmd%d", index)
|
||||
}
|
||||
if _, err := ValidateCommandNames(tooMany); err == nil {
|
||||
t.Fatal("expected error for too many names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolveCommandsOutput(t *testing.T) {
|
||||
paths, missing := ParseResolveCommandsOutput(
|
||||
[]string{"apt", "missing", "systemctl"},
|
||||
"apt=/usr/bin/apt\nmissing=\nsystemctl=/usr/bin/systemctl\n",
|
||||
)
|
||||
if paths["apt"] != "/usr/bin/apt" || paths["systemctl"] != "/usr/bin/systemctl" {
|
||||
t.Fatalf("paths = %#v", paths)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != "missing" {
|
||||
t.Fatalf("missing = %#v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResolveCommandsRemote(t *testing.T) {
|
||||
remote := BuildResolveCommandsRemote([]string{"apt"})
|
||||
if !strings.Contains(remote, "command -v -- 'apt'") {
|
||||
t.Fatalf("remote = %q", remote)
|
||||
}
|
||||
if !strings.Contains(remote, "printf '%s=%s\\n' 'apt'") {
|
||||
t.Fatalf("remote missing printf = %q", remote)
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,12 @@ func RunSSHShell(
|
||||
if strings.TrimSpace(command) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("command is required")
|
||||
}
|
||||
fullCommand := prependEnvExports(env, command)
|
||||
fullCommand := PrependEnvExports(env, command)
|
||||
return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout)
|
||||
}
|
||||
|
||||
// RunSSHScript uploads and runs a multi-line script via bash -s on the remote host.
|
||||
// When requiresSudo is true, the remote command is "sudo -n bash -s".
|
||||
func RunSSHScript(
|
||||
hostIP string,
|
||||
username string,
|
||||
@@ -45,6 +46,7 @@ func RunSSHScript(
|
||||
scriptBody string,
|
||||
env map[string]string,
|
||||
timeout time.Duration,
|
||||
requiresSudo bool,
|
||||
) (SSHRunResult, error) {
|
||||
if strings.TrimSpace(scriptBody) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("script body is required")
|
||||
@@ -95,7 +97,11 @@ func RunSSHScript(
|
||||
session.Stderr = &stderrBuf
|
||||
session.Stdin = strings.NewReader(scriptBody)
|
||||
|
||||
remoteCommand := prependEnvExports(env, "bash -s")
|
||||
bashCommand := "bash -s"
|
||||
if requiresSudo {
|
||||
bashCommand = "sudo -n bash -s"
|
||||
}
|
||||
remoteCommand := PrependEnvExports(env, bashCommand)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(remoteCommand)
|
||||
@@ -207,7 +213,8 @@ func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, err
|
||||
return result, err
|
||||
}
|
||||
|
||||
func prependEnvExports(env map[string]string, command string) string {
|
||||
// PrependEnvExports builds the remote command string with optional export PREFIX; command.
|
||||
func PrependEnvExports(env map[string]string, command string) string {
|
||||
if len(env) == 0 {
|
||||
return command
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user