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
118 changes: 117 additions & 1 deletion README.md

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions lib/entitlements/backend/github_repository.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

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

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

class Error < RuntimeError; end

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"
99 changes: 99 additions & 0 deletions lib/entitlements/backend/github_repository/configuration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

module Entitlements
class Backend
class GitHubRepository
class Configuration
# Underscores are used by Enterprise Managed User logins.
LOGIN = /\A[a-zA-Z0-9][a-zA-Z0-9_-]*\z/
REPOSITORY = /\A[a-zA-Z0-9_.-]{1,100}\z/

def self.validate!(key, data)
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] }
)
Entitlements::Util::Util.validate_attr!(spec, data, "GitHub repository backend #{key}")
%w[dir base org token].each do |name|
GitHubRepository.fail!("#{key}: #{name} must not be empty") if data.fetch(name).strip.empty?
end
validate_login!(data.fetch("org"))
{ "features" => FEATURES, "allowed_types" => %w[txt yaml rb],
"allowed_methods" => Entitlements::Data::Groups::Calculated.rules_index.keys }.each do |name, allowed|
invalid = data.fetch(name, []) - allowed
GitHubRepository.fail!("#{key}: invalid #{name}: #{invalid.inspect}") unless invalid.empty?
end
data.fetch("ignore", []).each { |login| validate_login!(login) }
return if data["addr"].nil?

uri = URI.parse(data.fetch("addr"))
unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
GitHubRepository.fail!("#{key}: addr must be an HTTP(S) API base URL without credentials, query or fragment")
end
rescue URI::InvalidURIError => e
GitHubRepository.fail!("#{key}: invalid addr: #{e.message}")
end

def self.validate_login!(login)
GitHubRepository.fail!("Invalid GitHub login: #{login.inspect}") unless login.is_a?(String) && LOGIN.match?(login)
end

def self.validate_repository!(repository)
unless repository.is_a?(String) && REPOSITORY.match?(repository) && !%w[. ..].include?(repository)
GitHubRepository.fail!("Invalid GitHub repository name: #{repository.inspect}")
end
end

def initialize(config)
@config = config
end

def load
root = File.expand_path(@config.fetch("dir"), Entitlements.config_path)
seen = Set.new
Dir.children(root).sort.map do |repository|
self.class.validate_repository!(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

def load_repository(repository, path)
roles = {}
seen_roles = Set.new
seen_users = Set.new
Dir.children(path).sort.each do |entry|
filename = File.join(path, entry)
role = File.basename(entry, File.extname(entry))
extension = File.extname(entry).delete_prefix(".")
unless File.file?(filename) && !File.symlink?(filename) && ROLES.key?(role) &&
@config.fetch("allowed_types", %w[txt yaml rb]).include?(extension) && seen_roles.add?(role)
GitHubRepository.fail!("Unexpected or duplicate repository role file: #{filename}")
end
ruleset = Entitlements::Data::Groups::Calculated.ruleset(filename: filename, config: @config)
ruleset.modified_filtered_members.each do |person|
login = person.uid
self.class.validate_login!(login)
unless seen_users.add?(login.downcase)
GitHubRepository.fail!("#{repository}: duplicate user across roles: #{login}")
end
roles[login] = role
end
end
Models::RepositoryAccess.new(repository: repository, roles: roles, ou: @config.fetch("base"))
end
end
end
end
end
38 changes: 38 additions & 0 deletions lib/entitlements/backend/github_repository/controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

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

register

def initialize(group_name, config = nil)
super
@provider = Provider.new(config: @config)
end

def validate_config!(key, data)
Configuration.validate!(key, data)
end

def validate
@repositories = Configuration.new(config).load
end

def calculate
# Evaluate every local file before making the first GitHub request.
validate
@actions = @repositories.filter_map { |repository| @provider.action_for(repository, group_name) }
end

def apply(action)
@provider.commit(action)
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

module Entitlements
class Backend
class GitHubRepository
module Models
class OrganizationAccess
attr_reader :members, :base_role, :assignments

def initialize(members:, base_role:, assignments:)
unless (ROLES.keys + ["none"]).include?(base_role) &&
members.values.all? { |role| %w[member admin].include?(role) }
GitHubRepository.fail!("Malformed organization access")
end
@members = members.transform_keys(&:downcase).freeze
@base_role = base_role
@assignments = assignments.transform_keys(&:downcase).transform_values do |roles|
roles.sort_by { |role| role.fetch(:id) }.freeze
end.freeze
end

def owner?(login)
members[login.downcase] == "admin"
end

def inherited_role(login)
return unless members.key?(login.downcase)
roles = assignments.fetch(login.downcase, []).filter_map { |assignment| assignment[:base_role] }
roles << base_role unless base_role == "none"
roles << "admin" if owner?(login)
roles.max_by { |role| ROLES.keys.index(role) }
end

def sources(login)
result = assignments.fetch(login.downcase, []).map { |role| "organization role #{role.fetch(:name).inspect}" }
result << "organization base #{base_role}" if members.key?(login.downcase) && base_role != "none"
result << "organization ownership" if owner?(login)
result
end

def ==(other)
other.is_a?(self.class) && members == other.members && base_role == other.base_role && assignments == other.assignments
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

module Entitlements
class Backend
class GitHubRepository
module Models
class RepositoryAccess < Entitlements::Models::Group
attr_reader :repository, :roles, :teams, :organization_access

def initialize(repository:, roles:, ou:, teams: [], organization_access: nil)
Configuration.validate_repository!(repository)
@repository = repository
@organization_access = organization_access
@roles = {}
@logins = {}
roles.sort_by { |login, _| login.downcase }.each do |login, role|
Configuration.validate_login!(login)
GitHubRepository.fail!("Unsupported repository role: #{role.inspect}") unless ROLES.key?(role)
key = login.downcase
GitHubRepository.fail!("Duplicate repository user: #{login}") if @roles.key?(key)
@roles[key] = role
@logins[key] = login
end
@roles.freeze
@logins.freeze
@teams = {}
slugs = Set.new
teams.each do |team|
unless team.is_a?(Hash) && team[:id].is_a?(Integer) && team[:id].positive? &&
team[:slug].is_a?(String) && /\A[a-zA-Z0-9_-]+\z/.match?(team[:slug]) &&
%w[direct organization enterprise].include?(team[:access_source]) &&
(team[:parent_id].nil? || (team[:parent_id].is_a?(Integer) && team[:parent_id].positive?))
GitHubRepository.fail!("Malformed repository team: #{team.inspect}")
end
if @teams.key?(team[:id]) || !slugs.add?(team[:slug].downcase)
GitHubRepository.fail!("Duplicate repository team: #{team[:slug]}")
end
@teams[team[:id]] = team.dup.freeze
end
@teams.freeze
ordered_teams
super(dn: "cn=#{repository},#{ou}", members: Set.new(@logins.values))
end

def ordered_teams
remaining = direct_teams.dup
ordered = []
until remaining.empty?
roots = remaining.values.reject { |team| remaining.key?(team[:parent_id]) }.sort_by { |team| team[:slug].downcase }
GitHubRepository.fail!("Cyclic repository team hierarchy") if roots.empty?
roots.each { |team| ordered << remaining.delete(team[:id]) }
end
ordered
end

def direct_teams
teams.select { |_, team| team[:access_source] == "direct" }
end

def role_for(login)
roles[login.downcase]
end

def login_for(login)
@logins.fetch(login.downcase)
end

def equals?(other)
other.is_a?(self.class) && dn.casecmp?(other.dn) && roles == other.roles &&
direct_teams == other.direct_teams && organization_access == other.organization_access
end

alias_method :==, :equals?
end
end
end
end
end
Loading
Loading