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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +13 -0
  4. data/.ruby-version +1 -0
  5. data/README.md +64 -0
  6. data/Rakefile +12 -0
  7. data/build.sh +5 -0
  8. data/docs/superpowers/plans/2026-07-08-framework-agnostic-core.md +68 -0
  9. data/docs/superpowers/specs/2026-07-08-framework-agnostic-core-design.md +58 -0
  10. data/end_point_blank.gemspec +45 -0
  11. data/lib/end_point_blank/access_tokens.rb +72 -0
  12. data/lib/end_point_blank/authorization.rb +36 -0
  13. data/lib/end_point_blank/commands/authentication_cache.rb +95 -0
  14. data/lib/end_point_blank/commands/basic_authenticate.rb +47 -0
  15. data/lib/end_point_blank/commands/bearer_generate.rb +33 -0
  16. data/lib/end_point_blank/commands/endpoint_authorize.rb +71 -0
  17. data/lib/end_point_blank/commands/endpoint_update.rb +129 -0
  18. data/lib/end_point_blank/commands/generate_access_token.rb +46 -0
  19. data/lib/end_point_blank/commands/http.rb +45 -0
  20. data/lib/end_point_blank/commands/route_pattern_finder.rb +21 -0
  21. data/lib/end_point_blank/commands/version_finder.rb +70 -0
  22. data/lib/end_point_blank/configuration.rb +107 -0
  23. data/lib/end_point_blank/fast_json_truncator.rb +49 -0
  24. data/lib/end_point_blank/log_entry.rb +16 -0
  25. data/lib/end_point_blank/loggers/logger.rb +30 -0
  26. data/lib/end_point_blank/masking.rb +282 -0
  27. data/lib/end_point_blank/middleware/rack/report_interaction.rb +54 -0
  28. data/lib/end_point_blank/rack/env_store.rb +34 -0
  29. data/lib/end_point_blank/rack/headers.rb +11 -0
  30. data/lib/end_point_blank/rails/authenticated.rb +21 -0
  31. data/lib/end_point_blank/rails/authorized.rb +33 -0
  32. data/lib/end_point_blank/rails/railtie.rb +18 -0
  33. data/lib/end_point_blank/rails/versioned.rb +64 -0
  34. data/lib/end_point_blank/session_configuration.rb +44 -0
  35. data/lib/end_point_blank/string_truncator.rb +21 -0
  36. data/lib/end_point_blank/unauthorized_error.rb +10 -0
  37. data/lib/end_point_blank/version.rb +5 -0
  38. data/lib/end_point_blank/writers/delayed_writer.rb +110 -0
  39. data/lib/end_point_blank/writers/direct_writer.rb +18 -0
  40. data/lib/end_point_blank/writers/exception_writer.rb +50 -0
  41. data/lib/end_point_blank/writers/log_writer.rb +69 -0
  42. data/lib/end_point_blank/writers/request_writer.rb +61 -0
  43. data/lib/end_point_blank/writers/response_writer.rb +70 -0
  44. data/lib/end_point_blank/writers/shared.rb +39 -0
  45. data/lib/end_point_blank/xml_truncator.rb +89 -0
  46. data/lib/end_point_blank.rb +57 -0
  47. data/sig/end_point_blank_rack.rbs +4 -0
  48. data/test.sh +5 -0
  49. 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
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,13 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.6
3
+
4
+ Style/StringLiterals:
5
+ Enabled: true
6
+ EnforcedStyle: double_quotes
7
+
8
+ Style/StringLiteralsInInterpolation:
9
+ Enabled: true
10
+ EnforcedStyle: double_quotes
11
+
12
+ Layout/LineLength:
13
+ Max: 120
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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/build.sh ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Install dependencies so the test suite can run.
3
+ set -euo pipefail
4
+ cd "$(dirname "$0")"
5
+ exec bundle install
@@ -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