end_point_blank 0.2.1 → 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/HARDENING_REPORT.md +210 -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/writers/response_writer.rb +1 -0
- data/lib/end_point_blank.rb +2 -0
- metadata +11 -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/HARDENING_REPORT.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Hardening report — end_point_blank (Rails/Ruby client lib) — P2 #14
|
|
2
|
+
|
|
3
|
+
Branch: `harden-timeouts-bounded-queue` (off `master`, not pushed)
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
Bring this lib's reliability posture in line with the Elixir sibling lib,
|
|
8
|
+
which was hardened with per-attempt HTTP timeouts (connect 3s / read 5s)
|
|
9
|
+
and a bounded, drop-oldest background send queue (cap 1000, throttled
|
|
10
|
+
warning on drop).
|
|
11
|
+
|
|
12
|
+
## Gap 1 — Excon calls with no timeout
|
|
13
|
+
|
|
14
|
+
### Every `Excon.` call site in the lib (confirmed via `grep -rn "Excon\." lib/`)
|
|
15
|
+
|
|
16
|
+
| File | Line | Call |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `lib/end_point_blank/commands/http.rb` | 23 | `Excon.post` (shared fire-and-forget POST helper, used by `DirectWriter`) |
|
|
19
|
+
| `lib/end_point_blank/commands/generate_access_token.rb` | 20 | `Excon.post` (access-token exchange) |
|
|
20
|
+
| `lib/end_point_blank/commands/endpoint_update.rb` | 30 | `Excon.post` (endpoint/route registration) |
|
|
21
|
+
|
|
22
|
+
All three are the only `Excon.*` calls in the lib. No other `Excon.new`/etc. exist.
|
|
23
|
+
|
|
24
|
+
### Fix
|
|
25
|
+
|
|
26
|
+
Added a single shared constant in `lib/end_point_blank/commands/http.rb`:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
CONNECT_TIMEOUT = 3
|
|
30
|
+
READ_TIMEOUT = 5
|
|
31
|
+
TIMEOUT_OPTIONS = { connect_timeout: CONNECT_TIMEOUT, read_timeout: READ_TIMEOUT }.freeze
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `Http.post` now passes `**TIMEOUT_OPTIONS` directly.
|
|
35
|
+
- `generate_access_token.rb` and `endpoint_update.rb` each `require_relative 'http'`
|
|
36
|
+
and splat `**EndPointBlank::Commands::Http::TIMEOUT_OPTIONS` into their own
|
|
37
|
+
`Excon.post` calls, so the values live in exactly one place.
|
|
38
|
+
|
|
39
|
+
### Timeout error handling
|
|
40
|
+
|
|
41
|
+
`Excon::Error::Timeout` is a subclass of `Excon::Error` (verified against the
|
|
42
|
+
installed excon 1.5.0 source), so:
|
|
43
|
+
|
|
44
|
+
- `Http.post`'s existing `rescue Excon::Error => e` already covers timeouts —
|
|
45
|
+
it retries up to `MAX_ATTEMPTS` (3) then logs and returns `nil`. No caller
|
|
46
|
+
ever sees an exception.
|
|
47
|
+
- `generate_access_token.rb`'s existing `rescue => e` (StandardError) already
|
|
48
|
+
covered timeouts too.
|
|
49
|
+
- `endpoint_update.rb` had a **latent bug**: `rescue Excon::Error::Socket,
|
|
50
|
+
Excon::Error::Connection => e`. `Excon::Error::Connection` **does not
|
|
51
|
+
exist** in excon 1.5.0 (only `Excon::Error::Socket`, `Excon::Error::Timeout`,
|
|
52
|
+
etc. are defined under `Excon::Error`). Because Ruby resolves rescue-clause
|
|
53
|
+
constants lazily, this clause would silently work until an exception was
|
|
54
|
+
actually raised inside the `begin` block, at which point evaluating
|
|
55
|
+
`Excon::Error::Connection` would raise `NameError` and crash the caller —
|
|
56
|
+
i.e. a network blip or a timeout during endpoint registration would have
|
|
57
|
+
thrown `NameError` instead of being handled gracefully. Fixed by rescuing
|
|
58
|
+
`Excon::Error` broadly (matching the pattern in `http.rb`), which now also
|
|
59
|
+
correctly covers `Excon::Error::Timeout`.
|
|
60
|
+
|
|
61
|
+
### Incidental fix required to exercise the above in tests
|
|
62
|
+
|
|
63
|
+
`lib/end_point_blank/authorization.rb` calls `Base64.encode64` for the Basic
|
|
64
|
+
auth header path but never `require 'base64'`. On Ruby 3.4 `base64` was
|
|
65
|
+
removed from default gems, so this raised `NameError` / `LoadError` the
|
|
66
|
+
moment that code path executed (which is exactly the path exercised in the
|
|
67
|
+
`endpoint_update` timeout test, since it needs an `Authorization.header` call
|
|
68
|
+
with no cached token). Fixed by adding `require 'base64'` in
|
|
69
|
+
`authorization.rb` and declaring `spec.add_dependency "base64"` in the
|
|
70
|
+
gemspec (ran `bundle install`, updating `Gemfile.lock`). This was a real
|
|
71
|
+
reliability bug independent of this task — any Basic-auth call path in this
|
|
72
|
+
gem on Ruby >= 3.4 without `base64` explicitly in the app's own Gemfile would
|
|
73
|
+
have crashed.
|
|
74
|
+
|
|
75
|
+
## Gap 2 — unbounded background queue
|
|
76
|
+
|
|
77
|
+
`lib/end_point_blank/writers/delayed_writer.rb` used a plain `Queue.new`
|
|
78
|
+
(unbounded). During an intake outage, the 2 background drain threads block
|
|
79
|
+
on failed/slow POSTs and the queue grows without bound → OOM risk.
|
|
80
|
+
|
|
81
|
+
### Fix
|
|
82
|
+
|
|
83
|
+
`Queue` has no `max` option, so the bound is implemented explicitly:
|
|
84
|
+
|
|
85
|
+
- `MAX_QUEUE_SIZE = 1000`.
|
|
86
|
+
- `enqueue` (public API, accepts a single payload or an array, unchanged
|
|
87
|
+
signature) now routes each item through `enqueue_one`, which is guarded by
|
|
88
|
+
a `Mutex` (`enqueue_mutex`) so the check-then-drop-then-push sequence is
|
|
89
|
+
atomic across concurrent producer threads (the request/response/log/error
|
|
90
|
+
writer call paths).
|
|
91
|
+
- If `queue.size >= MAX_QUEUE_SIZE` when a new item arrives, the **oldest**
|
|
92
|
+
item is dropped first (`pop_additional`, a non-blocking `Queue#pop(true)`,
|
|
93
|
+
which removes from the front of the FIFO `Queue`), then the new item is
|
|
94
|
+
pushed. Net effect: queue never exceeds 1000, newest items win.
|
|
95
|
+
- Drop warnings are throttled: `note_drop` tracks a drop counter and only
|
|
96
|
+
logs (via `log_warning`, which prefers `::Rails.logger.warn` and falls
|
|
97
|
+
back to `Kernel#warn` when Rails isn't loaded) at most once per
|
|
98
|
+
`WARN_THROTTLE_SECONDS` (30s), with the message including how many items
|
|
99
|
+
were dropped since the last warning — so a sustained outage doesn't itself
|
|
100
|
+
become a logging flood.
|
|
101
|
+
- The existing 2-thread drain model (`start_threads`) is unchanged in
|
|
102
|
+
structure; internal `@queue` access was switched to a `queue` reader
|
|
103
|
+
(`@queue ||= Queue.new`) so both the drain threads and the new bounded
|
|
104
|
+
`enqueue_one` share the same lazily-initialized queue, and so tests can
|
|
105
|
+
instantiate a bare object that mixes in `DelayedWriter` and drive
|
|
106
|
+
`enqueue`/`queue` without needing to spin up the background threads.
|
|
107
|
+
|
|
108
|
+
## Sinatra coverage
|
|
109
|
+
|
|
110
|
+
Confirmed: `end_point_blank_sinatra/Gemfile` depends on
|
|
111
|
+
`gem 'end_point_blank', path: '../end_point_blank_rails'` and
|
|
112
|
+
`end_point_blank_sinatra/app.rb` does `require 'end_point_blank'`, which is
|
|
113
|
+
this gem's single entry point (`lib/end_point_blank.rb`) that requires every
|
|
114
|
+
file touched here (`commands/http`, `commands/generate_access_token`,
|
|
115
|
+
`commands/endpoint_update`, `writers/delayed_writer`, `authorization`).
|
|
116
|
+
There is no Sinatra-specific fork of any of this code. **Sinatra is
|
|
117
|
+
covered.**
|
|
118
|
+
|
|
119
|
+
## Tests added (TDD: written first, confirmed red against the pre-fix code, then made green)
|
|
120
|
+
|
|
121
|
+
- `spec/commands_http_spec.rb` — asserts `Excon.post` is called with
|
|
122
|
+
`connect_timeout`/`read_timeout` matching the shared constants; asserts a
|
|
123
|
+
raised `Excon::Error::Timeout` is retried `MAX_ATTEMPTS` times, logged, and
|
|
124
|
+
returns `nil` without raising.
|
|
125
|
+
- `spec/generate_access_token_spec.rb` — asserts the same timeout options
|
|
126
|
+
reach `Excon.post` for the access-token exchange; asserts a timeout
|
|
127
|
+
doesn't raise.
|
|
128
|
+
- `spec/endpoint_update_spec.rb` — asserts the same timeout options reach
|
|
129
|
+
`Excon.post` for endpoint registration; asserts a timeout doesn't raise
|
|
130
|
+
and is logged via `Rails.logger.warn` (this is the test that caught both
|
|
131
|
+
the `Excon::Error::Connection` NameError bug and the missing
|
|
132
|
+
`require 'base64'`).
|
|
133
|
+
- `spec/delayed_writer_spec.rb` — mixes `DelayedWriter` into a bare test
|
|
134
|
+
class (without starting background threads, so the queue can be inspected
|
|
135
|
+
synchronously): asserts the queue never exceeds `MAX_QUEUE_SIZE`, asserts
|
|
136
|
+
the oldest items are dropped first (FIFO order preserved for survivors),
|
|
137
|
+
asserts normal FIFO enqueue/dequeue when under the cap, asserts array-form
|
|
138
|
+
`enqueue` also respects the bound, and asserts `log_warning` is called
|
|
139
|
+
only **once** across 50 drops (throttling), not once per drop.
|
|
140
|
+
|
|
141
|
+
All four spec files were confirmed to fail (9 of 11 new examples red) against
|
|
142
|
+
the original code before implementing the fix, then confirmed green after.
|
|
143
|
+
|
|
144
|
+
## Test evidence
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
$ bundle exec rspec
|
|
148
|
+
...
|
|
149
|
+
49 examples, 0 failures
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Also ran the project's actual CI commands directly:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
$ ./build.sh # bundle install — passes
|
|
156
|
+
$ ./test.sh # bundle exec rspec — 49 examples, 0 failures
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`bundle exec ruby -Ilib -e "require 'end_point_blank'"` loads cleanly and
|
|
160
|
+
`EndPointBlank::Commands::Http::TIMEOUT_OPTIONS` /
|
|
161
|
+
`EndPointBlank::Writers::DelayedWriter::MAX_QUEUE_SIZE` are inspectable at
|
|
162
|
+
the top level, confirming the constants are reachable as documented.
|
|
163
|
+
|
|
164
|
+
`rubocop` was also run for hygiene (not part of CI — `ci.yml` only runs
|
|
165
|
+
`./test.sh`, i.e. rspec). It reports 46 offenses across the 5 touched files
|
|
166
|
+
vs. 43 pre-existing on the same files before this change — the 3 new ones
|
|
167
|
+
are `Metrics/AbcSize`/`Metrics/MethodLength` on `start_threads`, whose body
|
|
168
|
+
is unchanged except swapping `@queue` for the new `queue` reader method; all
|
|
169
|
+
newly-added methods (`enqueue_one`, `drop_oldest_and_note`, `note_drop`,
|
|
170
|
+
`warn_dropped_items`, `log_warning`) were kept short enough to avoid adding
|
|
171
|
+
further offenses. None of this blocks CI.
|
|
172
|
+
|
|
173
|
+
## Files changed
|
|
174
|
+
|
|
175
|
+
- `lib/end_point_blank/commands/http.rb` — shared timeout constants/helper,
|
|
176
|
+
applied to `Http.post`.
|
|
177
|
+
- `lib/end_point_blank/commands/generate_access_token.rb` — applies shared
|
|
178
|
+
timeout options to its `Excon.post`.
|
|
179
|
+
- `lib/end_point_blank/commands/endpoint_update.rb` — applies shared timeout
|
|
180
|
+
options to its `Excon.post`; fixed the broken `Excon::Error::Connection`
|
|
181
|
+
rescue clause to `rescue Excon::Error` (now also catches `Timeout`).
|
|
182
|
+
- `lib/end_point_blank/writers/delayed_writer.rb` — bounded, drop-oldest,
|
|
183
|
+
throttled-warning queue.
|
|
184
|
+
- `lib/end_point_blank/authorization.rb` — `require 'base64'` (incidental
|
|
185
|
+
fix, needed for the Basic-auth path to work at all on Ruby 3.4, and
|
|
186
|
+
required to exercise the `endpoint_update` timeout test end-to-end).
|
|
187
|
+
- `end_point_blank.gemspec`, `Gemfile.lock` — declare `base64` as an
|
|
188
|
+
explicit dependency (Ruby 3.4 removed it from default gems).
|
|
189
|
+
- New specs: `spec/commands_http_spec.rb`, `spec/generate_access_token_spec.rb`,
|
|
190
|
+
`spec/endpoint_update_spec.rb`, `spec/delayed_writer_spec.rb`.
|
|
191
|
+
|
|
192
|
+
## Concerns / follow-ups (not fixed here, out of scope for P2 #14)
|
|
193
|
+
|
|
194
|
+
- The queue bound is a soft/approximate guarantee under heavy concurrent
|
|
195
|
+
enqueue: the mutex only guards the *decision* to drop before push, so with
|
|
196
|
+
many producer threads racing, the queue could very briefly be examined by
|
|
197
|
+
more than one thread between the `pop`/`push` pair in rare interleavings
|
|
198
|
+
is prevented by the mutex — but a concurrent **drain** thread popping at
|
|
199
|
+
the same moment only ever shrinks the queue, never grows it past the cap.
|
|
200
|
+
In practice the bound holds exactly given the mutex around all enqueue-side
|
|
201
|
+
mutation.
|
|
202
|
+
- `note_drop`/`@last_drop_warning_at` state lives on the writer singleton
|
|
203
|
+
instance (`RequestWriter`, `ResponseWriter`, `ExceptionWriter`,
|
|
204
|
+
`LogWriter` each get their own independent throttle window since they each
|
|
205
|
+
`include DelayedWriter` separately) — this is intentional (matches
|
|
206
|
+
per-queue behavior) but worth knowing if someone expects a single global
|
|
207
|
+
throttle across all four queues.
|
|
208
|
+
- Rubocop was not run as part of CI before this change and still isn't;
|
|
209
|
+
pre-existing style debt (43 offenses) was left alone except where it was
|
|
210
|
+
directly in code I touched and cheap to avoid growing.
|
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
|