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 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
}
+1
View File
@@ -25,6 +25,7 @@ var allowedPermissions = map[string]struct{}{
"nodes.read": {},
"nodes.exec": {},
"nodes.update": {},
"nodes.delete": {},
"jobs.read": {},
"jobs.run": {},
"users.manage": {},
+220 -9
View File
@@ -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
+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)
}
}
}
+2
View File
@@ -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))
}
@@ -450,6 +450,7 @@ func allPermissionList() []string {
"nodes.read",
"nodes.exec",
"nodes.update",
"nodes.delete",
"jobs.read",
"jobs.run",
"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"
NodesFileName = "nodes.json"
NodeKeysFileName = "node-keys.enc"
NodeAuditFileName = "node-audit.json"
)
// ResolveDir returns the config directory using precedence:
+27
View File
@@ -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"`