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
@@ -2,34 +2,267 @@
2
2
 
3
3
  require 'excon'
4
4
  require "json"
5
+ require "openssl"
6
+ require "socket"
7
+ require "timeout"
5
8
  require_relative 'http'
9
+ require_relative "../configuration_error"
10
+ require_relative "../target_url"
6
11
 
7
12
  module EndPointBlank
8
13
  module Commands
14
+ # The outcome of one attempt to mint an access token, with enough of the
15
+ # intake's answer attached that a caller can decide what to do about it.
16
+ #
17
+ # intake's access-token endpoint answers 201 on success, 400 for a bad
18
+ # request (an invalid token_ttl, a missing base_url), 401 for a rejected
19
+ # credential, 422 when the target or source application cannot be resolved
20
+ # or the mint itself failed, and 5xx for a genuine fault. Those are not
21
+ # interchangeable: 401 needs the credential re-issued, 400/422 need the
22
+ # request or the registration fixed, and 5xx or a dead socket just needs
23
+ # trying again. Collapsing them -- which is what returning a bare Hash or
24
+ # nil does -- is what made every failure look the same to a caller.
25
+ #
26
+ # A Data is deeply appropriate here: it is immutable and frozen, so a
27
+ # result can be handed across threads, cached, or logged without anyone
28
+ # being able to edit the verdict after the fact.
29
+ AccessTokenResult = Data.define(:outcome, :status, :payload) do
30
+ # A token really was minted: a 2xx whose body parsed and carries both a
31
+ # non-empty token and the non-empty base_url to cache it under. Nothing
32
+ # else is a success, so a caller that branches on this can read the
33
+ # token straight off the payload without checking again that there is
34
+ # one -- and a check that is not there is a check nobody can forget.
35
+ def success?
36
+ outcome == :success
37
+ end
38
+
39
+ # 401. Permanent until the API credential itself changes.
40
+ def credential_rejected?
41
+ outcome == :credential_rejected
42
+ end
43
+
44
+ # Any other 4xx -- in practice intake's 400 and 422. Permanent too, but
45
+ # the credential is fine; the request or the registration is not.
46
+ def request_rejected?
47
+ outcome == :request_rejected
48
+ end
49
+
50
+ # 5xx, any other unexpected non-2xx, and a 2xx that carried nothing
51
+ # usable -- unreadable, or missing the token, or missing the base_url
52
+ # there is no way to cache a token without. Empty counts as missing;
53
+ # see .usable_string?.
54
+ def server_error?
55
+ outcome == :server_error
56
+ end
57
+
58
+ # No answer at all: timeout, refused connection, DNS failure -- any
59
+ # case where no usable HTTP status was obtained. Transient.
60
+ #
61
+ # Note what is NOT here: a body we could not read. That is classified
62
+ # by the status that carried it, because the status is the part with
63
+ # the remedy in it.
64
+ def transport_error?
65
+ outcome == :transport_error
66
+ end
67
+
68
+ # The direct complement of #success?, and deliberately the only
69
+ # predicate here that spans more than one outcome. There is no
70
+ # #retriable? on purpose: a single retry/no-retry boolean would fold
71
+ # five honest names back into two, and it is one more thing that can
72
+ # answer wrongly for a 400 or a 422. A caller that wants a retry policy
73
+ # writes it against the outcome it can see.
74
+ def failure?
75
+ !success?
76
+ end
77
+
78
+ # True when payload carries everything a mint needs: a token, and the
79
+ # base URL to cache it under.
80
+ #
81
+ # The single definition of what counts as a mint, and it sits here next
82
+ # to the outcomes rather than in the cache that consumes them, so a
83
+ # second layer cannot form its own opinion and end up disagreeing with
84
+ # the outcome it was handed. EndPointBlank::AccessTokens asks this
85
+ # instead of re-deriving it, and words its failure log line from the
86
+ # same predicate.
87
+ def self.minted?(payload)
88
+ payload.is_a?(Hash) && usable_string?(payload[:token]) && usable_string?(payload[:base_url])
89
+ end
90
+
91
+ # True when value is something there is actually anything to be had
92
+ # from.
93
+ #
94
+ # Spelled out rather than left to a bare `payload[:token] &&`, which is
95
+ # what this replaced: "" is truthy in Ruby, so an empty token sailed
96
+ # through as a success, and an empty base_url became a cache key no
97
+ # lookup could ever match. Neither is ever legitimate -- intake's token
98
+ # and base_url are both NOT NULL -- so either one means a broken server.
99
+ def self.usable_string?(value)
100
+ value.is_a?(String) && !value.empty?
101
+ end
102
+ end
103
+
9
104
  module GenerateAccessTokenMethods
105
+ # What a request that never completed raises: Excon's own errors (its
106
+ # timeouts and socket failures among them), and the socket, SSL and
107
+ # timeout errors underneath, in case one arrives unwrapped. Only these
108
+ # are a :transport_error.
109
+ TRANSPORT_ERRORS = [
110
+ Excon::Error, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Timeout::Error
111
+ ].freeze
112
+
10
113
  module ClassMethods
11
114
  def configuration
12
115
  EndPointBlank::Configuration.instance
13
116
  end
14
117
 
118
+ # Mint an access token, reporting what actually happened.
119
+ #
120
+ # @param base_url [String] the URL a token is wanted for. Its
121
+ # userinfo, query and fragment are never sent ({TargetUrl.strip}).
122
+ # @return [AccessTokenResult] never nil. A URL that cannot be parsed
123
+ # into an http or https URL with a host is :request_rejected with no
124
+ # status, and nothing is sent.
125
+ # @raise [ConfigurationError] when client_id or client_secret is
126
+ # missing; nothing is sent.
127
+ # @raise [StandardError] anything raised while minting that is not
128
+ # one of {TRANSPORT_ERRORS}; see the rescue below.
129
+ def token_result(base_url) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
130
+ # Defensive: AccessTokens already strips, but this is callable on
131
+ # its own and must not put a raw URL in the request body either.
132
+ target = TargetUrl.strip(base_url)
133
+ if target.nil?
134
+ EndPointBlank.logger.error "Access token not requested: the URL could not be parsed " \
135
+ "into an http or https URL with a host (not shown)"
136
+ return AccessTokenResult.new(outcome: :request_rejected, status: nil, payload: nil)
137
+ end
138
+
139
+ response = post_token_request(target)
140
+
141
+ status = response.status
142
+ EndPointBlank.logger.info "Authentication response: #{status}"
143
+
144
+ # parse_payload never raises, so nothing between here and the
145
+ # classification can throw away a status we already hold.
146
+ payload = parse_payload(response.body)
147
+
148
+ # The invariant, enforced in exactly one place: :transport_error
149
+ # means no usable HTTP status was obtained. Anything that is not a
150
+ # real status code leaves here rather than reaching classification,
151
+ # so no other branch has to keep re-deciding what a missing status
152
+ # means. The payload still rides along, because `token` below is
153
+ # published API and has always handed back whatever it could parse.
154
+ return transport_error(payload) unless status.is_a?(Integer)
155
+
156
+ AccessTokenResult.new(outcome: outcome_for(status, payload), status: status, payload: payload)
157
+ rescue ConfigurationError
158
+ # Missing client credentials are not a transport error: nothing was
159
+ # sent, and retrying cannot help. Let it be seen (sc-1469).
160
+ raise
161
+ rescue *TRANSPORT_ERRORS => e
162
+ # Reached only when the request never completed, so there is no
163
+ # status to classify on. Anything else raised above is a bug, not
164
+ # an unreachable intake, and calling it a transport error would
165
+ # send the reader off to check the network: it propagates, and
166
+ # Authorization.header reports it as "the token request failed
167
+ # unexpectedly" with the exception as its cause (sc-1469).
168
+ EndPointBlank.logger.error "Error occurred during authentication: #{e.message}\n #{e.backtrace.join("\n")}"
169
+ transport_error
170
+ end
171
+
172
+ # Mint an access token.
173
+ #
174
+ # The body-or-nil accessor, where a body means a token was actually
175
+ # minted. Anything else answers nil: a 401 or 422 whose body explains
176
+ # the refusal, and a 2xx that parsed into something with no usable
177
+ # token in it.
178
+ #
179
+ # Returning those bodies would hand the caller a truthy value for a
180
+ # request that produced no token -- the failure {token_result} exists
181
+ # to remove, one layer down. Nothing is lost:
182
+ # `token_result(base_url).payload` is exactly what this used to
183
+ # return, now alongside the outcome that explains it.
184
+ #
185
+ # @param base_url [String] the URL a token is wanted for.
186
+ # @return [Hash, nil] symbol-keyed response body when a token was
187
+ # minted, otherwise nil.
188
+ # @raise [StandardError] whatever {token_result} raises, as itself.
15
189
  def token(base_url)
190
+ result = token_result(base_url)
191
+
192
+ result.success? ? result.payload : nil
193
+ end
194
+
195
+ private
196
+
197
+ def post_token_request(base_url)
16
198
  body = {base_url: base_url}
17
199
  if configuration.token_ttl
18
200
  body[:token_ttl] = configuration.token_ttl
19
201
  end
20
- auth = Authorization.header
21
- response = Excon.post(configuration.access_token_url,
22
- headers: {'Authorization' => auth, 'Content-Type' => 'application/json'},
202
+ auth = Authorization.intake_header
203
+ Excon.post(configuration.access_token_url,
204
+ headers: EndPointBlank::Commands::Http.headers(auth),
23
205
  body: body.to_json,
24
206
  **EndPointBlank::Commands::Http::TIMEOUT_OPTIONS
25
207
  )
26
- EndPointBlank.logger.info "Authentication response: #{response.status}"
27
- parsed_body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
28
- parsed_body.transform_keys(&:to_sym)
29
- rescue => e
30
- EndPointBlank.logger.error "Error occurred during authentication: #{e.message}\n #{e.backtrace.join("\n")}"
208
+ end
209
+
210
+ def transport_error(payload = nil)
211
+ AccessTokenResult.new(outcome: :transport_error, status: nil, payload: payload)
212
+ end
213
+
214
+ # Returns the symbolized body, or nil when it cannot be read. Total:
215
+ # it never raises, deliberately, so that a body it cannot make sense
216
+ # of can never cost us the status that body arrived under.
217
+ #
218
+ # A body we cannot parse is still reported rather than swallowed:
219
+ # before this, one blanket `rescue` turned a JSON::ParserError into
220
+ # the same silent nil as a dead socket. The message keeps its original
221
+ # prefix so any log alerting built on the old line still matches.
222
+ def parse_payload(body)
223
+ parsed = body.is_a?(String) ? JSON.parse(body) : body
224
+ parsed.transform_keys(&:to_sym)
225
+ rescue StandardError => e
226
+ EndPointBlank.logger.error "Error occurred during authentication: #{e.message} (response body was not JSON)"
31
227
  nil
32
228
  end
229
+
230
+ # Classify on the HTTP status FIRST; the body only ever refines the
231
+ # answer, and only for a 2xx.
232
+ #
233
+ # :transport_error means exactly one thing -- no usable HTTP status
234
+ # was obtained -- so it is not reachable from here. Do not
235
+ # reintroduce parse-first classification: a 401 whose body will not
236
+ # parse is still a rejected credential, and filing it under a name
237
+ # that reads as transient invites the forever-retry this exists to
238
+ # end. That is not hypothetical. The SDK
239
+ # reaches intake through Caddy, and any proxy, WAF or ALB in front of
240
+ # the app can answer 401 with an HTML error page intake never
241
+ # generated -- the credential really is rejected and the body really
242
+ # is unparseable, at the same time.
243
+ #
244
+ # The one thing the body decides, and only ever on a 2xx: whether a
245
+ # token was actually minted. A 2xx that is unreadable, or carries no
246
+ # token, or a token with no base_url to cache it under, is a broken
247
+ # server, and it is truthful to say so with the real 2xx status
248
+ # attached -- intake's base_url is NOT NULL and it answers 422 rather
249
+ # than minting when the URL resolves to nothing, so a 2xx without one
250
+ # cannot be anything else.
251
+ #
252
+ # Calling such a response a success instead would hand back a result
253
+ # whose #success? is true and whose token is absent, leaving every
254
+ # caller a re-check to remember -- which is exactly the check that
255
+ # gets forgotten. The payload rides along on the failure either way,
256
+ # so the specific reason survives for the log line and `token` still
257
+ # answers with precisely the body it always did.
258
+ def outcome_for(status, payload)
259
+ case status
260
+ when 200..299 then AccessTokenResult.minted?(payload) ? :success : :server_error
261
+ when 401 then :credential_rejected
262
+ when 400..499 then :request_rejected
263
+ else :server_error # 5xx, and any 3xx, which Excon does not follow.
264
+ end
265
+ end
33
266
  end
34
267
 
35
268
  def self.included(base)
@@ -1,4 +1,5 @@
1
1
  require 'excon'
2
+ require_relative '../version'
2
3
 
3
4
  module EndPointBlank
4
5
  module Commands
@@ -16,13 +17,31 @@ module EndPointBlank
16
17
  # Merge this into any `Excon.post`/`Excon.new` call in the lib.
17
18
  TIMEOUT_OPTIONS = { connect_timeout: CONNECT_TIMEOUT, read_timeout: READ_TIMEOUT }.freeze
18
19
 
20
+ # The x-epb-sdk value sent on every call to intake: ruby/<version>, the
21
+ # version of this gem as loaded (sc-1463). intake ignores it today; it is
22
+ # there so intake can record the oldest version seen per credential for
23
+ # the move gate. That gate's minimum Ruby version is the release that
24
+ # turns derive_base_url_from_client_id on by default, not the one that
25
+ # added this header: with the option at its default, this version keeps
26
+ # calling in.endpointblank.com after its organization moves.
27
+ def self.sdk_header
28
+ "ruby/#{EndPointBlank::VERSION}"
29
+ end
30
+
31
+ # The headers for every call to intake. Use this for any
32
+ # `Excon.post`/`Excon.new` call in the lib, so x-epb-sdk cannot be left
33
+ # off one of them.
34
+ def self.headers(auth)
35
+ { 'Authorization' => auth, 'Content-Type' => 'application/json', 'x-epb-sdk' => sdk_header }
36
+ end
37
+
19
38
  def self.post(url, auth, body)
20
39
  attempt = 0
21
40
  begin
22
41
  attempt += 1
23
42
  Excon.post(
24
43
  url,
25
- headers: { 'Authorization' => auth, 'Content-Type' => 'application/json' },
44
+ headers: headers(auth),
26
45
  body: body.to_json,
27
46
  **TIMEOUT_OPTIONS
28
47
  )
@@ -11,19 +11,108 @@ module EndPointBlank
11
11
  class Configuration
12
12
  include Singleton
13
13
 
14
+ # Seconds an authorization decision stays cached when cache_ttl is never
15
+ # assigned. See {#cache_ttl=}.
16
+ DEFAULT_CACHE_TTL = 300
17
+
18
+ DEFAULT_BASE_URL = "https://in.endpointblank.com"
19
+
20
+ # sc-1463: an organization's intake answers at
21
+ # https://<slug>.in.endpointblank.com. See {#base_url}.
22
+ DERIVED_BASE_URL_SUFFIX = ".in.endpointblank.com"
23
+
24
+ # app_portal's Organizations.Slug.valid?/1: a domain label of up to 20
25
+ # [a-z0-9-] characters that starts and ends alphanumeric, then "-" and 6
26
+ # random characters. Copied, not loosened.
27
+ CLIENT_ID_SLUG = /\A[a-z0-9](?:[a-z0-9-]{0,18}[a-z0-9])?-[a-z0-9]{6}\z/
28
+
14
29
  attr_writer :client_id, :client_secret, :base_url, :log_base_url, :app_name, :env_name
15
30
 
16
31
  attr_accessor :worker_count, :log_mode,
17
- :version_finder, :application_version, :token_ttl, :cache_ttl,
32
+ :version_finder, :application_version, :token_ttl,
18
33
  :masking_rules, :mask_hook, :logger, :trust_proxy_headers
19
34
 
35
+ attr_reader :cache_ttl, :derive_base_url_from_client_id
36
+
20
37
  def initialize
21
38
  @worker_count = 4
22
39
  @token_ttl = nil
23
- @cache_ttl = 300
40
+ self.cache_ttl = DEFAULT_CACHE_TTL
24
41
  @masking_rules = []
25
42
  @mask_hook = nil
26
43
  @trust_proxy_headers = true
44
+ @derive_base_url_from_client_id = false
45
+ end
46
+
47
+ # Sets the authorization decision cache's TTL, in whole seconds.
48
+ #
49
+ # sc-970 sets one rule for this setting across every EndPointBlank SDK:
50
+ #
51
+ # - never assigned: the default, {DEFAULT_CACHE_TTL} (300) seconds;
52
+ # - 0: the cache is disabled;
53
+ # - a positive Integer: that many seconds;
54
+ # - anything else -- an explicit nil, a negative number, or a non-Integer
55
+ # such as "300" or 3.5 -- raises ArgumentError here, at configure time,
56
+ # and leaves the previous value in place. It is never deferred to the
57
+ # first cache read or store, and never quietly read as "disabled" or
58
+ # as "use the default".
59
+ #
60
+ # @raise [ArgumentError] if value is not a non-negative Integer
61
+ def cache_ttl=(value)
62
+ unless value.is_a?(Integer) && !value.negative?
63
+ raise ArgumentError,
64
+ "EndPointBlank::Configuration#cache_ttl must be a non-negative Integer number of " \
65
+ "seconds, got #{value.inspect}. To use the default of #{DEFAULT_CACHE_TTL} seconds, " \
66
+ "omit the cache_ttl setting entirely; set it to 0 to disable the cache."
67
+ end
68
+
69
+ @cache_ttl = value
70
+ end
71
+
72
+ # Whether {#base_url} may derive the intake hostname from a slug-prefixed
73
+ # {#client_id} when no base URL is set (sc-1463). Defaults to false,
74
+ # because *.in.endpointblank.com has no DNS or TLS in production yet; it
75
+ # will default to true in a later release.
76
+ #
77
+ # Only true or false: a String "true" from an env var must not quietly
78
+ # leave derivation off, and nothing else has a sensible reading. Like
79
+ # {#cache_ttl=}, anything else raises here, at configure time, and
80
+ # leaves the previous value in place.
81
+ #
82
+ # @raise [ArgumentError] if value is not true or false
83
+ def derive_base_url_from_client_id=(value)
84
+ unless [true, false].include?(value)
85
+ raise ArgumentError,
86
+ "EndPointBlank::Configuration#derive_base_url_from_client_id must be true or false, " \
87
+ "got #{value.inspect}."
88
+ end
89
+
90
+ @derive_base_url_from_client_id = value
91
+ end
92
+
93
+ # The organization slug a client_id names, or nil for one without it
94
+ # (issued before sc-1463).
95
+ #
96
+ # The same rule as app_portal's Credentials.client_id_slug/1 and every
97
+ # other EndPointBlank SDK: the part before the first "." must have the
98
+ # exact shape of an organization slug, and something must follow the
99
+ # dot. "Contains a ." is not enough, because app_portal has always
100
+ # accepted a typed client_id, so a legacy "my.client" can exist and must
101
+ # keep calling the default intake.
102
+ #
103
+ # @param client_id [Object] anything; only a String can carry a slug
104
+ # @return [String, nil]
105
+ def self.client_id_slug(client_id)
106
+ return nil unless client_id.is_a?(String)
107
+ # Total, like Elixir's: split and match? raise on invalid bytes or on an
108
+ # encoding that is not ASCII-compatible (UTF-16), and neither can be a
109
+ # credential that authenticates.
110
+ return nil unless client_id.valid_encoding? && client_id.encoding.ascii_compatible?
111
+
112
+ slug, random = client_id.split(".", 2)
113
+ return nil if random.nil? || random.empty?
114
+
115
+ CLIENT_ID_SLUG.match?(slug) ? slug : nil
27
116
  end
28
117
 
29
118
  # Returns the configured client id, falling back to the
@@ -39,9 +128,11 @@ module EndPointBlank
39
128
  end
40
129
 
41
130
  # Returns the configured base URL, falling back to the
42
- # ENDPOINTBLANK_BASE_URL environment variable, then a built-in default.
131
+ # ENDPOINTBLANK_BASE_URL environment variable, then -- only while
132
+ # {#derive_base_url_from_client_id} is on -- the hostname derived from a
133
+ # slug-prefixed {#client_id}, then a built-in default.
43
134
  def base_url
44
- @base_url || ENV["ENDPOINTBLANK_BASE_URL"] || "https://in.endpointblank.com"
135
+ @base_url || ENV["ENDPOINTBLANK_BASE_URL"] || derived_base_url || DEFAULT_BASE_URL
45
136
  end
46
137
 
47
138
  # Returns the configured log base URL, falling back to the
@@ -104,5 +195,21 @@ module EndPointBlank
104
195
  def env_name
105
196
  @env_name || ENV["ENDPOINTBLANK_ENV"]
106
197
  end
198
+
199
+ private
200
+
201
+ # sc-1463: a new client_id is "<organization slug>.<random>", and that
202
+ # organization's intake answers at https://<slug>.in.endpointblank.com.
203
+ # Only while derive_base_url_from_client_id is on: *.in.endpointblank.com
204
+ # has no DNS or TLS in production yet, so it defaults off, and off means
205
+ # today's default for every client_id. Logs are not derived: whether they
206
+ # get a per-organization hostname is still open, so log_base_url keeps its
207
+ # own default.
208
+ def derived_base_url
209
+ return nil unless @derive_base_url_from_client_id == true
210
+
211
+ slug = self.class.client_id_slug(client_id)
212
+ slug && "https://#{slug}#{DERIVED_BASE_URL_SUFFIX}"
213
+ end
107
214
  end
108
215
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EndPointBlank
4
+ # Reopened with the same superclass in end_point_blank.rb; declared here too
5
+ # so this file can be required on its own.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when a setting the SDK cannot work without is missing -- today, a
9
+ # nil or empty client_id or client_secret when the SDK builds the Basic
10
+ # header for a call to its own intake.
11
+ #
12
+ # Loud on purpose (sc-1469). Before sc-1469 the header was built with
13
+ # `client_id + ":" + client_secret`, which raised on a nil; interpolation
14
+ # does not, and would quietly send `Basic Og==` (base64 of ":") on every
15
+ # call, which intake rejects as a bad credential -- a misconfiguration
16
+ # dressed up as a revoked one.
17
+ class ConfigurationError < Error; end
18
+ end
@@ -2,20 +2,75 @@ require "active_support/concern"
2
2
 
3
3
  module EndPointBlank
4
4
  module Rails
5
+ # Installs a `before_action :authenticate!` that asks intake whether the
6
+ # calling credential may reach this controller at all, refusing the request
7
+ # with an `UnauthorizedError` when it may not.
8
+ #
9
+ # == Why this concern exists rather than being deleted
10
+ #
11
+ # Until now it could not have worked once: it called
12
+ # `EndPointBlank::Commands::EndpointAuthenticate`, a constant that has never
13
+ # been defined anywhere in this gem, so every action of every controller
14
+ # including it raised `NameError` before authentication could succeed or
15
+ # fail. Nothing in this project included it, and the one test-application
16
+ # controller named for it (`epb_test_rails`'s `AuthenticatedController`)
17
+ # actually includes `Authorized`, so nothing ever noticed.
18
+ #
19
+ # That made "implement or delete" a real question. It is implemented,
20
+ # because the authenticate path is not a Rails-only idea that Rails
21
+ # happened not to need: the JS, Java and Python SDKs each expose one, and
22
+ # each of their commands documents itself as "equivalent to the Ruby gem's
23
+ # `EndPointBlank::Commands::BasicAuthenticate`". Three SDKs were ported
24
+ # from a Ruby original that was here the whole time and simply never wired
25
+ # up. (Elixir has no authenticate path at all, so "four other SDKs expose
26
+ # one", as sc-306 puts it, is three -- but three of three name this gem.) A
27
+ # missing authenticate path in Ruby is a gap, not a decision.
28
+ #
29
+ # == Why `BasicAuthenticate` and not a new `EndpointAuthenticate`
30
+ #
31
+ # Writing a `Commands::EndpointAuthenticate` to satisfy the old call site
32
+ # would have invented a second command for a job this gem already has a
33
+ # command for, and left `BasicAuthenticate` -- required by `end_point_blank.rb`,
34
+ # ported into three other SDKs, and carrying the request body shape intake
35
+ # actually reads -- dead in the tree next to it. Nothing can be depending on
36
+ # the name `EndpointAuthenticate`, because it never resolved; no public
37
+ # constant is being removed here, only one that was never there is being
38
+ # left absent.
39
+ #
40
+ # == Deliberately no cache
41
+ #
42
+ # `Authorized` caches intake's answer per credential/route/method/version.
43
+ # This path does not, matching every other SDK's authenticate command.
44
+ # Adding one here would be a behaviour change none of the four share and
45
+ # neither story asked for; it belongs in its own story if it is wanted.
5
46
  module Authenticated
6
- extend ActiveSupport::Concern
47
+ extend ActiveSupport::Concern
7
48
 
8
49
  included do
9
50
  before_action :authenticate!
10
51
  end
11
52
 
12
53
  def authenticate!
13
- result = EndPointBlank::Commands::EndpointAuthenticate.authenticate(request)
14
- result_json = JSON.parse(result.body)
15
- if !result || result.status != 201
16
- raise UnauthorizedError, "Authentication failed: #{result_json['error']}"
17
- end
54
+ result = EndPointBlank::Commands::BasicAuthenticate.authenticate(request)
55
+
56
+ # The guard comes first, and reads `result` before anything is asked of
57
+ # it. It used to run second: the body was parsed on the line above,
58
+ # so `result.nil?` could never be reached -- a nil result raised
59
+ # `NoMethodError` on nil instead of taking the branch written for it.
60
+ # Fixing the missing constant alone would have exposed exactly that,
61
+ # which is why both were fixed in one pass.
62
+ return if result && result.status == 201
63
+
64
+ # `raise UnauthorizedError.refusal_from(...)`, raising an instance,
65
+ # rather than `raise UnauthorizedError, "message"`. The two-argument
66
+ # `raise Class, message` form calls `Class.exception(message)` and can
67
+ # pass nothing else, so it structurally could not carry intake's status
68
+ # however willing `UnauthorizedError` was to accept one -- which is how
69
+ # every refusal from this path was going to arrive as the class's 401
70
+ # default, including the 403 that means "your credential is fine, you
71
+ # have no grant for this endpoint".
72
+ raise UnauthorizedError.refusal_from(result, "Authentication")
18
73
  end
19
74
  end
20
75
  end
21
- end
76
+ end
@@ -3,7 +3,7 @@ require "active_support/concern"
3
3
  module EndPointBlank
4
4
  module Rails
5
5
  module Authorized
6
- extend ActiveSupport::Concern
6
+ extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
9
  before_action :authorize!
@@ -12,7 +12,13 @@ module EndPointBlank
12
12
  def authorize!
13
13
  result = EndPointBlank::Commands::EndpointAuthorize.authorize(request)
14
14
  if result.nil? || result.status != 201
15
- raise UnauthorizedError.new(authorize_error_message(result), result&.status || 503)
15
+ # Shared with `Authenticated#authenticate!`. This path has always
16
+ # carried intake's status and that one dropped it; two copies of one
17
+ # decision is how that happens, so there is now one copy. Nothing
18
+ # this path produces for any input has changed -- the message and the
19
+ # status are the same for a refusal, for a 5xx, and for an intake
20
+ # that did not answer.
21
+ raise UnauthorizedError.refusal_from(result, "Authorization")
16
22
  end
17
23
  result_json = JSON.parse(result.body)
18
24
  app_env_id = result_json['data'][0]['source_application_environment_id']
@@ -21,16 +27,6 @@ module EndPointBlank
21
27
  # turns it into Deprecation / Sunset headers on the way out.
22
28
  ::EndPointBlank::Rack::EnvStore.set_deprecation(result_json['deprecation'])
23
29
  end
24
-
25
- private
26
-
27
- def authorize_error_message(result)
28
- return "Authorization service unavailable" if result.nil?
29
-
30
- parsed = JSON.parse(result.body) rescue nil
31
- detail = parsed.is_a?(Hash) ? parsed["error"] : nil
32
- "Authorization failed: #{detail || result.body}"
33
- end
34
30
  end
35
31
  end
36
- end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module EndPointBlank
6
+ # The form of a caller's target URL that the SDK is allowed to use:
7
+ # scheme, host, port and path, nothing else (sc-1469).
8
+ #
9
+ # The caller controls the URL, and its userinfo, query or fragment can
10
+ # carry a secret. None of them is needed -- intake resolves the environment
11
+ # from scheme, host, port and path alone, and its BaseUrl.normalize refuses
12
+ # a URL carrying any of them (an empty `?` or `#` included), so sending them
13
+ # would leak them to intake AND guarantee the mint fails. So every URL is
14
+ # stripped here on the way in, and only the stripped form is sent to
15
+ # intake, used as a cache or failure key, logged, or kept on an error.
16
+ #
17
+ # Built from the parsed parts, never by splitting the string, so an empty
18
+ # `?` or `#` cannot slip through.
19
+ #
20
+ # Only http and https are accepted, because only they can name a provider.
21
+ # Any other scheme is refused rather than rebuilt: Ruby parses it with a
22
+ # scheme-specific class whose parts need not fit "scheme://host/path"
23
+ # (URI::FTP's path has no leading slash, so "ftp://h:21/x" would come out
24
+ # as "ftp://hx").
25
+ module TargetUrl
26
+ HTTP_SCHEMES = %w[http https].freeze
27
+ # intake's BaseUrl refuses any other port, so asking would only cost a
28
+ # request and a recorded failure.
29
+ VALID_PORTS = (1..65_535).freeze
30
+ private_constant :HTTP_SCHEMES, :VALID_PORTS
31
+
32
+ # @param url [String, nil] the URL a caller is about to call
33
+ # @return [String, nil] "scheme://host[:port]/path" (scheme and host
34
+ # lowercased as intake's BaseUrl does, IPv6 in brackets, the port only
35
+ # when it is not the scheme default, the path as given), or nil when url
36
+ # cannot be parsed, is not http or https, has no host, or has a port
37
+ # outside 1..65535 -- the caller must then refuse it without making any
38
+ # request.
39
+ def self.strip(url)
40
+ return nil if url.nil?
41
+
42
+ uri = URI.parse(url.to_s)
43
+ return nil unless acceptable?(uri)
44
+
45
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
46
+ "#{uri.scheme}://#{uri.host.downcase}#{port}#{uri.path}"
47
+ rescue URI::Error
48
+ nil
49
+ end
50
+
51
+ # URI lowercases the scheme, so "HTTPS://..." is accepted here too.
52
+ def self.acceptable?(uri)
53
+ HTTP_SCHEMES.include?(uri.scheme) && !uri.host.to_s.empty? && VALID_PORTS.cover?(uri.port)
54
+ end
55
+ private_class_method :acceptable?
56
+ end
57
+ end