mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
auditfix (#64640)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) > (?,?)`,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+19
-12
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: "",
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
<Audit
|
||||
{...makeState(ctx, {
|
||||
events: [
|
||||
makeEvent({
|
||||
codeDesc: 'Local Login',
|
||||
message: 'Local user [root] successfully logged in',
|
||||
id: 'user.login:2021-05-25T14:37:27.848Z',
|
||||
code: 'T1000I',
|
||||
user: 'root',
|
||||
time: new Date('2021-05-25T14:37:27.848Z'),
|
||||
raw: {
|
||||
cluster_name: 'im-a-cluster-name',
|
||||
code: 'T1000I',
|
||||
ei: 0,
|
||||
event: 'user.login',
|
||||
method: 'local',
|
||||
success: true,
|
||||
time: '2021-05-25T14:37:27.848Z',
|
||||
user: 'root',
|
||||
},
|
||||
}),
|
||||
],
|
||||
hasNextPage: true,
|
||||
isPlaceholderData: true,
|
||||
fetchNextPage,
|
||||
})}
|
||||
/>,
|
||||
{
|
||||
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> = {}
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<FeatureBox unsetHeight>
|
||||
<FeatureHeader alignItems="center">
|
||||
@@ -121,7 +130,7 @@ export function Audit(props: State) {
|
||||
hideAdvancedSearch={true}
|
||||
filter={{ search }}
|
||||
/>
|
||||
{!isLoading && (
|
||||
{!isLoading && !isPlaceholderData && (
|
||||
<EventList
|
||||
events={events}
|
||||
search={search}
|
||||
@@ -130,10 +139,8 @@ export function Audit(props: State) {
|
||||
setSort={setSort}
|
||||
/>
|
||||
)}
|
||||
{((isLoading && events.length === 0) || isFetchingNextPage) && (
|
||||
<EventListSkeleton />
|
||||
)}
|
||||
<div ref={setTrigger} />
|
||||
{showSkeleton && <EventListSkeleton />}
|
||||
{!isPlaceholderData && <div ref={setTrigger} />}
|
||||
{isError && events.length > 0 && !isLoading && (
|
||||
<Box mt={2} textAlign="center">
|
||||
<ButtonSecondary onClick={onLoadMoreClicked}>
|
||||
|
||||
@@ -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 = (
|
||||
<Flex gap={2} justifyContent="flex-end">
|
||||
<ViewInPolicyButton event={event} />
|
||||
<ButtonBorder
|
||||
size="small"
|
||||
size="medium"
|
||||
onClick={() => onShowDetails(event)}
|
||||
width="87px"
|
||||
>
|
||||
@@ -94,11 +94,17 @@ export const renderActionCell = (
|
||||
);
|
||||
|
||||
export const renderTimeCell = ({ time }: Event) => (
|
||||
<Cell style={{ minWidth: '120px' }}>{time.toISOString()}</Cell>
|
||||
<Cell style={{ minWidth: '120px' }}>
|
||||
<Text color="text.slightlyMuted">{time.toISOString()}</Text>
|
||||
</Cell>
|
||||
);
|
||||
|
||||
export function renderDescCell({ message }: Event) {
|
||||
return <Cell style={{ wordBreak: 'break-word' }}>{message}</Cell>;
|
||||
return (
|
||||
<Cell style={{ wordBreak: 'break-word' }}>
|
||||
<Text color="text.slightlyMuted">{message}</Text>
|
||||
</Cell>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -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) {
|
||||
<Cell style={{ verticalAlign: 'inherit' }}>
|
||||
<StyledEventType>
|
||||
<Icon {...iconProps} size="medium" />
|
||||
{event.codeDesc}
|
||||
<Flex
|
||||
gap={0}
|
||||
flexDirection="column"
|
||||
minHeight={64}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text typography="body2" fontWeight={500} mb={0}>
|
||||
{event.raw.event}
|
||||
</Text>
|
||||
<Text typography="body2" color="text.muted">
|
||||
{event.codeDesc}
|
||||
</Text>
|
||||
</Flex>
|
||||
</StyledEventType>
|
||||
</Cell>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user