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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ var allowedPermissions = map[string]struct{}{
|
||||
"nodes.read": {},
|
||||
"nodes.exec": {},
|
||||
"nodes.update": {},
|
||||
"nodes.delete": {},
|
||||
"jobs.read": {},
|
||||
"jobs.run": {},
|
||||
"users.manage": {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user