end_point_blank 0.2.2 → 0.6.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 +36 -0
- data/README.md +59 -8
- data/deploy +5 -0
- data/end_point_blank.gemspec +7 -2
- data/lib/end_point_blank/access_tokens.rb +191 -43
- data/lib/end_point_blank/authorization.rb +10 -2
- data/lib/end_point_blank/base_url.rb +218 -0
- data/lib/end_point_blank/commands/endpoint_authorize.rb +34 -12
- data/lib/end_point_blank/commands/endpoint_update.rb +1 -1
- data/lib/end_point_blank/commands/generate_access_token.rb +3 -3
- data/lib/end_point_blank/configuration.rb +2 -1
- data/lib/end_point_blank/deprecation_headers.rb +90 -0
- data/lib/end_point_blank/middleware/rack/report_interaction.rb +8 -0
- data/lib/end_point_blank/rack/env_store.rb +48 -4
- data/lib/end_point_blank/rack/headers.rb +2 -0
- data/lib/end_point_blank/rails/authorized.rb +3 -0
- data/lib/end_point_blank/rails/versioned.rb +14 -16
- data/lib/end_point_blank/version.rb +1 -1
- data/lib/end_point_blank/writers/request_writer.rb +7 -3
- data/lib/end_point_blank.rb +2 -0
- metadata +10 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: edb9e7eb50879a3caf5cec7ae59210d29a3096e82d68c997dd4ae545cac00e2d
|
|
4
|
+
data.tar.gz: 4bd84f05bf328b387fdb6213194c4920c0282cb6eaaee0ad1eb20610785d53bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b958fd1b769033e6fe4420e371b568ec449fb55accac06c7ebb82a2a1ac0b2415770117c4d61b9746af87ef8d27e97c7e0888dbb3bfc55cba605f10c5d57d91d
|
|
7
|
+
data.tar.gz: 3ef96dd88833a733d9f4dd30592618df157ef6af60483c50aad82e2306c60ffb78348c9a7256a52102d34647b148ab8d58109b25088cf6480cd075b054d46d72
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- **`Authorization.header` and `AccessTokens.token` now take a URL, not a
|
|
8
|
+
hostname.** Pass the URL you are about to call —
|
|
9
|
+
`https://api.example.com/orders`, not `api.example.com`. Strip any query
|
|
10
|
+
string or fragment first; they are rejected. Earlier READMEs showed the
|
|
11
|
+
hostname form; those examples no longer work.
|
|
12
|
+
- **`AccessTokens#exists?` now requires the same URL argument.** It answers
|
|
13
|
+
for the entry covering that URL; there is no longer a single process-wide
|
|
14
|
+
token for it to answer about.
|
|
15
|
+
- **Requires an intake that accepts `base_url`.** An older intake returns
|
|
16
|
+
`400 {"error":"Missing required parameter: base_url"}`.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `endpoint_authorize` authenticates to intake with Basic instead of minting
|
|
21
|
+
an access token for itself. The inbound request path no longer touches the
|
|
22
|
+
token cache at all.
|
|
23
|
+
- A 401 from the authorize endpoint is returned to the caller rather than
|
|
24
|
+
retried once. With Basic, a 401 means the credential is wrong.
|
|
25
|
+
- Tokens are cached per application environment, keyed on the canonical base
|
|
26
|
+
URL intake resolves the request to, rather than one per process.
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
|
|
30
|
+
- The minted bearer token was previously written to the host application's
|
|
31
|
+
logs at info level: every successful token exchange logged the full intake
|
|
32
|
+
response body, which contains the live token, whenever your app's Rails
|
|
33
|
+
logger was set to info or more verbose. This release logs only the
|
|
34
|
+
response status code. If your logs go back further than this upgrade,
|
|
35
|
+
treat them as potentially containing live bearer tokens and handle them
|
|
36
|
+
per your own retention/rotation policy.
|
data/README.md
CHANGED
|
@@ -79,6 +79,7 @@ Every setting listed below can be set explicitly in that block, and most also fa
|
|
|
79
79
|
| `worker_count` | — | `4` | Currently unused by the delayed writer (which always spins up 2 threads); reserved. |
|
|
80
80
|
| `token_ttl` | — | `nil` | Optional TTL (seconds) requested when generating a `Bearer` access token. |
|
|
81
81
|
| `cache_ttl` | — | `300` | TTL (seconds) for the authorization decision cache. |
|
|
82
|
+
| `trust_proxy_headers` | — | `true` | Whether the per-request `scheme`/`host`/`port` report honors `X-Forwarded-Proto`/`-Host`/`-Port`. See [Reported base URL](#reported-base-url). |
|
|
82
83
|
| `masking_rules` | — | `[]` | Ordered list of masking rule hashes — see [Data masking](#data-masking). |
|
|
83
84
|
| `mask_hook` | — | `nil` | Optional `->(payload, record_type_string) { payload }` run after `masking_rules`. |
|
|
84
85
|
| `version_finder` | — | `nil` | Optional `->(request) { "1" }` overriding `EndPointBlank::Commands::VersionFinder`'s default header/param/path detection. |
|
|
@@ -88,6 +89,35 @@ Note: there is also a bare `environment` accessor on `Configuration`, but it is
|
|
|
88
89
|
code path in this gem (the real per-request environment name is `env_name`, described above) — do
|
|
89
90
|
not rely on it.
|
|
90
91
|
|
|
92
|
+
### Reported base URL
|
|
93
|
+
|
|
94
|
+
Every request payload carries the base URL the *caller* used, as three separate fields —
|
|
95
|
+
`scheme`, `host` and `port`. A field that cannot be resolved is omitted rather than sent as
|
|
96
|
+
null. EndPointBlank uses these to fill in an application environment's base URL for you,
|
|
97
|
+
instead of asking someone to type it.
|
|
98
|
+
|
|
99
|
+
By default the gem honors `X-Forwarded-Proto`, `X-Forwarded-Host` and `X-Forwarded-Port`,
|
|
100
|
+
reading the **last** comma-separated hop. It does this on its own, without consulting Rails'
|
|
101
|
+
or Rack's trusted-proxy configuration, so that all five EndPointBlank clients answer
|
|
102
|
+
identically for the same request.
|
|
103
|
+
|
|
104
|
+
**Turn this off if your application is reachable directly, with no proxy in front of it** —
|
|
105
|
+
or if you would simply rather report nothing than report something a caller could influence:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
EndPointBlank.configure { |c| c.trust_proxy_headers = false }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
With it off, the `X-Forwarded-*` headers are ignored entirely and `scheme`, `host` and `port`
|
|
112
|
+
come from the connection and the `Host` header only.
|
|
113
|
+
|
|
114
|
+
It defaults to `true` because the alternative is worse for almost everyone. Most production
|
|
115
|
+
deployments sit behind an ALB, nginx, Caddy or an Ingress, and a client that ignored the
|
|
116
|
+
forwarded headers there would not report *nothing* — it would confidently report an internal
|
|
117
|
+
hostname on an internal port. `host` is caller-controlled either way (it has always come from
|
|
118
|
+
the `Host` header), and none of these three values is ever used as an identity or
|
|
119
|
+
authorization key, so the worst case is a wrong *suggestion* that an admin has to approve.
|
|
120
|
+
|
|
91
121
|
### `configure` block example
|
|
92
122
|
|
|
93
123
|
```ruby
|
|
@@ -120,16 +150,28 @@ export ENDPOINTBLANK_ENV=staging
|
|
|
120
150
|
|
|
121
151
|
### Authorization
|
|
122
152
|
|
|
123
|
-
`EndPointBlank::Authorization.header(
|
|
124
|
-
used by the gem's own HTTP calls: a cached `Bearer` token
|
|
125
|
-
(via `EndPointBlank::AccessTokens`), otherwise `Basic` credentials built from
|
|
126
|
-
`client_secret
|
|
153
|
+
`EndPointBlank::Authorization.header(base_url = nil)` builds the outbound `Authorization` header
|
|
154
|
+
used by the gem's own HTTP calls: a cached `Bearer` token covering `base_url` when one is
|
|
155
|
+
available (via `EndPointBlank::AccessTokens`), otherwise `Basic` credentials built from
|
|
156
|
+
`client_id` / `client_secret` -- which covers both giving no target and a token that could not
|
|
157
|
+
be obtained.
|
|
127
158
|
|
|
128
159
|
```ruby
|
|
129
|
-
EndPointBlank::Authorization.header
|
|
130
|
-
|
|
160
|
+
EndPointBlank::Authorization.header # => "Basic ..."
|
|
161
|
+
|
|
162
|
+
# Pass the URL you are about to call, NOT a hostname.
|
|
163
|
+
# Strip any query string or fragment first -- intake rejects both.
|
|
164
|
+
EndPointBlank::Authorization.header("https://api.example.com/orders") # => "Bearer ..." if a token is cached
|
|
131
165
|
```
|
|
132
166
|
|
|
167
|
+
The argument is the URL you are about to call. intake matches it against registered base URLs by
|
|
168
|
+
longest path prefix, so you need not know how the target registered itself -- `header` for
|
|
169
|
+
`https://api.example.com/orders/42` reuses a token already cached for
|
|
170
|
+
`https://api.example.com/orders`. `EndPointBlank::AccessTokens` caches one token per base URL
|
|
171
|
+
intake resolves to, not one per process, so a service that calls several targets holds a token
|
|
172
|
+
for each. A URL that does not match character-for-character (a different case, a query string,
|
|
173
|
+
an unregistered path) simply misses and mints a new token -- it never guesses.
|
|
174
|
+
|
|
133
175
|
Under Rails, protect an inbound endpoint by including the `Authorized` concern in a controller —
|
|
134
176
|
it calls `EndPointBlank::Commands::EndpointAuthorize.authorize(request)` before the action, and
|
|
135
177
|
raises `EndPointBlank::UnauthorizedError` (which you can rescue with
|
|
@@ -143,8 +185,17 @@ end
|
|
|
143
185
|
|
|
144
186
|
`EndPointBlank::Commands::EndpointAuthorize.authorize` sends the request's path, HTTP method,
|
|
145
187
|
inbound `Authorization` header, app name, resolved endpoint version, and remote IP to
|
|
146
|
-
`#{base_url}/api/authorize`,
|
|
147
|
-
`EndPointBlank::Commands::AuthenticationCache`.
|
|
188
|
+
`#{base_url}/api/authorize`, authenticating itself to intake with `Basic`, and caches a positive
|
|
189
|
+
(201) result for `cache_ttl` seconds via `EndPointBlank::Commands::AuthenticationCache`. It never
|
|
190
|
+
mints or presents a Bearer token for this call: intake already holds this service's own
|
|
191
|
+
credential, so exchanging one to present it back would buy nothing.
|
|
192
|
+
|
|
193
|
+
**Behavior change:** `target_hostname` on the authorize call now comes from the `Host` header
|
|
194
|
+
only. It previously came from `request.host`, which reads the last `X-Forwarded-Host` hop. If
|
|
195
|
+
your app sits behind a proxy that **rewrites** `Host` (nginx's default; Caddy and most ALBs
|
|
196
|
+
preserve it) and you registered the external hostname in the portal, either update the
|
|
197
|
+
registered hostname to the internal one the app now reports, or configure the proxy to preserve
|
|
198
|
+
`Host`. Deployments where `Host` and `X-Forwarded-Host` agree are unaffected.
|
|
148
199
|
|
|
149
200
|
### Error reporting
|
|
150
201
|
|
data/deploy
ADDED
data/end_point_blank.gemspec
CHANGED
|
@@ -34,8 +34,13 @@ Gem::Specification.new do |spec|
|
|
|
34
34
|
|
|
35
35
|
# Uncomment to register a new dependency of your gem
|
|
36
36
|
spec.add_dependency "excon", "~> 1.0"
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
# rack and rexml carried no floor at all, which let a host application resolve
|
|
38
|
+
# them to versions with known advisories. These are the lowest releases clear
|
|
39
|
+
# of every advisory published against each gem, not the newest available — a
|
|
40
|
+
# library should state the oldest version it will vouch for, not force the
|
|
41
|
+
# host to the bleeding edge.
|
|
42
|
+
spec.add_dependency "rack", ">= 3.2.6"
|
|
43
|
+
spec.add_dependency "rexml", ">= 3.3.9"
|
|
39
44
|
# Base64 was removed from Ruby's default gems as of 3.4; Authorization
|
|
40
45
|
# uses it for the Basic-auth header, so declare it explicitly.
|
|
41
46
|
spec.add_dependency "base64"
|
|
@@ -4,69 +4,217 @@ require 'singleton'
|
|
|
4
4
|
require "time"
|
|
5
5
|
|
|
6
6
|
module EndPointBlank
|
|
7
|
-
# Thread-safe singleton
|
|
7
|
+
# Thread-safe singleton holding this process's access tokens, one per
|
|
8
|
+
# application environment.
|
|
9
|
+
#
|
|
10
|
+
# A token is cached under the canonical base URL intake resolved the
|
|
11
|
+
# request to -- not under the URL the caller supplied. A caller asks for the
|
|
12
|
+
# URL it is about to call; intake answers with the base URL of the
|
|
13
|
+
# environment that URL belongs to, and subsequent calls anywhere under that
|
|
14
|
+
# base URL reuse the entry.
|
|
15
|
+
#
|
|
16
|
+
# Lookup is a plain exact-or-path-prefix comparison, with the longest match
|
|
17
|
+
# winning. The SDK deliberately does not normalize: intake owns that rule,
|
|
18
|
+
# and a miss costs one extra request rather than a wrong answer.
|
|
19
|
+
#
|
|
20
|
+
# A lookup has to scan the keys, and the fast path deliberately does not
|
|
21
|
+
# take the mutex, so every write **replaces** the entries Hash instead of
|
|
22
|
+
# mutating it. A reader then takes one atomic read of @entries and iterates
|
|
23
|
+
# something nobody can change underneath it. Mutating in place would risk
|
|
24
|
+
# "can't add a new key into hash during iteration" as soon as one thread
|
|
25
|
+
# minted a token for a second target while another was doing a lookup.
|
|
8
26
|
class AccessTokens
|
|
9
27
|
include Singleton
|
|
10
28
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
29
|
+
# Replace a token this far ahead of its expiry. An expired token can never
|
|
30
|
+
# be revived, only replaced, so going early is what keeps an in-flight
|
|
31
|
+
# request from carrying one that dies before it lands.
|
|
32
|
+
REFRESH_WINDOW = 120
|
|
33
|
+
|
|
34
|
+
# exists? is used to decide whether a caller can proceed without a round
|
|
35
|
+
# trip, so it answers no while there is barely any life left.
|
|
36
|
+
PRESENCE_WINDOW = 30
|
|
37
|
+
|
|
38
|
+
# How long to hold a token whose expiry the intake sent unreadably.
|
|
39
|
+
DEFAULT_LIFETIME = 3600
|
|
40
|
+
|
|
41
|
+
def initialize
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@entries = {}
|
|
14
44
|
end
|
|
15
45
|
|
|
16
|
-
def self.token(
|
|
17
|
-
instance.token(
|
|
46
|
+
def self.token(base_url)
|
|
47
|
+
instance.token(base_url)
|
|
18
48
|
end
|
|
19
49
|
|
|
20
|
-
# Retrieve
|
|
21
|
-
#
|
|
22
|
-
# @
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
# Retrieve a token covering base_url, generating one if no usable entry
|
|
51
|
+
# covers it.
|
|
52
|
+
# @param base_url [String] the URL you are about to call, with any query
|
|
53
|
+
# string and fragment removed. It is sent verbatim; intake normalizes it
|
|
54
|
+
# and matches it against registered base URLs by longest path prefix.
|
|
55
|
+
# @return [String, nil] The access token string, or nil if generation
|
|
56
|
+
# failed -- which includes a response that carried a token but no
|
|
57
|
+
# base_url.
|
|
58
|
+
def token(base_url)
|
|
59
|
+
entry = match(base_url)
|
|
60
|
+
return entry[:token] if usable?(entry)
|
|
61
|
+
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
# Another caller may have filled it while this one waited.
|
|
64
|
+
entry = match(base_url)
|
|
65
|
+
return entry[:token] if usable?(entry)
|
|
66
|
+
|
|
67
|
+
payload = Commands::GenerateAccessToken.token(base_url)
|
|
68
|
+
|
|
69
|
+
# The key is what intake resolved to, and only that. There is no
|
|
70
|
+
# fallback to the requested URL: that would key on the resource the
|
|
71
|
+
# caller happened to ask about, so a service walking /orders/1,
|
|
72
|
+
# /orders/2, /orders/3 would mint and store a token per resource, and
|
|
73
|
+
# nothing here evicts. Without a base URL the right application
|
|
74
|
+
# cannot be found, so no token is handed back either.
|
|
75
|
+
key = payload && payload[:base_url]
|
|
76
|
+
|
|
77
|
+
if payload && payload[:token] && key
|
|
78
|
+
# The match that led here may have resolved under a different key
|
|
79
|
+
# than the one intake just returned -- an environment's base URL
|
|
80
|
+
# can change to a shorter path in the portal. Drop that stale key
|
|
81
|
+
# when it differs from the fresh one, or it goes on shadowing it:
|
|
82
|
+
# being the longer of the two, it keeps winning "longest match
|
|
83
|
+
# wins", keeps failing usable?, and keeps forcing a mint on every
|
|
84
|
+
# call until the process restarts. The failure branch below already
|
|
85
|
+
# does the equivalent for a match that turned out unusable; this is
|
|
86
|
+
# the same cleanup for a match that turned out to have moved.
|
|
87
|
+
stale = match_key(base_url, @entries)
|
|
88
|
+
new_entries = @entries.merge(
|
|
89
|
+
key => { token: payload[:token], expired_at: parse_expiry(payload[:expired_at]) }.freeze
|
|
90
|
+
)
|
|
91
|
+
new_entries = new_entries.reject { |k, _| k == stale } if stale && stale != key
|
|
92
|
+
@entries = new_entries.freeze
|
|
36
93
|
payload[:token]
|
|
37
94
|
else
|
|
38
|
-
|
|
95
|
+
# A failed refresh must not leave an expiring token behind claiming
|
|
96
|
+
# to be usable -- callers would keep presenting it right up to the
|
|
97
|
+
# 401. Only the entry that covers this URL goes: the longest match
|
|
98
|
+
# is the one that was just found unusable, so a shorter, still-good
|
|
99
|
+
# entry survives.
|
|
100
|
+
stale = match_key(base_url, @entries)
|
|
101
|
+
@entries = @entries.reject { |k, _| k == stale }.freeze if stale
|
|
102
|
+
|
|
103
|
+
EndPointBlank.logger.error "Failed to generate access token for #{base_url}: #{failure_reason(payload)}"
|
|
39
104
|
nil
|
|
40
105
|
end
|
|
41
106
|
end
|
|
42
107
|
end
|
|
43
108
|
|
|
44
|
-
#
|
|
45
|
-
# @return [
|
|
46
|
-
def clear
|
|
47
|
-
@
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
109
|
+
# Discard every held token
|
|
110
|
+
# @return [nil]
|
|
111
|
+
def clear
|
|
112
|
+
@mutex.synchronize { @entries = {}.freeze }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Discard the held token, but only if it is still the one the caller had
|
|
116
|
+
#
|
|
117
|
+
# Every request in flight when a token is rejected reports the same stale
|
|
118
|
+
# value. Only the first of them should cause an exchange -- the rest are
|
|
119
|
+
# holding a token that has already been replaced, and clearing on their
|
|
120
|
+
# behalf would discard a good token and stampede intake.
|
|
121
|
+
#
|
|
122
|
+
# The lookup is by token value because a rejected caller has a token, not
|
|
123
|
+
# a URL.
|
|
124
|
+
#
|
|
125
|
+
# @param stale_token [String, nil] the token the caller was rejected for;
|
|
126
|
+
# ignored when it is no longer the one held for its base URL.
|
|
127
|
+
# @return [nil]
|
|
128
|
+
def invalidate(stale_token)
|
|
129
|
+
return if stale_token.nil?
|
|
130
|
+
|
|
131
|
+
@mutex.synchronize do
|
|
132
|
+
@entries = @entries.reject { |_, entry| entry[:token] == stale_token }.freeze
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Check whether a token covering base_url is held and is not about to
|
|
137
|
+
# expire
|
|
138
|
+
# @param base_url [String] the URL to check coverage for
|
|
139
|
+
# @return [Boolean]
|
|
140
|
+
def exists?(base_url)
|
|
141
|
+
entry = match(base_url)
|
|
142
|
+
!entry.nil? && entry[:expired_at] > Time.now + PRESENCE_WINDOW
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Returns the longest key in entries covering base_url, or nil.
|
|
148
|
+
#
|
|
149
|
+
# A nil or empty base_url never matches. An empty cache can't raise on
|
|
150
|
+
# one -- the loop body never runs -- so a non-empty cache must not either,
|
|
151
|
+
# or the same call succeeds or raises NoMethodError (nil has no
|
|
152
|
+
# start_with?) depending on unrelated traffic that happened to warm the
|
|
153
|
+
# cache first. Checking once, here, keeps every caller consistent for
|
|
154
|
+
# free: the lookup, the stale-entry cleanup on a failed refresh, and the
|
|
155
|
+
# stale-entry cleanup on a successful one.
|
|
156
|
+
#
|
|
157
|
+
# Deliberately not a port of intake's matcher: no normalization on either
|
|
158
|
+
# side. A caller that passes a non-canonical URL simply misses and mints
|
|
159
|
+
# again, which costs one HTTP call and is never a wrong answer.
|
|
160
|
+
#
|
|
161
|
+
# Takes entries as an explicit argument, rather than reading @entries
|
|
162
|
+
# itself, so the snapshot discipline is structural: every caller decides
|
|
163
|
+
# which snapshot is being scanned instead of this method reaching for
|
|
164
|
+
# whatever @entries happens to be at the moment it runs.
|
|
165
|
+
def match_key(base_url, entries)
|
|
166
|
+
return nil if base_url.nil? || base_url.empty?
|
|
167
|
+
|
|
168
|
+
best = nil
|
|
169
|
+
entries.each_key do |key|
|
|
170
|
+
next unless base_url == key || base_url.start_with?("#{key}/")
|
|
171
|
+
|
|
172
|
+
best = key if best.nil? || key.length > best.length
|
|
51
173
|
end
|
|
174
|
+
best
|
|
52
175
|
end
|
|
53
176
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
177
|
+
def match(base_url)
|
|
178
|
+
entries = @entries # One atomic read; writes replace, never mutate.
|
|
179
|
+
key = match_key(base_url, entries)
|
|
180
|
+
key && entries[key]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def usable?(entry)
|
|
184
|
+
!entry.nil? && entry[:expired_at] > Time.now + REFRESH_WINDOW
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Why a mint produced no usable token, for the log.
|
|
188
|
+
def failure_reason(payload)
|
|
189
|
+
return "no response" unless payload.is_a?(Hash)
|
|
190
|
+
return payload[:error] if payload[:error]
|
|
191
|
+
|
|
192
|
+
if payload[:token]
|
|
193
|
+
# Distinct from a rejected request: intake's base_url is NOT NULL, and
|
|
194
|
+
# it answers 422 rather than minting when the caller's URL resolves to
|
|
195
|
+
# no environment. A token with no base_url is a broken server.
|
|
196
|
+
return "response carried a token but no base_url"
|
|
61
197
|
end
|
|
198
|
+
|
|
199
|
+
"no token in response"
|
|
62
200
|
end
|
|
63
201
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
202
|
+
# Time.parse raises on anything it cannot read — an ArgumentError for a
|
|
203
|
+
# string it fails to understand, a TypeError for a value that is not a
|
|
204
|
+
# string at all, including the nil left by a missing key. This runs inside
|
|
205
|
+
# the mutex on the path a caller's request goes through, so a malformed
|
|
206
|
+
# timestamp from the intake came out of Authorization.header and into the
|
|
207
|
+
# host application's request.
|
|
208
|
+
#
|
|
209
|
+
# An hour is a guess, but a working one. Treating the token as unusable
|
|
210
|
+
# instead would mean an exchange on every inbound request for as long as
|
|
211
|
+
# the far end misbehaves. There is no retry here if the token dies sooner
|
|
212
|
+
# than the guess -- invalidate has no caller on this path -- so a bad
|
|
213
|
+
# guess means 401s until the cache's own expiry-based refresh catches up.
|
|
214
|
+
def parse_expiry(value)
|
|
215
|
+
Time.parse(value.to_s)
|
|
216
|
+
rescue ArgumentError, TypeError
|
|
217
|
+
Time.now + DEFAULT_LIFETIME
|
|
70
218
|
end
|
|
71
219
|
end
|
|
72
220
|
end
|
|
@@ -9,9 +9,17 @@ module EndPointBlank
|
|
|
9
9
|
EndPointBlank::Configuration.instance
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# Builds an outbound Authorization header value.
|
|
13
|
+
# @param base_url [String, nil] the URL you are about to call, with any
|
|
14
|
+
# query string and fragment removed. If given, a token covering it is
|
|
15
|
+
# used (minting one if necessary) and returned as a Bearer header.
|
|
16
|
+
# Called with no argument this is the Basic form -- which is what the
|
|
17
|
+
# calls to intake itself use, since intake already holds this
|
|
18
|
+
# service's own credential.
|
|
19
|
+
# @return [String] "Bearer <token>" or "Basic <credentials>"
|
|
20
|
+
def header(base_url = nil)
|
|
13
21
|
token = nil
|
|
14
|
-
token = EndPointBlank::AccessTokens.token(
|
|
22
|
+
token = EndPointBlank::AccessTokens.token(base_url) if base_url
|
|
15
23
|
|
|
16
24
|
if token
|
|
17
25
|
"Bearer " + token
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EndPointBlank
|
|
4
|
+
# Resolves the base URL the caller used -- scheme, host and port -- from a
|
|
5
|
+
# Rack env.
|
|
6
|
+
#
|
|
7
|
+
# This deliberately does not go through Rack::Request#host / #scheme / #port.
|
|
8
|
+
# Rack, Express, the servlet spec and Plug each resolve "host" differently
|
|
9
|
+
# (Rack takes the last X-Forwarded-Host hop, Express the first, WSGI and Plug
|
|
10
|
+
# neither), which is precisely why the same request produced five different
|
|
11
|
+
# answers across the five clients. The env is read directly so that this
|
|
12
|
+
# algorithm is the same one implemented in the JS, Python, Java and Elixir
|
|
13
|
+
# libraries.
|
|
14
|
+
#
|
|
15
|
+
# Forwarded headers are honored when `trust_proxy_headers` is on, which it is
|
|
16
|
+
# by default: `host` was already caller-controlled in every client (all five
|
|
17
|
+
# read the Host header), so this opens no new hole for the field that matters
|
|
18
|
+
# most, and taking the LAST hop means that behind a proxy that appends, the
|
|
19
|
+
# value is the proxy's own observation rather than anything the caller
|
|
20
|
+
# planted. A directly-exposed deployment -- where nothing sits in front to
|
|
21
|
+
# overwrite the headers -- can set the flag to false, and then only the
|
|
22
|
+
# connection and the Host header are consulted.
|
|
23
|
+
#
|
|
24
|
+
# The flag arrives as an argument rather than being read from Configuration
|
|
25
|
+
# here, so that this module stays framework- and configuration-free and both
|
|
26
|
+
# states are directly testable.
|
|
27
|
+
module BaseUrl
|
|
28
|
+
HOSTNAME = /\A[a-z0-9._-]+\z/
|
|
29
|
+
IPV6 = /\A\[[0-9a-f:.]+\]\z/
|
|
30
|
+
SCHEME = /\A[a-z][a-z0-9+.-]{0,31}\z/
|
|
31
|
+
DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Returns a Hash carrying only the fields that resolved to a usable value.
|
|
36
|
+
# A field that could not be resolved is absent, never nil: the receiver has
|
|
37
|
+
# to be able to tell "this SDK did not report a port" from "the port is
|
|
38
|
+
# null".
|
|
39
|
+
#
|
|
40
|
+
# With trust_proxy_headers: false the three X-Forwarded-* headers are not
|
|
41
|
+
# read at all, so the request is never treated as proxied and the
|
|
42
|
+
# connection's scheme and port stay evidence.
|
|
43
|
+
def from_rack_env(env, trust_proxy_headers: true)
|
|
44
|
+
return {} unless env.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
forwarded_scheme = trust_proxy_headers ? clean_scheme(last_hop(env["HTTP_X_FORWARDED_PROTO"])) : nil
|
|
47
|
+
forwarded_host_part, forwarded_host_authority_port =
|
|
48
|
+
trust_proxy_headers ? split_authority(last_hop(env["HTTP_X_FORWARDED_HOST"])) : [nil, nil]
|
|
49
|
+
forwarded_host = clean_host(forwarded_host_part)
|
|
50
|
+
forwarded_port = trust_proxy_headers ? parse_port(last_hop(env["HTTP_X_FORWARDED_PORT"])) : nil
|
|
51
|
+
|
|
52
|
+
# Evidence is judged AFTER validation, not on raw header presence: a
|
|
53
|
+
# malformed header (e.g. "X-Forwarded-Port: not-a-port") parses to
|
|
54
|
+
# nothing and so must never count as proxy evidence, or an
|
|
55
|
+
# unauthenticated caller could blank an otherwise-valid field just by
|
|
56
|
+
# sending garbage.
|
|
57
|
+
#
|
|
58
|
+
# Only a forwarded scheme or port counts as evidence strong enough to
|
|
59
|
+
# distrust the raw connection. A forwarded Host alone does not: some
|
|
60
|
+
# proxies rewrite only the Host header and pass scheme/port through
|
|
61
|
+
# unchanged, so a valid X-Forwarded-Host by itself says nothing about
|
|
62
|
+
# whether the connection's own scheme/port belong to the proxy or the
|
|
63
|
+
# caller.
|
|
64
|
+
proxied = !forwarded_scheme.nil? || !forwarded_port.nil?
|
|
65
|
+
|
|
66
|
+
# A malformed X-Forwarded-Host (wrong shape, or too long -- see
|
|
67
|
+
# clean_host) gets the same treatment as a malformed proto or port: it
|
|
68
|
+
# is ignored entirely and falls back to the direct Host header, exactly
|
|
69
|
+
# as if the header were absent, rather than leaving host unresolved.
|
|
70
|
+
host_part, authority_port =
|
|
71
|
+
if forwarded_host
|
|
72
|
+
[forwarded_host_part, forwarded_host_authority_port]
|
|
73
|
+
else
|
|
74
|
+
split_authority(host_authority(env))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
scheme = forwarded_scheme || (proxied ? nil : clean_scheme(env["rack.url_scheme"]))
|
|
78
|
+
host = forwarded_host || clean_host(host_part)
|
|
79
|
+
port_candidate = forwarded_port ||
|
|
80
|
+
parse_port(authority_port) ||
|
|
81
|
+
(proxied ? nil : parse_port(env["SERVER_PORT"]))
|
|
82
|
+
port = usable_port(port_candidate, scheme)
|
|
83
|
+
|
|
84
|
+
resolved = {}
|
|
85
|
+
resolved[:scheme] = scheme if scheme
|
|
86
|
+
resolved[:host] = host if host
|
|
87
|
+
resolved[:port] = port if port
|
|
88
|
+
resolved
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The hostname alone, for the authorize path.
|
|
92
|
+
#
|
|
93
|
+
# Deliberately NOT from_rack_env(env)[:host]: reads the Host header only,
|
|
94
|
+
# never the forwarded chain, however trust_proxy_headers is set. The
|
|
95
|
+
# value feeds `target_hostname` and the access-token cache key, and the
|
|
96
|
+
# portal resolves an application environment from it -- a value matching
|
|
97
|
+
# no registered row is a hard 422 with no fallback, not a cache miss.
|
|
98
|
+
#
|
|
99
|
+
# Composed from the same split_authority/clean_host pair from_rack_env
|
|
100
|
+
# uses, so IPv6 bracketing, lowercasing, and shape and length validation
|
|
101
|
+
# are identical between the two; only the authority's source differs.
|
|
102
|
+
def hostname_from_rack_env(env)
|
|
103
|
+
return nil unless env.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
host_part, _authority_port = split_authority(host_authority(env))
|
|
106
|
+
clean_host(host_part)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A proxy that appends writes its own observation last. A proxy that
|
|
110
|
+
# overwrites (nginx, Caddy, ALB) emits one value, where first and last are
|
|
111
|
+
# the same thing.
|
|
112
|
+
def last_hop(value)
|
|
113
|
+
return nil unless value.is_a?(String)
|
|
114
|
+
|
|
115
|
+
hops = value.split(",").map(&:strip).reject(&:empty?)
|
|
116
|
+
hops.last
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The authority the caller named, for both resolution paths.
|
|
120
|
+
#
|
|
121
|
+
# An empty Host header is treated as ABSENT, not as a present-but-unusable
|
|
122
|
+
# value. A caller that sends `Host:` with nothing after it has said nothing
|
|
123
|
+
# about which host it meant, so there is nothing there to prefer over
|
|
124
|
+
# SERVER_NAME. Falling through concedes no control the caller did not
|
|
125
|
+
# already have either: SERVER_NAME is a server-side value, not one a
|
|
126
|
+
# request can steer.
|
|
127
|
+
#
|
|
128
|
+
# On the authorize path the alternative is worse than cosmetic. Resolving
|
|
129
|
+
# the host to nil there drops the request to Basic auth and skips the token
|
|
130
|
+
# mint entirely, where falling through yields a usable
|
|
131
|
+
# application-environment lookup key.
|
|
132
|
+
#
|
|
133
|
+
# This CHANGES Ruby's behavior: an empty Host header used to resolve the
|
|
134
|
+
# host to nil. `env["HTTP_HOST"] || env["SERVER_NAME"]` stops at "" because
|
|
135
|
+
# "" is truthy in Ruby. Python, Java and JS already fell through, because
|
|
136
|
+
# "" is falsy there; Ruby and Elixir stopped, because "" is truthy in both.
|
|
137
|
+
# One expression written five times, diverging only on the empty case. It
|
|
138
|
+
# now lives at one site per SDK, and this comment is why.
|
|
139
|
+
#
|
|
140
|
+
# `.to_s.empty?` so a nil Host header takes the same branch as an empty one.
|
|
141
|
+
def host_authority(env)
|
|
142
|
+
host = env["HTTP_HOST"]
|
|
143
|
+
host.to_s.empty? ? env["SERVER_NAME"] : host
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# "api.example.com:8443" -> ["api.example.com", "8443"]
|
|
147
|
+
# "[2001:db8::1]:8443" -> ["[2001:db8::1]", "8443"]
|
|
148
|
+
def split_authority(value)
|
|
149
|
+
return [nil, nil] unless value.is_a?(String)
|
|
150
|
+
|
|
151
|
+
authority = value.strip
|
|
152
|
+
if authority.start_with?("[")
|
|
153
|
+
head, bracket, tail = authority.partition("]")
|
|
154
|
+
return [nil, nil] if bracket.empty?
|
|
155
|
+
|
|
156
|
+
["#{head}]", tail.start_with?(":") ? tail[1..] : nil]
|
|
157
|
+
elsif authority.count(":") == 1
|
|
158
|
+
host, _, port = authority.partition(":")
|
|
159
|
+
[host, port]
|
|
160
|
+
else
|
|
161
|
+
[authority, nil]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Normalize, then validate. "HTTPS" and "https:" both have to reach intake
|
|
166
|
+
# as "https": JS's location.protocol and Node's URL#protocol keep the
|
|
167
|
+
# colon, nothing pins the case, and intake never rewrites a stored row --
|
|
168
|
+
# two spellings of the same scheme would split the dominant-triple
|
|
169
|
+
# grouping forever. delete_suffix removes one colon, not all of them, so
|
|
170
|
+
# "https::" still fails the shape check rather than sneaking through.
|
|
171
|
+
def clean_scheme(value)
|
|
172
|
+
return nil unless value.is_a?(String)
|
|
173
|
+
|
|
174
|
+
scheme = value.strip.downcase.delete_suffix(":")
|
|
175
|
+
scheme.match?(SCHEME) ? scheme : nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# DNS caps a hostname at 253 characters, and the receiving column is
|
|
179
|
+
# varchar(255). No web-server adapter validates the length of
|
|
180
|
+
# X-Forwarded-Host, so without this a caller could make the SDK report an
|
|
181
|
+
# arbitrarily long value; dropped, not truncated, because a truncated
|
|
182
|
+
# hostname is a plausible-looking WRONG one and the portal reads `host`
|
|
183
|
+
# verbatim to assemble a base URL.
|
|
184
|
+
def clean_host(value)
|
|
185
|
+
return nil unless value.is_a?(String)
|
|
186
|
+
|
|
187
|
+
host = value.strip.downcase
|
|
188
|
+
return nil if host.empty? || host.bytesize > 253
|
|
189
|
+
|
|
190
|
+
host.match?(HOSTNAME) || host.match?(IPV6) ? host : nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Numeric validation only -- 1..65535, nothing scheme-aware. Used both to
|
|
194
|
+
# decide whether a forwarded/authority port counts as a usable value and
|
|
195
|
+
# as a port candidate; default-port omission happens exactly once, in
|
|
196
|
+
# usable_port, against the FINAL resolved scheme, so it can never be
|
|
197
|
+
# skipped just because the scheme happened to resolve from a different
|
|
198
|
+
# source than the port did.
|
|
199
|
+
def parse_port(value)
|
|
200
|
+
port = Integer(value.to_s.strip, 10, exception: false)
|
|
201
|
+
return nil if port.nil? || port < 1 || port > 65_535
|
|
202
|
+
|
|
203
|
+
port
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# A port is reported only when it can be classified against a resolved
|
|
207
|
+
# scheme. With no scheme, "default" is meaningless, so an unclassifiable
|
|
208
|
+
# port is withheld entirely rather than guessed at -- the same origin must
|
|
209
|
+
# never be reportable two ways depending on which headers happened to
|
|
210
|
+
# arrive.
|
|
211
|
+
def usable_port(candidate, scheme)
|
|
212
|
+
return nil if candidate.nil? || scheme.nil?
|
|
213
|
+
return nil if DEFAULT_PORTS[scheme] == candidate
|
|
214
|
+
|
|
215
|
+
candidate
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/bin/ruby
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
3
4
|
require_relative 'http'
|
|
4
5
|
require_relative 'authentication_cache'
|
|
5
6
|
|
|
@@ -19,35 +20,56 @@ module EndPointBlank
|
|
|
19
20
|
method = request.request_method
|
|
20
21
|
path = request.route_uri_pattern.to_s.gsub(/\([^)]*\)/, '')
|
|
21
22
|
app_name = Configuration.instance.app_name
|
|
22
|
-
|
|
23
|
+
# The version is part of the key because authorization is decided per
|
|
24
|
+
# endpoint version, and so is the deprecation carried back with it.
|
|
25
|
+
# Without it, two callers on different versions of the same route share
|
|
26
|
+
# one entry: whichever authorizes first decides both, so a client on a
|
|
27
|
+
# deprecated version can get no warning, or one on a current version
|
|
28
|
+
# can be told it is retiring.
|
|
29
|
+
version = VersionFinder.new.find(request)
|
|
30
|
+
cache_key = "epb_auth:#{client_auth}:#{path}:#{method}:#{app_name}:#{version}"
|
|
23
31
|
|
|
24
32
|
cache = AuthenticationCache.instance
|
|
25
|
-
|
|
33
|
+
# The cached value is the authorize response body, not a truthy
|
|
34
|
+
# marker.
|
|
35
|
+
#
|
|
36
|
+
# It has to be, for two reasons. Callers parse the body — a cache hit
|
|
37
|
+
# returning '' made JSON.parse raise, so a cached authorization became
|
|
38
|
+
# a 500 rather than a fast success. And the body is where the
|
|
39
|
+
# deprecation block lives; without it the Deprecation and Sunset
|
|
40
|
+
# headers would appear only on cache misses, which reads as a flaky
|
|
41
|
+
# feature rather than a missing one.
|
|
42
|
+
if (cached = cache.retrieve(cache_key))
|
|
43
|
+
return CachedResponse.new(201, cached)
|
|
44
|
+
end
|
|
26
45
|
|
|
27
|
-
|
|
28
|
-
|
|
46
|
+
# Host header only, never the forwarded chain -- see
|
|
47
|
+
# BaseUrl.hostname_from_rack_env. request.host would read
|
|
48
|
+
# X-Forwarded-Host unconditionally.
|
|
49
|
+
hostname = EndPointBlank::BaseUrl.hostname_from_rack_env(request.env)
|
|
29
50
|
body = {
|
|
30
51
|
path: path,
|
|
31
52
|
http_method: method,
|
|
32
53
|
client_auth: client_auth,
|
|
33
54
|
target_hostname: hostname,
|
|
34
55
|
application: app_name,
|
|
35
|
-
endpoint_version:
|
|
56
|
+
endpoint_version: version,
|
|
36
57
|
source_ip: request.remote_ip,
|
|
37
58
|
uuid: request.uuid
|
|
38
59
|
}
|
|
39
|
-
response = Http.post(configuration.authorize_url, auth, body)
|
|
40
60
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
# Basic, not Bearer. This call is to intake, which already holds
|
|
62
|
+
# this service's credential -- minting a token to present it back
|
|
63
|
+
# was a hop that bought nothing. With no Bearer there is no stale
|
|
64
|
+
# token, so the 401 retry that used to live here is gone: a 401 now
|
|
65
|
+
# means the credential is wrong, which is worth surfacing rather
|
|
66
|
+
# than retrying.
|
|
67
|
+
response = Http.post(configuration.authorize_url, Authorization.header, body)
|
|
46
68
|
|
|
47
69
|
return nil if response.nil?
|
|
48
70
|
EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
|
|
49
71
|
if response.status == 201
|
|
50
|
-
cache.store(cache_key,
|
|
72
|
+
cache.store(cache_key, response.body)
|
|
51
73
|
elsif response.status > 299
|
|
52
74
|
EndPointBlank.logger.error "Failed to authorize endpoint: #{response.status} - #{response.body}"
|
|
53
75
|
end
|
|
@@ -110,7 +110,7 @@ module EndPointBlank
|
|
|
110
110
|
nil
|
|
111
111
|
else
|
|
112
112
|
controller_class = "#{route.defaults[:controller].camelize}Controller".constantize
|
|
113
|
-
versions = controller_class.respond_to?(:versions) ? controller_class.versions(route.defaults[:action].to_sym) :
|
|
113
|
+
versions = controller_class.respond_to?(:versions) ? controller_class.versions(route.defaults[:action].to_sym) : []
|
|
114
114
|
{
|
|
115
115
|
path: route.path.spec.to_s.gsub(/\([^)]*\)/, ''),
|
|
116
116
|
http_method: route.verb,
|
|
@@ -12,8 +12,8 @@ module EndPointBlank
|
|
|
12
12
|
EndPointBlank::Configuration.instance
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def token(
|
|
16
|
-
body = {
|
|
15
|
+
def token(base_url)
|
|
16
|
+
body = {base_url: base_url}
|
|
17
17
|
if configuration.token_ttl
|
|
18
18
|
body[:token_ttl] = configuration.token_ttl
|
|
19
19
|
end
|
|
@@ -23,7 +23,7 @@ module EndPointBlank
|
|
|
23
23
|
body: body.to_json,
|
|
24
24
|
**EndPointBlank::Commands::Http::TIMEOUT_OPTIONS
|
|
25
25
|
)
|
|
26
|
-
EndPointBlank.logger.info "Authentication response: #{response.status}
|
|
26
|
+
EndPointBlank.logger.info "Authentication response: #{response.status}"
|
|
27
27
|
parsed_body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
|
|
28
28
|
parsed_body.transform_keys(&:to_sym)
|
|
29
29
|
rescue => e
|
|
@@ -15,7 +15,7 @@ module EndPointBlank
|
|
|
15
15
|
|
|
16
16
|
attr_accessor :worker_count, :log_mode,
|
|
17
17
|
:version_finder, :application_version, :token_ttl, :cache_ttl,
|
|
18
|
-
:masking_rules, :mask_hook, :logger
|
|
18
|
+
:masking_rules, :mask_hook, :logger, :trust_proxy_headers
|
|
19
19
|
|
|
20
20
|
def initialize
|
|
21
21
|
@worker_count = 4
|
|
@@ -23,6 +23,7 @@ module EndPointBlank
|
|
|
23
23
|
@cache_ttl = 300
|
|
24
24
|
@masking_rules = []
|
|
25
25
|
@mask_hook = nil
|
|
26
|
+
@trust_proxy_headers = true
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# Returns the configured client id, falling back to the
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module EndPointBlank
|
|
6
|
+
# Formats the deprecation facts returned by an authorize call into the
|
|
7
|
+
# standard response headers.
|
|
8
|
+
#
|
|
9
|
+
# Deprecation RFC 9745 — an Item Structured Header Date: "@1688169599"
|
|
10
|
+
# Sunset RFC 8594 — an HTTP-date: "Sat, 31 Dec 2018 23:59:59 GMT"
|
|
11
|
+
#
|
|
12
|
+
# RFC 9745 permits a past value ("was deprecated at that date"), which is what
|
|
13
|
+
# EndPointBlank emits: deprecation takes effect when it is declared.
|
|
14
|
+
#
|
|
15
|
+
# Pure and stateless on purpose. The SDK does not know what a lifecycle is —
|
|
16
|
+
# it relays two timestamps the portal already decided about, and this turns
|
|
17
|
+
# them into two strings. That keeps the vectors in
|
|
18
|
+
# docs/superpowers/specs/2026-08-01-header-vectors.md assertable without
|
|
19
|
+
# constructing a request.
|
|
20
|
+
module DeprecationHeaders
|
|
21
|
+
DEPRECATION = "Deprecation"
|
|
22
|
+
SUNSET = "Sunset"
|
|
23
|
+
|
|
24
|
+
# Fixed English abbreviations. Ruby's %a/%b are locale-independent, but
|
|
25
|
+
# spelling them out removes the question entirely — a server running under a
|
|
26
|
+
# different locale must still emit an HTTP-date.
|
|
27
|
+
DAYS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze
|
|
28
|
+
MONTHS = %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec].freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# @param deprecation [Hash, nil] the authorize response's "deprecation"
|
|
32
|
+
# block: {"deprecated_at" => iso8601, "sunset_at" => iso8601 | nil}
|
|
33
|
+
# @return [Hash] header name => value; empty when there is nothing to say
|
|
34
|
+
def build(deprecation)
|
|
35
|
+
return {} unless deprecation.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
headers = {}
|
|
38
|
+
|
|
39
|
+
if (at = parse(deprecation["deprecated_at"] || deprecation[:deprecated_at]))
|
|
40
|
+
headers[DEPRECATION] = deprecation_value(at)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if (at = parse(deprecation["sunset_at"] || deprecation[:sunset_at]))
|
|
44
|
+
headers[SUNSET] = sunset_value(at)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
headers
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# "@1688169599" — no quotes, no sub-second precision.
|
|
51
|
+
def deprecation_value(time)
|
|
52
|
+
"@#{time.to_i}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# "Sat, 31 Dec 2018 23:59:59 GMT" — day-of-month zero padded, always GMT.
|
|
56
|
+
def sunset_value(time)
|
|
57
|
+
t = time.utc
|
|
58
|
+
format(
|
|
59
|
+
"%s, %02d %s %04d %02d:%02d:%02d GMT",
|
|
60
|
+
DAYS[t.wday], t.day, MONTHS[t.month - 1], t.year, t.hour, t.min, t.sec
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Never raise into the provider's response path. A malformed timestamp
|
|
67
|
+
# from the portal is a bug worth no header; it is not worth a 500 on a
|
|
68
|
+
# request that already succeeded.
|
|
69
|
+
#
|
|
70
|
+
# Types are matched rather than coerced. `Time.parse(12345)` does not
|
|
71
|
+
# raise — it happily returns a date in 2012 — so a `to_s` here would turn
|
|
72
|
+
# a nonsense value into a plausible-looking header, which is the one
|
|
73
|
+
# outcome worse than no header at all.
|
|
74
|
+
def parse(value)
|
|
75
|
+
case value
|
|
76
|
+
when Time then value.utc
|
|
77
|
+
when String then parse_string(value)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse_string(value)
|
|
82
|
+
return nil if value.empty?
|
|
83
|
+
|
|
84
|
+
Time.parse(value).utc
|
|
85
|
+
rescue ArgumentError, TypeError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -19,6 +19,14 @@ module EndPointBlank
|
|
|
19
19
|
|
|
20
20
|
status, headers, body = @app.call(env)
|
|
21
21
|
|
|
22
|
+
# RFC 9745 / RFC 8594. The provider writes no code for this: the
|
|
23
|
+
# portal decided the version is deprecated, authorize relayed the
|
|
24
|
+
# dates, and this attaches them to the response their consumer
|
|
25
|
+
# already receives.
|
|
26
|
+
::EndPointBlank::DeprecationHeaders
|
|
27
|
+
.build(::EndPointBlank::Rack::EnvStore.deprecation)
|
|
28
|
+
.each { |name, value| headers[name] = value }
|
|
29
|
+
|
|
22
30
|
[status, headers, body]
|
|
23
31
|
rescue ::EndPointBlank::UnauthorizedError => e
|
|
24
32
|
# We don't want to log unauthorized errors as they are expected to happen
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
module EndPointBlank
|
|
2
2
|
module Rack
|
|
3
|
+
# Per-request state, keyed off the Rack env.
|
|
4
|
+
#
|
|
5
|
+
# Only the env itself is held in a thread-local; everything derived from a
|
|
6
|
+
# request lives *inside* that env. That distinction is the whole point.
|
|
7
|
+
#
|
|
8
|
+
# Threads are reused between requests, so a thread-local is correct only for
|
|
9
|
+
# as long as something reliably clears it — here, the middleware's `ensure`.
|
|
10
|
+
# Anything that set a value without that middleware in the stack would
|
|
11
|
+
# strand it on the thread, and the next request served by that thread could
|
|
12
|
+
# read the previous caller's data: their app-environment id on our audit
|
|
13
|
+
# row, their sunset date on our response.
|
|
14
|
+
#
|
|
15
|
+
# The env is per-request by construction. The server builds a fresh hash per
|
|
16
|
+
# request and `set/1` replaces it wholesale at the top of the middleware, so
|
|
17
|
+
# a stranded value is unreachable even if `clear/0` never runs. Correctness
|
|
18
|
+
# stops depending on cleanup happening.
|
|
19
|
+
#
|
|
20
|
+
# Outside a Rack request there is nowhere to put anything and no response to
|
|
21
|
+
# decorate, so the writers are no-ops and the readers return nil.
|
|
3
22
|
class EnvStore
|
|
4
23
|
KEY = 'end_point_blank.rack_env'.freeze
|
|
5
24
|
SOURCE_ENV_ID_KEY = 'end_point_blank.source_application_environment_id'.freeze
|
|
25
|
+
DEPRECATION_KEY = 'end_point_blank.deprecation'.freeze
|
|
6
26
|
|
|
7
27
|
def self.set(env)
|
|
8
28
|
Thread.current[KEY] = env
|
|
@@ -17,18 +37,42 @@ module EndPointBlank
|
|
|
17
37
|
env && ::Rack::Request.new(env)
|
|
18
38
|
end
|
|
19
39
|
|
|
40
|
+
# The calling service's application environment, resolved by `authorize!`
|
|
41
|
+
# and stamped onto every audit row for this request.
|
|
20
42
|
def self.set_source_application_environment_id(id)
|
|
21
|
-
|
|
43
|
+
put(SOURCE_ENV_ID_KEY, id)
|
|
22
44
|
end
|
|
23
45
|
|
|
24
46
|
def self.source_application_environment_id
|
|
25
|
-
|
|
47
|
+
fetch(SOURCE_ENV_ID_KEY)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The authorize response's deprecation block, stashed on the way in so the
|
|
51
|
+
# response middleware can turn it into `Deprecation` and `Sunset` headers
|
|
52
|
+
# on the way out.
|
|
53
|
+
def self.set_deprecation(deprecation)
|
|
54
|
+
put(DEPRECATION_KEY, deprecation)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.deprecation
|
|
58
|
+
fetch(DEPRECATION_KEY)
|
|
26
59
|
end
|
|
27
60
|
|
|
28
61
|
def self.clear
|
|
29
62
|
Thread.current[KEY] = nil
|
|
30
|
-
Thread.current[SOURCE_ENV_ID_KEY] = nil
|
|
31
63
|
end
|
|
64
|
+
|
|
65
|
+
def self.put(key, value)
|
|
66
|
+
env = get
|
|
67
|
+
env[key] = value if env
|
|
68
|
+
end
|
|
69
|
+
private_class_method :put
|
|
70
|
+
|
|
71
|
+
def self.fetch(key)
|
|
72
|
+
env = get
|
|
73
|
+
env && env[key]
|
|
74
|
+
end
|
|
75
|
+
private_class_method :fetch
|
|
32
76
|
end
|
|
33
77
|
end
|
|
34
|
-
end
|
|
78
|
+
end
|
|
@@ -17,6 +17,9 @@ module EndPointBlank
|
|
|
17
17
|
result_json = JSON.parse(result.body)
|
|
18
18
|
app_env_id = result_json['data'][0]['source_application_environment_id']
|
|
19
19
|
::EndPointBlank::Rack::EnvStore.set_source_application_environment_id(app_env_id)
|
|
20
|
+
# Present only when the called version is deprecated; the middleware
|
|
21
|
+
# turns it into Deprecation / Sunset headers on the way out.
|
|
22
|
+
::EndPointBlank::Rack::EnvStore.set_deprecation(result_json['deprecation'])
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
private
|
|
@@ -4,15 +4,20 @@ module EndPointBlank
|
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
6
|
class_methods do
|
|
7
|
-
#
|
|
7
|
+
# Record which versions specific actions serve.
|
|
8
|
+
#
|
|
9
|
+
# Lifecycle state (Current, Deprecated, ...) is NOT declared here. It is
|
|
10
|
+
# managed in the EndPointBlank portal, where changing it does not require
|
|
11
|
+
# shipping code. This reports which versions exist, not what they mean.
|
|
12
|
+
#
|
|
8
13
|
# @param versions [Array<String>] List of version strings (e.g., ["v1", "v2"])
|
|
9
14
|
# @param options [Hash] Options hash with :only or :except keys
|
|
10
15
|
# @option options [Array<Symbol>] :only Actions to include in versioning
|
|
11
16
|
# @option options [Array<Symbol>] :except Actions to exclude from versioning
|
|
12
17
|
#
|
|
13
18
|
# Example:
|
|
14
|
-
# version ["v1", "v2"], only: [:index]
|
|
15
|
-
# version ["v3"], except: [:destroy]
|
|
19
|
+
# version ["v1", "v2"], only: [:index]
|
|
20
|
+
# version ["v3"], except: [:destroy]
|
|
16
21
|
def version(values, options = {})
|
|
17
22
|
versions = Array(values)
|
|
18
23
|
@versioning_config ||= {}
|
|
@@ -20,23 +25,16 @@ module EndPointBlank
|
|
|
20
25
|
actions = determine_actions(options)
|
|
21
26
|
|
|
22
27
|
actions.each do |action|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@versioning_config[action][
|
|
26
|
-
@versioning_config[action][state] = (@versioning_config[action][state] + versions).uniq
|
|
28
|
+
# uniq preserves declaration order, so the manifest stays stable
|
|
29
|
+
# between deploys instead of churning.
|
|
30
|
+
@versioning_config[action] = ((@versioning_config[action] || []) + versions).uniq
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
#
|
|
31
|
-
# @return [
|
|
32
|
-
#
|
|
33
|
-
# Example return value:
|
|
34
|
-
# {
|
|
35
|
-
# index: ["v1", "v2"],
|
|
36
|
-
# show: ["v1"]
|
|
37
|
-
# }
|
|
34
|
+
# Versions the given action serves.
|
|
35
|
+
# @return [Array<String>] e.g. ["v1", "v2"]; empty when none declared.
|
|
38
36
|
def versions(action)
|
|
39
|
-
@versioning_config&.fetch(action,
|
|
37
|
+
@versioning_config&.fetch(action, []) || []
|
|
40
38
|
end
|
|
41
39
|
|
|
42
40
|
private
|
|
@@ -26,11 +26,10 @@ module EndPointBlank
|
|
|
26
26
|
version = Commands::VersionFinder.new.find(request)
|
|
27
27
|
headers = ::EndPointBlank::Rack::Headers.extract
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
{
|
|
30
30
|
app_name: EndPointBlank::Configuration.instance.app_name,
|
|
31
31
|
env: SessionConfiguration.env_name,
|
|
32
32
|
uuid: request_uuid(env),
|
|
33
|
-
host: request.host,
|
|
34
33
|
status: request_status(request),
|
|
35
34
|
headers: headers,
|
|
36
35
|
path: request.path,
|
|
@@ -38,7 +37,12 @@ module EndPointBlank
|
|
|
38
37
|
endpoint_version: version,
|
|
39
38
|
request: request_body(request),
|
|
40
39
|
sent_at: Time.now.utc.iso8601(3)
|
|
41
|
-
}
|
|
40
|
+
}.merge(
|
|
41
|
+
::EndPointBlank::BaseUrl.from_rack_env(
|
|
42
|
+
env,
|
|
43
|
+
trust_proxy_headers: EndPointBlank::Configuration.instance.trust_proxy_headers
|
|
44
|
+
)
|
|
45
|
+
)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
def write()
|
data/lib/end_point_blank.rb
CHANGED
|
@@ -7,12 +7,14 @@ require_relative "end_point_blank/access_tokens"
|
|
|
7
7
|
require_relative "end_point_blank/authorization"
|
|
8
8
|
require_relative "end_point_blank/version"
|
|
9
9
|
require_relative "end_point_blank/configuration"
|
|
10
|
+
require_relative "end_point_blank/base_url"
|
|
10
11
|
require_relative "end_point_blank/session_configuration"
|
|
11
12
|
require_relative "end_point_blank/log_entry"
|
|
12
13
|
require_relative "end_point_blank/string_truncator"
|
|
13
14
|
require_relative "end_point_blank/fast_json_truncator"
|
|
14
15
|
require_relative "end_point_blank/xml_truncator"
|
|
15
16
|
require_relative "end_point_blank/masking"
|
|
17
|
+
require_relative "end_point_blank/deprecation_headers"
|
|
16
18
|
require_relative "end_point_blank/writers/shared"
|
|
17
19
|
require_relative "end_point_blank/writers/delayed_writer"
|
|
18
20
|
require_relative "end_point_blank/writers/direct_writer"
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: end_point_blank
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Robert A. Lasch
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-08-14 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: excon
|
|
@@ -29,28 +29,28 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version:
|
|
32
|
+
version: 3.2.6
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version:
|
|
39
|
+
version: 3.2.6
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: rexml
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version:
|
|
46
|
+
version: 3.3.9
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version:
|
|
53
|
+
version: 3.3.9
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: base64
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -91,17 +91,20 @@ files:
|
|
|
91
91
|
- ".rspec"
|
|
92
92
|
- ".rubocop.yml"
|
|
93
93
|
- ".ruby-version"
|
|
94
|
+
- CHANGELOG.md
|
|
94
95
|
- HARDENING_REPORT.md
|
|
95
96
|
- LICENSE
|
|
96
97
|
- README.md
|
|
97
98
|
- Rakefile
|
|
98
99
|
- build.sh
|
|
100
|
+
- deploy
|
|
99
101
|
- docs/superpowers/plans/2026-07-08-framework-agnostic-core.md
|
|
100
102
|
- docs/superpowers/specs/2026-07-08-framework-agnostic-core-design.md
|
|
101
103
|
- end_point_blank.gemspec
|
|
102
104
|
- lib/end_point_blank.rb
|
|
103
105
|
- lib/end_point_blank/access_tokens.rb
|
|
104
106
|
- lib/end_point_blank/authorization.rb
|
|
107
|
+
- lib/end_point_blank/base_url.rb
|
|
105
108
|
- lib/end_point_blank/commands/authentication_cache.rb
|
|
106
109
|
- lib/end_point_blank/commands/basic_authenticate.rb
|
|
107
110
|
- lib/end_point_blank/commands/bearer_generate.rb
|
|
@@ -112,6 +115,7 @@ files:
|
|
|
112
115
|
- lib/end_point_blank/commands/route_pattern_finder.rb
|
|
113
116
|
- lib/end_point_blank/commands/version_finder.rb
|
|
114
117
|
- lib/end_point_blank/configuration.rb
|
|
118
|
+
- lib/end_point_blank/deprecation_headers.rb
|
|
115
119
|
- lib/end_point_blank/fast_json_truncator.rb
|
|
116
120
|
- lib/end_point_blank/log_entry.rb
|
|
117
121
|
- lib/end_point_blank/loggers/logger.rb
|