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