# 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"