stitches 4.1.0RC2 → 4.2.0.RC1
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 +4 -4
- data/.circleci/config.yml +0 -38
- data/.gitignore +1 -0
- data/README.md +36 -4
- data/lib/stitches/add_disabled_at_to_api_clients_generator.rb +18 -0
- data/lib/stitches/allowlist_middleware.rb +20 -6
- data/lib/stitches/api_client_access_wrapper.rb +42 -11
- data/lib/stitches/api_key.rb +5 -5
- data/lib/stitches/configuration.rb +4 -0
- data/lib/stitches/generator_files/db/migrate/add_disabled_at_to_api_clients.rb +9 -0
- data/lib/stitches/generator_files/db/migrate/create_api_clients.rb +1 -0
- data/lib/stitches/render_timestamps_in_iso8601_in_json.rb +2 -6
- data/lib/stitches/valid_mime_type.rb +1 -1
- data/lib/stitches/version.rb +1 -1
- data/lib/stitches_norailtie.rb +1 -0
- data/spec/api_key_middleware_spec.rb +368 -0
- data/spec/api_version_constraint_middleware_spec.rb +58 -0
- data/spec/configuration_spec.rb +1 -1
- data/spec/deprecation_spec.rb +1 -1
- data/spec/error_spec.rb +1 -1
- data/spec/errors_spec.rb +3 -3
- data/spec/fake_app/.rspec +1 -0
- data/spec/fake_app/.ruby-version +1 -0
- data/spec/fake_app/Gemfile +53 -0
- data/spec/fake_app/README.md +24 -0
- data/spec/fake_app/Rakefile +6 -0
- data/spec/fake_app/app/assets/config/manifest.js +2 -0
- data/spec/fake_app/app/assets/stylesheets/application.css +15 -0
- data/spec/fake_app/app/controllers/api.rb +2 -0
- data/spec/fake_app/app/controllers/api/api_controller.rb +31 -0
- data/spec/fake_app/app/controllers/api/v1.rb +2 -0
- data/spec/fake_app/app/controllers/api/v1/hellos_controller.rb +7 -0
- data/spec/fake_app/app/controllers/api/v1/pings_controller.rb +16 -0
- data/spec/fake_app/app/controllers/api/v2.rb +2 -0
- data/spec/fake_app/app/controllers/api/v2/hellos_controller.rb +7 -0
- data/spec/fake_app/app/controllers/api/v2/pings_controller.rb +16 -0
- data/spec/fake_app/app/controllers/application_controller.rb +2 -0
- data/spec/fake_app/app/helpers/application_helper.rb +2 -0
- data/spec/fake_app/app/models/api_client.rb +2 -0
- data/spec/fake_app/app/models/application_record.rb +3 -0
- data/spec/fake_app/bin/rails +4 -0
- data/spec/fake_app/bin/rake +4 -0
- data/spec/fake_app/bin/setup +33 -0
- data/spec/fake_app/config.ru +6 -0
- data/spec/fake_app/config/application.rb +35 -0
- data/spec/fake_app/config/boot.rb +3 -0
- data/spec/fake_app/config/credentials.yml.enc +1 -0
- data/spec/fake_app/config/database.yml +25 -0
- data/spec/fake_app/config/environment.rb +5 -0
- data/spec/fake_app/config/environments/development.rb +71 -0
- data/spec/fake_app/config/environments/production.rb +109 -0
- data/spec/fake_app/config/environments/test.rb +52 -0
- data/spec/fake_app/config/initializers/assets.rb +12 -0
- data/spec/fake_app/config/initializers/cookies_serializer.rb +5 -0
- data/spec/fake_app/config/initializers/filter_parameter_logging.rb +6 -0
- data/spec/fake_app/config/initializers/stitches.rb +24 -0
- data/spec/fake_app/config/locales/en.yml +33 -0
- data/spec/fake_app/config/master.key +1 -0
- data/spec/fake_app/config/puma.rb +43 -0
- data/spec/fake_app/config/routes.rb +17 -0
- data/spec/fake_app/config/storage.yml +34 -0
- data/spec/fake_app/db/development.sqlite3 +0 -0
- data/spec/fake_app/db/migrate/20210802153118_enable_uuid_ossp_extension.rb +7 -0
- data/spec/fake_app/db/migrate/20210802153119_create_api_clients.rb +14 -0
- data/spec/fake_app/db/schema_missing_disabled_at.rb +12 -0
- data/spec/fake_app/db/schema_missing_enabled.rb +11 -0
- data/spec/fake_app/db/schema_modern.rb +13 -0
- data/spec/fake_app/db/seeds.rb +7 -0
- data/spec/fake_app/db/test.sqlite3 +0 -0
- data/spec/fake_app/doc/api.md +4 -0
- data/spec/fake_app/lib/tasks/generate_api_key.rake +10 -0
- data/spec/fake_app/public/404.html +67 -0
- data/spec/fake_app/public/422.html +67 -0
- data/spec/fake_app/public/500.html +66 -0
- data/spec/fake_app/public/apple-touch-icon-precomposed.png +0 -0
- data/spec/fake_app/public/apple-touch-icon.png +0 -0
- data/spec/fake_app/public/favicon.ico +0 -0
- data/spec/fake_app/public/javascripts/apitome/application.js +31 -0
- data/spec/fake_app/public/robots.txt +1 -0
- data/spec/fake_app/public/stylesheets/apitome/application.css +269 -0
- data/spec/fake_app/test/application_system_test_case.rb +5 -0
- data/spec/fake_app/test/test_helper.rb +13 -0
- data/spec/fake_app/tmp/development_secret.txt +1 -0
- data/spec/integration/add_to_rails_app_spec.rb +9 -1
- data/spec/rails_helper.rb +64 -0
- data/spec/valid_mime_type_middleware_spec.rb +59 -0
- data/spec/valid_mime_type_spec.rb +6 -4
- data/stitches.gemspec +2 -0
- metadata +165 -9
- data/spec/api_client_access_wrapper_spec.rb +0 -52
- data/spec/api_key_spec.rb +0 -208
- data/spec/api_version_constraint_spec.rb +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff331a78189008795f3789617197c5bf4ac77fb9b2db8fe036256903668656ba
|
|
4
|
+
data.tar.gz: 2757ff4ef4739e826b60406726547b225693f44846d811c0c8fd0afa093eb55a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c6a9e99b2f0410ba3e1bff9c851fde7ec923154388667ea86c7a080354eb3943a2188e33336cc23e5ea530a98851e1fdf00ceb750cd246ebc95a3f43f2ecb95e
|
|
7
|
+
data.tar.gz: ed1f7f688b16629940ccd0f7b201ad6b47e9fcfdc005969dd3fdff7264fb45bd7c31d572a1dda5a962386bf567e07bb1823dbab66ccea40e8bed9650d67b59c9
|
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
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
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
@@ -143,6 +171,10 @@ Also, the integration test does a lot of "testing the implementation", but since
|
|
|
143
171
|
failing with a successful result, we have to make sure that the various `inject_into_file` calls are actually working. Do not do
|
|
144
172
|
any fancy refactors here, just keep it up to date.
|
|
145
173
|
|
|
174
|
+
## Releases
|
|
175
|
+
|
|
176
|
+
See the release process for open source gems in the Stitch Fix engineering wiki under technical topics.
|
|
177
|
+
|
|
146
178
|
---
|
|
147
179
|
|
|
148
180
|
Provided with love by your friends at [Stitch Fix Engineering](http://technology.stitchfix.com)
|
|
@@ -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]
|
|
7
|
-
@except = options[:except]
|
|
6
|
+
@configuration = options[:configuration]
|
|
7
|
+
@except = options[:except]
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
raise ":except must be a Regexp"
|
|
11
|
-
end
|
|
9
|
+
allowlist_regex
|
|
12
10
|
end
|
|
11
|
+
|
|
13
12
|
def call(env)
|
|
14
|
-
if
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
data/lib/stitches/api_key.rb
CHANGED
|
@@ -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 =~ /#{
|
|
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[
|
|
33
|
-
env[
|
|
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" => "#{
|
|
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,9 @@
|
|
|
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 %>
|
|
6
|
+
def change
|
|
7
|
+
add_column :api_clients, :disabled_at, "timestamp with time zone", null: true
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -9,6 +9,7 @@ class CreateApiClients < ActiveRecord::Migration
|
|
|
9
9
|
t.column :key, "uuid default uuid_generate_v4()", null: false
|
|
10
10
|
t.column :enabled, :bool, null: false, default: true
|
|
11
11
|
t.column :created_at, "timestamp with time zone default now()", null: false
|
|
12
|
+
t.column :disabled_at, "timestamp with time zone", null: true
|
|
12
13
|
end
|
|
13
14
|
add_index :api_clients, [:name]
|
|
14
15
|
add_index :api_clients, [:key], unique: true
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
require 'active_support/time_with_zone'
|
|
2
2
|
|
|
3
3
|
class ActiveSupport::TimeWithZone
|
|
4
|
-
# We want dates to
|
|
4
|
+
# We want dates to be a) in UTC and b) in ISO8601 always
|
|
5
5
|
def as_json(options = {})
|
|
6
|
-
|
|
7
|
-
super
|
|
8
|
-
else
|
|
9
|
-
utc.as_json(options)
|
|
10
|
-
end
|
|
6
|
+
utc.iso8601
|
|
11
7
|
end
|
|
12
8
|
end
|
|
13
9
|
|
|
@@ -20,7 +20,7 @@ module Stitches
|
|
|
20
20
|
|
|
21
21
|
def do_call(env)
|
|
22
22
|
accept = String(env["HTTP_ACCEPT"])
|
|
23
|
-
if (
|
|
23
|
+
if (%r{application/json}.match?(accept) && %r{version=\d+}.match?(accept)) || %r{application/protobuf}.match?(accept)
|
|
24
24
|
@app.call(env)
|
|
25
25
|
else
|
|
26
26
|
not_acceptable_response(accept)
|
data/lib/stitches/version.rb
CHANGED
data/lib/stitches_norailtie.rb
CHANGED
|
@@ -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
|