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