From 77be394407eefd111e3031a5e866d82e8e6127e8 Mon Sep 17 00:00:00 2001 From: squid Date: Sat, 18 Jul 2026 16:54:13 +0200 Subject: [PATCH] Add nodes.delete with reauth, activity logs, and Administrators editing. Allow deleting managed hosts with mandatory inline re-auth, record node mutations in an audit log with a sidebar Logs view, and let Administrators group permissions be edited from the Groups tab. --- service/internal/api/authz.go | 17 + service/internal/api/groups_handlers.go | 1 + service/internal/api/nodes_handlers.go | 229 +++++++++++- service/internal/api/nodes_handlers_test.go | 302 ++++++++++++++++ service/internal/api/router.go | 2 + service/internal/api/setup_auth_handlers.go | 1 + service/internal/settings/node_audit.go | 77 ++++ service/internal/settings/node_audit_test.go | 82 +++++ service/internal/settings/path.go | 1 + service/internal/settings/types.go | 27 ++ webui/src/App.css | 69 ++++ webui/src/App.test.tsx | 116 ++++++- webui/src/App.tsx | 347 +++++++++++++++---- webui/src/api/client.test.ts | 65 ++++ webui/src/api/client.ts | 51 +++ webui/src/navigation.test.ts | 2 + webui/src/navigation.ts | 8 + 17 files changed, 1326 insertions(+), 71 deletions(-) create mode 100644 service/internal/settings/node_audit.go create mode 100644 service/internal/settings/node_audit_test.go diff --git a/service/internal/api/authz.go b/service/internal/api/authz.go index 2869ffc..af5c892 100644 --- a/service/internal/api/authz.go +++ b/service/internal/api/authz.go @@ -10,6 +10,7 @@ import ( 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") // permissionsForUser returns the union of permissions from the user's groups. func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} { @@ -95,3 +96,19 @@ func (app *App) authorizeNodesExec(request *http.Request) error { } return nil } + +func (app *App) authorizeNodesDelete(request *http.Request) error { + user, ok := UserFromContext(request.Context()) + if !ok { + return errors.New("authentication required") + } + + payload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + return err + } + if !userHasPermission(user, payload.Groups, "nodes.delete") { + return errNodesDeleteRequired + } + return nil +} diff --git a/service/internal/api/groups_handlers.go b/service/internal/api/groups_handlers.go index 256e79b..c326f09 100644 --- a/service/internal/api/groups_handlers.go +++ b/service/internal/api/groups_handlers.go @@ -25,6 +25,7 @@ var allowedPermissions = map[string]struct{}{ "nodes.read": {}, "nodes.exec": {}, "nodes.update": {}, + "nodes.delete": {}, "jobs.read": {}, "jobs.run": {}, "users.manage": {}, diff --git a/service/internal/api/nodes_handlers.go b/service/internal/api/nodes_handlers.go index b69baff..34a19ef 100644 --- a/service/internal/api/nodes_handlers.go +++ b/service/internal/api/nodes_handlers.go @@ -31,15 +31,24 @@ type createNodeNewGroup struct { } type createNodeRequest struct { - ID string `json:"id"` - Kind settings.NodeKind `json:"kind"` - Name string `json:"name"` - HostIP string `json:"host_ip"` - Username string `json:"username"` - GroupName string `json:"group_name"` - NewGroup *createNodeNewGroup `json:"new_group"` - Generate *createNodeGenerate `json:"generate"` - PrivateKey string `json:"private_key"` + ID string `json:"id"` + Kind settings.NodeKind `json:"kind"` + Name string `json:"name"` + HostIP string `json:"host_ip"` + Username string `json:"username"` + GroupName string `json:"group_name"` + NewGroup *createNodeNewGroup `json:"new_group"` + Generate *createNodeGenerate `json:"generate"` + PrivateKey string `json:"private_key"` +} + +type deleteNodeRequest struct { + CurrentPassword string `json:"current_password"` + TOTPCode string `json:"totp_code"` +} + +type nodeLogsResponse struct { + Events []settings.NodeAuditEvent `json:"events"` } func (app *App) nodesListHandler(writer http.ResponseWriter, request *http.Request) { @@ -207,6 +216,7 @@ func (app *App) nodesTestSSHHandler(writer http.ResponseWriter, request *http.Re } if err := auth.TestSSHConnection(node.HostIP, node.Username, keyEntry.PrivateKey, keyEntry.Passphrase); err != nil { + _ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionTestSSH, user.Username, node, err.Error()) writeJSON(writer, http.StatusOK, nodeSSHTestResponse{ OK: false, Message: err.Error(), @@ -214,6 +224,7 @@ func (app *App) nodesTestSSHHandler(writer http.ResponseWriter, request *http.Re return } + _ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionTestSSH, user.Username, node, "SSH connection succeeded") writeJSON(writer, http.StatusOK, nodeSSHTestResponse{ OK: true, Message: "SSH connection succeeded", @@ -328,9 +339,167 @@ func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Req return } + actorUsername := "" + if actor, ok := UserFromContext(request.Context()); ok { + actorUsername = actor.Username + } + _ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionCreate, actorUsername, node, "") + writeJSON(writer, http.StatusCreated, nodeResponse{Node: node}) } +func (app *App) nodesDeleteHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeNodesDelete(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 deleteNodeRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + + if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil { + writeJSON(writer, http.StatusUnauthorized, 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 deleted settings.Node + remaining := make([]settings.Node, 0, len(store.Nodes)) + found := false + for _, candidate := range store.Nodes { + if candidate.ID != nodeID { + remaining = append(remaining, candidate) + continue + } + found = true + deleted = candidate + if !userCanAccessNode(user, settingsPayload.Groups, candidate) { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"}) + return + } + } + if !found { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"}) + return + } + + keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + remainingKeys := make([]settings.NodeKeyEntry, 0, len(keyStore.Keys)) + for _, entry := range keyStore.Keys { + if entry.NodeID == nodeID { + continue + } + remainingKeys = append(remainingKeys, entry) + } + + store.Nodes = remaining + keyStore.Keys = remainingKeys + + if err := settings.SaveNodes(app.ConfigDir, store); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + if err := settings.SaveNodeKeys(app.ConfigDir, keyStore, app.Key); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + _ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionDelete, user.Username, deleted, "") + + filtered := make([]settings.Node, 0, len(store.Nodes)) + for _, node := range store.Nodes { + if userCanAccessNode(user, settingsPayload.Groups, node) { + filtered = append(filtered, node) + } + } + writeJSON(writer, http.StatusOK, nodesResponse{Nodes: filtered}) +} + +func (app *App) nodeLogsListHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeNodesRead(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"}) + return + } + + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + store, err := settings.LoadNodeAuditOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + nodesStore, err := settings.LoadNodesOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + nodeByID := make(map[string]settings.Node, len(nodesStore.Nodes)) + for _, node := range nodesStore.Nodes { + nodeByID[node.ID] = node + } + + kindFilter := strings.TrimSpace(request.URL.Query().Get("kind")) + isAdmin := userIsAdministrator(user) + filtered := make([]settings.NodeAuditEvent, 0, len(store.Events)) + for index := len(store.Events) - 1; index >= 0; index-- { + event := store.Events[index] + if kindFilter != "" && string(event.NodeKind) != kindFilter { + continue + } + if !userCanSeeNodeAuditEvent(user, settingsPayload.Groups, nodeByID, event, isAdmin) { + continue + } + filtered = append(filtered, event) + } + + writeJSON(writer, http.StatusOK, nodeLogsResponse{Events: filtered}) +} + func buildNodeFromCreateRequest(payload createNodeRequest) (settings.Node, settings.NodeKeyEntry, error) { kind := payload.Kind switch kind { @@ -440,6 +609,48 @@ func userCanAccessNode(user settings.UserCredential, groups []settings.Group, no return false } +func userCanSeeNodeAuditEvent( + user settings.UserCredential, + groups []settings.Group, + nodeByID map[string]settings.Node, + event settings.NodeAuditEvent, + isAdmin bool, +) bool { + if isAdmin { + return true + } + if event.Actor == user.Username { + return true + } + if node, ok := nodeByID[event.NodeID]; ok { + return userCanAccessNode(user, groups, node) + } + return false +} + +func appendNodeAudit( + configDir string, + action settings.NodeAuditAction, + actor string, + node settings.Node, + detail string, +) error { + eventID, err := auth.NewUUID() + if err != nil { + return err + } + return settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{ + ID: eventID, + At: time.Now().UTC(), + Action: action, + Actor: actor, + NodeID: node.ID, + NodeName: node.Name, + NodeKind: node.Kind, + Detail: detail, + }) +} + func isUUID(value string) bool { if len(value) != 36 { return false diff --git a/service/internal/api/nodes_handlers_test.go b/service/internal/api/nodes_handlers_test.go index 335244c..ed75d32 100644 --- a/service/internal/api/nodes_handlers_test.go +++ b/service/internal/api/nodes_handlers_test.go @@ -341,4 +341,306 @@ func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) { if response.Message == "" { t.Fatal("expected failure message") } + + audit, err := settings.LoadNodeAudit(configDir) + if err != nil { + t.Fatalf("LoadNodeAudit: %v", err) + } + foundTestSSH := false + for _, event := range audit.Events { + if event.Action == settings.NodeAuditActionTestSSH && event.NodeID == "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" { + foundTestSSH = true + } + } + if !foundTestSSH { + t.Fatal("expected test_ssh audit event") + } +} + +func TestNodesDeleteRequiresPermissionAndInlineReauth(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + router := NewRouter(configDir) + + createBody := []byte(`{ + "id":"bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "kind":"vm", + "name":"vm-delete-me", + "host_ip":"10.0.0.9", + "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()) + } + + missingAuthRequest := withSession( + httptest.NewRequest( + http.MethodDelete, + "/api/v1/nodes/bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee", + bytes.NewReader([]byte(`{}`)), + ), + cookie, + ) + missingAuthRecorder := httptest.NewRecorder() + router.ServeHTTP(missingAuthRecorder, missingAuthRequest) + if missingAuthRecorder.Code != http.StatusUnauthorized { + t.Fatalf("missing reauth status = %d body=%s", missingAuthRecorder.Code, missingAuthRecorder.Body.String()) + } + + deleteBody := []byte(`{"current_password":"correct horse battery staple extra"}`) + deleteRequest := withSession( + httptest.NewRequest( + http.MethodDelete, + "/api/v1/nodes/bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee", + bytes.NewReader(deleteBody), + ), + cookie, + ) + deleteRecorder := httptest.NewRecorder() + router.ServeHTTP(deleteRecorder, deleteRequest) + if deleteRecorder.Code != http.StatusOK { + t.Fatalf("delete status = %d body=%s", deleteRecorder.Code, deleteRecorder.Body.String()) + } + + var list nodesResponse + if err := json.Unmarshal(deleteRecorder.Body.Bytes(), &list); err != nil { + t.Fatalf("decode: %v", err) + } + for _, node := range list.Nodes { + if node.ID == "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" { + t.Fatal("deleted node still listed") + } + } + + store, err := settings.LoadNodes(configDir) + if err != nil { + t.Fatalf("LoadNodes: %v", err) + } + for _, node := range store.Nodes { + if node.ID == "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" { + t.Fatal("node still on disk") + } + } + + keyBytes, err := settings.KeyFromEnv() + if err != nil { + t.Fatalf("KeyFromEnv: %v", err) + } + keyStore, err := settings.LoadNodeKeys(configDir, keyBytes) + if err != nil { + t.Fatalf("LoadNodeKeys: %v", err) + } + for _, entry := range keyStore.Keys { + if entry.NodeID == "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" { + t.Fatal("private key still on disk") + } + } + + audit, err := settings.LoadNodeAudit(configDir) + if err != nil { + t.Fatalf("LoadNodeAudit: %v", err) + } + foundDelete := false + foundCreate := false + for _, event := range audit.Events { + if event.NodeID != "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" { + continue + } + if event.Action == settings.NodeAuditActionCreate { + foundCreate = true + } + if event.Action == settings.NodeAuditActionDelete { + foundDelete = true + } + } + if !foundCreate || !foundDelete { + t.Fatalf("audit create=%v delete=%v events=%+v", foundCreate, foundDelete, audit.Events) + } +} + +func TestNodesDeleteRejectsWithoutNodesDelete(t *testing.T) { + configDir := t.TempDir() + _ = seedCompletedSetup(t, configDir) + + keyBytes, err := settings.KeyFromEnv() + if err != nil { + t.Fatalf("KeyFromEnv: %v", err) + } + now := time.Now().UTC() + passwordHash, err := auth.HashPassword("correct horse battery staple extra") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + + payload := settings.Settings{ + SetupCompleted: true, + Groups: []settings.Group{ + { + Name: settings.AdministratorsGroupName, + ScopeKind: settings.GroupScopeGroup, + ScopeName: "Admin Group", + Permissions: allPermissionList(), + }, + { + Name: "Executors", + ScopeKind: settings.GroupScopeGroup, + ScopeName: "Executors", + Permissions: []string{"nodes.read", "nodes.exec"}, + }, + }, + Security: settings.DefaultSecuritySettings(), + Network: settings.DefaultNetworkSettings(), + } + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + if err := settings.SavePasswords(configDir, settings.PasswordStore{ + Users: []settings.UserCredential{ + { + ID: "admin-id", + Username: "Admin", + PasswordHash: passwordHash, + Enabled: true, + GroupNames: []string{settings.AdministratorsGroupName}, + CreatedAt: now, + PasswordChangedAt: now, + }, + { + ID: "exec-id", + Username: "executor", + PasswordHash: passwordHash, + Enabled: true, + GroupNames: []string{"Executors"}, + CreatedAt: now, + PasswordChangedAt: now, + }, + }, + }, keyBytes); err != nil { + t.Fatalf("SavePasswords: %v", err) + } + if err := settings.SaveNodes(configDir, settings.NodeStore{ + Nodes: []settings.Node{ + { + ID: "cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee", + Kind: settings.NodeKindContainer, + Name: "ct-keep", + HostIP: "10.0.0.3", + Username: "clustercanvas", + GroupName: "Executors", + PublicKey: "ssh-ed25519 AAAA", + KeyAlgo: "ed25519", + CreatedAt: now, + }, + }, + }); err != nil { + t.Fatalf("SaveNodes: %v", err) + } + if err := settings.SaveNodeKeys(configDir, settings.NodeKeyStore{ + Keys: []settings.NodeKeyEntry{ + { + NodeID: "cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee", + PrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----\n", + Algorithm: "ed25519", + }, + }, + }, keyBytes); err != nil { + t.Fatalf("SaveNodeKeys: %v", err) + } + + router := NewRouter(configDir) + loginRecorder := httptest.NewRecorder() + loginRequest := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/login", + bytes.NewReader([]byte(`{"username":"executor","password":"correct horse battery staple extra"}`)), + ) + router.ServeHTTP(loginRecorder, loginRequest) + if loginRecorder.Code != http.StatusOK { + t.Fatalf("login status = %d body=%s", loginRecorder.Code, loginRecorder.Body.String()) + } + var execCookie *http.Cookie + for _, candidate := range loginRecorder.Result().Cookies() { + if candidate.Name == auth.SessionCookieName { + execCookie = candidate + } + } + if execCookie == nil { + t.Fatal("missing session cookie") + } + + deleteBody := []byte(`{"current_password":"correct horse battery staple extra"}`) + request := withSession( + httptest.NewRequest( + http.MethodDelete, + "/api/v1/nodes/cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee", + bytes.NewReader(deleteBody), + ), + execCookie, + ) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestNodeLogsListFiltersByKind(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + router := NewRouter(configDir) + + for _, body := range []string{ + `{"kind":"container","name":"ct-log","host_ip":"10.0.0.1","group_name":"Administrators","generate":{"algorithm":"ed25519"}}`, + `{"kind":"vm","name":"vm-log","host_ip":"10.0.0.2","group_name":"Administrators","generate":{"algorithm":"ed25519"}}`, + } { + request := withSession( + httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader([]byte(body))), + cookie, + ) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusCreated { + t.Fatalf("create status = %d body=%s", recorder.Code, recorder.Body.String()) + } + } + + allRequest := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie) + allRecorder := httptest.NewRecorder() + router.ServeHTTP(allRecorder, allRequest) + if allRecorder.Code != http.StatusOK { + t.Fatalf("logs status = %d body=%s", allRecorder.Code, allRecorder.Body.String()) + } + var allLogs nodeLogsResponse + if err := json.Unmarshal(allRecorder.Body.Bytes(), &allLogs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(allLogs.Events) < 2 { + t.Fatalf("expected at least 2 events, got %d", len(allLogs.Events)) + } + + vmRequest := withSession( + httptest.NewRequest(http.MethodGet, "/api/v1/node-logs?kind=vm", nil), + cookie, + ) + vmRecorder := httptest.NewRecorder() + router.ServeHTTP(vmRecorder, vmRequest) + var vmLogs nodeLogsResponse + if err := json.Unmarshal(vmRecorder.Body.Bytes(), &vmLogs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(vmLogs.Events) == 0 { + t.Fatal("expected vm events") + } + for _, event := range vmLogs.Events { + if event.NodeKind != settings.NodeKindVM { + t.Fatalf("unexpected kind %q", event.NodeKind) + } + } } diff --git a/service/internal/api/router.go b/service/internal/api/router.go index 1bf6b11..06af277 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -49,7 +49,9 @@ func NewRouter(configDir string) http.Handler { mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler) mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler) 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("GET /api/v1/node-logs", app.nodeLogsListHandler) return app.withCORS(app.withMiddleware(mux)) } diff --git a/service/internal/api/setup_auth_handlers.go b/service/internal/api/setup_auth_handlers.go index 2c9689b..52a5de7 100644 --- a/service/internal/api/setup_auth_handlers.go +++ b/service/internal/api/setup_auth_handlers.go @@ -450,6 +450,7 @@ func allPermissionList() []string { "nodes.read", "nodes.exec", "nodes.update", + "nodes.delete", "jobs.read", "jobs.run", "users.manage", diff --git a/service/internal/settings/node_audit.go b/service/internal/settings/node_audit.go new file mode 100644 index 0000000..f925232 --- /dev/null +++ b/service/internal/settings/node_audit.go @@ -0,0 +1,77 @@ +package settings + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// MaxNodeAuditEvents is the retention cap for node-audit.json. +const MaxNodeAuditEvents = 1000 + +// LoadNodeAudit reads and parses node-audit.json from dir. +func LoadNodeAudit(dir string) (NodeAuditStore, error) { + path := filepath.Join(dir, NodeAuditFileName) + payload, err := os.ReadFile(path) + if err != nil { + return NodeAuditStore{}, err + } + + var store NodeAuditStore + if err := json.Unmarshal(payload, &store); err != nil { + return NodeAuditStore{}, fmt.Errorf("parse node audit: %w", err) + } + if store.Events == nil { + store.Events = []NodeAuditEvent{} + } + return store, nil +} + +// LoadNodeAuditOrEmpty returns an empty store when node-audit.json is missing. +func LoadNodeAuditOrEmpty(dir string) (NodeAuditStore, error) { + store, err := LoadNodeAudit(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return NodeAuditStore{Events: []NodeAuditEvent{}}, nil + } + return NodeAuditStore{}, err + } + return store, nil +} + +// SaveNodeAudit writes node-audit.json to dir with mode 0640. +func SaveNodeAudit(dir string, store NodeAuditStore) error { + if err := ensureConfigDir(dir); err != nil { + return err + } + if store.Events == nil { + store.Events = []NodeAuditEvent{} + } + + payload, err := json.MarshalIndent(store, "", " ") + if err != nil { + return fmt.Errorf("encode node audit: %w", err) + } + payload = append(payload, '\n') + + path := filepath.Join(dir, NodeAuditFileName) + if err := os.WriteFile(path, payload, 0o640); err != nil { + return fmt.Errorf("write node audit: %w", err) + } + return nil +} + +// AppendNodeAuditEvent appends event and trims to MaxNodeAuditEvents (newest kept). +func AppendNodeAuditEvent(dir string, event NodeAuditEvent) error { + store, err := LoadNodeAuditOrEmpty(dir) + if err != nil { + return err + } + store.Events = append(store.Events, event) + if len(store.Events) > MaxNodeAuditEvents { + store.Events = store.Events[len(store.Events)-MaxNodeAuditEvents:] + } + return SaveNodeAudit(dir, store) +} diff --git a/service/internal/settings/node_audit_test.go b/service/internal/settings/node_audit_test.go new file mode 100644 index 0000000..2c411f6 --- /dev/null +++ b/service/internal/settings/node_audit_test.go @@ -0,0 +1,82 @@ +package settings + +import ( + "testing" + "time" +) + +func TestNodeAuditRoundTripAndCap(t *testing.T) { + dir := t.TempDir() + + store, err := LoadNodeAuditOrEmpty(dir) + if err != nil { + t.Fatalf("LoadNodeAuditOrEmpty: %v", err) + } + if len(store.Events) != 0 { + t.Fatalf("expected empty, got %d", len(store.Events)) + } + + event := NodeAuditEvent{ + ID: "evt-1", + At: time.Now().UTC(), + Action: NodeAuditActionCreate, + Actor: "admin", + NodeID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + NodeName: "ct1", + NodeKind: NodeKindContainer, + } + if err := AppendNodeAuditEvent(dir, event); err != nil { + t.Fatalf("AppendNodeAuditEvent: %v", err) + } + + loaded, err := LoadNodeAudit(dir) + if err != nil { + t.Fatalf("LoadNodeAudit: %v", err) + } + if len(loaded.Events) != 1 || loaded.Events[0].Action != NodeAuditActionCreate { + t.Fatalf("loaded = %+v", loaded) + } +} + +func TestAppendNodeAuditEventTrimsToCap(t *testing.T) { + dir := t.TempDir() + originalCap := MaxNodeAuditEvents + // Use a small synthetic store by appending more than the cap. + store := NodeAuditStore{Events: make([]NodeAuditEvent, 0, MaxNodeAuditEvents+5)} + for index := 0; index < MaxNodeAuditEvents+5; index++ { + store.Events = append(store.Events, NodeAuditEvent{ + ID: "evt", + At: time.Now().UTC(), + Action: NodeAuditActionCreate, + Actor: "admin", + NodeID: "id", + NodeName: "n", + NodeKind: NodeKindVM, + Detail: string(rune('a' + (index % 26))), + }) + } + if err := SaveNodeAudit(dir, store); err != nil { + t.Fatalf("SaveNodeAudit: %v", err) + } + if err := AppendNodeAuditEvent(dir, NodeAuditEvent{ + ID: "newest", + At: time.Now().UTC(), + Action: NodeAuditActionDelete, + Actor: "admin", + NodeID: "id", + NodeName: "n", + NodeKind: NodeKindVM, + }); err != nil { + t.Fatalf("AppendNodeAuditEvent: %v", err) + } + loaded, err := LoadNodeAudit(dir) + if err != nil { + t.Fatalf("LoadNodeAudit: %v", err) + } + if len(loaded.Events) != originalCap { + t.Fatalf("len = %d, want %d", len(loaded.Events), originalCap) + } + if loaded.Events[len(loaded.Events)-1].ID != "newest" { + t.Fatalf("last id = %q", loaded.Events[len(loaded.Events)-1].ID) + } +} diff --git a/service/internal/settings/path.go b/service/internal/settings/path.go index dc73384..ee95b92 100644 --- a/service/internal/settings/path.go +++ b/service/internal/settings/path.go @@ -18,6 +18,7 @@ const ( SessionsFileName = "sessions.enc" NodesFileName = "nodes.json" NodeKeysFileName = "node-keys.enc" + NodeAuditFileName = "node-audit.json" ) // ResolveDir returns the config directory using precedence: diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go index 6858afa..a568251 100644 --- a/service/internal/settings/types.go +++ b/service/internal/settings/types.go @@ -127,6 +127,33 @@ type NodeKeyStore struct { Keys []NodeKeyEntry `json:"keys"` } +// NodeAuditAction identifies a recorded node mutation. +type NodeAuditAction string + +const ( + NodeAuditActionCreate NodeAuditAction = "create" + NodeAuditActionDelete NodeAuditAction = "delete" + NodeAuditActionTestSSH NodeAuditAction = "test_ssh" + NodeAuditActionUpdate NodeAuditAction = "update" +) + +// NodeAuditEvent is one append-only record of a node mutation. +type NodeAuditEvent struct { + ID string `json:"id"` + At time.Time `json:"at"` + Action NodeAuditAction `json:"action"` + Actor string `json:"actor"` + NodeID string `json:"node_id"` + NodeName string `json:"node_name"` + NodeKind NodeKind `json:"node_kind"` + Detail string `json:"detail,omitempty"` +} + +// NodeAuditStore is the plain JSON payload in node-audit.json. +type NodeAuditStore struct { + Events []NodeAuditEvent `json:"events"` +} + // UserCredential is one account stored in passwords.enc. type UserCredential struct { ID string `json:"id"` diff --git a/webui/src/App.css b/webui/src/App.css index b90b108..f30a2af 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -60,6 +60,24 @@ border: none; } +.sidebar-logs { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.5rem 0.75rem 0.75rem; + flex-shrink: 0; +} + +.sidebar-category-label { + margin: 0 0 0.15rem; + padding: 0 0.75rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ctp-subtext0); +} + .sidebar-footer { display: flex; flex-direction: column; @@ -395,6 +413,21 @@ cursor: default; } +.node-delete { + padding: 0.35rem 0.7rem; + border: 1px solid var(--ctp-red); + border-radius: 6px; + background: transparent; + color: var(--ctp-red); + font: inherit; + font-size: 0.85rem; + cursor: pointer; +} + +.node-delete:hover { + background: color-mix(in srgb, var(--ctp-red) 12%, transparent); +} + .node-test-result { margin: 0; font-size: 0.85rem; @@ -875,3 +908,39 @@ word-break: break-all; font-size: 0.85rem; } + +.logs-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.logs-filters { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.logs-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.logs-table th, +.logs-table td { + padding: 0.55rem 0.65rem; + text-align: left; + border-bottom: 1px solid var(--ctp-surface0); + vertical-align: top; +} + +.logs-table th { + font-weight: 600; + color: var(--ctp-subtext0); + font-size: 0.8rem; +} + +.logs-table code { + font-size: 0.85em; +} diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index 212b388..70ca1c0 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -30,6 +30,7 @@ function stubFetchOk() { 'nodes.read', 'nodes.exec', 'nodes.update', + 'nodes.delete', 'jobs.read', 'jobs.run', 'users.manage', @@ -70,6 +71,7 @@ function stubFetchOk() { 'nodes.read', 'nodes.exec', 'nodes.update', + 'nodes.delete', 'jobs.read', 'jobs.run', 'users.manage', @@ -373,6 +375,7 @@ describe('App', () => { 'nodes.read', 'nodes.exec', 'nodes.update', + 'nodes.delete', 'jobs.read', 'jobs.run', 'users.manage', @@ -466,6 +469,7 @@ describe('App', () => { 'nodes.read', 'nodes.exec', 'nodes.update', + 'nodes.delete', 'jobs.read', 'jobs.run', 'users.manage', @@ -749,14 +753,13 @@ describe('App', () => { expect(await screen.findByText('Operators')).toBeInTheDocument() expect(screen.getByText('Administrators')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() - expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(1) + expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(2) const deleteButtons = screen.getAllByRole('button', { name: 'Delete' }) expect(deleteButtons[0]).toBeDisabled() expect(deleteButtons[1]).toBeEnabled() - await user.click(screen.getByRole('button', { name: 'Edit' })) + await user.click(screen.getAllByRole('button', { name: 'Edit' })[1]) expect(screen.getByText('Edit group')).toBeInTheDocument() expect(screen.getByLabelText('Group name')).toHaveValue('Operators') @@ -778,6 +781,113 @@ describe('App', () => { ) }) + it('edits Administrators group permissions from the Groups tab', async () => { + const user = userEvent.setup() + const administratorsGroup = { + name: 'Administrators', + scope_kind: 'group' as const, + scope_name: 'Admin Group', + permissions: ['users.manage', 'roles.manage'], + } + const updatedAdministratorsGroup = { + ...administratorsGroup, + permissions: ['users.manage', 'roles.manage', 'nodes.delete'], + } + + const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString() + + if (url.includes('/api/v1/setup/status')) { + return Promise.resolve({ + ok: true, + json: async () => ({ completed: true, client_ip: '127.0.0.1' }), + }) + } + if (url.includes('/api/v1/auth/me')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + user_id: 'u1', + username: 'Admin', + groups: ['Administrators'], + }), + }) + } + if (url.includes('/health')) { + return Promise.resolve({ + ok: true, + json: async () => ({ status: 'ok' }), + }) + } + if (url.includes('/api/v1/status')) { + return Promise.resolve({ + ok: true, + json: async () => ({ service: 'clustercanvas', version: '0.1.0' }), + }) + } + if (url.includes('/api/v1/groups') && init?.method === 'POST') { + return Promise.resolve({ + ok: true, + json: async () => ({ + groups: [updatedAdministratorsGroup], + }), + }) + } + if (url.includes('/api/v1/groups')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + groups: [administratorsGroup], + }), + }) + } + return Promise.resolve({ ok: true, json: async () => ({}) }) + }) + vi.stubGlobal('fetch', fetchMock) + + render() + + expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Configuration' })) + await user.click(screen.getByRole('tab', { name: 'Groups' })) + + expect(await screen.findByText('Administrators')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'Edit' })) + + expect(screen.getByText('Edit group')).toBeInTheDocument() + expect(screen.getByLabelText('Group name')).toHaveValue('Administrators') + expect(screen.getByLabelText('Group name')).toHaveAttribute('readonly') + expect(screen.getByLabelText('users.manage')).toBeChecked() + expect(screen.getByLabelText('nodes.delete')).not.toBeChecked() + + await user.click(screen.getByLabelText('nodes.delete')) + await user.click(screen.getByRole('button', { name: 'Save changes' })) + + expect(await screen.findByText('Create group')).toBeInTheDocument() + expect( + screen.getByText( + 'Permissions: users.manage, roles.manage, nodes.delete', + ), + ).toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/groups'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + group: { + name: 'Administrators', + scope_kind: 'group', + scope_name: 'Admin Group', + permissions: ['users.manage', 'roles.manage', 'nodes.delete'], + }, + }), + }), + ) + }) + it('creates a new group from the Groups tab', async () => { const user = userEvent.setup() const createdGroup = { diff --git a/webui/src/App.tsx b/webui/src/App.tsx index e9ddd04..d2af23f 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -6,6 +6,7 @@ import { createNode, createUser, deleteGroup, + deleteNode, deleteUser, DEFAULT_NETWORK_SETTINGS, DEFAULT_SECURITY_SETTINGS, @@ -13,6 +14,7 @@ import { fetchGroups, fetchMe, fetchNetwork, + fetchNodeLogs, fetchNodes, fetchSecurity, fetchSetupStatus, @@ -28,6 +30,7 @@ import { type MeResponse, type NetworkSettings, type Node, + type NodeAuditEvent, type NodeKind, type ReauthCredentials, type SecuritySettings, @@ -76,6 +79,7 @@ const SECTION_TITLES: Record = { vms: 'Virtual Machines', docker: 'Docker', containers: 'Containers', + logs: 'Logs', profile: 'Profile', configuration: 'Configuration', setup: 'Setup', @@ -109,6 +113,7 @@ const DEFAULT_PERMISSIONS = [ 'nodes.read', 'nodes.exec', 'nodes.update', + 'nodes.delete', 'jobs.read', 'jobs.run', 'users.manage', @@ -1034,9 +1039,6 @@ function GroupsConfigTab() { } function handleEditGroup(group: Group) { - if (group.name === ADMINISTRATORS_GROUP_NAME) { - return - } setEditingGroupName(group.name) setDraftGroupName(group.name) setDraftScopeKind(group.scope_kind) @@ -1061,8 +1063,8 @@ function GroupsConfigTab() { async function handleSaveGroup() { setSubmitError(null) - if (draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) { - setSubmitError('Administrators group cannot be created or modified here') + if (!isEditing && draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) { + setSubmitError('Administrators group cannot be created here') return } @@ -1133,15 +1135,13 @@ function GroupsConfigTab() {
- {!isAdministrators ? ( - - ) : null} + - {testResult ? ( -

- {testResult.ok ? 'Succeeded: ' : 'Failed: '} - {testResult.message} -

- ) : null} + <> + {pendingDeleteNodeId ? ( + { + setPendingDeleteNodeId(null) + }} + onConfirm={handleDeleteConfirm} + /> + ) : null} +
    + {nodes.map((node) => { + const testResult = testResults[node.id] + return ( +
  • +
    + {node.name} + {node.id}
    - ) : null} -
  • - ) - })} -
+

+ {node.host_ip} · {node.username} · group {node.group_name} ·{' '} + {node.key_algo} +

+ {canExec || canDelete ? ( +
+ {canExec ? ( + + ) : null} + {canDelete ? ( + + ) : null} + {testResult ? ( +

+ {testResult.ok ? 'Succeeded: ' : 'Failed: '} + {testResult.message} +

+ ) : null} +
+ ) : null} + + ) + })} + + ) } @@ -2270,6 +2340,7 @@ function ResourceSectionPanel({ const canRead = Boolean(me?.permissions?.includes('nodes.read')) const canExec = Boolean(me?.permissions?.includes('nodes.exec')) + const canDelete = Boolean(me?.permissions?.includes('nodes.delete')) const tabs = resourceTabsFor(section) return ( @@ -2313,6 +2384,7 @@ function ResourceSectionPanel({ section={section} canRead={canRead} canExec={canExec} + canDelete={canDelete} /> ) : null} {activeResourceTab === 'security' ? ( @@ -2330,6 +2402,137 @@ function ResourceSectionPanel({ ) } +function LogsPanel() { + type KindFilter = 'all' | NodeKind + const [kindFilter, setKindFilter] = useState('all') + const [events, setEvents] = useState>([]) + const [isLoading, setIsLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [canRead, setCanRead] = useState(false) + + useEffect(() => { + let isCancelled = false + + async function load() { + setIsLoading(true) + setLoadError(null) + try { + const me = await fetchMe() + if (isCancelled) { + return + } + const allowed = Boolean(me?.permissions?.includes('nodes.read')) + setCanRead(allowed) + if (!allowed) { + setEvents([]) + return + } + const response = await fetchNodeLogs( + kindFilter === 'all' ? undefined : kindFilter, + ) + if (!isCancelled) { + setEvents(response.events) + } + } catch (err) { + if (!isCancelled) { + setLoadError( + err instanceof Error ? err.message : 'Failed to load logs', + ) + } + } finally { + if (!isCancelled) { + setIsLoading(false) + } + } + } + + void load() + + return () => { + isCancelled = true + } + }, [kindFilter]) + + const filters: ReadonlyArray<{ id: KindFilter; label: string }> = [ + { id: 'all', label: 'All' }, + { id: 'container', label: 'Containers' }, + { id: 'docker', label: 'Docker' }, + { id: 'vm', label: 'Virtual Machines' }, + ] + + if (!isLoading && !canRead) { + return ( +

+ You need the nodes.read permission to view node activity + logs. +

+ ) + } + + return ( +
+
+ {filters.map((filter) => { + const isSelected = kindFilter === filter.id + return ( + + ) + })} +
+ + {isLoading ?

Loading…

: null} + {loadError ?

{loadError}

: null} + {!isLoading && !loadError && events.length === 0 ? ( +

No node activity recorded yet.

+ ) : null} + {!isLoading && events.length > 0 ? ( + + + + + + + + + + + + + {events.map((event) => ( + + + + + + + + + ))} + +
WhenActionActorNodeKindDetail
{formatUserTimestamp(event.at)} + {event.action} + {event.actor} + {event.node_name} +
+ {event.node_id} +
+
{event.node_kind}{event.detail || '—'}
+ ) : null} +
+ ) +} + function ConfigurationPanel({ activeConfigTab, onConfigTabChange, @@ -2432,6 +2635,7 @@ function App() { ) const [backendVersion, setBackendVersion] = useState('…') const [bootState, setBootState] = useState({ kind: 'loading' }) + const [canReadLogs, setCanReadLogs] = useState(false) const { themePreference, updateThemePreference } = useThemePreference() const profileLabel = profileDisplayName ?? 'Profile' @@ -2471,6 +2675,7 @@ function App() { } setProfileDisplayName(me.username) + setCanReadLogs(Boolean(me.permissions?.includes('nodes.read'))) setBootState({ kind: 'ready', username: me.username }) if ( window.location.pathname === '/setup' || @@ -2577,6 +2782,9 @@ function App() { { setProfileDisplayName(username) + void fetchMe().then((me) => { + setCanReadLogs(Boolean(me?.permissions?.includes('nodes.read'))) + }) setBootState({ kind: 'ready', username }) navigateTo('/') setActiveSection('overview') @@ -2628,6 +2836,24 @@ function App() { ))} + {canReadLogs ? ( +
+

Logs

+ +
+ ) : null} +