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.
This commit is contained in:
2026-07-18 16:54:13 +02:00
parent f4dc8f63d7
commit 77be394407
17 changed files with 1326 additions and 71 deletions
+17
View File
@@ -10,6 +10,7 @@ import (
var errUsersManageRequired = errors.New("permission users.manage required") var errUsersManageRequired = errors.New("permission users.manage required")
var errNodesReadRequired = errors.New("permission nodes.read required") var errNodesReadRequired = errors.New("permission nodes.read required")
var errNodesExecRequired = errors.New("permission nodes.exec 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. // permissionsForUser returns the union of permissions from the user's groups.
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} { 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 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
}
+1
View File
@@ -25,6 +25,7 @@ var allowedPermissions = map[string]struct{}{
"nodes.read": {}, "nodes.read": {},
"nodes.exec": {}, "nodes.exec": {},
"nodes.update": {}, "nodes.update": {},
"nodes.delete": {},
"jobs.read": {}, "jobs.read": {},
"jobs.run": {}, "jobs.run": {},
"users.manage": {}, "users.manage": {},
+220 -9
View File
@@ -31,15 +31,24 @@ type createNodeNewGroup struct {
} }
type createNodeRequest struct { type createNodeRequest struct {
ID string `json:"id"` ID string `json:"id"`
Kind settings.NodeKind `json:"kind"` Kind settings.NodeKind `json:"kind"`
Name string `json:"name"` Name string `json:"name"`
HostIP string `json:"host_ip"` HostIP string `json:"host_ip"`
Username string `json:"username"` Username string `json:"username"`
GroupName string `json:"group_name"` GroupName string `json:"group_name"`
NewGroup *createNodeNewGroup `json:"new_group"` NewGroup *createNodeNewGroup `json:"new_group"`
Generate *createNodeGenerate `json:"generate"` Generate *createNodeGenerate `json:"generate"`
PrivateKey string `json:"private_key"` 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) { 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 { 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{ writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
OK: false, OK: false,
Message: err.Error(), Message: err.Error(),
@@ -214,6 +224,7 @@ func (app *App) nodesTestSSHHandler(writer http.ResponseWriter, request *http.Re
return return
} }
_ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionTestSSH, user.Username, node, "SSH connection succeeded")
writeJSON(writer, http.StatusOK, nodeSSHTestResponse{ writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
OK: true, OK: true,
Message: "SSH connection succeeded", Message: "SSH connection succeeded",
@@ -328,9 +339,167 @@ func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Req
return 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}) 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) { func buildNodeFromCreateRequest(payload createNodeRequest) (settings.Node, settings.NodeKeyEntry, error) {
kind := payload.Kind kind := payload.Kind
switch kind { switch kind {
@@ -440,6 +609,48 @@ func userCanAccessNode(user settings.UserCredential, groups []settings.Group, no
return false 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 { func isUUID(value string) bool {
if len(value) != 36 { if len(value) != 36 {
return false return false
+302
View File
@@ -341,4 +341,306 @@ func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) {
if response.Message == "" { if response.Message == "" {
t.Fatal("expected failure 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)
}
}
} }
+2
View File
@@ -49,7 +49,9 @@ func NewRouter(configDir string) http.Handler {
mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler) mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler)
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler) mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler) mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler) mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler)
return app.withCORS(app.withMiddleware(mux)) return app.withCORS(app.withMiddleware(mux))
} }
@@ -450,6 +450,7 @@ func allPermissionList() []string {
"nodes.read", "nodes.read",
"nodes.exec", "nodes.exec",
"nodes.update", "nodes.update",
"nodes.delete",
"jobs.read", "jobs.read",
"jobs.run", "jobs.run",
"users.manage", "users.manage",
+77
View File
@@ -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)
}
@@ -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)
}
}
+1
View File
@@ -18,6 +18,7 @@ const (
SessionsFileName = "sessions.enc" SessionsFileName = "sessions.enc"
NodesFileName = "nodes.json" NodesFileName = "nodes.json"
NodeKeysFileName = "node-keys.enc" NodeKeysFileName = "node-keys.enc"
NodeAuditFileName = "node-audit.json"
) )
// ResolveDir returns the config directory using precedence: // ResolveDir returns the config directory using precedence:
+27
View File
@@ -127,6 +127,33 @@ type NodeKeyStore struct {
Keys []NodeKeyEntry `json:"keys"` 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. // UserCredential is one account stored in passwords.enc.
type UserCredential struct { type UserCredential struct {
ID string `json:"id"` ID string `json:"id"`
+69
View File
@@ -60,6 +60,24 @@
border: none; 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 { .sidebar-footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -395,6 +413,21 @@
cursor: default; 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 { .node-test-result {
margin: 0; margin: 0;
font-size: 0.85rem; font-size: 0.85rem;
@@ -875,3 +908,39 @@
word-break: break-all; word-break: break-all;
font-size: 0.85rem; 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;
}
+113 -3
View File
@@ -30,6 +30,7 @@ function stubFetchOk() {
'nodes.read', 'nodes.read',
'nodes.exec', 'nodes.exec',
'nodes.update', 'nodes.update',
'nodes.delete',
'jobs.read', 'jobs.read',
'jobs.run', 'jobs.run',
'users.manage', 'users.manage',
@@ -70,6 +71,7 @@ function stubFetchOk() {
'nodes.read', 'nodes.read',
'nodes.exec', 'nodes.exec',
'nodes.update', 'nodes.update',
'nodes.delete',
'jobs.read', 'jobs.read',
'jobs.run', 'jobs.run',
'users.manage', 'users.manage',
@@ -373,6 +375,7 @@ describe('App', () => {
'nodes.read', 'nodes.read',
'nodes.exec', 'nodes.exec',
'nodes.update', 'nodes.update',
'nodes.delete',
'jobs.read', 'jobs.read',
'jobs.run', 'jobs.run',
'users.manage', 'users.manage',
@@ -466,6 +469,7 @@ describe('App', () => {
'nodes.read', 'nodes.read',
'nodes.exec', 'nodes.exec',
'nodes.update', 'nodes.update',
'nodes.delete',
'jobs.read', 'jobs.read',
'jobs.run', 'jobs.run',
'users.manage', 'users.manage',
@@ -749,14 +753,13 @@ describe('App', () => {
expect(await screen.findByText('Operators')).toBeInTheDocument() expect(await screen.findByText('Operators')).toBeInTheDocument()
expect(screen.getByText('Administrators')).toBeInTheDocument() expect(screen.getByText('Administrators')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(2)
expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(1)
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' }) const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
expect(deleteButtons[0]).toBeDisabled() expect(deleteButtons[0]).toBeDisabled()
expect(deleteButtons[1]).toBeEnabled() 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.getByText('Edit group')).toBeInTheDocument()
expect(screen.getByLabelText('Group name')).toHaveValue('Operators') 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(<App />)
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 () => { it('creates a new group from the Groups tab', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const createdGroup = { const createdGroup = {
+288 -59
View File
@@ -6,6 +6,7 @@ import {
createNode, createNode,
createUser, createUser,
deleteGroup, deleteGroup,
deleteNode,
deleteUser, deleteUser,
DEFAULT_NETWORK_SETTINGS, DEFAULT_NETWORK_SETTINGS,
DEFAULT_SECURITY_SETTINGS, DEFAULT_SECURITY_SETTINGS,
@@ -13,6 +14,7 @@ import {
fetchGroups, fetchGroups,
fetchMe, fetchMe,
fetchNetwork, fetchNetwork,
fetchNodeLogs,
fetchNodes, fetchNodes,
fetchSecurity, fetchSecurity,
fetchSetupStatus, fetchSetupStatus,
@@ -28,6 +30,7 @@ import {
type MeResponse, type MeResponse,
type NetworkSettings, type NetworkSettings,
type Node, type Node,
type NodeAuditEvent,
type NodeKind, type NodeKind,
type ReauthCredentials, type ReauthCredentials,
type SecuritySettings, type SecuritySettings,
@@ -76,6 +79,7 @@ const SECTION_TITLES: Record<SectionId, string> = {
vms: 'Virtual Machines', vms: 'Virtual Machines',
docker: 'Docker', docker: 'Docker',
containers: 'Containers', containers: 'Containers',
logs: 'Logs',
profile: 'Profile', profile: 'Profile',
configuration: 'Configuration', configuration: 'Configuration',
setup: 'Setup', setup: 'Setup',
@@ -109,6 +113,7 @@ const DEFAULT_PERMISSIONS = [
'nodes.read', 'nodes.read',
'nodes.exec', 'nodes.exec',
'nodes.update', 'nodes.update',
'nodes.delete',
'jobs.read', 'jobs.read',
'jobs.run', 'jobs.run',
'users.manage', 'users.manage',
@@ -1034,9 +1039,6 @@ function GroupsConfigTab() {
} }
function handleEditGroup(group: Group) { function handleEditGroup(group: Group) {
if (group.name === ADMINISTRATORS_GROUP_NAME) {
return
}
setEditingGroupName(group.name) setEditingGroupName(group.name)
setDraftGroupName(group.name) setDraftGroupName(group.name)
setDraftScopeKind(group.scope_kind) setDraftScopeKind(group.scope_kind)
@@ -1061,8 +1063,8 @@ function GroupsConfigTab() {
async function handleSaveGroup() { async function handleSaveGroup() {
setSubmitError(null) setSubmitError(null)
if (draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) { if (!isEditing && draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
setSubmitError('Administrators group cannot be created or modified here') setSubmitError('Administrators group cannot be created here')
return return
} }
@@ -1133,15 +1135,13 @@ function GroupsConfigTab() {
</div> </div>
</div> </div>
<div className="groups-li-actions"> <div className="groups-li-actions">
{!isAdministrators ? ( <button
<button type="button"
type="button" className="groups-edit"
className="groups-edit" onClick={() => handleEditGroup(group)}
onClick={() => handleEditGroup(group)} >
> Edit
Edit </button>
</button>
) : null}
<button <button
type="button" type="button"
className="groups-delete" className="groups-delete"
@@ -1770,10 +1770,12 @@ function ResourceOverviewTab({
section, section,
canRead, canRead,
canExec, canExec,
canDelete,
}: { }: {
section: ResourceSectionId section: ResourceSectionId
canRead: boolean canRead: boolean
canExec: boolean canExec: boolean
canDelete: boolean
}) { }) {
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([]) const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -1782,6 +1784,34 @@ function ResourceOverviewTab({
const [testResults, setTestResults] = useState( const [testResults, setTestResults] = useState(
{} as Record<string, { ok: boolean; message: string }>, {} as Record<string, { ok: boolean; message: string }>,
) )
const [me, setMe] = useState<MeResponse | null>(null)
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(
null,
)
const requireTotp = Boolean(me?.totp_confirmed && me?.totp_enabled)
useEffect(() => {
let isCancelled = false
async function loadMe() {
try {
const response = await fetchMe()
if (!isCancelled) {
setMe(response)
}
} catch {
if (!isCancelled) {
setMe(null)
}
}
}
void loadMe()
return () => {
isCancelled = true
}
}, [])
useEffect(() => { useEffect(() => {
if (!canRead) { if (!canRead) {
@@ -1846,6 +1876,19 @@ function ResourceOverviewTab({
} }
} }
async function handleDeleteConfirm(credentials: ReauthCredentials) {
if (!pendingDeleteNodeId) {
return
}
const response = await deleteNode(pendingDeleteNodeId, credentials)
setNodes(
response.nodes.filter(
(node) => node.kind === RESOURCE_SECTION_KIND[section],
),
)
setPendingDeleteNodeId(null)
}
if (!canRead) { if (!canRead) {
return ( return (
<p className="config-hint"> <p className="config-hint">
@@ -1872,52 +1915,79 @@ function ResourceOverviewTab({
} }
return ( return (
<ul className="node-list"> <>
{nodes.map((node) => { {pendingDeleteNodeId ? (
const testResult = testResults[node.id] <ReauthModal
return ( title="Confirm delete"
<li key={node.id} className="node-list-item"> hint="Re-enter your password to delete this host."
<div className="node-list-heading"> requireTotp={requireTotp}
<strong>{node.name}</strong> confirmLabel="Delete host"
<code className="node-id">{node.id}</code> onCancel={() => {
</div> setPendingDeleteNodeId(null)
<p className="config-hint"> }}
{node.host_ip} · {node.username} · group {node.group_name} ·{' '} onConfirm={handleDeleteConfirm}
{node.key_algo} />
</p> ) : null}
{canExec ? ( <ul className="node-list">
<div className="node-actions"> {nodes.map((node) => {
<button const testResult = testResults[node.id]
type="button" return (
className="node-test-ssh" <li key={node.id} className="node-list-item">
disabled={testingNodeId === node.id} <div className="node-list-heading">
onClick={() => { <strong>{node.name}</strong>
void handleTestSSH(node.id) <code className="node-id">{node.id}</code>
}}
>
{testingNodeId === node.id
? 'Testing SSH…'
: 'Test SSH connection'}
</button>
{testResult ? (
<p
className={
testResult.ok
? 'node-test-result node-test-result-ok'
: 'node-test-result node-test-result-fail'
}
role="status"
>
{testResult.ok ? 'Succeeded: ' : 'Failed: '}
{testResult.message}
</p>
) : null}
</div> </div>
) : null} <p className="config-hint">
</li> {node.host_ip} · {node.username} · group {node.group_name} ·{' '}
) {node.key_algo}
})} </p>
</ul> {canExec || canDelete ? (
<div className="node-actions">
{canExec ? (
<button
type="button"
className="node-test-ssh"
disabled={testingNodeId === node.id}
onClick={() => {
void handleTestSSH(node.id)
}}
>
{testingNodeId === node.id
? 'Testing SSH…'
: 'Test SSH connection'}
</button>
) : null}
{canDelete ? (
<button
type="button"
className="node-delete"
onClick={() => {
setPendingDeleteNodeId(node.id)
}}
>
Delete
</button>
) : null}
{testResult ? (
<p
className={
testResult.ok
? 'node-test-result node-test-result-ok'
: 'node-test-result node-test-result-fail'
}
role="status"
>
{testResult.ok ? 'Succeeded: ' : 'Failed: '}
{testResult.message}
</p>
) : null}
</div>
) : null}
</li>
)
})}
</ul>
</>
) )
} }
@@ -2270,6 +2340,7 @@ function ResourceSectionPanel({
const canRead = Boolean(me?.permissions?.includes('nodes.read')) const canRead = Boolean(me?.permissions?.includes('nodes.read'))
const canExec = Boolean(me?.permissions?.includes('nodes.exec')) const canExec = Boolean(me?.permissions?.includes('nodes.exec'))
const canDelete = Boolean(me?.permissions?.includes('nodes.delete'))
const tabs = resourceTabsFor(section) const tabs = resourceTabsFor(section)
return ( return (
@@ -2313,6 +2384,7 @@ function ResourceSectionPanel({
section={section} section={section}
canRead={canRead} canRead={canRead}
canExec={canExec} canExec={canExec}
canDelete={canDelete}
/> />
) : null} ) : null}
{activeResourceTab === 'security' ? ( {activeResourceTab === 'security' ? (
@@ -2330,6 +2402,137 @@ function ResourceSectionPanel({
) )
} }
function LogsPanel() {
type KindFilter = 'all' | NodeKind
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
const [events, setEvents] = useState<ReadonlyArray<NodeAuditEvent>>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(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 (
<p className="config-hint">
You need the <code>nodes.read</code> permission to view node activity
logs.
</p>
)
}
return (
<div className="logs-panel">
<div className="logs-filters" role="tablist" aria-label="Log kind filter">
{filters.map((filter) => {
const isSelected = kindFilter === filter.id
return (
<button
key={filter.id}
type="button"
role="tab"
className={
isSelected ? 'config-tab config-tab-active' : 'config-tab'
}
aria-selected={isSelected}
onClick={() => setKindFilter(filter.id)}
>
{filter.label}
</button>
)
})}
</div>
{isLoading ? <p className="config-hint">Loading</p> : null}
{loadError ? <p className="config-error">{loadError}</p> : null}
{!isLoading && !loadError && events.length === 0 ? (
<p className="config-hint">No node activity recorded yet.</p>
) : null}
{!isLoading && events.length > 0 ? (
<table className="logs-table">
<thead>
<tr>
<th scope="col">When</th>
<th scope="col">Action</th>
<th scope="col">Actor</th>
<th scope="col">Node</th>
<th scope="col">Kind</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
{events.map((event) => (
<tr key={event.id}>
<td>{formatUserTimestamp(event.at)}</td>
<td>
<code>{event.action}</code>
</td>
<td>{event.actor}</td>
<td>
<strong>{event.node_name}</strong>
<div>
<code className="node-id">{event.node_id}</code>
</div>
</td>
<td>{event.node_kind}</td>
<td>{event.detail || '—'}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
)
}
function ConfigurationPanel({ function ConfigurationPanel({
activeConfigTab, activeConfigTab,
onConfigTabChange, onConfigTabChange,
@@ -2432,6 +2635,7 @@ function App() {
) )
const [backendVersion, setBackendVersion] = useState('…') const [backendVersion, setBackendVersion] = useState('…')
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' }) const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
const [canReadLogs, setCanReadLogs] = useState(false)
const { themePreference, updateThemePreference } = useThemePreference() const { themePreference, updateThemePreference } = useThemePreference()
const profileLabel = profileDisplayName ?? 'Profile' const profileLabel = profileDisplayName ?? 'Profile'
@@ -2471,6 +2675,7 @@ function App() {
} }
setProfileDisplayName(me.username) setProfileDisplayName(me.username)
setCanReadLogs(Boolean(me.permissions?.includes('nodes.read')))
setBootState({ kind: 'ready', username: me.username }) setBootState({ kind: 'ready', username: me.username })
if ( if (
window.location.pathname === '/setup' || window.location.pathname === '/setup' ||
@@ -2577,6 +2782,9 @@ function App() {
<LoginPage <LoginPage
onLoggedIn={(username) => { onLoggedIn={(username) => {
setProfileDisplayName(username) setProfileDisplayName(username)
void fetchMe().then((me) => {
setCanReadLogs(Boolean(me?.permissions?.includes('nodes.read')))
})
setBootState({ kind: 'ready', username }) setBootState({ kind: 'ready', username })
navigateTo('/') navigateTo('/')
setActiveSection('overview') setActiveSection('overview')
@@ -2628,6 +2836,24 @@ function App() {
))} ))}
</nav> </nav>
{canReadLogs ? (
<div className="sidebar-logs">
<p className="sidebar-category-label">Logs</p>
<button
type="button"
className={
activeSection === 'logs'
? 'nav-item nav-item-active'
: 'nav-item'
}
aria-current={activeSection === 'logs' ? 'page' : undefined}
onClick={() => goToSection('logs')}
>
Activity
</button>
</div>
) : null}
<div className="sidebar-footer"> <div className="sidebar-footer">
<button <button
type="button" type="button"
@@ -2675,10 +2901,13 @@ function App() {
<ProfilePanel <ProfilePanel
onSignedOut={() => { onSignedOut={() => {
setProfileDisplayName(null) setProfileDisplayName(null)
setCanReadLogs(false)
setBootState({ kind: 'login' }) setBootState({ kind: 'login' })
navigateTo('/login') navigateTo('/login')
}} }}
/> />
) : activeSection === 'logs' ? (
<LogsPanel />
) : isResourceSectionId(activeSection) ? ( ) : isResourceSectionId(activeSection) ? (
<ResourceSectionPanel <ResourceSectionPanel
section={activeSection} section={activeSection}
+65
View File
@@ -1,10 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
deleteGroup, deleteGroup,
deleteNode,
deleteUser, deleteUser,
fetchGroups, fetchGroups,
fetchHealth, fetchHealth,
fetchNetwork, fetchNetwork,
fetchNodeLogs,
fetchSecurity, fetchSecurity,
fetchStatus, fetchStatus,
fetchUsers, fetchUsers,
@@ -326,6 +328,69 @@ describe('deleteUser', () => {
}) })
}) })
describe('deleteNode', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sends DELETE by id with reauth credentials and returns nodes', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ nodes: [] }),
})
await deleteNode(
'node-1',
{ current_password: 'correct horse battery staple extra' },
fetchMock,
)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes/node-1',
expect.objectContaining({
method: 'DELETE',
credentials: 'include',
body: JSON.stringify({
current_password: 'correct horse battery staple extra',
totp_code: '',
}),
}),
)
})
})
describe('fetchNodeLogs', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns parsed logs payload with optional kind filter', async () => {
const events = [
{
id: 'e1',
at: '2026-01-01T00:00:00Z',
action: 'create',
actor: 'Admin',
node_id: 'n1',
node_name: 'ct1',
node_kind: 'container',
},
]
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ events }),
})
const payload = await fetchNodeLogs('container', fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/node-logs?kind=container',
expect.objectContaining({ credentials: 'include' }),
)
expect(payload.events).toEqual(events)
})
})
describe('fetchSecurity', () => { describe('fetchSecurity', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
+51
View File
@@ -675,6 +675,28 @@ export async function createNode(
return (await response.json()) as NodeResponse return (await response.json()) as NodeResponse
} }
export async function deleteNode(
id: string,
credentials: ReauthCredentials,
fetchImpl: typeof fetch = fetch,
): Promise<NodesResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(id)}`,
{
method: 'DELETE',
body: JSON.stringify({
current_password: credentials.current_password,
totp_code: credentials.totp_code ?? '',
}),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodesResponse
}
export async function fetchNode( export async function fetchNode(
id: string, id: string,
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
@@ -709,3 +731,32 @@ export async function testNodeSSH(
} }
return (await response.json()) as NodeSSHTestResponse return (await response.json()) as NodeSSHTestResponse
} }
export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'update'
export type NodeAuditEvent = {
id: string
at: string
action: NodeAuditAction
actor: string
node_id: string
node_name: string
node_kind: NodeKind
detail?: string
}
export type NodeLogsResponse = {
events: ReadonlyArray<NodeAuditEvent>
}
export async function fetchNodeLogs(
kind?: NodeKind,
fetchImpl: typeof fetch = fetch,
): Promise<NodeLogsResponse> {
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
const response = await apiFetch(`/api/v1/node-logs${query}`, {}, fetchImpl)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodeLogsResponse
}
+2
View File
@@ -11,6 +11,7 @@ describe('pathFor', () => {
expect(pathFor('containers', 'overview', 'add')).toBe('/containers/add') expect(pathFor('containers', 'overview', 'add')).toBe('/containers/add')
expect(pathFor('docker', 'overview', 'add')).toBe('/docker/add') expect(pathFor('docker', 'overview', 'add')).toBe('/docker/add')
expect(pathFor('profile')).toBe('/profile') expect(pathFor('profile')).toBe('/profile')
expect(pathFor('logs')).toBe('/logs')
expect(pathFor('setup')).toBe('/setup') expect(pathFor('setup')).toBe('/setup')
expect(pathFor('login')).toBe('/login') expect(pathFor('login')).toBe('/login')
expect(pathFor('configuration')).toBe('/configuration') expect(pathFor('configuration')).toBe('/configuration')
@@ -31,6 +32,7 @@ describe('parseLocation', () => {
'/containers/add', '/containers/add',
'/docker/overview', '/docker/overview',
'/profile', '/profile',
'/logs',
'/setup', '/setup',
'/login', '/login',
'/configuration', '/configuration',
+8
View File
@@ -3,6 +3,7 @@ export type SectionId =
| 'vms' | 'vms'
| 'docker' | 'docker'
| 'containers' | 'containers'
| 'logs'
| 'profile' | 'profile'
| 'configuration' | 'configuration'
| 'setup' | 'setup'
@@ -92,6 +93,10 @@ export function parseLocation(pathname: string): AppLocation {
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' } return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
} }
if (first === 'logs' && segments.length === 1) {
return { section: 'logs', configTab: 'overview', resourceTab: 'overview' }
}
if (RESOURCE_SECTION_IDS.has(first)) { if (RESOURCE_SECTION_IDS.has(first)) {
const section = first as ResourceSectionId const section = first as ResourceSectionId
if (segments.length === 1) { if (segments.length === 1) {
@@ -141,6 +146,9 @@ export function pathFor(
if (section === 'profile') { if (section === 'profile') {
return '/profile' return '/profile'
} }
if (section === 'logs') {
return '/logs'
}
if (isResourceSectionId(section)) { if (isResourceSectionId(section)) {
if (resourceTab === 'overview') { if (resourceTab === 'overview') {
return `/${section}` return `/${section}`