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