Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![acceptance](https://github.com/github/entitlements-github-plugin/actions/workflows/acceptance.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/acceptance.yml) [![test](https://github.com/github/entitlements-github-plugin/actions/workflows/test.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/test.yml) [![lint](https://github.com/github/entitlements-github-plugin/actions/workflows/lint.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/lint.yml) [![release](https://github.com/github/entitlements-github-plugin/actions/workflows/release.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/release.yml) [![build](https://github.com/github/entitlements-github-plugin/actions/workflows/build.yml/badge.svg)](https://github.com/github/entitlements-github-plugin/actions/workflows/build.yml) [![coverage](https://img.shields.io/badge/coverage-100%25-success)](https://img.shields.io/badge/coverage-100%25-success) [![style](https://img.shields.io/badge/code%20style-rubocop--github-blue)](https://github.com/github/rubocop-github)

`entitlements-github-plugin` is an [entitlements-app](https://github.com/github/entitlements-app) plugin allowing entitlements configs to be used to manage membership of GitHub.com Organizations and Teams.
`entitlements-github-plugin` is an [entitlements-app](https://github.com/github/entitlements-app) plugin allowing entitlements configs to be used to manage membership of GitHub.com organizations, organization teams, and enterprise teams.

## Usage

Expand Down Expand Up @@ -36,6 +36,7 @@ require "bundler/setup"
require "entitlements"

# require entitlements plugins here
require "entitlements/backend/github_enterprise_team"
require "entitlements/backend/github_org"
require "entitlements/backend/github_team"
require "entitlements/service/github"
Expand Down Expand Up @@ -85,6 +86,21 @@ Entitlements configs can contain metadata which the plugin will use to make furt

`metadata_parent_team_name` - when defined in an entitlements config, the defined team will be made the parent team of this GitHub.com Team.

### GitHub Enterprise Teams

`entitlements-github-plugin` manages membership of existing GitHub Enterprise Cloud teams. Enterprise teams must be created separately; this backend only synchronizes their members.

```ruby
github.com/enterprises/acme/teams:
base: ou=teams,ou=acme,ou=GitHub,dc=github,dc=com
dir: github.com/enterprises/acme/teams
enterprise: acme
token: <%= ENV["GITHUB_ENTERPRISE_TOKEN"] %>
type: "github_enterprise_team"
```

The token must be a classic personal access token with `read:enterprise` and `admin:enterprise` scopes. GitHub App and fine-grained personal access tokens are not supported by the enterprise team membership API.

## Release 🚀

To release a new version of this Gem, do the following:
Expand Down
9 changes: 9 additions & 0 deletions lib/entitlements/backend/github_enterprise_team.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

require_relative "github_enterprise_team/controller"
require_relative "github_enterprise_team/provider"
require_relative "github_enterprise_team/service"
require_relative "github_team/models/team"
require_relative "../config/retry"

Retry.setup!
78 changes: 78 additions & 0 deletions lib/entitlements/backend/github_enterprise_team/controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

module Entitlements
class Backend
class GitHubEnterpriseTeam
class Controller < Entitlements::Backend::BaseController
def self.priority
40
end

register

include ::Contracts::Core
C = ::Contracts

Contract String, C::Maybe[C::HashOf[String => C::Any]] => C::Any
def initialize(group_name, config = nil)
super
@provider = Entitlements::Backend::GitHubEnterpriseTeam::Provider.new(config: @config)
end

def prefetch
teams = Entitlements::Data::Groups::Calculated.read_all(group_name, config)
teams.each do |team_slug|
provider.read(Entitlements::Data::Groups::Calculated.read(team_slug))
end
end

Contract C::None => C::Any
def calculate
changed = Entitlements::Data::Groups::Calculated.read_all(group_name, config).filter_map do |team_slug|
group = Entitlements::Data::Groups::Calculated.read(team_slug)
diff = provider.diff(group)

if diff[:added].empty? && diff[:removed].empty?
logger.debug "UNCHANGED: No GitHub enterprise team changes for #{group_name}:#{team_slug}"
next
end

Entitlements::Models::Action.new(team_slug, provider.read(group), group, group_name)
end

print_differences(key: group_name, added: [], removed: [], changed:)
@actions = changed
end

Contract Entitlements::Models::Action => C::Any
def apply(action)
unless action.updated.is_a?(Entitlements::Models::Group)
logger.fatal "#{action.dn}: GitHub enterprise team membership cannot remove a team"
raise RuntimeError, "Invalid Operation"
end

if provider.commit(action.updated)
logger.debug "APPLY: Updating GitHub enterprise team #{action.dn}"
else
logger.warn "DID NOT APPLY: Changes not needed to #{action.dn}"
end
end

Contract String, C::HashOf[String => C::Any] => nil
def validate_config!(key, data)
spec = COMMON_GROUP_CONFIG.merge({
"addr" => { required: false, type: String },
"base" => { required: true, type: String },
"enterprise" => { required: true, type: String },
"token" => { required: true, type: String },
})
Entitlements::Util::Util.validate_attr!(spec, data, "GitHub enterprise team group #{key.inspect}")
end

private

attr_reader :provider
end
end
end
end
47 changes: 47 additions & 0 deletions lib/entitlements/backend/github_enterprise_team/provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

require_relative "service"

module Entitlements
class Backend
class GitHubEnterpriseTeam
class Provider < Entitlements::Backend::BaseProvider
include ::Contracts::Core
C = ::Contracts

Contract C::KeywordArgs[
config: C::HashOf[String => C::Any],
] => C::Any
def initialize(config:)
@github = Entitlements::Backend::GitHubEnterpriseTeam::Service.new(
enterprise: config.fetch("enterprise"),
addr: config.fetch("addr", nil),
token: config.fetch("token"),
ou: config.fetch("base")
)
@team_cache = {}
end

Contract Entitlements::Models::Group => Entitlements::Models::Group
def read(entitlement_group)
slug = Entitlements::Util::Util.any_to_cn(entitlement_group.cn.downcase)
@team_cache[slug] ||= github.read_team(entitlement_group)
end

Contract Entitlements::Models::Group => Hash[added: C::SetOf[String], removed: C::SetOf[String]]
def diff(entitlement_group)
diff_existing_updated(read(entitlement_group), entitlement_group)
end

Contract Entitlements::Models::Group => C::Bool
def commit(entitlement_group)
github.sync_team(entitlement_group, read(entitlement_group))
end

private

attr_reader :github
end
end
end
end
145 changes: 145 additions & 0 deletions lib/entitlements/backend/github_enterprise_team/service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# frozen_string_literal: true

require_relative "../github_team/models/team"
require_relative "../../service/github"

require "cgi"
require "json"
require "net/http"
require "set"
require "uri"

module Entitlements
class Backend
class GitHubEnterpriseTeam
class Service < Entitlements::Service::GitHub
include ::Contracts::Core
C = ::Contracts

API_VERSION = "2026-03-10"
PER_PAGE = 100

class APIError < RuntimeError
attr_reader :status

def initialize(status, body)
@status = status
super("GitHub enterprise team API returned HTTP #{status}: #{body}")
end
end

class TeamNotFound < APIError; end

attr_reader :enterprise

Contract C::KeywordArgs[
addr: C::Maybe[String],
enterprise: String,
token: String,
ou: String,
] => C::Any
def initialize(enterprise:, token:, ou:, addr: nil)
@enterprise = enterprise
super(addr:, org: enterprise, token:, ou:)
end

Contract Entitlements::Models::Group => Entitlements::Backend::GitHubTeam::Models::Team
def read_team(entitlement_group)
team_name = entitlement_group.cn.downcase
members = list_members(team_name)
metadata = entitlement_group.metadata
Entitlements::Backend::GitHubTeam::Models::Team.new(
team_id: -1,
team_name:,
members: Set.new(members.map { |member| member.fetch("login").downcase }),
ou:,
metadata:
)
rescue Entitlements::Models::Group::NoMetadata
Entitlements::Backend::GitHubTeam::Models::Team.new(
team_id: -1,
team_name:,
members: Set.new(members.map { |member| member.fetch("login").downcase }),
ou:,
metadata: nil
)
end

Contract Entitlements::Models::Group, Entitlements::Backend::GitHubTeam::Models::Team => C::Bool
def sync_team(desired_state, current_state)
desired_members = Set.new(desired_state.member_strings.map(&:downcase))
current_members = Set.new(current_state.member_strings.map(&:downcase))
added_members = desired_members - current_members
removed_members = current_members - desired_members

bulk_update(current_state.team_name, "add", added_members) if added_members.any?
bulk_update(current_state.team_name, "remove", removed_members) if removed_members.any?

Entitlements.logger.debug(
"sync_enterprise_team(#{current_state.team_name}): Added #{added_members.count}, removed #{removed_members.count}"
)
added_members.any? || removed_members.any?
end

private

def list_members(team_name)
members = []
page = 1

loop do
path = "#{team_path(team_name)}/memberships?per_page=#{PER_PAGE}&page=#{page}"
page_members = request(Net::HTTP::Get, path)
members.concat(page_members)
break if page_members.length < PER_PAGE

page += 1
end

members
rescue APIError => e
raise TeamNotFound.new(e.status, e.message) if e.status == 404

raise
end

def bulk_update(team_name, operation, usernames)
request(
Net::HTTP::Post,
"#{team_path(team_name)}/memberships/#{operation}",
body: { usernames: usernames.to_a }
)
end

def team_path(team_name)
"/enterprises/#{escape(enterprise)}/teams/#{escape(team_name)}"
end

def escape(value)
CGI.escape(value).gsub("+", "%20")
end

def request(request_class, path, body: nil)
uri = URI.parse("#{(addr || "https://api.github.com").sub(%r{/+\z}, "")}#{path}")
request = request_class.new(uri)
request["Accept"] = "application/vnd.github+json"
request["Authorization"] = "Bearer #{token}"
request["X-GitHub-Api-Version"] = API_VERSION
if body
request["Content-Type"] = "application/json"
request.body = JSON.generate(body)
end

response = Retryable.with_context(:default) do
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
end
raise APIError.new(response.code.to_i, response.body) unless response.is_a?(Net::HTTPSuccess)

return nil if response.body.nil? || response.body.empty?

JSON.parse(response.body)
end
end
end
end
end
Loading
Loading