entitlements-github-plugin 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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