end_point_blank 0.6.1 → 0.13.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 +653 -0
- data/README.md +424 -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/management/client.rb +228 -0
- data/lib/end_point_blank/management/configuration.rb +56 -0
- data/lib/end_point_blank/management/error.rb +134 -0
- data/lib/end_point_blank/management/error_codes.rb +115 -0
- data/lib/end_point_blank/management/idempotency_key.rb +33 -0
- data/lib/end_point_blank/management/page.rb +55 -0
- data/lib/end_point_blank/management/resources/api_packages.rb +104 -0
- data/lib/end_point_blank/management/resources/applications.rb +167 -0
- data/lib/end_point_blank/management/resources/base.rb +117 -0
- data/lib/end_point_blank/management/resources/clients.rb +152 -0
- data/lib/end_point_blank/management/retry_policy.rb +65 -0
- data/lib/end_point_blank/management/transport.rb +167 -0
- data/lib/end_point_blank/management/url_path.rb +18 -0
- data/lib/end_point_blank/management.rb +52 -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 +240 -2
- metadata +29 -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
|