end_point_blank 0.6.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +621 -0
  3. data/README.md +236 -19
  4. data/end_point_blank.gemspec +5 -3
  5. data/lib/end_point_blank/access_tokens.rb +244 -25
  6. data/lib/end_point_blank/authorization.rb +105 -20
  7. data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
  8. data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
  9. data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
  10. data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
  11. data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
  12. data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
  13. data/lib/end_point_blank/commands/http.rb +20 -1
  14. data/lib/end_point_blank/configuration.rb +111 -4
  15. data/lib/end_point_blank/configuration_error.rb +18 -0
  16. data/lib/end_point_blank/rails/authenticated.rb +62 -7
  17. data/lib/end_point_blank/rails/authorized.rb +9 -13
  18. data/lib/end_point_blank/target_url.rb +57 -0
  19. data/lib/end_point_blank/token_unavailable_error.rb +102 -0
  20. data/lib/end_point_blank/unauthorized_error.rb +81 -1
  21. data/lib/end_point_blank/version.rb +1 -1
  22. data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
  23. data/lib/end_point_blank/writers/direct_writer.rb +1 -1
  24. data/lib/end_point_blank/writers/exception_writer.rb +11 -2
  25. data/lib/end_point_blank/writers/log_writer.rb +1 -1
  26. data/lib/end_point_blank/writers/request_writer.rb +1 -0
  27. data/lib/end_point_blank/writers/response_writer.rb +1 -0
  28. data/lib/end_point_blank/writers/shared.rb +35 -4
  29. data/lib/end_point_blank.rb +248 -3
  30. metadata +15 -10
  31. data/lib/end_point_blank/loggers/logger.rb +0 -30
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "target_url"
4
+
5
+ module EndPointBlank
6
+ # Reopened with the same superclass in end_point_blank.rb; declared here too
7
+ # so this file can be required on its own.
8
+ class Error < StandardError; end
9
+
10
+ # Raised by {Authorization.header} when no access token can be obtained for
11
+ # an outbound call to a provider.
12
+ #
13
+ # There is deliberately no fallback. Before sc-1469 a failed mint silently
14
+ # produced "Basic base64(client_id:client_secret)" instead, which sent this
15
+ # service's own credential to whichever provider it was calling (and to
16
+ # that provider's intake). A client must never do that, so the call cannot
17
+ # be authorized and the caller has to decide what to do: retry, degrade, or
18
+ # fail its own request.
19
+ #
20
+ # `base_url` is the URL the token was wanted for with its userinfo, query
21
+ # and fragment removed ({TargetUrl.strip}), or nil when it could not be
22
+ # parsed. The raw value is never kept: the caller already has it, and a
23
+ # field on an exception reaches error reporting as surely as the message
24
+ # does.
25
+ #
26
+ # `failure` is the {AccessTokens::Failure} recorded for the mint, when one
27
+ # was recorded, so a handler can branch on `failure.outcome`
28
+ # (:credential_rejected, :request_rejected, :server_error,
29
+ # :transport_error) exactly as it would after calling
30
+ # {AccessTokens.last_failure} itself.
31
+ #
32
+ # A mint that raised is constructed with `unexpected: true` and reported as
33
+ # :transport_error, with the exception as `cause` (Ruby sets it when this
34
+ # is raised from the rescue). Its message is deliberately not copied into
35
+ # this one.
36
+ class TokenUnavailableError < Error
37
+ attr_reader :base_url, :failure
38
+
39
+ # @param base_url [String] the URL the token was wanted for
40
+ # @param failure [AccessTokens::Failure, nil] why the mint failed
41
+ # @param unexpected [Boolean] true when the mint raised rather than
42
+ # reporting a failure
43
+ def initialize(base_url, failure = nil, unexpected: false)
44
+ @base_url = TargetUrl.strip(base_url)
45
+ @failure = failure
46
+ @unexpected = unexpected
47
+ super(build_message)
48
+ end
49
+
50
+ # @return [Boolean] true when the mint raised; `cause` holds what it raised
51
+ def unexpected?
52
+ @unexpected
53
+ end
54
+
55
+ # @return [Symbol, nil] the failure outcome, or nil when none was recorded
56
+ def outcome
57
+ failure&.outcome
58
+ end
59
+
60
+ # @return [Integer, nil] intake's HTTP status, or nil when it never answered
61
+ def status
62
+ failure&.status
63
+ end
64
+
65
+ private
66
+
67
+ UNPARSEABLE_URL = "the requested URL (not shown: it could not be parsed)"
68
+ private_constant :UNPARSEABLE_URL
69
+
70
+ # One fixed text per outcome, the same in every EndPointBlank SDK. Never
71
+ # intake's response body (Failure#reason) or an exception message: the
72
+ # message is what reaches logs and error reporting, and neither is ours
73
+ # to vouch for. Failure#reason stays available on #failure.
74
+ def reason # rubocop:disable Metrics/MethodLength
75
+ # Before the outcome, because a mint that raised is also :transport_error.
76
+ return "the token request failed unexpectedly" if unexpected?
77
+
78
+ http = status ? " (HTTP #{status})" : ""
79
+
80
+ case outcome
81
+ when :credential_rejected
82
+ "intake rejected this application's client credential#{http}; " \
83
+ "retrying cannot help -- re-issue the credential"
84
+ when :request_rejected
85
+ "intake refused the token request#{http}; check the URL and that a grant covers the target"
86
+ when :server_error
87
+ "intake failed to issue a token#{http}; this may be transient"
88
+ when :transport_error
89
+ "intake could not be reached (timeout, connection refused or retries exhausted); " \
90
+ "this may be transient"
91
+ else
92
+ "the token request failed for an unknown reason"
93
+ end
94
+ end
95
+
96
+ def build_message
97
+ "Could not mint an EndPointBlank access token for #{base_url || UNPARSEABLE_URL}: #{reason}. " \
98
+ "EndPointBlank never sends this service's client_id/client_secret to a provider, " \
99
+ "so there is no Basic-auth fallback and the call must not be made without a token."
100
+ end
101
+ end
102
+ end
@@ -1,4 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
1
5
  module EndPointBlank
6
+ # Raised when a request fails authentication or authorization.
7
+ #
8
+ # This error is intentionally not logged by the middleware, as unauthorized
9
+ # access attempts are expected to occur.
10
+ #
11
+ # `status` carries the status intake answered the authenticate or authorize
12
+ # call with, so a handler can tell the two refusals apart: 401 means the
13
+ # credential was not accepted and the integrator should check it, 403 means
14
+ # the credential was fine but no grant covers this endpoint and the
15
+ # integrator should ask for one. Collapsing both to 401 sends them to debug
16
+ # the wrong thing. The other SDKs each carry the same value under their own
17
+ # idiomatic name -- `statusCode` in JS, `getStatusCode()` in Java,
18
+ # `status_code` in Python; this is Ruby's.
19
+ #
20
+ # It defaults to 401 so that `UnauthorizedError.new(message)` keeps working
21
+ # unchanged, and because 401 is the safe reading of a refusal that arrives
22
+ # with no status attached. The concerns never lean on that default: they pass
23
+ # intake's status, or 503 when intake did not answer at all.
2
24
  class UnauthorizedError < StandardError
3
25
  attr_reader :status
4
26
 
@@ -6,5 +28,63 @@ module EndPointBlank
6
28
  super(message)
7
29
  @status = status
8
30
  end
31
+
32
+ # Builds the error for a non-201 answer to intake's authenticate or
33
+ # authorize call. `action` is "Authentication" or "Authorization".
34
+ #
35
+ # One method rather than a copy per concern. `Rails::Authenticated` and
36
+ # `Rails::Authorized` were two transcriptions of one decision, and two
37
+ # copies is how one path acquires a fix the other does not. That is not
38
+ # hypothetical here: `authorize!` passed intake's status and read the body
39
+ # defensively, while `authenticate!` used `raise UnauthorizedError,
40
+ # "message"` -- the two-argument `raise Class, message` form, which
41
+ # structurally cannot pass a status however willing this class is to accept
42
+ # one -- and parsed the body before it had checked there was a body to
43
+ # parse. Both are fixed here, once.
44
+ #
45
+ # @param result [#status, #body, nil] intake's answer, or nil if it did not
46
+ # answer at all.
47
+ # @param action [String] the word naming what was attempted.
48
+ # @return [UnauthorizedError]
49
+ def self.refusal_from(result, action)
50
+ if result.nil?
51
+ # Intake never answered at all, so nothing refused this caller and 401
52
+ # would blame a credential that was never judged. 503 says the true
53
+ # thing -- the check could not be made -- and is what the other four
54
+ # SDKs already send for this same case: JS and Java
55
+ # `response ? response.status : 503`, Python's `refusal_from`, and
56
+ # Elixir's literal `send_resp(503, ...)`. It is also the one case the
57
+ # "service unavailable" wording is actually true for.
58
+ #
59
+ # The wording deliberately has no "#{action} failed:" prefix, because
60
+ # that is what `authorize!` has always sent for this case and this
61
+ # method must not change what the working path produces for any input.
62
+ # The other SDKs prefix it; the status, which is what a handler
63
+ # branches on, agrees with them.
64
+ return new("#{action} service unavailable", 503)
65
+ end
66
+
67
+ # Intake's verdict verbatim. 401 tells an integrator to check the
68
+ # credential, 403 tells them to ask for a grant.
69
+ new("#{action} failed: #{reason_from(result)}", result.status)
70
+ end
71
+
72
+ # intake sends `{"error": "..."}`, but the SDK reaches it through a proxy
73
+ # and a WAF or load balancer can answer with an HTML page intake never
74
+ # generated. An unparseable body is therefore an expected case, not a
75
+ # swallowed failure: it falls back to the raw body so the operator still
76
+ # sees exactly what came back rather than an empty reason.
77
+ def self.reason_from(result)
78
+ parsed =
79
+ begin
80
+ JSON.parse(result.body)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ detail = parsed.is_a?(Hash) ? parsed["error"] : nil
86
+ detail || result.body
87
+ end
88
+ private_class_method :reason_from
9
89
  end
10
- end
90
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EndPointBlank
4
- VERSION = "0.6.0"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -16,14 +16,46 @@ module EndPointBlank
16
16
  module DelayedWriter
17
17
  MAX_QUEUE_SIZE = 1000
18
18
  WARN_THROTTLE_SECONDS = 30
19
+ # Payloads per outbound request. One request per payload would multiply
20
+ # the host application's outbound traffic by its own request rate; the
21
+ # drain exists to amortise that.
22
+ BATCH_SIZE = 6
19
23
  # Fallback thread count used when Configuration#worker_count is unset,
20
24
  # preserving the previously-hardcoded pool size.
21
25
  DEFAULT_WORKER_COUNT = 2
26
+ # Applied after a worker iteration raises, doubling with each consecutive
27
+ # failure up to the cap. Surviving a defect must not mean retrying as fast
28
+ # as the CPU allows: a persistent failure should read as a slow, loud
29
+ # retry, not as a silent hot loop.
30
+ WORKER_BACKOFF_SECONDS = 0.1
31
+ MAX_WORKER_BACKOFF_SECONDS = 30
32
+
33
+ # sc-376: eager, so `queue`/`enqueue_mutex` are never nil for any
34
+ # worker thread. Every real includer (RequestWriter, ResponseWriter,
35
+ # ExceptionWriter, LogWriter) calls `super()` here before
36
+ # `start_threads`, so this always runs first, single-threaded, before
37
+ # any worker exists. An includer whose `initialize` skips `super`
38
+ # (some of this module's own specs, deliberately) never reaches this;
39
+ # `queue`/`enqueue_mutex` fall back to lazy `||=` for those.
40
+ def initialize
41
+ @queue = Queue.new
42
+ @enqueue_mutex = Mutex.new
43
+ end
22
44
 
23
45
  def direct_writer
24
46
  @direct_writer ||= DirectWriter.new(url)
25
47
  end
26
48
 
49
+ # `@queue ||= Queue.new` is a read, a nil check, then a write -- not
50
+ # atomic. Two threads racing this before either assigns could each
51
+ # allocate their own Queue, and the loser's is an orphan nothing will
52
+ # ever read from again. In production only this class's own worker
53
+ # threads could ever race here (every writer is a Singleton whose
54
+ # `initialize`, and so `super()` above, completes before any caller
55
+ # can reach `enqueue`), and `initialize` above now sets `@queue`
56
+ # before `start_threads` spawns a single one of them. The `||=` stays
57
+ # only as a fallback for an includer that skips this module's
58
+ # `initialize` entirely -- see the comment there.
27
59
  def queue
28
60
  @queue ||= Queue.new
29
61
  end
@@ -36,27 +68,7 @@ module EndPointBlank
36
68
  @threads = []
37
69
 
38
70
  worker_count.times do
39
- @threads << Thread.new do
40
- loop do
41
- payload = queue.pop
42
- payloads = [payload]
43
- while (payload = pop_additional)
44
- payloads << payload
45
- end
46
-
47
- payloads.compact!
48
- while payloads.any?
49
- list = payloads[0..5]
50
- response = direct_writer.write(list)
51
- if response.status < 299
52
- on_success(response) if respond_to?(:on_success)
53
- elsif respond_to?(:on_failure)
54
- on_failure(response)
55
- end
56
- payloads -= list
57
- end
58
- end
59
- end
71
+ @threads << Thread.new { run_worker }
60
72
  end
61
73
  end
62
74
 
@@ -78,8 +90,106 @@ module EndPointBlank
78
90
  EndPointBlank.logger.warn(message)
79
91
  end
80
92
 
93
+ # Logs a delivery or worker-loop error, through the same seam. Everything
94
+ # a worker recovers from goes through here: recovering quietly would trade
95
+ # one silent failure for another.
96
+ def log_error(message)
97
+ EndPointBlank.logger.error(message)
98
+ end
99
+
81
100
  private
82
101
 
102
+ # The body of a worker thread.
103
+ #
104
+ # A thread that dies here takes every future payload in the process with
105
+ # it - silently, until a restart - which is far worse than losing the
106
+ # batch in flight. So the loop catches every StandardError, says what it
107
+ # caught, and carries on.
108
+ #
109
+ # What it deliberately does not catch is anything outside StandardError:
110
+ # SystemExit, Interrupt, SignalException, NoMemoryError. Those mean the
111
+ # process itself is going down or is already broken, and a telemetry
112
+ # worker has no business arguing with that.
113
+ def run_worker
114
+ consecutive_failures = 0
115
+
116
+ loop do
117
+ drain_once
118
+ consecutive_failures = 0
119
+ rescue StandardError => e
120
+ consecutive_failures += 1
121
+ note_worker_error(e, consecutive_failures)
122
+ sleep(worker_backoff(consecutive_failures))
123
+ end
124
+ end
125
+
126
+ # Blocks for the next payload, then takes everything else already waiting
127
+ # so a burst leaves as a few batches rather than one request per payload.
128
+ #
129
+ # The batches are cut by position, and that is the point. This used to
130
+ # take a prefix and then remove it with `payloads -= list`, and Array#-
131
+ # removes every element *equal to* one in the batch rather than the ones
132
+ # actually sent: seven byte-identical payloads with a batch size of six
133
+ # meant six delivered and all seven gone. Nothing counted the loss and the
134
+ # queue drained normally, so it read as if nothing had happened.
135
+ #
136
+ # Equal payloads are ordinary, not exotic. Payloads are hashes, and two
137
+ # requests to the same endpoint from the same application in the same
138
+ # environment differ only in high-cardinality fields; `sent_at` at
139
+ # millisecond precision is not a reliable discriminator at ingest volumes.
140
+ # Slicing by index makes equality irrelevant rather than merely handled.
141
+ def drain_once
142
+ payloads = [queue.pop]
143
+ while (payload = pop_additional)
144
+ payloads << payload
145
+ end
146
+
147
+ payloads.compact!
148
+ payloads.each_slice(BATCH_SIZE) { |list| deliver_batch(list) }
149
+ end
150
+
151
+ def deliver_batch(list)
152
+ response = direct_writer.write(list)
153
+ return note_unanswered(list) if response.nil?
154
+
155
+ if response.status < 299
156
+ on_success(response) if respond_to?(:on_success)
157
+ elsif respond_to?(:on_failure)
158
+ on_failure(response)
159
+ end
160
+ end
161
+
162
+ # nil is what Commands::Http returns once its retries are exhausted. It is
163
+ # not a status - it is the absence of an answer - so it is reported rather
164
+ # than compared: on_failure hears about it with nil, meaning "nothing
165
+ # answered", and the batch it cost us is named in the log either way.
166
+ def note_unanswered(list)
167
+ log_error(
168
+ "[EndPointBlank] no response from #{url} after retries; " \
169
+ "#{list.size} payload(s) in that batch are lost"
170
+ )
171
+ on_failure(nil) if respond_to?(:on_failure)
172
+ end
173
+
174
+ def note_worker_error(error, consecutive_failures)
175
+ log_error(
176
+ "[EndPointBlank] send worker recovered from #{error.class}: #{error.message} " \
177
+ "(consecutive failure #{consecutive_failures}); the batch in flight is lost, " \
178
+ "retrying in #{worker_backoff(consecutive_failures).round(1)}s at #{Array(error.backtrace).first}"
179
+ )
180
+ end
181
+
182
+ def worker_backoff(consecutive_failures)
183
+ [
184
+ WORKER_BACKOFF_SECONDS * (2**(consecutive_failures - 1)),
185
+ MAX_WORKER_BACKOFF_SECONDS
186
+ ].min
187
+ end
188
+
189
+ # Same shape and same fallback rationale as `queue` above, and worse in
190
+ # kind if it ever raced: two threads synchronizing on *different* Mutex
191
+ # objects are not synchronized at all, so the critical section in
192
+ # `enqueue_one` would silently stop being one.
83
193
  def enqueue_mutex
84
194
  @enqueue_mutex ||= Mutex.new
85
195
  end
@@ -10,7 +10,7 @@ module EndPointBlank
10
10
  end
11
11
 
12
12
  def write(list)
13
- auth = EndPointBlank::Authorization.header
13
+ auth = EndPointBlank::Authorization.intake_header
14
14
  EndPointBlank::Commands::Http.post(@url, auth, { payload: list })
15
15
  end
16
16
  end
@@ -12,6 +12,7 @@ module EndPointBlank
12
12
  attr_reader :url
13
13
 
14
14
  def initialize
15
+ super()
15
16
  @url = EndPointBlank::Configuration.instance.errors_url
16
17
  start_threads
17
18
  end
@@ -20,10 +21,18 @@ module EndPointBlank
20
21
  instance.write(exception)
21
22
  end
22
23
 
24
+ # Builds an error payload with or without an in-flight request. `env` is
25
+ # nil whenever an exception is reported from outside `EnvStore`'s scope
26
+ # (a background job, a worker, a boot-time failure) -- that is precisely
27
+ # the case this method exists to handle, not an edge case to special-case
28
+ # away. `Rack::Request.new(env)` was built unconditionally here even
29
+ # though nothing about an error report requires a request: version
30
+ # detection (Commands::VersionFinder) reads request params/path and is
31
+ # meaningless without one, so it is skipped rather than attempted and
32
+ # rescued. Its result was never even merged into the returned hash, so
33
+ # skipping it changes nothing for the in-request case either.
23
34
  def payload(exception)
24
35
  env = ::EndPointBlank::Rack::EnvStore.get
25
- request = ::Rack::Request.new(env)
26
- version = Commands::VersionFinder.new.find(request)
27
36
  {
28
37
  app_name: app_name,
29
38
  uuid: request_uuid(env),
@@ -12,6 +12,7 @@ module EndPointBlank
12
12
  attr_reader :url
13
13
 
14
14
  def initialize
15
+ super()
15
16
  @url = EndPointBlank::Configuration.instance.logs_url
16
17
  start_threads
17
18
  end
@@ -61,7 +62,6 @@ module EndPointBlank
61
62
  stamped_http_method: rack_req.request_method
62
63
  )
63
64
  end
64
- puts "Writing log: #{json}"
65
65
  enqueue(json)
66
66
  end
67
67
  end
@@ -12,6 +12,7 @@ module EndPointBlank
12
12
  attr_reader :url
13
13
 
14
14
  def initialize
15
+ super()
15
16
  @url = EndPointBlank::Configuration.instance.requests_url
16
17
  start_threads
17
18
  end
@@ -12,6 +12,7 @@ module EndPointBlank
12
12
  attr_reader :url
13
13
 
14
14
  def initialize
15
+ super()
15
16
  @url = EndPointBlank::Configuration.instance.responses_url
16
17
  start_threads
17
18
  end
@@ -1,8 +1,17 @@
1
+ require "securerandom"
2
+
1
3
  module EndPointBlank
2
4
  module Writers
3
5
  module Shared
4
6
  attr_accessor :url
5
7
 
8
+ # Where a minted request_uuid is cached on the Rack env, when the env
9
+ # carries no request id of its own. Distinct from
10
+ # "action_dispatch.request_id" so a later Rails middleware that sets
11
+ # the real key is never shadowed by ours -- we only ever read that key
12
+ # first and fall back to this one.
13
+ GENERATED_UUID_KEY = "end_point_blank.generated_request_uuid".freeze
14
+
6
15
  def configuration
7
16
  Configuration.instance
8
17
  end
@@ -24,11 +33,33 @@ module EndPointBlank
24
33
  end
25
34
 
26
35
  # Rails' ActionDispatch::Request#uuid simply reads this same Rack env
27
- # key, so this is behavior-preserving when running under Rails, and
28
- # gracefully returns nil when it isn't (plain Rack / Sinatra), instead
29
- # of requiring actionpack's ActionDispatch::Request to be loaded.
36
+ # key, so reading it directly is behavior-preserving when running under
37
+ # Rails, without requiring actionpack's ActionDispatch::Request to be
38
+ # loaded.
39
+ #
40
+ # Outside Rails (plain Rack / Sinatra) nothing sets that key. Falling
41
+ # through to nil there used to be described as "graceful", but it is
42
+ # not: intake requires `uuid` on every error/log/request/response row
43
+ # and refuses the ones that lack it, so a nil uuid is a silently
44
+ # dropped row, not a tolerated one. Minting a fresh uuid instead means
45
+ # the row still lands -- correlating with nothing is strictly better
46
+ # than not existing.
47
+ #
48
+ # The mint is cached back onto `env`, not just returned. RequestWriter
49
+ # and ResponseWriter both call this with the *same* env object for one
50
+ # HTTP request -- EnvStore hands back whatever `set/1` was given at the
51
+ # top of the middleware, unchanged, for the life of the request. Without
52
+ # caching, each writer's call under plain Rack would mint its own fresh
53
+ # uuid, and the request row and the response row for a single
54
+ # interaction would carry two different, unrelated ids -- silently
55
+ # defeating the one thing these columns exist for. When `env` is nil
56
+ # (an exception reported with no request in flight at all) there is no
57
+ # shared object to cache onto and nothing to correlate across, so a
58
+ # standalone mint every time is correct.
30
59
  def request_uuid(env)
31
- env && env["action_dispatch.request_id"]
60
+ return SecureRandom.uuid unless env
61
+
62
+ env["action_dispatch.request_id"] || (env[GENERATED_UUID_KEY] ||= SecureRandom.uuid)
32
63
  end
33
64
 
34
65
  def apply_masking(payload, record_type)