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