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,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models/team"
4
+ require_relative "../../service/github"
5
+
6
+ require "base64"
7
+
8
+ module Entitlements
9
+ class Backend
10
+ class GitHubTeam
11
+ class Service < Entitlements::Service::GitHub
12
+ include ::Contracts::Core
13
+ include ::Contracts::Builtin
14
+ C = ::Contracts
15
+
16
+ class TeamNotFound < RuntimeError; end
17
+
18
+ # Constructor.
19
+ #
20
+ # addr - Base URL a GitHub Enterprise API (leave undefined to use dotcom)
21
+ # org - String with organization name
22
+ # token - Access token for GitHub API
23
+ # ou - Base OU for fudged DNs
24
+ #
25
+ # Returns nothing.
26
+ Contract C::KeywordArgs[
27
+ addr: C::Maybe[String],
28
+ org: String,
29
+ token: String,
30
+ ou: String
31
+ ] => C::Any
32
+ def initialize(addr: nil, org:, token:, ou:)
33
+ super
34
+ Entitlements.cache[:github_team_members] ||= {}
35
+ Entitlements.cache[:github_team_members][org] ||= {}
36
+ @team_cache = Entitlements.cache[:github_team_members][org]
37
+ end
38
+
39
+ # Read a single team identified by its slug and return a team object.
40
+ # This is aware of the predictive cache and will use it if appropriate.
41
+ #
42
+ # entitlement_group - Entitlements::Models::Group representing the entitlement being worked on
43
+ #
44
+ # Returns a Entitlements::Backend::GitHubTeam::Models::Team or nil if the team does not exist
45
+ Contract Entitlements::Models::Group => C::Maybe[Entitlements::Backend::GitHubTeam::Models::Team]
46
+ def read_team(entitlement_group)
47
+ team_identifier = entitlement_group.cn.downcase
48
+ @team_cache[team_identifier] ||= begin
49
+ dn = "cn=#{team_identifier},#{ou}"
50
+ begin
51
+ entitlement_metadata = entitlement_group.metadata
52
+ rescue Entitlements::Models::Group::NoMetadata
53
+ entitlement_metadata = nil
54
+ end
55
+
56
+ if (cached_members = Entitlements::Data::Groups::Cached.members(dn))
57
+ Entitlements.logger.debug "Loading GitHub team #{identifier}:#{org}/#{team_identifier} from cache"
58
+
59
+ cached_metadata = Entitlements::Data::Groups::Cached.metadata(dn)
60
+ # If both the cached and entitlement metadata are nil, our team metadata is nil
61
+ # If one of the cached or entitlement metadata is nil, we use the other populated metadata hash as-is
62
+ # If both cached and entitlement metadata exist, we combine the two hashes with the cached metadata taking precedence
63
+ #
64
+ # The reason we do this is because an entitlement file should be 1:1 to a GitHub Team. However,
65
+ # entitlements files allow for metadata tags and the GitHub.com Team does not have a place to store those.
66
+ # Therefore, we must combine any existing entitlement metadata entries into the Team metadata hash
67
+ if cached_metadata.nil?
68
+ team_metadata = entitlement_metadata
69
+ elsif entitlement_metadata.nil?
70
+ team_metadata = cached_metadata
71
+ else
72
+ # Always merge the current state metadata (cached or API call) into the entitlement metadata, so that the current state takes precedent
73
+ team_metadata = entitlement_metadata.merge(cached_metadata)
74
+ end
75
+
76
+ team = Entitlements::Backend::GitHubTeam::Models::Team.new(
77
+ team_id: -1,
78
+ team_name: team_identifier,
79
+ members: cached_members,
80
+ ou: ou,
81
+ metadata: team_metadata
82
+ )
83
+
84
+ { cache: true, value: team }
85
+ else
86
+ Entitlements.logger.debug "Loading GitHub team #{identifier}:#{org}/#{team_identifier}"
87
+
88
+ begin
89
+ teamdata = graphql_team_data(team_identifier)
90
+ # The entitlement metadata may have GitHub.com Team metadata which it wants to set, so we must
91
+ # overwrite that metadata with what we get from the API
92
+ if teamdata[:parent_team_name].nil?
93
+ team_metadata = entitlement_metadata
94
+ else
95
+ parent_team_metadata = {
96
+ "parent_team_name" => teamdata[:parent_team_name]
97
+ }
98
+ if entitlement_metadata.nil?
99
+ team_metadata = parent_team_metadata
100
+ else
101
+ # Always merge the current state metadata (cached or API call) into the entitlement metadata, so that the current state takes precedent
102
+ team_metadata = entitlement_metadata.merge(parent_team_metadata)
103
+ end
104
+ end
105
+
106
+ team = Entitlements::Backend::GitHubTeam::Models::Team.new(
107
+ team_id: teamdata[:team_id],
108
+ team_name: team_identifier,
109
+ members: Set.new(teamdata[:members]),
110
+ ou: ou,
111
+ metadata: team_metadata
112
+ )
113
+ rescue TeamNotFound
114
+ Entitlements.logger.warn "Team #{team_identifier} does not exist in this GitHub.com organization"
115
+ return nil
116
+ end
117
+
118
+ { cache: false, value: team }
119
+ end
120
+ end
121
+
122
+ @team_cache[team_identifier][:value]
123
+ end
124
+
125
+ # Determine whether the most recent entry came from the predictive cache or an actual
126
+ # call to the API.
127
+ #
128
+ # entitlement_group - Entitlements::Models::Group representing the group from the entitlement
129
+ #
130
+ # Returns true if it came from the cache, or false if it came from the API.
131
+ Contract Entitlements::Models::Group => C::Bool
132
+ def from_predictive_cache?(entitlement_group)
133
+ team_identifier = entitlement_group.cn.downcase
134
+ read_team(entitlement_group) unless @team_cache[team_identifier]
135
+ (@team_cache[team_identifier] && @team_cache[team_identifier][:cache]) ? true : false
136
+ end
137
+
138
+ # Declare the entry to be invalid for a specific team, and if the prior knowledge
139
+ # of that team was from the cache, re-read from the actual data source.
140
+ #
141
+ # entitlement_group - Entitlements::Models::Group representing the group from the entitlement
142
+ #
143
+ # Returns nothing.
144
+ Contract Entitlements::Models::Group => nil
145
+ def invalidate_predictive_cache(entitlement_group)
146
+ # If the entry was not from the predictive cache in the first place, just return.
147
+ # This really should not get called if that's the case, but regardless, we don't
148
+ # want to pointlessly hit the API twice.
149
+ return unless from_predictive_cache?(entitlement_group)
150
+
151
+ # The entry did come from the predictive cache. Clear out all of the local caches
152
+ # in this object and re-read the data from the API.
153
+ team_identifier = entitlement_group.cn.downcase
154
+ dn = "cn=#{team_identifier},#{ou}"
155
+ Entitlements.logger.debug "Invalidating cache entry for #{dn}"
156
+ Entitlements::Data::Groups::Cached.invalidate(dn)
157
+ @team_cache.delete(team_identifier)
158
+ read_team(entitlement_group)
159
+ nil
160
+ end
161
+
162
+ # Sync a GitHub team. (The team must already exist and its ID must be known.)
163
+ #
164
+ # data - An Entitlements::Backend::GitHubTeam::Models::Team object with the new members and data.
165
+ #
166
+ # Returns true if it succeeded, false if it did not.
167
+ Contract Entitlements::Models::Group, C::Or[Entitlements::Backend::GitHubTeam::Models::Team, nil] => C::Bool
168
+ def sync_team(desired_state, current_state)
169
+ begin
170
+ desired_metadata = desired_state.metadata
171
+ rescue Entitlements::Models::Group::NoMetadata
172
+ desired_metadata = {}
173
+ end
174
+
175
+ begin
176
+ current_metadata = current_state.metadata
177
+ rescue Entitlements::Models::Group::NoMetadata, NoMethodError
178
+ current_metadata = {}
179
+ end
180
+
181
+ changed_parent_team = false
182
+ unless desired_metadata["parent_team_name"] == current_metadata["parent_team_name"]
183
+ # TODO: I'm hard-coding a block for deletes, for now. I'm doing that by making sure we dont set the desired parent_team_id to nil for teams where it is already set
184
+ # :nocov:
185
+ if desired_metadata["parent_team_name"].nil?
186
+ Entitlements.logger.debug "sync_team(team=#{current_state.team_name}): IGNORING GitHub Parent Team DELETE"
187
+ else
188
+ # :nocov:
189
+ Entitlements.logger.debug "sync_team(#{current_state.team_name}=#{current_state.team_id}): Parent team change found - From #{current_metadata["parent_team_name"] || "No Parent Team"} to #{desired_metadata["parent_team_name"]}"
190
+ desired_parent_team_id = team_by_name(org_name: org, team_name: desired_metadata["parent_team_name"])[:id]
191
+ unless desired_parent_team_id.nil?
192
+ # TODO: I'm hard-coding a block for deletes, for now. I'm doing that by making sure we dont set the desired parent_team_id to nil for teams where it is already set
193
+ update_team(team: current_state, metadata: { parent_team_id: desired_parent_team_id })
194
+ end
195
+ changed_parent_team = true
196
+ end
197
+ end
198
+
199
+ added_members = desired_state.member_strings.map { |u| u.downcase } - current_state.member_strings.map { |u| u.downcase }
200
+ removed_members = current_state.member_strings.map { |u| u.downcase } - desired_state.member_strings.map { |u| u.downcase }
201
+
202
+ added_members.select! { |username| add_user_to_team(user: username, team: current_state) }
203
+ removed_members.select! { |username| remove_user_from_team(user: username, team: current_state) }
204
+
205
+ Entitlements.logger.debug "sync_team(#{current_state.team_name}=#{current_state.team_id}): Added #{added_members.count}, removed #{removed_members.count}"
206
+ added_members.any? || removed_members.any? || changed_parent_team
207
+ end
208
+
209
+ # Create a team
210
+ #
211
+ # team - String with the desired team name
212
+ #
213
+ # Returns true if the team was created
214
+ Contract C::KeywordArgs[
215
+ entitlement_group: Entitlements::Models::Group,
216
+ ] => C::Bool
217
+ def create_team(entitlement_group:)
218
+ begin
219
+ team_name = entitlement_group.cn.downcase
220
+ team_options = { name: team_name, repo_names: [], privacy: "closed" }
221
+
222
+ begin
223
+ entitlement_metadata = entitlement_group.metadata
224
+ unless entitlement_metadata["parent_team_name"].nil?
225
+ parent_team_data = graphql_team_data(entitlement_metadata["parent_team_name"])
226
+ team_options[:parent_team_id] = parent_team_data[:team_id]
227
+ Entitlements.logger.debug "create_team(team=#{team_name}) Parent team #{entitlement_metadata["parent_team_name"]} with id #{parent_team_data[:team_id]} found"
228
+ end
229
+ rescue Entitlements::Models::Group::NoMetadata
230
+ Entitlements.logger.debug "create_team(team=#{team_name}) No metadata found"
231
+ end
232
+
233
+ Entitlements.logger.debug "create_team(team=#{team_name})"
234
+ octokit.create_team(org, team_options)
235
+ true
236
+ rescue Octokit::UnprocessableEntity => e
237
+ Entitlements.logger.debug "create_team(team=#{team_name}) ERROR - #{e.message}"
238
+ false
239
+ end
240
+ end
241
+
242
+ # Update a team
243
+ #
244
+ # team - Entitlements::Backend::GitHubTeam::Models::Team object
245
+ #
246
+ # Returns true if the team was updated
247
+ Contract C::KeywordArgs[
248
+ team: Entitlements::Backend::GitHubTeam::Models::Team,
249
+ metadata: C::Or[Hash, nil]
250
+ ] => C::Bool
251
+ def update_team(team:, metadata: {})
252
+ begin
253
+ Entitlements.logger.debug "update_team(team=#{team.team_name})"
254
+ options = { name: team.team_name, repo_names: [], privacy: "closed", parent_team_id: metadata[:parent_team_id] }
255
+ octokit.update_team(team.team_id, options)
256
+ true
257
+ rescue Octokit::UnprocessableEntity => e
258
+ Entitlements.logger.debug "update_team(team=#{team.team_name}) ERROR - #{e.message}"
259
+ false
260
+ end
261
+ end
262
+
263
+ # Gets a team by name
264
+ #
265
+ # team - Entitlements::Backend::GitHubTeam::Models::Team object
266
+ #
267
+ # Returns true if the team was updated
268
+ Contract C::KeywordArgs[
269
+ org_name: String,
270
+ team_name: String
271
+ ] => Sawyer::Resource
272
+ def team_by_name(org_name:, team_name:)
273
+ octokit.team_by_name(org_name, team_name)
274
+ end
275
+
276
+ private
277
+
278
+ # GraphQL query for the members of a team identified by a slug. (For now
279
+ # our GraphQL needs are simple so this is just a hard-coded query. In the
280
+ # future if this gets more widely used, consider one of the graphql client
281
+ # gems, such as https://github.com/github/graphql-client.)
282
+ #
283
+ # team_slug - Identifier of the team to retrieve.
284
+ #
285
+ # Returns a data structure with team data.
286
+ Contract String => { members: C::ArrayOf[String], team_id: Integer, parent_team_name: C::Or[String, nil] }
287
+ def graphql_team_data(team_slug)
288
+ cursor = nil
289
+ team_id = nil
290
+ result = []
291
+ sanity_counter = 0
292
+
293
+ while sanity_counter < 100
294
+ sanity_counter += 1
295
+ first_str = cursor.nil? ? "first: #{max_graphql_results}" : "first: #{max_graphql_results}, after: \"#{cursor}\""
296
+ query = "{
297
+ organization(login: \"#{org}\") {
298
+ team(slug: \"#{team_slug}\") {
299
+ databaseId
300
+ parentTeam {
301
+ slug
302
+ }
303
+ members(#{first_str}, membership: IMMEDIATE) {
304
+ edges {
305
+ node {
306
+ login
307
+ }
308
+ cursor
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }".gsub(/\n\s+/, "\n")
314
+
315
+ response = graphql_http_post(query)
316
+ unless response[:code] == 200
317
+ Entitlements.logger.fatal "Abort due to GraphQL failure on #{query.inspect}"
318
+ raise "GraphQL query failure"
319
+ end
320
+
321
+ team = response[:data].fetch("data").fetch("organization").fetch("team")
322
+ if team.nil?
323
+ raise TeamNotFound, "Requested team #{team_slug} does not exist in #{org}!"
324
+ end
325
+
326
+ team_id = team.fetch("databaseId")
327
+ parent_team_name = team.dig("parentTeam", "slug")
328
+
329
+ edges = team.fetch("members").fetch("edges")
330
+ break unless edges.any?
331
+
332
+ buffer = edges.map { |e| e.fetch("node").fetch("login").downcase }
333
+ result.concat buffer
334
+
335
+ cursor = edges.last.fetch("cursor")
336
+ next if cursor && buffer.size == max_graphql_results
337
+ break
338
+ end
339
+
340
+ { members: result, team_id: team_id, parent_team_name: parent_team_name }
341
+ end
342
+
343
+ # Ensure that the given team ID actually matches up to the team slug on GitHub. This is in place
344
+ # because we are relying on something in graphql that we shouldn't be, until the attribute we need
345
+ # is added as a first class citizen. Once that happens, this can be removed.
346
+ #
347
+ # team_id - ID number of the team (Integer)
348
+ # team_slug - Slug of the team (String)
349
+ #
350
+ # Returns nothing but raises if there's a mismatch.
351
+ Contract Integer, String => nil
352
+ def validate_team_id_and_slug!(team_id, team_slug)
353
+ return if team_id == -999
354
+
355
+ @validation_cache ||= {}
356
+ @validation_cache[team_id] ||= begin
357
+ Entitlements.logger.debug "validate_team_id_and_slug!(#{team_id}, #{team_slug.inspect})"
358
+ team_data = octokit.team(team_id)
359
+ team_data[:slug]
360
+ end
361
+ return if @validation_cache[team_id] == team_slug
362
+ raise "validate_team_id_and_slug! mismatch: team_id=#{team_id} expected=#{team_slug.inspect} got=#{@validation_cache[team_id].inspect}"
363
+ end
364
+
365
+ # Add user to team.
366
+ #
367
+ # user - String with the GitHub username
368
+ # team - Entitlements::Backend::GitHubTeam::Models::Team object for the team.
369
+ #
370
+ # Returns true if the user was added to the team, false if user was already on team.
371
+ Contract C::KeywordArgs[
372
+ user: String,
373
+ team: Entitlements::Backend::GitHubTeam::Models::Team,
374
+ ] => C::Bool
375
+ def add_user_to_team(user:, team:)
376
+ return false unless org_members.include?(user.downcase)
377
+ Entitlements.logger.debug "#{identifier} add_user_to_team(user=#{user}, org=#{org}, team_id=#{team.team_id})"
378
+ validate_team_id_and_slug!(team.team_id, team.team_name)
379
+ result = octokit.add_team_membership(team.team_id, user)
380
+ result[:state] == "active" || result[:state] == "pending"
381
+ end
382
+
383
+ # Remove user from team.
384
+ #
385
+ # user - String with the GitHub username
386
+ # team - Entitlements::Backend::GitHubTeam::Models::Team object for the team.
387
+ #
388
+ # Returns true if the user was removed from the team, false if user was not on team.
389
+ Contract C::KeywordArgs[
390
+ user: String,
391
+ team: Entitlements::Backend::GitHubTeam::Models::Team,
392
+ ] => C::Bool
393
+ def remove_user_from_team(user:, team:)
394
+ return false unless org_members.include?(user.downcase)
395
+ Entitlements.logger.debug "#{identifier} remove_user_from_team(user=#{user}, org=#{org}, team_id=#{team.team_id})"
396
+ validate_team_id_and_slug!(team.team_id, team.team_name)
397
+ octokit.remove_team_membership(team.team_id, user)
398
+ end
399
+ end
400
+ end
401
+ end
402
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "github_team/controller"
4
+ require_relative "github_team/models/team"
5
+ require_relative "github_team/provider"
6
+ require_relative "github_team/service"