end_point_blank 0.6.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +653 -0
- data/README.md +424 -19
- data/end_point_blank.gemspec +5 -3
- data/lib/end_point_blank/access_tokens.rb +244 -25
- data/lib/end_point_blank/authorization.rb +105 -20
- data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
- data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
- data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
- data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
- data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
- data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
- data/lib/end_point_blank/commands/http.rb +20 -1
- data/lib/end_point_blank/configuration.rb +111 -4
- data/lib/end_point_blank/configuration_error.rb +18 -0
- data/lib/end_point_blank/management/client.rb +228 -0
- data/lib/end_point_blank/management/configuration.rb +56 -0
- data/lib/end_point_blank/management/error.rb +134 -0
- data/lib/end_point_blank/management/error_codes.rb +115 -0
- data/lib/end_point_blank/management/idempotency_key.rb +33 -0
- data/lib/end_point_blank/management/page.rb +55 -0
- data/lib/end_point_blank/management/resources/api_packages.rb +104 -0
- data/lib/end_point_blank/management/resources/applications.rb +167 -0
- data/lib/end_point_blank/management/resources/base.rb +117 -0
- data/lib/end_point_blank/management/resources/clients.rb +152 -0
- data/lib/end_point_blank/management/retry_policy.rb +65 -0
- data/lib/end_point_blank/management/transport.rb +167 -0
- data/lib/end_point_blank/management/url_path.rb +18 -0
- data/lib/end_point_blank/management.rb +52 -0
- data/lib/end_point_blank/rails/authenticated.rb +62 -7
- data/lib/end_point_blank/rails/authorized.rb +9 -13
- data/lib/end_point_blank/target_url.rb +57 -0
- data/lib/end_point_blank/token_unavailable_error.rb +102 -0
- data/lib/end_point_blank/unauthorized_error.rb +81 -1
- data/lib/end_point_blank/version.rb +1 -1
- data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
- data/lib/end_point_blank/writers/direct_writer.rb +1 -1
- data/lib/end_point_blank/writers/exception_writer.rb +11 -2
- data/lib/end_point_blank/writers/log_writer.rb +1 -1
- data/lib/end_point_blank/writers/request_writer.rb +1 -0
- data/lib/end_point_blank/writers/response_writer.rb +1 -0
- data/lib/end_point_blank/writers/shared.rb +35 -4
- data/lib/end_point_blank.rb +240 -2
- metadata +29 -10
- data/lib/end_point_blank/loggers/logger.rb +0 -30
data/README.md
CHANGED
|
@@ -8,14 +8,17 @@ auto-loads (railtie + middleware) when Rails is present.
|
|
|
8
8
|
## Capabilities
|
|
9
9
|
|
|
10
10
|
- **Endpoint tracking** — every request/response passing through the Rack middleware is reported.
|
|
11
|
-
- **Authorization** — outbound calls to other EndPointBlank-protected services
|
|
12
|
-
|
|
11
|
+
- **Authorization** — outbound calls to other EndPointBlank-protected services carry a
|
|
12
|
+
`Bearer` access token (never this service's own client credentials), and inbound requests can be authorized
|
|
13
13
|
against the EndPointBlank service before your action runs.
|
|
14
14
|
- **Error, request, response, and log reporting** — background, queued, non-blocking delivery to
|
|
15
15
|
the EndPointBlank intake API.
|
|
16
16
|
- **Client-side data masking** (`EndPointBlank::Masking` / `masking_rules`) — strip or redact
|
|
17
17
|
sensitive fields from payloads *before* they leave your process, as defense in depth on top of
|
|
18
18
|
server-side masking.
|
|
19
|
+
- **Management API client** (`EndPointBlank::Management::Client`) — manage API packages, clients,
|
|
20
|
+
grants, applications, environments, credentials and managed clients from code. See
|
|
21
|
+
[Management API](#management-api).
|
|
19
22
|
- **Framework-agnostic core** — `EndPointBlank::Middleware::Rack::ReportInteraction` and the
|
|
20
23
|
writers work directly against Rack env/`::Rack::Request`, so the gem behaves correctly under
|
|
21
24
|
plain Ruby, Sinatra, or any Rack app. When `::Rails` is defined, a `Railtie` auto-inserts the
|
|
@@ -61,29 +64,51 @@ EndPointBlank::Writers::LogWriter.info("service started", { pid: Process.pid })
|
|
|
61
64
|
|
|
62
65
|
## Configuration
|
|
63
66
|
|
|
64
|
-
`EndPointBlank.configure { |c| ... }` yields the `EndPointBlank::Configuration`
|
|
67
|
+
`EndPointBlank.configure { |c| ... }` yields the `EndPointBlank::Configuration` settings and
|
|
68
|
+
applies every assignment made inside the block together, only once the block returns without
|
|
69
|
+
raising -- a block that raises leaves the configuration exactly as it was before the call.
|
|
65
70
|
Every setting listed below can be set explicitly in that block, and most also fall back to an
|
|
66
71
|
`ENDPOINTBLANK_*` environment variable, then to a built-in default.
|
|
67
72
|
|
|
73
|
+
`c` is only valid for the duration of the block: once `configure` returns, whether the block
|
|
74
|
+
returned normally or raised, `c` is frozen, and every String, Array or Hash value it holds is
|
|
75
|
+
first replaced with its own frozen deep copy. A write made through a reference to `c` kept past
|
|
76
|
+
the block always raises `FrozenError` -- a reassignment (`saved.app_name = "x"`) because `c`
|
|
77
|
+
itself is frozen, and an in-place edit (`saved.masking_rules << rule`, `saved.app_name << "x"`,
|
|
78
|
+
editing a rule Hash in place) because the value it points to is frozen too, not just `c`. A read
|
|
79
|
+
that bypasses `c` -- `EndPointBlank::Configuration.instance.app_name`, or `EndPointBlank.logger`
|
|
80
|
+
right after `c.logger = ...` earlier in the same block -- still sees the value from before the
|
|
81
|
+
`configure` call started, not what the block has set on `c` so far, until the block returns and
|
|
82
|
+
the change is applied.
|
|
83
|
+
|
|
84
|
+
Assigning a String, Array or Hash through `c` copies it rather than storing the object itself:
|
|
85
|
+
after `c.masking_rules = rules`, mutating the `rules` array you passed in no longer affects the
|
|
86
|
+
live configuration -- call `configure` again to apply a further change. `logger`, `mask_hook`
|
|
87
|
+
and `version_finder` are not String/Array/Hash, so they are held by reference like any other
|
|
88
|
+
object the caller hands in: mutating one through a retained `c` (`saved.logger.level = ...`)
|
|
89
|
+
still reaches the live value, the same as mutating it through `EndPointBlank.logger` or
|
|
90
|
+
`Configuration.instance.logger` directly would.
|
|
91
|
+
|
|
68
92
|
**Precedence: explicit `configure` value > `ENDPOINTBLANK_*` environment variable > default.**
|
|
69
93
|
|
|
70
94
|
| `configure` setting | Env var fallback | Default | Notes |
|
|
71
95
|
|---|---|---|---|
|
|
72
|
-
| `client_id` | `ENDPOINTBLANK_CLIENT_ID` | `nil` |
|
|
96
|
+
| `client_id` | `ENDPOINTBLANK_CLIENT_ID` | `nil` | Authenticates this service to its own intake (`Basic`). Never sent to a provider. |
|
|
73
97
|
| `client_secret` | `ENDPOINTBLANK_CLIENT_SECRET` | `nil` | Paired with `client_id`. |
|
|
74
98
|
| `base_url` | `ENDPOINTBLANK_BASE_URL` | `https://in.endpointblank.com` | Base for access-token, authorize, and endpoint-update APIs. |
|
|
75
99
|
| `log_base_url` | `ENDPOINTBLANK_LOG_BASE_URL` | `https://log.endpointblank.com` | Base for error/request/response/log reporting APIs. |
|
|
76
100
|
| `app_name` | `ENDPOINTBLANK_APP_NAME` | `Rails.application.name.underscore` if Rails is defined, else `nil` | Identifies your app to EndPointBlank. |
|
|
77
101
|
| `env_name` | `ENDPOINTBLANK_ENV` | `RACK_ENV`, then `APP_ENV`, then `Rails.env` if defined, else `"production"` (resolved per-request by `SessionConfiguration.env_name`, not read directly off `Configuration`) | The environment name reported with each request/response payload. |
|
|
78
102
|
| `logger` | — | A `::Logger.new($stdout, level: ::Logger::INFO)`, or `Rails.logger` under Rails (set by the railtie) | Any object with `.debug`/`.info`/`.warn`/`.error`/`.fatal` works. |
|
|
79
|
-
| `worker_count` | — | `4` |
|
|
103
|
+
| `worker_count` | — | `4` | Number of background threads draining the delayed writer's queue. Falls back to 2 when set to `nil`. |
|
|
80
104
|
| `token_ttl` | — | `nil` | Optional TTL (seconds) requested when generating a `Bearer` access token. |
|
|
81
|
-
| `cache_ttl` | — | `300` | TTL
|
|
105
|
+
| `cache_ttl` | — | `300` | TTL, in whole seconds, for the authorization decision cache. **Omit it to get the 300-second default.** `0` disables the cache; any positive `Integer` is that many seconds. Anything else — an explicit `nil`, a negative number, or a non-`Integer` such as `"300"` or `3.5` — raises `ArgumentError` from the `c.cache_ttl = ...` assignment itself, at configure time, and leaves the previous value in place, so a bad value stops your app at boot instead of surfacing on the first authorized request. The cache is a plain in-memory Hash scoped to this process (no `Rails.cache`, Redis, or other shared store), so a Puma or Unicorn worker and every separate app instance each hold their own cache and their own view of `cache_ttl`. Changing `cache_ttl` at runtime takes effect immediately for already-cached entries, not just new ones: an entry is valid only while it is within *both* its original write-time expiry and the *currently configured* `cache_ttl` measured from when it was written, so raising `cache_ttl` never extends an entry already in the cache, and lowering it shortens one on its next read. `cache_ttl = 0` disables the cache — the moment a read or a store in a given process observes it disabled, *that process's* entire cache is cleared, not just the entry being looked up or written, and a store made while disabled inserts nothing there. This is per process, not fleet-wide: each worker or instance clears only its own cache, and only once it has itself observed `cache_ttl` disabled and then handled an `Authorized` request (or a direct cache call) while disabled — a worker that is idle, or hasn't yet picked up the new config, keeps serving whatever it already cached until it does. (A disable followed by a re-enable with no cache read or store in between flushes nothing, since nothing observed the disabled state.) |
|
|
82
106
|
| `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). |
|
|
83
107
|
| `masking_rules` | — | `[]` | Ordered list of masking rule hashes — see [Data masking](#data-masking). |
|
|
84
108
|
| `mask_hook` | — | `nil` | Optional `->(payload, record_type_string) { payload }` run after `masking_rules`. |
|
|
85
109
|
| `version_finder` | — | `nil` | Optional `->(request) { "1" }` overriding `EndPointBlank::Commands::VersionFinder`'s default header/param/path detection. |
|
|
86
110
|
| `application_version` | — | `nil` | Reserved for reporting your app's own version. |
|
|
111
|
+
| `derive_base_url_from_client_id` | — | `false` | Derive the intake hostname from a slug-prefixed `client_id` when no `base_url` is set. See [Intake hostname from `client_id`](#intake-hostname-from-client_id). Only `true` or `false`; anything else raises `ArgumentError` at configure time. |
|
|
87
112
|
|
|
88
113
|
Note: there is also a bare `environment` accessor on `Configuration`, but it is not read by any
|
|
89
114
|
code path in this gem (the real per-request environment name is `env_name`, described above) — do
|
|
@@ -118,6 +143,42 @@ hostname on an internal port. `host` is caller-controlled either way (it has alw
|
|
|
118
143
|
the `Host` header), and none of these three values is ever used as an identity or
|
|
119
144
|
authorization key, so the worst case is a wrong *suggestion* that an admin has to approve.
|
|
120
145
|
|
|
146
|
+
### Intake hostname from `client_id`
|
|
147
|
+
|
|
148
|
+
Each organization's intake will answer at its own hostname,
|
|
149
|
+
`https://<slug>.in.endpointblank.com`, and every new `client_id` starts with
|
|
150
|
+
that slug and a dot (`acima-x7k2mq.ijXI+MVwmrC5xH/9ZuGiQlAbAyobTqMa`). With
|
|
151
|
+
`c.derive_base_url_from_client_id = true`, the gem picks its intake in this
|
|
152
|
+
order:
|
|
153
|
+
|
|
154
|
+
1. `base_url`, or else `ENDPOINTBLANK_BASE_URL`, if either is set;
|
|
155
|
+
2. else, if the `client_id` carries a slug prefix,
|
|
156
|
+
`https://<slug>.in.endpointblank.com`;
|
|
157
|
+
3. else `https://in.endpointblank.com`.
|
|
158
|
+
|
|
159
|
+
A `client_id` carries a slug prefix only when the part before its first `.`
|
|
160
|
+
has the exact shape of an organization slug and something follows the dot
|
|
161
|
+
(`EndPointBlank::Configuration.client_id_slug`). A credential issued before
|
|
162
|
+
slugs, including one with a `.` in it such as `my.client`, keeps calling
|
|
163
|
+
`https://in.endpointblank.com`.
|
|
164
|
+
|
|
165
|
+
**This is off by default, and turns on by default in a later release, once
|
|
166
|
+
DNS and TLS for `*.in.endpointblank.com` are live.** Until then those
|
|
167
|
+
hostnames do not resolve in production, so leave it off unless EndPointBlank
|
|
168
|
+
has told you otherwise. With it off, the base URL is `base_url`, else
|
|
169
|
+
`ENDPOINTBLANK_BASE_URL`, else `https://in.endpointblank.com`, whatever the
|
|
170
|
+
`client_id`.
|
|
171
|
+
|
|
172
|
+
The logs hostname is not derived: `log_base_url`, else
|
|
173
|
+
`ENDPOINTBLANK_LOG_BASE_URL`, else `https://log.endpointblank.com`, as before.
|
|
174
|
+
|
|
175
|
+
Every call to intake also sends `x-epb-sdk: ruby/<version>`, so
|
|
176
|
+
EndPointBlank can tell which SDK versions use a credential before it moves an
|
|
177
|
+
organization to another intake. The minimum Ruby version for a move is the
|
|
178
|
+
release that turns `derive_base_url_from_client_id` on by default, **not**
|
|
179
|
+
this one: with the option at its default here, the gem keeps calling
|
|
180
|
+
`https://in.endpointblank.com` after its organization has moved.
|
|
181
|
+
|
|
121
182
|
### `configure` block example
|
|
122
183
|
|
|
123
184
|
```ruby
|
|
@@ -150,27 +211,146 @@ export ENDPOINTBLANK_ENV=staging
|
|
|
150
211
|
|
|
151
212
|
### Authorization
|
|
152
213
|
|
|
153
|
-
`EndPointBlank::Authorization.header(base_url
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
`client_id` / `client_secret`
|
|
157
|
-
be obtained.
|
|
214
|
+
`EndPointBlank::Authorization.header(base_url)` builds the `Authorization` header for an outbound
|
|
215
|
+
call to a provider. It is always a `Bearer` token covering `base_url` (via
|
|
216
|
+
`EndPointBlank::AccessTokens`, minting one if none is cached). It **never** falls back to `Basic`:
|
|
217
|
+
a client must never send its own `client_id` / `client_secret` to a provider or to the provider's
|
|
218
|
+
intake. When no token can be obtained it raises `EndPointBlank::TokenUnavailableError` instead.
|
|
158
219
|
|
|
159
220
|
```ruby
|
|
160
|
-
|
|
221
|
+
# Pass the URL you are about to call, NOT a hostname. Its userinfo, query and
|
|
222
|
+
# fragment are removed before the token request; they are never sent to
|
|
223
|
+
# intake, logged, or kept on the error.
|
|
224
|
+
url = "https://api.example.com/orders"
|
|
161
225
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
226
|
+
begin
|
|
227
|
+
auth = EndPointBlank::Authorization.header(url) # => "Bearer ..."
|
|
228
|
+
Excon.post(url, headers: { "Authorization" => auth }, body: payload)
|
|
229
|
+
rescue EndPointBlank::TokenUnavailableError => e
|
|
230
|
+
# No token, so the provider was never called. e.outcome / e.status /
|
|
231
|
+
# e.failure say why (see "Why a token could not be minted" below): retry,
|
|
232
|
+
# degrade, or fail your own request -- but do not send credentials instead.
|
|
233
|
+
Rails.logger.warn(e.message)
|
|
234
|
+
raise
|
|
235
|
+
end
|
|
165
236
|
```
|
|
166
237
|
|
|
238
|
+
`base_url` is required. Until this release `header` with no argument returned `Basic` credentials; that
|
|
239
|
+
form is gone, and `header(nil)` or `header("")` raises `ArgumentError`, as does a URL that cannot be
|
|
240
|
+
parsed into an http or https URL with a host (nothing is sent, and the message does not repeat the URL). The SDK's own calls to its
|
|
241
|
+
own intake (authorize, token minting, endpoint updates, the log/request/response writers) still
|
|
242
|
+
authenticate with `Basic`, which is safe because intake already holds this service's credential;
|
|
243
|
+
they use the internal `EndPointBlank::Authorization.intake_header`, which is not for outbound
|
|
244
|
+
calls.
|
|
245
|
+
|
|
246
|
+
`TokenUnavailableError` (a subclass of `EndPointBlank::Error`) carries `base_url` (the URL with its
|
|
247
|
+
userinfo, query and fragment removed; the raw value is never kept), `failure` (the
|
|
248
|
+
`EndPointBlank::AccessTokens::Failure` recorded for the mint, or `nil`), and the shortcuts
|
|
249
|
+
`outcome` and `status`. Its message names the stripped URL and a fixed reason for the outcome
|
|
250
|
+
-- never intake's response body, which stays on `failure.reason` -- for example:
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
Could not mint an EndPointBlank access token for https://api.example.com/orders:
|
|
254
|
+
intake rejected this application's client credential (HTTP 401); retrying cannot help --
|
|
255
|
+
re-issue the credential. EndPointBlank never sends this service's client_id/client_secret to a
|
|
256
|
+
provider, so there is no Basic-auth fallback and the call must not be made without a token.
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
| `outcome` | Reason in the message |
|
|
260
|
+
|---|---|
|
|
261
|
+
| `:credential_rejected` | `intake rejected this application's client credential (HTTP 401); retrying cannot help -- re-issue the credential` |
|
|
262
|
+
| `:request_rejected` | `intake refused the token request (HTTP <status>); check the URL and that a grant covers the target` |
|
|
263
|
+
| `:server_error` | `intake failed to issue a token (HTTP <status>); this may be transient` |
|
|
264
|
+
| `:transport_error` | `intake could not be reached (timeout, connection refused or retries exhausted); this may be transient` |
|
|
265
|
+
| `:transport_error`, the mint raised | `the token request failed unexpectedly` |
|
|
266
|
+
| none recorded | `the token request failed for an unknown reason` |
|
|
267
|
+
|
|
268
|
+
` (HTTP <status>)` is left out when there is no status.
|
|
269
|
+
|
|
270
|
+
A mint that raises rather than reporting a failure -- a bug, not intake being unreachable -- is
|
|
271
|
+
reported as this error too, with outcome `:transport_error`, `unexpected?` true and the exception
|
|
272
|
+
as `cause`; its message is not copied into the error's. Only a missing credential's
|
|
273
|
+
`ConfigurationError` escapes `header` as itself.
|
|
274
|
+
|
|
275
|
+
The SDK's own calls to its intake raise `EndPointBlank::ConfigurationError` (also a subclass of
|
|
276
|
+
`EndPointBlank::Error`) when `client_id` or `client_secret` is missing or empty, rather than
|
|
277
|
+
sending an empty `Basic` credential.
|
|
278
|
+
|
|
167
279
|
The argument is the URL you are about to call. intake matches it against registered base URLs by
|
|
168
280
|
longest path prefix, so you need not know how the target registered itself -- `header` for
|
|
169
281
|
`https://api.example.com/orders/42` reuses a token already cached for
|
|
170
282
|
`https://api.example.com/orders`. `EndPointBlank::AccessTokens` caches one token per base URL
|
|
171
283
|
intake resolves to, not one per process, so a service that calls several targets holds a token
|
|
172
|
-
for each.
|
|
173
|
-
|
|
284
|
+
for each. The lookup uses the URL with its userinfo, query and fragment removed and its scheme and
|
|
285
|
+
host lowercased, as intake does; beyond that, a URL that does not match character-for-character
|
|
286
|
+
(a different path case, an unregistered path) simply misses and mints a new token -- it never guesses.
|
|
287
|
+
|
|
288
|
+
### Why a token could not be minted
|
|
289
|
+
|
|
290
|
+
`EndPointBlank::AccessTokens.token` answers with a token String or `nil` (or raises, as itself,
|
|
291
|
+
anything the mint raised that is not a transport error -- only `Authorization.header` wraps that),
|
|
292
|
+
which is all most callers need. When `nil` is not enough — when you want to know whether retrying could possibly
|
|
293
|
+
help — call `token_result` instead, which answers with the token or the `Failure` for that call:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
url = "https://api.example.com/orders"
|
|
297
|
+
result = EndPointBlank::AccessTokens.token_result(url)
|
|
298
|
+
|
|
299
|
+
if result.is_a?(EndPointBlank::AccessTokens::Failure)
|
|
300
|
+
failure = result
|
|
301
|
+
|
|
302
|
+
case failure.outcome
|
|
303
|
+
when :credential_rejected
|
|
304
|
+
# intake answered 401. Permanent until the credential itself changes:
|
|
305
|
+
# re-issue it in the portal and update client_id / client_secret.
|
|
306
|
+
raise "EndPointBlank credential rejected (#{failure.reason})"
|
|
307
|
+
when :request_rejected
|
|
308
|
+
# A 400 or 422: intake could not resolve the target or source
|
|
309
|
+
# application, or the request itself was malformed. Retrying will not fix
|
|
310
|
+
# it, but the credential is fine.
|
|
311
|
+
when :server_error, :transport_error
|
|
312
|
+
# A 5xx, an unusable response, a timeout, a refused connection. Try again.
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`EndPointBlank::AccessTokens.last_failure(url)` still answers the last failure recorded for a
|
|
318
|
+
URL, and returns `nil` once a mint for that URL succeeds again, so it never reports a problem
|
|
319
|
+
that has already cleared. It is a shared slot, though: read after `token` returned `nil`, another
|
|
320
|
+
thread may already have cleared or replaced it. When you need the reason for your own call, use
|
|
321
|
+
`token_result`, whose `Failure` is captured inside the cache's lock for that call. A `Failure` carries `base_url`, `outcome`, `status` (the HTTP
|
|
322
|
+
status, or `nil` when no usable one was obtained), `reason`, and `at`, and answers
|
|
323
|
+
`#credential_rejected?`, `#request_rejected?`, `#server_error?` and `#transport_error?`.
|
|
324
|
+
|
|
325
|
+
`token`, `token_result`, `exists?` and `last_failure` all remove the URL's userinfo, query and
|
|
326
|
+
fragment first, so the cache, the failure record (and its `base_url`) and the log lines only ever
|
|
327
|
+
hold the stripped URL. A URL that cannot be parsed into an http or https URL with a host is never sent:
|
|
328
|
+
`token_result` answers a `:request_rejected` `Failure` with no `status` and no `base_url`, and
|
|
329
|
+
nothing is recorded.
|
|
330
|
+
|
|
331
|
+
There is deliberately no `#retriable?` or other single retry/no-retry boolean. Retrying a `400`
|
|
332
|
+
or a `422` is exactly as futile as retrying a `401` — intake answers `400` for an invalid
|
|
333
|
+
`token_ttl` or a missing `base_url`, and `422` when the target or source application cannot be
|
|
334
|
+
resolved — so a boolean would have to answer for cases whose only honest answer is "it depends
|
|
335
|
+
what you are going to do about it". Branch on the outcome instead.
|
|
336
|
+
|
|
337
|
+
Classification is on the HTTP status first and the body second. A `401` whose body will not parse
|
|
338
|
+
is still `:credential_rejected`: the SDK reaches intake through a proxy, and a WAF or load
|
|
339
|
+
balancer can answer 401 with an HTML page intake never generated. `:transport_error` means one
|
|
340
|
+
thing only — no usable HTTP status was obtained because the request never completed (an Excon,
|
|
341
|
+
socket, SSL or timeout error). Anything else raised while minting is not a transport error and
|
|
342
|
+
propagates.
|
|
343
|
+
|
|
344
|
+
The body decides exactly one thing, and only on a 2xx: whether a token was actually minted. A
|
|
345
|
+
success means a token is there to read — the body parsed and carries a non-empty `token` and the
|
|
346
|
+
non-empty `base_url` to cache it under. A 2xx that will not parse, or carries no token, or a
|
|
347
|
+
token with no `base_url`, is a `:server_error` keeping its real 2xx status, because intake's
|
|
348
|
+
`base_url` is `NOT NULL` and it answers a 4xx rather than minting when the URL resolves to
|
|
349
|
+
nothing — so a 2xx missing one is a broken server, not a refused request.
|
|
350
|
+
|
|
351
|
+
The same verdict is available one level down, without the cache, from
|
|
352
|
+
`EndPointBlank::Commands::GenerateAccessToken.token_result(base_url)`, which returns an
|
|
353
|
+
`AccessTokenResult` with `outcome`, `status`, `payload` and the same predicates.
|
|
174
354
|
|
|
175
355
|
Under Rails, protect an inbound endpoint by including the `Authorized` concern in a controller —
|
|
176
356
|
it calls `EndPointBlank::Commands::EndpointAuthorize.authorize(request)` before the action, and
|
|
@@ -197,6 +377,32 @@ preserve it) and you registered the external hostname in the portal, either upda
|
|
|
197
377
|
registered hostname to the internal one the app now reports, or configure the proxy to preserve
|
|
198
378
|
`Host`. Deployments where `Host` and `X-Forwarded-Host` agree are unaffected.
|
|
199
379
|
|
|
380
|
+
The `Authenticated` concern is the lighter sibling: it calls
|
|
381
|
+
`EndPointBlank::Commands::BasicAuthenticate.authenticate(request)` before the action and refuses
|
|
382
|
+
the same way, but records nothing on the Rack env, sets no deprecation headers and — matching
|
|
383
|
+
every other SDK's authenticate path — does not cache intake's answer.
|
|
384
|
+
|
|
385
|
+
```ruby
|
|
386
|
+
class OrdersController < ApplicationController
|
|
387
|
+
include EndPointBlank::Rails::Authenticated
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`EndPointBlank::UnauthorizedError#status` is intake's own verdict, from either concern, so
|
|
392
|
+
`rescue_from` can render it directly. 401 and 403 are different remedies and must not be
|
|
393
|
+
collapsed:
|
|
394
|
+
|
|
395
|
+
| intake answered | `error.status` |
|
|
396
|
+
| --- | --- |
|
|
397
|
+
| 401 | `401` — re-check or re-issue the credential |
|
|
398
|
+
| 403 | `403` — ask for a grant covering this endpoint |
|
|
399
|
+
| any other non-201 | that status, verbatim |
|
|
400
|
+
| nothing at all | `503` — the check could not be made, so nothing judged this caller |
|
|
401
|
+
|
|
402
|
+
`UnauthorizedError.new(message)` still defaults to 401; the status is an optional second
|
|
403
|
+
argument. Raise the instance (`raise UnauthorizedError.new(msg, status)`) rather than
|
|
404
|
+
`raise UnauthorizedError, msg` — the two-argument form cannot carry a status.
|
|
405
|
+
|
|
200
406
|
### Error reporting
|
|
201
407
|
|
|
202
408
|
Exceptions raised while `EndPointBlank::Middleware::Rack::ReportInteraction` is on the stack are
|
|
@@ -226,8 +432,21 @@ EndPointBlank::Writers::LogWriter.fatal("out of workers")
|
|
|
226
432
|
|
|
227
433
|
All writers (`RequestWriter`, `ResponseWriter`, `ExceptionWriter`, `LogWriter`) enqueue their
|
|
228
434
|
payload onto a bounded, in-memory queue (`DelayedWriter`, capacity 1000, drop-oldest under
|
|
229
|
-
sustained backpressure) drained by
|
|
230
|
-
|
|
435
|
+
sustained backpressure) drained by `worker_count` background threads that POST batches via `excon`,
|
|
436
|
+
six payloads per request. Batches are cut by position, so two identical payloads are two payloads.
|
|
437
|
+
Delivery is fire-and-forget and never raises into your request cycle.
|
|
438
|
+
|
|
439
|
+
A worker thread does not die. A batch can be lost — the intake may be unreachable, or the send path
|
|
440
|
+
may raise something nobody anticipated — but the loop catches every `StandardError`, logs it at
|
|
441
|
+
`error` level through `EndPointBlank.logger` with a running count of consecutive failures, backs off
|
|
442
|
+
(0.1s, doubling, capped at 30s), and keeps draining; the count resets on the first clean pass. Only
|
|
443
|
+
an error outside `StandardError` — `SystemExit`, `Interrupt`, `SignalException`, `NoMemoryError`,
|
|
444
|
+
i.e. the process itself going down — ends a worker.
|
|
445
|
+
|
|
446
|
+
A writer may optionally define `on_success(response)` and `on_failure(response)` to hear about each
|
|
447
|
+
batch. `on_failure` receives `nil` when the intake never answered at all: `Commands::Http` returns
|
|
448
|
+
`nil` once its three attempts are exhausted, which is the absence of a status rather than a failing
|
|
449
|
+
one. A writer that defines neither is unaffected.
|
|
231
450
|
|
|
232
451
|
### Data masking
|
|
233
452
|
|
|
@@ -290,6 +509,7 @@ end
|
|
|
290
509
|
|
|
291
510
|
class OrdersController < ApplicationController
|
|
292
511
|
include EndPointBlank::Rails::Authorized # authorize inbound requests before each action
|
|
512
|
+
# or: include EndPointBlank::Rails::Authenticated # authenticate only, no caching
|
|
293
513
|
include EndPointBlank::Rails::Versioned
|
|
294
514
|
|
|
295
515
|
version ["v1", "v2"], only: [:index]
|
|
@@ -331,6 +551,191 @@ and clears the env store, reporting any raised exception via `ExceptionWriter` a
|
|
|
331
551
|
reads/writes plain Rack request objects (`::Rack::Request`), so it works identically under any
|
|
332
552
|
Rack-compatible server or framework, not only Sinatra.
|
|
333
553
|
|
|
554
|
+
## Management API
|
|
555
|
+
|
|
556
|
+
`EndPointBlank::Management::Client` manages your organization's EndPointBlank setup from code:
|
|
557
|
+
API packages, clients and their invites, package assignments, direct grants, applications,
|
|
558
|
+
environments, runtime credentials, and the managed clients you run for your customers. It calls
|
|
559
|
+
app_portal's management API (`https://app.endpointblank.com/api/v1`). See the
|
|
560
|
+
[guide](https://app.endpointblank.com/docs/management-api) and the
|
|
561
|
+
[reference](https://app.endpointblank.com/docs/management-api-reference).
|
|
562
|
+
|
|
563
|
+
It is plain Ruby, usable from a script, a job or a console as well as a Rails app, and it is
|
|
564
|
+
**separate from the runtime configuration above**. It authenticates only with a management API
|
|
565
|
+
key (create one in the portal under Settings > API Keys), sent as
|
|
566
|
+
`Authorization: Bearer epb_mk_...`. It never sends your runtime `client_id`/`client_secret`, never
|
|
567
|
+
calls intake, and never shows the key in `inspect`, `to_s` or an error message. A key without the
|
|
568
|
+
`epb_mk_` prefix is refused when the client is built, with `EndPointBlank::ConfigurationError`.
|
|
569
|
+
|
|
570
|
+
### Quick start
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
require "end_point_blank"
|
|
574
|
+
|
|
575
|
+
mgmt = EndPointBlank::Management::Client.new(api_key: ENV.fetch("EPB_MGMT_KEY"))
|
|
576
|
+
|
|
577
|
+
mgmt.organization # => {"id" => "...", "name" => "Acme", "slug" => "acme", "key" => {"name" => "ci", "scope" => "write"}, ...}
|
|
578
|
+
|
|
579
|
+
# One page at a time (limit 1..100, default 50) ...
|
|
580
|
+
page = mgmt.applications.list(limit: 20)
|
|
581
|
+
page.data # => [{"id" => "...", "name" => "Orders", ...}, ...]
|
|
582
|
+
page.next_cursor # => pass as `after:` for the next page; nil on the last one
|
|
583
|
+
|
|
584
|
+
# ... or every item, fetching pages as it goes (an Enumerator without a block).
|
|
585
|
+
mgmt.applications.each { |application| puts application["name"] }
|
|
586
|
+
names = mgmt.api_packages.each(limit: 100).map { |package| package["name"] }
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Every call answers what the API sent, decoded from JSON into Hashes with String keys: the
|
|
590
|
+
resource itself (the response's `data`), a `Page` for a list, and `{"id" => ..., "deleted" => true}`
|
|
591
|
+
for a delete. `api_packages.add_endpoint` and `remove_endpoint` answer the whole body,
|
|
592
|
+
`{"data" => ..., "warnings" => [...]}`, so the `assignment_derives_nothing` warnings are not lost.
|
|
593
|
+
Optional keyword arguments left `nil` are not sent.
|
|
594
|
+
|
|
595
|
+
### Invite a client and assign an API package
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
# Environment names are unique per organization, and "production" is reserved for the one every
|
|
599
|
+
# organization already has; look an existing one up with mgmt.environments.each instead.
|
|
600
|
+
staging = mgmt.environments.create(name: "staging", domain: "staging.example.com")
|
|
601
|
+
orders = mgmt.applications.create(name: "Orders",
|
|
602
|
+
environment_base_urls: { staging["id"] => "https://orders.staging.example.com" })
|
|
603
|
+
|
|
604
|
+
package = mgmt.api_packages.create(name: "Orders read")
|
|
605
|
+
|
|
606
|
+
# An application's endpoints are listed once its runtime SDK has reported them, so a just-created
|
|
607
|
+
# application has none yet. Publish one endpoint when it is there, else the whole application
|
|
608
|
+
# (endpoint_id nil covers every endpoint, including ones reported later).
|
|
609
|
+
endpoint = mgmt.endpoints.each(application_id: orders["id"]).find { |e| e["path"] == "/orders" && e["action"] == "GET" }
|
|
610
|
+
mgmt.api_packages.add_endpoint(package["id"], application_id: orders["id"], endpoint_id: endpoint&.fetch("id"),
|
|
611
|
+
environment_id: staging["id"])
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Then give a client the package in one of two ways; doing both for the same package and environment
|
|
615
|
+
is refused with `already_assigned`.
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
# Either: set it up on the invite, and it is assigned the moment the client accepts.
|
|
619
|
+
globex = mgmt.clients.invite(
|
|
620
|
+
name: "Globex",
|
|
621
|
+
contacts: [{ email: "dev@globex.example", first_name: "Hank", last_name: "Scorpio" }],
|
|
622
|
+
packages: [{ api_package_id: package["id"], environment_id: staging["id"] }]
|
|
623
|
+
)
|
|
624
|
+
globex["invite_code"] # send this to the client; it accepts from its own EndPointBlank organization
|
|
625
|
+
|
|
626
|
+
# Or: invite first, then assign (pending until the client accepts, active after) and grant directly.
|
|
627
|
+
initrode = mgmt.clients.invite(name: "Initrode")
|
|
628
|
+
mgmt.package_assignments.assign(initrode["id"], api_package_id: package["id"], environment_id: staging["id"])
|
|
629
|
+
mgmt.grants.create(initrode["id"], target_application_id: orders["id"], environment_id: staging["id"])
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
### Runtime credentials
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
app_env = mgmt.applications.list_environments(orders["id"]).first
|
|
636
|
+
credential = mgmt.credentials.create(application_environment_id: app_env["id"])
|
|
637
|
+
credential["client_id"]
|
|
638
|
+
credential["client_secret"] # shown once, here and nowhere else: store it now
|
|
639
|
+
|
|
640
|
+
rotated = mgmt.credentials.rotate(credential["id"])
|
|
641
|
+
rotated["client_secret"] # the new secret; the old one keeps working for the grace window
|
|
642
|
+
|
|
643
|
+
mgmt.credentials.revoke(credential["id"])
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
`list` and `get` answer metadata only (`secret_last_4`, never the secret). This SDK never logs a
|
|
647
|
+
secret, the key, or any request or response body.
|
|
648
|
+
|
|
649
|
+
### Managed clients
|
|
650
|
+
|
|
651
|
+
A managed client is an organization you create and run for a customer until they claim it.
|
|
652
|
+
`for_managed_client(id)` gives the same applications, environments and credentials calls, sent
|
|
653
|
+
under `/api/v1/clients/:client_id/`:
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
customer = mgmt.clients.create_managed(name: "Initech")
|
|
657
|
+
initech = mgmt.for_managed_client(customer["id"])
|
|
658
|
+
|
|
659
|
+
# The managed client's organization already has a "production" environment (the name is
|
|
660
|
+
# reserved); create others alongside it.
|
|
661
|
+
initech_staging = initech.environments.create(name: "staging", domain: "staging.initech.example")
|
|
662
|
+
billing_url = "https://billing.staging.initech.example"
|
|
663
|
+
app = initech.applications.create(name: "Initech billing",
|
|
664
|
+
environment_base_urls: { initech_staging["id"] => billing_url })
|
|
665
|
+
app_env = initech.applications.list_environments(app["id"]).first
|
|
666
|
+
secret = initech.credentials.create(application_environment_id: app_env["id"])["client_secret"]
|
|
667
|
+
|
|
668
|
+
# Grant it your APIs like any accepted client (package and staging from the example above) ...
|
|
669
|
+
mgmt.package_assignments.assign(customer["id"], api_package_id: package["id"], environment_id: staging["id"])
|
|
670
|
+
|
|
671
|
+
# ... and hand it over: the customer gets an email, and claiming rotates every credential you issued.
|
|
672
|
+
initech.claim_invite(email: "it@initech.example")
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Once claimed, the managed client's calls answer `not_found`. Remove an unclaimed one with
|
|
676
|
+
`mgmt.clients.delete(customer["id"])` after revoking its credentials.
|
|
677
|
+
|
|
678
|
+
### Errors, retries and idempotency
|
|
679
|
+
|
|
680
|
+
Every refusal raises `EndPointBlank::Management::Error` (a subclass of `EndPointBlank::Error`)
|
|
681
|
+
with `code`, `message`, `details`, `status`, `retry_after`, `location` and `request_id`. Match on
|
|
682
|
+
`code`, which is stable; `message` is for people. `EndPointBlank::Management::ErrorCodes` has a
|
|
683
|
+
constant for every code the API documents, and an unknown code still raises with that code.
|
|
684
|
+
|
|
685
|
+
```ruby
|
|
686
|
+
codes = EndPointBlank::Management::ErrorCodes
|
|
687
|
+
|
|
688
|
+
begin
|
|
689
|
+
mgmt.clients.invite(name: "Umbrella")
|
|
690
|
+
rescue EndPointBlank::Management::Error => e
|
|
691
|
+
case e.code
|
|
692
|
+
when codes::PLAN_LIMIT then warn "upgrade your plan to add clients" # 402
|
|
693
|
+
when codes::VALIDATION_FAILED then warn "invalid fields: #{e.details.inspect}" # 422
|
|
694
|
+
when codes::NOT_FOUND then warn "no such resource" # 404
|
|
695
|
+
when codes::INSUFFICIENT_SCOPE then warn "this is a read-only key" # 403
|
|
696
|
+
else raise
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
The SDK raises three codes of its own: `connection_error` (no answer at all; `status` is nil),
|
|
702
|
+
`http_error` (an error answer that is not the API's JSON, e.g. from a proxy) and
|
|
703
|
+
`invalid_response` (a success answer that is not JSON).
|
|
704
|
+
|
|
705
|
+
- **Idempotency.** Every POST sends an `Idempotency-Key`: a random UUID unless you pass
|
|
706
|
+
`idempotency_key:` (1 to 255 characters), and a retry sends the same key, so a POST is never run
|
|
707
|
+
twice. A credential `create` or `rotate` retried after the first one succeeded raises
|
|
708
|
+
`idempotency_replay_unavailable` instead of replaying the secret: read or list the credential
|
|
709
|
+
(rotate it if you never got the secret).
|
|
710
|
+
- **Retries.** A 429 `rate_limited` is retried after its `Retry-After` seconds (1 second
|
|
711
|
+
when it has none). A 5xx
|
|
712
|
+
(`internal_server_error`, `audit_unavailable`, `intake_unavailable`) or a request that got no
|
|
713
|
+
answer is retried with backoff for GET, DELETE and POST, never for PATCH.
|
|
714
|
+
`idempotency_request_in_progress` is retried shortly with the same key. 4xx refusals are never
|
|
715
|
+
retried.
|
|
716
|
+
|
|
717
|
+
| `Client.new` option | Env var fallback | Default | Notes |
|
|
718
|
+
|---|---|---|---|
|
|
719
|
+
| `api_key` | `ENDPOINTBLANK_MANAGEMENT_KEY` | none (required) | A management API key, `epb_mk_...`. |
|
|
720
|
+
| `base_url` | `ENDPOINTBLANK_MANAGEMENT_BASE_URL` | `https://app.endpointblank.com` | app_portal, not intake. |
|
|
721
|
+
| `max_retries` | — | `2` | Retries after the first attempt; `0` turns them off. |
|
|
722
|
+
| `max_retry_wait` | — | `60` | The longest single wait, in seconds; a longer `Retry-After` raises instead. |
|
|
723
|
+
| `connect_timeout` / `read_timeout` | — | `5` / `30` | Seconds. |
|
|
724
|
+
| `sleeper` | — | `Kernel#sleep` | Called with the seconds before each retry (replace it in tests). |
|
|
725
|
+
| `excon_options` | — | `{}` | Extra `Excon.new` options, e.g. a proxy. |
|
|
726
|
+
|
|
727
|
+
In a Rails app, set the defaults once in an initializer, apart from `EndPointBlank.configure`:
|
|
728
|
+
|
|
729
|
+
```ruby
|
|
730
|
+
# config/initializers/end_point_blank_management.rb
|
|
731
|
+
EndPointBlank::Management.configure do |m|
|
|
732
|
+
m.api_key = Rails.application.credentials.dig(:end_point_blank, :management_key)
|
|
733
|
+
m.max_retries = 3
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
EndPointBlank::Management.client.organization # a Client built from that configuration
|
|
737
|
+
```
|
|
738
|
+
|
|
334
739
|
## Development
|
|
335
740
|
|
|
336
741
|
```sh
|
data/end_point_blank.gemspec
CHANGED
|
@@ -8,9 +8,9 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["Robert A. Lasch"]
|
|
9
9
|
spec.email = ["rlasch@gmail.com"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "Ruby
|
|
12
|
-
spec.description = "
|
|
13
|
-
spec.homepage = "https://
|
|
11
|
+
spec.summary = "Ruby and Rails SDK for EndPointBlank: authorize service-to-service API calls, report endpoint versions, and see which clients still call deprecated API versions."
|
|
12
|
+
spec.description = "Ruby and Rails SDK for EndPointBlank: authorize service-to-service (machine-to-machine) API calls, report endpoint versions, and see which clients still call deprecated API versions before you sunset them. A framework-agnostic core runs in plain Ruby / Sinatra, with Rails supported as an auto-loaded adapter."
|
|
13
|
+
spec.homepage = "https://endpointblank.com"
|
|
14
14
|
spec.required_ruby_version = ">= 3.4.2"
|
|
15
15
|
spec.license = "Nonstandard"
|
|
16
16
|
|
|
@@ -18,6 +18,8 @@ Gem::Specification.new do |spec|
|
|
|
18
18
|
|
|
19
19
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
20
|
spec.metadata["source_code_uri"] = "https://github.com/EndPointBlank/end_point_blank_rails"
|
|
21
|
+
spec.metadata["documentation_uri"] = "https://endpointblank.com/docs/sdk-setup"
|
|
22
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/EndPointBlank/end_point_blank_rails/issues"
|
|
21
23
|
spec.metadata["changelog_uri"] = "https://github.com/EndPointBlank/end_point_blank_rails/releases"
|
|
22
24
|
|
|
23
25
|
# Specify which files should be added to the gem when it is released.
|