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