Skip to content
Open
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
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
entitlements-github-plugin (1.2.5)
entitlements-github-plugin (2.0.0)
contracts (~> 0.17.0)
faraday (~> 2.0)
faraday-net_http_persistent (~> 2.3)
Expand Down
60 changes: 59 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 manage GitHub organization and team membership, and direct repository access.

## Usage

Expand Down Expand Up @@ -38,6 +38,7 @@ require "entitlements"
# require entitlements plugins here
require "entitlements/backend/github_org"
require "entitlements/backend/github_team"
require "entitlements/backend/github_repository"
require "entitlements/service/github"
```

Expand Down Expand Up @@ -85,6 +86,63 @@ 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 repositories

The `github_repository` backend manages repository level grants for **individuals only**. Role files define the desired grants and all other direct access is removed when the `remove` option is enabled. Users must be active organization members.

Load `entitlements/backend/github_repository` in your plugin loader and add this entry under `groups`:

```yaml
github.com/github/repositories:
type: github_repository
dir: repositories/github
base: ou=repositories,ou=github,ou=GitHub,dc=github,dc=com
org: github
token: <%= ENV.fetch("GITHUB_REPOSITORY_TOKEN") %>
addr: <%= ENV["GITHUB_API_BASE"] %>
allowed_types: [txt]
allowed_methods: [username, group]
features: [add, update, remove]
ignore: []
ignore_not_found: false
```

`dir`, `base`, `org`, and `token` are required, nonempty strings.

#### Repository and role files

Each immediate subdirectory opts one repository into management:

```text
repositories/github/
entitlements-app/
read.txt
write.txt
maintain.txt
another.repository/
admin.txt
```

Role files use standard Entitlements syntax:

```text
username = alice
username = bob; expiration = 2027-01-01
group = engineering/platform
```

Group references, filters, and expiration are evaluated by the normal Entitlements rules engine. Group references expand to individual users, never GitHub team grants.

**Custom roles are currently unsupported and organization level grants are not removed.**

GitHub's REST collaborator inventory identifies direct repository associations, but reports each collaborator's highest
effective role after inherited team, organization, and enterprise access. A stronger inherited role can therefore mask a
lower direct role during calculation; the backend reconciles the effective role returned by GitHub.

**A missing role file means no desired members for that role and an empty repository directory would request the removal of all managed direct user and team grants.**

To opt-out a repository, its entire directory must be removed.

## Release 🚀

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

require_relative "github_org"
require_relative "../service/github"

module Entitlements
class Backend
class GitHubRepository
include ::Contracts::Core
C = ::Contracts

ROLES = {
"read" => "pull",
"triage" => "triage",
"write" => "push",
"maintain" => "maintain",
"admin" => "admin"
}.freeze
FEATURES = %w[add update remove].freeze

class Error < RuntimeError; end

# Report an invalid configuration, response or access change.
#
# message - String describing the failure.
#
# Always raises a backend error after logging the message.
Contract String => C::Any
def self.fail!(message)
Entitlements.logger.error(message)
raise Error, message
end
end
end
end

require_relative "github_repository/models/organization_access"
require_relative "github_repository/models/repository_access"
require_relative "github_repository/configuration"
require_relative "github_repository/service"
require_relative "github_repository/provider"
require_relative "github_repository/controller"
156 changes: 156 additions & 0 deletions lib/entitlements/backend/github_repository/configuration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# frozen_string_literal: true

module Entitlements
class Backend
class GitHubRepository
class Configuration
include ::Contracts::Core
C = ::Contracts

REQUIRED_SETTINGS = %w[dir base org token].freeze
DEFAULT_ALLOWED_TYPES = %w[txt yaml rb].freeze
VALIDATION_SPEC = Entitlements::Backend::BaseController::COMMON_GROUP_CONFIG.merge(
"dir" => { required: true, type: String },
"base" => { required: true, type: String },
"org" => { required: true, type: String },
"token" => { required: true, type: String },
"addr" => { required: false, type: [String, NilClass] },
"features" => { required: false, type: Array },
"ignore" => { required: false, type: Array },
"ignore_not_found" => { required: false, type: [TrueClass, FalseClass] }
).freeze

# Validate configuration options.
#
# key - String with the name of the group.
# data - Hash with the configuration data.
#
# Returns nothing.
Contract String, C::HashOf[String => C::Any] => nil
def self.validate!(key, data)
Entitlements::Util::Util.validate_attr!(VALIDATION_SPEC, data, "GitHub repository backend #{key}")
validate_required_settings!(key, data)
validate_allowed_values!(key, data)
validate_api_address!(key, data["addr"])
end

# Constructor.
#
# config - Configuration provided for the controller instantiation.
Contract C::HashOf[String => C::Any] => C::Any
def initialize(config)
@config = config
end

# Load the desired grants for every configured repository.
#
# Takes no arguments.
#
# Returns an Array of repository access models.
Contract C::None => C::ArrayOf[Models::RepositoryAccess]
def load
root = File.expand_path(@config.fetch("dir"), Entitlements.config_path)
seen = Set.new
Dir.children(root).sort.map do |repository|
path = File.join(root, repository)
unless File.directory?(path) && !File.symlink?(path) && seen.add?(repository.downcase)
GitHubRepository.fail!("Unexpected or duplicate repository directory: #{path}")
end
load_repository(repository, path)
end
end

private

# Evaluate a repository's role files using the standard rules engine.
#
# repository - String with the repository name.
# path - String with the absolute path to its role directory.
#
# Returns a repository access model.
Contract String, String => Models::RepositoryAccess
def load_repository(repository, path)
roles = {}
seen_roles = Set.new
seen_users = Set.new
Dir.children(path).sort.each do |entry|
filename, role = role_file(path, entry, seen_roles)
add_role_members(repository, filename, role, roles, seen_users)
end
Models::RepositoryAccess.new(repository: repository, roles: roles, ou: @config.fetch("base"))
end

# Validate required settings that cannot be blank.
Contract String, C::HashOf[String => C::Any] => nil
def self.validate_required_settings!(key, data)
REQUIRED_SETTINGS.each do |name|
GitHubRepository.fail!("#{key}: #{name} must not be empty") if data.fetch(name).strip.empty?
end
nil
end
private_class_method :validate_required_settings!

# Validate configured feature, file type and rule method allowlists.
Contract String, C::HashOf[String => C::Any] => nil
def self.validate_allowed_values!(key, data)
allowed_values = {
"features" => FEATURES,
"allowed_types" => DEFAULT_ALLOWED_TYPES,
"allowed_methods" => Entitlements::Data::Groups::Calculated.rules_index.keys,
}
allowed_values.each do |name, allowed|
invalid = data.fetch(name, []) - allowed
GitHubRepository.fail!("#{key}: invalid #{name}: #{invalid.inspect}") unless invalid.empty?
end
nil
end
private_class_method :validate_allowed_values!

# Validate an optional GitHub API base URL.
Contract String, C::Maybe[String] => nil
def self.validate_api_address!(key, address)
return if address.nil?

uri = URI.parse(address)
return if valid_api_address?(uri)

GitHubRepository.fail!("#{key}: addr must be an HTTP(S) API base URL without credentials, query or fragment")
rescue URI::InvalidURIError => e
GitHubRepository.fail!("#{key}: invalid addr: #{e.message}")
end
private_class_method :validate_api_address!

# Determine whether a parsed URI is an uncredentialed HTTP(S) API base.
Contract URI::Generic => C::Bool
def self.valid_api_address?(uri)
%w[http https].include?(uri.scheme) && !uri.host.nil? &&
uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
end
private_class_method :valid_api_address?

# Validate a role file and return its path and role.
Contract String, String, C::SetOf[String] => C::ArrayOf[String]
def role_file(path, entry, seen_roles)
filename = File.join(path, entry)
role = File.basename(entry, File.extname(entry))
extension = File.extname(entry).delete_prefix(".")
valid = File.file?(filename) && !File.symlink?(filename) && ROLES.key?(role) &&
@config.fetch("allowed_types", DEFAULT_ALLOWED_TYPES).include?(extension) && seen_roles.add?(role)
GitHubRepository.fail!("Unexpected or duplicate repository role file: #{filename}") unless valid
[filename, role]
end

# Add the calculated members from one role file.
Contract String, String, String, C::HashOf[String => String], C::SetOf[String] => C::Any
def add_role_members(repository, filename, role, roles, seen_users)
ruleset = Entitlements::Data::Groups::Calculated.ruleset(filename: filename, config: @config)
ruleset.modified_filtered_members.each do |person|
login = person.uid
GitHubRepository.fail!("#{repository}: duplicate user across roles: #{login}") unless seen_users.add?(login.downcase)
roles[login] = role
end
end
end
end
end
end
72 changes: 72 additions & 0 deletions lib/entitlements/backend/github_repository/controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module Entitlements
class Backend
class GitHubRepository
class Controller < Entitlements::Backend::BaseController
# Controller priority and registration
def self.priority
50
end

register

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

# Constructor. Generic constructor that takes a hash of configuration options.
#
# group_name - Name of the corresponding group in the entitlements configuration file.
# config - Optionally, a Hash of configuration information (configuration is referenced if empty).
Contract String, C::Maybe[C::HashOf[String => C::Any]] => C::Any
def initialize(group_name, config = nil)
super
@provider = Provider.new(config: @config)
end

# Validate configuration options.
#
# key - String with the name of the group.
# data - Hash with the configuration data.
#
# Returns nothing.
Contract String, C::HashOf[String => C::Any] => nil
def validate_config!(key, data)
Configuration.validate!(key, data)
end

# Validate and load all local repository role files.
#
# Takes no arguments.
#
# Returns an Array of repository access models.
Contract C::None => C::ArrayOf[Models::RepositoryAccess]
def validate
@repositories = Configuration.new(config).load
end

# Calculate changes after validating every local repository.
#
# Takes no arguments.
#
# Returns a list of @actions.
Contract C::None => C::ArrayOf[Entitlements::Models::Action]
def calculate
# Evaluate every local file before making the first GitHub request.
validate
@actions = @repositories.filter_map { |repository| @provider.diff(repository, group_name) }
end

# Apply changes.
#
# action - An Entitlements::Models::Action object.
#
# Returns nothing.
Contract Entitlements::Models::Action => nil
def apply(action)
@provider.commit(action)
end
end
end
end
end
Loading
Loading