noresponse 6.0.1 → 7.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/auth.rb +1 -1
- data/lib/no_response/late_results.rb +288 -435
- data/lib/no_response/models/shared/account.rb +1 -1
- data/lib/no_response/models/shared/accountdecision.rb +2 -1
- data/lib/no_response/models/shared/accountdecisionsbyflow.rb +4 -4
- data/lib/no_response/models/shared/accountrisk.rb +1 -1
- data/lib/no_response/models/shared/accountriskchanged.rb +1 -1
- data/lib/no_response/sdkconfiguration.rb +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b0beeaac217b57c273b36d932b3c3f99afd182c6d0d44df0cc6ca6b4555c58c
|
|
4
|
+
data.tar.gz: 6764d92c3245796bd28d1d4d36a098200fb08dbf2890108ac42e3d1e4471dfcf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84f2027ab4697d18e946d606dab0d7e2618dbe3e1ff6e455fdd1ab3cc9cb0f2ca6de8bea2db9f74c09f5e43182ed43ea29ba241bc4f6d14f60303b63bcc619e3
|
|
7
|
+
data.tar.gz: 655e2452327045a689c270f119b2366a62e58b1d7ca1c1a5dcdd1e602f751fd8ce8eae83fb6d3bf046ca1dd35f90510781330eb3b1ba95bbce381bcf8fa10482
|
data/lib/no_response/auth.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# typed: false
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Auth context adapters. Generated by tools/
|
|
4
|
+
# Auth context adapters. Generated by tools/generate_overlays.py; do not edit.
|
|
5
5
|
#
|
|
6
6
|
# Each adapter maps claims your backend has already verified into the
|
|
7
7
|
# auth_context of an assessment request. Pass verified claims, not a raw token.
|
|
@@ -1,47 +1,59 @@
|
|
|
1
|
-
# typed:
|
|
1
|
+
# typed: false
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Late
|
|
4
|
+
# Late enforcement. Generated by tools/generate_overlays.py from
|
|
5
|
+
# tools/late_results/actions.json; do not edit.
|
|
5
6
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# user.
|
|
7
|
+
# account.risk_changed only wakes the handler. LateResults.handle_risk_change
|
|
8
|
+
# verifies the delivery, reads the account's current risk
|
|
9
|
+
# (GET /v1/accounts/{account_id}), asks your get_account_state where the
|
|
10
|
+
# account is in your own flow, and takes the actions the table names for that
|
|
11
|
+
# state and the decision of its flow:
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
13
|
+
# onboarding account_creation review/block fail_onboarding, revoke_sessions
|
|
14
|
+
# provisional account_creation review/block reject_activation, revoke_sessions
|
|
15
|
+
# active login review revoke_sessions
|
|
16
|
+
# active login block restrict, revoke_sessions
|
|
17
|
+
# restricted login allow unrestrict, when flows['login'].reinstate
|
|
18
|
+
#
|
|
19
|
+
# Every action, or none, is recorded with accounts.record_enforcement at the
|
|
20
|
+
# risk_version read, and a refused action is recorded as failed and raised. No
|
|
21
|
+
# action deletes a user. The same table and vectors
|
|
22
|
+
# (tests/fixtures/late-results-vectors.json) run in every SDK.
|
|
18
23
|
|
|
19
24
|
require 'erb'
|
|
25
|
+
require 'faraday'
|
|
20
26
|
require 'json'
|
|
21
|
-
require 'net/http'
|
|
22
|
-
require 'uri'
|
|
23
27
|
|
|
24
28
|
module NoResponse
|
|
25
29
|
module LateResults
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
TABLE = JSON.parse('{"lifecycle":{"onboarding":{"flow":"account_creation","on":{"review":["fail_onboarding","revoke_sessions"],"block":["fail_onboarding","revoke_sessions"]}},"provisional":{"flow":"account_creation","on":{"review":["reject_activation","revoke_sessions"],"block":["reject_activation","revoke_sessions"]}},"active":{"flow":"login","on":{"review":["revoke_sessions"],"block":["restrict","revoke_sessions"]}},"restricted":{"flow":"login","on":{},"reinstate":{"allow":["unrestrict"]}},"rejected":{"flow":"account_creation","on":{}}},"providers":{"clerk":{"base_url":"https://api.clerk.com/v1","actions":{"fail_onboarding":[{"call":{"method":"POST","path":"/users/{user}/ban"}}],"reject_activation":[{"call":{"method":"POST","path":"/users/{user}/ban"}}],"restrict":[{"call":{"method":"POST","path":"/users/{user}/ban"}}],"unrestrict":[{"call":{"method":"POST","path":"/users/{user}/unban"}}],"revoke_sessions":[{"each":{"list":{"method":"GET","path":"/sessions","query":{"user_id":"{user}","status":"active","limit":"100"}},"page":{"kind":"offset","param":"offset","size":100},"where":{"status":"active"}},"call":{"method":"POST","path":"/sessions/{item}/revoke"}}]}},"workos":{"base_url":"https://api.workos.com","actions":{"fail_onboarding":[{"call":{"method":"PUT","path":"/user_management/users/{user}","body":{"metadata":{"noresponse_state":"onboarding_failed"}}}}],"reject_activation":[{"call":{"method":"PUT","path":"/user_management/users/{user}","body":{"metadata":{"noresponse_state":"activation_rejected"}}}}],"restrict":[{"call":{"method":"PUT","path":"/user_management/users/{user}","body":{"metadata":{"noresponse_state":"restricted"}}}}],"unrestrict":[{"call":{"method":"PUT","path":"/user_management/users/{user}","body":{"metadata":{"noresponse_state":null}}}}],"revoke_sessions":[{"each":{"list":{"method":"GET","path":"/user_management/users/{user}/sessions","query":{"limit":"100"}},"page":{"kind":"cursor","param":"after","next":["list_metadata","after"]},"where":{"status":"active"}},"call":{"method":"POST","path":"/user_management/sessions/revoke","body":{"session_id":"{item}"}}}]}},"auth0":{"base_url":"https://{domain}/api/v2","actions":{"fail_onboarding":[{"call":{"method":"PATCH","path":"/users/{user}","body":{"blocked":true}}}],"reject_activation":[{"call":{"method":"PATCH","path":"/users/{user}","body":{"blocked":true}}}],"restrict":[{"call":{"method":"PATCH","path":"/users/{user}","body":{"blocked":true}}}],"unrestrict":[{"call":{"method":"PATCH","path":"/users/{user}","body":{"blocked":false}}}],"revoke_sessions":[{"call":{"method":"DELETE","path":"/users/{user}/sessions"},"fallback":{"status":403,"code":"feature_not_enabled","reports":"restrict","steps":[{"call":{"method":"PATCH","path":"/users/{user}","body":{"blocked":true}}}]}},{"call":{"method":"DELETE","path":"/users/{user}/refresh-tokens"},"fallback":{"status":403,"code":"feature_not_enabled","steps":[{"each":{"list":{"method":"GET","path":"/device-credentials","query":{"user_id":"{user}","type":"refresh_token","per_page":"100"}},"page":{"kind":"index","param":"page","size":100}},"call":{"method":"DELETE","path":"/device-credentials/{item}"}},{"each":{"list":{"method":"GET","path":"/device-credentials","query":{"user_id":"{user}","type":"rotating_refresh_token","per_page":"100"}},"page":{"kind":"index","param":"page","size":100}},"call":{"method":"DELETE","path":"/device-credentials/{item}"}}]}}]}}},"limits":{"retries":4,"budget_ms":3000,"backoff_ms":250,"max_pages":100}}').freeze
|
|
31
|
+
LIMITS = TABLE.fetch('limits')
|
|
32
|
+
# Every action a provider runs, as the table names them.
|
|
33
|
+
ACTIONS = TABLE.fetch('providers').values.first.fetch('actions').keys.freeze
|
|
34
|
+
|
|
35
|
+
# Where the account is in your flow, from get_account_state. user_id is the
|
|
36
|
+
# provider's user id (default: the account id). enforced_risk_version is
|
|
37
|
+
# the risk_version you last enforced (a result's risk_version that you
|
|
38
|
+
# stored); an account at or below it is skipped.
|
|
39
|
+
AccountStateAnswer = Struct.new(:state, :user_id, :enforced_risk_version, keyword_init: true)
|
|
40
|
+
|
|
41
|
+
# reinstate lifts a restriction once the flow's decision is allow again. Off by default.
|
|
42
|
+
FlowPolicy = Struct.new(:reinstate, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
Enforced = Struct.new(:flow, :action, :status, :error_code, keyword_init: true)
|
|
45
|
+
|
|
46
|
+
# event is the verified event (from handle_risk_change). risk_version is the
|
|
47
|
+
# version acted on: store it and return it as enforced_risk_version. skipped
|
|
48
|
+
# says why nothing was done: not_actionable (not account.risk_changed, or a
|
|
49
|
+
# test event), account_gone (erased), unknown_user (get_account_state
|
|
50
|
+
# returned nil) or already_enforced. enforcements is what was recorded, in order.
|
|
51
|
+
EnforcementResult = Struct.new(:event, :account, :state, :risk_version, :skipped, :enforcements, keyword_init: true)
|
|
52
|
+
|
|
53
|
+
# The provider refused an action or could not be reached (status 0). Answer
|
|
54
|
+
# the webhook with a 5xx so it is delivered again. code is the provider's
|
|
55
|
+
# error code, such as Auth0's errorCode "insufficient_scope", when it sent one.
|
|
42
56
|
class ProviderActionError < StandardError
|
|
43
|
-
# code is the provider's error code from the response body, such as
|
|
44
|
-
# Auth0's errorCode "insufficient_scope", when it sent one.
|
|
45
57
|
attr_reader :provider, :status, :code
|
|
46
58
|
|
|
47
59
|
def initialize(provider, status, message, code = nil)
|
|
@@ -52,469 +64,310 @@ module NoResponse
|
|
|
52
64
|
end
|
|
53
65
|
end
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
# such as a Models::Shared::AssessmentUpdatedEvent. action is the action that ran:
|
|
57
|
-
# NONE when the policy, the event or the user id called for nothing. It can
|
|
58
|
-
# be stronger than the policy's action when the provider had to fall back:
|
|
59
|
-
# the auth0 provider reports BAN when it blocked the user because the
|
|
60
|
-
# tenant's plan cannot end sessions. user_id is
|
|
61
|
-
# the provider user id acted on, set only when an action was taken. Answer
|
|
62
|
-
# an endpoint.verification with { 'challenge' => event.data.challenge }.
|
|
63
|
-
Result = Struct.new(:event, :action, :user_id, keyword_init: true)
|
|
67
|
+
SLUG = /\A[a-z0-9_.-]+\z/
|
|
64
68
|
|
|
65
69
|
class << self
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
data = event.data
|
|
77
|
-
if event.type == 'account.risk_changed'
|
|
78
|
-
# The event is sent when any flow's decision changes; sessions are about the
|
|
79
|
-
# login decision, so an unchanged login decision acts on nothing.
|
|
80
|
-
decision = data.account.decisions_by_flow.login.to_s
|
|
81
|
-
return NONE if data.previous_decisions_by_flow&.login.to_s == decision
|
|
82
|
-
elsif event.type == 'assessment.updated'
|
|
83
|
-
return NONE if data.is_a?(Models::Shared::AssessmentExpired)
|
|
84
|
-
return NONE if data.status == Models::Shared::Status::PENDING || data.account.nil?
|
|
85
|
-
decision = data.account.risk.decision.to_s
|
|
86
|
-
else
|
|
87
|
-
return NONE
|
|
70
|
+
# Verify an account.risk_changed delivery and enforce the account's
|
|
71
|
+
# current risk. A delivery that fails verification raises
|
|
72
|
+
# WebhookVerification::VerificationError before anything is read; any
|
|
73
|
+
# other event, and a test event, does nothing.
|
|
74
|
+
def handle_risk_change(client, payload, headers, secrets, provider:, get_account_state:, flows: {},
|
|
75
|
+
tolerance_seconds: WebhookVerification::DEFAULT_TOLERANCE_SECONDS, now: nil)
|
|
76
|
+
event = WebhookVerification.construct_event(payload, headers, secrets, tolerance_seconds: tolerance_seconds, now: now)
|
|
77
|
+
if event.type != 'account.risk_changed' || (event.respond_to?(:test) && event.test == true)
|
|
78
|
+
return EnforcementResult.new(event: event, skipped: 'not_actionable', enforcements: [])
|
|
88
79
|
end
|
|
89
|
-
return NONE unless DECISIONS.include?(decision)
|
|
90
80
|
|
|
91
|
-
|
|
92
|
-
|
|
81
|
+
begin
|
|
82
|
+
account = client.accounts.get(account_id: event.data.account.id).result
|
|
83
|
+
rescue Models::Errors::NotFoundError
|
|
84
|
+
return EnforcementResult.new(event: event, skipped: 'account_gone', enforcements: [])
|
|
85
|
+
end
|
|
86
|
+
result = enforce_account(client, account, provider: provider, get_account_state: get_account_state, flows: flows)
|
|
87
|
+
result.event = event
|
|
88
|
+
result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Enforce one account as read: the account a handler fetched, or one from
|
|
92
|
+
# accounts.list_changed(unenforced: true) when catching up.
|
|
93
|
+
def enforce_account(client, account, provider:, get_account_state:, flows: {})
|
|
94
|
+
answer = get_account_state.call(account)
|
|
95
|
+
return EnforcementResult.new(account: account, skipped: 'unknown_user', enforcements: []) if answer.nil?
|
|
96
|
+
|
|
97
|
+
state = answer.state.to_s
|
|
98
|
+
rule = TABLE.fetch('lifecycle')[state] or raise ArgumentError, "unknown account state #{state.inspect}"
|
|
99
|
+
risk_version = account.risk_version
|
|
100
|
+
if !answer.enforced_risk_version.nil? && risk_version <= answer.enforced_risk_version
|
|
101
|
+
return EnforcementResult.new(account: account, state: state, risk_version: risk_version,
|
|
102
|
+
skipped: 'already_enforced', enforcements: [])
|
|
103
|
+
end
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
flow = rule.fetch('flow')
|
|
106
|
+
decision = account.decisions_by_flow.public_send(flow).to_s
|
|
107
|
+
policy = (flows || {})[flow] || (flows || {})[flow.to_sym]
|
|
108
|
+
actions = rule.fetch('on')[decision]
|
|
109
|
+
actions = (rule['reinstate'] || {})[decision] if actions.nil? && policy&.reinstate
|
|
110
|
+
enforcements = []
|
|
111
|
+
record = lambda do |action, status, error_code = nil|
|
|
112
|
+
body = Models::Shared::EnforcementRequest.new(
|
|
113
|
+
risk_version: risk_version,
|
|
114
|
+
flow: Models::Shared::Flow.deserialize(flow),
|
|
115
|
+
action: Models::Shared::EnforcementAction.deserialize(action),
|
|
116
|
+
state_before: Models::Shared::EnforcementState.deserialize(state),
|
|
117
|
+
status: Models::Shared::EnforcementStatus.deserialize(status),
|
|
118
|
+
provider: Models::Shared::EnforcementProvider.deserialize(provider.name),
|
|
119
|
+
error_code: error_code
|
|
120
|
+
)
|
|
121
|
+
client.accounts.record_enforcement(account_id: account.id, body: body)
|
|
122
|
+
enforcements << Enforced.new(flow: flow, action: action, status: status, error_code: error_code)
|
|
123
|
+
end
|
|
96
124
|
|
|
97
|
-
|
|
98
|
-
|
|
125
|
+
if actions.nil? || actions.empty?
|
|
126
|
+
# An adverse or step_up decision that needs nothing still counts as handled.
|
|
127
|
+
record.call('none', 'applied') unless decision == 'allow'
|
|
128
|
+
return EnforcementResult.new(account: account, state: state, risk_version: risk_version, enforcements: enforcements)
|
|
129
|
+
end
|
|
99
130
|
|
|
100
|
-
|
|
101
|
-
|
|
131
|
+
recorded = {}
|
|
132
|
+
user_id = answer.user_id.to_s.empty? ? account.id : answer.user_id
|
|
133
|
+
actions.each do |action|
|
|
134
|
+
begin
|
|
135
|
+
also_ran = provider.run(action, user_id)
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
record.call(action, 'failed', failure_code(provider.name, e))
|
|
138
|
+
raise
|
|
139
|
+
end
|
|
140
|
+
[*also_ran, action].each do |ran|
|
|
141
|
+
next if recorded[ran]
|
|
102
142
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# exactly what WebhookVerification.construct_event takes; a delivery that fails
|
|
107
|
-
# verification raises WebhookVerification::VerificationError before any provider
|
|
108
|
-
# call. Deduplicate on result.event.id and ignore versions older than one
|
|
109
|
-
# you have acted on.
|
|
110
|
-
#
|
|
111
|
-
# policy defaults to DEFAULT_POLICY. user_id is an optional callable
|
|
112
|
-
# (account_id, event) returning the provider user id for the assessment's
|
|
113
|
-
# account_id; it defaults to the account_id itself, as when you assess
|
|
114
|
-
# with the provider's user id. Return nil or "" to take no action.
|
|
115
|
-
def handle(payload, headers, secrets, provider, policy: DEFAULT_POLICY, user_id: nil, **verify)
|
|
116
|
-
event = WebhookVerification.construct_event(payload, headers, secrets, **verify)
|
|
117
|
-
action = action_for(event, policy)
|
|
118
|
-
return Result.new(event: event, action: NONE) if action == NONE
|
|
119
|
-
|
|
120
|
-
account_id = event.type == 'account.risk_changed' ? event.data.account.id : event.data.detail.account_id
|
|
121
|
-
resolved = user_id ? user_id.call(account_id, event) : account_id
|
|
122
|
-
return Result.new(event: event, action: NONE) if resolved.nil? || resolved.to_s.empty?
|
|
123
|
-
|
|
124
|
-
resolved = resolved.to_s
|
|
125
|
-
# A provider may define act(action, user_id), returning the action that
|
|
126
|
-
# ran; the auth0 provider does, to report a block it had to fall back to.
|
|
127
|
-
ran = action
|
|
128
|
-
if provider.respond_to?(:act)
|
|
129
|
-
ran = provider.act(action, resolved)
|
|
130
|
-
elsif action == BAN
|
|
131
|
-
provider.ban(resolved)
|
|
132
|
-
else
|
|
133
|
-
provider.revoke_sessions(resolved)
|
|
143
|
+
recorded[ran] = true
|
|
144
|
+
record.call(ran, 'applied')
|
|
145
|
+
end
|
|
134
146
|
end
|
|
135
|
-
|
|
147
|
+
EnforcementResult.new(account: account, state: state, risk_version: risk_version, enforcements: enforcements)
|
|
136
148
|
end
|
|
137
149
|
|
|
138
|
-
# Clerk
|
|
139
|
-
#
|
|
140
|
-
# (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
# optional callable (method, uri, headers, body) returning
|
|
146
|
-
# [status, body]; the default uses Net::HTTP.
|
|
147
|
-
def clerk(secret_key:, base_url: 'https://api.clerk.com/v1', http_client: nil)
|
|
148
|
-
raise ArgumentError, 'clerk needs secret_key' if secret_key.to_s.empty?
|
|
149
|
-
|
|
150
|
-
Clerk.new(Caller.new('clerk', base_url, "Bearer #{secret_key}", http_client))
|
|
151
|
-
end
|
|
150
|
+
# Clerk, WorkOS or Auth0, run from the shared action table. token is the
|
|
151
|
+
# Clerk secret key, WorkOS API key or Auth0 Management API token; domain
|
|
152
|
+
# is the Auth0 tenant domain. Pass http (a Faraday connection) for a proxy or for tests.
|
|
153
|
+
def identity_provider(name, token:, domain: nil, base_url: nil, http: nil)
|
|
154
|
+
name = name.to_s
|
|
155
|
+
spec = TABLE.fetch('providers')[name] or raise ArgumentError, "unknown identity provider #{name.inspect}"
|
|
156
|
+
raise ArgumentError, "#{name} needs token" if token.to_s.empty?
|
|
152
157
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
# so ban revokes the sessions and then deletes the user
|
|
157
|
-
# (DELETE /user_management/users/{id}); deletion is permanent. A 404 on
|
|
158
|
-
# any call (user or session already gone) counts as done, so a repeated
|
|
159
|
-
# delivery is safe.
|
|
160
|
-
#
|
|
161
|
-
# base_url defaults to https://api.workos.com.
|
|
162
|
-
def workos(api_key:, base_url: 'https://api.workos.com', http_client: nil)
|
|
163
|
-
raise ArgumentError, 'workos needs api_key' if api_key.to_s.empty?
|
|
164
|
-
|
|
165
|
-
WorkOS.new(Caller.new('workos', base_url, "Bearer #{api_key}", http_client))
|
|
166
|
-
end
|
|
158
|
+
base = base_url || spec.fetch('base_url')
|
|
159
|
+
if base.include?('{domain}')
|
|
160
|
+
raise ArgumentError, "#{name} needs domain or base_url" if domain.to_s.empty?
|
|
167
161
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
# (PATCH /users/{id}), then does the same. Pass domain (such as
|
|
172
|
-
# example.eu.auth0.com; the API is https://{domain}/api/v2) or base_url.
|
|
173
|
-
# token is a Management API access token with update:users,
|
|
174
|
-
# delete:sessions, delete:refresh_tokens, read:device_credentials and
|
|
175
|
-
# delete:device_credentials. A 404 on any call (user already gone)
|
|
176
|
-
# counts as done, so a repeated delivery is safe.
|
|
177
|
-
#
|
|
178
|
-
# Auth0 offers the sessions and refresh-token endpoints on Enterprise
|
|
179
|
-
# plans only; on other plans they answer 403 feature_not_enabled. Then
|
|
180
|
-
# refresh tokens are deleted one by one through /device-credentials
|
|
181
|
-
# (types refresh_token and rotating_refresh_token), which every plan
|
|
182
|
-
# has. A login session cannot be ended, so revoke_sessions blocks the
|
|
183
|
-
# user before deleting the refresh tokens, and act reports BAN. Unblock
|
|
184
|
-
# the user in the dashboard to undo it. Any other refusal, such as 403
|
|
185
|
-
# insufficient_scope, raises.
|
|
186
|
-
#
|
|
187
|
-
# A 429 from the Management API is waited out and retried: Retry-After,
|
|
188
|
-
# else X-RateLimit-Reset, else 0.25 s doubling, at most 4 retries a call
|
|
189
|
-
# and 3 s of waiting a revoke or ban. Past that the 429 raises as
|
|
190
|
-
# ProviderActionError. An http_client may return [status, body, headers]
|
|
191
|
-
# so the headers are honoured; without them the backoff applies.
|
|
192
|
-
def auth0(token:, domain: nil, base_url: nil, http_client: nil)
|
|
193
|
-
raise ArgumentError, 'auth0 needs token' if token.to_s.empty?
|
|
194
|
-
|
|
195
|
-
base = base_url || (domain.to_s.empty? ? nil : "https://#{domain}/api/v2")
|
|
196
|
-
raise ArgumentError, 'auth0 needs domain or base_url' if base.nil?
|
|
197
|
-
|
|
198
|
-
Auth0.new(Caller.new('auth0', base, "Bearer #{token}", http_client, retry_rate_limits: true))
|
|
162
|
+
base = base.sub('{domain}', domain)
|
|
163
|
+
end
|
|
164
|
+
TableProvider.new(name, spec, base.sub(%r{/+\z}, ''), token, http || Faraday.new(request: { timeout: 10 }))
|
|
199
165
|
end
|
|
200
166
|
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
# writes it to standard error with Kernel#warn. Replace it with your own
|
|
206
|
-
# provider (name, revoke_sessions and ban) once you know what revoking
|
|
207
|
-
# and banning mean in your app.
|
|
208
|
-
def log_only(log: nil)
|
|
209
|
-
LogOnly.new(log || ->(line) { Kernel.warn(line) })
|
|
167
|
+
# Your own user store: one callable per action you support, keyed by
|
|
168
|
+
# action. A callable may return other actions it ran. An action without one fails.
|
|
169
|
+
def custom_provider(handlers)
|
|
170
|
+
CustomProvider.new(handlers.transform_keys(&:to_s))
|
|
210
171
|
end
|
|
211
|
-
end
|
|
212
172
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
RATE_LIMIT_BACKOFF = 0.25
|
|
228
|
-
|
|
229
|
-
def initialize(name, base_url, authorization, http_client, retry_rate_limits: false)
|
|
230
|
-
@name = name
|
|
231
|
-
@base = base_url.to_s.sub(%r{/+\z}, '')
|
|
232
|
-
@authorization = authorization
|
|
233
|
-
@http_client = http_client
|
|
234
|
-
@retry_rate_limits = retry_rate_limits
|
|
235
|
-
@waited = 0.0
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
# The error_code of a failed action: the provider's code, else its HTTP status, as <provider>.<code>.
|
|
176
|
+
def failure_code(provider, error)
|
|
177
|
+
code = error.respond_to?(:code) ? error.code : nil
|
|
178
|
+
status = error.is_a?(ProviderActionError) ? error.status : nil
|
|
179
|
+
suffix =
|
|
180
|
+
if code.is_a?(String) && code.downcase.match?(SLUG) then code.downcase
|
|
181
|
+
elsif status&.zero? then 'unreachable'
|
|
182
|
+
elsif status then "http_#{status}"
|
|
183
|
+
else
|
|
184
|
+
'error'
|
|
185
|
+
end
|
|
186
|
+
"#{provider}.#{suffix}"[0, 128]
|
|
236
187
|
end
|
|
188
|
+
end
|
|
237
189
|
|
|
190
|
+
# Runs one action of the shared table against a provider's admin API.
|
|
191
|
+
class TableProvider
|
|
238
192
|
attr_reader :name
|
|
239
193
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
194
|
+
def initialize(name, spec, base, token, http)
|
|
195
|
+
@name = name
|
|
196
|
+
@spec = spec
|
|
197
|
+
@base = base
|
|
198
|
+
@authorization = "Bearer #{token}"
|
|
199
|
+
@http = http
|
|
243
200
|
end
|
|
244
201
|
|
|
245
|
-
#
|
|
246
|
-
def
|
|
247
|
-
|
|
248
|
-
return after if after && after >= 0
|
|
249
|
-
|
|
250
|
-
reset = number(header(headers, 'x-ratelimit-reset'))
|
|
251
|
-
return [0.0, reset - Time.now.to_f].max if reset
|
|
202
|
+
# Take the action and return any other action that had to run with it (Auth0 restricts to end a session).
|
|
203
|
+
def run(action, user_id)
|
|
204
|
+
raise ArgumentError, 'user id must be a non-empty string' unless user_id.is_a?(String) && !user_id.empty?
|
|
252
205
|
|
|
253
|
-
|
|
206
|
+
steps = @spec.fetch('actions')[action.to_s] or raise ArgumentError, "unknown action #{action.inspect}"
|
|
207
|
+
# Per call, so one provider can serve concurrent deliveries.
|
|
208
|
+
run_steps(steps, Call.new(user_id, 0.0))
|
|
254
209
|
end
|
|
255
210
|
|
|
256
|
-
|
|
257
|
-
(headers || {}).each { |key, value| return Array(value).first.to_s.strip if key.to_s.downcase == name }
|
|
258
|
-
nil
|
|
259
|
-
end
|
|
211
|
+
private
|
|
260
212
|
|
|
261
|
-
|
|
262
|
-
|
|
213
|
+
# The user acted on and the time spent waiting out 429s in this action.
|
|
214
|
+
Call = Struct.new(:user, :waited_ms)
|
|
263
215
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
216
|
+
def run_steps(steps, call)
|
|
217
|
+
also_ran = []
|
|
218
|
+
steps.each do |step|
|
|
219
|
+
if step['each']
|
|
220
|
+
collect(step['each'], call).each { |item| send_call(step['call'], call, item) }
|
|
221
|
+
next
|
|
222
|
+
end
|
|
223
|
+
begin
|
|
224
|
+
send_call(step['call'], call)
|
|
225
|
+
rescue ProviderActionError => e
|
|
226
|
+
fallback = step['fallback']
|
|
227
|
+
raise unless fallback && e.status == fallback['status'] && e.code == fallback['code']
|
|
228
|
+
|
|
229
|
+
also_ran.concat(run_steps(fallback['steps'], call))
|
|
230
|
+
also_ran << fallback['reports'] if fallback['reports']
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
also_ran
|
|
268
234
|
end
|
|
269
235
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
236
|
+
# Every matching item id across the list's pages, collected before any is acted on.
|
|
237
|
+
def collect(each, call)
|
|
238
|
+
listing = each['list']
|
|
239
|
+
page = each['page']
|
|
240
|
+
where = each['where'] || {}
|
|
241
|
+
ids = []
|
|
242
|
+
cursor = nil
|
|
243
|
+
LIMITS['max_pages'].times do |index|
|
|
244
|
+
query = (listing['query'] || {}).dup
|
|
245
|
+
case page['kind']
|
|
246
|
+
when 'offset' then query[page['param']] = (index * page['size']).to_s
|
|
247
|
+
when 'index' then query[page['param']] = index.to_s
|
|
248
|
+
else query[page['param']] = cursor unless cursor.nil?
|
|
249
|
+
end
|
|
250
|
+
body = send_call(listing.merge('query' => query), call)
|
|
251
|
+
items =
|
|
252
|
+
if body.is_a?(Array) then body
|
|
253
|
+
elsif body.is_a?(Hash) && body['data'].is_a?(Array) then body['data']
|
|
254
|
+
else
|
|
255
|
+
[]
|
|
256
|
+
end
|
|
257
|
+
items.each do |item|
|
|
258
|
+
next unless item.is_a?(Hash) && item['id'].is_a?(String) && !item['id'].empty?
|
|
259
|
+
|
|
260
|
+
ids << item['id'] if where.all? { |key, value| item[key] == value }
|
|
261
|
+
end
|
|
262
|
+
if page['kind'] == 'cursor'
|
|
263
|
+
following = page['next'].reduce(body) { |node, key| node.is_a?(Hash) ? node[key] : nil }
|
|
264
|
+
break unless following.is_a?(String) && !following.empty?
|
|
265
|
+
|
|
266
|
+
cursor = following
|
|
267
|
+
elsif items.length < page['size']
|
|
268
|
+
break
|
|
269
|
+
end
|
|
283
270
|
end
|
|
284
|
-
|
|
271
|
+
ids
|
|
272
|
+
end
|
|
285
273
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
274
|
+
def fill(value, user, item)
|
|
275
|
+
case value
|
|
276
|
+
when String then value.gsub('{user}', user).gsub('{item}', item.to_s)
|
|
277
|
+
when Array then value.map { |entry| fill(entry, user, item) }
|
|
278
|
+
when Hash then value.transform_values { |entry| fill(entry, user, item) }
|
|
279
|
+
else value
|
|
290
280
|
end
|
|
291
281
|
end
|
|
292
282
|
|
|
293
|
-
|
|
283
|
+
def number(text)
|
|
284
|
+
value = Float(text.to_s.strip, exception: false)
|
|
285
|
+
value&.finite? ? value : nil
|
|
286
|
+
end
|
|
294
287
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return [status, text] unless status == 429 && @retry_rate_limits && retry_index < RATE_LIMIT_RETRIES
|
|
288
|
+
# How long a 429 asks to wait, in milliseconds.
|
|
289
|
+
def rate_limit_wait_ms(headers, retry_count)
|
|
290
|
+
after = number(headers['retry-after'])
|
|
291
|
+
return after * 1000 if after && after >= 0
|
|
300
292
|
|
|
301
|
-
|
|
302
|
-
|
|
293
|
+
reset = number(headers['x-ratelimit-reset'])
|
|
294
|
+
return [0.0, (reset * 1000) - (Time.now.to_f * 1000)].max if reset
|
|
303
295
|
|
|
304
|
-
|
|
305
|
-
sleep(wait)
|
|
306
|
-
retry_index += 1
|
|
307
|
-
end
|
|
296
|
+
(LIMITS['backoff_ms'] * (2**retry_count)).to_f
|
|
308
297
|
end
|
|
309
298
|
|
|
310
|
-
# The
|
|
299
|
+
# The provider's error code: Auth0's errorCode, WorkOS's code or Clerk's errors[0].code.
|
|
311
300
|
def error_code(text)
|
|
312
301
|
body = JSON.parse(text.to_s)
|
|
313
302
|
return nil unless body.is_a?(Hash)
|
|
314
303
|
|
|
315
304
|
code = body['errorCode'] || body['code']
|
|
305
|
+
errors = body['errors']
|
|
306
|
+
code = errors.first['code'] if code.nil? && errors.is_a?(Array) && errors.first.is_a?(Hash)
|
|
316
307
|
code.is_a?(String) && !code.empty? ? code : nil
|
|
317
308
|
rescue JSON::ParserError
|
|
318
309
|
nil
|
|
319
310
|
end
|
|
320
311
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
request.body
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
end
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
module Segment
|
|
336
|
-
def self.encode(id)
|
|
337
|
-
raise ArgumentError, 'user id must be a non-empty string' unless id.is_a?(String) && !id.empty?
|
|
338
|
-
|
|
339
|
-
ERB::Util.url_encode(id)
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
def self.sessions(body)
|
|
343
|
-
list = body.is_a?(Hash) ? body['data'] : body
|
|
344
|
-
list.is_a?(Array) ? list.grep(Hash) : []
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
def self.active_ids(list)
|
|
348
|
-
list.select { |s| s['status'] == 'active' && s['id'].is_a?(String) && !s['id'].empty? }.map { |s| s['id'] }
|
|
349
|
-
end
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
class Clerk
|
|
353
|
-
PAGE = 100
|
|
354
|
-
|
|
355
|
-
def initialize(caller_)
|
|
356
|
-
@call = caller_
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
def name = 'clerk'
|
|
360
|
-
|
|
361
|
-
def revoke_sessions(user_id)
|
|
362
|
-
Segment.encode(user_id)
|
|
363
|
-
active = []
|
|
364
|
-
(0...1000).each do |round|
|
|
365
|
-
page = Segment.sessions(@call.call('GET', '/sessions', query: [['user_id', user_id], %w[status active],
|
|
366
|
-
['limit', PAGE.to_s],
|
|
367
|
-
['offset', (round * PAGE).to_s]], allow_404: true))
|
|
368
|
-
active.concat(Segment.active_ids(page))
|
|
369
|
-
break if page.length < PAGE
|
|
370
|
-
end
|
|
371
|
-
active.each { |id| @call.call('POST', "/sessions/#{ERB::Util.url_encode(id)}/revoke", allow_404: true) }
|
|
372
|
-
nil
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
def ban(user_id)
|
|
376
|
-
@call.call('POST', "/users/#{Segment.encode(user_id)}/ban", allow_404: true)
|
|
377
|
-
nil
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
class WorkOS
|
|
382
|
-
def initialize(caller_)
|
|
383
|
-
@call = caller_
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
def name = 'workos'
|
|
387
|
-
|
|
388
|
-
def revoke_sessions(user_id)
|
|
389
|
-
user = Segment.encode(user_id)
|
|
390
|
-
active = []
|
|
391
|
-
after = T.let(nil, T.nilable(String))
|
|
392
|
-
1000.times do
|
|
393
|
-
query = T.let([%w[limit 100]], T::Array[T::Array[String]])
|
|
394
|
-
query << ['after', after] if after
|
|
395
|
-
body = @call.call('GET', "/user_management/users/#{user}/sessions", query: query, allow_404: true)
|
|
396
|
-
active.concat(Segment.active_ids(Segment.sessions(body)))
|
|
397
|
-
nxt = body.is_a?(Hash) && body['list_metadata'].is_a?(Hash) ? body['list_metadata']['after'] : nil
|
|
398
|
-
break unless nxt.is_a?(String) && !nxt.empty?
|
|
399
|
-
|
|
400
|
-
after = nxt
|
|
401
|
-
end
|
|
402
|
-
active.each { |id| @call.call('POST', '/user_management/sessions/revoke', body: { 'session_id' => id }, allow_404: true) }
|
|
403
|
-
nil
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
def ban(user_id)
|
|
407
|
-
revoke_sessions(user_id)
|
|
408
|
-
@call.call('DELETE', "/user_management/users/#{Segment.encode(user_id)}", allow_404: true)
|
|
409
|
-
nil
|
|
410
|
-
end
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
class Auth0
|
|
414
|
-
PAGE = 100
|
|
415
|
-
|
|
416
|
-
def initialize(caller_)
|
|
417
|
-
@call = caller_
|
|
418
|
-
end
|
|
419
|
-
|
|
420
|
-
def name = 'auth0'
|
|
421
|
-
|
|
422
|
-
def revoke_sessions(user_id)
|
|
423
|
-
revoke(user_id)
|
|
424
|
-
nil
|
|
425
|
-
end
|
|
426
|
-
|
|
427
|
-
def ban(user_id)
|
|
428
|
-
user = Segment.encode(user_id)
|
|
429
|
-
call = @call.fresh
|
|
430
|
-
block(call, user)
|
|
431
|
-
# The block already ends a session the plan cannot delete.
|
|
432
|
-
delete_sessions(call, user)
|
|
433
|
-
delete_refresh_tokens(call, user_id, user)
|
|
434
|
-
nil
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
# Bans or revokes and returns the action that ran: BAN when revoking had
|
|
438
|
-
# to block the user.
|
|
439
|
-
def act(action, user_id)
|
|
440
|
-
if action == BAN
|
|
441
|
-
ban(user_id)
|
|
442
|
-
return BAN
|
|
312
|
+
# One admin API call. A 404 is done (nil); a 429 is waited out inside the action's budget; any other failure raises.
|
|
313
|
+
def send_call(request, call, item = nil)
|
|
314
|
+
path = request['path'].gsub('{user}', ERB::Util.url_encode(call.user)).gsub('{item}', ERB::Util.url_encode(item.to_s))
|
|
315
|
+
query = (request['query'] || {}).transform_values { |value| fill(value, call.user, item) }
|
|
316
|
+
url = @base + path
|
|
317
|
+
url += "?#{URI.encode_www_form(query)}" unless query.empty?
|
|
318
|
+
headers = { 'Authorization' => @authorization, 'Accept' => 'application/json' }
|
|
319
|
+
body = nil
|
|
320
|
+
if request.key?('body')
|
|
321
|
+
body = JSON.generate(fill(request['body'], call.user, item))
|
|
322
|
+
headers['Content-Type'] = 'application/json'
|
|
443
323
|
end
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
# Ends every session; false when the plan has no sessions API.
|
|
469
|
-
def delete_sessions(call, user)
|
|
470
|
-
call.call('DELETE', "/users/#{user}/sessions", allow_404: true)
|
|
471
|
-
true
|
|
472
|
-
rescue ProviderActionError => e
|
|
473
|
-
raise unless not_on_plan?(e)
|
|
474
|
-
|
|
475
|
-
false
|
|
476
|
-
end
|
|
324
|
+
label = "#{@name} #{request['method']} #{request['path']}"
|
|
325
|
+
retry_count = 0
|
|
326
|
+
loop do
|
|
327
|
+
begin
|
|
328
|
+
response = @http.run_request(request['method'].downcase.to_sym, url, body, headers)
|
|
329
|
+
rescue Faraday::Error => e
|
|
330
|
+
raise ProviderActionError.new(@name, 0, "#{label} failed: #{e.message}")
|
|
331
|
+
end
|
|
332
|
+
if response.status == 429 && retry_count < LIMITS['retries']
|
|
333
|
+
wait = rate_limit_wait_ms(response.headers, retry_count)
|
|
334
|
+
if call.waited_ms + wait <= LIMITS['budget_ms']
|
|
335
|
+
call.waited_ms += wait
|
|
336
|
+
sleep(wait / 1000.0)
|
|
337
|
+
retry_count += 1
|
|
338
|
+
next
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
return nil if response.status == 404
|
|
342
|
+
unless (200..299).cover?(response.status)
|
|
343
|
+
raise ProviderActionError.new(@name, response.status, "#{label} answered #{response.status}", error_code(response.body))
|
|
344
|
+
end
|
|
345
|
+
return nil if response.body.to_s.empty?
|
|
477
346
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
rescue ProviderActionError => e
|
|
483
|
-
raise unless not_on_plan?(e)
|
|
484
|
-
end
|
|
485
|
-
# Collect every id before deleting any, so the pages do not shift underneath.
|
|
486
|
-
ids = []
|
|
487
|
-
%w[refresh_token rotating_refresh_token].each do |type|
|
|
488
|
-
10.times do |page|
|
|
489
|
-
body = call.call('GET', '/device-credentials',
|
|
490
|
-
query: [['user_id', user_id], ['type', type], ['page', page.to_s], ['per_page', PAGE.to_s]],
|
|
491
|
-
allow_404: true)
|
|
492
|
-
listed = body.is_a?(Array) ? body.grep(Hash) : []
|
|
493
|
-
ids.concat(listed.map { |c| c['id'] }.select { |id| id.is_a?(String) && !id.empty? })
|
|
494
|
-
break if listed.length < PAGE
|
|
347
|
+
begin
|
|
348
|
+
return JSON.parse(response.body)
|
|
349
|
+
rescue JSON::ParserError
|
|
350
|
+
return nil
|
|
495
351
|
end
|
|
496
352
|
end
|
|
497
|
-
ids.each { |id| call.call('DELETE', "/device-credentials/#{ERB::Util.url_encode(id)}", allow_404: true) }
|
|
498
353
|
end
|
|
499
354
|
end
|
|
500
355
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
356
|
+
# Your own user store, from custom_provider.
|
|
357
|
+
class CustomProvider
|
|
358
|
+
def initialize(handlers)
|
|
359
|
+
@handlers = handlers
|
|
504
360
|
end
|
|
505
361
|
|
|
506
|
-
def name = '
|
|
362
|
+
def name = 'custom'
|
|
507
363
|
|
|
508
|
-
def
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
nil
|
|
512
|
-
|
|
364
|
+
def run(action, user_id)
|
|
365
|
+
handler = @handlers[action.to_s] or raise ArgumentError, "custom_provider has no #{action} handler"
|
|
366
|
+
also_ran = handler.call(user_id)
|
|
367
|
+
# Only an array of action names counts; any other return value (nil, true, a model) is no extra action.
|
|
368
|
+
return [] unless also_ran.is_a?(Array)
|
|
513
369
|
|
|
514
|
-
|
|
515
|
-
Segment.encode(user_id)
|
|
516
|
-
@log.call("NoResponse late result (log only): would ban user #{user_id} and end their sessions")
|
|
517
|
-
nil
|
|
370
|
+
also_ran.map(&:to_s).select { |name| ACTIONS.include?(name) }
|
|
518
371
|
end
|
|
519
372
|
end
|
|
520
373
|
end
|
|
@@ -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: the account's score under that flow's thresholds. A flow the workspace has not set uses its account_creation thresholds, so the flows differ only where it set them.
|
|
20
|
+
# The decision for each flow: the account's score under that flow's thresholds. A flow the workspace has not set uses its account_creation thresholds, so the flows differ only where it set them. The flow of the current assessment is held while that flow's admission is held and the assessment's investigation has not completed.
|
|
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
|
-
# AccountDecision - block when the score reached your block threshold (risk.block of the assessment), otherwise the assessment's recommendation. Your application applies it.
|
|
7
|
+
# AccountDecision - block when the score reached your block threshold (risk.block of the assessment), otherwise the assessment's recommendation. held only for a flow whose admission is held (settings flows.<flow>.admission), when the account's current assessment was made for that flow and its investigation has not completed (status partial): the final decision arrives as account.risk_changed when the investigation completes, decided by its score, or when the hold's deadline (hold_seconds after the assessment was created) passes first, as the flow's on_expiry action or the decision the evidence so far gives, whichever is stricter. Your application applies it.
|
|
8
8
|
class AccountDecision
|
|
9
9
|
include ::Crystalline::Enum
|
|
10
10
|
enums do
|
|
@@ -12,6 +12,7 @@ module NoResponse
|
|
|
12
12
|
STEP_UP = new("step_up")
|
|
13
13
|
REVIEW = new("review")
|
|
14
14
|
BLOCK = new("block")
|
|
15
|
+
HELD = new("held")
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
open!
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
module NoResponse
|
|
5
5
|
module Models
|
|
6
6
|
module Shared
|
|
7
|
-
# The decision for each flow: the account's score under that flow's thresholds. A flow the workspace has not set uses its account_creation thresholds, so the flows differ only where it set them.
|
|
7
|
+
# The decision for each flow: the account's score under that flow's thresholds. A flow the workspace has not set uses its account_creation thresholds, so the flows differ only where it set them. The flow of the current assessment is held while that flow's admission is held and the assessment's investigation has not completed.
|
|
8
8
|
class AccountDecisionsByFlow
|
|
9
9
|
extend T::Sig
|
|
10
10
|
include Crystalline::MetadataFields
|
|
11
11
|
|
|
12
|
-
# block when the score reached your block threshold (risk.block of the assessment), otherwise the assessment's recommendation. Your application applies it.
|
|
12
|
+
# block when the score reached your block threshold (risk.block of the assessment), otherwise the assessment's recommendation. held only for a flow whose admission is held (settings flows.<flow>.admission), when the account's current assessment was made for that flow and its investigation has not completed (status partial): the final decision arrives as account.risk_changed when the investigation completes, decided by its score, or when the hold's deadline (hold_seconds after the assessment was created) passes first, as the flow's on_expiry action or the decision the evidence so far gives, whichever is stricter. Your application applies it.
|
|
13
13
|
field(
|
|
14
14
|
:account_creation,
|
|
15
15
|
Models::Shared::AccountDecision,
|
|
@@ -21,7 +21,7 @@ module NoResponse
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
)
|
|
24
|
-
# block when the score reached your block threshold (risk.block of the assessment), otherwise the assessment's recommendation. Your application applies it.
|
|
24
|
+
# block when the score reached your block threshold (risk.block of the assessment), otherwise the assessment's recommendation. held only for a flow whose admission is held (settings flows.<flow>.admission), when the account's current assessment was made for that flow and its investigation has not completed (status partial): the final decision arrives as account.risk_changed when the investigation completes, decided by its score, or when the hold's deadline (hold_seconds after the assessment was created) passes first, as the flow's on_expiry action or the decision the evidence so far gives, whichever is stricter. Your application applies it.
|
|
25
25
|
field(
|
|
26
26
|
:login,
|
|
27
27
|
Models::Shared::AccountDecision,
|
|
@@ -33,7 +33,7 @@ module NoResponse
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
)
|
|
36
|
-
# block when the score reached your block threshold (risk.block of the assessment), otherwise the assessment's recommendation. Your application applies it.
|
|
36
|
+
# block when the score reached your block threshold (risk.block of the assessment), otherwise the assessment's recommendation. held only for a flow whose admission is held (settings flows.<flow>.admission), when the account's current assessment was made for that flow and its investigation has not completed (status partial): the final decision arrives as account.risk_changed when the investigation completes, decided by its score, or when the hold's deadline (hold_seconds after the assessment was created) passes first, as the flow's on_expiry action or the decision the evidence so far gives, whichever is stricter. Your application applies it.
|
|
37
37
|
field(
|
|
38
38
|
:purchase,
|
|
39
39
|
Models::Shared::AccountDecision,
|
|
@@ -15,7 +15,7 @@ module NoResponse
|
|
|
15
15
|
::Integer,
|
|
16
16
|
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("score"), required: true}}
|
|
17
17
|
)
|
|
18
|
-
# block when the score reached your block threshold (risk.block of the assessment), otherwise the assessment's recommendation. Your application applies it.
|
|
18
|
+
# block when the score reached your block threshold (risk.block of the assessment), otherwise the assessment's recommendation. held only for a flow whose admission is held (settings flows.<flow>.admission), when the account's current assessment was made for that flow and its investigation has not completed (status partial): the final decision arrives as account.risk_changed when the investigation completes, decided by its score, or when the hold's deadline (hold_seconds after the assessment was created) passes first, as the flow's on_expiry action or the decision the evidence so far gives, whichever is stricter. Your application applies it.
|
|
19
19
|
field(
|
|
20
20
|
:decision,
|
|
21
21
|
Models::Shared::AccountDecision,
|
|
@@ -15,7 +15,7 @@ module NoResponse
|
|
|
15
15
|
Models::Shared::Account,
|
|
16
16
|
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("account"), required: true}}
|
|
17
17
|
)
|
|
18
|
-
# The decision for each flow: the account's score under that flow's thresholds. A flow the workspace has not set uses its account_creation thresholds, so the flows differ only where it set them.
|
|
18
|
+
# The decision for each flow: the account's score under that flow's thresholds. A flow the workspace has not set uses its account_creation thresholds, so the flows differ only where it set them. The flow of the current assessment is held while that flow's admission is held and the assessment's investigation has not completed.
|
|
19
19
|
field(
|
|
20
20
|
:previous_decisions_by_flow,
|
|
21
21
|
Models::Shared::AccountDecisionsByFlow,
|
|
@@ -95,9 +95,9 @@ module NoResponse
|
|
|
95
95
|
|
|
96
96
|
@language = "ruby"
|
|
97
97
|
@openapi_doc_version = "1"
|
|
98
|
-
@sdk_version = "
|
|
98
|
+
@sdk_version = "7.0.1"
|
|
99
99
|
@gen_version = "internal"
|
|
100
|
-
@user_agent = "noresponse-sdk/ruby
|
|
100
|
+
@user_agent = "noresponse-sdk/ruby 7.0.1 internal 1 noresponse"
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
sig { returns([String, T::Hash[Symbol, String]]) }
|