entitlements-github-plugin 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 0e7386fa21d869d5038414c4d9b02c168c91900d08c6673a880a0a3aa540a469
|
4
|
+
data.tar.gz: 6c0a8c5f81c0df3b33d1e735d892ec69512ef8ad4c0dc9b59ba171282cece466
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: eed5def7b0b66d9fb6b6fdc968ef06ff41f5cea3365d5bc69ea4ef58080301a81aebe57dcec648cf29a91309c1684c100c7de081c301195def451d82d0b8c75e
|
7
|
+
data.tar.gz: 0f78afacdc94bee9b2a81d5347c82f64a6752b5437efc9373280c83db85308b3d2e446b19f74a43f9c3ba0baf64744a4ea3a2abeb2ffe7c5ab6b1548b1567590
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.0.1
|
@@ -0,0 +1,468 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# TL;DR: There are multiple shenanigans here, so please read this wall of text.
|
4
|
+
#
|
5
|
+
# This controller is different from many of the others because it has 2 mutually-exclusive entitlements that convey
|
6
|
+
# access to the same thing (a GitHub organization) with a different parameter (role). As such, this controller
|
7
|
+
# calculates the specific reconciliation actions and passes them along to the service. (Normally, the controller
|
8
|
+
# passes groups to the service, which figures out changes.) Taking the approach here allows less passing back and
|
9
|
+
# forth of data structures, such as "the set of all users we've seen".
|
10
|
+
#
|
11
|
+
# This controller also supports per-OU feature flags, settable in the configuration. It is possible to disable
|
12
|
+
# inviting new members to the organization, and removing old members from the organization, if there's already
|
13
|
+
# a process in place to manage that.
|
14
|
+
#
|
15
|
+
# Available features (defined in an array named `features` which can be empty):
|
16
|
+
# * invite: Invite a non-member to the organization
|
17
|
+
# * remove: Remove a non-member (or no-longer-known-to-entitlements user) from the organization
|
18
|
+
#
|
19
|
+
# If `features` is undefined, all available features will be applied. If you want to include neither of these features,
|
20
|
+
# set `features` to the empty array (`[]`) in the configuration. Note that moving an existing member of an organization
|
21
|
+
# from one role to another is always enabled, regardless of feature flag settings.
|
22
|
+
#
|
23
|
+
# But wait, there's even more. When a user gets added to a GitHub organization for the first time, they are not actually
|
24
|
+
# added to the member list right away, but instead they're invited and need to accept an invitation by e-mail before they
|
25
|
+
# show up in the list. We don't want to add (invite) a member via Entitlements and then have them showing up as a "needs
|
26
|
+
# to be added" diff on every single deploy until they accept the invitation. So, we will fudge an invited user to be
|
27
|
+
# "exactly the permissions Entitlements thinks they have" when they show up on the pending list. Unfortunately the pending
|
28
|
+
# list doesn't show whether they're invited as an admin or a member, so there's a potential gap between when they accept the
|
29
|
+
# invitation and the next Entitlements deploy where they could have the wrong privilege if they were invited as one thing
|
30
|
+
# but their role changed in Entitlements before they accepted the invite. This could be addressed by exposing their role
|
31
|
+
# on the pending member list in the GraphQL API.
|
32
|
+
#
|
33
|
+
# The mapping we need to implement looks like this:
|
34
|
+
#
|
35
|
+
# +-------------------+----------------+-----------------+----------------+----------------+
|
36
|
+
# | | Has admin role | Has member role | Pending invite | Does not exist |
|
37
|
+
# +-------------------+----------------+-----------------+----------------+----------------+
|
38
|
+
# | In "admin" group | No change | Move | Leave as-is | Invite |
|
39
|
+
# +-------------------+----------------+-----------------+----------------+----------------+
|
40
|
+
# | In "member" group | Move | No change | Leave as-is | Invite |
|
41
|
+
# +-------------------+----------------+-----------------+----------------+----------------+
|
42
|
+
# | No entitlement | Remove | Remove | Cancel invite | n/a |
|
43
|
+
# +-------------------+----------------+-----------------+----------------+----------------+
|
44
|
+
|
45
|
+
module Entitlements
|
46
|
+
class Backend
|
47
|
+
class GitHubOrg
|
48
|
+
class Controller < Entitlements::Backend::BaseController
|
49
|
+
# Controller priority and registration
|
50
|
+
def self.priority
|
51
|
+
30
|
52
|
+
end
|
53
|
+
|
54
|
+
register
|
55
|
+
|
56
|
+
include ::Contracts::Core
|
57
|
+
C = ::Contracts
|
58
|
+
|
59
|
+
AVAILABLE_FEATURES = %w[invite remove]
|
60
|
+
DEFAULT_FEATURES = %w[invite remove]
|
61
|
+
ROLES = Entitlements::Backend::GitHubOrg::ORGANIZATION_ROLES.keys.freeze
|
62
|
+
|
63
|
+
# Constructor. Generic constructor that takes a hash of configuration options.
|
64
|
+
#
|
65
|
+
# group_name - Name of the corresponding group in the entitlements configuration file.
|
66
|
+
# config - Optionally, a Hash of configuration information (configuration is referenced if empty).
|
67
|
+
Contract String, C::Maybe[C::HashOf[String => C::Any]] => C::Any
|
68
|
+
def initialize(group_name, config = nil)
|
69
|
+
super
|
70
|
+
@provider = Entitlements::Backend::GitHubOrg::Provider.new(config: @config)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Calculation routines.
|
74
|
+
#
|
75
|
+
# Takes no arguments.
|
76
|
+
#
|
77
|
+
# Returns a list of @actions.
|
78
|
+
Contract C::None => C::Any
|
79
|
+
def calculate
|
80
|
+
@actions = []
|
81
|
+
|
82
|
+
validate_github_org_ous! # calls read_all() for the OU
|
83
|
+
validate_no_dupes! # calls read() for each group
|
84
|
+
|
85
|
+
if changes.any?
|
86
|
+
print_differences(key: group_name, added: [], removed: [], changed: changes, ignored_users: ignored_users)
|
87
|
+
@actions.concat(changes)
|
88
|
+
else
|
89
|
+
logger.debug "UNCHANGED: No GitHub organization changes for #{group_name}"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Apply changes.
|
94
|
+
#
|
95
|
+
# action - Action array.
|
96
|
+
#
|
97
|
+
# Returns nothing.
|
98
|
+
Contract Entitlements::Models::Action => C::Any
|
99
|
+
def apply(action)
|
100
|
+
unless action.existing.is_a?(Entitlements::Models::Group) && action.updated.is_a?(Entitlements::Models::Group)
|
101
|
+
logger.fatal "#{action.dn}: GitHub entitlements interface does not support creating or removing a GitHub org"
|
102
|
+
raise RuntimeError, "Invalid Operation"
|
103
|
+
end
|
104
|
+
|
105
|
+
if provider.commit(action)
|
106
|
+
logger.debug "APPLY: Updating GitHub organization #{action.dn}"
|
107
|
+
else
|
108
|
+
logger.warn "DID NOT APPLY: Changes not needed to #{action.dn}"
|
109
|
+
logger.debug "Old: #{action.existing.inspect}"
|
110
|
+
logger.debug "New: #{action.updated.inspect}"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Validate configuration options.
|
115
|
+
#
|
116
|
+
# key - String with the name of the group.
|
117
|
+
# data - Hash with the configuration data.
|
118
|
+
#
|
119
|
+
# Returns nothing.
|
120
|
+
Contract String, C::HashOf[String => C::Any] => nil
|
121
|
+
def validate_config!(key, data)
|
122
|
+
spec = COMMON_GROUP_CONFIG.merge({
|
123
|
+
"base" => { required: true, type: String },
|
124
|
+
"addr" => { required: false, type: String },
|
125
|
+
"org" => { required: true, type: String },
|
126
|
+
"token" => { required: true, type: String },
|
127
|
+
"features" => { required: false, type: Array },
|
128
|
+
"ignore" => { required: false, type: Array }
|
129
|
+
})
|
130
|
+
text = "GitHub organization group #{key.inspect}"
|
131
|
+
Entitlements::Util::Util.validate_attr!(spec, data, text)
|
132
|
+
|
133
|
+
# Validate any features against the list of known features.
|
134
|
+
if data["features"].is_a?(Array)
|
135
|
+
invalid_flags = data["features"] - AVAILABLE_FEATURES
|
136
|
+
if invalid_flags.any?
|
137
|
+
raise "Invalid feature(s) in #{text}: #{invalid_flags.join(', ')}"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def prefetch
|
143
|
+
existing_groups
|
144
|
+
end
|
145
|
+
|
146
|
+
private
|
147
|
+
|
148
|
+
# Utility method to remove repetitive code. From a given hash (`added`, `moved`, `removed`), select
|
149
|
+
# changes for the specified role, sort by username, and return an array of properly capitalized usernames.
|
150
|
+
#
|
151
|
+
# obj - The hash (`added`, `moved`, `removed`)
|
152
|
+
# role - The role to be selected
|
153
|
+
#
|
154
|
+
# Returns an Array of Strings.
|
155
|
+
Contract C::HashOf[String => { member: String, role: String }], String => C::ArrayOf[String]
|
156
|
+
def sorted_users_from_hash(obj, role)
|
157
|
+
obj.select { |_, role_data| role_data[:role] == role }
|
158
|
+
.sort_by { |username, _| username } # Already downcased by the nature of the array
|
159
|
+
.map { |_, role_data| role_data[:member] } # Member name with proper case
|
160
|
+
end
|
161
|
+
|
162
|
+
# Validate that each entitlement defines the correct roles (and only the correct roles).
|
163
|
+
# Raise if this is not the case.
|
164
|
+
#
|
165
|
+
# Takes no arguments.
|
166
|
+
#
|
167
|
+
# Returns nothing (but, will raise an error if something is broken).
|
168
|
+
Contract C::None => C::Any
|
169
|
+
def validate_github_org_ous!
|
170
|
+
updated = Entitlements::Data::Groups::Calculated.read_all(group_name, config)
|
171
|
+
|
172
|
+
# If we are missing an expected role this is a fatal error.
|
173
|
+
ROLES.each do |role|
|
174
|
+
role_dn = ["cn=#{role}", config.fetch("base")].join(",")
|
175
|
+
unless updated.member?(role_dn)
|
176
|
+
logger.fatal "GitHubOrg: No group definition for #{group_name}:#{role} - abort!"
|
177
|
+
raise "GitHubOrg must define admin and member roles."
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# If we have an unexpected role that's also an error.
|
182
|
+
seen_roles = updated.map { |x| Entitlements::Util::Util.first_attr(x) }
|
183
|
+
unexpected_roles = seen_roles - ROLES
|
184
|
+
return unless unexpected_roles.any?
|
185
|
+
|
186
|
+
logger.fatal "GitHubOrg: Unexpected role(s) in #{group_name}: #{unexpected_roles.join(', ')}"
|
187
|
+
raise "GitHubOrg unexpected roles."
|
188
|
+
end
|
189
|
+
|
190
|
+
# Validate that within a given GitHub organization, a given person is not assigned to multiple
|
191
|
+
# roles. Raise if a duplicate user is found.
|
192
|
+
#
|
193
|
+
# Takes no arguments.
|
194
|
+
#
|
195
|
+
# Returns nothing (but, will raise an error if something is broken).
|
196
|
+
Contract C::None => C::Any
|
197
|
+
def validate_no_dupes!
|
198
|
+
users_seen = Set.new
|
199
|
+
|
200
|
+
ROLES.each do |role|
|
201
|
+
role_dn = ["cn=#{role}", config.fetch("base")].join(",")
|
202
|
+
group = Entitlements::Data::Groups::Calculated.read(role_dn)
|
203
|
+
|
204
|
+
users_set = Set.new(group.member_strings_insensitive)
|
205
|
+
dupes = users_seen & users_set
|
206
|
+
if dupes.empty?
|
207
|
+
users_seen.merge(users_set)
|
208
|
+
else
|
209
|
+
message = "Users in multiple roles for #{group_name}: #{dupes.to_a.sort.join(', ')}"
|
210
|
+
logger.fatal message
|
211
|
+
raise Entitlements::Backend::GitHubOrg::DuplicateUserError, "Abort due to users in multiple roles"
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
def existing_groups
|
217
|
+
@existing_groups ||= begin
|
218
|
+
ROLES.map do |role|
|
219
|
+
role_dn = ["cn=#{role}", config.fetch("base")].join(",")
|
220
|
+
[role, provider.read(role_dn)]
|
221
|
+
end.to_h
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
# For a given OU, calculate the changes.
|
226
|
+
#
|
227
|
+
# Takes no arguments.
|
228
|
+
#
|
229
|
+
# Returns an array of change actions.
|
230
|
+
Contract C::None => C::ArrayOf[Entitlements::Models::Action]
|
231
|
+
def changes
|
232
|
+
return @changes if @changes
|
233
|
+
begin
|
234
|
+
features = Set.new(config["features"] || DEFAULT_FEATURES)
|
235
|
+
|
236
|
+
# Populate group membership into groups hash, so that these groups can be mutated later if users
|
237
|
+
# are being ignored or organization membership is pending.
|
238
|
+
groups = ROLES.map do |role|
|
239
|
+
role_dn = ["cn=#{role}", config.fetch("base")].join(",")
|
240
|
+
[role, Entitlements::Data::Groups::Calculated.read(role_dn)]
|
241
|
+
end.to_h
|
242
|
+
|
243
|
+
# Categorize changes by :added (invite user to organization), :moved (change a user's role), and
|
244
|
+
# :removed (remove a user from the organization). This operates across all roles.
|
245
|
+
chg = categorized_changes
|
246
|
+
|
247
|
+
# Keep track of any actions needed to make changes.
|
248
|
+
result = []
|
249
|
+
|
250
|
+
# Get the pending members for the organization.
|
251
|
+
pending = provider.pending_members
|
252
|
+
|
253
|
+
# Handle pending members who are not in any entitlements groups (i.e. they were previously invited, but are
|
254
|
+
# not in entitlements, so we need to cancel their invitation). We don't know from the query whether these users
|
255
|
+
# are in the 'admin' or 'member' role, so just assign them to the member role. It really doesn't matter except
|
256
|
+
# for display purposes because the net result is the same -- entitlements will see them as existing in the provider
|
257
|
+
# but not supposed to exist so it will remove them.
|
258
|
+
disinvited_users(groups, pending).each do |person_dn|
|
259
|
+
existing_groups[ROLES.last].add_member(person_dn)
|
260
|
+
chg[:removed][person_dn.downcase] = { member: person_dn, role: ROLES.last }
|
261
|
+
end
|
262
|
+
|
263
|
+
# For each role:
|
264
|
+
# - Create actions respecting feature flags
|
265
|
+
# - Hack changes to calculated membership if invite/remove is disabled by feature flag
|
266
|
+
# - Calculate actions needed to make changes
|
267
|
+
ROLES.each do |role|
|
268
|
+
role_dn = ["cn=#{role}", config.fetch("base")].join(",")
|
269
|
+
|
270
|
+
# Respecting feature flags, batch up the additions, move-ins, and removals in separate actions.
|
271
|
+
# Note that "move-outs" are not tracked because moving in to one role automatically removes from
|
272
|
+
# the existing role without an explicit API call for the removal.
|
273
|
+
action = Entitlements::Models::Action.new(role_dn, existing_groups[role], groups[role], group_name)
|
274
|
+
invited = sorted_users_from_hash(chg[:added], role)
|
275
|
+
moved_in = sorted_users_from_hash(chg[:moved], role)
|
276
|
+
removals = sorted_users_from_hash(chg[:removed], role)
|
277
|
+
|
278
|
+
# If there are any `invited` members that are also `pending`, remove these from invited, and fake
|
279
|
+
# them into the groups they are slated to join. This will make Entitlements treat this as a no-op
|
280
|
+
# to avoid re-inviting these members.
|
281
|
+
already_invited = remove_pending(invited, pending)
|
282
|
+
already_invited.each do |person_dn|
|
283
|
+
# Fake the member into their existing group so this does not show up as a change every time
|
284
|
+
# that Entitlements runs.
|
285
|
+
existing_groups[role].add_member(person_dn)
|
286
|
+
end
|
287
|
+
|
288
|
+
# `invited` are users who did not have any role in the organization before. Adding them to the
|
289
|
+
# organization will generate an invitation that they must accept.
|
290
|
+
if features.member?("invite")
|
291
|
+
invited.each do |person_dn|
|
292
|
+
action.add_implementation({ action: :add, person: person_dn })
|
293
|
+
end
|
294
|
+
elsif invited.any?
|
295
|
+
suppressed = invited.map { |k| Entitlements::Util::Util.first_attr(k) }.sort
|
296
|
+
targets = [invited.size, invited.size == 1 ? "person:" : "people:", suppressed.join(", ")].join(" ")
|
297
|
+
logger.debug "GitHubOrg #{group_name}:#{role}: Feature `invite` disabled. Not inviting #{targets}."
|
298
|
+
|
299
|
+
invited.each do |person_dn|
|
300
|
+
# Remove the user from their new group so this does not show up as a change every time
|
301
|
+
# that Entitlements runs.
|
302
|
+
groups[role].remove_member(person_dn)
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
# `moved_in` are users who exist in the organization but currently have a different role. Adding them
|
307
|
+
# to the current role will also remove them from their old role (since a person can have exactly one role).
|
308
|
+
# There is no feature flag to disable this action.
|
309
|
+
moved_in.each do |person_dn|
|
310
|
+
action.add_implementation({ action: :add, person: person_dn })
|
311
|
+
end
|
312
|
+
|
313
|
+
# `removals` are users who were in the organization but no longer are assigned to any role.
|
314
|
+
# The resulting API call will remove the user from the organization.
|
315
|
+
if features.member?("remove")
|
316
|
+
removals.each do |person_dn|
|
317
|
+
action.add_implementation({ action: :remove, person: person_dn })
|
318
|
+
end
|
319
|
+
elsif removals.any?
|
320
|
+
suppressed = removals.map { |k| Entitlements::Util::Util.first_attr(k) }.sort
|
321
|
+
targets = [removals.size, removals.size == 1 ? "person:" : "people:", suppressed.join(", ")].join(" ")
|
322
|
+
logger.debug "GitHubOrg #{group_name}:#{role}: Feature `remove` disabled. Not removing #{targets}."
|
323
|
+
|
324
|
+
removals.each do |person_dn|
|
325
|
+
# Add the user back to their group so this does not show up as a change every time
|
326
|
+
# that Entitlements runs.
|
327
|
+
groups[role].add_member(person_dn)
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
# re-diff with the modified groups to give accurate responses on whether there are changes.
|
332
|
+
# Also, each move has an addition and a removal, but there's just one API call (the addition),
|
333
|
+
# but for consistency we want the "diff" to show both the addition and the removal.
|
334
|
+
diff = provider.diff(groups[role], ignored_users)
|
335
|
+
|
336
|
+
if diff[:added].empty? && diff[:removed].empty?
|
337
|
+
logger.debug "UNCHANGED: No GitHub organization changes for #{group_name}:#{role}"
|
338
|
+
next
|
339
|
+
end
|
340
|
+
|
341
|
+
# Case-sensitize the existing members, which will be reporting all names in lower case because that's
|
342
|
+
# how they come from the GitHub provider. If we have seen the member with correct capitalization,
|
343
|
+
# replace the member entry with the correctly cased one. (There's no need to do this for the newly
|
344
|
+
# invited members beause those won't show up in the group of existing members.)
|
345
|
+
all_changes = chg[:moved].merge(chg[:removed])
|
346
|
+
all_changes.each do |_, data|
|
347
|
+
action.existing.update_case(data[:member])
|
348
|
+
end
|
349
|
+
|
350
|
+
result << action
|
351
|
+
end
|
352
|
+
|
353
|
+
# If there are changes, determine if the computed `org_members` are based on a predictive cache
|
354
|
+
# or actual data from the API. If they are based on a predictive cache, then we need to invalidate
|
355
|
+
# the predictive cache and repeat *all* of this logic with fresh data from the API. (We will just
|
356
|
+
# call ourselves once the cache is invalidated to repeat.)
|
357
|
+
if result.any? && provider.github.org_members_from_predictive_cache?
|
358
|
+
provider.invalidate_predictive_cache
|
359
|
+
result = changes
|
360
|
+
else
|
361
|
+
result
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
@changes ||= result
|
366
|
+
result
|
367
|
+
end
|
368
|
+
|
369
|
+
# For a given OU, translate the entitlement members into `invited`, `removed`, and `moved` hashes.
|
370
|
+
#
|
371
|
+
# Takes no arguments.
|
372
|
+
#
|
373
|
+
# Returns the structured hash of hashes, with keys :added, :removed, and :moved.
|
374
|
+
Contract C::None => Hash[
|
375
|
+
added: C::HashOf[String => Hash],
|
376
|
+
removed: C::HashOf[String => Hash],
|
377
|
+
moved: C::HashOf[String => Hash]
|
378
|
+
]
|
379
|
+
def categorized_changes
|
380
|
+
added = {}
|
381
|
+
removed = {}
|
382
|
+
moved = {}
|
383
|
+
|
384
|
+
ROLES.each do |role|
|
385
|
+
role_dn = ["cn=#{role}", config.fetch("base")].join(",")
|
386
|
+
|
387
|
+
# Read the users calculated by Entitlements for this role.
|
388
|
+
groups = Entitlements::Data::Groups::Calculated.read(role_dn)
|
389
|
+
|
390
|
+
# "diff" makes a call to GitHub API to read the team as it currently exists there.
|
391
|
+
# Returns a hash { added: Set(members), removed: Set(members) }
|
392
|
+
diff = provider.diff(groups, ignored_users)
|
393
|
+
|
394
|
+
# For comparison purposes we need to downcase the member DNs when populating the
|
395
|
+
# `added`, `moved` and `removed` hashes. We need to store the original capitalization
|
396
|
+
# for later reporting.
|
397
|
+
diff[:added].each do |member|
|
398
|
+
if removed.key?(member.downcase)
|
399
|
+
# Already removed from a previous role. Therefore this is a move to a different role.
|
400
|
+
removed.delete(member.downcase)
|
401
|
+
moved[member.downcase] = { member: member, role: role }
|
402
|
+
else
|
403
|
+
# Not removed from a previous role. Suspect this is an addition to the org (if we later spot a removal
|
404
|
+
# from a role, then the code below will update that to be a move instead).
|
405
|
+
added[member.downcase] = { member: member, role: role }
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
diff[:removed].each do |member|
|
410
|
+
if added.key?(member.downcase)
|
411
|
+
# Already added to a previous role. Therefore this is a move to a different role.
|
412
|
+
moved[member.downcase] = added[member.downcase]
|
413
|
+
added.delete(member.downcase)
|
414
|
+
else
|
415
|
+
# Not added to a previous role. Suspect this is a removal from the org (if we later spot an addition
|
416
|
+
# to another role, then the code above will update that to be a move instead).
|
417
|
+
removed[member.downcase] = { member: member, role: role }
|
418
|
+
end
|
419
|
+
end
|
420
|
+
end
|
421
|
+
|
422
|
+
{ added: added, removed: removed, moved: moved }
|
423
|
+
end
|
424
|
+
|
425
|
+
# Admins or members who are both `invited` and `pending` do not need to be re-invited. We're waiting for them
|
426
|
+
# to accept their invitation but we don't want to re-invite them (and display a diff) over and over again
|
427
|
+
# while we patiently wait for their acceptance. This method mutates `invited` and returns a set of pending
|
428
|
+
# user distinguished names.
|
429
|
+
#
|
430
|
+
# invited - Set of correct-cased distinguished names (mutated)
|
431
|
+
# pending - Set of lowercase GitHub usernames of pending members in an organization
|
432
|
+
#
|
433
|
+
# Returns a Set of correct-cased distinguished names removed from `invited` because they're pending.
|
434
|
+
Contract C::ArrayOf[String], C::SetOf[String] => C::SetOf[String]
|
435
|
+
def remove_pending(invited, pending)
|
436
|
+
result = Set.new(invited.select { |k| pending.member?(Entitlements::Util::Util.first_attr(k).downcase) })
|
437
|
+
invited.reject! { |item| result.member?(item) }
|
438
|
+
result
|
439
|
+
end
|
440
|
+
|
441
|
+
# Given a list of groups and a list of pending members from the provider, determine which pending users are not
|
442
|
+
# in any of the given groups. Return a list of these pending users (as distinguished names).
|
443
|
+
#
|
444
|
+
# groups - Hash of calculated groups: { "role" => Entitlements::Models::Group }
|
445
|
+
# pending - Set of Strings of pending usernames from GitHub
|
446
|
+
#
|
447
|
+
# Returns an Array of Strings with distinguished names.
|
448
|
+
Contract C::HashOf[String => Entitlements::Models::Group], C::SetOf[String] => C::ArrayOf[String]
|
449
|
+
def disinvited_users(groups, pending)
|
450
|
+
all_users = groups.map do |_, grp|
|
451
|
+
grp.member_strings.map { |ms| Entitlements::Util::Util.first_attr(ms).downcase }
|
452
|
+
end.compact.flatten
|
453
|
+
|
454
|
+
pending.to_a - all_users
|
455
|
+
end
|
456
|
+
|
457
|
+
def ignored_users
|
458
|
+
@ignored_users ||= begin
|
459
|
+
ignored_user_list = config["ignore"] || []
|
460
|
+
Set.new(ignored_user_list.map(&:downcase))
|
461
|
+
end
|
462
|
+
end
|
463
|
+
|
464
|
+
attr_reader :provider
|
465
|
+
end
|
466
|
+
end
|
467
|
+
end
|
468
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "service"
|
4
|
+
|
5
|
+
require "set"
|
6
|
+
require "uri"
|
7
|
+
|
8
|
+
module Entitlements
|
9
|
+
class Backend
|
10
|
+
class GitHubOrg
|
11
|
+
class Provider < Entitlements::Backend::BaseProvider
|
12
|
+
include ::Contracts::Core
|
13
|
+
C = ::Contracts
|
14
|
+
|
15
|
+
attr_reader :github
|
16
|
+
|
17
|
+
# Constructor.
|
18
|
+
#
|
19
|
+
# config - Configuration provided for the controller instantiation
|
20
|
+
Contract C::KeywordArgs[
|
21
|
+
config: C::HashOf[String => C::Any],
|
22
|
+
] => C::Any
|
23
|
+
def initialize(config:)
|
24
|
+
@github = Entitlements::Backend::GitHubOrg::Service.new(
|
25
|
+
org: config.fetch("org"),
|
26
|
+
addr: config.fetch("addr", nil),
|
27
|
+
token: config.fetch("token"),
|
28
|
+
ou: config.fetch("base")
|
29
|
+
)
|
30
|
+
@role_cache = {}
|
31
|
+
end
|
32
|
+
|
33
|
+
# Read in a github organization and enumerate its members and their roles. Results are cached
|
34
|
+
# for future runs. The organization is defined per-entitlement as the `.org` method of the
|
35
|
+
# github object.
|
36
|
+
#
|
37
|
+
# role_identifier - String with the role (a key from Entitlements::Backend::GitHubOrg::ORGANIZATION_ROLES) or a group.
|
38
|
+
#
|
39
|
+
# Returns a Entitlements::Models::Group object.
|
40
|
+
Contract C::Or[String, Entitlements::Models::Group] => Entitlements::Models::Group
|
41
|
+
def read(role_identifier)
|
42
|
+
role_cn = role_name(role_identifier)
|
43
|
+
@role_cache[role_cn] ||= role_to_group(role_cn)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Commit changes.
|
47
|
+
#
|
48
|
+
# action - An Entitlements::Models::Action object.
|
49
|
+
#
|
50
|
+
# Returns true if a change was made, false if no change was made.
|
51
|
+
Contract Entitlements::Models::Action => C::Bool
|
52
|
+
def commit(action)
|
53
|
+
# `false` usually means "What's going on, there are changes but nothing to apply!" Here it is
|
54
|
+
# more routine that there are removals that are not processed (because adding to one role removes
|
55
|
+
# from the other), so `true` is more accurate.
|
56
|
+
return true unless action.implementation
|
57
|
+
github.sync(action.implementation, role_name(action.updated))
|
58
|
+
end
|
59
|
+
|
60
|
+
# Invalidate the predictive cache.
|
61
|
+
#
|
62
|
+
# Takes no arguments.
|
63
|
+
#
|
64
|
+
# Returns nothing.
|
65
|
+
Contract C::None => nil
|
66
|
+
def invalidate_predictive_cache
|
67
|
+
@role_cache = {}
|
68
|
+
github.invalidate_org_members_predictive_cache
|
69
|
+
nil
|
70
|
+
end
|
71
|
+
|
72
|
+
# Pending members.
|
73
|
+
#
|
74
|
+
# Takes no arguments.
|
75
|
+
#
|
76
|
+
# Returns Set of usernames.
|
77
|
+
Contract C::None => C::SetOf[String]
|
78
|
+
def pending_members
|
79
|
+
github.pending_members
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
# Determine the role name from a string or a group (with validation).
|
85
|
+
#
|
86
|
+
# role_identifier - String (a key from Entitlements::Backend::GitHubOrg::ORGANIZATION_ROLES) or a group.
|
87
|
+
#
|
88
|
+
# Returns a string with the role name.
|
89
|
+
Contract C::Or[String, Entitlements::Models::Group] => String
|
90
|
+
def role_name(role_identifier)
|
91
|
+
role = Entitlements::Util::Util.any_to_cn(role_identifier)
|
92
|
+
return role if Entitlements::Backend::GitHubOrg::ORGANIZATION_ROLES.key?(role)
|
93
|
+
|
94
|
+
supported = Entitlements::Backend::GitHubOrg::ORGANIZATION_ROLES.keys.join(", ")
|
95
|
+
message = "Invalid role #{role.inspect}. Supported values: #{supported}."
|
96
|
+
raise ArgumentError, message
|
97
|
+
end
|
98
|
+
|
99
|
+
# Construct an Entitlements::Models::Group from a given role.
|
100
|
+
#
|
101
|
+
# role - A String with the role name.
|
102
|
+
#
|
103
|
+
# Returns an Entitlements::Models::Group object.
|
104
|
+
Contract String => Entitlements::Models::Group
|
105
|
+
def role_to_group(role)
|
106
|
+
members = github.org_members.keys.select { |username| github.org_members[username] == role }
|
107
|
+
Entitlements::Models::Group.new(
|
108
|
+
dn: role_dn(role),
|
109
|
+
members: Set.new(members),
|
110
|
+
description: role_description(role)
|
111
|
+
)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Default description for a given role.
|
115
|
+
#
|
116
|
+
# role - A String with the role name.
|
117
|
+
#
|
118
|
+
# Returns a String with the default description for the role.
|
119
|
+
Contract String => String
|
120
|
+
def role_description(role)
|
121
|
+
"Users with role #{role} on organization #{github.org}"
|
122
|
+
end
|
123
|
+
|
124
|
+
# Default distinguished name for a given role.
|
125
|
+
#
|
126
|
+
# role - A String with the role name.
|
127
|
+
#
|
128
|
+
# Returns a String with the distinguished name for the role.
|
129
|
+
Contract String => String
|
130
|
+
def role_dn(role)
|
131
|
+
"cn=#{role},#{github.ou}"
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|