stitches 5.1.0.RC3 → 5.1.0.RC5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2b49b7056175fa55cc2b597754f143cca6e841a3aafe723fab3681fb646f4c2
4
- data.tar.gz: 87a15dcf53ad770755faec02eae327a9195c26b834babaef526dedffe4d9568e
3
+ metadata.gz: f589f4d3f2537a4f9b17328ad88ca1b89031bd80309eddc63657b2799fb7e0e9
4
+ data.tar.gz: 1c51e01a86ee6ad3fa70f7e7e69b9a700bfc2ea87040f8b9c059e52648a70632
5
5
  SHA512:
6
- metadata.gz: b076a27646bd5919cdcf1a2c066536888b90e9a851ce9828369891c3198cbdef8f3208ee1d643344b7f592057d72fefd1b855ab0de0705cdcb708a1d91aa1ccc
7
- data.tar.gz: e5d3445946a09d9e54f63060edea96fd5fd0b9156622f9f094850d7ae93cca9a7737d5c7d3800e3c722dbe87a69ea3911e332da1fe3d5ee8c680e4f045cca016
6
+ metadata.gz: f5e2b42fa7a6609e8dc8e6bec1cf863e4c2e0e7d5bc47ae581187ce7ae2b15c672ec407fb20d07fe3829807e0ec976cbb7b56eeb47d47303352d3807ffd99b40
7
+ data.tar.gz: 766a73badd49a7aee8c3b16635230082dc0fb7e39cba5e20ef0e7acaaab42641874dd2348b233adcfa7f15aee883bef42965f82d3a7d46187f04799a1e632939
data/README.md CHANGED
@@ -42,73 +42,77 @@ Stitches.configure do |config|
42
42
  end
43
43
  ```
44
44
 
45
- ### Caller Identification via `X-StitchFix-Calling-Service`
45
+ ### Caller Identification
46
46
 
47
47
  When API key auth is disabled, services lose the ability to identify which
48
- internal service is calling them. The `Stitches::CallingServiceName` concern
49
- provides a replacement that works with or without API keys enabled.
48
+ internal service is calling them. Stitches 5.1+ provides two mechanisms that
49
+ work together to restore this one automatic, one opt-in.
50
50
 
51
- **Include the concern in your API controller:**
51
+ The calling service header name defaults to `X-StitchFix-Calling-Service` but
52
+ is configurable via `Stitches.configuration.calling_service_header`.
52
53
 
53
- ```ruby
54
- class Api::ApiController < ActionController::API
55
- include Stitches::CallingServiceName
54
+ #### CallingServiceMiddleware (automatic)
56
55
 
57
- # Replace api_client.name with calling_service_name anywhere you need
58
- # to know who the caller is (stats, routing, updated_by, etc.)
59
- end
60
- ```
56
+ `CallingServiceMiddleware` is registered via the railtie and runs after the
57
+ `ApiKey` middleware. When no auth middleware has populated the caller identity
58
+ env var (`Stitches.configuration.env_var_to_hold_api_client`), it reads the
59
+ calling service header and populates it with a `CallingServiceClient` struct.
61
60
 
62
- For controllers that don't inherit from your ApiController (e.g. Devise
63
- controllers), include the concern directly:
61
+ **This means existing code that reads the caller identity object's `.name`
62
+ continues to work with no changes** — the value now comes from the header
63
+ instead of a DB lookup.
64
64
 
65
- ```ruby
66
- class Api::V1::SessionsController < Devise::SessionsController
67
- include Stitches::CallingServiceName
68
- end
69
- ```
65
+ `CallingServiceClient` implements:
66
+ - `.name` the header value (e.g. "my-app")
67
+ - `.id` — nil
68
+ - `.key` — nil
70
69
 
71
- **Resolution order:**
70
+ **Resolution order (for `request.env[env_var_to_hold_api_client]`):**
72
71
 
73
- 1. `X-StitchFix-Calling-Service` request header (preferred)
74
- 2. `api_client&.name` the stitches-authenticated ApiClient (works when API keys are enabled)
75
- 3. `""` (empty string) fallback when neither is available
72
+ 1. If the `ApiKey` middleware authenticated a key → `ApiClient` record (has `.name`, `.id`, `.key`)
73
+ 2. If JWT or other auth middleware set the env var that object (e.g. a user)
74
+ 3. If neither ran, but `X-StitchFix-Calling-Service` header is present `CallingServiceClient` struct
75
+ 4. If nothing → nil
76
76
 
77
- This is backwards compatible. When `disable_api_key_support` is false, the
78
- stitches middleware still populates `api_client`, so the fallback returns the
79
- authenticated name. When true, callers identify themselves via the header.
77
+ **Middleware ordering:** `ApiKey` `CallingServiceMiddleware` `ValidMimeType`
80
78
 
81
- **Client-side setup:**
79
+ #### CallingServiceName concern (opt-in)
82
80
 
83
- Clients using `stitchfix-api_client` (v5.1+) automatically send this header.
84
- The value is derived from the `app_name` config option or `ENV["APP_NAME"]`:
81
+ For cases where you want strictly the header value (e.g. metrics tags where
82
+ you never want a human user's name), include the concern:
85
83
 
86
84
  ```ruby
87
- MyServiceClient.new(
88
- endpoint: "https://my-service.internal",
89
- app_name: "kingmob" # sent as X-StitchFix-Calling-Service
90
- )
85
+ class Api::ApiController < ActionController::API
86
+ include Stitches::CallingServiceName
87
+ end
88
+
89
+ # Returns the header value only, empty string if absent:
90
+ calling_service_name # => "my-app" or ""
91
91
  ```
92
92
 
93
- If `app_name` is not set, `ENV["APP_NAME"]` is used (typically set in
94
- production deployments). If neither is available, the header is not sent.
93
+ #### Configuration
94
+
95
+ The header name is configurable (defaults to `X-StitchFix-Calling-Service`):
96
+
97
+ ```ruby
98
+ Stitches.configure do |config|
99
+ config.calling_service_header = "X-My-Custom-Header"
100
+ end
101
+ ```
95
102
 
96
103
  #### Security considerations
97
104
 
98
105
  `X-StitchFix-Calling-Service` is a **self-declared, unsigned header**. Any
99
106
  caller that can reach your service can set it to any value. This means:
100
107
 
101
- - **Do not use `calling_service_name` as a sole authorization mechanism for
102
- sensitive operations** (e.g. granting admin tokens, bypassing verification
103
- flows) unless the network layer guarantees that only the legitimate service
104
- can reach the endpoint.
108
+ - **Do not use it as a sole authorization mechanism** for sensitive operations
109
+ unless the network layer guarantees that only the legitimate service can
110
+ reach the endpoint.
105
111
  - **Strip this header at public ingress points** (Traefik, ALB, API gateway)
106
112
  to prevent external callers from spoofing internal service identities.
107
- Internal-only traffic should be the only source of this header.
108
- - **This header replaces API key-based identity, not authorization.** API
109
- keys provided a weak form of authentication (possession of a shared secret).
110
- This header provides identification only. If you need to verify the caller's
111
- identity cryptographically, use mTLS or a service mesh AuthorizationPolicy.
113
+ - **This header replaces API key-based identity, not authorization.** It
114
+ provides identification only. If you need to verify the caller's identity
115
+ cryptographically, use mTLS or a service mesh AuthorizationPolicy.
112
116
  - **Safe uses:** stats tagging, logging, `updated_by` audit fields, routing
113
117
  hints, non-security-critical behavioral branching.
114
118
  - **Unsafe without network enforcement:** access control decisions, privilege
@@ -0,0 +1,15 @@
1
+ module Stitches
2
+ # Lightweight stand-in for ApiClient when the caller is identified by
3
+ # the calling service header rather than an API key lookup. Implements
4
+ # the same interface (.name, .id, .key) so existing code that reads
5
+ # the caller identity object continues to work.
6
+ CallingServiceClient = Struct.new(:name) do
7
+ def id
8
+ nil
9
+ end
10
+
11
+ def key
12
+ nil
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,28 @@
1
+ require_relative 'calling_service_client'
2
+
3
+ module Stitches
4
+ # Rack middleware that populates the caller identity env var from the
5
+ # calling service header when no other auth middleware has already set
6
+ # it. This allows existing code that reads the caller identity object
7
+ # to work transparently after API keys are disabled.
8
+ class CallingServiceMiddleware
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ client_key = Stitches.configuration.env_var_to_hold_api_client
15
+
16
+ unless env[client_key]
17
+ header_name = Stitches.configuration.calling_service_header
18
+ rack_key = "HTTP_#{header_name.upcase.tr('-', '_')}"
19
+
20
+ if (name = env[rack_key]).present?
21
+ env[client_key] = CallingServiceClient.new(name)
22
+ end
23
+ end
24
+
25
+ @app.call(env)
26
+ end
27
+ end
28
+ end
@@ -2,9 +2,7 @@ module Stitches
2
2
  module CallingServiceName
3
3
  def calling_service_name
4
4
  @calling_service_name ||=
5
- request.headers[Stitches.configuration.calling_service_header].presence ||
6
- request.env[Stitches.configuration.env_var_to_hold_api_client]&.name ||
7
- ""
5
+ request.headers[Stitches.configuration.calling_service_header].presence || ""
8
6
  end
9
7
  end
10
8
  end
@@ -1,10 +1,12 @@
1
1
  require 'stitches/api_key'
2
2
  require 'stitches/valid_mime_type'
3
3
  require 'stitches/api_client_access_wrapper'
4
+ require 'stitches/calling_service_middleware'
4
5
 
5
6
  module Stitches
6
7
  class Railtie < Rails::Railtie
7
8
  config.app_middleware.use Stitches::ApiKey
9
+ config.app_middleware.use Stitches::CallingServiceMiddleware
8
10
  config.app_middleware.use Stitches::ValidMimeType
9
11
  end
10
12
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stitches
4
- VERSION = '5.1.0.RC3'
4
+ VERSION = '5.1.0.RC5'
5
5
  end
@@ -0,0 +1,72 @@
1
+ require 'rails_helper'
2
+
3
+ describe Stitches::CallingServiceMiddleware do
4
+ let(:app) { ->(env) { [200, env, "OK"] } }
5
+ let(:middleware) { described_class.new(app) }
6
+ let(:env) { Rack::MockRequest.env_for("/api/test", method: "POST") }
7
+
8
+ let(:client_key) { Stitches.configuration.env_var_to_hold_api_client }
9
+
10
+ describe "#call" do
11
+ context "when the header is present and env var is not set" do
12
+ before { env["HTTP_X_STITCHFIX_CALLING_SERVICE"] = "my-app" }
13
+
14
+ it "populates the env var with a CallingServiceClient" do
15
+ _status, result_env, _body = middleware.call(env)
16
+ client = result_env[client_key]
17
+
18
+ expect(client).to be_a(Stitches::CallingServiceClient)
19
+ expect(client.name).to eq("my-app")
20
+ expect(client.id).to be_nil
21
+ expect(client.key).to be_nil
22
+ end
23
+ end
24
+
25
+ context "when the env var is already set (API key or JWT auth ran)" do
26
+ let(:existing_client) { double("ApiClient", name: "existing-client", id: 42) }
27
+
28
+ before do
29
+ env[client_key] = existing_client
30
+ env["HTTP_X_STITCHFIX_CALLING_SERVICE"] = "some-service"
31
+ end
32
+
33
+ it "does not overwrite the existing value" do
34
+ _status, result_env, _body = middleware.call(env)
35
+ expect(result_env[client_key]).to eq(existing_client)
36
+ end
37
+ end
38
+
39
+ context "when the header is absent and env var is not set" do
40
+ it "leaves the env var nil" do
41
+ _status, result_env, _body = middleware.call(env)
42
+ expect(result_env[client_key]).to be_nil
43
+ end
44
+ end
45
+
46
+ context "when the header is blank" do
47
+ before { env["HTTP_X_STITCHFIX_CALLING_SERVICE"] = "" }
48
+
49
+ it "leaves the env var nil" do
50
+ _status, result_env, _body = middleware.call(env)
51
+ expect(result_env[client_key]).to be_nil
52
+ end
53
+ end
54
+
55
+ context "with a custom configured header" do
56
+ before do
57
+ Stitches.configuration.calling_service_header = "X-Custom-Caller"
58
+ env["HTTP_X_CUSTOM_CALLER"] = "custom-service"
59
+ end
60
+
61
+ after { Stitches.configuration.reset_to_defaults! }
62
+
63
+ it "reads from the configured header" do
64
+ _status, result_env, _body = middleware.call(env)
65
+ client = result_env[client_key]
66
+
67
+ expect(client).to be_a(Stitches::CallingServiceClient)
68
+ expect(client.name).to eq("custom-service")
69
+ end
70
+ end
71
+ end
72
+ end
@@ -2,8 +2,7 @@ require 'rails_helper'
2
2
 
3
3
  describe Stitches::CallingServiceName do
4
4
  let(:headers) { {} }
5
- let(:env) { {} }
6
- let(:fake_request) { double("request", headers: headers, env: env) }
5
+ let(:fake_request) { double("request", headers: headers) }
7
6
  let(:fake_controller) {
8
7
  req = fake_request
9
8
  Object.new.tap { |c|
@@ -13,53 +12,27 @@ describe Stitches::CallingServiceName do
13
12
  }
14
13
 
15
14
  describe "#calling_service_name" do
16
- context "when X-StitchFix-Calling-Service header is present" do
17
- let(:headers) { {"X-StitchFix-Calling-Service" => "kingmob"} }
15
+ context "when the header is present" do
16
+ let(:headers) { {"X-StitchFix-Calling-Service" => "my-app"} }
18
17
 
19
18
  it "returns the header value" do
20
- expect(fake_controller.calling_service_name).to eq("kingmob")
21
- end
22
-
23
- context "and env var client is also present" do
24
- let(:env) { {Stitches.configuration.env_var_to_hold_api_client => double(name: "other-service")} }
25
-
26
- it "prefers the header" do
27
- expect(fake_controller.calling_service_name).to eq("kingmob")
28
- end
19
+ expect(fake_controller.calling_service_name).to eq("my-app")
29
20
  end
30
21
  end
31
22
 
32
- context "when header is absent but env var client is present" do
33
- let(:env) { {Stitches.configuration.env_var_to_hold_api_client => double(name: "mobile-service")} }
34
-
35
- it "returns the client name from env" do
36
- expect(fake_controller.calling_service_name).to eq("mobile-service")
37
- end
38
- end
39
-
40
- context "when header is blank" do
41
- let(:headers) { {"X-StitchFix-Calling-Service" => ""} }
42
- let(:env) { {Stitches.configuration.env_var_to_hold_api_client => double(name: "fallback-service")} }
43
-
44
- it "treats blank as absent and falls through to env var client" do
45
- expect(fake_controller.calling_service_name).to eq("fallback-service")
46
- end
47
- end
48
-
49
- context "when neither header nor env var client is present" do
23
+ context "when the header is absent" do
50
24
  it "returns empty string" do
51
25
  expect(fake_controller.calling_service_name).to eq("")
52
26
  end
53
27
  end
54
28
 
55
- context "when env var client is nil" do
56
- let(:env) { {Stitches.configuration.env_var_to_hold_api_client => nil} }
29
+ context "when the header is blank" do
30
+ let(:headers) { {"X-StitchFix-Calling-Service" => ""} }
57
31
 
58
32
  it "returns empty string" do
59
33
  expect(fake_controller.calling_service_name).to eq("")
60
34
  end
61
35
  end
62
-
63
36
  end
64
37
 
65
38
  describe "configurable header" do
@@ -70,13 +43,8 @@ describe Stitches::CallingServiceName do
70
43
  context "when configured to a custom header" do
71
44
  let(:headers) { {"X-Custom-Caller" => "my-service"} }
72
45
 
73
- before do
74
- Stitches.configuration.calling_service_header = "X-Custom-Caller"
75
- end
76
-
77
- after do
78
- Stitches.configuration.reset_to_defaults!
79
- end
46
+ before { Stitches.configuration.calling_service_header = "X-Custom-Caller" }
47
+ after { Stitches.configuration.reset_to_defaults! }
80
48
 
81
49
  it "reads from the configured header" do
82
50
  expect(fake_controller.calling_service_name).to eq("my-service")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stitches
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.0.RC3
4
+ version: 5.1.0.RC5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stitch Fix Engineering
@@ -174,6 +174,8 @@ files:
174
174
  - lib/stitches/api_key.rb
175
175
  - lib/stitches/api_migration_generator.rb
176
176
  - lib/stitches/api_version_constraint.rb
177
+ - lib/stitches/calling_service_client.rb
178
+ - lib/stitches/calling_service_middleware.rb
177
179
  - lib/stitches/calling_service_name.rb
178
180
  - lib/stitches/configuration.rb
179
181
  - lib/stitches/deprecation.rb
@@ -209,6 +211,7 @@ files:
209
211
  - owners.json
210
212
  - spec/api_key_middleware_spec.rb
211
213
  - spec/api_version_constraint_middleware_spec.rb
214
+ - spec/calling_service_middleware_spec.rb
212
215
  - spec/calling_service_name_spec.rb
213
216
  - spec/configuration_spec.rb
214
217
  - spec/deprecation_spec.rb
@@ -306,6 +309,7 @@ summary: You'll be in stitches at how easy it is to create a service at Stitch F
306
309
  test_files:
307
310
  - spec/api_key_middleware_spec.rb
308
311
  - spec/api_version_constraint_middleware_spec.rb
312
+ - spec/calling_service_middleware_spec.rb
309
313
  - spec/calling_service_name_spec.rb
310
314
  - spec/configuration_spec.rb
311
315
  - spec/deprecation_spec.rb