Skip to content
This repository was archived by the owner on Jun 2, 2021. It is now read-only.

Commit 666a038

Browse files
reidmitBrian Cunnie
andcommitted
V3: filter audit events: multiple timestamps
Users can filter audit events with several timestamps, e.g. `GET /v3/audit_events?created_ats=2020-06-30T12:01:05Z,2020-07-02T12:00:00Z` - We feel comfortable extending the `to_array!` of the base class to return nil when passed a hash (which means, "don't change this!"). Prior to the recent addition of inequality filters, we did not pass hashes, and now we do, and it's appropriate to accommodate that. - For our newer methods, we add another `9` to our timespan (i.e. `0.99999` (five nines) is increased to `0.999999` (six nines). PostgreSQL stores timestamps in microseconds, and there is an obscure corner case where the query will unexpected fail if the timestamp is barely just before the second begins. We should probably backfill this. [finishes #173575312] Co-authored-by: Reid Mitchell <rmitchell@pivotal.io> Co-authored-by: Brian Cunnie <bcunnie@pivotal.io>
1 parent fb5c067 commit 666a038

9 files changed

Lines changed: 84 additions & 33 deletions

File tree

app/fetchers/event_list_fetcher.rb

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,25 +37,32 @@ def filter(message, dataset)
3737
normalized_timestamp = Time.parse(given_timestamp).utc
3838
dataset = dataset.where(Sequel.lit('created_at < ?', normalized_timestamp))
3939
elsif operator == Event::LESS_THAN_OR_EQUAL_COMPARATOR
40-
normalized_timestamp = (Time.parse(given_timestamp).utc + 0.99999).utc
40+
normalized_timestamp = (Time.parse(given_timestamp).utc + 0.999999).utc
4141
dataset = dataset.where(Sequel.lit('created_at <= ?', normalized_timestamp))
4242
elsif operator == Event::GREATER_THAN_COMPARATOR
43-
normalized_timestamp = (Time.parse(given_timestamp).utc + 0.99999).utc
43+
normalized_timestamp = (Time.parse(given_timestamp).utc + 0.999999).utc
4444
dataset = dataset.where(Sequel.lit('created_at > ?', normalized_timestamp))
4545
elsif operator == Event::GREATER_THAN_OR_EQUAL_COMPARATOR
4646
normalized_timestamp = Time.parse(given_timestamp).utc
4747
dataset = dataset.where(Sequel.lit('created_at >= ?', normalized_timestamp))
4848
end
4949
else
5050
# Gotcha: unlike the other relational operators, which are hashes such as
51-
# { lt: '2020-06-30T12:34:56Z' }, the equals operator is simply a value, e.g.
52-
# '2020-06-30T12:34:56Z'.
51+
# { lt: '2020-06-30T12:34:56Z' }, the equals operator is simply an array, e.g.
52+
# [ '2020-06-30T12:34:56Z' ].
5353
# Gotcha: the equals operator returns all resources occurring within
5454
# the span of the second (e.g. "12:34:56.00-12:34:56.9999999"), for databases store
5555
# timestamps in sub-second accuracy (PostgreSQL stores in microseconds, for example)
56-
lower_bound = Time.parse(message.created_ats).utc
57-
upper_bound = Time.at(lower_bound + 0.99999).utc
58-
dataset = dataset.where(Sequel.lit('created_at BETWEEN ? AND ?', lower_bound, upper_bound))
56+
sequel_query =
57+
(['created_at BETWEEN ? AND ?'] * message.created_ats.size).join(' OR ')
58+
59+
times = message.created_ats.map do |created_at|
60+
lower_bound = Time.parse(created_at).utc
61+
upper_bound = Time.at(lower_bound + 0.999999).utc
62+
[lower_bound, upper_bound]
63+
end.flatten
64+
65+
dataset = dataset.where(Sequel.lit(sequel_query, *times))
5966
end
6067
end
6168

app/messages/base_message.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def self.from_params(params, to_array_keys, fields: [])
7979

8080
def self.to_array!(params, key)
8181
return if params[key].nil?
82+
return if params[key].is_a?(Hash)
8283

8384
params[key] = if params[key] == ''
8485
['']

app/messages/events_list_message.rb

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,41 @@ class EventsListMessage < ListMessage
55
class CreatedAtValidator < ActiveModel::Validator
66
def validate(record)
77
if record.requested?(:created_ats)
8-
if record.created_ats.is_a?(String)
9-
timestamp = record.created_ats
8+
if record.created_ats.is_a?(Array)
9+
record.created_ats.each do |timestamp|
10+
opinionated_iso_8601(timestamp, record)
11+
end
1012
else
1113
unless record.created_ats.is_a?(Hash)
1214
record.errors[:created_ats] << 'comparison operator and timestamp must be specified'
1315
return
1416
end
1517

16-
comparison_operator = record.created_ats.keys[0]
1718
valid_comparision_operators = [
1819
Event::LESS_THAN_COMPARATOR,
1920
Event::GREATER_THAN_COMPARATOR,
2021
Event::LESS_THAN_OR_EQUAL_COMPARATOR,
2122
Event::GREATER_THAN_OR_EQUAL_COMPARATOR,
2223
]
23-
unless valid_comparision_operators.include?(comparison_operator)
24-
record.errors[:created_ats] << "Invalid comparison operator: '#{comparison_operator}'"
25-
end
2624

27-
timestamp = record.created_ats.values[0]
28-
end
29-
begin
30-
raise ArgumentError.new('invalid date') unless timestamp =~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z/
25+
record.created_ats.each do |comparison_operator, timestamp|
26+
unless valid_comparision_operators.include?(comparison_operator)
27+
record.errors[:created_ats] << "Invalid comparison operator: '#{comparison_operator}'"
28+
end
3129

32-
Time.iso8601(timestamp)
33-
rescue
34-
record.errors[:created_ats] << "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'"
35-
return
30+
opinionated_iso_8601(timestamp, record)
31+
end
3632
end
3733
end
3834
end
35+
36+
private
37+
38+
def opinionated_iso_8601(timestamp, record)
39+
if timestamp !~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z/
40+
record.errors[:created_ats] << "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'"
41+
end
42+
end
3943
end
4044

4145
register_allowed_keys [
@@ -55,7 +59,7 @@ def validate(record)
5559
validates :organization_guids, array: true, allow_nil: true
5660

5761
def self.from_params(params)
58-
super(params, %w(types target_guids space_guids organization_guids))
62+
super(params, %w(types target_guids space_guids organization_guids created_ats))
5963
end
6064
end
6165
end

docs/v3/source/includes/concepts/_filters.md.erb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ This will return all apps with name `the_name`.
1515

1616
This will return all apps with name `the_name` OR `second_name`.
1717

18+
In the case of audit events, multiple timestamps can be requested, which will return all audit
19+
events that occurred at those timestamps. In the following request, all audit events that occurred
20+
New Year's just before midnight and July 4th at noon will be returned:
21+
22+
`GET /v3/audit_events?created_ats=2019-12-31T23:59:59Z,2020-07-04T12:00:00Z`
23+
1824
###### **Exception**
1925
The `label_selector` query parameter will act as AND function, not an OR.
2026

docs/v3/source/includes/resources/audit_events/_list.md.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Retrieve all audit events the user has access to.
3030

3131
Name | Type | Description
3232
---- | ---- | ------------
33-
**created_ats** (*experimental*)| _[timestamp](#timestamps)_ | Timestamp to filter by. Only supports relational operators, see [relational operators](#relational-operators-experimental)
33+
**created_ats** (*experimental*)| _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators-experimental)
3434
**types** | _list of strings_ | Comma-delimited list of event types to filter by
3535
**target_guids** | _list of strings_ | Comma-delimited list of target guids to filter by
3636
**space_guids** | _list of strings_ | Comma-delimited list of space guids to filter by

spec/request/events_spec.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,11 +343,12 @@
343343
it 'returns events at the given timestamp' do
344344
get "/v3/audit_events?created_ats=#{timestamp}", nil, admin_header
345345

346+
expect(last_response).to have_status_code(200)
346347
expect(
347348
resources: parsed_response['resources']
348349
).to match_json_response(
349350
resources: [same_time_event_json]
350-
)
351+
)
351352
end
352353
end
353354

spec/unit/fetchers/event_list_fetcher_spec.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ module VCAP::CloudController
148148

149149
context 'requesting events equal to a timestamp' do
150150
let(:filters) do
151-
{ created_ats: event_3.created_at.iso8601 }
151+
{ created_ats: [event_3.created_at.iso8601] }
152152
end
153153

154154
it 'returns events with a created_at timestamp at or after a given timestamp' do
@@ -163,6 +163,25 @@ module VCAP::CloudController
163163
end
164164
end
165165
end
166+
167+
context 'requesting events equal to several timestamps' do
168+
let!(:event_5) { Event.make(guid: '5', created_at: '2020-05-26T18:47:05Z') }
169+
let(:filters) do
170+
{ created_ats: [event_2.created_at.iso8601, event_4.created_at.iso8601] }
171+
end
172+
173+
it 'returns events with a created_at timestamp at or after a given timestamp' do
174+
expect(subject).to match_array([event_2, event_4])
175+
end
176+
177+
context 'when there are events with subsecond timestamps' do
178+
let!(:event_between_2_and_3) { Event.make(guid: '2.5', created_at: '2020-05-26T18:47:02.5Z') }
179+
180+
it 'returns events with a created_at timestamp at a given timestamp' do
181+
expect(subject).to match_array([event_2, event_between_2_and_3, event_4])
182+
end
183+
end
184+
end
166185
end
167186
end
168187
end

spec/unit/messages/base_message_spec.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ class ParamsClass < BaseMessage
132132
string_field: 'stringval&',
133133
nil_field: nil,
134134
empty_field: '',
135+
hash_not_array: { pi: 3.141592653589792 }
135136
}
136137
end
137138

@@ -166,6 +167,10 @@ class ParamsClass < BaseMessage
166167
it 'handles empty values' do
167168
expect(BaseMessage.to_array!(params, :empty_field)).to eq([''])
168169
end
170+
171+
it "doesn't try to convert a hash to an array" do
172+
expect(BaseMessage.to_array!(params, :hash_not_array)).to eq(nil)
173+
end
169174
end
170175

171176
describe 'additional keys validation' do

spec/unit/messages/events_list_message_spec.rb

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ module VCAP::CloudController
6565
end
6666

6767
context 'validates the created_ats filter' do
68-
it 'requires a hash or a timestamp' do
69-
message = EventsListMessage.from_params({ created_ats: [Time.now.utc.iso8601] })
68+
it 'requires a hash or an array of timestamps' do
69+
message = EventsListMessage.from_params({ created_ats: 47 })
7070
expect(message).not_to be_valid
7171
expect(message.errors[:created_ats]).to include('comparison operator and timestamp must be specified')
7272
end
@@ -78,23 +78,36 @@ module VCAP::CloudController
7878
end
7979

8080
context 'requires a valid timestamp' do
81+
it "won't accept a malformed timestamp" do
82+
message = EventsListMessage.from_params({ 'created_ats' => "#{Time.now.utc.iso8601},bogus" })
83+
expect(message).not_to be_valid
84+
expect(message.errors[:created_ats]).to include("has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'")
85+
end
86+
8187
it "won't accept garbage" do
8288
message = EventsListMessage.from_params({ created_ats: { gt: 123 } })
8389
expect(message).not_to be_valid
8490
expect(message.errors[:created_ats]).to include("has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'")
8591
end
92+
8693
it "won't accept fractional seconds even though it's ISO 8601-compliant" do
8794
message = EventsListMessage.from_params({ created_ats: { gt: '2020-06-30T12:34:56.78Z' } })
8895
expect(message).not_to be_valid
8996
expect(message.errors[:created_ats]).to include("has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'")
9097
end
98+
9199
it "won't accept local time zones even though it's ISO 8601-compliant" do
92100
message = EventsListMessage.from_params({ created_ats: { gt: '2020-06-30T12:34:56.78-0700' } })
93101
expect(message).not_to be_valid
94102
expect(message.errors[:created_ats]).to include("has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'")
95103
end
96104
end
97105

106+
it 'allows comma-separated timestamps' do
107+
message = EventsListMessage.from_params({ 'created_ats' => "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}" })
108+
expect(message).to be_valid
109+
end
110+
98111
it 'allows the lt operator' do
99112
message = EventsListMessage.from_params({ created_ats: { lt: Time.now.utc.iso8601 } })
100113
expect(message).to be_valid
@@ -117,14 +130,9 @@ module VCAP::CloudController
117130

118131
context 'when the operator is an equals operator' do
119132
it 'allows the equals operator' do
120-
message = EventsListMessage.from_params({ created_ats: Time.now.utc.iso8601 })
133+
message = EventsListMessage.from_params({ 'created_ats' => Time.now.utc.iso8601 })
121134
expect(message).to be_valid
122135
end
123-
124-
it 'errors on invalid (non-ISO 8601) timestamps' do
125-
message = EventsListMessage.from_params({ created_ats: 'yesterday' })
126-
expect(message).to be_invalid
127-
end
128136
end
129137
end
130138
end

0 commit comments

Comments
 (0)