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
+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