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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56aaefd008d296d55c857bf9c0e392efc13bc34e14d074a65e60b229cbaa1857
4
- data.tar.gz: '028e0dcffa18cb2ae93c635198feb4633ba3dbdb3f3f95a56a925faea244eaf1'
3
+ metadata.gz: 2bcf837fdfb9024b8ba0da1bbb00919a7fef30f69b5145f93fa7d44cc8202989
4
+ data.tar.gz: 16916a1f6be864f19999e16c808499a91df7a425ce7855e14fbc7a6ff2e3710f
5
5
  SHA512:
6
- metadata.gz: eb270f0d0061e112b7b783b102566c58a3f50a963029153d56be1c7219a84b82ec6fc2439ca32d2fe9c3a1c772b95a973265412d1c72dbef5bf03f4ea3c6d251
7
- data.tar.gz: 8cb20a842aacaf22c1063192a269dfb053894bae994569e03332aec42a90d3e827f69b9b63f1e69bc647a91e0899cdf02cb124536bdd7737c18ac0a446f60204
6
+ metadata.gz: 2d7cf234140ce2e3f0ca7e3c6e4983e0a89d411067156da2f9c55334623de060c2abe2e8e26b1747b97b88ceb25fd8aeddcde98c156093f41f58032cb3eb3b2f
7
+ data.tar.gz: cde80e613c12e4db455e0f08b8c253c74eec1b0a2f5947b1d57dfa76bc6ce0152632d24b5a97150f9702d493411fe5194d9fe604fb9633df98d28424685e8c34
@@ -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,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
@@ -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,7 +1,7 @@
1
1
  module Startback
2
2
  module Version
3
3
  MAJOR = 1
4
- MINOR = 1
4
+ MINOR = 2
5
5
  TINY = 0
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
@@ -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,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.1.0
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-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