entitlements-app/lib/entitlements.rb

617 строки
20 KiB
Ruby

# frozen_string_literal: true
# Hey there! With our use of the "contracts" module, load order is important.
# Load third party dependencies first.
require "concurrent"
require "ruby_version_check"
# contracts.ruby has two specific ruby-version specific libraries, which we have vendored into lib/
# :nocov:
if RubyVersionCheck.ruby_version2?
$LOAD_PATH.unshift(File.expand_path(File.join(__dir__, "contracts-ruby2/lib")))
else
$LOAD_PATH.unshift(File.expand_path(File.join(__dir__, "contracts-ruby3/lib")))
end
# :nocov:
require "contracts"
require "erb"
require "logger"
require "ostruct"
require "stringio"
require "uri"
require "yaml"
# Next, pre-declare any classes that are referenced from contracts.
module Entitlements
class Auditor
class Base; end
end
class Data
class Groups
class Cached; end
class Calculated
class Base; end
class Ruby < Base; end
class Text < Base; end
class YAML < Base; end
end
end
class People
class Combined; end
class Dummy; end
class LDAP; end
class YAML; end
end
end
module Extras; end
class Models
class Action; end
class Group; end
class Person; end
class RuleSet
class Base; end
class Ruby < Base; end
class YAML < Base; end
end
end
class Service
class GitHub; end
class LDAP; end
end
end
module Entitlements
include ::Contracts::Core
C = ::Contracts
IGNORED_FILES = Set.new(%w[README.md PR_TEMPLATE.md])
# Allows interpretation of ERB for the configuration file to make things less hokey.
class ERB < OpenStruct
def self.render_from_hash(template, hash)
new(hash).render(template)
end
def render(template)
::ERB.new(template, trim_mode: "-").result(binding)
end
end
# Reset all Entitlements state
#
# Takes no arguments
def self.reset!
@cache = nil
@child_classes = nil
@config = nil
@config_file = nil
@config_path_override = nil
@person_extra_methods = {}
reset_extras!
Entitlements::Data::Groups::Calculated.reset!
end
def self.reset_extras!
extras_loaded = @extras_loaded
if extras_loaded
extras_loaded.each { |clazz| clazz.reset! if clazz.respond_to?(:reset!) }
end
@extras_loaded = nil
end
# Set up a dummy logger.
#
# Returns a Logger.
Contract C::None => Logger
def self.dummy_logger
# :nocov:
Logger.new(StringIO.new)
# :nocov:
end
# Read the configuration file and return it as a hash.
#
# Takes no arguments.
#
# Returns a Hash.
Contract C::None => C::HashOf[String => C::Any]
def self.config
@config ||= begin
content = ERB.render_from_hash(File.read(config_file), {})
::YAML.safe_load(content)
end
end
# Set the configuration directly to a Hash.
#
# config_hash - Desired value for the configuration.
#
# Returns the supplied configuration.
Contract C::HashOf[String => C::Any] => C::HashOf[String => C::Any]
def self.config=(config_hash)
@config = config_hash
end
# Determine the configuration file location. Gets the default if
# it is called before explicitly set.
#
# Returns a String.
Contract C::None => String
def self.config_file
@config_file || File.expand_path("../config/entitlements/config.yaml", File.dirname(__FILE__))
end
# Allow an alternate configuration file to be set. When this is set, it
# clears @config so it gets read upon the next invocation.
#
# path - Path to config file.
Contract String => C::Any
def self.config_file=(path)
unless File.file?(path)
raise "Specified config file = #{path.inspect} but it does not exist!"
end
@config_file = path
@config = nil
end
# Get the configuration path for the groups. This is based on the relative
# location to the configuration file if it doesn't start with a "/".
#
# Takes no arguments.
#
# Returns a String with the config path.
def self.config_path
return @config_path_override if @config_path_override
base = config.fetch("configuration_path")
return base if base.start_with?("/")
File.expand_path(base, File.dirname(config_file))
end
# Set the configuration path for the groups. This will override the automatically
# calculated config_path that respects the algorithm noted above.
#
# path - Path to the base directory of groups.
#
# Returns the config_path that was set.
Contract String => C::Any
def self.config_path=(path)
unless path.start_with?("/")
raise ArgumentError, "Path must be absolute when setting config_path!"
end
unless File.directory?(path)
raise Errno::ENOENT, "config_path #{path.inspect} is not a directory!"
end
@config["configuration_path"] = path if @config
@config_path_override = path
end
# Keep track of backends that are registered when backends are loaded.
#
# identifier - A String with the identifier for the backend as it appears in the configuration file
# clazz - A Class reference to the backend
# priority - An Integer with the order of execution (smaller = first)
#
# Returns nothing.
Contract String, Class, Integer, C::Maybe[C::Bool] => C::Any
def self.register_backend(identifier, clazz, priority)
@backends ||= {}
@backends[identifier] = { class: clazz, priority: priority }
end
# Return the registered backends.
#
# Takes no arguments.
#
# Returns a Hash of backend identifier => class and priority.
Contract C::None => C::HashOf[String => C::HashOf[Symbol, C::Any]]
def self.backends
@backends || {}
end
# Load all extras configured by the "extras" key in the entitlements configuration.
#
# Takes no arguments.
#
# Returns nothing.
Contract C::None => nil
def self.load_extras
Entitlements.config.fetch("extras", {}).each do |extra_name, extra_cfg|
path = extra_cfg.key?("path") ? Entitlements::Util::Util.absolute_path(extra_cfg["path"]) : nil
logger.debug "Loading extra #{extra_name} (path = #{path || 'default'})"
Entitlements::Extras.load_extra(extra_name, path)
end
nil
end
# Handle a callback from Entitlements::Extras.load_extra to add a class to the tracker of loaded extra classes.
#
# clazz - Class that was loaded.
#
# Returns nothing.
Contract Class => C::Any
def self.record_loaded_extra(clazz)
@extras_loaded ||= Set.new
@extras_loaded.add(clazz)
end
# Register all filters configured by the "filters" key in the entitlements configuration.
#
# Takes no arguments.
#
# Returns nothing.
Contract C::None => nil
def self.register_filters
Entitlements.config.fetch("filters", {}).each do |filter_name, filter_cfg|
filter_class = filter_cfg.fetch("class")
filter_clazz = Kernel.const_get(filter_class)
filter_config = filter_cfg.fetch("config", {})
logger.debug "Registering filter #{filter_name} (class: #{filter_class})"
Entitlements::Data::Groups::Calculated.register_filter(filter_name, { class: filter_clazz, config: filter_config })
end
nil
end
@person_extra_methods = {}
# Register a method on the Entitlements::Models::Person objects. Methods are registered at
# a class level by extras. This updates @person_methods with a Hash of method_name => reference.
#
# method_name - A String with the extra method name to register.
# method_ref - A reference to the method within the appropriate class.
#
# Returns nothing.
Contract String, C::Any => C::Any
def self.register_person_extra_method(method_name, method_class_ref)
@person_extra_methods[method_name.to_sym] = method_class_ref
end
# Get the current entries in @person_methods as a hash.
#
# Takes no arguments.
#
# Returns a Hash of method_name => reference.
Contract C::None => C::HashOf[Symbol => C::Any]
def self.person_extra_methods
@person_extra_methods
end
# Return array of all registered child classes.
#
# Takes no arguments.
#
# Returns a Hash of instantiated Class objects, indexed by group name, sorted by priority.
Contract C::None => C::HashOf[C::Or[Symbol, String] => Object]
def self.child_classes
@child_classes ||= begin
backend_obj = Entitlements.config["groups"].map do |group_name, data|
[group_name, Entitlements.backends[data["type"]][:class].new(group_name)]
end.compact.to_h
# Sort first by priority, then by whether this is a mirror or not (mirrors go last), and
# finally by the length of the OU name from shortest to longest.
backend_obj.sort_by do |k, v|
[
v.priority,
Entitlements.config["groups"][k] && Entitlements.config["groups"][k].key?("mirror") ? 1 : 0,
k.length
]
end.to_h
end
end
# Method to access the configured auditors.
#
# Takes no arguments.
#
# Returns an Array of Entitlements::Auditor::* objects.
Contract C::None => C::ArrayOf[Entitlements::Auditor::Base]
def self.auditors
@auditors ||= begin
if Entitlements.config.key?("auditors")
Entitlements.config["auditors"].map do |auditor|
unless auditor.is_a?(Hash)
# :nocov:
raise ArgumentError, "Configuration error: Expected auditor to be a hash, got #{auditor.inspect}!"
# :nocov:
end
auditor_class = auditor.fetch("auditor_class")
begin
clazz = Kernel.const_get("Entitlements::Auditor::#{auditor_class}")
rescue NameError
raise ArgumentError, "Auditor class #{auditor_class.inspect} is invalid"
end
clazz.new(logger, auditor)
end
else
[]
end
end
end
# Global logger for this run of Entitlements.
#
# Takes no arguments.
#
# Returns a Logger.
# :nocov:
def self.logger
@logger ||= dummy_logger
end
def self.set_logger(logger)
@logger = logger
end
# :nocov:
# Calculate - This runs the entitlements logic to calculate the differences, ultimately
# populating a cache and returning a list of actions. The cache and actions can then be
# consumed by `execute` to implement the changes.
#
# Takes no arguments.
#
# Returns the array of actions.
Contract C::None => C::ArrayOf[Entitlements::Models::Action]
def self.calculate
# Load extras that are configured.
Entitlements.load_extras if Entitlements.config.key?("extras")
# Pre-fetch people from configured people data sources.
Entitlements.prefetch_people
# Register filters that are configured.
Entitlements.register_filters if Entitlements.config.key?("filters")
# Keep track of the total change count.
cache[:change_count] = 0
max_parallelism = Entitlements.config["max_parallelism"] || 1
# Calculate old and new membership in each group.
thread_pool = Concurrent::FixedThreadPool.new(max_parallelism)
logger.debug("Begin prefetch and validate for all groups")
prep_start = Time.now
futures = Entitlements.child_classes.map do |group_name, obj|
Concurrent::Future.execute({ executor: thread_pool }) do
group_start = Time.now
logger.debug("Begin prefetch and validate for #{group_name}")
obj.prefetch
obj.validate
logger.debug("Finished prefetch and validate for #{group_name} in #{Time.now - group_start}")
end
end
futures.each(&:value!)
logger.debug("Finished all prefetch and validate in #{Time.now - prep_start}")
logger.debug("Begin all calculations")
calc_start = Time.now
actions = []
Entitlements.child_classes.map do |group_name, obj|
obj.calculate
if obj.change_count > 0
logger.debug "Group #{group_name.inspect} contributes #{obj.change_count} change(s)."
cache[:change_count] += obj.change_count
end
actions.concat(obj.actions)
end
logger.debug("Finished all calculations in #{Time.now - calc_start}")
logger.debug("Finished all prefetch, validate, and calculation in #{Time.now - prep_start}")
actions
end
# Method to execute all of the actions and run the auditors. Returns an Array of the exceptions
# raised by auditors. Any exceptions raised by providers will be raised once the auditors are
# executed.
#
# actions - An Array of Entitlements::Models::Action
#
# Returns nothing.
Contract C::KeywordArgs[
actions: C::ArrayOf[Entitlements::Models::Action]
] => nil
def self.execute(actions:)
# Set up auditors.
Entitlements.auditors.each { |auditor| auditor.setup }
# Track any raised exception to pass to the auditors.
provider_exception = nil
audit_exceptions = []
successful_actions = Set.new
# Sort the child classes by priority
begin
# Pre-apply changes for each class.
Entitlements.child_classes.each do |_, obj|
obj.preapply
end
# Apply changes from all actions.
actions.each do |action|
obj = Entitlements.child_classes.fetch(action.ou)
obj.apply(action)
successful_actions.add(action.dn)
end
rescue => e
# Populate 'provider_exception' for the auditors and then raise the exception.
provider_exception = e
raise e
ensure
# Run the audit "commit" action for each auditor. This needs to happen despite any failures that
# may occur when pre-applying or applying actions, because actions might have been applied despite
# any failures raised. Run each audit, even if one fails, and batch up the exceptions for the end.
# If there was an original exception from one of the providers, this block will be executed and then
# that original exception will be raised.
if Entitlements.auditors.any?
logger.debug "Recording data to #{Entitlements.auditors.size} audit provider(s)"
Entitlements.auditors.each do |audit|
begin
audit.commit(
actions: actions,
successful_actions: successful_actions,
provider_exception: provider_exception
)
logger.debug "Audit #{audit.description} completed successfully"
rescue => e
logger.error "Audit #{audit.description} failed: #{e.class} #{e.message}"
e.backtrace.each { |line| logger.error line }
audit_exceptions << e
end
end
end
end
# If we get here there were no provider exceptions. If there were audit exceptions raise them here.
# If there were multiple exceptions we can only raise the first one, but log a message indicating this.
return if audit_exceptions.empty?
if audit_exceptions.size > 1
logger.warn "There were #{audit_exceptions.size} audit exceptions. Only the first one is raised."
end
raise audit_exceptions.first
end
# Validate the configuration file.
#
# Takes no input.
#
# Returns nothing.
Contract C::None => nil
def self.validate_configuration_file!
# Required attributes
spec = {
"configuration_path" => { required: true, type: String },
"backends" => { required: false, type: Hash },
"people" => { required: true, type: Hash },
"people_data_source" => { required: true, type: String },
"groups" => { required: true, type: Hash },
"auditors" => { required: false, type: Array },
"filters" => { required: false, type: Hash },
"extras" => { required: false, type: Hash },
"max_parallelism" => { required: false, type: Integer },
}
Entitlements::Util::Util.validate_attr!(spec, Entitlements.config, "Entitlements configuration file")
# Make sure each group has a valid type, and then forward the validator to the child class.
# If a named backend is chosen, merge the parameters from the backend with the parameters given
# for the class configuration, and then remove all indication that a backend was used.
Entitlements.config["groups"].each do |key, data|
if data.key?("backend")
unless Entitlements.config["backends"] && Entitlements.config["backends"].key?(data["backend"])
raise "Entitlements configuration group #{key.inspect} references non-existing backend #{data['backend'].inspect}!"
end
backend = Entitlements.config["backends"].fetch(data["backend"])
unless backend.key?("type")
raise "Entitlements backend #{data['backend'].inspect} is missing a type!"
end
# Priority in the merge is given to the specific OU configured. Backend data is filled
# in only as default values when not otherwise defined.
Entitlements.config["groups"][key] = backend.merge(data)
Entitlements.config["groups"][key].delete("backend")
data = Entitlements.config["groups"][key]
end
unless data["type"].is_a?(String)
raise "Entitlements configuration group #{key.inspect} does not properly declare a type!"
end
unless Entitlements.backends.key?(data["type"])
raise "Entitlements configuration group #{key.inspect} has invalid type (#{data['type'].inspect})"
end
end
# Good if nothing is raised by here.
nil
end
# Method to go through each person data source and retrieve the list of people from it. Populates
# Entitlements.cache[:people][<datasource>] with the objects that can be subsequently `read` from
# with no penalty.
#
# Takes no arguments.
#
# Returns the Entitlements::Data::People::* object.
Contract C::None => C::Any
def self.prefetch_people
Entitlements.cache[:people_obj] ||= begin
people_data_sources = Entitlements.config.fetch("people", [])
if people_data_sources.empty?
raise ArgumentError, "At least one data source for people must be specified in the Entitlements configuration!"
end
# TODO: In the future, have separate data sources per group.
people_data_source_name = Entitlements.config.fetch("people_data_source", "")
if people_data_source_name.empty?
raise ArgumentError, "The Entitlements configuration must define a people_data_source!"
end
unless people_data_sources.key?(people_data_source_name)
raise ArgumentError, "The people_data_source #{people_data_source_name.inspect} is invalid!"
end
objects = people_data_sources.map do |ds_name, ds_config|
people_obj = Entitlements::Data::People.new_from_config(ds_config)
people_obj.read
[ds_name, people_obj]
end.to_h
objects.fetch(people_data_source_name)
end
end
# This is a global cache for the whole run of entitlements. To avoid passing objects around, since Entitlements
# by its nature is a run-once-upon-demand application.
#
# Takes no arguments.
#
# Returns a Hash that contains the cache.
#
# Note: Since this is hit a lot, to avoid the performance penalty, Contracts is not used here.
# :nocov:
def self.cache
@cache ||= {
calculated: {},
file_objects: {}
}
end
# :nocov:
end
# Finally, load everything else. Order should be unimportant here.
require_relative "entitlements/auditor/base"
require_relative "entitlements/backend/base_controller"
require_relative "entitlements/backend/base_provider"
require_relative "entitlements/backend/dummy"
require_relative "entitlements/backend/ldap"
require_relative "entitlements/backend/member_of"
require_relative "entitlements/cli"
require_relative "entitlements/data/groups"
require_relative "entitlements/data/people"
require_relative "entitlements/extras"
require_relative "entitlements/extras/base"
require_relative "entitlements/models/action"
require_relative "entitlements/models/group"
require_relative "entitlements/models/person"
require_relative "entitlements/plugins"
require_relative "entitlements/plugins/dummy"
require_relative "entitlements/plugins/group_of_names"
require_relative "entitlements/plugins/posix_group"
require_relative "entitlements/rule/base"
require_relative "entitlements/service/ldap"
require_relative "entitlements/util/mirror"
require_relative "entitlements/util/override"
require_relative "entitlements/util/util"