startback 1.1.0 → 1.2.1

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: 56aaefd008d296d55c857bf9c0e392efc13bc34e14d074a65e60b229cbaa1857
4
- data.tar.gz: '028e0dcffa18cb2ae93c635198feb4633ba3dbdb3f3f95a56a925faea244eaf1'
3
+ metadata.gz: 72d21c18d06f3f3c99a95b4ebe7573b9fc61021579f707501a2f00652007df03
4
+ data.tar.gz: 131a14bfcc89c7a04414f044c0ad02aacc6dbf95920add865eb14ee4ecb26e78
5
5
  SHA512:
6
- metadata.gz: eb270f0d0061e112b7b783b102566c58a3f50a963029153d56be1c7219a84b82ec6fc2439ca32d2fe9c3a1c772b95a973265412d1c72dbef5bf03f4ea3c6d251
7
- data.tar.gz: 8cb20a842aacaf22c1063192a269dfb053894bae994569e03332aec42a90d3e827f69b9b63f1e69bc647a91e0899cdf02cb124536bdd7737c18ac0a446f60204
6
+ metadata.gz: ceee786b3b488f7a3f0b5fcbe831ee5318cc017a19c2f2771252206ff8a3319dc6b2165f41f774f29b1ee32f50adcdc2e154436a4b640c294c12a1c1e3c84c1b
7
+ data.tar.gz: 567a6f507af84f76df13eb64fa333aeb51079fcd1efe2950bdda027ee8b3036b13b26fbba3fae8f104cff1e22ef56701dee25f773f96b0c1c957ad255a246524
@@ -94,6 +94,10 @@ module Startback
94
94
  status 428
95
95
  end
96
96
 
97
+ class TooManyRequestsError < BadRequestError
98
+ status 429
99
+ end
100
+
97
101
  class InternalServerError < Error
98
102
  status 500
99
103
  keep_error(true)
@@ -0,0 +1,5 @@
1
+ module Startback
2
+ class Operation
3
+ extend Security::RateLimit
4
+ end # class Operation
5
+ end # module Startback
@@ -0,0 +1 @@
1
+ require_relative 'ext/operation'
@@ -0,0 +1,25 @@
1
+ module Startback
2
+ module Security
3
+ module RateLimit
4
+
5
+ def rate_limit(options = {})
6
+ @rate_limit = options
7
+ end
8
+
9
+ def has_rate_limit?
10
+ !!@rate_limit
11
+ end
12
+
13
+ def rate_limit_options(op, defaults)
14
+ case @rate_limit
15
+ when NilClass then defaults
16
+ when Hash then defaults.merge(@rate_limit)
17
+ when Symbol then defaults.merge(op.send(@rate_limit))
18
+ else
19
+ raise ArgumentError
20
+ end
21
+ end
22
+
23
+ end # module RateLimit
24
+ end # module Security
25
+ end # module Startback
@@ -0,0 +1,143 @@
1
+ require 'startback/audit'
2
+
3
+ module Startback
4
+ module Security
5
+ #
6
+ # This class can be used as operation arounder to skip operation executions
7
+ # via a rate limiting process.
8
+ #
9
+ # Example:
10
+ #
11
+ # RATE_LIMITER = Startback::Security::RateLimiter.new({
12
+ # store: Startback::Caching::Store.new, # use a redis cache store in practice
13
+ # defaults: {
14
+ # strategy: :silent_drop,
15
+ # detection: :input,
16
+ # periodicity: 60,
17
+ # max_occurence: 3,
18
+ # },
19
+ # })
20
+ #
21
+ # # in api.rb
22
+ # around_run(RATE_LIMITER)
23
+ #
24
+ # # in an operation.rb
25
+ # class Op < Startback::Operation
26
+ # rate_limit { max_occurences: 2 } # Partial<Config>
27
+ # rate_limit :dynamic_config # Config obtained on op instance
28
+ # end
29
+ #
30
+ # Reference:
31
+ #
32
+ # strategy: Symbol => :silent_drop or :fail (429 status code)
33
+ # detection: Symbol => method to call on Operation instance to detect call duplicates via pure data
34
+ # periodicity: Integer => periodicity of occurence count, in seconds
35
+ # max_occurences: Integer => max number of occurences during the period
36
+ #
37
+ class RateLimiter
38
+ include Support::Robustness
39
+
40
+ DEFAULT_OPTIONS = {
41
+ max_occurences: 1
42
+ }
43
+
44
+ def initialize(options = {})
45
+ @options = DEFAULT_OPTIONS.merge(options)
46
+
47
+ configuration_error!("Missing store") unless @options[:store]
48
+ end
49
+
50
+ def call(runner, op, &then_block)
51
+ raise ArgumentError, "A block is required" unless then_block
52
+
53
+ if op.class.has_rate_limit?
54
+ limit_options = op.class.rate_limit_options(op, defaults || {})
55
+ key, authorized = authorize_call!(op, limit_options)
56
+ unless authorized
57
+ log_rate_limited(op, key, limit_options)
58
+ return nil
59
+ end
60
+ end
61
+
62
+ then_block.call
63
+ end
64
+
65
+ private
66
+
67
+ def authorize_call!(op, limit_options)
68
+ key = get_detection_key(op, limit_options)
69
+ count = get_detection_count(key)
70
+ strat = strategy(limit_options)
71
+ authorize = (count < max_occurences_allowed(limit_options))
72
+ if authorize
73
+ save_detection_count(key, count + 1, limit_options)
74
+ [key, authorize]
75
+ elsif strat === :silent_drop
76
+ [key, authorize]
77
+ elsif strat === :fail
78
+ raise Startback::Errors::TooManyRequestsError, op.class.name.to_s
79
+ else
80
+ [key, authorize]
81
+ end
82
+ end
83
+
84
+ def get_detection_count(key)
85
+ value = store.get(key)
86
+ value.nil? ? 0 : value.to_i
87
+ end
88
+
89
+ def save_detection_count(key, count, limit_options)
90
+ store.set(key, count.to_s, ttl(limit_options))
91
+ end
92
+
93
+ def get_detection_key(op, limit_options)
94
+ value = case detection = limit_options[:detection]
95
+ when String
96
+ detection
97
+ when Symbol
98
+ op.send(detection)
99
+ else
100
+ configuration_error!("Unrecognized :detection `#{detection}`")
101
+ end
102
+ key = {
103
+ model: "Startback::Security::RateLimiter",
104
+ op_class: op.class.name.to_s,
105
+ value: value,
106
+ }
107
+ JSON.fast_generate(key)
108
+ end
109
+
110
+ def defaults
111
+ @defaults ||= @options[:defaults]
112
+ end
113
+
114
+ def store
115
+ @store ||= @options[:store]
116
+ end
117
+
118
+ def ttl(limit_options)
119
+ limit_options[:periodicity] || @options[:periodicity] || 60
120
+ end
121
+
122
+ def max_occurences_allowed(limit_options)
123
+ limit_options[:max_occurences] || @options[:max_occurences] || 1
124
+ end
125
+
126
+ def strategy(limit_options)
127
+ limit_options[:strategy] || @options[:strategy] || :silent_drop
128
+ end
129
+
130
+ def log_rate_limited(op, key, limit_options)
131
+ logger_for(op).warn({
132
+ op: self.class,
133
+ op_data: { key: key, options: limit_options },
134
+ })
135
+ end
136
+
137
+ def configuration_error!(msg)
138
+ raise Startback::Error, msg
139
+ end
140
+
141
+ end # class RateLimiter
142
+ end # module Audit
143
+ end # module Startback
@@ -0,0 +1,3 @@
1
+ require_relative 'security/rate_limit'
2
+ require_relative 'security/rate_limiter'
3
+ require_relative 'security/ext'
@@ -51,6 +51,11 @@ module Startback
51
51
  end
52
52
  module_function :development?
53
53
 
54
+ def test?
55
+ ENV['RACK_ENV'] =~ /^test/
56
+ end
57
+ module_function :test?
58
+
54
59
  end # module Env
55
60
  end # module Support
56
61
  end # module Startback
@@ -45,7 +45,6 @@ module Startback
45
45
  l.formatter = LogFormatter.new
46
46
  l.warn(op: "#{self}", op_data: {
47
47
  msg: "Using default logger to STDOUT",
48
- caller: caller
49
48
  })
50
49
  @@default_logger = l
51
50
  end
@@ -92,6 +91,11 @@ module Startback
92
91
 
93
92
  end # module Tools
94
93
 
94
+ # Returns the natural logger to use for a specific arg
95
+ def logger_for(arg)
96
+ Tools.logger_for(arg)
97
+ end
98
+
95
99
  # Logs a specific message with a given severity.
96
100
  #
97
101
  # Severity can be :debug, :info, :warn, :error or :fatal.
@@ -0,0 +1,38 @@
1
+ module Startback
2
+ module Support
3
+ #
4
+ # A Logger extension that spies message for inspection during integration
5
+ # testing
6
+ #
7
+ class SpyLogger < ::Logger
8
+
9
+ def initialize(*args, &bl)
10
+ super(*args, &bl)
11
+ reset_spy_state!
12
+ end
13
+
14
+ def reset_spy_state!
15
+ @state = Hash.new
16
+ end
17
+
18
+ def has?(severity, match = {})
19
+ @state[severity] && @state[severity].find{|x|
20
+ match.each_pair.all?{|(k,v)| x[k] == v }
21
+ }
22
+ end
23
+
24
+ def spy(severity, args)
25
+ @state[severity] ||= []
26
+ @state[severity] << args[0]
27
+ end
28
+
29
+ [ :info, :warn, :error, :fatal ].each {|meth|
30
+ define_method(meth) do |*args, &bl|
31
+ spy(meth, args)
32
+ super(*args, &bl)
33
+ end
34
+ }
35
+
36
+ end # class SpyLogger
37
+ end # module Support
38
+ end # module Startback
@@ -18,6 +18,7 @@ require_relative 'support/env'
18
18
  require_relative 'support/redactor'
19
19
  require_relative 'support/log_formatter'
20
20
  require_relative 'support/logger'
21
+ require_relative 'support/spy_logger'
21
22
  require_relative 'support/robustness'
22
23
  require_relative 'support/hooks'
23
24
  require_relative 'support/operation_runner'
@@ -1,8 +1,8 @@
1
1
  module Startback
2
2
  module Version
3
3
  MAJOR = 1
4
- MINOR = 1
5
- TINY = 0
4
+ MINOR = 2
5
+ TINY = 1
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
8
8
  end
@@ -35,7 +35,7 @@ module Startback
35
35
  DEVELOPMENT_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_DEVELOPMENT_CACHE_CONTROL'] || \
36
36
  "no-cache, no-store, max-age=0, must-revalidate"
37
37
 
38
- # Cache-Control header to use in produdction mode
38
+ # Cache-Control header to use in production mode
39
39
  PRODUCTION_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_PRODUCTION_CACHE_CONTROL'] ||\
40
40
  "public, must-revalidate, max-age=3600, s-max-age=3600"
41
41
 
data/spec/spec_helper.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  require 'startback'
2
+ require 'startback/caching'
2
3
  require 'startback/event'
3
4
  require 'startback/support/fake_logger'
4
5
  require 'startback/audit'
6
+ require 'startback/security'
5
7
  require 'rack/test'
6
8
  require 'ostruct'
7
9
 
@@ -0,0 +1,215 @@
1
+ require 'spec_helper'
2
+ module Startback
3
+ module Security
4
+ describe RateLimiter do
5
+ let(:limiter) do
6
+ RateLimiter.new(options)
7
+ end
8
+
9
+ let(:store) {
10
+ Caching::Store.new
11
+ }
12
+
13
+ let(:options) {
14
+ {
15
+ store: store,
16
+ defaults: {
17
+ strategy: :silent_drop,
18
+ detection: 'any',
19
+ },
20
+ }
21
+ }
22
+
23
+ before(:each) do
24
+ op_class.reset
25
+ end
26
+
27
+ class RateLimitedOp < Startback::Operation
28
+ def initialize(input)
29
+ @input = input
30
+ end
31
+ attr_reader :input
32
+
33
+ def self.reset
34
+ @called = 0
35
+ end
36
+
37
+ def self.called
38
+ @called = @called + 1
39
+ end
40
+
41
+ def self.called_count
42
+ @called
43
+ end
44
+
45
+ def call
46
+ self.class.called
47
+ end
48
+ end
49
+
50
+ def call_it_once(input = {})
51
+ op = op_class.new(input)
52
+ limiter.call(nil, op){ op.call }
53
+ end
54
+
55
+ context 'when silent_drop with a constant' do
56
+ class ConstantRateLimitedOp < RateLimitedOp
57
+ rate_limit({
58
+ strategy: :silent_drop,
59
+ detection: "constant"
60
+ })
61
+ end
62
+
63
+ let (:op_class) {
64
+ ConstantRateLimitedOp
65
+ }
66
+
67
+ it 'works when called once' do
68
+ call_it_once
69
+ expect(op_class.called_count).to eql(1)
70
+ end
71
+
72
+ it 'silently ignores second call' do
73
+ call_it_once
74
+ call_it_once
75
+ expect(op_class.called_count).to eql(1)
76
+ end
77
+ end
78
+
79
+ context 'when :fail with a constant' do
80
+ class FailingRateLimitedOp < RateLimitedOp
81
+ rate_limit({
82
+ strategy: :fail,
83
+ detection: "constant"
84
+ })
85
+ end
86
+
87
+ let (:op_class) {
88
+ FailingRateLimitedOp
89
+ }
90
+
91
+ it 'works when called once' do
92
+ call_it_once
93
+ expect(op_class.called_count).to eql(1)
94
+ end
95
+
96
+ it 'fails on second call' do
97
+ call_it_once
98
+ expect {
99
+ call_it_once
100
+ }.to raise_error(Startback::Errors::TooManyRequestsError)
101
+ end
102
+ end
103
+
104
+ context 'when silent_drop with a symbol' do
105
+ class InputRateLimitedOp < RateLimitedOp
106
+ rate_limit({
107
+ strategy: :silent_drop,
108
+ detection: :input
109
+ })
110
+ end
111
+
112
+ let (:op_class) {
113
+ InputRateLimitedOp
114
+ }
115
+
116
+ context 'when called with the same input' do
117
+ it 'works when called once' do
118
+ call_it_once({ hello: 'foo' })
119
+ expect(op_class.called_count).to eql(1)
120
+ end
121
+
122
+ it 'silently ignores second call' do
123
+ call_it_once({ hello: 'foo' })
124
+ call_it_once({ hello: 'foo' })
125
+ expect(op_class.called_count).to eql(1)
126
+ end
127
+ end
128
+
129
+ context 'when called with different inputs' do
130
+ it 'accepts every call' do
131
+ call_it_once({ hello: 'foo' })
132
+ call_it_once({ hello: 'bar' })
133
+ expect(op_class.called_count).to eql(2)
134
+ end
135
+ end
136
+ end
137
+
138
+ context "when the defaults options are used" do
139
+ class DefaultsRateLimitedOp < RateLimitedOp
140
+ rate_limit
141
+ end
142
+
143
+ let (:op_class) {
144
+ DefaultsRateLimitedOp
145
+ }
146
+
147
+ it 'works when called once' do
148
+ call_it_once
149
+ expect(op_class.called_count).to eql(1)
150
+ end
151
+
152
+ it 'silently ignores second call' do
153
+ call_it_once
154
+ call_it_once
155
+ expect(op_class.called_count).to eql(1)
156
+ end
157
+ end
158
+
159
+ context 'when using the occurence option to allow more than 1 execution' do
160
+ class OccurencesRateLimitedOp < RateLimitedOp
161
+ rate_limit({
162
+ strategy: :silent_drop,
163
+ detection: "constant",
164
+ max_occurences: 3,
165
+ })
166
+ end
167
+
168
+ let (:op_class) {
169
+ OccurencesRateLimitedOp
170
+ }
171
+
172
+ it 'works when called three times in a row' do
173
+ call_it_once
174
+ call_it_once
175
+ call_it_once
176
+ expect(op_class.called_count).to eql(3)
177
+ end
178
+
179
+ it 'silently ignores further calls' do
180
+ call_it_once
181
+ call_it_once
182
+ call_it_once
183
+ call_it_once
184
+ call_it_once
185
+ expect(op_class.called_count).to eql(3)
186
+ end
187
+ end
188
+
189
+ context 'when using a dynamic configuration' do
190
+ class DynamicRateLimitedOp < RateLimitedOp
191
+ rate_limit :rate_limit_options
192
+
193
+ def rate_limit_options
194
+ {
195
+ strategy: :silent_drop,
196
+ detection: "constant",
197
+ max_occurences: input[:max],
198
+ }
199
+ end
200
+ end
201
+
202
+ let (:op_class) {
203
+ DynamicRateLimitedOp
204
+ }
205
+
206
+ it 'works when called three times in a row' do
207
+ call_it_once(max: 2)
208
+ call_it_once(max: 2)
209
+ call_it_once(max: 2)
210
+ expect(op_class.called_count).to eql(2)
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: startback
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bernard Lambeau
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-02-09 00:00:00.000000000 Z
11
+ date: 2025-09-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -56,20 +56,20 @@ dependencies:
56
56
  requirements:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
- version: 0.26.0
59
+ version: 0.27.0
60
60
  - - "<"
61
61
  - !ruby/object:Gem::Version
62
- version: '0.27'
62
+ version: '0.28'
63
63
  type: :development
64
64
  prerelease: false
65
65
  version_requirements: !ruby/object:Gem::Requirement
66
66
  requirements:
67
67
  - - ">="
68
68
  - !ruby/object:Gem::Version
69
- version: 0.26.0
69
+ version: 0.27.0
70
70
  - - "<"
71
71
  - !ruby/object:Gem::Version
72
- version: '0.27'
72
+ version: '0.28'
73
73
  - !ruby/object:Gem::Dependency
74
74
  name: rake
75
75
  requirement: !ruby/object:Gem::Requirement
@@ -421,6 +421,11 @@ files:
421
421
  - lib/startback/operation.rb
422
422
  - lib/startback/operation/error_operation.rb
423
423
  - lib/startback/operation/multi_operation.rb
424
+ - lib/startback/security.rb
425
+ - lib/startback/security/ext.rb
426
+ - lib/startback/security/ext/operation.rb
427
+ - lib/startback/security/rate_limit.rb
428
+ - lib/startback/security/rate_limiter.rb
424
429
  - lib/startback/services.rb
425
430
  - lib/startback/support.rb
426
431
  - lib/startback/support/data_object.rb
@@ -432,6 +437,7 @@ files:
432
437
  - lib/startback/support/operation_runner.rb
433
438
  - lib/startback/support/redactor.rb
434
439
  - lib/startback/support/robustness.rb
440
+ - lib/startback/support/spy_logger.rb
435
441
  - lib/startback/support/transaction_manager.rb
436
442
  - lib/startback/support/transaction_policy.rb
437
443
  - lib/startback/support/world.rb
@@ -460,6 +466,7 @@ files:
460
466
  - spec/unit/context/test_world.rb
461
467
  - spec/unit/event/bus/memory/test_async.rb
462
468
  - spec/unit/event/bus/memory/test_sync.rb
469
+ - spec/unit/security/test_rate_limiter.rb
463
470
  - spec/unit/support/hooks/test_after_hook.rb
464
471
  - spec/unit/support/hooks/test_before_hook.rb
465
472
  - spec/unit/support/operation_runner/test_around_run.rb