From b9b609678b3f72c9c22e97dd3da8a135a6259181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Thu, 9 Apr 2026 19:54:00 +0200 Subject: [PATCH 1/2] feat(admin): add audit log backend with FTS5 search and retention cleanup Add audit logging to the admin portal: - Store layer: InsertAuditEntry, ListAuditEntries (paginated, filtered by user/action/instance/date range, FTS5 full-text search on detail), DeleteAuditEntriesBefore for retention cleanup - Migration v2: FTS5 virtual table with insert/delete/update triggers for automatic index sync - REST API: GET /api/audit with query params (user, action, instance_id, from, to, q, page, per_page) capped at 100 per page - Write-on-action: instance create/update/delete, login, logout, and password change all produce audit entries with contextual detail - Retention cleanup: background goroutine runs immediately on startup then daily, configurable via audit.retention_days (default 90, 0 to keep forever) --- admin/api/audit.go | 117 +++++++++ admin/api/audit_actions.go | 14 ++ admin/api/audit_test.go | 241 ++++++++++++++++++ admin/api/auth.go | 25 +- admin/api/auth_test.go | 2 +- admin/api/instance.go | 26 ++ admin/cmd/chaperone-admin/main.go | 48 +++- admin/server.go | 6 +- admin/store/audit.go | 162 ++++++++++++ admin/store/audit_test.go | 392 ++++++++++++++++++++++++++++++ admin/store/migrations.go | 24 ++ admin/store/store_test.go | 10 +- 12 files changed, 1053 insertions(+), 14 deletions(-) create mode 100644 admin/api/audit.go create mode 100644 admin/api/audit_actions.go create mode 100644 admin/api/audit_test.go create mode 100644 admin/store/audit.go create mode 100644 admin/store/audit_test.go 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..e82b59d --- /dev/null +++ b/admin/api/audit_actions.go @@ -0,0 +1,14 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +// Audit action constants logged for each portal operation. +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)) } } From ab1bcc9042b2d2f4015b1527c5499a64c7c8a9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Fri, 10 Apr 2026 15:28:00 +0200 Subject: [PATCH 2/2] feat(admin): add audit log UI with filters, pagination, and search Filterable, paginated audit log table with debounced FTS5 search, action type dropdown, and date range pickers. Extracts logic into pure functions (utils/audit.js) and composable (useAuditLog.js). - Date filters use local-timezone boundaries, not UTC - Stale response guard discards out-of-order fetches - 25 utility + 11 composable tests (165 total passing) --- admin/api/audit_actions.go | 1 + admin/ui/src/assets/variables.css | 2 + admin/ui/src/composables/useAuditLog.js | 81 ++++ admin/ui/src/composables/useAuditLog.test.js | 179 +++++++ admin/ui/src/utils/audit.js | 104 ++++ admin/ui/src/utils/audit.test.js | 198 ++++++++ admin/ui/src/views/AuditLogView.vue | 486 ++++++++++++++++++- 7 files changed, 1050 insertions(+), 1 deletion(-) create mode 100644 admin/ui/src/composables/useAuditLog.js create mode 100644 admin/ui/src/composables/useAuditLog.test.js create mode 100644 admin/ui/src/utils/audit.js create mode 100644 admin/ui/src/utils/audit.test.js diff --git a/admin/api/audit_actions.go b/admin/api/audit_actions.go index e82b59d..0275ecd 100644 --- a/admin/api/audit_actions.go +++ b/admin/api/audit_actions.go @@ -4,6 +4,7 @@ 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" 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 }} + + +
+
+