gitlab-labkit 1.11.1 → 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 +4 -4
- data/.copier-answers.yml +1 -1
- data/CODEOWNERS +1 -1
- data/lib/gitlab-labkit.rb +1 -0
- data/lib/labkit/fields.rb +18 -0
- data/lib/labkit/rate_limit/evaluator.rb +191 -0
- data/lib/labkit/rate_limit/identifier.rb +36 -0
- data/lib/labkit/rate_limit/rule.rb +18 -0
- data/lib/labkit/rate_limit.rb +34 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8db8c324d6fe60ad1b16ab51dd87207d4a5d08e1d83ad99a3ae900392bd0739c
|
|
4
|
+
data.tar.gz: 4487ae2ff2bacac400f753c92012cd7b118525f0bed143794ca4fd12c14cca53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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:
|
data/lib/labkit/fields.rb
CHANGED
|
@@ -102,6 +102,22 @@ module Labkit
|
|
|
102
102
|
# Name of the service or component emitting the log event.
|
|
103
103
|
SERVICE_NAME = "service_name"
|
|
104
104
|
|
|
105
|
+
# GitLab organization numeric ID.
|
|
106
|
+
GL_ORGANIZATION_ID = "gl_organization_id"
|
|
107
|
+
|
|
108
|
+
# GitLab project path.
|
|
109
|
+
GL_PROJECT_PATH = "gl_project_path"
|
|
110
|
+
|
|
111
|
+
# The endpoint_id of the current endpoint to be passed as caller_id when
|
|
112
|
+
# calling another service.
|
|
113
|
+
ENDPOINT_ID = "endpoint_id"
|
|
114
|
+
|
|
115
|
+
# The ID of the caller of the current service.
|
|
116
|
+
CALLER_ID = "caller_id"
|
|
117
|
+
|
|
118
|
+
# The endpoint_id of the original caller at the root of the call chain.
|
|
119
|
+
ROOT_CALLER_ID = "root_caller_id"
|
|
120
|
+
|
|
105
121
|
# Get the constant name for a field value
|
|
106
122
|
# @param field_value [String] The field value (e.g., "gl_user_id")
|
|
107
123
|
# @return [String, nil] The constant name (e.g., "GL_USER_ID") or nil if not found
|
|
@@ -135,6 +151,8 @@ module Labkit
|
|
|
135
151
|
Fields::LOG_MESSAGE => %w[msg custom_message extra.message fields.message graphql.message reason color_message exception.gitaly],
|
|
136
152
|
Fields::CLASS_NAME => %w[class author_class exception.class extra.class extra.class_name],
|
|
137
153
|
Fields::SERVICE_NAME => %w[service grpc.service_name auth_service type component subcomponent],
|
|
154
|
+
Fields::GL_ORGANIZATION_ID => %w[organization_id],
|
|
155
|
+
Fields::GL_PROJECT_PATH => %w[project_path full_path root_pipeline_project_path requested_project_path auth_project_path extra.gl_project_path],
|
|
138
156
|
}.freeze
|
|
139
157
|
|
|
140
158
|
class << self
|
|
@@ -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.
|
|
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
|