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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +653 -0
  3. data/README.md +424 -19
  4. data/end_point_blank.gemspec +5 -3
  5. data/lib/end_point_blank/access_tokens.rb +244 -25
  6. data/lib/end_point_blank/authorization.rb +105 -20
  7. data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
  8. data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
  9. data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
  10. data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
  11. data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
  12. data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
  13. data/lib/end_point_blank/commands/http.rb +20 -1
  14. data/lib/end_point_blank/configuration.rb +111 -4
  15. data/lib/end_point_blank/configuration_error.rb +18 -0
  16. data/lib/end_point_blank/management/client.rb +228 -0
  17. data/lib/end_point_blank/management/configuration.rb +56 -0
  18. data/lib/end_point_blank/management/error.rb +134 -0
  19. data/lib/end_point_blank/management/error_codes.rb +115 -0
  20. data/lib/end_point_blank/management/idempotency_key.rb +33 -0
  21. data/lib/end_point_blank/management/page.rb +55 -0
  22. data/lib/end_point_blank/management/resources/api_packages.rb +104 -0
  23. data/lib/end_point_blank/management/resources/applications.rb +167 -0
  24. data/lib/end_point_blank/management/resources/base.rb +117 -0
  25. data/lib/end_point_blank/management/resources/clients.rb +152 -0
  26. data/lib/end_point_blank/management/retry_policy.rb +65 -0
  27. data/lib/end_point_blank/management/transport.rb +167 -0
  28. data/lib/end_point_blank/management/url_path.rb +18 -0
  29. data/lib/end_point_blank/management.rb +52 -0
  30. data/lib/end_point_blank/rails/authenticated.rb +62 -7
  31. data/lib/end_point_blank/rails/authorized.rb +9 -13
  32. data/lib/end_point_blank/target_url.rb +57 -0
  33. data/lib/end_point_blank/token_unavailable_error.rb +102 -0
  34. data/lib/end_point_blank/unauthorized_error.rb +81 -1
  35. data/lib/end_point_blank/version.rb +1 -1
  36. data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
  37. data/lib/end_point_blank/writers/direct_writer.rb +1 -1
  38. data/lib/end_point_blank/writers/exception_writer.rb +11 -2
  39. data/lib/end_point_blank/writers/log_writer.rb +1 -1
  40. data/lib/end_point_blank/writers/request_writer.rb +1 -0
  41. data/lib/end_point_blank/writers/response_writer.rb +1 -0
  42. data/lib/end_point_blank/writers/shared.rb +35 -4
  43. data/lib/end_point_blank.rb +240 -2
  44. metadata +29 -10
  45. 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 are signed
12
- (`Basic` client-credential or cached `Bearer` token), and inbound requests can be authorized
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` singleton.
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` | Used to build the `Basic` authorization header. |
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` | Currently unused by the delayed writer (which always spins up 2 threads); reserved. |
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 (seconds) for the authorization decision cache. |
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 = 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.
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
- EndPointBlank::Authorization.header # => "Basic ..."
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
- # 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
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. 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.
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 two background threads that POST batches via `excon`. Delivery
230
- is fire-and-forget and never raises into your request cycle.
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
@@ -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/Rails client for EndPointBlank — endpoint tracking, authorization, and error/request/response/log reporting."
12
- spec.description = "EndPointBlank client library for Ruby. A framework-agnostic core runs in plain Ruby / Sinatra, with Rails supported as an auto-loaded adapter. Provides API endpoint tracking, authorization, and error/request/response/log reporting."
13
- spec.homepage = "https://github.com/EndPointBlank/end_point_blank_rails"
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.