noresponse 2.1.15 → 3.0.1
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/accounts.rb +1 -1
- data/lib/no_response/assessments.rb +2 -2
- data/lib/no_response/late_results.rb +108 -35
- data/lib/no_response/models/shared/account.rb +1 -1
- data/lib/no_response/models/shared/accountdecisionsbyflow.rb +1 -1
- data/lib/no_response/models/shared/assessment.rb +41 -176
- data/lib/no_response/models/shared/assessment.rbi +10 -82
- data/lib/no_response/models/shared/assessmentdetail.rb +245 -0
- data/lib/no_response/models/shared/assessmentdetail.rbi +122 -0
- data/lib/no_response/models/shared/{assessmentauthcontext.rb → assessmentdetailauthcontext.rb} +1 -1
- data/lib/no_response/models/shared/{assessmentauthcontext.rbi → assessmentdetailauthcontext.rbi} +2 -2
- data/lib/no_response/models/shared/{assessmenttiming.rb → assessmentdetailtiming.rb} +1 -1
- data/lib/no_response/models/shared/{assessmenttiming.rbi → assessmentdetailtiming.rbi} +2 -2
- data/lib/no_response/models/shared/assessmentlist.rb +3 -3
- data/lib/no_response/models/shared/assessmentrequest.rb +15 -14
- data/lib/no_response/models/shared/assessmentrequest.rbi +4 -4
- data/lib/no_response/models/shared/assessmentrequestpolicycontext.rb +3 -16
- data/lib/no_response/models/shared/assessmentrequestpolicycontext.rbi +0 -6
- data/lib/no_response/models/shared/depth.rb +4 -1
- data/lib/no_response/models/shared/flow.rb +6 -4
- data/lib/no_response/models/shared.rb +3 -3
- data/lib/no_response/sdkconfiguration.rb +2 -2
- data/lib/no_response/webhooks.rb +1 -1
- metadata +7 -7
- data/lib/no_response/models/shared/assessmentflow.rb +0 -17
- data/lib/no_response/models/shared/assessmentflow.rbi +0 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ced920c7288c6434e1fbd06de24541b1ac3bec7dccb43bcef7aa7ccad375377
|
|
4
|
+
data.tar.gz: 67f9fc2cce7b114edd72d84031d91312c04414114fd61ce3f7db0c3e63c6714e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a7c8af727605dcef753fb10d0d3c46bf0a5fc8c90bb411d0117aceb023fb5807af788984dd5d77a09dc984a0979ee797ea75cdf6c301edc8d4aa4f74e3c83fdd
|
|
7
|
+
data.tar.gz: 02faf461ec16e415a4c799557fff8ae5e9bdcd5f62a192dfa5682c0a37bdbe10c00176d71b7de6f360616442f00b055ce429be012dc70a24acf75b18bdc3fc96
|
data/lib/no_response/accounts.rb
CHANGED
|
@@ -49,7 +49,7 @@ module NoResponse
|
|
|
49
49
|
}
|
|
50
50
|
def get(account_id:, retries: nil, timeout_ms: nil, http_headers: nil)
|
|
51
51
|
# get - Read an account's one current risk.
|
|
52
|
-
# An account is the account_id you send with your assessments. Its current risk is the risk of its latest assessment with a decision (status complete or partial), scored as GET /v1/assessments/{assessment_id} scores that assessment, with a decision for each flow. Every assessment updates its account: once an assessment completes, the account carries its risk. Requires the history scope. Read-only and safe to retry. An account with no unexpired assessment that has a decision answers 404 not_found, and a malformed account_id answers 400 invalid_request.
|
|
52
|
+
# An account is the account_id you send with your assessments. Its current risk is the risk of its latest assessment with a decision (status complete or partial), scored as GET /v1/assessments/{assessment_id} scores that assessment, with a decision for each flow; risk.decision is the decision for the flow of that assessment. Every assessment answers with this same account. Every assessment updates its account: once an assessment completes, the account carries its risk. Requires the history scope. Read-only and safe to retry. An account with no unexpired assessment that has a decision answers 404 not_found, and a malformed account_id answers 400 invalid_request.
|
|
53
53
|
request = Models::Operations::GetAccountRequest.new(
|
|
54
54
|
account_id: account_id
|
|
55
55
|
)
|
|
@@ -50,7 +50,7 @@ module NoResponse
|
|
|
50
50
|
}
|
|
51
51
|
def create(body:, idempotency_key: nil, retries: nil, timeout_ms: nil, http_headers: nil)
|
|
52
52
|
# create - Assess identifiers for a signup or gateway decision.
|
|
53
|
-
#
|
|
53
|
+
# Assesses one signup, login or purchase (flow) and answers with the account's one current risk: account.risk.decision is the decision for this flow (allow, step_up, review or block, where block means your block threshold was reached), and detail holds this assessment's own facts. depth sets how long the answer waits for sources: quick waits only for the fast ones (500 ms), full for the whole budget (3000 ms at most); it defaults to full for signup and purchase and quick for login. 200 carries complete or partial, 202 carries pending with Location and Retry-After. While the account's first assessment is pending, account is absent: apply your own fallback for that, 429, 503 or a timeout. NoResponse blocks nothing itself; you decide. Requires the assess scope. Idempotency-Key is optional in the SDK and is bound to the body for 24 hours: the same key with the same body replays the latest assessment with Idempotent-Replayed: true and starts no new work; the same key with a different body answers 409 idempotency_key_reused. response_mode defaults to standard; detailed needs the detailed scope.
|
|
54
54
|
request = Models::Operations::CreateAssessmentRequest.new(
|
|
55
55
|
body: body,
|
|
56
56
|
idempotency_key: idempotency_key
|
|
@@ -348,7 +348,7 @@ module NoResponse
|
|
|
348
348
|
}
|
|
349
349
|
def get(assessment_id:, response_mode: nil, retries: nil, timeout_ms: nil, http_headers: nil)
|
|
350
350
|
# get - Poll the authoritative assessment of one assessment.
|
|
351
|
-
# Returns the highest assessment_version. Requires the assess or history scope. While status is pending, or partial with a source still running, the response carries Retry-After; poll no faster than it says. Another tenant's assessment, an unknown or expired assessment and a malformed assessment_id all answer 404 not_found. response_mode=detailed needs the detailed scope.
|
|
351
|
+
# Returns the highest assessment_version, with the account's current risk. Requires the assess or history scope. While status is pending, or partial with a source still running, the response carries Retry-After; poll no faster than it says. Another tenant's assessment, an unknown or expired assessment and a malformed assessment_id all answer 404 not_found. response_mode=detailed needs the detailed scope.
|
|
352
352
|
request = Models::Operations::GetAssessmentRequest.new(
|
|
353
353
|
assessment_id: assessment_id,
|
|
354
354
|
response_mode: response_mode
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# Late-result handlers. Hand-written beside the generated client.
|
|
5
5
|
#
|
|
6
6
|
# An assessment can be revised after the signup was allowed: a slow source
|
|
7
|
-
# settles and the
|
|
7
|
+
# settles and the account's decision rises. NoResponse sends the new version as a
|
|
8
8
|
# signed assessment.updated webhook. LateResults.handle verifies that delivery
|
|
9
9
|
# with Webhooks.construct_event, picks an action from your policy, and acts
|
|
10
10
|
# through the auth provider's admin API: revoke the user's sessions or ban the
|
|
@@ -30,10 +30,12 @@ module NoResponse
|
|
|
30
30
|
BAN = 'ban'
|
|
31
31
|
ACTIONS = [NONE, REVOKE_SESSIONS, BAN].freeze
|
|
32
32
|
|
|
33
|
-
# Revoke sessions on review; do nothing on allow or step_up.
|
|
34
|
-
|
|
33
|
+
# Revoke sessions on review and block; do nothing on allow or step_up.
|
|
34
|
+
# Banning is opt-in.
|
|
35
|
+
DEFAULT_POLICY = { 'allow' => NONE, 'step_up' => NONE, 'review' => REVOKE_SESSIONS,
|
|
36
|
+
'block' => REVOKE_SESSIONS }.freeze
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
DECISIONS = %w[allow step_up review block].freeze
|
|
37
39
|
|
|
38
40
|
# Raised when the provider's admin API refuses a call. Answer the webhook
|
|
39
41
|
# with a 5xx so NoResponse retries it. The message holds no response body
|
|
@@ -61,23 +63,25 @@ module NoResponse
|
|
|
61
63
|
Result = Struct.new(:event, :action, :user_id, keyword_init: true)
|
|
62
64
|
|
|
63
65
|
class << self
|
|
64
|
-
# The action your policy names for a verified event
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
66
|
+
# The action your policy names for a verified event, from the account's
|
|
67
|
+
# decision (data.account.risk.decision). Only a served assessment whose
|
|
68
|
+
# account has a decision can act; a pending assessment, an expiry and a
|
|
69
|
+
# test event never do. policy maps allow, step_up, review and block (as
|
|
70
|
+
# strings or symbols) to NONE, REVOKE_SESSIONS or BAN; a decision you
|
|
71
|
+
# leave out does nothing. An unknown action raises ArgumentError.
|
|
69
72
|
def action_for(event, policy = DEFAULT_POLICY)
|
|
70
73
|
return NONE if event.type != 'assessment.updated' || event.test? || event.expired?
|
|
71
74
|
|
|
72
75
|
data = event.data
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
return NONE if data.status == Models::Shared::Status::PENDING || data.account.nil?
|
|
77
|
+
|
|
78
|
+
decision = data.account.risk.decision.to_s
|
|
79
|
+
return NONE unless DECISIONS.include?(decision)
|
|
76
80
|
|
|
77
81
|
table = policy.to_h.transform_keys(&:to_s)
|
|
78
|
-
return NONE unless table.key?(
|
|
82
|
+
return NONE unless table.key?(decision)
|
|
79
83
|
|
|
80
|
-
action = table[
|
|
84
|
+
action = table[decision]
|
|
81
85
|
return NONE if action.nil?
|
|
82
86
|
|
|
83
87
|
action = action.to_s
|
|
@@ -103,7 +107,7 @@ module NoResponse
|
|
|
103
107
|
action = action_for(event, policy)
|
|
104
108
|
return Result.new(event: event, action: NONE) if action == NONE || event.type != 'assessment.updated'
|
|
105
109
|
|
|
106
|
-
account_id = event.data.account_id
|
|
110
|
+
account_id = event.data.detail.account_id
|
|
107
111
|
resolved = user_id ? user_id.call(account_id, event) : account_id
|
|
108
112
|
return Result.new(event: event, action: NONE) if resolved.nil? || resolved.to_s.empty?
|
|
109
113
|
|
|
@@ -169,13 +173,19 @@ module NoResponse
|
|
|
169
173
|
# user before deleting the refresh tokens, and act reports BAN. Unblock
|
|
170
174
|
# the user in the dashboard to undo it. Any other refusal, such as 403
|
|
171
175
|
# insufficient_scope, raises.
|
|
176
|
+
#
|
|
177
|
+
# A 429 from the Management API is waited out and retried: Retry-After,
|
|
178
|
+
# else X-RateLimit-Reset, else 0.25 s doubling, at most 4 retries a call
|
|
179
|
+
# and 3 s of waiting a revoke or ban. Past that the 429 raises as
|
|
180
|
+
# ProviderActionError. An http_client may return [status, body, headers]
|
|
181
|
+
# so the headers are honoured; without them the backoff applies.
|
|
172
182
|
def auth0(token:, domain: nil, base_url: nil, http_client: nil)
|
|
173
183
|
raise ArgumentError, 'auth0 needs token' if token.to_s.empty?
|
|
174
184
|
|
|
175
185
|
base = base_url || (domain.to_s.empty? ? nil : "https://#{domain}/api/v2")
|
|
176
186
|
raise ArgumentError, 'auth0 needs domain or base_url' if base.nil?
|
|
177
187
|
|
|
178
|
-
Auth0.new(Caller.new('auth0', base, "Bearer #{token}", http_client))
|
|
188
|
+
Auth0.new(Caller.new('auth0', base, "Bearer #{token}", http_client, retry_rate_limits: true))
|
|
179
189
|
end
|
|
180
190
|
|
|
181
191
|
# A provider for an app with its own login, or for trying the handler
|
|
@@ -191,16 +201,62 @@ module NoResponse
|
|
|
191
201
|
end
|
|
192
202
|
|
|
193
203
|
# One provider HTTP client: base URL, Authorization header, error mapping.
|
|
204
|
+
#
|
|
205
|
+
# Rate limits. Auth0 answers 429 when the Management API rate limit is
|
|
206
|
+
# hit, and a 429 means it did not act, so the call is safe to send again,
|
|
207
|
+
# a DELETE included. One revoke or ban waits at most RATE_LIMIT_BUDGET
|
|
208
|
+
# seconds in all, and each call retries at most RATE_LIMIT_RETRIES times:
|
|
209
|
+
# it waits Retry-After seconds, else until X-RateLimit-Reset (Unix
|
|
210
|
+
# seconds), else 0.25, 0.5, 1 and 2 s. A wait that would pass the budget is
|
|
211
|
+
# not started: the 429 raises at once as ProviderActionError, the webhook
|
|
212
|
+
# answers 5xx and NoResponse delivers it again later. The budget keeps the
|
|
213
|
+
# handler inside the 5 s a delivery has to be answered.
|
|
194
214
|
class Caller
|
|
195
|
-
|
|
215
|
+
RATE_LIMIT_RETRIES = 4
|
|
216
|
+
RATE_LIMIT_BUDGET = 3.0
|
|
217
|
+
RATE_LIMIT_BACKOFF = 0.25
|
|
218
|
+
|
|
219
|
+
def initialize(name, base_url, authorization, http_client, retry_rate_limits: false)
|
|
196
220
|
@name = name
|
|
197
221
|
@base = base_url.to_s.sub(%r{/+\z}, '')
|
|
198
222
|
@authorization = authorization
|
|
199
223
|
@http_client = http_client
|
|
224
|
+
@retry_rate_limits = retry_rate_limits
|
|
225
|
+
@waited = 0.0
|
|
200
226
|
end
|
|
201
227
|
|
|
202
228
|
attr_reader :name
|
|
203
229
|
|
|
230
|
+
# A caller with the same settings and its own rate-limit budget, for one action.
|
|
231
|
+
def fresh
|
|
232
|
+
Caller.new(@name, @base, @authorization, @http_client, retry_rate_limits: @retry_rate_limits)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# How long a 429 asks to wait, in seconds.
|
|
236
|
+
def self.rate_limit_wait(headers, retry_index)
|
|
237
|
+
after = number(header(headers, 'retry-after'))
|
|
238
|
+
return after if after && after >= 0
|
|
239
|
+
|
|
240
|
+
reset = number(header(headers, 'x-ratelimit-reset'))
|
|
241
|
+
return [0.0, reset - Time.now.to_f].max if reset
|
|
242
|
+
|
|
243
|
+
RATE_LIMIT_BACKOFF * (2**retry_index)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def self.header(headers, name)
|
|
247
|
+
(headers || {}).each { |key, value| return Array(value).first.to_s.strip if key.to_s.downcase == name }
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def self.number(text)
|
|
252
|
+
return nil if text.nil? || text.empty?
|
|
253
|
+
|
|
254
|
+
value = Float(text)
|
|
255
|
+
value.finite? ? value : nil
|
|
256
|
+
rescue ArgumentError
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
|
|
204
260
|
def call(method, path, query: nil, body: nil, allow_404: false)
|
|
205
261
|
uri = URI.parse(@base + path)
|
|
206
262
|
uri.query = URI.encode_www_form(query) if query && !query.empty?
|
|
@@ -210,7 +266,7 @@ module NoResponse
|
|
|
210
266
|
headers['Content-Type'] = 'application/json'
|
|
211
267
|
payload = JSON.generate(body)
|
|
212
268
|
end
|
|
213
|
-
status, text =
|
|
269
|
+
status, text = send_waiting_out_rate_limits(method, uri, headers, payload)
|
|
214
270
|
return nil if allow_404 && status == 404
|
|
215
271
|
unless status.between?(200, 299)
|
|
216
272
|
raise ProviderActionError.new(@name, status, "#{@name} #{method} #{path} answered #{status}", error_code(text))
|
|
@@ -226,6 +282,21 @@ module NoResponse
|
|
|
226
282
|
|
|
227
283
|
private
|
|
228
284
|
|
|
285
|
+
def send_waiting_out_rate_limits(method, uri, headers, payload)
|
|
286
|
+
retry_index = 0
|
|
287
|
+
loop do
|
|
288
|
+
status, text, response_headers = perform(method, uri, headers, payload)
|
|
289
|
+
return [status, text] unless status == 429 && @retry_rate_limits && retry_index < RATE_LIMIT_RETRIES
|
|
290
|
+
|
|
291
|
+
wait = Caller.rate_limit_wait(response_headers, retry_index)
|
|
292
|
+
return [status, text] if @waited + wait > RATE_LIMIT_BUDGET
|
|
293
|
+
|
|
294
|
+
@waited += wait
|
|
295
|
+
sleep(wait)
|
|
296
|
+
retry_index += 1
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
229
300
|
# The error code in a provider's error body: Auth0's errorCode, or a top-level code.
|
|
230
301
|
def error_code(text)
|
|
231
302
|
body = JSON.parse(text.to_s)
|
|
@@ -247,7 +318,7 @@ module NoResponse
|
|
|
247
318
|
request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, uri.request_uri, headers)
|
|
248
319
|
request.body = payload unless payload.nil?
|
|
249
320
|
response = http.start { |connection| connection.request(request) }
|
|
250
|
-
[response.code.to_i, response.body.to_s]
|
|
321
|
+
[response.code.to_i, response.body.to_s, response.to_hash]
|
|
251
322
|
end
|
|
252
323
|
end
|
|
253
324
|
|
|
@@ -345,10 +416,11 @@ module NoResponse
|
|
|
345
416
|
|
|
346
417
|
def ban(user_id)
|
|
347
418
|
user = Segment.encode(user_id)
|
|
348
|
-
|
|
419
|
+
call = @call.fresh
|
|
420
|
+
block(call, user)
|
|
349
421
|
# The block already ends a session the plan cannot delete.
|
|
350
|
-
delete_sessions(user)
|
|
351
|
-
delete_refresh_tokens(user_id, user)
|
|
422
|
+
delete_sessions(call, user)
|
|
423
|
+
delete_refresh_tokens(call, user_id, user)
|
|
352
424
|
nil
|
|
353
425
|
end
|
|
354
426
|
|
|
@@ -366,10 +438,11 @@ module NoResponse
|
|
|
366
438
|
|
|
367
439
|
def revoke(user_id)
|
|
368
440
|
user = Segment.encode(user_id)
|
|
369
|
-
|
|
441
|
+
call = @call.fresh
|
|
442
|
+
ended = delete_sessions(call, user)
|
|
370
443
|
# Without the sessions API a login session survives; blocking is the only way to end it.
|
|
371
|
-
block(user) unless ended
|
|
372
|
-
delete_refresh_tokens(user_id, user)
|
|
444
|
+
block(call, user) unless ended
|
|
445
|
+
delete_refresh_tokens(call, user_id, user)
|
|
373
446
|
ended ? REVOKE_SESSIONS : BAN
|
|
374
447
|
end
|
|
375
448
|
|
|
@@ -378,13 +451,13 @@ module NoResponse
|
|
|
378
451
|
error.status == 403 && error.code == 'feature_not_enabled'
|
|
379
452
|
end
|
|
380
453
|
|
|
381
|
-
def block(user)
|
|
382
|
-
|
|
454
|
+
def block(call, user)
|
|
455
|
+
call.call('PATCH', "/users/#{user}", body: { 'blocked' => true }, allow_404: true)
|
|
383
456
|
end
|
|
384
457
|
|
|
385
458
|
# Ends every session; false when the plan has no sessions API.
|
|
386
|
-
def delete_sessions(user)
|
|
387
|
-
|
|
459
|
+
def delete_sessions(call, user)
|
|
460
|
+
call.call('DELETE', "/users/#{user}/sessions", allow_404: true)
|
|
388
461
|
true
|
|
389
462
|
rescue ProviderActionError => e
|
|
390
463
|
raise unless not_on_plan?(e)
|
|
@@ -392,9 +465,9 @@ module NoResponse
|
|
|
392
465
|
false
|
|
393
466
|
end
|
|
394
467
|
|
|
395
|
-
def delete_refresh_tokens(user_id, user)
|
|
468
|
+
def delete_refresh_tokens(call, user_id, user)
|
|
396
469
|
begin
|
|
397
|
-
|
|
470
|
+
call.call('DELETE', "/users/#{user}/refresh-tokens", allow_404: true)
|
|
398
471
|
return
|
|
399
472
|
rescue ProviderActionError => e
|
|
400
473
|
raise unless not_on_plan?(e)
|
|
@@ -403,15 +476,15 @@ module NoResponse
|
|
|
403
476
|
ids = []
|
|
404
477
|
%w[refresh_token rotating_refresh_token].each do |type|
|
|
405
478
|
10.times do |page|
|
|
406
|
-
body =
|
|
407
|
-
|
|
408
|
-
|
|
479
|
+
body = call.call('GET', '/device-credentials',
|
|
480
|
+
query: [['user_id', user_id], ['type', type], ['page', page.to_s], ['per_page', PAGE.to_s]],
|
|
481
|
+
allow_404: true)
|
|
409
482
|
listed = body.is_a?(Array) ? body.grep(Hash) : []
|
|
410
483
|
ids.concat(listed.map { |c| c['id'] }.select { |id| id.is_a?(String) && !id.empty? })
|
|
411
484
|
break if listed.length < PAGE
|
|
412
485
|
end
|
|
413
486
|
end
|
|
414
|
-
ids.each { |id|
|
|
487
|
+
ids.each { |id| call.call('DELETE', "/device-credentials/#{ERB::Util.url_encode(id)}", allow_404: true) }
|
|
415
488
|
end
|
|
416
489
|
end
|
|
417
490
|
|
|
@@ -17,7 +17,7 @@ module NoResponse
|
|
|
17
17
|
Models::Shared::AccountRisk,
|
|
18
18
|
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("risk"), required: true}}
|
|
19
19
|
)
|
|
20
|
-
# The decision for each flow. Every flow uses the tenant's signup thresholds today, so all three equal
|
|
20
|
+
# The decision for each flow. Every flow uses the tenant's signup thresholds today, so all three are equal.
|
|
21
21
|
field(
|
|
22
22
|
:decisions_by_flow,
|
|
23
23
|
Models::Shared::AccountDecisionsByFlow,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
module NoResponse
|
|
5
5
|
module Models
|
|
6
6
|
module Shared
|
|
7
|
-
# The decision for each flow. Every flow uses the tenant's signup thresholds today, so all three equal
|
|
7
|
+
# The decision for each flow. Every flow uses the tenant's signup thresholds today, so all three are equal.
|
|
8
8
|
class AccountDecisionsByFlow
|
|
9
9
|
extend T::Sig
|
|
10
10
|
include Crystalline::MetadataFields
|
|
@@ -4,23 +4,13 @@
|
|
|
4
4
|
module NoResponse
|
|
5
5
|
module Models
|
|
6
6
|
module Shared
|
|
7
|
-
# One assessment
|
|
7
|
+
# One assessment, as POST /v1/assessments, GET /v1/assessments/{assessment_id} and the assessment.updated webhook return it. account is the account's one current risk: its score, the decision for this assessment's flow, the reasons and the decision for each flow. detail holds this assessment's own facts. status says whether the assessment is final (complete), usable with a source still running (partial) or not yet usable (pending). While the account's first assessment is pending, account is absent; wait for the webhook or poll the assessment. Keep the highest detail.assessment_version.
|
|
8
8
|
class Assessment
|
|
9
9
|
extend T::Sig
|
|
10
10
|
include Crystalline::MetadataFields
|
|
11
11
|
|
|
12
|
-
#
|
|
13
|
-
field(
|
|
14
|
-
:assessment_id,
|
|
15
|
-
::String,
|
|
16
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("assessment_id"), required: true}}
|
|
17
|
-
)
|
|
18
|
-
# Your own opaque identifier for the account being checked, scoped to your tenant. Use an id you mint, with no direct identifier (email, phone, name or national id) in it. '|' is accepted so an Auth0 user id such as 'auth0|64abc' passes unchanged; '@', '+', '$' and whitespace are refused by the pattern, as is an exact-shape JWT; a hex digest such as a SHA-256 is allowed, because hashing an internal id is a common pseudonymous scheme. A value such as '07700900123' or 'jane.doe.gmail.com' cannot be told apart from an opaque id. It travels in bodies, and in a URL only as the path segment of GET /v1/accounts/{account_id} and its /assessments. An assessment requested by a provider hook at pre_registration, which has no account yet, carries the server-issued account_id pre: followed by its assessment_id.
|
|
19
|
-
field(
|
|
20
|
-
:account_id,
|
|
21
|
-
::String,
|
|
22
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("account_id"), required: true}}
|
|
23
|
-
)
|
|
12
|
+
# This assessment's id, equal to detail.assessment_id.
|
|
13
|
+
field :id, ::String, {'format_json': {'letter_case': ::NoResponse::Utils.field_name("id"), required: true}}
|
|
24
14
|
# complete: final; nothing is still running. A lane that failed or missed its 10 s deadline stays listed in incomplete_sources, and confidence is lower. partial: a recommendation exists and at least one lane is still running or did not settle (see incomplete_sources). pending: nothing usable before the deadline; no recommendation yet, poll or wait for the webhook.
|
|
25
15
|
field(
|
|
26
16
|
:status,
|
|
@@ -33,210 +23,85 @@ module NoResponse
|
|
|
33
23
|
}
|
|
34
24
|
}
|
|
35
25
|
)
|
|
36
|
-
#
|
|
26
|
+
# The customer event an assessment is made for: signup (a new account), login (a returning sign-in) or purchase (a payment or credit grant). The account's risk is reported with the decision for this flow.
|
|
37
27
|
field(
|
|
38
|
-
:
|
|
39
|
-
Models::Shared::
|
|
28
|
+
:flow,
|
|
29
|
+
Models::Shared::Flow,
|
|
40
30
|
{
|
|
41
31
|
'format_json': {
|
|
42
|
-
'letter_case': ::NoResponse::Utils.field_name("
|
|
32
|
+
'letter_case': ::NoResponse::Utils.field_name("flow"),
|
|
43
33
|
required: true,
|
|
44
|
-
'decoder': ::NoResponse::Utils.open_enum_from_string(Models::Shared::
|
|
34
|
+
'decoder': ::NoResponse::Utils.open_enum_from_string(Models::Shared::Flow, false)
|
|
45
35
|
}
|
|
46
36
|
}
|
|
47
37
|
)
|
|
48
|
-
#
|
|
38
|
+
# How long the synchronous answer waits for sources. quick waits only for the fast sources, those that answer within 500 ms of ingress (or deadline_ms when lower); full waits for the whole budget, up to 3000 ms. Sources still running at the cut continue asynchronously and update the assessment either way.
|
|
49
39
|
field(
|
|
50
|
-
:
|
|
51
|
-
|
|
52
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("incomplete_sources"), required: true}}
|
|
53
|
-
)
|
|
54
|
-
# Version of the hand-authored heuristic weights that produced the recommendation, for example 'risk-2026-10.7'. A tenant threshold override adds a +t<N> suffix, e.g. 'risk-2026-10.7+t3'.
|
|
55
|
-
field(
|
|
56
|
-
:policy_version,
|
|
57
|
-
::String,
|
|
58
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("policy_version"), required: true}}
|
|
59
|
-
)
|
|
60
|
-
# Monotonic revision of this assessment. 1 on the synchronous answer; each material change while the assessment is served (an async continuation settling a source, a change of recommendation, confidence or status) increments it. Expiry does not increment it: an expired assessment is gone, not revised, and the expiry webhook carries the last served version.
|
|
61
|
-
field(
|
|
62
|
-
:assessment_version,
|
|
63
|
-
::Integer,
|
|
64
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("assessment_version"), required: true}}
|
|
65
|
-
)
|
|
66
|
-
# When the assessment was created. Fixed for the life of the assessment: every revision carries the same created_at.
|
|
67
|
-
field(
|
|
68
|
-
:created_at,
|
|
69
|
-
::String,
|
|
70
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("created_at"), required: true}}
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
field(
|
|
74
|
-
:updated_at,
|
|
75
|
-
::String,
|
|
76
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("updated_at"), required: true}}
|
|
77
|
-
)
|
|
78
|
-
# After this instant the assessment is no longer served: GET /v1/assessments/{assessment_id} answers 404 and it leaves the account history. It is created_at plus your retention period at creation (default and maximum 365 days) and is fixed per record: a later revision keeps it, and a new assessment for the same account starts its own clock. If you later shorten retention, the effective expiry becomes created_at plus the shorter of the two periods.
|
|
79
|
-
field(
|
|
80
|
-
:expires_at,
|
|
81
|
-
::String,
|
|
82
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("expires_at"), required: true}}
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
field(
|
|
86
|
-
:timing,
|
|
87
|
-
Models::Shared::AssessmentTiming,
|
|
88
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("timing"), required: true}}
|
|
89
|
-
)
|
|
90
|
-
# Version of the body shape.
|
|
91
|
-
field(
|
|
92
|
-
:schema_version,
|
|
93
|
-
::String,
|
|
94
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("schema_version"), required: true}}
|
|
95
|
-
)
|
|
96
|
-
# An optional provider correlation id, echoed back. It is not an idempotency key. Refuses an exact-shape JWT or a bare 40, 64 or 128 character hex digest; a UUID is accepted.
|
|
97
|
-
field(
|
|
98
|
-
:request_id,
|
|
99
|
-
Crystalline::Nilable.new(::String),
|
|
100
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("request_id")}}
|
|
101
|
-
)
|
|
102
|
-
# The recommended action: allow, step_up or review. Your application applies it.
|
|
103
|
-
field(
|
|
104
|
-
:recommendation,
|
|
105
|
-
Crystalline::Nilable.new(Models::Shared::Recommendation),
|
|
40
|
+
:depth,
|
|
41
|
+
Models::Shared::Depth,
|
|
106
42
|
{
|
|
107
43
|
'format_json': {
|
|
108
|
-
'letter_case': ::NoResponse::Utils.field_name("
|
|
109
|
-
|
|
44
|
+
'letter_case': ::NoResponse::Utils.field_name("depth"),
|
|
45
|
+
required: true,
|
|
46
|
+
'decoder': ::NoResponse::Utils.open_enum_from_string(Models::Shared::Depth, false)
|
|
110
47
|
}
|
|
111
48
|
}
|
|
112
49
|
)
|
|
113
|
-
#
|
|
114
|
-
field(
|
|
115
|
-
:risk,
|
|
116
|
-
Crystalline::Nilable.new(Models::Shared::Risk),
|
|
117
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("risk")}}
|
|
118
|
-
)
|
|
119
|
-
# Detailed view only (response_mode=detailed, which needs the detailed scope): a standard answer omits signals[], and a detailed one always carries it, possibly empty. The body does not name its mode, so that rule is the serving gate's; the schema states the half it can: an answer with risk.details is a detailed view and carries signals[]. At most one signal per (type, source_id, identifier).
|
|
50
|
+
# One assessment's own facts: its score and recommendation, the signals behind them, the lanes still running and its timing. risk here is this assessment's own score; the account's current risk is the account member of the assessment. risk and recommendation are present for complete and partial; assessment_url is present for pending.
|
|
120
51
|
field(
|
|
121
|
-
:
|
|
122
|
-
|
|
123
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("
|
|
52
|
+
:detail,
|
|
53
|
+
Models::Shared::AssessmentDetail,
|
|
54
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("detail"), required: true}}
|
|
124
55
|
)
|
|
125
|
-
#
|
|
56
|
+
# Version of the body shape.
|
|
126
57
|
field(
|
|
127
|
-
:
|
|
58
|
+
:schema_version,
|
|
128
59
|
::String,
|
|
129
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("
|
|
130
|
-
)
|
|
131
|
-
# Server-relative path of the authoritative assessment, GET /v1/assessments/{assessment_id}.
|
|
132
|
-
field(
|
|
133
|
-
:assessment_url,
|
|
134
|
-
Crystalline::Nilable.new(::String),
|
|
135
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("assessment_url")}}
|
|
60
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("schema_version"), required: true}}
|
|
136
61
|
)
|
|
137
|
-
#
|
|
62
|
+
# Body of the 200 answer to GET /v1/accounts/{account_id}: the one current risk of one of the calling tenant's accounts. It is the risk of the account's latest assessment that has a decision (status complete or partial), by created_at and then assessment_id, scored exactly as GET /v1/assessments/{assessment_id} scores that assessment. Its assessments are the account's history (GET /v1/accounts/{account_id}/assessments).
|
|
138
63
|
field(
|
|
139
|
-
:
|
|
140
|
-
Crystalline::Nilable.new(Models::Shared::
|
|
141
|
-
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("
|
|
64
|
+
:account,
|
|
65
|
+
Crystalline::Nilable.new(Models::Shared::Account),
|
|
66
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("account")}}
|
|
142
67
|
)
|
|
143
68
|
|
|
144
69
|
sig {
|
|
145
70
|
params(
|
|
146
|
-
|
|
147
|
-
account_id: ::String,
|
|
71
|
+
id: ::String,
|
|
148
72
|
status: Models::Shared::Status,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
assessment_version: ::Integer,
|
|
153
|
-
created_at: ::String,
|
|
154
|
-
updated_at: ::String,
|
|
155
|
-
expires_at: ::String,
|
|
156
|
-
timing: Models::Shared::AssessmentTiming,
|
|
73
|
+
flow: Models::Shared::Flow,
|
|
74
|
+
depth: Models::Shared::Depth,
|
|
75
|
+
detail: Models::Shared::AssessmentDetail,
|
|
157
76
|
schema_version: ::String,
|
|
158
|
-
|
|
159
|
-
recommendation: T.nilable(Models::Shared::Recommendation),
|
|
160
|
-
risk: T.nilable(Models::Shared::Risk),
|
|
161
|
-
signals: T.nilable(T::Array[Models::Shared::Signal]),
|
|
162
|
-
policy_basis: ::String,
|
|
163
|
-
assessment_url: T.nilable(::String),
|
|
164
|
-
auth_context: T.nilable(Models::Shared::AssessmentAuthContext)
|
|
77
|
+
account: T.nilable(Models::Shared::Account)
|
|
165
78
|
)
|
|
166
79
|
.void
|
|
167
80
|
}
|
|
168
|
-
def initialize(
|
|
169
|
-
|
|
170
|
-
account_id:,
|
|
171
|
-
status:,
|
|
172
|
-
confidence:,
|
|
173
|
-
incomplete_sources:,
|
|
174
|
-
policy_version:,
|
|
175
|
-
assessment_version:,
|
|
176
|
-
created_at:,
|
|
177
|
-
updated_at:,
|
|
178
|
-
expires_at:,
|
|
179
|
-
timing:,
|
|
180
|
-
schema_version: "1",
|
|
181
|
-
request_id: nil,
|
|
182
|
-
recommendation: nil,
|
|
183
|
-
risk: nil,
|
|
184
|
-
signals: nil,
|
|
185
|
-
policy_basis: "heuristic_unvalidated",
|
|
186
|
-
assessment_url: nil,
|
|
187
|
-
auth_context: nil
|
|
188
|
-
)
|
|
189
|
-
@assessment_id = assessment_id
|
|
190
|
-
@account_id = account_id
|
|
81
|
+
def initialize(id:, status:, flow:, depth:, detail:, schema_version: "1", account: nil)
|
|
82
|
+
@id = id
|
|
191
83
|
@status = status
|
|
192
|
-
@
|
|
193
|
-
@
|
|
194
|
-
@
|
|
195
|
-
@assessment_version = assessment_version
|
|
196
|
-
@created_at = created_at
|
|
197
|
-
@updated_at = updated_at
|
|
198
|
-
@expires_at = expires_at
|
|
199
|
-
@timing = timing
|
|
84
|
+
@flow = flow
|
|
85
|
+
@depth = depth
|
|
86
|
+
@detail = detail
|
|
200
87
|
unless schema_version == "1"
|
|
201
88
|
raise ArgumentError, "Invalid value for schema_version"
|
|
202
89
|
end
|
|
203
90
|
|
|
204
91
|
@schema_version = "1"
|
|
205
|
-
@
|
|
206
|
-
@recommendation = recommendation
|
|
207
|
-
@risk = risk
|
|
208
|
-
@signals = signals
|
|
209
|
-
unless policy_basis == "heuristic_unvalidated"
|
|
210
|
-
raise ArgumentError, "Invalid value for policy_basis"
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
@policy_basis = "heuristic_unvalidated"
|
|
214
|
-
@assessment_url = assessment_url
|
|
215
|
-
@auth_context = auth_context
|
|
92
|
+
@account = account
|
|
216
93
|
end
|
|
217
94
|
|
|
218
95
|
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
219
96
|
def ==(other)
|
|
220
97
|
return false unless other.is_a?(self.class)
|
|
221
|
-
return false unless @
|
|
222
|
-
return false unless @account_id == other.account_id
|
|
98
|
+
return false unless @id == other.id
|
|
223
99
|
return false unless @status == other.status
|
|
224
|
-
return false unless @
|
|
225
|
-
return false unless @
|
|
226
|
-
return false unless @
|
|
227
|
-
return false unless @assessment_version == other.assessment_version
|
|
228
|
-
return false unless @created_at == other.created_at
|
|
229
|
-
return false unless @updated_at == other.updated_at
|
|
230
|
-
return false unless @expires_at == other.expires_at
|
|
231
|
-
return false unless @timing == other.timing
|
|
100
|
+
return false unless @flow == other.flow
|
|
101
|
+
return false unless @depth == other.depth
|
|
102
|
+
return false unless @detail == other.detail
|
|
232
103
|
return false unless @schema_version == other.schema_version
|
|
233
|
-
return false unless @
|
|
234
|
-
return false unless @recommendation == other.recommendation
|
|
235
|
-
return false unless @risk == other.risk
|
|
236
|
-
return false unless @signals == other.signals
|
|
237
|
-
return false unless @policy_basis == other.policy_basis
|
|
238
|
-
return false unless @assessment_url == other.assessment_url
|
|
239
|
-
return false unless @auth_context == other.auth_context
|
|
104
|
+
return false unless @account == other.account
|
|
240
105
|
true
|
|
241
106
|
end
|
|
242
107
|
end
|