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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +621 -0
- data/README.md +236 -19
- data/end_point_blank.gemspec +5 -3
- data/lib/end_point_blank/access_tokens.rb +244 -25
- data/lib/end_point_blank/authorization.rb +105 -20
- data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
- data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
- data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
- data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
- data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
- data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
- data/lib/end_point_blank/commands/http.rb +20 -1
- data/lib/end_point_blank/configuration.rb +111 -4
- data/lib/end_point_blank/configuration_error.rb +18 -0
- data/lib/end_point_blank/rails/authenticated.rb +62 -7
- data/lib/end_point_blank/rails/authorized.rb +9 -13
- data/lib/end_point_blank/target_url.rb +57 -0
- data/lib/end_point_blank/token_unavailable_error.rb +102 -0
- data/lib/end_point_blank/unauthorized_error.rb +81 -1
- data/lib/end_point_blank/version.rb +1 -1
- data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
- data/lib/end_point_blank/writers/direct_writer.rb +1 -1
- data/lib/end_point_blank/writers/exception_writer.rb +11 -2
- data/lib/end_point_blank/writers/log_writer.rb +1 -1
- data/lib/end_point_blank/writers/request_writer.rb +1 -0
- data/lib/end_point_blank/writers/response_writer.rb +1 -0
- data/lib/end_point_blank/writers/shared.rb +35 -4
- data/lib/end_point_blank.rb +248 -3
- metadata +15 -10
- 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.
|
|
21
|
-
|
|
22
|
-
headers:
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
|
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"] ||
|
|
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
|
-
|
|
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::
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|