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 5189114..530b8d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,7 @@ PATH entitlements-app (1.2.3) concurrent-ruby (~> 1.3, >= 1.3.1) dogstatsd-ruby (~> 5.7) + extralite-bundle (= 2.10) faraday (~> 2.0) logger (~> 1.6) net-ldap (~> 0.19) @@ -42,6 +43,7 @@ GEM docile (1.4.0) dogstatsd-ruby (5.7.1) drb (2.2.1) + extralite-bundle (2.10) faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json 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 14549c1..e91edde 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" @@ -24,6 +24,7 @@ Gem::Specification.new do |s| s.add_dependency "optimist", "~> 3.1" s.add_dependency "ostruct", "~> 0.6.0" s.add_dependency "logger", "~> 1.6" + s.add_dependency "extralite-bundle", "2.10" s.add_development_dependency "debug", "<= 1.8.0" s.add_development_dependency "rake", "~> 13.2", ">= 13.2.1" diff --git a/lib/entitlements.rb b/lib/entitlements.rb index 0239733..eefd791 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/ @@ -90,6 +91,7 @@ def self.reset! @config = nil @config_file = nil @config_path_override = nil + @evaluation_time = nil @person_extra_methods = {} @statsd = nil @@ -97,6 +99,41 @@ def self.reset! Entitlements::Data::Groups::Calculated.reset! end + def self.with_evaluation_context(config_file:, evaluated_at:, tree_root: nil, prepare: nil) + original_dir = ENV["DIR"] + ENV["DIR"] = File.expand_path(tree_root) if tree_root + reset! + self.config_file = config_file + self.evaluation_time = evaluated_at + prepare.call(config) if prepare + load_extras + register_filters + yield config + ensure + reset! + if tree_root + original_dir ? ENV["DIR"] = original_dir : ENV.delete("DIR") + end + end + + # Return the fixed time used for the current 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 @@ -674,6 +711,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" @@ -685,6 +723,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..1936e1c 100644 --- a/lib/entitlements/data/groups/calculated.rb +++ b/lib/entitlements/data/groups/calculated.rb @@ -39,6 +39,7 @@ def self.reset! @groups_in_ou_cache = {} @groups_cache = {} @config_cache = {} + Entitlements::Data::Groups::Calculated::Rules::Group.reset! end # Construct a group object. diff --git a/lib/entitlements/data/groups/calculated/base.rb b/lib/entitlements/data/groups/calculated/base.rb index d19d08b..f973512 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 @@ -340,7 +340,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/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..7349de3 --- /dev/null +++ b/lib/entitlements/desired_groups.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "digest" +require "json" + +module Entitlements + # Calculates desired group memberships from the provided configuration files. + class DesiredGroups + SCHEMA_VERSION = 1 + + def self.export(config_file:, source_sha:, people_source:, evaluated_at:, tree_root: nil, entitlement_groups: nil) + 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 + backend_identifiers = nil + prepare = lambda do |config| + backend_identifiers = backend_identifiers(config) + use_people_snapshot!(config, people_source) + Entitlements.validate_configuration_file! + end + + Entitlements.with_evaluation_context( + config_file: config_file, + evaluated_at: evaluation_time, + tree_root: tree_root, + prepare: prepare + ) do + Entitlements.prefetch_people + Entitlements.cache[:desired_groups_export] = true + memberships = export_memberships(backend_identifiers, entitlement_groups: entitlement_groups) + { + "schema_version" => SCHEMA_VERSION, + "source_sha" => source_sha.downcase, + "people_snapshot_sha256" => people_hash, + "evaluated_at" => evaluation_time.utc.iso8601, + "people" => people_snapshot(people_source), + "memberships" => memberships + } + 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!(config, people_source) + config["people"] = { + "smart_diff" => { + "type" => "yaml", + "config" => {"filename" => File.expand_path(people_source)} + } + } + config["people_data_source"] = "smart_diff" + end + private_class_method :use_people_snapshot! + + def self.people_snapshot(people_source) + data = YAML.safe_load_file(people_source, permitted_classes: [Date]) || {} + raise ArgumentError, "people_source must contain a hash" unless data.is_a?(Hash) + + data.to_h do |username, attributes| + raise ArgumentError, "People attributes for #{username} must be a hash" unless attributes.is_a?(Hash) + [username.to_s, attributes.transform_keys(&:to_s)] + end + end + private_class_method :people_snapshot + + def self.export_memberships(backend_identifiers, entitlement_groups:) + return export_all_memberships(backend_identifiers) unless entitlement_groups + + records = {} + entitlement_groups.sort.each do |entitlement_group| + group_name = configured_group_name(entitlement_group) + next unless group_name + + username_records( + backend: backend_identifiers.fetch(group_name), + entitlement_group: entitlement_group, + members: members_for(entitlement_group, group_name) + ).each do |record| + records[record.values_at("backend", "entitlement_group", "username")] = record + end + end + records.values.sort_by { |record| record.values_at("backend", "entitlement_group", "username") } + end + private_class_method :export_memberships + + def self.export_all_memberships(backend_identifiers) + records = {} + exportable_groups.each do |group_name, group_config| + Entitlements::Data::Groups::Calculated.read_all(group_name, group_config).each do |group_dn| + group = Entitlements::Data::Groups::Calculated.read(group_dn) + username_records( + backend: backend_identifiers.fetch(group_name), + entitlement_group: "#{group_name}/#{group.cn}", + members: group.members + ).each do |record| + 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_all_memberships + + def self.configured_group_name(entitlement_group) + Entitlements.config.fetch("groups").keys.select do |group_name| + entitlement_group.start_with?("#{group_name}/") + end.max_by(&:length) + end + private_class_method :configured_group_name + + def self.members_for(entitlement_group, group_name) + group_config = Entitlements.config.fetch("groups").fetch(group_name) + cn = entitlement_group.delete_prefix("#{group_name}/") + if group_config["mirror"] + return members_for("#{group_config.fetch('mirror')}/#{cn}", group_config.fetch("mirror")) + end + + group_path = Entitlements::Util::Util.path_for_group(group_name) + filenames = %w[rb txt yaml].filter_map do |extension| + filename = File.join(group_path, "#{cn}.#{extension}") + filename if File.file?(filename) + end + return Set.new if filenames.empty? + raise ArgumentError, "Multiple entitlement files found for #{entitlement_group}" if filenames.length > 1 + + ruleset = Entitlements::Data::Groups::Calculated.ruleset( + filename: filenames.first, + config: group_config + ) + ruleset.modified_filtered_members + rescue Errno::ENOENT + Set.new + end + private_class_method :members_for + + def self.username_records(backend:, entitlement_group:, members:) + members.map do |member| + { + "backend" => backend, + "entitlement_group" => entitlement_group, + "username" => member.uid.downcase + } + end + end + private_class_method :username_records + + 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..5e53b03 --- /dev/null +++ b/lib/entitlements/smart_diff.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +require "cgi" +require "digest" +require "json" +require "open3" +require "rbconfig" +require "set" +require_relative "smart_diff/database" +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:, evaluated_at:, people_source: nil, base_people_source: nil, head_people_source: nil, base_tree: nil, head_tree: nil, markdown_limit: DEFAULT_MARKDOWN_LIMIT, required_features: []) + base_people_source, head_people_source = people_sources( + people_source: people_source, + base_people_source: base_people_source, + head_people_source: head_people_source + ) + identity_sources_changed = if base_tree && head_tree + Digest::SHA256.file(base_people_source).hexdigest != Digest::SHA256.file(head_people_source).hexdigest + end + 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, + identity_sources_changed: identity_sources_changed + ) + end + common = { + evaluated_at: evaluated_at, + required_features: required_features + } + snapshots = parallel_snapshots( + "base" => { + config_file: base_config, + source_sha: base_sha, + tree_root: base_tree, + entitlement_groups: affected_groups, + people_source: base_people_source, + **common + }, + "head" => { + config_file: head_config, + source_sha: head_sha, + tree_root: head_tree, + entitlement_groups: affected_groups, + people_source: head_people_source, + **common + } + ) + compare( + base: snapshots.fetch("base"), + head: snapshots.fetch("head"), + markdown_limit: markdown_limit, + affected_groups: affected_groups + ) + end + + def self.people_sources(people_source:, base_people_source:, head_people_source:) + if people_source + if base_people_source || head_people_source + raise ArgumentError, "people_source cannot be combined with base_people_source or head_people_source" + end + return [people_source, people_source] + end + unless base_people_source && head_people_source + raise ArgumentError, "provide people_source or both base_people_source and head_people_source" + end + + [base_people_source, head_people_source] + end + private_class_method :people_sources + + 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 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) } + changed_usernames = (gains + losses).map { |record| record.fetch("username") }.to_set + + result = { + "schema_version" => SCHEMA_VERSION, + "base" => snapshot_metadata(base), + "head" => snapshot_metadata(head), + "counts" => {"gains" => gains.length, "losses" => losses.length}, + "gains" => gains, + "losses" => losses, + "people" => { + "base" => selected_people(base, changed_usernames), + "head" => selected_people(head, changed_usernames) + } + } + result["scope"] = {"affected_groups" => affected_groups} if affected_groups + [result, markdown(result, limit: markdown_limit)] + end + + def self.snapshot(label:, **options) + required_features = Array(options.delete(:required_features)) + unless required_features.all? { |feature| feature.is_a?(String) && !feature.empty? } + raise ArgumentError, "required_features must contain non-empty strings" + end + require_options = required_features.flat_map { |feature| ["-r", feature] } + stdout, stderr, status = Open3.capture3( + {"RUBYLIB" => $LOAD_PATH.uniq.join(File::PATH_SEPARATOR)}, + RbConfig.ruby, + *require_options, + File.expand_path("smart_diff/snapshot_worker.rb", __dir__), + stdin_data: JSON.generate(options) + ) + unless status.success? + detail = stderr.strip + detail = "worker exited with status #{status.exitstatus}" if detail.empty? + raise ArgumentError, "#{label} snapshot failed: #{detail}" + end + + JSON.parse(stdout) + rescue JSON::ParserError => e + raise ArgumentError, "#{label} snapshot returned invalid JSON: #{e.message}" + end + private_class_method :snapshot + + def self.parallel_snapshots(requests) + threads = requests.map do |label, options| + Thread.new do + [label, snapshot(label: label, **options), nil] + rescue StandardError => e + [label, nil, e] + end + end + results = threads.map(&:value) + failed = results.find { |_label, _snapshot, error| error } + raise failed.fetch(2) if failed + + results.to_h { |label, result, _error| [label, result] } + end + private_class_method :parallel_snapshots + + 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')}` ", + "Base identity: `#{result.fetch('base').fetch('people_snapshot_sha256')}` ", + "Head: `#{result.fetch('head').fetch('source_sha')}` ", + "Head identity: `#{result.fetch('head').fetch('people_snapshot_sha256')}`", + "" + ] + 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; query the SQLite artifact for the complete diff._", ""]) 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 people 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) + raise ArgumentError, "#{label} people must be a hash" unless snapshot["people"].is_a?(Hash) + 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.selected_people(snapshot, usernames) + snapshot.fetch("people").filter_map do |username, attributes| + next unless usernames.include?(username.downcase) + raise ArgumentError, "Invalid people attributes for #{username}" unless attributes.is_a?(Hash) + + {"username" => username.downcase, "attributes" => attributes} + end.sort_by { |record| record.fetch("username") } + end + private_class_method :selected_people + + 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..875a492 --- /dev/null +++ b/lib/entitlements/smart_diff/cli.rb @@ -0,0 +1,89 @@ +# 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, = 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[:people_snapshot], + base_people_source: options[:base_people_snapshot], + head_people_source: options[:head_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), + required_features: options.fetch(:required_features) + ) + Entitlements::SmartDiff::Database.write(path: options.fetch(:sqlite), result: result) + markdown = Entitlements::SmartDiff::Database.markdown( + path: options.fetch(:sqlite), + limit: options.fetch(:markdown_limit) + ) + 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, + required_features: [] + } + 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("--base-people-snapshot PATH") { |value| options[:base_people_snapshot] = value } + opts.on("--head-people-snapshot PATH") { |value| options[:head_people_snapshot] = value } + opts.on("--evaluated-at TIMESTAMP") { |value| options[:evaluated_at] = value } + opts.on("--sqlite PATH") { |value| options[:sqlite] = value } + opts.on("--markdown PATH") { |value| options[:markdown] = value } + opts.on("--markdown-limit COUNT", Integer) { |value| options[:markdown_limit] = value } + opts.on("--require FEATURE") { |value| options[:required_features] << value } + end + parser.parse!(argv) + required = %i[base_tree head_tree base_sha head_sha evaluated_at sqlite markdown] + missing = required.reject { |key| options.key?(key) } + raise OptionParser::MissingArgument, missing.join(", ") if missing.any? + shared_snapshot = options.key?(:people_snapshot) + separate_snapshots = options.key?(:base_people_snapshot) || options.key?(:head_people_snapshot) + if shared_snapshot && separate_snapshots + raise OptionParser::InvalidArgument, "people_snapshot cannot be combined with base/head people snapshots" + end + if separate_snapshots && !(options.key?(:base_people_snapshot) && options.key?(:head_people_snapshot)) + raise OptionParser::MissingArgument, "base_people_snapshot, head_people_snapshot" + end + unless shared_snapshot || separate_snapshots + raise OptionParser::MissingArgument, "people_snapshot or base_people_snapshot, head_people_snapshot" + end + 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/database.rb b/lib/entitlements/smart_diff/database.rb new file mode 100644 index 0000000..c48006b --- /dev/null +++ b/lib/entitlements/smart_diff/database.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "fileutils" +require "extralite" + +module Entitlements + class SmartDiff + class Database + SCHEMA = <<~SQL + PRAGMA foreign_keys = ON; + + CREATE TABLE metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + schema_version INTEGER NOT NULL, + evaluated_at TEXT NOT NULL, + scoped INTEGER NOT NULL CHECK (scoped IN (0, 1)) + ); + + CREATE TABLE snapshots ( + snapshot TEXT PRIMARY KEY CHECK (snapshot IN ('base', 'head')), + source_sha TEXT NOT NULL, + people_snapshot_sha256 TEXT NOT NULL + ); + + CREATE TABLE affected_groups ( + entitlement_group TEXT PRIMARY KEY + ); + + CREATE TABLE membership_changes ( + change_type TEXT NOT NULL CHECK (change_type IN ('gain', 'loss')), + backend TEXT NOT NULL, + entitlement_group TEXT NOT NULL, + username TEXT NOT NULL, + PRIMARY KEY (change_type, backend, entitlement_group, username) + ); + + CREATE TABLE people ( + snapshot TEXT NOT NULL CHECK (snapshot IN ('base', 'head')), + username TEXT NOT NULL, + PRIMARY KEY (snapshot, username), + FOREIGN KEY (snapshot) REFERENCES snapshots(snapshot) + ); + + CREATE TABLE person_facts ( + snapshot TEXT NOT NULL CHECK (snapshot IN ('base', 'head')), + username TEXT NOT NULL, + attribute TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (snapshot, username, attribute, value), + FOREIGN KEY (snapshot, username) REFERENCES people(snapshot, username) + ); + SQL + + def self.write(path:, result:) + FileUtils.rm_f(path) + db = Extralite::Database.new(path) + db.execute(SCHEMA) + db.transaction do + insert_result(db, result) + end + db.execute("PRAGMA optimize") + ensure + db&.close + end + + def self.read(path) + db = Extralite::Database.new(path) + yield db + ensure + db&.close + end + + def self.markdown(path:, limit:) + result = read(path) { |db| result_from(db) } + Entitlements::SmartDiff.markdown(result, limit: limit) + end + + def self.insert_result(db, result) + db.execute( + "INSERT INTO metadata (id, schema_version, evaluated_at, scoped) VALUES (1, ?, ?, ?)", + result.fetch("schema_version"), + result.fetch("base").fetch("evaluated_at"), + result.key?("scope") ? 1 : 0 + ) + %w[base head].each do |snapshot| + metadata = result.fetch(snapshot) + db.execute( + "INSERT INTO snapshots (snapshot, source_sha, people_snapshot_sha256) VALUES (?, ?, ?)", + snapshot, + metadata.fetch("source_sha"), + metadata.fetch("people_snapshot_sha256") + ) + end + result.fetch("scope", {}).fetch("affected_groups", []).each do |group| + db.execute("INSERT INTO affected_groups (entitlement_group) VALUES (?)", group) + end + {"gain" => "gains", "loss" => "losses"}.each do |change_type, key| + result.fetch(key).each do |record| + db.execute( + "INSERT INTO membership_changes (change_type, backend, entitlement_group, username) VALUES (?, ?, ?, ?)", + change_type, + record.fetch("backend"), + record.fetch("entitlement_group"), + record.fetch("username") + ) + end + end + result.fetch("people").each do |snapshot, people| + people.each do |record| + db.execute( + "INSERT INTO people (snapshot, username) VALUES (?, ?)", + snapshot, + record.fetch("username") + ) + record.fetch("attributes").each do |attribute, value| + fact_values(value, username: record.fetch("username"), attribute: attribute).each do |fact_value| + db.execute( + "INSERT INTO person_facts (snapshot, username, attribute, value) VALUES (?, ?, ?, ?)", + snapshot, + record.fetch("username"), + attribute, + fact_value + ) + end + end + end + end + end + private_class_method :insert_result + + def self.fact_values(value, username:, attribute:) + values = value.is_a?(Array) ? value : [value] + if values.any? { |item| item.is_a?(Array) || item.is_a?(Hash) } + raise ArgumentError, "Identity attribute #{attribute.inspect} for #{username} must contain scalar values" + end + + values.compact.map(&:to_s).uniq + end + private_class_method :fact_values + + def self.result_from(db) + metadata = db.query_single("SELECT schema_version, evaluated_at, scoped FROM metadata WHERE id = 1") + snapshots = db.query("SELECT snapshot, source_sha, people_snapshot_sha256 FROM snapshots").to_h do |row| + [ + row.fetch(:snapshot), + { + "source_sha" => row.fetch(:source_sha), + "people_snapshot_sha256" => row.fetch(:people_snapshot_sha256), + "evaluated_at" => metadata.fetch(:evaluated_at) + } + ] + end + result = { + "schema_version" => metadata.fetch(:schema_version), + "base" => snapshots.fetch("base"), + "head" => snapshots.fetch("head"), + "counts" => { + "gains" => db.query_single_splat("SELECT count(*) FROM membership_changes WHERE change_type = 'gain'"), + "losses" => db.query_single_splat("SELECT count(*) FROM membership_changes WHERE change_type = 'loss'") + }, + "gains" => membership_changes(db, "gain"), + "losses" => membership_changes(db, "loss") + } + groups = db.query("SELECT entitlement_group FROM affected_groups ORDER BY entitlement_group").map do |row| + row.fetch(:entitlement_group) + end + result["scope"] = {"affected_groups" => groups} if metadata.fetch(:scoped) == 1 + result + end + private_class_method :result_from + + def self.membership_changes(db, change_type) + rows = db.query(<<~SQL, change_type) + SELECT backend, entitlement_group, username + FROM membership_changes + WHERE change_type = ? + ORDER BY backend, entitlement_group, username + SQL + rows.map do |row| + { + "backend" => row.fetch(:backend), + "entitlement_group" => row.fetch(:entitlement_group), + "username" => row.fetch(:username) + } + end + end + private_class_method :membership_changes + 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..5bca4b4 --- /dev/null +++ b/lib/entitlements/smart_diff/scope.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require "digest" +require "set" + +module Entitlements + class SmartDiff + # Identifies the entitlement groups whose desired memberships may change between two source trees. + class Scope + GROUP_FILE_EXTENSIONS = %w[.rb .txt .yaml].freeze + + def self.affected_groups(base_config:, head_config:, base_tree:, head_tree:, evaluated_at:, identity_sources_changed: false) + 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) + validate_changed_paths!(base, head) + return all_groups.to_a.sort if identity_sources_changed + + changed_groups = changed_groups(base, head) + reverse_dependencies = reverse_dependencies(base, head, all_groups) + + dependency_closure(changed_groups, reverse_dependencies).to_a.sort + end + + def self.catalog(config_file:, tree:, evaluated_at:) + Entitlements.with_evaluation_context( + config_file: config_file, + evaluated_at: Time.iso8601(evaluated_at.to_s), + tree_root: tree + ) do |config| + groups_config = config.fetch("groups") + groups = Set.new + files = {} + entitlement_files = {} + path_groups = Hash.new { |hash, key| hash[key] = Set.new } + references = Hash.new { |hash, key| hash[key] = 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 if Entitlements::IGNORED_FILES.member?(basename) + + relative_path = relative_path(filename, tree) + entitlement_files[relative_path] = Digest::SHA256.file(filename).hexdigest + next unless GROUP_FILE_EXTENSIONS.include?(File.extname(filename)) + + group_id = "#{group_name}/#{File.basename(filename, File.extname(filename))}" + groups.add(group_id) + path_groups[relative_path].add(group_id) + files[relative_path] = Digest::SHA256.file(filename).hexdigest + next if File.extname(filename) == ".rb" + + ruleset = Entitlements::Data::Groups::Calculated.ruleset( + filename: filename, + config: group_config + ) + validate_ruleset!(ruleset) + 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) + end + end + + { + config_digest: Digest::SHA256.file(config_file).hexdigest, + files: files, + entitlement_files: entitlement_files, + groups: groups, + path_groups: path_groups, + references: references + } + end + end + private_class_method :catalog + + def self.validate_ruleset!(ruleset) + ruleset.send(:rules) + ruleset.filters + ruleset.metadata + ruleset.modifiers + ruleset.schema_version if ruleset.respond_to?(:schema_version) + end + private_class_method :validate_ruleset! + + def self.validate_changed_paths!(base, head) + paths = base.fetch(:entitlement_files).keys | head.fetch(:entitlement_files).keys + paths.each do |path| + next if base.fetch(:entitlement_files)[path] == head.fetch(:entitlement_files)[path] + next if GROUP_FILE_EXTENSIONS.include?(File.extname(path)) + + extension = File.extname(path) + detail = extension.empty? ? "has no extension" : "has unsupported extension #{extension.inspect}" + raise ArgumentError, "Changed entitlement file #{path} #{detail}; expected .rb, .txt, or .yaml" + end + end + private_class_method :validate_changed_paths! + + 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")) + Array(filter_value).select { |value| value.is_a?(String) && value.include?("/") }.each do |value| + result.add(value) + end + 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/entitlements/smart_diff/snapshot_worker.rb b/lib/entitlements/smart_diff/snapshot_worker.rb new file mode 100644 index 0000000..03e65ff --- /dev/null +++ b/lib/entitlements/smart_diff/snapshot_worker.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "json" +require_relative "../../entitlements" + +module Entitlements + class SmartDiff + class SnapshotWorker + def self.run(input: $stdin, output: $stdout, error: $stderr) + request = JSON.parse(input.read) + snapshot = export(request, request.fetch("people_source")) + output.write(JSON.generate(snapshot)) + 0 + rescue StandardError => e + error.puts "#{e.class}: #{e.message}" + 1 + end + + def self.export(request, people_source) + Entitlements::DesiredGroups.export( + config_file: request.fetch("config_file"), + source_sha: request.fetch("source_sha"), + people_source: people_source, + evaluated_at: request.fetch("evaluated_at"), + tree_root: request["tree_root"], + entitlement_groups: request["entitlement_groups"] + ) + end + private_class_method :export + end + end +end + +# :nocov: +exit Entitlements::SmartDiff::SnapshotWorker.run if $PROGRAM_NAME == __FILE__ +# :nocov: diff --git a/spec/unit/entitlements/data/groups/calculated/base_spec.rb b/spec/unit/entitlements/data/groups/calculated/base_spec.rb index d1cd846..89fd5f2 100644 --- a/spec/unit/entitlements/data/groups/calculated/base_spec.rb +++ b/spec/unit/entitlements/data/groups/calculated/base_spec.rb @@ -158,6 +158,15 @@ 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 "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..305b53d --- /dev/null +++ b/spec/unit/entitlements/desired_groups_spec.rb @@ -0,0 +1,159 @@ +# 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["people"]).to eq( + "Alice" => {"manager" => "Alice"}, + "bob" => {"manager" => "Alice"}, + "contractor" => {"manager" => "Alice"} + ) + 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 "normalizes exported identity facts and rejects malformed people data" do + Dir.mktmpdir do |directory| + normalized = File.join(directory, "normalized.yaml") + File.write(normalized, YAML.dump( + "alice" => { + "status" => ["employee"] + } + )) + result = described_class.export(**args.merge(people_source: normalized)) + expect(result.fetch("people")).to eq( + "alice" => { + "status" => ["employee"] + } + ) + + invalid_root = File.join(directory, "invalid-root.yaml") + File.write(invalid_root, YAML.dump([])) + expect do + described_class.send(:people_snapshot, invalid_root) + end.to raise_error(ArgumentError, /must contain a hash/) + + invalid_attributes = File.join(directory, "invalid-attributes.yaml") + File.write(invalid_attributes, YAML.dump("alice" => [])) + expect do + described_class.send(:people_snapshot, invalid_attributes) + end.to raise_error(ArgumentError, /People attributes for alice must be a hash/) + end + 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 export only requested groups and evaluates Ruby dependencies normally" do + dynamic_args = args.merge(config_file: fixture("dynamic-groups/config.yaml")) + result = described_class.export( + **dynamic_args, + entitlement_groups: ["teams/static", "teams_mirror/static"] + ) + expect(result["memberships"]).to eq([ + {"backend" => "dummy", "entitlement_group" => "teams/static", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams_mirror/static", "username" => "alice"} + ]) + + expect do + described_class.export(**dynamic_args, entitlement_groups: ["teams/dynamic"]) + end.to raise_error(KeyError, /DYNAMIC_GROUP_TOKEN/) + end + + it "treats requested groups missing from one tree as empty" do + result = described_class.export(**args, entitlement_groups: ["teams/missing"]) + expect(result["memberships"]).to be_empty + end + + it "treats requested groups in missing directories as empty" do + allow(Entitlements::Util::Util).to receive(:path_for_group).and_raise(Errno::ENOENT) + result = described_class.export(**args, entitlement_groups: ["teams/missing"]) + expect(result["memberships"]).to be_empty + end + + it "rejects multiple files defining the same requested group" do + Dir.mktmpdir do |directory| + FileUtils.cp_r(Dir.glob(File.join(fixture("smart-diff"), "*")), directory) + File.write(File.join(directory, "groups", "teams", "direct.yaml"), "---\nrules: {username: alice}\n") + + expect do + described_class.export( + **args.merge(config_file: File.join(directory, "config.yaml")), + entitlement_groups: ["teams/direct"] + ) + end.to raise_error(ArgumentError, /Multiple entitlement files/) + 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/database_spec.rb b/spec/unit/entitlements/smart_diff/database_spec.rb new file mode 100644 index 0000000..ab5119b --- /dev/null +++ b/spec/unit/entitlements/smart_diff/database_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" +require "tmpdir" + +describe Entitlements::SmartDiff::Database do + let(:result) do + { + "schema_version" => 1, + "base" => { + "source_sha" => "a" * 40, + "people_snapshot_sha256" => "base-people", + "evaluated_at" => "2026-09-18T14:00:19Z" + }, + "head" => { + "source_sha" => "b" * 40, + "people_snapshot_sha256" => "head-people", + "evaluated_at" => "2026-09-18T14:00:19Z" + }, + "counts" => {"gains" => 1, "losses" => 1}, + "gains" => [ + {"backend" => "aad", "entitlement_group" => "apps/admin", "username" => "alice"} + ], + "losses" => [ + {"backend" => "ldap", "entitlement_group" => "pizza_teams/old", "username" => "bob"} + ], + "people" => { + "base" => [ + {"username" => "alice", "attributes" => {"country" => "CA"}}, + {"username" => "bob", "attributes" => {"status" => ["employee"]}} + ], + "head" => [ + {"username" => "alice", "attributes" => {"country" => "US"}} + ] + }, + "scope" => {"affected_groups" => ["apps/admin", "pizza_teams/old"]} + } + end + + it "writes the canonical schema, facts, changes, and SQL-backed Markdown" do + Dir.mktmpdir do |directory| + path = File.join(directory, "smart-diff.sqlite3") + described_class.write(path: path, result: result) + + described_class.read(path) do |db| + expect(db.query_single_splat("PRAGMA integrity_check")).to eq("ok") + expect(db.query_single_splat("SELECT schema_version FROM metadata")).to eq(1) + expect(db.query_single_splat("SELECT count(*) FROM membership_changes")).to eq(2) + expect(db.query_single_splat(<<~SQL)).to eq("US") + SELECT value + FROM person_facts + WHERE snapshot = 'head' AND username = 'alice' AND attribute = 'country' + SQL + tables = db.query(<<~SQL).map { |row| row.fetch(:name) } + SELECT name + FROM sqlite_schema + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + SQL + expect(tables).to contain_exactly( + "affected_groups", + "membership_changes", + "metadata", + "people", + "person_facts", + "snapshots" + ) + expect(db.query_single_splat(<<~SQL)).to eq(1) + SELECT count(*) + FROM person_facts + WHERE snapshot = 'base' AND username = 'bob' AND attribute = 'status' AND value = 'employee' + SQL + end + + markdown = described_class.markdown(path: path, limit: 1) + expect(markdown).to include("1 membership added; 1 membership removed") + expect(markdown).to include("query the SQLite artifact") + expect(markdown).to include("Base identity: `base-people`") + expect(markdown).to include("Affected entitlement groups: 2") + end + end + + it "fails closed on nested identity facts" do + nested_result = result.merge( + "people" => { + "base" => [{"username" => "alice", "attributes" => {"manager" => {"username" => "bob"}}}], + "head" => [] + } + ) + + Dir.mktmpdir do |directory| + expect do + described_class.write(path: File.join(directory, "smart-diff.sqlite3"), result: nested_result) + end.to raise_error(ArgumentError, /Identity attribute "manager" for alice must contain scalar values/) + end + end + + it "preserves an explicitly empty scope" do + Dir.mktmpdir do |directory| + path = File.join(directory, "smart-diff.sqlite3") + described_class.write( + path: path, + result: result.merge( + "gains" => [], + "losses" => [], + "people" => {"base" => [], "head" => []}, + "scope" => {"affected_groups" => []} + ) + ) + + expect(described_class.markdown(path: path, limit: 10)).to include("Affected entitlement groups: 0") + end + end +end 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..6d5e67b --- /dev/null +++ b/spec/unit/entitlements/smart_diff/scope_spec.rb @@ -0,0 +1,274 @@ +# 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 "fails for changed entitlement files without a supported extension" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |head| + copy_fixture(base) + copy_fixture(head) + File.write(File.join(head, "groups", "teams", "no-extension"), "username = Alice\n") + + expect do + 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" + ) + end.to raise_error(ArgumentError, /groups\/teams\/no-extension has no extension/) + + FileUtils.rm(File.join(head, "groups", "teams", "no-extension")) + File.write(File.join(head, "groups", "teams", "unsupported.json"), "{}") + expect do + 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" + ) + end.to raise_error(ArgumentError, /groups\/teams\/unsupported.json has unsupported extension ".json"/) + end + end + end + + it "ignores changes to README.md and PR_TEMPLATE.md within a group directory" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |head| + copy_fixture(base) + copy_fixture(head) + %w[README.md PR_TEMPLATE.md].each do |ignored_file| + File.write(File.join(base, "groups", "teams", ignored_file), "base copy\n") + File.write(File.join(head, "groups", "teams", ignored_file), "head copy\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([]) + end + end + end + + it "evaluates every cataloged group when identity sources change" do + affected = 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", + identity_sources_changed: true + ) + + expect(affected).to include( + "internal/engineers", + "teams/direct", + "teams/filtered", + "teams_mirror/direct", + "teams_mirror/filtered" + ) + end + + it "globally validates unchanged supported entitlement files" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |head| + copy_fixture(base) + copy_fixture(head) + [base, head].each do |tree| + File.write(File.join(tree, "groups", "teams", "invalid.txt"), "description = Missing rules\n") + end + + expect do + 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", + identity_sources_changed: true + ) + end.to raise_error(RuntimeError, /No conditions were found in .*invalid.txt/) + end + end + end + + it "keeps inline predicate and contradictory filter validation fail closed" do + { + "invalid-predicate.txt" => "username = Alice; expiration 2029-09-10\n", + "contradictory-filter.txt" => "username = Alice\nfilter_contractors = all\nfilter_contractors = internal/contractors\n" + }.each do |filename, content| + Dir.mktmpdir do |base| + Dir.mktmpdir do |head| + copy_fixture(base) + copy_fixture(head) + [base, head].each do |tree| + File.write(File.join(tree, "groups", "teams", filename), content) + end + + expect do + 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", + identity_sources_changed: true + ) + end.to raise_error(/#{Regexp.escape(filename)}/) + end + end + end + 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 "includes changed 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([ + "teams/dependent", + "teams/dynamic", + "teams/filtered", + "teams_mirror/dependent", + "teams_mirror/dynamic", + "teams_mirror/filtered" + ]) + end + end + end + + it "includes groups referenced by per-file filter values" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |head| + copy_fixture(base) + copy_fixture(head) + [base, head].each do |tree| + File.write( + File.join(tree, "groups", "teams", "filter-target.txt"), + "username = contractor\n" + ) + File.write( + File.join(tree, "groups", "teams", "filter-dependent.txt"), + "username = contractor\nfilter_contractors = teams/filter-target\n" + ) + end + File.open(File.join(head, "groups", "teams", "filter-target.txt"), "a") do |file| + file.puts "username = alice" + 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 include("teams/filter-dependent") + end + end + end +end diff --git a/spec/unit/entitlements/smart_diff/snapshot_worker_spec.rb b/spec/unit/entitlements/smart_diff/snapshot_worker_spec.rb new file mode 100644 index 0000000..0636747 --- /dev/null +++ b/spec/unit/entitlements/smart_diff/snapshot_worker_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" +require_relative "../../../../lib/entitlements/smart_diff/snapshot_worker" + +describe Entitlements::SmartDiff::SnapshotWorker do + it "writes a desired membership snapshot" do + snapshot = { + "schema_version" => 1, + "source_sha" => "a" * 40, + "people_snapshot_sha256" => "people", + "evaluated_at" => "2026-09-02T19:58:54Z", + "people" => {}, + "memberships" => [] + } + input = StringIO.new(JSON.generate( + config_file: "/base/config.yaml", + source_sha: "a" * 40, + people_source: "/people.yaml", + evaluated_at: "2026-09-02T19:58:54Z", + tree_root: "/base", + entitlement_groups: ["teams/direct"] + )) + output = StringIO.new + error = StringIO.new + expect(Entitlements::DesiredGroups).to receive(:export).with( + config_file: "/base/config.yaml", + source_sha: "a" * 40, + people_source: "/people.yaml", + evaluated_at: "2026-09-02T19:58:54Z", + tree_root: "/base", + entitlement_groups: ["teams/direct"] + ).and_return(snapshot) + + expect(described_class.run(input: input, output: output, error: error)).to eq(0), error.string + expect(error.string).to be_empty + expect(JSON.parse(output.string)).to eq(snapshot) + end + + it "reports invalid requests without emitting a snapshot" do + output = StringIO.new + error = StringIO.new + + expect(described_class.run(input: StringIO.new("{}"), output: output, error: error)).to eq(1) + expect(output.string).to be_empty + expect(error.string).to include("KeyError") + 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..e410059 --- /dev/null +++ b/spec/unit/entitlements/smart_diff_spec.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "fileutils" +require "tmpdir" +require "timeout" + +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", + "people" => { + "alice" => {"manager" => "manager"}, + "bob" => {"manager" => "manager"} + }, + "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", + "people" => { + "" => {"country" => "US"}, + "bob" => {"manager" => "manager"} + }, + "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(result.dig("people", "base")).to eq([ + {"username" => "alice", "attributes" => {"manager" => "manager"}} + ]) + expect(result.dig("people", "head")).to eq([ + {"username" => "", "attributes" => {"country" => "US"}} + ]) + 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 "limits comparison to affected groups" do + result, = described_class.compare( + base: base, + head: head, + affected_groups: ["teams/same"] + ) + expect(result["counts"]).to eq("gains" => 0, "losses" => 0) + expect(result["scope"]).to eq("affected_groups" => ["teams/same"]) + 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 "permits different people snapshots with the same frozen evaluation time" do + result, = described_class.compare( + base: base, + head: head.merge("people_snapshot_sha256" => "different") + ) + + expect(result.dig("base", "people_snapshot_sha256")).to eq("people") + expect(result.dig("head", "people_snapshot_sha256")).to eq("different") + end + + it "diffs caller-supplied base and head people snapshots" do + Dir.mktmpdir do |base_tree| + Dir.mktmpdir do |head_tree| + [base_tree, head_tree].each do |tree| + FileUtils.cp_r(Dir.glob(File.join(fixture("smart-diff"), "*")), tree) + File.write(File.join(tree, "groups", "teams", "identity.txt"), "username = new-user\n") + end + base_people = File.join(base_tree, "people.yaml") + head_people = File.join(head_tree, "people.yaml") + File.write(base_people, YAML.dump({"Alice" => {"manager" => "Alice"}})) + File.write(head_people, YAML.dump({ + "Alice" => {"manager" => "Alice"}, + "new-user" => {"manager" => "Alice"} + })) + + result, = described_class.run( + base_config: File.join(base_tree, "config.yaml"), + head_config: File.join(head_tree, "config.yaml"), + base_sha: "a" * 40, + head_sha: "b" * 40, + base_people_source: base_people, + head_people_source: head_people, + evaluated_at: "2026-09-02T19:58:54Z", + base_tree: base_tree, + head_tree: head_tree + ) + + expect(result["gains"]).to include( + "backend" => "dummy", + "entitlement_group" => "teams/identity", + "username" => "new-user" + ) + expect(result.dig("base", "people_snapshot_sha256")).not_to eq(result.dig("head", "people_snapshot_sha256")) + expect(result.dig("base", "evaluated_at")).to eq(result.dig("head", "evaluated_at")) + expect(result.dig("scope", "affected_groups")).to include("teams/identity", "teams_mirror/identity") + end + end + end + + it "requires exactly one people snapshot mode" do + common = { + base_config: fixture("smart-diff/config.yaml"), + head_config: fixture("smart-diff/config.yaml"), + base_sha: "a" * 40, + head_sha: "b" * 40, + evaluated_at: "2026-09-02T19:58:54Z" + } + + expect { described_class.run(**common) } + .to raise_error(ArgumentError, /provide people_source or both/) + expect do + described_class.run(**common, base_people_source: fixture("smart-diff/people.yaml")) + end.to raise_error(ArgumentError, /provide people_source or both/) + expect do + described_class.run( + **common, + people_source: fixture("smart-diff/people.yaml"), + base_people_source: fixture("smart-diff/people.yaml"), + head_people_source: fixture("smart-diff/people.yaml") + ) + end.to raise_error(ArgumentError, /cannot be combined/) + end + + it "accepts paired people snapshots without source trees" do + result, = described_class.run( + base_config: fixture("smart-diff/config.yaml"), + head_config: fixture("smart-diff/config.yaml"), + base_sha: "a" * 40, + head_sha: "b" * 40, + base_people_source: fixture("smart-diff/people.yaml"), + head_people_source: fixture("smart-diff/people.yaml"), + evaluated_at: "2026-09-02T19:58:54Z" + ) + + expect(result["counts"]).to eq("gains" => 0, "losses" => 0) + expect(result).not_to have_key("scope") + end + + it "isolates Ruby state between base and head snapshot processes" do + Dir.mktmpdir do |base_tree| + Dir.mktmpdir do |head_tree| + [base_tree, head_tree].each do |tree| + FileUtils.cp_r(Dir.glob(File.join(fixture("smart-diff"), "*")), tree) + end + + File.write(File.join(base_tree, "groups", "teams", "ruby-group.rb"), <<~RUBY) + Object.const_set(:SmartDiffProcessLeak, true) + module Entitlements + class Rule + class Teams + class RubyGroup < Entitlements::Rule::Base + def members + Set.new([Entitlements.cache[:people_obj].read("ALICE")]) + end + end + end + end + end + RUBY + File.write(File.join(head_tree, "groups", "teams", "ruby-group.rb"), <<~RUBY) + module Entitlements + class Rule + class Teams + class RubyGroup < Entitlements::Rule::Base + def members + username = defined?(::SmartDiffProcessLeak) ? "BOB" : "ALICE" + Set.new([Entitlements.cache[:people_obj].read(username)]) + end + end + end + end + end + RUBY + + result, = described_class.run( + base_config: File.join(base_tree, "config.yaml"), + head_config: File.join(head_tree, "config.yaml"), + base_sha: "a" * 40, + head_sha: "b" * 40, + people_source: fixture("smart-diff/people.yaml"), + evaluated_at: "2026-09-02T19:58:54Z", + base_tree: base_tree, + head_tree: head_tree + ) + + expect(result["scope"]).to eq("affected_groups" => ["teams/ruby-group", "teams_mirror/ruby-group"]) + expect(result["counts"]).to eq("gains" => 0, "losses" => 0) + end + end + end + + it "calculates base and head snapshots in parallel" do + mutex = Mutex.new + ready = ConditionVariable.new + started = 0 + release = false + allow(described_class).to receive(:snapshot) do |options| + label = options.fetch(:label) + mutex.synchronize do + started += 1 + ready.broadcast + ready.wait(mutex) until release + end + {"label" => label} + end + + result = nil + begin + result = Timeout.timeout(2) do + thread = Thread.new do + described_class.send(:parallel_snapshots, "base" => {}, "head" => {}) + end + mutex.synchronize do + ready.wait(mutex) until started == 2 + release = true + ready.broadcast + end + thread.value + end + ensure + mutex.synchronize do + release = true + ready.broadcast + end + end + + expect(result).to eq( + "base" => {"label" => "base"}, + "head" => {"label" => "head"} + ) + end + + it "reports snapshot worker failures" do + status = instance_double(Process::Status, success?: false, exitstatus: 1) + allow(Open3).to receive(:capture3).and_return(["", "worker error\n", status]) + + expect do + described_class.send(:parallel_snapshots, "base" => {source_sha: "a" * 40}) + end.to raise_error(ArgumentError, "base snapshot failed: worker error") + end + + it "rejects invalid snapshot worker requirements" do + expect do + described_class.send(:snapshot, label: "base", required_features: [nil]) + end.to raise_error(ArgumentError, /required_features/) + end + + it "rejects invalid snapshot worker output" do + status = instance_double(Process::Status, success?: true) + allow(Open3).to receive(:capture3).and_return(["not json", "", status]) + + expect do + described_class.send(:snapshot, label: "head", source_sha: "b" * 40) + end.to raise_error(ArgumentError, /head snapshot returned invalid JSON/) + 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("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/entitlements_spec.rb b/spec/unit/entitlements_spec.rb index 390608f..d40c77b 100644 --- a/spec/unit/entitlements_spec.rb +++ b/spec/unit/entitlements_spec.rb @@ -5,6 +5,59 @@ describe Entitlements do let(:subject) { Entitlements } + describe "#evaluation_time" do + it "uses one timestamp until Entitlements state is reset" do + first_time = Time.utc(2026, 9, 16, 23, 59, 59) + next_time = Time.utc(2026, 9, 17, 0, 0, 0) + allow(Time).to receive(:now).and_return(first_time, next_time) + + expect(subject.evaluation_time).to eq(first_time) + expect(subject.evaluation_time).to eq(first_time) + + subject.reset! + + expect(subject.evaluation_time).to eq(next_time) + end + + it "preserves an explicitly configured evaluation timestamp" do + configured_time = Time.utc(2026, 9, 16, 12, 0, 0) + + subject.evaluation_time = configured_time + + expect(subject.evaluation_time).to eq(configured_time) + expect(Time).not_to receive(:now) + expect(subject.evaluation_time).to eq(configured_time) + end + end + + describe "#with_evaluation_context" do + it "prepares isolated state and restores the source tree environment after failures" do + original_dir = ENV["DIR"] + ENV["DIR"] = "/original" + evaluation_time = Time.utc(2026, 9, 16, 12, 0, 0) + prepared_config = nil + + expect do + subject.with_evaluation_context( + config_file: fixture("smart-diff/config.yaml"), + evaluated_at: evaluation_time, + tree_root: fixture("smart-diff"), + prepare: ->(config) { prepared_config = config } + ) do |config| + expect(config).to equal(prepared_config) + expect(subject.evaluation_time).to eq(evaluation_time) + expect(ENV["DIR"]).to eq(fixture("smart-diff")) + raise "context failure" + end + end.to raise_error("context failure") + + expect(ENV["DIR"]).to eq("/original") + expect(subject.config_file).not_to eq(fixture("smart-diff/config.yaml")) + ensure + original_dir ? ENV["DIR"] = original_dir : ENV.delete("DIR") + end + end + describe "#config" do before(:each) do ENV["TEST_ERB_VARIABLE"] = "Hello, ERB world!" 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 diff --git a/vendor/cache/extralite-bundle-2.10.gem b/vendor/cache/extralite-bundle-2.10.gem new file mode 100644 index 0000000..94d4d48 Binary files /dev/null and b/vendor/cache/extralite-bundle-2.10.gem differ