end_point_blank 0.2.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/.ruby-version +1 -0
- data/README.md +64 -0
- data/Rakefile +12 -0
- data/build.sh +5 -0
- data/docs/superpowers/plans/2026-07-08-framework-agnostic-core.md +68 -0
- data/docs/superpowers/specs/2026-07-08-framework-agnostic-core-design.md +58 -0
- data/end_point_blank.gemspec +45 -0
- data/lib/end_point_blank/access_tokens.rb +72 -0
- data/lib/end_point_blank/authorization.rb +36 -0
- data/lib/end_point_blank/commands/authentication_cache.rb +95 -0
- data/lib/end_point_blank/commands/basic_authenticate.rb +47 -0
- data/lib/end_point_blank/commands/bearer_generate.rb +33 -0
- data/lib/end_point_blank/commands/endpoint_authorize.rb +71 -0
- data/lib/end_point_blank/commands/endpoint_update.rb +129 -0
- data/lib/end_point_blank/commands/generate_access_token.rb +46 -0
- data/lib/end_point_blank/commands/http.rb +45 -0
- data/lib/end_point_blank/commands/route_pattern_finder.rb +21 -0
- data/lib/end_point_blank/commands/version_finder.rb +70 -0
- data/lib/end_point_blank/configuration.rb +107 -0
- data/lib/end_point_blank/fast_json_truncator.rb +49 -0
- data/lib/end_point_blank/log_entry.rb +16 -0
- data/lib/end_point_blank/loggers/logger.rb +30 -0
- data/lib/end_point_blank/masking.rb +282 -0
- data/lib/end_point_blank/middleware/rack/report_interaction.rb +54 -0
- data/lib/end_point_blank/rack/env_store.rb +34 -0
- data/lib/end_point_blank/rack/headers.rb +11 -0
- data/lib/end_point_blank/rails/authenticated.rb +21 -0
- data/lib/end_point_blank/rails/authorized.rb +33 -0
- data/lib/end_point_blank/rails/railtie.rb +18 -0
- data/lib/end_point_blank/rails/versioned.rb +64 -0
- data/lib/end_point_blank/session_configuration.rb +44 -0
- data/lib/end_point_blank/string_truncator.rb +21 -0
- data/lib/end_point_blank/unauthorized_error.rb +10 -0
- data/lib/end_point_blank/version.rb +5 -0
- data/lib/end_point_blank/writers/delayed_writer.rb +110 -0
- data/lib/end_point_blank/writers/direct_writer.rb +18 -0
- data/lib/end_point_blank/writers/exception_writer.rb +50 -0
- data/lib/end_point_blank/writers/log_writer.rb +69 -0
- data/lib/end_point_blank/writers/request_writer.rb +61 -0
- data/lib/end_point_blank/writers/response_writer.rb +70 -0
- data/lib/end_point_blank/writers/shared.rb +39 -0
- data/lib/end_point_blank/xml_truncator.rb +89 -0
- data/lib/end_point_blank.rb +57 -0
- data/sig/end_point_blank_rack.rbs +4 -0
- data/test.sh +5 -0
- metadata +163 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b2327b3053463662a656ad3f6d03596e7e21c72beae8c30aa18fc445eff230a5
|
|
4
|
+
data.tar.gz: 4c80916d52eab6b760ec5ebedf1ee70da4132697e49f37305d106aa5637677d5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8c13f72a824fa9b0e8d05bbf6a039bae0aec1f9c24fb5226bb23de58cf8f6fe75f99c3089ff95fc7a1989b8dd90bb705d246558fb62f1d8073d10b394ed1b927
|
|
7
|
+
data.tar.gz: 786fb59740f10dc62c61bdd8ffc014cd72c0fba1c223ff6c9465b840c4c6134720cec8eceb321cd78caee0dd8a4a8ba58f9db0db05ed6cf756eb1fe78239f5ed
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.4.2
|
data/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# EndPointBlankRack
|
|
2
|
+
|
|
3
|
+
TODO: Delete this and the text below, and describe your gem
|
|
4
|
+
|
|
5
|
+
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/end_point_blank_rack`. To experiment with that code, run `bin/console` for an interactive prompt.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
|
|
10
|
+
|
|
11
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
12
|
+
|
|
13
|
+
$ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
|
|
14
|
+
|
|
15
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
16
|
+
|
|
17
|
+
$ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Masking
|
|
22
|
+
|
|
23
|
+
Mask sensitive data **before it leaves your app**. Configure an ordered list of rules; each rule
|
|
24
|
+
targets one field and masks by a JSONPath, a regex, or both. (Server-side intake also masks
|
|
25
|
+
independently, so this is defense in depth.)
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
EndPointBlank.configure do |config|
|
|
29
|
+
config.masking_rules = [
|
|
30
|
+
# Replace any "ssn" field at any depth in the request body.
|
|
31
|
+
{ target: "request_body", path: "$..ssn", replacement_value: "***" },
|
|
32
|
+
# Keep first/last 4 of a card number in error messages via backreferences.
|
|
33
|
+
{ target: "error_message", regex: "(\\d{4})-\\d{4}-\\d{4}-(\\d{4})", replacement_value: "$1-****-****-$2" }
|
|
34
|
+
]
|
|
35
|
+
# Optional: runs after the rules; last chance to transform the payload.
|
|
36
|
+
config.mask_hook = ->(payload, record_type) { payload }
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Rules are hashes with symbol keys.
|
|
41
|
+
|
|
42
|
+
**Rule fields**
|
|
43
|
+
|
|
44
|
+
- `target` — exactly one of `"request_body"`, `"request_headers"`, `"path"`, `"response_body"`, `"error_message"`.
|
|
45
|
+
- `path` — an optional JSONPath (supported subset: `$`, `.name`, `['name']`, `[n]`, `.*` / `[*]`,
|
|
46
|
+
and `..name` for recursive descent). Keys are case-sensitive.
|
|
47
|
+
- `regex` — an optional regular expression.
|
|
48
|
+
- `replacement_value` — the replacement string (default `"..."`).
|
|
49
|
+
|
|
50
|
+
**Semantics — path scopes, regex matches within.** With only a `path`, the selected node is replaced
|
|
51
|
+
entirely. With only a `regex`, every matching string is replaced. With both, the regex is applied
|
|
52
|
+
only within the path-selected node(s). When a `regex` is present, `replacement_value` supports
|
|
53
|
+
backreferences: `$1`, `$2`, … insert capture groups (`$0` the whole match; `$$` for a literal `$`).
|
|
54
|
+
Stacktraces and log messages are never masked.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
59
|
+
|
|
60
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
61
|
+
|
|
62
|
+
## Contributing
|
|
63
|
+
|
|
64
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/end_point_blank_rack.
|
data/Rakefile
ADDED
data/build.sh
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Framework-agnostic `end_point_blank` (Ruby) — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]` tracking. This is a Ruby gem: RSpec (`bundle exec rspec`) + RuboCop (`bundle exec rubocop`).
|
|
4
|
+
|
|
5
|
+
**Goal:** Decouple the gem's core from `::Rails` (pluggable logging, env-var config, Rails-only introspection gated) so it runs in plain Ruby / Sinatra, with Rails as an auto-loaded adapter.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A single logger seam (`EndPointBlank.logger`), `Configuration` reading `ENDPOINTBLANK_*`, and the railtie wiring Rails specifics. Core never calls `::Rails` except through these seams.
|
|
8
|
+
|
|
9
|
+
## Global Constraints
|
|
10
|
+
- Design source of truth: `docs/superpowers/specs/2026-07-08-framework-agnostic-core-design.md`.
|
|
11
|
+
- Config precedence everywhere: **explicit `configure` value > `ENDPOINTBLANK_*` env > built-in default**.
|
|
12
|
+
- The public API (`EndPointBlank.configure { |c| ... }`, `Configuration.instance`) does not change shape.
|
|
13
|
+
- After each task: `bundle exec rspec` green and `bundle exec rubocop` clean.
|
|
14
|
+
- Do NOT touch `HARDENING_REPORT.md` (pre-existing, untracked, not ours).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
### Task 1: Pluggable logging seam
|
|
19
|
+
|
|
20
|
+
**Files:**
|
|
21
|
+
- Modify: `lib/end_point_blank.rb` (logger accessor), `lib/end_point_blank/configuration.rb` (`logger` attr), `lib/end_point_blank/loggers/logger.rb`, `lib/end_point_blank/rails/railtie.rb`
|
|
22
|
+
- Modify (replace `::Rails.logger`): `lib/end_point_blank/access_tokens.rb`, `commands/http.rb`, `commands/basic_authenticate.rb`, `commands/generate_access_token.rb`, `commands/endpoint_authorize.rb`, `commands/endpoint_update.rb`, `writers/delayed_writer.rb`, `middleware/rack/report_interaction.rb`
|
|
23
|
+
- Test: `spec/logger_spec.rb` (new), and adjust any spec that stubs `::Rails.logger`
|
|
24
|
+
|
|
25
|
+
**Interfaces (Produces):** `EndPointBlank.logger` → a `::Logger`-like object; `EndPointBlank.logger=`; `Configuration#logger` / `#logger=`.
|
|
26
|
+
|
|
27
|
+
- [ ] **Step 1:** Add to `EndPointBlank` (in `end_point_blank.rb`): `def self.logger; Configuration.instance.logger || (@default_logger ||= ::Logger.new($stdout)); end` and `def self.logger=(l); Configuration.instance.logger = l; end`. `require "logger"` at the top.
|
|
28
|
+
- [ ] **Step 2:** `Configuration` — add `:logger` to `attr_accessor` (default `nil`).
|
|
29
|
+
- [ ] **Step 3 (TDD):** `spec/logger_spec.rb` (fail first):
|
|
30
|
+
- with `::Rails` not defined, `EndPointBlank.logger` is a `Logger` writing to `$stdout`;
|
|
31
|
+
- `EndPointBlank.configure { |c| c.logger = my_logger }` → `EndPointBlank.logger == my_logger`;
|
|
32
|
+
- `EndPointBlank::Loggers::Logger.error("x")` calls `EndPointBlank.logger.error`.
|
|
33
|
+
- [ ] **Step 4:** In `loggers/logger.rb`, change `error/warn/fatal` to `EndPointBlank.logger.error/warn/fatal` (leave `debug`/`info` Writer behavior).
|
|
34
|
+
- [ ] **Step 5:** Replace every remaining `::Rails.logger.X` / `Rails.logger.X` in the 8 core files with `EndPointBlank.logger.X`. (`delayed_writer.rb` already guards — simplify to `EndPointBlank.logger.warn`.)
|
|
35
|
+
- [ ] **Step 6:** In `rails/railtie.rb`, on boot set `EndPointBlank::Configuration.instance.logger ||= ::Rails.logger` (initializer). So Rails apps log via Rails unchanged.
|
|
36
|
+
- [ ] **Step 7:** Update any existing spec stubbing `::Rails.logger` to stub `EndPointBlank.logger`. `bundle exec rspec` green, `rubocop` clean. Commit `feat: pluggable EndPointBlank.logger, decouple core logging from ::Rails`.
|
|
37
|
+
|
|
38
|
+
### Task 2: Env-var config + Rails-free `app_name`
|
|
39
|
+
|
|
40
|
+
**Files:**
|
|
41
|
+
- Modify: `lib/end_point_blank/configuration.rb`
|
|
42
|
+
- Test: `spec/configuration_spec.rb` (new or extend)
|
|
43
|
+
|
|
44
|
+
**Interfaces:** `Configuration#client_id/#client_secret/#base_url/#log_base_url/#app_name` honor `ENDPOINTBLANK_*` fallbacks.
|
|
45
|
+
|
|
46
|
+
- [ ] **Step 1 (TDD):** `spec/configuration_spec.rb` (fail first), resetting the singleton + `ENV` per example:
|
|
47
|
+
- unset `client_id` returns `ENV["ENDPOINTBLANK_CLIENT_ID"]`; same for `client_secret`, and `base_url`/`log_base_url`/`app_name`;
|
|
48
|
+
- explicit `configure` value beats the env var; env var beats the built-in default (`base_url` default stays `https://in.endpointblank.com` when neither set);
|
|
49
|
+
- `app_name` with `::Rails` undefined + `ENDPOINTBLANK_APP_NAME` set returns that; unset + no Rails → `nil` (no `NameError`).
|
|
50
|
+
- [ ] **Step 2:** Implement env fallbacks. For `client_id`/`client_secret` (no default): `@client_id || ENV["ENDPOINTBLANK_CLIENT_ID"]` via overridden readers. For `base_url`/`log_base_url`: default applied only when both ivar and env are nil. `app_name`: `@app_name || ENV["ENDPOINTBLANK_APP_NAME"] || (::Rails.application.name.underscore if defined?(::Rails))`.
|
|
51
|
+
- [ ] **Step 3:** `bundle exec rspec` green, `rubocop` clean. Commit `feat: ENDPOINTBLANK_* env-var configuration + Rails-free app_name`.
|
|
52
|
+
|
|
53
|
+
### Task 3: No-Rails packaging + smoke test
|
|
54
|
+
|
|
55
|
+
**Files:**
|
|
56
|
+
- Modify (only if a load-time/core-path `::Rails` reference is found): the offending file
|
|
57
|
+
- Test: `spec/no_rails_spec.rb` (new)
|
|
58
|
+
|
|
59
|
+
- [ ] **Step 1:** Grep the core (everything except `lib/end_point_blank/rails/*`, `commands/endpoint_update.rb`, `commands/route_pattern_finder.rb`) for any remaining `::Rails` / `Rails.` reference. Each must be either gone (Task 1) or guarded by `defined?(::Rails)`. Fix any stray one by routing through a seam or guarding.
|
|
60
|
+
- [ ] **Step 2 (TDD):** `spec/no_rails_spec.rb`:
|
|
61
|
+
- `require "end_point_blank"` succeeds with `::Rails` not defined (it isn't, in specs);
|
|
62
|
+
- a representative core flow runs without a `NameError: ::Rails` — e.g. build a `Configuration` via `configure`, run a `Middleware::Rack::ReportInteraction` call (or a `Writers` write) with HTTP stubbed, asserting no `::Rails`-related error is raised. (Stub `EndPointBlank::Commands::Http` / excon so nothing leaves the process.)
|
|
63
|
+
- [ ] **Step 3:** Confirm the Rails-only introspection (`endpoint_update`, `route_pattern_finder`) is never reached in this flow. `bundle exec rspec` green, `rubocop` clean. Commit `test: no-Rails smoke coverage for the framework-agnostic core`.
|
|
64
|
+
|
|
65
|
+
## Notes
|
|
66
|
+
- `Configuration.instance` is a singleton — specs must reset it between examples (a `reset!` helper or `Singleton.__init__`), and save/restore `ENV` keys they touch.
|
|
67
|
+
- Keep changes minimal and mechanical in Task 1 (it's a wide but shallow find-replace); the behavior change is only "logs go through a seam."
|
|
68
|
+
- Do not introduce a `railties` dependency; the railtie is already loaded only under `defined?(::Rails)`.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Framework-agnostic `end_point_blank` (Ruby) — Design
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
Make the `end_point_blank` gem run in **plain Ruby / Sinatra**, not just Rails, by decoupling the core from `::Rails`, and add **`ENDPOINTBLANK_*`** environment-variable configuration. Rails stays supported as a thin, auto-loaded adapter. This is the first slice of the client-lib parity work (#1); env-var config for Java/JS/Python/Elixir is a follow-up.
|
|
5
|
+
|
|
6
|
+
## Context (current state)
|
|
7
|
+
- RSpec suite of 6 specs (`bundle exec rspec`) — the refactor's safety net.
|
|
8
|
+
- The Rails concerns + railtie (`end_point_blank/rails/*`) are **already** gated by `if defined?(::Rails)` in `lib/end_point_blank.rb`. Good — packaging is half-done.
|
|
9
|
+
- Runtime coupling to `::Rails` is three things: **logging** (`::Rails.logger` in ~10 files), **`app_name`** (`::Rails.application.name`), and **route/host/port/env introspection** (`commands/endpoint_update.rb`, `commands/route_pattern_finder.rb`). The introspection is not called from the core request/authorize path — only from Rails-gated code.
|
|
10
|
+
- `Configuration` is a singleton (`Configuration.instance`) with `attr_accessor :client_id, :client_secret, :base_url, :log_base_url, …`, an `app_name` method that falls back to `::Rails.application.name`, and no env-var reading.
|
|
11
|
+
- `EndPointBlank::Loggers::Logger` exists but is inconsistent (`error/warn/fatal` → `::Rails.logger`; `debug`/`info` write via `Writers`; ). It's the seam to build on.
|
|
12
|
+
|
|
13
|
+
## Design
|
|
14
|
+
|
|
15
|
+
### 1. Pluggable logging (the pervasive coupling)
|
|
16
|
+
Introduce a single logger seam:
|
|
17
|
+
- `EndPointBlank.logger` — module accessor. Resolution order: `Configuration.instance.logger` (if set) → a memoized default `::Logger.new($stdout)` (level `INFO`). `EndPointBlank.logger=` setter also supported.
|
|
18
|
+
- `Configuration` gains a `logger` accessor (default `nil` → falls through to the stdout default).
|
|
19
|
+
- Replace **every** `::Rails.logger.X` / `Rails.logger.X` call in the core (access_tokens, commands/*, writers/delayed_writer, middleware/rack/report_interaction, loggers/logger) with `EndPointBlank.logger.X`.
|
|
20
|
+
- `Loggers::Logger`'s `error/warn/fatal` route through `EndPointBlank.logger`; its `debug`/`info` Writer behavior is unchanged (that's interaction-emission, a separate concern).
|
|
21
|
+
- The Rails adapter (railtie) sets `Configuration.instance.logger = ::Rails.logger` on boot, so Rails apps log through Rails exactly as today.
|
|
22
|
+
|
|
23
|
+
### 2. Env-var configuration (`ENDPOINTBLANK_*`)
|
|
24
|
+
`Configuration` reads env vars as fallbacks when a value isn't set in the `configure` block. Precedence: **explicit `configure` value > `ENDPOINTBLANK_*` env var > built-in default**. Mapping:
|
|
25
|
+
|
|
26
|
+
| setting | env var |
|
|
27
|
+
|---------|---------|
|
|
28
|
+
| `client_id` | `ENDPOINTBLANK_CLIENT_ID` |
|
|
29
|
+
| `client_secret` | `ENDPOINTBLANK_CLIENT_SECRET` |
|
|
30
|
+
| `base_url` | `ENDPOINTBLANK_BASE_URL` |
|
|
31
|
+
| `log_base_url` | `ENDPOINTBLANK_LOG_BASE_URL` |
|
|
32
|
+
| `app_name` | `ENDPOINTBLANK_APP_NAME` |
|
|
33
|
+
|
|
34
|
+
Implemented so an unset accessor returns the env value if present (e.g. `client_id` returns `@client_id || ENV["ENDPOINTBLANK_CLIENT_ID"]`), keeping the singleton + `configure` API intact.
|
|
35
|
+
|
|
36
|
+
### 3. `app_name` without Rails
|
|
37
|
+
`Configuration#app_name` resolution: explicit `@app_name` → `ENDPOINTBLANK_APP_NAME` → (only if `defined?(::Rails)`) `::Rails.application.name.underscore` → `nil`/raise-with-clear-message. The Rails adapter may still default it from the Rails app; non-Rails apps set it via `configure` or the env var. Same treatment for the `::Rails.application.class.module_parent_name` fallback in `endpoint_update.rb` (that path is Rails-only anyway).
|
|
38
|
+
|
|
39
|
+
### 4. Rails-only introspection stays in the adapter
|
|
40
|
+
`commands/endpoint_update.rb` and `commands/route_pattern_finder.rb` (routes, `force_ssl`, host, port, `::Rails.env`, `::Rails.application.routes`) are inherently Rails-specific. They remain, but every `::Rails.*` call in them is reached only under Rails (they're invoked from the railtie / Rails concerns). Requiring the files is fine (load-time touches no `::Rails`); we only guarantee the **core request/authorize + logging path never calls them** without Rails. Non-Rails apps get no automatic endpoint registration — acceptable (the core Rack middleware is enough).
|
|
41
|
+
|
|
42
|
+
### 5. Packaging
|
|
43
|
+
- `lib/end_point_blank.rb` already loads core files unconditionally and `rails/*` + railtie under `if defined?(::Rails)`. Keep. Verify no core file raises at **load** time when `::Rails` is undefined.
|
|
44
|
+
- The gemspec stays Rails-free (already no railties dependency).
|
|
45
|
+
- The `end_point_blank_sinatra` sample app (requires the gem, no Rails) becomes the real-world validation: it should boot and the middleware should emit interactions without `::Rails`.
|
|
46
|
+
|
|
47
|
+
### 6. Testing (extend RSpec)
|
|
48
|
+
- **Logger:** with `::Rails` undefined, `EndPointBlank.logger` returns a stdout `Logger`; a configured `config.logger` is honored; `Loggers::Logger.error` routes to it. (Assert via a captured/stubbed logger.)
|
|
49
|
+
- **Env config:** `ENDPOINTBLANK_CLIENT_ID` etc. are read when `configure` doesn't set them; explicit `configure` wins over env; env wins over default.
|
|
50
|
+
- **No-Rails smoke:** in an example where `::Rails` is not defined, `require "end_point_blank"` succeeds and a representative core flow (e.g. a `report_interaction` write, mocked HTTP) runs without raising `NameError: ::Rails`.
|
|
51
|
+
- Adjust existing specs that stub `::Rails.logger` to stub `EndPointBlank.logger` instead.
|
|
52
|
+
- `bundle exec rspec` green; `rubocop` clean (Rakefile runs both).
|
|
53
|
+
|
|
54
|
+
## Out of scope (follow-ups)
|
|
55
|
+
- Env-var config for the Java / JS / Python / Elixir SDKs (separate slice of #1).
|
|
56
|
+
- A dedicated Sinatra/Rack adapter class (core middleware is enough for now).
|
|
57
|
+
- Non-Rails automatic endpoint/route registration.
|
|
58
|
+
- Renaming the gem or changing the public `configure` API shape.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/end_point_blank/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "end_point_blank"
|
|
7
|
+
spec.version = EndPointBlank::VERSION
|
|
8
|
+
spec.authors = ["Robert A. Lasch"]
|
|
9
|
+
spec.email = ["rlasch@gmail.com"]
|
|
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"
|
|
14
|
+
spec.required_ruby_version = ">= 3.4.2"
|
|
15
|
+
|
|
16
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
17
|
+
|
|
18
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["source_code_uri"] = "https://github.com/EndPointBlank/end_point_blank_rails"
|
|
20
|
+
spec.metadata["changelog_uri"] = "https://github.com/EndPointBlank/end_point_blank_rails/releases"
|
|
21
|
+
|
|
22
|
+
# Specify which files should be added to the gem when it is released.
|
|
23
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
24
|
+
spec.files = Dir.chdir(__dir__) do
|
|
25
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
26
|
+
(File.expand_path(f) == __FILE__) ||
|
|
27
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
spec.bindir = "exe"
|
|
31
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
|
|
34
|
+
# Uncomment to register a new dependency of your gem
|
|
35
|
+
spec.add_dependency "excon", "~> 1.0"
|
|
36
|
+
spec.add_dependency "rack"
|
|
37
|
+
spec.add_dependency "rexml"
|
|
38
|
+
# Base64 was removed from Ruby's default gems as of 3.4; Authorization
|
|
39
|
+
# uses it for the Basic-auth header, so declare it explicitly.
|
|
40
|
+
spec.add_dependency "base64"
|
|
41
|
+
spec.add_development_dependency "debug", ">= 1.0.0"
|
|
42
|
+
|
|
43
|
+
# For more information and examples about making a new gem, check out our
|
|
44
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
|
45
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'singleton'
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module EndPointBlank
|
|
7
|
+
# Thread-safe singleton cache for storing access tokens per hostname
|
|
8
|
+
class AccessTokens
|
|
9
|
+
include Singleton
|
|
10
|
+
|
|
11
|
+
def initialize()
|
|
12
|
+
@tokens = {}
|
|
13
|
+
@mutexes = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.token(arg)
|
|
17
|
+
instance.token(arg)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Retrieve or generate an access token for the given hostname
|
|
21
|
+
# @param hostname [String] The hostname for which to retrieve the token
|
|
22
|
+
# @return [String, nil] The access token or nil if generation fails
|
|
23
|
+
def token(arg)
|
|
24
|
+
hostname = arg.downcase
|
|
25
|
+
@mutexes[hostname] ||= Mutex.new
|
|
26
|
+
@mutexes[hostname].synchronize do
|
|
27
|
+
# Return cached token if it exists and is not expired
|
|
28
|
+
return @tokens[hostname][:token] if @tokens.key?(hostname) && @tokens[hostname][:expired_at] > Time.now + 120
|
|
29
|
+
|
|
30
|
+
# Fetch new token
|
|
31
|
+
payload = Commands::GenerateAccessToken.token(hostname)
|
|
32
|
+
|
|
33
|
+
if payload && payload[:token]
|
|
34
|
+
payload[:expired_at] = Time.parse(payload[:expired_at])
|
|
35
|
+
@tokens[hostname] = payload
|
|
36
|
+
payload[:token]
|
|
37
|
+
else
|
|
38
|
+
EndPointBlank.logger.error "Failed to generate access token for #{hostname}: #{payload&.fetch('error')}"
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Clear all tokens from the cache
|
|
45
|
+
# @return [Hash] Empty hash
|
|
46
|
+
def clear(arg)
|
|
47
|
+
@mutexes.keys.each do |hostname|
|
|
48
|
+
@mutexes[hostname].synchronize do
|
|
49
|
+
@tokens.delete(hostname)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Remove token for a specific hostname
|
|
55
|
+
# @param hostname [String] The hostname for which to remove the token
|
|
56
|
+
# @return [Object, nil] The removed token data or nil if not found
|
|
57
|
+
def remove(arg)
|
|
58
|
+
hostname = arg.downcase
|
|
59
|
+
@mutexes[hostname].synchronize do
|
|
60
|
+
@tokens.delete(hostname)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if a valid token exists for a given hostname
|
|
65
|
+
# @param hostname [String] The hostname to check
|
|
66
|
+
# @return [Boolean] True if a valid token exists, false otherwise
|
|
67
|
+
def exists?(arg)
|
|
68
|
+
hostname = arg.downcase
|
|
69
|
+
@tokens.key?(hostname) && @tokens[hostname][:expired_at] > Time.now + 30
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/bin/ruby
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module EndPointBlank
|
|
6
|
+
module AuthorizationMethods
|
|
7
|
+
module ClassMethods
|
|
8
|
+
def configuration
|
|
9
|
+
EndPointBlank::Configuration.instance
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def header(hostname = nil)
|
|
13
|
+
token = nil
|
|
14
|
+
token = EndPointBlank::AccessTokens.token(hostname) if hostname
|
|
15
|
+
|
|
16
|
+
if token
|
|
17
|
+
"Bearer " + token
|
|
18
|
+
else
|
|
19
|
+
"Basic " + Base64.encode64(configuration.client_id + ":" + configuration.client_secret).gsub("\n", "")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.included(base)
|
|
25
|
+
base.extend(ClassMethods)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Generates HTTP Basic Authorization headers using client credentials.
|
|
30
|
+
# Creates a Base64-encoded string from the client_id and client_secret
|
|
31
|
+
# configured in EndPointBlank::Configuration.
|
|
32
|
+
# Use header class method to get a properly formatted "Basic {credentials}" header.
|
|
33
|
+
class Authorization
|
|
34
|
+
include AuthorizationMethods
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'singleton'
|
|
4
|
+
|
|
5
|
+
module EndPointBlank
|
|
6
|
+
module Commands
|
|
7
|
+
# Thread-safe singleton cache for storing authentication credentials.
|
|
8
|
+
# Capped at MAX_SIZE entries. When full, expired entries are evicted first;
|
|
9
|
+
# if still at capacity the entry expiring soonest is removed.
|
|
10
|
+
class AuthenticationCache
|
|
11
|
+
include Singleton
|
|
12
|
+
|
|
13
|
+
MAX_SIZE = 1000
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@cache = {}
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Store credentials in the cache
|
|
21
|
+
# @param key [String, Symbol] The identifier for the credentials
|
|
22
|
+
# @param credentials [Object] The credentials to store
|
|
23
|
+
# @return [Object] The stored credentials
|
|
24
|
+
def store(key, credentials)
|
|
25
|
+
return unless credentials
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
now = Time.now
|
|
28
|
+
# Evict expired entries first
|
|
29
|
+
@cache.delete_if { |_, v| v[:expired_at] <= now }
|
|
30
|
+
# Evict the entry expiring soonest if still at capacity
|
|
31
|
+
if @cache.size >= MAX_SIZE
|
|
32
|
+
oldest_key = @cache.min_by { |_, v| v[:expired_at] }.first
|
|
33
|
+
@cache.delete(oldest_key)
|
|
34
|
+
end
|
|
35
|
+
@cache[key] = { expired_at: now + ::EndPointBlank::Configuration.instance.cache_ttl, credentials: credentials }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Retrieve credentials from the cache
|
|
40
|
+
# @param key [String, Symbol] The identifier for the credentials
|
|
41
|
+
# @return [Object, nil] The stored credentials or nil if not found
|
|
42
|
+
def retrieve(key)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
if @cache.key?(key)
|
|
45
|
+
@cache[key][:credentials] if @cache[key][:expired_at] > Time.now
|
|
46
|
+
else
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Check if credentials exist for a given key
|
|
53
|
+
# @param key [String, Symbol] The identifier to check
|
|
54
|
+
# @return [Boolean] True if credentials exist, false otherwise
|
|
55
|
+
def exists?(key)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@cache.key?(key) && @cache[key][:expired_at] > Time.now
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Remove credentials from the cache
|
|
62
|
+
# @param key [String, Symbol] The identifier for the credentials to remove
|
|
63
|
+
# @return [Object, nil] The removed credentials or nil if not found
|
|
64
|
+
def remove(key)
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@cache.delete(key)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Clear all credentials from the cache
|
|
71
|
+
# @return [Hash] Empty hash
|
|
72
|
+
def clear
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
@cache.clear
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get all keys in the cache
|
|
79
|
+
# @return [Array] Array of all keys
|
|
80
|
+
def keys
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
@cache.keys.dup
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get the number of credentials stored
|
|
87
|
+
# @return [Integer] The size of the cache
|
|
88
|
+
def size
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
@cache.size
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/bin/ruby
|
|
2
|
+
|
|
3
|
+
require_relative 'http'
|
|
4
|
+
|
|
5
|
+
module EndPointBlank
|
|
6
|
+
module Commands
|
|
7
|
+
module BasicAuthenticateMethods
|
|
8
|
+
module ClassMethods
|
|
9
|
+
def configuration
|
|
10
|
+
EndPointBlank::Configuration.instance
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def authenticate(request)
|
|
14
|
+
client_auth = request.headers['Authorization']
|
|
15
|
+
auth = "Basic #{AuthorizationGenerate.generate}"
|
|
16
|
+
body = {
|
|
17
|
+
path: request.route_uri_pattern.to_s.gsub(/\([^)]*\)/, ''),
|
|
18
|
+
http_method: request.request_method,
|
|
19
|
+
client_auth: client_auth,
|
|
20
|
+
application: Configuration.instance.app_name,
|
|
21
|
+
endpoint_version: VersionFinder.new.find(request),
|
|
22
|
+
ip_address: request.remote_ip
|
|
23
|
+
}
|
|
24
|
+
response = Http.post(configuration.authorize_url, auth, body)
|
|
25
|
+
return nil if response.nil?
|
|
26
|
+
EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
|
|
27
|
+
if response.status > 299
|
|
28
|
+
EndPointBlank.logger.error "Failed to authenticate: #{response.status} - #{response.body}"
|
|
29
|
+
end
|
|
30
|
+
response
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.included(base)
|
|
35
|
+
base.extend(ClassMethods)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Authenticates incoming requests by sending request details to a remote authorization service.
|
|
40
|
+
# Sends the request path, HTTP method, client authorization header, application name,
|
|
41
|
+
# API version, and client IP address to the configured authorize_url for validation.
|
|
42
|
+
# Returns the HTTP response from the authorization service or nil if an error occurs.
|
|
43
|
+
class BasicAuthenticate
|
|
44
|
+
include BasicAuthenticateMethods
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/bin/ruby
|
|
2
|
+
|
|
3
|
+
module EndPointBlank
|
|
4
|
+
module Commands
|
|
5
|
+
module BearerGenerateMethods
|
|
6
|
+
module ClassMethods
|
|
7
|
+
def configuration
|
|
8
|
+
EndPointBlank::Configuration.instance
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def generate()
|
|
12
|
+
Base64.encode64(configuration.client_id + ":" + configuration.client_secret).gsub("\n", "")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def auth_header
|
|
16
|
+
"Basic " + generate
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.included(base)
|
|
21
|
+
base.extend(ClassMethods)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Generates HTTP Basic Authorization headers using client credentials.
|
|
26
|
+
# Creates a Base64-encoded string from the client_id and client_secret
|
|
27
|
+
# configured in EndPointBlank::Configuration.
|
|
28
|
+
# Use auth_header class method to get a properly formatted "Basic {credentials}" header.
|
|
29
|
+
class BearerGenerate
|
|
30
|
+
include BearerGenerateMethods
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/bin/ruby
|
|
2
|
+
|
|
3
|
+
require_relative 'http'
|
|
4
|
+
require_relative 'authentication_cache'
|
|
5
|
+
|
|
6
|
+
module EndPointBlank
|
|
7
|
+
module Commands
|
|
8
|
+
# Synthetic response returned on a cache hit to avoid a network call.
|
|
9
|
+
CachedResponse = Struct.new(:status, :body)
|
|
10
|
+
|
|
11
|
+
module EndpointAuthorizeMethods
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def configuration
|
|
14
|
+
EndPointBlank::Configuration.instance
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authorize(request)
|
|
18
|
+
client_auth = request.headers['Authorization'].to_s
|
|
19
|
+
method = request.request_method
|
|
20
|
+
path = request.route_uri_pattern.to_s.gsub(/\([^)]*\)/, '')
|
|
21
|
+
app_name = Configuration.instance.app_name
|
|
22
|
+
cache_key = "epb_auth:#{client_auth}:#{path}:#{method}:#{app_name}"
|
|
23
|
+
|
|
24
|
+
cache = AuthenticationCache.instance
|
|
25
|
+
return CachedResponse.new(201, '') if cache.exists?(cache_key)
|
|
26
|
+
|
|
27
|
+
hostname = request.host
|
|
28
|
+
auth = Authorization.header(hostname)
|
|
29
|
+
body = {
|
|
30
|
+
path: path,
|
|
31
|
+
http_method: method,
|
|
32
|
+
client_auth: client_auth,
|
|
33
|
+
target_hostname: hostname,
|
|
34
|
+
application: app_name,
|
|
35
|
+
endpoint_version: VersionFinder.new.find(request),
|
|
36
|
+
source_ip: request.remote_ip,
|
|
37
|
+
uuid: request.uuid
|
|
38
|
+
}
|
|
39
|
+
response = Http.post(configuration.authorize_url, auth, body)
|
|
40
|
+
|
|
41
|
+
if response&.status == 401 && auth.to_s.start_with?("Bearer ")
|
|
42
|
+
EndPointBlank::AccessTokens.instance.remove(hostname)
|
|
43
|
+
auth = Authorization.header(hostname)
|
|
44
|
+
response = Http.post(configuration.authorize_url, auth, body)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return nil if response.nil?
|
|
48
|
+
EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
|
|
49
|
+
if response.status == 201
|
|
50
|
+
cache.store(cache_key, true)
|
|
51
|
+
elsif response.status > 299
|
|
52
|
+
EndPointBlank.logger.error "Failed to authorize endpoint: #{response.status} - #{response.body}"
|
|
53
|
+
end
|
|
54
|
+
response
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.included(base)
|
|
59
|
+
base.extend(ClassMethods)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Authenticates incoming requests by sending request details to a remote authorization service.
|
|
64
|
+
# Sends the request path, HTTP method, client authorization header, application name,
|
|
65
|
+
# API version, and client IP address to the configured authorize_url for validation.
|
|
66
|
+
# Returns the HTTP response from the authorization service or nil if an error occurs.
|
|
67
|
+
class EndpointAuthorize
|
|
68
|
+
include EndpointAuthorizeMethods
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|