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.
- checksums.yaml +4 -4
- data/lib/no_response/late_results.rb +10 -8
- data/lib/no_response/models/shared/accountdeleted.rb +49 -0
- data/lib/no_response/models/shared/accountdeleted.rbi +26 -0
- data/lib/no_response/models/shared/accountdeletedevent.rb +70 -0
- data/lib/no_response/models/shared/accountdeletedevent.rbi +38 -0
- data/lib/no_response/models/shared/accountriskchanged.rb +44 -0
- data/lib/no_response/models/shared/accountriskchanged.rbi +20 -0
- data/lib/no_response/models/shared/accountriskchangedevent.rb +70 -0
- data/lib/no_response/models/shared/accountriskchangedevent.rbi +38 -0
- data/lib/no_response/models/shared/assessmentexpired.rb +82 -0
- data/lib/no_response/models/shared/assessmentexpired.rbi +38 -0
- data/lib/no_response/models/shared/assessmentupdateddata.rb +23 -0
- data/lib/no_response/models/shared/assessmentupdateddata.rbi +9 -0
- data/lib/no_response/models/shared/assessmentupdatedevent.rb +79 -0
- data/lib/no_response/models/shared/assessmentupdatedevent.rbi +38 -0
- data/lib/no_response/models/shared/endpointverification.rb +41 -0
- data/lib/no_response/models/shared/endpointverification.rbi +20 -0
- data/lib/no_response/models/shared/endpointverificationevent.rb +70 -0
- data/lib/no_response/models/shared/endpointverificationevent.rbi +38 -0
- data/lib/no_response/models/shared/environment.rb +16 -0
- data/lib/no_response/models/shared/environment.rbi +9 -0
- data/lib/no_response/models/shared/webhookendpointdisabled.rb +49 -0
- data/lib/no_response/models/shared/webhookendpointdisabled.rbi +26 -0
- data/lib/no_response/models/shared/webhookendpointdisabledevent.rb +70 -0
- data/lib/no_response/models/shared/webhookendpointdisabledevent.rbi +38 -0
- data/lib/no_response/models/shared/webhookevent.rb +23 -0
- data/lib/no_response/models/shared/webhookevent.rbi +9 -0
- data/lib/no_response/models/shared/webhookverificationresponse.rb +33 -0
- data/lib/no_response/models/shared/webhookverificationresponse.rbi +14 -0
- data/lib/no_response/models/shared/workspacedeleted.rb +55 -0
- data/lib/no_response/models/shared/workspacedeleted.rbi +26 -0
- data/lib/no_response/models/shared/workspacedeletedevent.rb +70 -0
- data/lib/no_response/models/shared/workspacedeletedevent.rbi +38 -0
- data/lib/no_response/models/shared.rb +16 -0
- data/lib/no_response/sdk_hooks/registration.rb +0 -1
- data/lib/no_response/sdkconfiguration.rb +2 -2
- data/lib/no_response/webhook_verification.rb +170 -0
- data/lib/noresponse.rb +2 -0
- metadata +34 -2
- data/lib/no_response/webhooks.rb +0 -264
data/lib/no_response/webhooks.rb
DELETED
|
@@ -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
|