Add Configuration Actions tab for shell and script action definitions.

Admins can manage built-in and custom actions with placeholders, ENV vars, and actions.create/update/delete permissions.
This commit is contained in:
2026-07-18 19:18:13 +02:00
parent 77be394407
commit c90a47c3ea
17 changed files with 1770 additions and 9 deletions
+261
View File
@@ -0,0 +1,261 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type actionsResponse struct {
Actions []settings.Action `json:"actions"`
}
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"`
}
type patchActionRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Body *string `json:"body"`
Env *[]settings.ActionEnvVar `json:"env"`
}
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
var (
errActionNameRequired = errors.New("action name is required")
errActionBodyRequired = errors.New("action body is required")
errActionKindInvalid = errors.New("action kind must be shell or script")
errActionNotFound = errors.New("action not found")
errActionBuiltinRO = errors.New("built-in actions cannot be modified")
errActionIDRequired = errors.New("action id is required")
)
func actionsGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsCreateHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsCreate(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload createActionRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
action, err := buildCustomAction(payload)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store.Actions = append(store.Actions, action)
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsPatchHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsUpdate(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
if actionID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
return
}
var payload patchActionRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := findActionIndex(store.Actions, actionID)
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
return
}
if store.Actions[index].Builtin {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
return
}
updated := store.Actions[index]
if payload.Name != nil {
updated.Name = strings.TrimSpace(*payload.Name)
}
if payload.Description != nil {
updated.Description = strings.TrimSpace(*payload.Description)
}
if payload.Body != nil {
updated.Body = strings.TrimSpace(*payload.Body)
}
if payload.Env != nil {
updated.Env = *payload.Env
}
updated.UpdatedAt = time.Now().UTC()
if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if updated.Env == nil {
updated.Env = []settings.ActionEnvVar{}
}
store.Actions[index] = updated
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsDeleteHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsDelete(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
if actionID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := findActionIndex(store.Actions, actionID)
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
return
}
if store.Actions[index].Builtin {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
return
}
store.Actions = append(store.Actions[:index], store.Actions[index+1:]...)
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func buildCustomAction(payload createActionRequest) (settings.Action, error) {
name := strings.TrimSpace(payload.Name)
description := strings.TrimSpace(payload.Description)
body := strings.TrimSpace(payload.Body)
env := payload.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
return settings.Action{}, err
}
actionID, err := auth.NewUUID()
if err != nil {
return settings.Action{}, err
}
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,
}, nil
}
func validateActionFields(
name string,
kind settings.ActionKind,
body string,
env []settings.ActionEnvVar,
) error {
if name == "" {
return errActionNameRequired
}
if body == "" {
return errActionBodyRequired
}
if kind != settings.ActionKindShell && kind != settings.ActionKindScript {
return errActionKindInvalid
}
for index := range env {
env[index].Name = strings.TrimSpace(env[index].Name)
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
return errors.New("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")
}
}
return nil
}
func findActionIndex(actions []settings.Action, actionID string) int {
for index := range actions {
if actions[index].ID == actionID {
return index
}
}
return -1
}
@@ -0,0 +1,225 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type actionsResponseTest struct {
Actions []settings.Action `json:"actions"`
}
func TestActionsGetSeedsBuiltins(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
var payload actionsResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Actions) != 3 {
t.Fatalf("expected 3 builtins, got %d", len(payload.Actions))
}
for _, action := range payload.Actions {
if !action.Builtin {
t.Fatalf("expected builtin, got %#v", action)
}
}
}
func TestActionsCreatePatchDelete(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"name": "Echo host",
"description": "Print the target IP",
"kind": "shell",
"body": "echo {{node.ip}}",
"env": [{"name": "APP_HOME", "value": "/opt/app"}]
}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create status %d body=%s", createRec.Code, createRec.Body.String())
}
var created actionsResponseTest
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
t.Fatalf("decode create: %v", err)
}
if len(created.Actions) != 4 {
t.Fatalf("expected 4 actions after create, got %d", len(created.Actions))
}
custom := created.Actions[3]
if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell {
t.Fatalf("custom = %#v", custom)
}
patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}"}`)
patchReq := withSession(
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)),
cookie,
)
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusOK {
t.Fatalf("patch status %d body=%s", patchRec.Code, patchRec.Body.String())
}
var patched actionsResponseTest
if err := json.NewDecoder(patchRec.Body).Decode(&patched); err != nil {
t.Fatalf("decode patch: %v", err)
}
found := false
for _, action := range patched.Actions {
if action.ID == custom.ID {
found = true
if action.Name != "Echo target" || action.Body != "echo {{node.host}}" {
t.Fatalf("patched = %#v", action)
}
}
}
if !found {
t.Fatal("patched action missing")
}
deleteReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+custom.ID, nil),
cookie,
)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete status %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
var deleted actionsResponseTest
if err := json.NewDecoder(deleteRec.Body).Decode(&deleted); err != nil {
t.Fatalf("decode delete: %v", err)
}
if len(deleted.Actions) != 3 {
t.Fatalf("expected 3 after delete, got %d", len(deleted.Actions))
}
}
func TestActionsRejectBuiltinMutationAndInvalidInput(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
var listed actionsResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&listed); err != nil {
t.Fatalf("decode list: %v", err)
}
builtinID := listed.Actions[0].ID
patchBody := []byte(`{"name":"Nope"}`)
patchReq := withSession(
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+builtinID, bytes.NewReader(patchBody)),
cookie,
)
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusBadRequest {
t.Fatalf("expected builtin patch 400, got %d", patchRec.Code)
}
deleteReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+builtinID, nil),
cookie,
)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusBadRequest {
t.Fatalf("expected builtin delete 400, got %d", deleteRec.Code)
}
badCreate := []byte(`{"name":"x","kind":"shell","body":"echo","env":[{"name":"bad-name","value":"1"}]}`)
badReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(badCreate)), cookie)
badRec := httptest.NewRecorder()
router.ServeHTTP(badRec, badReq)
if badRec.Code != http.StatusBadRequest {
t.Fatalf("expected invalid env 400, got %d", badRec.Code)
}
invalidKind := []byte(`{"name":"x","kind":"nope","body":"echo"}`)
kindReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(invalidKind)), cookie)
kindRec := httptest.NewRecorder()
router.ServeHTTP(kindRec, kindReq)
if kindRec.Code != http.StatusBadRequest {
t.Fatalf("expected invalid kind 400, got %d", kindRec.Code)
}
}
func TestActionsMutationsRequirePermissions(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
// Strip action permissions from Administrators.
settingsPayload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range settingsPayload.Groups {
if settingsPayload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
filtered := make([]string, 0, len(settingsPayload.Groups[index].Permissions))
for _, permission := range settingsPayload.Groups[index].Permissions {
if permission == "actions.create" || permission == "actions.update" || permission == "actions.delete" {
continue
}
filtered = append(filtered, permission)
}
settingsPayload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
createBody := []byte(`{"name":"x","kind":"shell","body":"echo"}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusForbidden {
t.Fatalf("expected create 403, got %d body=%s", createRec.Code, createRec.Body.String())
}
patchReq := withSession(
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id=custom", bytes.NewReader([]byte(`{"name":"y"}`))),
cookie,
)
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusForbidden {
t.Fatalf("expected patch 403, got %d", patchRec.Code)
}
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id=custom", nil), cookie)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusForbidden {
t.Fatalf("expected delete 403, got %d", deleteRec.Code)
}
}
+31
View File
@@ -11,6 +11,9 @@ var errUsersManageRequired = errors.New("permission users.manage required")
var errNodesReadRequired = errors.New("permission nodes.read required")
var errNodesExecRequired = errors.New("permission nodes.exec required")
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")
// permissionsForUser returns the union of permissions from the user's groups.
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
@@ -49,6 +52,22 @@ func permissionList(user settings.UserCredential, groups []settings.Group) []str
return result
}
func authorizeNamedPermission(request *http.Request, configDir string, permission string, missing error) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(configDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, permission) {
return missing
}
return nil
}
func (app *App) authorizeUsersWrite(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
@@ -112,3 +131,15 @@ func (app *App) authorizeNodesDelete(request *http.Request) error {
}
return nil
}
func authorizeActionsCreate(request *http.Request, configDir string) error {
return authorizeNamedPermission(request, configDir, "actions.create", errActionsCreateRequired)
}
func authorizeActionsUpdate(request *http.Request, configDir string) error {
return authorizeNamedPermission(request, configDir, "actions.update", errActionsUpdateRequired)
}
func authorizeActionsDelete(request *http.Request, configDir string) error {
return authorizeNamedPermission(request, configDir, "actions.delete", errActionsDeleteRequired)
}
+3
View File
@@ -28,6 +28,9 @@ var allowedPermissions = map[string]struct{}{
"nodes.delete": {},
"jobs.read": {},
"jobs.run": {},
"actions.create": {},
"actions.update": {},
"actions.delete": {},
"users.manage": {},
"secrets.manage": {},
"roles.manage": {},
+4
View File
@@ -37,6 +37,10 @@ func NewRouter(configDir string) http.Handler {
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
mux.HandleFunc("GET /api/v1/actions", actionsGetHandler(configDir))
mux.HandleFunc("POST /api/v1/actions", actionsCreateHandler(configDir))
mux.HandleFunc("PATCH /api/v1/actions", actionsPatchHandler(configDir))
mux.HandleFunc("DELETE /api/v1/actions", actionsDeleteHandler(configDir))
mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir))
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
mux.HandleFunc("GET /api/v1/network", networkGetHandler(configDir))
@@ -453,6 +453,9 @@ func allPermissionList() []string {
"nodes.delete",
"jobs.read",
"jobs.run",
"actions.create",
"actions.update",
"actions.delete",
"users.manage",
"secrets.manage",
"roles.manage",