diff --git a/service/internal/api/auth_audit.go b/service/internal/api/auth_audit.go new file mode 100644 index 0000000..2ac96cc --- /dev/null +++ b/service/internal/api/auth_audit.go @@ -0,0 +1,66 @@ +package api + +import ( + "net/http" + "time" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +type authLogsResponse struct { + Events []settings.AuthAuditEvent `json:"events"` +} + +func (app *App) authLogsListHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeLogsRead(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + + store, err := settings.LoadAuthAuditOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + filtered := make([]settings.AuthAuditEvent, 0, len(store.Events)) + for index := len(store.Events) - 1; index >= 0; index-- { + filtered = append(filtered, store.Events[index]) + } + + writeJSON(writer, http.StatusOK, authLogsResponse{Events: filtered}) +} + +func appendAuthAudit( + configDir string, + action string, + outcome settings.AuthAuditOutcome, + category settings.AuthAuditCategory, + actor string, + target string, + detail string, +) { + eventID, err := auth.NewUUID() + if err != nil { + return + } + _ = settings.AppendAuthAuditEvent(configDir, settings.AuthAuditEvent{ + ID: eventID, + At: time.Now().UTC(), + Action: action, + Outcome: outcome, + Category: category, + Actor: actor, + Target: target, + Detail: detail, + }) +} + +func actorUsernameFromRequest(request *http.Request) string { + user, ok := UserFromContext(request.Context()) + if !ok { + return "" + } + return user.Username +} diff --git a/service/internal/api/auth_audit_test.go b/service/internal/api/auth_audit_test.go new file mode 100644 index 0000000..d391f92 --- /dev/null +++ b/service/internal/api/auth_audit_test.go @@ -0,0 +1,211 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +func TestAuthLogsRequiresLogsRead(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + + payload, err := settings.LoadSettings(configDir) + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + for index := range payload.Groups { + if payload.Groups[index].Name != settings.AdministratorsGroupName { + continue + } + // Strip logs.read and the permissions that would trigger migration. + filtered := make([]string, 0) + for _, permission := range payload.Groups[index].Permissions { + switch permission { + case "logs.read", "nodes.read", "users.manage": + continue + } + filtered = append(filtered, permission) + } + payload.Groups[index].Permissions = filtered + } + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + + router := NewRouter(configDir) + request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String()) + } +} + +func TestAuthLogsAllowsLogsReadWithoutUsersManage(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + + payload, err := settings.LoadSettings(configDir) + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + for index := range payload.Groups { + if payload.Groups[index].Name != settings.AdministratorsGroupName { + continue + } + payload.Groups[index].Permissions = []string{"logs.read"} + } + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + + router := NewRouter(configDir) + request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String()) + } +} + +func TestAuthLogsRecordsLoginAndUserCreate(t *testing.T) { + configDir := t.TempDir() + _ = seedCompletedSetup(t, configDir) + router := NewRouter(configDir) + + failBody := []byte(`{"username":"Admin","password":"wrong password that fails"}`) + failReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(failBody)) + failRec := httptest.NewRecorder() + router.ServeHTTP(failRec, failReq) + if failRec.Code != http.StatusUnauthorized { + t.Fatalf("failed login status %d body %s", failRec.Code, failRec.Body.String()) + } + + okBody := []byte(`{"username":"Admin","password":"correct horse battery staple extra"}`) + okReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(okBody)) + okRec := httptest.NewRecorder() + router.ServeHTTP(okRec, okReq) + if okRec.Code != http.StatusOK { + t.Fatalf("login status %d body %s", okRec.Code, okRec.Body.String()) + } + var loginCookie *http.Cookie + for _, candidate := range okRec.Result().Cookies() { + if candidate.Name == auth.SessionCookieName { + loginCookie = candidate + break + } + } + if loginCookie == nil { + t.Fatal("login did not set session cookie") + } + + createReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/users", + strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":[]}`), + ), + loginCookie, + ) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + router.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create user status %d body %s", createRec.Code, createRec.Body.String()) + } + + logsReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), loginCookie) + logsRec := httptest.NewRecorder() + router.ServeHTTP(logsRec, logsReq) + if logsRec.Code != http.StatusOK { + t.Fatalf("auth-logs status %d body %s", logsRec.Code, logsRec.Body.String()) + } + + var logs authLogsResponse + if err := json.Unmarshal(logsRec.Body.Bytes(), &logs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(logs.Events) < 3 { + t.Fatalf("expected at least 3 events, got %d", len(logs.Events)) + } + + var sawLoginFailure, sawLoginSuccess, sawUserCreate bool + for _, event := range logs.Events { + switch { + case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeFailure: + sawLoginFailure = true + if event.Detail != "invalid credentials" { + t.Fatalf("failure detail = %q", event.Detail) + } + case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeSuccess: + sawLoginSuccess = true + if event.Actor != "Admin" { + t.Fatalf("login actor = %q", event.Actor) + } + case event.Action == "user_create" && event.Target == "alice": + sawUserCreate = true + if event.Category != settings.AuthAuditCategoryUser { + t.Fatalf("category = %q", event.Category) + } + } + } + if !sawLoginFailure || !sawLoginSuccess || !sawUserCreate { + t.Fatalf( + "missing events: failure=%v success=%v create=%v events=%+v", + sawLoginFailure, + sawLoginSuccess, + sawUserCreate, + logs.Events, + ) + } + + // Newest first: user_create should appear before older login events. + if logs.Events[0].Action != "user_create" { + t.Fatalf("expected newest event user_create, got %q", logs.Events[0].Action) + } +} + +func TestMigrateLogsReadPermission(t *testing.T) { + payload := settings.Settings{ + Groups: []settings.Group{ + {Name: "ops", Permissions: []string{"nodes.read", "jobs.read"}}, + {Name: "hr", Permissions: []string{"users.manage"}}, + {Name: "auditors", Permissions: []string{"logs.read"}}, + {Name: "viewers", Permissions: []string{"jobs.read"}}, + }, + } + if !migrateLogsReadPermission(&payload) { + t.Fatal("expected migration to change settings") + } + if !containsPermission(payload.Groups[0].Permissions, "logs.read") { + t.Fatal("ops should gain logs.read from nodes.read") + } + if !containsPermission(payload.Groups[1].Permissions, "logs.read") { + t.Fatal("hr should gain logs.read from users.manage") + } + if len(payload.Groups[2].Permissions) != 1 { + t.Fatal("auditors should be unchanged") + } + if containsPermission(payload.Groups[3].Permissions, "logs.read") { + t.Fatal("viewers should not gain logs.read") + } + if migrateLogsReadPermission(&payload) { + t.Fatal("second migration should be a no-op") + } +} + +func containsPermission(permissions []string, wanted string) bool { + for _, permission := range permissions { + if permission == wanted { + return true + } + } + return false +} diff --git a/service/internal/api/authz.go b/service/internal/api/authz.go index b312676..89f5570 100644 --- a/service/internal/api/authz.go +++ b/service/internal/api/authz.go @@ -14,6 +14,7 @@ var errNodesDeleteRequired = errors.New("permission nodes.delete required") var errActionsCreateRequired = errors.New("permission actions.create required") var errActionsUpdateRequired = errors.New("permission actions.update required") var errActionsDeleteRequired = errors.New("permission actions.delete required") +var errLogsReadRequired = errors.New("permission logs.read required") // permissionsForUser returns the union of permissions from the user's groups. func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} { @@ -100,6 +101,22 @@ func (app *App) authorizeNodesRead(request *http.Request) error { return nil } +func (app *App) authorizeLogsRead(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, "logs.read") { + return errLogsReadRequired + } + return nil +} + func (app *App) authorizeNodesExec(request *http.Request) error { user, ok := UserFromContext(request.Context()) if !ok { diff --git a/service/internal/api/groups_handlers.go b/service/internal/api/groups_handlers.go index 0332113..e98b913 100644 --- a/service/internal/api/groups_handlers.go +++ b/service/internal/api/groups_handlers.go @@ -34,6 +34,7 @@ var allowedPermissions = map[string]struct{}{ "users.manage": {}, "secrets.manage": {}, "roles.manage": {}, + "logs.read": {}, } func groupsGetHandler(configDir string) http.HandlerFunc { @@ -89,6 +90,19 @@ func groupsUpsertHandler(configDir string) http.HandlerFunc { return } + action := "group_create" + if updated { + action = "group_update" + } + appendAuthAudit( + configDir, + action, + settings.AuthAuditOutcomeSuccess, + settings.AuthAuditCategoryGroup, + actorUsernameFromRequest(request), + payload.Group.Name, + "", + ) writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups}) } } @@ -137,6 +151,15 @@ func groupsDeleteHandler(configDir string) http.HandlerFunc { return } + appendAuthAudit( + configDir, + "group_delete", + settings.AuthAuditOutcomeSuccess, + settings.AuthAuditCategoryGroup, + actorUsernameFromRequest(request), + name, + "", + ) writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups}) } } diff --git a/service/internal/api/me_handlers.go b/service/internal/api/me_handlers.go index 137cfd9..c2cf9b0 100644 --- a/service/internal/api/me_handlers.go +++ b/service/internal/api/me_handlers.go @@ -140,6 +140,15 @@ func (app *App) mePasswordHandler(writer http.ResponseWriter, request *http.Requ _ = app.Sessions.DestroySession(writer, request) } + appendAuthAudit( + app.ConfigDir, + "password_change", + settings.AuthAuditOutcomeSuccess, + settings.AuthAuditCategorySelf, + user.Username, + user.Username, + "", + ) writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) } @@ -257,6 +266,15 @@ func (app *App) meTOTPConfirmHandler(writer http.ResponseWriter, request *http.R return } + appendAuthAudit( + app.ConfigDir, + "totp_enable", + settings.AuthAuditOutcomeSuccess, + settings.AuthAuditCategorySelf, + user.Username, + user.Username, + "", + ) writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) } @@ -319,5 +337,14 @@ func (app *App) meTOTPDisableHandler(writer http.ResponseWriter, request *http.R return } + appendAuthAudit( + app.ConfigDir, + "totp_disable", + settings.AuthAuditOutcomeSuccess, + settings.AuthAuditCategorySelf, + user.Username, + user.Username, + "", + ) writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) } diff --git a/service/internal/api/middleware.go b/service/internal/api/middleware.go index 853b47e..291b91c 100644 --- a/service/internal/api/middleware.go +++ b/service/internal/api/middleware.go @@ -10,6 +10,8 @@ import ( "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) +const sessionIdleExemptHeader = "X-Session-Idle-Exempt" + func (app *App) withMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { path := request.URL.Path @@ -75,7 +77,8 @@ func (app *App) withMiddleware(next http.Handler) http.Handler { } security := effectiveSecurity(settingsPayload.Security) - session, err := app.Sessions.LookupValidSession(writer, request, security) + touchActivity := request.Header.Get(sessionIdleExemptHeader) != "1" + session, err := app.Sessions.LookupValidSession(writer, request, security, touchActivity) if err != nil { errorMessage := "authentication required" if errors.Is(err, auth.ErrSessionIdle) { @@ -142,7 +145,7 @@ func (app *App) withCORS(next http.Handler) http.Handler { writer.Header().Set("Vary", "Origin") } writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") + writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, "+sessionIdleExemptHeader) if request.Method == http.MethodOptions { writer.WriteHeader(http.StatusNoContent) diff --git a/service/internal/api/middleware_idle_test.go b/service/internal/api/middleware_idle_test.go index 77f66a8..acb9a6d 100644 --- a/service/internal/api/middleware_idle_test.go +++ b/service/internal/api/middleware_idle_test.go @@ -81,3 +81,129 @@ func TestMiddlewareReturnsAuthenticationRequiredWithoutCookie(t *testing.T) { t.Fatalf("expected authentication required, got %q", payload.Error) } } + +func TestMiddlewareIdleExemptDoesNotAdvanceLastSeenAt(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + + keyBytes := make([]byte, 32) + for index := range keyBytes { + keyBytes[index] = byte(index + 1) + } + + store, err := settings.LoadSessions(configDir, keyBytes) + if err != nil { + t.Fatalf("LoadSessions: %v", err) + } + originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second) + store.Sessions[0].LastSeenAt = originalLastSeen + if err := settings.SaveSessions(configDir, store, keyBytes); err != nil { + t.Fatalf("SaveSessions: %v", err) + } + + router := NewRouter(configDir) + request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie) + request.Header.Set(sessionIdleExemptHeader, "1") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String()) + } + + store, err = settings.LoadSessions(configDir, keyBytes) + if err != nil { + t.Fatalf("LoadSessions after request: %v", err) + } + if len(store.Sessions) != 1 { + t.Fatalf("expected 1 session, got %d", len(store.Sessions)) + } + if !store.Sessions[0].LastSeenAt.Equal(originalLastSeen) { + t.Fatalf( + "expected LastSeenAt %v unchanged, got %v", + originalLastSeen, + store.Sessions[0].LastSeenAt, + ) + } +} + +func TestMiddlewareIdleExemptStillReturnsSessionIdle(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + + keyBytes := make([]byte, 32) + for index := range keyBytes { + keyBytes[index] = byte(index + 1) + } + + store, err := settings.LoadSessions(configDir, keyBytes) + if err != nil { + t.Fatalf("LoadSessions: %v", err) + } + store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-31 * time.Minute) + if err := settings.SaveSessions(configDir, store, keyBytes); err != nil { + t.Fatalf("SaveSessions: %v", err) + } + + router := NewRouter(configDir) + request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie) + request.Header.Set(sessionIdleExemptHeader, "1") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String()) + } + + var payload apiErrorResponse + if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Error != "session_idle" { + t.Fatalf("expected session_idle, got %q", payload.Error) + } +} + +func TestMiddlewareNormalRequestAdvancesLastSeenAt(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + + keyBytes := make([]byte, 32) + for index := range keyBytes { + keyBytes[index] = byte(index + 1) + } + + store, err := settings.LoadSessions(configDir, keyBytes) + if err != nil { + t.Fatalf("LoadSessions: %v", err) + } + originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second) + store.Sessions[0].LastSeenAt = originalLastSeen + if err := settings.SaveSessions(configDir, store, keyBytes); err != nil { + t.Fatalf("SaveSessions: %v", err) + } + + router := NewRouter(configDir) + request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String()) + } + + store, err = settings.LoadSessions(configDir, keyBytes) + if err != nil { + t.Fatalf("LoadSessions after request: %v", err) + } + if len(store.Sessions) != 1 { + t.Fatalf("expected 1 session, got %d", len(store.Sessions)) + } + if !store.Sessions[0].LastSeenAt.After(originalLastSeen) { + t.Fatalf( + "expected LastSeenAt after %v, got %v", + originalLastSeen, + store.Sessions[0].LastSeenAt, + ) + } +} diff --git a/service/internal/api/nodes_handlers.go b/service/internal/api/nodes_handlers.go index 34a19ef..d22163b 100644 --- a/service/internal/api/nodes_handlers.go +++ b/service/internal/api/nodes_handlers.go @@ -450,50 +450,24 @@ func (app *App) nodesDeleteHandler(writer http.ResponseWriter, request *http.Req } func (app *App) nodeLogsListHandler(writer http.ResponseWriter, request *http.Request) { - if err := app.authorizeNodesRead(request); err != nil { + if err := app.authorizeLogsRead(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) } @@ -609,25 +583,6 @@ 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, diff --git a/service/internal/api/nodes_handlers_test.go b/service/internal/api/nodes_handlers_test.go index ed75d32..cf62965 100644 --- a/service/internal/api/nodes_handlers_test.go +++ b/service/internal/api/nodes_handlers_test.go @@ -644,3 +644,84 @@ func TestNodeLogsListFiltersByKind(t *testing.T) { } } } + +func TestNodeLogsRequiresLogsRead(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + + payload, err := settings.LoadSettings(configDir) + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + for index := range payload.Groups { + if payload.Groups[index].Name != settings.AdministratorsGroupName { + continue + } + filtered := make([]string, 0) + for _, permission := range payload.Groups[index].Permissions { + switch permission { + case "logs.read", "nodes.read", "users.manage": + continue + } + filtered = append(filtered, permission) + } + payload.Groups[index].Permissions = filtered + } + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + + router := NewRouter(configDir) + request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String()) + } +} + +func TestNodeLogsAllowsLogsReadWithoutNodesRead(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + + payload, err := settings.LoadSettings(configDir) + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + for index := range payload.Groups { + if payload.Groups[index].Name != settings.AdministratorsGroupName { + continue + } + payload.Groups[index].Permissions = []string{"logs.read"} + } + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + + if err := settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{ + ID: "evt-1", + At: time.Now().UTC(), + Action: settings.NodeAuditActionCreate, + Actor: "Admin", + NodeID: "node-1", + NodeName: "test-node", + NodeKind: settings.NodeKindContainer, + }); err != nil { + t.Fatalf("AppendNodeAuditEvent: %v", err) + } + + router := NewRouter(configDir) + request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String()) + } + var logs nodeLogsResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &logs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(logs.Events) != 1 { + t.Fatalf("expected 1 event, got %d", len(logs.Events)) + } +} diff --git a/service/internal/api/router.go b/service/internal/api/router.go index 06c2171..e33e139 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -56,6 +56,7 @@ func NewRouter(configDir string) http.Handler { 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) + mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler) return app.withCORS(app.withMiddleware(mux)) } diff --git a/service/internal/api/setup_auth_handlers.go b/service/internal/api/setup_auth_handlers.go index e17ce03..ce02240 100644 --- a/service/internal/api/setup_auth_handlers.go +++ b/service/internal/api/setup_auth_handlers.go @@ -360,13 +360,32 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) break } } + attemptedUsername := strings.TrimSpace(payload.Username) if matched == nil || !matched.Enabled { + appendAuthAudit( + app.ConfigDir, + "login", + settings.AuthAuditOutcomeFailure, + settings.AuthAuditCategoryAuth, + attemptedUsername, + attemptedUsername, + "invalid credentials", + ) writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"}) return } ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash) if err != nil || !ok { + appendAuthAudit( + app.ConfigDir, + "login", + settings.AuthAuditOutcomeFailure, + settings.AuthAuditCategoryAuth, + attemptedUsername, + matched.Username, + "invalid credentials", + ) writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"}) return } @@ -381,6 +400,15 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != "" if requiresTOTP { if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) { + appendAuthAudit( + app.ConfigDir, + "login", + settings.AuthAuditOutcomeFailure, + settings.AuthAuditCategoryAuth, + matched.Username, + matched.Username, + "invalid or missing TOTP code", + ) writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"}) return } @@ -391,13 +419,34 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) return } + appendAuthAudit( + app.ConfigDir, + "login", + settings.AuthAuditOutcomeSuccess, + settings.AuthAuditCategoryAuth, + matched.Username, + matched.Username, + "", + ) writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload)) } func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) { + actor := actorUsernameFromRequest(request) if app.Sessions != nil { _ = app.Sessions.DestroySession(writer, request) } + if actor != "" { + appendAuthAudit( + app.ConfigDir, + "logout", + settings.AuthAuditOutcomeSuccess, + settings.AuthAuditCategoryAuth, + actor, + actor, + "", + ) + } writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) } @@ -459,6 +508,7 @@ func allPermissionList() []string { "users.manage", "secrets.manage", "roles.manage", + "logs.read", } _ = list return ordered @@ -484,5 +534,35 @@ func loadSettingsOrDefault(configDir string) (settings.Settings, error) { payload.Groups = []settings.Group{} } payload.Network = settings.EffectiveNetwork(payload.Network) + if migrateLogsReadPermission(&payload) { + if err := settings.SaveSettings(configDir, payload); err != nil { + return settings.Settings{}, err + } + } return payload, nil } + +// migrateLogsReadPermission grants logs.read to groups that previously could +// view logs via nodes.read or users.manage. Returns true when settings changed. +func migrateLogsReadPermission(payload *settings.Settings) bool { + changed := false + for index := range payload.Groups { + group := &payload.Groups[index] + hasLogsRead := false + hadLogAccess := false + for _, permission := range group.Permissions { + switch permission { + case "logs.read": + hasLogsRead = true + case "nodes.read", "users.manage": + hadLogAccess = true + } + } + if hasLogsRead || !hadLogAccess { + continue + } + group.Permissions = append(group.Permissions, "logs.read") + changed = true + } + return changed +} diff --git a/service/internal/api/users_handlers.go b/service/internal/api/users_handlers.go index 058e255..80f0e6c 100644 --- a/service/internal/api/users_handlers.go +++ b/service/internal/api/users_handlers.go @@ -137,6 +137,15 @@ func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Req return } + appendAuthAudit( + app.ConfigDir, + "user_create", + settings.AuthAuditOutcomeSuccess, + settings.AuthAuditCategoryUser, + actorUsernameFromRequest(request), + username, + "", + ) writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)}) } @@ -254,6 +263,15 @@ func (app *App) usersPatchHandler(writer http.ResponseWriter, request *http.Requ _ = app.Sessions.InvalidateUserSessions(userID) } + appendAuthAudit( + app.ConfigDir, + "user_update", + settings.AuthAuditOutcomeSuccess, + settings.AuthAuditCategoryUser, + actorUsernameFromRequest(request), + target.Username, + "", + ) writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)}) } @@ -293,12 +311,14 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req adminCount := countAdministrators(store.Users) remaining := make([]settings.UserCredential, 0, len(store.Users)) found := false + deletedUsername := "" for _, user := range store.Users { if user.ID != userID { remaining = append(remaining, user) continue } found = true + deletedUsername = user.Username if userIsAdministrator(user) && adminCount <= 1 { writeJSON(writer, http.StatusConflict, apiErrorResponse{ Error: "cannot delete the last administrator", @@ -322,6 +342,15 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req _ = app.Sessions.InvalidateUserSessions(userID) } + appendAuthAudit( + app.ConfigDir, + "user_delete", + settings.AuthAuditOutcomeSuccess, + settings.AuthAuditCategoryUser, + actorUsernameFromRequest(request), + deletedUsername, + "", + ) writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)}) } diff --git a/service/internal/auth/session.go b/service/internal/auth/session.go index ac1e9b9..0ada5f3 100644 --- a/service/internal/auth/session.go +++ b/service/internal/auth/session.go @@ -87,11 +87,15 @@ func (manager *SessionManager) CreateSession( return record, nil } -// LookupValidSession finds a non-expired, non-idle session and may rotate it. +// LookupValidSession finds a non-expired, non-idle session. +// When touchActivity is true, LastSeenAt is updated and the session may rotate. +// When touchActivity is false, the session is validated only (no LastSeenAt update, +// rotation, or sessions file write unless the session is being destroyed as idle/expired). func (manager *SessionManager) LookupValidSession( writer http.ResponseWriter, request *http.Request, security settings.SecuritySettings, + touchActivity bool, ) (settings.SessionRecord, error) { cookie, err := request.Cookie(SessionCookieName) if err != nil || strings.TrimSpace(cookie.Value) == "" { @@ -140,6 +144,10 @@ func (manager *SessionManager) LookupValidSession( return settings.SessionRecord{}, ErrSessionNotFound } + if !touchActivity { + return *found, nil + } + shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter || now.Sub(found.CreatedAt) >= lifetime/2 diff --git a/service/internal/auth/session_test.go b/service/internal/auth/session_test.go index 56ad29e..ba8e619 100644 --- a/service/internal/auth/session_test.go +++ b/service/internal/auth/session_test.go @@ -39,7 +39,7 @@ func TestLookupValidSessionReturnsIdle(t *testing.T) { request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID}) lookupRec := httptest.NewRecorder() - _, err = manager.LookupValidSession(lookupRec, request, security) + _, err = manager.LookupValidSession(lookupRec, request, security, true) if !errors.Is(err, ErrSessionIdle) { t.Fatalf("expected ErrSessionIdle, got %v", err) } @@ -61,7 +61,7 @@ func TestLookupValidSessionMissingCookie(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) lookupRec := httptest.NewRecorder() - _, err := manager.LookupValidSession(lookupRec, request, security) + _, err := manager.LookupValidSession(lookupRec, request, security, true) if !errors.Is(err, ErrSessionNotFound) { t.Fatalf("expected ErrSessionNotFound, got %v", err) } @@ -76,12 +76,95 @@ func TestLookupValidSessionUnknownCookie(t *testing.T) { request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "unknown-session"}) lookupRec := httptest.NewRecorder() - _, err := manager.LookupValidSession(lookupRec, request, security) + _, err := manager.LookupValidSession(lookupRec, request, security, true) if !errors.Is(err, ErrSessionNotFound) { t.Fatalf("expected ErrSessionNotFound, got %v", err) } } +func TestLookupValidSessionWithoutTouchLeavesLastSeenAt(t *testing.T) { + configDir := t.TempDir() + key := testSessionKey() + manager := NewSessionManager(configDir, key) + security := settings.DefaultSecuritySettings() + security.IdleTimeoutMinutes = 30 + + createRec := httptest.NewRecorder() + record, err := manager.CreateSession(createRec, "user-1", security) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + store, err := settings.LoadSessions(configDir, key) + if err != nil { + t.Fatalf("LoadSessions: %v", err) + } + originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second) + store.Sessions[0].LastSeenAt = originalLastSeen + if err := settings.SaveSessions(configDir, store, key); err != nil { + t.Fatalf("SaveSessions: %v", err) + } + + request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) + request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID}) + lookupRec := httptest.NewRecorder() + + got, err := manager.LookupValidSession(lookupRec, request, security, false) + if err != nil { + t.Fatalf("LookupValidSession: %v", err) + } + if got.ID != record.ID { + t.Fatalf("expected session id %q, got %q", record.ID, got.ID) + } + + store, err = settings.LoadSessions(configDir, key) + if err != nil { + t.Fatalf("LoadSessions after lookup: %v", err) + } + if len(store.Sessions) != 1 { + t.Fatalf("expected 1 session, got %d", len(store.Sessions)) + } + if !store.Sessions[0].LastSeenAt.Equal(originalLastSeen) { + t.Fatalf( + "expected LastSeenAt %v unchanged, got %v", + originalLastSeen, + store.Sessions[0].LastSeenAt, + ) + } +} + +func TestLookupValidSessionIdleWithoutTouchStillIdle(t *testing.T) { + configDir := t.TempDir() + key := testSessionKey() + manager := NewSessionManager(configDir, key) + security := settings.DefaultSecuritySettings() + security.IdleTimeoutMinutes = 5 + + createRec := httptest.NewRecorder() + record, err := manager.CreateSession(createRec, "user-1", security) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + store, err := settings.LoadSessions(configDir, key) + if err != nil { + t.Fatalf("LoadSessions: %v", err) + } + store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute) + if err := settings.SaveSessions(configDir, store, key); err != nil { + t.Fatalf("SaveSessions: %v", err) + } + + request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) + request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID}) + lookupRec := httptest.NewRecorder() + + _, err = manager.LookupValidSession(lookupRec, request, security, false) + if !errors.Is(err, ErrSessionIdle) { + t.Fatalf("expected ErrSessionIdle, got %v", err) + } +} + func testSessionKey() []byte { key := make([]byte, 32) for index := range key { diff --git a/service/internal/settings/auth_audit.go b/service/internal/settings/auth_audit.go new file mode 100644 index 0000000..1dcd0f8 --- /dev/null +++ b/service/internal/settings/auth_audit.go @@ -0,0 +1,77 @@ +package settings + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// MaxAuthAuditEvents is the retention cap for auth-audit.json. +const MaxAuthAuditEvents = 1000 + +// LoadAuthAudit reads and parses auth-audit.json from dir. +func LoadAuthAudit(dir string) (AuthAuditStore, error) { + path := filepath.Join(dir, AuthAuditFileName) + payload, err := os.ReadFile(path) + if err != nil { + return AuthAuditStore{}, err + } + + var store AuthAuditStore + if err := json.Unmarshal(payload, &store); err != nil { + return AuthAuditStore{}, fmt.Errorf("parse auth audit: %w", err) + } + if store.Events == nil { + store.Events = []AuthAuditEvent{} + } + return store, nil +} + +// LoadAuthAuditOrEmpty returns an empty store when auth-audit.json is missing. +func LoadAuthAuditOrEmpty(dir string) (AuthAuditStore, error) { + store, err := LoadAuthAudit(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return AuthAuditStore{Events: []AuthAuditEvent{}}, nil + } + return AuthAuditStore{}, err + } + return store, nil +} + +// SaveAuthAudit writes auth-audit.json to dir with mode 0640. +func SaveAuthAudit(dir string, store AuthAuditStore) error { + if err := ensureConfigDir(dir); err != nil { + return err + } + if store.Events == nil { + store.Events = []AuthAuditEvent{} + } + + payload, err := json.MarshalIndent(store, "", " ") + if err != nil { + return fmt.Errorf("encode auth audit: %w", err) + } + payload = append(payload, '\n') + + path := filepath.Join(dir, AuthAuditFileName) + if err := os.WriteFile(path, payload, 0o640); err != nil { + return fmt.Errorf("write auth audit: %w", err) + } + return nil +} + +// AppendAuthAuditEvent appends event and trims to MaxAuthAuditEvents (newest kept). +func AppendAuthAuditEvent(dir string, event AuthAuditEvent) error { + store, err := LoadAuthAuditOrEmpty(dir) + if err != nil { + return err + } + store.Events = append(store.Events, event) + if len(store.Events) > MaxAuthAuditEvents { + store.Events = store.Events[len(store.Events)-MaxAuthAuditEvents:] + } + return SaveAuthAudit(dir, store) +} diff --git a/service/internal/settings/auth_audit_test.go b/service/internal/settings/auth_audit_test.go new file mode 100644 index 0000000..d56d0e1 --- /dev/null +++ b/service/internal/settings/auth_audit_test.go @@ -0,0 +1,81 @@ +package settings + +import ( + "testing" + "time" +) + +func TestAuthAuditRoundTripAndCap(t *testing.T) { + dir := t.TempDir() + + store, err := LoadAuthAuditOrEmpty(dir) + if err != nil { + t.Fatalf("LoadAuthAuditOrEmpty: %v", err) + } + if len(store.Events) != 0 { + t.Fatalf("expected empty, got %d", len(store.Events)) + } + + event := AuthAuditEvent{ + ID: "evt-1", + At: time.Now().UTC(), + Action: "login", + Outcome: AuthAuditOutcomeSuccess, + Category: AuthAuditCategoryAuth, + Actor: "admin", + } + if err := AppendAuthAuditEvent(dir, event); err != nil { + t.Fatalf("AppendAuthAuditEvent: %v", err) + } + + loaded, err := LoadAuthAudit(dir) + if err != nil { + t.Fatalf("LoadAuthAudit: %v", err) + } + if len(loaded.Events) != 1 || loaded.Events[0].Action != "login" { + t.Fatalf("loaded = %+v", loaded) + } + if loaded.Events[0].Outcome != AuthAuditOutcomeSuccess { + t.Fatalf("outcome = %q", loaded.Events[0].Outcome) + } +} + +func TestAppendAuthAuditEventTrimsToCap(t *testing.T) { + dir := t.TempDir() + originalCap := MaxAuthAuditEvents + store := AuthAuditStore{Events: make([]AuthAuditEvent, 0, MaxAuthAuditEvents+5)} + for index := 0; index < MaxAuthAuditEvents+5; index++ { + store.Events = append(store.Events, AuthAuditEvent{ + ID: "evt", + At: time.Now().UTC(), + Action: "login", + Outcome: AuthAuditOutcomeSuccess, + Category: AuthAuditCategoryAuth, + Actor: "admin", + Detail: string(rune('a' + (index % 26))), + }) + } + if err := SaveAuthAudit(dir, store); err != nil { + t.Fatalf("SaveAuthAudit: %v", err) + } + if err := AppendAuthAuditEvent(dir, AuthAuditEvent{ + ID: "newest", + At: time.Now().UTC(), + Action: "logout", + Outcome: AuthAuditOutcomeSuccess, + Category: AuthAuditCategoryAuth, + Actor: "admin", + }); err != nil { + t.Fatalf("AppendAuthAuditEvent: %v", err) + } + loaded, err := LoadAuthAudit(dir) + if err != nil { + t.Fatalf("LoadAuthAudit: %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) + } +} diff --git a/service/internal/settings/path.go b/service/internal/settings/path.go index 1a01451..e391caa 100644 --- a/service/internal/settings/path.go +++ b/service/internal/settings/path.go @@ -19,6 +19,7 @@ const ( NodesFileName = "nodes.json" NodeKeysFileName = "node-keys.enc" NodeAuditFileName = "node-audit.json" + AuthAuditFileName = "auth-audit.json" ActionsFileName = "actions.json" ) diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go index 92a8b7f..2f069a7 100644 --- a/service/internal/settings/types.go +++ b/service/internal/settings/types.go @@ -186,6 +186,41 @@ type NodeAuditStore struct { Events []NodeAuditEvent `json:"events"` } +// AuthAuditOutcome is success or failure for an auth/user/group audit event. +type AuthAuditOutcome string + +const ( + AuthAuditOutcomeSuccess AuthAuditOutcome = "success" + AuthAuditOutcomeFailure AuthAuditOutcome = "failure" +) + +// AuthAuditCategory groups auth audit events for UI styling and filtering. +type AuthAuditCategory string + +const ( + AuthAuditCategoryAuth AuthAuditCategory = "auth" + AuthAuditCategoryUser AuthAuditCategory = "user" + AuthAuditCategoryGroup AuthAuditCategory = "group" + AuthAuditCategorySelf AuthAuditCategory = "self" +) + +// AuthAuditEvent is one append-only record of a login or user/group change. +type AuthAuditEvent struct { + ID string `json:"id"` + At time.Time `json:"at"` + Action string `json:"action"` + Outcome AuthAuditOutcome `json:"outcome"` + Category AuthAuditCategory `json:"category"` + Actor string `json:"actor"` + Target string `json:"target,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// AuthAuditStore is the plain JSON payload in auth-audit.json. +type AuthAuditStore struct { + Events []AuthAuditEvent `json:"events"` +} + // UserCredential is one account stored in passwords.enc. type UserCredential struct { ID string `json:"id"` diff --git a/webui/src/App.css b/webui/src/App.css index b91abe1..02ec07b 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -1,6 +1,8 @@ .app-shell { display: flex; min-height: 100vh; + height: 100vh; + overflow: hidden; color: var(--ctp-text); font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; } @@ -86,6 +88,18 @@ border-top: 1px solid var(--ctp-surface0); } +.sidebar-footer-profile-row { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.sidebar-footer-profile-row .profile-item { + flex: 1; + min-width: 0; + width: auto; +} + .nav-item { display: flex; align-items: center; @@ -119,13 +133,51 @@ font-weight: 600; } +.nav-item-label { + min-width: 0; +} + +.nav-count-badge { + margin-left: auto; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.35rem; + min-height: 1.35rem; + padding: 0.1rem 0.35rem; + border: 1px solid var(--ctp-surface1); + border-radius: 0.2rem; + background: var(--ctp-surface0); + color: var(--ctp-subtext0); + font-size: 0.7rem; + font-weight: 650; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.nav-count-badge-alert { + border-color: color-mix(in srgb, var(--ctp-red) 45%, var(--ctp-surface1)); + background: color-mix(in srgb, var(--ctp-red) 14%, transparent); + color: var(--ctp-red); +} + .config-item, .profile-item { font-weight: 500; } +.sidebar-footer-profile-row .logout-item { + flex: 0 0 auto; + width: fit-content; + min-width: 0; + justify-content: center; + padding: 0.6rem 0.35rem; +} + .cog-icon, -.user-icon { +.user-icon, +.logout-icon { flex-shrink: 0; } @@ -237,6 +289,14 @@ flex: 1; padding: 2rem 2.5rem; min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: auto; +} + +.content:has(.logs-panel) { + overflow: hidden; } .content h2 { @@ -244,6 +304,7 @@ font-size: 1.75rem; letter-spacing: -0.02em; color: var(--ctp-text); + flex-shrink: 0; } .content p { @@ -1106,7 +1167,101 @@ .logs-panel { display: flex; flex-direction: column; - gap: 1rem; + gap: 0.75rem; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.logs-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; + align-items: end; +} + +.logs-filters-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + appearance: none; + border: 1px solid var(--ctp-surface1); + border-radius: 0.35rem; + background: var(--ctp-surface0); + color: var(--ctp-text); + padding: 0.45rem 0.7rem; + font: inherit; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + align-self: end; +} + +.logs-filters-toggle:hover { + border-color: var(--ctp-overlay0); + background: var(--ctp-surface1); +} + +.logs-filters-toggle-open { + border-color: var(--ctp-overlay0); + background: var(--ctp-surface1); +} + +.logs-filters-chevron { + flex-shrink: 0; + transition: transform 160ms ease; + transform: rotate(0deg); +} + +.logs-filters-chevron-expanded { + transform: rotate(180deg); +} + +.logs-filters-panel { + display: flex; + flex-direction: column; + gap: 0.65rem; + padding: 0.75rem; + border: 1px solid var(--ctp-surface0); + border-radius: 0.35rem; + background: color-mix(in srgb, var(--ctp-mantle) 70%, var(--ctp-base)); +} + +.logs-search-label, +.logs-page-size-label, +.logs-date-custom label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--ctp-subtext0); +} + +.logs-search-input { + min-width: min(100%, 18rem); + padding: 0.45rem 0.65rem; + border: 1px solid var(--ctp-surface1); + border-radius: 0.35rem; + background: var(--ctp-base); + color: var(--ctp-text); + font: inherit; +} + +.logs-page-size-label select, +.logs-date-custom input { + padding: 0.45rem 0.65rem; + border: 1px solid var(--ctp-surface1); + border-radius: 0.35rem; + background: var(--ctp-base); + color: var(--ctp-text); + font: inherit; +} + +.logs-date-custom { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; } .logs-filters { @@ -1115,6 +1270,14 @@ gap: 0.35rem; } +.logs-table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid var(--ctp-surface0); + border-radius: 0.35rem; +} + .logs-table { width: 100%; border-collapse: collapse; @@ -1129,12 +1292,70 @@ vertical-align: top; } -.logs-table th { +.logs-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--ctp-mantle); font-weight: 600; color: var(--ctp-subtext0); font-size: 0.8rem; } +.logs-sort-button { + appearance: none; + border: none; + background: transparent; + color: inherit; + font: inherit; + font-weight: 600; + padding: 0; + cursor: pointer; + text-align: left; +} + +.logs-sort-button:hover { + color: var(--ctp-text); +} + .logs-table code { font-size: 0.85em; } + +.logs-table tr.logs-row-failure { + background: color-mix(in srgb, var(--ctp-red) 18%, var(--ctp-base)); +} + +.logs-table tr.logs-row-success { + color: var(--ctp-green); +} + +.logs-table tr.logs-row-self { + color: color-mix(in srgb, var(--ctp-blue) 70%, var(--ctp-text)); +} + +.logs-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-shrink: 0; +} + +.logs-pagination-label, +.logs-pagination-page { + font-size: 0.85rem; + color: var(--ctp-subtext0); +} + +.logs-pagination-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.logs-pagination-controls button:disabled { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index bbbc045..a1510a0 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -1,13 +1,11 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import App from './App' import { THEME_STORAGE_KEY } from './theme' function stubFetchOk() { - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation((input: RequestInfo | URL) => { + const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString() if (url.includes('/api/v1/setup/status')) { @@ -17,6 +15,13 @@ function stubFetchOk() { }) } + if (url.includes('/api/v1/auth/logout')) { + return Promise.resolve({ + ok: true, + json: async () => ({ ok: true }), + }) + } + if (url.includes('/api/v1/auth/me')) { return Promise.resolve({ ok: true, @@ -39,6 +44,7 @@ function stubFetchOk() { 'users.manage', 'secrets.manage', 'roles.manage', + 'logs.read', ], }), }) @@ -61,6 +67,13 @@ function stubFetchOk() { }) } + if (url.includes('/api/v1/node-logs') || url.includes('/api/v1/auth-logs')) { + return Promise.resolve({ + ok: true, + json: async () => ({ events: [] }), + }) + } + if (url.includes('/api/v1/groups')) { return Promise.resolve({ ok: true, @@ -83,6 +96,7 @@ function stubFetchOk() { 'users.manage', 'secrets.manage', 'roles.manage', + 'logs.read', ], }, ], @@ -194,8 +208,10 @@ function stubFetchOk() { ok: true, json: async () => ({}), }) - }), - ) + }) + + vi.stubGlobal('fetch', fetchMock) + return fetchMock } function stubMatchMedia(prefersDark: boolean) { @@ -226,6 +242,7 @@ describe('App', () => { localStorage.clear() delete document.documentElement.dataset.theme window.history.replaceState(null, '', '/') + vi.useRealTimers() vi.unstubAllGlobals() vi.restoreAllMocks() }) @@ -248,6 +265,7 @@ describe('App', () => { expect( screen.getByRole('button', { name: 'Containers' }), ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Activity' })).toBeInTheDocument() expect( screen.getByRole('button', { name: 'Configuration' }), ).toBeInTheDocument() @@ -259,6 +277,370 @@ describe('App', () => { expect(screen.getByText('Coming soon')).toBeInTheDocument() }) + it('shows node count badges on resource nav items', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((input: RequestInfo | URL) => { + 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'], + totp_confirmed: false, + totp_enabled: false, + permissions: [ + 'nodes.read', + 'logs.read', + ], + }), + }) + } + + if (url.includes('/api/v1/status')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + service: 'clustercanvas', + version: '0.1.0', + }), + }) + } + + if (url.includes('/api/v1/auth-logs')) { + return Promise.resolve({ + ok: true, + json: async () => ({ events: [] }), + }) + } + + if (url.includes('/api/v1/nodes')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + nodes: [ + { id: 'c1', kind: 'container', name: 'alpha' }, + { id: 'c2', kind: 'container', name: 'beta' }, + { id: 'd1', kind: 'docker', name: 'dock' }, + ], + }), + }) + } + + return Promise.resolve({ + ok: true, + json: async () => ({}), + }) + }), + ) + + render() + + const containersButton = await screen.findByRole('button', { + name: 'Containers', + }) + await waitFor(() => { + expect( + containersButton.querySelector('.nav-count-badge'), + ).toHaveTextContent('2') + }) + expect( + screen + .getByRole('button', { name: 'Docker' }) + .querySelector('.nav-count-badge'), + ).toHaveTextContent('1') + expect( + screen + .getByRole('button', { name: 'Virtual Machines' }) + .querySelector('.nav-count-badge'), + ).toHaveTextContent('0') + expect( + screen + .getByRole('button', { name: 'Activity' }) + .querySelector('.nav-count-badge-alert'), + ).toBeNull() + }) + + it('shows a red Activity badge for auth failures today', async () => { + const todayIso = new Date( + new Date().getFullYear(), + new Date().getMonth(), + new Date().getDate(), + 12, + 0, + 0, + ).toISOString() + const yesterdayIso = new Date( + new Date().getFullYear(), + new Date().getMonth(), + new Date().getDate() - 1, + 12, + 0, + 0, + ).toISOString() + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((input: RequestInfo | URL) => { + 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'], + totp_confirmed: false, + totp_enabled: false, + permissions: ['nodes.read', 'logs.read'], + }), + }) + } + + if (url.includes('/api/v1/status')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + service: 'clustercanvas', + version: '0.1.0', + }), + }) + } + + if (url.includes('/api/v1/auth-logs')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + events: [ + { + id: 'a1', + at: todayIso, + action: 'login', + outcome: 'failure', + category: 'auth', + actor: 'eve', + }, + { + id: 'a2', + at: todayIso, + action: 'login', + outcome: 'failure', + category: 'auth', + actor: 'mallory', + }, + { + id: 'a3', + at: yesterdayIso, + action: 'login', + outcome: 'failure', + category: 'auth', + actor: 'old', + }, + { + id: 'a4', + at: todayIso, + action: 'login', + outcome: 'success', + category: 'auth', + actor: 'Admin', + }, + ], + }), + }) + } + + if (url.includes('/api/v1/nodes')) { + return Promise.resolve({ + ok: true, + json: async () => ({ nodes: [] }), + }) + } + + return Promise.resolve({ + ok: true, + json: async () => ({}), + }) + }), + ) + + render() + + const activityButton = await screen.findByRole('button', { + name: 'Activity', + }) + const alertBadge = await waitFor(() => { + const badge = activityButton.querySelector('.nav-count-badge-alert') + expect(badge).not.toBeNull() + return badge as Element + }) + expect(alertBadge).toHaveTextContent('2') + }) + + it('polls sidebar counts every 30s with idle-exempt header', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => { + 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'], + totp_confirmed: false, + totp_enabled: false, + permissions: ['nodes.read', 'logs.read'], + }), + }) + } + + if (url.includes('/api/v1/status')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + service: 'clustercanvas', + version: '0.1.0', + }), + }) + } + + if (url.includes('/api/v1/auth-logs')) { + return Promise.resolve({ + ok: true, + json: async () => ({ events: [] }), + }) + } + + if (url.includes('/api/v1/nodes')) { + return Promise.resolve({ + ok: true, + json: async () => ({ nodes: [] }), + }) + } + + return Promise.resolve({ + ok: true, + json: async () => ({}), + }) + }) + vi.stubGlobal('fetch', fetchMock) + + render() + + await screen.findByRole('heading', { name: 'Cluster Canvas' }) + await waitFor(() => { + expect( + fetchMock.mock.calls.some(([input]) => + String(input).includes('/api/v1/nodes'), + ), + ).toBe(true) + }) + + const nodesCallsBeforePoll = fetchMock.mock.calls.filter(([input]) => + String(input).includes('/api/v1/nodes'), + ).length + const authCallsBeforePoll = fetchMock.mock.calls.filter(([input]) => + String(input).includes('/api/v1/auth-logs'), + ).length + + await vi.advanceTimersByTimeAsync(30_000) + + await waitFor(() => { + const nodesCalls = fetchMock.mock.calls.filter(([input]) => + String(input).includes('/api/v1/nodes'), + ) + expect(nodesCalls.length).toBeGreaterThan(nodesCallsBeforePoll) + }) + + const pollNodesCall = [...fetchMock.mock.calls] + .reverse() + .find(([input]) => String(input).includes('/api/v1/nodes')) + expect(pollNodesCall).toBeDefined() + const pollNodesHeaders = (pollNodesCall?.[1] as { headers: Headers }).headers + expect(pollNodesHeaders.get('X-Session-Idle-Exempt')).toBe('1') + + const pollAuthCall = [...fetchMock.mock.calls] + .reverse() + .find(([input]) => String(input).includes('/api/v1/auth-logs')) + expect(pollAuthCall).toBeDefined() + const pollAuthHeaders = (pollAuthCall?.[1] as { headers: Headers }).headers + expect(pollAuthHeaders.get('X-Session-Idle-Exempt')).toBe('1') + + expect(authCallsBeforePoll).toBeGreaterThan(0) + + vi.useRealTimers() + }) + + it('hides Activity logs when logs.read is missing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((input: RequestInfo | URL) => { + 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'], + totp_confirmed: false, + totp_enabled: false, + permissions: ['nodes.read', 'users.manage'], + }), + }) + } + if (url.includes('/health') || url.includes('/api/v1/status')) { + return Promise.resolve({ + ok: true, + json: async () => ({ status: 'ok', service: 'clustercanvas', version: '0.1.0' }), + }) + } + return Promise.resolve({ ok: true, json: async () => ({}) }) + }), + ) + + render() + + expect( + await screen.findByRole('heading', { name: 'Cluster Canvas' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Activity' }), + ).not.toBeInTheDocument() + }) + it('opens Profile from the sidebar and updates the URL', async () => { const user = userEvent.setup() stubFetchOk() @@ -281,6 +663,32 @@ describe('App', () => { ).toBeInTheDocument() }) + it('logs out from the sidebar and shows a signed-out notice', async () => { + const user = userEvent.setup() + const fetchMock = stubFetchOk() + + render() + + expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Admin' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Log out' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Log out' })) + + expect( + await screen.findByRole('heading', { name: 'Sign in' }), + ).toBeInTheDocument() + expect(screen.getByText(/You have been logged out/i)).toBeInTheDocument() + expect(window.location.pathname).toBe('/login') + expect( + fetchMock.mock.calls.some( + ([input, init]) => + String(input).includes('/api/v1/auth/logout') && + (init as RequestInit | undefined)?.method === 'POST', + ), + ).toBe(true) + }) + it('restores Profile from the URL on load', async () => { stubFetchOk() window.history.replaceState(null, '', '/profile') @@ -423,6 +831,7 @@ describe('App', () => { 'users.manage', 'secrets.manage', 'roles.manage', + 'logs.read', ]) { expect(screen.getByText(permission)).toBeInTheDocument() } diff --git a/webui/src/App.tsx b/webui/src/App.tsx index cc9a675..de6e308 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -14,9 +14,11 @@ import { DEFAULT_SECURITY_SETTINGS, disableMyTOTP, fetchActions, + fetchAuthLogs, fetchGroups, fetchMe, fetchNetwork, + logout, fetchNodeLogs, fetchNodes, fetchSecurity, @@ -32,11 +34,13 @@ import { type Action, type ActionEnvVar, type ActionKind, + type AuthAuditEvent, type Group, type GroupScopeKind, type MeResponse, type NetworkSettings, type Node, + type NodeAuditAction, type NodeAuditEvent, type NodeKind, type ReauthCredentials, @@ -46,7 +50,34 @@ import { saveSecurity, upsertGroup, } from './api/client' +import { + filterAuthEvents, + filterNodeEvents, + pageRangeLabel, + paginateItems, + resolveDateRangeBounds, + sortAuthEvents, + sortNodeEvents, + type AuthActionQuickFilter, + type AuthCategoryFilter, + type AuthLogsSortKey, + type AuthOutcomeFilter, + type DateRangePreset, + type NodeLogsSortKey, + type SortDirection, +} from './logsFiltering' +import { + LOGS_PAGE_SIZE_OPTIONS, + readLogsPageSize, + writeLogsPageSize, + type LogsPageSize, +} from './logsPreferences' import { LoginPage } from './LoginPage' +import { + countAuthFailuresToday, + countNodesBySection, + type NodeSectionCounts, +} from './sidebarCounts' import { isResourceSectionId, navigateTo, @@ -130,6 +161,7 @@ const DEFAULT_PERMISSIONS = [ 'users.manage', 'secrets.manage', 'roles.manage', + 'logs.read', ] as const const ADMINISTRATORS_GROUP_NAME = 'Administrators' @@ -178,6 +210,46 @@ function UserIcon() { ) } +function LogoutIcon() { + return ( + + ) +} + +function FiltersChevronIcon({ expanded }: { expanded: boolean }) { + return ( + + ) +} + function ProfilePanel({ onSignedOut, }: { @@ -2954,35 +3026,93 @@ function ResourceSectionPanel({ } function LogsPanel() { + type LogsSection = 'nodes' | 'auth' type KindFilter = 'all' | NodeKind + type NodeActionFilter = 'all' | NodeAuditAction + + const [logsSection, setLogsSection] = useState('nodes') const [kindFilter, setKindFilter] = useState('all') - const [events, setEvents] = useState>([]) + const [nodeActionFilter, setNodeActionFilter] = + useState('all') + const [authOutcomeFilter, setAuthOutcomeFilter] = + useState('all') + const [authCategoryFilter, setAuthCategoryFilter] = + useState('all') + const [authActionFilter, setAuthActionFilter] = + useState('all') + const [searchQuery, setSearchQuery] = useState('') + const [datePreset, setDatePreset] = useState('all') + const [customDateFrom, setCustomDateFrom] = useState('') + const [customDateTo, setCustomDateTo] = useState('') + const [pageSize, setPageSize] = useState(() => + readLogsPageSize(), + ) + const [filtersExpanded, setFiltersExpanded] = useState(false) + const [page, setPage] = useState(1) + const [nodeSortKey, setNodeSortKey] = useState('at') + const [authSortKey, setAuthSortKey] = useState('at') + const [sortDirection, setSortDirection] = useState('desc') + const [nodeEvents, setNodeEvents] = useState>( + [], + ) + const [authEvents, setAuthEvents] = useState>( + [], + ) const [isLoading, setIsLoading] = useState(true) const [loadError, setLoadError] = useState(null) - const [canRead, setCanRead] = useState(false) + const [canReadLogs, setCanReadLogs] = useState(false) + const [permissionsReady, setPermissionsReady] = useState(false) useEffect(() => { let isCancelled = false + async function loadPermissions() { + try { + const me = await fetchMe() + if (isCancelled) { + return + } + setCanReadLogs(Boolean(me?.permissions?.includes('logs.read'))) + } catch { + if (!isCancelled) { + setCanReadLogs(false) + } + } finally { + if (!isCancelled) { + setPermissionsReady(true) + } + } + } + + void loadPermissions() + + return () => { + isCancelled = true + } + }, []) + + useEffect(() => { + if (!permissionsReady || !canReadLogs) { + return + } + + let isCancelled = false + async function load() { setIsLoading(true) setLoadError(null) try { - const me = await fetchMe() - if (isCancelled) { + if (logsSection === 'nodes') { + const response = await fetchNodeLogs() + if (!isCancelled) { + setNodeEvents(response.events) + } return } - const allowed = Boolean(me?.permissions?.includes('nodes.read')) - setCanRead(allowed) - if (!allowed) { - setEvents([]) - return - } - const response = await fetchNodeLogs( - kindFilter === 'all' ? undefined : kindFilter, - ) + + const response = await fetchAuthLogs() if (!isCancelled) { - setEvents(response.events) + setAuthEvents(response.events) } } catch (err) { if (!isCancelled) { @@ -3002,29 +3132,184 @@ function LogsPanel() { return () => { isCancelled = true } - }, [kindFilter]) + }, [logsSection, canReadLogs, permissionsReady]) - const filters: ReadonlyArray<{ id: KindFilter; label: string }> = [ + useEffect(() => { + setPage(1) + }, [ + logsSection, + kindFilter, + nodeActionFilter, + authOutcomeFilter, + authCategoryFilter, + authActionFilter, + searchQuery, + datePreset, + customDateFrom, + customDateTo, + pageSize, + ]) + + const kindFilters: 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 ( -

- You need the nodes.read permission to view node activity - logs. -

- ) + const nodeActionFilters: ReadonlyArray<{ + id: NodeActionFilter + label: string + }> = [ + { id: 'all', label: 'All actions' }, + { id: 'create', label: 'create' }, + { id: 'update', label: 'update' }, + { id: 'delete', label: 'delete' }, + { id: 'test_ssh', label: 'test_ssh' }, + ] + + const authOutcomeFilters: ReadonlyArray<{ + id: AuthOutcomeFilter + label: string + }> = [ + { id: 'all', label: 'All outcomes' }, + { id: 'failure', label: 'Failures' }, + { id: 'success', label: 'Success' }, + ] + + const authCategoryFilters: ReadonlyArray<{ + id: AuthCategoryFilter + label: string + }> = [ + { id: 'all', label: 'All categories' }, + { id: 'auth', label: 'auth' }, + { id: 'user', label: 'user' }, + { id: 'group', label: 'group' }, + { id: 'self', label: 'self' }, + ] + + const authActionFilters: ReadonlyArray<{ + id: AuthActionQuickFilter + label: string + }> = [ + { id: 'all', label: 'All events' }, + { id: 'login_failures', label: 'Login failures' }, + { id: 'login', label: 'login' }, + { id: 'logout', label: 'logout' }, + { id: 'user_create', label: 'user_create' }, + { id: 'user_update', label: 'user_update' }, + { id: 'user_delete', label: 'user_delete' }, + { id: 'group_create', label: 'group_create' }, + { id: 'group_update', label: 'group_update' }, + { id: 'group_delete', label: 'group_delete' }, + { id: 'password_change', label: 'password_change' }, + { id: 'totp_enable', label: 'totp_enable' }, + { id: 'totp_disable', label: 'totp_disable' }, + ] + + const datePresets: ReadonlyArray<{ id: DateRangePreset; label: string }> = [ + { id: 'all', label: 'All time' }, + { id: 'today', label: 'Today' }, + { id: '7d', label: 'Last 7 days' }, + { id: '30d', label: 'Last 30 days' }, + { id: 'custom', label: 'Custom' }, + ] + + const { from: dateFrom, to: dateTo } = resolveDateRangeBounds( + datePreset, + customDateFrom, + customDateTo, + ) + + const filteredNodeEvents = sortNodeEvents( + filterNodeEvents(nodeEvents, { + search: searchQuery, + kind: kindFilter, + action: nodeActionFilter, + dateFrom, + dateTo, + }), + nodeSortKey, + sortDirection, + ) + + const filteredAuthEvents = sortAuthEvents( + filterAuthEvents(authEvents, { + search: searchQuery, + outcome: authOutcomeFilter, + category: authCategoryFilter, + actionFilter: authActionFilter, + dateFrom, + dateTo, + }), + authSortKey, + sortDirection, + ) + + const activeEvents = + logsSection === 'nodes' ? filteredNodeEvents : filteredAuthEvents + const nodePagination = paginateItems(filteredNodeEvents, page, pageSize) + const authPagination = paginateItems(filteredAuthEvents, page, pageSize) + const pagination = + logsSection === 'nodes' ? nodePagination : authPagination + const displayNodeEvents = + logsSection === 'nodes' ? nodePagination.pageItems : [] + const displayAuthEvents = + logsSection === 'auth' ? authPagination.pageItems : [] + + function handlePageSizeChange(nextSize: LogsPageSize) { + setPageSize(nextSize) + writeLogsPageSize(nextSize) } - return ( -
-
+ function toggleNodeSort(key: NodeLogsSortKey) { + if (nodeSortKey === key) { + setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) + return + } + setNodeSortKey(key) + setSortDirection(key === 'at' ? 'desc' : 'asc') + } + + function toggleAuthSort(key: AuthLogsSortKey) { + if (authSortKey === key) { + setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) + return + } + setAuthSortKey(key) + setSortDirection(key === 'at' ? 'desc' : 'asc') + } + + function sortIndicator(active: boolean): string { + if (!active) { + return '' + } + return sortDirection === 'asc' ? ' ▲' : ' ▼' + } + + function authRowClassName(event: AuthAuditEvent): string { + if (event.outcome === 'failure') { + return 'logs-row-failure' + } + if (event.category === 'self') { + return 'logs-row-self' + } + if (event.outcome === 'success') { + return 'logs-row-success' + } + return '' + } + + function renderFilterChip( + filters: ReadonlyArray<{ id: T; label: string }>, + selected: T, + onSelect: (id: T) => void, + ariaLabel: string, + ) { + return ( +
{filters.map((filter) => { - const isSelected = kindFilter === filter.id + const isSelected = selected === filter.id return ( ) })}
+ ) + } + + if (!permissionsReady) { + return ( +
+

Loading…

+
+ ) + } + + if (!canReadLogs) { + return ( +

+ You need the logs.read permission to view activity logs. +

+ ) + } + + return ( +
+
+ {( + [ + { id: 'nodes' as const, label: 'Nodes' }, + { id: 'auth' as const, label: 'Users & Auth' }, + ] as const + ).map((section) => { + const isSelected = logsSection === section.id + return ( + + ) + })} +
+ +
+ + + + + +
+ + {filtersExpanded ? ( +
+ {renderFilterChip( + datePresets, + datePreset, + setDatePreset, + 'Date range', + )} + + {datePreset === 'custom' ? ( +
+ + +
+ ) : null} + + {logsSection === 'nodes' + ? renderFilterChip( + kindFilters, + kindFilter, + setKindFilter, + 'Log kind filter', + ) + : null} + {logsSection === 'nodes' + ? renderFilterChip( + nodeActionFilters, + nodeActionFilter, + setNodeActionFilter, + 'Node action filter', + ) + : null} + + {logsSection === 'auth' + ? renderFilterChip( + authOutcomeFilters, + authOutcomeFilter, + setAuthOutcomeFilter, + 'Auth outcome filter', + ) + : null} + {logsSection === 'auth' + ? renderFilterChip( + authCategoryFilters, + authCategoryFilter, + setAuthCategoryFilter, + 'Auth category filter', + ) + : null} + {logsSection === 'auth' + ? renderFilterChip( + authActionFilters, + authActionFilter, + setAuthActionFilter, + 'Auth event filter', + ) + : null} +
+ ) : null} {isLoading ?

Loading…

: null} {loadError ?

{loadError}

: null} - {!isLoading && !loadError && events.length === 0 ? ( -

No node activity recorded yet.

+ + {!isLoading && !loadError && activeEvents.length === 0 ? ( +

+ {logsSection === 'nodes' + ? 'No node activity matches the current filters.' + : 'No user or auth activity matches the current filters.'} +

) : null} - {!isLoading && events.length > 0 ? ( - - - - - - - - - - - - - {events.map((event) => ( - - - - - - - + + {!isLoading && displayNodeEvents.length > 0 ? ( +
+
WhenActionActorNodeKindDetail
{formatUserTimestamp(event.at)} - {event.action} - {event.actor} - {event.node_name} -
- {event.node_id} -
-
{event.node_kind}{event.detail || '—'}
+ + + {( + [ + { key: 'at' as const, label: 'When' }, + { key: 'action' as const, label: 'Action' }, + { key: 'actor' as const, label: 'Actor' }, + { key: 'node' as const, label: 'Node' }, + { key: 'kind' as const, label: 'Kind' }, + { key: 'detail' as const, label: 'Detail' }, + ] as const + ).map((column) => ( + + ))} - ))} - -
+ +
+ + + {displayNodeEvents.map((event) => ( + + {formatUserTimestamp(event.at)} + + {event.action} + + {event.actor} + + {event.node_name} +
+ {event.node_id} +
+ + {event.node_kind} + {event.detail || '—'} + + ))} + + +
+ ) : null} + + {!isLoading && displayAuthEvents.length > 0 ? ( +
+ + + + {( + [ + { key: 'at' as const, label: 'When' }, + { key: 'action' as const, label: 'Action' }, + { key: 'outcome' as const, label: 'Outcome' }, + { key: 'category' as const, label: 'Category' }, + { key: 'actor' as const, label: 'Actor' }, + { key: 'target' as const, label: 'Target' }, + { key: 'detail' as const, label: 'Detail' }, + ] as const + ).map((column) => ( + + ))} + + + + {displayAuthEvents.map((event) => ( + + + + + + + + + + ))} + +
+ +
{formatUserTimestamp(event.at)} + {event.action} + {event.outcome}{event.category}{event.actor}{event.target || '—'}{event.detail || '—'}
+
+ ) : null} + + {!isLoading && activeEvents.length > 0 ? ( +
+ + {pageRangeLabel( + activeEvents.length, + pagination.safePage, + pageSize, + )} + +
+ + + Page {pagination.safePage} of {pagination.totalPages} + + +
+
) : null}
) @@ -3187,6 +3760,11 @@ function App() { const [backendVersion, setBackendVersion] = useState('…') const [bootState, setBootState] = useState({ kind: 'loading' }) const [canReadLogs, setCanReadLogs] = useState(false) + const [nodeSectionCounts, setNodeSectionCounts] = + useState(null) + const [authFailuresToday, setAuthFailuresToday] = useState( + null, + ) const { themePreference, updateThemePreference } = useThemePreference() const profileLabel = profileDisplayName ?? 'Profile' @@ -3226,7 +3804,7 @@ function App() { } setProfileDisplayName(me.username) - setCanReadLogs(Boolean(me.permissions?.includes('nodes.read'))) + setCanReadLogs(Boolean(me.permissions?.includes('logs.read'))) setBootState({ kind: 'ready', username: me.username }) if ( window.location.pathname === '/setup' || @@ -3249,6 +3827,58 @@ function App() { } }, []) + useEffect(() => { + if (bootState.kind !== 'ready') { + return + } + + let isCancelled = false + + async function loadSidebarCounts(idleExempt: boolean) { + try { + const nodesResponse = await fetchNodes(undefined, fetch, { + idleExempt, + }) + if (!isCancelled) { + setNodeSectionCounts(countNodesBySection(nodesResponse.nodes)) + } + } catch { + if (!isCancelled) { + setNodeSectionCounts(null) + } + } + + if (!canReadLogs) { + if (!isCancelled) { + setAuthFailuresToday(null) + } + return + } + + try { + const authResponse = await fetchAuthLogs(fetch, { idleExempt }) + if (!isCancelled) { + setAuthFailuresToday(countAuthFailuresToday(authResponse.events)) + } + } catch { + if (!isCancelled) { + setAuthFailuresToday(null) + } + } + } + + void loadSidebarCounts(false) + + const pollIntervalId = window.setInterval(() => { + void loadSidebarCounts(true) + }, 30_000) + + return () => { + isCancelled = true + window.clearInterval(pollIntervalId) + } + }, [bootState.kind, canReadLogs, activeSection]) + useEffect(() => { if (bootState.kind !== 'ready') { return @@ -3308,6 +3938,22 @@ function App() { navigateTo(pathFor(activeSection, 'overview', tab)) } + function clearSignedInState() { + setProfileDisplayName(null) + setCanReadLogs(false) + } + + async function handleLogout() { + try { + await logout() + } catch { + // Still leave the signed-in UI even if the session is already gone. + } + clearSignedInState() + window.history.replaceState(null, '', '/login?reason=logout') + setBootState({ kind: 'login' }) + } + if (bootState.kind === 'loading') { return (
@@ -3321,8 +3967,8 @@ function App() { { + window.history.replaceState(null, '', '/login?from=setup') setBootState({ kind: 'login' }) - navigateTo('/login') }} /> ) @@ -3333,12 +3979,17 @@ function App() { { setProfileDisplayName(username) - void fetchMe().then((me) => { - setCanReadLogs(Boolean(me?.permissions?.includes('nodes.read'))) - }) - setBootState({ kind: 'ready', username }) - navigateTo('/') - setActiveSection('overview') + void (async () => { + try { + const me = await fetchMe() + setCanReadLogs(Boolean(me?.permissions?.includes('logs.read'))) + } catch { + setCanReadLogs(false) + } + setBootState({ kind: 'ready', username }) + navigateTo('/') + setActiveSection('overview') + })() }} /> ) @@ -3370,21 +4021,32 @@ function App() { ))} ) : null}
- +
+ + +