stitches 4.0.2 → 4.2.0.RC3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +0 -38
  3. data/.gitignore +1 -0
  4. data/README.md +32 -4
  5. data/lib/stitches/add_disabled_at_to_api_clients_generator.rb +18 -0
  6. data/lib/stitches/allowlist_middleware.rb +20 -6
  7. data/lib/stitches/api_client_access_wrapper.rb +42 -11
  8. data/lib/stitches/api_key.rb +5 -5
  9. data/lib/stitches/configuration.rb +4 -0
  10. data/lib/stitches/generator_files/db/migrate/add_disabled_at_to_api_clients.rb +5 -0
  11. data/lib/stitches/generator_files/db/migrate/add_enabled_to_api_clients.rb +1 -5
  12. data/lib/stitches/generator_files/db/migrate/create_api_clients.rb +2 -5
  13. data/lib/stitches/generator_files/db/migrate/enable_uuid_ossp_extension.rb +1 -5
  14. data/lib/stitches/version.rb +1 -1
  15. data/lib/stitches_norailtie.rb +1 -0
  16. data/spec/api_key_middleware_spec.rb +368 -0
  17. data/spec/api_version_constraint_middleware_spec.rb +58 -0
  18. data/spec/configuration_spec.rb +1 -1
  19. data/spec/deprecation_spec.rb +1 -1
  20. data/spec/error_spec.rb +1 -1
  21. data/spec/errors_spec.rb +3 -3
  22. data/spec/fake_app/.rspec +1 -0
  23. data/spec/fake_app/.ruby-version +1 -0
  24. data/spec/fake_app/Gemfile +53 -0
  25. data/spec/fake_app/README.md +24 -0
  26. data/spec/fake_app/Rakefile +6 -0
  27. data/spec/fake_app/app/assets/config/manifest.js +2 -0
  28. data/spec/fake_app/app/assets/stylesheets/application.css +15 -0
  29. data/spec/fake_app/app/controllers/api.rb +2 -0
  30. data/spec/fake_app/app/controllers/api/api_controller.rb +31 -0
  31. data/spec/fake_app/app/controllers/api/v1.rb +2 -0
  32. data/spec/fake_app/app/controllers/api/v1/hellos_controller.rb +7 -0
  33. data/spec/fake_app/app/controllers/api/v1/pings_controller.rb +16 -0
  34. data/spec/fake_app/app/controllers/api/v2.rb +2 -0
  35. data/spec/fake_app/app/controllers/api/v2/hellos_controller.rb +7 -0
  36. data/spec/fake_app/app/controllers/api/v2/pings_controller.rb +16 -0
  37. data/spec/fake_app/app/controllers/application_controller.rb +2 -0
  38. data/spec/fake_app/app/helpers/application_helper.rb +2 -0
  39. data/spec/fake_app/app/models/api_client.rb +2 -0
  40. data/spec/fake_app/app/models/application_record.rb +3 -0
  41. data/spec/fake_app/bin/rails +4 -0
  42. data/spec/fake_app/bin/rake +4 -0
  43. data/spec/fake_app/bin/setup +33 -0
  44. data/spec/fake_app/config.ru +6 -0
  45. data/spec/fake_app/config/application.rb +35 -0
  46. data/spec/fake_app/config/boot.rb +3 -0
  47. data/spec/fake_app/config/credentials.yml.enc +1 -0
  48. data/spec/fake_app/config/database.yml +25 -0
  49. data/spec/fake_app/config/environment.rb +5 -0
  50. data/spec/fake_app/config/environments/development.rb +71 -0
  51. data/spec/fake_app/config/environments/production.rb +109 -0
  52. data/spec/fake_app/config/environments/test.rb +52 -0
  53. data/spec/fake_app/config/initializers/assets.rb +12 -0
  54. data/spec/fake_app/config/initializers/cookies_serializer.rb +5 -0
  55. data/spec/fake_app/config/initializers/filter_parameter_logging.rb +6 -0
  56. data/spec/fake_app/config/initializers/stitches.rb +24 -0
  57. data/spec/fake_app/config/locales/en.yml +33 -0
  58. data/spec/fake_app/config/master.key +1 -0
  59. data/spec/fake_app/config/puma.rb +43 -0
  60. data/spec/fake_app/config/routes.rb +17 -0
  61. data/spec/fake_app/config/storage.yml +34 -0
  62. data/spec/fake_app/db/development.sqlite3 +0 -0
  63. data/spec/fake_app/db/migrate/20210802153118_enable_uuid_ossp_extension.rb +7 -0
  64. data/spec/fake_app/db/migrate/20210802153119_create_api_clients.rb +14 -0
  65. data/spec/fake_app/db/schema_missing_disabled_at.rb +12 -0
  66. data/spec/fake_app/db/schema_missing_enabled.rb +11 -0
  67. data/spec/fake_app/db/schema_modern.rb +13 -0
  68. data/spec/fake_app/db/seeds.rb +7 -0
  69. data/spec/fake_app/db/test.sqlite3 +0 -0
  70. data/spec/fake_app/doc/api.md +4 -0
  71. data/spec/fake_app/lib/tasks/generate_api_key.rake +10 -0
  72. data/spec/fake_app/public/404.html +67 -0
  73. data/spec/fake_app/public/422.html +67 -0
  74. data/spec/fake_app/public/500.html +66 -0
  75. data/spec/fake_app/public/apple-touch-icon-precomposed.png +0 -0
  76. data/spec/fake_app/public/apple-touch-icon.png +0 -0
  77. data/spec/fake_app/public/favicon.ico +0 -0
  78. data/spec/fake_app/public/javascripts/apitome/application.js +31 -0
  79. data/spec/fake_app/public/robots.txt +1 -0
  80. data/spec/fake_app/public/stylesheets/apitome/application.css +269 -0
  81. data/spec/fake_app/test/application_system_test_case.rb +5 -0
  82. data/spec/fake_app/test/test_helper.rb +13 -0
  83. data/spec/fake_app/tmp/development_secret.txt +1 -0
  84. data/spec/integration/add_to_rails_app_spec.rb +9 -1
  85. data/spec/rails_helper.rb +64 -0
  86. data/spec/valid_mime_type_middleware_spec.rb +59 -0
  87. data/spec/valid_mime_type_spec.rb +6 -4
  88. data/stitches.gemspec +2 -0
  89. metadata +167 -11
  90. data/spec/api_client_access_wrapper_spec.rb +0 -52
  91. data/spec/api_key_spec.rb +0 -208
  92. data/spec/api_version_constraint_spec.rb +0 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd17ff0af94c6588ec7860bb211a183d3448274fd6d7d6b5cd928a9d8270ecce
4
- data.tar.gz: 10f81db7c4c79ca89bbaa6f43e3d4732fffc04a8facadbbe87ae7c27f7d0e8b7
3
+ metadata.gz: af8d764fc08f299d6288afb01cd9902499648f6ab0060ef2284ed54e52cbb1ab
4
+ data.tar.gz: db185f1144d53b6811b3c8c886307eb0a5dcd2205e495310348050438f0f7bcf
5
5
  SHA512:
6
- metadata.gz: b42a292647e4ad8f85e3fd94992c08e5f97c242778cc49162245b976512ad416fe7e2d081698ac3a8e91af71884821e979af2eb1b95138e8d11f9ed0c11c35e1
7
- data.tar.gz: 9c9404c85ee61a6c2d5453be4a17c305f1d588c490eb0bcd4893632d4f8f32b9f5f6639fd7738ef81e2d77588527f6ebfab7a882f4dff30c3f8200ab04cad642
6
+ metadata.gz: 2acae056f61e3cc352bce03375f8895c953f93c85e58829159c1d9328f9ac165a9bba6506dd9ab71bd4bb6cd82b794853335f293f21e70f43252d0bf5aa72939
7
+ data.tar.gz: 9e2f8e2d3ba91b54d8fa862e9375e736570ea75605a260f2562d033455ba4df0bd8f8c37be0d47598fdd0dde5d693e796f6bd93bf1b202a90ffb5801ce8f082b
data/.circleci/config.yml CHANGED
@@ -161,36 +161,6 @@ jobs:
161
161
  when: on_fail
162
162
  - store_test_results:
163
163
  path: "/tmp/test-results"
164
- ruby-2.7.2-rails-5.2:
165
- docker:
166
- - image: circleci/ruby:2.7.2
167
- auth:
168
- username: "$DOCKERHUB_USERNAME"
169
- password: "$DOCKERHUB_PASSWORD"
170
- environment:
171
- BUNDLE_GEMFILE: Gemfile.rails-5.2
172
- working_directory: "~/stitches"
173
- steps:
174
- - checkout
175
- - run:
176
- name: Check for Gemfile.lock presence
177
- command: ' if (test -f Gemfile.lock) then echo "Dont commit Gemfile.lock (see
178
- https://github.com/stitchfix/eng-wiki/blob/master/architecture-decisions/0009-rubygem-dependencies-will-be-managed-more-explicitly.md)"
179
- 1>&2 ; exit 1 ; else exit 0 ; fi '
180
- - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN
181
- - run: bundle install --full-index
182
- - run: bundle exec rspec --format RspecJunitFormatter --out /tmp/test-results/rspec.xml
183
- --format=doc
184
- - run:
185
- name: Run Additional CI Steps
186
- command: if [ -e bin/additional-ci-steps ]; then bin/additional-ci-steps;
187
- fi
188
- - run:
189
- name: Notify Pager Duty
190
- command: bundle exec y-notify "#eng-runtime-alerts"
191
- when: on_fail
192
- - store_test_results:
193
- path: "/tmp/test-results"
194
164
  workflows:
195
165
  version: 2
196
166
  on-commit:
@@ -202,7 +172,6 @@ workflows:
202
172
  - ruby-2.7.2-rails-6.1
203
173
  - ruby-3.0.0-rails-6.0
204
174
  - ruby-2.7.2-rails-6.0
205
- - ruby-2.7.2-rails-5.2
206
175
  filters:
207
176
  tags:
208
177
  only: /^[0-9]+\.[0-9]+\.[0-9]+(\.?(RC|rc)[-\.]?\w*)?$/
@@ -237,11 +206,6 @@ workflows:
237
206
  filters:
238
207
  tags:
239
208
  only: *1
240
- - ruby-2.7.2-rails-5.2:
241
- context: org-global
242
- filters:
243
- tags:
244
- only: *1
245
209
  scheduled:
246
210
  triggers:
247
211
  - schedule:
@@ -259,5 +223,3 @@ workflows:
259
223
  context: org-global
260
224
  - ruby-2.7.2-rails-6.0:
261
225
  context: org-global
262
- - ruby-2.7.2-rails-5.2:
263
- context: org-global
data/.gitignore CHANGED
@@ -1,5 +1,6 @@
1
1
  pkg
2
2
  spec/reports
3
+ spec/fake_app/log/
3
4
  .vimrc
4
5
  *.sw?
5
6
  .idea/
data/README.md CHANGED
@@ -35,7 +35,7 @@ Then, set it up:
35
35
 
36
36
  ### Upgrading from an older version
37
37
 
38
- - When upgrading to version 4.0.0 you may now take advantage of an in-memory cache
38
+ - When upgrading to version 4.0.0 and above you may now take advantage of an in-memory cache
39
39
 
40
40
  You can enabled it like so
41
41
 
@@ -46,18 +46,46 @@ Stitches.configure do |config|
46
46
  end
47
47
  ```
48
48
 
49
- - If you have a version lower than 3.3.0, you need to run two generators, one of which creates a new database migration on your
50
- `api_clients` table:
49
+ You can also set a leniency for disabled API keys, which will allow old API keys to continue to be used if they have a
50
+ `disabled_at` field set as long as the leniency is not exceeded. Note that if the `disabled_at` field is not populated
51
+ the behavior will remain the same as it always was, and the request will be denied when the `enabled` field is set to
52
+ `true`. If Stitches allows a call due to leniency settings, a log message will be generated with a severity depending on
53
+ how long ago the API key was disabled.
54
+
55
+ ```ruby
56
+ Stitches.configure do |config|
57
+ config.disabled_key_leniency_in_seconds = 3 * 24 * 60 * 60 # Time in seconds, defaults to three days
58
+ config.disabled_key_leniency_error_log_threshold_in_seconds = 2 * 24 * 60 * 60 # Time in seconds, defaults to two days
59
+ end
60
+ ```
61
+
62
+ If a disabled key is used within the `disabled_key_leniency_in_seconds`, it will be allowed.
63
+
64
+ Anytime a disabled key is used a log will be generated. If it is before the
65
+ `disabled_key_leniency_error_log_threshold_in_seconds` it will be a warning log message, if it is after that, it will be
66
+ an error message. `disabled_key_leniency_error_log_threshold_in_seconds` should never be a greater number than
67
+ `disabled_key_leniency_in_seconds`, as this provides an escallating series of warnings before finally disabling access.
68
+
69
+ - If you are upgrading from a version older than 3.3.0 you need to run three generators, two of which create database
70
+ migrations on your `api_clients` table:
51
71
 
52
72
  ```
53
73
  > bin/rails generate stitches:add_enabled_to_api_clients
54
74
  > bin/rails generate stitches:add_deprecation
75
+ > bin/rails generate stitches:add_disabled_at_to_api_clients
55
76
  ```
56
77
 
57
- - If you have a version lower than 3.6.0, you need to run one generator:
78
+ - If you are upgrading from a version between 3.3.0 and 3.5.0 you need to run two generators:
58
79
 
59
80
  ```
60
81
  > bin/rails generate stitches:add_deprecation
82
+ > bin/rails generate stitches:add_disabled_at_to_api_clients
83
+ ```
84
+
85
+ - If you are upgrading from a version between 3.6.0 and 4.0.2 you need to run one generator:
86
+
87
+ ```
88
+ > bin/rails generate stitches:add_disabled_at_to_api_clients
61
89
  ```
62
90
 
63
91
  ## Example Microservice Endpoint
@@ -0,0 +1,18 @@
1
+ require 'rails/generators'
2
+
3
+ module Stitches
4
+ class AddDisabledAtToApiClientsGenerator < Rails::Generators::Base
5
+ include Rails::Generators::Migration
6
+
7
+ source_root(File.expand_path(File.join(File.dirname(__FILE__),"generator_files")))
8
+
9
+ def self.next_migration_number(path)
10
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
11
+ end
12
+
13
+ desc "Upgrade your api_clients table so it includes the `disabled_at` field"
14
+ def update_api_clients_table
15
+ migration_template "db/migrate/add_disabled_at_to_api_clients.rb", "db/migrate/add_disabled_at_to_api_clients.rb"
16
+ end
17
+ end
18
+ end
@@ -3,15 +3,14 @@ module Stitches
3
3
  class AllowlistMiddleware
4
4
  def initialize(app, options={})
5
5
  @app = app
6
- @configuration = options[:configuration] || Stitches.configuration
7
- @except = options[:except] || @configuration.allowlist_regexp
6
+ @configuration = options[:configuration]
7
+ @except = options[:except]
8
8
 
9
- unless @except.nil? || @except.is_a?(Regexp)
10
- raise ":except must be a Regexp"
11
- end
9
+ allowlist_regex
12
10
  end
11
+
13
12
  def call(env)
14
- if @except && @except.match(env["PATH_INFO"])
13
+ if allowlist_regex && allowlist_regex.match(env["PATH_INFO"])
15
14
  @app.call(env)
16
15
  else
17
16
  do_call(env)
@@ -24,5 +23,20 @@ module Stitches
24
23
  raise 'subclass must implement'
25
24
  end
26
25
 
26
+ def configuration
27
+ @configuration || Stitches.configuration
28
+ end
29
+
30
+ private
31
+
32
+ def allowlist_regex
33
+ regex = @except || configuration.allowlist_regexp
34
+
35
+ if !regex.nil? && !regex.is_a?(Regexp)
36
+ raise ":except must be a Regexp"
37
+ end
38
+
39
+ regex
40
+ end
27
41
  end
28
42
  end
@@ -2,26 +2,57 @@ require 'lru_redux'
2
2
 
3
3
  module Stitches::ApiClientAccessWrapper
4
4
 
5
- def self.fetch_for_key(key)
5
+ def self.fetch_for_key(key, configuration)
6
6
  if cache_enabled
7
- fetch_for_key_from_cache(key)
7
+ fetch_for_key_from_cache(key, configuration)
8
8
  else
9
- fetch_for_key_from_db(key)
9
+ fetch_for_key_from_db(key, configuration)
10
10
  end
11
11
  end
12
12
 
13
- def self.fetch_for_key_from_cache(key)
13
+ def self.fetch_for_key_from_cache(key, configuration)
14
14
  api_key_cache.getset(key) do
15
- fetch_for_key_from_db(key)
15
+ fetch_for_key_from_db(key, configuration)
16
16
  end
17
17
  end
18
18
 
19
- def self.fetch_for_key_from_db(key)
20
- if ::ApiClient.column_names.include?("enabled")
21
- ::ApiClient.find_by(key: key, enabled: true)
19
+ def self.fetch_for_key_from_db(key, configuration)
20
+ api_client = ::ApiClient.find_by(key: key)
21
+ return unless api_client
22
+
23
+ unless api_client.respond_to?(:enabled?)
24
+ logger.warn('api_keys is missing "enabled" column. Run "rails g stitches:add_enabled_to_api_clients"')
25
+ return api_client
26
+ end
27
+
28
+ unless api_client.respond_to?(:disabled_at)
29
+ logger.warn('api_keys is missing "disabled_at" column. Run "rails g stitches:add_disabled_at_to_api_clients"')
30
+ end
31
+
32
+ return api_client if api_client.enabled?
33
+
34
+ disabled_at = api_client.respond_to?(:disabled_at) ? api_client.disabled_at : nil
35
+ if disabled_at && disabled_at > configuration.disabled_key_leniency_in_seconds.seconds.ago
36
+ message = "Allowing disabled ApiClient: #{api_client.name} with key #{api_client.key} disabled at #{disabled_at}"
37
+ if disabled_at > configuration.disabled_key_leniency_error_log_threshold_in_seconds.seconds.ago
38
+ logger.warn(message)
39
+ else
40
+ logger.error(message)
41
+ end
42
+ return api_client
43
+ else
44
+ logger.error("Rejecting disabled ApiClient: #{api_client.name} with key #{api_client.key}")
45
+ end
46
+ nil
47
+ end
48
+
49
+ def self.logger
50
+ if defined?(StitchFix::Logger::LogWriter)
51
+ StitchFix::Logger::LogWriter
52
+ elsif defined?(Rails.logger)
53
+ Rails.logger
22
54
  else
23
- ActiveSupport::Deprecation.warn('api_keys is missing "enabled" column. Run "rails g stitches:add_enabled_to_api_clients"')
24
- ::ApiClient.find_by(key: key)
55
+ ::Logger.new('/dev/null')
25
56
  end
26
57
  end
27
58
 
@@ -39,4 +70,4 @@ module Stitches::ApiClientAccessWrapper
39
70
  def self.cache_enabled
40
71
  Stitches.configuration.max_cache_ttl.positive?
41
72
  end
42
- end
73
+ end
@@ -25,12 +25,12 @@ module Stitches
25
25
  def do_call(env)
26
26
  authorization = env["HTTP_AUTHORIZATION"]
27
27
  if authorization
28
- if authorization =~ /#{@configuration.custom_http_auth_scheme}\s+key=(.*)\s*$/
28
+ if authorization =~ /#{configuration.custom_http_auth_scheme}\s+key=(.*)\s*$/
29
29
  key = $1
30
- client = Stitches::ApiClientAccessWrapper.fetch_for_key(key)
30
+ client = Stitches::ApiClientAccessWrapper.fetch_for_key(key, configuration)
31
31
  if client.present?
32
- env[@configuration.env_var_to_hold_api_client_primary_key] = client.id
33
- env[@configuration.env_var_to_hold_api_client] = client
32
+ env[configuration.env_var_to_hold_api_client_primary_key] = client.id
33
+ env[configuration.env_var_to_hold_api_client] = client
34
34
  @app.call(env)
35
35
  else
36
36
  unauthorized_response("key invalid")
@@ -59,7 +59,7 @@ module Stitches
59
59
  def unauthorized_response(reason)
60
60
  status = 401
61
61
  body = "Unauthorized - #{reason}"
62
- header = { "WWW-Authenticate" => "#{@configuration.custom_http_auth_scheme} realm=#{rails_app_module}" }
62
+ header = { "WWW-Authenticate" => "#{configuration.custom_http_auth_scheme} realm=#{rails_app_module}" }
63
63
  Rack::Response.new(body, status, header).finish
64
64
  end
65
65
 
@@ -15,8 +15,12 @@ class Stitches::Configuration
15
15
  @env_var_to_hold_api_client= NonNullString.new("env_var_to_hold_api_client","STITCHES_API_CLIENT")
16
16
  @max_cache_ttl = NonNullInteger.new("max_cache_ttl", 0)
17
17
  @max_cache_size = NonNullInteger.new("max_cache_size", 0)
18
+ @disabled_key_leniency_in_seconds = ActiveSupport::Duration.days(3)
19
+ @disabled_key_leniency_error_log_threshold_in_seconds = ActiveSupport::Duration.days(2)
18
20
  end
19
21
 
22
+ attr_accessor :disabled_key_leniency_in_seconds, :disabled_key_leniency_error_log_threshold_in_seconds
23
+
20
24
  # A RegExp that allows URLS around the mime type and api key requirements.
21
25
  # nil means that ever request must have a proper mime type and api key.
22
26
  attr_reader :allowlist_regexp
@@ -0,0 +1,5 @@
1
+ class AddDisabledAtToApiClients < ActiveRecord::Migration<% if Rails::VERSION::MAJOR >= 5 %>[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]<% end %>
2
+ def change
3
+ add_column :api_clients, :disabled_at, "timestamp with time zone", null: true
4
+ end
5
+ end
@@ -1,8 +1,4 @@
1
- <% if Rails::VERSION::MAJOR >= 5 %>
2
- class AddEnabledToApiClients < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
3
- <% else %>
4
- class AddEnabledToApiClients < ActiveRecord::Migration
5
- <% end %>
1
+ class AddEnabledToApiClients < ActiveRecord::Migration<% if Rails::VERSION::MAJOR >= 5 %>[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]<% end %>
6
2
  def change
7
3
  add_column :api_clients, :enabled, :bool, null: false, default: true
8
4
  remove_index :api_clients, [:name ] # existing one would be unique
@@ -1,14 +1,11 @@
1
- <% if Rails::VERSION::MAJOR >= 5 %>
2
- class CreateApiClients < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
3
- <% else %>
4
- class CreateApiClients < ActiveRecord::Migration
5
- <% end %>
1
+ class CreateApiClients < ActiveRecord::Migration<% if Rails::VERSION::MAJOR >= 5 %>[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]<% end %>
6
2
  def change
7
3
  create_table :api_clients do |t|
8
4
  t.string :name, null: false
9
5
  t.column :key, "uuid default uuid_generate_v4()", null: false
10
6
  t.column :enabled, :bool, null: false, default: true
11
7
  t.column :created_at, "timestamp with time zone default now()", null: false
8
+ t.column :disabled_at, "timestamp with time zone", null: true
12
9
  end
13
10
  add_index :api_clients, [:name]
14
11
  add_index :api_clients, [:key], unique: true
@@ -1,8 +1,4 @@
1
- <% if Rails::VERSION::MAJOR >= 5 %>
2
- class EnableUuidOsspExtension < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
3
- <% else %>
4
- class EnableUuidOsspExtension < ActiveRecord::Migration
5
- <% end %>
1
+ class EnableUuidOsspExtension < ActiveRecord::Migration<% if Rails::VERSION::MAJOR >= 5 %>[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]<% end %>
6
2
  def change
7
3
  enable_extension 'uuid-ossp'
8
4
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stitches
4
- VERSION = '4.0.2'
4
+ VERSION = '4.2.0.RC3'
5
5
  end
@@ -14,6 +14,7 @@ require 'stitches/errors'
14
14
  require 'stitches/api_generator'
15
15
  require 'stitches/add_deprecation_generator'
16
16
  require 'stitches/add_enabled_to_api_clients_generator'
17
+ require 'stitches/add_disabled_at_to_api_clients_generator'
17
18
  require 'stitches/api_version_constraint'
18
19
  require 'stitches/api_key'
19
20
  require 'stitches/deprecation'
@@ -0,0 +1,368 @@
1
+ require 'rails_helper'
2
+ require 'securerandom'
3
+
4
+ class FakeLogger
5
+ # This shouldn't be needed but there's a weird mocking conflict with kernal warn method otherwise
6
+ def warn(message)
7
+ end
8
+ end
9
+
10
+ RSpec.describe "/api/hellos", type: :request do
11
+ let(:uuid) { api_client.key }
12
+ let(:auth_header) { "MyAwesomeInternalScheme key=#{uuid}" }
13
+ let(:allowlist) { nil }
14
+
15
+ before do
16
+ Stitches.configuration.reset_to_defaults!
17
+ Stitches.configuration.custom_http_auth_scheme = 'MyAwesomeInternalScheme'
18
+ Stitches.configuration.allowlist_regexp = allowlist if allowlist
19
+ Stitches::ApiClientAccessWrapper.clear_api_cache
20
+ end
21
+
22
+ def execute_call(auth: auth_header)
23
+ headers = {
24
+ "Accept" => "application/json; version=1"
25
+ }
26
+ headers["Authorization"] = auth if auth
27
+
28
+ get "/api/hellos", headers: headers
29
+ end
30
+
31
+ def expect_unauthorized
32
+ expect(response.body).to include "Unauthorized"
33
+ expect(response.status).to eq 401
34
+ expect(response.headers["WWW-Authenticate"]).to eq("MyAwesomeInternalScheme realm=FakeApp")
35
+ end
36
+
37
+ context "with modern schema" do
38
+ let(:api_client_enabled) { true }
39
+ let(:disabled_at) { nil }
40
+ let!(:api_client) {
41
+ uuid = SecureRandom.uuid
42
+ ApiClient.create(name: "MyApiClient", key: SecureRandom.uuid, enabled: false, created_at: 20.days.ago, disabled_at: 15.days.ago)
43
+ ApiClient.create(name: "MyApiClient", key: uuid, enabled: api_client_enabled, created_at: 10.days.ago, disabled_at: disabled_at)
44
+ }
45
+
46
+ context "when path is not on allowlist" do
47
+ context "when api_client is valid" do
48
+ it "executes the correct controller" do
49
+ execute_call
50
+
51
+ expect(response.body).to include "Hello"
52
+ end
53
+
54
+ it "saves the api_client information used" do
55
+ execute_call
56
+
57
+ expect(response.body).to include "MyApiClient"
58
+ expect(response.body).to include "#{api_client.id}"
59
+ end
60
+
61
+ context "caching is enabled" do
62
+ before do
63
+ allow(ApiClient).to receive(:find_by).and_call_original
64
+
65
+ Stitches.configure do |config|
66
+ config.max_cache_ttl = 5
67
+ config.max_cache_size = 10
68
+ end
69
+ end
70
+
71
+ it "only gets the the api_client information once" do
72
+ execute_call
73
+ execute_call
74
+
75
+ expect(response.body).to include "#{api_client.id}"
76
+ expect(ApiClient).to have_received(:find_by).once
77
+ end
78
+ end
79
+ end
80
+
81
+ context "when api client key does not match" do
82
+ let(:uuid) { SecureRandom.uuid } # random uuid
83
+
84
+ it "rejects request" do
85
+ execute_call
86
+
87
+ expect_unauthorized
88
+ end
89
+ end
90
+
91
+ context "when api client key not enabled" do
92
+ let(:api_client_enabled) { false }
93
+
94
+ context "when disabled_at is not set" do
95
+ it "rejects request" do
96
+ execute_call
97
+
98
+ expect_unauthorized
99
+ end
100
+ end
101
+
102
+ context "when disabled_at is set to a time older than three days ago" do
103
+ let(:disabled_at) { 4.day.ago }
104
+
105
+ it "allows the call" do
106
+ execute_call
107
+
108
+ expect_unauthorized
109
+ end
110
+ end
111
+
112
+ context "when disabled_at is set to a recent time" do
113
+ let(:disabled_at) { 1.day.ago }
114
+
115
+ it "allows the call" do
116
+ execute_call
117
+
118
+ expect(response.body).to include "Hello"
119
+ expect(response.body).to include "MyApiClient"
120
+ expect(response.body).to include "#{api_client.id}"
121
+ end
122
+
123
+ it "warns about the disabled key to log writer when available" do
124
+ stub_const("StitchFix::Logger::LogWriter", FakeLogger.new)
125
+ allow(StitchFix::Logger::LogWriter).to receive(:warn)
126
+
127
+ execute_call
128
+
129
+ expect(StitchFix::Logger::LogWriter).to have_received(:warn).once
130
+ end
131
+
132
+ it "warns about the disabled key to the Rails.logger" do
133
+ allow(Rails.logger).to receive(:warn)
134
+ allow(Rails.logger).to receive(:error)
135
+
136
+ execute_call
137
+
138
+ expect(Rails.logger).to have_received(:warn).once
139
+ expect(Rails.logger).not_to have_received(:error)
140
+ end
141
+ end
142
+
143
+ context "when disabled_at is set to a dangerously long time" do
144
+ let(:disabled_at) { 52.hours.ago }
145
+
146
+ it "allows the call" do
147
+ execute_call
148
+
149
+ expect(response.body).to include "Hello"
150
+ expect(response.body).to include "MyApiClient"
151
+ expect(response.body).to include "#{api_client.id}"
152
+ end
153
+
154
+ it "logs error about the disabled key to log writer when available" do
155
+ stub_const("StitchFix::Logger::LogWriter", FakeLogger.new)
156
+ allow(StitchFix::Logger::LogWriter).to receive(:error)
157
+
158
+ execute_call
159
+
160
+ expect(StitchFix::Logger::LogWriter).to have_received(:error).once
161
+ end
162
+
163
+ it "logs error about the disabled key to the Rails.logger" do
164
+ allow(Rails.logger).to receive(:warn)
165
+ allow(Rails.logger).to receive(:error)
166
+
167
+ execute_call
168
+
169
+ expect(Rails.logger).to have_received(:error).once
170
+ expect(Rails.logger).not_to have_received(:warn)
171
+ end
172
+ end
173
+
174
+ context "when disabled_at is set to an unacceptably long time" do
175
+ let(:disabled_at) { 5.days.ago }
176
+
177
+ it "forbids the call" do
178
+ execute_call
179
+
180
+ expect_unauthorized
181
+ end
182
+
183
+ it "logs error about the disabled key to log writer when available" do
184
+ stub_const("StitchFix::Logger::LogWriter", FakeLogger.new)
185
+ allow(StitchFix::Logger::LogWriter).to receive(:error)
186
+
187
+ execute_call
188
+
189
+ expect(StitchFix::Logger::LogWriter).to have_received(:error).once
190
+ end
191
+
192
+ it "logs error about the disabled key to the Rails.logger" do
193
+ allow(Rails.logger).to receive(:warn)
194
+ allow(Rails.logger).to receive(:error)
195
+
196
+ execute_call
197
+
198
+ expect(Rails.logger).to have_received(:error).once
199
+ expect(Rails.logger).not_to have_received(:warn)
200
+ end
201
+ end
202
+
203
+ context "custom leniency is set" do
204
+ before do
205
+ Stitches.configuration.disabled_key_leniency_in_seconds = 100
206
+ Stitches.configuration.disabled_key_leniency_error_log_threshold_in_seconds = 50
207
+ end
208
+
209
+ context "when disabled_at is set to an unacceptably long time" do
210
+ let(:disabled_at) { 101.seconds.ago }
211
+
212
+ it "forbids the call" do
213
+ allow(Rails.logger).to receive(:error)
214
+ execute_call
215
+
216
+ expect_unauthorized
217
+ expect(Rails.logger).to have_received(:error).once
218
+ end
219
+ end
220
+
221
+ context "when disabled_at is set to a dangerously long time" do
222
+ let(:disabled_at) { 75.seconds.ago }
223
+
224
+ it "allows the call" do
225
+ allow(Rails.logger).to receive(:error)
226
+
227
+ execute_call
228
+
229
+ expect(response.body).to include "Hello"
230
+ expect(Rails.logger).to have_received(:error).once
231
+ end
232
+ end
233
+
234
+ context "when disabled_at is set to a short time ago" do
235
+ let(:disabled_at) { 25.seconds.ago }
236
+
237
+ it "allows the call" do
238
+ allow(Rails.logger).to receive(:warn)
239
+
240
+ execute_call
241
+
242
+ expect(response.body).to include "Hello"
243
+ expect(Rails.logger).to have_received(:warn).once
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ context "when authorization header is missing" do
250
+ it "rejects request" do
251
+ execute_call(auth: nil)
252
+
253
+ expect_unauthorized
254
+ end
255
+ end
256
+
257
+ context "when scheme does not match" do
258
+ it "rejects request" do
259
+ execute_call(auth: "OtherScheme key=#{uuid}")
260
+
261
+ expect_unauthorized
262
+ end
263
+ end
264
+ end
265
+
266
+ context "when path is on allowlist" do
267
+ let(:allowlist) { /.*hello.*/ }
268
+
269
+ context "when api_client is valid" do
270
+ it "executes the correct controller" do
271
+ execute_call
272
+
273
+ expect(response.body).to include "Hello"
274
+ end
275
+
276
+ it "does not save the api_client information used" do
277
+ execute_call
278
+
279
+ expect(response.body).to include "NameNotFound"
280
+ expect(response.body).to include "IdNotFound"
281
+ end
282
+ end
283
+
284
+ context "when api client key does not match" do
285
+ let(:uuid) { SecureRandom.uuid } # random uuid
286
+
287
+ it "executes the correct controller" do
288
+ execute_call
289
+
290
+ expect(response.body).to include "Hello"
291
+ end
292
+ end
293
+ end
294
+ end
295
+
296
+ context "when schema is old and missing disabled_at field" do
297
+ around(:each) do |example|
298
+ load 'fake_app/db/schema_missing_disabled_at.rb'
299
+ ApiClient.reset_column_information
300
+ example.run
301
+ load 'fake_app/db/schema_modern.rb'
302
+ ApiClient.reset_column_information
303
+ end
304
+
305
+ context "when api_client is valid" do
306
+ let!(:api_client) {
307
+ uuid = SecureRandom.uuid
308
+ ApiClient.create(name: "MyApiClient", key: uuid, created_at: Time.now(), enabled: true)
309
+ }
310
+
311
+ it "executes the correct controller" do
312
+ execute_call
313
+
314
+ expect(response.body).to include "Hello"
315
+ end
316
+
317
+ it "saves the api_client information used" do
318
+ execute_call
319
+
320
+ expect(response.body).to include "MyApiClient"
321
+ expect(response.body).to include "#{api_client.id}"
322
+ end
323
+ end
324
+
325
+ context "when api_client is not enabled" do
326
+ let!(:api_client) {
327
+ uuid = SecureRandom.uuid
328
+ ApiClient.create(name: "MyApiClient", key: uuid, created_at: Time.now(), enabled: false)
329
+ }
330
+
331
+ it "rejects request" do
332
+ execute_call
333
+
334
+ expect_unauthorized
335
+ end
336
+ end
337
+ end
338
+
339
+ context "when schema is old and missing enabled field" do
340
+ around(:each) do |example|
341
+ load 'fake_app/db/schema_missing_enabled.rb'
342
+ ApiClient.reset_column_information
343
+ example.run
344
+ load 'fake_app/db/schema_modern.rb'
345
+ ApiClient.reset_column_information
346
+ end
347
+
348
+ let!(:api_client) {
349
+ uuid = SecureRandom.uuid
350
+ ApiClient.create(name: "MyApiClient", key: uuid, created_at: Time.now())
351
+ }
352
+
353
+ context "when api_client is valid" do
354
+ it "executes the correct controller" do
355
+ execute_call
356
+
357
+ expect(response.body).to include "Hello"
358
+ end
359
+
360
+ it "saves the api_client information used" do
361
+ execute_call
362
+
363
+ expect(response.body).to include "MyApiClient"
364
+ expect(response.body).to include "#{api_client.id}"
365
+ end
366
+ end
367
+ end
368
+ end