diff --git a/.gitignore b/.gitignore index f3949cc..01ac342 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Ignore binstubs but do commit the one specific for this code. bin/* !bin/deploy-entitlements +!bin/entitlements-smart-diff # There's a place for local caching of container gems to make local builds faster. # Keep the .keep file but not the gems themselves diff --git a/Gemfile.lock b/Gemfile.lock index a9787f3..ab5f2c9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - entitlements-app (1.2.1) + entitlements-app (1.2.2) concurrent-ruby (~> 1.3, >= 1.3.1) faraday (~> 2.0) logger (~> 1.6) diff --git a/bin/entitlements-smart-diff b/bin/entitlements-smart-diff new file mode 100755 index 0000000..39e015e --- /dev/null +++ b/bin/entitlements-smart-diff @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require "entitlements" +require "entitlements/smart_diff/cli" + +exit Entitlements::SmartDiff::Cli.run diff --git a/entitlements-app.gemspec b/entitlements-app.gemspec index 717eda0..0060bd7 100644 --- a/entitlements-app.gemspec +++ b/entitlements-app.gemspec @@ -10,9 +10,9 @@ Gem::Specification.new do |s| s.authors = ["GitHub, Inc. Security Ops"] s.email = "opensource+entitlements-app@github.com" s.license = "MIT" - s.files = Dir.glob("lib/**/*") + %w[bin/deploy-entitlements] + s.files = Dir.glob("lib/**/*") + %w[bin/deploy-entitlements bin/entitlements-smart-diff] s.homepage = "https://github.com/github/entitlements-app" - s.executables = %w[deploy-entitlements] + s.executables = %w[deploy-entitlements entitlements-smart-diff] s.required_ruby_version = ">= 3.0.0" diff --git a/lib/entitlements.rb b/lib/entitlements.rb index 6e89811..ce52603 100644 --- a/lib/entitlements.rb +++ b/lib/entitlements.rb @@ -4,6 +4,7 @@ # Load third party dependencies first. require "concurrent" require "ruby_version_check" +require "time" # contracts.ruby has two specific ruby-version specific libraries, which we have vendored into lib/ @@ -88,12 +89,77 @@ def self.reset! @config = nil @config_file = nil @config_path_override = nil + @evaluation_time = nil @person_extra_methods = {} reset_extras! + reset_rule_classes! Entitlements::Data::Groups::Calculated.reset! end + # Remove classes loaded from Ruby entitlement files so separate evaluations cannot + # retain class-level descriptions, filters, metadata, or methods. + # + # Takes no arguments. + def self.reset_rule_classes! + Array(@loaded_rule_constant_paths).sort_by { |path| -path.count(":") }.each do |path| + parent_name, _, constant_name = path.rpartition("::") + parent = Kernel.const_get(parent_name) + constant = constant_name.to_sym + parent.send(:remove_const, constant) if parent.const_defined?(constant, false) + end + @loaded_rule_constant_paths = nil + end + + # Return all constants currently defined below Entitlements::Rule. + # + # Takes no arguments. + def self.rule_constant_paths + return Set.new unless const_defined?(:Rule, false) + + collect_rule_constant_paths(Entitlements::Rule, "Entitlements::Rule", Set.new, Set.new) + end + + # Record constants introduced by loading an entitlement Ruby file. + # + # paths - Set of fully qualified constant names. + def self.record_rule_constants(paths) + @loaded_rule_constant_paths ||= Set.new + @loaded_rule_constant_paths.merge(paths) + end + + def self.collect_rule_constant_paths(namespace, prefix, result, visited) + return result if visited.include?(namespace.object_id) + + visited.add(namespace.object_id) + namespace.constants(false).each do |constant| + path = "#{prefix}::#{constant}" + result.add(path) + value = namespace.const_get(constant, false) + collect_rule_constant_paths(value, path, result, visited) if value.is_a?(Module) + end + result + end + private_class_method :collect_rule_constant_paths + + # Return the time used for date-sensitive entitlement evaluation. + # + # Returns a Time. + Contract C::None => Time + def self.evaluation_time + @evaluation_time || Time.now + end + + # Set the time used for date-sensitive entitlement evaluation. + # + # value - A Time. + # + # Returns the supplied Time. + Contract Time => Time + def self.evaluation_time=(value) + @evaluation_time = value + end + def self.reset_extras! extras_loaded = @extras_loaded if extras_loaded @@ -600,6 +666,7 @@ def self.cache require_relative "entitlements/cli" require_relative "entitlements/data/groups" require_relative "entitlements/data/people" +require_relative "entitlements/desired_groups" require_relative "entitlements/extras" require_relative "entitlements/extras/base" require_relative "entitlements/models/action" @@ -611,6 +678,7 @@ def self.cache require_relative "entitlements/plugins/posix_group" require_relative "entitlements/rule/base" require_relative "entitlements/service/ldap" +require_relative "entitlements/smart_diff" require_relative "entitlements/util/mirror" require_relative "entitlements/util/override" require_relative "entitlements/util/util" diff --git a/lib/entitlements/data/groups/calculated.rb b/lib/entitlements/data/groups/calculated.rb index fc86574..74fc492 100644 --- a/lib/entitlements/data/groups/calculated.rb +++ b/lib/entitlements/data/groups/calculated.rb @@ -16,6 +16,8 @@ class Calculated include ::Contracts::Core C = ::Contracts + class DynamicGroupError < RuntimeError; end + FILE_EXTENSIONS = { "rb" => "Entitlements::Data::Groups::Calculated::Ruby", "txt" => "Entitlements::Data::Groups::Calculated::Text", @@ -39,6 +41,7 @@ def self.reset! @groups_in_ou_cache = {} @groups_cache = {} @config_cache = {} + Entitlements::Data::Groups::Calculated::Rules::Group.reset! end # Construct a group object. @@ -60,10 +63,11 @@ def self.read(dn) # # Returns a Set of Strings (DNs) of the groups in this OU. Contract String, C::HashOf[String => C::Any], C::KeywordArgs[ - skip_broken_references: C::Optional[C::Bool] + skip_broken_references: C::Optional[C::Bool], + skip_dynamic_groups: C::Optional[C::Bool] ] => C::SetOf[String] - def self.read_all(ou_key, cfg_obj, skip_broken_references: false) - return read_mirror(ou_key, cfg_obj) if cfg_obj["mirror"] + def self.read_all(ou_key, cfg_obj, skip_broken_references: false, skip_dynamic_groups: false) + return read_mirror(ou_key, cfg_obj, skip_dynamic_groups: skip_dynamic_groups) if cfg_obj["mirror"] @config_cache[ou_key] ||= cfg_obj @groups_in_ou_cache[ou_key] ||= begin @@ -94,15 +98,24 @@ def self.read_all(ou_key, cfg_obj, skip_broken_references: false) group_dn = ["cn=#{file_without_extension}", cfg_obj.fetch("base")].join(",") # Use the ruleset to build the group. - options = { skip_broken_references: skip_broken_references } - - Entitlements.cache[:file_objects][filename] ||= ruleset(filename: filename, config: cfg_obj, options: options) - @groups_cache[group_dn] = Entitlements::Models::Group.new( - dn: group_dn, - members: Entitlements.cache[:file_objects][filename].modified_filtered_members, - description: Entitlements.cache[:file_objects][filename].description, - metadata: Entitlements.cache[:file_objects][filename].metadata.merge("_filename" => filename) - ) + options = { + skip_broken_references: skip_broken_references, + skip_dynamic_groups: skip_dynamic_groups + } + + begin + Entitlements.cache[:file_objects][filename] ||= ruleset(filename: filename, config: cfg_obj, options: options) + @groups_cache[group_dn] = Entitlements::Models::Group.new( + dn: group_dn, + members: Entitlements.cache[:file_objects][filename].modified_filtered_members, + description: Entitlements.cache[:file_objects][filename].description, + metadata: Entitlements.cache[:file_objects][filename].metadata.merge("_filename" => filename) + ) + rescue DynamicGroupError + raise unless skip_dynamic_groups + + next + end result.add group_dn end @@ -152,8 +165,10 @@ def self.all_groups # cfg_obj - Hash with the configuration for that key from the configuration file. # # Returns a Set of Strings (DNs) of the groups in this OU. - Contract String, C::HashOf[String => C::Any] => C::SetOf[String] - def self.read_mirror(ou_key, cfg_obj) + Contract String, C::HashOf[String => C::Any], C::KeywordArgs[ + skip_dynamic_groups: C::Optional[C::Bool] + ] => C::SetOf[String] + def self.read_mirror(ou_key, cfg_obj, skip_dynamic_groups: false) @groups_in_ou_cache[ou_key] ||= begin Entitlements.logger.debug "Mirroring #{ou_key} from #{cfg_obj['mirror']}" diff --git a/lib/entitlements/data/groups/calculated/base.rb b/lib/entitlements/data/groups/calculated/base.rb index d19d08b..22fee22 100644 --- a/lib/entitlements/data/groups/calculated/base.rb +++ b/lib/entitlements/data/groups/calculated/base.rb @@ -118,7 +118,7 @@ def filtered_members filters.reject { |_, filter_val| filter_val == :all }.each do |filter_name, filter_val| filter_cfg = Entitlements::Data::Groups::Calculated.filters_index[filter_name] clazz = filter_cfg.fetch(:class) - obj = clazz.new(filter: filter_val, config: filter_cfg.fetch(:config, {})) + obj = clazz.new(filter: filter_val, config: filter_cfg.fetch(:config, {}), options: options) # If excluded_paths is set, ignore any of those excluded paths unless filter_cfg[:config]["excluded_paths"].nil? # if the filename is not in any of the excluded paths, filter it @@ -217,7 +217,7 @@ def expired?(expiration, context) return false if expiration.nil? || expiration.strip.empty? if expiration =~ /\A(\d{4})-(\d{2})-(\d{2})\z/ year, month, day = Regexp.last_match(1).to_i, Regexp.last_match(2).to_i, Regexp.last_match(3).to_i - return Time.utc(year, month, day, 0, 0, 0) <= Time.now.utc + return Time.utc(year, month, day, 0, 0, 0) <= Entitlements.evaluation_time.utc end message = "Invalid expiration date #{expiration.inspect} in #{context} (expected format: YYYY-MM-DD)" raise ArgumentError, message @@ -243,7 +243,16 @@ def members_from_rules(rule) Entitlements.cache[:dependencies] << "#{rou}/#{cn}" # Actually calculate it. - Entitlements.cache[:calculated][rou][cn] = _members_from_rules(rule) + begin + Entitlements.cache[:calculated][rou][cn] = _members_from_rules(rule) + rescue Entitlements::Data::Groups::Calculated::DynamicGroupError + raise unless options[:skip_dynamic_groups] + + Entitlements.cache[:calculated][rou].delete(cn) + Entitlements.cache[:dependencies].delete("#{rou}/#{cn}") + Entitlements.cache.fetch(:file_objects, {}).delete(filename) + raise + end # This should be the last item on the dependencies array, so pop it off. unless Entitlements.cache[:dependencies].last == "#{rou}/#{cn}" @@ -340,7 +349,7 @@ def handle_or(rule) # Returns C::SetOf[Entitlements::Models::Person] from a recursive call. def handle_and(rule) ensure_type!("and", rule, Array) - return result unless rule.any? + return Set.new unless rule.any? first_rule = rule.shift ensure_type!("and", first_rule, Hash) diff --git a/lib/entitlements/data/groups/calculated/filters/base.rb b/lib/entitlements/data/groups/calculated/filters/base.rb index 0cf1e36..afc0677 100644 --- a/lib/entitlements/data/groups/calculated/filters/base.rb +++ b/lib/entitlements/data/groups/calculated/filters/base.rb @@ -31,16 +31,18 @@ def filtered?(_member) # config - Configuration data (Hash, optional) Contract C::KeywordArgs[ filter: C::Or[:none, C::ArrayOf[String]], - config: C::Maybe[Hash] + config: C::Maybe[Hash], + options: C::Optional[C::HashOf[Symbol => C::Any]] ] => C::Any - def initialize(filter:, config: {}) + def initialize(filter:, config: {}, options: {}) @filter = filter @config = config + @options = options end private - attr_reader :config, :filter + attr_reader :config, :filter, :options # Helper method: Determine if the person is listed in an array of filter conditions. # Filter conditions that have no `/` are interpreted to be usernames, whereas filter @@ -79,6 +81,7 @@ def member_of_named_group?(member, group_ref) Entitlements.cache[:member_of_named_group][group_ref] ||= begin member_set = Entitlements::Data::Groups::Calculated::Rules::Group.matches( value: group_ref, + options: options ) member_set.map { |person| person.uid.downcase } end diff --git a/lib/entitlements/data/groups/calculated/modifiers/expiration.rb b/lib/entitlements/data/groups/calculated/modifiers/expiration.rb index 4b57c7b..2436a4f 100644 --- a/lib/entitlements/data/groups/calculated/modifiers/expiration.rb +++ b/lib/entitlements/data/groups/calculated/modifiers/expiration.rb @@ -30,7 +30,7 @@ def modify(result) end # If the date is in the future, leave the entitlement unchanged. - return false if parse_date > Time.now.utc.to_date + return false if parse_date > Entitlements.evaluation_time.utc.to_date # Empty the group. Set metadata allowing no members. Return true to indicate modification. rs.metadata["no_members_ok"] = true diff --git a/lib/entitlements/data/groups/calculated/ruby.rb b/lib/entitlements/data/groups/calculated/ruby.rb index e39a60c..fe6e3f9 100644 --- a/lib/entitlements/data/groups/calculated/ruby.rb +++ b/lib/entitlements/data/groups/calculated/ruby.rb @@ -101,7 +101,17 @@ def initialize_metadata Contract C::None => Object def rule_obj @rule_obj ||= begin - require filename + if options[:skip_dynamic_groups] + raise Entitlements::Data::Groups::Calculated::DynamicGroupError, + "Dynamic group #{rou}/#{cn} uses arbitrary Ruby code" + end + + constants_before_load = Entitlements.rule_constant_paths + begin + load filename + ensure + Entitlements.record_rule_constants(Entitlements.rule_constant_paths - constants_before_load) + end clazz = Kernel.const_get(ruby_class_name) clazz.new end diff --git a/lib/entitlements/data/groups/calculated/rules/group.rb b/lib/entitlements/data/groups/calculated/rules/group.rb index c74f4d2..db8714f 100644 --- a/lib/entitlements/data/groups/calculated/rules/group.rb +++ b/lib/entitlements/data/groups/calculated/rules/group.rb @@ -14,6 +14,10 @@ class Group < Entitlements::Data::Groups::Calculated::Rules::Base "yaml" => "Entitlements::Data::Groups::Calculated::YAML" } + def self.reset! + @files_for_cache = {} + end + # Interface method: Get a Set[Entitlements::Models::Person] matching this condition. # # value - The value to match. @@ -66,6 +70,7 @@ def self.matches(value:, filename: nil, options: {}) clazz = Kernel.const_get(FILE_EXTENSIONS[ext]) Entitlements.cache[:file_objects][filebase_with_path] = clazz.new( filename: "#{filebase_with_path}.#{ext}", + options: options ) if Entitlements.cache[:file_objects][filebase_with_path].members == :calculating next if matching_files.size > 1 diff --git a/lib/entitlements/data/groups/calculated/text.rb b/lib/entitlements/data/groups/calculated/text.rb index b78828a..1513778 100644 --- a/lib/entitlements/data/groups/calculated/text.rb +++ b/lib/entitlements/data/groups/calculated/text.rb @@ -21,10 +21,12 @@ class Text < Entitlements::Data::Groups::Calculated::Base # Returns a Set[String] with DN's of the people in the group. Contract C::None => C::Or[:calculating, C::SetOf[Entitlements::Models::Person]] def members - @members ||= begin - Entitlements.logger.debug "Calculating members from #{filename}" - members_from_rules(rules) - end + return @members if @members + + Entitlements.logger.debug "Calculating members from #{filename}" + result = members_from_rules(rules) + @members = result unless result == :calculating + result end # Standard interface: Get the description of this group. @@ -182,7 +184,7 @@ def rules if parsed_data.key?("modifier_expiration") && affirmative.empty? exp_date = parsed_data.fetch("modifier_expiration").fetch("=").first.fetch(:key) date = Entitlements::Util::Util.parse_date(exp_date) - return {"always" => false} if date <= Time.now.utc.to_date + return {"always" => false} if date <= Entitlements.evaluation_time.utc.to_date end # There has to be at least one affirmative condition, not just all negative ones. diff --git a/lib/entitlements/data/groups/calculated/yaml.rb b/lib/entitlements/data/groups/calculated/yaml.rb index 0a45a78..73f4136 100644 --- a/lib/entitlements/data/groups/calculated/yaml.rb +++ b/lib/entitlements/data/groups/calculated/yaml.rb @@ -18,10 +18,12 @@ class YAML < Entitlements::Data::Groups::Calculated::Base # Returns a Set[String] with DN's of the people in the group. Contract C::None => C::Or[:calculating, C::SetOf[Entitlements::Models::Person]] def members - @members ||= begin - Entitlements.logger.debug "Calculating members from #{filename}" - members_from_rules(rules) - end + return @members if @members + + Entitlements.logger.debug "Calculating members from #{filename}" + result = members_from_rules(rules) + @members = result unless result == :calculating + result end # Standard interface: Get the description of this group. diff --git a/lib/entitlements/desired_groups.rb b/lib/entitlements/desired_groups.rb new file mode 100644 index 0000000..0986223 --- /dev/null +++ b/lib/entitlements/desired_groups.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "digest" +require "json" + +module Entitlements + class DesiredGroups + SCHEMA_VERSION = 1 + + def self.export(config_file:, source_sha:, people_source:, evaluated_at:, tree_root: nil, skip_dynamic_groups: false) + validate_inputs!( + config_file: config_file, + source_sha: source_sha, + people_source: people_source, + evaluated_at: evaluated_at + ) + + evaluation_time = parse_time(evaluated_at) + people_hash = Digest::SHA256.file(people_source).hexdigest + original_dir = ENV["DIR"] + ENV["DIR"] = File.expand_path(tree_root) if tree_root + + Entitlements.reset! + Entitlements.config_file = config_file + backend_identifiers = backend_identifiers(Entitlements.config) + use_people_snapshot!(people_source) + Entitlements.validate_configuration_file! + Entitlements.evaluation_time = evaluation_time + Entitlements.load_extras if Entitlements.config.key?("extras") + Entitlements.prefetch_people + Entitlements.cache[:desired_groups_export] = true + Entitlements.register_filters if Entitlements.config.key?("filters") + + memberships = export_memberships(backend_identifiers, skip_dynamic_groups: skip_dynamic_groups) + { + "schema_version" => SCHEMA_VERSION, + "source_sha" => source_sha.downcase, + "people_snapshot_sha256" => people_hash, + "evaluated_at" => evaluation_time.utc.iso8601, + "memberships" => memberships + } + ensure + Entitlements.reset! + if tree_root + original_dir ? ENV["DIR"] = original_dir : ENV.delete("DIR") + end + end + + def self.export_json(**args) + JSON.pretty_generate(export(**args)) << "\n" + end + + def self.validate_inputs!(config_file:, source_sha:, people_source:, evaluated_at:) + raise ArgumentError, "config_file must be a readable file" unless File.file?(config_file) && File.readable?(config_file) + raise ArgumentError, "people_source must be a readable file" unless File.file?(people_source) && File.readable?(people_source) + raise ArgumentError, "source_sha must be a commit SHA" unless source_sha.is_a?(String) && source_sha.match?(/\A[0-9a-f]{7,64}\z/i) + + parse_time(evaluated_at) + end + private_class_method :validate_inputs! + + def self.parse_time(value) + parsed = value.is_a?(Time) ? value : Time.iso8601(value.to_s) + raise ArgumentError, "evaluated_at must include a timezone" if !value.is_a?(Time) && value.to_s !~ /(Z|[+-]\d{2}:\d{2})\z/ + parsed + rescue ArgumentError + raise ArgumentError, "evaluated_at must be an ISO 8601 timestamp with a timezone" + end + private_class_method :parse_time + + def self.backend_identifiers(config) + config.fetch("groups").to_h do |group_name, group_config| + identifier = group_config["backend"] || group_config["type"] + unless identifier.is_a?(String) && !identifier.empty? + raise ArgumentError, "Group #{group_name.inspect} has no stable backend identifier" + end + [group_name, identifier] + end + end + private_class_method :backend_identifiers + + def self.use_people_snapshot!(people_source) + Entitlements.config["people"] = { + "smart_diff" => { + "type" => "yaml", + "config" => {"filename" => File.expand_path(people_source)} + } + } + Entitlements.config["people_data_source"] = "smart_diff" + end + private_class_method :use_people_snapshot! + + def self.export_memberships(backend_identifiers, skip_dynamic_groups:) + records = {} + exportable_groups.each do |group_name, group_config| + Entitlements::Data::Groups::Calculated.read_all( + group_name, + group_config, + skip_dynamic_groups: skip_dynamic_groups + ).each do |group_dn| + group = Entitlements::Data::Groups::Calculated.read(group_dn) + group.member_strings.each do |username| + record = { + "backend" => backend_identifiers.fetch(group_name), + "entitlement_group" => "#{group_name}/#{group.cn}", + "username" => username.downcase + } + records[record.values_at("backend", "entitlement_group", "username")] = record + end + end + end + records.values.sort_by { |record| record.values_at("backend", "entitlement_group", "username") } + end + private_class_method :export_memberships + + def self.exportable_groups + Entitlements.config.fetch("groups").select { |_name, config| config.key?("base") }.sort_by do |group_name, config| + backend = Entitlements.backends.fetch(config.fetch("type")) + [backend.fetch(:priority), config.key?("mirror") ? 1 : 0, group_name.length, group_name] + end + end + private_class_method :exportable_groups + end +end diff --git a/lib/entitlements/extras/ldap_group/rules/ldap_group.rb b/lib/entitlements/extras/ldap_group/rules/ldap_group.rb index 9dd72e9..e906364 100644 --- a/lib/entitlements/extras/ldap_group/rules/ldap_group.rb +++ b/lib/entitlements/extras/ldap_group/rules/ldap_group.rb @@ -22,6 +22,16 @@ class LDAPGroup < Entitlements::Data::Groups::Calculated::Rules::Base options: C::Optional[C::HashOf[Symbol => C::Any]] ] => C::SetOf[Entitlements::Models::Person] def self.matches(value:, filename: nil, options: {}) + if Entitlements.cache[:desired_groups_export] + return Set.new(Entitlements.cache[:people_obj].read.values.select do |person| + begin + Array(person["shellentitlements"]).map(&:downcase).include?(value.downcase) + rescue KeyError + false + end + end) + end + Entitlements.cache[:ldap_cache] ||= {} Entitlements.cache[:ldap_cache][value] ||= begin entry = ldap.read(value) diff --git a/lib/entitlements/smart_diff.rb b/lib/entitlements/smart_diff.rb new file mode 100644 index 0000000..99e0b30 --- /dev/null +++ b/lib/entitlements/smart_diff.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "cgi" +require "json" +require "set" +require_relative "smart_diff/scope" + +module Entitlements + class SmartDiff + SCHEMA_VERSION = 1 + DEFAULT_MARKDOWN_LIMIT = 200 + LIMITATION = "This compares desired entitlement-group membership. It does not predict provider-specific roles, " \ + "resource mappings, drift, invitations, JIT sessions, or API operations." + + def self.run(base_config:, head_config:, base_sha:, head_sha:, people_source:, evaluated_at:, base_tree: nil, head_tree: nil, markdown_limit: DEFAULT_MARKDOWN_LIMIT) + affected_groups = if base_tree && head_tree + Entitlements::SmartDiff::Scope.affected_groups( + base_config: base_config, + head_config: head_config, + base_tree: base_tree, + head_tree: head_tree, + evaluated_at: evaluated_at + ) + end + common = {people_source: people_source, evaluated_at: evaluated_at} + base = Entitlements::DesiredGroups.export( + config_file: base_config, + source_sha: base_sha, + tree_root: base_tree, + skip_dynamic_groups: true, + **common + ) + head = Entitlements::DesiredGroups.export( + config_file: head_config, + source_sha: head_sha, + tree_root: head_tree, + skip_dynamic_groups: true, + **common + ) + compare(base: base, head: head, markdown_limit: markdown_limit, affected_groups: affected_groups) + end + + def self.compare(base:, head:, markdown_limit: DEFAULT_MARKDOWN_LIMIT, affected_groups: nil) + validate_snapshot!(base, "base") + validate_snapshot!(head, "head") + raise ArgumentError, "Base and head used different people snapshots" unless base["people_snapshot_sha256"] == head["people_snapshot_sha256"] + raise ArgumentError, "Base and head used different evaluation timestamps" unless base["evaluated_at"] == head["evaluated_at"] + raise ArgumentError, "markdown_limit must be a positive integer" unless markdown_limit.is_a?(Integer) && markdown_limit.positive? + + if affected_groups + base = scoped_snapshot(base, affected_groups) + head = scoped_snapshot(head, affected_groups) + end + base_memberships = indexed_memberships(base) + head_memberships = indexed_memberships(head) + gains = (head_memberships.keys - base_memberships.keys).sort.map { |identity| head_memberships.fetch(identity) } + losses = (base_memberships.keys - head_memberships.keys).sort.map { |identity| base_memberships.fetch(identity) } + + result = { + "schema_version" => SCHEMA_VERSION, + "base" => snapshot_metadata(base), + "head" => snapshot_metadata(head), + "counts" => {"gains" => gains.length, "losses" => losses.length}, + "gains" => gains, + "losses" => losses + } + result["scope"] = {"affected_groups" => affected_groups} if affected_groups + [result, markdown(result, limit: markdown_limit)] + end + + def self.json(result) + JSON.pretty_generate(result) << "\n" + end + + def self.markdown(result, limit: DEFAULT_MARKDOWN_LIMIT) + lines = [ + "## Proposed entitlement membership changes", + "", + "**#{membership_count(result.fetch('counts').fetch('gains'))} added; " \ + "#{membership_count(result.fetch('counts').fetch('losses'))} removed.**", + "", + "Base: `#{result.fetch('base').fetch('source_sha')}` ", + "Head: `#{result.fetch('head').fetch('source_sha')}`", + "" + ] + if result["scope"] + lines.concat(["Affected entitlement groups: #{result.fetch('scope').fetch('affected_groups').length}", ""]) + end + + changes_by_backend = Hash.new { |hash, backend| hash[backend] = [] } + [["Added", "gains"], ["Removed", "losses"]].each do |change, key| + result.fetch(key).each do |record| + changes_by_backend[record.fetch("backend")] << [change, record] + end + end + + if changes_by_backend.empty? + lines.concat(["No membership changes.", ""]) + end + + remaining = limit + changes_by_backend.sort.each do |backend, changes| + added_count = changes.count { |change, _record| change == "Added" } + removed_count = changes.length - added_count + lines.concat([ + "
", + "#{escape_html(backend)} - #{membership_count(added_count)} added; " \ + "#{membership_count(removed_count)} removed", + "" + ]) + + visible = changes.first(remaining) + if visible.any? + lines.concat(["| Change | User | Entitlement group |", "|---|---|---|"]) + visible.each do |change, record| + lines << "| #{change} | #{escape_table(record.fetch('username'))} | " \ + "#{escape_table(record.fetch('entitlement_group'))} |" + end + lines << "" + end + + remaining -= visible.length + omitted = changes.length - visible.length + lines.concat(["_#{omitted} additional memberships omitted; see the JSON artifact._", ""]) if omitted.positive? + lines.concat(["
", ""]) + end + + lines.concat(["> #{LIMITATION}", ""]) + lines.join("\n") + end + + def self.validate_snapshot!(snapshot, label) + raise ArgumentError, "#{label} snapshot must be a hash" unless snapshot.is_a?(Hash) + raise ArgumentError, "#{label} snapshot has an unsupported schema version" unless snapshot["schema_version"] == Entitlements::DesiredGroups::SCHEMA_VERSION + %w[source_sha people_snapshot_sha256 evaluated_at memberships].each do |key| + raise ArgumentError, "#{label} snapshot is missing #{key}" unless snapshot.key?(key) + end + unless snapshot.fetch("source_sha").is_a?(String) && snapshot.fetch("source_sha").match?(/\A[0-9a-f]{7,64}\z/i) + raise ArgumentError, "#{label} snapshot has an invalid source_sha" + end + raise ArgumentError, "#{label} memberships must be an array" unless snapshot["memberships"].is_a?(Array) + end + private_class_method :validate_snapshot! + + def self.indexed_memberships(snapshot) + snapshot.fetch("memberships").to_h do |record| + unless record.is_a?(Hash) && %w[backend entitlement_group username].all? { |key| record[key].is_a?(String) } + raise ArgumentError, "Invalid membership record: #{record.inspect}" + end + identity = record.values_at("backend", "entitlement_group", "username") + [identity, record] + end + end + private_class_method :indexed_memberships + + def self.snapshot_metadata(snapshot) + snapshot.slice("source_sha", "people_snapshot_sha256", "evaluated_at") + end + private_class_method :snapshot_metadata + + def self.scoped_snapshot(snapshot, affected_groups) + included = affected_groups.to_set + snapshot.merge( + "memberships" => snapshot.fetch("memberships").select do |record| + included.include?(record.fetch("entitlement_group")) + end + ) + end + private_class_method :scoped_snapshot + + def self.escape_html(value) + CGI.escapeHTML(value.to_s.gsub(/[\r\n]+/, " ")) + end + private_class_method :escape_html + + def self.escape_table(value) + escape_html(value).gsub("|") { "|" } + end + private_class_method :escape_table + + def self.membership_count(count) + "#{count} #{count == 1 ? 'membership' : 'memberships'}" + end + private_class_method :membership_count + end +end diff --git a/lib/entitlements/smart_diff/cli.rb b/lib/entitlements/smart_diff/cli.rb new file mode 100644 index 0000000..c97ad00 --- /dev/null +++ b/lib/entitlements/smart_diff/cli.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "optparse" + +module Entitlements + class SmartDiff + class Cli + # :nocov: + DEFAULT_CONFIG = "config/entitlements.yaml" + + def self.run(argv = ARGV) + options = parse(argv) + result, markdown = Entitlements::SmartDiff.run( + base_config: config_path(options.fetch(:base_tree), options[:base_config]), + head_config: config_path(options.fetch(:head_tree), options[:head_config]), + base_sha: options.fetch(:base_sha), + head_sha: options.fetch(:head_sha), + people_source: options.fetch(:people_snapshot), + evaluated_at: options.fetch(:evaluated_at), + base_tree: options.fetch(:base_tree), + head_tree: options.fetch(:head_tree), + markdown_limit: options.fetch(:markdown_limit) + ) + File.write(options.fetch(:json), Entitlements::SmartDiff.json(result)) + File.write(options.fetch(:markdown), markdown) + 0 + rescue KeyError, OptionParser::ParseError, ArgumentError, SystemCallError => e + warn "entitlements-smart-diff: #{e.message}" + 1 + end + + def self.parse(argv) + options = {markdown_limit: Entitlements::SmartDiff::DEFAULT_MARKDOWN_LIMIT} + parser = OptionParser.new do |opts| + opts.banner = "Usage: entitlements-smart-diff [options]" + opts.on("--base-tree PATH") { |value| options[:base_tree] = value } + opts.on("--head-tree PATH") { |value| options[:head_tree] = value } + opts.on("--base-config PATH") { |value| options[:base_config] = value } + opts.on("--head-config PATH") { |value| options[:head_config] = value } + opts.on("--base-sha SHA") { |value| options[:base_sha] = value } + opts.on("--head-sha SHA") { |value| options[:head_sha] = value } + opts.on("--people-snapshot PATH") { |value| options[:people_snapshot] = value } + opts.on("--evaluated-at TIMESTAMP") { |value| options[:evaluated_at] = value } + opts.on("--json PATH") { |value| options[:json] = value } + opts.on("--markdown PATH") { |value| options[:markdown] = value } + opts.on("--markdown-limit COUNT", Integer) { |value| options[:markdown_limit] = value } + end + parser.parse!(argv) + required = %i[base_tree head_tree base_sha head_sha people_snapshot evaluated_at json markdown] + missing = required.reject { |key| options.key?(key) } + raise OptionParser::MissingArgument, missing.join(", ") if missing.any? + options + end + private_class_method :parse + + def self.config_path(tree, configured_path) + path = configured_path || DEFAULT_CONFIG + return path if path.start_with?("/") + File.expand_path(path, tree) + end + private_class_method :config_path + # :nocov: + end + end +end diff --git a/lib/entitlements/smart_diff/scope.rb b/lib/entitlements/smart_diff/scope.rb new file mode 100644 index 0000000..77d2820 --- /dev/null +++ b/lib/entitlements/smart_diff/scope.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "digest" +require "set" + +module Entitlements + class SmartDiff + class Scope + GROUP_FILE_EXTENSIONS = %w[.rb .txt .yaml].freeze + + def self.affected_groups(base_config:, head_config:, base_tree:, head_tree:, evaluated_at:) + base = catalog(config_file: base_config, tree: base_tree, evaluated_at: evaluated_at) + head = catalog(config_file: head_config, tree: head_tree, evaluated_at: evaluated_at) + all_groups = base.fetch(:groups) | head.fetch(:groups) + changed_groups = changed_groups(base, head) + reverse_dependencies = reverse_dependencies(base, head, all_groups) + dynamic_groups = dependency_closure( + base.fetch(:dynamic_groups) | head.fetch(:dynamic_groups), + reverse_dependencies + ) + + (dependency_closure(changed_groups, reverse_dependencies) - dynamic_groups).to_a.sort + end + + def self.catalog(config_file:, tree:, evaluated_at:) + original_dir = ENV["DIR"] + ENV["DIR"] = File.expand_path(tree) + Entitlements.reset! + Entitlements.config_file = config_file + groups_config = Entitlements.config.fetch("groups") + Entitlements.evaluation_time = Time.iso8601(evaluated_at.to_s) + Entitlements.load_extras if Entitlements.config.key?("extras") + Entitlements.register_filters if Entitlements.config.key?("filters") + groups = Set.new + files = {} + path_groups = Hash.new { |hash, key| hash[key] = Set.new } + references = Hash.new { |hash, key| hash[key] = Set.new } + dynamic_groups = Set.new + mirrors = [] + + groups_config.each do |group_name, group_config| + if group_config["mirror"] + mirrors << [group_name, group_config.fetch("mirror")] + next + end + + begin + group_path = Entitlements::Util::Util.path_for_group(group_name) + rescue Errno::ENOENT + next + end + Dir.children(group_path).sort.each do |basename| + filename = File.join(group_path, basename) + next unless File.file?(filename) + next unless GROUP_FILE_EXTENSIONS.include?(File.extname(filename)) + + group_id = "#{group_name}/#{File.basename(filename, File.extname(filename))}" + relative_path = relative_path(filename, tree) + groups.add(group_id) + path_groups[relative_path].add(group_id) + files[relative_path] = Digest::SHA256.file(filename).hexdigest + if File.extname(filename) == ".rb" + dynamic_groups.add(group_id) + next + end + + ruleset = Entitlements::Data::Groups::Calculated.ruleset( + filename: filename, + config: group_config + ) + collect_group_references(ruleset.send(:rules), references[group_id]) + collect_filter_references(ruleset, filename, references[group_id]) + end + end + + mirrors.each do |mirror_name, source_name| + groups.select { |group_id| group_id.start_with?("#{source_name}/") }.each do |source_group| + mirror_group = "#{mirror_name}/#{source_group.delete_prefix("#{source_name}/")}" + groups.add(mirror_group) + references[mirror_group].add(source_group) + dynamic_groups.add(mirror_group) if dynamic_groups.include?(source_group) + end + end + + { + config_digest: Digest::SHA256.file(config_file).hexdigest, + files: files, + groups: groups, + path_groups: path_groups, + references: references, + dynamic_groups: dynamic_groups + } + ensure + Entitlements.reset! + original_dir ? ENV["DIR"] = original_dir : ENV.delete("DIR") + end + private_class_method :catalog + + def self.collect_group_references(value, result) + case value + when Array + value.each { |item| collect_group_references(item, result) } + when Hash + value.each do |key, item| + if %w[group entitlements_group].include?(key) && item.is_a?(String) + result.add(item) + else + collect_group_references(item, result) + end + end + end + end + private_class_method :collect_group_references + + def self.collect_filter_references(ruleset, filename, result) + ruleset.filters.each do |filter_name, filter_value| + next if filter_value == :all + + filter = Entitlements::Data::Groups::Calculated.filters_index.fetch(filter_name) + next unless filter.fetch(:class) <= Entitlements::Data::Groups::Calculated::Filters::MemberOfGroup + next unless filter_applies?(filename, filter.fetch(:config)) + + result.add(filter.fetch(:config).fetch("group")) + end + end + private_class_method :collect_filter_references + + def self.filter_applies?(filename, config) + included = config.fetch("included_paths", []) + excluded = config.fetch("excluded_paths", []) + return true if included.empty? && excluded.empty? + + excluded_match = excluded.any? { |path| filename.include?(path) } + included_match = included.any? { |path| filename.include?(path) } + (!excluded.empty? && !excluded_match) || (!included.empty? && included_match) + end + private_class_method :filter_applies? + + def self.changed_groups(base, head) + return base.fetch(:groups) | head.fetch(:groups) if base.fetch(:config_digest) != head.fetch(:config_digest) + + paths = base.fetch(:files).keys | head.fetch(:files).keys + paths.each_with_object(Set.new) do |path, result| + next if base.fetch(:files)[path] == head.fetch(:files)[path] + + result.merge(base.fetch(:path_groups)[path]) + result.merge(head.fetch(:path_groups)[path]) + end + end + private_class_method :changed_groups + + def self.dependency_closure(initial_groups, reverse_dependencies) + result = initial_groups.dup + pending = initial_groups.to_a + until pending.empty? + group = pending.shift + reverse_dependencies.fetch(group, Set.new).each do |dependent| + next if result.include?(dependent) + + result.add(dependent) + pending << dependent + end + end + result + end + private_class_method :dependency_closure + + def self.reverse_dependencies(base, head, all_groups) + result = Hash.new { |hash, key| hash[key] = Set.new } + references = merge_references(base.fetch(:references), head.fetch(:references)) + references.each do |dependent, group_references| + group_references.each do |reference| + matching_groups(reference, all_groups).each { |dependency| result[dependency].add(dependent) } + end + end + result + end + private_class_method :reverse_dependencies + + def self.merge_references(base, head) + (base.keys | head.keys).to_h do |group| + [group, base.fetch(group, Set.new) | head.fetch(group, Set.new)] + end + end + private_class_method :merge_references + + def self.matching_groups(reference, all_groups) + return [reference] unless reference.include?("*") + + pattern = Regexp.new("\\A#{Regexp.escape(reference).gsub('\\*', '.*')}\\z") + all_groups.select { |group| pattern.match?(group) } + end + private_class_method :matching_groups + + def self.relative_path(filename, tree) + filename.delete_prefix("#{File.expand_path(tree)}/") + end + private_class_method :relative_path + end + end +end diff --git a/lib/version.rb b/lib/version.rb index 412c637..06191dc 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -2,6 +2,6 @@ module Entitlements module Version - VERSION = "1.2.1" + VERSION = "1.2.2" end end diff --git a/spec/unit/entitlements/data/groups/calculated/base_spec.rb b/spec/unit/entitlements/data/groups/calculated/base_spec.rb index d1cd846..ad9f82e 100644 --- a/spec/unit/entitlements/data/groups/calculated/base_spec.rb +++ b/spec/unit/entitlements/data/groups/calculated/base_spec.rb @@ -158,6 +158,43 @@ end end + context "with an empty 'and' rule set" do + let(:file) { fixture("ldap-config/logic_tests/simple_and.yaml") } + let(:obj) { Entitlements::Data::Groups::Calculated::YAML.new(filename: file, config: config) } + + it "returns an empty set" do + expect(obj.send(:handle_and, [])).to eq(Set.new) + end + end + + context "when a dynamic dependency interrupts calculation" do + let(:file) { fixture("ldap-config/logic_tests/simple_and.yaml") } + let(:options) { {skip_dynamic_groups: true} } + let(:obj) { Entitlements::Data::Groups::Calculated::YAML.new(filename: file, config: config, options: options) } + + it "removes the partially calculated file object for smart diff" do + Entitlements.cache[:file_objects] = {file => obj} + allow(obj).to receive(:_members_from_rules) + .and_raise(Entitlements::Data::Groups::Calculated::DynamicGroupError, "dynamic") + + expect { obj.send(:members_from_rules, {"always" => false}) } + .to raise_error(Entitlements::Data::Groups::Calculated::DynamicGroupError, "dynamic") + expect(Entitlements.cache[:file_objects]).not_to have_key(file) + end + + it "preserves the normal error state outside smart diff" do + obj = Entitlements::Data::Groups::Calculated::YAML.new(filename: file, config: config) + Entitlements.cache[:file_objects] = {file => obj} + allow(obj).to receive(:_members_from_rules) + .and_raise(Entitlements::Data::Groups::Calculated::DynamicGroupError, "dynamic") + + expect { obj.send(:members_from_rules, {"always" => false}) } + .to raise_error(Entitlements::Data::Groups::Calculated::DynamicGroupError, "dynamic") + expect(Entitlements.cache[:file_objects]).to have_key(file) + expect(Entitlements.cache[:calculated][obj.send(:rou)][obj.send(:cn)]).to eq(:calculating) + end + end + context "with a simple 'or' rule set" do let(:file) { fixture("ldap-config/logic_tests/simple_or.yaml") } let(:obj) { Entitlements::Data::Groups::Calculated::YAML.new(filename: file, config: config) } diff --git a/spec/unit/entitlements/data/groups/calculated/text_spec.rb b/spec/unit/entitlements/data/groups/calculated/text_spec.rb index d1f5913..3a493dd 100644 --- a/spec/unit/entitlements/data/groups/calculated/text_spec.rb +++ b/spec/unit/entitlements/data/groups/calculated/text_spec.rb @@ -32,6 +32,14 @@ answer_set = Set.new(answer_array) expect(result_set).to eq(answer_set) end + + it "does not cache the calculating sentinel" do + members = Set.new([people_obj.read["blackmanx"]]) + allow(subject).to receive(:members_from_rules).and_return(:calculating, members) + + expect(subject.members).to eq(:calculating) + expect(subject.members).to eq(members) + end end describe "#description" do diff --git a/spec/unit/entitlements/data/groups/calculated/yaml_spec.rb b/spec/unit/entitlements/data/groups/calculated/yaml_spec.rb index 8a176ae..8916d41 100644 --- a/spec/unit/entitlements/data/groups/calculated/yaml_spec.rb +++ b/spec/unit/entitlements/data/groups/calculated/yaml_spec.rb @@ -21,6 +21,16 @@ expect(result.size).to eq(2) expect(result.map { |i| i.uid }.sort).to eq(answer) end + + it "does not cache the calculating sentinel" do + filename = fixture("ldap-config/filters/no-filters.yaml") + subject = described_class.new(filename: filename) + members = Set.new([people_obj.read["blackmanx"]]) + allow(subject).to receive(:members_from_rules).and_return(:calculating, members) + + expect(subject.members).to eq(:calculating) + expect(subject.members).to eq(members) + end end describe "#description" do diff --git a/spec/unit/entitlements/desired_groups_spec.rb b/spec/unit/entitlements/desired_groups_spec.rb new file mode 100644 index 0000000..1944817 --- /dev/null +++ b/spec/unit/entitlements/desired_groups_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "fileutils" +require "tmpdir" + +describe Entitlements::DesiredGroups do + let(:config_file) { fixture("smart-diff/config.yaml") } + let(:people_source) { fixture("smart-diff/people.yaml") } + let(:source_sha) { "a" * 40 } + let(:evaluated_at) { "2026-09-02T19:58:54Z" } + let(:args) do + { + config_file: config_file, + source_sha: source_sha, + people_source: people_source, + evaluated_at: evaluated_at + } + end + + before do + allow(Entitlements).to receive(:cache).and_call_original + end + + it "exports deterministic, normalized desired memberships without provider access" do + expect(Entitlements::Backend::Dummy::Controller).not_to receive(:new) + + first = described_class.export(**args) + second = described_class.export(**args) + + expect(first).to eq(second) + expect(first["schema_version"]).to eq(1) + expect(first["source_sha"]).to eq(source_sha) + expect(first["people_snapshot_sha256"]).to eq(Digest::SHA256.file(people_source).hexdigest) + expect(first["evaluated_at"]).to eq(evaluated_at) + expect(first).not_to have_key("complete") + expect(first).not_to have_key("warnings") + expect(first["memberships"]).to eq(first["memberships"].sort_by(&:values)) + expect(first["memberships"].length).to eq(10) + expect(first["memberships"]).to include( + {"backend" => "dummy", "entitlement_group" => "teams/direct", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams/nested", "username" => "bob"}, + {"backend" => "dummy", "entitlement_group" => "teams/ruby-group", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams_mirror/direct", "username" => "alice"} + ) + expect(first["memberships"]).not_to include( + {"backend" => "dummy", "entitlement_group" => "teams/expiring", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams/filtered", "username" => "contractor"} + ) + end + + it "serializes byte-for-byte deterministic JSON" do + expect(described_class.export_json(**args)).to eq(described_class.export_json(**args)) + expect(described_class.export_json(**args)).to end_with("\n") + end + + it "accepts a Time evaluation value" do + result = described_class.export(**args.merge(evaluated_at: Time.new(2026, 9, 1, 12, 0, 0, "-04:00"))) + expect(result["evaluated_at"]).to eq("2026-09-01T16:00:00Z") + expect(result["memberships"]).to include( + {"backend" => "dummy", "entitlement_group" => "teams/expiring", "username" => "alice"} + ) + end + + it "sets and restores the source tree environment for configuration ERB" do + original = ENV["DIR"] + result = described_class.export(**args.merge(tree_root: fixture("smart-diff"))) + expect(result["memberships"]).not_to be_empty + expect(ENV["DIR"]).to eq(original) + end + + it "rejects invalid inputs" do + expect { described_class.export(**args.merge(config_file: "missing")) }.to raise_error(ArgumentError, /config_file/) + expect { described_class.export(**args.merge(people_source: "missing")) }.to raise_error(ArgumentError, /people_source/) + expect { described_class.export(**args.merge(source_sha: "nope")) }.to raise_error(ArgumentError, /source_sha/) + expect { described_class.export(**args.merge(evaluated_at: "2026-09-02")) }.to raise_error(ArgumentError, /evaluated_at/) + end + + it "rejects groups without stable backend identifiers" do + allow(Entitlements).to receive(:config).and_return("groups" => {"teams" => {}}) + expect { described_class.export(**args) }.to raise_error(ArgumentError, /stable backend identifier/) + end + + it "can skip dynamic groups and their dependents without reporting them" do + dynamic_args = args.merge(config_file: fixture("dynamic-groups/config.yaml")) + expect { described_class.export(**dynamic_args) } + .to raise_error(KeyError, /DYNAMIC_GROUP_TOKEN/) + + result = described_class.export(**dynamic_args.merge(skip_dynamic_groups: true)) + expect(result).not_to have_key("complete") + expect(result).not_to have_key("warnings") + expect(result["memberships"]).to eq([ + {"backend" => "dummy", "entitlement_group" => "teams/static", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams_mirror/static", "username" => "alice"} + ]) + end + + it "preserves rule constants that were not loaded from entitlement files" do + shared_rule = Class.new + Entitlements::Rule.const_set(:SharedRule, shared_rule) + + described_class.export(**args) + + expect(Entitlements::Rule.const_get(:SharedRule, false)).to equal(shared_rule) + ensure + Entitlements::Rule.send(:remove_const, :SharedRule) if Entitlements::Rule.const_defined?(:SharedRule, false) + end + + it "does not leak Ruby rule class state between trees" do + Dir.mktmpdir do |directory| + FileUtils.cp_r(Dir.glob(File.join(fixture("smart-diff"), "*")), directory) + ruby_file = File.join(directory, "groups", "teams", "ruby-group.rb") + File.write(ruby_file, <<~RUBY) + module Entitlements + class Rule + class Teams + class RubyGroup < Entitlements::Rule::Base + filter "contractors" => :all + def members + Set.new([Entitlements.cache[:people_obj].read("contractor")]) + end + end + end + end + end + RUBY + base = described_class.export(**args.merge(config_file: File.join(directory, "config.yaml"))) + expect(base["memberships"]).to include( + {"backend" => "dummy", "entitlement_group" => "teams/ruby-group", "username" => "contractor"} + ) + + File.write(ruby_file, <<~RUBY) + module Entitlements + class Rule + class Teams + class RubyGroup < Entitlements::Rule::Base + def members + Set.new([Entitlements.cache[:people_obj].read("contractor")]) + end + end + end + end + end + RUBY + head = described_class.export(**args.merge(config_file: File.join(directory, "config.yaml"))) + expect(head["memberships"]).not_to include( + {"backend" => "dummy", "entitlement_group" => "teams/ruby-group", "username" => "contractor"} + ) + end + end +end diff --git a/spec/unit/entitlements/extras/ldap_group/rules/ldap_group_spec.rb b/spec/unit/entitlements/extras/ldap_group/rules/ldap_group_spec.rb index 9d13e4d..97f7f4b 100644 --- a/spec/unit/entitlements/extras/ldap_group/rules/ldap_group_spec.rb +++ b/spec/unit/entitlements/extras/ldap_group/rules/ldap_group_spec.rb @@ -21,6 +21,22 @@ let(:members) { %w[NEBELUNg russianblue oJosazuLEs].map { |uid| people_obj.read(uid) } } describe "#matches" do + context "during a desired-groups export" do + let(:people) do + { + "member" => Entitlements::Models::Person.new(uid: "member", attributes: {"shellentitlements" => [dn]}), + "other" => Entitlements::Models::Person.new(uid: "other", attributes: {}) + } + end + let(:people_obj) { Entitlements::Data::People::YAML.new(filename: fixture("people.yaml"), people: people) } + let(:cache) { { people_obj: people_obj, desired_groups_export: true } } + + it "uses frozen person attributes instead of LDAP" do + expect(described_class).not_to receive(:ldap) + expect(obj.members.map(&:uid)).to eq(["member"]) + end + end + context "for a group that was cached" do let(:ldap_cache) { { dn => group } } diff --git a/spec/unit/entitlements/smart_diff/scope_spec.rb b/spec/unit/entitlements/smart_diff/scope_spec.rb new file mode 100644 index 0000000..7aca5f9 --- /dev/null +++ b/spec/unit/entitlements/smart_diff/scope_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +describe Entitlements::SmartDiff::Scope do + def copy_fixture(destination) + FileUtils.cp_r(Dir.glob(File.join(fixture("smart-diff"), "*")), destination) + end + + it "returns no groups when entitlement files are unchanged" do + expect(described_class.affected_groups( + base_config: fixture("smart-diff/config.yaml"), + head_config: fixture("smart-diff/config.yaml"), + base_tree: fixture("smart-diff"), + head_tree: fixture("smart-diff"), + evaluated_at: "2026-09-02T19:58:54Z" + )).to eq([]) + end + + it "includes changed groups, static dependents, and mirrors" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |head| + copy_fixture(base) + copy_fixture(head) + File.open(File.join(head, "groups", "internal", "engineers.txt"), "a") do |file| + file.puts "username = contractor" + end + [base, head].each do |tree| + File.write( + File.join(tree, "groups", "teams", "wildcard.txt"), + "group = internal/*\n" + ) + File.write( + File.join(tree, "groups", "teams", "flow.yaml"), + "---\nrules: {group: internal/engineers}\n" + ) + end + + expect(described_class.affected_groups( + base_config: File.join(base, "config.yaml"), + head_config: File.join(head, "config.yaml"), + base_tree: base, + head_tree: head, + evaluated_at: "2026-09-02T19:58:54Z" + )).to eq([ + "internal/engineers", + "teams/flow", + "teams/nested", + "teams/wildcard", + "teams_mirror/flow", + "teams_mirror/nested", + "teams_mirror/wildcard" + ]) + end + end + end + + it "includes groups whose configured filters depend on a changed group" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |head| + copy_fixture(base) + copy_fixture(head) + File.open(File.join(head, "groups", "internal", "contractors.txt"), "a") do |file| + file.puts "username = Alice" + end + + affected = described_class.affected_groups( + base_config: File.join(base, "config.yaml"), + head_config: File.join(head, "config.yaml"), + base_tree: base, + head_tree: head, + evaluated_at: "2026-09-02T19:58:54Z" + ) + expect(affected).to include("internal/contractors", "teams/filtered", "teams_mirror/filtered") + end + end + end + + it "honors filter path inclusions and exclusions" do + expect(described_class.send(:filter_applies?, "/groups/included/team.txt", { + "included_paths" => ["included"] + })).to be true + expect(described_class.send(:filter_applies?, "/groups/other/team.txt", { + "included_paths" => ["included"] + })).to be false + expect(described_class.send(:filter_applies?, "/groups/excluded/team.txt", { + "excluded_paths" => ["excluded"] + })).to be false + expect(described_class.send(:filter_applies?, "/groups/other/team.txt", { + "excluded_paths" => ["excluded"] + })).to be true + end + + it "omits dynamic groups and all groups that depend on them" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |head| + [base, head].each do |tree| + FileUtils.cp_r(Dir.glob(File.join(fixture("dynamic-groups"), "*")), tree) + end + File.open(File.join(head, "groups", "teams", "dynamic.rb"), "a") do |file| + file.puts "# changed" + end + + expect(described_class.affected_groups( + base_config: File.join(base, "config.yaml"), + head_config: File.join(head, "config.yaml"), + base_tree: base, + head_tree: head, + evaluated_at: "2026-09-02T19:58:54Z" + )).to eq([]) + end + end + end +end diff --git a/spec/unit/entitlements/smart_diff_spec.rb b/spec/unit/entitlements/smart_diff_spec.rb new file mode 100644 index 0000000..28d1872 --- /dev/null +++ b/spec/unit/entitlements/smart_diff_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe Entitlements::SmartDiff do + let(:base) do + { + "schema_version" => 1, + "source_sha" => "a" * 40, + "people_snapshot_sha256" => "people", + "evaluated_at" => "2026-09-02T19:58:54Z", + "memberships" => [ + {"backend" => "dummy", "entitlement_group" => "teams/old", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams/same", "username" => "bob"} + ] + } + end + let(:head) do + { + "schema_version" => 1, + "source_sha" => "b" * 40, + "people_snapshot_sha256" => "people", + "evaluated_at" => "2026-09-02T19:58:54Z", + "memberships" => [ + {"backend" => "dummy", "entitlement_group" => "teams/new\\|group", "username" => ""}, + {"backend" => "dummy", "entitlement_group" => "teams/same", "username" => "bob"} + ] + } + end + + it "calculates gains and losses and renders safe bounded Markdown" do + result, markdown = described_class.compare(base: base, head: head) + + expect(result["counts"]).to eq("gains" => 1, "losses" => 1) + expect(result["gains"]).to eq([head["memberships"].first]) + expect(result["losses"]).to eq([base["memberships"].first]) + expect(result["base"]).not_to have_key("memberships") + expect(markdown).to include("1 membership added; 1 membership removed") + expect(markdown).to include("<alice>") + expect(markdown).to include("teams/new\\|group") + expect(markdown).to include("
") + expect(markdown).to include("dummy") + expect(markdown).to include("| Change | User | Entitlement group |") + expect(markdown).not_to include("| User | Backend |") + expect(markdown).to include(described_class::LIMITATION) + expect(described_class.json(result)).to end_with("\n") + end + + it "renders empty output and deterministic truncation" do + unchanged = base.merge("source_sha" => "c" * 40) + result, markdown = described_class.compare(base: base, head: unchanged, markdown_limit: 1) + expect(result["counts"]).to eq("gains" => 0, "losses" => 0) + expect(markdown).to include("0 memberships added; 0 memberships removed") + expect(markdown).to include("No membership changes.") + + large_head = head.merge("memberships" => head["memberships"] + [ + {"backend" => "dummy", "entitlement_group" => "teams/new2", "username" => "carol"} + ]) + _large_result, truncated = described_class.compare(base: base, head: large_head, markdown_limit: 1) + expect(truncated).to include("2 additional memberships omitted") + end + + it "renders one collapsible table per backend" do + multi_backend_head = head.merge("memberships" => head.fetch("memberships") + [ + {"backend" => "github", "entitlement_group" => "org/team", "username" => "carol"} + ]) + _result, markdown = described_class.compare(base: base, head: multi_backend_head) + expect(markdown.scan("
").length).to eq(2) + expect(markdown).to include("dummy") + expect(markdown).to include("github") + expect(markdown).to include("| Added | carol | org/team |") + end + + it "runs both exports with identical frozen inputs" do + common = { + config_file: fixture("smart-diff/config.yaml"), + people_source: fixture("smart-diff/people.yaml"), + evaluated_at: "2026-09-02T19:58:54Z" + } + result, _markdown = described_class.run( + base_config: common[:config_file], + head_config: common[:config_file], + base_sha: "a" * 40, + head_sha: "b" * 40, + people_source: common[:people_source], + evaluated_at: common[:evaluated_at], + base_tree: fixture("smart-diff"), + head_tree: fixture("smart-diff") + ) + expect(result["counts"]).to eq("gains" => 0, "losses" => 0) + expect(result).not_to have_key("complete") + expect(result).not_to have_key("warnings") + expect(result["scope"]).to eq("affected_groups" => []) + expect(result["base"]["people_snapshot_sha256"]).to eq(result["head"]["people_snapshot_sha256"]) + expect(result["base"]["evaluated_at"]).to eq(result["head"]["evaluated_at"]) + + unscoped, = described_class.run( + base_config: common[:config_file], + head_config: common[:config_file], + base_sha: "a" * 40, + head_sha: "b" * 40, + people_source: common[:people_source], + evaluated_at: common[:evaluated_at] + ) + expect(unscoped).not_to have_key("scope") + end + + it "rejects invalid or inconsistent snapshots" do + expect { described_class.compare(base: [], head: head) }.to raise_error(ArgumentError, /must be a hash/) + expect { described_class.compare(base: base.merge("schema_version" => 2), head: head) }.to raise_error(ArgumentError, /schema version/) + expect { described_class.compare(base: base.reject { |key| key == "source_sha" }, head: head) }.to raise_error(ArgumentError, /missing source_sha/) + expect { described_class.compare(base: base.merge("memberships" => {}), head: head) }.to raise_error(ArgumentError, /must be an array/) + expect { described_class.compare(base: base, head: head.merge("people_snapshot_sha256" => "other")) }.to raise_error(ArgumentError, /people snapshots/) + expect { described_class.compare(base: base, head: head.merge("evaluated_at" => "other")) }.to raise_error(ArgumentError, /evaluation timestamps/) + expect { described_class.compare(base: base, head: head, markdown_limit: 0) }.to raise_error(ArgumentError, /markdown_limit/) + expect { described_class.compare(base: base.merge("memberships" => ["bad"]), head: head) }.to raise_error(ArgumentError, /Invalid membership/) + expect { described_class.compare(base: base.merge("source_sha" => "`bad`"), head: head) }.to raise_error(ArgumentError, /source_sha/) + end +end diff --git a/spec/unit/fixtures/dynamic-groups/config.yaml b/spec/unit/fixtures/dynamic-groups/config.yaml new file mode 100644 index 0000000..d2254c0 --- /dev/null +++ b/spec/unit/fixtures/dynamic-groups/config.yaml @@ -0,0 +1,23 @@ +--- +configuration_path: ./groups +people: + original: + type: yaml + config: + filename: unavailable.yaml +people_data_source: original +filters: + dynamic_members: + class: Entitlements::Data::Groups::Calculated::Filters::MemberOfGroup + config: + group: teams/dynamic + included_paths: + - filtered.txt +groups: + teams: + type: dummy + base: ou=Teams,dc=example,dc=com + teams_mirror: + type: dummy + base: ou=TeamsMirror,dc=example,dc=com + mirror: teams diff --git a/spec/unit/fixtures/dynamic-groups/groups/teams/dependent.txt b/spec/unit/fixtures/dynamic-groups/groups/teams/dependent.txt new file mode 100644 index 0000000..7bae8b2 --- /dev/null +++ b/spec/unit/fixtures/dynamic-groups/groups/teams/dependent.txt @@ -0,0 +1,2 @@ +description = Depends on a dynamic group +group = teams/dynamic diff --git a/spec/unit/fixtures/dynamic-groups/groups/teams/dynamic.rb b/spec/unit/fixtures/dynamic-groups/groups/teams/dynamic.rb new file mode 100644 index 0000000..bd89f33 --- /dev/null +++ b/spec/unit/fixtures/dynamic-groups/groups/teams/dynamic.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Entitlements + class Rule + class Teams + class Dynamic < Entitlements::Rule::Base + def members + ENV.fetch("DYNAMIC_GROUP_TOKEN") + Octokit::Client + Entitlements::Service::GitHub + Set.new + end + end + end + end +end diff --git a/spec/unit/fixtures/dynamic-groups/groups/teams/filtered.txt b/spec/unit/fixtures/dynamic-groups/groups/teams/filtered.txt new file mode 100644 index 0000000..239ef45 --- /dev/null +++ b/spec/unit/fixtures/dynamic-groups/groups/teams/filtered.txt @@ -0,0 +1,3 @@ +description = Filtered by a dynamic group +filter_dynamic_members = none +username = Alice diff --git a/spec/unit/fixtures/dynamic-groups/groups/teams/static-ruby.rb b/spec/unit/fixtures/dynamic-groups/groups/teams/static-ruby.rb new file mode 100644 index 0000000..13a9498 --- /dev/null +++ b/spec/unit/fixtures/dynamic-groups/groups/teams/static-ruby.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Documentation may mention ENV["TOKEN"] or Net::HTTP without using either. +module Entitlements + class Rule + class Teams + class StaticRuby < Entitlements::Rule::Base + description "Does not call Octokit or Faraday" + + def members + Set.new([Entitlements.cache[:people_obj].read("Alice")]) + end + end + end + end +end diff --git a/spec/unit/fixtures/dynamic-groups/groups/teams/static.txt b/spec/unit/fixtures/dynamic-groups/groups/teams/static.txt new file mode 100644 index 0000000..176a791 --- /dev/null +++ b/spec/unit/fixtures/dynamic-groups/groups/teams/static.txt @@ -0,0 +1,2 @@ +description = Static group +username = Alice diff --git a/spec/unit/fixtures/smart-diff/config.yaml b/spec/unit/fixtures/smart-diff/config.yaml new file mode 100644 index 0000000..706124d --- /dev/null +++ b/spec/unit/fixtures/smart-diff/config.yaml @@ -0,0 +1,26 @@ +--- +configuration_path: ./groups +people: + original: + type: yaml + config: + filename: unavailable.yaml +people_data_source: original +filters: + contractors: + class: Entitlements::Data::Groups::Calculated::Filters::MemberOfGroup + config: + group: internal/contractors +groups: + internal: + type: dummy + missing: + type: dummy + dir: missing + teams: + type: dummy + base: ou=Teams,dc=example,dc=com + teams_mirror: + type: dummy + base: ou=TeamsMirror,dc=example,dc=com + mirror: teams diff --git a/spec/unit/fixtures/smart-diff/groups/internal/contractors.txt b/spec/unit/fixtures/smart-diff/groups/internal/contractors.txt new file mode 100644 index 0000000..ec2c89b --- /dev/null +++ b/spec/unit/fixtures/smart-diff/groups/internal/contractors.txt @@ -0,0 +1,2 @@ +description = Contractors +username = contractor diff --git a/spec/unit/fixtures/smart-diff/groups/internal/engineers.txt b/spec/unit/fixtures/smart-diff/groups/internal/engineers.txt new file mode 100644 index 0000000..371f98d --- /dev/null +++ b/spec/unit/fixtures/smart-diff/groups/internal/engineers.txt @@ -0,0 +1,3 @@ +description = Engineers +username = Alice +username = bob diff --git a/spec/unit/fixtures/smart-diff/groups/teams/direct.txt b/spec/unit/fixtures/smart-diff/groups/teams/direct.txt new file mode 100644 index 0000000..44524b5 --- /dev/null +++ b/spec/unit/fixtures/smart-diff/groups/teams/direct.txt @@ -0,0 +1,2 @@ +description = Direct membership +username = Alice diff --git a/spec/unit/fixtures/smart-diff/groups/teams/expiring.yaml b/spec/unit/fixtures/smart-diff/groups/teams/expiring.yaml new file mode 100644 index 0000000..c066e87 --- /dev/null +++ b/spec/unit/fixtures/smart-diff/groups/teams/expiring.yaml @@ -0,0 +1,7 @@ +--- +description: Expiring membership +rules: + or: + - username: Alice + expiration: "2026-09-02" + - username: bob diff --git a/spec/unit/fixtures/smart-diff/groups/teams/filtered.txt b/spec/unit/fixtures/smart-diff/groups/teams/filtered.txt new file mode 100644 index 0000000..8f1e77f --- /dev/null +++ b/spec/unit/fixtures/smart-diff/groups/teams/filtered.txt @@ -0,0 +1,3 @@ +description = Filtered membership +filter_contractors = none +username = contractor diff --git a/spec/unit/fixtures/smart-diff/groups/teams/nested.yaml b/spec/unit/fixtures/smart-diff/groups/teams/nested.yaml new file mode 100644 index 0000000..0441aff --- /dev/null +++ b/spec/unit/fixtures/smart-diff/groups/teams/nested.yaml @@ -0,0 +1,4 @@ +--- +description: Nested membership +rules: + group: internal/engineers diff --git a/spec/unit/fixtures/smart-diff/groups/teams/ruby-group.rb b/spec/unit/fixtures/smart-diff/groups/teams/ruby-group.rb new file mode 100644 index 0000000..3c6c25d --- /dev/null +++ b/spec/unit/fixtures/smart-diff/groups/teams/ruby-group.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Entitlements + class Rule + class Teams + class RubyGroup < Entitlements::Rule::Base + description "Ruby membership" + + def members + Set.new([Entitlements.cache[:people_obj].read("ALICE")]) + end + end + end + end +end diff --git a/spec/unit/fixtures/smart-diff/people.yaml b/spec/unit/fixtures/smart-diff/people.yaml new file mode 100644 index 0000000..a069f29 --- /dev/null +++ b/spec/unit/fixtures/smart-diff/people.yaml @@ -0,0 +1,7 @@ +--- +Alice: + manager: Alice +bob: + manager: Alice +contractor: + manager: Alice