diff --git a/service/internal/api/actions_handlers.go b/service/internal/api/actions_handlers.go new file mode 100644 index 0000000..95432d1 --- /dev/null +++ b/service/internal/api/actions_handlers.go @@ -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 +} diff --git a/service/internal/api/actions_handlers_test.go b/service/internal/api/actions_handlers_test.go new file mode 100644 index 0000000..0b9cd52 --- /dev/null +++ b/service/internal/api/actions_handlers_test.go @@ -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) + } +} diff --git a/service/internal/api/authz.go b/service/internal/api/authz.go index af5c892..b312676 100644 --- a/service/internal/api/authz.go +++ b/service/internal/api/authz.go @@ -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) +} diff --git a/service/internal/api/groups_handlers.go b/service/internal/api/groups_handlers.go index c326f09..0332113 100644 --- a/service/internal/api/groups_handlers.go +++ b/service/internal/api/groups_handlers.go @@ -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": {}, diff --git a/service/internal/api/router.go b/service/internal/api/router.go index 06af277..06c2171 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -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)) diff --git a/service/internal/api/setup_auth_handlers.go b/service/internal/api/setup_auth_handlers.go index 52a5de7..e17ce03 100644 --- a/service/internal/api/setup_auth_handlers.go +++ b/service/internal/api/setup_auth_handlers.go @@ -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", diff --git a/service/internal/settings/actions.go b/service/internal/settings/actions.go new file mode 100644 index 0000000..f3fe84c --- /dev/null +++ b/service/internal/settings/actions.go @@ -0,0 +1,127 @@ +package settings + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// Stable IDs for seeded built-in actions. +const ( + BuiltinActionUptimeID = "00000000-0000-4000-8000-000000000001" + BuiltinActionDiskUsageID = "00000000-0000-4000-8000-000000000002" + BuiltinActionSysInfoID = "00000000-0000-4000-8000-000000000003" +) + +// DefaultBuiltinActions returns the read-only seed actions. +func DefaultBuiltinActions() []Action { + now := time.Now().UTC() + return []Action{ + { + ID: BuiltinActionUptimeID, + Name: "Uptime", + Description: "Show how long the target system has been running.", + Kind: ActionKindShell, + Body: "uptime", + Env: []ActionEnvVar{}, + Builtin: true, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: BuiltinActionDiskUsageID, + Name: "Disk usage", + Description: "Show filesystem disk space usage on the target.", + Kind: ActionKindShell, + Body: "df -h", + Env: []ActionEnvVar{}, + Builtin: true, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: BuiltinActionSysInfoID, + Name: "System info", + Description: "Print kernel and OS release details from the target.", + Kind: ActionKindScript, + Body: `uname -a +if [ -f /etc/os-release ]; then + . /etc/os-release + echo "OS: ${NAME:-unknown} ${VERSION:-}" +fi`, + Env: []ActionEnvVar{}, + Builtin: true, + CreatedAt: now, + UpdatedAt: now, + }, + } +} + +// LoadActions reads and parses actions.json from dir. +func LoadActions(dir string) (ActionStore, error) { + path := filepath.Join(dir, ActionsFileName) + payload, err := os.ReadFile(path) + if err != nil { + return ActionStore{}, err + } + + var store ActionStore + if err := json.Unmarshal(payload, &store); err != nil { + return ActionStore{}, fmt.Errorf("parse actions: %w", err) + } + if store.Actions == nil { + store.Actions = []Action{} + } + for index := range store.Actions { + if store.Actions[index].Env == nil { + store.Actions[index].Env = []ActionEnvVar{} + } + } + return store, nil +} + +// LoadActionsOrSeed returns seeded builtins (and persists them) when actions.json is missing. +func LoadActionsOrSeed(dir string) (ActionStore, error) { + store, err := LoadActions(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + seeded := ActionStore{Actions: DefaultBuiltinActions()} + if saveErr := SaveActions(dir, seeded); saveErr != nil { + return ActionStore{}, saveErr + } + return seeded, nil + } + return ActionStore{}, err + } + return store, nil +} + +// SaveActions writes actions.json to dir with mode 0640. +func SaveActions(dir string, store ActionStore) error { + if err := ensureConfigDir(dir); err != nil { + return err + } + if store.Actions == nil { + store.Actions = []Action{} + } + for index := range store.Actions { + if store.Actions[index].Env == nil { + store.Actions[index].Env = []ActionEnvVar{} + } + } + + payload, err := json.MarshalIndent(store, "", " ") + if err != nil { + return fmt.Errorf("encode actions: %w", err) + } + payload = append(payload, '\n') + + path := filepath.Join(dir, ActionsFileName) + if err := os.WriteFile(path, payload, 0o640); err != nil { + return fmt.Errorf("write actions: %w", err) + } + return nil +} diff --git a/service/internal/settings/actions_test.go b/service/internal/settings/actions_test.go new file mode 100644 index 0000000..c5d5f16 --- /dev/null +++ b/service/internal/settings/actions_test.go @@ -0,0 +1,68 @@ +package settings + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadActionsOrSeedCreatesBuiltins(t *testing.T) { + dir := t.TempDir() + + store, err := LoadActionsOrSeed(dir) + if err != nil { + t.Fatalf("LoadActionsOrSeed: %v", err) + } + if len(store.Actions) != 3 { + t.Fatalf("expected 3 builtins, got %d", len(store.Actions)) + } + for _, action := range store.Actions { + if !action.Builtin { + t.Fatalf("expected builtin action, got %#v", action) + } + } + + path := filepath.Join(dir, ActionsFileName) + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected actions.json to exist: %v", err) + } + + reloaded, err := LoadActions(dir) + if err != nil { + t.Fatalf("LoadActions: %v", err) + } + if len(reloaded.Actions) != 3 { + t.Fatalf("reloaded len = %d", len(reloaded.Actions)) + } +} + +func TestActionsRoundTrip(t *testing.T) { + dir := t.TempDir() + store := ActionStore{ + Actions: append(DefaultBuiltinActions(), Action{ + ID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + Name: "Custom", + Description: "A custom shell action", + Kind: ActionKindShell, + Body: "echo hello", + Env: []ActionEnvVar{ + {Name: "APP_HOME", Value: "/opt/app"}, + }, + Builtin: false, + }), + } + if err := SaveActions(dir, store); err != nil { + t.Fatalf("SaveActions: %v", err) + } + loaded, err := LoadActions(dir) + if err != nil { + t.Fatalf("LoadActions: %v", err) + } + if len(loaded.Actions) != 4 { + t.Fatalf("loaded len = %d", len(loaded.Actions)) + } + custom := loaded.Actions[3] + if custom.Name != "Custom" || len(custom.Env) != 1 || custom.Env[0].Name != "APP_HOME" { + t.Fatalf("custom = %#v", custom) + } +} diff --git a/service/internal/settings/path.go b/service/internal/settings/path.go index ee95b92..1a01451 100644 --- a/service/internal/settings/path.go +++ b/service/internal/settings/path.go @@ -19,6 +19,7 @@ const ( NodesFileName = "nodes.json" NodeKeysFileName = "node-keys.enc" NodeAuditFileName = "node-audit.json" + ActionsFileName = "actions.json" ) // ResolveDir returns the config directory using precedence: diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go index a568251..92a8b7f 100644 --- a/service/internal/settings/types.go +++ b/service/internal/settings/types.go @@ -112,6 +112,38 @@ type NodeStore struct { Nodes []Node `json:"nodes"` } +// ActionKind identifies how an action body is interpreted when run on a node. +type ActionKind string + +const ( + ActionKindShell ActionKind = "shell" + ActionKindScript ActionKind = "script" +) + +// ActionEnvVar is one environment variable applied before an action runs. +type ActionEnvVar struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// Action is a named shell command or script that can run on managed nodes. +type Action struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Kind ActionKind `json:"kind"` + Body string `json:"body"` + Env []ActionEnvVar `json:"env"` + Builtin bool `json:"builtin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ActionStore is the plain JSON payload in actions.json. +type ActionStore struct { + Actions []Action `json:"actions"` +} + // NodeKeyEntry holds private key material for one node, keyed by node UUID. type NodeKeyEntry struct { NodeID string `json:"node_id"` diff --git a/webui/src/App.css b/webui/src/App.css index f30a2af..eeda4ff 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -133,7 +133,7 @@ display: flex; flex-direction: column; gap: 1.5rem; - max-width: 40rem; + width: 100%; } .profile-section { @@ -300,14 +300,14 @@ .config-tab-panel { min-width: 0; - max-width: 40rem; + width: 100%; } .config-field { display: flex; flex-direction: column; gap: 0.4rem; - max-width: 28rem; + max-width: 36rem; } .config-field label { @@ -347,7 +347,6 @@ .config-empty { margin: 0; - max-width: 36rem; line-height: 1.5; color: var(--ctp-subtext1); } @@ -432,7 +431,6 @@ margin: 0; font-size: 0.85rem; line-height: 1.4; - max-width: 40rem; word-break: break-word; } @@ -448,7 +446,8 @@ display: flex; flex-direction: column; gap: 0.9rem; - max-width: 36rem; + width: 100%; + max-width: 48rem; } .resource-add-form .field { @@ -566,14 +565,14 @@ display: flex; flex-direction: column; gap: 1.25rem; - max-width: 40rem; + width: 100%; } .security-panel { display: flex; flex-direction: column; gap: 0.9rem; - max-width: 40rem; + width: 100%; } .groups-list { @@ -652,11 +651,13 @@ cursor: pointer; } +.groups-edit:disabled, .groups-delete:disabled { opacity: 0.45; cursor: not-allowed; } +.groups-edit:disabled:hover, .groups-delete:disabled:hover { background: transparent; color: var(--ctp-text); @@ -732,11 +733,198 @@ background: var(--ctp-mauve-hover); } +.groups-save:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.groups-save:disabled:hover { + background: var(--ctp-mauve); + filter: none; +} + .groups-save:focus-visible { outline: 2px solid var(--ctp-mauve); outline-offset: 2px; } +.actions-panel { + display: flex; + flex-direction: column; + gap: 1.25rem; + width: 100%; +} + +.actions-layout { + display: flex; + align-items: flex-start; + gap: 1.25rem; +} + +.actions-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + flex: 1 1 0; + min-width: 0; +} + +.actions-list-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.actions-list-header .config-hint { + margin: 0; +} + +.actions-body-preview { + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, + monospace; + font-size: 0.8rem; + white-space: pre-wrap; + word-break: break-word; +} + +.actions-placeholders { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 0 0 24.75rem; + max-width: 27rem; + padding: 0.75rem 0.9rem; + border: 1px solid var(--ctp-surface1); + border-radius: 0.35rem; + background: var(--ctp-mantle); + position: sticky; + top: 1rem; +} + +@media (max-width: 52rem) { + .actions-layout { + flex-direction: column; + } + + .actions-placeholders { + flex: 1 1 auto; + max-width: none; + width: 100%; + position: static; + } +} + +.actions-placeholders-intro { + margin: 0; +} + +.actions-placeholder-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.actions-placeholder-list li { + display: flex; + flex-direction: column; + gap: 0.15rem; + font-size: 0.85rem; + color: var(--ctp-subtext0); +} + +.actions-placeholder-list code { + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, + monospace; + color: var(--ctp-text); + font-size: 0.85rem; +} + +.actions-placeholder-example { + color: var(--ctp-overlay1); + font-size: 0.8rem; +} + +.actions-type-modal { + width: min(28rem, 100%); +} + +.actions-editor-modal { + width: min(36rem, 100%); + max-height: min(90vh, 40rem); + overflow: auto; +} + +.actions-type-choices { + display: flex; + flex-direction: column; + gap: 0.65rem; + margin: 0.75rem 0 1rem; +} + +.actions-type-choice { + appearance: none; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; + text-align: left; + width: 100%; + padding: 0.75rem 0.9rem; + border: 1px solid var(--ctp-surface1); + border-radius: 0.35rem; + background: var(--ctp-surface0); + color: var(--ctp-text); + font: inherit; + cursor: pointer; +} + +.actions-type-choice:hover { + border-color: var(--ctp-mauve); + background: var(--ctp-mantle); +} + +.actions-type-choice:focus-visible { + outline: 2px solid var(--ctp-mauve); + outline-offset: 2px; +} + +.actions-type-choice strong { + font-size: 0.95rem; +} + +.actions-type-choice span { + font-size: 0.85rem; + color: var(--ctp-subtext0); + line-height: 1.4; +} + +.actions-script-input { + resize: vertical; + min-height: 8rem; + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, + monospace; + font-size: 0.85rem; +} + +.actions-env-rows { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.actions-env-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto; + gap: 0.4rem; + align-items: center; +} + .config-actions { display: flex; flex-wrap: wrap; @@ -744,7 +932,7 @@ gap: 0.6rem; padding-top: 1rem; border-top: 1px solid var(--ctp-surface1); - max-width: 40rem; + width: 100%; } .config-action-revert, diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index 70ca1c0..bbbc045 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -33,6 +33,9 @@ function stubFetchOk() { 'nodes.delete', 'jobs.read', 'jobs.run', + 'actions.create', + 'actions.update', + 'actions.delete', 'users.manage', 'secrets.manage', 'roles.manage', @@ -74,6 +77,9 @@ function stubFetchOk() { 'nodes.delete', 'jobs.read', 'jobs.run', + 'actions.create', + 'actions.update', + 'actions.delete', 'users.manage', 'secrets.manage', 'roles.manage', @@ -145,6 +151,38 @@ function stubFetchOk() { }) } + if (url.includes('/api/v1/actions')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + actions: [ + { + id: '00000000-0000-4000-8000-000000000001', + name: 'Uptime', + description: 'Show how long the target system has been running.', + kind: 'shell', + body: 'uptime', + env: [], + builtin: true, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }, + { + id: 'custom-1', + name: 'Echo host', + description: 'Print the target IP', + kind: 'shell', + body: 'echo {{node.ip}}', + env: [{ name: 'APP_HOME', value: '/opt/app' }], + builtin: false, + created_at: '2026-01-02T00:00:00.000Z', + updated_at: '2026-01-02T00:00:00.000Z', + }, + ], + }), + }) + } + if (url.includes('/api/v1/nodes')) { return Promise.resolve({ ok: true, @@ -355,6 +393,7 @@ describe('App', () => { 'Users', 'Roles', 'Groups', + 'Actions', 'Security', 'Integrations', 'Network', @@ -378,6 +417,9 @@ describe('App', () => { 'nodes.delete', 'jobs.read', 'jobs.run', + 'actions.create', + 'actions.update', + 'actions.delete', 'users.manage', 'secrets.manage', 'roles.manage', @@ -395,6 +437,33 @@ describe('App', () => { expect(screen.getByLabelText('roles.manage')).toBeInTheDocument() expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument() + await user.click(screen.getByRole('tab', { name: 'Actions' })) + expect(screen.getByRole('tab', { name: 'Actions' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + expect(await screen.findByText('Uptime')).toBeInTheDocument() + expect(screen.getByText('Echo host')).toBeInTheDocument() + expect(screen.getByText('{{cc.host}}')).toBeInTheDocument() + expect(screen.getByText('{{secret.NAME}}')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Add Action…' }), + ).toBeInTheDocument() + const actionDeleteButtons = screen.getAllByRole('button', { name: 'Delete' }) + expect(actionDeleteButtons[0]).toBeDisabled() + expect(actionDeleteButtons[1]).toBeEnabled() + + await user.click(screen.getByRole('button', { name: 'Add Action…' })) + expect( + screen.getByRole('heading', { name: 'Add Action' }), + ).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /Shell Command/i })) + expect( + screen.getByRole('heading', { name: 'New shell command' }), + ).toBeInTheDocument() + expect(screen.getByLabelText('Command')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await user.click(screen.getByRole('tab', { name: 'Security' })) expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute( 'aria-selected', diff --git a/webui/src/App.tsx b/webui/src/App.tsx index d2af23f..cc9a675 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -5,12 +5,15 @@ import { confirmMyTOTP, createNode, createUser, + createAction, + deleteAction, deleteGroup, deleteNode, deleteUser, DEFAULT_NETWORK_SETTINGS, DEFAULT_SECURITY_SETTINGS, disableMyTOTP, + fetchActions, fetchGroups, fetchMe, fetchNetwork, @@ -21,10 +24,14 @@ import { fetchStatus, fetchUsers, isReauthRequiredError, + patchAction, patchUser, reauth, testNodeSSH, type AccessMode, + type Action, + type ActionEnvVar, + type ActionKind, type Group, type GroupScopeKind, type MeResponse, @@ -91,6 +98,7 @@ const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [ { id: 'users', label: 'Users' }, { id: 'roles', label: 'Roles' }, { id: 'groups', label: 'Groups' }, + { id: 'actions', label: 'Actions' }, { id: 'security', label: 'Security' }, { id: 'integrations', label: 'Integrations' }, { id: 'network', label: 'Network' }, @@ -116,6 +124,9 @@ const DEFAULT_PERMISSIONS = [ 'nodes.delete', 'jobs.read', 'jobs.run', + 'actions.create', + 'actions.update', + 'actions.delete', 'users.manage', 'secrets.manage', 'roles.manage', @@ -984,6 +995,542 @@ function OverviewConfigTab({ ) } +const ACTION_PLACEHOLDERS: ReadonlyArray<{ + token: string + description: string + example: string +}> = [ + { + token: '{{cc.host}}', + description: 'ClusterCanvas public hostname', + example: 'canvas.example.com', + }, + { + token: '{{cc.ip}}', + description: 'ClusterCanvas listen / resolved IP', + example: '192.168.1.10', + }, + { + token: '{{node.host}}', + description: 'Target node hostname / address as stored', + example: 'vm-web-01', + }, + { + token: '{{node.ip}}', + description: 'Target node host IP', + example: '10.0.0.5', + }, + { + token: '{{node.username}}', + description: 'SSH username on the target node', + example: 'root', + }, + { + token: '{{env.NAME}}', + description: 'Value of an ENV var defined on this action', + example: '{{env.APP_HOME}} → /opt/app', + }, + { + token: '{{secret.NAME}}', + description: + 'Reserved for the future Secrets page (not resolved yet)', + example: '{{secret.db_password}}', + }, +] + +function emptyEnvRow(): ActionEnvVar { + return { name: '', value: '' } +} + +function ActionsConfigTab() { + const [actions, setActions] = useState([]) + const [me, setMe] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [submitError, setSubmitError] = useState(null) + const [showTypePicker, setShowTypePicker] = useState(false) + const [editorKind, setEditorKind] = useState(null) + const [editingActionId, setEditingActionId] = useState(null) + const [draftName, setDraftName] = useState('') + const [draftDescription, setDraftDescription] = useState('') + const [draftBody, setDraftBody] = useState('') + const [draftEnv, setDraftEnv] = useState([emptyEnvRow()]) + const [isSaving, setIsSaving] = useState(false) + + const isEditing = editingActionId !== null + const showEditor = editorKind !== null + const canCreateActions = Boolean(me?.permissions?.includes('actions.create')) + const canUpdateActions = Boolean(me?.permissions?.includes('actions.update')) + const canDeleteActions = Boolean(me?.permissions?.includes('actions.delete')) + + useEffect(() => { + let isCancelled = false + + async function loadActions() { + setIsLoading(true) + setLoadError(null) + try { + const [actionsResponse, meResponse] = await Promise.all([ + fetchActions(), + fetchMe(), + ]) + if (!isCancelled) { + setActions([...actionsResponse.actions]) + setMe(meResponse) + } + } catch (err) { + if (!isCancelled) { + setLoadError( + err instanceof Error ? err.message : 'Failed to load actions', + ) + } + } finally { + if (!isCancelled) { + setIsLoading(false) + } + } + } + + void loadActions() + + return () => { + isCancelled = true + } + }, []) + + function clearEditor() { + setShowTypePicker(false) + setEditorKind(null) + setEditingActionId(null) + setDraftName('') + setDraftDescription('') + setDraftBody('') + setDraftEnv([emptyEnvRow()]) + setSubmitError(null) + } + + function openTypePicker() { + clearEditor() + setShowTypePicker(true) + } + + function startCreate(kind: ActionKind) { + setShowTypePicker(false) + setEditorKind(kind) + setEditingActionId(null) + setDraftName('') + setDraftDescription('') + setDraftBody('') + setDraftEnv([emptyEnvRow()]) + setSubmitError(null) + } + + function startEdit(action: Action) { + if (action.builtin || !canUpdateActions) { + return + } + setShowTypePicker(false) + setEditorKind(action.kind) + setEditingActionId(action.id) + setDraftName(action.name) + setDraftDescription(action.description) + setDraftBody(action.body) + setDraftEnv( + action.env.length > 0 + ? action.env.map((envVar) => ({ ...envVar })) + : [emptyEnvRow()], + ) + setSubmitError(null) + } + + function updateEnvRow(index: number, field: 'name' | 'value', value: string) { + setDraftEnv((previous) => + previous.map((row, rowIndex) => + rowIndex === index ? { ...row, [field]: value } : row, + ), + ) + } + + function addEnvRow() { + setDraftEnv((previous) => [...previous, emptyEnvRow()]) + } + + function removeEnvRow(index: number) { + setDraftEnv((previous) => { + if (previous.length <= 1) { + return [emptyEnvRow()] + } + return previous.filter((_, rowIndex) => rowIndex !== index) + }) + } + + function normalizedEnv(): ActionEnvVar[] { + return draftEnv + .map((row) => ({ + name: row.name.trim(), + value: row.value, + })) + .filter((row) => row.name !== '' || row.value.trim() !== '') + } + + async function handleSaveAction() { + if (!editorKind) { + return + } + setSubmitError(null) + setIsSaving(true) + try { + const env = normalizedEnv() + const response = isEditing + ? await patchAction(editingActionId!, { + name: draftName, + description: draftDescription, + body: draftBody, + env, + }) + : await createAction({ + name: draftName, + description: draftDescription, + kind: editorKind, + body: draftBody, + env, + }) + setActions([...response.actions]) + clearEditor() + } catch (err) { + setSubmitError( + err instanceof Error ? err.message : 'Failed to save action', + ) + } finally { + setIsSaving(false) + } + } + + async function handleDeleteAction(action: Action) { + if (action.builtin || !canDeleteActions) { + return + } + setSubmitError(null) + try { + const response = await deleteAction(action.id) + setActions([...response.actions]) + if (editingActionId === action.id) { + clearEditor() + } + } catch (err) { + setSubmitError( + err instanceof Error ? err.message : 'Failed to delete action', + ) + } + } + + return ( +
+

+ Pre-defined and custom actions for managed nodes. Placeholders and ENV + vars are expanded when an action runs. +

+ + {submitError && !showEditor ? ( +

+ {submitError} +

+ ) : null} + +
+
+
+

Configured actions

+ +
+ {isLoading ? ( +

Loading…

+ ) : loadError ? ( +

+ {loadError} +

+ ) : actions.length === 0 ? ( +

No actions configured yet.

+ ) : ( +
    + {actions.map((action) => { + const isSelected = editingActionId === action.id + return ( +
  • +
    +
    + {action.name} + {action.kind} + {action.builtin ? ( + built-in + ) : null} +
    +
    + {action.description || 'No description'} +
    +
    + + {action.body.length > 80 + ? `${action.body.slice(0, 80)}…` + : action.body} + +
    +
    +
    + + +
    +
  • + ) + })} +
+ )} +
+ + +
+ + {showTypePicker ? ( +
+
+

Add Action

+

Choose the type of action to create.

+
+ + +
+
+ +
+
+
+ ) : null} + + {showEditor && editorKind ? ( +
+
+

+ {isEditing + ? `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}` + : `New ${editorKind === 'shell' ? 'shell command' : 'script'}`} +

+ + {submitError ? ( +

+ {submitError} +

+ ) : null} + +
+ + setDraftName(event.target.value)} + /> +
+ +
+ + setDraftDescription(event.target.value)} + /> +
+ +
+ + {editorKind === 'shell' ? ( + setDraftBody(event.target.value)} + /> + ) : ( +