entitlements-github-plugin 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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