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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user