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