From 397849f7094d228a85dad62f7c9b5cff0f06621b Mon Sep 17 00:00:00 2001 From: Elijah Buck Date: Tue, 18 Aug 2026 12:03:09 -0700 Subject: [PATCH] Add enterprise team membership support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ea2214e-34f7-4e83-b7fd-413b4a534cd6 --- README.md | 18 ++- .../backend/github_enterprise_team.rb | 9 ++ .../github_enterprise_team/controller.rb | 78 ++++++++++ .../github_enterprise_team/provider.rb | 47 ++++++ .../backend/github_enterprise_team/service.rb | 145 ++++++++++++++++++ .../github_enterprise_team/controller_spec.rb | 92 +++++++++++ .../github_enterprise_team/provider_spec.rb | 55 +++++++ .../github_enterprise_team/service_spec.rb | 117 ++++++++++++++ spec/unit/spec_helper.rb | 1 + 9 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 lib/entitlements/backend/github_enterprise_team.rb create mode 100644 lib/entitlements/backend/github_enterprise_team/controller.rb create mode 100644 lib/entitlements/backend/github_enterprise_team/provider.rb create mode 100644 lib/entitlements/backend/github_enterprise_team/service.rb create mode 100644 spec/unit/entitlements/backend/github_enterprise_team/controller_spec.rb create mode 100644 spec/unit/entitlements/backend/github_enterprise_team/provider_spec.rb create mode 100644 spec/unit/entitlements/backend/github_enterprise_team/service_spec.rb diff --git a/README.md b/README.md index dffde10..af40c78 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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" @@ -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: diff --git a/lib/entitlements/backend/github_enterprise_team.rb b/lib/entitlements/backend/github_enterprise_team.rb new file mode 100644 index 0000000..2048aed --- /dev/null +++ b/lib/entitlements/backend/github_enterprise_team.rb @@ -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! diff --git a/lib/entitlements/backend/github_enterprise_team/controller.rb b/lib/entitlements/backend/github_enterprise_team/controller.rb new file mode 100644 index 0000000..1aabc41 --- /dev/null +++ b/lib/entitlements/backend/github_enterprise_team/controller.rb @@ -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 diff --git a/lib/entitlements/backend/github_enterprise_team/provider.rb b/lib/entitlements/backend/github_enterprise_team/provider.rb new file mode 100644 index 0000000..aefea80 --- /dev/null +++ b/lib/entitlements/backend/github_enterprise_team/provider.rb @@ -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 diff --git a/lib/entitlements/backend/github_enterprise_team/service.rb b/lib/entitlements/backend/github_enterprise_team/service.rb new file mode 100644 index 0000000..c34ea6d --- /dev/null +++ b/lib/entitlements/backend/github_enterprise_team/service.rb @@ -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 diff --git a/spec/unit/entitlements/backend/github_enterprise_team/controller_spec.rb b/spec/unit/entitlements/backend/github_enterprise_team/controller_spec.rb new file mode 100644 index 0000000..78f0394 --- /dev/null +++ b/spec/unit/entitlements/backend/github_enterprise_team/controller_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require_relative "../../../spec_helper" + +describe Entitlements::Backend::GitHubEnterpriseTeam::Controller do + let(:config) do + { + "base" => "ou=teams,ou=GitHub,dc=github,dc=fake", + "enterprise" => "kittensinc", + "token" => "GoPackGo", + "type" => "github_enterprise_team", + } + end + let(:subject) { described_class.new("enterprise-teams", config) } + let(:provider) { instance_double(Entitlements::Backend::GitHubEnterpriseTeam::Provider) } + let(:group) do + Entitlements::Models::Group.new( + dn: "cn=cats,ou=teams,ou=GitHub,dc=github,dc=fake", + members: Set.new(%w[octocat]) + ) + end + let(:team) do + Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: -1, + team_name: "cats", + members: Set.new, + ou: config.fetch("base"), + metadata: nil + ) + end + + before do + subject.instance_variable_set("@provider", provider) + allow(Entitlements::Data::Groups::Calculated).to receive(:read_all) + .with("enterprise-teams", hash_including("enterprise" => "kittensinc")) + .and_return(Set.new(["cats"])) + allow(Entitlements::Data::Groups::Calculated).to receive(:read).with("cats").and_return(group) + end + + it "has the standard team controller priority" do + expect(described_class.priority).to eq(40) + end + + it "prefetches configured teams" do + expect(provider).to receive(:read).with(group).and_return(team) + + subject.prefetch + end + + it "returns actions for changed teams" do + allow(provider).to receive(:diff).with(group).and_return(added: Set.new(["octocat"]), removed: Set.new) + allow(provider).to receive(:read).with(group).and_return(team) + allow(subject).to receive(:print_differences) + + result = subject.calculate + + expect(result.length).to eq(1) + expect(result.first.existing).to eq(team) + expect(result.first.updated).to eq(group) + end + + it "skips unchanged teams" do + allow(provider).to receive(:diff).with(group).and_return(added: Set.new, removed: Set.new) + expect(logger).to receive(:debug).with("UNCHANGED: No GitHub enterprise team changes for enterprise-teams:cats") + allow(subject).to receive(:print_differences) + + expect(subject.calculate).to eq([]) + end + + it "applies membership updates" do + action = Entitlements::Models::Action.new("cats", team, group, "enterprise-teams") + expect(provider).to receive(:commit).with(group).and_return(true) + expect(logger).to receive(:debug).with("APPLY: Updating GitHub enterprise team cats") + + subject.apply(action) + end + + it "warns when no membership update is needed" do + action = Entitlements::Models::Action.new("cats", team, group, "enterprise-teams") + expect(provider).to receive(:commit).with(group).and_return(false) + expect(logger).to receive(:warn).with("DID NOT APPLY: Changes not needed to cats") + + subject.apply(action) + end + + it "rejects attempts to remove enterprise teams" do + action = Entitlements::Models::Action.new("cats", team, nil, "enterprise-teams") + expect(logger).to receive(:fatal).with("cats: GitHub enterprise team membership cannot remove a team") + + expect { subject.apply(action) }.to raise_error(RuntimeError, "Invalid Operation") + end +end diff --git a/spec/unit/entitlements/backend/github_enterprise_team/provider_spec.rb b/spec/unit/entitlements/backend/github_enterprise_team/provider_spec.rb new file mode 100644 index 0000000..63c4abf --- /dev/null +++ b/spec/unit/entitlements/backend/github_enterprise_team/provider_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative "../../../spec_helper" + +describe Entitlements::Backend::GitHubEnterpriseTeam::Provider do + let(:config) do + { + "addr" => "https://github.fake/api/v3", + "base" => "ou=teams,ou=GitHub,dc=github,dc=fake", + "enterprise" => "kittensinc", + "token" => "GoPackGo", + } + end + let(:subject) { described_class.new(config:) } + let(:service) { instance_double(Entitlements::Backend::GitHubEnterpriseTeam::Service) } + let(:group) do + Entitlements::Models::Group.new( + dn: "cn=cats,ou=teams,ou=GitHub,dc=github,dc=fake", + members: Set.new(%w[octocat monalisa]) + ) + end + let(:team) do + Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: -1, + team_name: "cats", + members: Set.new(%w[octocat]), + ou: config.fetch("base"), + metadata: nil + ) + end + + before do + subject.instance_variable_set("@github", service) + end + + it "reads each team once" do + expect(service).to receive(:read_team).with(group).once.and_return(team) + + expect(subject.read(group)).to eq(team) + expect(subject.read(group)).to eq(team) + end + + it "calculates membership differences" do + allow(service).to receive(:read_team).with(group).and_return(team) + + expect(subject.diff(group)).to eq(added: Set.new(%w[monalisa]), removed: Set.new) + end + + it "commits membership differences" do + allow(service).to receive(:read_team).with(group).and_return(team) + expect(service).to receive(:sync_team).with(group, team).and_return(true) + + expect(subject.commit(group)).to eq(true) + end +end diff --git a/spec/unit/entitlements/backend/github_enterprise_team/service_spec.rb b/spec/unit/entitlements/backend/github_enterprise_team/service_spec.rb new file mode 100644 index 0000000..2f895d1 --- /dev/null +++ b/spec/unit/entitlements/backend/github_enterprise_team/service_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require_relative "../../../spec_helper" + +describe Entitlements::Backend::GitHubEnterpriseTeam::Service do + let(:subject) do + described_class.new( + addr: "https://github.fake/api/v3", + enterprise: "kittens inc", + token: "GoPackGo", + ou: "ou=teams,ou=GitHub,dc=github,dc=fake" + ) + end + let(:group) do + Entitlements::Models::Group.new( + dn: "cn=cuddly kittens,ou=teams,ou=GitHub,dc=github,dc=fake", + members: Set.new(%w[octocat monalisa]), + metadata: { "owner" => "octocat" } + ) + end + let(:team) do + Entitlements::Backend::GitHubTeam::Models::Team.new( + team_id: -1, + team_name: "cuddly kittens", + members: Set.new(%w[octocat]), + ou: "ou=teams,ou=GitHub,dc=github,dc=fake", + metadata: nil + ) + end + let(:headers) do + { + "Accept" => "application/vnd.github+json", + "Authorization" => "Bearer GoPackGo", + "X-GitHub-Api-Version" => "2026-03-10", + } + end + let(:members_url) do + "https://github.fake/api/v3/enterprises/kittens%20inc/teams/cuddly%20kittens/memberships?page=1&per_page=100" + end + + describe "#read_team" do + it "reads and normalizes enterprise team members" do + stub_request(:get, members_url).with(headers:).to_return( + status: 200, + body: JSON.generate([{ "login" => "OctoCat" }, { "login" => "MonaLisa" }]) + ) + + result = subject.read_team(group) + + expect(result.team_name).to eq("cuddly kittens") + expect(result.team_id).to eq(-1) + expect(result.member_strings).to eq(Set.new(%w[octocat monalisa])) + expect(result.metadata).to eq("owner" => "octocat") + end + + it "supports entitlement groups without metadata" do + no_metadata = Entitlements::Models::Group.new(dn: group.dn, members: Set.new, metadata: nil) + stub_request(:get, members_url).to_return(status: 200, body: JSON.generate([{ "login" => "OctoCat" }])) + + result = subject.read_team(no_metadata) + expect(result.member_strings).to eq(Set.new(["octocat"])) + expect { result.metadata } + .to raise_error(Entitlements::Models::Group::NoMetadata) + end + + it "paginates member listings" do + first_page = Array.new(100) { |index| { "login" => "cat-#{index}" } } + second_url = members_url.sub("page=1", "page=2") + stub_request(:get, members_url).to_return(status: 200, body: JSON.generate(first_page)) + stub_request(:get, second_url).to_return(status: 200, body: JSON.generate([{ "login" => "last-cat" }])) + + expect(subject.read_team(group).member_strings.size).to eq(101) + end + + it "raises a team not found error for a missing team" do + stub_request(:get, members_url).to_return(status: 404, body: '{"message":"Not Found"}') + + expect { subject.read_team(group) } + .to raise_error(described_class::TeamNotFound, /HTTP 404/) + end + + it "propagates other API failures" do + stub_request(:get, members_url).to_return(status: 403, body: '{"message":"Forbidden"}') + + expect { subject.read_team(group) } + .to raise_error(described_class::APIError, /HTTP 403/) + end + end + + describe "#sync_team" do + it "bulk adds and removes members" do + desired = Entitlements::Models::Group.new( + dn: group.dn, + members: Set.new(%w[monalisa]) + ) + add_url = "https://github.fake/api/v3/enterprises/kittens%20inc/teams/cuddly%20kittens/memberships/add" + remove_url = add_url.sub("/add", "/remove") + stub_request(:post, add_url) + .with(headers:, body: JSON.generate(usernames: ["monalisa"])) + .to_return(status: 200, body: "[]") + stub_request(:post, remove_url) + .with(headers:, body: JSON.generate(usernames: ["octocat"])) + .to_return(status: 200, body: "[]") + expect(logger).to receive(:debug).with("sync_enterprise_team(cuddly kittens): Added 1, removed 1") + + expect(subject.sync_team(desired, team)).to eq(true) + end + + it "does not call the API when membership is unchanged" do + desired = Entitlements::Models::Group.new(dn: group.dn, members: Set.new(%w[OctoCat])) + expect(logger).to receive(:debug).with("sync_enterprise_team(cuddly kittens): Added 0, removed 0") + + expect(subject.sync_team(desired, team)).to eq(false) + expect(WebMock).not_to have_requested(:post, /memberships/) + end + end +end diff --git a/spec/unit/spec_helper.rb b/spec/unit/spec_helper.rb index 2a4ee2a..adeedd9 100644 --- a/spec/unit/spec_helper.rb +++ b/spec/unit/spec_helper.rb @@ -41,6 +41,7 @@ require "entitlements" require_relative "../../lib/entitlements/backend/github_org" +require_relative "../../lib/entitlements/backend/github_enterprise_team" require_relative "../../lib/entitlements/backend/github_team" require_relative "../../lib/entitlements/service/github"