From fb4cc37a4a462b39cebdc0949059c86bc66d6b5f Mon Sep 17 00:00:00 2001 From: pascal Date: Wed, 11 Feb 2026 14:41:52 +0100 Subject: [PATCH 1/7] add pagination for access logs --- .../reverseproxy/accesslogs/interface.go | 2 +- .../reverseproxy/accesslogs/manager/api.go | 25 ++++++++- .../accesslogs/manager/manager.go | 14 ++--- management/server/store/sql_store.go | 23 ++++++-- management/server/store/store.go | 2 +- shared/management/http/api/openapi.yml | 54 +++++++++++++++++-- shared/management/http/api/types.gen.go | 27 ++++++++++ 7 files changed, 126 insertions(+), 21 deletions(-) diff --git a/management/internals/modules/reverseproxy/accesslogs/interface.go b/management/internals/modules/reverseproxy/accesslogs/interface.go index 3b92b04e0..af5b0c806 100644 --- a/management/internals/modules/reverseproxy/accesslogs/interface.go +++ b/management/internals/modules/reverseproxy/accesslogs/interface.go @@ -6,5 +6,5 @@ import ( type Manager interface { SaveAccessLog(ctx context.Context, proxyLog *AccessLogEntry) error - GetAllAccessLogs(ctx context.Context, accountID, userID string) ([]*AccessLogEntry, error) + GetAllAccessLogs(ctx context.Context, accountID, userID string, filter AccessLogFilter) ([]*AccessLogEntry, int64, error) } diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/api.go b/management/internals/modules/reverseproxy/accesslogs/manager/api.go index a0e5ad84c..8c3bbdc39 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/api.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/api.go @@ -30,7 +30,11 @@ func (h *handler) getAccessLogs(w http.ResponseWriter, r *http.Request) { return } - logs, err := h.manager.GetAllAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId) + // Parse pagination parameters from request + var filter accesslogs.AccessLogFilter + filter.ParseFromRequest(r) + + logs, totalCount, err := h.manager.GetAllAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId, filter) if err != nil { util.WriteError(r.Context(), err, w) return @@ -41,5 +45,22 @@ func (h *handler) getAccessLogs(w http.ResponseWriter, r *http.Request) { apiLogs = append(apiLogs, *log.ToAPIResponse()) } - util.WriteJSONObject(r.Context(), w, apiLogs) + // Return paginated response + response := &api.ProxyAccessLogsResponse{ + Data: apiLogs, + Page: filter.Page, + PageSize: filter.PageSize, + TotalRecords: int(totalCount), + TotalPages: getTotalPageCount(int(totalCount), filter.PageSize), + } + + util.WriteJSONObject(r.Context(), w, response) +} + +// getTotalPageCount calculates the total number of pages +func getTotalPageCount(totalCount, pageSize int) int { + if pageSize <= 0 { + return 0 + } + return (totalCount + pageSize - 1) / pageSize } diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go index 319e401be..087069cc0 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go @@ -55,20 +55,20 @@ func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.Ac return nil } -// GetAllAccessLogs retrieves all access logs for an account -func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string) ([]*accesslogs.AccessLogEntry, error) { +// GetAllAccessLogs retrieves access logs for an account with pagination +func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) if err != nil { - return nil, status.NewPermissionValidationError(err) + return nil, 0, status.NewPermissionValidationError(err) } if !ok { - return nil, status.NewPermissionDeniedError() + return nil, 0, status.NewPermissionDeniedError() } - logs, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID) + logs, totalCount, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID, filter) if err != nil { - return nil, err + return nil, 0, err } - return logs, nil + return logs, totalCount, nil } diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 048cd8962..c432ce7e3 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -5061,14 +5061,27 @@ func (s *SqlStore) CreateAccessLog(ctx context.Context, logEntry *accesslogs.Acc return nil } -// GetAccountAccessLogs retrieves all access logs for a given account -func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*accesslogs.AccessLogEntry, error) { +// GetAccountAccessLogs retrieves access logs for a given account with pagination +func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { var logs []*accesslogs.AccessLogEntry + var totalCount int64 + // Count total records for pagination metadata + countQuery := s.db.WithContext(ctx). + Model(&accesslogs.AccessLogEntry{}). + Where(accountIDCondition, accountID) + + if err := countQuery.Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count access logs: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count access logs") + } + + // Query with pagination query := s.db.WithContext(ctx). Where(accountIDCondition, accountID). Order("timestamp DESC"). - Limit(1000) + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) if lockStrength != LockingStrengthNone { query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) @@ -5077,10 +5090,10 @@ func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength Lockin result := query.Find(&logs) if result.Error != nil { log.WithContext(ctx).Errorf("failed to get access logs from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get access logs from store") + return nil, 0, status.Errorf(status.Internal, "failed to get access logs from store") } - return logs, nil + return logs, totalCount, nil } func (s *SqlStore) GetReverseProxyTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*reverseproxy.Target, error) { diff --git a/management/server/store/store.go b/management/server/store/store.go index 94e5a50b9..6afad460f 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -266,7 +266,7 @@ type Store interface { DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error - GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*accesslogs.AccessLogEntry, error) + GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) GetReverseProxyTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*reverseproxy.Target, error) } diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 0c9a168d4..644617f4f 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -2773,6 +2773,36 @@ components: - path - duration_ms - status_code + ProxyAccessLogsResponse: + type: object + properties: + data: + type: array + description: List of proxy access log entries + items: + $ref: "#/components/schemas/ProxyAccessLog" + page: + type: integer + description: Current page number + example: 1 + page_size: + type: integer + description: Number of items per page + example: 50 + total_records: + type: integer + description: Total number of log records available + example: 523 + total_pages: + type: integer + description: Total number of pages available + example: 11 + required: + - data + - page + - page_size + - total_records + - total_pages IdentityProviderType: type: string description: Type of identity provider @@ -6341,17 +6371,31 @@ paths: /api/events/proxy: get: summary: List all Reverse Proxy Access Logs - description: Returns a list of all reverse proxy access log entries + description: Returns a paginated list of all reverse proxy access log entries tags: [ Events ] + parameters: + - in: query + name: page + schema: + type: integer + default: 1 + minimum: 1 + description: Page number for pagination (1-indexed) + - in: query + name: page_size + schema: + type: integer + default: 50 + minimum: 1 + maximum: 100 + description: Number of items per page (max 100) responses: "200": - description: List of reverse proxy access logs + description: Paginated list of reverse proxy access logs content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/ProxyAccessLog" + $ref: "#/components/schemas/ProxyAccessLogsResponse" '401': "$ref": "#/components/responses/requires_authentication" '403': diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 53a6f0070..df59b129a 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1950,6 +1950,24 @@ type ProxyAccessLog struct { UserId *string `json:"user_id,omitempty"` } +// ProxyAccessLogsResponse defines model for ProxyAccessLogsResponse. +type ProxyAccessLogsResponse struct { + // Data List of proxy access log entries + Data []ProxyAccessLog `json:"data"` + + // Page Current page number + Page int `json:"page"` + + // PageSize Number of items per page + PageSize int `json:"page_size"` + + // TotalPages Total number of pages available + TotalPages int `json:"total_pages"` + + // TotalRecords Total number of log records available + TotalRecords int `json:"total_records"` +} + // ProxyCluster A proxy cluster represents a group of proxy nodes serving the same address type ProxyCluster struct { // Address Cluster address used for CNAME targets @@ -2655,6 +2673,15 @@ type GetApiEventsNetworkTrafficParamsConnectionType string // GetApiEventsNetworkTrafficParamsDirection defines parameters for GetApiEventsNetworkTraffic. type GetApiEventsNetworkTrafficParamsDirection string +// GetApiEventsProxyParams defines parameters for GetApiEventsProxy. +type GetApiEventsProxyParams struct { + // Page Page number for pagination (1-indexed) + Page *int `form:"page,omitempty" json:"page,omitempty"` + + // PageSize Number of items per page (max 100) + PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"` +} + // GetApiGroupsParams defines parameters for GetApiGroups. type GetApiGroupsParams struct { // Name Filter groups by name (exact match) From bf48044e5c233b197e30c9c72d20fd33b5a1b84a Mon Sep 17 00:00:00 2001 From: pascal Date: Wed, 11 Feb 2026 14:52:44 +0100 Subject: [PATCH 2/7] push filter files --- .../modules/reverseproxy/accesslogs/filter.go | 53 ++++++ .../reverseproxy/accesslogs/filter_test.go | 161 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 management/internals/modules/reverseproxy/accesslogs/filter.go create mode 100644 management/internals/modules/reverseproxy/accesslogs/filter_test.go diff --git a/management/internals/modules/reverseproxy/accesslogs/filter.go b/management/internals/modules/reverseproxy/accesslogs/filter.go new file mode 100644 index 000000000..39b3fb677 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/filter.go @@ -0,0 +1,53 @@ +package accesslogs + +import ( + "net/http" + "strconv" +) + +const ( + // DefaultPageSize is the default number of records per page + DefaultPageSize = 50 + // MaxPageSize is the maximum number of records allowed per page + MaxPageSize = 100 +) + +// AccessLogFilter holds pagination and filtering parameters for access logs +type AccessLogFilter struct { + // Page is the current page number (1-indexed) + Page int + // PageSize is the number of records per page + PageSize int +} + +// ParseFromRequest parses pagination parameters from HTTP request query parameters +func (f *AccessLogFilter) ParseFromRequest(r *http.Request) { + // Parse page number (default: 1) + f.Page = 1 + if pageStr := r.URL.Query().Get("page"); pageStr != "" { + if page, err := strconv.Atoi(pageStr); err == nil && page > 0 { + f.Page = page + } + } + + // Parse page size (default: DefaultPageSize, max: MaxPageSize) + f.PageSize = DefaultPageSize + if pageSizeStr := r.URL.Query().Get("page_size"); pageSizeStr != "" { + if pageSize, err := strconv.Atoi(pageSizeStr); err == nil && pageSize > 0 { + f.PageSize = pageSize + if f.PageSize > MaxPageSize { + f.PageSize = MaxPageSize + } + } + } +} + +// GetOffset calculates the database offset for pagination +func (f *AccessLogFilter) GetOffset() int { + return (f.Page - 1) * f.PageSize +} + +// GetLimit returns the page size for database queries +func (f *AccessLogFilter) GetLimit() int { + return f.PageSize +} diff --git a/management/internals/modules/reverseproxy/accesslogs/filter_test.go b/management/internals/modules/reverseproxy/accesslogs/filter_test.go new file mode 100644 index 000000000..4ca4508bc --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/filter_test.go @@ -0,0 +1,161 @@ +package accesslogs + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccessLogFilter_ParseFromRequest(t *testing.T) { + tests := []struct { + name string + queryParams map[string]string + expectedPage int + expectedPageSize int + }{ + { + name: "default values when no params provided", + queryParams: map[string]string{}, + expectedPage: 1, + expectedPageSize: DefaultPageSize, + }, + { + name: "valid page and page_size", + queryParams: map[string]string{ + "page": "2", + "page_size": "25", + }, + expectedPage: 2, + expectedPageSize: 25, + }, + { + name: "page_size exceeds max, should cap at MaxPageSize", + queryParams: map[string]string{ + "page": "1", + "page_size": "200", + }, + expectedPage: 1, + expectedPageSize: MaxPageSize, + }, + { + name: "invalid page number, should use default", + queryParams: map[string]string{ + "page": "invalid", + "page_size": "10", + }, + expectedPage: 1, + expectedPageSize: 10, + }, + { + name: "invalid page_size, should use default", + queryParams: map[string]string{ + "page": "2", + "page_size": "invalid", + }, + expectedPage: 2, + expectedPageSize: DefaultPageSize, + }, + { + name: "zero page number, should use default", + queryParams: map[string]string{ + "page": "0", + "page_size": "10", + }, + expectedPage: 1, + expectedPageSize: 10, + }, + { + name: "negative page number, should use default", + queryParams: map[string]string{ + "page": "-1", + "page_size": "10", + }, + expectedPage: 1, + expectedPageSize: 10, + }, + { + name: "zero page_size, should use default", + queryParams: map[string]string{ + "page": "1", + "page_size": "0", + }, + expectedPage: 1, + expectedPageSize: DefaultPageSize, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + q := req.URL.Query() + for key, value := range tt.queryParams { + q.Set(key, value) + } + req.URL.RawQuery = q.Encode() + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expectedPage, filter.Page, "Page mismatch") + assert.Equal(t, tt.expectedPageSize, filter.PageSize, "PageSize mismatch") + }) + } +} + +func TestAccessLogFilter_GetOffset(t *testing.T) { + tests := []struct { + name string + page int + pageSize int + expectedOffset int + }{ + { + name: "first page", + page: 1, + pageSize: 50, + expectedOffset: 0, + }, + { + name: "second page", + page: 2, + pageSize: 50, + expectedOffset: 50, + }, + { + name: "third page with page size 25", + page: 3, + pageSize: 25, + expectedOffset: 50, + }, + { + name: "page 10 with page size 10", + page: 10, + pageSize: 10, + expectedOffset: 90, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := &AccessLogFilter{ + Page: tt.page, + PageSize: tt.pageSize, + } + + offset := filter.GetOffset() + assert.Equal(t, tt.expectedOffset, offset) + }) + } +} + +func TestAccessLogFilter_GetLimit(t *testing.T) { + filter := &AccessLogFilter{ + Page: 2, + PageSize: 25, + } + + limit := filter.GetLimit() + assert.Equal(t, 25, limit, "GetLimit should return PageSize") +} From d069145bd1c57df70a8e80123a6af28c637e4dbe Mon Sep 17 00:00:00 2001 From: pascal Date: Wed, 11 Feb 2026 16:23:52 +0100 Subject: [PATCH 3/7] add more filters --- .../modules/reverseproxy/accesslogs/filter.go | 71 +++++++++++++++++-- .../reverseproxy/accesslogs/interface.go | 2 +- .../reverseproxy/accesslogs/manager/api.go | 4 +- .../accesslogs/manager/manager.go | 40 ++++++++++- management/server/store/sql_store.go | 54 ++++++++++++-- shared/management/http/api/openapi.yml | 55 ++++++++++++++ 6 files changed, 208 insertions(+), 18 deletions(-) diff --git a/management/internals/modules/reverseproxy/accesslogs/filter.go b/management/internals/modules/reverseproxy/accesslogs/filter.go index 39b3fb677..5dccea235 100644 --- a/management/internals/modules/reverseproxy/accesslogs/filter.go +++ b/management/internals/modules/reverseproxy/accesslogs/filter.go @@ -3,6 +3,7 @@ package accesslogs import ( "net/http" "strconv" + "time" ) const ( @@ -18,21 +19,33 @@ type AccessLogFilter struct { Page int // PageSize is the number of records per page PageSize int + + // Filtering parameters + SourceIP *string // Filter by source IP address + Host *string // Filter by host header + Path *string // Filter by request path (supports LIKE pattern) + UserID *string // Filter by authenticated user ID + UserEmail *string // Filter by user email (requires user lookup) + UserName *string // Filter by user name (requires user lookup) + Method *string // Filter by HTTP method + StatusCode *int // Filter by HTTP status code + StartDate *time.Time // Filter by timestamp >= start_date + EndDate *time.Time // Filter by timestamp <= end_date } -// ParseFromRequest parses pagination parameters from HTTP request query parameters +// ParseFromRequest parses pagination and filter parameters from HTTP request query parameters func (f *AccessLogFilter) ParseFromRequest(r *http.Request) { - // Parse page number (default: 1) + queryParams := r.URL.Query() + f.Page = 1 - if pageStr := r.URL.Query().Get("page"); pageStr != "" { + if pageStr := queryParams.Get("page"); pageStr != "" { if page, err := strconv.Atoi(pageStr); err == nil && page > 0 { f.Page = page } } - // Parse page size (default: DefaultPageSize, max: MaxPageSize) f.PageSize = DefaultPageSize - if pageSizeStr := r.URL.Query().Get("page_size"); pageSizeStr != "" { + if pageSizeStr := queryParams.Get("page_size"); pageSizeStr != "" { if pageSize, err := strconv.Atoi(pageSizeStr); err == nil && pageSize > 0 { f.PageSize = pageSize if f.PageSize > MaxPageSize { @@ -40,6 +53,54 @@ func (f *AccessLogFilter) ParseFromRequest(r *http.Request) { } } } + + if sourceIP := queryParams.Get("source_ip"); sourceIP != "" { + f.SourceIP = &sourceIP + } + + if host := queryParams.Get("host"); host != "" { + f.Host = &host + } + + if path := queryParams.Get("path"); path != "" { + f.Path = &path + } + + if userID := queryParams.Get("user_id"); userID != "" { + f.UserID = &userID + } + + if userEmail := queryParams.Get("user_email"); userEmail != "" { + f.UserEmail = &userEmail + } + + if userName := queryParams.Get("user_name"); userName != "" { + f.UserName = &userName + } + + if method := queryParams.Get("method"); method != "" { + f.Method = &method + } + + if statusCodeStr := queryParams.Get("status_code"); statusCodeStr != "" { + if statusCode, err := strconv.Atoi(statusCodeStr); err == nil && statusCode > 0 { + f.StatusCode = &statusCode + } + } + + if startDate := queryParams.Get("start_date"); startDate != "" { + parsedStartDate, err := time.Parse(time.RFC3339, startDate) + if err == nil { + f.StartDate = &parsedStartDate + } + } + + if endDate := queryParams.Get("end_date"); endDate != "" { + parsedEndDate, err := time.Parse(time.RFC3339, endDate) + if err == nil { + f.EndDate = &parsedEndDate + } + } } // GetOffset calculates the database offset for pagination diff --git a/management/internals/modules/reverseproxy/accesslogs/interface.go b/management/internals/modules/reverseproxy/accesslogs/interface.go index af5b0c806..1c51a8a7d 100644 --- a/management/internals/modules/reverseproxy/accesslogs/interface.go +++ b/management/internals/modules/reverseproxy/accesslogs/interface.go @@ -6,5 +6,5 @@ import ( type Manager interface { SaveAccessLog(ctx context.Context, proxyLog *AccessLogEntry) error - GetAllAccessLogs(ctx context.Context, accountID, userID string, filter AccessLogFilter) ([]*AccessLogEntry, int64, error) + GetAllAccessLogs(ctx context.Context, accountID, userID string, filter *AccessLogFilter) ([]*AccessLogEntry, int64, error) } diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/api.go b/management/internals/modules/reverseproxy/accesslogs/manager/api.go index 8c3bbdc39..1e1414ca5 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/api.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/api.go @@ -30,11 +30,10 @@ func (h *handler) getAccessLogs(w http.ResponseWriter, r *http.Request) { return } - // Parse pagination parameters from request var filter accesslogs.AccessLogFilter filter.ParseFromRequest(r) - logs, totalCount, err := h.manager.GetAllAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId, filter) + logs, totalCount, err := h.manager.GetAllAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId, &filter) if err != nil { util.WriteError(r.Context(), err, w) return @@ -45,7 +44,6 @@ func (h *handler) getAccessLogs(w http.ResponseWriter, r *http.Request) { apiLogs = append(apiLogs, *log.ToAPIResponse()) } - // Return paginated response response := &api.ProxyAccessLogsResponse{ Data: apiLogs, Page: filter.Page, diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go index 087069cc0..369b53547 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go @@ -2,6 +2,7 @@ package manager import ( "context" + "strings" log "github.com/sirupsen/logrus" @@ -55,8 +56,8 @@ func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.Ac return nil } -// GetAllAccessLogs retrieves access logs for an account with pagination -func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { +// GetAllAccessLogs retrieves access logs for an account with pagination and filtering +func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string, filter *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) if err != nil { return nil, 0, status.NewPermissionValidationError(err) @@ -65,10 +66,43 @@ func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID st return nil, 0, status.NewPermissionDeniedError() } - logs, totalCount, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID, filter) + if err := m.resolveUserFilters(ctx, accountID, filter); err != nil { + log.WithContext(ctx).Warnf("failed to resolve user filters: %v", err) + } + + logs, totalCount, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID, *filter) if err != nil { return nil, 0, err } return logs, totalCount, nil } + +// resolveUserFilters converts user email/name filters to user ID filter +func (m *managerImpl) resolveUserFilters(ctx context.Context, accountID string, filter *accesslogs.AccessLogFilter) error { + if filter.UserEmail == nil && filter.UserName == nil { + return nil + } + + users, err := m.store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + + var matchingUserIDs []string + for _, user := range users { + if filter.UserEmail != nil && strings.Contains(strings.ToLower(user.Email), strings.ToLower(*filter.UserEmail)) { + matchingUserIDs = append(matchingUserIDs, user.Id) + continue + } + if filter.UserName != nil && strings.Contains(strings.ToLower(user.Name), strings.ToLower(*filter.UserName)) { + matchingUserIDs = append(matchingUserIDs, user.Id) + } + } + + if len(matchingUserIDs) > 0 { + filter.UserID = &matchingUserIDs[0] + } + + return nil +} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index c432ce7e3..beaa724ea 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -5061,24 +5061,28 @@ func (s *SqlStore) CreateAccessLog(ctx context.Context, logEntry *accesslogs.Acc return nil } -// GetAccountAccessLogs retrieves access logs for a given account with pagination +// GetAccountAccessLogs retrieves access logs for a given account with pagination and filtering func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { var logs []*accesslogs.AccessLogEntry var totalCount int64 - // Count total records for pagination metadata - countQuery := s.db.WithContext(ctx). + baseQuery := s.db.WithContext(ctx). Model(&accesslogs.AccessLogEntry{}). Where(accountIDCondition, accountID) - if err := countQuery.Count(&totalCount).Error; err != nil { + baseQuery = s.applyAccessLogFilters(baseQuery, filter) + + if err := baseQuery.Count(&totalCount).Error; err != nil { log.WithContext(ctx).Errorf("failed to count access logs: %v", err) return nil, 0, status.Errorf(status.Internal, "failed to count access logs") } - // Query with pagination query := s.db.WithContext(ctx). - Where(accountIDCondition, accountID). + Where(accountIDCondition, accountID) + + query = s.applyAccessLogFilters(query, filter) + + query = query. Order("timestamp DESC"). Limit(filter.GetLimit()). Offset(filter.GetOffset()) @@ -5096,6 +5100,44 @@ func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength Lockin return logs, totalCount, nil } +// applyAccessLogFilters applies filter conditions to the query +func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { + if filter.SourceIP != nil { + query = query.Where("source_ip = ?", *filter.SourceIP) + } + + if filter.Host != nil { + query = query.Where("host = ?", *filter.Host) + } + + if filter.Path != nil { + // Support LIKE pattern for path filtering + query = query.Where("path LIKE ?", "%"+*filter.Path+"%") + } + + if filter.UserID != nil { + query = query.Where("user_id = ?", *filter.UserID) + } + + if filter.Method != nil { + query = query.Where("method = ?", *filter.Method) + } + + if filter.StatusCode != nil { + query = query.Where("status_code = ?", *filter.StatusCode) + } + + if filter.StartDate != nil { + query = query.Where("timestamp >= ?", *filter.StartDate) + } + + if filter.EndDate != nil { + query = query.Where("timestamp <= ?", *filter.EndDate) + } + + return query +} + func (s *SqlStore) GetReverseProxyTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*reverseproxy.Target, error) { tx := s.db if lockStrength != LockingStrengthNone { diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 644617f4f..d2790fd71 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -6389,6 +6389,61 @@ paths: minimum: 1 maximum: 100 description: Number of items per page (max 100) + - in: query + name: source_ip + schema: + type: string + description: Filter by source IP address + - in: query + name: host + schema: + type: string + description: Filter by host header + - in: query + name: path + schema: + type: string + description: Filter by request path (supports partial matching) + - in: query + name: user_id + schema: + type: string + description: Filter by authenticated user ID + - in: query + name: user_email + schema: + type: string + description: Filter by user email (partial matching) + - in: query + name: user_name + schema: + type: string + description: Filter by user name (partial matching) + - in: query + name: method + schema: + type: string + enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS] + description: Filter by HTTP method + - in: query + name: status_code + schema: + type: integer + minimum: 100 + maximum: 599 + description: Filter by HTTP status code + - in: query + name: start_date + schema: + type: string + format: date-time + description: Filter by timestamp >= start_date (RFC3339 format) + - in: query + name: end_date + schema: + type: string + format: date-time + description: Filter by timestamp <= end_date (RFC3339 format) responses: "200": description: Paginated list of reverse proxy access logs From 1ffe8deb104cfc3a734d3e1c33eda32dc0822753 Mon Sep 17 00:00:00 2001 From: pascal Date: Wed, 11 Feb 2026 16:38:31 +0100 Subject: [PATCH 4/7] add general search filter --- .../internals/modules/reverseproxy/accesslogs/filter.go | 5 +++++ management/server/store/sql_store.go | 8 ++++++++ shared/management/http/api/openapi.yml | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/management/internals/modules/reverseproxy/accesslogs/filter.go b/management/internals/modules/reverseproxy/accesslogs/filter.go index 5dccea235..bd031d126 100644 --- a/management/internals/modules/reverseproxy/accesslogs/filter.go +++ b/management/internals/modules/reverseproxy/accesslogs/filter.go @@ -21,6 +21,7 @@ type AccessLogFilter struct { PageSize int // Filtering parameters + Search *string // General search across host, path, source IP, and user fields SourceIP *string // Filter by source IP address Host *string // Filter by host header Path *string // Filter by request path (supports LIKE pattern) @@ -54,6 +55,10 @@ func (f *AccessLogFilter) ParseFromRequest(r *http.Request) { } } + if search := queryParams.Get("search"); search != "" { + f.Search = &search + } + if sourceIP := queryParams.Get("source_ip"); sourceIP != "" { f.SourceIP = &sourceIP } diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index beaa724ea..381ea37ef 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -5102,6 +5102,14 @@ func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength Lockin // applyAccessLogFilters applies filter conditions to the query func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { + if filter.Search != nil { + searchPattern := "%" + *filter.Search + "%" + query = query.Where( + "source_ip LIKE ? OR host LIKE ? OR path LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", + searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, + ) + } + if filter.SourceIP != nil { query = query.Where("source_ip = ?", *filter.SourceIP) } diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index d2790fd71..69d07b44a 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -6389,6 +6389,11 @@ paths: minimum: 1 maximum: 100 description: Number of items per page (max 100) + - in: query + name: search + schema: + type: string + description: General search across host, path, source IP, user email, and user name - in: query name: source_ip schema: From 5bcdf36377971635c84889a0bcaaf8f5235ad100 Mon Sep 17 00:00:00 2001 From: pascal Date: Wed, 11 Feb 2026 16:50:27 +0100 Subject: [PATCH 5/7] fix source_ip --- management/server/store/sql_store.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 381ea37ef..8cf98810c 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -5105,13 +5105,13 @@ func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.Acces if filter.Search != nil { searchPattern := "%" + *filter.Search + "%" query = query.Where( - "source_ip LIKE ? OR host LIKE ? OR path LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", + "location_connection_ip LIKE ? OR host LIKE ? OR path LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, ) } if filter.SourceIP != nil { - query = query.Where("source_ip = ?", *filter.SourceIP) + query = query.Where("location_connection_ip = ?", *filter.SourceIP) } if filter.Host != nil { From 9dba262a20e1cd51c1fa88b4e1da347704151a94 Mon Sep 17 00:00:00 2001 From: pascal Date: Wed, 11 Feb 2026 17:07:15 +0100 Subject: [PATCH 6/7] add index to access log entries --- .../reverseproxy/accesslogs/accesslogentry.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go index b524d027b..1101dc681 100644 --- a/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go +++ b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go @@ -16,14 +16,14 @@ type AccessLogEntry struct { ProxyID string `gorm:"index"` Timestamp time.Time `gorm:"index"` GeoLocation peer.Location `gorm:"embedded;embeddedPrefix:location_"` - Method string - Host string - Path string - Duration time.Duration - StatusCode int + Method string `gorm:"index"` + Host string `gorm:"index"` + Path string `gorm:"index"` + Duration time.Duration `gorm:"index"` + StatusCode int `gorm:"index"` Reason string - UserId string - AuthMethodUsed string + UserId string `gorm:"index"` + AuthMethodUsed string `gorm:"index"` } // FromProto creates an AccessLogEntry from a proto.AccessLog From e020950cfd0f28dd2c30640ad165008e79039c3c Mon Sep 17 00:00:00 2001 From: pascal Date: Wed, 11 Feb 2026 17:54:29 +0100 Subject: [PATCH 7/7] concat host and path for search and add a status filter --- .../modules/reverseproxy/accesslogs/filter.go | 5 +++++ management/server/store/sql_store.go | 12 ++++++++++-- shared/management/http/api/openapi.yml | 6 ++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/management/internals/modules/reverseproxy/accesslogs/filter.go b/management/internals/modules/reverseproxy/accesslogs/filter.go index bd031d126..23dbb25ca 100644 --- a/management/internals/modules/reverseproxy/accesslogs/filter.go +++ b/management/internals/modules/reverseproxy/accesslogs/filter.go @@ -29,6 +29,7 @@ type AccessLogFilter struct { UserEmail *string // Filter by user email (requires user lookup) UserName *string // Filter by user name (requires user lookup) Method *string // Filter by HTTP method + Status *string // Filter by status: "success" (2xx/3xx) or "failed" (1xx/4xx/5xx) StatusCode *int // Filter by HTTP status code StartDate *time.Time // Filter by timestamp >= start_date EndDate *time.Time // Filter by timestamp <= end_date @@ -87,6 +88,10 @@ func (f *AccessLogFilter) ParseFromRequest(r *http.Request) { f.Method = &method } + if status := queryParams.Get("status"); status != "" { + f.Status = &status + } + if statusCodeStr := queryParams.Get("status_code"); statusCodeStr != "" { if statusCode, err := strconv.Atoi(statusCodeStr); err == nil && statusCode > 0 { f.StatusCode = &statusCode diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 8cf98810c..2c374bfd4 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -5105,8 +5105,8 @@ func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.Acces if filter.Search != nil { searchPattern := "%" + *filter.Search + "%" query = query.Where( - "location_connection_ip LIKE ? OR host LIKE ? OR path LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", - searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, + "location_connection_ip LIKE ? OR host LIKE ? OR path LIKE ? OR CONCAT(host, path) LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", + searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, ) } @@ -5131,6 +5131,14 @@ func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.Acces query = query.Where("method = ?", *filter.Method) } + if filter.Status != nil { + if *filter.Status == "success" { + query = query.Where("status_code >= ? AND status_code < ?", 200, 400) + } else if *filter.Status == "failed" { + query = query.Where("status_code < ? OR status_code >= ?", 200, 400) + } + } + if filter.StatusCode != nil { query = query.Where("status_code = ?", *filter.StatusCode) } diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 69d07b44a..11ced2e9d 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -6430,6 +6430,12 @@ paths: type: string enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS] description: Filter by HTTP method + - in: query + name: status + schema: + type: string + enum: [success, failed] + description: Filter by status (success = 2xx/3xx, failed = 1xx/4xx/5xx) - in: query name: status_code schema: