startback 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/startback/security/ext/operation.rb +5 -0
- data/lib/startback/security/ext.rb +1 -0
- data/lib/startback/security/rate_limit.rb +19 -0
- data/lib/startback/security/rate_limiter.rb +117 -0
- data/lib/startback/security.rb +3 -0
- data/lib/startback/support/env.rb +5 -0
- data/lib/startback/support/robustness.rb +5 -1
- data/lib/startback/support/spy_logger.rb +38 -0
- data/lib/startback/support.rb +1 -0
- data/lib/startback/version.rb +1 -1
- data/lib/startback/web/auto_caching.rb +1 -1
- data/spec/spec_helper.rb +2 -0
- data/spec/unit/security/test_rate_limiter.rb +165 -0
- metadata +13 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2bcf837fdfb9024b8ba0da1bbb00919a7fef30f69b5145f93fa7d44cc8202989
|
4
|
+
data.tar.gz: 16916a1f6be864f19999e16c808499a91df7a425ce7855e14fbc7a6ff2e3710f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2d7cf234140ce2e3f0ca7e3c6e4983e0a89d411067156da2f9c55334623de060c2abe2e8e26b1747b97b88ceb25fd8aeddcde98c156093f41f58032cb3eb3b2f
|
7
|
+
data.tar.gz: cde80e613c12e4db455e0f08b8c253c74eec1b0a2f5947b1d57dfa76bc6ce0152632d24b5a97150f9702d493411fe5194d9fe604fb9633df98d28424685e8c34
|
@@ -0,0 +1 @@
|
|
1
|
+
require_relative 'ext/operation'
|
@@ -0,0 +1,19 @@
|
|
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(defaults)
|
14
|
+
defaults.merge(@rate_limit || {})
|
15
|
+
end
|
16
|
+
|
17
|
+
end # module RateLimit
|
18
|
+
end # module Security
|
19
|
+
end # module Startback
|
@@ -0,0 +1,117 @@
|
|
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, # simply ignore the call
|
15
|
+
# detection: :input, # method to call on Operation instance to detect call duplicates via pure data
|
16
|
+
# periodicity: 60, # periodicity of occurence count, in seconds
|
17
|
+
# max_occurence: 3, # max number of occurences during the period
|
18
|
+
# },
|
19
|
+
# })
|
20
|
+
#
|
21
|
+
# # in api.rb
|
22
|
+
# around_run(RATE_LIMITER)
|
23
|
+
#
|
24
|
+
class RateLimiter
|
25
|
+
include Support::Robustness
|
26
|
+
|
27
|
+
DEFAULT_OPTIONS = {
|
28
|
+
max_occurences: 1
|
29
|
+
}
|
30
|
+
|
31
|
+
def initialize(options = {})
|
32
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
33
|
+
|
34
|
+
configuration_error!("Missing store") unless @options[:store]
|
35
|
+
end
|
36
|
+
|
37
|
+
def call(runner, op, &then_block)
|
38
|
+
raise ArgumentError, "A block is required" unless then_block
|
39
|
+
|
40
|
+
if op.class.has_rate_limit?
|
41
|
+
limit_options = op.class.rate_limit_options(defaults || {})
|
42
|
+
key, authorized = authorize_call!(op, limit_options)
|
43
|
+
unless authorized
|
44
|
+
log_rate_limited(op, key, limit_options)
|
45
|
+
return nil
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
then_block.call
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def authorize_call!(op, limit_options)
|
55
|
+
key = get_detection_key(op, limit_options)
|
56
|
+
count = get_detection_count(key)
|
57
|
+
authorize = (count < max_occurences_allowed(limit_options))
|
58
|
+
save_detection_count(key, count + 1, limit_options) if authorize
|
59
|
+
[key, authorize]
|
60
|
+
end
|
61
|
+
|
62
|
+
def get_detection_count(key)
|
63
|
+
value = store.get(key)
|
64
|
+
value.nil? ? 0 : value.to_i
|
65
|
+
end
|
66
|
+
|
67
|
+
def save_detection_count(key, count, limit_options)
|
68
|
+
store.set(key, count.to_s, ttl(limit_options))
|
69
|
+
end
|
70
|
+
|
71
|
+
def get_detection_key(op, limit_options)
|
72
|
+
value = case detection = limit_options[:detection]
|
73
|
+
when String
|
74
|
+
detection
|
75
|
+
when Symbol
|
76
|
+
op.send(detection)
|
77
|
+
else
|
78
|
+
configuration_error!("Unrecognized :detection `#{detection}`")
|
79
|
+
end
|
80
|
+
key = {
|
81
|
+
model: "Startback::Security::RateLimiter",
|
82
|
+
op_class: op.class.name.to_s,
|
83
|
+
value: value,
|
84
|
+
}
|
85
|
+
JSON.fast_generate(key)
|
86
|
+
end
|
87
|
+
|
88
|
+
def defaults
|
89
|
+
@defaults ||= @options[:defaults]
|
90
|
+
end
|
91
|
+
|
92
|
+
def store
|
93
|
+
@store ||= @options[:store]
|
94
|
+
end
|
95
|
+
|
96
|
+
def ttl(limit_options)
|
97
|
+
limit_options[:periodicity] || @options[:periodicity] || 60
|
98
|
+
end
|
99
|
+
|
100
|
+
def max_occurences_allowed(limit_options)
|
101
|
+
limit_options[:max_occurences] || @options[:max_occurences] || 1
|
102
|
+
end
|
103
|
+
|
104
|
+
def log_rate_limited(op, key, limit_options)
|
105
|
+
logger_for(op).warn({
|
106
|
+
op: self.class,
|
107
|
+
op_data: { key: key, options: limit_options },
|
108
|
+
})
|
109
|
+
end
|
110
|
+
|
111
|
+
def configuration_error!(msg)
|
112
|
+
raise Startback::Error, msg
|
113
|
+
end
|
114
|
+
|
115
|
+
end # class RateLimiter
|
116
|
+
end # module Audit
|
117
|
+
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
|
data/lib/startback/support.rb
CHANGED
@@ -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'
|
data/lib/startback/version.rb
CHANGED
@@ -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
|
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
@@ -0,0 +1,165 @@
|
|
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 silent_drop with a symbol' do
|
80
|
+
class InputRateLimitedOp < RateLimitedOp
|
81
|
+
rate_limit({
|
82
|
+
strategy: :silent_drop,
|
83
|
+
detection: :input
|
84
|
+
})
|
85
|
+
end
|
86
|
+
|
87
|
+
let (:op_class) {
|
88
|
+
InputRateLimitedOp
|
89
|
+
}
|
90
|
+
|
91
|
+
context 'when called with the same input' do
|
92
|
+
it 'works when called once' do
|
93
|
+
call_it_once({ hello: 'foo' })
|
94
|
+
expect(op_class.called_count).to eql(1)
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'silently ignores second call' do
|
98
|
+
call_it_once({ hello: 'foo' })
|
99
|
+
call_it_once({ hello: 'foo' })
|
100
|
+
expect(op_class.called_count).to eql(1)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
context 'when called with different inputs' do
|
105
|
+
it 'accepts every call' do
|
106
|
+
call_it_once({ hello: 'foo' })
|
107
|
+
call_it_once({ hello: 'bar' })
|
108
|
+
expect(op_class.called_count).to eql(2)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
context "when the defaults options are used" do
|
114
|
+
class DefaultsRateLimitedOp < RateLimitedOp
|
115
|
+
rate_limit
|
116
|
+
end
|
117
|
+
|
118
|
+
let (:op_class) {
|
119
|
+
DefaultsRateLimitedOp
|
120
|
+
}
|
121
|
+
|
122
|
+
it 'works when called once' do
|
123
|
+
call_it_once
|
124
|
+
expect(op_class.called_count).to eql(1)
|
125
|
+
end
|
126
|
+
|
127
|
+
it 'silently ignores second call' do
|
128
|
+
call_it_once
|
129
|
+
call_it_once
|
130
|
+
expect(op_class.called_count).to eql(1)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
context 'when using the occurence option to allow more than 1 execution' do
|
135
|
+
class OccurencesRateLimitedOp < RateLimitedOp
|
136
|
+
rate_limit({
|
137
|
+
strategy: :silent_drop,
|
138
|
+
detection: "constant",
|
139
|
+
max_occurences: 3,
|
140
|
+
})
|
141
|
+
end
|
142
|
+
|
143
|
+
let (:op_class) {
|
144
|
+
OccurencesRateLimitedOp
|
145
|
+
}
|
146
|
+
|
147
|
+
it 'works when called three times in a row' do
|
148
|
+
call_it_once
|
149
|
+
call_it_once
|
150
|
+
call_it_once
|
151
|
+
expect(op_class.called_count).to eql(3)
|
152
|
+
end
|
153
|
+
|
154
|
+
it 'silently ignores further calls' do
|
155
|
+
call_it_once
|
156
|
+
call_it_once
|
157
|
+
call_it_once
|
158
|
+
call_it_once
|
159
|
+
call_it_once
|
160
|
+
expect(op_class.called_count).to eql(3)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
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.
|
4
|
+
version: 1.2.0
|
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-
|
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.
|
59
|
+
version: 0.27.0
|
60
60
|
- - "<"
|
61
61
|
- !ruby/object:Gem::Version
|
62
|
-
version: '0.
|
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.
|
69
|
+
version: 0.27.0
|
70
70
|
- - "<"
|
71
71
|
- !ruby/object:Gem::Version
|
72
|
-
version: '0.
|
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
|