diff --git a/admin/api/audit.go b/admin/api/audit.go new file mode 100644 index 0000000..6ae4612 --- /dev/null +++ b/admin/api/audit.go @@ -0,0 +1,117 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/cloudblue/chaperone/admin/store" +) + +// AuditHandler serves the audit log REST endpoint. +type AuditHandler struct { + store *store.Store +} + +// NewAuditHandler creates a handler for the audit log endpoint. +func NewAuditHandler(st *store.Store) *AuditHandler { + return &AuditHandler{store: st} +} + +// Register mounts audit routes on the given mux. +func (h *AuditHandler) Register(mux *http.ServeMux) { + mux.HandleFunc("GET /api/audit", h.list) +} + +func (h *AuditHandler) list(w http.ResponseWriter, r *http.Request) { + filter, err := parseAuditFilter(r.URL.Query()) + if err != nil { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) + return + } + + page, err := h.store.ListAuditEntries(r.Context(), filter) + if err != nil { + respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list audit entries") + return + } + + respondJSON(w, http.StatusOK, page) +} + +func parseAuditFilter(q url.Values) (store.AuditFilter, error) { + filter := store.AuditFilter{ + Action: strings.TrimSpace(q.Get("action")), + Query: strings.TrimSpace(q.Get("q")), + Page: 1, + PerPage: 20, + } + + if err := parseIDParam(q, "user", &filter.UserID); err != nil { + return filter, err + } + if err := parseIDParam(q, "instance_id", &filter.InstanceID); err != nil { + return filter, err + } + if err := parseTimeParam(q, "from", &filter.From); err != nil { + return filter, err + } + if err := parseTimeParam(q, "to", &filter.To); err != nil { + return filter, err + } + if err := parsePageParams(q, &filter.Page, &filter.PerPage); err != nil { + return filter, err + } + + return filter, nil +} + +func parseIDParam(q url.Values, key string, dst **int64) error { + v := q.Get(key) + if v == "" { + return nil + } + id, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return fmt.Errorf("invalid %s: %q", key, v) + } + *dst = &id + return nil +} + +func parseTimeParam(q url.Values, key string, dst **time.Time) error { + v := q.Get(key) + if v == "" { + return nil + } + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return fmt.Errorf("invalid %s: %q (expected RFC 3339)", key, v) + } + *dst = &t + return nil +} + +func parsePageParams(q url.Values, page, perPage *int) error { + if v := q.Get("page"); v != "" { + p, err := strconv.Atoi(v) + if err != nil || p < 1 { + return fmt.Errorf("invalid page: %q", v) + } + *page = p + } + if v := q.Get("per_page"); v != "" { + pp, err := strconv.Atoi(v) + if err != nil || pp < 1 || pp > 100 { + return fmt.Errorf("invalid per_page: %q (must be 1-100)", v) + } + *perPage = pp + } + return nil +} diff --git a/admin/api/audit_actions.go b/admin/api/audit_actions.go new file mode 100644 index 0000000..0275ecd --- /dev/null +++ b/admin/api/audit_actions.go @@ -0,0 +1,15 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +// Audit action constants logged for each portal operation. +// Keep in sync with the frontend labels in admin/ui/src/utils/audit.js. +const ( + AuditActionInstanceCreate = "instance.create" + AuditActionInstanceUpdate = "instance.update" + AuditActionInstanceDelete = "instance.delete" + AuditActionUserLogin = "user.login" + AuditActionUserLogout = "user.logout" + AuditActionPasswordChange = "user.password_change" +) diff --git a/admin/api/audit_test.go b/admin/api/audit_test.go new file mode 100644 index 0000000..010562c --- /dev/null +++ b/admin/api/audit_test.go @@ -0,0 +1,241 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/cloudblue/chaperone/admin/store" +) + +func newAuditTestMux(t *testing.T) (*http.ServeMux, *store.Store) { + t.Helper() + st := openTestStore(t) + h := NewAuditHandler(st) + mux := http.NewServeMux() + h.Register(mux) + return mux, st +} + +func seedAuditData(t *testing.T, st *store.Store) int64 { + t.Helper() + ctx := context.Background() + user, err := st.CreateUser(ctx, "admin", "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234") + if err != nil { + t.Fatalf("CreateUser() error = %v", err) + } + inst, err := st.CreateInstance(ctx, "proxy-1", "10.0.0.1:9090") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + entries := []struct { + action string + instanceID *int64 + detail string + }{ + {"instance.create", &inst.ID, "Created instance proxy-1 at 10.0.0.1:9090"}, + {"instance.update", &inst.ID, "Updated instance proxy-1"}, + {"user.login", nil, "User admin logged in"}, + } + for _, e := range entries { + if err := st.InsertAuditEntry(ctx, user.ID, e.action, e.instanceID, e.detail); err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } + } + return user.ID +} + +func TestAuditList_Empty_ReturnsEmptyPage(t *testing.T) { + t.Parallel() + mux, _ := newAuditTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/api/audit", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding response: %v", err) + } + if page.Total != 0 { + t.Errorf("Total = %d, want 0", page.Total) + } + if len(page.Items) != 0 { + t.Errorf("len(Items) = %d, want 0", len(page.Items)) + } +} + +func TestAuditList_ReturnsEntries(t *testing.T) { + t.Parallel() + mux, st := newAuditTestMux(t) + seedAuditData(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/audit", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding: %v", err) + } + if page.Total != 3 { + t.Errorf("Total = %d, want 3", page.Total) + } + if len(page.Items) != 3 { + t.Errorf("len(Items) = %d, want 3", len(page.Items)) + } +} + +func TestAuditList_FilterByAction(t *testing.T) { + t.Parallel() + mux, st := newAuditTestMux(t) + seedAuditData(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?action=user.login", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding: %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestAuditList_FilterByUser(t *testing.T) { + t.Parallel() + mux, st := newAuditTestMux(t) + userID := seedAuditData(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?user="+itoa(userID), nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding: %v", err) + } + if page.Total != 3 { + t.Errorf("Total = %d, want 3", page.Total) + } +} + +func TestAuditList_FullTextSearch(t *testing.T) { + t.Parallel() + mux, st := newAuditTestMux(t) + seedAuditData(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?q=proxy-1", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding: %v", err) + } + // "proxy-1" appears in instance.create and instance.update details. + if page.Total != 2 { + t.Errorf("Total = %d, want 2", page.Total) + } +} + +func TestAuditList_Pagination(t *testing.T) { + t.Parallel() + mux, st := newAuditTestMux(t) + seedAuditData(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?page=1&per_page=2", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding: %v", err) + } + if page.Total != 3 { + t.Errorf("Total = %d, want 3", page.Total) + } + if len(page.Items) != 2 { + t.Errorf("len(Items) = %d, want 2", len(page.Items)) + } + if page.Page != 1 { + t.Errorf("Page = %d, want 1", page.Page) + } +} + +func TestAuditList_InvalidPage_Returns400(t *testing.T) { + t.Parallel() + mux, _ := newAuditTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?page=abc", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestAuditList_InvalidPerPage_Returns400(t *testing.T) { + t.Parallel() + mux, _ := newAuditTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?per_page=999", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestAuditList_InvalidUserID_Returns400(t *testing.T) { + t.Parallel() + mux, _ := newAuditTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?user=notanumber", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestAuditList_InvalidFromDate_Returns400(t *testing.T) { + t.Parallel() + mux, _ := newAuditTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?from=not-a-date", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func itoa(n int64) string { + return strconv.FormatInt(n, 10) +} diff --git a/admin/api/auth.go b/admin/api/auth.go index fd2ef06..2ef0271 100644 --- a/admin/api/auth.go +++ b/admin/api/auth.go @@ -4,6 +4,7 @@ package api import ( + "context" "errors" "fmt" "log/slog" @@ -12,19 +13,22 @@ import ( "time" "github.com/cloudblue/chaperone/admin/auth" + "github.com/cloudblue/chaperone/admin/store" ) // AuthHandler handles login, logout, and password change endpoints. type AuthHandler struct { auth *auth.Service + store *store.Store secureCookies bool sessionMaxAge time.Duration } // NewAuthHandler creates a handler for auth endpoints. -func NewAuthHandler(authService *auth.Service, secureCookies bool, sessionMaxAge time.Duration) *AuthHandler { +func NewAuthHandler(authService *auth.Service, st *store.Store, secureCookies bool, sessionMaxAge time.Duration) *AuthHandler { return &AuthHandler{ auth: authService, + store: st, secureCookies: secureCookies, sessionMaxAge: sessionMaxAge, } @@ -83,6 +87,9 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) { h.setSessionCookie(w, result.SessionToken) h.setCSRFCookie(w) + h.auditLog(r.Context(), result.User.ID, AuditActionUserLogin, + fmt.Sprintf("User %q logged in from %s", result.User.Username, ip)) + respondJSON(w, http.StatusOK, loginResponse{ User: loginUser{ ID: result.User.ID, @@ -103,12 +110,20 @@ func (h *AuthHandler) me(w http.ResponseWriter, r *http.Request) { } func (h *AuthHandler) logout(w http.ResponseWriter, r *http.Request) { + user := auth.ContextUser(r.Context()) + cookie, err := r.Cookie(auth.SessionCookieName) if err == nil { if logoutErr := h.auth.Logout(r.Context(), cookie.Value); logoutErr != nil { slog.Error("logout session deletion", "error", logoutErr) } } + + if user != nil { + h.auditLog(r.Context(), user.ID, AuditActionUserLogout, + fmt.Sprintf("User %q logged out", user.Username)) + } + h.clearCookies(w) w.WriteHeader(http.StatusNoContent) } @@ -162,6 +177,8 @@ func (h *AuthHandler) changePassword(w http.ResponseWriter, r *http.Request) { return } + h.auditLog(r.Context(), user.ID, AuditActionPasswordChange, + fmt.Sprintf("User %q changed password", user.Username)) w.WriteHeader(http.StatusNoContent) } @@ -215,6 +232,12 @@ func (h *AuthHandler) clearCookies(w http.ResponseWriter) { }) } +func (h *AuthHandler) auditLog(ctx context.Context, userID int64, action, detail string) { + if err := h.store.InsertAuditEntry(ctx, userID, action, nil, detail); err != nil { + slog.Error("writing audit entry", "action", action, "error", err) + } +} + // clientIP extracts the client IP from the request's TCP peer address. // The admin portal is deployed direct-to-network within Distributor infrastructure; // X-Forwarded-For is not trusted and must be ignored for rate-limiting. diff --git a/admin/api/auth_test.go b/admin/api/auth_test.go index 8a833ad..190bcb7 100644 --- a/admin/api/auth_test.go +++ b/admin/api/auth_test.go @@ -21,7 +21,7 @@ func newTestAuthMux(t *testing.T) (*http.ServeMux, *auth.Service) { t.Helper() st := openTestStore(t) svc := auth.NewService(st, 24*time.Hour, 2*time.Hour) - h := NewAuthHandler(svc, false, 24*time.Hour) + h := NewAuthHandler(svc, st, false, 24*time.Hour) mux := http.NewServeMux() h.Register(mux) return mux, svc diff --git a/admin/api/instance.go b/admin/api/instance.go index 3d60dae..de00b9b 100644 --- a/admin/api/instance.go +++ b/admin/api/instance.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/cloudblue/chaperone/admin/auth" "github.com/cloudblue/chaperone/admin/poller" "github.com/cloudblue/chaperone/admin/store" ) @@ -99,6 +100,9 @@ func (h *InstanceHandler) create(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create instance") return } + + h.audit(r, AuditActionInstanceCreate, &inst.ID, + fmt.Sprintf("Created instance %q at %s", inst.Name, inst.Address)) respondJSON(w, http.StatusCreated, inst) } @@ -131,6 +135,9 @@ func (h *InstanceHandler) update(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update instance") return } + + h.audit(r, AuditActionInstanceUpdate, &inst.ID, + fmt.Sprintf("Updated instance %q (address: %s)", inst.Name, inst.Address)) respondJSON(w, http.StatusOK, inst) } @@ -140,6 +147,9 @@ func (h *InstanceHandler) delete(w http.ResponseWriter, r *http.Request) { return } + // Fetch instance name before deletion for the audit detail. + inst, getErr := h.store.GetInstance(r.Context(), id) + err := h.store.DeleteInstance(r.Context(), id) if errors.Is(err, store.ErrInstanceNotFound) { respondError(w, http.StatusNotFound, "INSTANCE_NOT_FOUND", fmt.Sprintf("No instance with ID %d", id)) @@ -150,6 +160,12 @@ func (h *InstanceHandler) delete(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete instance") return } + + detail := fmt.Sprintf("Deleted instance ID %d", id) + if getErr == nil { + detail = fmt.Sprintf("Deleted instance %q (%s)", inst.Name, inst.Address) + } + h.audit(r, AuditActionInstanceDelete, nil, detail) w.WriteHeader(http.StatusNoContent) } @@ -217,6 +233,16 @@ func validateInstanceRequest(w http.ResponseWriter, req *instanceRequest) bool { var errInvalidHostPort = errors.New("address must be a valid host:port (e.g. 192.168.1.10:9090)") +func (h *InstanceHandler) audit(r *http.Request, action string, instanceID *int64, detail string) { + user := auth.ContextUser(r.Context()) + if user == nil { + return + } + if err := h.store.InsertAuditEntry(r.Context(), user.ID, action, instanceID, detail); err != nil { + slog.Error("writing audit entry", "action", action, "error", err) + } +} + func validHostPort(addr string) error { host, portStr, err := net.SplitHostPort(addr) if err != nil { diff --git a/admin/cmd/chaperone-admin/main.go b/admin/cmd/chaperone-admin/main.go index 4a9950f..40d2691 100644 --- a/admin/cmd/chaperone-admin/main.go +++ b/admin/cmd/chaperone-admin/main.go @@ -100,18 +100,28 @@ func runServer(args []string) error { return fmt.Errorf("creating server: %w", err) } - // Start background goroutines. bgCtx, bgCancel := context.WithCancel(context.Background()) defer bgCancel() - - p := poller.New(st, collector, cfg.Scraper.Interval.Unwrap(), cfg.Scraper.Timeout.Unwrap()) - go p.Run(bgCtx) - go cleanupExpiredSessions(bgCtx, st) - go sweepRateLimiter(bgCtx, srv) + startBackground(bgCtx, cfg, st, collector, srv) return serve(cfg.Server.Addr, srv) } +func startBackground(ctx context.Context, cfg *config.Config, st *store.Store, collector *metrics.Collector, srv *admin.Server) { + p := poller.New(st, collector, cfg.Scraper.Interval.Unwrap(), cfg.Scraper.Timeout.Unwrap()) + go p.Run(ctx) + go cleanupExpiredSessions(ctx, st) + go sweepRateLimiter(ctx, srv) + + if cfg.Audit.RetentionDays == nil || *cfg.Audit.RetentionDays > 0 { + retentionDays := 90 + if cfg.Audit.RetentionDays != nil { + retentionDays = *cfg.Audit.RetentionDays + } + go cleanupOldAuditEntries(ctx, st, retentionDays) + } +} + func runCreateUser(args []string) error { fs := flag.NewFlagSet("create-user", flag.ExitOnError) configPath := fs.String("config", "", "Path to config file") @@ -252,6 +262,32 @@ func sweepRateLimiter(ctx context.Context, srv *admin.Server) { } } +func cleanupOldAuditEntries(ctx context.Context, st *store.Store, retentionDays int) { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + runCleanup := func() { + cutoff := time.Now().AddDate(0, 0, -retentionDays) + n, err := st.DeleteAuditEntriesBefore(ctx, cutoff) + if err != nil { + slog.Error("cleaning up old audit entries", "error", err) + } else if n > 0 { + slog.Info("cleaned up old audit entries", "count", n, "retention_days", retentionDays) + } + } + + runCleanup() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runCleanup() + } + } +} + func serve(addr string, srv *admin.Server) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/admin/server.go b/admin/server.go index e61ada8..e6f6bb7 100644 --- a/admin/server.go +++ b/admin/server.go @@ -79,7 +79,7 @@ func (s *Server) routes(mux *http.ServeMux, authService *auth.Service, secureCoo mux.HandleFunc("GET /api/health", s.handleHealth) // Auth endpoints (login, logout, password change). - authHandler := api.NewAuthHandler(authService, secureCookies, s.config.Session.MaxAge.Unwrap()) + authHandler := api.NewAuthHandler(authService, s.store, secureCookies, s.config.Session.MaxAge.Unwrap()) authHandler.Register(mux) // Instance CRUD + test connection. @@ -90,6 +90,10 @@ func (s *Server) routes(mux *http.ServeMux, authService *auth.Service, secureCoo metricsAPI := api.NewMetricsHandler(s.store, s.collector) metricsAPI.Register(mux) + // Audit log API. + audit := api.NewAuditHandler(s.store) + audit.Register(mux) + // SPA serving — all non-API routes serve the Vue app. assets, err := loadUIAssets() if err != nil { diff --git a/admin/store/audit.go b/admin/store/audit.go new file mode 100644 index 0000000..b7ff251 --- /dev/null +++ b/admin/store/audit.go @@ -0,0 +1,162 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "context" + "fmt" + "strings" + "time" +) + +// AuditEntry represents a single audit log record. +type AuditEntry struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Username string `json:"user"` + Action string `json:"action"` + InstanceID *int64 `json:"instance_id,omitempty"` + Detail string `json:"detail"` + CreatedAt time.Time `json:"created_at"` +} + +// AuditFilter specifies query parameters for listing audit entries. +type AuditFilter struct { + UserID *int64 + Action string + InstanceID *int64 + From *time.Time + To *time.Time + Query string // full-text search on detail + Page int + PerPage int +} + +// AuditPage is a paginated response of audit entries. +type AuditPage struct { + Items []AuditEntry `json:"items"` + Total int `json:"total"` + Page int `json:"page"` +} + +// InsertAuditEntry records an action in the audit log. +func (s *Store) InsertAuditEntry(ctx context.Context, userID int64, action string, instanceID *int64, detail string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO audit_log (user_id, action, instance_id, detail) VALUES (?, ?, ?, ?)`, + userID, action, instanceID, detail) + if err != nil { + return fmt.Errorf("inserting audit entry: %w", err) + } + return nil +} + +// ListAuditEntries returns a paginated, filtered list of audit entries. +func (s *Store) ListAuditEntries(ctx context.Context, filter AuditFilter) (*AuditPage, error) { + if filter.Page < 1 { + filter.Page = 1 + } + if filter.PerPage < 1 { + filter.PerPage = 20 + } + + conditions, args := buildAuditConditions(filter) + joins := "JOIN users u ON a.user_id = u.id" + if filter.Query != "" { + joins += " JOIN audit_log_fts f ON a.id = f.rowid" + } + + where := "1=1" + if len(conditions) > 0 { + where = strings.Join(conditions, " AND ") + } + + // Count total matching entries. + // Dynamic SQL is safe: joins and where are built from fixed strings, not user input. + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM audit_log a %s WHERE %s", joins, where) //nolint:gosec // G201 -- see above + var total int + if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, fmt.Errorf("counting audit entries: %w", err) + } + + // Fetch the page. + offset := (filter.Page - 1) * filter.PerPage + dataQuery := fmt.Sprintf( //nolint:gosec // G201 -- joins/where built from fixed strings + `SELECT a.id, a.user_id, u.username, a.action, a.instance_id, a.detail, a.created_at + FROM audit_log a %s + WHERE %s + ORDER BY a.created_at DESC + LIMIT ? OFFSET ?`, joins, where) + dataArgs := append(args, filter.PerPage, offset) //nolint:gocritic // append to copy is intentional + + rows, err := s.db.QueryContext(ctx, dataQuery, dataArgs...) + if err != nil { + return nil, fmt.Errorf("listing audit entries: %w", err) + } + defer rows.Close() + + items := make([]AuditEntry, 0) + for rows.Next() { + var e AuditEntry + if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.InstanceID, &e.Detail, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("scanning audit entry: %w", err) + } + items = append(items, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating audit entries: %w", err) + } + + return &AuditPage{Items: items, Total: total, Page: filter.Page}, nil +} + +// DeleteAuditEntriesBefore removes audit entries older than the given time. +// Returns the number of deleted rows. +func (s *Store) DeleteAuditEntriesBefore(ctx context.Context, before time.Time) (int64, error) { + result, err := s.db.ExecContext(ctx, + `DELETE FROM audit_log WHERE created_at < ?`, before) + if err != nil { + return 0, fmt.Errorf("deleting old audit entries: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("checking rows affected: %w", err) + } + return n, nil +} + +func buildAuditConditions(filter AuditFilter) (conditions []string, args []any) { + if filter.UserID != nil { + conditions = append(conditions, "a.user_id = ?") + args = append(args, *filter.UserID) + } + if filter.Action != "" { + conditions = append(conditions, "a.action = ?") + args = append(args, filter.Action) + } + if filter.InstanceID != nil { + conditions = append(conditions, "a.instance_id = ?") + args = append(args, *filter.InstanceID) + } + if filter.From != nil { + conditions = append(conditions, "a.created_at >= ?") + args = append(args, *filter.From) + } + if filter.To != nil { + conditions = append(conditions, "a.created_at <= ?") + args = append(args, *filter.To) + } + if filter.Query != "" { + conditions = append(conditions, "audit_log_fts MATCH ?") + args = append(args, ftsQuote(filter.Query)) + } + + return conditions, args +} + +// ftsQuote wraps a user query in double quotes so FTS5 treats it as a +// literal phrase. Internal double quotes are escaped per FTS5 rules. +func ftsQuote(q string) string { + escaped := strings.ReplaceAll(q, `"`, `""`) + return `"` + escaped + `"` +} diff --git a/admin/store/audit_test.go b/admin/store/audit_test.go new file mode 100644 index 0000000..ed6e485 --- /dev/null +++ b/admin/store/audit_test.go @@ -0,0 +1,392 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "context" + "testing" + "time" +) + +func createTestUser(t *testing.T, st *Store) int64 { + t.Helper() + user, err := st.CreateUser(context.Background(), "testuser", "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234") + if err != nil { + t.Fatalf("CreateUser() error = %v", err) + } + return user.ID +} + +func createTestInstance(t *testing.T, st *Store) int64 { + t.Helper() + inst, err := st.CreateInstance(context.Background(), "test-proxy", "10.0.0.1:9090") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + return inst.ID +} + +func TestInsertAuditEntry_Success(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + instID := createTestInstance(t, st) + + err := st.InsertAuditEntry(ctx, userID, "instance.create", &instID, "Created instance test-proxy at 10.0.0.1:9090") + if err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } +} + +func TestInsertAuditEntry_NilInstanceID(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + err := st.InsertAuditEntry(ctx, userID, "user.login", nil, "User logged in") + if err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } +} + +func TestListAuditEntries_Empty_ReturnsEmptyPage(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + + page, err := st.ListAuditEntries(ctx, AuditFilter{Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 0 { + t.Errorf("Total = %d, want 0", page.Total) + } + if len(page.Items) != 0 { + t.Errorf("len(Items) = %d, want 0", len(page.Items)) + } + if page.Page != 1 { + t.Errorf("Page = %d, want 1", page.Page) + } +} + +func TestListAuditEntries_ReturnsEntriesOrderedByCreatedAtDesc(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + actions := []string{"user.login", "instance.create", "instance.delete"} + for _, action := range actions { + if err := st.InsertAuditEntry(ctx, userID, action, nil, "detail for "+action); err != nil { + t.Fatalf("InsertAuditEntry(%s) error = %v", action, err) + } + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 3 { + t.Fatalf("Total = %d, want 3", page.Total) + } + + // Most recent first. + if page.Items[0].Action != "instance.delete" { + t.Errorf("Items[0].Action = %q, want %q", page.Items[0].Action, "instance.delete") + } + if page.Items[2].Action != "user.login" { + t.Errorf("Items[2].Action = %q, want %q", page.Items[2].Action, "user.login") + } +} + +func TestListAuditEntries_JoinsUsername(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + if err := st.InsertAuditEntry(ctx, userID, "user.login", nil, "Login"); err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Items[0].Username != "testuser" { + t.Errorf("Username = %q, want %q", page.Items[0].Username, "testuser") + } +} + +func TestListAuditEntries_FilterByAction(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + for _, action := range []string{"user.login", "instance.create", "user.login"} { + if err := st.InsertAuditEntry(ctx, userID, action, nil, "detail"); err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{Action: "user.login", Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 2 { + t.Errorf("Total = %d, want 2", page.Total) + } + for _, item := range page.Items { + if item.Action != "user.login" { + t.Errorf("Action = %q, want %q", item.Action, "user.login") + } + } +} + +func TestListAuditEntries_FilterByUserID(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + user2, createErr := st.CreateUser(ctx, "other", "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234") + if createErr != nil { + t.Fatalf("CreateUser() error = %v", createErr) + } + + if insertErr := st.InsertAuditEntry(ctx, userID, "user.login", nil, "u1"); insertErr != nil { + t.Fatal(insertErr) + } + if insertErr := st.InsertAuditEntry(ctx, user2.ID, "user.login", nil, "u2"); insertErr != nil { + t.Fatal(insertErr) + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{UserID: &userID, Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestListAuditEntries_FilterByInstanceID(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + instID := createTestInstance(t, st) + + if err := st.InsertAuditEntry(ctx, userID, "instance.create", &instID, "with instance"); err != nil { + t.Fatal(err) + } + if err := st.InsertAuditEntry(ctx, userID, "user.login", nil, "without instance"); err != nil { + t.Fatal(err) + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{InstanceID: &instID, Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestListAuditEntries_FilterByDateRange(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + // Insert entries with explicit timestamps via raw SQL. + for _, ts := range []string{"2026-01-01 00:00:00", "2026-02-01 00:00:00", "2026-03-01 00:00:00"} { + _, err := st.db.ExecContext(ctx, + `INSERT INTO audit_log (user_id, action, detail, created_at) VALUES (?, 'user.login', ?, ?)`, + userID, "entry at "+ts, ts) + if err != nil { + t.Fatalf("inserting audit entry: %v", err) + } + } + + from := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC) + + page, err := st.ListAuditEntries(ctx, AuditFilter{From: &from, To: &to, Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestListAuditEntries_FullTextSearch(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + entries := []struct{ action, detail string }{ + {"instance.create", "Created instance production-proxy at 10.0.0.1:9090"}, + {"instance.create", "Created instance staging-proxy at 10.0.0.2:9090"}, + {"user.login", "User logged in from 192.168.1.1"}, + } + for _, e := range entries { + if err := st.InsertAuditEntry(ctx, userID, e.action, nil, e.detail); err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{Query: "production", Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } + if page.Total > 0 && page.Items[0].Detail != "Created instance production-proxy at 10.0.0.1:9090" { + t.Errorf("Detail = %q, unexpected", page.Items[0].Detail) + } +} + +func TestListAuditEntries_Pagination(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + for i := 0; i < 5; i++ { + if err := st.InsertAuditEntry(ctx, userID, "user.login", nil, "entry"); err != nil { + t.Fatal(err) + } + } + + page1, err := st.ListAuditEntries(ctx, AuditFilter{Page: 1, PerPage: 2}) + if err != nil { + t.Fatalf("page 1: %v", err) + } + if len(page1.Items) != 2 { + t.Errorf("page 1 len = %d, want 2", len(page1.Items)) + } + if page1.Total != 5 { + t.Errorf("page 1 Total = %d, want 5", page1.Total) + } + + page3, err := st.ListAuditEntries(ctx, AuditFilter{Page: 3, PerPage: 2}) + if err != nil { + t.Fatalf("page 3: %v", err) + } + if len(page3.Items) != 1 { + t.Errorf("page 3 len = %d, want 1", len(page3.Items)) + } +} + +func TestListAuditEntries_CombinedFilters(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + instID := createTestInstance(t, st) + + if err := st.InsertAuditEntry(ctx, userID, "instance.create", &instID, "Created production-proxy"); err != nil { + t.Fatal(err) + } + if err := st.InsertAuditEntry(ctx, userID, "instance.delete", &instID, "Deleted production-proxy"); err != nil { + t.Fatal(err) + } + if err := st.InsertAuditEntry(ctx, userID, "user.login", nil, "Login"); err != nil { + t.Fatal(err) + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{ + Action: "instance.create", + InstanceID: &instID, + Query: "production", + Page: 1, + PerPage: 20, + }) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestDeleteAuditEntriesBefore_DeletesOldEntries(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + // Insert old and new entries via raw SQL for controlled timestamps. + _, insertErr := st.db.ExecContext(ctx, + `INSERT INTO audit_log (user_id, action, detail, created_at) VALUES (?, 'user.login', 'old', '2025-01-01 00:00:00')`, + userID) + if insertErr != nil { + t.Fatal(insertErr) + } + if recentErr := st.InsertAuditEntry(ctx, userID, "user.login", nil, "recent"); recentErr != nil { + t.Fatal(recentErr) + } + + cutoff := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + deleted, err := st.DeleteAuditEntriesBefore(ctx, cutoff) + if err != nil { + t.Fatalf("DeleteAuditEntriesBefore() error = %v", err) + } + if deleted != 1 { + t.Errorf("deleted = %d, want 1", deleted) + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{Page: 1, PerPage: 20}) + if err != nil { + t.Fatal(err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestFtsQuote_EscapesSpecialCharacters(t *testing.T) { + t.Parallel() + tests := []struct { + input string + want string + }{ + {"simple", `"simple"`}, + {"proxy-1", `"proxy-1"`}, + {"", `""`}, + {`has "quotes"`, `"has ""quotes"""`}, + {`double "" already`, `"double """" already"`}, + {"AND OR NOT", `"AND OR NOT"`}, + {"prefix*", `"prefix*"`}, + {"NEAR/2", `"NEAR/2"`}, + {`back\slash`, `"back\slash"`}, + } + for _, tt := range tests { + got := ftsQuote(tt.input) + if got != tt.want { + t.Errorf("ftsQuote(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestDeleteAuditEntriesBefore_NothingToDelete(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + cutoff := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + deleted, err := st.DeleteAuditEntriesBefore(context.Background(), cutoff) + if err != nil { + t.Fatalf("DeleteAuditEntriesBefore() error = %v", err) + } + if deleted != 0 { + t.Errorf("deleted = %d, want 0", deleted) + } +} diff --git a/admin/store/migrations.go b/admin/store/migrations.go index 5d8fc7e..2c2caf6 100644 --- a/admin/store/migrations.go +++ b/admin/store/migrations.go @@ -63,6 +63,30 @@ CREATE INDEX idx_audit_log_action ON audit_log(action); CREATE INDEX idx_sessions_token ON sessions(token); CREATE INDEX idx_sessions_user_id ON sessions(user_id); CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); +`, + }, + { + Version: 2, + Description: "add FTS5 index for audit log full-text search", + SQL: ` +CREATE VIRTUAL TABLE audit_log_fts USING fts5( + detail, + content='audit_log', + content_rowid='id' +); + +CREATE TRIGGER audit_log_ai AFTER INSERT ON audit_log BEGIN + INSERT INTO audit_log_fts(rowid, detail) VALUES (new.id, new.detail); +END; + +CREATE TRIGGER audit_log_ad AFTER DELETE ON audit_log BEGIN + INSERT INTO audit_log_fts(audit_log_fts, rowid, detail) VALUES('delete', old.id, old.detail); +END; + +CREATE TRIGGER audit_log_au AFTER UPDATE ON audit_log BEGIN + INSERT INTO audit_log_fts(audit_log_fts, rowid, detail) VALUES('delete', old.id, old.detail); + INSERT INTO audit_log_fts(rowid, detail) VALUES (new.id, new.detail); +END; `, }, } diff --git a/admin/store/store_test.go b/admin/store/store_test.go index 31a1e07..2b2bc9e 100644 --- a/admin/store/store_test.go +++ b/admin/store/store_test.go @@ -48,7 +48,7 @@ func TestOpen_CreatesAllTables(t *testing.T) { t.Fatalf("iterating rows: %v", err) } - expected := []string{"audit_log", "instances", "schema_migrations", "sessions", "users"} + expected := []string{"audit_log", "audit_log_fts", "audit_log_fts_config", "audit_log_fts_data", "audit_log_fts_docsize", "audit_log_fts_idx", "instances", "schema_migrations", "sessions", "users"} sort.Strings(tables) if len(tables) != len(expected) { @@ -85,8 +85,8 @@ func TestOpen_MigrationIdempotent(t *testing.T) { if err := st2.DB().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil { t.Fatalf("counting migrations: %v", err) } - if count != 1 { - t.Errorf("migration count = %d, want 1", count) + if count != len(migrations) { + t.Errorf("migration count = %d, want %d", count, len(migrations)) } } @@ -117,8 +117,8 @@ func TestOpen_SchemaMigrations_TracksVersion(t *testing.T) { if err := st.DB().QueryRowContext(context.Background(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil { t.Fatalf("querying schema version: %v", err) } - if version != 1 { - t.Errorf("schema version = %d, want 1", version) + if version != len(migrations) { + t.Errorf("schema version = %d, want %d", version, len(migrations)) } } diff --git a/admin/ui/src/assets/variables.css b/admin/ui/src/assets/variables.css index 9b55bf6..2d4f22f 100644 --- a/admin/ui/src/assets/variables.css +++ b/admin/ui/src/assets/variables.css @@ -30,6 +30,8 @@ --color-error-bg: #fef2f2; --color-error-hover: #b91c1c; --color-error-border: #fecaca; + --color-purple: #7c3aed; + --color-purple-light: #f3e8ff; /* Colors — Border */ --color-border: #e5e7eb; diff --git a/admin/ui/src/composables/useAuditLog.js b/admin/ui/src/composables/useAuditLog.js new file mode 100644 index 0000000..f4c2234 --- /dev/null +++ b/admin/ui/src/composables/useAuditLog.js @@ -0,0 +1,81 @@ +import { ref, computed, watch } from 'vue'; +import { buildAuditQueryString, totalPages } from '../utils/audit.js'; + +export function useAuditLog(api) { + const items = ref([]); + const total = ref(0); + const loading = ref(false); + const error = ref(null); + + const filters = ref({ + q: '', + action: '', + from: '', + to: '', + page: 1, + perPage: 20, + }); + + let fetchId = 0; + + async function fetch() { + const id = ++fetchId; + loading.value = true; + error.value = null; + try { + const qs = buildAuditQueryString(filters.value); + const data = await api.get(`/api/audit${qs}`); + if (id !== fetchId) return; + items.value = data.items; + total.value = data.total; + } catch (err) { + if (id !== fetchId) return; + error.value = err.message || 'Failed to load audit log'; + items.value = []; + total.value = 0; + } finally { + if (id === fetchId) loading.value = false; + } + } + + function setFilter(key, value) { + filters.value = { ...filters.value, [key]: value, page: 1 }; + } + + function setPage(page) { + const max = totalPages(total.value, filters.value.perPage); + filters.value = { + ...filters.value, + page: Math.max(1, Math.min(page, max)), + }; + } + + function nextPage() { + setPage(filters.value.page + 1); + } + + function prevPage() { + setPage(filters.value.page - 1); + } + + const pageCount = computed(() => + totalPages(total.value, filters.value.perPage), + ); + + // Refetch when filters change. + watch(filters, () => fetch(), { deep: true }); + + return { + items, + total, + loading, + error, + filters, + fetch, + setFilter, + setPage, + nextPage, + prevPage, + pageCount, + }; +} diff --git a/admin/ui/src/composables/useAuditLog.test.js b/admin/ui/src/composables/useAuditLog.test.js new file mode 100644 index 0000000..c673eda --- /dev/null +++ b/admin/ui/src/composables/useAuditLog.test.js @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { nextTick } from 'vue'; +import { withSetup } from '../utils/test-utils.js'; +import { useAuditLog } from './useAuditLog.js'; + +function makeApi(response) { + return { + get: vi + .fn() + .mockResolvedValue(response ?? { items: [], total: 0, page: 1 }), + }; +} + +function makeMockData(count = 3) { + return { + items: Array.from({ length: count }, (_, i) => ({ + id: i + 1, + user_id: 1, + user: 'admin', + action: 'instance.create', + instance_id: i + 10, + detail: `Created instance ${i + 1}`, + created_at: '2026-03-09T12:00:00Z', + })), + total: count, + page: 1, + }; +} + +describe('useAuditLog', () => { + let api; + + beforeEach(() => { + api = makeApi(makeMockData()); + }); + + it('initializes with empty state', () => { + const { result } = withSetup(() => useAuditLog(api)); + expect(result.items.value).toEqual([]); + expect(result.total.value).toBe(0); + expect(result.loading.value).toBe(false); + expect(result.error.value).toBeNull(); + }); + + it('fetches audit entries from API', async () => { + const { result } = withSetup(() => useAuditLog(api)); + await result.fetch(); + expect(api.get).toHaveBeenCalledWith('/api/audit'); + expect(result.items.value).toHaveLength(3); + expect(result.total.value).toBe(3); + }); + + it('sets loading state during fetch', async () => { + let resolvePromise; + api.get = vi.fn( + () => + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + const { result } = withSetup(() => useAuditLog(api)); + + const fetchPromise = result.fetch(); + expect(result.loading.value).toBe(true); + + resolvePromise({ items: [], total: 0, page: 1 }); + await fetchPromise; + expect(result.loading.value).toBe(false); + }); + + it('sets error on API failure', async () => { + api.get = vi.fn().mockRejectedValue(new Error('Network error')); + const { result } = withSetup(() => useAuditLog(api)); + + await result.fetch(); + expect(result.error.value).toBe('Network error'); + expect(result.items.value).toEqual([]); + }); + + it('builds query string from filters', async () => { + const { result } = withSetup(() => useAuditLog(api)); + result.setFilter('action', 'user.login'); + await nextTick(); + // Wait for the watcher-triggered fetch + await vi.waitFor(() => { + expect(api.get).toHaveBeenCalledWith( + expect.stringContaining('action=user.login'), + ); + }); + }); + + it('resets page to 1 when setting a filter', () => { + const { result } = withSetup(() => useAuditLog(api)); + result.filters.value.page = 3; + result.setFilter('q', 'test'); + expect(result.filters.value.page).toBe(1); + }); + + it('navigates pages with nextPage/prevPage', async () => { + api = makeApi({ items: [], total: 60, page: 1 }); + const { result } = withSetup(() => useAuditLog(api)); + await result.fetch(); + + result.nextPage(); + expect(result.filters.value.page).toBe(2); + + result.nextPage(); + expect(result.filters.value.page).toBe(3); + + result.prevPage(); + expect(result.filters.value.page).toBe(2); + }); + + it('clamps page within valid range', async () => { + api = makeApi({ items: [], total: 40, page: 1 }); + const { result } = withSetup(() => useAuditLog(api)); + await result.fetch(); + + result.prevPage(); + expect(result.filters.value.page).toBe(1); + + result.setPage(999); + expect(result.filters.value.page).toBe(2); // total 40, perPage 20 = 2 pages + }); + + it('computes pageCount correctly', async () => { + api = makeApi({ items: [], total: 45, page: 1 }); + const { result } = withSetup(() => useAuditLog(api)); + await result.fetch(); + expect(result.pageCount.value).toBe(3); + }); + + it('discards stale responses from overlapping fetches', async () => { + let resolveFirst; + let resolveSecond; + api.get = vi + .fn() + .mockImplementationOnce( + () => + new Promise((r) => { + resolveFirst = r; + }), + ) + .mockImplementationOnce( + () => + new Promise((r) => { + resolveSecond = r; + }), + ); + const { result } = withSetup(() => useAuditLog(api)); + + const first = result.fetch(); + const second = result.fetch(); + + // Resolve second (newer) first + resolveSecond({ items: [{ id: 2 }], total: 1, page: 1 }); + await second; + + // Resolve first (stale) after + resolveFirst({ items: [{ id: 1 }], total: 1, page: 1 }); + await first; + + // Should keep the second (newer) result + expect(result.items.value).toEqual([{ id: 2 }]); + }); + + it('refetches automatically when filters change', async () => { + const { result } = withSetup(() => useAuditLog(api)); + // Initial fetch + await result.fetch(); + api.get.mockClear(); + + result.setFilter('q', 'proxy'); + await nextTick(); + await vi.waitFor(() => { + expect(api.get).toHaveBeenCalled(); + }); + }); +}); diff --git a/admin/ui/src/utils/audit.js b/admin/ui/src/utils/audit.js new file mode 100644 index 0000000..026d9d9 --- /dev/null +++ b/admin/ui/src/utils/audit.js @@ -0,0 +1,104 @@ +// Action identifiers must match the backend constants in admin/api/audit_actions.go. +const ACTION_LABELS = { + 'instance.create': 'Instance created', + 'instance.update': 'Instance updated', + 'instance.delete': 'Instance deleted', + 'user.login': 'User logged in', + 'user.logout': 'User logged out', + 'user.password_change': 'Password changed', +}; + +const ACTION_OPTIONS = [ + { value: '', label: 'All actions' }, + { value: 'instance.create', label: 'Instance created' }, + { value: 'instance.update', label: 'Instance updated' }, + { value: 'instance.delete', label: 'Instance deleted' }, + { value: 'user.login', label: 'User logged in' }, + { value: 'user.logout', label: 'User logged out' }, + { value: 'user.password_change', label: 'Password changed' }, +]; + +export function getActionLabel(action) { + return ACTION_LABELS[action] || action; +} + +export function getActionOptions() { + return ACTION_OPTIONS; +} + +export function formatAuditTimestamp(isoString) { + if (!isoString) return ''; + const d = new Date(isoString); + if (isNaN(d.getTime())) return ''; + + const now = new Date(); + const diffMs = now - d; + const diffSecs = Math.floor(diffMs / 1000); + + if (diffSecs < 60) return 'just now'; + if (diffSecs < 3600) return `${Math.floor(diffSecs / 60)}m ago`; + + const isToday = + d.getDate() === now.getDate() && + d.getMonth() === now.getMonth() && + d.getFullYear() === now.getFullYear(); + + const time = d.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + + if (isToday) return `Today ${time}`; + + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const isYesterday = + d.getDate() === yesterday.getDate() && + d.getMonth() === yesterday.getMonth() && + d.getFullYear() === yesterday.getFullYear(); + + if (isYesterday) return `Yesterday ${time}`; + + return d.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + hour: '2-digit', + minute: '2-digit', + }); +} + +// Converts a YYYY-MM-DD date string to an RFC 3339 UTC timestamp +// representing the start of that day in the user's local timezone. +export function startOfLocalDay(dateStr) { + if (!dateStr) return ''; + return new Date(dateStr + 'T00:00:00').toISOString(); +} + +// Converts a YYYY-MM-DD date string to an RFC 3339 UTC timestamp +// representing the end of that day in the user's local timezone. +export function endOfLocalDay(dateStr) { + if (!dateStr) return ''; + return new Date(dateStr + 'T23:59:59').toISOString(); +} + +export function buildAuditQueryString(filters) { + const params = new URLSearchParams(); + + if (filters.q) params.set('q', filters.q); + if (filters.action) params.set('action', filters.action); + if (filters.from) params.set('from', startOfLocalDay(filters.from)); + if (filters.to) params.set('to', endOfLocalDay(filters.to)); + if (filters.page && filters.page > 1) + params.set('page', String(filters.page)); + if (filters.perPage && filters.perPage !== 20) + params.set('per_page', String(filters.perPage)); + + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + +export function totalPages(total, perPage) { + if (total <= 0 || perPage <= 0) return 1; + return Math.ceil(total / perPage); +} diff --git a/admin/ui/src/utils/audit.test.js b/admin/ui/src/utils/audit.test.js new file mode 100644 index 0000000..69e666e --- /dev/null +++ b/admin/ui/src/utils/audit.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + getActionLabel, + getActionOptions, + formatAuditTimestamp, + buildAuditQueryString, + totalPages, + startOfLocalDay, + endOfLocalDay, +} from './audit.js'; + +describe('getActionLabel', () => { + it('returns human-readable label for known actions', () => { + expect(getActionLabel('instance.create')).toBe('Instance created'); + expect(getActionLabel('user.login')).toBe('User logged in'); + expect(getActionLabel('user.password_change')).toBe('Password changed'); + }); + + it('returns raw action string for unknown actions', () => { + expect(getActionLabel('some.unknown')).toBe('some.unknown'); + }); +}); + +describe('getActionOptions', () => { + it('returns array with "All actions" as first option', () => { + const options = getActionOptions(); + expect(options[0]).toEqual({ value: '', label: 'All actions' }); + expect(options.length).toBeGreaterThan(1); + }); + + it('includes all known action types', () => { + const options = getActionOptions(); + const values = options.map((o) => o.value); + expect(values).toContain('instance.create'); + expect(values).toContain('instance.delete'); + expect(values).toContain('user.login'); + expect(values).toContain('user.logout'); + }); +}); + +describe('formatAuditTimestamp', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-09T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns empty string for falsy input', () => { + expect(formatAuditTimestamp(null)).toBe(''); + expect(formatAuditTimestamp('')).toBe(''); + expect(formatAuditTimestamp(undefined)).toBe(''); + }); + + it('returns empty string for invalid date', () => { + expect(formatAuditTimestamp('not-a-date')).toBe(''); + }); + + it('returns "just now" for timestamps less than 60s ago', () => { + expect(formatAuditTimestamp('2026-03-09T11:59:30Z')).toBe('just now'); + }); + + it('returns minutes ago for timestamps less than 1h ago', () => { + expect(formatAuditTimestamp('2026-03-09T11:45:00Z')).toBe('15m ago'); + }); + + it('returns "Today" with time for older timestamps today', () => { + const result = formatAuditTimestamp('2026-03-09T08:30:00Z'); + expect(result).toMatch(/^Today /); + }); + + it('returns "Yesterday" with time for timestamps from yesterday', () => { + const result = formatAuditTimestamp('2026-03-08T14:00:00Z'); + expect(result).toMatch(/^Yesterday /); + }); + + it('returns formatted date for older timestamps', () => { + const result = formatAuditTimestamp('2026-03-01T10:00:00Z'); + expect(result).toBeTruthy(); + expect(result).not.toMatch(/^Today/); + expect(result).not.toMatch(/^Yesterday/); + }); +}); + +describe('buildAuditQueryString', () => { + it('returns empty string when no filters are set', () => { + expect(buildAuditQueryString({})).toBe(''); + }); + + it('includes search query', () => { + expect(buildAuditQueryString({ q: 'test' })).toBe('?q=test'); + }); + + it('includes action filter', () => { + expect(buildAuditQueryString({ action: 'user.login' })).toBe( + '?action=user.login', + ); + }); + + it('converts date-only from/to into RFC 3339 UTC via local timezone', () => { + const qs = buildAuditQueryString({ + from: '2026-03-01', + to: '2026-03-09', + }); + expect(qs).toContain('from='); + expect(qs).toContain('to='); + // The serialized values should be valid ISO timestamps, not date-only + const params = new URLSearchParams(qs.slice(1)); + expect(params.get('from')).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/, + ); + expect(params.get('to')).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/, + ); + // The UTC timestamps must represent local midnight and local end-of-day + expect(new Date(params.get('from')).getTime()).toBe( + new Date('2026-03-01T00:00:00').getTime(), + ); + expect(new Date(params.get('to')).getTime()).toBe( + new Date('2026-03-09T23:59:59').getTime(), + ); + }); + + it('includes page only when > 1', () => { + expect(buildAuditQueryString({ page: 1 })).toBe(''); + expect(buildAuditQueryString({ page: 3 })).toBe('?page=3'); + }); + + it('includes per_page only when not default', () => { + expect(buildAuditQueryString({ perPage: 20 })).toBe(''); + expect(buildAuditQueryString({ perPage: 50 })).toBe('?per_page=50'); + }); + + it('combines multiple filters', () => { + const qs = buildAuditQueryString({ + q: 'proxy', + action: 'instance.create', + page: 2, + }); + expect(qs).toContain('q=proxy'); + expect(qs).toContain('action=instance.create'); + expect(qs).toContain('page=2'); + }); +}); + +describe('totalPages', () => { + it('returns 1 for zero or negative total', () => { + expect(totalPages(0, 20)).toBe(1); + expect(totalPages(-5, 20)).toBe(1); + }); + + it('returns 1 for zero or negative perPage', () => { + expect(totalPages(100, 0)).toBe(1); + expect(totalPages(100, -1)).toBe(1); + }); + + it('computes correct page count', () => { + expect(totalPages(20, 20)).toBe(1); + expect(totalPages(21, 20)).toBe(2); + expect(totalPages(100, 20)).toBe(5); + expect(totalPages(1, 20)).toBe(1); + }); +}); + +describe('startOfLocalDay', () => { + it('returns empty string for falsy input', () => { + expect(startOfLocalDay('')).toBe(''); + expect(startOfLocalDay(null)).toBe(''); + expect(startOfLocalDay(undefined)).toBe(''); + }); + + it('returns an ISO string based on local midnight', () => { + const result = startOfLocalDay('2026-03-09'); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/); + // Parse back and verify it represents local midnight + const d = new Date(result); + const local = new Date('2026-03-09T00:00:00'); + expect(d.getTime()).toBe(local.getTime()); + }); +}); + +describe('endOfLocalDay', () => { + it('returns empty string for falsy input', () => { + expect(endOfLocalDay('')).toBe(''); + expect(endOfLocalDay(null)).toBe(''); + expect(endOfLocalDay(undefined)).toBe(''); + }); + + it('returns an ISO string based on local end of day', () => { + const result = endOfLocalDay('2026-03-09'); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/); + const d = new Date(result); + const local = new Date('2026-03-09T23:59:59'); + expect(d.getTime()).toBe(local.getTime()); + }); +}); diff --git a/admin/ui/src/views/AuditLogView.vue b/admin/ui/src/views/AuditLogView.vue index 923247e..ffa3b86 100644 --- a/admin/ui/src/views/AuditLogView.vue +++ b/admin/ui/src/views/AuditLogView.vue @@ -3,8 +3,82 @@

Audit Log

+ + +
+
+ + +
+ + + +
+
- + +
+ Failed to load audit log: {{ audit.error.value }} +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
TimeUserActionDetail
+ + {{ entry.user }} + + {{ getActionLabel(entry.action) }} + + + {{ entry.detail }} +
+
+ + +
+ + {{ paginationLabel }} + +
+ + + {{ audit.filters.value.page }} of {{ audit.pageCount.value }} + + +
+
+