entitlements-github-plugin 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/VERSION +1 -0
- data/lib/entitlements/backend/github_org/controller.rb +468 -0
- data/lib/entitlements/backend/github_org/provider.rb +136 -0
- data/lib/entitlements/backend/github_org/service.rb +88 -0
- data/lib/entitlements/backend/github_org.rb +26 -0
- data/lib/entitlements/backend/github_team/controller.rb +126 -0
- data/lib/entitlements/backend/github_team/models/team.rb +36 -0
- data/lib/entitlements/backend/github_team/provider.rb +208 -0
- data/lib/entitlements/backend/github_team/service.rb +402 -0
- data/lib/entitlements/backend/github_team.rb +6 -0
- data/lib/entitlements/service/github.rb +394 -0
- metadata +325 -0
@@ -0,0 +1,394 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "net/http"
|
4
|
+
require "octokit"
|
5
|
+
require "uri"
|
6
|
+
|
7
|
+
module Entitlements
|
8
|
+
class Service
|
9
|
+
class GitHub
|
10
|
+
include ::Contracts::Core
|
11
|
+
C = ::Contracts
|
12
|
+
|
13
|
+
# This is a limitation of the GitHub API
|
14
|
+
MAX_GRAPHQL_RESULTS = 100
|
15
|
+
|
16
|
+
# Retries to smooth over transient network blips
|
17
|
+
MAX_GRAPHQL_RETRIES = 3
|
18
|
+
WAIT_BETWEEN_GRAPHQL_RETRIES = 1
|
19
|
+
|
20
|
+
attr_reader :addr, :org, :token, :ou
|
21
|
+
|
22
|
+
# Constructor.
|
23
|
+
#
|
24
|
+
# addr - Base URL a GitHub Enterprise API (leave undefined to use dotcom)
|
25
|
+
# org - String with organization name
|
26
|
+
# token - Access token for GitHub API
|
27
|
+
# ou - Base OU for fudged DNs
|
28
|
+
#
|
29
|
+
# Returns nothing.
|
30
|
+
Contract C::KeywordArgs[
|
31
|
+
addr: C::Maybe[String],
|
32
|
+
org: String,
|
33
|
+
token: String,
|
34
|
+
ou: String
|
35
|
+
] => C::Any
|
36
|
+
def initialize(addr: nil, org:, token:, ou:)
|
37
|
+
# Save some parameters for the connection but don't actually connect yet.
|
38
|
+
@addr = addr
|
39
|
+
@org = org
|
40
|
+
@token = token
|
41
|
+
@ou = ou
|
42
|
+
|
43
|
+
# This is a global cache across all invocations of this object. GitHub membership
|
44
|
+
# need to be obtained only one time per organization, but might be used multiple times.
|
45
|
+
Entitlements.cache[:github_pending_members] ||= {}
|
46
|
+
Entitlements.cache[:github_org_members] ||= {}
|
47
|
+
end
|
48
|
+
|
49
|
+
# Return the identifier, either the address specified or otherwise "github.com".
|
50
|
+
#
|
51
|
+
# Takes no arguments.
|
52
|
+
#
|
53
|
+
# Returns the address.
|
54
|
+
Contract C::None => String
|
55
|
+
def identifier
|
56
|
+
@identifier ||= begin
|
57
|
+
if addr.nil?
|
58
|
+
"github.com"
|
59
|
+
else
|
60
|
+
u = URI(addr)
|
61
|
+
u.host
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Read the members of an organization and return a Hash of users with their role.
|
67
|
+
# This method does not need parameters because the underlying service already
|
68
|
+
# has the organization available in an `org` method.
|
69
|
+
#
|
70
|
+
# Takes no parameters.
|
71
|
+
#
|
72
|
+
# Returns Hash of { "username" => "role" }.
|
73
|
+
Contract C::None => C::HashOf[String => String]
|
74
|
+
def org_members
|
75
|
+
Entitlements.cache[:github_org_members][org_signature] ||= begin
|
76
|
+
roles = Entitlements::Backend::GitHubOrg::ORGANIZATION_ROLES.invert
|
77
|
+
|
78
|
+
# Some basic stats are helpful for debugging
|
79
|
+
data, cache = members_and_roles_from_graphql_or_cache
|
80
|
+
result = data.map { |username, role| [username, roles.fetch(role)] }.to_h
|
81
|
+
admin_count = result.count { |_, role| role == "admin" }
|
82
|
+
member_count = result.count { |_, role| role == "member" }
|
83
|
+
Entitlements.logger.debug "Currently #{org} has #{admin_count} admin(s) and #{member_count} member(s)"
|
84
|
+
|
85
|
+
{ cache: cache, value: result }
|
86
|
+
end
|
87
|
+
|
88
|
+
Entitlements.cache[:github_org_members][org_signature][:value]
|
89
|
+
end
|
90
|
+
|
91
|
+
# Returns true if the github instance is an enterprise server instance
|
92
|
+
Contract C::None => C::Bool
|
93
|
+
def enterprise?
|
94
|
+
meta = octokit.github_meta
|
95
|
+
meta.key? :installed_version
|
96
|
+
end
|
97
|
+
|
98
|
+
# Read the members of an organization who are in a "pending" role. These users should
|
99
|
+
# not be re-invited or updated unless and until they have accepted the invitation.
|
100
|
+
#
|
101
|
+
# Takes no parameters.
|
102
|
+
#
|
103
|
+
# Returns Set of usernames.
|
104
|
+
Contract C::None => C::SetOf[String]
|
105
|
+
def pending_members
|
106
|
+
Entitlements.cache[:github_pending_members][org_signature] ||= begin
|
107
|
+
# ghes does not support org invites
|
108
|
+
return Set.new if enterprise?
|
109
|
+
pm = pending_members_from_graphql
|
110
|
+
Entitlements.logger.debug "Currently #{org} has #{pm.size} pending member(s)"
|
111
|
+
pm
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# Determine whether the most recent entry came from the predictive cache or an actual
|
116
|
+
# call to the API.
|
117
|
+
#
|
118
|
+
# Takes no arguments.
|
119
|
+
#
|
120
|
+
# Returns true if it came from the cache, or false if it came from the API.
|
121
|
+
Contract C::None => C::Bool
|
122
|
+
def org_members_from_predictive_cache?
|
123
|
+
org_members # Force this to be read if for some reason it has not been yet.
|
124
|
+
Entitlements.cache[:github_org_members][org_signature][:cache] || false
|
125
|
+
end
|
126
|
+
|
127
|
+
# Invalidate the predictive cache for organization members, and if the prior knowledge
|
128
|
+
# of that role was from the cache, re-read from the actual data source.
|
129
|
+
#
|
130
|
+
# Takes no arguments.
|
131
|
+
#
|
132
|
+
# Returns nothing.
|
133
|
+
Contract C::None => nil
|
134
|
+
def invalidate_org_members_predictive_cache
|
135
|
+
# If the entry was not from the predictive cache in the first place, just return.
|
136
|
+
# This really should not get called if that's the case, but regardless, we don't
|
137
|
+
# want to pointlessly hit the API twice.
|
138
|
+
return unless org_members_from_predictive_cache?
|
139
|
+
|
140
|
+
# The entry did come from the predictive cache. Invalidate the entry, clear local
|
141
|
+
# caches, and re-read the data from the API.
|
142
|
+
Entitlements.logger.debug "Invalidating cache entries for cn=(admin|member),#{ou}"
|
143
|
+
Entitlements::Data::Groups::Cached.invalidate("cn=admin,#{ou}")
|
144
|
+
Entitlements::Data::Groups::Cached.invalidate("cn=member,#{ou}")
|
145
|
+
Entitlements.cache[:github_org_members].delete(org_signature)
|
146
|
+
org_members
|
147
|
+
nil
|
148
|
+
end
|
149
|
+
|
150
|
+
private
|
151
|
+
|
152
|
+
# The octokit object is initialized the first time it's called.
|
153
|
+
#
|
154
|
+
# Takes no arguments.
|
155
|
+
#
|
156
|
+
# Returns an Octokit client object.
|
157
|
+
Contract C::None => Octokit::Client
|
158
|
+
def octokit
|
159
|
+
@octokit ||= begin
|
160
|
+
client = Octokit::Client.new(access_token: token)
|
161
|
+
client.api_endpoint = addr if addr
|
162
|
+
client.auto_paginate = true
|
163
|
+
Entitlements.logger.debug "Setting up GitHub API connection to #{client.api_endpoint}"
|
164
|
+
client
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Get data from the predictive updates cache if it's available and valid, or else get it
|
169
|
+
# from GraphQL API. This is a shim between readers and `members_and_roles_from_graphql`.
|
170
|
+
#
|
171
|
+
# Takes no parameters.
|
172
|
+
#
|
173
|
+
# Returns Hash of { "username" => "ROLE" } where "ROLE" is from GraphQL Enum.
|
174
|
+
Contract C::None => [C::HashOf[String => String], C::Bool]
|
175
|
+
def members_and_roles_from_graphql_or_cache
|
176
|
+
admin_from_cache = Entitlements::Data::Groups::Cached.members("cn=admin,#{ou}")
|
177
|
+
member_from_cache = Entitlements::Data::Groups::Cached.members("cn=member,#{ou}")
|
178
|
+
|
179
|
+
# If we do not have *both* admins and members, we need to call the API
|
180
|
+
return [members_and_roles_from_rest, false] unless admin_from_cache && member_from_cache
|
181
|
+
|
182
|
+
# Convert the Sets of strings into the expected hash structure.
|
183
|
+
Entitlements.logger.debug "Loading organization members and roles for #{org} from cache"
|
184
|
+
result = admin_from_cache.map { |uid| [uid, "ADMIN"] }.to_h
|
185
|
+
result.merge! member_from_cache.map { |uid| [uid, "MEMBER"] }.to_h
|
186
|
+
[result, true]
|
187
|
+
end
|
188
|
+
|
189
|
+
# Query GraphQL API to get a list of members and their roles.
|
190
|
+
#
|
191
|
+
# Takes no parameters.
|
192
|
+
#
|
193
|
+
# Returns Hash of { "username" => "ROLE" } where "ROLE" is from GraphQL Enum.
|
194
|
+
Contract C::None => C::HashOf[String => String]
|
195
|
+
def members_and_roles_from_graphql
|
196
|
+
Entitlements.logger.debug "Loading organization members and roles for #{org}"
|
197
|
+
|
198
|
+
cursor = nil
|
199
|
+
result = {}
|
200
|
+
sanity_counter = 0
|
201
|
+
|
202
|
+
while sanity_counter < 100
|
203
|
+
sanity_counter += 1
|
204
|
+
first_str = cursor.nil? ? "first: #{max_graphql_results}" : "first: #{max_graphql_results}, after: \"#{cursor}\""
|
205
|
+
query = "{
|
206
|
+
organization(login: \"#{org}\") {
|
207
|
+
membersWithRole(#{first_str}) {
|
208
|
+
edges {
|
209
|
+
node {
|
210
|
+
login
|
211
|
+
}
|
212
|
+
role
|
213
|
+
cursor
|
214
|
+
}
|
215
|
+
}
|
216
|
+
}
|
217
|
+
}".gsub(/\n\s+/, "\n")
|
218
|
+
|
219
|
+
response = graphql_http_post(query)
|
220
|
+
unless response[:code] == 200
|
221
|
+
Entitlements.logger.fatal "Abort due to GraphQL failure on #{query.inspect}"
|
222
|
+
raise "GraphQL query failure"
|
223
|
+
end
|
224
|
+
|
225
|
+
edges = response[:data].fetch("data").fetch("organization").fetch("membersWithRole").fetch("edges")
|
226
|
+
break unless edges.any?
|
227
|
+
|
228
|
+
edges.each do |edge|
|
229
|
+
result[edge.fetch("node").fetch("login").downcase] = edge.fetch("role")
|
230
|
+
end
|
231
|
+
|
232
|
+
cursor = edges.last.fetch("cursor")
|
233
|
+
next if cursor && edges.size == max_graphql_results
|
234
|
+
break
|
235
|
+
end
|
236
|
+
|
237
|
+
result
|
238
|
+
end
|
239
|
+
|
240
|
+
# Returns Hash of { "username" => "ROLE" } where "ROLE" is ADMIN or MEMBER
|
241
|
+
Contract C::None => C::HashOf[String => String]
|
242
|
+
def members_and_roles_from_rest
|
243
|
+
Entitlements.logger.debug "Loading organization members and roles for #{org}"
|
244
|
+
result = {}
|
245
|
+
members = octokit.organization_members(org, { role: "admin" })
|
246
|
+
members.each do |member|
|
247
|
+
result[member[:login].downcase] = "ADMIN"
|
248
|
+
end
|
249
|
+
octokit.organization_members(org, { role: "member" }).each do |member|
|
250
|
+
result[member[:login].downcase] = "MEMBER"
|
251
|
+
end
|
252
|
+
|
253
|
+
result
|
254
|
+
end
|
255
|
+
|
256
|
+
# Query GraphQL API to get a list of pending members for the organization.
|
257
|
+
#
|
258
|
+
# Takes no parameters.
|
259
|
+
#
|
260
|
+
# Returns Set of usernames.
|
261
|
+
def pending_members_from_graphql
|
262
|
+
# Since pending members is really a state and not an entitlement, this code does
|
263
|
+
# not attempt to use a predictive cache. When this is invoked, it contacts the API.
|
264
|
+
|
265
|
+
cursor = nil
|
266
|
+
result = Set.new
|
267
|
+
sanity_counter = 0
|
268
|
+
|
269
|
+
while sanity_counter < 100
|
270
|
+
sanity_counter += 1
|
271
|
+
first_str = cursor.nil? ? "first: #{max_graphql_results}" : "first: #{max_graphql_results}, after: \"#{cursor}\""
|
272
|
+
query = "{
|
273
|
+
organization(login: \"#{org}\") {
|
274
|
+
pendingMembers(#{first_str}) {
|
275
|
+
edges {
|
276
|
+
node {
|
277
|
+
login
|
278
|
+
}
|
279
|
+
cursor
|
280
|
+
}
|
281
|
+
}
|
282
|
+
}
|
283
|
+
}".gsub(/\n\s+/, "\n")
|
284
|
+
|
285
|
+
response = graphql_http_post(query)
|
286
|
+
unless response[:code] == 200
|
287
|
+
Entitlements.logger.fatal "Abort due to GraphQL failure on #{query.inspect}"
|
288
|
+
raise "GraphQL query failure"
|
289
|
+
end
|
290
|
+
|
291
|
+
edges = response[:data].fetch("data").fetch("organization").fetch("pendingMembers").fetch("edges")
|
292
|
+
break unless edges.any?
|
293
|
+
|
294
|
+
edges.each do |edge|
|
295
|
+
result.add(edge.fetch("node").fetch("login").downcase)
|
296
|
+
end
|
297
|
+
|
298
|
+
cursor = edges.last.fetch("cursor")
|
299
|
+
next if cursor && edges.size == max_graphql_results
|
300
|
+
break
|
301
|
+
end
|
302
|
+
|
303
|
+
result
|
304
|
+
end
|
305
|
+
|
306
|
+
# Helper method: Do the HTTP POST to the GitHub API for GraphQL. This has a retry which is
|
307
|
+
# intended to avoid a failure due to a network blip.
|
308
|
+
#
|
309
|
+
# query - String with the data to be posted.
|
310
|
+
#
|
311
|
+
# Returns { code: <Integer>, data: <response data structure> }
|
312
|
+
Contract String => { code: Integer, data: C::Or[nil, Hash] }
|
313
|
+
def graphql_http_post(query)
|
314
|
+
1.upto(MAX_GRAPHQL_RETRIES) do |try_number|
|
315
|
+
result = graphql_http_post_real(query)
|
316
|
+
if result[:code] < 500
|
317
|
+
return result
|
318
|
+
elsif try_number >= MAX_GRAPHQL_RETRIES
|
319
|
+
Entitlements.logger.error "Query still failing after #{MAX_GRAPHQL_RETRIES} tries. Giving up."
|
320
|
+
return result
|
321
|
+
else
|
322
|
+
Entitlements.logger.warn "GraphQL failed on try #{try_number} of #{MAX_GRAPHQL_RETRIES}. Will retry."
|
323
|
+
sleep WAIT_BETWEEN_GRAPHQL_RETRIES * (2 ** (try_number - 1))
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
# Helper method: Do the HTTP POST to the GitHub API for GraphQL.
|
329
|
+
#
|
330
|
+
# query - String with the data to be posted.
|
331
|
+
#
|
332
|
+
# Returns { code: <Integer>, data: <response data structure> }
|
333
|
+
Contract String => { code: Integer, data: C::Or[nil, Hash] }
|
334
|
+
def graphql_http_post_real(query)
|
335
|
+
uri = URI.parse(File.join(octokit.api_endpoint, "graphql"))
|
336
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
337
|
+
http.use_ssl = uri.scheme == "https"
|
338
|
+
|
339
|
+
request = Net::HTTP::Post.new(uri)
|
340
|
+
request.add_field("Authorization", "bearer #{token}")
|
341
|
+
request.add_field("Content-Type", "application/json")
|
342
|
+
request.body = JSON.generate("query" => query)
|
343
|
+
|
344
|
+
begin
|
345
|
+
response = http.request(request)
|
346
|
+
|
347
|
+
if response.code != "200"
|
348
|
+
Entitlements.logger.error "Got HTTP #{response.code} POSTing to #{uri}"
|
349
|
+
Entitlements.logger.error response.body
|
350
|
+
return { code: response.code.to_i, data: { "body" => response.body } }
|
351
|
+
end
|
352
|
+
|
353
|
+
begin
|
354
|
+
data = JSON.parse(response.body)
|
355
|
+
if data.key?("errors")
|
356
|
+
Entitlements.logger.error "Errors reported: #{data['errors'].inspect}"
|
357
|
+
return { code: 500, data: data }
|
358
|
+
end
|
359
|
+
{ code: response.code.to_i, data: data }
|
360
|
+
rescue JSON::ParserError => e
|
361
|
+
Entitlements.logger.error "#{e.class} #{e.message}: #{response.body.inspect}"
|
362
|
+
{ code: 500, data: { "body" => response.body } }
|
363
|
+
end
|
364
|
+
rescue => e
|
365
|
+
Entitlements.logger.error "Caught #{e.class} POSTing to #{uri}: #{e.message}"
|
366
|
+
{ code: 500, data: nil }
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
# Create a unique signature for this GitHub instance to identify it in a global cache.
|
371
|
+
#
|
372
|
+
# Takes no arguments.
|
373
|
+
#
|
374
|
+
# Returns a String.
|
375
|
+
Contract C::None => String
|
376
|
+
def org_signature
|
377
|
+
[addr || "", org].join("|")
|
378
|
+
end
|
379
|
+
|
380
|
+
# Get the maximum GraphQL results. This is a method that just returns the constant
|
381
|
+
# but this way it can be overridden in tests.
|
382
|
+
#
|
383
|
+
# Takes no arguments.
|
384
|
+
#
|
385
|
+
# Returns an Integer.
|
386
|
+
# :nocov:
|
387
|
+
Contract C::None => Integer
|
388
|
+
def max_graphql_results
|
389
|
+
MAX_GRAPHQL_RESULTS
|
390
|
+
end
|
391
|
+
# :nocov:
|
392
|
+
end
|
393
|
+
end
|
394
|
+
end
|