gitlab-labkit 1.12.0 → 1.13.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: e99cf18b21d9da22b488d0dd0031fc9030aeded745239b4e72e8d3f7c1c36555
4
- data.tar.gz: 664ba45d3535d3310431eece7890c58ddfa9cd8a9dfa3c235b66fd20b2b5480e
3
+ metadata.gz: 8db8c324d6fe60ad1b16ab51dd87207d4a5d08e1d83ad99a3ae900392bd0739c
4
+ data.tar.gz: 4487ae2ff2bacac400f753c92012cd7b118525f0bed143794ca4fd12c14cca53
5
5
  SHA512:
6
- metadata.gz: f7959c2eb8f54ee928a7841c2b8de8011c1e2a481c3c6aaf8e94b53a7ca186e8c28ad192a641c4c8ffac5bd8fd39a2c7c6afad324dfae77531b00e3089de3dc0
7
- data.tar.gz: 05d07f6b1dffe39907c372ca0cb1c638cbc3898f39faf33f82ef5e042c9ea916a94d297963706e0010bc30dc14df2ebf2a1d89a342866630753af233f52934d1
6
+ metadata.gz: 7292067a1a5d8e231776b65b381ba161a2123c6363df5ebd4269ae65b596871ebddb5bd610e5ab33da0939cdeab4639a51bcb2636046cfd8cb941ac6f6baa9bb
7
+ data.tar.gz: 771b7fded81f969ee414ef737b74fa9c22d620762f0e56c669e89de9c4ebf6c022fdbf91513fe630a580acfc8408d20a0eb55e3f05e0c97c412ab6ab818a75bf
data/.copier-answers.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  # See the project for instructions on how to update the project
4
4
  #
5
5
  # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
6
- _commit: v1.45.0
6
+ _commit: v1.46.0
7
7
  _src_path: https://gitlab.com/gitlab-com/gl-infra/common-template-copier.git
8
8
  ee_licensed: false
9
9
  golang: false
data/CODEOWNERS CHANGED
@@ -1,4 +1,4 @@
1
1
  # CODEOWNERS is used to lookup assignees for
2
2
  # Renovate Bot dependency change Merge Requests.
3
3
  # https://docs.renovatebot.com/configuration-options/#assigneesfromcodeowners
4
- * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael @e_forbes @M_Alvarez
4
+ * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael @e_forbes @M_Alvarez @mwoolf
data/lib/gitlab-labkit.rb CHANGED
@@ -18,6 +18,7 @@ module Labkit
18
18
  autoload :Metrics, "labkit/metrics"
19
19
  autoload :Middleware, "labkit/middleware"
20
20
  autoload :Fields, "labkit/fields"
21
+ autoload :RateLimit, "labkit/rate_limit"
21
22
 
22
23
  # Publishers to publish notifications whenever a HTTP reqeust is made.
23
24
  # A broadcasted notification's payload in topic "request.external_http" includes:
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "labkit/logging/json_logger"
5
+
6
+ module Labkit
7
+ module RateLimit
8
+ # Evaluator contains the core rule-matching + Redis counter logic.
9
+ class Evaluator
10
+ KNOWN_CHARACTERISTICS = [:user, :ip, :namespace, :plan, :endpoint].freeze
11
+ KNOWN_ACTIONS = [:block, :log].freeze
12
+ REDIS_KEY_PREFIX = "labkit:rl"
13
+ CHAR_VALUE_MAX_LENGTH = 200
14
+ UNKNOWN_SENTINEL = "unknown_characteristic"
15
+ CALL_SITE_PATTERN = /\A[a-z0-9_]+\z/
16
+
17
+ def initialize(call_site:, identifier:, rules:, redis:, logger: nil)
18
+ @call_site = call_site
19
+ @identifier = identifier
20
+ @rules = rules
21
+ @redis = redis
22
+ @logger = logger || build_default_logger
23
+ end
24
+
25
+ def evaluate
26
+ validate_call_site!
27
+ evaluate_rules
28
+ rescue ArgumentError
29
+ raise
30
+ rescue StandardError => e
31
+ # Intentionally broad: fail-open applies to any unexpected error (network,
32
+ # timeout, OOM, etc.), not only Redis protocol errors.
33
+ log_evaluate_error(e)
34
+ :allow
35
+ end
36
+
37
+ private
38
+
39
+ def evaluate_rules
40
+ aggregate = :allow
41
+
42
+ @rules.each_with_index do |rule, index|
43
+ next unless rule_matches?(rule, @identifier)
44
+
45
+ result = evaluate_rule(rule, index)
46
+ aggregate = :block if result == :block
47
+ end
48
+
49
+ aggregate
50
+ end
51
+
52
+ def validate_call_site!
53
+ return if CALL_SITE_PATTERN.match?(@call_site)
54
+
55
+ raise ArgumentError, "Invalid call_site: #{@call_site.inspect}. Must match /\\A[a-z0-9_]+\\z/" if dev_or_test?
56
+
57
+ sanitized = @call_site.gsub(/[^a-z0-9_]/, "_")
58
+ @logger.warn(
59
+ message: "rate_limit_invalid_call_site",
60
+ call_site: @call_site,
61
+ sanitized: sanitized
62
+ )
63
+ @call_site = sanitized
64
+ end
65
+
66
+ def rule_matches?(rule, identifier)
67
+ rule.match.all? do |key, value|
68
+ identifier[key] == value
69
+ end
70
+ end
71
+
72
+ def evaluate_rule(rule, index)
73
+ exceeded = false
74
+
75
+ rule.characteristics.each do |char|
76
+ char_value = resolve_characteristic(char, @identifier)
77
+
78
+ if char_value.nil?
79
+ log_skipped_characteristic(rule, index, char)
80
+ next
81
+ end
82
+
83
+ redis_key = build_redis_key(@call_site, index, char, char_value)
84
+
85
+ count = incr_with_ttl(redis_key, rule.period)
86
+ rule_exceeded = count > rule.limit
87
+
88
+ exceeded = true if rule_exceeded
89
+
90
+ log_rule(rule, index, count, redis_key, rule_exceeded)
91
+ end
92
+
93
+ exceeded && rule.action == :block ? :block : :allow
94
+ end
95
+
96
+ def resolve_characteristic(char, identifier)
97
+ unless KNOWN_CHARACTERISTICS.include?(char)
98
+ raise ArgumentError, "Unknown characteristic: #{char.inspect}. Known: #{KNOWN_CHARACTERISTICS.inspect}" if dev_or_test?
99
+
100
+ @logger.warn(
101
+ message: "rate_limit_unknown_characteristic",
102
+ characteristic: char
103
+ )
104
+ return UNKNOWN_SENTINEL
105
+ end
106
+
107
+ value = identifier[char]
108
+
109
+ # Normalize endpoint: strip query string
110
+ value = Identifier.normalize_endpoint(value) if char == :endpoint
111
+
112
+ # Treat nil and empty-string the same: anonymous traffic must not collide on a shared bucket.
113
+ return nil if value.nil? || value.to_s.empty?
114
+
115
+ value.to_s
116
+ end
117
+
118
+ def build_redis_key(call_site, rule_index, char, char_value)
119
+ safe_value = encode_char_value(char_value.to_s)
120
+ "#{REDIS_KEY_PREFIX}:#{call_site}:#{rule_index}:#{char}:#{safe_value}"
121
+ end
122
+
123
+ def encode_char_value(value)
124
+ if value.length > CHAR_VALUE_MAX_LENGTH
125
+ OpenSSL::Digest::SHA256.hexdigest(value)
126
+ else
127
+ value
128
+ end
129
+ end
130
+
131
+ def incr_with_ttl(redis_key, period)
132
+ count = @redis.incr(redis_key)
133
+ # Set expiry only on first write to avoid resetting TTL on each call
134
+ @redis.expire(redis_key, period) if count == 1
135
+ count
136
+ end
137
+
138
+ def log_rule(rule, index, count, redis_key, exceeded)
139
+ @logger.info(
140
+ message: "rate_limit_check",
141
+ call_site: @call_site,
142
+ rule_index: index,
143
+ action: rule.action.to_s,
144
+ limit: rule.limit,
145
+ period: rule.period,
146
+ count: count,
147
+ matched: true,
148
+ exceeded: exceeded,
149
+ identifier: @identifier.to_h,
150
+ redis_key: redis_key
151
+ )
152
+ end
153
+
154
+ def log_skipped_characteristic(rule, index, char)
155
+ @logger.info(
156
+ message: "rate_limit_check",
157
+ call_site: @call_site,
158
+ rule_index: index,
159
+ action: rule.action.to_s,
160
+ limit: rule.limit,
161
+ period: rule.period,
162
+ characteristic: char,
163
+ matched: true,
164
+ skipped: true,
165
+ identifier: @identifier.to_h
166
+ )
167
+ end
168
+
169
+ def log_evaluate_error(error)
170
+ @logger.warn(
171
+ message: "rate_limit_redis_error",
172
+ call_site: @call_site,
173
+ error: error.class.to_s,
174
+ result: "allow"
175
+ )
176
+ end
177
+
178
+ def dev_or_test?
179
+ # Memoized: ENV access is not free under concurrency.
180
+ return @dev_or_test unless @dev_or_test.nil?
181
+
182
+ env = ENV.fetch("LABKIT_ENV", nil)
183
+ @dev_or_test = env == "test" || env == "development"
184
+ end
185
+
186
+ def build_default_logger
187
+ Labkit::Logging::JsonLogger.new($stdout)
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module RateLimit
5
+ # Identifier is a value object wrapping a hash of key-value pairs that
6
+ # describe the caller (e.g. user, ip, endpoint).
7
+ class Identifier
8
+ # Normalize an endpoint value: strip query string.
9
+ def self.normalize_endpoint(value)
10
+ return value unless value.is_a?(String)
11
+
12
+ value.split("?", 2).first
13
+ end
14
+
15
+ attr_reader :attributes
16
+
17
+ def initialize(attributes = {})
18
+ @attributes = attributes.transform_keys(&:to_sym).freeze
19
+ end
20
+
21
+ # Return the value for a characteristic key.
22
+ def [](key)
23
+ @attributes[key.to_sym]
24
+ end
25
+
26
+ # Serialize to a plain Hash suitable for JSON logging.
27
+ def to_h
28
+ @attributes.transform_keys(&:to_s)
29
+ end
30
+
31
+ def ==(other)
32
+ other.is_a?(Identifier) && other.attributes == @attributes
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module RateLimit
5
+ # Rule is a value object describing a single rate limit rule.
6
+ Rule = Data.define(:match, :limit, :period, :action, :characteristics) do
7
+ def initialize(limit:, period:, characteristics:, match: {}, action: :block)
8
+ super(
9
+ match: match.transform_keys(&:to_sym).freeze,
10
+ limit: limit,
11
+ period: period,
12
+ action: action.to_sym,
13
+ characteristics: Array(characteristics).map(&:to_sym).freeze
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ # RateLimit provides a simple rules-based rate limiting API backed by Redis counters.
5
+ module RateLimit
6
+ autoload :Identifier, "labkit/rate_limit/identifier"
7
+ autoload :Rule, "labkit/rate_limit/rule"
8
+ autoload :Evaluator, "labkit/rate_limit/evaluator"
9
+
10
+ # Defined independently to avoid forcing eager load of Evaluator at module load time.
11
+ # Must stay in sync with Evaluator::KNOWN_CHARACTERISTICS.
12
+ KNOWN_CHARACTERISTICS = [:user, :ip, :namespace, :plan, :endpoint].freeze
13
+
14
+ # Check whether the given call_site + identifier combination is within the
15
+ # configured rules.
16
+ #
17
+ # @param call_site [String] machine-readable name of the call site
18
+ # @param identifier [Identifier, Hash] caller attributes
19
+ # @param rules [Array<Rule>] ordered list of rate limit rules
20
+ # @param redis [Object] Redis client (must respond to #incr and #expire)
21
+ # @param logger [Logger, nil] optional logger override
22
+ # @return [:allow, :block]
23
+ def self.check(call_site:, identifier:, rules:, redis:, logger: nil)
24
+ id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
25
+ Evaluator.new(
26
+ call_site: call_site,
27
+ identifier: id,
28
+ rules: rules,
29
+ redis: redis,
30
+ logger: logger
31
+ ).evaluate
32
+ end
33
+ end
34
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-labkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.0
4
+ version: 1.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -598,6 +598,10 @@ files:
598
598
  - lib/labkit/middleware/sidekiq/user_experience_sli/client.rb
599
599
  - lib/labkit/middleware/sidekiq/user_experience_sli/server.rb
600
600
  - lib/labkit/net_http_publisher.rb
601
+ - lib/labkit/rate_limit.rb
602
+ - lib/labkit/rate_limit/evaluator.rb
603
+ - lib/labkit/rate_limit/identifier.rb
604
+ - lib/labkit/rate_limit/rule.rb
601
605
  - lib/labkit/rspec/README.md
602
606
  - lib/labkit/rspec/matchers.rb
603
607
  - lib/labkit/rspec/matchers/user_experience_matchers.rb