entitlements-github-plugin 0.0.1
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 +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
|