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
+302
View File
@@ -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)
}
}
}