noresponse 5.1.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/lib/no_response/late_results.rb +10 -8
  3. data/lib/no_response/models/shared/accountdeleted.rb +49 -0
  4. data/lib/no_response/models/shared/accountdeleted.rbi +26 -0
  5. data/lib/no_response/models/shared/accountdeletedevent.rb +70 -0
  6. data/lib/no_response/models/shared/accountdeletedevent.rbi +38 -0
  7. data/lib/no_response/models/shared/accountriskchanged.rb +44 -0
  8. data/lib/no_response/models/shared/accountriskchanged.rbi +20 -0
  9. data/lib/no_response/models/shared/accountriskchangedevent.rb +70 -0
  10. data/lib/no_response/models/shared/accountriskchangedevent.rbi +38 -0
  11. data/lib/no_response/models/shared/assessmentexpired.rb +82 -0
  12. data/lib/no_response/models/shared/assessmentexpired.rbi +38 -0
  13. data/lib/no_response/models/shared/assessmentupdateddata.rb +23 -0
  14. data/lib/no_response/models/shared/assessmentupdateddata.rbi +9 -0
  15. data/lib/no_response/models/shared/assessmentupdatedevent.rb +79 -0
  16. data/lib/no_response/models/shared/assessmentupdatedevent.rbi +38 -0
  17. data/lib/no_response/models/shared/endpointverification.rb +41 -0
  18. data/lib/no_response/models/shared/endpointverification.rbi +20 -0
  19. data/lib/no_response/models/shared/endpointverificationevent.rb +70 -0
  20. data/lib/no_response/models/shared/endpointverificationevent.rbi +38 -0
  21. data/lib/no_response/models/shared/environment.rb +16 -0
  22. data/lib/no_response/models/shared/environment.rbi +9 -0
  23. data/lib/no_response/models/shared/webhookendpointdisabled.rb +49 -0
  24. data/lib/no_response/models/shared/webhookendpointdisabled.rbi +26 -0
  25. data/lib/no_response/models/shared/webhookendpointdisabledevent.rb +70 -0
  26. data/lib/no_response/models/shared/webhookendpointdisabledevent.rbi +38 -0
  27. data/lib/no_response/models/shared/webhookevent.rb +23 -0
  28. data/lib/no_response/models/shared/webhookevent.rbi +9 -0
  29. data/lib/no_response/models/shared/webhookverificationresponse.rb +33 -0
  30. data/lib/no_response/models/shared/webhookverificationresponse.rbi +14 -0
  31. data/lib/no_response/models/shared/workspacedeleted.rb +55 -0
  32. data/lib/no_response/models/shared/workspacedeleted.rbi +26 -0
  33. data/lib/no_response/models/shared/workspacedeletedevent.rb +70 -0
  34. data/lib/no_response/models/shared/workspacedeletedevent.rbi +38 -0
  35. data/lib/no_response/models/shared.rb +16 -0
  36. data/lib/no_response/sdk_hooks/registration.rb +0 -1
  37. data/lib/no_response/sdkconfiguration.rb +2 -2
  38. data/lib/no_response/webhook_verification.rb +170 -0
  39. data/lib/noresponse.rb +2 -0
  40. metadata +34 -2
  41. data/lib/no_response/webhooks.rb +0 -264
@@ -1,264 +0,0 @@
1
- # typed: true
2
- # frozen_string_literal: true
3
-
4
- # Webhook verification. Hand-written beside the generated client.
5
- #
6
- # Verify the exact bytes received: the signature is HMAC-SHA256 over
7
- # "<timestamp>.<raw body>", so re-serialising parsed JSON breaks it.
8
-
9
- require 'json'
10
- require 'openssl'
11
- require 'time'
12
-
13
- module NoResponse
14
- module Webhooks
15
- DEFAULT_TOLERANCE_SECONDS = 300
16
-
17
- REQUIRED = %w[nr-webhook-id nr-webhook-timestamp nr-webhook-key-id nr-webhook-signature].freeze
18
- KEY_ID = /\Awhk_[A-Za-z0-9]{8,64}\z/
19
- SIGNATURE = /\Av1=[0-9a-f]{64}\z/
20
- TIMESTAMP = /\A[0-9]{1,12}\z/
21
- CHALLENGE = /\A[A-Za-z0-9_-]{43}\z/
22
- EVENT_ID = /\Aevt_[0-9A-HJKMNP-TV-Z]{26}\z/
23
- ASSESSMENT_ID = /\Aasm_[0-9A-HJKMNP-TV-Z]{26}\z/
24
- ACCOUNT_ID = /\A[A-Za-z0-9][A-Za-z0-9._:|-]{0,127}\z/
25
- ENDPOINT_ID = /\Awhe_[0-9A-HJKMNP-TV-Z]{26}\z/
26
- WORKSPACE_ID = /\A[A-Za-z0-9][A-Za-z0-9_.-]{0,122}\z/
27
-
28
- # Raised by construct_event. Answer 400 and do not process the delivery.
29
- # code is one of :missing_header, :duplicate_header, :invalid_timestamp,
30
- # :unknown_key, :invalid_signature or :invalid_payload.
31
- class VerificationError < StandardError
32
- attr_reader :code
33
-
34
- def initialize(code, message)
35
- super(message)
36
- @code = code
37
- end
38
- end
39
-
40
- # The final event for an assessment: it has expired and get now answers
41
- # 404. assessment_version is the last served version.
42
- AssessmentExpired = Struct.new(:assessment_id, :account_id, :assessment_version, keyword_init: true) do
43
- def status = 'expired'
44
- def change_reason = 'expired'
45
- end
46
-
47
- EndpointVerification = Struct.new(:endpoint_id, :challenge, keyword_init: true)
48
-
49
- # An account you erased with accounts.delete: delete what you hold for it.
50
- # assessments_deleted is at least 1, and deleted_at equals deleted_at in
51
- # the delete answer.
52
- AccountDeleted = Struct.new(:account_id, :assessments_deleted, :deleted_at, keyword_init: true)
53
-
54
- # The workspace was deleted. Sent once per environment ("live" or "test")
55
- # before its endpoints are torn down: the last event this endpoint receives.
56
- WorkspaceDeleted = Struct.new(:workspace_id, :environment, :deleted_at, keyword_init: true)
57
-
58
- # Another endpoint of this environment was disabled after failing
59
- # continuously for 72 hours, since failing_since. It receives nothing more
60
- # until it is verified again.
61
- EndpointDisabled = Struct.new(:endpoint_id, :failing_since, :disabled_at, keyword_init: true)
62
-
63
- # The account's decision for at least one flow changed. account is a
64
- # Models::Shared::Account exactly as accounts.get returns it, with its new
65
- # risk and decisions; previous_decisions_by_flow is a
66
- # Models::Shared::AccountDecisionsByFlow holding the decisions it replaced.
67
- # It is not sent when the score moves within the same decision, nor for an
68
- # account's first risk.
69
- AccountRiskChanged = Struct.new(:account, :previous_decisions_by_flow, keyword_init: true)
70
-
71
- # A verified delivery. data is a Models::Shared::Assessment (exactly as
72
- # assessments.get returns it) or an AssessmentExpired for type
73
- # "assessment.updated", an EndpointVerification for
74
- # "endpoint.verification", an AccountDeleted for "account.deleted", a
75
- # WorkspaceDeleted for "workspace.deleted", an EndpointDisabled for
76
- # "webhook_endpoint.disabled" and an AccountRiskChanged for
77
- # "account.risk_changed". Deliveries are at least once and unordered:
78
- # deduplicate on id and keep the highest detail.assessment_version.
79
- Event = Struct.new(:id, :type, :created_at, :data, :test, keyword_init: true) do
80
- # True for the terminal expiry event: delete what you hold and fetch nothing.
81
- def expired? = data.is_a?(AssessmentExpired)
82
-
83
- # True on an event sent by the send-test-event call: data is a fixed
84
- # example assessment, not one of yours. Acknowledge it and apply nothing.
85
- def test? = test == true
86
- end
87
-
88
- class << self
89
-
90
- # Verify one delivery and return the typed Event.
91
- #
92
- # payload is the raw request body. headers is a Hash, Rack env-style
93
- # pairs or any object with each; each NR-Webhook-* header must appear
94
- # exactly once, in any letter case. secrets maps each whk_ key id
95
- # NoResponse gave you to its secret; hold both during a rotation.
96
- def construct_event(payload, headers, secrets, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: Time.now.to_i)
97
- raw = payload.to_s.b
98
- found = collect(headers)
99
- timestamp = found['nr-webhook-timestamp']
100
- if !TIMESTAMP.match?(timestamp) || (now.to_i - timestamp.to_i).abs > tolerance_seconds
101
- raise VerificationError.new(:invalid_timestamp, 'Webhook timestamp is outside the replay window')
102
- end
103
-
104
- key_id = found['nr-webhook-key-id']
105
- unless KEY_ID.match?(key_id) && secrets.key?(key_id)
106
- raise VerificationError.new(:unknown_key, 'Webhook key id is not one this receiver holds')
107
- end
108
-
109
- signature = found['nr-webhook-signature']
110
- raise VerificationError.new(:invalid_signature, 'Webhook signature is malformed') unless SIGNATURE.match?(signature)
111
-
112
- expected = OpenSSL::HMAC.hexdigest('SHA256', secrets.fetch(key_id).to_s, "#{timestamp}.".b + raw)
113
- unless OpenSSL.fixed_length_secure_compare(expected, signature.delete_prefix('v1='))
114
- raise VerificationError.new(:invalid_signature, 'Webhook signature does not match')
115
- end
116
-
117
- event(raw, found['nr-webhook-id'])
118
- end
119
-
120
- # The body to answer an endpoint.verification event with, as JSON, status 200.
121
- def verification_response(event)
122
- unless event.data.is_a?(EndpointVerification)
123
- raise VerificationError.new(:invalid_payload, 'Not an endpoint.verification event')
124
- end
125
-
126
- { 'challenge' => event.data.challenge }
127
- end
128
-
129
- private
130
-
131
- def collect(headers)
132
- found = {}
133
- headers.each do |name, value|
134
- key = name.to_s.downcase.tr('_', '-').delete_prefix('http-')
135
- next unless REQUIRED.include?(key)
136
-
137
- values = Array(value)
138
- if values.length != 1 || found.key?(key)
139
- raise VerificationError.new(:duplicate_header, "Webhook header #{key} is repeated")
140
- end
141
-
142
- found[key] = values.first.to_s
143
- end
144
- REQUIRED.each do |key|
145
- raise VerificationError.new(:missing_header, "Webhook header #{key} is missing") if found[key].to_s.empty?
146
- end
147
- found
148
- end
149
-
150
- def event(raw, header_id)
151
- body = begin
152
- JSON.parse(raw)
153
- rescue JSON::ParserError
154
- raise VerificationError.new(:invalid_payload, 'Webhook body is not JSON')
155
- end
156
- unless body.is_a?(Hash) && body['id'] == header_id && EVENT_ID.match?(body['id'].to_s)
157
- raise VerificationError.new(:invalid_payload, 'Webhook event id does not match NR-Webhook-Id')
158
- end
159
-
160
- data = body['data']
161
- unless body['created_at'].is_a?(String) && valid_datetime?(body['created_at']) && data.is_a?(Hash)
162
- raise VerificationError.new(:invalid_payload, 'Webhook event envelope is malformed')
163
- end
164
-
165
- Event.new(id: body['id'], type: body['type'], created_at: body['created_at'], data: event_data(body['type'], data),
166
- test: body['test'] == true && body['type'] == 'assessment.updated')
167
- end
168
-
169
- def event_data(type, data)
170
- case type
171
- when 'endpoint.verification'
172
- unless ENDPOINT_ID.match?(data['endpoint_id'].to_s) && CHALLENGE.match?(data['challenge'].to_s)
173
- raise VerificationError.new(:invalid_payload, 'Endpoint verification event is malformed')
174
- end
175
-
176
- EndpointVerification.new(endpoint_id: data['endpoint_id'], challenge: data['challenge'])
177
- when 'assessment.updated'
178
- return expired(data) if data['status'] == 'expired'
179
- begin
180
- Crystalline.unmarshal_json(data, Models::Shared::Assessment)
181
- rescue KeyError, TypeError, ArgumentError, NoMethodError
182
- raise VerificationError.new(:invalid_payload, 'Webhook data is not an assessment')
183
- end
184
- when 'account.deleted'
185
- account_deleted(data)
186
- when 'workspace.deleted'
187
- workspace_deleted(data)
188
- when 'webhook_endpoint.disabled'
189
- endpoint_disabled(data)
190
- when 'account.risk_changed'
191
- account_risk_changed(data)
192
- else
193
- raise VerificationError.new(:invalid_payload, 'Unknown webhook event type')
194
- end
195
- end
196
-
197
- def expired(data)
198
- unless ASSESSMENT_ID.match?(data['assessment_id'].to_s) &&
199
- ACCOUNT_ID.match?(data['account_id'].to_s) && data['assessment_version'].is_a?(Integer) &&
200
- data['assessment_version'] >= 1 && data['status'] == 'expired' && data['change_reason'] == 'expired'
201
- raise VerificationError.new(:invalid_payload, 'Expired assessment event is malformed')
202
- end
203
-
204
- AssessmentExpired.new(assessment_id: data['assessment_id'], account_id: data['account_id'],
205
- assessment_version: data['assessment_version'])
206
- end
207
-
208
- def account_deleted(data)
209
- unless data['account_id'].is_a?(String) && ACCOUNT_ID.match?(data['account_id']) &&
210
- data['assessments_deleted'].is_a?(Integer) && data['assessments_deleted'] >= 1 &&
211
- data['deleted_at'].is_a?(String) && valid_datetime?(data['deleted_at'])
212
- raise VerificationError.new(:invalid_payload, 'Account deleted event is malformed')
213
- end
214
-
215
- AccountDeleted.new(account_id: data['account_id'], assessments_deleted: data['assessments_deleted'],
216
- deleted_at: data['deleted_at'])
217
- end
218
-
219
- def workspace_deleted(data)
220
- unless data['workspace_id'].is_a?(String) && WORKSPACE_ID.match?(data['workspace_id']) &&
221
- %w[live test].include?(data['environment']) &&
222
- data['deleted_at'].is_a?(String) && valid_datetime?(data['deleted_at'])
223
- raise VerificationError.new(:invalid_payload, 'Workspace deleted event is malformed')
224
- end
225
-
226
- WorkspaceDeleted.new(workspace_id: data['workspace_id'], environment: data['environment'],
227
- deleted_at: data['deleted_at'])
228
- end
229
-
230
- def endpoint_disabled(data)
231
- unless data['endpoint_id'].is_a?(String) && ENDPOINT_ID.match?(data['endpoint_id']) &&
232
- data['failing_since'].is_a?(String) && valid_datetime?(data['failing_since']) &&
233
- data['disabled_at'].is_a?(String) && valid_datetime?(data['disabled_at'])
234
- raise VerificationError.new(:invalid_payload, 'Endpoint disabled event is malformed')
235
- end
236
-
237
- EndpointDisabled.new(endpoint_id: data['endpoint_id'], failing_since: data['failing_since'],
238
- disabled_at: data['disabled_at'])
239
- end
240
-
241
- def account_risk_changed(data)
242
- account = data['account']
243
- previous = data['previous_decisions_by_flow']
244
- unless account.is_a?(Hash) && previous.is_a?(Hash) && account['id'].is_a?(String) && ACCOUNT_ID.match?(account['id'])
245
- raise VerificationError.new(:invalid_payload, 'Account risk changed event is malformed')
246
- end
247
-
248
- begin
249
- AccountRiskChanged.new(account: Crystalline.unmarshal_json(account, Models::Shared::Account),
250
- previous_decisions_by_flow: Crystalline.unmarshal_json(previous, Models::Shared::AccountDecisionsByFlow))
251
- rescue KeyError, TypeError, ArgumentError, NoMethodError
252
- raise VerificationError.new(:invalid_payload, 'Account risk changed event is malformed')
253
- end
254
- end
255
-
256
- def valid_datetime?(value)
257
- Time.iso8601(value)
258
- true
259
- rescue ArgumentError
260
- false
261
- end
262
- end
263
- end
264
- end