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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7330b6a0690fe0bbf5b80f1a2332e313bcc60f92aa2a3ac05c74db48eabcd4e2
4
- data.tar.gz: 39aa2d9d9ce07baf3356c2838834d804b1b830c9842dca5397488445c74681fa
3
+ metadata.gz: 1b0beeaac217b57c273b36d932b3c3f99afd182c6d0d44df0cc6ca6b4555c58c
4
+ data.tar.gz: 6764d92c3245796bd28d1d4d36a098200fb08dbf2890108ac42e3d1e4471dfcf
5
5
  SHA512:
6
- metadata.gz: f78c249866e436cbb122f9c0a6498f3faef3f2f5eae4fbb58c90ea745b75ff00055f95feed2a15602614e3aecabf05ccc152037e5d8751d672230616f480e66a
7
- data.tar.gz: e1ac053441377752e13a5519f75bdac2c7f14871e8dc20fa55ec4eb5f953c1663e54f0b7d99e0aba00a30f04abad545c33ee595d985d813e81dc8ae3189099a8
6
+ metadata.gz: 84f2027ab4697d18e946d606dab0d7e2618dbe3e1ff6e455fdd1ab3cc9cb0f2ca6de8bea2db9f74c09f5e43182ed43ea29ba241bc4f6d14f60303b63bcc619e3
7
+ data.tar.gz: 655e2452327045a689c270f119b2366a62e58b1d7ca1c1a5dcdd1e602f751fd8ce8eae83fb6d3bf046ca1dd35f90510781330eb3b1ba95bbce381bcf8fa10482
@@ -1,7 +1,7 @@
1
1
  # typed: false
2
2
  # frozen_string_literal: true
3
3
 
4
- # Auth context adapters. Generated by tools/generate_auth_adapters.py; do not edit.
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: true
1
+ # typed: false
2
2
  # frozen_string_literal: true
3
3
 
4
- # Late-result handlers. Hand-written beside the generated client.
4
+ # Late enforcement. Generated by tools/generate_overlays.py from
5
+ # tools/late_results/actions.json; do not edit.
5
6
  #
6
- # An assessment can be revised after the account creation was allowed: a slow source
7
- # settles and the account's decision rises. NoResponse sends the new version as a
8
- # signed assessment.updated webhook. LateResults.handle verifies that delivery
9
- # with WebhookVerification.construct_event, picks an action from your policy, and acts
10
- # through the auth provider's admin API: revoke the user's sessions or ban the
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
- # The handler verifies the delivery itself, so it never acts on an unverified
14
- # event, and it never acts on a test event from the send-test-event call. Every
15
- # provider call treats a 404 (user or session already gone) as done, so a
16
- # repeated delivery is safe. The provider calls are thin: you pass the credentials, and each
17
- # action is the provider's documented admin endpoint.
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
- # What to do to the user. BAN also ends every session.
27
- NONE = 'none'
28
- REVOKE_SESSIONS = 'revoke_sessions'
29
- BAN = 'ban'
30
- ACTIONS = [NONE, REVOKE_SESSIONS, BAN].freeze
31
-
32
- # Revoke sessions on review and block; do nothing on allow or step_up.
33
- # Banning is opt-in.
34
- DEFAULT_POLICY = { 'allow' => NONE, 'step_up' => NONE, 'review' => REVOKE_SESSIONS,
35
- 'block' => REVOKE_SESSIONS }.freeze
36
-
37
- DECISIONS = %w[allow step_up review block].freeze
38
-
39
- # Raised when the provider's admin API refuses a call. Answer the webhook
40
- # with a 5xx so NoResponse retries it. The message holds no response body
41
- # and no credentials.
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
- # event is the verified event from WebhookVerification.construct_event,
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
- # 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.
72
- def action_for(event, policy = DEFAULT_POLICY)
73
- # Only an assessment.updated event can be a test event.
74
- return NONE if event.type == 'assessment.updated' && event.test == true
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
- table = policy.to_h.transform_keys(&:to_s)
92
- return NONE unless table.key?(decision)
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
- action = table[decision]
95
- return NONE if action.nil?
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
- action = action.to_s
98
- raise ArgumentError, "unknown late result action #{action}" unless ACTIONS.include?(action)
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
- action
101
- end
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
- # Verify a webhook delivery and apply your policy to it.
104
- #
105
- # payload, headers and secrets, plus tolerance_seconds and now, are
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
- Result.new(event: event, action: ACTIONS.include?(ran) ? ran : action, user_id: resolved)
147
+ EnforcementResult.new(account: account, state: state, risk_version: risk_version, enforcements: enforcements)
136
148
  end
137
149
 
138
- # Clerk Backend API. revoke_sessions lists the user's active sessions
139
- # (GET /sessions?user_id&status=active) and revokes each
140
- # (POST /sessions/{id}/revoke). ban is POST /users/{id}/ban, which
141
- # revokes every session and blocks sign-in. A 404 on any call (user or
142
- # session already gone) counts as done, so a repeated delivery is safe.
143
- #
144
- # base_url defaults to https://api.clerk.com/v1. http_client is an
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
- # WorkOS User Management. revoke_sessions pages through
154
- # GET /user_management/users/{id}/sessions and revokes each active one
155
- # with POST /user_management/sessions/revoke. WorkOS has no block or ban,
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
- # Auth0 Management API. revoke_sessions deletes every session
169
- # (DELETE /users/{id}/sessions) and refresh token
170
- # (DELETE /users/{id}/refresh-tokens). ban first sets blocked: true
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
- # A provider for an app with its own login, or for trying the handler
202
- # before you wire up a real one. revoke_sessions and ban each log one
203
- # line saying what they would do and call nothing: no request leaves
204
- # your process. log is any callable that takes the line; the default
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
- # One provider HTTP client: base URL, Authorization header, error mapping.
214
- #
215
- # Rate limits. Auth0 answers 429 when the Management API rate limit is
216
- # hit, and a 429 means it did not act, so the call is safe to send again,
217
- # a DELETE included. One revoke or ban waits at most RATE_LIMIT_BUDGET
218
- # seconds in all, and each call retries at most RATE_LIMIT_RETRIES times:
219
- # it waits Retry-After seconds, else until X-RateLimit-Reset (Unix
220
- # seconds), else 0.25, 0.5, 1 and 2 s. A wait that would pass the budget is
221
- # not started: the 429 raises at once as ProviderActionError, the webhook
222
- # answers 5xx and NoResponse delivers it again later. The budget keeps the
223
- # handler inside the 5 s a delivery has to be answered.
224
- class Caller
225
- RATE_LIMIT_RETRIES = 4
226
- RATE_LIMIT_BUDGET = 3.0
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
- # A caller with the same settings and its own rate-limit budget, for one action.
241
- def fresh
242
- Caller.new(@name, @base, @authorization, @http_client, retry_rate_limits: @retry_rate_limits)
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
- # How long a 429 asks to wait, in seconds.
246
- def self.rate_limit_wait(headers, retry_index)
247
- after = number(header(headers, 'retry-after'))
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
- RATE_LIMIT_BACKOFF * (2**retry_index)
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
- def self.header(headers, name)
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
- def self.number(text)
262
- return nil if text.nil? || text.empty?
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
- value = Float(text)
265
- value.finite? ? value : nil
266
- rescue ArgumentError
267
- nil
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
- def call(method, path, query: nil, body: nil, allow_404: false)
271
- uri = URI.parse(@base + path)
272
- uri.query = URI.encode_www_form(query) if query && !query.empty?
273
- headers = { 'Authorization' => @authorization, 'Accept' => 'application/json' }
274
- payload = nil
275
- unless body.nil?
276
- headers['Content-Type'] = 'application/json'
277
- payload = JSON.generate(body)
278
- end
279
- status, text = send_waiting_out_rate_limits(method, uri, headers, payload)
280
- return nil if allow_404 && status == 404
281
- unless status.between?(200, 299)
282
- raise ProviderActionError.new(@name, status, "#{@name} #{method} #{path} answered #{status}", error_code(text))
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
- return nil if text.nil? || text.empty?
271
+ ids
272
+ end
285
273
 
286
- begin
287
- JSON.parse(text)
288
- rescue JSON::ParserError
289
- nil
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
- private
283
+ def number(text)
284
+ value = Float(text.to_s.strip, exception: false)
285
+ value&.finite? ? value : nil
286
+ end
294
287
 
295
- def send_waiting_out_rate_limits(method, uri, headers, payload)
296
- retry_index = 0
297
- loop do
298
- status, text, response_headers = perform(method, uri, headers, payload)
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
- wait = Caller.rate_limit_wait(response_headers, retry_index)
302
- return [status, text] if @waited + wait > RATE_LIMIT_BUDGET
293
+ reset = number(headers['x-ratelimit-reset'])
294
+ return [0.0, (reset * 1000) - (Time.now.to_f * 1000)].max if reset
303
295
 
304
- @waited += wait
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 error code in a provider's error body: Auth0's errorCode, or a top-level code.
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
- def perform(method, uri, headers, payload)
322
- return @http_client.call(method, uri, headers, payload) if @http_client
323
-
324
- http = Net::HTTP.new(uri.host, uri.port)
325
- http.use_ssl = uri.scheme == 'https'
326
- http.open_timeout = 10
327
- http.read_timeout = 30
328
- request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, uri.request_uri, headers)
329
- request.body = payload unless payload.nil?
330
- response = http.start { |connection| connection.request(request) }
331
- [response.code.to_i, response.body.to_s, response.to_hash]
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
- revoke(user_id)
445
- end
446
-
447
- private
448
-
449
- def revoke(user_id)
450
- user = Segment.encode(user_id)
451
- call = @call.fresh
452
- ended = delete_sessions(call, user)
453
- # Without the sessions API a login session survives; blocking is the only way to end it.
454
- block(call, user) unless ended
455
- delete_refresh_tokens(call, user_id, user)
456
- ended ? REVOKE_SESSIONS : BAN
457
- end
458
-
459
- # Auth0 answers 403 feature_not_enabled when the tenant's plan does not include an endpoint.
460
- def not_on_plan?(error)
461
- error.status == 403 && error.code == 'feature_not_enabled'
462
- end
463
-
464
- def block(call, user)
465
- call.call('PATCH', "/users/#{user}", body: { 'blocked' => true }, allow_404: true)
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
- def delete_refresh_tokens(call, user_id, user)
479
- begin
480
- call.call('DELETE', "/users/#{user}/refresh-tokens", allow_404: true)
481
- return
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
- class LogOnly
502
- def initialize(log)
503
- @log = log
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 = 'log_only'
362
+ def name = 'custom'
507
363
 
508
- def revoke_sessions(user_id)
509
- Segment.encode(user_id)
510
- @log.call("NoResponse late result (log only): would revoke every session of user #{user_id}")
511
- nil
512
- end
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
- def ban(user_id)
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 = "6.0.1"
98
+ @sdk_version = "7.0.1"
99
99
  @gen_version = "internal"
100
- @user_agent = "noresponse-sdk/ruby 6.0.1 internal 1 noresponse"
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]]) }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: noresponse
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.1
4
+ version: 7.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - NoResponse