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.
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../service/github"
4
+
5
+ module Entitlements
6
+ class Backend
7
+ class GitHubOrg
8
+ class Service < Entitlements::Service::GitHub
9
+ include ::Contracts::Core
10
+ C = ::Contracts
11
+
12
+ # Sync the members of an organization in a given role to match the member list.
13
+ #
14
+ # implementation - An Hash of { action: :add/:remove, person: <person DN> }
15
+ # role - A String with the role, matching a key of Entitlements::Backend::GitHubOrg::ORGANIZATION_ROLES.
16
+ #
17
+ # Returns true if it succeeded, false if it did not.
18
+ Contract C::ArrayOf[{ action: C::Or[:add, :remove], person: String }], String => C::Bool
19
+ def sync(implementation, role)
20
+ added_members = []
21
+ removed_members = []
22
+
23
+ implementation.each do |instruction|
24
+ username = Entitlements::Util::Util.first_attr(instruction[:person]).downcase
25
+ if instruction[:action] == :add
26
+ added_members << username if add_user_to_organization(username, role)
27
+ else
28
+ removed_members << username if remove_user_from_organization(username)
29
+ end
30
+ end
31
+
32
+ Entitlements.logger.debug "sync(#{role}): Added #{added_members.count}, removed #{removed_members.count}"
33
+ added_members.any? || removed_members.any?
34
+ end
35
+
36
+ private
37
+
38
+ # Upsert a user with a role to the organization.
39
+ #
40
+ # user: A String with the (GitHub) username of the person to add or modify.
41
+ # role: A String with the role, matching a key of Entitlements::Backend::GitHubOrg::ORGANIZATION_ROLES.
42
+ #
43
+ # Returns true if the user was added to the organization, false otherwise.
44
+ Contract String, String => C::Bool
45
+ def add_user_to_organization(user, role)
46
+ Entitlements.logger.debug "#{identifier} add_user_to_organization(user=#{user}, org=#{org}, role=#{role})"
47
+ new_membership = octokit.update_organization_membership(org, user: user, role: role)
48
+
49
+ # Happy path
50
+ if new_membership[:role] == role
51
+ if new_membership[:state] == "pending"
52
+ pending_members.add(user)
53
+ return true
54
+ elsif new_membership[:state] == "active"
55
+ org_members[user] = role
56
+ return true
57
+ end
58
+ end
59
+
60
+ Entitlements.logger.debug new_membership.inspect
61
+ Entitlements.logger.error "Failed to adjust membership for #{user} in organization #{org} with role #{role}!"
62
+ false
63
+ end
64
+
65
+ # Remove a user from the organization.
66
+ #
67
+ # user: A String with the (GitHub) username of the person to remove.
68
+ #
69
+ # Returns true if the user was removed, false otherwise.
70
+ Contract String => C::Bool
71
+ def remove_user_from_organization(user)
72
+ Entitlements.logger.debug "#{identifier} remove_user_from_organization(user=#{user}, org=#{org})"
73
+ result = octokit.remove_organization_membership(org, user: user)
74
+
75
+ # If we removed the user, remove them from the cache of members, so that any GitHub team
76
+ # operations in this organization will ignore this user.
77
+ if result
78
+ org_members.delete(user)
79
+ pending_members.delete(user)
80
+ end
81
+
82
+ # Return the result, true or false
83
+ result
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Entitlements
4
+ class Backend
5
+ class GitHubOrg
6
+ include ::Contracts::Core
7
+ C = ::Contracts
8
+
9
+ # There are certain supported roles (which are mutually exclusive): admin, billing manager, member.
10
+ # Define these in this one central place to be consumed everywhere.
11
+ # The key is the name of the Entitlement, and that data is how this role appears on dotcom.
12
+ ORGANIZATION_ROLES = {
13
+ "admin" => "ADMIN",
14
+ # `billing-manager` is currently not supported
15
+ "member" => "MEMBER"
16
+ }
17
+
18
+ # Error classes
19
+ class DuplicateUserError < RuntimeError; end
20
+ end
21
+ end
22
+ end
23
+
24
+ require_relative "github_org/controller"
25
+ require_relative "github_org/provider"
26
+ require_relative "github_org/service"
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Entitlements
4
+ class Backend
5
+ class GitHubTeam
6
+ class Controller < Entitlements::Backend::BaseController
7
+ # Controller priority and registration
8
+ def self.priority
9
+ 40
10
+ end
11
+
12
+ register
13
+
14
+ include ::Contracts::Core
15
+ C = ::Contracts
16
+
17
+ # Constructor. Generic constructor that takes a hash of configuration options.
18
+ #
19
+ # group_name - Name of the corresponding group in the entitlements configuration file.
20
+ # config - Optionally, a Hash of configuration information (configuration is referenced if empty).
21
+ Contract String, C::Maybe[C::HashOf[String => C::Any]] => C::Any
22
+ def initialize(group_name, config = nil)
23
+ super
24
+ @provider = Entitlements::Backend::GitHubTeam::Provider.new(config: @config)
25
+ end
26
+
27
+ def prefetch
28
+ teams = Entitlements::Data::Groups::Calculated.read_all(group_name, config)
29
+ teams.each do |team_slug|
30
+ entitlement_group = Entitlements::Data::Groups::Calculated.read(team_slug)
31
+ provider.read(entitlement_group)
32
+ end
33
+ end
34
+
35
+ # Calculation routines.
36
+ #
37
+ # Takes no arguments.
38
+ #
39
+ # Returns a list of @actions.
40
+ Contract C::None => C::Any
41
+ def calculate
42
+ added = []
43
+ changed = []
44
+ teams = Entitlements::Data::Groups::Calculated.read_all(group_name, config)
45
+ teams.each do |team_slug|
46
+ group = Entitlements::Data::Groups::Calculated.read(team_slug)
47
+
48
+ # Anyone who is not a member of the organization is ignored in the diff calculation.
49
+ # This avoids adding an organization membership for someone by virtue of adding them
50
+ # to a team, without declaring them as an administrator or a member of the org. Also
51
+ # this avoids having a pending member show up in diffs until they accept their invite.
52
+ ignored_users = provider.auto_generate_ignored_users(group)
53
+
54
+ # "diff" includes a call to GitHub API to read the team as it currently exists there.
55
+ # Returns a hash { added: Set(members), removed: Set(members) }
56
+ diff = provider.diff(group, ignored_users)
57
+
58
+ if diff[:added].empty? && diff[:removed].empty? && diff[:metadata].nil?
59
+ logger.debug "UNCHANGED: No GitHub team changes for #{group_name}:#{team_slug}"
60
+ next
61
+ end
62
+
63
+ if diff[:metadata] && diff[:metadata][:create_team]
64
+ added << Entitlements::Models::Action.new(team_slug, provider.read(group), group, group_name, ignored_users: ignored_users)
65
+ else
66
+ changed << Entitlements::Models::Action.new(team_slug, provider.read(group), group, group_name, ignored_users: ignored_users)
67
+ end
68
+ end
69
+ print_differences(key: group_name, added: added, removed: [], changed: changed)
70
+
71
+ @actions = added + changed
72
+ end
73
+
74
+ # Apply changes.
75
+ #
76
+ # action - Action array.
77
+ #
78
+ # Returns nothing.
79
+ Contract Entitlements::Models::Action => C::Any
80
+ def apply(action)
81
+ unless action.updated.is_a?(Entitlements::Models::Group)
82
+ logger.fatal "#{action.dn}: GitHub entitlements interface does not support removing a team at this point"
83
+ raise RuntimeError, "Invalid Operation"
84
+ end
85
+
86
+ if provider.change_ignored?(action)
87
+ logger.debug "SKIP: GitHub team #{action.dn} only changes organization non-members or pending members"
88
+ return
89
+ end
90
+
91
+ if provider.commit(action.updated)
92
+ logger.debug "APPLY: Updating GitHub team #{action.dn}"
93
+ else
94
+ logger.warn "DID NOT APPLY: Changes not needed to #{action.dn}"
95
+ logger.debug "Old: #{action.existing.inspect}"
96
+ logger.debug "New: #{action.updated.inspect}"
97
+ end
98
+ end
99
+
100
+ # Validate configuration options.
101
+ #
102
+ # key - String with the name of the group.
103
+ # data - Hash with the configuration data.
104
+ #
105
+ # Returns nothing.
106
+ # :nocov:
107
+ Contract String, C::HashOf[String => C::Any] => nil
108
+ def validate_config!(key, data)
109
+ spec = COMMON_GROUP_CONFIG.merge({
110
+ "base" => { required: true, type: String },
111
+ "addr" => { required: false, type: String },
112
+ "org" => { required: true, type: String },
113
+ "token" => { required: true, type: String }
114
+ })
115
+ text = "GitHub group #{key.inspect}"
116
+ Entitlements::Util::Util.validate_attr!(spec, data, text)
117
+ end
118
+ # :nocov:
119
+
120
+ private
121
+
122
+ attr_reader :provider
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Entitlements
4
+ class Backend
5
+ class GitHubTeam
6
+ class Models
7
+ class Team < Entitlements::Models::Group
8
+ include ::Contracts::Core
9
+ C = ::Contracts
10
+
11
+ attr_reader :team_id, :team_name, :team_dn
12
+
13
+ # Constructor.
14
+ #
15
+ # team_id - Integer with the team ID
16
+ # team_name - String with the team name
17
+ # members - Set of String with member UID
18
+ # ou - A String with the base OU
19
+ Contract C::KeywordArgs[
20
+ team_id: Integer,
21
+ team_name: String,
22
+ members: C::SetOf[String],
23
+ ou: String,
24
+ metadata: C::Or[C::HashOf[String => C::Any], nil]
25
+ ] => C::Any
26
+ def initialize(team_id:, team_name:, members:, ou:, metadata:)
27
+ @team_id = team_id
28
+ @team_name = team_name.downcase
29
+ @team_dn = ["cn=#{team_name.downcase}", ou].join(",")
30
+ super(dn: @team_dn, members: Set.new(members.map { |m| m.downcase }), metadata: metadata)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,208 @@
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 GitHubTeam
11
+ class Provider < Entitlements::Backend::BaseProvider
12
+ include ::Contracts::Core
13
+ C = ::Contracts
14
+
15
+ # Constructor.
16
+ #
17
+ # config - Configuration provided for the controller instantiation
18
+ Contract C::KeywordArgs[
19
+ config: C::HashOf[String => C::Any],
20
+ ] => C::Any
21
+ def initialize(config:)
22
+ @github = Entitlements::Backend::GitHubTeam::Service.new(
23
+ org: config.fetch("org"),
24
+ addr: config.fetch("addr", nil),
25
+ token: config.fetch("token"),
26
+ ou: config.fetch("base")
27
+ )
28
+
29
+ @github_team_cache = {}
30
+ end
31
+
32
+ # Read in a specific GitHub.com Team and enumerate its members. Results are cached
33
+ # for future runs.
34
+ #
35
+ # team_identifier - Entitlements::Models::Group representing the entitlement
36
+ #
37
+ # Returns a Entitlements::Models::Group object representing the GitHub group or nil if the GitHub.com Team does not exist
38
+ Contract Entitlements::Models::Group => C::Maybe[Entitlements::Models::Group]
39
+ def read(entitlement_group)
40
+ slug = Entitlements::Util::Util.any_to_cn(entitlement_group.cn.downcase)
41
+ return @github_team_cache[slug] if @github_team_cache[slug]
42
+
43
+ github_team = github.read_team(entitlement_group)
44
+
45
+ # We should not cache a team which does not exist
46
+ return nil if github_team.nil?
47
+
48
+ Entitlements.logger.debug "Loaded #{github_team.team_dn} (id=#{github_team.team_id}) with #{github_team.member_strings.count} member(s)"
49
+ @github_team_cache[github_team.team_name] = github_team
50
+ end
51
+
52
+ # Dry run of committing changes. Returns a list of users added or removed.
53
+ #
54
+ # group - An Entitlements::Models::Group object.
55
+ #
56
+ # Returns added / removed hash.
57
+ Contract Entitlements::Models::Group, C::Maybe[C::SetOf[String]] => Hash[added: C::SetOf[String], removed: C::SetOf[String]]
58
+ def diff(entitlement_group, ignored_users = Set.new)
59
+ # The current value of the team from `read` might be based on the predictive cache
60
+ # or on an actual API call. At this stage we don't care.
61
+ team_identifier = entitlement_group.cn.downcase
62
+ github_team_group = read(entitlement_group)
63
+ if github_team_group.nil?
64
+ github_team_group = create_github_team_group(entitlement_group)
65
+ end
66
+
67
+ result = diff_existing_updated(github_team_group, entitlement_group, ignored_users)
68
+
69
+ # If there are no differences, return. (If we read from the predictive cache, we just saved ourselves a call
70
+ # to the API. Hurray.)
71
+ return result unless result[:added].any? || result[:removed].any? || result[:metadata]
72
+
73
+ # If the group doesn't exist yet, we know we're not using the cache and we can save on any further API calls
74
+ unless github_team_group.metadata_fetch_if_exists("team_id") == -999
75
+ # There are differences so we don't want to use the predictive cache. Call to `from_predictive_cache?`
76
+ # to determine whether our source of "current state" came from the predictive cache or from the API.
77
+ # If it returns false, it came from the API, and we should just return what we got
78
+ # (since pulling the data from the API again would be pointless).
79
+ return result unless github.from_predictive_cache?(entitlement_group)
80
+
81
+ # If `from_predictive_cache?` returned true, the data came from the predictive cache. We need
82
+ # to invalidate the predictive cache entry, clean up the instance variable and re-read the refreshed data.
83
+ github.invalidate_predictive_cache(entitlement_group)
84
+ @github_team_cache.delete(team_identifier)
85
+ github_team_group = read(entitlement_group)
86
+ end
87
+
88
+ # And finally, we have to calculate a new diff, which this time uses the fresh data from the API as
89
+ # its basis, rather than the predictive cache.
90
+ diff_existing_updated(github_team_group, entitlement_group, ignored_users)
91
+ end
92
+
93
+ # Dry run of committing changes. Returns a list of users added or removed and a hash explaining metadata changes
94
+ # Takes an existing and an updated group object, avoiding a lookup in the backend.
95
+ #
96
+ # existing_group - An Entitlements::Models::Group object.
97
+ # group - An Entitlements::Models::Group object.
98
+ # ignored_users - Optionally, a Set of lower-case Strings of users to ignore.
99
+ Contract Entitlements::Models::Group, Entitlements::Models::Group, C::Maybe[C::SetOf[String]] => Hash[added: C::SetOf[String], removed: C::SetOf[String], metadata: C::Maybe[Hash[]]]
100
+ def diff_existing_updated(existing_group, group, ignored_users = Set.new)
101
+ diff_existing_updated_metadata(existing_group, group, super)
102
+ end
103
+
104
+ # Determine if a change needs to be ignored. This will return true if the
105
+ # user being added or removed is ignored.
106
+ #
107
+ # action - Entitlements::Models::Action object
108
+ #
109
+ # Returns true if the change should be ignored, false otherwise.
110
+ Contract Entitlements::Models::Action => C::Bool
111
+ def change_ignored?(action)
112
+ return false if action.existing.nil?
113
+
114
+ result = diff_existing_updated(action.existing, action.updated, action.ignored_users)
115
+ result[:added].empty? && result[:removed].empty? && result[:metadata].nil?
116
+ end
117
+
118
+ # Commit changes.
119
+ #
120
+ # group - An Entitlements::Models::Group object.
121
+ #
122
+ # Returns true if a change was made, false if no change was made.
123
+ Contract Entitlements::Models::Group => C::Bool
124
+ def commit(entitlement_group)
125
+ github_team = github.read_team(entitlement_group)
126
+
127
+ # Create the new team and invalidate the cache
128
+ if github_team.nil?
129
+ team_name = entitlement_group.cn.downcase
130
+ github.create_team(entitlement_group: entitlement_group)
131
+ github.invalidate_predictive_cache(entitlement_group)
132
+ @github_team_cache.delete(team_name)
133
+ github_team = github.read_team(entitlement_group)
134
+ end
135
+ github.sync_team(entitlement_group, github_team)
136
+ end
137
+
138
+ # Automatically generate ignored users for a group. Find all members listed in the group who are not
139
+ # admins or members of the GitHub organization in question.
140
+ #
141
+ # group - An Entitlements::Models::Group object.
142
+ #
143
+ # Returns a set of strings with usernames meeting the criteria.
144
+ Contract Entitlements::Models::Group => C::SetOf[String]
145
+ def auto_generate_ignored_users(entitlement_group)
146
+ org_members = github.org_members.keys.map(&:downcase)
147
+ group_members = entitlement_group.member_strings.map(&:downcase)
148
+ Set.new(group_members - org_members)
149
+ end
150
+
151
+ private
152
+
153
+ # Construct an Entitlements::Models::Group for a new group and team
154
+ #
155
+ # group - An Entitlements::Models::Group object representing the defined group
156
+ #
157
+ # Returns an Entitlements::Models::Group for a new group
158
+ Contract Entitlements::Models::Group => Entitlements::Models::Group
159
+ def create_github_team_group(entitlement_group)
160
+ begin
161
+ metadata = entitlement_group.metadata
162
+ metadata["team_id"] = -999
163
+ rescue Entitlements::Models::Group::NoMetadata
164
+ metadata = {"team_id" => -999}
165
+ end
166
+ Entitlements::Backend::GitHubTeam::Models::Team.new(
167
+ team_id: -999,
168
+ team_name: entitlement_group.cn.downcase,
169
+ members: Set.new,
170
+ ou: github.ou,
171
+ metadata: metadata
172
+ )
173
+ end
174
+
175
+ # Returns a diff hash of group metadata
176
+ # Takes an existing and an updated group object, avoiding a lookup in the backend.
177
+ #
178
+ # existing_group - An Entitlements::Models::Group object.
179
+ # group - An Entitlements::Models::Group object.
180
+ # base_diff - Hash representing the base diff from diff_existing_updated
181
+ Contract Entitlements::Models::Group, Entitlements::Models::Group, Hash[added: C::SetOf[String], removed: C::SetOf[String], metadata: C::Or[Hash[], nil]] => Hash[added: C::SetOf[String], removed: C::SetOf[String], metadata: C::Or[Hash[], nil]]
182
+ def diff_existing_updated_metadata(existing_group, group, base_diff)
183
+ if existing_group.metadata_fetch_if_exists("team_id") == -999
184
+ base_diff[:metadata] = { create_team: true }
185
+ end
186
+ existing_parent_team = existing_group.metadata_fetch_if_exists("parent_team_name")
187
+ changed_parent_team = group.metadata_fetch_if_exists("parent_team_name")
188
+
189
+ if existing_parent_team != changed_parent_team
190
+ if existing_parent_team.nil? && !changed_parent_team.nil?
191
+ base_diff[:metadata] = { parent_team: "add" }
192
+ Entitlements.logger.info "ADD github_parent_team #{changed_parent_team} to #{existing_group.dn} in #{github.org}"
193
+ elsif !existing_parent_team.nil? && changed_parent_team.nil?
194
+ base_diff[:metadata] = { parent_team: "remove" }
195
+ Entitlements.logger.info "REMOVE (NOOP) github_parent_team #{existing_parent_team} from #{existing_group.dn} in #{github.org}"
196
+ else
197
+ base_diff[:metadata] = { parent_team: "change" }
198
+ Entitlements.logger.info "CHANGE github_parent_team from #{existing_parent_team} to #{changed_parent_team} for #{existing_group.dn} in #{github.org}"
199
+ end
200
+ end
201
+ base_diff
202
+ end
203
+
204
+ attr_reader :github
205
+ end
206
+ end
207
+ end
208
+ end