lex-github 0.2.4 → 0.3.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/.github/workflows/ci.yml +3 -3
- data/.rubocop.yml +2 -53
- data/CHANGELOG.md +55 -0
- data/CLAUDE.md +45 -19
- data/Gemfile +1 -0
- data/README.md +155 -83
- data/lex-github.gemspec +2 -0
- data/lib/legion/extensions/github/app/actor/token_refresh.rb +68 -0
- data/lib/legion/extensions/github/app/actor/webhook_poller.rb +65 -0
- data/lib/legion/extensions/github/app/hooks/setup.rb +19 -0
- data/lib/legion/extensions/github/app/hooks/webhook.rb +19 -0
- data/lib/legion/extensions/github/app/runners/auth.rb +48 -0
- data/lib/legion/extensions/github/app/runners/credential_store.rb +46 -0
- data/lib/legion/extensions/github/app/runners/installations.rb +56 -0
- data/lib/legion/extensions/github/app/runners/manifest.rb +65 -0
- data/lib/legion/extensions/github/app/runners/webhooks.rb +118 -0
- data/lib/legion/extensions/github/app/transport/exchanges/app.rb +17 -0
- data/lib/legion/extensions/github/app/transport/messages/event.rb +18 -0
- data/lib/legion/extensions/github/app/transport/queues/auth.rb +18 -0
- data/lib/legion/extensions/github/app/transport/queues/webhooks.rb +18 -0
- data/lib/legion/extensions/github/cli/app.rb +57 -0
- data/lib/legion/extensions/github/cli/auth.rb +99 -0
- data/lib/legion/extensions/github/client.rb +24 -0
- data/lib/legion/extensions/github/errors.rb +44 -0
- data/lib/legion/extensions/github/helpers/browser_auth.rb +106 -0
- data/lib/legion/extensions/github/helpers/cache.rb +99 -0
- data/lib/legion/extensions/github/helpers/callback_server.rb +89 -0
- data/lib/legion/extensions/github/helpers/client.rb +292 -2
- data/lib/legion/extensions/github/helpers/scope_registry.rb +91 -0
- data/lib/legion/extensions/github/helpers/token_cache.rb +86 -0
- data/lib/legion/extensions/github/middleware/credential_fallback.rb +76 -0
- data/lib/legion/extensions/github/middleware/rate_limit.rb +40 -0
- data/lib/legion/extensions/github/middleware/scope_probe.rb +37 -0
- data/lib/legion/extensions/github/oauth/actor/token_refresh.rb +76 -0
- data/lib/legion/extensions/github/oauth/hooks/callback.rb +19 -0
- data/lib/legion/extensions/github/oauth/runners/auth.rb +111 -0
- data/lib/legion/extensions/github/oauth/transport/exchanges/oauth.rb +17 -0
- data/lib/legion/extensions/github/oauth/transport/queues/auth.rb +18 -0
- data/lib/legion/extensions/github/runners/actions.rb +100 -0
- data/lib/legion/extensions/github/runners/branches.rb +8 -6
- data/lib/legion/extensions/github/runners/checks.rb +84 -0
- data/lib/legion/extensions/github/runners/comments.rb +15 -9
- data/lib/legion/extensions/github/runners/commits.rb +13 -8
- data/lib/legion/extensions/github/runners/contents.rb +6 -4
- data/lib/legion/extensions/github/runners/deployments.rb +76 -0
- data/lib/legion/extensions/github/runners/gists.rb +11 -6
- data/lib/legion/extensions/github/runners/issues.rb +18 -11
- data/lib/legion/extensions/github/runners/labels.rb +18 -11
- data/lib/legion/extensions/github/runners/organizations.rb +12 -10
- data/lib/legion/extensions/github/runners/pull_requests.rb +26 -16
- data/lib/legion/extensions/github/runners/releases.rb +89 -0
- data/lib/legion/extensions/github/runners/repositories.rb +19 -12
- data/lib/legion/extensions/github/runners/repository_webhooks.rb +76 -0
- data/lib/legion/extensions/github/runners/search.rb +13 -10
- data/lib/legion/extensions/github/runners/users.rb +14 -10
- data/lib/legion/extensions/github/version.rb +1 -1
- data/lib/legion/extensions/github.rb +23 -1
- metadata +63 -1
|
@@ -1,21 +1,311 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
|
+
require 'legion/extensions/github/helpers/token_cache'
|
|
5
|
+
require 'legion/extensions/github/helpers/scope_registry'
|
|
6
|
+
require 'legion/extensions/github/middleware/credential_fallback'
|
|
4
7
|
|
|
5
8
|
module Legion
|
|
6
9
|
module Extensions
|
|
7
10
|
module Github
|
|
8
11
|
module Helpers
|
|
9
12
|
module Client
|
|
10
|
-
|
|
13
|
+
include TokenCache
|
|
14
|
+
include ScopeRegistry
|
|
15
|
+
|
|
16
|
+
CREDENTIAL_RESOLVERS = %i[
|
|
17
|
+
resolve_vault_delegated resolve_settings_delegated
|
|
18
|
+
resolve_vault_app resolve_settings_app
|
|
19
|
+
resolve_vault_pat resolve_settings_pat
|
|
20
|
+
resolve_gh_cli resolve_env
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def connection(owner: nil, repo: nil, api_url: 'https://api.github.com', token: nil, **_opts)
|
|
24
|
+
resolved = token ? { token: token } : resolve_credential(owner: owner, repo: repo)
|
|
25
|
+
resolved_token = resolved&.dig(:token)
|
|
26
|
+
@current_credential = resolved
|
|
27
|
+
@skipped_fingerprints = []
|
|
28
|
+
|
|
11
29
|
Faraday.new(url: api_url) do |conn|
|
|
30
|
+
conn.use :github_credential_fallback, resolver: self
|
|
12
31
|
conn.request :json
|
|
13
32
|
conn.response :json, content_type: /\bjson$/
|
|
33
|
+
conn.response :github_rate_limit, handler: self
|
|
34
|
+
conn.response :github_scope_probe, handler: self
|
|
14
35
|
conn.headers['Accept'] = 'application/vnd.github+json'
|
|
15
|
-
conn.headers['Authorization'] = "Bearer #{
|
|
36
|
+
conn.headers['Authorization'] = "Bearer #{resolved_token}" if resolved_token
|
|
16
37
|
conn.headers['X-GitHub-Api-Version'] = '2022-11-28'
|
|
17
38
|
end
|
|
18
39
|
end
|
|
40
|
+
|
|
41
|
+
def resolve_next_credential(owner: nil, repo: nil)
|
|
42
|
+
fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
|
|
43
|
+
@skipped_fingerprints ||= []
|
|
44
|
+
@skipped_fingerprints << fingerprint if fingerprint
|
|
45
|
+
|
|
46
|
+
CREDENTIAL_RESOLVERS.each do |method|
|
|
47
|
+
next unless respond_to?(method, true)
|
|
48
|
+
|
|
49
|
+
result = send(method)
|
|
50
|
+
next unless result
|
|
51
|
+
|
|
52
|
+
fp = result.dig(:metadata, :credential_fingerprint)
|
|
53
|
+
next if fp && @skipped_fingerprints.include?(fp)
|
|
54
|
+
next if fp && rate_limited?(fingerprint: fp)
|
|
55
|
+
|
|
56
|
+
if owner && fp
|
|
57
|
+
scope = scope_status(fingerprint: fp, owner: owner, repo: repo)
|
|
58
|
+
next if scope == :denied
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@current_credential = result
|
|
62
|
+
return result
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def max_fallback_retries
|
|
68
|
+
CREDENTIAL_RESOLVERS.size
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def on_rate_limit(remaining:, reset_at:, status:, url:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
72
|
+
fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
|
|
73
|
+
return unless fingerprint
|
|
74
|
+
|
|
75
|
+
mark_rate_limited(fingerprint: fingerprint, reset_at: reset_at)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def on_scope_denied(status:, url:, path:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
79
|
+
fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
|
|
80
|
+
owner, repo = extract_owner_repo(path)
|
|
81
|
+
return unless fingerprint && owner
|
|
82
|
+
|
|
83
|
+
register_scope(fingerprint: fingerprint, owner: owner, repo: repo, status: :denied)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def on_scope_authorized(status:, url:, path:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
87
|
+
fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
|
|
88
|
+
owner, repo = extract_owner_repo(path)
|
|
89
|
+
return unless fingerprint && owner
|
|
90
|
+
|
|
91
|
+
register_scope(fingerprint: fingerprint, owner: owner, repo: repo, status: :authorized)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_credential(owner: nil, repo: nil)
|
|
95
|
+
CREDENTIAL_RESOLVERS.each do |method|
|
|
96
|
+
next unless respond_to?(method, true)
|
|
97
|
+
|
|
98
|
+
result = send(method)
|
|
99
|
+
next unless result
|
|
100
|
+
|
|
101
|
+
fingerprint = result.dig(:metadata, :credential_fingerprint)
|
|
102
|
+
|
|
103
|
+
next if fingerprint && rate_limited?(fingerprint: fingerprint)
|
|
104
|
+
|
|
105
|
+
if owner && fingerprint
|
|
106
|
+
scope = scope_status(fingerprint: fingerprint, owner: owner, repo: repo)
|
|
107
|
+
next if scope == :denied
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return result
|
|
111
|
+
end
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolve_vault_delegated
|
|
116
|
+
return nil unless defined?(Legion::Crypt)
|
|
117
|
+
|
|
118
|
+
token_data = vault_get('github/oauth/delegated/token')
|
|
119
|
+
return nil unless token_data&.dig('access_token')
|
|
120
|
+
|
|
121
|
+
fp = credential_fingerprint(auth_type: :oauth_user, identifier: 'vault_delegated')
|
|
122
|
+
{ token: token_data['access_token'], auth_type: :oauth_user,
|
|
123
|
+
expires_at: token_data['expires_at'],
|
|
124
|
+
metadata: { source: :vault, credential_fingerprint: fp } }
|
|
125
|
+
rescue StandardError => _e
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def resolve_settings_delegated
|
|
130
|
+
return nil unless defined?(Legion::Settings)
|
|
131
|
+
|
|
132
|
+
token = Legion::Settings.dig(:github, :oauth, :access_token)
|
|
133
|
+
return nil unless token
|
|
134
|
+
|
|
135
|
+
fp = credential_fingerprint(auth_type: :oauth_user, identifier: 'settings_delegated')
|
|
136
|
+
{ token: token, auth_type: :oauth_user,
|
|
137
|
+
metadata: { source: :settings, credential_fingerprint: fp } }
|
|
138
|
+
rescue StandardError => _e
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resolve_vault_app
|
|
143
|
+
return nil unless defined?(Legion::Crypt)
|
|
144
|
+
|
|
145
|
+
private_key = begin
|
|
146
|
+
vault_get('github/app/private_key')
|
|
147
|
+
rescue StandardError => _e
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
return nil unless private_key
|
|
151
|
+
|
|
152
|
+
app_id = begin
|
|
153
|
+
vault_get('github/app/app_id')
|
|
154
|
+
rescue StandardError => _e
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
installation_id = begin
|
|
158
|
+
vault_get('github/app/installation_id')
|
|
159
|
+
rescue StandardError => _e
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
return nil unless app_id && installation_id
|
|
163
|
+
|
|
164
|
+
fp = credential_fingerprint(auth_type: :app_installation, identifier: "vault_app_#{app_id}")
|
|
165
|
+
cached = fetch_token(auth_type: :app_installation, installation_id: installation_id)
|
|
166
|
+
return cached.merge(metadata: { source: :vault, credential_fingerprint: fp }) if cached
|
|
167
|
+
|
|
168
|
+
jwt = generate_jwt(app_id: app_id, private_key: private_key)[:result]
|
|
169
|
+
token_data = create_installation_token(jwt: jwt, installation_id: installation_id)[:result]
|
|
170
|
+
return nil unless token_data&.dig('token')
|
|
171
|
+
|
|
172
|
+
expires_at = begin
|
|
173
|
+
Time.parse(token_data['expires_at'])
|
|
174
|
+
rescue StandardError => _e
|
|
175
|
+
Time.now + 3600
|
|
176
|
+
end
|
|
177
|
+
result = { token: token_data['token'], auth_type: :app_installation,
|
|
178
|
+
expires_at: expires_at, installation_id: installation_id,
|
|
179
|
+
metadata: { source: :vault, installation_id: installation_id,
|
|
180
|
+
credential_fingerprint: fp } }
|
|
181
|
+
store_token(**result)
|
|
182
|
+
result
|
|
183
|
+
rescue StandardError => _e
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def resolve_settings_app
|
|
188
|
+
return nil unless defined?(Legion::Settings)
|
|
189
|
+
|
|
190
|
+
app_id = begin
|
|
191
|
+
Legion::Settings.dig(:github, :app, :app_id)
|
|
192
|
+
rescue StandardError => _e
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
return nil unless app_id
|
|
196
|
+
|
|
197
|
+
fp = credential_fingerprint(auth_type: :app_installation, identifier: "settings_app_#{app_id}")
|
|
198
|
+
|
|
199
|
+
key_path = begin
|
|
200
|
+
Legion::Settings.dig(:github, :app, :private_key_path)
|
|
201
|
+
rescue StandardError => _e
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
installation_id = begin
|
|
205
|
+
Legion::Settings.dig(:github, :app, :installation_id)
|
|
206
|
+
rescue StandardError => _e
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
return nil unless key_path && installation_id
|
|
210
|
+
|
|
211
|
+
cached = fetch_token(auth_type: :app_installation, installation_id: installation_id)
|
|
212
|
+
return cached.merge(metadata: { source: :settings, credential_fingerprint: fp }) if cached
|
|
213
|
+
|
|
214
|
+
private_key = ::File.read(key_path)
|
|
215
|
+
jwt = generate_jwt(app_id: app_id, private_key: private_key)[:result]
|
|
216
|
+
token_data = create_installation_token(jwt: jwt, installation_id: installation_id)[:result]
|
|
217
|
+
return nil unless token_data&.dig('token')
|
|
218
|
+
|
|
219
|
+
expires_at = begin
|
|
220
|
+
Time.parse(token_data['expires_at'])
|
|
221
|
+
rescue StandardError => _e
|
|
222
|
+
Time.now + 3600
|
|
223
|
+
end
|
|
224
|
+
result = { token: token_data['token'], auth_type: :app_installation,
|
|
225
|
+
expires_at: expires_at, installation_id: installation_id,
|
|
226
|
+
metadata: { source: :settings, installation_id: installation_id,
|
|
227
|
+
credential_fingerprint: fp } }
|
|
228
|
+
store_token(**result)
|
|
229
|
+
result
|
|
230
|
+
rescue StandardError => _e
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def resolve_vault_pat
|
|
235
|
+
return nil unless defined?(Legion::Crypt)
|
|
236
|
+
|
|
237
|
+
token = vault_get('github/token')
|
|
238
|
+
return nil unless token
|
|
239
|
+
|
|
240
|
+
fp = credential_fingerprint(auth_type: :pat, identifier: 'vault_pat')
|
|
241
|
+
{ token: token, auth_type: :pat, metadata: { source: :vault, credential_fingerprint: fp } }
|
|
242
|
+
rescue StandardError => _e
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def resolve_settings_pat
|
|
247
|
+
return nil unless defined?(Legion::Settings)
|
|
248
|
+
|
|
249
|
+
token = Legion::Settings.dig(:github, :token)
|
|
250
|
+
return nil unless token
|
|
251
|
+
|
|
252
|
+
fp = credential_fingerprint(auth_type: :pat, identifier: 'settings_pat')
|
|
253
|
+
{ token: token, auth_type: :pat, metadata: { source: :settings, credential_fingerprint: fp } }
|
|
254
|
+
rescue StandardError => _e
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def resolve_gh_cli
|
|
259
|
+
if cache_connected? || local_cache_connected?
|
|
260
|
+
cached = cache_connected? ? cache_get('github:cli_token') : local_cache_get('github:cli_token')
|
|
261
|
+
return cached if cached
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
output = gh_cli_token_output
|
|
265
|
+
return nil unless output
|
|
266
|
+
|
|
267
|
+
fp = credential_fingerprint(auth_type: :cli, identifier: 'gh_cli')
|
|
268
|
+
result = { token: output, auth_type: :cli, metadata: { source: :gh_cli, credential_fingerprint: fp } }
|
|
269
|
+
cache_set('github:cli_token', result, ttl: 300) if cache_connected?
|
|
270
|
+
local_cache_set('github:cli_token', result, ttl: 300) if local_cache_connected?
|
|
271
|
+
result
|
|
272
|
+
rescue StandardError => _e
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def gh_cli_token_output
|
|
277
|
+
output = `gh auth token 2>/dev/null`.strip
|
|
278
|
+
return nil unless $?&.success? && !output.empty? # rubocop:disable Style/SpecialGlobalVars
|
|
279
|
+
|
|
280
|
+
output
|
|
281
|
+
rescue StandardError => _e
|
|
282
|
+
nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def resolve_env
|
|
286
|
+
token = ENV.fetch('GITHUB_TOKEN', nil)
|
|
287
|
+
return nil if token.nil? || token.empty?
|
|
288
|
+
|
|
289
|
+
fp = credential_fingerprint(auth_type: :env, identifier: 'env')
|
|
290
|
+
{ token: token, auth_type: :env, metadata: { source: :env, credential_fingerprint: fp } }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
def extract_owner_repo(path)
|
|
296
|
+
match = path.match(%r{^/repos/([^/]+)/([^/]+)})
|
|
297
|
+
return [nil, nil] unless match
|
|
298
|
+
|
|
299
|
+
[match[1], match[2]]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def credential_fallback?
|
|
303
|
+
return true unless defined?(Legion::Settings)
|
|
304
|
+
|
|
305
|
+
Legion::Settings.dig(:github, :credential_fallback) != false
|
|
306
|
+
rescue StandardError => _e
|
|
307
|
+
true
|
|
308
|
+
end
|
|
19
309
|
end
|
|
20
310
|
end
|
|
21
311
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Github
|
|
8
|
+
module Helpers
|
|
9
|
+
module ScopeRegistry
|
|
10
|
+
def credential_fingerprint(auth_type:, identifier:)
|
|
11
|
+
Digest::SHA256.hexdigest("#{auth_type}:#{identifier}")[0, 16]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def scope_status(fingerprint:, owner:, repo: nil)
|
|
15
|
+
if repo
|
|
16
|
+
status = scope_cache_get("github:scope:#{fingerprint}:#{owner}/#{repo}")
|
|
17
|
+
return status if status
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
scope_cache_get("github:scope:#{fingerprint}:#{owner}") || :unknown
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def register_scope(fingerprint:, owner:, status:, repo: nil)
|
|
24
|
+
key = repo ? "github:scope:#{fingerprint}:#{owner}/#{repo}" : "github:scope:#{fingerprint}:#{owner}"
|
|
25
|
+
ttl = if status == :denied
|
|
26
|
+
scope_denied_ttl
|
|
27
|
+
else
|
|
28
|
+
(repo ? scope_repo_ttl : scope_org_ttl)
|
|
29
|
+
end
|
|
30
|
+
cache_set(key, status, ttl: ttl) if cache_connected?
|
|
31
|
+
local_cache_set(key, status, ttl: ttl) if local_cache_connected?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def rate_limited?(fingerprint:)
|
|
35
|
+
entry = scope_cache_get("github:rate_limit:#{fingerprint}")
|
|
36
|
+
return false unless entry
|
|
37
|
+
|
|
38
|
+
entry[:reset_at] > Time.now
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def mark_rate_limited(fingerprint:, reset_at:)
|
|
42
|
+
ttl = [(reset_at - Time.now).ceil, 1].max
|
|
43
|
+
value = { reset_at: reset_at, remaining: 0 }
|
|
44
|
+
cache_set("github:rate_limit:#{fingerprint}", value, ttl: ttl) if cache_connected?
|
|
45
|
+
local_cache_set("github:rate_limit:#{fingerprint}", value, ttl: ttl) if local_cache_connected?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def invalidate_scope(fingerprint:, owner:, repo: nil)
|
|
49
|
+
key = repo ? "github:scope:#{fingerprint}:#{owner}/#{repo}" : "github:scope:#{fingerprint}:#{owner}"
|
|
50
|
+
cache_delete(key) if cache_connected?
|
|
51
|
+
local_cache_delete(key) if local_cache_connected?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def scope_cache_get(key)
|
|
57
|
+
if cache_connected?
|
|
58
|
+
result = cache_get(key)
|
|
59
|
+
return result if result
|
|
60
|
+
end
|
|
61
|
+
local_cache_get(key) if local_cache_connected?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def scope_org_ttl
|
|
65
|
+
return 3600 unless defined?(Legion::Settings)
|
|
66
|
+
|
|
67
|
+
Legion::Settings.dig(:github, :scope_registry, :org_ttl) || 3600
|
|
68
|
+
rescue StandardError => _e
|
|
69
|
+
3600
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def scope_repo_ttl
|
|
73
|
+
return 300 unless defined?(Legion::Settings)
|
|
74
|
+
|
|
75
|
+
Legion::Settings.dig(:github, :scope_registry, :repo_ttl) || 300
|
|
76
|
+
rescue StandardError => _e
|
|
77
|
+
300
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def scope_denied_ttl
|
|
81
|
+
return 300 unless defined?(Legion::Settings)
|
|
82
|
+
|
|
83
|
+
Legion::Settings.dig(:github, :scope_registry, :denied_ttl) || 300
|
|
84
|
+
rescue StandardError => _e
|
|
85
|
+
300
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'legion/cache/helper'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Github
|
|
9
|
+
module Helpers
|
|
10
|
+
module TokenCache
|
|
11
|
+
include Legion::Cache::Helper
|
|
12
|
+
|
|
13
|
+
TOKEN_BUFFER_SECONDS = 300
|
|
14
|
+
|
|
15
|
+
def store_token(token:, auth_type:, expires_at:, installation_id: nil, metadata: {}, **)
|
|
16
|
+
entry = { token: token, auth_type: auth_type,
|
|
17
|
+
expires_at: expires_at.respond_to?(:iso8601) ? expires_at.iso8601 : expires_at,
|
|
18
|
+
installation_id: installation_id, metadata: metadata }
|
|
19
|
+
ttl = [(expires_at.respond_to?(:to_i) ? expires_at.to_i - Time.now.to_i : 3600), 60].max
|
|
20
|
+
key = token_cache_key(auth_type, installation_id)
|
|
21
|
+
cache_set(key, entry, ttl: ttl) if cache_connected?
|
|
22
|
+
local_cache_set(key, entry, ttl: ttl) if local_cache_connected?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch_token(auth_type:, installation_id: nil, **)
|
|
26
|
+
key = token_cache_key(auth_type, installation_id)
|
|
27
|
+
entry = token_cache_read(key)
|
|
28
|
+
|
|
29
|
+
entry = token_cache_read(token_cache_key(auth_type, nil)) if entry.nil? && installation_id
|
|
30
|
+
|
|
31
|
+
return nil unless entry
|
|
32
|
+
|
|
33
|
+
expires = begin
|
|
34
|
+
Time.parse(entry[:expires_at].to_s)
|
|
35
|
+
rescue StandardError => _e
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
return nil if expires && expires < Time.now + TOKEN_BUFFER_SECONDS
|
|
39
|
+
|
|
40
|
+
entry
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def mark_rate_limited(auth_type:, reset_at:, **)
|
|
44
|
+
entry = { reset_at: reset_at.respond_to?(:iso8601) ? reset_at.iso8601 : reset_at }
|
|
45
|
+
ttl = [(reset_at.respond_to?(:to_i) ? reset_at.to_i - Time.now.to_i : 300), 10].max
|
|
46
|
+
key = "github:rate_limit:#{auth_type}"
|
|
47
|
+
cache_set(key, entry, ttl: ttl) if cache_connected?
|
|
48
|
+
local_cache_set(key, entry, ttl: ttl) if local_cache_connected?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def rate_limited?(auth_type:, **)
|
|
52
|
+
key = "github:rate_limit:#{auth_type}"
|
|
53
|
+
entry = if cache_connected?
|
|
54
|
+
cache_get(key)
|
|
55
|
+
elsif local_cache_connected?
|
|
56
|
+
local_cache_get(key)
|
|
57
|
+
end
|
|
58
|
+
return false unless entry
|
|
59
|
+
|
|
60
|
+
reset = begin
|
|
61
|
+
Time.parse(entry[:reset_at].to_s)
|
|
62
|
+
rescue StandardError => _e
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
reset.nil? || reset > Time.now
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def token_cache_key(auth_type, installation_id)
|
|
71
|
+
base = "github:token:#{auth_type}"
|
|
72
|
+
installation_id ? "#{base}:#{installation_id}" : base
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def token_cache_read(key)
|
|
76
|
+
if cache_connected?
|
|
77
|
+
cache_get(key)
|
|
78
|
+
elsif local_cache_connected?
|
|
79
|
+
local_cache_get(key)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Github
|
|
8
|
+
module Middleware
|
|
9
|
+
class CredentialFallback < ::Faraday::Middleware
|
|
10
|
+
RETRYABLE_STATUSES = [403, 429].freeze
|
|
11
|
+
IDEMPOTENT_METHODS = %w[GET HEAD OPTIONS PUT DELETE].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(app, resolver: nil)
|
|
14
|
+
super(app)
|
|
15
|
+
@resolver = resolver
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(env)
|
|
19
|
+
response = @app.call(env)
|
|
20
|
+
return response unless should_retry?(response)
|
|
21
|
+
|
|
22
|
+
retries = 0
|
|
23
|
+
max = @resolver.respond_to?(:max_fallback_retries) ? @resolver.max_fallback_retries : 3
|
|
24
|
+
|
|
25
|
+
while retries < max && should_retry?(response)
|
|
26
|
+
notify_resolver(response)
|
|
27
|
+
|
|
28
|
+
owner, repo = extract_owner_repo_from_env(env)
|
|
29
|
+
next_credential = @resolver&.resolve_next_credential(owner: owner, repo: repo)
|
|
30
|
+
break unless next_credential
|
|
31
|
+
|
|
32
|
+
env[:request_headers]['Authorization'] = "Bearer #{next_credential[:token]}"
|
|
33
|
+
|
|
34
|
+
response = @app.call(env)
|
|
35
|
+
retries += 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
response
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def should_retry?(response)
|
|
44
|
+
return false unless @resolver.respond_to?(:credential_fallback?)
|
|
45
|
+
return false unless @resolver.credential_fallback?
|
|
46
|
+
return false unless IDEMPOTENT_METHODS.include?(response.env[:method].to_s.upcase)
|
|
47
|
+
|
|
48
|
+
RETRYABLE_STATUSES.include?(response.status)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def extract_owner_repo_from_env(env)
|
|
52
|
+
path = env.url&.path.to_s
|
|
53
|
+
match = path.match(%r{^/repos/([^/]+)/([^/]+)})
|
|
54
|
+
match ? [match[1], match[2]] : [nil, nil]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def notify_resolver(response)
|
|
58
|
+
if response.status == 429 && @resolver.respond_to?(:on_rate_limit)
|
|
59
|
+
reset = response.headers['x-ratelimit-reset']
|
|
60
|
+
reset_at = reset ? Time.at(reset.to_i) : Time.now + 60
|
|
61
|
+
@resolver.on_rate_limit(remaining: 0, reset_at: reset_at,
|
|
62
|
+
status: 429, url: response.env.url.to_s)
|
|
63
|
+
elsif response.status == 403 && @resolver.respond_to?(:on_scope_denied)
|
|
64
|
+
@resolver.on_scope_denied(status: 403, url: response.env.url.to_s,
|
|
65
|
+
path: response.env.url.path)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Faraday::Middleware.register_middleware(
|
|
75
|
+
github_credential_fallback: Legion::Extensions::Github::Middleware::CredentialFallback
|
|
76
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Github
|
|
8
|
+
module Middleware
|
|
9
|
+
class RateLimit < ::Faraday::Middleware
|
|
10
|
+
def initialize(app, handler: nil)
|
|
11
|
+
super(app)
|
|
12
|
+
@handler = handler
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def on_complete(env)
|
|
16
|
+
remaining = env.response_headers['x-ratelimit-remaining']
|
|
17
|
+
reset = env.response_headers['x-ratelimit-reset']
|
|
18
|
+
return unless remaining
|
|
19
|
+
|
|
20
|
+
remaining_int = remaining.to_i
|
|
21
|
+
return unless remaining_int.zero? || env.status == 429
|
|
22
|
+
return unless @handler.respond_to?(:on_rate_limit)
|
|
23
|
+
|
|
24
|
+
reset_at = reset ? Time.at(reset.to_i) : Time.now + 60
|
|
25
|
+
@handler.on_rate_limit(
|
|
26
|
+
remaining: remaining_int,
|
|
27
|
+
reset_at: reset_at,
|
|
28
|
+
status: env.status,
|
|
29
|
+
url: env.url.to_s
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Faraday::Response.register_middleware(
|
|
39
|
+
github_rate_limit: Legion::Extensions::Github::Middleware::RateLimit
|
|
40
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Github
|
|
8
|
+
module Middleware
|
|
9
|
+
class ScopeProbe < ::Faraday::Middleware
|
|
10
|
+
REPO_PATH_PATTERN = %r{^/repos/([^/]+)/([^/]+)}
|
|
11
|
+
|
|
12
|
+
def initialize(app, handler: nil)
|
|
13
|
+
super(app)
|
|
14
|
+
@handler = handler
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def on_complete(env)
|
|
18
|
+
return unless @handler
|
|
19
|
+
return unless env.url.path.match?(REPO_PATH_PATTERN)
|
|
20
|
+
|
|
21
|
+
info = { status: env.status, url: env.url.to_s, path: env.url.path }
|
|
22
|
+
|
|
23
|
+
if [403, 404].include?(env.status)
|
|
24
|
+
@handler.on_scope_denied(info) if @handler.respond_to?(:on_scope_denied)
|
|
25
|
+
elsif env.status >= 200 && env.status < 300
|
|
26
|
+
@handler.on_scope_authorized(info) if @handler.respond_to?(:on_scope_authorized)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Faraday::Response.register_middleware(
|
|
36
|
+
github_scope_probe: Legion::Extensions::Github::Middleware::ScopeProbe
|
|
37
|
+
)
|