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,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"