end_point_blank 0.6.1 → 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 +608 -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 +239 -2
  30. metadata +15 -10
  31. data/lib/end_point_blank/loggers/logger.rb +0 -30
@@ -1,12 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'singleton'
3
+ require "singleton"
4
4
 
5
5
  module EndPointBlank
6
6
  module Commands
7
7
  # Thread-safe singleton cache for storing authentication credentials.
8
- # Capped at MAX_SIZE entries. When full, expired entries are evicted first;
9
- # if still at capacity the entry expiring soonest is removed.
8
+ # It is scoped to a single Ruby process: there is nothing behind it but
9
+ # a plain Hash (no Rails.cache, Redis, or other shared store), so a
10
+ # Puma or Unicorn worker, and every separate app instance, each hold
11
+ # their own cache and their own view of cache_ttl. Nothing described
12
+ # below crosses a process boundary.
13
+ #
14
+ # Capped at MAX_SIZE entries. When full, stale entries are evicted first;
15
+ # if still at capacity, whichever remaining entry's recorded expires_at
16
+ # is earliest is removed (see the note on make_room_for below -- that is
17
+ # a heuristic, not exactly "closest to becoming stale").
18
+ #
19
+ # Validity is re-derived on every read against the cache_ttl configured
20
+ # AT READ TIME (sc-755), not just against the expiry computed when the
21
+ # entry was written:
22
+ #
23
+ # - If cache_ttl is currently 0 (disabled), the ENTIRE cache **in
24
+ # this process** is cleared -- every entry, not just the one looked
25
+ # up or being stored -- on both a read and a store that observe the
26
+ # disabled state. (Amended 2026-09-14: an earlier version of this
27
+ # cache deleted only the single key being read, which let a
28
+ # *different* key go on answering after cache_ttl was raised again
29
+ # -- a revoked grant could resurrect. This matches the Elixir SDK's
30
+ # sc-660 `AuthCache.clear/0`, which clears the whole ETS table on
31
+ # the disabled get/put path.)
32
+ # Because each process's cache is independent, this does not reach
33
+ # any other worker or instance: each one clears only when it has
34
+ # itself observed cache_ttl disabled and then handled an Authorized
35
+ # request (or a direct cache call) while disabled. A worker sitting
36
+ # idle, or one that has not yet picked up the new config, keeps
37
+ # serving whatever it already cached until it does -- nothing here
38
+ # coordinates that across processes, and there is no fleet-wide
39
+ # schedule for when, or whether, any given process's turn comes.
40
+ # Known residual, left unaddressed here rather than fixed: a disable
41
+ # followed by a re-enable with **no cache read or store in between**
42
+ # flushes nothing, because nothing ever observed the disabled state
43
+ # to trigger the clear. This story does not add configure-time
44
+ # flushing.
45
+ # - Otherwise an entry HITs only while it is within BOTH its original
46
+ # write-time expiry (so raising cache_ttl later never extends an
47
+ # entry's life) AND the CURRENT cache_ttl measured from its write
48
+ # time (so lowering cache_ttl shortens already-cached entries on
49
+ # their next read, without waiting for the original expiry).
50
+ #
51
+ # This is deliberately not "remaining time until the original expiry <=
52
+ # the new ttl" -- that arithmetic drifts back into looking valid once
53
+ # enough real time has passed, even though the entry is older than the
54
+ # new ttl allows. Elapsed-since-write is compared to the current ttl
55
+ # directly instead.
56
+ #
57
+ # cache_ttl is always a non-negative Integer by the time this cache reads
58
+ # it: Configuration#cache_ttl= refuses nil, negative numbers and
59
+ # non-Integers at configure time (sc-970), so there is no invalid value
60
+ # left for this class to detect at read or store time.
10
61
  class AuthenticationCache
11
62
  include Singleton
12
63
 
@@ -23,16 +74,17 @@ module EndPointBlank
23
74
  # @return [Object] The stored credentials
24
75
  def store(key, credentials)
25
76
  return unless credentials
77
+
26
78
  @mutex.synchronize do
79
+ ttl = current_ttl
80
+ # A store made while disabled clears whatever is already cached,
81
+ # same as a disabled read -- see the class comment -- and inserts
82
+ # nothing itself.
83
+ next if clear_if_disabled!(ttl)
84
+
27
85
  now = Time.now
28
- # Evict expired entries first
29
- @cache.delete_if { |_, v| v[:expired_at] <= now }
30
- # Evict the entry expiring soonest if still at capacity
31
- if @cache.size >= MAX_SIZE
32
- oldest_key = @cache.min_by { |_, v| v[:expired_at] }.first
33
- @cache.delete(oldest_key)
34
- end
35
- @cache[key] = { expired_at: now + ::EndPointBlank::Configuration.instance.cache_ttl, credentials: credentials }
86
+ make_room_for(now, ttl)
87
+ @cache[key] = { written_at: now, expires_at: now + ttl, credentials: credentials }
36
88
  end
37
89
  end
38
90
 
@@ -41,11 +93,8 @@ module EndPointBlank
41
93
  # @return [Object, nil] The stored credentials or nil if not found
42
94
  def retrieve(key)
43
95
  @mutex.synchronize do
44
- if @cache.key?(key)
45
- @cache[key][:credentials] if @cache[key][:expired_at] > Time.now
46
- else
47
- nil
48
- end
96
+ entry = valid_entry(key)
97
+ entry && entry[:credentials]
49
98
  end
50
99
  end
51
100
 
@@ -53,9 +102,7 @@ module EndPointBlank
53
102
  # @param key [String, Symbol] The identifier to check
54
103
  # @return [Boolean] True if credentials exist, false otherwise
55
104
  def exists?(key)
56
- @mutex.synchronize do
57
- @cache.key?(key) && @cache[key][:expired_at] > Time.now
58
- end
105
+ @mutex.synchronize { !valid_entry(key).nil? }
59
106
  end
60
107
 
61
108
  # Remove credentials from the cache
@@ -90,6 +137,81 @@ module EndPointBlank
90
137
  @cache.size
91
138
  end
92
139
  end
140
+
141
+ private
142
+
143
+ # Looks up `key` against the CURRENT cache_ttl.
144
+ #
145
+ # If the cache is disabled, the entire cache is cleared -- see the
146
+ # class comment -- and this returns nil regardless of whether `key`
147
+ # was ever present (a disabled read of a key that was never cached
148
+ # still has to flush everything else).
149
+ #
150
+ # Otherwise, if `key` is present but stale under the current ttl, only
151
+ # that single entry is deleted. Must be called with @mutex already
152
+ # held: every delete here runs in the same critical section as the
153
+ # read that decided it was stale, so a concurrently-written fresh
154
+ # entry for the same key can never be the one removed.
155
+ def valid_entry(key)
156
+ ttl = current_ttl
157
+ return nil if clear_if_disabled!(ttl)
158
+
159
+ entry = @cache[key]
160
+ return nil unless entry
161
+
162
+ if fresh?(entry, Time.now, ttl)
163
+ entry
164
+ else
165
+ @cache.delete(key)
166
+ nil
167
+ end
168
+ end
169
+
170
+ # Already validated by Configuration#cache_ttl= -- see the class
171
+ # comment.
172
+ def current_ttl
173
+ ::EndPointBlank::Configuration.instance.cache_ttl
174
+ end
175
+
176
+ def cache_disabled?(ttl)
177
+ ttl <= 0
178
+ end
179
+
180
+ # If `ttl` means disabled, clears the whole cache and returns true;
181
+ # otherwise returns false and leaves the cache untouched. Shared by
182
+ # the read and write paths so "disabled" clears everything identically
183
+ # from either one -- see the class comment.
184
+ def clear_if_disabled!(ttl)
185
+ return false unless cache_disabled?(ttl)
186
+
187
+ @cache.clear
188
+ true
189
+ end
190
+
191
+ # Evict stale entries first (using the ttl we are about to write with
192
+ # -- the same rule a subsequent read would apply); if still at
193
+ # capacity, evict whichever remaining entry's recorded expires_at is
194
+ # earliest. That is a heuristic, not the true "closest to becoming
195
+ # stale under the current ttl" (which would be
196
+ # min(expires_at, written_at + ttl)) -- harmless today since it only
197
+ # ever picks among entries `fresh?` already accepted, but not the same
198
+ # claim as "expires soonest".
199
+ def make_room_for(now, ttl)
200
+ @cache.delete_if { |_, v| !fresh?(v, now, ttl) }
201
+ return if @cache.size < MAX_SIZE
202
+
203
+ oldest_key = @cache.min_by { |_, v| v[:expires_at] }.first
204
+ @cache.delete(oldest_key)
205
+ end
206
+
207
+ # An entry is fresh only while it is within BOTH its own write-time
208
+ # expiry (raising ttl later never extends it) AND the currently
209
+ # configured ttl measured from when it was written (lowering ttl
210
+ # shortens it immediately). Never compare remaining-time-to-expiry
211
+ # against the new ttl -- see the class comment.
212
+ def fresh?(entry, now, ttl)
213
+ now < entry[:expires_at] && (now - entry[:written_at]) < ttl
214
+ end
93
215
  end
94
216
  end
95
217
  end
@@ -1,5 +1,6 @@
1
1
  #!/bin/ruby
2
2
 
3
+ require 'json'
3
4
  require_relative 'http'
4
5
 
5
6
  module EndPointBlank
@@ -12,23 +13,86 @@ module EndPointBlank
12
13
 
13
14
  def authenticate(request)
14
15
  client_auth = request.headers['Authorization']
15
- auth = "Basic #{AuthorizationGenerate.generate}"
16
+ # `Authorization.intake_header` (the no-argument `Authorization.header`
17
+ # before sc-1469), as EndpointAuthorize uses. It previously read
18
+ # `"Basic #{AuthorizationGenerate.generate}"` -- a second constant
19
+ # this gem has never defined, so the only caller of this command
20
+ # could not have reached the network even once the caller's own
21
+ # missing constant was fixed. Fixing one without the other just moves
22
+ # the NameError a frame deeper.
23
+ #
24
+ # This already returns a complete "Basic <base64>" string; wrapping
25
+ # it in another "Basic " would send `Basic Basic ...` and intake
26
+ # would refuse every request. Basic is right here: this call goes to
27
+ # this service's own intake, never to a provider.
28
+ auth = Authorization.intake_header
16
29
  body = {
17
30
  path: request.route_uri_pattern.to_s.gsub(/\([^)]*\)/, ''),
18
31
  http_method: request.request_method,
19
32
  client_auth: client_auth,
20
33
  application: Configuration.instance.app_name,
21
34
  endpoint_version: VersionFinder.new.find(request),
22
- ip_address: request.remote_ip
35
+ # `source_ip`, which is the key intake reads. This sent
36
+ # `ip_address` from the beginning -- and js, py and java copied it
37
+ # faithfully, so all four were wrong together until sc-320 fixed
38
+ # the three ports. intake ignores keys it does not cast, so nothing
39
+ # ever failed: `source_ip_address` was simply NULL on every
40
+ # authenticate row from a Rails application, and every per-source-IP
41
+ # question about authenticate traffic read as though there were
42
+ # none. EndpointAuthorize on the next path over has always sent it
43
+ # under the right name.
44
+ source_ip: request.remote_ip
23
45
  }
24
46
  response = Http.post(configuration.authorize_url, auth, body)
25
47
  return nil if response.nil?
26
48
  EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
49
+ if response.status == 201
50
+ source_env_id = source_application_environment_id(response.body)
51
+ ::EndPointBlank::Rack::EnvStore.set_source_application_environment_id(source_env_id)
52
+ end
27
53
  if response.status > 299
28
54
  EndPointBlank.logger.error "Failed to authenticate: #{response.status} - #{response.body}"
29
55
  end
30
56
  response
31
57
  end
58
+
59
+ private
60
+
61
+ def source_application_environment_id(body)
62
+ parsed = JSON.parse(body)
63
+ # `JSON.parse` succeeds on any RFC 7159 document, not just objects --
64
+ # `null`, `[]`, `5` and `"x"` all parse cleanly. `.dig('data', 0, ...)`
65
+ # on any of those raises `TypeError`/`NoMethodError`, which nothing
66
+ # downstream rescues: it would escape `authenticate` and turn a 201
67
+ # intake just granted into a 500 for the caller. Guard the shape
68
+ # before reading fields out of it, the same way
69
+ # `UnauthorizedError.reason_from` guards its own parsed body.
70
+ unless parsed.is_a?(Hash)
71
+ EndPointBlank.logger.error(
72
+ "Authenticated, but the authorize response body was not a JSON " \
73
+ "object, so this request's responses, logs and errors will not " \
74
+ "name their caller: body=#{body}"
75
+ )
76
+ return nil
77
+ end
78
+
79
+ id = parsed.dig('data', 0, 'source_application_environment_id')
80
+ return id if id.is_a?(String) && !id.empty?
81
+
82
+ EndPointBlank.logger.error(
83
+ "Authenticated, but the authorize response has no " \
84
+ "data[0].source_application_environment_id, so this request's responses, " \
85
+ "logs and errors will not name their caller: body=#{body}"
86
+ )
87
+ nil
88
+ rescue JSON::ParserError => error
89
+ EndPointBlank.logger.error(
90
+ "Authenticated, but the authorize response body was not valid " \
91
+ "JSON, so this request's responses, logs and errors will not " \
92
+ "name their caller: #{error.message}"
93
+ )
94
+ nil
95
+ end
32
96
  end
33
97
 
34
98
  def self.included(base)
@@ -2,6 +2,14 @@
2
2
 
3
3
  module EndPointBlank
4
4
  module Commands
5
+ BEARER_GENERATE_DEPRECATION =
6
+ "EndPointBlank::Commands::BearerGenerate is deprecated and will be removed: its header " \
7
+ "carries this service's own client_id/client_secret and is only valid for this service's " \
8
+ "own EndPointBlank intake. Never send it to a provider; use " \
9
+ "EndPointBlank::Authorization.header(base_url) for outbound calls (sc-1469).".freeze
10
+ BEARER_GENERATE_DEPRECATION_MUTEX = Mutex.new
11
+ private_constant :BEARER_GENERATE_DEPRECATION_MUTEX
12
+
5
13
  module BearerGenerateMethods
6
14
  module ClassMethods
7
15
  def configuration
@@ -9,12 +17,36 @@ module EndPointBlank
9
17
  end
10
18
 
11
19
  def generate()
20
+ warn_deprecated
12
21
  Base64.encode64(configuration.client_id + ":" + configuration.client_secret).gsub("\n", "")
13
22
  end
14
23
 
15
24
  def auth_header
16
25
  "Basic " + generate
17
26
  end
27
+
28
+ private
29
+
30
+ # Once per process, not once per call: this sits on a request path,
31
+ # and a warning per request would bury the log it is meant to reach.
32
+ # The first caller wins the flag under a mutex so two threads racing
33
+ # the first call still print one line.
34
+ #
35
+ # Kernel#warn with category: :deprecated is printed only while
36
+ # Warning[:deprecated] is on (`ruby -W:deprecated`, or
37
+ # `Warning[:deprecated] = true`), which is how Ruby gates its own
38
+ # deprecations; the gem has no ActiveSupport outside Rails to lean on.
39
+ def warn_deprecated
40
+ return if @deprecation_warned
41
+
42
+ BEARER_GENERATE_DEPRECATION_MUTEX.synchronize do
43
+ return if @deprecation_warned
44
+
45
+ @deprecation_warned = true
46
+ end
47
+
48
+ warn(BEARER_GENERATE_DEPRECATION, category: :deprecated)
49
+ end
18
50
  end
19
51
 
20
52
  def self.included(base)
@@ -26,6 +58,10 @@ module EndPointBlank
26
58
  # Creates a Base64-encoded string from the client_id and client_secret
27
59
  # configured in EndPointBlank::Configuration.
28
60
  # Use auth_header class method to get a properly formatted "Basic {credentials}" header.
61
+ #
62
+ # @deprecated The header carries this service's own client secret and is only valid
63
+ # for this service's own EndPointBlank intake (see Authorization.intake_header).
64
+ # Never send it to a provider; use Authorization.header(base_url) (sc-1469).
29
65
  class BearerGenerate
30
66
  include BearerGenerateMethods
31
67
  end
@@ -40,6 +40,7 @@ module EndPointBlank
40
40
  # headers would appear only on cache misses, which reads as a flaky
41
41
  # feature rather than a missing one.
42
42
  if (cached = cache.retrieve(cache_key))
43
+ record_source_application_environment_id(cached)
43
44
  return CachedResponse.new(201, cached)
44
45
  end
45
46
 
@@ -64,17 +65,61 @@ module EndPointBlank
64
65
  # token, so the 401 retry that used to live here is gone: a 401 now
65
66
  # means the credential is wrong, which is worth surfacing rather
66
67
  # than retrying.
67
- response = Http.post(configuration.authorize_url, Authorization.header, body)
68
+ response = Http.post(configuration.authorize_url, Authorization.intake_header, body)
68
69
 
69
70
  return nil if response.nil?
70
71
  EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
71
72
  if response.status == 201
73
+ record_source_application_environment_id(response.body)
72
74
  cache.store(cache_key, response.body)
73
75
  elsif response.status > 299
74
76
  EndPointBlank.logger.error "Failed to authorize endpoint: #{response.status} - #{response.body}"
75
77
  end
76
78
  response
77
79
  end
80
+
81
+ private
82
+
83
+ # Intake renders the grant under data[0]. The Rails controller also
84
+ # records this value, but the command is also used directly (today,
85
+ # by end_point_blank_deploy's conformance driver), so the command must
86
+ # carry the grant into the request store before any writer runs.
87
+ def record_source_application_environment_id(body)
88
+ parsed = JSON.parse(body)
89
+ # A 201 whose body is valid JSON but not a Hash -- `[]`, `null`,
90
+ # `"x"` -- used to reach `dig` below and raise (TypeError for an
91
+ # Array, NoMethodError for nil or a String), turning a request
92
+ # intake had actually GRANTED into a crash for this direct-caller
93
+ # path. The other SDKs all guard the type before digging into the
94
+ # body (py: isinstance on both levels, js: optional chaining,
95
+ # elixir: pattern match with a fallback clause); this matches them.
96
+ unless parsed.is_a?(Hash)
97
+ EndPointBlank.logger.error(
98
+ "Authorized, but the response body did not parse to a JSON object, so this " \
99
+ "request's responses, logs and errors will not name their caller: body=#{body}"
100
+ )
101
+ return ::EndPointBlank::Rack::EnvStore.set_source_application_environment_id(nil)
102
+ end
103
+
104
+ id = parsed.dig('data', 0, 'source_application_environment_id')
105
+ # An empty string is the same absence as a missing id -- do not
106
+ # record it, or the log line above claiming the caller "will not be
107
+ # named" is contradicted by the very next line.
108
+ id = nil if id == ''
109
+ if id.nil?
110
+ EndPointBlank.logger.error(
111
+ "Authorized, but the response has no data[0].source_application_environment_id, " \
112
+ "so this request's responses, logs and errors will not name their caller: body=#{body}"
113
+ )
114
+ end
115
+ ::EndPointBlank::Rack::EnvStore.set_source_application_environment_id(id)
116
+ rescue JSON::ParserError
117
+ EndPointBlank.logger.error(
118
+ "Authorized, but the response body is not valid JSON, so this request's responses, " \
119
+ "logs and errors will not name their caller: body=#{body}"
120
+ )
121
+ ::EndPointBlank::Rack::EnvStore.set_source_application_environment_id(nil)
122
+ end
78
123
  end
79
124
 
80
125
  def self.included(base)
@@ -19,7 +19,7 @@ module EndPointBlank
19
19
  end
20
20
 
21
21
  def auth
22
- Authorization.header
22
+ Authorization.intake_header
23
23
  end
24
24
 
25
25
  def write(data)
@@ -28,7 +28,7 @@ module EndPointBlank
28
28
  "app_version=#{data[:app_version]}"
29
29
 
30
30
  response = Excon.post(configuration.endpoint_update_url,
31
- headers: {'Authorization' => auth, 'Content-Type' => 'application/json'},
31
+ headers: EndPointBlank::Commands::Http.headers(auth),
32
32
  body: data.to_json,
33
33
  **EndPointBlank::Commands::Http::TIMEOUT_OPTIONS
34
34
  )