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 errUsersManageRequired = errors.New("permission users.manage required")
|
||||||
var errNodesReadRequired = errors.New("permission nodes.read required")
|
var errNodesReadRequired = errors.New("permission nodes.read required")
|
||||||
var errNodesExecRequired = errors.New("permission nodes.exec 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.
|
// permissionsForUser returns the union of permissions from the user's groups.
|
||||||
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
|
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
|
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.read": {},
|
||||||
"nodes.exec": {},
|
"nodes.exec": {},
|
||||||
"nodes.update": {},
|
"nodes.update": {},
|
||||||
|
"nodes.delete": {},
|
||||||
"jobs.read": {},
|
"jobs.read": {},
|
||||||
"jobs.run": {},
|
"jobs.run": {},
|
||||||
"users.manage": {},
|
"users.manage": {},
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ type createNodeRequest struct {
|
|||||||
PrivateKey string `json:"private_key"`
|
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) {
|
func (app *App) nodesListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||||
if err := app.authorizeNodesRead(request); err != nil {
|
if err := app.authorizeNodesRead(request); err != nil {
|
||||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||||
@@ -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 {
|
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{
|
writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
|
||||||
OK: false,
|
OK: false,
|
||||||
Message: err.Error(),
|
Message: err.Error(),
|
||||||
@@ -214,6 +224,7 @@ func (app *App) nodesTestSSHHandler(writer http.ResponseWriter, request *http.Re
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionTestSSH, user.Username, node, "SSH connection succeeded")
|
||||||
writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
|
writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
|
||||||
OK: true,
|
OK: true,
|
||||||
Message: "SSH connection succeeded",
|
Message: "SSH connection succeeded",
|
||||||
@@ -328,9 +339,167 @@ func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Req
|
|||||||
return
|
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})
|
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) {
|
func buildNodeFromCreateRequest(payload createNodeRequest) (settings.Node, settings.NodeKeyEntry, error) {
|
||||||
kind := payload.Kind
|
kind := payload.Kind
|
||||||
switch kind {
|
switch kind {
|
||||||
@@ -440,6 +609,48 @@ func userCanAccessNode(user settings.UserCredential, groups []settings.Group, no
|
|||||||
return false
|
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 {
|
func isUUID(value string) bool {
|
||||||
if len(value) != 36 {
|
if len(value) != 36 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -341,4 +341,306 @@ func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) {
|
|||||||
if response.Message == "" {
|
if response.Message == "" {
|
||||||
t.Fatal("expected failure 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("GET /api/v1/nodes", app.nodesListHandler)
|
||||||
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
|
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
|
||||||
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
|
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("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
|
||||||
|
mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler)
|
||||||
|
|
||||||
return app.withCORS(app.withMiddleware(mux))
|
return app.withCORS(app.withMiddleware(mux))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -450,6 +450,7 @@ func allPermissionList() []string {
|
|||||||
"nodes.read",
|
"nodes.read",
|
||||||
"nodes.exec",
|
"nodes.exec",
|
||||||
"nodes.update",
|
"nodes.update",
|
||||||
|
"nodes.delete",
|
||||||
"jobs.read",
|
"jobs.read",
|
||||||
"jobs.run",
|
"jobs.run",
|
||||||
"users.manage",
|
"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"
|
SessionsFileName = "sessions.enc"
|
||||||
NodesFileName = "nodes.json"
|
NodesFileName = "nodes.json"
|
||||||
NodeKeysFileName = "node-keys.enc"
|
NodeKeysFileName = "node-keys.enc"
|
||||||
|
NodeAuditFileName = "node-audit.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResolveDir returns the config directory using precedence:
|
// ResolveDir returns the config directory using precedence:
|
||||||
|
|||||||
@@ -127,6 +127,33 @@ type NodeKeyStore struct {
|
|||||||
Keys []NodeKeyEntry `json:"keys"`
|
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.
|
// UserCredential is one account stored in passwords.enc.
|
||||||
type UserCredential struct {
|
type UserCredential struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -60,6 +60,24 @@
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-logs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 0.75rem 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-category-label {
|
||||||
|
margin: 0 0 0.15rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -395,6 +413,21 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-delete {
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border: 1px solid var(--ctp-red);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ctp-red);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-delete:hover {
|
||||||
|
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.node-test-result {
|
.node-test-result {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -875,3 +908,39 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logs-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table th,
|
||||||
|
.logs-table td {
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table code {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|||||||
+113
-3
@@ -30,6 +30,7 @@ function stubFetchOk() {
|
|||||||
'nodes.read',
|
'nodes.read',
|
||||||
'nodes.exec',
|
'nodes.exec',
|
||||||
'nodes.update',
|
'nodes.update',
|
||||||
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
@@ -70,6 +71,7 @@ function stubFetchOk() {
|
|||||||
'nodes.read',
|
'nodes.read',
|
||||||
'nodes.exec',
|
'nodes.exec',
|
||||||
'nodes.update',
|
'nodes.update',
|
||||||
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
@@ -373,6 +375,7 @@ describe('App', () => {
|
|||||||
'nodes.read',
|
'nodes.read',
|
||||||
'nodes.exec',
|
'nodes.exec',
|
||||||
'nodes.update',
|
'nodes.update',
|
||||||
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
@@ -466,6 +469,7 @@ describe('App', () => {
|
|||||||
'nodes.read',
|
'nodes.read',
|
||||||
'nodes.exec',
|
'nodes.exec',
|
||||||
'nodes.update',
|
'nodes.update',
|
||||||
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
@@ -749,14 +753,13 @@ describe('App', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('Operators')).toBeInTheDocument()
|
expect(await screen.findByText('Operators')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Administrators')).toBeInTheDocument()
|
expect(screen.getByText('Administrators')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
|
expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(2)
|
||||||
expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(1)
|
|
||||||
|
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||||
expect(deleteButtons[0]).toBeDisabled()
|
expect(deleteButtons[0]).toBeDisabled()
|
||||||
expect(deleteButtons[1]).toBeEnabled()
|
expect(deleteButtons[1]).toBeEnabled()
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
await user.click(screen.getAllByRole('button', { name: 'Edit' })[1])
|
||||||
|
|
||||||
expect(screen.getByText('Edit group')).toBeInTheDocument()
|
expect(screen.getByText('Edit group')).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText('Group name')).toHaveValue('Operators')
|
expect(screen.getByLabelText('Group name')).toHaveValue('Operators')
|
||||||
@@ -778,6 +781,113 @@ describe('App', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('edits Administrators group permissions from the Groups tab', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const administratorsGroup = {
|
||||||
|
name: 'Administrators',
|
||||||
|
scope_kind: 'group' as const,
|
||||||
|
scope_name: 'Admin Group',
|
||||||
|
permissions: ['users.manage', 'roles.manage'],
|
||||||
|
}
|
||||||
|
const updatedAdministratorsGroup = {
|
||||||
|
...administratorsGroup,
|
||||||
|
permissions: ['users.manage', 'roles.manage', 'nodes.delete'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input.toString()
|
||||||
|
|
||||||
|
if (url.includes('/api/v1/setup/status')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.includes('/api/v1/auth/me')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
user_id: 'u1',
|
||||||
|
username: 'Admin',
|
||||||
|
groups: ['Administrators'],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.includes('/health')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ status: 'ok' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.includes('/api/v1/status')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ service: 'clustercanvas', version: '0.1.0' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.includes('/api/v1/groups') && init?.method === 'POST') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
groups: [updatedAdministratorsGroup],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.includes('/api/v1/groups')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
groups: [administratorsGroup],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) })
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||||
|
await user.click(screen.getByRole('tab', { name: 'Groups' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('Administrators')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Edit group')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Group name')).toHaveValue('Administrators')
|
||||||
|
expect(screen.getByLabelText('Group name')).toHaveAttribute('readonly')
|
||||||
|
expect(screen.getByLabelText('users.manage')).toBeChecked()
|
||||||
|
expect(screen.getByLabelText('nodes.delete')).not.toBeChecked()
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText('nodes.delete'))
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save changes' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('Create group')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Permissions: users.manage, roles.manage, nodes.delete',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/v1/groups'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
group: {
|
||||||
|
name: 'Administrators',
|
||||||
|
scope_kind: 'group',
|
||||||
|
scope_name: 'Admin Group',
|
||||||
|
permissions: ['users.manage', 'roles.manage', 'nodes.delete'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('creates a new group from the Groups tab', async () => {
|
it('creates a new group from the Groups tab', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const createdGroup = {
|
const createdGroup = {
|
||||||
|
|||||||
+237
-8
@@ -6,6 +6,7 @@ import {
|
|||||||
createNode,
|
createNode,
|
||||||
createUser,
|
createUser,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
|
deleteNode,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
DEFAULT_NETWORK_SETTINGS,
|
DEFAULT_NETWORK_SETTINGS,
|
||||||
DEFAULT_SECURITY_SETTINGS,
|
DEFAULT_SECURITY_SETTINGS,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
fetchGroups,
|
fetchGroups,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchNetwork,
|
fetchNetwork,
|
||||||
|
fetchNodeLogs,
|
||||||
fetchNodes,
|
fetchNodes,
|
||||||
fetchSecurity,
|
fetchSecurity,
|
||||||
fetchSetupStatus,
|
fetchSetupStatus,
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
type MeResponse,
|
type MeResponse,
|
||||||
type NetworkSettings,
|
type NetworkSettings,
|
||||||
type Node,
|
type Node,
|
||||||
|
type NodeAuditEvent,
|
||||||
type NodeKind,
|
type NodeKind,
|
||||||
type ReauthCredentials,
|
type ReauthCredentials,
|
||||||
type SecuritySettings,
|
type SecuritySettings,
|
||||||
@@ -76,6 +79,7 @@ const SECTION_TITLES: Record<SectionId, string> = {
|
|||||||
vms: 'Virtual Machines',
|
vms: 'Virtual Machines',
|
||||||
docker: 'Docker',
|
docker: 'Docker',
|
||||||
containers: 'Containers',
|
containers: 'Containers',
|
||||||
|
logs: 'Logs',
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
configuration: 'Configuration',
|
configuration: 'Configuration',
|
||||||
setup: 'Setup',
|
setup: 'Setup',
|
||||||
@@ -109,6 +113,7 @@ const DEFAULT_PERMISSIONS = [
|
|||||||
'nodes.read',
|
'nodes.read',
|
||||||
'nodes.exec',
|
'nodes.exec',
|
||||||
'nodes.update',
|
'nodes.update',
|
||||||
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
@@ -1034,9 +1039,6 @@ function GroupsConfigTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEditGroup(group: Group) {
|
function handleEditGroup(group: Group) {
|
||||||
if (group.name === ADMINISTRATORS_GROUP_NAME) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEditingGroupName(group.name)
|
setEditingGroupName(group.name)
|
||||||
setDraftGroupName(group.name)
|
setDraftGroupName(group.name)
|
||||||
setDraftScopeKind(group.scope_kind)
|
setDraftScopeKind(group.scope_kind)
|
||||||
@@ -1061,8 +1063,8 @@ function GroupsConfigTab() {
|
|||||||
async function handleSaveGroup() {
|
async function handleSaveGroup() {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
|
|
||||||
if (draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
|
if (!isEditing && draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
|
||||||
setSubmitError('Administrators group cannot be created or modified here')
|
setSubmitError('Administrators group cannot be created here')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,7 +1135,6 @@ function GroupsConfigTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="groups-li-actions">
|
<div className="groups-li-actions">
|
||||||
{!isAdministrators ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="groups-edit"
|
className="groups-edit"
|
||||||
@@ -1141,7 +1142,6 @@ function GroupsConfigTab() {
|
|||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="groups-delete"
|
className="groups-delete"
|
||||||
@@ -1770,10 +1770,12 @@ function ResourceOverviewTab({
|
|||||||
section,
|
section,
|
||||||
canRead,
|
canRead,
|
||||||
canExec,
|
canExec,
|
||||||
|
canDelete,
|
||||||
}: {
|
}: {
|
||||||
section: ResourceSectionId
|
section: ResourceSectionId
|
||||||
canRead: boolean
|
canRead: boolean
|
||||||
canExec: boolean
|
canExec: boolean
|
||||||
|
canDelete: boolean
|
||||||
}) {
|
}) {
|
||||||
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
|
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -1782,6 +1784,34 @@ function ResourceOverviewTab({
|
|||||||
const [testResults, setTestResults] = useState(
|
const [testResults, setTestResults] = useState(
|
||||||
{} as Record<string, { ok: boolean; message: string }>,
|
{} as Record<string, { ok: boolean; message: string }>,
|
||||||
)
|
)
|
||||||
|
const [me, setMe] = useState<MeResponse | null>(null)
|
||||||
|
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const requireTotp = Boolean(me?.totp_confirmed && me?.totp_enabled)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
|
async function loadMe() {
|
||||||
|
try {
|
||||||
|
const response = await fetchMe()
|
||||||
|
if (!isCancelled) {
|
||||||
|
setMe(response)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setMe(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadMe()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canRead) {
|
if (!canRead) {
|
||||||
@@ -1846,6 +1876,19 @@ function ResourceOverviewTab({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfirm(credentials: ReauthCredentials) {
|
||||||
|
if (!pendingDeleteNodeId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const response = await deleteNode(pendingDeleteNodeId, credentials)
|
||||||
|
setNodes(
|
||||||
|
response.nodes.filter(
|
||||||
|
(node) => node.kind === RESOURCE_SECTION_KIND[section],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setPendingDeleteNodeId(null)
|
||||||
|
}
|
||||||
|
|
||||||
if (!canRead) {
|
if (!canRead) {
|
||||||
return (
|
return (
|
||||||
<p className="config-hint">
|
<p className="config-hint">
|
||||||
@@ -1872,6 +1915,19 @@ function ResourceOverviewTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{pendingDeleteNodeId ? (
|
||||||
|
<ReauthModal
|
||||||
|
title="Confirm delete"
|
||||||
|
hint="Re-enter your password to delete this host."
|
||||||
|
requireTotp={requireTotp}
|
||||||
|
confirmLabel="Delete host"
|
||||||
|
onCancel={() => {
|
||||||
|
setPendingDeleteNodeId(null)
|
||||||
|
}}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<ul className="node-list">
|
<ul className="node-list">
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
const testResult = testResults[node.id]
|
const testResult = testResults[node.id]
|
||||||
@@ -1885,8 +1941,9 @@ function ResourceOverviewTab({
|
|||||||
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
|
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
|
||||||
{node.key_algo}
|
{node.key_algo}
|
||||||
</p>
|
</p>
|
||||||
{canExec ? (
|
{canExec || canDelete ? (
|
||||||
<div className="node-actions">
|
<div className="node-actions">
|
||||||
|
{canExec ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="node-test-ssh"
|
className="node-test-ssh"
|
||||||
@@ -1899,6 +1956,18 @@ function ResourceOverviewTab({
|
|||||||
? 'Testing SSH…'
|
? 'Testing SSH…'
|
||||||
: 'Test SSH connection'}
|
: 'Test SSH connection'}
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canDelete ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="node-delete"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingDeleteNodeId(node.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{testResult ? (
|
{testResult ? (
|
||||||
<p
|
<p
|
||||||
className={
|
className={
|
||||||
@@ -1918,6 +1987,7 @@ function ResourceOverviewTab({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2270,6 +2340,7 @@ function ResourceSectionPanel({
|
|||||||
|
|
||||||
const canRead = Boolean(me?.permissions?.includes('nodes.read'))
|
const canRead = Boolean(me?.permissions?.includes('nodes.read'))
|
||||||
const canExec = Boolean(me?.permissions?.includes('nodes.exec'))
|
const canExec = Boolean(me?.permissions?.includes('nodes.exec'))
|
||||||
|
const canDelete = Boolean(me?.permissions?.includes('nodes.delete'))
|
||||||
const tabs = resourceTabsFor(section)
|
const tabs = resourceTabsFor(section)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2313,6 +2384,7 @@ function ResourceSectionPanel({
|
|||||||
section={section}
|
section={section}
|
||||||
canRead={canRead}
|
canRead={canRead}
|
||||||
canExec={canExec}
|
canExec={canExec}
|
||||||
|
canDelete={canDelete}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeResourceTab === 'security' ? (
|
{activeResourceTab === 'security' ? (
|
||||||
@@ -2330,6 +2402,137 @@ function ResourceSectionPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LogsPanel() {
|
||||||
|
type KindFilter = 'all' | NodeKind
|
||||||
|
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
|
||||||
|
const [events, setEvents] = useState<ReadonlyArray<NodeAuditEvent>>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const [canRead, setCanRead] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setIsLoading(true)
|
||||||
|
setLoadError(null)
|
||||||
|
try {
|
||||||
|
const me = await fetchMe()
|
||||||
|
if (isCancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const allowed = Boolean(me?.permissions?.includes('nodes.read'))
|
||||||
|
setCanRead(allowed)
|
||||||
|
if (!allowed) {
|
||||||
|
setEvents([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const response = await fetchNodeLogs(
|
||||||
|
kindFilter === 'all' ? undefined : kindFilter,
|
||||||
|
)
|
||||||
|
if (!isCancelled) {
|
||||||
|
setEvents(response.events)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setLoadError(
|
||||||
|
err instanceof Error ? err.message : 'Failed to load logs',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
}, [kindFilter])
|
||||||
|
|
||||||
|
const filters: ReadonlyArray<{ id: KindFilter; label: string }> = [
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'container', label: 'Containers' },
|
||||||
|
{ id: 'docker', label: 'Docker' },
|
||||||
|
{ id: 'vm', label: 'Virtual Machines' },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!isLoading && !canRead) {
|
||||||
|
return (
|
||||||
|
<p className="config-hint">
|
||||||
|
You need the <code>nodes.read</code> permission to view node activity
|
||||||
|
logs.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="logs-panel">
|
||||||
|
<div className="logs-filters" role="tablist" aria-label="Log kind filter">
|
||||||
|
{filters.map((filter) => {
|
||||||
|
const isSelected = kindFilter === filter.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={
|
||||||
|
isSelected ? 'config-tab config-tab-active' : 'config-tab'
|
||||||
|
}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onClick={() => setKindFilter(filter.id)}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <p className="config-hint">Loading…</p> : null}
|
||||||
|
{loadError ? <p className="config-error">{loadError}</p> : null}
|
||||||
|
{!isLoading && !loadError && events.length === 0 ? (
|
||||||
|
<p className="config-hint">No node activity recorded yet.</p>
|
||||||
|
) : null}
|
||||||
|
{!isLoading && events.length > 0 ? (
|
||||||
|
<table className="logs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">When</th>
|
||||||
|
<th scope="col">Action</th>
|
||||||
|
<th scope="col">Actor</th>
|
||||||
|
<th scope="col">Node</th>
|
||||||
|
<th scope="col">Kind</th>
|
||||||
|
<th scope="col">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.map((event) => (
|
||||||
|
<tr key={event.id}>
|
||||||
|
<td>{formatUserTimestamp(event.at)}</td>
|
||||||
|
<td>
|
||||||
|
<code>{event.action}</code>
|
||||||
|
</td>
|
||||||
|
<td>{event.actor}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{event.node_name}</strong>
|
||||||
|
<div>
|
||||||
|
<code className="node-id">{event.node_id}</code>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{event.node_kind}</td>
|
||||||
|
<td>{event.detail || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ConfigurationPanel({
|
function ConfigurationPanel({
|
||||||
activeConfigTab,
|
activeConfigTab,
|
||||||
onConfigTabChange,
|
onConfigTabChange,
|
||||||
@@ -2432,6 +2635,7 @@ function App() {
|
|||||||
)
|
)
|
||||||
const [backendVersion, setBackendVersion] = useState('…')
|
const [backendVersion, setBackendVersion] = useState('…')
|
||||||
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
|
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
|
||||||
|
const [canReadLogs, setCanReadLogs] = useState(false)
|
||||||
const { themePreference, updateThemePreference } = useThemePreference()
|
const { themePreference, updateThemePreference } = useThemePreference()
|
||||||
|
|
||||||
const profileLabel = profileDisplayName ?? 'Profile'
|
const profileLabel = profileDisplayName ?? 'Profile'
|
||||||
@@ -2471,6 +2675,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setProfileDisplayName(me.username)
|
setProfileDisplayName(me.username)
|
||||||
|
setCanReadLogs(Boolean(me.permissions?.includes('nodes.read')))
|
||||||
setBootState({ kind: 'ready', username: me.username })
|
setBootState({ kind: 'ready', username: me.username })
|
||||||
if (
|
if (
|
||||||
window.location.pathname === '/setup' ||
|
window.location.pathname === '/setup' ||
|
||||||
@@ -2577,6 +2782,9 @@ function App() {
|
|||||||
<LoginPage
|
<LoginPage
|
||||||
onLoggedIn={(username) => {
|
onLoggedIn={(username) => {
|
||||||
setProfileDisplayName(username)
|
setProfileDisplayName(username)
|
||||||
|
void fetchMe().then((me) => {
|
||||||
|
setCanReadLogs(Boolean(me?.permissions?.includes('nodes.read')))
|
||||||
|
})
|
||||||
setBootState({ kind: 'ready', username })
|
setBootState({ kind: 'ready', username })
|
||||||
navigateTo('/')
|
navigateTo('/')
|
||||||
setActiveSection('overview')
|
setActiveSection('overview')
|
||||||
@@ -2628,6 +2836,24 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{canReadLogs ? (
|
||||||
|
<div className="sidebar-logs">
|
||||||
|
<p className="sidebar-category-label">Logs</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
activeSection === 'logs'
|
||||||
|
? 'nav-item nav-item-active'
|
||||||
|
: 'nav-item'
|
||||||
|
}
|
||||||
|
aria-current={activeSection === 'logs' ? 'page' : undefined}
|
||||||
|
onClick={() => goToSection('logs')}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2675,10 +2901,13 @@ function App() {
|
|||||||
<ProfilePanel
|
<ProfilePanel
|
||||||
onSignedOut={() => {
|
onSignedOut={() => {
|
||||||
setProfileDisplayName(null)
|
setProfileDisplayName(null)
|
||||||
|
setCanReadLogs(false)
|
||||||
setBootState({ kind: 'login' })
|
setBootState({ kind: 'login' })
|
||||||
navigateTo('/login')
|
navigateTo('/login')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : activeSection === 'logs' ? (
|
||||||
|
<LogsPanel />
|
||||||
) : isResourceSectionId(activeSection) ? (
|
) : isResourceSectionId(activeSection) ? (
|
||||||
<ResourceSectionPanel
|
<ResourceSectionPanel
|
||||||
section={activeSection}
|
section={activeSection}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
|
deleteNode,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
fetchGroups,
|
fetchGroups,
|
||||||
fetchHealth,
|
fetchHealth,
|
||||||
fetchNetwork,
|
fetchNetwork,
|
||||||
|
fetchNodeLogs,
|
||||||
fetchSecurity,
|
fetchSecurity,
|
||||||
fetchStatus,
|
fetchStatus,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
@@ -326,6 +328,69 @@ describe('deleteUser', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('deleteNode', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends DELETE by id with reauth credentials and returns nodes', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ nodes: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await deleteNode(
|
||||||
|
'node-1',
|
||||||
|
{ current_password: 'correct horse battery staple extra' },
|
||||||
|
fetchMock,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:8080/api/v1/nodes/node-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
current_password: 'correct horse battery staple extra',
|
||||||
|
totp_code: '',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchNodeLogs', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns parsed logs payload with optional kind filter', async () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'e1',
|
||||||
|
at: '2026-01-01T00:00:00Z',
|
||||||
|
action: 'create',
|
||||||
|
actor: 'Admin',
|
||||||
|
node_id: 'n1',
|
||||||
|
node_name: 'ct1',
|
||||||
|
node_kind: 'container',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ events }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = await fetchNodeLogs('container', fetchMock)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:8080/api/v1/node-logs?kind=container',
|
||||||
|
expect.objectContaining({ credentials: 'include' }),
|
||||||
|
)
|
||||||
|
expect(payload.events).toEqual(events)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('fetchSecurity', () => {
|
describe('fetchSecurity', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
|
|||||||
@@ -675,6 +675,28 @@ export async function createNode(
|
|||||||
return (await response.json()) as NodeResponse
|
return (await response.json()) as NodeResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteNode(
|
||||||
|
id: string,
|
||||||
|
credentials: ReauthCredentials,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<NodesResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`/api/v1/nodes/${encodeURIComponent(id)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({
|
||||||
|
current_password: credentials.current_password,
|
||||||
|
totp_code: credentials.totp_code ?? '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
fetchImpl,
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response))
|
||||||
|
}
|
||||||
|
return (await response.json()) as NodesResponse
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchNode(
|
export async function fetchNode(
|
||||||
id: string,
|
id: string,
|
||||||
fetchImpl: typeof fetch = fetch,
|
fetchImpl: typeof fetch = fetch,
|
||||||
@@ -709,3 +731,32 @@ export async function testNodeSSH(
|
|||||||
}
|
}
|
||||||
return (await response.json()) as NodeSSHTestResponse
|
return (await response.json()) as NodeSSHTestResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'update'
|
||||||
|
|
||||||
|
export type NodeAuditEvent = {
|
||||||
|
id: string
|
||||||
|
at: string
|
||||||
|
action: NodeAuditAction
|
||||||
|
actor: string
|
||||||
|
node_id: string
|
||||||
|
node_name: string
|
||||||
|
node_kind: NodeKind
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeLogsResponse = {
|
||||||
|
events: ReadonlyArray<NodeAuditEvent>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNodeLogs(
|
||||||
|
kind?: NodeKind,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<NodeLogsResponse> {
|
||||||
|
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
|
||||||
|
const response = await apiFetch(`/api/v1/node-logs${query}`, {}, fetchImpl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response))
|
||||||
|
}
|
||||||
|
return (await response.json()) as NodeLogsResponse
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe('pathFor', () => {
|
|||||||
expect(pathFor('containers', 'overview', 'add')).toBe('/containers/add')
|
expect(pathFor('containers', 'overview', 'add')).toBe('/containers/add')
|
||||||
expect(pathFor('docker', 'overview', 'add')).toBe('/docker/add')
|
expect(pathFor('docker', 'overview', 'add')).toBe('/docker/add')
|
||||||
expect(pathFor('profile')).toBe('/profile')
|
expect(pathFor('profile')).toBe('/profile')
|
||||||
|
expect(pathFor('logs')).toBe('/logs')
|
||||||
expect(pathFor('setup')).toBe('/setup')
|
expect(pathFor('setup')).toBe('/setup')
|
||||||
expect(pathFor('login')).toBe('/login')
|
expect(pathFor('login')).toBe('/login')
|
||||||
expect(pathFor('configuration')).toBe('/configuration')
|
expect(pathFor('configuration')).toBe('/configuration')
|
||||||
@@ -31,6 +32,7 @@ describe('parseLocation', () => {
|
|||||||
'/containers/add',
|
'/containers/add',
|
||||||
'/docker/overview',
|
'/docker/overview',
|
||||||
'/profile',
|
'/profile',
|
||||||
|
'/logs',
|
||||||
'/setup',
|
'/setup',
|
||||||
'/login',
|
'/login',
|
||||||
'/configuration',
|
'/configuration',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type SectionId =
|
|||||||
| 'vms'
|
| 'vms'
|
||||||
| 'docker'
|
| 'docker'
|
||||||
| 'containers'
|
| 'containers'
|
||||||
|
| 'logs'
|
||||||
| 'profile'
|
| 'profile'
|
||||||
| 'configuration'
|
| 'configuration'
|
||||||
| 'setup'
|
| 'setup'
|
||||||
@@ -92,6 +93,10 @@ export function parseLocation(pathname: string): AppLocation {
|
|||||||
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
|
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (first === 'logs' && segments.length === 1) {
|
||||||
|
return { section: 'logs', configTab: 'overview', resourceTab: 'overview' }
|
||||||
|
}
|
||||||
|
|
||||||
if (RESOURCE_SECTION_IDS.has(first)) {
|
if (RESOURCE_SECTION_IDS.has(first)) {
|
||||||
const section = first as ResourceSectionId
|
const section = first as ResourceSectionId
|
||||||
if (segments.length === 1) {
|
if (segments.length === 1) {
|
||||||
@@ -141,6 +146,9 @@ export function pathFor(
|
|||||||
if (section === 'profile') {
|
if (section === 'profile') {
|
||||||
return '/profile'
|
return '/profile'
|
||||||
}
|
}
|
||||||
|
if (section === 'logs') {
|
||||||
|
return '/logs'
|
||||||
|
}
|
||||||
if (isResourceSectionId(section)) {
|
if (isResourceSectionId(section)) {
|
||||||
if (resourceTab === 'overview') {
|
if (resourceTab === 'overview') {
|
||||||
return `/${section}`
|
return `/${section}`
|
||||||
|
|||||||
Reference in New Issue
Block a user