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.
@@ -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