diff --git a/lib/events/athena/integration_test.go b/lib/events/athena/integration_test.go
index a38e1a97622..d7a68e48b45 100644
--- a/lib/events/athena/integration_test.go
+++ b/lib/events/athena/integration_test.go
@@ -155,6 +155,17 @@ func TestIntegrationAthenaEventPagination(t *testing.T) {
})
}
+func TestIntegrationAthenaSearchEventsBySearchTerm(t *testing.T) {
+ t.Run("sns", func(t *testing.T) {
+ const bypassSNSFalse = false
+ testIntegrationAthenaSearchEventsBySearchTerm(t, bypassSNSFalse)
+ })
+ t.Run("sqs", func(t *testing.T) {
+ const bypassSNSTrue = true
+ testIntegrationAthenaSearchEventsBySearchTerm(t, bypassSNSTrue)
+ })
+}
+
func testIntegrationAthenaEventPagination(t *testing.T, bypassSNS bool) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -172,6 +183,22 @@ func testIntegrationAthenaEventPagination(t *testing.T, bypassSNS bool) {
eventsSuite.EventPagination(t)
}
+func testIntegrationAthenaSearchEventsBySearchTerm(t *testing.T, bypassSNS bool) {
+ ctx := t.Context()
+ ac := SetupAthenaContext(t, ctx, AthenaContextConfig{BypassSNS: bypassSNS})
+ auditLogger := &EventuallyConsistentAuditLogger{
+ Inner: ac.log,
+ // Additional 5s is used to compensate for uploading parquet on s3.
+ QueryDelay: ac.batcherInterval + 5*time.Second,
+ }
+ eventsSuite := test.EventsSuite{
+ Log: auditLogger,
+ Clock: ac.clock,
+ }
+
+ eventsSuite.SearchEventsBySearchTerm(t)
+}
+
func TestIntegrationAthenaLargeEvents(t *testing.T) {
t.Run("sns", func(t *testing.T) {
const bypassSNSFalse = false
diff --git a/lib/events/athena/querier.go b/lib/events/athena/querier.go
index 37f47c45e96..41c08eef4b4 100644
--- a/lib/events/athena/querier.go
+++ b/lib/events/athena/querier.go
@@ -208,7 +208,7 @@ func (q *querier) SearchEvents(ctx context.Context, req events.SearchEventsReque
limit: req.Limit,
order: req.Order,
startKey: startKeyset,
- filter: searchEventsFilter{eventTypes: req.EventTypes},
+ filter: searchEventsFilter{eventTypes: req.EventTypes, search: req.Search},
sessionID: "",
})
return events, keyset, trace.Wrap(err)
@@ -220,7 +220,7 @@ func (q *querier) SearchEvents(ctx context.Context, req events.SearchEventsReque
limit: req.Limit,
order: req.Order,
startKey: startKeyset,
- filter: searchEventsFilter{eventTypes: req.EventTypes},
+ filter: searchEventsFilter{eventTypes: req.EventTypes, search: req.Search},
sessionID: "",
})
return events, keyset, trace.Wrap(err)
@@ -662,6 +662,7 @@ func (q *querier) searchEvents(ctx context.Context, req searchEventsRequest) ([]
type searchEventsFilter struct {
eventTypes []string
+ search string
condition utils.FieldsCondition
}
@@ -673,7 +674,8 @@ type queryBuilder struct {
// withTicks wraps string with ticks.
// string params in athena need to be wrapped by "ticks".
func withTicks(in string) string {
- return fmt.Sprintf("'%s'", in)
+ escaped := strings.ReplaceAll(in, "'", "''")
+ return fmt.Sprintf("'%s'", escaped)
}
func sliceWithTicks(ss []string) []string {
@@ -740,6 +742,12 @@ func prepareQuery(params searchParams) (query string, execParams []string, err e
)
}
+ if params.filter.search != "" {
+ for term := range strings.FieldsSeq(strings.ToLower(params.filter.search)) {
+ qb.Append(" AND strpos(lower(event_data), ?) > 0", withTicks(term))
+ }
+ }
+
if params.order == types.EventOrderAscending {
if params.startKeyset != nil {
qb.Append(` AND (event_time, uid) > (?,?)`,
diff --git a/lib/events/athena/querier_test.go b/lib/events/athena/querier_test.go
index 100815cb5d6..b519496f671 100644
--- a/lib/events/athena/querier_test.go
+++ b/lib/events/athena/querier_test.go
@@ -187,6 +187,57 @@ func TestSearchEvents(t *testing.T) {
wantQueryParams(t, mock, append(timeRangeParams, "'app.create'", "'app.delete'")...)
},
},
+ {
+ name: "query with search terms",
+ searchParams: &events.SearchEventsRequest{
+ From: fromUTC,
+ To: toUTC,
+ Limit: 100,
+ Search: "Root ALICE",
+ },
+ queryResultsResps: singleCallResults(100),
+ check: func(t *testing.T, mock *mockAthenaExecutor, paginationKey string) {
+ t.Helper()
+ wantSingleCallToAthena(t, mock)
+ wantQuery(t, mock, selectFromPrefix+whereTimeRange+
+ ` AND strpos(lower(event_data), ?) > 0 AND strpos(lower(event_data), ?) > 0 ORDER BY event_time ASC, uid ASC LIMIT 100;`)
+ wantQueryParams(t, mock, append(timeRangeParams, "'root'", "'alice'")...)
+ },
+ },
+ {
+ name: "query with apostrophe in search term",
+ searchParams: &events.SearchEventsRequest{
+ From: fromUTC,
+ To: toUTC,
+ Limit: 100,
+ Search: "O'Connor",
+ },
+ queryResultsResps: singleCallResults(100),
+ check: func(t *testing.T, mock *mockAthenaExecutor, paginationKey string) {
+ t.Helper()
+ wantSingleCallToAthena(t, mock)
+ wantQuery(t, mock, selectFromPrefix+whereTimeRange+
+ ` AND strpos(lower(event_data), ?) > 0 ORDER BY event_time ASC, uid ASC LIMIT 100;`)
+ wantQueryParams(t, mock, append(timeRangeParams, "'o''connor'")...)
+ },
+ },
+ {
+ name: "query with wildcard characters in search term",
+ searchParams: &events.SearchEventsRequest{
+ From: fromUTC,
+ To: toUTC,
+ Limit: 100,
+ Search: "alice_admin svc%prod",
+ },
+ queryResultsResps: singleCallResults(100),
+ check: func(t *testing.T, mock *mockAthenaExecutor, paginationKey string) {
+ t.Helper()
+ wantSingleCallToAthena(t, mock)
+ wantQuery(t, mock, selectFromPrefix+whereTimeRange+
+ ` AND strpos(lower(event_data), ?) > 0 AND strpos(lower(event_data), ?) > 0 ORDER BY event_time ASC, uid ASC LIMIT 100;`)
+ wantQueryParams(t, mock, append(timeRangeParams, "'alice_admin'", "'svc%prod'")...)
+ },
+ },
{
name: "session id",
searchSessionParams: &events.SearchSessionEventsRequest{
diff --git a/lib/events/dynamoevents/dynamoevents.go b/lib/events/dynamoevents/dynamoevents.go
index 2d3bca38679..c0d9d0f8c56 100644
--- a/lib/events/dynamoevents/dynamoevents.go
+++ b/lib/events/dynamoevents/dynamoevents.go
@@ -775,7 +775,7 @@ type legacyCheckpointKey struct {
//
// This function may never return more than 1 MiB of event data.
func (l *Log) SearchEvents(ctx context.Context, req events.SearchEventsRequest) ([]apievents.AuditEvent, string, error) {
- values, next, err := l.searchEventsWithFilter(ctx, req.From, req.To, apidefaults.Namespace, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes}, "")
+ values, next, err := l.searchEventsWithFilter(ctx, req.From, req.To, apidefaults.Namespace, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes, search: req.Search}, "")
if err != nil {
return nil, "", trace.Wrap(err)
}
@@ -787,7 +787,7 @@ func (l *Log) SearchEvents(ctx context.Context, req events.SearchEventsRequest)
}
func (l *Log) SearchUnstructuredEvents(ctx context.Context, req events.SearchEventsRequest) ([]*auditlogpb.EventUnstructured, string, error) {
- values, next, err := l.searchEventsWithFilter(ctx, req.From, req.To, apidefaults.Namespace, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes}, "")
+ values, next, err := l.searchEventsWithFilter(ctx, req.From, req.To, apidefaults.Namespace, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes, search: req.Search}, "")
if err != nil {
return nil, "", trace.Wrap(err)
}
@@ -1109,6 +1109,7 @@ func (l *Log) SearchSessionEvents(ctx context.Context, req events.SearchSessionE
type searchEventsFilter struct {
eventTypes []string
+ search string
condExpr string
condParams condFilterParams
filterFunc utils.FieldsCondition
@@ -1618,6 +1619,9 @@ func (l *eventsFetcher) processQueryOutput(output *dynamodb.QueryOutput) ([]even
if err != nil {
return nil, false, trace.Wrap(err)
}
+ if l.filter.search != "" && !events.MatchSearch(l.filter.search, string(data)) {
+ continue
+ }
// Stop early when the fetcher's total size exceeds the response size limit.
if l.totalSize+len(data) > events.MaxEventBytesInResponse {
diff --git a/lib/events/dynamoevents/dynamoevents_test.go b/lib/events/dynamoevents/dynamoevents_test.go
index 6eec968abc1..7f047d2cfff 100644
--- a/lib/events/dynamoevents/dynamoevents_test.go
+++ b/lib/events/dynamoevents/dynamoevents_test.go
@@ -130,6 +130,12 @@ func TestSearchSessionEvensBySessionID(t *testing.T) {
tt.suite.SearchSessionEventsBySessionID(t)
}
+func TestSearchEventsBySearchTerm(t *testing.T) {
+ tt := setupDynamoContext(t)
+
+ tt.suite.SearchEventsBySearchTerm(t)
+}
+
// TestCheckpointOutsideOfWindow tests if [Log] doesn't panic
// if checkpoint date is outside of the window [fromUTC,toUTC].
func TestCheckpointOutsideOfWindow(t *testing.T) {
diff --git a/lib/events/filelog.go b/lib/events/filelog.go
index 45070fbe8d0..5fcd1157b41 100644
--- a/lib/events/filelog.go
+++ b/lib/events/filelog.go
@@ -716,18 +716,7 @@ func (l *FileLog) findInFile(path string, filter searchEventsFilter) ([]EventFie
}
// Check if search filter matches.
if accepted && filter.search != "" {
- eventJSON := strings.ToLower(string(scanner.Bytes()))
- searchTerms := strings.Fields(strings.ToLower(filter.search))
-
- matchedAll := true
- for _, term := range searchTerms {
- if !strings.Contains(eventJSON, term) {
- matchedAll = false
- break
- }
- }
-
- accepted = matchedAll
+ accepted = MatchSearch(filter.search, string(scanner.Bytes()))
}
if accepted {
@@ -782,6 +771,24 @@ func (f ByTimeAndIndex) Swap(i, j int) {
f[i], f[j] = f[j], f[i]
}
+// MatchSearch returns true if all whitespace-delimited search terms are present
+// in text, using case-insensitive substring matching.
+func MatchSearch(search, text string) bool {
+ terms := strings.Fields(strings.ToLower(search))
+ if len(terms) == 0 {
+ return true
+ }
+
+ text = strings.ToLower(text)
+ for _, term := range terms {
+ if !strings.Contains(text, term) {
+ return false
+ }
+ }
+
+ return true
+}
+
// getTime converts json time to string
func getTime(v any) time.Time {
sval, ok := v.(string)
diff --git a/lib/events/filelog_test.go b/lib/events/filelog_test.go
index 5d793117a3c..171504d1d5c 100644
--- a/lib/events/filelog_test.go
+++ b/lib/events/filelog_test.go
@@ -274,6 +274,58 @@ func TestFileLogCheckpoint(t *testing.T) {
}
}
+func TestMatchSearch(t *testing.T) {
+ tests := []struct {
+ name string
+ search string
+ text string
+ want bool
+ }{
+ {
+ name: "empty search matches any text",
+ search: "",
+ text: `{"user":"alice"}`,
+ want: true,
+ },
+ {
+ name: "whitespace-only search matches any text",
+ search: " \t\n ",
+ text: `{"user":"alice"}`,
+ want: true,
+ },
+ {
+ name: "single term is case-insensitive",
+ search: "ALICE",
+ text: `{"user":"alice-search-target"}`,
+ want: true,
+ },
+ {
+ name: "all search terms must be present",
+ search: "target alice",
+ text: `{"user":"alice-search-target"}`,
+ want: true,
+ },
+ {
+ name: "missing term does not match",
+ search: "target carol",
+ text: `{"user":"alice-search-target"}`,
+ want: false,
+ },
+ {
+ name: "wildcard-like characters are treated literally",
+ search: "svc%prod",
+ text: `route svc%prod backend`,
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, MatchSearch(tt.search, tt.text))
+ })
+ }
+}
+
func TestSearchSessionEvents(t *testing.T) {
clock := clockwork.NewFakeClock()
start := clock.Now()
diff --git a/lib/events/firestoreevents/firestoreevents.go b/lib/events/firestoreevents/firestoreevents.go
index 9e1e97a0aef..4e630f656d2 100644
--- a/lib/events/firestoreevents/firestoreevents.go
+++ b/lib/events/firestoreevents/firestoreevents.go
@@ -369,7 +369,7 @@ func (l *Log) SearchEvents(ctx context.Context, req events.SearchEventsRequest)
limit: req.Limit,
order: req.Order,
lastKey: req.StartKey,
- filter: searchEventsFilter{eventTypes: req.EventTypes},
+ filter: searchEventsFilter{eventTypes: req.EventTypes, search: req.Search},
sessionID: "",
})
if err != nil {
@@ -479,6 +479,20 @@ func (l *Log) query(
docID = checkpointParts[1]
}
+ getCheckpointTime := func(docSnap *firestore.DocumentSnapshot) (int64, error) {
+ createdAtValue, ok := docSnap.Data()[createdAtDocProperty]
+ if !ok {
+ return 0, trace.BadParameter("missing %q field in event document %q", createdAtDocProperty, docSnap.Ref.ID)
+ }
+
+ checkpointTime, ok := createdAtValue.(int64)
+ if !ok {
+ return 0, trace.BadParameter("invalid %q type %T in event document %q", createdAtDocProperty, createdAtValue, docSnap.Ref.ID)
+ }
+
+ return checkpointTime, nil
+ }
+
for {
if lastKey != "" {
query = query.StartAfter(checkpointTime, docID)
@@ -520,13 +534,26 @@ func (l *Log) query(
return nil, "", trace.Errorf("failed to unmarshal event %v", err)
}
+ if filter.search != "" && !events.MatchSearch(filter.search, string(data)) {
+ checkpointTime, err = getCheckpointTime(docSnap)
+ if err != nil {
+ return nil, "", trace.Wrap(err)
+ }
+ docID = docSnap.Ref.ID
+ lastKey = strconv.FormatInt(checkpointTime, 10) + ":" + docID
+ continue
+ }
+
// if the total size of the events exceeds the limit, return the events
// collected so far and the last key to resume the query.
if totalSize+len(data) >= events.MaxEventBytesInResponse {
return values, lastKey, nil
}
- checkpointTime = docSnap.Data()[createdAtDocProperty].(int64)
+ checkpointTime, err = getCheckpointTime(docSnap)
+ if err != nil {
+ return nil, "", trace.Wrap(err)
+ }
docID = docSnap.Ref.ID
lastKey = strconv.FormatInt(checkpointTime, 10) + ":" + docID
@@ -588,6 +615,7 @@ func (l *Log) GetEventExportChunks(ctx context.Context, req *auditlogpb.GetEvent
type searchEventsFilter struct {
eventTypes []string
+ search string
condition utils.FieldsCondition
}
@@ -663,7 +691,7 @@ func (l *Log) SearchUnstructuredEvents(ctx context.Context, req events.SearchEve
limit: req.Limit,
order: req.Order,
lastKey: req.StartKey,
- filter: searchEventsFilter{eventTypes: req.EventTypes},
+ filter: searchEventsFilter{eventTypes: req.EventTypes, search: req.Search},
sessionID: "",
})
diff --git a/lib/events/firestoreevents/firestoreevents_test.go b/lib/events/firestoreevents/firestoreevents_test.go
index afcadfefed7..2c50b8f6d7a 100644
--- a/lib/events/firestoreevents/firestoreevents_test.go
+++ b/lib/events/firestoreevents/firestoreevents_test.go
@@ -129,12 +129,18 @@ func (tt *firestoreContext) testSearchSessionEvensBySessionID(t *testing.T) {
tt.suite.SearchSessionEventsBySessionID(t)
}
+func (tt *firestoreContext) testSearchEventsBySearchTerm(t *testing.T) {
+ tt.setupTest(t)
+ tt.suite.SearchEventsBySearchTerm(t)
+}
+
func TestFirestoreEvents(t *testing.T) {
tt := setupFirestoreContext(t)
t.Run("TestSessionEventsCRUD", tt.testSessionEventsCRUD)
t.Run("TestPagination", tt.testPagination)
t.Run("TestSearchSessionEvensBySessionID", tt.testSearchSessionEvensBySessionID)
+ t.Run("TestSearchEventsBySearchTerm", tt.testSearchEventsBySearchTerm)
}
func emulatorRunning() bool {
diff --git a/lib/events/pgevents/pgevents.go b/lib/events/pgevents/pgevents.go
index 35e787a97d1..0f49e1286e1 100644
--- a/lib/events/pgevents/pgevents.go
+++ b/lib/events/pgevents/pgevents.go
@@ -428,7 +428,7 @@ func (l *Log) EmitAuditEvent(ctx context.Context, event apievents.AuditEvent) er
func (l *Log) searchEvents(
ctx context.Context,
fromTime, toTime time.Time,
- eventTypes []string, cond *utils.ToFieldsConditionConfig, sessionID string,
+ eventTypes []string, cond *utils.ToFieldsConditionConfig, sessionID, search string,
limit int, order types.EventOrder, startKey string,
) ([]events.EventFields, string, error) {
if limit <= 0 {
@@ -457,6 +457,8 @@ func (l *Log) searchEvents(
}
}
+ searchTerms := strings.Fields(strings.ToLower(search))
+
sessionUUID := l.deriveSessionID(ctx, sessionID)
var qb strings.Builder
@@ -473,6 +475,9 @@ func (l *Log) searchEvents(
// no matter what the argument is
qb.WriteString(" AND events.session_id != '00000000-0000-0000-0000-000000000000' AND events.session_id = @session_id")
}
+ for i := range searchTerms {
+ fmt.Fprintf(&qb, " AND POSITION(@search_term_%d IN lower(events.event_data::text)) > 0", i)
+ }
if order != types.EventOrderDescending {
if startKey != "" {
qb.WriteString(" AND (events.event_time, events.event_id) > (@start_time, @start_id)")
@@ -494,6 +499,9 @@ func (l *Log) searchEvents(
"start_time": startTime,
"start_id": startID,
}
+ for i, term := range searchTerms {
+ queryArgs[fmt.Sprintf("search_term_%d", i)] = term
+ }
const fetchSize = defaults.EventsIterationLimit
fetchQuery := fmt.Sprintf("FETCH %d FROM cur", fetchSize)
@@ -588,7 +596,7 @@ func (l *Log) SearchEvents(ctx context.Context, req events.SearchEventsRequest)
var emptyCond *utils.ToFieldsConditionConfig
const emptySessionID = ""
- evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, req.EventTypes, emptyCond, emptySessionID, req.Limit, req.Order, req.StartKey)
+ evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, req.EventTypes, emptyCond, emptySessionID, req.Search, req.Limit, req.Order, req.StartKey)
if err != nil {
return nil, next, trace.Wrap(err)
}
@@ -605,7 +613,7 @@ func (l *Log) SearchUnstructuredEvents(ctx context.Context, req events.SearchEve
var emptyCond *utils.ToFieldsConditionConfig
const emptySessionID = ""
- evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, req.EventTypes, emptyCond, emptySessionID, req.Limit, req.Order, req.StartKey)
+ evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, req.EventTypes, emptyCond, emptySessionID, req.Search, req.Limit, req.Order, req.StartKey)
if err != nil {
return nil, next, trace.Wrap(err)
}
@@ -626,7 +634,8 @@ func (l *Log) GetEventExportChunks(ctx context.Context, req *auditlogpb.GetEvent
// SearchSessionEvents implements [events.AuditLogger].
func (l *Log) SearchSessionEvents(ctx context.Context, req events.SearchSessionEventsRequest) ([]apievents.AuditEvent, string, error) {
- evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, events.SessionRecordingEvents, req.Cond, req.SessionID, req.Limit, req.Order, req.StartKey)
+ const emptySearch = ""
+ evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, events.SessionRecordingEvents, req.Cond, req.SessionID, emptySearch, req.Limit, req.Order, req.StartKey)
if err != nil {
return nil, next, trace.Wrap(err)
}
diff --git a/lib/events/pgevents/pgevents_test.go b/lib/events/pgevents/pgevents_test.go
index 0ba2190aeee..5ab36b55458 100644
--- a/lib/events/pgevents/pgevents_test.go
+++ b/lib/events/pgevents/pgevents_test.go
@@ -78,6 +78,10 @@ func TestPostgresEvents(t *testing.T) {
truncateEvents(t)
suite.SearchSessionEventsBySessionID(t)
})
+ t.Run("SearchEventsBySearchTerm", func(t *testing.T) {
+ truncateEvents(t)
+ suite.SearchEventsBySearchTerm(t)
+ })
}
// TestLog_nonStandardSessionID tests for
@@ -136,7 +140,8 @@ func TestLog_nonStandardSessionID(t *testing.T) {
[]string{appStartEvent.Metadata.Type}, // eventTypes
nil, // cond
appStartEvent.SessionID,
- 2, // limit
+ "", // search
+ 2, // limit
types.EventOrderAscending,
"", // startKey
)
diff --git a/lib/events/test/suite.go b/lib/events/test/suite.go
index ac05ae60a57..e73ac5e4169 100644
--- a/lib/events/test/suite.go
+++ b/lib/events/test/suite.go
@@ -519,6 +519,57 @@ Outer:
}
}
+func (s *EventsSuite) SearchEventsBySearchTerm(t *testing.T) {
+ ctx := t.Context()
+ baseTime := time.Now().UTC()
+
+ testUsers := []string{"alice-search-target", "bob-search-target"}
+ for i, user := range testUsers {
+ err := s.Log.EmitAuditEvent(ctx, &apievents.UserLogin{
+ Method: events.LoginMethodSAML,
+ Status: apievents.Status{Success: true},
+ UserMetadata: apievents.UserMetadata{User: user},
+ Metadata: apievents.Metadata{
+ ID: uuid.NewString(),
+ Type: events.UserLoginEvent,
+ Time: baseTime.Add(time.Second * time.Duration(i)),
+ },
+ })
+ require.NoError(t, err)
+ }
+
+ if s.QueryDelay != 0 {
+ time.Sleep(s.QueryDelay)
+ }
+
+ searchAndAssertUsers := func(search string, wantUsers ...string) {
+ require.EventuallyWithT(t, func(t *assert.CollectT) {
+ history, _, err := s.Log.SearchEvents(ctx, events.SearchEventsRequest{
+ From: baseTime.Add(-1 * time.Minute),
+ To: baseTime.Add(time.Hour),
+ Limit: 100,
+ Order: types.EventOrderAscending,
+ Search: search,
+ })
+ require.NoError(t, err)
+
+ gotUsers := make([]string, 0, len(history))
+ for _, event := range history {
+ loginEvent, ok := event.(*apievents.UserLogin)
+ require.True(t, ok)
+ gotUsers = append(gotUsers, loginEvent.User)
+ }
+
+ require.ElementsMatch(t, wantUsers, gotUsers)
+ }, 30*time.Second, 500*time.Millisecond)
+ }
+
+ searchAndAssertUsers("alice-search-target", "alice-search-target")
+ searchAndAssertUsers("search-target alice", "alice-search-target")
+ searchAndAssertUsers("search-target", "alice-search-target", "bob-search-target")
+ searchAndAssertUsers("search-target carol")
+}
+
// SessionEventsCRUD covers session events
func (s *EventsSuite) SessionEventsCRUD(t *testing.T) {
loginTime := s.Clock.Now().UTC()
diff --git a/web/packages/teleport/src/Audit/Audit.test.tsx b/web/packages/teleport/src/Audit/Audit.test.tsx
index 05b80269e46..2ed5ff46dee 100644
--- a/web/packages/teleport/src/Audit/Audit.test.tsx
+++ b/web/packages/teleport/src/Audit/Audit.test.tsx
@@ -31,7 +31,8 @@ import TeleportContext from 'teleport/teleportContext';
import { renderWithMemoryRouter } from 'teleport/test/helpers/router';
import { ContextProvider } from '..';
-import { AuditContainer } from './Audit';
+import { Audit, AuditContainer } from './Audit';
+import type { State } from './useAuditEvents';
const mio = mockIntersectionObserver();
@@ -93,6 +94,54 @@ describe('Audit', () => {
expect(router.state.location.pathname).toBe('/web/cluster/root/audit');
expect(router.state.location.search).toContain('order=ASC');
});
+
+ it('does not fetch next page while placeholder data is shown', async () => {
+ const ctx = createTeleportContext();
+ jest
+ .spyOn(ctx.clusterService, 'fetchClusters')
+ .mockImplementation(() => new Promise(() => {}));
+
+ const fetchNextPage = jest.fn();
+
+ renderWithMemoryRouter(
+ ,
+ {
+ path: cfg.routes.audit,
+ initialEntries: ['/web/cluster/root/audit'],
+ wrapper: makeWrapper({ ctx }),
+ }
+ );
+
+ act(mio.enterAll);
+
+ expect(fetchNextPage).not.toHaveBeenCalled();
+ });
});
function renderComponent(ctx: TeleportContext) {
@@ -116,3 +165,30 @@ function makeWrapper({ ctx }: { ctx: TeleportContext }) {
);
};
}
+
+function makeState(
+ ctx: TeleportContext,
+ overrides: Partial = {}
+): State {
+ return {
+ events: [],
+ fetchNextPage: jest.fn(),
+ hasNextPage: false,
+ isPlaceholderData: false,
+ isFetchingNextPage: false,
+ isLoading: false,
+ error: null,
+ isSuccess: true,
+ refetch: jest.fn(),
+ isError: false,
+ clusterId: 'root',
+ range: undefined,
+ setRange: jest.fn(),
+ search: '',
+ setSearch: jest.fn(),
+ sort: { fieldName: 'time', dir: 'DESC' },
+ setSort: jest.fn(),
+ ctx,
+ ...overrides,
+ };
+}
diff --git a/web/packages/teleport/src/Audit/Audit.tsx b/web/packages/teleport/src/Audit/Audit.tsx
index e03dbb78972..1b873f0146e 100644
--- a/web/packages/teleport/src/Audit/Audit.tsx
+++ b/web/packages/teleport/src/Audit/Audit.tsx
@@ -54,6 +54,7 @@ export function Audit(props: State) {
clusterId,
fetchNextPage,
hasNextPage,
+ isPlaceholderData,
isFetchingNextPage,
error,
isLoading,
@@ -68,9 +69,12 @@ export function Audit(props: State) {
const [errorMessage, setErrorMessage] = useState('');
+ const canFetchNextPage =
+ hasNextPage && !isFetchingNextPage && !isError && !isPlaceholderData;
+
const { setTrigger } = useInfiniteScroll({
fetch: async () => {
- if (hasNextPage && !isFetchingNextPage && !isError) {
+ if (canFetchNextPage) {
fetchNextPage();
}
},
@@ -81,11 +85,16 @@ export function Audit(props: State) {
};
const onLoadMoreClicked = () => {
- if (hasNextPage && !isFetchingNextPage) {
+ if (canFetchNextPage) {
fetchNextPage();
}
};
+ const showSkeleton =
+ (isLoading && events.length === 0) ||
+ isFetchingNextPage ||
+ isPlaceholderData;
+
return (
@@ -121,7 +130,7 @@ export function Audit(props: State) {
hideAdvancedSearch={true}
filter={{ search }}
/>
- {!isLoading && (
+ {!isLoading && !isPlaceholderData && (
)}
- {((isLoading && events.length === 0) || isFetchingNextPage) && (
-
- )}
-
+ {showSkeleton && }
+ {!isPlaceholderData && }
{isError && events.length > 0 && !isLoading && (
diff --git a/web/packages/teleport/src/Audit/EventList/EventList.tsx b/web/packages/teleport/src/Audit/EventList/EventList.tsx
index c744f3e96e1..fe55010412c 100644
--- a/web/packages/teleport/src/Audit/EventList/EventList.tsx
+++ b/web/packages/teleport/src/Audit/EventList/EventList.tsx
@@ -18,7 +18,7 @@
import { useState } from 'react';
-import { ButtonBorder, Flex } from 'design';
+import { ButtonBorder, Flex, Text } from 'design';
import Table, { Cell } from 'design/DataTable';
import { Event } from 'teleport/services/audit';
@@ -38,7 +38,7 @@ export default function EventList(props: Props) {
columns={[
{
key: 'codeDesc',
- headerText: 'Type',
+ headerText: 'Event',
isSortable: false,
render: event => renderTypeCell(event),
},
@@ -83,7 +83,7 @@ export const renderActionCell = (
onShowDetails(event)}
width="87px"
>
@@ -94,11 +94,17 @@ export const renderActionCell = (
);
export const renderTimeCell = ({ time }: Event) => (
- | {time.toISOString()} |
+
+ {time.toISOString()}
+ |
);
export function renderDescCell({ message }: Event) {
- return {message} | ;
+ return (
+
+ {message}
+ |
+ );
}
type Props = {
diff --git a/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx b/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx
index 7909a9dfde5..823b631cabc 100644
--- a/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx
+++ b/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx
@@ -19,7 +19,9 @@
import styled from 'styled-components';
import { Cell } from 'design/DataTable';
+import Flex from 'design/Flex';
import * as Icons from 'design/Icon';
+import Text from 'design/Text/Text';
import { Event, EventCode, eventCodes } from 'teleport/services/audit';
@@ -372,7 +374,7 @@ export default function renderTypeCell(event: Event) {
const Icon = EventIconMap[event.code] || Icons.ListThin;
const iconProps = {
- p: 1,
+ p: 2,
mr: 3,
};
@@ -380,7 +382,19 @@ export default function renderTypeCell(event: Event) {
- {event.codeDesc}
+
+
+ {event.raw.event}
+
+
+ {event.codeDesc}
+
+
|
);
diff --git a/web/packages/teleport/src/Audit/useAuditEvents.ts b/web/packages/teleport/src/Audit/useAuditEvents.ts
index c43b349379c..d3680430f03 100644
--- a/web/packages/teleport/src/Audit/useAuditEvents.ts
+++ b/web/packages/teleport/src/Audit/useAuditEvents.ts
@@ -55,6 +55,7 @@ export default function useAuditEvents(
error,
fetchNextPage,
hasNextPage,
+ isPlaceholderData,
isFetchingNextPage,
isLoading,
isSuccess,
@@ -152,6 +153,7 @@ export default function useAuditEvents(
events,
fetchNextPage,
hasNextPage,
+ isPlaceholderData,
isFetchingNextPage,
isLoading,
error,